From 238ca0d2a03bd4a46b86658e07d0c49a2c2207bb Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:41:24 +0000 Subject: [PATCH] Shortcuts: the shared craft-keymap dialog (craft-libs#186) Edit > Keyboard Shortcuts is craft_keymap::ui::ShortcutsDialog over the keymap and named shortcut sets. ui.json stores the sets as shortcutSets and still writes the active set as shortcuts plus shortcutsMore. The InDesign importer is the dialog's second import format, with a review, and window.importShortcuts imports as a new set. window.shortcutSets lists and selects sets. The native macOS menu drops its key equivalents while the dialog records. --- ROADMAP.md | 3 +- apps/designcraft-web/src/web.rs | 2 +- apps/designcraft/src/main.rs | 2 +- apps/designcraft/src/native_menu.rs | 28 +- crates/ui-egui/src/dialogs.rs | 144 +------- crates/ui-egui/src/i18n.rs | 10 + crates/ui-egui/src/keys.rs | 49 ++- crates/ui-egui/src/lib.rs | 28 +- crates/ui-egui/src/menus.rs | 36 +- crates/ui-egui/src/prefs.rs | 7 + crates/ui-egui/src/rtl.rs | 33 ++ crates/ui-egui/src/shortcut_import.rs | 86 ++--- crates/ui-egui/src/shortcuts_dialog.rs | 468 +++++++++++++++++++++++++ docs/target-app-parity.md | 4 +- 14 files changed, 698 insertions(+), 202 deletions(-) create mode 100644 crates/ui-egui/src/shortcuts_dialog.rs diff --git a/ROADMAP.md b/ROADMAP.md index d4b0788d5..df3eaf675 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -151,7 +151,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes 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`. +- Edit › Keyboard Shortcuts (the shared craft-keymap dialog): every command grouped by menu, recording with conflicts asked about, undo, named shortcut sets, an on-screen US keyboard, InDesign set import with a review, JSON export and a printable summary; `window.setShortcut`, `window.shortcutSets`. - 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. @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **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. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 27d236a2f..ce86c5949 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -396,7 +396,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) } else if request.purpose == "shortcuts" { - rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"]) + rfd::AsyncFileDialog::new().add_filter("Shortcut set (JSON, InDesign text)", &["json", "txt"]) } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index af2f22b21..4788275f8 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -188,7 +188,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { match purpose { "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], - "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }], + "shortcuts" => &[OpenFilter { name: "Shortcut set (JSON, InDesign text)", extensions: &["json", "txt"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 86a88e5c0..396254669 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -22,9 +22,10 @@ pub struct NativeMenu { items: HashMap, events: Receiver, last_refresh: f64, - /// The interface language the titles are in and whether CJK features are shown: the menu is - /// rebuilt when either changes. - shown_for: (String, bool), + /// The interface language the titles are in, whether CJK features are shown and whether the + /// Keyboard Shortcuts dialog records keys (the menu then has no key equivalents, so the keys + /// reach the dialog): the menu is rebuilt when any of them changes. + shown_for: (String, bool, bool), /// File › Open Recent, rebuilt when the list of recent files changes. recent: Option, } @@ -75,7 +76,7 @@ impl NativeMenu { for entry in menus::MAC_APP_MENU { let _ = match *entry { AppMenuItem::Cmd { id, label, command } => { - let sc = shortcut(command).and_then(accel); + let sc = shortcut(command).filter(|_| !app.shortcut_recording()).and_then(accel); let item = MenuItem::with_id(id, menu_title(app, label), true, sc); let appended = app_menu.append(&item); items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item))); @@ -104,7 +105,14 @@ impl NativeMenu { }) .collect(); let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None }); - Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent } + Self { + _menu: menu, + items, + events, + last_refresh: 0.0, + shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app), app.shortcut_recording()), + recent, + } } /// Put the current recent files at the top of File › Open Recent when they changed. @@ -151,8 +159,12 @@ impl NativeMenu { } // Interface Language and Show CJK Features (set in Preferences or by `app.language`, or put // back by Cancel): the system menu bar follows at once, like the panels (the new menu - // replaces the old one as the application's menu before that one is dropped). - if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 { + // replaces the old one as the application's menu before that one is dropped). While the + // Keyboard Shortcuts dialog records, the bar has no key equivalents. + if app.ui.language != self.shown_for.0 + || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 + || app.shortcut_recording() != self.shown_for.2 + { *self = Self::install(app, ctx); } let now = designcraft_ui_egui::now_ms(); @@ -202,7 +214,7 @@ fn build( Item::Cmd { label, id, params, shortcut } => { *counter += 1; let mid = format!("dc{counter}"); - let sc = if params.is_null() { shortcut.and_then(accel) } else { None }; + let sc = if params.is_null() && !app.shortcut_recording() { shortcut.and_then(accel) } else { None }; let label = menu_title(app, label); let handle = if menus::checked(app, id, params).is_some() { let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7152d6fb8..717c8d154 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -197,8 +197,6 @@ mod color_theme_tests { } } -/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the -/// new keys (Esc cancels, Backspace clears). /// Edit › Spelling › User Dictionary: added words and hyphenation exceptions (applied at once). fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Hyphenation Exceptions")).font(semibold(12.0))); @@ -238,116 +236,6 @@ fn user_dictionary(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn is_modifier_key(key: egui::Key) -> bool { - use egui::Key::*; - matches!(key, ShiftLeft | ShiftRight | ControlLeft | ControlRight | AltLeft | AltRight | SuperLeft | SuperRight) -} - -fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { - let mut q = d.s("query"); - ui.add( - egui::TextEdit::singleline(&mut q) - .hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search commands"))) - .desired_width(f32::INFINITY), - ); - d.fields.insert("query".into(), json!(q)); - let recording = d.s("recording"); - // Capture the next key press for the command being recorded. Pressing a modifier - // reports the modifier itself as a key; skip it and wait for the key it modifies. - if !recording.is_empty() { - let pressed = ui.input(|i| { - i.events.iter().find_map(|e| match e { - egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => Some((*key, *modifiers)), - _ => None, - }) - }); - if let Some((key, m)) = pressed { - let sc = match key { - egui::Key::Escape => None, - 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 (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)); - } - d.fields.insert("recording".into(), json!("")); - } - } - let ql = q.to_lowercase(); - // (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 - .catalog() - .iter() - .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)); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - egui::Grid::new("shortcuts").num_columns(3).striped(true).spacing([12.0, 4.0]).show(ui, |ui| { - 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 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 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": cmd.id, "params": cmd.params, "shortcut": null})); - } - } else { - ui.label(""); - } - ui.end_row(); - } - }); - }); - let msg = d.s("message"); - if !msg.is_empty() { - ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60))); - } - ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { - let _ = app.run("window.resetShortcuts", json!({})); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked() - && let Some(msg) = crate::shortcut_import::pick_and_import(app) - { - d.fields.insert("message".into(), json!(msg)); - } - }); -} - /// File › Print, with the printers the system knows. pub fn open_print(app: &mut DesignApp) { let p = app.session.execute("file.printers", &json!({})).unwrap_or_default(); @@ -2053,6 +1941,15 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } pub fn show(app: &mut DesignApp, ctx: &egui::Context) { + let shortcuts = app.ui.dialog.as_ref().is_some_and(|d| d.id == crate::shortcuts_dialog::DIALOG_ID); + if shortcuts { + crate::shortcuts_dialog::show(app, ctx); + return; + } + if app.shortcut_dialog.is_some() { + // Another dialog took its place: its edits are cancelled. + let _ = crate::shortcuts_dialog::close(app, false); + } let Some(mut d) = app.ui.dialog.clone() else { return }; // Enter and Escape belong to an open popup (a menu, a name field) rather than the dialog. // Read before the dialog draws: a popup closed by this frame's Escape is still open here. @@ -2109,7 +2006,6 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"), "colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"), "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"), - "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"), "colorPicker" => crate::i18n::tr(&app.ui.language, "Color Picker"), "swatchOptions" => crate::i18n::tr(&app.ui.language, "Swatch Options"), "colorGroupOptions" => crate::i18n::tr(&app.ui.language, "Color Group Options"), @@ -2484,7 +2380,6 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } } - "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), "userDictionary" => user_dictionary(app, ui, &mut d), "menus" => { text_field(ui, &mut d, "query", 260.0); @@ -3032,6 +2927,9 @@ pub fn cancel(app: &mut DesignApp) { app.session.reset_find_scope(); } let d = app.ui.dialog.take(); + if d.as_ref().is_some_and(|d| d.id == crate::shortcuts_dialog::DIALOG_ID) { + let _ = crate::shortcuts_dialog::close(app, false); + } crate::new_document::end_preview(app); if d.as_ref().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { app.session.cancel_interaction(); @@ -3060,6 +2958,9 @@ fn find_change_params(d: &Dialog) -> Value { /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; + if d.id == crate::shortcuts_dialog::DIALOG_ID { + return crate::shortcuts_dialog::close(app, true).map(|()| Value::Null); + } if matches!(d.id.as_str(), "paragraphStyleOptions" | "characterStyleOptions") && d.fields.contains_key(PREVIEWED) { // Restore the original before the ordinary command commits one undoable edit. app.session.cancel_interaction(); @@ -9750,21 +9651,6 @@ mod tests { } } - #[test] - fn shortcut_recorder_waits_for_the_key_after_its_modifiers() { - let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - let mut d = Dialog::new("keyboardShortcuts", json!({"query": "", "recording": "app.palette"})); - let ctx = egui::Context::default(); - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - // Holding Ctrl reports Ctrl itself as a key press, then Ctrl+K arrives. - for event in [key(egui::Key::ControlLeft, egui::Modifiers::COMMAND), key(egui::Key::K, egui::Modifiers::COMMAND)] { - let input = egui::RawInput { events: vec![event], ..Default::default() }; - ctx.run_ui(input, |ui| keyboard_shortcuts(&mut app, ui, &mut d)).textures_delta.clear(); - } - assert_eq!(crate::menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+K")); - assert_eq!(d.s("recording"), "", "recording ends with the recorded shortcut"); - } - #[test] fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() { use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..5e3604a81 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4712,6 +4712,8 @@ mod tests { for table in [ar::TABLE, de::TABLE, it::TABLE, ja::TABLE, pt_br::TABLE, uk::TABLE, ru::TABLE, cs::TABLE] { keys.extend(table.iter().map(|(key, _)| *key)); } + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); assert!(stale.is_empty(), "French entries for unknown keys: {stale:?}"); let placeholders = |s: &str| { @@ -4739,6 +4741,8 @@ mod tests { keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key)); // A new English string falls back to English until it is translated, so only stale // entries (keys no catalog knows any more) fail. + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); assert!(stale.is_empty(), "Ukrainian entries for unknown keys: {stale:?}"); let placeholders = @@ -4824,6 +4828,8 @@ mod tests { keys.extend(uk::TABLE.iter().map(|(key, _)| *key)); // A new English string falls back to English until it is translated, so only stale // entries (keys no catalog knows any more) fail. + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}"); let placeholders = @@ -4889,6 +4895,8 @@ mod tests { keys.extend(table.iter().map(|(key, _)| *key)); } // A new English string falls back to English until it is translated; only stale entries fail. + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); assert!(stale.is_empty(), "Polish entries for unknown keys: {stale:?}"); let placeholders = @@ -5026,6 +5034,8 @@ mod tests { ]); // A new English string falls back to English until it is translated, so only stale // entries (keys nothing passes to `tr` any more) fail. + // The Keyboard Shortcuts dialog's strings (craft-keymap) reach `tr` too. + keys.extend(crate::shortcuts_dialog::strings()); let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect(); assert!(stale.is_empty(), "Czech entries for unknown keys: {stale:?}"); let placeholders = diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 2058b7435..9874603bd 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -1,12 +1,12 @@ //! Keyboard shortcuts on `craft-keymap`: the catalog (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. +//! `window.setShortcut` and the shortcut-set importer make. use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use std::sync::atomic::{AtomicU8, Ordering}; -use craft_keymap::{AssignError, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes}; +use craft_keymap::{AssignError, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes, Trigger}; use serde_json::{Value, json}; use crate::DesignApp; @@ -142,8 +142,8 @@ pub fn keymap_for(platform: Platform, overrides: Overrides) -> Keymap { } /// 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. +/// commands come first (⌘N is the New Document dialog, not a bare `file.new`), then the engine +/// commands in menus or with default keys, panels and tools. pub fn catalog() -> &'static [Bindable] { static CATALOG: OnceLock> = OnceLock::new(); CATALOG.get_or_init(build_catalog) @@ -172,9 +172,24 @@ fn build_catalog() -> Vec { for (id, label, _, _) in menus::UI_COMMANDS { add(CommandRef::new(*id), label, menus::default_shortcut(id).as_slice(), None, Kind::Command); } + // A UI command's default key runs it, never the engine command it stands in for (⌘S is the + // Save dialog when needed, not a bare `file.save`): the engine command does not get that key + // too, so the two are not listed as a conflict. Engine commands left with neither a menu row + // nor a default key are for scripts and the control channel (`file.save`, `selection.set`): + // they take no shortcut and stay out of the Keyboard Shortcuts dialog. + let ui_keys: Vec = menus::UI_COMMANDS + .iter() + .filter_map(|(id, ..)| menus::default_shortcut(id)) + .filter_map(|d| Binding::parse(d).ok()) + .map(|b| b.trigger) + .collect(); for c in designcraft_engine::command_specs() { + let shortcut = c.shortcut.filter(|d| Binding::parse(d).is_ok_and(|b| !ui_keys.contains(&b.trigger))); + if c.menu.is_empty() && shortcut.is_none() && !paths.contains_key(c.id) { + continue; + } 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.as_slice(), path, Kind::Command); + add(CommandRef::new(c.id), c.label, shortcut.as_slice(), path, Kind::Command); } let mut seen = Vec::new(); for (id, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { @@ -262,11 +277,28 @@ pub fn chord_text(m: egui::Modifiers, key: egui::Key) -> Option { Some(Chord::new(Mods::from_egui(&m, platform()), key).to_string()) } -/// Copies the keymap's overrides to the saved UI state. +/// Copies the keymap's overrides to the saved UI state and, outside the Keyboard Shortcuts +/// dialog (which saves sets itself), to the active shortcut set. pub fn store(app: &mut DesignApp) { if app.ui.shortcuts != *app.keymap.overrides() { app.ui.shortcuts = app.keymap.overrides().clone(); } + if app.shortcut_dialog.is_none() { + store_in_set(&mut app.ui); + } +} + +/// Saves `ui.shortcuts` to the active shortcut set when they differ. Edits of the built-in set go +/// to a new set, Custom, which becomes active. +pub fn store_in_set(ui: &mut crate::UiState) { + let builtins = &crate::shortcuts_dialog::BUILTINS; + if ui.shortcut_sets.active_overrides(builtins) == &ui.shortcuts { + return; + } + let custom = crate::i18n::tr(&ui.language, crate::shortcuts_dialog::CUSTOM_SET); + // Refused only with the most sets already made: ui.json still saves the shortcuts themselves, + // and the next frame tries again. + let _ = ui.shortcut_sets.store(ui.shortcuts.clone(), builtins, custom); } /// Gives a command keys (replacing its others), no keys (`""`), or its defaults back (`None`). @@ -332,11 +364,6 @@ pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { 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()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a1c6574e5..eea218d3a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ pub mod render_worker; mod rtl; mod section_options; mod shortcut_import; +mod shortcuts_dialog; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -251,10 +252,14 @@ pub struct UiState { pub rich_black: bool, /// View › Overprint Preview. pub overprint_preview: bool, - /// Edit › Keyboard Shortcuts: the user's changes to the default shortcuts. Saved as - /// `shortcuts` and `shortcutsMore` (see `prefs::saved_shortcuts`). + /// Edit › Keyboard Shortcuts: the active shortcut set's changes to the default shortcuts, which + /// the keymap uses. Saved as `shortcuts` and `shortcutsMore` (see `prefs::saved_shortcuts`), + /// which earlier versions read. #[serde(flatten, with = "prefs::saved_shortcuts")] pub shortcuts: craft_keymap::Overrides, + /// The user's named shortcut sets and which one is active (see `shortcuts_dialog`). + #[serde(deserialize_with = "prefs::lenient_sets")] + pub shortcut_sets: craft_keymap::Profiles, pub control_bar: bool, pub tools_double_column: bool, /// Expanded right-dock panel group tab. @@ -398,6 +403,7 @@ impl Default for UiState { rich_black: true, overprint_preview: false, shortcuts: Default::default(), + shortcut_sets: Default::default(), control_bar: false, tools_double_column: false, dock_tab: "properties".into(), @@ -611,6 +617,10 @@ pub struct DesignApp { /// 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, + /// The open Keyboard Shortcuts dialog. + pub(crate) shortcut_dialog: Option>, + /// Answers the user gave in an import review (item → command), for later imports. + pub(crate) shortcut_answers: std::collections::BTreeMap, last_time: f64, /// When recovery data was last written (seconds, egui time). pub last_recovery: f64, @@ -699,6 +709,8 @@ impl DesignApp { native_shortcuts: Default::default(), keymap: keys::new_keymap(craft_keymap::Overrides::new()), key_dispatcher: craft_keymap::egui::Dispatcher::new(), + shortcut_dialog: None, + shortcut_answers: Default::default(), last_time: 0.0, last_recovery: 0.0, egui_ctx: None, @@ -1130,8 +1142,12 @@ impl DesignApp { /// Applies `ui.shortcuts` to the keymap when they differ, e.g. after preferences were loaded, /// and rebuilds it when the shortcut platform changed (the web build learns the browser's - /// operating system on the first frame). + /// operating system on the first frame). Outside the Keyboard Shortcuts dialog the active + /// shortcut set takes `ui.shortcuts` too: an earlier version may have changed them. pub fn sync_keymap(&mut self) { + if self.shortcut_dialog.is_none() { + keys::store_in_set(&mut self.ui); + } if self.keymap.platform() != keys::platform() { self.keymap = keys::new_keymap(self.ui.shortcuts.clone()); } else if *self.keymap.overrides() != self.ui.shortcuts { @@ -1139,6 +1155,12 @@ impl DesignApp { } } + /// Whether the Keyboard Shortcuts dialog is recording keys: a native menu bar pauses its key + /// equivalents meanwhile, so the keys reach the dialog. + pub fn shortcut_recording(&self) -> bool { + self.shortcut_dialog.as_ref().is_some_and(|d| d.recording()) + } + pub fn select_tool(&mut self, id: &str) { let _ = self.run("tool.select", json!({"tool": id})); self.ui.flyout = None; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 43ad71424..8137b4b1c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -81,7 +81,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), ("app.preferences", "Preferences…", Some("Cmd+K"), "{}"), ("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"), + ( + "app.keyboardShortcuts", + "Keyboard Shortcuts…", + None, + "{command?, params?} — view and change the shortcut of any command, and the shortcut sets; with command, the dialog opens on it", + ), + ( + "window.shortcutSets", + "Shortcut Sets", + None, + "{select?: name} — the shortcut sets (Default is built in; Edit › Keyboard Shortcuts makes the others); select makes one active → {active, sets, builtIn}", + ), ("window.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"), ( "window.setShortcut", @@ -89,12 +100,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ 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.resetShortcuts", "Reset Shortcuts", None, "{} — every command of the active shortcut set back to its default shortcut"), ( "window.importShortcuts", "Import InDesign Shortcut Set", None, - "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", + "{path | text} — a set saved by InDesign's Show Set (.txt), imported as a new shortcut set, which becomes active: matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated, shortcutSet (the new set's name)}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), @@ -1685,9 +1696,26 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); + let select = p.get("command").and_then(Value::as_str).map(|id| crate::keys::command_ref(id, p.get("params").unwrap_or(&Value::Null))); + crate::shortcuts_dialog::open(app, select.as_ref()); Ok(Value::Null) } + "window.shortcutSets" => { + let builtins = &crate::shortcuts_dialog::BUILTINS; + if let Some(name) = p.get("select").and_then(Value::as_str) { + if app.shortcut_dialog.is_some() { + return Some(Err("close the Keyboard Shortcuts dialog first".into())); + } + if let Err(e) = app.ui.shortcut_sets.select(name, builtins) { + return Some(Err(format!("{name}: {e}"))); + } + crate::shortcuts_dialog::load_active_set(app); + } + let sets = &app.ui.shortcut_sets; + Ok( + json!({"active": sets.active_name(builtins), "sets": sets.names(builtins), "builtIn": builtins.iter().map(|(n, _)| *n).collect::>()}), + ) + } "window.setShortcut" => { 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)); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 96fb1288d..3ce8943db 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -74,6 +74,13 @@ pub(crate) mod saved_shortcuts { } } +/// `UiState::shortcut_sets`: anything that does not read as shortcut sets (a later format, a +/// hand edit) loads as none, keeping the rest of ui.json and the `shortcuts` the keymap uses. +pub(crate) fn lenient_sets<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let value = ::deserialize(deserializer)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + fn recover_docking(saved: &mut Value) { let Some(fields) = saved.as_object_mut() else { return }; if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index cb88619ec..7e3ba7943 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -90,6 +90,31 @@ pub fn paint(p: &egui::Painter, pos: Pos2, anchor: Align2, text: &str, font: Fon p.galley(rect.min, galley, color); } +/// `text` in display order for widgets that draw a plain string (craft-keymap's Keyboard +/// Shortcuts dialog): directional runs reordered as [`visual_line`] does, Arabic runs left +/// logical for the shaper, neutral right-to-left runs reversed and mirrored. +pub fn visual_text(text: &str) -> String { + if !has_rtl(text) { + return text.to_owned(); + } + let single = text.replace(['\n', '\r'], " "); + let bidi = BidiInfo::new(&single, None); + let mut out = String::with_capacity(single.len()); + for para in &bidi.paragraphs { + let (levels, runs) = bidi.visual_runs(para, para.range.clone()); + for run in runs { + let rtl = levels.get(run.start).is_some_and(|l| l.is_rtl()); + let Some(part) = single.get(run) else { continue }; + if rtl && !part.chars().any(designcraft_fonts::is_rtl) { + out.extend(part.chars().rev().map(|c| unicode_bidi_mirroring::get_mirrored(c).unwrap_or(c))); + } else { + out.push_str(part); + } + } + } + out +} + /// Reorder directional runs, not characters: the shaper needs logical Arabic to join letters. fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range) -> LayoutJob { let mut visual = LayoutJob { break_on_newline: false, ..Default::default() }; @@ -488,6 +513,14 @@ mod tests { }); } + /// Plain strings for the Keyboard Shortcuts dialog: runs in display order, Arabic left logical + /// for the shaper. + #[test] + fn visual_text_orders_runs_and_keeps_arabic_logical() { + assert_eq!(visual_text("Save (Ctrl+S)"), "Save (Ctrl+S)"); + assert_eq!(visual_text("حفظ Save"), "Saveحفظ "); + } + #[test] fn isolates_arabic_names_from_latin_brand_and_zoom_suffix() { with_fonts(|ctx| { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 015d2bc69..66a98c34e 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -1,5 +1,6 @@ //! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text -//! (Show Set): `window.importShortcuts`. +//! (Show Set): `window.importShortcuts`, and the InDesign format of the Keyboard Shortcuts +//! dialog's Import. //! //! The file lists items under `Product area : ` headers, one per line: //! ` --- [none defined]` or ` --- : , : `. Item names @@ -7,8 +8,10 @@ //! [`craft_keymap::ForeignSet`]; `craft_keymap::import` matches and applies it. Default keys bind //! in the command's home scope; an item without Default keys takes its Text keys. +use std::collections::BTreeMap; + use craft_keymap::import::{MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT}; -use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport}; +use craft_keymap::{CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport}; use serde_json::{Value, json}; use crate::DesignApp; @@ -189,25 +192,43 @@ fn foreign(set: &ParsedSet) -> ForeignSet { ForeignSet { name: Some(set.name.clone()), entries } } -/// Imports a set: every matched command takes the set's shortcuts (or none, when every item -/// matched to it has none), other commands lose keys it takes (unless they share them as -/// defaults), and the rest keep theirs. Returns the report. -pub fn import(app: &mut DesignApp, text: &str) -> Result { +/// What DesignCraft's contexts take from a set: Default keys bind in the command's home scope; an +/// item without Default keys takes its Text keys. Unmatched items get likely commands. +pub fn import_options(remembered: BTreeMap) -> ImportOptions { + ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home), (crate::keys::TEXT_CONTEXT.into(), ContextRule::HomeWithoutHomeEntry)], + remembered, + suggest: true, + ..ImportOptions::default() + } +} + +/// Reads a set's text for craft-keymap's importer. Returns the set and the number of items past +/// [`MAX_IMPORT_ENTRIES`], which were not read. +pub fn read_set(text: &str) -> Result<(ForeignSet, usize), String> { if text.len() > MAX_BYTES { return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); } let parsed = parse(text)?; - let set = foreign(&parsed); - let options = ImportOptions { - contexts: vec![("Default".into(), ContextRule::Home), (crate::keys::TEXT_CONTEXT.into(), ContextRule::HomeWithoutHomeEntry)], - suggest: true, - ..ImportOptions::default() - }; + Ok((foreign(&parsed), parsed.dropped)) +} + +/// Imports a set as a new shortcut set, which becomes active: every matched command takes the +/// set's shortcuts (or none, when every item matched to it has none), other commands lose keys it +/// takes (unless they share them as defaults), and the rest keep theirs. Returns the report, with +/// the new set's name as `shortcutSet`. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + let (set, dropped) = read_set(text)?; app.sync_keymap(); - let mut report = craft_keymap::plan(&set, &app.keymap, &options).apply(&mut app.keymap); - report.truncated = report.truncated.saturating_add(parsed.dropped); - crate::keys::store(app); - Ok(report_json(&report)) + let options = import_options(app.shortcut_answers.clone()); + let plan = craft_keymap::plan(&set, &app.keymap, &options); + let (name, mut report) = + app.ui.shortcut_sets.import_plan(&plan, &app.keymap, None, &crate::shortcuts_dialog::BUILTINS).map_err(|e| e.to_string())?; + report.truncated = report.truncated.saturating_add(dropped); + crate::shortcuts_dialog::load_active_set(app); + let mut out = report_json(&report); + out["shortcutSet"] = json!(name); + Ok(out) } /// An enum of the report as its serde name (`noMatch`, `numpad`). @@ -254,7 +275,7 @@ fn report_json(r: &ImportReport) -> Value { }) } -/// One line for the Keyboard Shortcuts dialog about an import's report. +/// One line for the status bar about an import's report. pub fn summary(lang: &str, report: &Value) -> String { let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string(); let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string(); @@ -275,36 +296,17 @@ pub fn summary(lang: &str, report: &Value) -> String { out } -/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and -/// returns the summary; in a browser the chosen file arrives later through the import inbox. -pub fn pick_and_import(app: &mut DesignApp) -> Option { - if let Some(pick) = app.services.pick_open.as_mut() { - let path = pick("shortcuts")?; - return Some(match app.run("window.importShortcuts", json!({"path": path})) { - Ok(report) => summary(&app.ui.language, &report), - Err(e) => e, - }); - } - let request = app.import_request("shortcuts"); - if let Some(open) = app.services.open_async.as_mut() { - open(request); - } - None -} - -/// A set file chosen in the browser: import it and say how it went in the open Keyboard -/// Shortcuts dialog, or the status bar. +/// A set file chosen in the browser: the open Keyboard Shortcuts dialog reviews it; without the +/// dialog it is imported at once and the status bar says how it went. pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { + if crate::shortcuts_dialog::deliver(app, bytes) { + return; + } let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) { Ok(report) => summary(&app.ui.language, &report), Err(e) => e, }; - match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") { - Some(d) => { - d.fields.insert("message".into(), json!(msg)); - } - None => app.status(msg), - } + app.status(msg); } #[cfg(test)] diff --git a/crates/ui-egui/src/shortcuts_dialog.rs b/crates/ui-egui/src/shortcuts_dialog.rs new file mode 100644 index 000000000..30636b593 --- /dev/null +++ b/crates/ui-egui/src/shortcuts_dialog.rs @@ -0,0 +1,468 @@ +//! Edit › Keyboard Shortcuts: craft-keymap's shared dialog over DesignCraft's keymap and the user's +//! named shortcut sets. +//! +//! The dialog edits `DesignApp::keymap` and `UiState::shortcut_sets` in place, so menus show new +//! keys at once; Cancel puts both back. `UiState::shortcuts` follows the keymap every frame: it is +//! what ui.json saves for earlier versions and what `sync_keymap` applies. While the dialog is open +//! `app.ui.dialog` holds a `keyboardShortcuts` entry, so the canvas, the menus' key dispatch and +//! the control channel treat it as the open modal dialog. +//! +//! The InDesign importer stays here (`shortcut_import`): the dialog's Import offers its format +//! next to craft-keymap's JSON, and the dialog reviews the set before it becomes a new set. + +use std::borrow::Cow; + +use craft_keymap::ui::{DialogState, Format, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::{Bindable, Overrides}; +use serde_json::json; + +use crate::DesignApp; +use crate::i18n::tr; + +/// The `app.ui.dialog` id while the dialog is open. +pub const DIALOG_ID: &str = "keyboardShortcuts"; + +/// DesignCraft's built-in shortcut set: its default keys. +pub static BUILTINS: [(&str, Overrides); 1] = [("Default", Overrides::new())]; + +/// The name edits of the built-in set are saved under (translated, made unique). +pub const CUSTOM_SET: &str = "Custom"; + +/// The file-picker purpose of the dialog's Import (`Services::pick_open`, `open_async`). +pub const PURPOSE: &str = "shortcuts"; + +/// A set saved by InDesign's Show Set, read by `shortcut_import`. +pub const INDESIGN_FORMAT: Format = Format { id: "indesign", label: "InDesign Shortcut Set (Show Set text)", extensions: &["txt"] }; + +/// The open dialog: its state and options. +pub struct Host { + state: DialogState, + options: Options, +} + +impl Host { + fn new() -> Host { + Host { state: DialogState::new(), options: options() } + } + + pub(crate) fn recording(&self) -> bool { + self.state.is_recording() + } +} + +/// The owner-approved behavior: live editing with Cancel restoring, conflicts asked about, no +/// double presses, the US keyboard pane, InDesign sets to import. +fn options() -> Options { + Options::default().with(|o| { + o.keyboard = true; + o.import_formats = vec![INDESIGN_FORMAT]; + }) +} + +/// The dialog's words through DesignCraft's catalogs; strings without a translation stay English. +pub struct DcText<'a> { + pub lang: &'a str, +} + +impl Text for DcText<'_> { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + tr(self.lang, source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + tr(self.lang, if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + tr(self.lang, &b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + tr(self.lang, s).into() + } + + fn rtl(&self) -> bool { + crate::i18n::is_rtl(self.lang) + } + + fn visual(&self, s: &str) -> String { + crate::rtl::visual_text(s) + } +} + +/// Edit › Keyboard Shortcuts…; with `select`, the dialog opens on that command. +pub fn open(app: &mut DesignApp, select: Option<&craft_keymap::CommandRef>) { + crate::dialogs::cancel(app); + app.sync_keymap(); + let mut host = Host::new(); + if let Some(command) = select { + host.state.select(command); + } + app.shortcut_dialog = Some(Box::new(host)); + app.ui.dialog = Some(crate::dialogs::Dialog::new(DIALOG_ID, json!({}))); +} + +/// The dialog's size: most of the window, within the dialog's own limits. +fn size(screen: egui::Vec2) -> egui::Vec2 { + let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(48.0, 96.0)).max(egui::vec2(240.0, 180.0)); + let min = egui::vec2(640.0, 480.0).min(max); + (screen * 0.8).clamp(min, max) +} + +/// One frame of the open dialog, drawn in DesignCraft's movable modal. +pub(crate) fn show(app: &mut DesignApp, ctx: &egui::Context) { + let mut host = app.shortcut_dialog.take().unwrap_or_else(|| Box::new(Host::new())); + let lang = app.ui.language.clone(); + let text = DcText { lang: &lang }; + let title = tr(&lang, "Keyboard Shortcuts"); + let area = egui::Id::new("dialog"); + let instance = area.with(DIALOG_ID); + let body = size(ctx.content_rect().size()); + let mut out = None; + crate::modal_drag::modal(ctx, area, instance).backdrop_color(egui::Color32::TRANSPARENT).show(ctx, |ui| { + let title_top = ui.cursor().top(); + crate::rtl::label(ui, egui::RichText::new(title).font(crate::theme::semibold(16.0))); + let margin = egui::Frame::popup(ui.style()).inner_margin; + let title_row = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - margin.leftf(), title_top - margin.topf()), + egui::pos2(ui.max_rect().left() + body.x + margin.rightf(), ui.min_rect().bottom()), + ); + crate::modal_drag::title_drag(ui, instance, title_row); + ui.add_space(8.0); + let (rect, _) = ui.allocate_exact_size(body, egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + let dialog = ShortcutsDialog::new(&mut app.keymap, &text).options(&host.options).sets(&mut app.ui.shortcut_sets, &BUILTINS); + out = dialog.ui(&mut child, &mut host.state); + }); + // Live: the menus, `sync_keymap` and ui.json follow every edit, and a Cancel. The dialog + // saves the sets itself. + if app.ui.shortcuts != *app.keymap.overrides() { + app.ui.shortcuts = app.keymap.overrides().clone(); + } + match out { + Some(Outcome::Import { .. }) => request_import(app, &mut host), + Some(Outcome::Export { name, json }) => save_file(app, &format!("{name}.json"), json.as_bytes()), + Some(Outcome::Summary { name, html, .. }) => save_file(app, &format!("{name}.html"), html.as_bytes()), + Some(Outcome::Remembered(answers)) => app.shortcut_answers = answers, + _ => {} + } + if host.state.is_open() { + app.shortcut_dialog = Some(host); + return; + } + if app.ui.dialog.as_ref().is_some_and(|d| d.id == DIALOG_ID) { + app.ui.dialog = None; + } + crate::keys::store(app); +} + +/// OK (`ok`) or Cancel from outside the dialog: `ui.dialog.confirm` and `ui.dialog.cancel`, or +/// another dialog taking its place. An OK the dialog refuses (no room for another set) leaves it +/// open with its message and returns the error. +pub(crate) fn close(app: &mut DesignApp, ok: bool) -> Result<(), String> { + let Some(mut host) = app.shortcut_dialog.take() else { return Ok(()) }; + let lang = app.ui.language.clone(); + let text = DcText { lang: &lang }; + let dialog = ShortcutsDialog::new(&mut app.keymap, &text).options(&host.options).sets(&mut app.ui.shortcut_sets, &BUILTINS); + if ok { + dialog.ok(&mut host.state); + } else { + dialog.cancel(&mut host.state); + } + crate::keys::store(app); + if ok && host.state.is_open() { + app.shortcut_dialog = Some(host); + app.ui.dialog = Some(crate::dialogs::Dialog::new(DIALOG_ID, json!({}))); + return Err("the shortcut set could not be saved".into()); + } + Ok(()) +} + +/// Import: a native picker reads the file at once; in a browser it arrives later through +/// [`deliver`]. +fn request_import(app: &mut DesignApp, host: &mut Host) { + if let Some(pick) = app.services.pick_open.as_mut() { + let Some(path) = pick(PURPOSE) else { return }; + match crate::shortcut_import::read_file(&path) { + Ok(text) => review(app, host, &text), + Err(e) => app.status(e), + } + return; + } + let request = app.import_request(PURPOSE); + if let Some(open) = app.services.open_async.as_mut() { + open(request); + } +} + +/// A set file chosen in the browser, for the open dialog. False when the dialog is closed. +pub(crate) fn deliver(app: &mut DesignApp, bytes: &[u8]) -> bool { + let Some(mut host) = app.shortcut_dialog.take() else { return false }; + match crate::shortcut_import::decode(bytes) { + Ok(text) => review(app, &mut host, &text), + Err(e) => app.status(e), + } + app.shortcut_dialog = Some(host); + true +} + +/// A set file's text into the dialog: craft-keymap JSON becomes a new set at once (the dialog +/// shows why it cannot); an InDesign set opens the import review. +fn review(app: &mut DesignApp, host: &mut Host, text: &str) { + let lang = app.ui.language.clone(); + let words = DcText { lang: &lang }; + if text.trim_start().starts_with('{') { + let dialog = ShortcutsDialog::new(&mut app.keymap, &words).options(&host.options).sets(&mut app.ui.shortcut_sets, &BUILTINS); + // A refused file is reported in the dialog. + let _ = dialog.import_json(&mut host.state, text); + return; + } + match crate::shortcut_import::read_set(text) { + Ok((set, _)) => { + let options = crate::shortcut_import::import_options(app.shortcut_answers.clone()); + let dialog = ShortcutsDialog::new(&mut app.keymap, &words).options(&host.options).sets(&mut app.ui.shortcut_sets, &BUILTINS); + dialog.review_import(&mut host.state, set, options); + } + Err(e) => app.status(e), + } +} + +/// Writes a file the dialog made where the user chooses (a download on the web). +fn save_file(app: &mut DesignApp, name: &str, bytes: &[u8]) { + let name: String = name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect(); + let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return }; + let result = match app.services.write.as_mut() { + Some(write) => write(&path, bytes), + None => Err("no writer".into()), + }; + match result { + Ok(()) => app.status(tr(&app.ui.language, "Saved to {path}").replace("{path}", &path)), + Err(e) => app.status(e), + } +} + +/// Loads the active set into the keymap and `ui.shortcuts` (after an import made a new set). +pub(crate) fn load_active_set(app: &mut DesignApp) { + let overrides = app.ui.shortcut_sets.active_overrides(&BUILTINS).clone(); + app.keymap.set_overrides(overrides.clone()); + app.ui.shortcuts = overrides; +} + +/// The strings the dialog looks up: `craft_keymap::ui::STRINGS` and both forms of its plurals. +#[cfg(test)] +pub(crate) fn strings() -> impl Iterator { + craft_keymap::ui::STRINGS.iter().copied().chain(craft_keymap::ui::PLURALS.iter().flat_map(|(one, other)| [*one, *other])) +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_keymap::{CommandRef, Trigger}; + use egui::{Key, Modifiers, vec2}; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + use crate::menus::shortcut_of; + + fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) + } + + /// The app's frame as far as shortcuts go: key dispatch, then the open dialog. + fn harness(app: DesignApp) -> Harness<'static, DesignApp> { + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(1280.0, 860.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ready = true; + return; + } + crate::menus::shortcuts(app, ui.ctx()); + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run(); + h + } + + fn opened() -> Harness<'static, DesignApp> { + let mut app = app(); + app.run("app.keyboardShortcuts", json!({})).unwrap(); + harness(app) + } + + fn click(h: &mut Harness<'_, DesignApp>, label: &str) { + h.get_by_label(label).click(); + h.run(); + } + + fn press(h: &mut Harness<'_, DesignApp>, modifiers: Modifiers, key: Key) { + h.key_press_modifiers(modifiers, key); + h.run(); + } + + fn has(h: &Harness<'_, DesignApp>, label: &str) -> bool { + h.query_by_label(label).is_some() + } + + fn chord(h: &Harness<'_, DesignApp>, text: &str) -> String { + Trigger::parse(text).unwrap().display(h.state().keymap.platform()) + } + + /// Searches for a command and returns the label of its row's shortcut cell, which starts + /// recording. + fn row(h: &mut Harness<'_, DesignApp>, label: &str, id: &str) -> String { + h.get_by_label("Search:").focus(); + h.run(); + h.get_by_label("Search:").type_text(label); + h.run(); + format!("{label}: {}", crate::keys::shortcut_display(&h.state().keymap, &CommandRef::new(id)).unwrap()) + } + + /// A command's shortcut with no changes. + fn default_of(id: &str) -> Option { + crate::keys::shortcut(&crate::keys::new_keymap(Overrides::new()), &CommandRef::new(id)) + } + + fn open(h: &Harness<'_, DesignApp>) -> bool { + h.state().ui.dialog.as_ref().is_some_and(|d| d.id == DIALOG_ID) && h.state().shortcut_dialog.is_some() + } + + #[test] + fn a_recorded_chord_runs_nothing_changes_the_menus_at_once_and_ok_keeps_a_custom_set() { + let mut h = opened(); + let quick_apply = row(&mut h, "Quick Apply…", "app.palette"); + click(&mut h, &quick_apply); + // Preferences' chord is recorded, not run. + press(&mut h, Modifiers::COMMAND, Key::K); + assert!(open(&h), "Preferences did not open"); + assert!(has(&h, &format!("⚠ {} is used by Preferences…", chord(&h, "Cmd+K")))); + click(&mut h, "Take It"); + assert_eq!(shortcut_of(h.state(), "app.palette").as_deref(), Some("Cmd+K")); + assert_eq!(shortcut_of(h.state(), "app.preferences"), None); + assert_eq!(h.state().ui.shortcuts.get("app.palette"), Some(&["Cmd+K".to_string()][..]), "ui.json follows live"); + assert!(h.state().ui.shortcut_sets.sets().is_empty(), "the set is saved by OK"); + + click(&mut h, "OK"); + assert!(!open(&h) && h.state().ui.dialog.is_none()); + let sets = &h.state().ui.shortcut_sets; + assert_eq!(sets.active(), "Custom", "edits of the built-in set become a copy"); + assert_eq!(sets.active_overrides(&BUILTINS), &h.state().ui.shortcuts); + let saved = serde_json::to_value(&h.state().ui).unwrap(); + assert_eq!(saved["shortcuts"]["app.palette"], "Cmd+K", "the map earlier versions read"); + assert_eq!(saved["shortcutSets"]["active"], "Custom"); + // The next frames keep it. + h.run(); + assert_eq!(shortcut_of(h.state(), "app.palette").as_deref(), Some("Cmd+K")); + } + + #[test] + fn cancel_puts_the_keys_and_the_sets_back() { + let mut h = opened(); + let quick_apply = row(&mut h, "Quick Apply…", "app.palette"); + click(&mut h, &quick_apply); + press(&mut h, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT, Key::Num9); + assert_eq!(shortcut_of(h.state(), "app.palette").as_deref(), Some("Cmd+Alt+Shift+9")); + click(&mut h, "Cancel"); + h.run(); + assert!(!open(&h)); + assert_eq!(shortcut_of(h.state(), "app.palette"), default_of("app.palette")); + assert!(h.state().ui.shortcuts.is_empty()); + assert_eq!(h.state().ui.shortcut_sets, craft_keymap::Profiles::default()); + + // `ui.dialog.cancel` from the control channel does the same. + h.state_mut().run("app.keyboardShortcuts", json!({"command": "app.palette"})).unwrap(); + h.run(); + let quick_apply = row(&mut h, "Quick Apply…", "app.palette"); + click(&mut h, &quick_apply); + press(&mut h, Modifiers::NONE, Key::F2); + assert_eq!(shortcut_of(h.state(), "app.palette").as_deref(), Some("F2")); + crate::dialogs::cancel(h.state_mut()); + h.run(); + assert!(!open(&h)); + assert_eq!(shortcut_of(h.state(), "app.palette"), default_of("app.palette")); + } + + /// An InDesign set through the dialog's Import: reviewed, then a new set with the Window menu + /// panel item on the panel and its twin command, and the Tools item on the tool. + #[test] + fn an_indesign_set_imports_through_the_review_as_a_new_set() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("studio.txt"); + std::fs::write( + &path, + "Shortcut Set : Studio\r\n\ + Product area : Window Menu\r\n\ + \tType & Tables: Character --- Default: Ctrl+T\r\n\ + Product area : Tools\r\n\ + \tPen Tool --- Default: Shift+Ctrl+F3\r\n\ + \tFrobnicate Tool --- Default: Shift+Ctrl+F4\r\n", + ) + .unwrap(); + let picked = path.to_string_lossy().to_string(); + let mut app = app(); + app.services.pick_open = Some(Box::new(move |purpose| (purpose == PURPOSE).then(|| picked.clone()))); + app.run("app.keyboardShortcuts", json!({})).unwrap(); + let mut h = harness(app); + click(&mut h, "Import…"); + assert!(has(&h, "Import “Studio”")); + assert!(h.state().ui.shortcuts.is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert!(has(&h, "Imported as “Studio”")); + let panel = |h: &Harness<'_, DesignApp>| crate::keys::shortcut(&h.state().keymap, &crate::keys::panel("character")); + assert_eq!(panel(&h).as_deref(), Some("Cmd+T")); + assert_eq!(shortcut_of(h.state(), "app.characterPanel").as_deref(), Some("Cmd+T"), "the panel's twin keeps the key they share"); + assert_eq!(crate::keys::shortcut(&h.state().keymap, &crate::keys::tool("pen")).as_deref(), Some("Cmd+Shift+F3")); + click(&mut h, "Done"); + click(&mut h, "OK"); + assert!(!open(&h)); + assert_eq!(h.state().ui.shortcut_sets.active(), "Studio"); + assert_eq!(h.state().ui.shortcut_sets.active_overrides(&BUILTINS), &h.state().ui.shortcuts); + // Back to the defaults by switching sets. + h.state_mut().run("window.shortcutSets", json!({"select": "Default"})).unwrap(); + assert_eq!( + crate::keys::shortcut(&h.state().keymap, &crate::keys::tool("pen")), + crate::keys::shortcut(&crate::keys::new_keymap(Overrides::new()), &crate::keys::tool("pen")) + ); + assert!(h.state().ui.shortcuts.is_empty()); + } + + #[test] + fn shortcuts_from_an_earlier_version_become_the_active_set() { + // A ui.json without sets: its changes become Custom. + let mut app = app(); + app.ui = crate::UiState::from_preferences_json(br#"{"shortcuts": {"app.palette": "Cmd+Alt+Shift+9"}, "shortcutSets": false}"#).unwrap(); + app.sync_keymap(); + assert_eq!(app.ui.shortcut_sets.active(), "Custom"); + assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Alt+Shift+9")); + // An earlier version changed the shortcuts of the active set "Mine". + let mut saved = serde_json::to_value(&app.ui).unwrap(); + saved["shortcutSets"] = json!({"version": 1, "active": "Mine", "sets": [{"name": "Mine", "overrides": []}]}); + saved["shortcuts"] = json!({"app.palette": "F2"}); + app.ui = crate::UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); + app.sync_keymap(); + assert_eq!(app.ui.shortcut_sets.active(), "Mine"); + assert_eq!(app.ui.shortcut_sets.active_overrides(&BUILTINS).get("app.palette"), Some(&["F2".to_string()][..])); + assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("F2")); + let r = app.run("window.shortcutSets", json!({"select": "Default"})).unwrap(); + assert_eq!(r, json!({"active": "Default", "sets": ["Default", "Mine"], "builtIn": ["Default"]})); + assert_eq!(shortcut_of(&app, "app.palette"), default_of("app.palette")); + assert!(app.run("window.shortcutSets", json!({"select": "Nope"})).is_err()); + assert!(serde_json::to_value(&app.ui).unwrap()["shortcuts"].as_object().is_some_and(|m| m.is_empty())); + } + + #[test] + fn every_dialog_string_reads_in_english_without_a_translation() { + let text = DcText { lang: "" }; + for s in strings() { + assert_eq!(text.ui(s), s); + } + assert_eq!(craft_keymap::ui::STRINGS.len(), 174); + assert_eq!(craft_keymap::ui::PLURALS.len(), 11); + assert_eq!(text.plural(2, "{n} command will change", "{n} commands will change"), "2 commands will change"); + assert_eq!(DcText { lang: "fr" }.ui("Cancel"), tr("fr", "Cancel")); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 47fd03ea3..5fc232699 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (Keyboard Shortcuts on craft-keymap's shared dialog, with shortcut sets; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -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, 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), including its Window menu panel items and Tools items, and reports what it changed and what it could not match, with likely commands for unmatched items; `window.importShortcuts`. +- Edit › Keyboard Shortcuts (craft-keymap's shared dialog): every command, panel and tool grouped by menu, click and press the new keys (a key another command holds asks first: take it, take it and go there, or keep both), undo, per-command and global reset, named shortcut sets (Default is built in; edits of it become Custom), an on-screen US keyboard, JSON export and import, an HTML summary; `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… reads a set saved by InDesign's Show Set (.txt), including its Window menu panel items and Tools items, and shows what it will change and what it could not match, with likely commands to pick for unmatched items, before it becomes a set of its own; `window.importShortcuts`, `window.shortcutSets`. - 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.