From 8383d828ad5808f9ca51deec36902b78ea6fcc2b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:14:59 +0000 Subject: [PATCH] keymap: DesignCraft shortcuts on craft-keymap The shortcut model, dispatch and storage move onto the shared craft-keymap crate. The catalogue is built from UI_COMMANDS, the engine command specs, the panels (window.panel {panel}) and the tools (tool.select {tool}, one group per Tools-panel group), with menu paths from the menu tree. - Dispatch goes through craft_keymap::egui::Dispatcher; presentation keys, Esc-to-selection, Clear and the native-menu skip stay app-side, and the clipboard events stay with the canvas. - Panels take InDesign's default keys where DesignCraft leaves them free. - Tool keys are editable; tools sharing a key take turns. - ui.json keeps its shortcuts as craft-keymap Overrides; the map form older versions saved loads unchanged. - window.setShortcut accepts params (panels), tool ids and steal; the Keyboard Shortcuts dialog lists panels and tools and moves recorded keys. --- Cargo.lock | 11 + Cargo.toml | 2 + ROADMAP.md | 1 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 51 ++-- crates/ui-egui/src/keys.rs | 214 ++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/menus.rs | 356 ++++++++++++++------------ crates/ui-egui/src/shortcut_import.rs | 19 +- crates/ui-egui/src/toolbar.rs | 6 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 13 files changed, 499 insertions(+), 186 deletions(-) create mode 100644 crates/ui-egui/src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index bc2369d12..91e7a6b89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=d6c7b45350f7a4352b845c9fdd2e63913d1af27d#d6c7b45350f7a4352b845c9fdd2e63913d1af27d" +dependencies = [ + "egui", + "serde", + "serde_json", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1212,6 +1222,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.8.0" dependencies = [ + "craft-keymap", "craft-ui", "designcraft-color", "designcraft-compose", diff --git a/Cargo.toml b/Cargo.toml index a9aeccd3e..4767c4ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared writer; the immutable revision must be published before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } +# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch. +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "d6c7b45350f7a4352b845c9fdd2e63913d1af27d", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 5af957ba6..6ca512064 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index da483931d..381010a6d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -152,6 +152,7 @@ fn load_prefs(app: &mut DesignApp) { && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { app.ui = ui; + app.sync_keymap(); } // Engine preferences (Preferences dialog, favourites…) live beside the UI state. if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c9d85253f..c780f3647 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-keymap = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index edef4affa..35779b3ec 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -264,16 +264,27 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if let Some((key, m)) = pressed { let sc = match key { egui::Key::Escape => None, - egui::Key::Backspace if !m.any() => Some(String::new()), - _ => Some(crate::menus::shortcut_string(m, key)), + egui::Key::Backspace if !m.any() => Some(Some(String::new())), + _ => Some(crate::keys::chord_text(m, key)), }; if let Some(sc) = sc { - let msg = match app.run("window.setShortcut", json!({"id": recording, "shortcut": sc})) { - Ok(v) => match v["conflicts"].as_array().filter(|c| !c.is_empty()) { - Some(c) => format!("Also used by: {}", c.iter().filter_map(|x| x.as_str()).collect::>().join(", ")), - None => String::new(), - }, - Err(e) => e, + let msg = match (sc, craft_keymap::CommandRef::parse_key(&recording)) { + (Some(sc), Some(cmd)) => { + // The recorded keys move to this command; the commands that held them lose them. + match app.run("window.setShortcut", json!({"id": cmd.id, "params": cmd.params, "shortcut": sc, "steal": true})) { + Ok(v) => match v["conflicts"].as_array().filter(|c| !c.is_empty()) { + Some(c) => { + let labels: Vec = + c.iter().filter_map(|x| x.as_str()).map(|k| crate::keys::label(&app.keymap, k)).collect(); + format!("Taken from: {}", labels.join(", ")) + } + None => String::new(), + }, + Err(e) => e, + } + } + (None, _) => "That key can't be used in a shortcut".to_string(), + (_, None) => String::new(), }; d.fields.insert("message".into(), json!(msg)); } @@ -281,11 +292,20 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } let ql = q.to_lowercase(); - let mut rows: Vec<(String, String, String)> = designcraft_engine::command_specs() + // (command key, label, menu path): every command with a menu row or a default shortcut, the + // UI commands, the panels and the tools. + let mut rows: Vec<(String, String, String)> = app + .keymap + .catalogue() .iter() - .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) - .chain(crate::menus::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), String::new()))) + .filter(|b| { + let ui_command = crate::menus::UI_COMMANDS.iter().any(|c| c.0 == b.command.id); + ui_command || !b.paths.is_empty() || !b.defaults.is_empty() || b.command.id == crate::keys::PANEL_COMMAND + }) + .map(|b| { + let menu = b.paths.first().map(|p| p.get(..p.len().saturating_sub(1)).unwrap_or_default().join(" › ")).unwrap_or_default(); + (b.command.key(), b.label.clone(), menu) + }) .filter(|(id, l, _)| ql.is_empty() || l.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql)) .collect(); rows.sort_by(|a, b| a.1.cmp(&b.1)); @@ -294,15 +314,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { for (id, label, menu) in &rows { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label.trim_end_matches('…'))) .on_hover_text(format!("{id}{}", if menu.is_empty() { String::new() } else { format!(" ({menu})") })); - let cur = crate::menus::shortcut_of(app, id).map(|s| crate::menus::shortcut_text(&s)).unwrap_or_else(|| "—".into()); + let cmd = craft_keymap::CommandRef::parse_key(id); + let cur = cmd.as_ref().and_then(|c| crate::keys::shortcut_display(&app.keymap, c)).unwrap_or_else(|| "—".into()); let text = if recording == *id { crate::i18n::tr(&app.ui.language, "Press keys…").to_string() } else { cur }; if ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0))).clicked() { d.fields.insert("recording".into(), json!(id)); d.fields.insert("message".into(), json!("")); } - if app.ui.shortcuts.contains_key(id) { + if let Some(cmd) = cmd.filter(|_| app.keymap.overrides().get(id).is_some()) { if ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Default"))).clicked() { - let _ = app.run("window.setShortcut", json!({"id": id, "shortcut": null})); + let _ = app.run("window.setShortcut", json!({"id": cmd.id, "params": cmd.params, "shortcut": null})); } } else { ui.label(""); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs new file mode 100644 index 000000000..bc3c53b54 --- /dev/null +++ b/crates/ui-egui/src/keys.rs @@ -0,0 +1,214 @@ +//! Keyboard shortcuts on `craft-keymap`: the catalogue (UI commands, engine commands, panels and +//! tools with their default keys and menu paths), shortcut text, and the edits that +//! `window.setShortcut`, the Keyboard Shortcuts dialog and the shortcut-set importer make. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::menus::{self, Item}; + +/// The context bindings such as `Text: Cmd+B` name; active while text is being typed on the canvas. +pub const TEXT_CONTEXT: &str = "Text"; + +/// The command that opens a panel; a binding on it carries the panel id. +pub const PANEL_COMMAND: &str = "window.panel"; +/// The command that selects a tool; a binding on it carries the tool id. +pub const TOOL_COMMAND: &str = "tool.select"; + +/// InDesign's default keys for the panels DesignCraft has, where DesignCraft's own defaults leave +/// the key free. +const PANEL_KEYS: &[(&str, &str)] = &[ + ("swatches", "F5"), + ("color", "F6"), + ("layers", "F7"), + ("info", "F8"), + ("stroke", "F10"), + ("paragraphStyles", "F11"), + ("characterStyles", "Shift+F11"), + ("pages", "F12"), + ("align", "Shift+F7"), +]; + +pub fn panel(id: &str) -> CommandRef { + CommandRef::with_params(PANEL_COMMAND, json!({ "panel": id })) +} + +pub fn tool(id: &str) -> CommandRef { + CommandRef::with_params(TOOL_COMMAND, json!({ "tool": id })) +} + +/// The command a menu row or a `window.setShortcut` call names. +pub fn command_ref(id: &str, params: &Value) -> CommandRef { + match params { + Value::Null => CommandRef::new(id), + Value::Object(m) if m.is_empty() => CommandRef::new(id), + p => CommandRef::with_params(id, p.clone()), + } +} + +/// The keymap with the user's overrides. +pub fn new_keymap(overrides: Overrides) -> Keymap { + Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), Platform::current()).with_overrides(overrides) +} + +/// Every bindable command, in dispatch order: where two share keys the earlier one runs. UI +/// commands come first (⌘N is the New Document dialog, not a bare `file.new`), then engine +/// commands, panels and tools. +pub fn catalogue() -> &'static [Bindable] { + static CATALOGUE: OnceLock> = OnceLock::new(); + CATALOGUE.get_or_init(build_catalogue) +} + +fn build_catalogue() -> Vec { + let paths = menu_paths(); + let app = Scope::app(); + let mut out = Vec::new(); + let mut add = |command: CommandRef, label: &str, default: Option<&str>, fallback_path: Option>, kind: Kind| { + let mut b = Bindable::new(command, label, app.clone()).with_kind(kind); + match paths.get(&b.command.key()) { + Some(found) => b.paths.extend(found.iter().cloned()), + None => b.paths.extend(fallback_path), + } + if let Some(binding) = default.and_then(|d| Binding::parse(d).ok()) { + b = b.with_default(binding); + } + out.push(b); + }; + for (id, label, _, _) in menus::UI_COMMANDS { + add(CommandRef::new(*id), label, menus::default_shortcut(id), None, Kind::Command); + } + for c in designcraft_engine::command_specs() { + let path = (!c.menu.is_empty()).then(|| c.menu.iter().map(|s| s.to_string()).chain([c.label.to_string()]).collect()); + add(CommandRef::new(c.id), c.label, c.shortcut, path, Kind::Command); + } + let mut seen = Vec::new(); + for (id, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if seen.contains(id) { + continue; + } + seen.push(id); + let default = PANEL_KEYS.iter().find(|(p, _)| p == id).map(|(_, k)| *k); + add(panel(id), label, default, Some(vec!["Window".into(), (*label).into()]), Kind::Command); + } + for group in designcraft_tools::TOOL_GROUPS { + let Some(first) = group.first() else { continue }; + for t in group.iter() { + let kind = Kind::Tool { group: Some(first.id.to_string()) }; + add(tool(t.id), t.label, t.shortcut, Some(vec!["Tools".into(), t.label.into()]), kind); + } + } + out +} + +/// Menu paths (menu, submenus, label) of every menu row, by command key. +fn menu_paths() -> HashMap>> { + fn walk(items: &[Item], path: &mut Vec, out: &mut HashMap>>) { + for it in items { + match it { + Item::Sub(name, children) => { + path.push(name.clone()); + walk(children, path, out); + path.pop(); + } + Item::Cmd { label, id, params, .. } => { + let mut full = path.clone(); + full.push(label.clone()); + out.entry(command_ref(id, params).key()).or_default().push(full); + } + Item::Sep => {} + } + } + } + let mut out = HashMap::new(); + for (menu, entries) in menus::menu_tree() { + walk(&entries, &mut vec![menu.to_string()], &mut out); + } + out +} + +/// The binding a command shows: the first in its home scope, else its first. +fn shown_binding(keymap: &Keymap, cmd: &CommandRef) -> Option { + let bindings = keymap.bindings(cmd); + bindings.iter().find(|b| b.scope.is_none()).or_else(|| bindings.first()).cloned() +} + +/// The shortcut in effect for a command, as stored and sent ("Cmd+Alt+K", "Text: F7"). +pub fn shortcut(keymap: &Keymap, cmd: &CommandRef) -> Option { + let b = shown_binding(keymap, cmd)?; + let home = keymap.bindable(cmd).map_or_else(Scope::app, |c| c.scope.clone()); + Some(b.text(&home)) +} + +/// The shortcut in effect for a command, for menus and lists (⌥⌘K on macOS, Ctrl+Alt+K elsewhere). +pub fn shortcut_display(keymap: &Keymap, cmd: &CommandRef) -> Option { + shown_binding(keymap, cmd).map(|b| b.trigger.display(keymap.platform())) +} + +/// A key press as shortcut text ("Cmd+Shift+K"); `None` for a key the keymap has no name for. +pub fn chord_text(m: egui::Modifiers, key: egui::Key) -> Option { + let key = Key::from_egui(key)?; + Some(Chord::new(Mods::from_egui(&m, Platform::current()), key).to_string()) +} + +/// Copies the keymap's overrides to the saved UI state. +pub fn store(app: &mut DesignApp) { + if app.ui.shortcuts != *app.keymap.overrides() { + app.ui.shortcuts = app.keymap.overrides().clone(); + } +} + +/// Gives a command keys (replacing its others), no keys (`""`), or its defaults back (`None`). +/// Other commands on the same keys keep them unless `policy` is [`Policy::Steal`]. Returns the +/// commands it took the keys from. +pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy: Policy) -> Result, String> { + app.sync_keymap(); + if app.keymap.bindable(cmd).is_none() { + return Err(format!("unknown command `{cmd}`")); + } + let mut taken = Vec::new(); + match shortcut.map(str::trim) { + None => { + app.keymap.reset(cmd); + } + Some("") => { + if !app.keymap.clear(cmd) { + return Err("too many changed shortcuts".into()); + } + } + Some(sc) => { + let binding = Binding::parse(sc).map_err(|e| format!("can't read shortcut `{sc}`: {e}"))?; + let done = app.keymap.set_bindings(cmd, vec![binding], policy).map_err(|e| format!("can't assign `{sc}`: {e}"))?; + taken = done.taken_from.into_iter().map(|h| h.command).collect(); + } + } + store(app); + Ok(taken) +} + +/// Other commands on the keys `cmd` holds, by command key. +pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { + let mut out: Vec = Vec::new(); + for c in keymap.conflicts() { + let other = if c.a.0 == *cmd { + &c.b.0 + } else if c.b.0 == *cmd { + &c.a.0 + } else { + continue; + }; + let key = other.key(); + if !out.contains(&key) { + out.push(key); + } + } + out +} + +/// A command's label, by its key. +pub fn label(keymap: &Keymap, key: &str) -> String { + CommandRef::parse_key(key).and_then(|c| keymap.bindable(&c).map(|b| b.label.clone())).unwrap_or_else(|| key.to_string()) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cb1503f3..0fd461867 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod dock; pub mod effects_dialog; pub mod i18n; pub mod icons; +pub mod keys; pub mod menus; mod modal_drag; mod new_document; @@ -250,8 +251,9 @@ pub struct UiState { pub rich_black: bool, /// View › Overprint Preview. pub overprint_preview: bool, - /// Edit › Keyboard Shortcuts: command id → shortcut ("" = none), over the defaults. - pub shortcuts: std::collections::BTreeMap, + /// Edit › Keyboard Shortcuts: the user's changes to the default shortcuts. Older files store a + /// map of command id → shortcut ("" = none), which loads unchanged. + pub shortcuts: craft_keymap::Overrides, pub control_bar: bool, pub tools_double_column: bool, /// Expanded right-dock panel group tab. @@ -605,6 +607,9 @@ pub struct DesignApp { pub native_menu: bool, /// Shortcuts the native menu handles (skip them in the egui shortcut handler). pub native_shortcuts: std::collections::HashSet, + /// Every command's shortcuts, with the user's changes (`ui.shortcuts`) applied. + pub keymap: craft_keymap::Keymap, + pub(crate) key_dispatcher: craft_keymap::egui::Dispatcher, last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, @@ -691,6 +696,8 @@ impl DesignApp { pending_window_commands: vec![], native_menu: false, native_shortcuts: Default::default(), + keymap: keys::new_keymap(craft_keymap::Overrides::new()), + key_dispatcher: craft_keymap::egui::Dispatcher::new(), last_time: 0.0, last_recovery: 0.0, egui_ctx: None, @@ -1118,6 +1125,13 @@ impl DesignApp { } } + /// Applies `ui.shortcuts` to the keymap when they differ, e.g. after preferences were loaded. + pub fn sync_keymap(&mut self) { + if *self.keymap.overrides() != self.ui.shortcuts { + self.keymap.set_overrides(self.ui.shortcuts.clone()); + } + } + pub fn select_tool(&mut self, id: &str) { let _ = self.run("tool.select", json!({"tool": id})); self.ui.flyout = None; @@ -1128,6 +1142,7 @@ impl DesignApp { if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } + self.sync_keymap(); self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { self.color_applied = true; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c261ceb4a..87bbfd4fa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -83,7 +83,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.uiScale", "UI Scaling", None, "{scale: 0.5–3 (1 = 100%)} — the size of the whole interface"), ("app.keyboardShortcuts", "Keyboard Shortcuts…", None, "{} — view and change the shortcut of any command"), ("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"), - ("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"), + ( + "window.setShortcut", + "Set Shortcut", + None, + "{id, params?, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default), steal?} — id is a command; a panel is id \"window.panel\" with params {panel: \"layers\"}, a tool is id \"tool.select\" with params {tool: \"pen\"} (or its tool id, \"pen\"); steal: true takes the keys from the commands holding them → {shortcut, conflicts: [command keys: ids, or `window.panel {\"panel\":\"layers\"}`]}", + ), ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), ( "window.importShortcuts", @@ -1683,32 +1688,34 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let Some(cmd) = p.get("id").and_then(Value::as_str) else { return Some(Err("missing id".into())) }; - if designcraft_engine::find_command(cmd).is_none() && ui_label(cmd).is_none() { - return Some(Err(format!("unknown command `{cmd}`"))); + let Some(id) = p.get("id").and_then(Value::as_str) else { return Some(Err("missing id".into())) }; + let mut cmd = crate::keys::command_ref(id, p.get("params").unwrap_or(&Value::Null)); + // A bare tool id ("pen") names its tool. + if app.keymap.bindable(&cmd).is_none() && designcraft_tools::tool_info(id).is_some() { + cmd = crate::keys::tool(id); } - match p.get("shortcut") { - None | Some(Value::Null) => { - app.ui.shortcuts.remove(cmd); - } - Some(Value::String(sc)) if sc.is_empty() => { - app.ui.shortcuts.insert(cmd.to_string(), String::new()); - } - Some(Value::String(sc)) => { - if parse_shortcut(sc).is_none() { - return Some(Err(format!("can't read shortcut `{sc}`"))); - } - app.ui.shortcuts.insert(cmd.to_string(), sc.clone()); - } + let shortcut = match p.get("shortcut") { + None | Some(Value::Null) => None, + Some(Value::String(sc)) => Some(sc.as_str()), Some(_) => return Some(Err("shortcut: a string or null".into())), + }; + let steal = p.get("steal").and_then(Value::as_bool).unwrap_or(false); + let policy = if steal { craft_keymap::Policy::Steal } else { craft_keymap::Policy::Keep }; + let taken = match crate::keys::set(app, &cmd, shortcut, policy) { + Ok(t) => t, + Err(e) => return Some(Err(e)), + }; + let mut conflicts: Vec = taken.iter().map(|c| c.key()).collect(); + for key in crate::keys::conflicts(&app.keymap, &cmd) { + if !conflicts.contains(&key) { + conflicts.push(key); + } } - let sc = shortcut_of(app, cmd); - let conflicts: Vec = - effective_shortcuts(app).into_iter().filter(|(i, s)| i != cmd && Some(s) == sc.as_ref()).map(|(i, _)| i).collect(); - Ok(json!({"shortcut": sc, "conflicts": conflicts})) + Ok(json!({"shortcut": crate::keys::shortcut(&app.keymap, &cmd), "conflicts": conflicts})) } "window.resetShortcuts" => { - app.ui.shortcuts.clear(); + app.keymap.reset_all(); + crate::keys::store(app); Ok(Value::Null) } "window.importShortcuts" => { @@ -2139,59 +2146,41 @@ pub fn default_shortcut(id: &str) -> Option<&'static str> { designcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| ui_label(id).and_then(|(_, s)| s)) } -/// The shortcut in effect for a command (the user's, else the default). +/// The shortcut in effect for a command (the user's, else the default), as stored ("Cmd+Alt+K"). pub fn shortcut_of(app: &DesignApp, id: &str) -> Option { - match app.ui.shortcuts.get(id) { - Some(s) if s.is_empty() => None, - Some(s) => Some(s.clone()), - None => default_shortcut(id).map(str::to_string), - } + crate::keys::shortcut(&app.keymap, &craft_keymap::CommandRef::new(id)) } -/// Every command with a shortcut in effect: (id, shortcut). UI commands come first: where one -/// shares keys with an engine command (⌘N, ⌘O…) it's the one with the dialog. +/// The shortcut a menu row shows: its own binding (a panel's), else its command's. +pub fn shortcut_label(app: &DesignApp, id: &str, params: &Value) -> Option { + let cmd = crate::keys::command_ref(id, params); + let cmd = if app.keymap.bindable(&cmd).is_some() { cmd } else { craft_keymap::CommandRef::new(id) }; + crate::keys::shortcut_display(&app.keymap, &cmd) +} + +/// The shortcut of a tool, for its button's tooltip. +pub fn tool_shortcut_label(app: &DesignApp, tool: &str) -> Option { + crate::keys::shortcut_display(&app.keymap, &crate::keys::tool(tool)) +} + +/// Every command with a shortcut in effect: (command key, shortcut), in dispatch order. UI +/// commands come first: where one shares keys with an engine command (⌘N, ⌘O…) it's the one +/// with the dialog. pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> { - UI_COMMANDS - .iter() - .map(|c| c.0) - .chain(designcraft_engine::command_specs().iter().map(|c| c.id)) - .filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), s))) - .collect() + app.keymap.catalogue().iter().filter_map(|b| crate::keys::shortcut(&app.keymap, &b.command).map(|s| (b.command.key(), s))).collect() } /// A shortcut as written in this app ("Cmd+Alt+Shift+K") from a key press. pub fn shortcut_string(m: egui::Modifiers, key: egui::Key) -> String { - let mut s = String::new(); - if m.command { - s += "Cmd+"; - } - if m.alt { - s += "Alt+"; - } - if m.shift { - s += "Shift+"; - } - s += key.name(); - s + crate::keys::chord_text(m, key).unwrap_or_else(|| key.name().to_string()) } -/// A shortcut for display: ⌃⌥⇧⌘ order on macOS, Ctrl+Alt+Shift+ elsewhere. +/// A shortcut for display: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. pub fn shortcut_text(sc: &str) -> String { - let mac = cfg!(target_os = "macos"); - // The key is the last part ("Cmd+-" ends in an empty part before "-"). - let (mods, key) = match sc.rsplit_once('+') { - Some((m, "")) => (m.trim_end_matches('+'), "+"), - Some((m, k)) => (m, k), - None => ("", sc), - }; - let has = |m: &str| mods.split('+').any(|x| x == m); - let mut out = String::new(); - for (m, sym, word) in [("Ctrl", "⌃", "Ctrl+"), ("Alt", "⌥", "Alt+"), ("Shift", "⇧", "Shift+"), ("Cmd", "⌘", "Ctrl+")] { - if has(m) && !(m == "Cmd" && !mac && has("Ctrl")) { - out += if mac { sym } else { word }; - } + match craft_keymap::Binding::parse(sc) { + Ok(b) => b.trigger.display(craft_keymap::Platform::current()), + Err(_) => sc.to_string(), } - out + key } /// A menu entry, shared by the in-window menu bar and the native macOS menu. @@ -2913,8 +2902,8 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str None => label.to_owned(), }; let mut b = egui::Button::new(crate::rtl::widget(ui, text)); - if let Some(sc) = shortcut_of(app, id) { - b = b.shortcut_text(shortcut_text(&sc)); + if let Some(sc) = shortcut_label(app, id, params) { + b = b.shortcut_text(sc); } if ui.add_enabled(menu_enabled(app, id), b).clicked() { activate(app, id, params); @@ -2993,49 +2982,17 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } +/// Reads shortcut text into egui's modifiers and key (`command` for Cmd, `ctrl` for the Mac +/// Control key). pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { - let mut m = egui::Modifiers::NONE; - let mut key = None; - for part in sc.split('+').filter(|s| !s.is_empty()) { - match part { - "Cmd" => m.command = true, - "Shift" => m.shift = true, - "Alt" => m.alt = true, - "Ctrl" => m.ctrl = true, - "=" => key = Some(egui::Key::Equals), - "-" => key = Some(egui::Key::Minus), - "[" => key = Some(egui::Key::OpenBracket), - "]" => key = Some(egui::Key::CloseBracket), - ";" => key = Some(egui::Key::Semicolon), - "'" => key = Some(egui::Key::Quote), - "," => key = Some(egui::Key::Comma), - "." => key = Some(egui::Key::Period), - "\\" => key = Some(egui::Key::Backslash), - "/" => key = Some(egui::Key::Slash), - "Delete" => key = Some(egui::Key::Delete), - k => key = egui::Key::from_name(k), - } - } - if sc.ends_with("+-") || sc == "-" { - key = Some(egui::Key::Minus); - } - key.map(|k| (m, k)) + let chord = craft_keymap::Chord::parse(sc).ok()?; + let s = chord.to_egui_shortcut(); + Some((s.modifiers, s.logical_key)) } -/// Whether a key press matches a parsed shortcut `m`+`k`: `None` when it doesn't, `Some(true)` for -/// the shortcut's own key and modifiers, `Some(false)` for the Plus key standing in for `=`. On -/// layouts with a dedicated + key (German) or Shift+= (US), Cmd+= arrives as Plus. -fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui::Modifiers) -> Option { - let plus_for_equals = key == egui::Key::Plus; - let matches = (k == key || (plus_for_equals && k == egui::Key::Equals)) - && m.command == modifiers.command - && (m.shift == modifiers.shift || (plus_for_equals && !m.shift)) - && m.alt == modifiers.alt; - matches.then_some(k == key && m.shift == modifiers.shift) -} - -/// Global keyboard shortcuts: menu commands and single-key tool shortcuts. +/// Global keyboard shortcuts: menu commands, panels and tools, through the keymap. pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { + app.sync_keymap(); // Only a focused text field takes the keys: the canvas (or a button) having focus after a click // must not swallow tool shortcuts until Esc clears it. // While an IME composes at the text caret, the keyboard is its own. An open canvas context menu @@ -3050,17 +3007,87 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { return; } let typing = app.session.wants_text(); + if app.ui.screen_mode == ScreenMode::Presentation { + presentation_keys(app, ctx); + } + let contexts: &[&str] = if typing { &[crate::keys::TEXT_CONTEXT] } else { &[] }; + let active = craft_keymap::Active { panel: None, contexts, typing }; + let hits = ctx.input_mut(|i| { + let platform = app.keymap.platform(); + // Presses the dispatcher must not take: the clipboard events the canvas and text tools + // handle themselves (Paste carries the text), Clear (the active tool deletes), and the + // keys the native menu's accelerators already handled. They go back afterwards. + let mut held = Vec::new(); + i.events.retain(|e| { + let keep = match e { + egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) => false, + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let exact = + craft_keymap::Key::from_egui(*key).map(|k| craft_keymap::Chord::new(craft_keymap::Mods::from_egui(modifiers, platform), k)); + let run = match exact.map(|c| app.keymap.lookup(&c, false, &active)) { + Some(craft_keymap::Lookup::Run(c)) => Some(c), + _ => None, + }; + // The native menu's accelerator holds the command's default keys and fires + // only for them exactly: the Plus key standing in for = never reaches it. + !run.is_some_and(|c| { + c.id == "edit.clear" + || (c.params.is_null() && app.native_shortcuts.contains(&c.id) && app.keymap.overrides().get(&c.id).is_none()) + }) + } + _ => true, + }; + if !keep { + held.push(e.clone()); + } + keep + }); + let hits = app.key_dispatcher.dispatch(i, &app.keymap, &active); + i.events.extend(held); + hits + }); + for hit in hits { + if !hit.cycle.is_empty() { + // Tools sharing a key: the press after the group's current tool selects the next one. + let current = app.session.tool_id(); + let tools: Vec<&str> = hit.cycle.iter().filter_map(|c| c.params.get("tool").and_then(Value::as_str)).collect(); + let next = tools.iter().position(|t| *t == current).and_then(|i| tools.get((i + 1) % tools.len())).or(tools.first()); + if let Some(tool) = next.map(|t| t.to_string()) { + log::debug!("tool shortcut -> {tool}"); + app.select_tool(&tool); + } + } else if hit.command.id == crate::keys::TOOL_COMMAND + && let Some(tool) = hit.command.params.get("tool").and_then(Value::as_str).map(str::to_string) + { + log::debug!("tool shortcut -> {tool}"); + app.select_tool(&tool); + } else { + // Like choosing the menu item: "…" commands open their dialog. + activate(app, &hit.command.id, &hit.command.params); + } + } + // Esc with no shortcut of its own goes back to the Selection tool. + let escape = ctx.input(|i| { + i.events.iter().any( + |e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, repeat: false, modifiers, .. } if !modifiers.command && !modifiers.alt), + ) + }); + if escape && !typing && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { + app.select_tool("selection"); + } +} + +/// Presentation's keys: Esc leaves it, and only that (the canvas must not also see the press and +/// switch tools or deselect); the arrow keys, Page Up/Down, Home and End go through the spreads. +fn presentation_keys(app: &mut DesignApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue }; - // Presentation hides every control, so Esc always leaves it, and only that: the canvas must - // not also see the press and switch tools or deselect. if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation { ctx.input_mut(|i| i.consume_key(modifiers, key)); let _ = app.run("view.togglePresentation", json!({})); continue; } - // Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End. if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt { let step = match key { egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1), @@ -3072,55 +3099,6 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { if let Some(step) = step { ctx.input_mut(|i| i.consume_key(modifiers, key)); crate::canvas::step_spread(app, step); - continue; - } - } - // Command shortcuts. - let mut fired: Option<(String, bool)> = None; - let all = effective_shortcuts(app); - for (id, sc) in &all { - let id = id.as_str(); - if let Some((m, k)) = parse_shortcut(sc) - && let Some(exact) = key_matches(m, k, key, modifiers) - && (m.command || m.alt || !typing) - && !(id == "edit.clear" && typing) - { - // Single-key shortcuts (W) only when not typing. - if !m.command && !m.alt && !m.shift && typing { - continue; - } - if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) { - continue; // Handled by the active tool / text clipboard events. - } - fired = Some((id.to_string(), exact)); - break; - } - } - if let Some((id, exact)) = fired { - // The native menu's accelerator fires only for the shortcut's own key and modifiers; the - // Plus key standing in for = never reaches it. - if exact && app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) { - continue; // The native menu handles it. - } - // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's - // default button) must not see the same press as Enter or Space this frame. - ctx.input_mut(|i| i.consume_key(modifiers, key)); - // Like choosing the menu item: "…" commands open their dialog. - activate(app, &id, &Value::Null); - continue; - } - // Tool shortcuts (single keys, Shift+key). - if !typing && !modifiers.command && !modifiers.alt { - let name = key.name(); - let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() }; - if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) { - log::debug!("tool shortcut {sc} -> {tool}"); - if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() { - eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}"); - } - app.select_tool(tool); - } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { - app.select_tool("selection"); } } } @@ -3512,16 +3490,78 @@ mod tests { // ⌘N is the New Document dialog, not a bare file.new. let first_cmd_n = effective_shortcuts(&app).into_iter().find(|(_, s)| s == "Cmd+N").map(|(i, _)| i); assert_eq!(first_cmd_n.as_deref(), Some("app.newDocumentDialog")); - assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return")); + assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K"); if cfg!(target_os = "macos") { assert_eq!(shortcut_text("Cmd+Alt+3"), "⌥⌘3"); assert_eq!(shortcut_text("Cmd+Shift+."), "⇧⌘."); } else { - assert_eq!(shortcut_text("Cmd+Alt+3"), "Alt+Ctrl+3"); + assert_eq!(shortcut_text("Cmd+Alt+3"), "Ctrl+Alt+3"); } } + /// One frame of key presses through the shortcut dispatcher. + fn press_keys(app: &mut crate::DesignApp, ctx: &egui::Context, keys: &[(egui::Key, egui::Modifiers)]) { + let events = keys + .iter() + .flat_map(|&(key, modifiers)| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }) + .collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts(app, &ui.ctx().clone())).textures_delta.clear(); + } + + #[test] + fn shortcuts_saved_as_a_map_by_older_versions_load_unchanged() { + let saved = br#"{"shortcuts": {"app.preferences": "Cmd+Alt+Shift+9", "app.palette": "", "file.close": [""]}}"#; + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui = crate::UiState::from_preferences_json(saved).unwrap(); + app.sync_keymap(); + assert_eq!(shortcut_of(&app, "app.preferences").as_deref(), Some("Cmd+Alt+Shift+9")); + assert_eq!(shortcut_of(&app, "app.palette"), None, "a removed default stays removed"); + assert_eq!(shortcut_of(&app, "file.close"), None); + assert_eq!(shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); + // Saved again in the current form, it reads back the same. + let resaved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&resaved).unwrap().shortcuts, app.ui.shortcuts); + } + + #[test] + fn panel_shortcuts_open_their_panels() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let shown = |app: &crate::DesignApp, p: &str| app.ui.dock_tab == p || app.ui.open_panel.as_deref() == Some(p); + assert!(!shown(&app, "layers")); + press_keys(&mut app, &ctx, &[(egui::Key::F7, egui::Modifiers::NONE)]); + assert!(shown(&app, "layers"), "F7 opens Layers"); + // A panel without a default key takes one. + let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "window.panel", "params": {"panel": "links"}, "shortcut": "Shift+F12"})) + .unwrap() + .unwrap(); + assert_eq!(r["shortcut"], "Shift+F12"); + press_keys(&mut app, &ctx, &[(egui::Key::F12, egui::Modifiers::SHIFT)]); + assert!(shown(&app, "links"), "the new key opens Links"); + } + + #[test] + fn a_reassigned_tool_key_selects_the_tool_and_the_old_key_does_not() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let ctx = egui::Context::default(); + let none = egui::Modifiers::NONE; + run_ui(&mut app, "window.setShortcut", &json!({"id": "pen", "shortcut": "Y"})).unwrap().unwrap(); + press_keys(&mut app, &ctx, &[(egui::Key::P, none)]); + assert_eq!(app.session.tool_id(), "selection", "P no longer selects the Pen"); + press_keys(&mut app, &ctx, &[(egui::Key::Y, none)]); + assert_eq!(app.session.tool_id(), "pen"); + // Tools sharing a key take turns. + press_keys(&mut app, &ctx, &[(egui::Key::B, none)]); + assert_eq!(app.session.tool_id(), "contentCollector"); + press_keys(&mut app, &ctx, &[(egui::Key::B, none)]); + assert_eq!(app.session.tool_id(), "contentPlacer"); + } + #[test] fn split_window_panes_have_their_own_views() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 9ccbef1b9..8654bf553 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -350,6 +350,13 @@ fn command_label(id: &str) -> String { .unwrap_or_else(|| id.to_string()) } +/// Gives the command with this key the shortcut, none (`""`), or its default (`None`). +fn store(app: &mut DesignApp, key: &str, shortcut: Option<&str>) { + if let Some(cmd) = craft_keymap::CommandRef::parse_key(key) { + let _ = crate::keys::set(app, &cmd, shortcut, craft_keymap::Policy::Keep); + } +} + fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> { menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k)) } @@ -444,16 +451,16 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { } taken.push((keys, d.id.clone())); if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { - app.ui.shortcuts.remove(&d.id); + store(app, &d.id, None); } else { - app.ui.shortcuts.insert(d.id.clone(), sc.clone()); + store(app, &d.id, Some(sc.as_str())); } } None if d.all_none => { if menus::default_shortcut(&d.id).is_none() { - app.ui.shortcuts.remove(&d.id); + store(app, &d.id, None); } else { - app.ui.shortcuts.insert(d.id.clone(), String::new()); + store(app, &d.id, Some("")); } } None => continue, @@ -475,7 +482,7 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { if let Some(keys) = keys_of(&sc) && taken.iter().any(|(k, by)| *k == keys && !twin(by)) { - app.ui.shortcuts.insert(id.clone(), String::new()); + store(app, &id, Some("")); conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); } } @@ -590,7 +597,7 @@ mod tests { assert_eq!(r["imported"], 4); // Reset Shortcuts is the way back. menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap(); - assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return")); + assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6b7d2c1c2..47098e8ce 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -84,7 +84,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]); let active = g.iter().any(|tl| tl.id == current); let name = crate::i18n::tr(&app.ui.language, shown.label); - let tip = match shown.shortcut { + let tip = match crate::menus::tool_shortcut_label(app, shown.id) { Some(s) => format!("{name} ({s})"), None => name.to_string(), }; @@ -157,7 +157,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { egui::FontId::proportional(12.5), t.text, ); - if let Some(sc) = tl.shortcut { + if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) { ui.painter().text( r.right_center() - vec2(6.0, 0.0), egui::Align2::RIGHT_CENTER, @@ -432,7 +432,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u } let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false); let label = VIEW_OPTIONS[k]; - let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or(""); + let sc = crate::menus::shortcut_label(app, id, &Value::Null).unwrap_or_default(); let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label)); if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() { let _ = app.run(id, json!({})); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 71a95b341..c38b86d59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). -- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`. +- Edit › Keyboard Shortcuts: every command, panel and tool, click and press the new keys (they move from the commands that held them, which the dialog lists), per-command and global reset; `window.setShortcut` (panels as `window.panel` with `{panel}`, tools by id). Panels open with InDesign's keys where DesignCraft leaves them free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tools sharing a key take turns (B: Content Collector, Content Placer). Shortcuts run on the shared craft-keymap crate. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`. - Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b66fb585c..72729afef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux. | Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | | IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) | | Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope | -| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied | +| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; panels take keys (InDesign's F5–F12 defaults where free) and tool keys are editable; imports an InDesign Show Set `.txt` | ✓ | the importer applies menu items only, so a set's Tools and panel items are reported, not applied; no Object Styles or Transform panel to give Cmd+F7 or F9, and Text Wrap (Cmd+Alt+W) and Links (Cmd+Shift+D) keep DesignCraft's owners of those keys | | Quick Apply | ✓ | ✓ | ✓ | | | Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | | | Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |