diff --git a/ROADMAP.md b/ROADMAP.md index 5a442cef1..df4bd0f78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. - 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/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ecc11a3a4..5b0f9b7c3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -102,6 +102,80 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the +/// new keys (Esc cancels, Backspace clears). +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("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. + if !recording.is_empty() { + let pressed = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } => 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(String::new()), + _ => Some(crate::menus::shortcut_string(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, + }; + d.fields.insert("message".into(), json!(msg)); + } + d.fields.insert("recording".into(), json!("")); + } + } + let ql = q.to_lowercase(); + let mut rows: Vec<(String, String, String)> = designcraft_engine::command_specs() + .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(|(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 { + ui.label(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 text = if recording == *id { "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 ui.small_button("Default").clicked() { + let _ = app.run("window.setShortcut", json!({"id": id, "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))); + } + if ui.button("Reset All to Defaults").clicked() { + let _ = app.run("window.resetShortcuts", json!({})); + } +} + /// File › Document Setup, filled from the document. pub fn open_document_setup(app: &mut DesignApp) { let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return }; @@ -406,6 +480,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findFont" => "Find/Replace Font", "polygonSettings" => "Polygon Settings", "preferences" => "Preferences", + "keyboardShortcuts" => "Keyboard Shortcuts", "colorPicker" => "Color Picker", id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => c.label.trim_end_matches('…'), @@ -543,6 +618,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), "preferences" => preferences(ui, &mut d), + "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), "polygonSettings" => { egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { ui.label("Number of Sides:"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 746873b6e..be01ddb10 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ pub struct UiState { pub display_quality: designcraft_render::DisplayQuality, /// Preferences › Interface › UI scaling (1 = 100%). pub ui_scale: f32, + /// Edit › Keyboard Shortcuts: command id → shortcut ("" = none), over the defaults. + pub shortcuts: std::collections::BTreeMap, pub control_bar: bool, pub tools_double_column: bool, /// Expanded right-dock panel group tab. @@ -136,6 +138,7 @@ impl Default for UiState { hidden_characters: false, display_quality: designcraft_render::DisplayQuality::High, ui_scale: 1.0, + shortcuts: Default::default(), control_bar: false, tools_double_column: false, dock_tab: "properties".into(), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 29e5d8816..778123876 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -21,6 +21,9 @@ 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"), + ("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"), + ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), @@ -135,6 +138,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:app.palette", + "ui:app.keyboardShortcuts", "ui:app.preferences", ], ), @@ -709,6 +713,39 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""}))); + Ok(Value::Null) + } + "window.setShortcut" => { + 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}`"))); + } + 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()); + } + Some(_) => return Some(Err("shortcut: a string or null".into())), + } + 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})) + } + "window.resetShortcuts" => { + app.ui.shortcuts.clear(); + Ok(Value::Null) + } "window.uiScale" => { let v = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0) as f32; app.ui.ui_scale = v.clamp(0.5, 3.0); @@ -868,18 +905,65 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool { } } -fn shortcut_text(sc: &str) -> String { - let mac = cfg!(target_os = "macos"); - sc.split('+') - .map(|k| match k { - "Cmd" => if mac { "⌘" } else { "Ctrl+" }.to_string(), - "Shift" => if mac { "⇧" } else { "Shift+" }.to_string(), - "Alt" => if mac { "⌥" } else { "Alt+" }.to_string(), - k => k.to_string(), - }) +/// The default shortcut of a command. +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). +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), + } +} + +/// Every command with a shortcut in effect: (id, shortcut). +pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> { + designcraft_engine::command_specs() + .iter() + .map(|c| c.id) + .chain(UI_COMMANDS.iter().map(|c| c.0)) + .filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), 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 +} + +/// A shortcut for display: ⌃⌥⇧⌘ order on macOS, Ctrl+Alt+Shift+ 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 }; + } + } + out + key +} + /// A menu entry, shared by the in-window menu bar and the native macOS menu. #[derive(Clone, Debug)] pub enum Item { @@ -1036,15 +1120,15 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item]) { Item::Sub(name, children) => { ui.menu_button(name, |ui| menu_items(app, ui, children)); } - Item::Cmd { label, id, params, shortcut } => { + Item::Cmd { label, id, params, shortcut: _ } => { let text = match checked(app, id, params) { Some(true) => format!("✓ {label}"), Some(false) => format!(" {label}"), None => label.clone(), }; let mut b = egui::Button::new(text); - if let Some(sc) = shortcut { - b = b.shortcut_text(shortcut_text(sc)); + if let Some(sc) = shortcut_of(app, id) { + b = b.shortcut_text(shortcut_text(&sc)); } if ui.add_enabled(menu_enabled(app, id), b).clicked() { activate(app, id, params); @@ -1093,12 +1177,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { for e in events { let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue }; // Command shortcuts. - let mut fired = None; - let all = designcraft_engine::command_specs() - .iter() - .filter_map(|c| c.shortcut.map(|s| (c.id, s))) - .chain(UI_COMMANDS.iter().filter_map(|c| c.2.map(|s| (c.0, s)))); - for (id, sc) in all { + let mut fired: Option = None; + let all = effective_shortcuts(app); + for (id, sc) in &all { + let id = id.as_str(); if let Some((m, k)) = parse_shortcut(sc) && k == key && m.command == modifiers.command @@ -1114,15 +1196,15 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { 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); + fired = Some(id.to_string()); break; } } if let Some(id) = fired { - if app.native_shortcuts.contains(id) { + if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) { continue; // The native menu handles it. } - let _ = app.run(id, json!({})); + let _ = app.run(&id, json!({})); continue; } // Tool shortcuts (single keys, Shift+key). @@ -1239,6 +1321,30 @@ mod tests { } } + #[test] + fn custom_shortcuts_override_defaults() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + assert_eq!(shortcut_of(&app, "app.preferences").as_deref(), Some("Cmd+K")); + let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "app.preferences", "shortcut": "Cmd+Alt+Shift+9"})).unwrap().unwrap(); + assert_eq!(r["shortcut"], "Cmd+Alt+Shift+9"); + assert!(effective_shortcuts(&app).contains(&("app.preferences".into(), "Cmd+Alt+Shift+9".into()))); + // Taking another command's keys reports the conflict. + let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+Shift+9"})).unwrap().unwrap(); + assert_eq!(r["conflicts"], json!(["app.preferences"])); + run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": ""})).unwrap().unwrap(); + assert_eq!(shortcut_of(&app, "app.palette"), None); + assert!(run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Nope"})).unwrap().is_err()); + run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap(); + assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return")); + 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"); + } + } + #[test] fn panels_float_and_dock() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/parity.md b/docs/parity.md index 162cd4929..839494ab7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,12 +10,12 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 51 | 23 | 23 | 64% | +| P1 | 97 | 52 | 23 | 22 | 65% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | |---|---| -| Application shell & workspace | 74% | +| Application shell & workspace | 80% | | Documents, pages, spreads | 74% | | Layers | 95% | | Frames, shapes & paths | 85% | @@ -51,7 +51,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Application shell & workspace | Contextual task bar | P2 | D | | Application shell & workspace | Themes | P0 | D | | Application shell & workspace | UI scaling | P1 | D | -| Application shell & workspace | Keyboard shortcut editor | P1 | M | +| Application shell & workspace | Keyboard shortcut editor | P1 | D | | Application shell & workspace | Menu customization | P2 | M | | Application shell & workspace | Show Full Menus | P2 | M | | Application shell & workspace | Recovery / autosave | P0 | D |