Repository navigation
Add keyboard shortcut customization #1004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1aa99c0
7a150af
0bcf9aa
de99d9d
67d85f9
5e78ae9
c8eb294
dedb10d
d558db0
e039442
001e426
e133a5c
8ca5404
c130c26
52ef5dc
5e53b61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -489,7 +489,7 @@ pub fn shortcuts(app: &mut RustGisApp, ctx: &egui::Context) { | |
| // Behind a dialog (Save changes?, Recover unsaved work?, Command Search…) the keys are the | ||
| // dialog's: Escape answers it rather than switching tools, and Ctrl+N can't start what it | ||
| // asks about. | ||
| if ctx.memory(|m| m.top_modal_layer()).is_some() { | ||
| if ctx.memory(|m| m.top_modal_layer()).is_some() || app.ui.shortcut_editor.capturing() { | ||
| return; | ||
| } | ||
| crate::modelbuilder::shortcuts(app, ctx); | ||
|
|
@@ -507,7 +507,15 @@ pub fn shortcuts(app: &mut RustGisApp, ctx: &egui::Context) { | |
| // While a text field has focus, it keeps the keys it acts on: those without Ctrl (⌘), | ||
| // which type text, and undo and redo, whatever they're bound to (#685), as ModelBuilder | ||
| // leaves them. | ||
| if (typing && !b.chord.command) || (text_field && b.chord.is_text_undo()) { | ||
| // Pause unmodified shortcuts during first-person movement, retaining the existing Escape, Home and Delete controls. | ||
| if (typing && !b.chord.command) | ||
| || (text_field && b.chord.is_text_undo()) | ||
| || (app.session.map().view.first_person | ||
| && !b.chord.command | ||
| && !b.chord.alt | ||
| && !b.chord.shift | ||
| && !matches!(b.chord.key, Key::Escape | Key::Home | Key::Delete)) | ||
| { | ||
| continue; | ||
| } | ||
| // Duplicating needs a selected layout element, and not a text field being typed in; | ||
|
|
@@ -632,8 +640,14 @@ pub fn shortcuts(app: &mut RustGisApp, ctx: &egui::Context) { | |
| } | ||
| } | ||
|
|
||
| /// Whether a UI command is available on the current platform. | ||
| pub(crate) fn command_available(id: &str, web: bool) -> bool { | ||
| !web || !matches!(id, "ui.checkUpdate" | "ui.mcpRelay") | ||
| } | ||
|
|
||
| /// The `ui.*` commands Command Search lists: (id, label, ribbon location). | ||
| pub(crate) const UI_COMMANDS: &[(&str, &str, &str)] = &[ | ||
| ("ui.customizeShortcuts", "Keyboard Shortcuts", "Settings › Customize"), | ||
|
giswqs marked this conversation as resolved.
|
||
| ("ui.addData", "Add Data…", "Home › Add"), | ||
| ("ui.addDelimited", "Add Delimited Text…", "Home › Add › Add Data"), | ||
| ("ui.addUrl", "Add Data From URL…", "Home › Add › Add Data"), | ||
|
|
@@ -946,7 +960,7 @@ pub fn command_search(app: &mut RustGisApp, ctx: &egui::Context) { | |
| for &(id, label, loc) in UI_COMMANDS { | ||
| // The web app always serves the latest release (no Check for Updates button) and is | ||
| // one process a loopback relay could not reach (`ui.mcpRelay` is desktop only). | ||
| if matches!(id, "ui.checkUpdate" | "ui.mcpRelay") && cfg!(target_arch = "wasm32") { | ||
| if !command_available(id, cfg!(target_arch = "wasm32")) { | ||
| continue; | ||
| } | ||
| if ql.is_empty() || label.to_lowercase().contains(&ql) || tl!(label).to_lowercase().contains(&ql) { | ||
|
|
@@ -970,7 +984,11 @@ pub fn command_search(app: &mut RustGisApp, ctx: &egui::Context) { | |
| let on = why.is_none(); | ||
| let mut b = egui::Button::new(egui::RichText::new(label).size(13.0)).frame(false).min_size(vec2(row_w, 26.0)); | ||
| // The command's shortcut after its location. | ||
| let keys = if id.contains(':') { String::new() } else { crate::keymap::shortcut_text(ui.ctx(), app, id, &Value::Null) }; | ||
| let keys = if id.contains(':') && !id.starts_with("plugin:") { | ||
| String::new() | ||
| } else { | ||
| crate::keymap::shortcut_text(ui.ctx(), app, id, &Value::Null) | ||
| }; | ||
| let right = match (show_loc, keys.is_empty()) { | ||
| (true, false) => format!("{loc} {keys}"), | ||
| (true, true) => loc.clone(), | ||
|
|
@@ -984,6 +1002,20 @@ pub fn command_search(app: &mut RustGisApp, ctx: &egui::Context) { | |
| if let Some(why) = why { | ||
| resp = resp.on_disabled_hover_text(crate::i18n::fmt("Unavailable: {reason}", &[("reason", tl_raw!(why))])); | ||
| } | ||
| let mut customize = false; | ||
| if (!id.contains(':') || id.starts_with("plugin:")) && crate::shortcutui::can_customize(app, id) { | ||
| resp.context_menu(|ui| { | ||
| if ui.button(tl!("Keyboard Shortcuts")).clicked() { | ||
| customize = true; | ||
| ui.close(); | ||
| } | ||
| }); | ||
| } | ||
| if customize { | ||
| app.ui.shortcut_editor.open = true; | ||
| app.ui.shortcut_editor.filter = id.clone(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quality (low-medium confidence): the editor's "Search commands" box is filled with the internal command id (for example
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair, but the command id is used on purpose: it matches exactly one row in every language, whereas a translated label could match several. Leaving open as optional polish. |
||
| keep = false; | ||
| } | ||
| let enter = on && first && ui.input(|i| i.key_pressed(Key::Enter)); | ||
| first &= !on; | ||
| if resp.clicked() || enter { | ||
|
|
@@ -1279,6 +1311,61 @@ mod tests { | |
| assert_eq!(h.state().session.undo_depth(), depth - 1, "no field focused: the project"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn first_person_preserves_default_controls_and_pauses_custom_single_keys() { | ||
| let mut app = RustGisApp::with_sample(); | ||
| app.session.map_mut().view.first_person = true; | ||
| app.run("tool.set", json!({"tool": "select"})).unwrap(); | ||
| app.ui.keymap.assign(crate::keymap::Chord::parse("T").unwrap(), "ui.theme", Value::Null); | ||
| let before = app.ui.dark; | ||
| let mut h = egui_kittest::Harness::builder().build_ui_state( | ||
| |ui, app: &mut RustGisApp| { | ||
| shortcuts(app, ui.ctx()); | ||
| }, | ||
| app, | ||
| ); | ||
| h.key_press(Key::T); | ||
| h.run(); | ||
| assert_eq!(h.state().ui.dark, before); | ||
| h.key_press(Key::Escape); | ||
| h.run(); | ||
| assert_eq!(h.state().session.tool.code(), "explore"); | ||
| // Home and Delete must also reach the keymap while navigating. | ||
| for key in ["Home", "Delete"] { | ||
| h.state_mut().ui.keymap.assign(crate::keymap::Chord::parse(key).unwrap(), "ui.theme", Value::Null); | ||
| let before = h.state().ui.dark; | ||
| h.key_press(Key::from_name(key).unwrap()); | ||
| h.run(); | ||
| assert_ne!(h.state().ui.dark, before); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn typing_keeps_shift_and_alt_shortcuts_in_the_text_field() { | ||
| use egui_kittest::kittest::Queryable; | ||
| let mut app = RustGisApp::with_sample(); | ||
| for key in ["Shift+T", "Alt+T"] { | ||
| app.ui.keymap.assign(crate::keymap::Chord::parse(key).unwrap(), "ui.theme", Value::Null); | ||
| } | ||
| let before = app.ui.dark; | ||
| let mut h = egui_kittest::Harness::builder().build_ui_state( | ||
| |ui, app: &mut RustGisApp| { | ||
| shortcuts(app, ui.ctx()); | ||
| let label = ui.label("Script"); | ||
| ui.text_edit_singleline(&mut app.workflow.script).labelled_by(label.id); | ||
| }, | ||
| app, | ||
| ); | ||
| h.run(); | ||
| h.get_by_role(egui::accesskit::Role::TextInput).focus(); | ||
| h.run(); | ||
| for modifiers in [Modifiers::SHIFT, Modifiers::ALT] { | ||
| h.key_press_modifiers(modifiers, Key::T); | ||
| h.run(); | ||
| assert_eq!(h.state().ui.dark, before); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn header_without_a_unified_title_bar_keeps_its_usual_size() { | ||
| let l = header_layout(None); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,10 +7,15 @@ | |
|
|
||
| use egui::Key; | ||
| use rustgis_engine::Registry; | ||
| use serde::{Deserialize, Serialize}; | ||
| use serde_json::Value; | ||
|
|
||
| use crate::RustGisApp; | ||
|
|
||
| const MAX_PREF_BYTES: usize = 100_000; | ||
| const MAX_PREF_CHANGES: usize = 1_000; | ||
| const MAX_COMMAND_LEN: usize = 200; | ||
|
|
||
| /// The default shortcuts: (keys, command, parameters as JSON, or empty for none). Clipboard | ||
| /// keys are not here: they arrive as copy, cut and paste events, not key presses. | ||
| const DEFAULTS: &[(&str, &str, &str)] = &[ | ||
|
|
@@ -174,6 +179,17 @@ impl Binding { | |
| } | ||
| } | ||
|
|
||
| /// One user change to the defaults, stored in preferences. | ||
| #[derive(Serialize, Deserialize)] | ||
| struct Change { | ||
| keys: String, | ||
| command: String, | ||
| #[serde(default)] | ||
| params: Value, | ||
| #[serde(default)] | ||
| remove: bool, | ||
| } | ||
|
|
||
| /// Every shortcut. | ||
| #[derive(Clone, Debug, PartialEq)] | ||
| pub struct Keymap { | ||
|
|
@@ -214,6 +230,74 @@ impl Keymap { | |
| map | ||
| } | ||
|
|
||
| /// Apply stored user changes to this version's defaults. Returns false if any of them could | ||
| /// not be read: the valid ones still apply, but the stored text must not be overwritten. | ||
| pub fn apply_preferences(&mut self, text: &str) -> bool { | ||
| if text.len() > MAX_PREF_BYTES { | ||
| return false; | ||
| } | ||
| let Ok(changes) = serde_json::from_str::<Vec<Value>>(text) else { return false }; | ||
| if changes.len() > MAX_PREF_CHANGES { | ||
| return false; | ||
| } | ||
| let mut readable = true; | ||
| for record in changes { | ||
| let Ok(change) = serde_json::from_value::<Change>(record) else { | ||
| readable = false; | ||
| continue; | ||
| }; | ||
| let Some(chord) = Chord::parse(&change.keys) else { | ||
| readable = false; | ||
| continue; | ||
| }; | ||
| if is_clipboard(chord) || change.command.is_empty() || change.command.len() > MAX_COMMAND_LEN { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bugs (low severity, low-medium confidence):
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Left for a maintainer. Rejecting stored commands that the editor would not offer needs the command-availability checks (NEEDS_DIALOG, needs_input, UI and plugin commands) passed into keymap.rs, and that is a design choice. Until then, a hand-edited record for a dialog command does nothing when pressed.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low impact: a hand-edited record for a dialog-only command fails when pressed, is logged, and does nothing else; normal edits can't create one. Leaving open for the maintainer rather than filtering stored records against the editor's row rules. |
||
| readable = false; | ||
| continue; | ||
| } | ||
| if change.remove { | ||
| self.bindings.retain(|b| b.chord != chord || !b.runs(&change.command, &change.params)); | ||
| } else { | ||
| self.bindings.retain(|b| b.chord != chord); | ||
| self.bindings.push(Binding { chord, command: change.command, params: change.params }); | ||
| } | ||
| } | ||
| readable | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Design (medium, confidence medium): one unreadable record (for example, a key name that a later egui version renames, or a hand-edited file) sets
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is deliberate and documented in the PR and workspace guide: an unreadable record most likely comes from a newer build, and blocking saves stops an older build from rewriting that file and dropping what it cannot read. Keeping bad records verbatim is a possible follow-up; leaving open for the maintainer's call.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude autofix (unclear): Left for a maintainer. Blocking saves on an unreadable record is deliberate, so an older build does not overwrite a newer build's preferences. Keeping unreadable records verbatim and writing them back with new changes would change that file format and save behaviour. |
||
| } | ||
|
|
||
| /// Store differences only; return None if they would exceed the load limits. | ||
| pub fn preferences(&self, defaults: &Keymap) -> Option<String> { | ||
| let mut changes = Vec::new(); | ||
| for b in &defaults.bindings { | ||
| if !self.bindings.contains(b) { | ||
| changes.push(Change { keys: b.chord.to_string(), command: b.command.clone(), params: b.params.clone(), remove: true }); | ||
| } | ||
| } | ||
| for b in &self.bindings { | ||
| if !defaults.bindings.contains(b) { | ||
| changes.push(Change { keys: b.chord.to_string(), command: b.command.clone(), params: b.params.clone(), remove: false }); | ||
| } | ||
| } | ||
| if changes.len() > MAX_PREF_CHANGES || changes.iter().any(|c| c.command.is_empty() || c.command.len() > MAX_COMMAND_LEN) { | ||
| return None; | ||
| } | ||
| serde_json::to_string(&changes).ok().filter(|text| text.len() <= MAX_PREF_BYTES) | ||
| } | ||
|
|
||
| /// Assign a chord, replacing its previous assignment. Clipboard chords are reserved. | ||
| pub fn assign(&mut self, chord: Chord, command: &str, params: Value) -> bool { | ||
| if is_clipboard(chord) || command.is_empty() { | ||
| return false; | ||
| } | ||
| self.bindings.retain(|b| b.chord != chord); | ||
| self.bindings.push(Binding { chord, command: command.to_owned(), params }); | ||
| true | ||
| } | ||
|
|
||
| /// Remove a particular chord. | ||
| pub fn remove(&mut self, chord: Chord) { | ||
| self.bindings.retain(|b| b.chord != chord); | ||
| } | ||
|
|
||
| /// The binding a key combination runs. | ||
| pub fn command_for(&self, chord: Chord) -> Option<&Binding> { | ||
| self.bindings.iter().find(|b| b.chord == chord) | ||
|
|
@@ -297,6 +381,72 @@ pub fn label(app: &RustGisApp, command: &str, params: &Value) -> String { | |
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn over_limit_preferences_are_rejected_without_partial_changes() { | ||
| let defaults = Keymap::default(); | ||
| let too_many = | ||
| serde_json::to_string(&vec![serde_json::json!({"keys":"D","command":"select.clear"}); MAX_PREF_CHANGES + 1]).unwrap(); | ||
| let too_large = " ".repeat(MAX_PREF_BYTES + 1); | ||
| for saved in [&too_many, &too_large] { | ||
| let mut keymap = defaults.clone(); | ||
| assert!(!keymap.apply_preferences(saved)); | ||
| assert_eq!(keymap, defaults); | ||
| } | ||
| let mut changed = defaults.clone(); | ||
| changed.assign(Chord::parse("D").unwrap(), "test.command", Value::String("x".repeat(MAX_PREF_BYTES))); | ||
| assert!(changed.preferences(&defaults).is_none(), "never save a value that cannot be loaded"); | ||
| let mut long_id = defaults.clone(); | ||
| long_id.assign(Chord::parse("D").unwrap(), &"x".repeat(MAX_COMMAND_LEN + 1), Value::Null); | ||
| assert!(long_id.preferences(&defaults).is_none(), "never save a command id that loading rejects"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn removed_registry_shortcut_stays_removed_after_preference_reload() { | ||
| let registry = | ||
| Registry { commands: vec![rustgis_engine::CommandSpec::new("test.action", "Test", "", |_, _| Ok(Value::Null)).key("F8")] }; | ||
| let defaults = Keymap::new(®istry); | ||
| let chord = Chord::parse("F8").unwrap(); | ||
| assert_eq!(defaults.command_for(chord).unwrap().command, "test.action"); | ||
| let mut changed = defaults.clone(); | ||
| changed.remove(chord); | ||
| let saved = changed.preferences(&defaults).unwrap(); | ||
| let mut reloaded = Keymap::new(®istry); | ||
| reloaded.apply_preferences(&saved); | ||
| assert!(reloaded.command_for(chord).is_none()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn shortcut_preferences_round_trip_and_keep_new_defaults() { | ||
| let defaults = Keymap::default(); | ||
|
choupeanut marked this conversation as resolved.
|
||
| let mut changed = defaults.clone(); | ||
| let table = Chord::parse("Mod+T").unwrap(); | ||
| changed.remove(table); | ||
| let d = Chord::parse("D").unwrap(); | ||
| assert!(changed.assign(d, "tool.set", serde_json::json!({"tool": "select"}))); | ||
| assert!(changed.assign(table, "select.clear", Value::Null)); | ||
| assert!(!changed.assign(Chord::parse("Mod+C").unwrap(), "select.clear", Value::Null)); | ||
| let saved = changed.preferences(&defaults).unwrap(); | ||
| let mut newer = defaults.clone(); | ||
| let fresh = Chord::parse("F8").unwrap(); | ||
| assert!(newer.assign(fresh, "new.command", Value::Null)); | ||
| newer.apply_preferences(&saved); | ||
| assert_eq!(newer.command_for(d).unwrap().params, serde_json::json!({"tool": "select"})); | ||
| assert_eq!(newer.command_for(table).unwrap().command, "select.clear"); | ||
| assert_eq!(newer.command_for(fresh).unwrap().command, "new.command"); | ||
| newer.apply_preferences("not json"); | ||
| assert_eq!(newer.command_for(table).unwrap().command, "select.clear"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn malformed_preference_records_do_not_hide_valid_bindings() { | ||
| let mut keymap = Keymap::default(); | ||
| let readable = | ||
| keymap.apply_preferences(r#"[{"keys":"D","command":"select.clear"},{"keys":42},{"keys":"W","command":"table.open"}]"#); | ||
| assert!(!readable, "an unreadable record blocks saving over the stored text"); | ||
| assert_eq!(keymap.command_for(Chord::parse("D").unwrap()).unwrap().command, "select.clear"); | ||
| assert_eq!(keymap.command_for(Chord::parse("W").unwrap()).unwrap().command, "table.open"); | ||
| } | ||
|
|
||
| /// Undo and redo are a text field's own, an extra Alt or Shift aside; nothing else is. | ||
| #[test] | ||
| fn text_undo_chords_are_undo_and_redo() { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.