From 3c862cc20fabcfdd0554650c9ffecd5f13aa2387 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 19:53:29 +0000 Subject: [PATCH 01/14] #1180: import an InDesign keyboard shortcut set Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the .txt that InDesign's Show Set writes (window.importShortcuts {path|text}). Matched commands take the set's Default shortcut, or lose theirs when every occurrence says [none defined]; other commands give up keys the set takes. The report lists changes, cleared conflicts, and the items that could not be matched or read. Tool keys stay fixed. --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 2 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 13 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/shortcut_import.rs | 601 ++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 641 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/shortcut_import.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..5af957ba6 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-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. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe0f4a7fa..27d236a2f 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); 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"]) } 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 a1b4d8276..da483931d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -187,6 +187,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"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..edef4affa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if !msg.is_empty() { ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60))); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { - let _ = app.run("window.resetShortcuts", json!({})); - } + 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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79eb2c44c..2cb1503f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +mod shortcut_import; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -1058,6 +1059,10 @@ impl DesignApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); for file in files { + if file.request.purpose == "shortcuts" { + shortcut_import::import_delivered(self, &file.bytes); + continue; + } if self.ui.dialog.is_some() { self.status( crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again") diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e42..e8cf910ba 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("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.resetShortcuts", "Reset Shortcuts", None, "{} — every command 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 take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped, unmatched, unmatchedWithoutShortcut, unreadable}", + ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ( @@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) { + (Some(text), _) => text.to_string(), + (None, Some(path)) => match crate::shortcut_import::read_file(path) { + Ok(text) => text, + Err(e) => return Some(Err(e)), + }, + (None, None) => return Some(Err("give the set's path or text".into())), + }; + crate::shortcut_import::import(app, &text) + } "view.snapPreferences" => { let b = |k: &str| p.get(k).and_then(Value::as_bool); let ui = &mut app.ui; @@ -2976,7 +2993,7 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } -fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::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()) { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs new file mode 100644 index 000000000..bb00198b6 --- /dev/null +++ b/crates/ui-egui/src/shortcut_import.rs @@ -0,0 +1,601 @@ +//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text +//! (Show Set): `window.importShortcuts`. +//! +//! The file lists items under `Product area : ` headers, one per line: +//! ` --- [none defined]` or ` --- : , : `. Item names +//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no +//! shortcut contexts); a type command with no Default entry takes its Text entry. + +use std::collections::{BTreeSet, HashMap}; +use std::io::Read; + +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::menus::{self, Item}; + +/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16). +pub const MAX_BYTES: usize = 8 << 20; +/// Entries read per item: a real item has one or two per context. +const MAX_ENTRIES: usize = 64; + +/// One `: ` entry of an item. +struct Entry { + context: String, + keys: String, +} + +struct SetItem { + area: String, + name: String, + entries: Vec, +} + +struct ParsedSet { + name: String, + items: Vec, +} + +/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or +/// by the zero bytes of ASCII text without one). +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20)); + } + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + Ok(match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, true), + [0xFE, 0xFF, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [a, 0, ..] if *a != 0 => utf16(bytes, true), + [0, b, ..] if *b != 0 => utf16(bytes, false), + _ => String::from_utf8_lossy(bytes).into_owned(), + }) +} + +/// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. +pub fn read_file(path: &str) -> Result { + let file = std::fs::File::open(path).map_err(|e| format!("can't open {path}: {e}"))?; + let mut bytes = Vec::new(); + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX).saturating_add(1); + file.take(cap).read_to_end(&mut bytes).map_err(|e| format!("can't read {path}: {e}"))?; + decode(&bytes) +} + +/// `Label : value` with any spacing around the colon, when the line starts with `label`. +fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> { + let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?; + rest.trim_start().strip_prefix(':').map(str::trim) +} + +fn parse(text: &str) -> Result { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let mut set = ParsedSet { name: String::new(), items: Vec::new() }; + let mut area = String::new(); + let mut header_seen = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if let Some(name) = header(trimmed, "Shortcut Set") { + set.name = name.to_string(); + header_seen = true; + } else if let Some(a) = header(trimmed, "Product area") { + area = a.to_string(); + header_seen = true; + } else if let Some((name, keys)) = trimmed.split_once(" --- ") { + let name = name.trim(); + if name.is_empty() { + continue; + } + set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) }); + } + } + if set.items.is_empty() && !header_seen { + return Err("not a shortcut set: no `Product area` or ` --- ` lines".into()); + } + Ok(set) +} + +/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a +/// piece without `: ` belongs to the entry before it. +fn entries(keys: &str) -> Vec { + let keys = keys.trim(); + if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() { + return Vec::new(); + } + let mut pieces: Vec = Vec::new(); + for piece in keys.split(", ") { + match pieces.last_mut() { + Some(last) if !piece.contains(": ") => { + last.push_str(", "); + last.push_str(piece); + } + _ => pieces.push(piece.to_string()), + } + } + pieces + .into_iter() + .take(MAX_ENTRIES) + .filter_map(|p| { + let (context, keys) = p.split_once(": ")?; + Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() }) + }) + .collect() +} + +/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the +/// command modifier. +fn is_mac_set(set: &ParsedSet) -> bool { + set.items.iter().flat_map(|i| &i.entries).any(|e| { + let k = e.keys.to_lowercase(); + k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥') + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mod { + Command, + Alt, + Shift, + Control, +} + +/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a +/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set. +fn read_keys(keys: &str, mac: bool) -> Result { + const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)]; + const WORDS: &[(&str, Mod)] = &[ + ("command", Mod::Command), + ("cmd", Mod::Command), + ("option", Mod::Alt), + ("opt", Mod::Alt), + ("alt", Mod::Alt), + ("shift", Mod::Shift), + ("control", Mod::Control), + ("ctrl", Mod::Control), + ]; + let mut m = egui::Modifiers::NONE; + let mut set = |md: Mod| match md { + Mod::Command => m.command = true, + Mod::Alt => m.alt = true, + Mod::Shift => m.shift = true, + Mod::Control if mac => m.ctrl = true, + Mod::Control => m.command = true, + }; + let mut rest = keys.trim(); + 'strip: loop { + for (sym, md) in SYMBOLS { + if let Some(r) = rest.strip_prefix(sym) { + set(*md); + rest = r.strip_prefix('+').unwrap_or(r).trim_start(); + continue 'strip; + } + } + for (word, md) in WORDS { + let Some(head) = rest.get(..word.len()) else { continue }; + let Some(after) = rest.get(word.len()..) else { continue }; + // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name. + let mut chars = after.chars(); + if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() { + set(*md); + rest = chars.as_str().trim_start(); + continue 'strip; + } + } + break; + } + if m.ctrl { + return Err("Control-key shortcuts are not supported"); + } + let key = key_named(rest.trim()).ok_or("unknown key")?; + let sc = menus::shortcut_string(m, key); + // Only a string the shortcut dispatcher reads back as these keys is imported. + match menus::parse_shortcut(&sc) { + Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc), + _ => Err("unknown key"), + } +} + +fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool { + a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl +} + +/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive. +fn key_named(name: &str) -> Option { + use egui::Key; + if name.is_empty() { + return None; + } + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase(); + // The numeric keypad: egui doesn't tell its keys apart from the main ones. + let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed); + let key = match n { + "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp, + "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown, + "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft, + "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight, + "pageup" | "pgup" | "⇞" => Key::PageUp, + "pagedown" | "pgdn" | "⇟" => Key::PageDown, + "home" | "↖" => Key::Home, + "end" | "↘" => Key::End, + "esc" | "escape" | "⎋" => Key::Escape, + "tab" | "⇥" => Key::Tab, + "space" | "spacebar" => Key::Space, + "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter, + "backspace" | "⌫" => Key::Backspace, + "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete, + "insert" | "ins" | "help" => Key::Insert, + _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)), + }; + Some(key) +} + +/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and +/// no Show/Hide in front (InDesign names a toggle by what choosing it does now). +fn norm(s: &str) -> String { + let s = s.replace('…', "").replace("...", "").replace('&', ""); + let s = s.split_whitespace().collect::>().join(" ").to_lowercase(); + ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string) +} + +/// DesignCraft's commands by menu path and label. +#[derive(Default)] +struct Index { + /// `menu\nsubmenu\nlabel` → ids, in the menu bar. + path: HashMap>, + /// The same from the engine commands' own menu placement, which a UI command (the one with + /// the dialog) may stand in for in the menu bar. + engine_path: HashMap>, + /// (menu, label) → ids, at any depth of that menu. + in_menu: HashMap<(String, String), BTreeSet>, + /// label → ids, anywhere. + label: HashMap>, +} + +impl Index { + fn build() -> Self { + fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { + for it in items { + match it { + Item::Sub(name, children) => { + path.push(norm(name)); + walk(ix, children, path); + path.pop(); + } + // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one. + Item::Cmd { label, id, params, .. } if params.is_null() => { + let label = norm(label); + let top = path.first().cloned().unwrap_or_default(); + add_path(&mut ix.path, path, &label, id); + ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone()); + ix.label.entry(label).or_default().insert(id.clone()); + } + _ => {} + } + } + } + let mut ix = Index::default(); + for (menu, entries) in menus::menu_tree() { + walk(&mut ix, &entries, &mut vec![norm(menu)]); + } + for c in designcraft_engine::command_specs() { + let label = norm(c.label); + if !c.menu.is_empty() { + let path: Vec = c.menu.iter().map(|m| norm(m)).collect(); + add_path(&mut ix.engine_path, &path, &label, c.id); + } + ix.label.entry(label).or_default().insert(c.id.to_string()); + } + for (id, label, _, _) in menus::UI_COMMANDS { + ix.label.entry(norm(label)).or_default().insert((*id).to_string()); + } + ix + } + + /// The command an item names, trying the most specific match first. Matches by label alone + /// only count when exactly one command has that label. + fn find(&self, area: &str, name: &str) -> Option { + let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); + let segments: Vec = name.split(": ").map(norm).collect(); + let last = segments.last().cloned().unwrap_or_default(); + let area = norm(area); + if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) { + let mut key = menu.to_string(); + for s in &segments { + key.push('\n'); + key.push_str(s); + } + if let Some(id) = one(self.path.get(&key)) { + return Some(id); + } + if segments.len() > 1 + && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" ")))) + { + return Some(id); + } + if let Some(id) = one(self.engine_path.get(&key)) { + return Some(id); + } + if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) { + return Some(id); + } + // Menus are arranged differently: the item's own label, if no other command has it. + return one(self.label.get(&last)); + } + if segments.len() == 1 { one(self.label.get(&last)) } else { None } + } +} + +fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) { + let mut key = path.join("\n"); + key.push('\n'); + key.push_str(label); + map.entry(key).or_default().insert(id.to_string()); +} + +/// What the set says about one DesignCraft command, over every item matched to it. +struct Decision { + id: String, + item: String, + /// The first readable shortcut. + shortcut: Option, + /// Every matched item said `[none defined]`. + all_none: bool, +} + +fn command_label(id: &str) -> String { + menus::ui_label(id) + .map(|(l, _)| l.to_string()) + .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string())) + .unwrap_or_else(|| id.to_string()) +} + +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)) +} + +/// Imports a set: every matched command takes the set's shortcut (or none), another command +/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + if text.len() > MAX_BYTES { + return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); + } + let set = parse(text)?; + let mac = is_mac_set(&set); + let ix = Index::build(); + let mut decisions: Vec = Vec::new(); + let mut unmatched = Vec::new(); + let mut unmatched_without_shortcut = 0usize; + let mut unreadable = Vec::new(); + for item in &set.items { + let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; + let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); + let Some(id) = ix.find(&item.area, &item.name) else { + if item.entries.is_empty() { + unmatched_without_shortcut += 1; + } else { + let tool = norm(&item.area) == "tools" + && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); + let mut row = json!({"item": shown, "keys": all_keys}); + if tool { + row["reason"] = json!("tool keys are fixed"); + } + unmatched.push(row); + } + continue; + }; + let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); + let text_command = id.starts_with("type.") || id.starts_with("text."); + let mut considered = in_context("Default"); + if considered.is_empty() && text_command { + considered = in_context("Text"); + } + let mut read = None; + let mut why = ""; + for e in &considered { + match read_keys(&e.keys, mac) { + Ok(sc) => { + read = Some(sc); + break; + } + Err(w) => why = w, + } + } + if read.is_none() && !considered.is_empty() { + unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); + } + let pos = match decisions.iter().position(|d| d.id == id) { + Some(p) => p, + None => { + decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); + decisions.len() - 1 + } + }; + let Some(d) = decisions.get_mut(pos) else { continue }; + d.all_none &= item.entries.is_empty(); + if d.shortcut.is_none() + && let Some(sc) = read + { + d.shortcut = Some(sc); + d.item = shown; + } + } + + let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); + let mut imported = 0usize; + let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); + let mut skipped = Vec::new(); + let mut decided: BTreeSet = BTreeSet::new(); + for d in &decisions { + match &d.shortcut { + Some(sc) => { + let Some(keys) = keys_of(sc) else { continue }; + if let Some((_, by)) = taken.iter().find(|(k, _)| *k == keys) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "keptBy": by})); + continue; + } + taken.push((keys, d.id.clone())); + if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), sc.clone()); + } + } + None if d.all_none => { + if menus::default_shortcut(&d.id).is_none() { + app.ui.shortcuts.remove(&d.id); + } else { + app.ui.shortcuts.insert(d.id.clone(), String::new()); + } + } + None => continue, + } + imported += 1; + decided.insert(d.id.clone()); + } + let mut conflicts = Vec::new(); + for (id, sc) in menus::effective_shortcuts(app) { + if decided.contains(&id) { + continue; + } + // A command sharing its default keys with the one taking them (file.new and the New + // Document dialog) keeps them: the dispatcher already prefers the other. + let twin = |by: &str| { + let default = |id: &str| menus::default_shortcut(id).and_then(keys_of); + default(&id).is_some() && default(&id) == default(by) + }; + if let Some(keys) = keys_of(&sc) + && taken.iter().any(|(k, by)| *k == keys && !twin(by)) + { + app.ui.shortcuts.insert(id.clone(), String::new()); + conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); + } + } + let changed: Vec = decisions + .iter() + .filter(|d| decided.contains(&d.id)) + .filter_map(|d| { + let from = before.get(&d.id).cloned().flatten(); + let to = menus::shortcut_of(app, &d.id); + (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to})) + }) + .collect(); + Ok(json!({ + "set": set.name, + "imported": imported, + "changed": changed, + "conflictsCleared": conflicts, + "skipped": skipped, + "unmatched": unmatched, + "unmatchedWithoutShortcut": unmatched_without_shortcut, + "unreadable": unreadable, + })) +} + +/// One line for the Keyboard Shortcuts dialog 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(); + crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") + .replace("{count}", &n("imported")) + .replace("{set}", report["set"].as_str().unwrap_or_default()) + .replace("{unmatched}", &len("unmatched")) + .replace("{unreadable}", &len("unreadable")) +} + +/// 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. +pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { + 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), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) + } + + #[test] + fn imports_a_windows_set_and_reports_what_it_could_not_use() { + let mut app = app(); + // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling. + menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap(); + let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\ + Product area : Edit Menu\r\n\ + \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tFrobnicate Everything --- Default: Ctrl+F12\r\n\ + \tNever Heard Of It --- [none defined]\r\n\ + Product area : File Menu\r\n\ + \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ + \tSave --- Default: Ctrl+Hyperspace\r\n\ + garbage line without a separator\r\n"; + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); + assert_eq!(r["set"], "My Set"); + // A submenu item, with Ctrl as the command modifier. + assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5")); + // `[none defined]` in every occurrence clears a matched command. + assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None); + // The command that held the imported keys loses them. + assert_eq!(menus::shortcut_of(&app, "app.palette"), None); + assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}"); + // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut. + assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}])); + assert_eq!(r["unmatchedWithoutShortcut"], 1); + assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); + assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); + assert_eq!(r["imported"], 3); + // 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")); + } + + #[test] + fn hostile_input_returns_a_report_or_an_error() { + let mut app = app(); + for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] { + let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap(); + } + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err()); + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err()); + // UTF-16 with a BOM, and Mac modifier spellings. + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes)); + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap(); + assert_eq!(r["set"], "Mac"); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 507358dec..381498fb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in | PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | | Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | | Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | -| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) | | User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | | Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991edd..71a95b341 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`. +- 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`. - 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 2dcf4687a..b66fb585c 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 | ✓ | | +| 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 | | 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) | From fb3fd8ad3b27a38116e074f8ca42e62c898fd907 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 20:03:14 +0000 Subject: [PATCH 02/14] #1180: read the set file through the checked reader --- crates/ui-egui/src/shortcut_import.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index bb00198b6..ba6457647 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -7,7 +7,6 @@ //! shortcut contexts); a type command with no Default entry takes its Text entry. use std::collections::{BTreeSet, HashMap}; -use std::io::Read; use serde_json::{Value, json}; @@ -58,10 +57,8 @@ pub fn decode(bytes: &[u8]) -> Result { /// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. pub fn read_file(path: &str) -> Result { - let file = std::fs::File::open(path).map_err(|e| format!("can't open {path}: {e}"))?; - let mut bytes = Vec::new(); - let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX).saturating_add(1); - file.take(cap).read_to_end(&mut bytes).map_err(|e| format!("can't read {path}: {e}"))?; + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX); + let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?; decode(&bytes) } From 68e2044b918ccf0e3b919b456cdf3cf8c8b433a1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 03:42:41 +0000 Subject: [PATCH 03/14] #1180: when two items want the same keys, the later one takes them --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcut_import.rs | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e8cf910ba..c261ceb4a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,7 +89,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.importShortcuts", "Import InDesign Shortcut Set", None, - "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped, unmatched, unmatchedWithoutShortcut, unreadable}", + "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index ba6457647..9ccbef1b9 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -426,12 +426,20 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); let mut skipped = Vec::new(); let mut decided: BTreeSet = BTreeSet::new(); - for d in &decisions { + // When two items in the set want the same keys, the later one takes them (the rule shared by + // the Craft apps' importers). + let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new(); + for (i, d) in decisions.iter().enumerate() { + if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) { + winner.insert(keys, i); + } + } + for (i, d) in decisions.iter().enumerate() { match &d.shortcut { Some(sc) => { let Some(keys) = keys_of(sc) else { continue }; - if let Some((_, by)) = taken.iter().find(|(k, _)| *k == keys) { - skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "keptBy": by})); + if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); continue; } taken.push((keys, d.id.clone())); @@ -558,6 +566,8 @@ mod tests { Product area : File Menu\r\n\ \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ \tSave --- Default: Ctrl+Hyperspace\r\n\ + \tRevert --- Default: Shift+Ctrl+F9\r\n\ + \tSave As... --- Default: Shift+Ctrl+F9\r\n\ garbage line without a separator\r\n"; let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); assert_eq!(r["set"], "My Set"); @@ -574,7 +584,10 @@ mod tests { assert_eq!(r["unmatchedWithoutShortcut"], 1); assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); - assert_eq!(r["imported"], 3); + // Two items want the same keys: the later one takes them. + assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9")); + assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}"); + 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")); From 8383d828ad5808f9ca51deec36902b78ea6fcc2b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:14:59 +0000 Subject: [PATCH 04/14] 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) | From 8bb887a188d7ef10a7f9388d9fb3e8c7a18ff070 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:26:41 +0000 Subject: [PATCH 05/14] keymap: the InDesign set import runs on craft_keymap::import shortcut_import.rs keeps the Show Set reader (decoding, the 8 MB cap, header and item lines, entry splitting that keeps the comma key) and maps each item to a craft_keymap::ForeignSet entry: ` Menu` areas become menus, the Tools area marks `... Tool` items as tool entries, and keys are read with ForeignKey::read in the set's Windows or Mac flavour. Matching, conflicts and apply are craft_keymap::plan + ImportPlan::apply with Default as the home context and Text used when an item has no Default keys; the keymap's overrides are stored afterwards. - Window menu panel items and Tools items set panel and tool shortcuts. - An item's Default keys all apply, not only the first readable one. - The window.importShortcuts result keeps its field names and adds failed, approximated, suggestions and truncated; rows carry line and reason, ids are command keys. - An engine command's own menu path is left out of the catalogue where a menu row of another command already stands, so the importer can pick the menu row's command. - The dialog summary counts unmatched items without the rows for keys in contexts DesignCraft lacks. --- ROADMAP.md | 1 + crates/ui-egui/src/keys.rs | 9 +- crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/shortcut_import.rs | 535 +++++++------------------- docs/target-app-parity.md | 2 +- 5 files changed, 153 insertions(+), 404 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6ca512064..0ba1879fc 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** · 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 280 of 1,629 items (was 233) and changes 27 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. - **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. diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index bc3c53b54..8e3c16452 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -2,7 +2,7 @@ //! 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::collections::{HashMap, HashSet}; use std::sync::OnceLock; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes}; @@ -65,13 +65,18 @@ pub fn catalogue() -> &'static [Bindable] { fn build_catalogue() -> Vec { let paths = menu_paths(); + // An engine command's own menu placement counts only where no menu row stands: a row there + // runs a UI command in its place (File › Save is the Save dialog when needed), and two + // commands on one path leave the importer unable to pick either. + let normalized = |path: &[String]| path.iter().map(|s| craft_keymap::import::normalize_label(s)).collect::>(); + let rows: HashSet> = paths.values().flatten().map(|p| normalized(p)).collect(); 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), + None => b.paths.extend(fallback_path.filter(|p| !rows.contains(&normalized(p)))), } if let Some(binding) = default.and_then(|d| Binding::parse(d).ok()) { b = b.with_default(binding); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87bbfd4fa..fb61be02e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -94,7 +94,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.importShortcuts", "Import InDesign Shortcut Set", None, - "{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}", + "{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), truncated}; 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)"), @@ -2982,14 +2982,6 @@ 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 chord = craft_keymap::Chord::parse(sc).ok()?; - let s = chord.to_egui_shortcut(); - Some((s.modifiers, s.logical_key)) -} - /// Global keyboard shortcuts: menu commands, panels and tools, through the keymap. pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { app.sync_keymap(); diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 8654bf553..0d62a535a 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -3,15 +3,15 @@ //! //! The file lists items under `Product area : ` headers, one per line: //! ` --- [none defined]` or ` --- : , : `. Item names -//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no -//! shortcut contexts); a type command with no Default entry takes its Text entry. - -use std::collections::{BTreeSet, HashMap}; +//! use `: ` between submenu levels. This module reads the file into a +//! [`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 craft_keymap::import::{MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport}; use serde_json::{Value, json}; use crate::DesignApp; -use crate::menus::{self, Item}; /// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16). pub const MAX_BYTES: usize = 8 << 20; @@ -25,14 +25,20 @@ struct Entry { } struct SetItem { - area: String, + /// Index into [`ParsedSet::areas`]; `None` before the first `Product area` line. + area: Option, name: String, entries: Vec, + /// 1-based line in the file. + line: usize, } struct ParsedSet { name: String, + areas: Vec, items: Vec, + /// Items past [`MAX_IMPORT_ENTRIES`], not read. + dropped: usize, } /// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or @@ -68,26 +74,34 @@ fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> { rest.trim_start().strip_prefix(':').map(str::trim) } +/// The first [`MAX_IMPORT_TEXT`] chars of `s`. +fn capped(s: &str) -> &str { + s.char_indices().nth(MAX_IMPORT_TEXT).and_then(|(end, _)| s.get(..end)).unwrap_or(s) +} + fn parse(text: &str) -> Result { let text = text.strip_prefix('\u{feff}').unwrap_or(text); - let mut set = ParsedSet { name: String::new(), items: Vec::new() }; - let mut area = String::new(); + let mut set = ParsedSet { name: String::new(), areas: Vec::new(), items: Vec::new(), dropped: 0 }; let mut header_seen = false; - for line in text.lines() { - let line = line.trim_end_matches('\r'); - let trimmed = line.trim(); + for (n, line) in text.lines().enumerate() { + let trimmed = line.trim_end_matches('\r').trim(); if let Some(name) = header(trimmed, "Shortcut Set") { - set.name = name.to_string(); + set.name = capped(name).to_string(); header_seen = true; } else if let Some(a) = header(trimmed, "Product area") { - area = a.to_string(); + set.areas.push(capped(a).to_string()); header_seen = true; } else if let Some((name, keys)) = trimmed.split_once(" --- ") { let name = name.trim(); if name.is_empty() { continue; } - set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) }); + if set.items.len() >= MAX_IMPORT_ENTRIES { + set.dropped += 1; + continue; + } + let area = set.areas.len().checked_sub(1); + set.items.push(SetItem { area, name: name.to_string(), entries: entries(keys), line: n.saturating_add(1) }); } } if set.items.is_empty() && !header_seen { @@ -105,17 +119,18 @@ fn entries(keys: &str) -> Vec { } let mut pieces: Vec = Vec::new(); for piece in keys.split(", ") { + let full = pieces.len() >= MAX_ENTRIES; match pieces.last_mut() { Some(last) if !piece.contains(": ") => { last.push_str(", "); last.push_str(piece); } + _ if full => break, _ => pieces.push(piece.to_string()), } } pieces .into_iter() - .take(MAX_ENTRIES) .filter_map(|p| { let (context, keys) = p.split_once(": ")?; Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() }) @@ -123,398 +138,116 @@ fn entries(keys: &str) -> Vec { .collect() } -/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the -/// command modifier. -fn is_mac_set(set: &ParsedSet) -> bool { - set.items.iter().flat_map(|i| &i.entries).any(|e| { - let k = e.keys.to_lowercase(); - k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥') - }) +/// `s` without a trailing `suffix`, compared without ASCII case. +fn strip_suffix_ignore_case<'a>(s: &'a str, suffix: &str) -> Option<&'a str> { + let at = s.len().checked_sub(suffix.len())?; + s.get(at..).filter(|end| end.eq_ignore_ascii_case(suffix)).and_then(|_| s.get(..at)) } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Mod { - Command, - Alt, - Shift, - Control, -} - -/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a -/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set. -fn read_keys(keys: &str, mac: bool) -> Result { - const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)]; - const WORDS: &[(&str, Mod)] = &[ - ("command", Mod::Command), - ("cmd", Mod::Command), - ("option", Mod::Alt), - ("opt", Mod::Alt), - ("alt", Mod::Alt), - ("shift", Mod::Shift), - ("control", Mod::Control), - ("ctrl", Mod::Control), - ]; - let mut m = egui::Modifiers::NONE; - let mut set = |md: Mod| match md { - Mod::Command => m.command = true, - Mod::Alt => m.alt = true, - Mod::Shift => m.shift = true, - Mod::Control if mac => m.ctrl = true, - Mod::Control => m.command = true, - }; - let mut rest = keys.trim(); - 'strip: loop { - for (sym, md) in SYMBOLS { - if let Some(r) = rest.strip_prefix(sym) { - set(*md); - rest = r.strip_prefix('+').unwrap_or(r).trim_start(); - continue 'strip; +/// The set as `craft_keymap::import` reads it. An area named ` Menu` is that menu; the +/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of other areas +/// (panel menus, text and tables, views) have no menu, so they match only by a one-segment name. +/// The keys are read with Mac modifier names when any key in the set uses one. +fn foreign(set: &ParsedSet) -> ForeignSet { + let flavor = Flavor::detect(set.items.iter().flat_map(|i| &i.entries).map(|e| e.keys.as_str())); + let menus: Vec<(Option<&str>, bool)> = set + .areas + .iter() + .map(|a| { + let a = a.trim(); + if a.eq_ignore_ascii_case("tools") { + (Some(a), true) + } else { + (strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')), false) } - } - for (word, md) in WORDS { - let Some(head) = rest.get(..word.len()) else { continue }; - let Some(after) = rest.get(word.len()..) else { continue }; - // The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name. - let mut chars = after.chars(); - if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() { - set(*md); - rest = chars.as_str().trim_start(); - continue 'strip; - } - } - break; - } - if m.ctrl { - return Err("Control-key shortcuts are not supported"); - } - let key = key_named(rest.trim()).ok_or("unknown key")?; - let sc = menus::shortcut_string(m, key); - // Only a string the shortcut dispatcher reads back as these keys is imported. - match menus::parse_shortcut(&sc) { - Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc), - _ => Err("unknown key"), - } + }) + .collect(); + let entries = set + .items + .iter() + .map(|item| { + let (menu, tools) = item.area.and_then(|a| menus.get(a)).copied().unwrap_or((None, false)); + let mut entry = ForeignEntry::new(menu, item.name.split(": ").map(str::trim)).at_line(item.line); + entry.is_tool = tools && strip_suffix_ignore_case(&item.name, " tool").is_some(); + entry.keys = item.entries.iter().map(|e| ForeignKey::read(Some(&e.context), &e.keys, flavor)).collect(); + entry + }) + .collect(); + ForeignSet { name: Some(set.name.clone()), entries } } -fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool { - a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl -} - -/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive. -fn key_named(name: &str) -> Option { - use egui::Key; - if name.is_empty() { - return None; - } - let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_lowercase(); - // The numeric keypad: egui doesn't tell its keys apart from the main ones. - let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed); - let key = match n { - "up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp, - "down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown, - "left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft, - "right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight, - "pageup" | "pgup" | "⇞" => Key::PageUp, - "pagedown" | "pgdn" | "⇟" => Key::PageDown, - "home" | "↖" => Key::Home, - "end" | "↘" => Key::End, - "esc" | "escape" | "⎋" => Key::Escape, - "tab" | "⇥" => Key::Tab, - "space" | "spacebar" => Key::Space, - "enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter, - "backspace" | "⌫" => Key::Backspace, - "delete" | "del" | "forwarddelete" | "⌦" => Key::Delete, - "insert" | "ins" | "help" => Key::Insert, - _ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)), - }; - Some(key) -} - -/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and -/// no Show/Hide in front (InDesign names a toggle by what choosing it does now). -fn norm(s: &str) -> String { - let s = s.replace('…', "").replace("...", "").replace('&', ""); - let s = s.split_whitespace().collect::>().join(" ").to_lowercase(); - ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string) -} - -/// DesignCraft's commands by menu path and label. -#[derive(Default)] -struct Index { - /// `menu\nsubmenu\nlabel` → ids, in the menu bar. - path: HashMap>, - /// The same from the engine commands' own menu placement, which a UI command (the one with - /// the dialog) may stand in for in the menu bar. - engine_path: HashMap>, - /// (menu, label) → ids, at any depth of that menu. - in_menu: HashMap<(String, String), BTreeSet>, - /// label → ids, anywhere. - label: HashMap>, -} - -impl Index { - fn build() -> Self { - fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { - for it in items { - match it { - Item::Sub(name, children) => { - path.push(norm(name)); - walk(ix, children, path); - path.pop(); - } - // A shortcut runs the command without parameters; a fixed-parameter variant can't hold one. - Item::Cmd { label, id, params, .. } if params.is_null() => { - let label = norm(label); - let top = path.first().cloned().unwrap_or_default(); - add_path(&mut ix.path, path, &label, id); - ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone()); - ix.label.entry(label).or_default().insert(id.clone()); - } - _ => {} - } - } - } - let mut ix = Index::default(); - for (menu, entries) in menus::menu_tree() { - walk(&mut ix, &entries, &mut vec![norm(menu)]); - } - for c in designcraft_engine::command_specs() { - let label = norm(c.label); - if !c.menu.is_empty() { - let path: Vec = c.menu.iter().map(|m| norm(m)).collect(); - add_path(&mut ix.engine_path, &path, &label, c.id); - } - ix.label.entry(label).or_default().insert(c.id.to_string()); - } - for (id, label, _, _) in menus::UI_COMMANDS { - ix.label.entry(norm(label)).or_default().insert((*id).to_string()); - } - ix - } - - /// The command an item names, trying the most specific match first. Matches by label alone - /// only count when exactly one command has that label. - fn find(&self, area: &str, name: &str) -> Option { - let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); - let segments: Vec = name.split(": ").map(norm).collect(); - let last = segments.last().cloned().unwrap_or_default(); - let area = norm(area); - if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) { - let mut key = menu.to_string(); - for s in &segments { - key.push('\n'); - key.push_str(s); - } - if let Some(id) = one(self.path.get(&key)) { - return Some(id); - } - if segments.len() > 1 - && let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" ")))) - { - return Some(id); - } - if let Some(id) = one(self.engine_path.get(&key)) { - return Some(id); - } - if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) { - return Some(id); - } - // Menus are arranged differently: the item's own label, if no other command has it. - return one(self.label.get(&last)); - } - if segments.len() == 1 { one(self.label.get(&last)) } else { None } - } -} - -fn add_path(map: &mut HashMap>, path: &[String], label: &str, id: &str) { - let mut key = path.join("\n"); - key.push('\n'); - key.push_str(label); - map.entry(key).or_default().insert(id.to_string()); -} - -/// What the set says about one DesignCraft command, over every item matched to it. -struct Decision { - id: String, - item: String, - /// The first readable shortcut. - shortcut: Option, - /// Every matched item said `[none defined]`. - all_none: bool, -} - -fn command_label(id: &str) -> String { - menus::ui_label(id) - .map(|(l, _)| l.to_string()) - .or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_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)) -} - -/// Imports a set: every matched command takes the set's shortcut (or none), another command -/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. +/// 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 { if text.len() > MAX_BYTES { return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); } - let set = parse(text)?; - let mac = is_mac_set(&set); - let ix = Index::build(); - let mut decisions: Vec = Vec::new(); - let mut unmatched = Vec::new(); - let mut unmatched_without_shortcut = 0usize; - let mut unreadable = Vec::new(); - for item in &set.items { - let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; - let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); - let Some(id) = ix.find(&item.area, &item.name) else { - if item.entries.is_empty() { - unmatched_without_shortcut += 1; - } else { - let tool = norm(&item.area) == "tools" - && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); - let mut row = json!({"item": shown, "keys": all_keys}); - if tool { - row["reason"] = json!("tool keys are fixed"); - } - unmatched.push(row); - } - continue; - }; - let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); - let text_command = id.starts_with("type.") || id.starts_with("text."); - let mut considered = in_context("Default"); - if considered.is_empty() && text_command { - considered = in_context("Text"); - } - let mut read = None; - let mut why = ""; - for e in &considered { - match read_keys(&e.keys, mac) { - Ok(sc) => { - read = Some(sc); - break; - } - Err(w) => why = w, - } - } - if read.is_none() && !considered.is_empty() { - unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); - } - let pos = match decisions.iter().position(|d| d.id == id) { - Some(p) => p, - None => { - decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); - decisions.len() - 1 - } - }; - let Some(d) = decisions.get_mut(pos) else { continue }; - d.all_none &= item.entries.is_empty(); - if d.shortcut.is_none() - && let Some(sc) = read - { - d.shortcut = Some(sc); - d.item = shown; - } - } + 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() + }; + 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 before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); - let mut imported = 0usize; - let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); - let mut skipped = Vec::new(); - let mut decided: BTreeSet = BTreeSet::new(); - // When two items in the set want the same keys, the later one takes them (the rule shared by - // the Craft apps' importers). - let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new(); - for (i, d) in decisions.iter().enumerate() { - if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) { - winner.insert(keys, i); - } - } - for (i, d) in decisions.iter().enumerate() { - match &d.shortcut { - Some(sc) => { - let Some(keys) = keys_of(sc) else { continue }; - if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { - skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); - continue; - } - taken.push((keys, d.id.clone())); - if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { - store(app, &d.id, None); - } else { - store(app, &d.id, Some(sc.as_str())); - } - } - None if d.all_none => { - if menus::default_shortcut(&d.id).is_none() { - store(app, &d.id, None); - } else { - store(app, &d.id, Some("")); - } - } - None => continue, - } - imported += 1; - decided.insert(d.id.clone()); - } - let mut conflicts = Vec::new(); - for (id, sc) in menus::effective_shortcuts(app) { - if decided.contains(&id) { - continue; - } - // A command sharing its default keys with the one taking them (file.new and the New - // Document dialog) keeps them: the dispatcher already prefers the other. - let twin = |by: &str| { - let default = |id: &str| menus::default_shortcut(id).and_then(keys_of); - default(&id).is_some() && default(&id) == default(by) - }; - if let Some(keys) = keys_of(&sc) - && taken.iter().any(|(k, by)| *k == keys && !twin(by)) - { - store(app, &id, Some("")); - conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); - } - } - let changed: Vec = decisions - .iter() - .filter(|d| decided.contains(&d.id)) - .filter_map(|d| { - let from = before.get(&d.id).cloned().flatten(); - let to = menus::shortcut_of(app, &d.id); - (from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to})) - }) - .collect(); - Ok(json!({ - "set": set.name, - "imported": imported, - "changed": changed, - "conflictsCleared": conflicts, - "skipped": skipped, - "unmatched": unmatched, - "unmatchedWithoutShortcut": unmatched_without_shortcut, - "unreadable": unreadable, - })) +/// An enum of the report as its serde name (`noMatch`, `numpad`). +fn name_of(v: &T) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} + +/// The report as `window.importShortcuts` returns it. Commands are command keys (`file.save`, +/// `window.panel {"panel":"layers"}`) under `id`. +fn report_json(r: &ImportReport) -> Value { + json!({ + "set": r.set, + "imported": r.imported, + "changed": Value::Array(r.changed.iter().map(|c| json!({ + "id": c.command.key(), "label": c.label, "item": c.item, "line": c.line, "from": c.from, "to": c.to, + })).collect()), + "conflictsCleared": Value::Array(r.conflicts_cleared.iter().map(|c| json!({ + "id": c.command.key(), "label": c.label, "shortcut": c.shortcut, + })).collect()), + "skipped": Value::Array(r.skipped.iter().map(|s| json!({ + "id": s.command.key(), "item": s.item, "line": s.line, "shortcut": s.shortcut, + "takenBy": s.kept_by.as_ref().map(|c| c.key()), "reason": name_of(&s.reason), + })).collect()), + "failed": Value::Array(r.failed.iter().map(|f| json!({ + "id": f.command.key(), "label": f.label, "change": name_of(&f.change), "shortcuts": f.shortcuts, + })).collect()), + "unmatched": Value::Array(r.unmatched.iter().map(|u| json!({ + "item": u.item, "line": u.line, "keys": u.keys, "reason": name_of(&u.reason), + })).collect()), + "unmatchedWithoutShortcut": r.unmatched_without_shortcut, + "unreadable": Value::Array(r.unreadable.iter().map(|u| json!({ + "item": u.item, "line": u.line, "keys": u.keys, "reason": u.reason.to_string(), + })).collect()), + "approximated": Value::Array(r.approximated.iter().map(|a| json!({ + "item": a.item, "line": a.line, "keys": a.keys, "note": name_of(&a.note), + })).collect()), + "suggestions": Value::Array(r.suggestions.iter().map(|s| json!({ + "item": s.item, "line": s.line, "candidates": s.candidates.iter().map(|c| c.key()).collect::>(), + })).collect()), + "truncated": r.truncated, + }) } /// One line for the Keyboard Shortcuts dialog 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(); + // Rows for keys in contexts DesignCraft lacks belong to items that did match. + let unmatched = report["unmatched"].as_array().map_or(0, |rows| rows.iter().filter(|r| r["reason"] != "context").count()); crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") .replace("{count}", &n("imported")) .replace("{set}", report["set"].as_str().unwrap_or_default()) - .replace("{unmatched}", &len("unmatched")) + .replace("{unmatched}", &unmatched.to_string()) .replace("{unreadable}", &len("unreadable")) } @@ -553,6 +286,7 @@ pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { #[cfg(test)] mod tests { use super::*; + use crate::menus; fn app() -> DesignApp { DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) @@ -571,10 +305,14 @@ mod tests { \tFrobnicate Everything --- Default: Ctrl+F12\r\n\ \tNever Heard Of It --- [none defined]\r\n\ Product area : File Menu\r\n\ - \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ + \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4, Tables: Ctrl+3\r\n\ \tSave --- Default: Ctrl+Hyperspace\r\n\ \tRevert --- Default: Shift+Ctrl+F9\r\n\ \tSave As... --- Default: Shift+Ctrl+F9\r\n\ + Product area : Window Menu\r\n\ + \tColor: Swatches --- Default: Shift+Ctrl+F2\r\n\ + Product area : Tools\r\n\ + \tPen Tool --- Default: Shift+Ctrl+F3\r\n\ garbage line without a separator\r\n"; let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); assert_eq!(r["set"], "My Set"); @@ -587,14 +325,27 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette"), None); assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}"); // Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut. - assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}])); + assert_eq!( + r["unmatched"], + json!([ + {"item": "Edit > Frobnicate Everything", "line": 7, "keys": "Default: Ctrl+F12", "reason": "noMatch"}, + {"item": "File > Close", "line": 10, "keys": "Tables: Ctrl+3", "reason": "context"}, + ]) + ); + // Keys in a context DesignCraft lacks don't make their matched item count as unmatched. + assert!(summary("en", &r).contains("; 1 items not matched"), "{}", summary("en", &r)); assert_eq!(r["unmatchedWithoutShortcut"], 1); - assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); + assert_eq!(r["unreadable"][0]["item"], "File > Save"); assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); // Two items want the same keys: the later one takes them. assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9")); assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}"); - assert_eq!(r["imported"], 4); + // A Window menu panel item and a Tools item apply to the panel and the tool. + assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("swatches")).as_deref(), Some("Cmd+Shift+F2")); + assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::tool("pen")).as_deref(), Some("Cmd+Shift+F3")); + // The numeric keypad key is bound as the main key of the same name, and reported. + assert_eq!(r["approximated"][0]["item"], "File > Close", "{r}"); + assert_eq!(r["imported"], 6, "{r}"); // 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+Enter")); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c38b86d59..daeb1eadd 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, 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`. +- 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`. - 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. From 506c429c652591ba55f37f551575b91bb2b64ce8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:32 +0000 Subject: [PATCH 06/14] keymap: dispatch_filtered, panel twins and import aliases on craft-keymap 66f2202 - shortcuts() declines Clear and native-menu presses through dispatch_filtered; clipboard events stay with the app by default. - keys::conflicts uses Keymap::conflicts_with. - Panel bindings share the default key of the UI command that opens the same panel, so importing InDesign's set leaves both with it. - Import aliases and a Tools path for InDesign feature names DesignCraft has commands for. - Overrides with one key per command save in the map form older versions read. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/keys.rs | 68 ++++++++++++++++++++------- crates/ui-egui/src/menus.rs | 52 ++++++++------------ crates/ui-egui/src/shortcut_import.rs | 10 ++++ 5 files changed, 83 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91e7a6b89..863e963d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=d6c7b45350f7a4352b845c9fdd2e63913d1af27d#d6c7b45350f7a4352b845c9fdd2e63913d1af27d" +source = "git+https://github.com/aiongg/craft-libs?rev=66f22029ba71dad29dcda156f01f680763aa6659#66f22029ba71dad29dcda156f01f680763aa6659" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4767c4ee5..0936e75a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # 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"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "66f22029ba71dad29dcda156f01f680763aa6659", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 8e3c16452..ee85a0400 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -33,6 +33,39 @@ const PANEL_KEYS: &[(&str, &str)] = &[ ("align", "Shift+F7"), ]; +/// Other names shortcut sets use for a command (InDesign's feature names), by command id. +const IMPORT_ALIASES: &[(&str, &str)] = &[ + ("app.quit", "Exit"), + ("app.preferences", "General"), + ("app.insertTableDialog", "Insert Table"), + ("view.togglePresentation", "Presentation"), + ("color.defaultFillStroke", "Apply Default Fill and Stroke Colors"), + ("color.swapFillStroke", "Swap Fill and Stroke Colors"), + ("view.togglePreview", "Toggle View Setting Between Default and Preview"), + ("type.bold", "Apply Bold"), + ("type.italic", "Apply Italic"), + ("window.hidePanels", "Show/Hide All Panels"), + ("window.hidePanelsExceptTools", "Show/Hide All Panels Except Toolbox"), + ("window.nextDocument", "Next Window"), + ("window.previousDocument", "Previous Window"), +]; + +/// Extra menu paths for import, where an alias would leave two commands on one name: InDesign's +/// Tools area has Apply Color, which is also `object.color`'s label. +const IMPORT_PATHS: &[(&str, &[&str])] = &[("color.applyColor", &["Tools", "Apply Color"])]; + +/// Commands that open a panel, by panel id. The panel's binding gets the command's default key +/// too, so the two are twins: a shortcut set that gives either one the key leaves both with it. +const PANEL_TWINS: &[(&str, &str)] = &[ + ("character", "app.characterPanel"), + ("paragraph", "app.paragraphPanel"), + ("glyphs", "app.glyphsPanel"), + ("characterStyles", "app.characterStylesPanel"), + ("paragraphStyles", "app.paragraphStylesPanel"), + ("table", "app.tablePanel"), + ("textWrap", "object.textWrap"), +]; + pub fn panel(id: &str) -> CommandRef { CommandRef::with_params(PANEL_COMMAND, json!({ "panel": id })) } @@ -72,23 +105,23 @@ fn build_catalogue() -> Vec { let rows: HashSet> = paths.values().flatten().map(|p| normalized(p)).collect(); 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 add = |command: CommandRef, label: &str, defaults: &[&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.filter(|p| !rows.contains(&normalized(p)))), } - if let Some(binding) = default.and_then(|d| Binding::parse(d).ok()) { + for binding in defaults.iter().filter_map(|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); + add(CommandRef::new(*id), label, menus::default_shortcut(id).as_slice(), 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); + add(CommandRef::new(c.id), c.label, c.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) { @@ -96,16 +129,26 @@ fn build_catalogue() -> Vec { 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); + let own = PANEL_KEYS.iter().find(|(p, _)| p == id).map(|(_, k)| *k); + let twin = PANEL_TWINS.iter().find(|(p, _)| p == id).and_then(|(_, c)| menus::default_shortcut(c)); + let defaults: Vec<&str> = own.into_iter().chain(twin).collect(); + add(panel(id), label, &defaults, 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); + add(tool(t.id), t.label, t.shortcut.as_slice(), Some(vec!["Tools".into(), t.label.into()]), kind); } } + for b in &mut out { + if !b.command.params.is_null() { + continue; + } + let id = b.command.id.as_str(); + b.aliases.extend(IMPORT_ALIASES.iter().filter(|(c, _)| *c == id).map(|(_, a)| a.to_string())); + b.paths.extend(IMPORT_PATHS.iter().filter(|(c, _)| *c == id).map(|(_, p)| p.iter().map(|s| s.to_string()).collect())); + } out } @@ -197,15 +240,8 @@ pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy /// 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(); + for c in keymap.conflicts_with(cmd) { + let key = c.b.0.key(); if !out.contains(&key) { out.push(key); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb61be02e..74571bf4f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3005,38 +3005,21 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { 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 + let native = &app.native_shortcuts; + let overrides = app.keymap.overrides(); + // Presses the dispatcher leaves alone: Clear (the active tool deletes) and the keys the + // native menu's accelerators already handled. An accelerator holds the command's default + // keys and fires only for them exactly: the Plus key standing in for = never reaches it. + // Copy, Cut and Paste stay too (the dispatcher's default): the canvas and text tools + // handle them, and Paste carries the text. + let accept = |hit: &craft_keymap::egui::Hit| { + let c = &hit.command; + let leave = hit.exact + && hit.cycle.is_empty() + && (c.id == "edit.clear" || (c.params.is_null() && native.contains(&c.id) && overrides.get(&c.id).is_none())); + !leave + }; + app.key_dispatcher.dispatch_filtered(i, &app.keymap, &active, accept) }); for hit in hits { if !hit.cycle.is_empty() { @@ -3513,9 +3496,12 @@ mod tests { 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. + // Saved again it reads back the same, and with one key per command it keeps the map form + // an older version reads. let resaved = serde_json::to_vec(&app.ui).unwrap(); assert_eq!(crate::UiState::from_preferences_json(&resaved).unwrap().shortcuts, app.ui.shortcuts); + let saved: Value = serde_json::from_slice(&resaved).unwrap(); + assert_eq!(saved["shortcuts"], json!({"app.preferences": "Cmd+Alt+Shift+9", "app.palette": "", "file.close": ""})); } #[test] diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 0d62a535a..e413369d6 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -351,6 +351,16 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } + #[test] + fn a_panel_item_shares_its_key_with_the_command_that_opens_the_panel() { + let mut app = app(); + let set = "Shortcut Set : Panels\r\nProduct area : Window Menu\r\n\tType & Tables: Character --- Default: Ctrl+T\r\n"; + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); + assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("character")).as_deref(), Some("Cmd+T")); + assert_eq!(menus::shortcut_of(&app, "app.characterPanel").as_deref(), Some("Cmd+T")); + assert!(!r["conflictsCleared"].as_array().into_iter().flatten().any(|c| c["id"] == "app.characterPanel"), "{r}"); + } + #[test] fn hostile_input_returns_a_report_or_an_error() { let mut app = app(); From 8e20bdfe2d2a5fcf8eb9905d59c2b5fb2939bad4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:40:50 +0000 Subject: [PATCH 07/14] keymap: panel menus, toggles and aliases on craft-keymap 6e136c9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The importer marks Panel Menus items as panel-menu entries. - Show/Hide commands and the other show-and-hide toggles are marked as toggles, so Show and Hide names match only them. - The Preferences alias names its submenu (Preferences: General…). - The import test checks an alias match (Exit). --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/keys.rs | 8 +++++- crates/ui-egui/src/shortcut_import.rs | 37 +++++++++++++++++++-------- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 863e963d9..9743d4872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=66f22029ba71dad29dcda156f01f680763aa6659#66f22029ba71dad29dcda156f01f680763aa6659" +source = "git+https://github.com/aiongg/craft-libs?rev=6e136c9114d4461e791f0599c4976fc0c01e0048#6e136c9114d4461e791f0599c4976fc0c01e0048" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0936e75a1..776f89724 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # 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 = "66f22029ba71dad29dcda156f01f680763aa6659", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "6e136c9114d4461e791f0599c4976fc0c01e0048", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index ee85a0400..8600dbae5 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -36,7 +36,7 @@ const PANEL_KEYS: &[(&str, &str)] = &[ /// Other names shortcut sets use for a command (InDesign's feature names), by command id. const IMPORT_ALIASES: &[(&str, &str)] = &[ ("app.quit", "Exit"), - ("app.preferences", "General"), + ("app.preferences", "Preferences: General…"), ("app.insertTableDialog", "Insert Table"), ("view.togglePresentation", "Presentation"), ("color.defaultFillStroke", "Apply Default Fill and Stroke Colors"), @@ -50,6 +50,11 @@ const IMPORT_ALIASES: &[(&str, &str)] = &[ ("window.previousDocument", "Previous Window"), ]; +/// Commands that show and hide something without "Show/Hide" in their label. A shortcut set may +/// name a toggle by what it does now (`Hide Hidden Characters`); the importer matches such names +/// only to toggles. +const TOGGLES: &[&str] = &["view.hiddenCharacters", "view.tagMarkers"]; + /// Extra menu paths for import, where an alias would leave two commands on one name: InDesign's /// Tools area has Apply Color, which is also `object.color`'s label. const IMPORT_PATHS: &[(&str, &[&str])] = &[("color.applyColor", &["Tools", "Apply Color"])]; @@ -146,6 +151,7 @@ fn build_catalogue() -> Vec { continue; } let id = b.command.id.as_str(); + b.is_toggle = b.label.starts_with("Show/Hide ") || TOGGLES.contains(&id); b.aliases.extend(IMPORT_ALIASES.iter().filter(|(c, _)| *c == id).map(|(_, a)| a.to_string())); b.paths.extend(IMPORT_PATHS.iter().filter(|(c, _)| *c == id).map(|(_, p)| p.iter().map(|s| s.to_string()).collect())); } diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index e413369d6..d5161bf65 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -145,20 +145,30 @@ fn strip_suffix_ignore_case<'a>(s: &'a str, suffix: &str) -> Option<&'a str> { } /// The set as `craft_keymap::import` reads it. An area named ` Menu` is that menu; the -/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of other areas -/// (panel menus, text and tables, views) have no menu, so they match only by a one-segment name. -/// The keys are read with Mac modifier names when any key in the set uses one. +/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of the Panel Menus +/// area name their panel first (`Character: All Caps`) and match a command under that panel. +/// Items of other areas (text and tables, views) have no menu, so they match only by a one-segment +/// name. The keys are read with Mac modifier names when any key in the set uses one. fn foreign(set: &ParsedSet) -> ForeignSet { + #[derive(Clone, Copy, Default)] + struct Area<'a> { + menu: Option<&'a str>, + tools: bool, + panel_menu: bool, + } let flavor = Flavor::detect(set.items.iter().flat_map(|i| &i.entries).map(|e| e.keys.as_str())); - let menus: Vec<(Option<&str>, bool)> = set + let areas: Vec = set .areas .iter() .map(|a| { let a = a.trim(); if a.eq_ignore_ascii_case("tools") { - (Some(a), true) + Area { menu: Some(a), tools: true, panel_menu: false } + } else if a.eq_ignore_ascii_case("panel menus") { + Area { panel_menu: true, ..Area::default() } } else { - (strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')), false) + let menu = strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')); + Area { menu, ..Area::default() } } }) .collect(); @@ -166,9 +176,12 @@ fn foreign(set: &ParsedSet) -> ForeignSet { .items .iter() .map(|item| { - let (menu, tools) = item.area.and_then(|a| menus.get(a)).copied().unwrap_or((None, false)); - let mut entry = ForeignEntry::new(menu, item.name.split(": ").map(str::trim)).at_line(item.line); - entry.is_tool = tools && strip_suffix_ignore_case(&item.name, " tool").is_some(); + let area = item.area.and_then(|a| areas.get(a)).copied().unwrap_or_default(); + let mut entry = ForeignEntry::new(area.menu, item.name.split(": ").map(str::trim)).at_line(item.line); + if area.panel_menu { + entry = entry.in_panel_menu(); + } + entry.is_tool = area.tools && strip_suffix_ignore_case(&item.name, " tool").is_some(); entry.keys = item.entries.iter().map(|e| ForeignKey::read(Some(&e.context), &e.keys, flavor)).collect(); entry }) @@ -313,6 +326,8 @@ mod tests { \tColor: Swatches --- Default: Shift+Ctrl+F2\r\n\ Product area : Tools\r\n\ \tPen Tool --- Default: Shift+Ctrl+F3\r\n\ + Product area : File Menu\r\n\ + \tExit --- Default: Ctrl+Alt+F12\r\n\ garbage line without a separator\r\n"; let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); assert_eq!(r["set"], "My Set"); @@ -343,9 +358,11 @@ mod tests { // A Window menu panel item and a Tools item apply to the panel and the tool. assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("swatches")).as_deref(), Some("Cmd+Shift+F2")); assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::tool("pen")).as_deref(), Some("Cmd+Shift+F3")); + // InDesign's name for a command, as an alias. + assert_eq!(menus::shortcut_of(&app, "app.quit").as_deref(), Some("Cmd+Alt+F12")); // The numeric keypad key is bound as the main key of the same name, and reported. assert_eq!(r["approximated"][0]["item"], "File > Close", "{r}"); - assert_eq!(r["imported"], 6, "{r}"); + assert_eq!(r["imported"], 7, "{r}"); // 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+Enter")); From e50d5983e124ca45a0825b6fb8f6c6747a105454 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:41:50 +0000 Subject: [PATCH 08/14] keymap: parity row lists what the importer still can't apply --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72729afef..6c49aa314 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; 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 | +| 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`, panel and tool items included | ✓ | no Object Styles or Transform panel to give Cmd+F7 or F9; Links (Cmd+Shift+D) keeps DesignCraft's owner of that key | | 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) | From 4f7c7e85bec85b4d7518ae6c3bdbbbe9cb7f717c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:48:25 +0000 Subject: [PATCH 09/14] keymap: craft-keymap 1e4ab21; report lists keyless panel-menu matches --- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 2 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcut_import.rs | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9743d4872..19e31782d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=6e136c9114d4461e791f0599c4976fc0c01e0048#6e136c9114d4461e791f0599c4976fc0c01e0048" +source = "git+https://github.com/aiongg/craft-libs?rev=1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305#1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 776f89724..def2203aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # 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 = "6e136c9114d4461e791f0599c4976fc0c01e0048", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305", 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 0ba1879fc..b303c4e5c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,7 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **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 280 of 1,629 items (was 233) and changes 27 commands. 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. - **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. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 74571bf4f..6cce528bc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -94,7 +94,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "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), truncated}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", + "{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\"}`)", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index d5161bf65..f7b30ecac 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -247,6 +247,9 @@ fn report_json(r: &ImportReport) -> Value { "suggestions": Value::Array(r.suggestions.iter().map(|s| json!({ "item": s.item, "line": s.line, "candidates": s.candidates.iter().map(|c| c.key()).collect::>(), })).collect()), + "matchedWithoutChange": Value::Array(r.matched_without_change.iter().map(|m| json!({ + "id": m.command.key(), "label": m.label, "item": m.item, "line": m.line, "reason": name_of(&m.reason), + })).collect()), "truncated": r.truncated, }) } From a8ca532c4eebca762139ee83df8f16e3abc518a9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:11:14 +0000 Subject: [PATCH 10/14] #1298: browser OS for the web keymap The keymap, shortcut text and recorded key presses follow keys::platform(): the build's platform, and on the web the operating system egui reads from the browser's user agent, so Control stays Control in a browser on a Mac. The keymap is rebuilt on the first frame once the browser's OS is known. --- crates/ui-egui/src/keys.rs | 79 +++++++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 9 ++++- crates/ui-egui/src/menus.rs | 2 +- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 8600dbae5..784ee5dbc 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; +use std::sync::atomic::{AtomicU8, Ordering}; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes}; use serde_json::{Value, json}; @@ -88,9 +89,53 @@ pub fn command_ref(id: &str, params: &Value) -> CommandRef { } } -/// The keymap with the user's overrides. +/// The browser's operating system as [`note_os`] recorded it, by [`platform_code`]; 0 before the +/// first frame. +static WEB_PLATFORM: AtomicU8 = AtomicU8::new(0); + +fn platform_code(p: Platform) -> u8 { + match p { + Platform::Mac => 1, + Platform::Windows => 2, + Platform::Linux => 3, + } +} + +/// The platform shortcuts follow: the build's, and on the web the browser's operating system, so a +/// browser on a Mac reads ⌘ as Cmd and leaves Control as Control. +pub fn platform() -> Platform { + if !cfg!(target_arch = "wasm32") { + return Platform::current(); + } + match WEB_PLATFORM.load(Ordering::Relaxed) { + 1 => Platform::Mac, + 2 => Platform::Windows, + 3 => Platform::Linux, + _ => Platform::current(), + } +} + +/// The platform for an operating system egui reports (on the web, guessed from the browser's user +/// agent). An iPad with a keyboard has ⌘ as on a Mac. +pub fn platform_of(os: egui::os::OperatingSystem) -> Platform { + use egui::os::OperatingSystem as Os; + match os { + Os::Mac | Os::IOS => Platform::Mac, + Os::Windows => Platform::Windows, + Os::Nix | Os::Android | Os::Unknown => Platform::Linux, + } +} + +/// Records the operating system egui reports, which the web build reads from the browser. +pub fn note_os(os: egui::os::OperatingSystem) { + if cfg!(target_arch = "wasm32") { + WEB_PLATFORM.store(platform_code(platform_of(os)), Ordering::Relaxed); + } +} + +/// The keymap with the user's overrides, for [`platform`]. pub fn new_keymap(overrides: Overrides) -> Keymap { - Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), Platform::current()).with_overrides(overrides) + Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform()).with_overrides(overrides) } /// Every bindable command, in dispatch order: where two share keys the earlier one runs. UI @@ -205,7 +250,7 @@ pub fn shortcut_display(keymap: &Keymap, cmd: &CommandRef) -> Option { /// 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()) + Some(Chord::new(Mods::from_egui(&m, platform()), key).to_string()) } /// Copies the keymap's overrides to the saved UI state. @@ -259,3 +304,31 @@ pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { 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::*; + + /// The web build follows the operating system in the browser's user agent. + #[test] + fn browser_user_agent_picks_the_platform() { + use egui::os::OperatingSystem as Os; + let of = |ua: &str| platform_of(Os::from_user_agent(ua)); + let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"; + let ipad = "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1"; + let windows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + let linux = "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"; + let android = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"; + assert_eq!(of(mac), Platform::Mac); + assert_eq!(of(ipad), Platform::Mac); + assert_eq!(of(windows), Platform::Windows); + assert_eq!(of(linux), Platform::Linux); + assert_eq!(of(android), Platform::Linux); + assert_eq!(platform_of(Os::Unknown), Platform::Linux); + // Native builds keep their own platform whatever egui reports. + if !cfg!(target_arch = "wasm32") { + note_os(Os::Mac); + assert_eq!(platform(), Platform::current()); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c25b4c0..744e7ce8c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1127,9 +1127,13 @@ impl DesignApp { } } - /// Applies `ui.shortcuts` to the keymap when they differ, e.g. after preferences were loaded. + /// 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). pub fn sync_keymap(&mut self) { - if *self.keymap.overrides() != self.ui.shortcuts { + if self.keymap.platform() != keys::platform() { + self.keymap = keys::new_keymap(self.ui.shortcuts.clone()); + } else if *self.keymap.overrides() != self.ui.shortcuts { self.keymap.set_overrides(self.ui.shortcuts.clone()); } } @@ -1144,6 +1148,7 @@ impl DesignApp { if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } + keys::note_os(ctx.os()); self.sync_keymap(); self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ab3835f74..190a4cdff 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2179,7 +2179,7 @@ pub fn shortcut_string(m: egui::Modifiers, key: egui::Key) -> String { /// A shortcut for display: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. pub fn shortcut_text(sc: &str) -> String { match craft_keymap::Binding::parse(sc) { - Ok(b) => b.trigger.display(craft_keymap::Platform::current()), + Ok(b) => b.trigger.display(crate::keys::platform()), Err(_) => sc.to_string(), } } From b677ed750033db6b5137fc0151415e78b02c338c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:13:02 +0000 Subject: [PATCH 11/14] #1298: reserve system shortcuts The keymap reserves craft-keymap's system chords for its platform (window switching and closing, the virtual consoles). Binding one is refused with " is reserved by the system"; an imported set's key on one is left out and the import summary counts it. No default shortcut sits on a reserved chord on any platform (test). --- crates/ui-egui/src/keys.rs | 76 +++++++++++++++++++++++++-- crates/ui-egui/src/shortcut_import.rs | 30 +++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 784ee5dbc..394643a50 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use std::sync::atomic::{AtomicU8, Ordering}; -use craft_keymap::{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}; use serde_json::{Value, json}; use crate::DesignApp; @@ -135,7 +135,15 @@ pub fn note_os(os: egui::os::OperatingSystem) { /// The keymap with the user's overrides, for [`platform`]. pub fn new_keymap(overrides: Overrides) -> Keymap { - Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform()).with_overrides(overrides) + keymap_for(platform(), overrides) +} + +/// The keymap for `platform`: the system's own chords are reserved, so neither a user edit nor an +/// imported set can take them. +pub fn keymap_for(platform: Platform, overrides: Overrides) -> Keymap { + Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform) + .with_reserved(craft_keymap::reserved::system(platform)) + .with_overrides(overrides) } /// Every bindable command, in dispatch order: where two share keys the earlier one runs. UI @@ -280,7 +288,7 @@ pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy } 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}"))?; + let done = app.keymap.set_bindings(cmd, vec![binding], policy).map_err(|e| assign_error(&app.keymap, sc, &e))?; taken = done.taken_from.into_iter().map(|h| h.command).collect(); } } @@ -288,6 +296,15 @@ pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy Ok(taken) } +/// Why the keymap refused `sc`. A chord the system takes is named as the menus show it +/// ("⌘Tab is reserved by the system"). +fn assign_error(keymap: &Keymap, sc: &str, e: &AssignError) -> String { + match e { + AssignError::Reserved(t) => format!("{} is reserved by the system", t.display(keymap.platform())), + e => format!("can't assign `{sc}`: {e}"), + } +} + /// Other commands on the keys `cmd` holds, by command key. pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { let mut out: Vec = Vec::new(); @@ -309,6 +326,59 @@ pub fn label(keymap: &Keymap, key: &str) -> String { mod tests { use super::*; + const PLATFORMS: [Platform; 3] = [Platform::Mac, Platform::Windows, Platform::Linux]; + + /// No default shortcut sits on a chord an operating system takes before the app sees it. + #[test] + fn defaults_avoid_reserved_chords() { + for p in PLATFORMS { + let km = keymap_for(p, Overrides::new()); + let reserved: Vec = km + .catalogue() + .iter() + .flat_map(|b| km.default_bindings(&b.command).into_iter().map(move |d| (b.command.key(), d))) + .filter(|(_, d)| km.is_reserved(&d.trigger.chord)) + .map(|(k, d)| format!("{k} {}", d.trigger)) + .collect(); + assert_eq!(reserved, Vec::::new(), "{p:?}: defaults on reserved chords"); + } + } + + /// A chord the operating system takes, per platform. + fn os_chord(p: Platform) -> &'static str { + match p { + Platform::Mac => "Cmd+Tab", + Platform::Windows => "Alt+F4", + Platform::Linux => "Cmd+Alt+F2", + } + } + + /// Binding a chord the system takes is refused with a message naming it, and a saved override + /// on one leaves the command its default. + #[test] + fn a_reserved_chord_is_refused() { + let cmd = CommandRef::new("app.palette"); + for p in PLATFORMS { + let mut km = keymap_for(p, Overrides::new()); + let before = km.bindings(&cmd); + let sc = os_chord(p); + let binding = Binding::parse(sc).unwrap(); + let shown = binding.trigger.display(p); + let err = km.set_bindings(&cmd, vec![binding], Policy::Steal).unwrap_err(); + assert_eq!(assign_error(&km, sc, &err), format!("{shown} is reserved by the system"), "{p:?}"); + assert_eq!(km.bindings(&cmd), before, "{p:?}"); + let mut saved = Overrides::new(); + saved.insert(cmd.key(), vec![sc.to_string()]); + assert_eq!(keymap_for(p, saved).bindings(&cmd), before, "{p:?}: a saved reserved chord"); + } + // Through `window.setShortcut`, as the Keyboard Shortcuts dialog and the control channel set keys. + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let sc = os_chord(platform()); + let r = menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": sc, "steal": true})).unwrap(); + assert!(r.unwrap_err().ends_with("is reserved by the system")); + assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); + } + /// The web build follows the operating system in the browser's user agent. #[test] fn browser_user_agent_picks_the_platform() { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index f7b30ecac..a4c57d3c4 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -260,11 +260,19 @@ pub fn summary(lang: &str, report: &Value) -> String { let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string(); // Rows for keys in contexts DesignCraft lacks belong to items that did match. let unmatched = report["unmatched"].as_array().map_or(0, |rows| rows.iter().filter(|r| r["reason"] != "context").count()); - crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") + let mut out = crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.") .replace("{count}", &n("imported")) .replace("{set}", report["set"].as_str().unwrap_or_default()) .replace("{unmatched}", &unmatched.to_string()) - .replace("{unreadable}", &len("unreadable")) + .replace("{unreadable}", &len("unreadable")); + let reserved = report["skipped"].as_array().map_or(0, |rows| rows.iter().filter(|r| r["reason"] == "reserved").count()); + if reserved > 0 { + out.push(' '); + out.push_str( + &crate::i18n::tr(lang, "{count} shortcuts are reserved by the system and were left out.").replace("{count}", &reserved.to_string()), + ); + } + out } /// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and @@ -330,7 +338,7 @@ mod tests { Product area : Tools\r\n\ \tPen Tool --- Default: Shift+Ctrl+F3\r\n\ Product area : File Menu\r\n\ - \tExit --- Default: Ctrl+Alt+F12\r\n\ + \tExit --- Default: Ctrl+Alt+F13\r\n\ garbage line without a separator\r\n"; let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); assert_eq!(r["set"], "My Set"); @@ -362,7 +370,7 @@ mod tests { assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("swatches")).as_deref(), Some("Cmd+Shift+F2")); assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::tool("pen")).as_deref(), Some("Cmd+Shift+F3")); // InDesign's name for a command, as an alias. - assert_eq!(menus::shortcut_of(&app, "app.quit").as_deref(), Some("Cmd+Alt+F12")); + assert_eq!(menus::shortcut_of(&app, "app.quit").as_deref(), Some("Cmd+Alt+F13")); // The numeric keypad key is bound as the main key of the same name, and reported. assert_eq!(r["approximated"][0]["item"], "File > Close", "{r}"); assert_eq!(r["imported"], 7, "{r}"); @@ -371,6 +379,20 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } + /// A set's key on a chord the system takes is left out and the summary says so. + #[test] + fn a_reserved_chord_in_a_set_is_left_out() { + let mut app = app(); + // Ctrl in a Windows set is ⌘ on a Mac. + let keys = if crate::keys::platform().is_mac() { "Ctrl+Tab" } else { "Alt+F4" }; + let set = format!("Shortcut Set : OS\r\nProduct area : File Menu\r\n\tExit --- Default: {keys}\r\n"); + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({ "text": set })).unwrap().unwrap(); + assert_eq!(r["skipped"][0]["reason"], "reserved", "{r}"); + assert_eq!(r["imported"], 0, "{r}"); + assert_eq!(menus::shortcut_of(&app, "app.quit"), menus::default_shortcut("app.quit").map(str::to_string)); + assert!(summary("en", &r).ends_with("1 shortcuts are reserved by the system and were left out."), "{}", summary("en", &r)); + } + #[test] fn a_panel_item_shares_its_key_with_the_command_that_opens_the_panel() { let mut app = app(); From 691eb35a28e20a6d4c63d60aefe443951466cef3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:30:52 +0000 Subject: [PATCH 12/14] #1298: ui.json keeps the map form older versions read; extra keys in their own field --- crates/ui-egui/src/lib.rs | 5 ++- crates/ui-egui/src/menus.rs | 3 +- crates/ui-egui/src/prefs.rs | 54 +++++++++++++++++++++++++++ crates/ui-egui/src/shortcut_import.rs | 26 +++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 744e7ce8c..957ccd836 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -251,8 +251,9 @@ 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. Older files store a - /// map of command id → shortcut ("" = none), which loads unchanged. + /// Edit › Keyboard Shortcuts: the user's changes to the default shortcuts. Saved as + /// `shortcuts` and `shortcutsMore` (see `prefs::saved_shortcuts`). + #[serde(flatten, with = "prefs::saved_shortcuts")] pub shortcuts: craft_keymap::Overrides, pub control_bar: bool, pub tools_double_column: bool, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 190a4cdff..4e2392a7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3497,8 +3497,7 @@ mod tests { 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 it reads back the same, and with one key per command it keeps the map form - // an older version reads. + // Saved again it reads back the same, in the map form an older version reads. let resaved = serde_json::to_vec(&app.ui).unwrap(); assert_eq!(crate::UiState::from_preferences_json(&resaved).unwrap().shortcuts, app.ui.shortcuts); let saved: Value = serde_json::from_slice(&resaved).unwrap(); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index cb5a0b54c..96fb1288d 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -20,6 +20,60 @@ impl UiState { } } +/// `UiState::shortcuts` in ui.json. `shortcuts` maps each changed command to its first +/// shortcut (`""` for none): the map of one text per command that earlier DesignCraft versions +/// read, and fail on with anything else, which drops the whole file. A command's further +/// shortcuts go in `shortcutsMore` (command → texts), which those versions ignore. Loading +/// appends them to the command's entry in `shortcuts`; extra shortcuts for a command missing +/// there are ignored, because an earlier version that saved the file left that command at its +/// defaults. `shortcuts` also loads in the shared keymap's array form, which the first builds +/// on it wrote. +pub(crate) mod saved_shortcuts { + use std::collections::BTreeMap; + + use craft_keymap::{MAX_BINDINGS, Overrides}; + use serde::ser::SerializeMap; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(overrides: &Overrides, serializer: S) -> Result { + let mut first = BTreeMap::new(); + let mut more = BTreeMap::new(); + for (command, texts) in overrides.iter() { + // A blank text is no shortcut, as `""` is in the map. + let mut texts = texts.iter().map(String::as_str).filter(|t| !t.trim().is_empty()); + first.insert(command, texts.next().unwrap_or_default()); + let rest: Vec<&str> = texts.collect(); + if !rest.is_empty() { + more.insert(command, rest); + } + } + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("shortcuts", &first)?; + if !more.is_empty() { + map.serialize_entry("shortcutsMore", &more)?; + } + map.end() + } + + // `Overrides` loads leniently (never fails), so neither field can make ui.json unreadable. + #[derive(Default, Deserialize)] + #[serde(default, rename_all = "camelCase")] + struct Saved { + shortcuts: Overrides, + shortcuts_more: Overrides, + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let Saved { mut shortcuts, shortcuts_more } = Saved::deserialize(deserializer)?; + for (command, rest) in shortcuts_more.iter() { + let Some(first) = shortcuts.get(command) else { continue }; + let texts: Vec = first.iter().chain(rest).take(MAX_BINDINGS).cloned().collect(); + shortcuts.insert(command, texts); + } + Ok(shortcuts) + } +} + 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/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index a4c57d3c4..015d2bc69 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -379,6 +379,32 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } + /// Two keys for one command still save a ui.json that earlier versions read: they deserialize + /// `shortcuts` as a map of one text per command and drop the whole file otherwise. + #[test] + fn a_command_with_two_imported_shortcuts_saves_a_ui_json_older_versions_read() { + let mut app = app(); + let set = "Shortcut Set : Two\r\nProduct area : File Menu\r\n\tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n"; + menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap(); + let keys = app.ui.shortcuts.get("file.close").unwrap().to_vec(); + assert_eq!(keys.len(), 2, "{keys:?}"); + let saved = serde_json::to_vec_pretty(&app.ui).unwrap(); + let fields: Value = serde_json::from_slice(&saved).unwrap(); + let old: std::collections::BTreeMap = serde_json::from_value(fields["shortcuts"].clone()).unwrap(); + assert_eq!(old.get("file.close"), Some(&keys[0])); + assert_eq!(fields["shortcutsMore"], json!({"file.close": [keys[1]]})); + let loaded = crate::UiState::from_preferences_json(&saved).unwrap(); + assert_eq!(loaded.shortcuts, app.ui.shortcuts); + // The array form an earlier build of the shared keymap wrote. + let draft = json!({"shortcuts": [{"command": "file.close", "keys": keys}, {"command": "app.palette", "keys": []}]}); + let loaded = crate::UiState::from_preferences_json(&serde_json::to_vec(&draft).unwrap()).unwrap(); + assert_eq!(loaded.shortcuts.get("file.close"), Some(keys.as_slice())); + assert_eq!(loaded.shortcuts.get("app.palette"), Some(&[][..])); + // Extra keys for a command an earlier version reset are left out. + let reset = json!({"shortcuts": {}, "shortcutsMore": {"file.close": ["Cmd+F4"]}}); + assert!(crate::UiState::from_preferences_json(&serde_json::to_vec(&reset).unwrap()).unwrap().shortcuts.is_empty()); + } + /// A set's key on a chord the system takes is left out and the summary says so. #[test] fn a_reserved_chord_in_a_set_is_left_out() { From c464072ee705747ef3e80b2545365b1607e4fbda Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 16:07:19 +0000 Subject: [PATCH 13/14] #1298: craft-keymap 9aaf9df (craft-libs#164 review fixes) Keymap::catalog(), Platform's non-exhaustive match, and the web platform from craft_keymap::egui::platform. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/keys.rs | 51 ++++++++++++++++------------------- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menus.rs | 2 +- 6 files changed, 28 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 742a5bbe7..2ceb0c2e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,7 +832,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305#1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305" +source = "git+https://github.com/aiongg/craft-libs?rev=9aaf9dfd3882c12b6aab5604cc2567fc3b64127b#9aaf9dfd3882c12b6aab5604cc2567fc3b64127b" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9cc47de24..eb2ee13c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # 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 = "1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "9aaf9dfd3882c12b6aab5604cc2567fc3b64127b", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ba4759852..7152d6fb8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -296,7 +296,7 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { // UI commands, the panels and the tools. let mut rows: Vec<(String, String, String)> = app .keymap - .catalogue() + .catalog() .iter() .filter(|b| { let ui_command = crate::menus::UI_COMMANDS.iter().any(|c| c.0 == b.command.id); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 394643a50..3c7f42a9e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -1,4 +1,4 @@ -//! Keyboard shortcuts on `craft-keymap`: the catalogue (UI commands, engine commands, panels and +//! 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. @@ -89,8 +89,8 @@ pub fn command_ref(id: &str, params: &Value) -> CommandRef { } } -/// The browser's operating system as [`note_os`] recorded it, by [`platform_code`]; 0 before the -/// first frame. +/// The browser's operating system as [`note_platform`] recorded it, by [`platform_code`]; 0 +/// before the first frame. static WEB_PLATFORM: AtomicU8 = AtomicU8::new(0); fn platform_code(p: Platform) -> u8 { @@ -98,6 +98,7 @@ fn platform_code(p: Platform) -> u8 { Platform::Mac => 1, Platform::Windows => 2, Platform::Linux => 3, + _ => 0, } } @@ -115,21 +116,10 @@ pub fn platform() -> Platform { } } -/// The platform for an operating system egui reports (on the web, guessed from the browser's user -/// agent). An iPad with a keyboard has ⌘ as on a Mac. -pub fn platform_of(os: egui::os::OperatingSystem) -> Platform { - use egui::os::OperatingSystem as Os; - match os { - Os::Mac | Os::IOS => Platform::Mac, - Os::Windows => Platform::Windows, - Os::Nix | Os::Android | Os::Unknown => Platform::Linux, - } -} - -/// Records the operating system egui reports, which the web build reads from the browser. -pub fn note_os(os: egui::os::OperatingSystem) { +/// Records the platform egui reports, which the web build reads from the browser's user agent. +pub fn note_platform(ctx: &egui::Context) { if cfg!(target_arch = "wasm32") { - WEB_PLATFORM.store(platform_code(platform_of(os)), Ordering::Relaxed); + WEB_PLATFORM.store(platform_code(craft_keymap::egui::platform(ctx)), Ordering::Relaxed); } } @@ -141,7 +131,7 @@ pub fn new_keymap(overrides: Overrides) -> Keymap { /// The keymap for `platform`: the system's own chords are reserved, so neither a user edit nor an /// imported set can take them. pub fn keymap_for(platform: Platform, overrides: Overrides) -> Keymap { - Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform) + Keymap::new(catalog().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform) .with_reserved(craft_keymap::reserved::system(platform)) .with_overrides(overrides) } @@ -149,12 +139,12 @@ 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. -pub fn catalogue() -> &'static [Bindable] { - static CATALOGUE: OnceLock> = OnceLock::new(); - CATALOGUE.get_or_init(build_catalogue) +pub fn catalog() -> &'static [Bindable] { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG.get_or_init(build_catalog) } -fn build_catalogue() -> Vec { +fn build_catalog() -> Vec { let paths = menu_paths(); // An engine command's own menu placement counts only where no menu row stands: a row there // runs a UI command in its place (File › Save is the Save dialog when needed), and two @@ -334,7 +324,7 @@ mod tests { for p in PLATFORMS { let km = keymap_for(p, Overrides::new()); let reserved: Vec = km - .catalogue() + .catalog() .iter() .flat_map(|b| km.default_bindings(&b.command).into_iter().map(move |d| (b.command.key(), d))) .filter(|(_, d)| km.is_reserved(&d.trigger.chord)) @@ -349,7 +339,7 @@ mod tests { match p { Platform::Mac => "Cmd+Tab", Platform::Windows => "Alt+F4", - Platform::Linux => "Cmd+Alt+F2", + _ => "Cmd+Alt+F2", } } @@ -379,11 +369,16 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } - /// The web build follows the operating system in the browser's user agent. + /// The web build follows the operating system in the browser's user agent; an iPad counts as a + /// Mac. #[test] fn browser_user_agent_picks_the_platform() { use egui::os::OperatingSystem as Os; - let of = |ua: &str| platform_of(Os::from_user_agent(ua)); + let ctx = egui::Context::default(); + let of = |ua: &str| { + ctx.set_os(Os::from_user_agent(ua)); + craft_keymap::egui::platform(&ctx) + }; let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"; let ipad = "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1"; let windows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; @@ -394,10 +389,10 @@ mod tests { assert_eq!(of(windows), Platform::Windows); assert_eq!(of(linux), Platform::Linux); assert_eq!(of(android), Platform::Linux); - assert_eq!(platform_of(Os::Unknown), Platform::Linux); // Native builds keep their own platform whatever egui reports. if !cfg!(target_arch = "wasm32") { - note_os(Os::Mac); + ctx.set_os(Os::Mac); + note_platform(&ctx); assert_eq!(platform(), Platform::current()); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 957ccd836..a1c6574e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1149,7 +1149,7 @@ impl DesignApp { if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } - keys::note_os(ctx.os()); + keys::note_platform(ctx); self.sync_keymap(); self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4e2392a7b..c680da7c5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2168,7 +2168,7 @@ pub fn tool_shortcut_label(app: &DesignApp, tool: &str) -> Option { /// 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)> { - app.keymap.catalogue().iter().filter_map(|b| crate::keys::shortcut(&app.keymap, &b.command).map(|s| (b.command.key(), s))).collect() + app.keymap.catalog().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. From f314aea7112c07d88102bdbc51b08828cd495a8c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:04:46 +0000 Subject: [PATCH 14/14] #1298: typing chords, Ctrl+Alt and clipboard chords on craft-keymap 9aaf9df Undo, Redo, Select All and Story Editor keep running while typing in a text frame through Text-context copies of their defaults. Tests for Ctrl+Alt+Shift+N and AltGr while typing, and for Cmd+Shift+C as the desktop and the web send it. window.setShortcut leaves a panel's twin out of its conflicts. Removes the unused tool_for_shortcut, and moves effective_shortcuts and shortcut_string out of the app code. --- crates/tools/src/catalog.rs | 5 -- crates/tools/src/lib.rs | 2 +- crates/ui-egui/src/keys.rs | 130 +++++++++++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 26 ++++---- 4 files changed, 142 insertions(+), 21 deletions(-) diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index 054a2ca45..e63debb4d 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -85,8 +85,3 @@ pub const SPLIT_IN_TWO_COLUMNS: &[&str] = &["contentCollector"]; pub fn tool_info(id: &str) -> Option { TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied() } - -/// Tool for a single-key shortcut (`"T"`, `"Shift+P"`). -pub fn tool_for_shortcut(key: &str) -> Option<&'static str> { - TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut == Some(key)).map(|t| t.id) -} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a92f1c132..fd5141516 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -31,7 +31,7 @@ use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2}; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info}; +pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_info}; pub use layout::{CanvasLayout, SpreadSlot}; pub use nav::ZoomTool; diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 3c7f42a9e..2058b7435 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -60,6 +60,11 @@ const TOGGLES: &[&str] = &["view.hiddenCharacters", "view.tagMarkers"]; /// Tools area has Apply Color, which is also `object.color`'s label. const IMPORT_PATHS: &[(&str, &[&str])] = &[("color.applyColor", &["Tools", "Apply Color"])]; +/// Commands on text-editing chords (Cmd+Z, Cmd+A, and Ctrl+Y off macOS) that run while typing on the +/// canvas too: the text tool has no undo or select-all of its own, and Story Editor toggles from the +/// text. Their defaults get a copy in the Text context, which takes the chords from the text field. +const TYPING_COMMANDS: &[&str] = &["edit.undo", "edit.redo", "edit.selectAll", "app.storyEditor"]; + /// Commands that open a panel, by panel id. The panel's binding gets the command's default key /// too, so the two are twins: a shortcut set that gives either one the key leaves both with it. const PANEL_TWINS: &[(&str, &str)] = &[ @@ -195,6 +200,12 @@ fn build_catalog() -> Vec { } let id = b.command.id.as_str(); b.is_toggle = b.label.starts_with("Show/Hide ") || TOGGLES.contains(&id); + if TYPING_COMMANDS.contains(&id) + && let Some(text) = Scope::new(TEXT_CONTEXT) + { + let copies: Vec = b.defaults.iter().filter(|d| d.scope.is_none()).map(|d| Binding::in_scope(d.trigger, text.clone())).collect(); + b.defaults.extend(copies); + } b.aliases.extend(IMPORT_ALIASES.iter().filter(|(c, _)| *c == id).map(|(_, a)| a.to_string())); b.paths.extend(IMPORT_PATHS.iter().filter(|(c, _)| *c == id).map(|(_, p)| p.iter().map(|s| s.to_string()).collect())); } @@ -295,10 +306,24 @@ fn assign_error(keymap: &Keymap, sc: &str, e: &AssignError) -> String { } } -/// Other commands on the keys `cmd` holds, by command key. +/// Whether `a` and `b` are a panel and the command that opens it ([`PANEL_TWINS`]). +pub fn twins(a: &CommandRef, b: &CommandRef) -> bool { + let one_way = |p: &CommandRef, c: &CommandRef| { + p.id == PANEL_COMMAND + && c.params.is_null() + && p.params.get("panel").and_then(Value::as_str).is_some_and(|id| PANEL_TWINS.contains(&(id, c.id.as_str()))) + }; + one_way(a, b) || one_way(b, a) +} + +/// Other commands on the keys `cmd` holds, by command key. A panel's twin is not a conflict: both +/// open the panel. pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { let mut out: Vec = Vec::new(); for c in keymap.conflicts_with(cmd) { + if twins(cmd, &c.b.0) { + continue; + } let key = c.b.0.key(); if !out.contains(&key) { out.push(key); @@ -396,4 +421,107 @@ mod tests { assert_eq!(platform(), Platform::current()); } } + + /// An app typing in a text frame holding "Page ", with the caret at its end, and a function + /// that runs one frame of the whole UI with `events` and `modifiers` held. + fn typing_in_a_text_frame() -> (DesignApp, Value, impl FnMut(&mut DesignApp, egui::Modifiers, Vec)) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Page "})).unwrap()["story"].clone(); + app.select_tool("type"); + app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); + assert!(app.session.wants_text()); + let ctx = egui::Context::default(); + let frame = move |app: &mut DesignApp, modifiers: egui::Modifiers, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events: [egui::Event::ModifiersChanged(modifiers)].into_iter().chain(events).collect(), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + (app, story, frame) + } + + fn story_text(app: &mut DesignApp, story: &Value) -> String { + app.session.execute("story.get", &json!({"story": story})).unwrap()["text"].as_str().unwrap().to_string() + } + + /// A key press as egui sends it, with its release. + fn press(key: egui::Key, modifiers: egui::Modifiers) -> Vec { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into() + } + + /// The command modifier as egui reports it on this platform. + fn cmd() -> egui::Modifiers { + if platform().is_mac() { egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND } else { egui::Modifiers::CTRL | egui::Modifiers::COMMAND } + } + + /// Off macOS, Ctrl+Alt+Shift+N inserts the page number while typing; AltGr+E, which a browser + /// on Windows reports as Ctrl+Alt+E (Fit Content to Frame) followed by the text it typed, + /// types `€`. + #[test] + fn ctrl_alt_chords_run_while_typing_unless_they_type_text() { + if platform().is_mac() { + return; + } + let (mut app, story, mut frame) = typing_in_a_text_frame(); + frame(&mut app, Default::default(), vec![]); + let ctrl_alt = egui::Modifiers::CTRL | egui::Modifiers::COMMAND | egui::Modifiers::ALT; + frame(&mut app, ctrl_alt | egui::Modifiers::SHIFT, press(egui::Key::N, ctrl_alt | egui::Modifiers::SHIFT)); + assert_eq!(story_text(&mut app, &story), "Page \u{e000}"); + let mut altgr = press(egui::Key::E, ctrl_alt); + altgr.insert(1, egui::Event::Text("€".into())); + frame(&mut app, ctrl_alt, altgr); + assert_eq!(story_text(&mut app, &story), "Page \u{e000}€"); + } + + /// While typing, Cmd+Z undoes the document's last edit and Cmd+A selects the story's text: the + /// text tool has neither of its own. + #[test] + fn undo_and_select_all_work_while_typing() { + let (mut app, story, mut frame) = typing_in_a_text_frame(); + frame(&mut app, Default::default(), vec![]); + frame(&mut app, Default::default(), vec![egui::Event::Text("X".into())]); + assert_eq!(story_text(&mut app, &story), "Page X"); + frame(&mut app, cmd(), press(egui::Key::Z, cmd())); + assert_eq!(story_text(&mut app, &story), "Page "); + assert!(app.session.wants_text(), "the caret stays"); + frame(&mut app, cmd(), press(egui::Key::A, cmd())); + frame(&mut app, Default::default(), vec![egui::Event::Text("Y".into())]); + assert_eq!(story_text(&mut app, &story), "Y"); + } + + /// Cmd+Shift+C centers the paragraph while typing, both as egui-winit sends it (a Copy event + /// with the modifiers held) and as the web build does (the press, then Copy); nothing is + /// copied. + #[test] + fn cmd_shift_c_centers_the_paragraph_while_typing() { + let cmd_shift = cmd() | egui::Modifiers::SHIFT; + let mut web = press(egui::Key::C, cmd_shift); + web.insert(1, egui::Event::Copy); + for (form, events) in [("desktop", vec![egui::Event::Copy]), ("web", web)] { + let (mut app, _, mut frame) = typing_in_a_text_frame(); + frame(&mut app, Default::default(), vec![]); + frame(&mut app, cmd_shift, events); + let align = crate::panels::text_attrs(&mut app).unwrap()["para"]["align"].clone(); + assert_eq!(align, "center", "{form}"); + } + } + + /// A panel and the command that opens it share their key without a reported conflict. + #[test] + fn a_panels_twin_is_not_a_conflict() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + for steal in [false, true] { + let r = menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.characterPanel", "shortcut": "Cmd+T", "steal": steal})) + .unwrap() + .unwrap(); + assert_eq!(r["conflicts"], json!([]), "steal: {steal}"); + } + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c680da7c5..43ad71424 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1706,7 +1706,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option t, Err(e) => return Some(Err(e)), }; - let mut conflicts: Vec = taken.iter().map(|c| c.key()).collect(); + let mut conflicts: Vec = taken.iter().filter(|c| !crate::keys::twins(&cmd, c)).map(|c| c.key()).collect(); for key in crate::keys::conflicts(&app.keymap, &cmd) { if !conflicts.contains(&key) { conflicts.push(key); @@ -2164,18 +2164,6 @@ 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)> { - app.keymap.catalog().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 { - crate::keys::chord_text(m, key).unwrap_or_else(|| key.name().to_string()) -} - /// A shortcut for display: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K elsewhere. pub fn shortcut_text(sc: &str) -> String { match craft_keymap::Binding::parse(sc) { @@ -3449,6 +3437,13 @@ mod tests { assert_eq!(r["zone"], 0.0, "a negative zone turns snapping off"); } + /// 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. + fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> { + app.keymap.catalog().iter().filter_map(|b| crate::keys::shortcut(&app.keymap, &b.command).map(|s| (b.command.key(), s))).collect() + } + #[test] fn custom_shortcuts_override_defaults() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -3467,7 +3462,10 @@ mod tests { 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+Enter")); - assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K"); + assert_eq!( + crate::keys::chord_text(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K).as_deref(), + Some("Cmd+Shift+K") + ); if cfg!(target_os = "macos") { assert_eq!(shortcut_text("Cmd+Alt+3"), "⌥⌘3"); assert_eq!(shortcut_text("Cmd+Shift+."), "⇧⌘.");