Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
95 changes: 91 additions & 4 deletions crates/ui-egui/src/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Comment thread
giswqs marked this conversation as resolved.
&& !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;
Expand Down Expand Up @@ -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"),
Comment thread
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"),
Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
Expand All @@ -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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 ui.theme), so users see implementation names instead of the labels they picked in Command Search. The editor already matches on the label, so passing the translated label through would give a clearer filter. Note this would need care because the label is translated and may match more than one row.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 {
Expand Down Expand Up @@ -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);
Expand Down
150 changes: 150 additions & 0 deletions crates/ui-egui/src/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = &[
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugs (low severity, low-medium confidence): apply_preferences checks the format of each record but not whether its command is one the editor would offer. The editor filters with needs_input, NEEDS_DIALOG, command_available and edit.copy/cut/paste, but stored records skip those filters. A hand-edited or synced preference could therefore bind a key to ui.importShapefile (which needs dialog parameters) or to a command with no parameters that needs input. Dispatch ignores the result (let _ = app.run_by_user), so the key would silently do nothing. The fix is cheap: pass the registry in and reject unknown or non-customizable command ids here, marking the record unreadable. Otherwise, state in the doc comment that stored commands are not validated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 shortcut_preferences_blocked. From then on nothing is saved, and the only way out is Reset All, which discards every valid stored change as well. Consider keeping unreadable records verbatim and writing them back alongside the new diff, so that valid edits can still be saved and only the bad record is preserved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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(&registry);
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(&registry);
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();
Comment thread
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() {
Expand Down
12 changes: 12 additions & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ mod scriptcolor;
mod scriptcomplete;
mod searchbox;
mod serviceui;
mod shortcutui;
mod showme;
mod sourcespane;
mod sqlbox;
Expand Down Expand Up @@ -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<egui::Rect>,
/// Where the left and right panes were drawn on the last frame, while they show.
pub pane_rects: [Option<egui::Rect>; 2],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading