collective

Browse / github.com/storytold/gridcraft / patch

feat(ui): add Tools › Customize Keyboard dialog and custom shortcut keymap

[Feature Request] Tools › Customize Keyboard dialog and custom shortcut keymap ### Summary Users need the ability to customize keyboard shortcuts to match their workflow and muscle memory. On macOS, this feature is standard in the top application menu bar under `Tools > Customize Keyboard...`. Currently, GridCraft defines built-in keyboard shortcuts in `crates/ui-egui/src/keymap.rs`, but does not provide an in-app dialog or keymap configuration to view and reassign shortcuts to ribbon/menu commands. ### Proposed Solution 1. In `Tools` menu (and via command/dialog `dialog:customizeKeyboard`): - Add a `Customize Keyboard…` item. 2. In the Customize Keyboard dialog:

Status
open
Score
0.00
Adopters
0 other operators
Author
piyushgoel2808 (imported from pull request #2581, open)
Base
f86aec12dabc744b5f75e1033f87bfe685e78481
Size
+540 −3 in 6 files
Review
pending
Shared
2026-10-28
Id
e645e129eccc6a4643817d9f

How it went

No other operator has reported using it yet.

Diff

raw · apps/gridcraft/src/native_menu.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/keymap.rs crates/ui-egui/src/lib.rs crates/ui-egui/tests/it/keyboard_shortcuts.rs

diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs--- a/apps/gridcraft/src/native_menu.rs+++ b/apps/gridcraft/src/native_menu.rs@@ -143,6 +143,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {                 ("Evaluate Formula…", "dialog:evaluateFormula", None),                 ("-", "", None),                 ("Record Actions", "automate.recordActions", None),+                ("Customize Keyboard…", "dialog:customizeKeyboard", None),                 ("Command Palette…", "dialog:commandSearch", Some("CMD+SHIFT+P")),             ],         ),diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -620,6 +620,16 @@ impl Dialog {                 d             }             "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),+            "customizeKeyboard" => Dialog::custom(+                "customizeKeyboard",+                "Customize Keyboard",+                json!({+                    "category": "Home",+                    "command": "home.bold",+                    "current": "",+                    "pressed": "",+                }),+            ),             "subtotal" => Dialog::form(                 "subtotal",                 "Subtotal",@@ -1262,7 +1272,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {     let width = match d.name.as_str() {         "formatCells" => 560.0,         "about" => 660.0,-        "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,+        "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" | "customizeKeyboard" => 520.0,         "recommendedCharts" => 600.0,         "analysisToolPak" => 420.0,         "solver" => 460.0,@@ -1307,6 +1317,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {                 "solver" => solver(ui, &mut d, &mut confirm, &mut open),                 "solverResults" => solver_results(app, ui, &mut d, &mut open),                 "spelling" => spelling(app, ui, &mut d, &mut open),+                "customizeKeyboard" => customize_keyboard(app, ui, &mut d, &mut open),                 "agents" => {                     ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");                     ui.add_space(4.0);@@ -3308,6 +3319,170 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo     }); } +/// Dialog allowing users to customize keyboard shortcuts for ribbon and menu commands.+fn customize_keyboard(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {+    let categories = ["Home", "Insert", "Page Layout", "Formulas", "Data", "Review", "View", "Automate", "All Commands"];+    let cat = d.values.get("category").and_then(Json::as_str).unwrap_or("Home").to_string();+    let mut pressed = d.values.get("pressed").and_then(Json::as_str).unwrap_or("").to_string();++    ui.horizontal_top(|ui| {+        // Left Column: Categories+        ui.vertical(|ui| {+            ui.label(egui::RichText::new("Categories:").strong());+            egui::ScrollArea::vertical().id_salt("cat_scroll").max_height(160.0).min_scrolled_width(140.0).show(ui, |ui| {+                for &c in &categories {+                    let selected = cat == c;+                    if ui.selectable_label(selected, c).clicked() {+                        d.values.insert("category".into(), json!(c));+                        d.values.insert("command".into(), json!(""));+                    }+                }+            });+        });++        ui.add_space(8.0);++        // Refresh category and current_cmd after category interaction+        let cat = d.values.get("category").and_then(Json::as_str).unwrap_or("Home").to_string();+        let current_cmd = d.values.get("command").and_then(Json::as_str).unwrap_or("").to_string();++        // Right Column: Commands+        ui.vertical(|ui| {+            ui.label(egui::RichText::new("Commands:").strong());+            let all_specs = gridcraft_engine::command_specs();+            let filtered: Vec<&gridcraft_engine::cmd::CommandSpec> =+                all_specs.iter().filter(|s| if cat == "All Commands" { true } else { s.menu.first().is_some_and(|m| *m == cat) }).collect();++            let active_cmd = if current_cmd.is_empty() { filtered.first().map(|s| s.id).unwrap_or("") } else { &current_cmd };++            egui::ScrollArea::vertical().id_salt("cmd_scroll").max_height(160.0).min_scrolled_width(220.0).show(ui, |ui| {+                for s in &filtered {+                    let selected = active_cmd == s.id;+                    if ui.selectable_label(selected, s.label).clicked() {+                        d.values.insert("command".into(), json!(s.id));+                    }+                }+            });++            if current_cmd.is_empty() && !active_cmd.is_empty() {+                d.values.insert("command".into(), json!(active_cmd));+            }+        });+    });++    ui.separator();++    let sel_id = d.values.get("command").and_then(Json::as_str).unwrap_or("").to_string();+    let sel_spec = gridcraft_engine::find_command(&sel_id);++    // Current shortcuts for this command+    let mut current_keys: Vec<String> = Vec::new();+    if let Some(spec) = sel_spec+        && let Some(sc) = spec.shortcut+        && !app.ui.shortcuts.get(sc).is_some_and(|t| t != &sel_id)+    {+        current_keys.push(sc.to_string());+    }+    for (k, target) in &app.ui.shortcuts {+        if target == &sel_id && !current_keys.contains(k) {+            current_keys.push(k.clone());+        }+    }++    ui.horizontal(|ui| {+        ui.vertical(|ui| {+            ui.label("Current keys:");+            if current_keys.is_empty() {+                ui.weak("[None]");+            } else {+                for k in &current_keys {+                    ui.monospace(k);+                }+            }+        });++        ui.add_space(20.0);++        ui.vertical(|ui| {+            let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;+            ui.label("Press new keyboard shortcut:");+            let r = ui.add(egui::TextEdit::singleline(&mut pressed).hint_text("e.g. Control+Option+E, Ctrl+Shift+F"));+            if r.changed() {+                d.values.insert("pressed".into(), json!(pressed.clone()));+            }++            if r.has_focus() {+                ui.input(|i| {+                    for e in &i.events {+                        if let egui::Event::Key { key, pressed: true, modifiers, .. } = e+                            && let Some(combo) = crate::keymap::canonical_combo(*key, *modifiers, mac)+                        {+                            d.values.insert("pressed".into(), json!(combo));+                        }+                    }+                });+            }++            let p_val = d.values.get("pressed").and_then(Json::as_str).unwrap_or("");+            let norm_p_val = crate::keymap::normalize_for_platform(p_val, mac);+            ui.horizontal(|ui| {+                ui.label("Currently assigned to:");+                if p_val.is_empty() {+                    ui.weak("[unassigned]");+                } else if let Some(assigned_to) = norm_p_val.as_ref().and_then(|norm| app.ui.shortcuts.get(norm)) {+                    let lbl = gridcraft_engine::find_command(assigned_to).map(|s| s.label).unwrap_or(assigned_to);+                    ui.label(egui::RichText::new(lbl).strong());+                } else {+                    let mut found = None;+                    for spec in gridcraft_engine::command_specs() {+                        if let Some(sc) = spec.shortcut+                            && (sc.eq_ignore_ascii_case(p_val) || norm_p_val.as_deref().is_some_and(|n| sc.eq_ignore_ascii_case(n)))+                            && !app.ui.shortcuts.contains_key(sc)+                        {+                            found = Some(spec.label);+                            break;+                        }+                    }+                    if let Some(lbl) = found {+                        ui.label(egui::RichText::new(lbl).strong());+                    } else {+                        ui.weak("[unassigned]");+                    }+                }+            });+        });+    });++    ui.add_space(8.0);+    ui.horizontal(|ui| {+        let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;+        let p_val = d.values.get("pressed").and_then(Json::as_str).unwrap_or("").to_string();+        let can_assign = !sel_id.is_empty() && !p_val.is_empty();+        if ui.add_enabled(can_assign, egui::Button::new("Assign")).clicked()+            && let Some(norm) = crate::keymap::normalize_for_platform(&p_val, mac)+        {+            app.ui.shortcuts.insert(norm, sel_id.clone());+            d.values.insert("pressed".into(), json!(""));+        }+        let can_remove = current_keys.iter().any(|k| app.ui.shortcuts.get(k) == Some(&sel_id));+        if ui.add_enabled(can_remove, egui::Button::new("Remove")).clicked() {+            for k in &current_keys {+                if app.ui.shortcuts.get(k) == Some(&sel_id) {+                    app.ui.shortcuts.remove(k);+                }+            }+        }+        if ui.add_enabled(!app.ui.shortcuts.is_empty(), egui::Button::new("Reset All…")).clicked() {+            app.ui.shortcuts.clear();+        }+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+            if ui.button("OK").clicked() {+                *open = false;+            }+        });+    });+}+ #[cfg(test)] mod data_validation_tests {     use super::*;@@ -3409,4 +3584,15 @@ mod tests {         assert_eq!(d.values["type"], "list");         assert!(d.values.get("formula1").is_none());     }++    #[test]+    fn customize_keyboard_opens_with_default_category_and_command() {+        let mut session = gridcraft_engine::Session::default();+        session.new_workbook();+        let mut app = SheetApp::new(session, Default::default());+        let d = Dialog::open(&mut app, "customizeKeyboard", json!({})).unwrap();+        assert_eq!(d.title, "Customize Keyboard");+        assert_eq!(d.values["category"], "Home");+        assert_eq!(d.values["command"], "home.bold");+    } }diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -3037,10 +3037,38 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:     for ev in events {         match ev {             egui::Event::Copy => {-                app.copy_to_clipboard(ctx, "edit.copy");+                let target = if ctx.input(|i| i.key_down(egui::Key::Insert)) {+                    app.ui.shortcuts.get("Ctrl+Insert").cloned()+                } else if ctx.input(|i| i.key_down(egui::Key::C)) || !ctx.input(|i| i.modifiers.ctrl || i.modifiers.command) {+                    let mac = ctx.os() == egui::os::OperatingSystem::Mac;+                    let copy_combo = if mac { "Cmd+C" } else { "Ctrl+C" };+                    app.ui.shortcuts.get(copy_combo).cloned()+                } else {+                    None+                };+                if let Some(target) = target {+                    crate::keymap::dispatch_target(app, ctx, &target);+                    shortcut_ran = true;+                } else {+                    app.copy_to_clipboard(ctx, "edit.copy");+                }             }             egui::Event::Cut => {-                app.copy_to_clipboard(ctx, "edit.cut");+                let target = if ctx.input(|i| i.key_down(egui::Key::Delete)) {+                    app.ui.shortcuts.get("Shift+Delete").cloned()+                } else if ctx.input(|i| i.key_down(egui::Key::X)) || !ctx.input(|i| i.modifiers.ctrl || i.modifiers.command) {+                    let mac = ctx.os() == egui::os::OperatingSystem::Mac;+                    let cut_combo = if mac { "Cmd+X" } else { "Ctrl+X" };+                    app.ui.shortcuts.get(cut_combo).cloned()+                } else {+                    None+                };+                if let Some(target) = target {+                    crate::keymap::dispatch_target(app, ctx, &target);+                    shortcut_ran = true;+                } else {+                    app.copy_to_clipboard(ctx, "edit.cut");+                }             }             egui::Event::Paste(text) => app.paste_external(text),             egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs--- a/crates/ui-egui/src/keymap.rs+++ b/crates/ui-egui/src/keymap.rs@@ -178,12 +178,253 @@ pub(crate) fn altgr_text(ctx: &egui::Context, m: Modifiers, mac: bool) -> bool {     !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))) } +/// Canonical name for an egui Key.+pub fn key_name(k: Key) -> Option<&'static str> {+    Some(match k {+        Key::ArrowLeft => "Left",+        Key::ArrowRight => "Right",+        Key::ArrowUp => "Up",+        Key::ArrowDown => "Down",+        Key::Home => "Home",+        Key::End => "End",+        Key::PageUp => "PageUp",+        Key::PageDown => "PageDown",+        Key::Enter => "Enter",+        Key::Tab => "Tab",+        Key::Backspace => "Backspace",+        Key::Delete => "Delete",+        Key::Escape => "Escape",+        Key::Space => "Space",+        Key::Insert => "Insert",+        Key::Equals | Key::Plus => "=",+        Key::Minus => "-",+        Key::Period => ".",+        Key::Comma => ",",+        Key::OpenBracket => "[",+        Key::CloseBracket => "]",+        Key::Semicolon => ";",+        Key::Quote => "'",+        Key::Backtick => "`",+        Key::Num0 => "0",+        Key::Num1 => "1",+        Key::Num2 => "2",+        Key::Num3 => "3",+        Key::Num4 => "4",+        Key::Num5 => "5",+        Key::Num6 => "6",+        Key::Num7 => "7",+        Key::Num8 => "8",+        Key::Num9 => "9",+        Key::F1 => "F1",+        Key::F2 => "F2",+        Key::F3 => "F3",+        Key::F4 => "F4",+        Key::F5 => "F5",+        Key::F6 => "F6",+        Key::F7 => "F7",+        Key::F8 => "F8",+        Key::F9 => "F9",+        Key::F10 => "F10",+        Key::F11 => "F11",+        Key::F12 => "F12",+        Key::A => "A",+        Key::B => "B",+        Key::C => "C",+        Key::D => "D",+        Key::E => "E",+        Key::F => "F",+        Key::G => "G",+        Key::H => "H",+        Key::I => "I",+        Key::J => "J",+        Key::K => "K",+        Key::L => "L",+        Key::M => "M",+        Key::N => "N",+        Key::O => "O",+        Key::P => "P",+        Key::Q => "Q",+        Key::R => "R",+        Key::S => "S",+        Key::T => "T",+        Key::U => "U",+        Key::V => "V",+        Key::W => "W",+        Key::X => "X",+        Key::Y => "Y",+        Key::Z => "Z",+        _ => return None,+    })+}++/// Canonical representation of a key combination, e.g. "Ctrl+Shift+E" or "Cmd+Option+E".+pub fn canonical_combo(key: Key, m: Modifiers, mac: bool) -> Option<String> {+    let name = key_name(key)?;+    let is_function_key =+        matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12);+    if !m.ctrl && !m.command && !m.alt && !m.shift && !is_function_key {+        return None;+    }+    let mut s = String::new();+    if mac {+        if m.command || m.mac_cmd {+            s.push_str("Cmd+");+        }+        if m.ctrl {+            s.push_str("Ctrl+");+        }+        if m.alt {+            s.push_str("Option+");+        }+        if m.shift {+            s.push_str("Shift+");+        }+    } else {+        if m.ctrl || m.command {+            s.push_str("Ctrl+");+        }+        if m.alt {+            s.push_str("Alt+");+        }+        if m.shift {+            s.push_str("Shift+");+        }+    }+    s.push_str(name);+    Some(s)+}++fn canonical_key_name(s: &str) -> Option<String> {+    if s.len() == 1 {+        let c = s.chars().next()?.to_ascii_uppercase();+        if c.is_ascii_alphanumeric() || matches!(c, '=' | '-' | '.' | ',' | '[' | ']' | ';' | '\'' | '`') {+            return Some(c.to_string());+        }+        return None;+    }+    if let Some(f) = s.strip_prefix(['f', 'F'])+        && let Ok(n) = f.parse::<u8>()+        && (1..=12).contains(&n)+    {+        return Some(format!("F{n}"));+    }+    match s.to_ascii_lowercase().as_str() {+        "left" | "arrowleft" => Some("Left".into()),+        "right" | "arrowright" => Some("Right".into()),+        "up" | "arrowup" => Some("Up".into()),+        "down" | "arrowdown" => Some("Down".into()),+        "home" => Some("Home".into()),+        "end" => Some("End".into()),+        "pageup" | "pgup" => Some("PageUp".into()),+        "pagedown" | "pgdn" => Some("PageDown".into()),+        "enter" | "return" => Some("Enter".into()),+        "tab" => Some("Tab".into()),+        "backspace" => Some("Backspace".into()),+        "delete" | "del" => Some("Delete".into()),+        "escape" | "esc" => Some("Escape".into()),+        "space" => Some("Space".into()),+        "insert" | "ins" => Some("Insert".into()),+        _ => None,+    }+}++/// Normalizes any user-entered shortcut string for the specified platform.+pub fn normalize_for_platform(s: &str, mac: bool) -> Option<String> {+    let s = s.trim();+    if s.is_empty() {+        return None;+    }+    let parts: Vec<&str> = s.split('+').map(str::trim).collect();+    if parts.is_empty() {+        return None;+    }+    let mut ctrl = false;+    let mut alt = false;+    let mut shift = false;+    let mut cmd = false;+    let key_part = parts.last()?;+    for p in &parts[..parts.len() - 1] {+        match p.to_ascii_lowercase().as_str() {+            "ctrl" | "control" => ctrl = true,+            "alt" | "option" | "opt" => alt = true,+            "shift" => shift = true,+            "cmd" | "command" | "mod" => cmd = true,+            _ => return None,+        }+    }+    let key_name = canonical_key_name(key_part)?;+    let is_function_key = key_name.starts_with('F') && key_name[1..].parse::<u8>().is_ok();+    if !ctrl && !alt && !shift && !cmd && !is_function_key {+        return None;+    }+    let mut out = String::new();+    if mac {+        if cmd {+            out.push_str("Cmd+");+        }+        if ctrl {+            out.push_str("Ctrl+");+        }+        if alt {+            out.push_str("Option+");+        }+        if shift {+            out.push_str("Shift+");+        }+    } else {+        if cmd || ctrl {+            out.push_str("Ctrl+");+        }+        if alt {+            out.push_str("Alt+");+        }+        if shift {+            out.push_str("Shift+");+        }+    }+    out.push_str(&key_name);+    Some(out)+}++/// Normalizes any user-entered shortcut string (case-insensitively, e.g. "ctrl+shift+f" -> "Ctrl+Shift+F").+pub fn normalize(s: &str) -> Option<String> {+    normalize_for_platform(s, cfg!(target_os = "macos"))+}++pub(crate) fn dispatch_target(app: &mut SheetApp, ctx: &egui::Context, target: &str) {+    if let Some(dialog_name) = target.strip_prefix("dialog:") {+        app.open_dialog(dialog_name, json!({}));+    } else if matches!(target, "edit.copy" | "edit.cut") {+        app.copy_to_clipboard(ctx, target);+    } else if target == "view.zoom" {+        app.open_dialog("zoom", json!({}));+    } else {+        let params = match target {+            "home.borders" => {+                let preset = if app.grid.last_border.is_empty() { "all" } else { &app.grid.last_border };+                json!({"preset": preset})+            }+            _ => json!({}),+        };+        app.run_or_alert(target, params);+    }+    app.grid.ensure_visible = true;+}+ /// Handles a key press in the grid. Returns whether it was a shortcut. pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {     let mac = ctx.os() == egui::os::OperatingSystem::Mac;     if altgr_text(ctx, m, mac) {         return false;     }+    // Check custom shortcuts before built-in shortcuts+    if let Some(combo) = canonical_combo(key, m, mac)+        && let Some(target) =+            app.ui.shortcuts.get(&combo).or_else(|| normalize_for_platform(&combo, mac).as_ref().and_then(|nc| app.ui.shortcuts.get(nc))).cloned()+    {+        dispatch_target(app, ctx, &target);+        return true;+    }     let Some(shortcut) = lookup(key, physical, m, mac) else { return false };     let (active, anchor) = app.session.active().map(|d| (d.selection.active, d.selection.anchor)).unwrap_or_default();     match shortcut {@@ -267,3 +508,35 @@ fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) {     app.grid.ensure_visible = true;     app.grid.reveal_moving_end = true; }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn canonical_combo_formats_keys() {+        let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };+        let ctrl_shift = Modifiers { ctrl: true, shift: true, command: true, ..Modifiers::NONE };+        assert_eq!(canonical_combo(Key::B, mac_cmd, true), Some("Cmd+B".into()));+        assert_eq!(canonical_combo(Key::K, ctrl_shift, false), Some("Ctrl+Shift+K".into()));+        assert_eq!(canonical_combo(Key::F5, Modifiers::NONE, false), Some("F5".into()));+        assert_eq!(canonical_combo(Key::F, Modifiers::NONE, false), None);+    }++    #[test]+    fn normalize_shortcuts() {+        assert_eq!(normalize_for_platform("ctrl+shift+k", false), Some("Ctrl+Shift+K".into()));+        assert_eq!(normalize_for_platform("cmd+b", false), Some("Ctrl+B".into()));+        assert_eq!(normalize_for_platform("cmd+b", true), Some("Cmd+B".into()));+        assert_eq!(normalize_for_platform("alt+k", true), Some("Option+K".into()));+        assert_eq!(normalize_for_platform("option+k", false), Some("Alt+K".into()));+        assert_eq!(normalize_for_platform("mod+shift+m", true), Some("Cmd+Shift+M".into()));+        assert_eq!(normalize_for_platform("ctrl + alt + f1", false), Some("Ctrl+Alt+F1".into()));+        assert_eq!(normalize_for_platform("ctrl+pageup", false), Some("Ctrl+PageUp".into()));+        assert_eq!(normalize_for_platform("ctrl+pagedown", false), Some("Ctrl+PageDown".into()));+        assert_eq!(normalize_for_platform("ctrl+whatever", false), None);+        assert_eq!(normalize_for_platform("ctrl+f13", false), None);+        assert_eq!(normalize_for_platform("f", false), None);+        assert_eq!(normalize_for_platform("f2", false), Some("F2".into()));+    }+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -81,6 +81,9 @@ pub struct UiState {     /// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100.     #[serde(deserialize_with = "lenient_interface_scale")]     pub interface_scale: u16,+    /// Custom keyboard shortcut bindings (canonical shortcut -> command id).+    #[serde(default)]+    pub shortcuts: std::collections::HashMap<String, String>, }  pub const INTERFACE_SCALE_MIN: u16 = 75;@@ -113,6 +116,7 @@ impl Default for UiState {             keytip_invoke: None,             language: i18n::Language::En,             interface_scale: 100,+            shortcuts: std::collections::HashMap::new(),         }     } }diff --git a/crates/ui-egui/tests/it/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs--- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs+++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs@@ -306,3 +306,48 @@ fn ctrl_alt_letter_that_typed_text_is_not_select_all() {     assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "B2");     assert_eq!(h.state().editor.as_ref().unwrap().text, "\u{e4}"); }++#[test]+fn custom_shortcut_triggers_command_and_dialog() {+    let mut h = harness(book("A1:B2"));+    let mac = h.ctx.os() == egui::os::OperatingSystem::Mac;+    let combo = if mac { "Cmd+Shift+K" } else { "Ctrl+Shift+K" };+    let mods = if mac { Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE } } else { CTRL_SHIFT };+    h.state_mut().ui.shortcuts.insert(combo.into(), "dialog:customizeKeyboard".into());+    key(&mut h, Key::K, mods);+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("customizeKeyboard"));+    h.state_mut().dialog = None;++    // Configurable commands like home.borders receive default parameters+    let border_combo = if mac { "Cmd+Shift+B" } else { "Ctrl+Shift+B" };+    h.state_mut().ui.shortcuts.insert(border_combo.into(), "home.borders".into());+    key(&mut h, Key::B, mods);+    assert!(style(&h, "B2").borders.bottom.style != BorderStyle::None);++    // view.zoom opens the zoom dialog without error+    let zoom_combo = if mac { "Cmd+Shift+Z" } else { "Ctrl+Shift+Z" };+    h.state_mut().ui.shortcuts.insert(zoom_combo.into(), "view.zoom".into());+    key(&mut h, Key::Z, mods);+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("zoom"));+    h.state_mut().dialog = None;++    // Native copy event checks custom shortcuts before clipboard fallback+    let copy_combo = if mac { "Cmd+C" } else { "Ctrl+C" };+    h.state_mut().ui.shortcuts.insert(copy_combo.into(), "dialog:customizeKeyboard".into());+    h.input_mut().events.push(Event::Copy);+    h.run_steps(1);+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("customizeKeyboard"));+    h.state_mut().dialog = None;++    // Alternate clipboard keys (Insert held) stay on clipboard path, not triggering Cmd/Ctrl+C target+    h.input_mut().events.push(Event::Key {+        key: Key::Insert,+        physical_key: Some(Key::Insert),+        pressed: true,+        repeat: false,+        modifiers: Modifiers::CTRL,+    });+    h.input_mut().events.push(Event::Copy);+    h.run_steps(1);+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), None);+}