diff --git a/ROADMAP.md b/ROADMAP.md index 2e5837881..9d5632f8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,8 @@ comment on its issue or pull request and open a draft pull request early. - Georeferencer: side-by-side window, transform picker, more formats ([#609](https://github.com/opengeos/RustGIS/issues/609)). +- Rebindable keyboard shortcuts ([#562](https://github.com/opengeos/RustGIS/issues/562)). + ## Next ### Fixes and quick wins @@ -151,7 +153,6 @@ built-in version must justify itself on one of those. - Auto-refresh for web service layers. - A catalog of data cubes for the time slider ([#683](https://github.com/opengeos/RustGIS/issues/683)). -- Rebindable keyboard shortcuts ([#562](https://github.com/opengeos/RustGIS/issues/562)). ## Exploratory Open questions rather than plans; discuss before building. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 39e5a8070..7a211bc3a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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"), ("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(); + 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); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 098d2625b..c2a956b72 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -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::>(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::(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 { + 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 + } + + /// Store differences only; return None if they would exceed the load limits. + pub fn preferences(&self, defaults: &Keymap) -> Option { + 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(); + 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() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 514c1f680..b72fc1056 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,7 @@ mod scriptcolor; mod scriptcomplete; mod searchbox; mod serviceui; +mod shortcutui; mod showme; mod sourcespane; mod sqlbox; @@ -574,6 +575,11 @@ pub struct UiState { pub shortcuts: bool, /// The keyboard shortcuts. pub keymap: keymap::Keymap, + pub(crate) shortcut_editor: shortcutui::Editor, + /// Preserve unreadable saved shortcuts until the user explicitly resets them. + pub(crate) shortcut_preferences_blocked: bool, + /// The shortcut changes are too many to save: they apply only to this session. + pub(crate) shortcut_preferences_full: bool, pub tab_strip_rect: Option, /// Where the left and right panes were drawn on the last frame, while they show. pub pane_rects: [Option; 2], @@ -770,6 +776,9 @@ impl Default for UiState { about: false, shortcuts: false, keymap: keymap::Keymap::default(), + shortcut_editor: shortcutui::Editor::default(), + shortcut_preferences_blocked: false, + shortcut_preferences_full: false, tab_strip_rect: None, pane_rects: [None; 2], copy_out: None, @@ -1033,6 +1042,7 @@ impl RustGisApp { // Both saved database passwords and S3 keys use the same store, installed before prefs. self.session.database_logins.set_store(services.secrets.clone()); self.services = services; + shortcutui::load(&mut self); sourcespane::load_connections(&mut self); autosave::load(&mut self); if let Some(lang) = self.services.load_pref.as_ref().and_then(|load| load("language")) { @@ -2234,6 +2244,7 @@ impl RustGisApp { } } "ui.shortcuts" => self.ui.shortcuts = true, + "ui.customizeShortcuts" => self.ui.shortcut_editor.open = true, // Copy, cut and copy path also put their text on the system clipboard. "ui.copy" | "ui.cut" | "ui.copyPath" => { let cmd = match id { @@ -3809,6 +3820,7 @@ impl RustGisApp { update::show(self, &ctx); showme::tick(self, &ctx); chrome::command_search(self, &ctx); + shortcutui::show(self, &ctx); chrome::about(self, &ctx); #[cfg(not(target_arch = "wasm32"))] agentthreads::windows(self, &ctx); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 0621c09f9..1d98142e9 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1720,7 +1720,8 @@ fn finish_area_selection(app: &mut RustGisApp, ui: &Ui, rect: Rect) { fn selection_interact(app: &mut RustGisApp, ui: &Ui, resp: &egui::Response, rect: Rect) { let tool = app.session.tool; - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // While a shortcut is being captured, Escape cancels the capture, not the sketch. + if !app.ui.shortcut_editor.capturing() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.map.cancel_sketch(); return; } @@ -1996,7 +1997,7 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect } } // First-person navigation: W A S D walk, Q and E fly up and down. - if app.session.map().view.first_person && !ui.ctx().egui_wants_keyboard_input() { + if app.session.map().view.first_person && !app.ui.shortcut_editor.capturing() && !ui.ctx().egui_wants_keyboard_input() { let (mut fw, mut rt, mut up) = (0.0, 0.0, 0.0); ui.input(|i| { // Shortcuts (Ctrl+S, Ctrl+E…) aren't steps. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e9e3d6ff8..d922f3a4a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -3290,6 +3290,7 @@ fn settings_tab(app: &mut RustGisApp, ui: &mut Ui) { } } }); + big(ui, app, "command", "Keyboard\nShortcuts", "Customize keyboard shortcuts.", "ui.customizeShortcuts", Value::Null, false, false); let qa = app.ui.quick_access.clone(); big_form_menu(ui, app, "command", "Quick\nAccess", "Choose extra buttons for the Quick Access Toolbar.", move |ui, app| { for (id, label) in QUICK { diff --git a/crates/ui-egui/src/shortcutui.rs b/crates/ui-egui/src/shortcutui.rs new file mode 100644 index 000000000..3a2a22286 --- /dev/null +++ b/crates/ui-egui/src/shortcutui.rs @@ -0,0 +1,338 @@ +//! User customization of keyboard shortcuts. +use crate::{ + RustGisApp, + keymap::{self, Chord, Keymap}, +}; +use egui::{Context, Event}; +use serde_json::{Value, json}; + +const PREF: &str = "shortcut_changes"; + +#[derive(Clone)] +struct Target { + command: String, + params: Value, +} + +#[derive(Default)] +pub struct Editor { + pub open: bool, + pub filter: String, + capture: Option, + pending: Option<(Chord, Target)>, + notice_clipboard: bool, +} + +impl Editor { + pub fn capturing(&self) -> bool { + self.capture.is_some() || self.pending.is_some() + } +} + +fn save(app: &mut RustGisApp) { + if app.ui.shortcut_preferences_blocked { + return; + } + let defaults = Keymap::new(&app.session.registry); + if let Some(changes) = app.ui.keymap.preferences(&defaults) { + app.ui.shortcut_preferences_full = false; + if let Some(save) = &app.services.save_pref { + save(PREF, &changes); + } + } else { + // Too many changes to store: the saved ones stay as they are, and these apply to this session. + app.ui.shortcut_preferences_full = true; + } +} + +pub fn load(app: &mut RustGisApp) { + if let Some(changes) = app.services.load_pref.as_ref().and_then(|load| load(PREF)) { + app.ui.shortcut_preferences_blocked = !app.ui.keymap.apply_preferences(&changes); + } +} + +fn assign(app: &mut RustGisApp, editor: &mut Editor, chord: Chord, target: Target) { + if keymap::is_clipboard(chord) { + editor.notice_clipboard = true; + } else if app.ui.keymap.command_for(chord).is_some_and(|b| !b.runs(&target.command, &target.params)) { + editor.pending = Some((chord, target)); + } else if app.ui.keymap.assign(chord, &target.command, target.params) { + save(app); + } +} + +/// UI commands that need parameters only their dialog supplies, so a bare shortcut would fail. +const NEEDS_DIALOG: &[&str] = &["ui.importShapefile"]; + +fn rows(app: &RustGisApp, web: bool) -> Vec<(String, Value, String)> { + let mut rows = Vec::new(); + for c in &app.session.registry.commands { + if !c.needs_input() && !matches!(c.id, "edit.copy" | "edit.cut" | "edit.paste") { + rows.push((c.id.to_string(), Value::Null, tl!(c.label).to_string())); + } + } + for &(id, label, _) in crate::chrome::UI_COMMANDS { + if !NEEDS_DIALOG.contains(&id) { + rows.push((id.to_string(), Value::Null, tl!(label).to_string())); + } + } + for c in rustgis_engine::plugins::commands() { + let label = c.plugin.manifest.translate(app.lang().code(), &c.decl.label).to_string(); + rows.push((c.id, Value::Null, label)); + } + for &(tool, label) in keymap::MAP_TOOLS { + rows.push(("tool.set".to_string(), json!({"tool": tool}), crate::i18n::fmt("Map Tool: {tool}", &[("tool", tl_raw!(label))]))); + } + for (command, params, _) in app.ui.keymap.grouped() { + if !rows.iter().any(|(c, p, _)| c == command && p == params) { + rows.push((command.to_string(), params.clone(), keymap::label(app, command, params))); + } + } + rows.retain(|(id, _, _)| crate::chrome::command_available(id, web)); + let mut seen = std::collections::HashSet::new(); + rows.retain(|(command, params, _)| seen.insert((command.clone(), params.to_string()))); + rows.sort_by_cached_key(|a| a.2.to_lowercase()); + rows +} + +/// Whether Command Search can open a matching row in the editor. +pub fn can_customize(app: &RustGisApp, id: &str) -> bool { + if !crate::chrome::command_available(id, cfg!(target_arch = "wasm32")) + || matches!(id, "edit.copy" | "edit.cut" | "edit.paste") + || NEEDS_DIALOG.contains(&id) + { + return false; + } + crate::chrome::UI_COMMANDS.iter().any(|(command, _, _)| *command == id) + || app.session.registry.commands.iter().any(|c| c.id == id && !c.needs_input()) + || rustgis_engine::plugins::command(id).is_some() + || app.ui.keymap.bindings.iter().any(|b| b.command == id) +} + +/// The chord a key press assigns. On a Mac, Ctrl alone is not one: ⌘ is the command key there +/// and Ctrl is a separate modifier, so Ctrl+T and Ctrl+⌘+T are skipped rather than stored as T +/// and ⌘T. Elsewhere `mac_cmd` is never set and `command` mirrors Ctrl. +fn captured_chord(event: &Event) -> Option { + match event { + Event::Key { key, pressed: true, repeat: false, modifiers, .. } + if !(modifiers.ctrl && (!modifiers.command || modifiers.mac_cmd)) => + { + Some(Chord { command: modifiers.command, alt: modifiers.alt, shift: modifiers.shift, key: *key }) + } + _ => None, + } +} + +/// Draws the editor after the normal map and dialogs. +pub fn show(app: &mut RustGisApp, ctx: &Context) { + let mut editor = std::mem::take(&mut app.ui.shortcut_editor); + let was_capturing = editor.capture.is_some(); + if !editor.open { + app.ui.shortcut_editor = editor; + return; + } + let mut open = true; + let mut remove = None; + let mut restore = None; + let mut reset_all = false; + let mut assign_escape = false; + let defaults = Keymap::new(&app.session.registry); + let rows = rows(app, cfg!(target_arch = "wasm32")); + egui::Window::new(tl!("Keyboard Shortcuts")).id(egui::Id::unique("shortcut_editor")).open(&mut open).default_width(620.0).default_height(500.0).resizable(true).show( + ctx, + |ui| { + ui.horizontal(|ui| { + let label = ui.label(tl!("Search commands")); + ui.add_enabled(!editor.capturing(), egui::TextEdit::singleline(&mut editor.filter)).labelled_by(label.id); + if ui.button(tl!("Reset All")).clicked() { + reset_all = true; + } + }); + if app.ui.shortcut_preferences_blocked { + ui.label(tl!("Saved shortcuts are unreadable or exceed the limit. Changes apply only to this session; Reset All replaces the saved shortcuts.")); + } else if app.ui.shortcut_preferences_full { + ui.label(tl!("Too many shortcut changes to save. Changes apply only to this session.")); + } + if cfg!(target_arch = "wasm32") { + ui.label(tl!("Your browser may reserve some shortcuts.")); + } + ui.label(tl!("While typing, shortcuts without Ctrl/⌘ are paused. First-person navigation keeps Escape, Home and Delete.")); + if editor.notice_clipboard { + ui.label(tl!("Copy, Cut and Paste keys cannot be reassigned.")); + } + if editor.capture.is_some() { + ui.horizontal(|ui| { + ui.label(tl!("Press a key combination to assign it. Escape cancels.")); + assign_escape = ui.button(tl!("Assign Escape")).clicked(); + if ui.button(tl!("Cancel")).clicked() { + editor.capture = None; + } + }); + } + let filter = editor.filter.to_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + for (command, params, label) in &rows { + if !filter.is_empty() && !label.to_lowercase().contains(&filter) && !command.to_lowercase().contains(&filter) { + continue; + } + ui.horizontal_wrapped(|ui| { + ui.label(label); + for chord in app.ui.keymap.keys_for(command, params).collect::>() { + if ui + .small_button(format!("{} ×", chord.text(ctx.os().is_mac()))) + .on_hover_text(tl!("Remove shortcut")) + .clicked() + { + remove = Some(chord); + } + } + if ui + .small_button(tl!("Add…")) + .on_hover_text(tl!("Tab, Enter, Space and arrow key bindings can override keyboard navigation.")) + .clicked() + { + editor.notice_clipboard = false; + editor.capture = Some(Target { command: command.clone(), params: params.clone() }); + } + for chord in defaults.keys_for(command, params) { + if app.ui.keymap.command_for(chord).is_none_or(|b| !b.runs(command, params)) + && ui.small_button(crate::i18n::fmt("Reset {key}", &[("key", &chord.text(ctx.os().is_mac()))])).clicked() + { + restore = Some((chord, Target { command: command.clone(), params: params.clone() })); + } + } + }); + ui.separator(); + } + }); + }, + ); + if !open { + editor = Editor::default(); + } + if reset_all { + app.ui.keymap = defaults; + app.ui.shortcut_preferences_blocked = false; + editor.capture = None; + editor.pending = None; + editor.notice_clipboard = false; + save(app); + } + if let Some(chord) = remove { + app.ui.keymap.remove(chord); + save(app); + } + if let Some((chord, target)) = restore { + assign(app, &mut editor, chord, target); + } + if assign_escape { + if let Some(target) = editor.capture.take() + && let Some(chord) = Chord::parse("Escape") + { + assign(app, &mut editor, chord, target); + } + } else if was_capturing && editor.capture.is_some() { + let chord = ctx.input(|i| i.events.iter().find_map(captured_chord)); + if let Some(chord) = chord + && let Some(target) = editor.capture.take() + { + if chord == (Chord { command: false, alt: false, shift: false, key: egui::Key::Escape }) { + editor.capture = None; + } else { + assign(app, &mut editor, chord, target); + } + } + } + if let Some((chord, target)) = editor.pending.clone() { + let existing = app.ui.keymap.command_for(chord).map(|b| keymap::label(app, &b.command, &b.params)).unwrap_or_default(); + egui::Modal::new(egui::Id::unique("shortcut_conflict")).show(ctx, |ui| { + ui.heading(tl!("Shortcut already assigned")); + ui.label(crate::i18n::fmt("{key} is used by {command}.", &[("key", &chord.text(ctx.os().is_mac())), ("command", &existing)])); + ui.horizontal(|ui| { + if ui.button(tl!("Replace")).clicked() { + if app.ui.keymap.assign(chord, &target.command, target.params.clone()) { + save(app); + } + editor.pending = None; + } + if ui.button(tl!("Cancel")).clicked() { + editor.pending = None; + } + }); + }); + } + app.ui.shortcut_editor = editor; +} + +#[cfg(test)] +mod tests { + use super::*; + use rustgis_engine::{Project, Session}; + + #[test] + fn web_rows_exclude_desktop_commands_even_with_existing_bindings() { + let mut app = RustGisApp::new(Session::new(Project::default())); + for id in ["ui.checkUpdate", "ui.mcpRelay"] { + app.ui.keymap.assign(Chord::parse("F8").unwrap(), id, Value::Null); + assert!(!crate::chrome::command_available(id, true)); + assert!(!rows(&app, true).iter().any(|(command, _, _)| command == id)); + assert!(rows(&app, false).iter().any(|(command, _, _)| command == id)); + } + assert!(crate::chrome::command_available("ui.theme", true)); + } + + #[test] + fn conflicting_and_clipboard_keys_are_not_changed_without_confirmation() { + let mut app = RustGisApp::new(Session::new(Project::default())); + let mut editor = Editor::default(); + let target = Target { command: "select.clear".into(), params: Value::Null }; + let chord = Chord::parse("Mod+T").unwrap(); + let before = app.ui.keymap.command_for(chord).unwrap().command.clone(); + assign(&mut app, &mut editor, chord, target.clone()); + assert!(editor.pending.is_some()); + assert_eq!(app.ui.keymap.command_for(chord).unwrap().command, before); + editor.pending = None; + let clipboard = Chord::parse("Mod+C").unwrap(); + assign(&mut app, &mut editor, clipboard, target); + assert!(editor.notice_clipboard); + assert!(app.ui.keymap.command_for(clipboard).is_none()); + } + + #[test] + fn capture_takes_the_command_key_not_a_bare_ctrl() { + let press = + |modifiers: egui::Modifiers| Event::Key { key: egui::Key::T, physical_key: None, pressed: true, repeat: false, modifiers }; + assert_eq!(captured_chord(&press(egui::Modifiers::COMMAND)), Chord::parse("Mod+T")); + // On a Mac, Ctrl+T is Ctrl alone: it is skipped, not stored as a bare T. + assert_eq!(captured_chord(&press(egui::Modifiers::CTRL)), None); + } + + #[test] + fn search_customize_requires_an_editor_row() { + let mut app = RustGisApp::new(Session::new(Project::default())); + assert!(can_customize(&app, "ui.theme")); + assert!(!can_customize(&app, "layer.addData")); + assert!(!can_customize(&app, "edit.copy")); + assert!(!can_customize(&app, "ui.identify")); + assert!(app.ui.keymap.assign(Chord::parse("D").unwrap(), "layer.addData", json!({"path": "example.geojson"}))); + assert!(can_customize(&app, "layer.addData")); + } + + /// Command Search's menu entry and the editor's rows must agree: every row can be customized, + /// and every command that can be customized has a row. + #[test] + fn search_customize_agrees_with_the_editor_rows() { + let app = RustGisApp::new(Session::new(Project::default())); + let ids: std::collections::HashSet = rows(&app, false).into_iter().map(|(id, _, _)| id).collect(); + for id in &ids { + assert!(can_customize(&app, id), "{id} has an editor row but Command Search cannot customize it"); + } + let registry = app.session.registry.commands.iter().map(|c| c.id.to_string()); + let ui = crate::chrome::UI_COMMANDS.iter().map(|&(id, _, _)| id.to_string()); + let plugins = rustgis_engine::plugins::commands().into_iter().map(|c| c.id); + let bound = app.ui.keymap.bindings.iter().map(|b| b.command.clone()); + for id in registry.chain(ui).chain(plugins).chain(bound) { + assert_eq!(can_customize(&app, &id), ids.contains(&id), "{id}"); + } + } +} diff --git a/crates/ui-egui/tests/shortcut_customization.rs b/crates/ui-egui/tests/shortcut_customization.rs new file mode 100644 index 000000000..c6de2cfc1 --- /dev/null +++ b/crates/ui-egui/tests/shortcut_customization.rs @@ -0,0 +1,157 @@ +//! Interaction tests for customizable shortcuts. +use egui_kittest::kittest::Queryable; +use rustgis_engine::{Project, Session}; +use rustgis_ui_egui::{RustGisApp, Services}; +use serde_json::Value; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +fn harness(prefs: Arc>>) -> egui_kittest::Harness<'static, RustGisApp> { + let reads = prefs.clone(); + let writes = prefs; + let services = Services { + load_pref: Some(Box::new(move |key| reads.lock().unwrap().get(key).cloned())), + save_pref: Some(Box::new(move |key, value| { + writes.lock().unwrap().insert(key.to_string(), value.to_string()); + })), + ..Services::default() + }; + let app = RustGisApp::new(Session::new(Project::default())).with_services(services); + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) +} + +#[test] +fn assigns_a_key_confirms_conflicts_and_restores_saved_preferences() { + let prefs = Arc::new(Mutex::new(HashMap::new())); + let mut h = harness(prefs.clone()); + h.state_mut().run("ui.customizeShortcuts", Value::Null).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search commands").focus(); + h.event(egui::Event::Text("ui.theme".into())); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press(egui::Key::T); + h.run_steps(3); + assert!(prefs.lock().unwrap().get("shortcut_changes").is_some_and(|p| p.contains("ui.theme"))); + + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); + h.run_steps(3); + let before = prefs.lock().unwrap().get("shortcut_changes").cloned().unwrap(); + assert!(!before.contains("Mod+T")); + + let restored = harness(prefs); + assert!(restored.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme" && b.chord.key == egui::Key::T)); + assert!( + restored.state().ui.keymap.bindings.iter().any(|b| b.command == "table.open" && b.chord.key == egui::Key::T && b.chord.command) + ); +} + +#[test] +fn tab_and_arrow_keys_can_be_captured() { + let mut h = harness(Arc::new(Mutex::new(HashMap::new()))); + h.state_mut().run("ui.customizeShortcuts", Value::Null).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search commands").focus(); + h.event(egui::Event::Text("ui.theme".into())); + h.run_steps(3); + for key in [egui::Key::Tab, egui::Key::ArrowLeft] { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press(key); + h.run_steps(3); + assert!(h.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme" && b.chord.key == key), "{key:?} was not captured"); + } +} + +#[test] +fn escape_cancels_capture_but_can_be_assigned_explicitly_and_reset() { + let prefs = Arc::new(Mutex::new(HashMap::new())); + let mut h = harness(prefs.clone()); + h.state_mut().run("ui.customizeShortcuts", Value::Null).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search commands").focus(); + h.event(egui::Event::Text("ui.theme".into())); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").focus(); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.step(); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Assign Escape").is_none()); + assert!(prefs.lock().unwrap().get("shortcut_changes").is_none()); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Assign Escape").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Replace").click(); + h.run_steps(3); + assert!(h.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme" && b.chord.key == egui::Key::Escape)); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset All").click(); + h.run_steps(3); + assert!(h.state().ui.keymap.bindings.iter().any(|b| b.command == "tool.set" && b.chord.key == egui::Key::Escape)); + assert!(!h.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme")); + assert_eq!(prefs.lock().unwrap().get("shortcut_changes").unwrap(), "[]"); +} + +#[test] +fn command_search_context_menu_opens_matching_editor_row() { + let mut h = harness(Arc::new(Mutex::new(HashMap::new()))); + h.state_mut().run("ui.commandSearch", Value::Null).unwrap(); + h.run_steps(3); + h.event(egui::Event::Text("Toggle Dark Mode".into())); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Toggle Dark Mode View").click_secondary(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Keyboard Shortcuts").click(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press(egui::Key::T); + h.run_steps(3); + assert!(h.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme" && b.chord.key == egui::Key::T)); +} + +#[test] +fn unreadable_preferences_survive_edits_until_an_explicit_reset() { + let too_many = serde_json::to_string(&vec![serde_json::json!({"keys":"D","command":"select.clear"}); 1_001]).unwrap(); + for original in ["not json".to_string(), " ".repeat(100_001), too_many] { + let prefs = Arc::new(Mutex::new(HashMap::from([("shortcut_changes".into(), original.clone())]))); + let mut h = harness(prefs.clone()); + h.state_mut().run("ui.customizeShortcuts", Value::Null).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Saved shortcuts are unreadable or exceed the limit. Changes apply only to this session; Reset All replaces the saved shortcuts.").is_some()); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search commands").focus(); + h.event(egui::Event::Text("ui.theme".into())); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press(egui::Key::T); + h.run_steps(3); + assert!(h.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme")); + assert_eq!(prefs.lock().unwrap().get("shortcut_changes").unwrap(), &original); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Reset All").click(); + h.run_steps(3); + assert_eq!(prefs.lock().unwrap().get("shortcut_changes").unwrap(), "[]"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Add…").click(); + h.step(); + h.key_press(egui::Key::T); + h.run_steps(3); + let reloaded = harness(prefs); + assert!(reloaded.state().ui.keymap.bindings.iter().any(|b| b.command == "ui.theme" && b.chord.key == egui::Key::T)); + } +} diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index ed9d47c49..17b531dc3 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -170,6 +170,19 @@ Use scripts or the assistant for parameterized commands without an interactive e ## Keyboard shortcuts +**Settings › Customize › Keyboard Shortcuts** assigns multiple keys to commands and map tools. +Click **Add…** and press the desired key combination. Escape cancels; **Assign Escape** binds Escape. +If the combination is already in use, choose **Replace** or **Cancel**. +Tab, Enter, Space and arrow key bindings can override their usual keyboard navigation actions. +Click a displayed key to remove it, **Reset {key}** (for example, **Reset Ctrl+T**) to restore a default key, or **Reset All** to restore +all defaults. These changes are saved as a user preference on this device. Copy, Cut and Paste stay +fixed. While typing, shortcuts without Ctrl/⌘ are paused, including Shift and Alt combinations. During +first-person navigation, unmodified shortcuts are paused except Escape, Home and Delete. Browsers may +reserve some shortcuts. In Command Search, right-click a command and choose **Keyboard Shortcuts** +to open it in the editor. If saved preferences are unreadable or exceed the supported limits, the +original value is preserved and edits apply only to the current session. **Reset All** explicitly +replaces the saved shortcuts with the defaults. + See the [keyboard shortcut reference](index.md#keyboard-shortcuts) for project, map and editing shortcuts, macOS bindings and text-field undo behavior. **Help › Shortcuts** lists the active bindings in the app. Feature editing is described in [Editing](editing.md); layout deletion,