From d6c7b45350f7a4352b845c9fdd2e63913d1af27d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 03:52:23 +0000 Subject: [PATCH 001/124] keymap: shared keyboard bindings, scopes, overrides and shortcut-set import craft-keymap holds the data and logic for keyboard bindings: egui-free chords with one canonical text form, bindings to commands with params, several bindings per command, scopes, tool groups, conflicts, overrides kept in a craft-settings type, an import pipeline for other applications' shortcut sets, and an optional egui dispatcher. Apps keep their command catalogue, file readers and UI. --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 4 + crates/keymap/Cargo.toml | 23 + crates/keymap/README.md | 183 +++++ crates/keymap/src/chord.rs | 585 ++++++++++++++++ crates/keymap/src/chord/foreign.rs | 195 ++++++ crates/keymap/src/command.rs | 127 ++++ crates/keymap/src/egui.rs | 340 +++++++++ crates/keymap/src/import.rs | 837 +++++++++++++++++++++++ crates/keymap/src/import/namematch.rs | 351 ++++++++++ crates/keymap/src/keymap.rs | 947 ++++++++++++++++++++++++++ crates/keymap/src/keymap/overrides.rs | 337 +++++++++ crates/keymap/src/lib.rs | 146 ++++ crates/keymap/src/reserved.rs | 36 + crates/keymap/src/scope.rs | 205 ++++++ crates/keymap/tests/chord.rs | 178 +++++ crates/keymap/tests/egui.rs | 290 ++++++++ crates/keymap/tests/import.rs | 546 +++++++++++++++ crates/keymap/tests/keymap.rs | 523 ++++++++++++++ crates/keymap/tests/settings.rs | 122 ++++ 21 files changed, 5987 insertions(+) create mode 100644 crates/keymap/Cargo.toml create mode 100644 crates/keymap/README.md create mode 100644 crates/keymap/src/chord.rs create mode 100644 crates/keymap/src/chord/foreign.rs create mode 100644 crates/keymap/src/command.rs create mode 100644 crates/keymap/src/egui.rs create mode 100644 crates/keymap/src/import.rs create mode 100644 crates/keymap/src/import/namematch.rs create mode 100644 crates/keymap/src/keymap.rs create mode 100644 crates/keymap/src/keymap/overrides.rs create mode 100644 crates/keymap/src/lib.rs create mode 100644 crates/keymap/src/reserved.rs create mode 100644 crates/keymap/src/scope.rs create mode 100644 crates/keymap/tests/chord.rs create mode 100644 crates/keymap/tests/egui.rs create mode 100644 crates/keymap/tests/import.rs create mode 100644 crates/keymap/tests/keymap.rs create mode 100644 crates/keymap/tests/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 6471411f..7ae3ad37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,6 +618,17 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "craft-keymap" +version = "0.1.0" +dependencies = [ + "craft-settings", + "egui", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "craft-pdf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 868e568d..c9def5c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } craft-i18n = { path = "crates/i18n" } +craft-keymap = { path = "crates/keymap" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } diff --git a/README.md b/README.md index 78b24340..166c3b9e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "" = [keys] }` also loads; there a single +text counts as a one-item list, and `""` means no shortcut, as in apps that store one text per +command. + +Texts the keymap cannot use (a key name from a newer version, an unknown scope, a reserved chord) +stay in the entry when the command is edited, unless the edit leaves it without bindings. +`Keymap::problems` lists them. + +```rust +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +use craft_settings::{Location, Settings, SettingsStore}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq)] +#[serde(default)] +struct Prefs { + shortcuts: Overrides, +} + +impl Settings for Prefs { + const APP_ID: &'static str = "acmecraft"; + const SCHEMA_VERSION: u32 = 1; +} + +// `catalogue` is the app's Vec. +let (mut store, _report) = SettingsStore::::open(Location::Auto); +let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); +// Entries for unknown commands and texts that do not parse are kept and listed here. +let _problems = keymap.problems(); + +// After each change in the shortcut editor: +keymap.assign(&"file.save".into(), Binding::parse("Cmd+Alt+S").unwrap(), Policy::Steal).ok(); +store.update(|p| p.shortcuts = keymap.overrides().clone()); +``` + +## Importing a shortcut set + +The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an +[`import::ForeignSet`](src/import.rs): entries with a menu, a path and keys read by +`ForeignKey::read`. `import::plan` matches each entry to one command and changes nothing; +`ImportPlan::apply` applies the plan and returns an `ImportReport` (serde, camelCase). Report +rows carry the entry's `item` (its path) and `line`. + +```rust +use craft_keymap::{Bindable, Binding, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Platform}; +use craft_keymap::{Scope, Scopes, plan}; + +let save = Bindable::new("file.save", "Save", Scope::app()) + .with_path(vec!["File".into(), "Save".into()]) + .with_default(Binding::parse("Cmd+S").unwrap()); +let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows); + +let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); +let set = ForeignSet { + name: Some("My Set".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), + ], +}; +let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; +let planned = plan(&set, &keymap, &options); +// A review UI can show planned.commands and planned.report here. +let report = planned.apply(&mut keymap); +assert_eq!(report.changed[0].to, ["Cmd+Alt+S"]); +assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File > Frobnicate", Some(13))); +``` + +- Matching, most specific first, each step needing exactly one command: the user's remembered + answers, the full menu path, the joined submenu segments as a label in that menu, the label in + that menu, the label or an alias anywhere. Labels are compared after `import::normalize_label`. + Tool entries match only tools. +- `ImportOptions.contexts` says what each source context becomes (home scope, another scope, the + home scope only without a home entry, or skipped). +- The set wins: a chord it gives to a command is taken from commands outside it (except twins + sharing it as a default), a later entry takes a chord from an earlier one (as when the entries + are applied in file order; the earlier key is listed in `skipped`), and an entry without keys + can clear its command. +- Keys read approximately (a numeric keypad key bound as the main key) are listed in + `approximated`. +- With `ImportOptions.suggest`, unmatched entries get fuzzy candidates (`import::namematch`); + they are reported, never applied. Only the first 200 unmatched entries get them, and only the + first 200 characters of a name are compared. + +## Features + +- `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the + shifted key egui reports for a Shift binding (`Shift+;` matches `:`); and `egui::Dispatcher`, + which runs a frame's key presses against the keymap: + + ```rust,ignore + let hits = ctx.input_mut(|i| dispatcher.dispatch(i, &keymap, &active)); + for hit in hits { + app.run(&hit.command); + } + ``` + + A press that runs a command is removed from egui's events, with the text it typed, so widgets + do not see it too. egui's Copy, Cut and Paste events count as Cmd+C, Cmd+X and Cmd+V outside + text fields. While a text field has the keyboard, clipboard events, clipboard key presses, + presses followed by text, and off macOS Ctrl+Alt with a letter, digit, punctuation or Space + stay with it: AltGr arrives as Ctrl+Alt on Windows and some Linux setups, and egui-winit sends + no text while Ctrl is held. + `Dispatcher::new().with_repeat(true)` also runs commands on key repeats (held arrows). diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs new file mode 100644 index 00000000..740ccf21 --- /dev/null +++ b/crates/keymap/src/chord.rs @@ -0,0 +1,585 @@ +//! Keys, modifiers, chords and their text forms. +//! +//! Canonical text is `Cmd+Ctrl+Alt+Shift+Key`, written by [`Chord`]'s `Display` and read back by +//! [`Chord::parse`]. A double press is the chord twice, separated by a space: `A A`. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +pub mod foreign; + +/// The longest chord or trigger text [`Chord::parse`] and [`Trigger::parse`] accept, in bytes. +pub const MAX_CHORD_TEXT: usize = 64; + +/// The platform a keymap is used on. It decides display text and whether `ctrl` folds into `cmd`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Mac, + Windows, + Linux, +} + +impl Platform { + /// The platform this build runs on. Web builds count as Linux. + pub const fn current() -> Platform { + if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(target_os = "windows") { + Platform::Windows + } else { + Platform::Linux + } + } + + pub const fn is_mac(self) -> bool { + matches!(self, Platform::Mac) + } +} + +macro_rules! keys { + ($($variant:ident => $name:literal),* $(,)?) => { + /// A key on the keyboard, independent of any UI toolkit. + /// + /// Digits are the main row; numeric keypad keys map to the same keys. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub enum Key { + $($variant),* + } + + impl Key { + /// Every key, in declaration order. + pub const ALL: &'static [Key] = &[$(Key::$variant),*]; + + /// The canonical name: `K`, `5`, `F5`, `PageUp`, `;`. + pub const fn name(self) -> &'static str { + match self { + $(Key::$variant => $name),* + } + } + + // The Rust variant name, which is also egui's `Key` variant name except for arrows. + const fn variant_name(self) -> &'static str { + match self { + $(Key::$variant => stringify!($variant)),* + } + } + } + }; +} + +keys! { + A => "A", B => "B", C => "C", D => "D", E => "E", F => "F", G => "G", H => "H", I => "I", + J => "J", K => "K", L => "L", M => "M", N => "N", O => "O", P => "P", Q => "Q", R => "R", + S => "S", T => "T", U => "U", V => "V", W => "W", X => "X", Y => "Y", Z => "Z", + Num0 => "0", Num1 => "1", Num2 => "2", Num3 => "3", Num4 => "4", + Num5 => "5", Num6 => "6", Num7 => "7", Num8 => "8", Num9 => "9", + F1 => "F1", F2 => "F2", F3 => "F3", F4 => "F4", F5 => "F5", F6 => "F6", F7 => "F7", + F8 => "F8", F9 => "F9", F10 => "F10", F11 => "F11", F12 => "F12", F13 => "F13", + F14 => "F14", F15 => "F15", F16 => "F16", F17 => "F17", F18 => "F18", F19 => "F19", + F20 => "F20", F21 => "F21", F22 => "F22", F23 => "F23", F24 => "F24", F25 => "F25", + F26 => "F26", F27 => "F27", F28 => "F28", F29 => "F29", F30 => "F30", F31 => "F31", + F32 => "F32", F33 => "F33", F34 => "F34", F35 => "F35", + Escape => "Escape", Tab => "Tab", Backspace => "Backspace", Enter => "Enter", + Space => "Space", Insert => "Insert", Delete => "Delete", Home => "Home", End => "End", + PageUp => "PageUp", PageDown => "PageDown", + Up => "Up", Down => "Down", Left => "Left", Right => "Right", + Minus => "-", Equals => "=", Plus => "+", OpenBracket => "[", CloseBracket => "]", + Backslash => "\\", Semicolon => ";", Quote => "'", Backtick => "`", + Comma => ",", Period => ".", Slash => "/", + Colon => ":", Pipe => "|", Questionmark => "?", Exclamationmark => "!", + OpenCurlyBracket => "{", CloseCurlyBracket => "}", +} + +/// Key aliases beyond the canonical and egui variant names. Matched without regard to ASCII case. +const KEY_ALIASES: &[(&str, Key)] = &[ + ("esc", Key::Escape), + ("⎋", Key::Escape), + ("return", Key::Enter), + ("⏎", Key::Enter), + ("↩", Key::Enter), + ("del", Key::Delete), + ("⌦", Key::Delete), + ("⌫", Key::Backspace), + ("⇥", Key::Tab), + ("pgup", Key::PageUp), + ("pgdn", Key::PageDown), + ("page up", Key::PageUp), + ("page down", Key::PageDown), + ("arrowup", Key::Up), + ("arrowdown", Key::Down), + ("arrowleft", Key::Left), + ("arrowright", Key::Right), + ("↑", Key::Up), + ("↓", Key::Down), + ("←", Key::Left), + ("→", Key::Right), + ("spacebar", Key::Space), + ("ins", Key::Insert), + ("equal", Key::Equals), + ("grave", Key::Backtick), + ("backquote", Key::Backtick), + ("apostrophe", Key::Quote), +]; + +impl Key { + /// Reads a key name: canonical (`K`, `PageUp`, `;`), egui variant names (`ArrowUp`, `Num5`, + /// `Semicolon`) and the aliases `esc`, `return`, `del`, `pgup`, `pgdn`, `spacebar`, `ins`, + /// and the Mac key glyphs. Case does not matter. + pub fn parse(name: &str) -> Option { + Key::ALL + .iter() + .copied() + .find(|k| k.name().eq_ignore_ascii_case(name) || k.variant_name().eq_ignore_ascii_case(name)) + .or_else(|| KEY_ALIASES.iter().find(|(alias, _)| alias.eq_ignore_ascii_case(name)).map(|&(_, k)| k)) + } + + /// `F1` to `F35`. + pub const fn is_function_key(self) -> bool { + matches!( + self, + Key::F1 + | Key::F2 + | Key::F3 + | Key::F4 + | Key::F5 + | Key::F6 + | Key::F7 + | Key::F8 + | Key::F9 + | Key::F10 + | Key::F11 + | Key::F12 + | Key::F13 + | Key::F14 + | Key::F15 + | Key::F16 + | Key::F17 + | Key::F18 + | Key::F19 + | Key::F20 + | Key::F21 + | Key::F22 + | Key::F23 + | Key::F24 + | Key::F25 + | Key::F26 + | Key::F27 + | Key::F28 + | Key::F29 + | Key::F30 + | Key::F31 + | Key::F32 + | Key::F33 + | Key::F34 + | Key::F35 + ) + } + + /// The key as macOS menus show it: arrows and editing keys as glyphs. + fn mac_symbol(self) -> &'static str { + match self { + Key::Up => "↑", + Key::Down => "↓", + Key::Left => "←", + Key::Right => "→", + Key::Enter => "⏎", + Key::Backspace => "⌫", + Key::Delete => "⌦", + Key::Escape => "⎋", + Key::Tab => "⇥", + k => k.text_name(), + } + } + + /// The key as Windows and Linux show it. + fn text_name(self) -> &'static str { + match self { + Key::PageUp => "Page Up", + Key::PageDown => "Page Down", + k => k.name(), + } + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl Serialize for Key { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + +impl<'de> Deserialize<'de> for Key { + fn deserialize>(deserializer: D) -> Result { + let text = >::deserialize(deserializer)?; + if text.len() > MAX_CHORD_TEXT { + return Err(serde::de::Error::custom(ParseError::TooLong)); + } + Key::parse(&text).ok_or_else(|| serde::de::Error::custom(ParseError::UnknownKey(text.into_owned()))) + } +} + +/// Modifier keys held with a chord's key. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Mods { + /// The command modifier: ⌘ on macOS, Ctrl on Windows and Linux. + pub cmd: bool, + /// The Control key on macOS. Off macOS it folds into `cmd`. + pub ctrl: bool, + /// Alt, Option on macOS. + pub alt: bool, + pub shift: bool, +} + +impl Mods { + pub const NONE: Mods = Mods { cmd: false, ctrl: false, alt: false, shift: false }; + pub const CMD: Mods = Mods { cmd: true, ctrl: false, alt: false, shift: false }; + pub const CTRL: Mods = Mods { cmd: false, ctrl: true, alt: false, shift: false }; + pub const ALT: Mods = Mods { cmd: false, ctrl: false, alt: true, shift: false }; + pub const SHIFT: Mods = Mods { cmd: false, ctrl: false, alt: false, shift: true }; + + pub const fn is_none(self) -> bool { + !(self.cmd || self.ctrl || self.alt || self.shift) + } + + /// The modifiers as `platform` sees them: off macOS `ctrl` becomes `cmd`. + pub const fn effective(self, platform: Platform) -> Mods { + if platform.is_mac() || !self.ctrl { self } else { Mods { cmd: true, ctrl: false, ..self } } + } +} + +/// A key with modifiers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Chord { + pub mods: Mods, + pub key: Key, +} + +/// Why chord or trigger text was refused. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParseError { + Empty, + /// Longer than [`MAX_CHORD_TEXT`]. + TooLong, + /// Modifiers with no key, or a trailing `+`. + MissingKey, + UnknownKey(String), + UnknownModifier(String), + DuplicateModifier(String), + /// Two different chords: key sequences are not supported. + Sequence, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParseError::Empty => f.write_str("empty shortcut"), + ParseError::TooLong => write!(f, "shortcut longer than {MAX_CHORD_TEXT} bytes"), + ParseError::MissingKey => f.write_str("shortcut has no key"), + ParseError::UnknownKey(k) => write!(f, "unknown key `{k}`"), + ParseError::UnknownModifier(m) => write!(f, "unknown modifier `{m}`"), + ParseError::DuplicateModifier(m) => write!(f, "modifier `{m}` given twice"), + ParseError::Sequence => f.write_str("key sequences are not supported"), + } + } +} + +impl std::error::Error for ParseError {} + +#[derive(Clone, Copy)] +enum Modifier { + Cmd, + Ctrl, + Alt, + Shift, +} + +fn modifier_named(name: &str) -> Option { + const NAMES: &[(&str, Modifier)] = &[ + ("cmd", Modifier::Cmd), + ("command", Modifier::Cmd), + ("meta", Modifier::Cmd), + ("super", Modifier::Cmd), + ("mod", Modifier::Cmd), + ("⌘", Modifier::Cmd), + ("ctrl", Modifier::Ctrl), + ("control", Modifier::Ctrl), + ("ctl", Modifier::Ctrl), + ("⌃", Modifier::Ctrl), + ("alt", Modifier::Alt), + ("opt", Modifier::Alt), + ("option", Modifier::Alt), + ("⌥", Modifier::Alt), + ("shift", Modifier::Shift), + ("⇧", Modifier::Shift), + ]; + NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|&(_, m)| m) +} + +/// A modifier glyph at the start of `text`, and the text after it. +fn strip_glyph(text: &str) -> Option<(Modifier, &str)> { + let mut chars = text.chars(); + let m = match chars.next()? { + '⌘' => Modifier::Cmd, + '⌃' => Modifier::Ctrl, + '⌥' => Modifier::Alt, + '⇧' => Modifier::Shift, + _ => return None, + }; + Some((m, chars.as_str())) +} + +fn add_modifier(mods: &mut Mods, m: Modifier, written: &str) -> Result<(), ParseError> { + let slot = match m { + Modifier::Cmd => &mut mods.cmd, + Modifier::Ctrl => &mut mods.ctrl, + Modifier::Alt => &mut mods.alt, + Modifier::Shift => &mut mods.shift, + }; + if *slot { + return Err(ParseError::DuplicateModifier(written.to_owned())); + } + *slot = true; + Ok(()) +} + +impl Chord { + pub const fn new(mods: Mods, key: Key) -> Chord { + Chord { mods, key } + } + + /// The key with no modifiers. + pub const fn plain(key: Key) -> Chord { + Chord { mods: Mods::NONE, key } + } + + /// Reads canonical text (`Cmd+Shift+Z`) and the common aliases: modifier words in any order + /// and case (`cmd command meta super mod`, `ctrl control ctl`, `alt opt option`, `shift`), + /// Mac glyphs, glued (`⇧⌘Z`) or with `+`, and the key names [`Key::parse`] reads. `Cmd++` is + /// Cmd with the Plus key. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_CHORD_TEXT { + return Err(ParseError::TooLong); + } + let mut rest = text.trim(); + if rest.is_empty() { + return Err(ParseError::Empty); + } + let mut mods = Mods::NONE; + // Each pass consumes at least one byte, so the loop ends within MAX_CHORD_TEXT passes. + loop { + if let Some((m, after)) = strip_glyph(rest) { + add_modifier(&mut mods, m, rest.get(..rest.len() - after.len()).unwrap_or_default())?; + // `⌘+K` and `⌘K` are the same; in `⌘+` the `+` is the key. + rest = match after.strip_prefix('+') { + Some(r) if !r.is_empty() => r, + _ => after, + }; + if rest.trim().is_empty() { + return Err(ParseError::MissingKey); + } + continue; + } + // A `+` in first position is the Plus key, never a separator. + let first = rest.chars().next().map_or(0, char::len_utf8); + let separator = rest.get(first..).and_then(|r| r.find('+')).map(|i| i + first); + match separator { + Some(i) => { + let token = rest.get(..i).unwrap_or_default().trim(); + let after = rest.get(i + 1..).unwrap_or_default(); + if after.trim().is_empty() { + return Err(ParseError::MissingKey); + } + let m = modifier_named(token).ok_or_else(|| ParseError::UnknownModifier(token.to_owned()))?; + add_modifier(&mut mods, m, token)?; + rest = after; + } + None => { + let token = rest.trim(); + if modifier_named(token).is_some() { + return Err(ParseError::MissingKey); + } + let key = Key::parse(token).ok_or_else(|| ParseError::UnknownKey(token.to_owned()))?; + return Ok(Chord { mods, key }); + } + } + } + } + + /// Canonical text, the same as `to_string()`: `Cmd+Ctrl+Alt+Shift+Key`. + pub fn portable(&self) -> String { + self.to_string() + } + + /// The chord as `platform` sees it: off macOS `ctrl` folds into `cmd`. Lookups and conflict + /// checks compare effective chords. + pub const fn effective(self, platform: Platform) -> Chord { + Chord { mods: self.mods.effective(platform), key: self.key } + } + + /// Text for menus and settings: `⌃⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere. + pub fn display(&self, platform: Platform) -> String { + let m = self.mods.effective(platform); + let mut s = String::new(); + if platform.is_mac() { + for (on, glyph) in [(m.ctrl, "⌃"), (m.alt, "⌥"), (m.shift, "⇧"), (m.cmd, "⌘")] { + if on { + s.push_str(glyph); + } + } + s.push_str(self.key.mac_symbol()); + } else { + for (on, name) in [(m.cmd, "Ctrl+"), (m.alt, "Alt+"), (m.shift, "Shift+")] { + if on { + s.push_str(name); + } + } + s.push_str(self.key.text_name()); + } + s + } + + pub const fn is_function_key(&self) -> bool { + self.key.is_function_key() + } + + /// No cmd, ctrl or alt. Shift alone still counts as plain: such chords are tool keys that + /// typing in a text field would trigger. + pub const fn is_plain(&self) -> bool { + !(self.mods.cmd || self.mods.ctrl || self.mods.alt) + } + + /// Holds the command modifier: cmd, or ctrl (Control on macOS, folded into cmd elsewhere). + pub const fn has_command(&self) -> bool { + self.mods.cmd || self.mods.ctrl + } +} + +impl From for Chord { + fn from(key: Key) -> Chord { + Chord::plain(key) + } +} + +impl fmt::Display for Chord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (on, name) in [(self.mods.cmd, "Cmd+"), (self.mods.ctrl, "Ctrl+"), (self.mods.alt, "Alt+"), (self.mods.shift, "Shift+")] { + if on { + f.write_str(name)?; + } + } + f.write_str(self.key.name()) + } +} + +impl std::str::FromStr for Chord { + type Err = ParseError; + fn from_str(s: &str) -> Result { + Chord::parse(s) + } +} + +impl Serialize for Chord { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Chord { + fn deserialize>(deserializer: D) -> Result { + let text = >::deserialize(deserializer)?; + Chord::parse(&text).map_err(serde::de::Error::custom) + } +} + +/// What starts a command: a chord, pressed once or twice in a row. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Trigger { + pub chord: Chord, + /// The chord pressed twice. + pub double: bool, +} + +impl Trigger { + pub const fn single(chord: Chord) -> Trigger { + Trigger { chord, double: false } + } + + pub const fn double(chord: Chord) -> Trigger { + Trigger { chord, double: true } + } + + /// Reads a chord as [`Chord::parse`] does, or the same chord twice separated by whitespace + /// (`A A`) as a double press. Two different chords are [`ParseError::Sequence`]. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_CHORD_TEXT { + return Err(ParseError::TooLong); + } + let single = match Chord::parse(text) { + Ok(chord) => return Ok(Trigger::single(chord)), + Err(e) => e, + }; + let mut parts = text.split_whitespace(); + match (parts.next(), parts.next(), parts.next()) { + (Some(first), Some(second), None) => { + let (first, second) = (Chord::parse(first)?, Chord::parse(second)?); + if first == second { Ok(Trigger::double(first)) } else { Err(ParseError::Sequence) } + } + _ => Err(single), + } + } + + /// Canonical text, the same as `to_string()`. + pub fn portable(&self) -> String { + self.to_string() + } + + pub const fn effective(self, platform: Platform) -> Trigger { + Trigger { chord: self.chord.effective(platform), double: self.double } + } + + /// [`Chord::display`], twice with a space between for a double press. + pub fn display(&self, platform: Platform) -> String { + let one = self.chord.display(platform); + if self.double { format!("{one} {one}") } else { one } + } +} + +impl From for Trigger { + fn from(chord: Chord) -> Trigger { + Trigger::single(chord) + } +} + +impl fmt::Display for Trigger { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.double { write!(f, "{0} {0}", self.chord) } else { write!(f, "{}", self.chord) } + } +} + +impl std::str::FromStr for Trigger { + type Err = ParseError; + fn from_str(s: &str) -> Result { + Trigger::parse(s) + } +} + +impl Serialize for Trigger { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Trigger { + fn deserialize>(deserializer: D) -> Result { + let text = >::deserialize(deserializer)?; + Trigger::parse(&text).map_err(serde::de::Error::custom) + } +} diff --git a/crates/keymap/src/chord/foreign.rs b/crates/keymap/src/chord/foreign.rs new file mode 100644 index 00000000..b7e58a35 --- /dev/null +++ b/crates/keymap/src/chord/foreign.rs @@ -0,0 +1,195 @@ +//! Key text as other applications write it in their shortcut files and menus. +//! +//! Windows sets name the command modifier `Ctrl`; Mac sets name it `Cmd` and use `Ctrl` for the +//! Control key. [`read_foreign`] reads either into a [`Trigger`], tolerating separators +//! (`+`, `-`, space), spelled-out key names (`Page Down`, `Up Arrow`) and numeric keypad keys. + +use serde::{Deserialize, Serialize}; + +use super::{Chord, Key, MAX_CHORD_TEXT, Mods, Trigger}; + +/// Which modifier naming a shortcut set uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Flavor { + /// `Ctrl` is the command modifier. + Windows, + /// `Cmd` is the command modifier and `Ctrl` the Control key. + Mac, +} + +impl Flavor { + /// Mac when any text names cmd, command, opt or option, or uses ⌘ or ⌥; Windows otherwise. + pub fn detect(texts: I) -> Flavor + where + I: IntoIterator, + S: AsRef, + { + let mac = texts.into_iter().any(|t| { + let t = t.as_ref(); + t.contains(['⌘', '⌥']) || { + let lower = t.to_ascii_lowercase(); + lower.contains("cmd") || lower.contains("command") || lower.contains("opt") + } + }); + if mac { Flavor::Mac } else { Flavor::Windows } + } +} + +/// Something [`read_foreign`] changed while reading that an import report may mention. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum ReadNote { + /// A numeric keypad key was read as the main key of the same name. + Numpad, +} + +/// Why foreign key text could not be read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ForeignError { + Empty, + /// Longer than [`MAX_CHORD_TEXT`]. + TooLong, + UnknownKey(String), + /// A modifier with no counterpart here, such as the Windows key. + UnsupportedModifier(String), +} + +impl std::fmt::Display for ForeignError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ForeignError::Empty => f.write_str("empty shortcut"), + ForeignError::TooLong => write!(f, "shortcut longer than {MAX_CHORD_TEXT} bytes"), + ForeignError::UnknownKey(k) => write!(f, "unknown key `{k}`"), + ForeignError::UnsupportedModifier(m) => write!(f, "unsupported modifier `{m}`"), + } + } +} + +impl std::error::Error for ForeignError {} + +#[derive(Clone, Copy)] +enum Word { + Command, + Control, + Alt, + Shift, + Unsupported, +} + +const GLYPHS: &[(char, Word)] = &[('⌘', Word::Command), ('⌃', Word::Control), ('⌥', Word::Alt), ('⇧', Word::Shift)]; + +// Longer words first where one is a prefix of another. +const WORDS: &[(&str, Word)] = &[ + ("command", Word::Command), + ("cmd", Word::Command), + ("control", Word::Control), + ("ctrl", Word::Control), + ("option", Word::Alt), + ("opt", Word::Alt), + ("alt", Word::Alt), + ("shift", Word::Shift), + ("windows", Word::Unsupported), + ("win", Word::Unsupported), + ("meta", Word::Unsupported), + ("super", Word::Unsupported), +]; + +/// Key names some applications use that [`Key::parse`] does not read. Compared after removing +/// whitespace and lower-casing. +const FOREIGN_KEYS: &[(&str, Key)] = &[ + ("uparrow", Key::Up), + ("downarrow", Key::Down), + ("leftarrow", Key::Left), + ("rightarrow", Key::Right), + ("pageup", Key::PageUp), + ("pagedown", Key::PageDown), + ("⇞", Key::PageUp), + ("⇟", Key::PageDown), + ("↖", Key::Home), + ("↘", Key::End), + ("⌤", Key::Enter), + ("forwarddelete", Key::Delete), + ("help", Key::Insert), +]; + +/// Reads one shortcut as another application writes it: `Shift+Ctrl+S`, `Ctrl+Num 5`, +/// `Page Down`, `⇧⌘S`, `Opt-Left`. `flavor` decides what `Ctrl` means. The notes say what was +/// approximated. +pub fn read_foreign(text: &str, flavor: Flavor) -> Result<(Trigger, Vec), ForeignError> { + if text.len() > MAX_CHORD_TEXT { + return Err(ForeignError::TooLong); + } + let mut rest = text.trim(); + if rest.is_empty() { + return Err(ForeignError::Empty); + } + let mut mods = Mods::NONE; + // Each pass consumes at least one byte. + 'strip: loop { + for &(glyph, word) in GLYPHS { + if let Some(after) = rest.strip_prefix(glyph) { + apply(&mut mods, word, flavor, rest.get(..glyph.len_utf8()).unwrap_or_default())?; + let after = after.trim_start(); + rest = match after.strip_prefix('+') { + Some(r) if !r.trim().is_empty() => r.trim_start(), + _ => after, + }; + continue 'strip; + } + } + for &(name, word) in WORDS { + let (Some(head), Some(after)) = (rest.get(..name.len()), rest.get(name.len()..)) else { continue }; + // A modifier word needs a separator and a key after it: `Alt` alone is a key name. + let mut chars = after.chars(); + if head.eq_ignore_ascii_case(name) && matches!(chars.next(), Some('+' | '-' | ' ')) && !chars.as_str().trim().is_empty() { + apply(&mut mods, word, flavor, head)?; + rest = after_separator(chars.as_str()); + continue 'strip; + } + } + break; + } + let (key, numpad) = foreign_key(rest).ok_or_else(|| ForeignError::UnknownKey(rest.trim().to_owned()))?; + let notes = if numpad { vec![ReadNote::Numpad] } else { Vec::new() }; + Ok((Trigger::single(Chord::new(mods, key)), notes)) +} + +/// The text after a modifier word and its first separator; `Ctrl + S` reads like `Ctrl+S`. +fn after_separator(after: &str) -> &str { + let after = after.trim_start(); + match after.strip_prefix(['+', '-']) { + Some(r) if !r.trim().is_empty() => r.trim_start(), + _ => after, + } +} + +fn apply(mods: &mut Mods, word: Word, flavor: Flavor, written: &str) -> Result<(), ForeignError> { + match word { + Word::Command => mods.cmd = true, + Word::Control if flavor == Flavor::Mac => mods.ctrl = true, + Word::Control => mods.cmd = true, + Word::Alt => mods.alt = true, + Word::Shift => mods.shift = true, + Word::Unsupported => return Err(ForeignError::UnsupportedModifier(written.to_owned())), + } + Ok(()) +} + +/// The key a foreign key name stands for, and whether it was on the numeric keypad. +fn foreign_key(name: &str) -> Option<(Key, bool)> { + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().to_ascii_lowercase(); + if squashed.is_empty() { + return None; + } + // egui reports keypad keys as the main keys, so `Num 5` binds `5`. + let keypad = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num")).filter(|r| !r.is_empty() && *r != "lock"); + let (n, numpad) = match keypad { + Some(r) => (r, true), + None => (squashed.as_str(), false), + }; + let key = FOREIGN_KEYS.iter().find(|(alias, _)| *alias == n).map(|&(_, k)| k).or_else(|| Key::parse(n))?; + Some((key, numpad)) +} diff --git a/crates/keymap/src/command.rs b/crates/keymap/src/command.rs new file mode 100644 index 00000000..d297e7bc --- /dev/null +++ b/crates/keymap/src/command.rs @@ -0,0 +1,127 @@ +//! Commands with parameters and their binding keys. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Longest binding key [`CommandRef::parse_key`] accepts, in bytes. +pub const MAX_KEY: usize = 256; + +/// A command id with the parameters a binding runs it with. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandRef { + pub id: String, + /// `Null` for no parameters. + #[serde(default)] + pub params: Value, +} + +impl CommandRef { + /// A command without parameters. + pub fn new(id: impl Into) -> CommandRef { + CommandRef { id: id.into(), params: Value::Null } + } + + pub fn with_params(id: impl Into, params: Value) -> CommandRef { + CommandRef { id: id.into(), params } + } + + /// The binding key: the id, or `id {json}` with object keys sorted and no spaces. `Null` and + /// an empty object give the bare id. + pub fn key(&self) -> String { + match &self.params { + Value::Null => self.id.clone(), + Value::Object(m) if m.is_empty() => self.id.clone(), + p => { + let mut out = String::with_capacity(self.id.len() + 16); + out.push_str(&self.id); + out.push(' '); + write_canonical(p, &mut out); + out + } + } + } + + /// Reads a binding key: the id up to the first space, then JSON parameters. `None` for an + /// empty id, a key over [`MAX_KEY`] bytes or parameters that are not JSON. + pub fn parse_key(key: &str) -> Option { + if key.len() > MAX_KEY { + return None; + } + let (id, params) = match key.split_once(' ') { + Some((id, rest)) => (id, serde_json::from_str(rest).ok()?), + None => (key, Value::Null), + }; + if id.is_empty() || id.chars().any(char::is_whitespace) { + return None; + } + Some(CommandRef { id: id.to_string(), params }) + } +} + +impl fmt::Display for CommandRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.key()) + } +} + +impl From<&str> for CommandRef { + fn from(id: &str) -> CommandRef { + CommandRef::new(id) + } +} + +// Written by hand so the key does not depend on serde_json's map order: an app's dependency graph +// may turn on `preserve_order`. +fn write_canonical(v: &Value, out: &mut String) { + match v { + Value::Null => out.push_str("null"), + Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + Value::Number(n) => out.push_str(&n.to_string()), + Value::String(s) => write_string(s, out), + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + out.push('{'); + for (i, k) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + write_string(k, out); + out.push(':'); + if let Some(item) = map.get(k) { + write_canonical(item, out); + } + } + out.push('}'); + } + } +} + +fn write_string(s: &str, out: &mut String) { + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); +} diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs new file mode 100644 index 00000000..2d783839 --- /dev/null +++ b/crates/keymap/src/egui.rs @@ -0,0 +1,340 @@ +//! Conversions between the crate's keys and egui's, matching egui key presses to chords, and a +//! [`Dispatcher`] that turns a frame's key events into the commands they run. + +use ::egui::{Event, InputState, Key as EguiKey, KeyboardShortcut, Modifiers}; + +use crate::chord::{Chord, Key, Mods, Platform}; +use crate::command::CommandRef; +use crate::keymap::{Keymap, Lookup}; +use crate::scope::Active; + +// Both matches are generated from one list so that a key added to `Key` without an egui +// counterpart fails to compile. +macro_rules! egui_keys { + ($($ours:ident = $theirs:ident),* ; $($same:ident),* $(,)?) => { + impl Key { + /// The key for an egui key. `None` for modifier keys, Copy, Cut, Paste, BrowserBack + /// and IntlBackslash. + pub fn from_egui(key: EguiKey) -> Option { + match key { + $(EguiKey::$theirs => Some(Key::$ours),)* + $(EguiKey::$same => Some(Key::$same),)* + _ => None, + } + } + + pub fn to_egui(self) -> EguiKey { + match self { + $(Key::$ours => EguiKey::$theirs,)* + $(Key::$same => EguiKey::$same,)* + } + } + } + }; +} + +egui_keys! { + Up = ArrowUp, Down = ArrowDown, Left = ArrowLeft, Right = ArrowRight; + A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, + Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, F18, F19, F20, + F21, F22, F23, F24, F25, F26, F27, F28, F29, F30, F31, F32, F33, F34, F35, + Escape, Tab, Backspace, Enter, Space, Insert, Delete, Home, End, PageUp, PageDown, + Minus, Equals, Plus, OpenBracket, CloseBracket, Backslash, Semicolon, Quote, Backtick, + Comma, Period, Slash, Colon, Pipe, Questionmark, Exclamationmark, OpenCurlyBracket, CloseCurlyBracket, +} + +impl Mods { + /// The modifiers egui reports, read for `platform`. On macOS cmd is ⌘ and ctrl the Control + /// key; elsewhere Ctrl is cmd and ctrl stays false. + pub fn from_egui(mods: &Modifiers, platform: Platform) -> Mods { + if platform.is_mac() { + // egui sets `command` together with `mac_cmd` on macOS; synthetic events may set + // only one of them. + Mods { cmd: mods.mac_cmd || mods.command, ctrl: mods.ctrl, alt: mods.alt, shift: mods.shift } + } else { + Mods { cmd: mods.ctrl || mods.command, ctrl: false, alt: mods.alt, shift: mods.shift } + } + } +} + +impl Chord { + /// The egui shortcut for this chord, for egui's own shortcut text and `consume_shortcut`. + /// cmd becomes egui's `command` and ctrl its `ctrl`, which egui folds the same way off + /// macOS. + pub fn to_egui_shortcut(&self) -> KeyboardShortcut { + let m = self.mods; + KeyboardShortcut::new(Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: false, command: m.cmd }, self.key.to_egui()) + } +} + +/// Base keys and the key egui reports for them with Shift held on a US layout. +const SHIFTED: &[(Key, Key)] = &[ + (Key::Semicolon, Key::Colon), + (Key::Slash, Key::Questionmark), + (Key::Backslash, Key::Pipe), + (Key::OpenBracket, Key::OpenCurlyBracket), + (Key::CloseBracket, Key::CloseCurlyBracket), + (Key::Equals, Key::Plus), + (Key::Num1, Key::Exclamationmark), +]; + +fn without_shift(m: Mods) -> Mods { + Mods { shift: false, ..m } +} + +/// Whether a key press egui reported triggers `binding` on `platform`. +/// +/// Besides an exact match: +/// - Shift with a base key matches the shifted key egui reports (`Shift+;` matches `:`). +/// - A shifted symbol bound without Shift matches when Shift is held to type it (`?`). +/// - A binding on `=` matches `+` with or without Shift, and a binding on `+` matches +/// `Shift+=`, so zoom keys work with and without Shift. +pub fn matches(binding: &Chord, key: EguiKey, mods: &Modifiers, platform: Platform) -> bool { + let Some(pressed) = Key::from_egui(key) else { return false }; + matches_press(binding, pressed, Mods::from_egui(mods, platform), platform) +} + +fn matches_press(binding: &Chord, pressed: Key, held: Mods, platform: Platform) -> bool { + let want = binding.effective(platform); + if pressed == want.key && held == want.mods { + return true; + } + let same_but_shift = without_shift(held) == without_shift(want.mods); + if !same_but_shift { + return false; + } + let shifted_of = |base: Key| SHIFTED.iter().find(|(b, _)| *b == base).map(|&(_, s)| s); + let is_shifted_symbol = SHIFTED.iter().any(|&(_, s)| s == want.key); + match (want.key, pressed) { + (Key::Equals, Key::Plus) => !want.mods.shift || held.shift, + (Key::Plus, Key::Equals) => held.shift && !want.mods.shift, + (w, p) if w == p => is_shifted_symbol && !want.mods.shift && held.shift, + (w, p) => want.mods.shift && held.shift && shifted_of(w) == Some(p), + } +} + +/// The chords a press can stand for, most specific first: the press itself, then those that keep +/// Shift (`Shift+;` for `:`), then those without it (`=` for `+`). Each one [`matches`] the press. +fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { + let base = SHIFTED.iter().find(|&&(_, s)| s == pressed).map(|&(b, _)| b); + let swap = match pressed { + Key::Plus => Some(Key::Equals), + Key::Equals => Some(Key::Plus), + _ => None, + }; + let mut out = Vec::with_capacity(6); + for mods in [held, without_shift(held)] { + for key in [Some(pressed), base, swap].into_iter().flatten() { + let chord = Chord::new(mods, key); + if !out.contains(&chord) && matches_press(&chord, pressed, held, platform) { + out.push(chord); + } + } + } + out +} + +/// A command a key press runs. +/// Seconds within which a second press of the same chord counts as a double press. +pub const DOUBLE_PRESS: f64 = 0.6; + +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + /// The command to run; for tools sharing the chord, the first of `cycle`. + pub command: CommandRef, + /// Tools of one group sharing the chord, in catalogue order (the app picks the next one); + /// empty for a plain command. + pub cycle: Vec, +} + +impl Hit { + fn from_lookup(lookup: Lookup) -> Option { + match lookup { + Lookup::None => None, + Lookup::Run(command) => Some(Hit { command, cycle: Vec::new() }), + Lookup::Cycle(cycle) => Some(Hit { command: cycle.first()?.clone(), cycle }), + } + } +} + +#[derive(Clone, Copy, Debug)] +struct LastPress { + chord: Chord, + at: f64, + ran_double: bool, +} + +/// Runs a keymap against egui's input, one call per frame before the UI reads its keys. +/// +/// It reads key presses (repeats only when [`with_repeat`](Self::with_repeat) is on) and egui's +/// Copy, Cut and Paste events, which egui-winit sends in place of the Cmd+C, Cmd+X and Cmd+V +/// presses. A press that runs a command is removed from the events, together with the text it +/// typed when it has no command modifier, so no widget sees it too. Other events stay. +/// +/// While [`Active::typing`], the text field keeps, without lookup: +/// - clipboard events, and the clipboard key presses the web build sends with them (Cmd+C, Cmd+X, +/// Cmd+V, Shift+Delete, Shift+Insert, Ctrl+Insert); +/// - a press followed by a Text event before the next press, since it typed that text (the web +/// build's text agent can send these); +/// - off macOS, a printable key (letter, digit, punctuation, Space) pressed with Ctrl and Alt. +/// Windows and some Linux setups report AltGr as Ctrl+Alt, so AltGr+Q (`@` on a German layout) +/// arrives as Ctrl+Alt+Q, and egui-winit sends no Text event while Ctrl is held, so the press +/// cannot be told from a command chord. Ctrl+Alt with other keys (F-keys, arrows) still runs. +#[derive(Clone, Debug, Default)] +pub struct Dispatcher { + last: Option, + repeat: bool, +} + +impl Dispatcher { + pub fn new() -> Dispatcher { + Dispatcher::default() + } + + /// Whether held keys run their command again on each repeat (off by default), so a held + /// arrow can nudge. Repeats never count toward a double press. + pub fn with_repeat(mut self, on: bool) -> Dispatcher { + self.repeat = on; + self + } + + /// The commands this frame's key presses run with `active` focus, in press order. + /// + /// A chord pressed again within [`DOUBLE_PRESS`] runs its double binding when it has one; + /// the first press has already run the single binding. The press after a double starts over. + pub fn dispatch(&mut self, input: &mut InputState, keymap: &Keymap, active: &Active) -> Vec { + let platform = keymap.platform(); + let now = input.time; + let frame_mods = Mods::from_egui(&input.modifiers, platform); + let mut hits = Vec::new(); + let mut kept = Vec::with_capacity(input.events.len()); + // The Text event egui sends after a key press belongs to that press. + let mut drop_text = false; + // The web build sends both the Cmd+C key press and the Copy event it causes. + let mut clipboard_key = None; + let events = std::mem::take(&mut input.events); + let typed = if active.typing { typed_text(&events) } else { Vec::new() }; + for (i, event) in events.into_iter().enumerate() { + match &event { + Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { + drop_text = false; + let held = Mods::from_egui(modifiers, platform); + if let Some(key) = Key::from_egui(*key) { + if active.typing && (typed.get(i) == Some(&true) || is_clipboard_press(key, held) || may_be_alt_gr(key, held, platform)) { + kept.push(event); + continue; + } + if held.cmd && matches!(key, Key::C | Key::X | Key::V) { + clipboard_key = Some(key); + } + if let Some(hit) = self.press(key, held, *repeat, now, keymap, active) { + hits.push(hit); + drop_text = !(held.cmd || held.ctrl); + continue; + } + } + } + Event::Text(_) if drop_text => { + drop_text = false; + continue; + } + // A focused text field copies and pastes on these events itself. + Event::Copy | Event::Cut | Event::Paste(_) if !active.typing => { + let (key, held) = clipboard_chord(&event, frame_mods); + if clipboard_key == Some(key) { + clipboard_key = None; + } else if let Some(hit) = self.press(key, held, false, now, keymap, active) { + hits.push(hit); + continue; + } + } + _ => {} + } + kept.push(event); + } + input.events = kept; + hits + } + + fn press(&mut self, key: Key, held: Mods, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { + let chords = candidates(key, held, keymap.platform()); + let first = *chords.first()?; + let find = |double: bool| chords.iter().find_map(|c| Hit::from_lookup(keymap.lookup(c, double, active))); + if repeat { + return find(false); + } + let again = self.last.is_some_and(|l| l.chord == first && !l.ran_double && now >= l.at && now - l.at < DOUBLE_PRESS); + let double = if again { find(true) } else { None }; + self.last = Some(LastPress { chord: first, at: now, ran_double: double.is_some() }); + double.or_else(|| find(false)) + } +} + +/// For each event, whether it is a key press followed by a Text event before the next press. +fn typed_text(events: &[Event]) -> Vec { + let mut out = vec![false; events.len()]; + let mut text_ahead = false; + for (event, typed) in events.iter().zip(out.iter_mut()).rev() { + match event { + Event::Text(t) if !t.is_empty() => text_ahead = true, + Event::Key { pressed: true, .. } => { + *typed = text_ahead; + text_ahead = false; + } + _ => {} + } + } + out +} + +/// Whether a press could be AltGr typing a character: Ctrl+Alt off macOS on a key that types. +fn may_be_alt_gr(key: Key, held: Mods, platform: Platform) -> bool { + !platform.is_mac() && held.cmd && held.alt && types_text(key) +} + +/// Letters, digits, punctuation and Space. +fn types_text(key: Key) -> bool { + !key.is_function_key() + && !matches!( + key, + Key::Escape + | Key::Tab + | Key::Backspace + | Key::Enter + | Key::Insert + | Key::Delete + | Key::Home + | Key::End + | Key::PageUp + | Key::PageDown + | Key::Up + | Key::Down + | Key::Left + | Key::Right + ) +} + +/// Whether a press is one a backend turns into a Copy, Cut or Paste event. +fn is_clipboard_press(key: Key, held: Mods) -> bool { + match key { + Key::C | Key::X | Key::V => held.cmd, + Key::Delete => held.shift, + Key::Insert => held.shift || held.cmd || held.ctrl, + _ => false, + } +} + +/// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also +/// sends Cut for Shift+Delete and Paste for Shift+Insert. +fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { + let with_cmd = Mods { cmd: true, ..held }; + let shift_only = held.shift && !held.cmd; + match event { + Event::Cut if shift_only => (Key::Delete, held), + Event::Cut => (Key::X, with_cmd), + Event::Paste(_) if shift_only => (Key::Insert, held), + Event::Paste(_) => (Key::V, with_cmd), + _ => (Key::C, with_cmd), + } +} diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs new file mode 100644 index 00000000..2af0ed4a --- /dev/null +++ b/crates/keymap/src/import.rs @@ -0,0 +1,837 @@ +//! Importing another application's shortcut set. +//! +//! The app's reader turns a file into a [`ForeignSet`]. [`plan`] matches its entries to the +//! keymap's commands without changing anything, and [`ImportPlan::apply`] applies the plan and +//! returns an [`ImportReport`]. Matching and the apply rules are ported from the DesignCraft +//! InDesign importer (#1180). + +pub mod namematch; + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::chord::Trigger; +use crate::chord::foreign::{Flavor, ForeignError, ReadNote, read_foreign}; +use crate::command::CommandRef; +use crate::keymap::{Bindable, Binding, Keymap, Kind, MAX_BINDINGS, Policy}; +use crate::scope::Scope; + +/// Most entries of a set read; the rest are ignored and counted in [`ImportReport::truncated`]. +pub const MAX_IMPORT_ENTRIES: usize = 20_000; +/// Most keys read per entry. +pub const MAX_KEYS_PER_ENTRY: usize = 64; +/// Longest text field read, in chars; longer ones are cut. +pub const MAX_IMPORT_TEXT: usize = 1024; +/// Most path segments read per entry; the label (last segment) is always kept. +pub const MAX_PATH_SEGMENTS: usize = 32; +/// Most unmatched entries [`ImportOptions::suggest`] looks for commands for; the rest are counted +/// in [`ImportReport::truncated`]. +pub const MAX_SUGGESTIONS: usize = 200; + +/// A shortcut set as an app's reader found it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ForeignSet { + pub name: Option, + pub entries: Vec, +} + +/// One item of a foreign set. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ForeignEntry { + /// The top menu or product area as the source names it, without decoration such as a + /// trailing " Menu". + pub menu: Option, + /// Submenu segments and the item label; the label is last. + pub path: Vec, + /// Empty when the source says the item has no shortcut. + pub keys: Vec, + pub line: Option, + /// The item selects a tool. + pub is_tool: bool, +} + +impl ForeignEntry { + /// An entry without keys. + pub fn new>(menu: Option<&str>, path: impl IntoIterator) -> ForeignEntry { + ForeignEntry { menu: menu.map(str::to_string), path: path.into_iter().map(Into::into).collect(), ..ForeignEntry::default() } + } + + /// Marks the entry as selecting a tool. + pub fn tool(mut self) -> ForeignEntry { + self.is_tool = true; + self + } + + /// Adds a key after the others. + pub fn with_key(mut self, key: ForeignKey) -> ForeignEntry { + self.keys.push(key); + self + } + + /// The entry's line in the source file. + pub fn at_line(mut self, line: usize) -> ForeignEntry { + self.line = Some(line); + self + } + + /// The source's own path, menu first, joined with ` > `: the `item` of report rows. + pub fn item(&self) -> String { + self.menu.iter().chain(&self.path).map(String::as_str).collect::>().join(" > ") + } + + /// The key of [`ImportOptions::remembered`] for this entry: the normalised menu and path + /// joined by U+001F. + pub fn remember_key(&self) -> String { + self.menu.iter().chain(&self.path).map(|s| normalize_label(s)).collect::>().join("\u{1f}") + } +} + +/// One shortcut of an entry. +#[derive(Clone, Debug, PartialEq)] +pub struct ForeignKey { + /// The context the source gives (InDesign's `Default`, `Text`, …); `None` reads as a + /// [`ContextRule::Home`] context. + pub context: Option, + /// The key text as written, for reports. + pub text: String, + pub trigger: Result, + /// What reading the text approximated, reported in [`ImportReport::approximated`]. + pub notes: Vec, +} + +impl ForeignKey { + /// Reads `text` with [`read_foreign`]. + pub fn read(context: Option<&str>, text: &str, flavor: Flavor) -> ForeignKey { + let (trigger, notes) = match read_foreign(text, flavor) { + Ok((t, notes)) => (Ok(t), notes), + Err(e) => (Err(e), Vec::new()), + }; + ForeignKey { context: context.map(str::to_string), text: text.to_string(), trigger, notes } + } +} + +/// What keys in a source context become. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ContextRule { + /// Bindings in the command's home scope. + Home, + /// Bindings in this scope. + Scope(Scope), + /// Bindings in the home scope, only when the entry has no key in a [`ContextRule::Home`] + /// context (DesignCraft reads InDesign's `Text` context this way). + HomeWithoutHomeEntry, + /// Ignored without a report row. + Skip, +} + +/// What an import starts from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Start { + /// The keymap's bindings as they are. + #[default] + Current, + /// Every command's defaults; other overrides are dropped. + Defaults, +} + +/// What an entry without keys does to its command. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NoneDefined { + /// The command loses its shortcuts. + #[default] + Clear, + /// The command keeps its shortcuts. + Keep, +} + +/// How [`plan`] reads a set and how [`ImportPlan::apply`] applies it. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ImportOptions { + /// Source contexts by name, compared case-insensitively. Keys in other contexts are reported + /// as [`UnmatchedReason::Context`]. + pub contexts: Vec<(String, ContextRule)>, + /// Earlier answers of the user, by [`ForeignEntry::remember_key`]. They are tried first. + pub remembered: BTreeMap, + pub start: Start, + pub none_defined: NoneDefined, + /// Look for likely commands for unmatched entries ([`ImportReport::suggestions`]). + pub suggest: bool, +} + +/// One planned binding, with the entry it came from. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlannedBinding { + pub binding: Binding, + pub item: String, + pub line: Option, + /// The entry's position in the set: a later entry takes a chord from an earlier one. + pub order: usize, +} + +/// What the set says about one command, over every entry matched to it. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Planned { + pub command: CommandRef, + /// The first entry matched to it, and its line. + pub item: String, + pub line: Option, + pub bindings: Vec, + /// Every matched entry had no keys. + pub none_defined: bool, +} + +/// The result of [`plan`]: what would change, and the report rows known before applying. +/// +/// Serializable for a review UI; it does not deserialize, so every plan applied comes from +/// [`plan`] and keeps its caps. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportPlan { + /// In the order of their first entry. + pub commands: Vec, + pub start: Start, + pub none_defined: NoneDefined, + /// `unmatched`, `unreadable`, `approximated`, `suggestions` and the counts; + /// [`ImportPlan::apply`] adds the rest. + pub report: ImportReport, +} + +/// A command whose bindings the import changed. Shortcuts are binding texts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Changed { + pub command: CommandRef, + pub label: String, + /// The first entry matched to the command, and its line. + pub item: String, + pub line: Option, + pub from: Vec, + pub to: Vec, +} + +/// A binding taken from a command outside the set. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cleared { + pub command: CommandRef, + pub label: String, + pub shortcut: String, +} + +/// Why a key of the set was not bound. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SkipReason { + /// A later entry of the set takes the chord. + Taken, + /// The system takes the chord. + Reserved, + /// The binding's scope ([`ContextRule::Scope`]) is not one the keymap knows + /// ([`Keymap::knows_scope`]). + UnknownScope, +} + +/// A key of the set that was not bound. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Skipped { + pub command: CommandRef, + pub item: String, + pub line: Option, + pub shortcut: String, + /// The command of the later entry that takes the chord ([`SkipReason::Taken`]). + pub kept_by: Option, + pub reason: SkipReason, +} + +/// A change [`ImportPlan::apply`] could not make, so the command keeps the bindings it had. The +/// overrides had no room for another changed command ([`AssignError::Full`]), or the keymap lacks +/// the command (a plan applied to a keymap it was not made from). +/// +/// [`AssignError::Full`]: crate::AssignError::Full +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Failed { + pub command: CommandRef, + pub label: String, + pub change: FailedChange, + /// The set's shortcuts for [`FailedChange::Bind`], the one left on the command for + /// [`FailedChange::Unbind`]; empty for [`FailedChange::Clear`]. + pub shortcuts: Vec, +} + +/// What a [`Failed`] change was. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FailedChange { + /// Giving the command the set's bindings. + Bind, + /// Leaving the command without shortcuts: the set has it without keys and + /// [`NoneDefined::Clear`] applies. + Clear, + /// Taking a chord the set gives to another command; both commands keep it. + Unbind, +} + +/// Why keys of an entry were not used. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UnmatchedReason { + NoMatch, + /// Several commands fit and no match step picked one. + Ambiguous, + /// The keys are in contexts [`ImportOptions::contexts`] does not list. + Context, + /// Only a command of the other kind fits: a tool entry naming a command, or the other way round. + Tool, +} + +/// An entry with keys that was not used, or keys of a matched entry in unlisted contexts. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Unmatched { + pub item: String, + pub line: Option, + /// The keys as written, `: ` joined with `, `. + pub keys: String, + pub reason: UnmatchedReason, +} + +/// Keys of a matched entry that could not be read. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Unreadable { + pub item: String, + pub line: Option, + pub keys: String, + /// Why the first of them could not be read. + pub reason: ForeignError, +} + +/// Likely commands for an unmatched entry, best first. Never applied. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Suggestion { + pub item: String, + pub line: Option, + pub candidates: Vec, +} + +/// Keys of a matched entry that were read approximately, such as a numeric keypad key bound as +/// the main key of the same name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Approximated { + pub item: String, + pub line: Option, + /// The keys with this note, as written: `: ` joined with `, `. + pub keys: String, + pub note: ReadNote, +} + +/// What an import did. Shortcuts are canonical binding texts; `item` is the source's path and +/// `line` the entry's [`ForeignEntry::line`]. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportReport { + pub set: Option, + /// Commands of the set whose bindings changed. + pub imported: usize, + pub changed: Vec, + pub conflicts_cleared: Vec, + pub skipped: Vec, + #[serde(default)] + pub failed: Vec, + pub unmatched: Vec, + /// Entries without keys that matched no command. + pub unmatched_without_shortcut: usize, + pub unreadable: Vec, + pub approximated: Vec, + pub suggestions: Vec, + /// Entries, keys, path segments, bindings and text fields cut at the caps, and unmatched + /// entries past [`MAX_SUGGESTIONS`] that got no suggestion. + pub truncated: usize, +} + +/// The matching form of a label or menu name: lower case, no `…`, `...` or `&`, single spaces, +/// and no leading `show/hide `, `show ` or `hide ` when something remains (sources name a toggle +/// by what choosing it does now). +pub fn normalize_label(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_else(|| s.clone(), str::to_string) +} + +/// Matches `set` to the keymap's commands. Pure: the keymap is not changed. +pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> ImportPlan { + let mut cut = 0usize; + let mut report = ImportReport { set: set.name.as_deref().map(|n| cap_text(n, &mut cut)), ..ImportReport::default() }; + cut += set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES); + let index = Index::new(keymap); + let contexts: Vec<(String, &ContextRule)> = options.contexts.iter().map(|(n, r)| (n.to_lowercase(), r)).collect(); + let mut commands: Vec = Vec::new(); + let mut planned_at: HashMap = HashMap::new(); + let mut to_suggest: Vec = Vec::new(); + + for (order, raw) in set.entries.iter().take(MAX_IMPORT_ENTRIES).enumerate() { + let entry = capped(raw, &mut cut); + let item = entry.item(); + let found = index.find(&entry, entry.is_tool, &options.remembered); + let Found::One(i) = found else { + if entry.keys.is_empty() { + report.unmatched_without_shortcut += 1; + continue; + } + let reason = match found { + Found::Many => UnmatchedReason::Ambiguous, + _ if matches!(index.find(&entry, !entry.is_tool, &BTreeMap::new()), Found::One(_)) => UnmatchedReason::Tool, + _ => UnmatchedReason::NoMatch, + }; + if options.suggest && reason != UnmatchedReason::Tool { + if to_suggest.len() < MAX_SUGGESTIONS { + let label = entry.path.last().cloned().unwrap_or_default(); + to_suggest.push(ToSuggest { item: item.clone(), line: entry.line, label, tool: entry.is_tool }); + } else { + cut += 1; + } + } + report.unmatched.push(Unmatched { item, line: entry.line, keys: keys_text(entry.keys.iter()), reason }); + continue; + }; + + let rule_of = |k: &ForeignKey| -> Option<&ContextRule> { + match &k.context { + None => Some(&ContextRule::Home), + Some(c) => { + let c = c.to_lowercase(); + contexts.iter().find(|(n, _)| *n == c).map(|(_, r)| *r) + } + } + }; + let has_home = entry.keys.iter().any(|k| rule_of(k) == Some(&ContextRule::Home)); + let mut unlisted = Vec::new(); + let mut unreadable = Vec::new(); + let mut approximated: Vec<(ReadNote, Vec<&ForeignKey>)> = Vec::new(); + let mut bindings = Vec::new(); + for k in &entry.keys { + let scope = match rule_of(k) { + None => { + unlisted.push(k); + continue; + } + Some(ContextRule::Skip) => continue, + Some(ContextRule::HomeWithoutHomeEntry) if has_home => continue, + Some(ContextRule::Home | ContextRule::HomeWithoutHomeEntry) => None, + Some(ContextRule::Scope(s)) => Some(s.clone()), + }; + match &k.trigger { + Ok(t) => bindings.push(Binding { trigger: *t, scope }), + Err(e) => unreadable.push((k, e)), + } + for ¬e in &k.notes { + match approximated.iter_mut().find(|(n, _)| *n == note) { + Some((_, keys)) => keys.push(k), + None => approximated.push((note, vec![k])), + } + } + } + let line = entry.line; + if !unlisted.is_empty() { + report.unmatched.push(Unmatched { item: item.clone(), line, keys: keys_text(unlisted.into_iter()), reason: UnmatchedReason::Context }); + } + if let Some((_, e)) = unreadable.first() { + let reason = (*e).clone(); + report.unreadable.push(Unreadable { item: item.clone(), line, keys: keys_text(unreadable.iter().map(|(k, _)| *k)), reason }); + } + for (note, keys) in approximated { + report.approximated.push(Approximated { item: item.clone(), line, keys: keys_text(keys.into_iter()), note }); + } + + let at = *planned_at.entry(i).or_insert_with(|| { + let command = keymap.catalogue().get(i).map(|b| b.command.clone()).unwrap_or_else(|| CommandRef::new("")); + commands.push(Planned { command, item: item.clone(), line, bindings: Vec::new(), none_defined: true }); + commands.len() - 1 + }); + let Some(p) = commands.get_mut(at) else { continue }; + p.none_defined &= entry.keys.is_empty(); + for binding in bindings { + if p.bindings.iter().any(|b| b.binding == binding) { + continue; + } + if p.bindings.len() >= MAX_BINDINGS { + cut += 1; + continue; + } + p.bindings.push(PlannedBinding { binding, item: item.clone(), line, order }); + } + } + + if !to_suggest.is_empty() { + report.suggestions = suggest(keymap, &to_suggest); + } + report.truncated = cut; + ImportPlan { commands, start: options.start, none_defined: options.none_defined, report } +} + +impl ImportPlan { + /// Applies the plan to `keymap`, which should be the one it was made from. The set wins: a + /// chord it gives to a command is taken from other commands (except twins sharing it as a + /// default). Within the set, a later entry takes a chord from an earlier one, as when the + /// entries are applied in file order; the earlier entry's key is reported in + /// [`ImportReport::skipped`]. + /// + /// A change that finds the overrides full is left out and reported in + /// [`ImportReport::failed`]; the others still apply. With [`Start::Defaults`] that cannot + /// happen. + pub fn apply(&self, keymap: &mut Keymap) -> ImportReport { + let mut report = self.report.clone(); + let before: Vec> = self.commands.iter().map(|p| keymap.bindings(&p.command)).collect(); + // Dropping every entry leaves room for all of them: each catalogue command takes at most one + // entry, and the catalogue holds at most `MAX_COMMANDS`. + if self.start == Start::Defaults { + keymap.reset_all(); + } + let home_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map_or_else(Scope::app, |b| b.scope.clone()); + + let mut claims: Vec<(usize, &PlannedBinding)> = + self.commands.iter().enumerate().flat_map(|(c, p)| p.bindings.iter().map(move |b| (c, b))).collect(); + claims.sort_by_key(|(_, b)| b.order); + let platform = keymap.platform(); + let mut taken: HashMap> = HashMap::new(); + let mut accepted: Vec> = vec![Vec::new(); self.commands.len()]; + for (c, pb) in claims { + let Some(p) = self.commands.get(c) else { continue }; + let shortcut = || pb.binding.text(&home_of(keymap, &p.command)); + let skip = + |reason| Skipped { command: p.command.clone(), item: pb.item.clone(), line: pb.line, shortcut: shortcut(), kept_by: None, reason }; + if keymap.is_reserved(&pb.binding.trigger.chord) { + report.skipped.push(skip(SkipReason::Reserved)); + continue; + } + if pb.binding.scope.as_ref().is_some_and(|s| !keymap.knows_scope(s)) { + report.skipped.push(skip(SkipReason::UnknownScope)); + continue; + } + let effective = pb.binding.trigger.effective(platform); + let (scope, tools) = match keymap.bindable(&p.command) { + Some(b) => (pb.binding.scope_or(&b.scope).clone(), if let Kind::Tool { group } = &b.kind { Some(group.clone()) } else { None }), + None => (pb.binding.scope.clone().unwrap_or_else(Scope::app), None), + }; + let groups = taken.entry(effective).or_default(); + let mut lost: Vec<(usize, &PlannedBinding)> = Vec::new(); + for g in groups.iter_mut().filter(|g| !(tools.is_some() && g.tools == tools) && keymap.scopes().relation(&g.scope, &scope) == Some(true)) + { + g.holders.retain(|&(k, earlier)| { + let clash = + self.commands.get(k).is_some_and(|other| keymap.pair_clashes(&other.command, &earlier.binding, &p.command, &pb.binding)); + if clash { + lost.push((k, earlier)); + } + !clash + }); + } + for (k, earlier) in lost { + if let Some(list) = accepted.get_mut(k) { + list.retain(|b| !std::ptr::eq(*b, earlier)); + } + let Some(other) = self.commands.get(k) else { continue }; + report.skipped.push(Skipped { + command: other.command.clone(), + item: earlier.item.clone(), + line: earlier.line, + shortcut: earlier.binding.text(&home_of(keymap, &other.command)), + kept_by: Some(p.command.clone()), + reason: SkipReason::Taken, + }); + } + match groups.iter_mut().find(|g| g.scope == scope && g.tools == tools) { + Some(g) => g.holders.push((c, pb)), + None => groups.push(TakenGroup { scope, tools, holders: vec![(c, pb)] }), + } + if let Some(list) = accepted.get_mut(c) { + list.push(pb); + } + } + let accepted: Vec> = accepted.into_iter().map(|l| l.into_iter().map(|b| b.binding.clone()).collect()).collect(); + + let label_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map(|b| b.label.clone()).unwrap_or_default(); + let mut decided: HashSet = HashSet::new(); + for (p, list) in self.commands.iter().zip(&accepted) { + let failed = |km: &Keymap, change, shortcuts| Failed { command: p.command.clone(), label: label_of(km, &p.command), change, shortcuts }; + if !list.is_empty() { + let list = in_default_order(keymap, &p.command, list); + if keymap.set_bindings(&p.command, list.clone(), Policy::Keep).is_err() { + let home = home_of(keymap, &p.command); + report.failed.push(failed(keymap, FailedChange::Bind, list.iter().map(|b| b.text(&home)).collect())); + continue; + } + } else if p.none_defined && self.none_defined == NoneDefined::Clear { + if !keymap.clear(&p.command) { + report.failed.push(failed(keymap, FailedChange::Clear, Vec::new())); + continue; + } + } else { + continue; + } + decided.insert(p.command.key()); + } + + for (p, list) in self.commands.iter().zip(&accepted) { + if list.is_empty() || !decided.contains(&p.command.key()) { + continue; + } + for h in keymap.clashing(&p.command, list) { + if decided.contains(&h.command.key()) { + continue; + } + let home = home_of(keymap, &h.command); + let label = label_of(keymap, &h.command); + let shortcut = h.binding.text(&home); + if keymap.remove(&h.command, &h.binding) { + report.conflicts_cleared.push(Cleared { command: h.command, label, shortcut }); + } else { + report.failed.push(Failed { command: h.command, label, change: FailedChange::Unbind, shortcuts: vec![shortcut] }); + } + } + } + + for (p, from) in self.commands.iter().zip(before) { + if !decided.contains(&p.command.key()) { + continue; + } + let to = keymap.bindings(&p.command); + if to == from { + continue; + } + let home = home_of(keymap, &p.command); + let texts = |l: &[Binding]| l.iter().map(|b| b.text(&home)).collect::>(); + report.changed.push(Changed { + command: p.command.clone(), + label: label_of(keymap, &p.command), + item: p.item.clone(), + line: p.line, + from: texts(&from), + to: texts(&to), + }); + } + report.imported = report.changed.len(); + report + } +} + +/// Claims of one chord that [`ImportPlan::apply`] accepted, with one scope and tool group (as +/// [`Keymap::conflicts`] groups them), so a claim is compared only with groups it can clash with. +struct TakenGroup<'a> { + scope: Scope, + /// The tool group, for tools. + tools: Option>, + /// Plan command index and claim. + holders: Vec<(usize, &'a PlannedBinding)>, +} + +/// `list` in the order of the command's defaults when it holds the same bindings, so an import +/// equal to the defaults leaves no override. +fn in_default_order(keymap: &Keymap, command: &CommandRef, list: &[Binding]) -> Vec { + let Some(home) = keymap.bindable(command).map(|b| b.scope.clone()) else { return list.to_vec() }; + let key = |b: &Binding| (b.trigger, b.scope_or(&home).clone()); + let defaults = keymap.default_bindings(command); + let mut a: Vec<_> = list.iter().map(key).collect(); + let mut d: Vec<_> = defaults.iter().map(key).collect(); + a.sort(); + d.sort(); + if a == d { defaults } else { list.to_vec() } +} + +fn keys_text<'a>(keys: impl Iterator) -> String { + keys.map(|k| match &k.context { + Some(c) => format!("{c}: {}", k.text), + None => k.text.clone(), + }) + .collect::>() + .join(", ") +} + +/// `s` cut to [`MAX_IMPORT_TEXT`] chars, counting a cut in `cut`. +fn cap_text(s: &str, cut: &mut usize) -> String { + match s.char_indices().nth(MAX_IMPORT_TEXT) { + Some((end, _)) => { + *cut += 1; + s.get(..end).unwrap_or_default().to_string() + } + None => s.to_string(), + } +} + +/// A copy of `e` within the caps. +fn capped(e: &ForeignEntry, cut: &mut usize) -> ForeignEntry { + let mut path: Vec = e.path.iter().take(MAX_PATH_SEGMENTS.saturating_sub(1)).map(|s| cap_text(s, cut)).collect(); + if e.path.len() > MAX_PATH_SEGMENTS { + *cut += e.path.len() - MAX_PATH_SEGMENTS; + } + if let Some(last) = e.path.get(path.len()..).and_then(<[String]>::last) { + path.push(cap_text(last, cut)); + } + *cut += e.keys.len().saturating_sub(MAX_KEYS_PER_ENTRY); + let keys = e + .keys + .iter() + .take(MAX_KEYS_PER_ENTRY) + .map(|k| ForeignKey { + context: k.context.as_deref().map(|c| cap_text(c, cut)), + text: cap_text(&k.text, cut), + trigger: match &k.trigger { + Ok(t) => Ok(*t), + Err(ForeignError::UnknownKey(s)) => Err(ForeignError::UnknownKey(cap_text(s, cut))), + Err(ForeignError::UnsupportedModifier(s)) => Err(ForeignError::UnsupportedModifier(cap_text(s, cut))), + Err(other) => Err(other.clone()), + }, + notes: k.notes.iter().fold(Vec::new(), |mut notes, n| { + if !notes.contains(n) { + notes.push(*n); + } + notes + }), + }) + .collect(); + ForeignEntry { menu: e.menu.as_deref().map(|m| cap_text(m, cut)), path, keys, line: e.line, is_tool: e.is_tool } +} + +enum Found { + One(usize), + Many, + None, +} + +/// The catalogue by normalised path and label, for the match steps. +struct Index { + tool: Vec, + /// Binding keys, for remembered answers. + keys: HashMap, + /// Normalised paths joined by U+001F. + paths: HashMap>, + /// (top menu, label) at any depth of that menu. + in_menu: HashMap<(String, String), Vec>, + /// Labels, aliases and path labels anywhere. + anywhere: HashMap>, +} + +fn add(map: &mut HashMap>, key: K, i: usize) { + let list = map.entry(key).or_default(); + if list.last() != Some(&i) { + list.push(i); + } +} + +impl Index { + fn new(keymap: &Keymap) -> Index { + let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), paths: HashMap::new(), in_menu: HashMap::new(), anywhere: HashMap::new() }; + for (i, b) in keymap.catalogue().iter().enumerate() { + ix.tool.push(matches!(b.kind, Kind::Tool { .. })); + ix.keys.insert(b.command.key(), i); + let label = normalize_label(&b.label); + add(&mut ix.anywhere, label.clone(), i); + for a in &b.aliases { + add(&mut ix.anywhere, normalize_label(a), i); + } + for path in &b.paths { + let segs: Vec = path.iter().map(|s| normalize_label(s)).collect(); + add(&mut ix.paths, segs.join("\u{1f}"), i); + let Some(top) = segs.first() else { continue }; + add(&mut ix.in_menu, (top.clone(), label.clone()), i); + if let [_, .., last] = segs.as_slice() { + add(&mut ix.in_menu, (top.clone(), last.clone()), i); + add(&mut ix.anywhere, last.clone(), i); + } + } + } + ix + } + + fn pick(&self, list: Option<&Vec>, tool: bool) -> Found { + let mut fits = list.into_iter().flatten().filter(|i| self.tool.get(**i) == Some(&tool)); + match (fits.next(), fits.next()) { + (Some(i), None) => Found::One(*i), + (Some(_), Some(_)) => Found::Many, + _ => Found::None, + } + } + + /// The match steps in order; the first that finds exactly one command decides. + fn find(&self, entry: &ForeignEntry, tool: bool, remembered: &BTreeMap) -> Found { + if let Some(c) = remembered.get(&entry.remember_key()) + && let Some(&i) = self.keys.get(&c.key()) + && self.tool.get(i) == Some(&tool) + { + return Found::One(i); + } + let segs: Vec = entry.path.iter().map(|s| normalize_label(s)).collect(); + let Some(last) = segs.last() else { return Found::None }; + let menu = entry.menu.as_deref().map(normalize_label); + let full = menu.iter().chain(&segs).map(String::as_str).collect::>().join("\u{1f}"); + let mut steps = vec![self.paths.get(&full)]; + if let Some(m) = &menu { + if segs.len() > 1 { + steps.push(self.in_menu.get(&(m.clone(), segs.join(" ")))); + } + steps.push(self.in_menu.get(&(m.clone(), last.clone()))); + } + if menu.is_some() || segs.len() == 1 { + steps.push(self.anywhere.get(last)); + } + let mut many = false; + for list in steps { + match self.pick(list, tool) { + Found::One(i) => return Found::One(i), + Found::Many => many = true, + Found::None => {} + } + } + if many { Found::Many } else { Found::None } + } +} + +/// An unmatched entry to look for likely commands for. +struct ToSuggest { + item: String, + line: Option, + label: String, + tool: bool, +} + +/// Fuzzy candidates for unmatched entries, among commands of the same kind. +fn suggest(keymap: &Keymap, entries: &[ToSuggest]) -> Vec { + let pool = |tool: bool| -> Vec<&Bindable> { keymap.catalogue().iter().filter(|b| matches!(b.kind, Kind::Tool { .. }) == tool).collect() }; + let matcher = |pool: &[&Bindable]| { + let candidates: Vec = pool + .iter() + .map(|b| { + let labels = std::iter::once(&b.label).chain(&b.aliases).chain(b.paths.iter().filter_map(|p| p.last())).cloned().collect(); + namematch::Candidate { id: b.command.id.clone(), labels } + }) + .collect(); + namematch::Matcher::new(&candidates) + }; + let pools = [pool(false), pool(true)]; + let mut matchers: [Option; 2] = [None, None]; + let mut out = Vec::new(); + for ToSuggest { item, line, label, tool } in entries { + let k = usize::from(*tool); + let (Some(pool), Some(slot)) = (pools.get(k), matchers.get_mut(k)) else { continue }; + let picks = match slot.get_or_insert_with(|| matcher(pool)).propose(label) { + namematch::Proposal::Match(i, _) => vec![i], + namematch::Proposal::Choice(v) => v, + }; + let candidates: Vec = picks.iter().filter_map(|i| pool.get(*i)).map(|b| b.command.clone()).collect(); + if !candidates.is_empty() { + out.push(Suggestion { item: item.clone(), line: *line, candidates }); + } + } + out +} diff --git a/crates/keymap/src/import/namematch.rs b/crates/keymap/src/import/namematch.rs new file mode 100644 index 00000000..e889d118 --- /dev/null +++ b/crates/keymap/src/import/namematch.rs @@ -0,0 +1,351 @@ +//! Fuzzy matching of a name another application's file writes (a shortcut set's item label) +//! against an app's commands, by their ids and labels only. A match is proposed when one candidate +//! scores well and clearly ahead of the next; otherwise the best few are offered to choose from. +//! +//! From VectorCraft `crates/engine/src/namematch.rs`: Dice coefficients over words and over +//! character pairs. + +use std::collections::HashMap; + +/// A command a name can stand for. +#[derive(Clone, Debug)] +pub struct Candidate { + /// A command id such as `select.all`; a `tool:` or `panel:` prefix is ignored. + pub id: String, + /// Its labels: the English one, the one in the interface language, other names. + pub labels: Vec, +} + +/// What a name stands for, as far as [`propose`] can tell. +#[derive(Clone, Debug, PartialEq)] +pub enum Proposal { + /// The candidate at this index of the list, with its score (0 to 1). + Match(usize, f64), + /// The best candidates, best first, when none stands out. + Choice(Vec), +} + +/// The lowest score [`Proposal::Match`] proposes. +pub const THRESHOLD: f64 = 0.6; +/// How far the best score must lead the next one to be proposed. +pub const MARGIN: f64 = 0.1; +/// The most candidates a [`Proposal::Choice`] lists. +pub const CHOICES: usize = 5; +/// The lowest score a [`Proposal::Choice`] lists. +pub const CHOICE_FLOOR: f64 = 0.3; +/// How many characters of a name or label are compared; the rest is ignored, so a long name +/// costs no more than this. +pub const MAX_NAME_CHARS: usize = 200; + +/// The first [`MAX_NAME_CHARS`] characters of `s`. +fn head(s: &str) -> &str { + s.char_indices().nth(MAX_NAME_CHARS).and_then(|(end, _)| s.get(..end)).unwrap_or(s) +} + +/// `s` with only its letters and digits, lowercased. +fn folded(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() +} + +/// The words of `s`, lowercased: runs of letters and digits, split where a lowercase letter or a digit +/// meets an uppercase one (`selectAll`: `select`, `all`). +fn words(s: &str) -> Vec { + let mut out = vec![]; + let mut word = String::new(); + let mut prev_lower = false; + for c in s.chars() { + if !c.is_alphanumeric() { + if !word.is_empty() { + out.push(std::mem::take(&mut word)); + } + prev_lower = false; + continue; + } + if c.is_uppercase() && prev_lower && !word.is_empty() { + out.push(std::mem::take(&mut word)); + } + prev_lower = c.is_lowercase() || c.is_ascii_digit(); + word.extend(c.to_lowercase()); + } + if !word.is_empty() { + out.push(word); + } + out +} + +/// The words of a candidate's id: its segments after any `tool:` or `panel:`, split as [`words`]. +fn id_words(id: &str) -> Vec { + let id = id.strip_prefix("tool:").or_else(|| id.strip_prefix("panel:")).unwrap_or(id); + id.split('.').flat_map(words).collect() +} + +/// How many words `a` and `b` share, each word counted once, as a Dice coefficient (0 to 1). +fn word_overlap(a: &[String], b: &[String]) -> f64 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + let mut rest: Vec<&String> = b.iter().collect(); + let mut shared = 0usize; + for w in a { + if let Some(i) = rest.iter().position(|x| *x == w) { + rest.swap_remove(i); + shared += 1; + } + } + (2 * shared) as f64 / (a.len() + b.len()) as f64 +} + +/// The pairs of neighboring characters in `s`. +fn pairs(s: &str) -> Vec<(char, char)> { + let c: Vec = s.chars().collect(); + c.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + +/// How many character pairs `a` and `b` (each sorted) share, as a Dice coefficient (0 to 1): for +/// names written without spaces. Linear in their lengths, so long names in a file stay quick. +fn pair_similarity(a: &[(char, char)], b: &[(char, char)]) -> f64 { + if a.is_empty() || b.is_empty() { + return 0.0; + } + let (mut i, mut j, mut shared) = (0usize, 0usize, 0usize); + while let (Some(p), Some(q)) = (a.get(i), b.get(j)) { + match p.cmp(q) { + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + std::cmp::Ordering::Equal => { + shared += 1; + i += 1; + j += 1; + } + } + } + (2 * shared) as f64 / (a.len() + b.len()) as f64 +} + +/// A name or label as the scores compare it: its letters and digits (lowercased), its words and the +/// character pairs of the former (sorted). +struct Text { + folded: String, + words: Vec, + pairs: Vec<(char, char)>, +} + +impl Text { + fn of(s: &str) -> Self { + let s = head(s); + let folded = folded(s); + let mut pairs = pairs(&folded); + pairs.sort_unstable(); + Self { pairs, words: words(s), folded } + } +} + +/// A candidate as the scores compare it. +struct Prepared { + /// Its id's words, and those joined. + id_words: Vec, + id: String, + labels: Vec, +} + +impl Prepared { + fn of(c: &Candidate) -> Self { + let id_words = id_words(&c.id); + Self { id: id_words.concat(), id_words, labels: c.labels.iter().map(|l| Text::of(l)).collect() } + } + + /// See [`score`]. + fn score(&self, name: &Text) -> f64 { + if name.folded.is_empty() { + return 0.0; + } + if name.folded == self.id || self.labels.iter().any(|l| l.folded == name.folded) { + return 1.0; + } + let by_words = self.labels.iter().map(|l| word_overlap(&name.words, &l.words)).fold(word_overlap(&name.words, &self.id_words), f64::max); + let by_pairs = self.labels.iter().map(|l| pair_similarity(&name.pairs, &l.pairs)).fold(0.0, f64::max); + by_words.max(by_pairs * 0.9) + } +} + +/// How well `name` fits `candidate` (0 to 1): 1 when it equals a label or the id, its letters and +/// digits compared without case; else the best of word overlap and character-pair similarity, the +/// latter counted a little lower. Only the first [`MAX_NAME_CHARS`] characters of the name and +/// of each label count. +pub fn score(name: &str, candidate: &Candidate) -> f64 { + Prepared::of(candidate).score(&Text::of(name)) +} + +/// The candidate `name` stands for, when one scores at least 0.6 and 0.1 ahead of every other; else +/// the best five that score at least 0.3, to choose from. +pub fn propose(name: &str, candidates: &[Candidate]) -> Proposal { + Matcher::new(candidates).propose(name) +} + +/// [`propose`] for many names against the same candidates: each candidate's words and character +/// pairs are worked out once, and a name is scored only against the candidates that share a word or +/// a character pair with it (the others score 0). +pub struct Matcher { + prepared: Vec, + /// Candidates by their joined id words and their labels' letters and digits. + exact: HashMap>, + /// Candidates by a word of their id or labels. + by_word: HashMap>, + /// Candidates by a character pair of their labels. + by_pair: HashMap<(char, char), Vec>, +} + +impl Matcher { + /// Prepares `candidates` once for many [`Matcher::propose`] calls. + pub fn new(candidates: &[Candidate]) -> Self { + let prepared: Vec = candidates.iter().map(Prepared::of).collect(); + let (mut exact, mut by_word, mut by_pair) = (HashMap::new(), HashMap::new(), HashMap::new()); + fn add(map: &mut HashMap>, key: K, i: usize) { + let list = map.entry(key).or_default(); + if list.last() != Some(&i) { + list.push(i); + } + } + for (i, p) in prepared.iter().enumerate() { + add(&mut exact, p.id.clone(), i); + for l in &p.labels { + add(&mut exact, l.folded.clone(), i); + } + for w in p.id_words.iter().chain(p.labels.iter().flat_map(|l| &l.words)) { + add(&mut by_word, w.clone(), i); + } + for pair in p.labels.iter().flat_map(|l| &l.pairs) { + add(&mut by_pair, *pair, i); + } + } + Self { prepared, exact, by_word, by_pair } + } + + /// See [`propose`]. + pub fn propose(&self, name: &str) -> Proposal { + let text = Text::of(name); + let mut seen = vec![false; self.prepared.len()]; + let lists = self + .exact + .get(&text.folded) + .into_iter() + .chain(text.words.iter().filter_map(|w| self.by_word.get(w))) + // Each pair looked up once (they are sorted). + .chain(text.pairs.chunk_by(|a, b| a == b).filter_map(|c| self.by_pair.get(c.first()?))); + let mut scored: Vec<(usize, f64)> = vec![]; + for &i in lists.flatten() { + if let Some(s) = seen.get_mut(i) + && !*s + { + *s = true; + let score = self.prepared.get(i).map_or(0.0, |p| p.score(&text)); + if score >= CHOICE_FLOOR { + scored.push((i, score)); + } + } + } + scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + match (scored.first(), scored.get(1)) { + (Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best), + _ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidates(list: &[(&str, &str)]) -> Vec { + list.iter().map(|(id, label)| Candidate { id: id.to_string(), labels: vec![label.to_string()] }).collect() + } + + fn proposed<'a>(name: &str, list: &'a [Candidate]) -> Option<&'a str> { + match propose(name, list) { + Proposal::Match(i, _) => list.get(i).map(|c| c.id.as_str()), + Proposal::Choice(_) => None, + } + } + + #[test] + fn extra_words_still_find_the_tool() { + let tools = candidates(&[ + ("tool:rectangle", "Rectangle Tool"), + ("tool:roundedRectangle", "Rounded Rectangle Tool"), + ("tool:rectangularGrid", "Rectangular Grid Tool"), + ("tool:ellipse", "Ellipse Tool"), + ]); + assert_eq!(proposed("Acme Rectangle Tool", &tools), Some("tool:rectangle")); + assert_eq!(proposed("Rounded Rectangle", &tools), Some("tool:roundedRectangle")); + assert_eq!(proposed("ellipse tool", &tools), Some("tool:ellipse"), "case aside, the label itself"); + } + + #[test] + fn letters_only_names_match_ids() { + let commands = candidates(&[("acme.copyAll", "Everything"), ("acme.copyAllOnPage", "Everything on This Page"), ("acme.clear", "Clear Out")]); + assert_eq!(proposed("acmecopyall", &commands), Some("acme.copyAll")); + assert_eq!(proposed("Acme Copy All On Page", &commands), Some("acme.copyAllOnPage")); + assert_eq!(proposed("clear out", &commands), Some("acme.clear")); + } + + #[test] + fn ties_need_a_choice() { + let tools = candidates(&[("tool:rectangle", "Rectangle Tool"), ("tool:ellipse", "Ellipse Tool"), ("tool:star", "Star Tool")]); + assert_eq!(propose("Tool", &tools), Proposal::Choice(vec![0, 1, 2])); + assert_eq!(propose("Nothing Alike", &tools), Proposal::Choice(vec![])); + assert_eq!(propose("", &tools), Proposal::Choice(vec![])); + } + + #[test] + fn names_without_spaces_match_by_character_pairs() { + let list = vec![ + Candidate { id: "tool:rectangle".into(), labels: vec!["Rectangle Tool".into(), "長方形ツール".into()] }, + Candidate { id: "tool:ellipse".into(), labels: vec!["Ellipse Tool".into(), "楕円形ツール".into()] }, + ]; + assert_eq!(proposed("長方形ツール", &list), Some("tool:rectangle")); + assert_eq!(proposed("楕円形 ツール", &list), Some("tool:ellipse"), "spaces and punctuation aside"); + assert_eq!(proposed("楕円形ツール2", &list), Some("tool:ellipse"), "most character pairs shared"); + } + + #[test] + fn the_index_finds_what_scoring_every_candidate_finds() { + let list = candidates(&[ + ("tool:rectangle", "Rectangle Tool"), + ("tool:roundedRectangle", "Rounded Rectangle Tool"), + ("tool:ellipse", "Ellipse Tool"), + ("select.all", "All"), + ("select.none", "Deselect"), + ("edit.undo", "Undo"), + ("foo.bar", "Zzz"), + ("panel:layers", "Layers"), + ]); + let matcher = Matcher::new(&list); + for name in ["Acme Rectangle Tool", "rounded", "acmecopyall", "foobar", "zz", "Lay", "Undo Redo", "ツール", "", "e"] { + let mut scored: Vec<(usize, f64)> = + list.iter().enumerate().map(|(i, c)| (i, score(name, c))).filter(|(_, s)| *s >= CHOICE_FLOOR).collect(); + scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + let every = match (scored.first(), scored.get(1)) { + (Some(&(i, best)), next) if best >= THRESHOLD && next.is_none_or(|n| best - n.1 >= MARGIN) => Proposal::Match(i, best), + _ => Proposal::Choice(scored.iter().take(CHOICES).map(|(i, _)| *i).collect()), + }; + assert_eq!(matcher.propose(name), every, "{name}"); + } + assert_eq!(proposed("foobar", &list), Some("foo.bar"), "the id alone, sharing no word or pair with the label"); + } + + #[test] + fn only_the_head_of_a_long_name_counts() { + let label: String = "ä€𝄞b".repeat(50); + let candidate = Candidate { id: "acme.long".into(), labels: vec![label.clone()] }; + assert_eq!(head(&label).chars().count(), MAX_NAME_CHARS); + assert_eq!(score(&format!("{label}and more words after the head"), &candidate), 1.0); + assert_eq!(head("short"), "short"); + } + + #[test] + fn words_split_camel_case_and_separators() { + assert_eq!(words("selectAll_onArtboard 2x"), ["select", "all", "on", "artboard", "2x"]); + assert_eq!(words("Acme\u{a0}Lasso-Tool"), ["acme", "lasso", "tool"]); + assert_eq!(id_words("tool:roundedRectangle"), ["rounded", "rectangle"]); + } +} diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs new file mode 100644 index 00000000..6281868e --- /dev/null +++ b/crates/keymap/src/keymap.rs @@ -0,0 +1,947 @@ +//! A command catalogue with default bindings, the user's overrides, dispatch and conflicts. + +pub(crate) mod overrides; + +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::chord::{Chord, ParseError, Platform, Trigger}; +use crate::command::{CommandRef, MAX_KEY}; +use crate::scope::{Active, Scope, ScopeKind, Scopes}; + +pub use overrides::Overrides; + +/// Most commands a catalogue or an [`Overrides`] holds. +pub const MAX_COMMANDS: usize = 4096; +/// Most bindings per command. +pub const MAX_BINDINGS: usize = 16; +/// Longest binding text, in bytes. +pub const MAX_BINDING_TEXT: usize = 128; +/// Most entries [`Keymap::conflicts`] returns. +pub const MAX_CONFLICTS: usize = 4096; + +/// What a binding does when pressed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + #[default] + Command, + /// Selects a tool. Tools of one group share keys and cycle; tools without a group name form one + /// group together. + Tool { group: Option }, +} + +/// A trigger in a scope. `scope: None` is the command's home scope. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Binding { + pub trigger: Trigger, + pub scope: Option, +} + +/// Why binding text was refused. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BindingError { + TooLong, + /// The part before `": "` is not a scope name. + Scope, + Trigger(ParseError), +} + +impl fmt::Display for BindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingError::TooLong => write!(f, "binding text over {MAX_BINDING_TEXT} bytes"), + BindingError::Scope => f.write_str("bad scope name"), + BindingError::Trigger(e) => e.fmt(f), + } + } +} + +impl std::error::Error for BindingError {} + +impl Binding { + /// A binding in the command's home scope. + pub fn new(trigger: impl Into) -> Binding { + Binding { trigger: trigger.into(), scope: None } + } + + pub fn in_scope(trigger: impl Into, scope: Scope) -> Binding { + Binding { trigger: trigger.into(), scope: Some(scope) } + } + + /// Reads `` or `: `. The whole text is tried as a trigger first, so + /// `Cmd+: Cmd+:` is the Colon chord pressed twice; otherwise it is split at the first `": "`. + pub fn parse(text: &str) -> Result { + if text.len() > MAX_BINDING_TEXT { + return Err(BindingError::TooLong); + } + let whole = match Trigger::parse(text.trim()) { + Ok(trigger) => return Ok(Binding::new(trigger)), + Err(e) => e, + }; + match text.split_once(": ") { + Some((scope, trigger)) => { + let scope = Scope::new(scope.trim()).ok_or(BindingError::Scope)?; + let trigger = Trigger::parse(trigger.trim()).map_err(BindingError::Trigger)?; + Ok(Binding::in_scope(trigger, scope)) + } + None => Err(BindingError::Trigger(whole)), + } + } + + /// The binding text for a command whose home scope is `home`: the scope is written only when + /// it differs. + pub fn text(&self, home: &Scope) -> String { + match &self.scope { + Some(s) if s != home => format!("{s}: {}", self.trigger), + _ => self.trigger.to_string(), + } + } + + /// The scope, `home` when unset. + pub fn scope_or<'a>(&'a self, home: &'a Scope) -> &'a Scope { + self.scope.as_ref().unwrap_or(home) + } +} + +impl fmt::Display for Binding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.scope { + Some(s) => write!(f, "{s}: {}", self.trigger), + None => write!(f, "{}", self.trigger), + } + } +} + +impl Serialize for Binding { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Binding { + fn deserialize>(deserializer: D) -> Result { + let text = >::deserialize(deserializer)?; + Binding::parse(&text).map_err(serde::de::Error::custom) + } +} + +/// A command the user can bind, as the app describes it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bindable { + pub command: CommandRef, + pub label: String, + /// Menu paths, the main one first; used by search and import. + #[serde(default)] + pub paths: Vec>, + /// Other names: labels in other UI languages, older names. + #[serde(default)] + pub aliases: Vec, + /// Home scope. + pub scope: Scope, + #[serde(default)] + pub kind: Kind, + #[serde(default)] + pub defaults: Vec, +} + +impl Bindable { + /// A command in `scope` without default bindings. + pub fn new(command: impl Into, label: impl Into, scope: Scope) -> Bindable { + Bindable { + command: command.into(), + label: label.into(), + paths: Vec::new(), + aliases: Vec::new(), + scope, + kind: Kind::Command, + defaults: Vec::new(), + } + } + + pub fn with_kind(mut self, kind: Kind) -> Bindable { + self.kind = kind; + self + } + + pub fn with_default(mut self, binding: Binding) -> Bindable { + self.defaults.push(binding); + self + } + + pub fn with_path(mut self, path: Vec) -> Bindable { + self.paths.push(path); + self + } + + pub fn with_alias(mut self, alias: impl Into) -> Bindable { + self.aliases.push(alias.into()); + self + } +} + +/// Which chords work while a text field has the keyboard. Bindings in an active context always +/// work: a `Text: Cmd+B` binding is meant for typing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TypingRule { + /// Plain chords (no cmd, ctrl or alt) go to the text; function keys still fire. + #[default] + PlainKeysWait, + /// Only chords with the command modifier and function keys fire. + CommandKeysOnly, + /// Nothing fires. + AllWait, +} + +impl TypingRule { + /// Whether `chord` may fire while typing. + pub fn fires(self, chord: &Chord) -> bool { + match self { + TypingRule::PlainKeysWait => !chord.is_plain() || chord.is_function_key(), + TypingRule::CommandKeysOnly => chord.has_command() || chord.is_function_key(), + TypingRule::AllWait => false, + } + } +} + +/// What a key press runs. +#[derive(Clone, Debug, PartialEq)] +pub enum Lookup { + None, + Run(CommandRef), + /// Tools of one group share the chord: the app picks the next one (catalogue order). + Cycle(Vec), +} + +/// What to do with other commands that hold a chord being assigned. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Policy { + /// Remove the chord from them. + #[default] + Steal, + /// Assign anyway; both keep it. + Keep, + /// Do not assign. + Refuse, +} + +/// A command's binding. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Holder { + pub command: CommandRef, + pub binding: Binding, +} + +/// The result of an assignment. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Assigned { + /// Bindings removed from other commands ([`Policy::Steal`]). + pub taken_from: Vec, + /// Bindings of other commands that now clash ([`Policy::Keep`]). + pub clashes: Vec, +} + +/// Why an assignment was refused. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AssignError { + UnknownCommand, + /// The system takes this chord. + Reserved(Trigger), + /// More than [`MAX_BINDINGS`] bindings. + TooMany, + /// Other commands hold the chord ([`Policy::Refuse`]). + Taken(Vec), + /// A scope that is neither registered in [`Scopes`] nor used by the catalogue. + UnknownScope(Scope), + /// The change needs a new [`Overrides`] entry and it already holds [`MAX_COMMANDS`]. + Full, +} + +impl fmt::Display for AssignError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AssignError::UnknownCommand => f.write_str("unknown command"), + AssignError::Reserved(t) => write!(f, "{t} is reserved by the system"), + AssignError::TooMany => write!(f, "more than {MAX_BINDINGS} bindings"), + AssignError::Taken(h) => write!(f, "in use by {} other command(s)", h.len()), + AssignError::UnknownScope(s) => write!(f, "unknown scope {s}"), + AssignError::Full => write!(f, "more than {MAX_COMMANDS} changed commands"), + } + } +} + +impl std::error::Error for AssignError {} + +/// Why loading dropped part of an [`Overrides`] entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DropReason { + KeyTooLong, + /// The value is not a list of texts. + NotAList, + /// Items of the list that are not text. + NotText, + TextTooLong, + /// Texts past [`MAX_BINDINGS`]. + TooManyBindings, +} + +/// Something wrong in the stored overrides. Load reports these instead of failing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Problem { + /// No command in the catalogue has this key; the entry is kept. + UnknownCommand { key: String }, + /// A text that does not parse; the entry keeps it. + BadBinding { key: String, text: String, error: BindingError }, + /// A reserved chord; not bound. + Reserved { key: String, text: String }, + /// A scope that is neither registered in [`Scopes`] nor used by the catalogue; not bound. + UnknownScope { key: String, text: String }, + /// Another entry names the same command; this one is ignored. + DuplicateKey { key: String }, + /// Part of an entry dropped while loading. + Dropped { key: String, reason: DropReason }, + /// Entries past [`MAX_COMMANDS`] dropped while loading. + TooManyCommands { ignored: usize }, +} + +/// How two bindings on one chord relate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ConflictKind { + /// Only one of the two can run. + Clash, + /// Both work: the nearer scope runs where it applies, the other elsewhere. + Shadow, +} + +/// Two commands on one chord. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Conflict { + /// The effective trigger on the keymap's platform. + pub chord: Trigger, + pub a: (CommandRef, Binding), + pub b: (CommandRef, Binding), + pub kind: ConflictKind, + /// Both bindings are defaults. + pub in_defaults: bool, +} + +/// A binding with its scope resolved. +type Bound = (Trigger, Scope); + +/// A command's bindings on one chord grouped by scope and tool group, for [`Keymap::conflicts`]. +struct Group<'a> { + scope: &'a Scope, + /// The tool group for tools, `None` for other commands. + tools: Option<&'a Option>, + /// (position in the chord's list, command, binding). + items: Vec<(usize, usize, &'a Bound)>, +} + +/// Two holders of one chord: their positions in the chord's list, whether they clash, and each +/// one's command and binding. +type Pair<'a> = (usize, usize, bool, (usize, &'a Bound), (usize, &'a Bound)); + +/// The catalogue, the scope registry, the platform and the user's overrides. +#[derive(Clone, Debug)] +pub struct Keymap { + catalogue: Vec, + keys: Vec, + index: HashMap, + defaults: Vec>, + current: Vec>, + /// Effective trigger → (command, position in its `current` list), kept in step with `current`. + by_trigger: HashMap>, + /// The overrides entry each command reads, when it has one. + stored: Vec>, + /// Home and default scopes of the catalogue. + catalogue_scopes: HashSet, + scopes: Scopes, + platform: Platform, + typing: TypingRule, + reserved: Vec, + overrides: Overrides, +} + +impl Keymap { + /// A keymap with default bindings. Entries past [`MAX_COMMANDS`], with a key over + /// [`MAX_KEY`] bytes, or repeating an earlier key are left out. + pub fn new(catalogue: Vec, scopes: Scopes, platform: Platform) -> Keymap { + let mut km = Keymap { + catalogue: Vec::new(), + keys: Vec::new(), + index: HashMap::new(), + defaults: Vec::new(), + current: Vec::new(), + by_trigger: HashMap::new(), + stored: Vec::new(), + catalogue_scopes: HashSet::new(), + scopes, + platform, + typing: TypingRule::default(), + reserved: Vec::new(), + overrides: Overrides::new(), + }; + for b in catalogue { + let key = b.command.key(); + if km.catalogue.len() >= MAX_COMMANDS || key.len() > MAX_KEY || km.index.contains_key(&key) { + continue; + } + let defaults = km.normalize(b.defaults.iter().cloned(), &b.scope); + km.catalogue_scopes.insert(b.scope.clone()); + km.catalogue_scopes.extend(defaults.iter().map(|(_, s)| s.clone())); + km.index.insert(key.clone(), km.catalogue.len()); + km.keys.push(key); + km.current.push(defaults.clone()); + km.defaults.push(defaults); + km.stored.push(None); + km.catalogue.push(b); + } + km.rebuild_index(); + km + } + + pub fn with_typing_rule(mut self, rule: TypingRule) -> Keymap { + self.typing = rule; + self + } + + /// Chords that cannot be assigned, e.g. [`reserved::system`](crate::reserved::system). + pub fn with_reserved(mut self, chords: Vec) -> Keymap { + self.reserved = chords; + self.reload(); + self + } + + pub fn with_overrides(mut self, overrides: Overrides) -> Keymap { + self.set_overrides(overrides); + self + } + + pub fn set_overrides(&mut self, overrides: Overrides) { + self.overrides = overrides; + self.reload(); + } + + pub fn overrides(&self) -> &Overrides { + &self.overrides + } + + pub fn platform(&self) -> Platform { + self.platform + } + + pub fn scopes(&self) -> &Scopes { + &self.scopes + } + + pub fn typing_rule(&self) -> TypingRule { + self.typing + } + + pub fn catalogue(&self) -> &[Bindable] { + &self.catalogue + } + + pub fn bindable(&self, command: &CommandRef) -> Option<&Bindable> { + self.find(command).map(|i| &self.catalogue[i]) + } + + /// The effective bindings of a command; empty for an unknown one. + pub fn bindings(&self, command: &CommandRef) -> Vec { + self.find(command).map(|i| self.current[i].iter().map(|b| self.public(i, b)).collect()).unwrap_or_default() + } + + /// The default bindings of a command; empty for an unknown one. + pub fn default_bindings(&self, command: &CommandRef) -> Vec { + self.find(command).map(|i| self.defaults[i].iter().map(|b| self.public(i, b)).collect()).unwrap_or_default() + } + + pub fn is_reserved(&self, chord: &Chord) -> bool { + let c = chord.effective(self.platform); + self.reserved.iter().any(|r| r.effective(self.platform) == c) + } + + /// Whether bindings may use `scope`: the application, a scope registered in [`Scopes`], or a + /// home or default scope of the catalogue. + pub fn knows_scope(&self, scope: &Scope) -> bool { + scope.is_app() || self.scopes.knows(scope) || self.catalogue_scopes.contains(scope) + } + + /// The command `chord` (pressed twice when `double`) runs with `active` focus. + pub fn lookup(&self, chord: &Chord, double: bool, active: &Active) -> Lookup { + let want = Trigger { chord: chord.effective(self.platform), double }; + let held = active.typing && !self.typing.fires(&want.chord); + let Some(holders) = self.by_trigger.get(&want) else { return Lookup::None }; + // Each command's nearest rank; `holders` is in catalogue order. + let mut ranks: Vec<(usize, u8)> = Vec::new(); + for &(i, k) in holders { + let Some(r) = self.at(i, k).and_then(|(_, s)| self.scopes.rank(s, active)) else { continue }; + // The typing rule holds back everything except bindings of an active context (rank 0). + if held && r != 0 { + continue; + } + match ranks.last_mut() { + Some((j, best)) if *j == i => *best = (*best).min(r), + _ => ranks.push((i, r)), + } + } + let Some(best) = ranks.iter().map(|(_, r)| *r).min() else { return Lookup::None }; + let winners: Vec = ranks.iter().filter(|(_, r)| *r == best).map(|(i, _)| *i).collect(); + match winners.as_slice() { + [] => Lookup::None, + [i] => Lookup::Run(self.catalogue[*i].command.clone()), + [first, rest @ ..] => { + if rest.iter().all(|j| self.same_group(*first, *j)) { + Lookup::Cycle(winners.iter().map(|i| self.catalogue[*i].command.clone()).collect()) + } else { + Lookup::Run(self.catalogue[*first].command.clone()) + } + } + } + } + + /// Adds a binding to a command. + pub fn assign(&mut self, command: &CommandRef, binding: Binding, policy: Policy) -> Result { + let i = self.find(command).ok_or(AssignError::UnknownCommand)?; + let new = self.bound(i, &binding); + if self.current[i].iter().any(|b| self.same(b, &new)) { + return Ok(Assigned::default()); + } + let mut list = self.current[i].clone(); + list.push(new.clone()); + self.apply(i, list, &[new], policy) + } + + /// Replaces all bindings of a command. + pub fn set_bindings(&mut self, command: &CommandRef, bindings: Vec, policy: Policy) -> Result { + let i = self.find(command).ok_or(AssignError::UnknownCommand)?; + if bindings.len() > MAX_BINDINGS { + return Err(AssignError::TooMany); + } + let home = self.catalogue[i].scope.clone(); + let list = self.normalize(bindings, &home); + let new: Vec = list.iter().filter(|b| !self.current[i].iter().any(|c| self.same(c, b))).cloned().collect(); + self.apply(i, list, &new, policy) + } + + /// Removes one binding (matched by effective trigger and scope). `false` when the command does + /// not have it, or when the change needs a new [`Overrides`] entry and the overrides are full + /// ([`AssignError::Full`]); nothing changes then. + pub fn remove(&mut self, command: &CommandRef, binding: &Binding) -> bool { + let Some(i) = self.find(command) else { return false }; + let gone = self.bound(i, binding); + let before = self.current[i].len(); + let list: Vec = self.current[i].iter().filter(|b| !self.same(b, &gone)).cloned().collect(); + list.len() != before && self.commit(vec![(i, list)]).is_ok() + } + + /// Leaves a command without shortcuts, in every scope. `false` for an unknown command, or when + /// the overrides are full as for [`Keymap::remove`]. + pub fn clear(&mut self, command: &CommandRef) -> bool { + let Some(i) = self.find(command) else { return false }; + self.commit(vec![(i, Vec::new())]).is_ok() + } + + /// Gives a command its default bindings back. + pub fn reset(&mut self, command: &CommandRef) -> bool { + let Some(i) = self.find(command) else { return false }; + // The whole entry goes, texts this build cannot use included. + if let Some(old) = self.stored[i].take() { + self.overrides.remove(&old); + } + self.set_current(i, self.defaults[i].clone()); + true + } + + /// Every command gets its defaults; unknown entries go too. + pub fn reset_all(&mut self) { + self.overrides.clear(); + self.reload(); + } + + /// Pairs of commands on one chord, at most [`MAX_CONFLICTS`], in chord order, then in + /// catalogue order. Tools of one group and bindings of panels that are never active together + /// are left out; with panel fallthrough two unrelated panels on one chord tie in a third panel, + /// which is reported as a [`ConflictKind::Shadow`]. + /// + /// When the cap cuts a chord's pairs, the pairs kept are the first found comparing the + /// chord's holders grouped by scope and tool group, group by group in the order each group + /// first appears in the catalogue; the kept pairs are still returned in chord order, then + /// catalogue order. + pub fn conflicts(&self) -> Vec { + let mut chords: Vec<(&Trigger, &BTreeSet<(usize, usize)>)> = self.by_trigger.iter().filter(|(_, h)| h.len() > 1).collect(); + chords.sort_by_key(|(t, _)| **t); + let mut out = Vec::new(); + for (chord, holders) in chords { + if !self.chord_conflicts(*chord, holders, &mut out) { + break; + } + } + out + } + + /// What is wrong with the stored overrides: load notes, unknown commands, texts that do not + /// parse, reserved chords, unknown scopes, duplicate entries. + pub fn problems(&self) -> Vec { + let mut out = self.overrides.load_notes().to_vec(); + for (key, texts) in self.overrides.iter() { + let Some(i) = self.resolve_key(key) else { + out.push(Problem::UnknownCommand { key: key.to_string() }); + continue; + }; + if self.stored[i].as_deref() != Some(key) { + out.push(Problem::DuplicateKey { key: key.to_string() }); + continue; + } + let home = &self.catalogue[i].scope; + for text in texts { + let (key, text) = (key.to_string(), text.clone()); + match Binding::parse(&text) { + Err(error) => out.push(Problem::BadBinding { key, text, error }), + Ok(b) if self.is_reserved(&b.trigger.chord) => out.push(Problem::Reserved { key, text }), + Ok(b) if !self.usable_scope(b.scope_or(home), home) => out.push(Problem::UnknownScope { key, text }), + Ok(_) => {} + } + } + } + out + } + + /// Bindings of other commands that only one of them and `bindings` on `command` could run, + /// leaving out tools of the same group and twins (as [`Policy::Steal`] does). + pub(crate) fn clashing(&self, command: &CommandRef, bindings: &[Binding]) -> Vec { + let Some(i) = self.find(command) else { return Vec::new() }; + let new: Vec = bindings.iter().map(|b| self.bound(i, b)).collect(); + self.clashes(i, &new).into_iter().map(|(j, b)| Holder { command: self.catalogue[j].command.clone(), binding: self.public(j, &b) }).collect() + } + + /// Whether binding `x` of command `a` and binding `y` of another command `b` clash, by the + /// rules of [`Keymap::clashing`]. + pub(crate) fn pair_clashes(&self, a: &CommandRef, x: &Binding, b: &CommandRef, y: &Binding) -> bool { + let (Some(i), Some(j)) = (self.find(a), self.find(b)) else { return false }; + if i == j || self.same_group(i, j) { + return false; + } + let (bx, by) = (self.bound(i, x), self.bound(j, y)); + let t = bx.0.effective(self.platform); + t == by.0.effective(self.platform) && !self.twins(i, j, t) && self.scopes.relation(&bx.1, &by.1) == Some(true) + } + + fn find(&self, command: &CommandRef) -> Option { + self.index.get(&command.key()).copied() + } + + fn resolve_key(&self, key: &str) -> Option { + self.index.get(key).or_else(|| CommandRef::parse_key(key).and_then(|c| self.index.get(&c.key()))).copied() + } + + fn at(&self, i: usize, k: usize) -> Option<&Bound> { + self.current.get(i).and_then(|l| l.get(k)) + } + + fn bound(&self, i: usize, binding: &Binding) -> Bound { + (binding.trigger, binding.scope_or(&self.catalogue[i].scope).clone()) + } + + fn public(&self, i: usize, (trigger, scope): &Bound) -> Binding { + let scope = (*scope != self.catalogue[i].scope).then(|| scope.clone()); + Binding { trigger: *trigger, scope } + } + + fn same(&self, a: &Bound, b: &Bound) -> bool { + a.1 == b.1 && a.0.effective(self.platform) == b.0.effective(self.platform) + } + + /// Whether a stored binding of a command with home scope `home` may use `scope`. + fn usable_scope(&self, scope: &Scope, home: &Scope) -> bool { + scope == home || self.knows_scope(scope) + } + + /// Resolves scopes, drops repeats and caps the count. + fn normalize(&self, bindings: impl IntoIterator, home: &Scope) -> Vec { + let mut out: Vec = Vec::new(); + for b in bindings { + let bound = (b.trigger, b.scope_or(home).clone()); + if out.len() < MAX_BINDINGS && !out.contains(&bound) { + out.push(bound); + } + } + out + } + + fn same_group(&self, i: usize, j: usize) -> bool { + match (&self.catalogue[i].kind, &self.catalogue[j].kind) { + (Kind::Tool { group: a }, Kind::Tool { group: b }) => a == b, + _ => false, + } + } + + /// Both commands have `trigger` among their defaults: they share it by design. + fn twins(&self, i: usize, j: usize, trigger: Trigger) -> bool { + let has = |k: usize| self.defaults[k].iter().any(|(t, _)| t.effective(self.platform) == trigger); + has(i) && has(j) + } + + /// Bindings of other commands that only one of `new` and them could run. + fn clashes(&self, i: usize, new: &[Bound]) -> Vec<(usize, Bound)> { + let mut out = Vec::new(); + let mut seen: HashSet<(usize, usize)> = HashSet::new(); + for (t, s) in new { + let t = t.effective(self.platform); + let Some(holders) = self.by_trigger.get(&t) else { continue }; + for &(j, k) in holders { + if j == i || self.same_group(i, j) || self.twins(i, j, t) { + continue; + } + let Some(b) = self.at(j, k) else { continue }; + if self.scopes.relation(s, &b.1) == Some(true) && seen.insert((j, k)) { + out.push((j, b.clone())); + } + } + } + out + } + + /// How two scopes on one chord relate for [`Keymap::conflicts`]: [`Scopes::relation`], and with + /// panel fallthrough two unrelated panels shadow (they tie in any third panel). + fn conflict_relation(&self, a: &Scope, b: &Scope) -> Option { + self.scopes.relation(a, b).or_else(|| { + let panels = self.scopes.kind(a) == ScopeKind::Panel && self.scopes.kind(b) == ScopeKind::Panel; + (panels && self.scopes.panel_fallthrough()).then_some(false) + }) + } + + /// Adds the conflicts on one chord to `out`; `false` once `out` is full. + /// + /// The holders are grouped by scope and tool group, and only groups that relate are compared, + /// so pairs that are left out (unrelated panels, tools of one group) cost nothing per pair. + fn chord_conflicts(&self, chord: Trigger, holders: &BTreeSet<(usize, usize)>, out: &mut Vec) -> bool { + let mut groups: Vec = Vec::new(); + let mut group_of: HashMap<(&Scope, Option<&Option>), usize> = HashMap::new(); + for (pos, &(i, k)) in holders.iter().enumerate() { + let Some(b) = self.at(i, k) else { continue }; + let tools = match &self.catalogue[i].kind { + Kind::Tool { group } => Some(group), + _ => None, + }; + let g = *group_of.entry((&b.1, tools)).or_insert_with(|| { + groups.push(Group { scope: &b.1, tools, items: Vec::new() }); + groups.len() - 1 + }); + if let Some(group) = groups.get_mut(g) { + group.items.push((pos, i, b)); + } + } + let room = MAX_CONFLICTS.saturating_sub(out.len()); + let mut found: Vec = Vec::new(); + 'groups: for (n, g) in groups.iter().enumerate() { + for (m, h) in groups.iter().enumerate().skip(n) { + if g.tools.is_some() && g.tools == h.tools { + continue; + } + let Some(clash) = self.conflict_relation(g.scope, h.scope) else { continue }; + let same = m == n; + for (x, &(pa, i, a)) in g.items.iter().enumerate() { + let rest = if same { g.items.get(x + 1..).unwrap_or_default() } else { &h.items }; + for &(pb, j, b) in rest { + if i == j { + continue; + } + if found.len() >= room { + break 'groups; + } + let pair = if pa < pb { (pa, pb, clash, (i, a), (j, b)) } else { (pb, pa, clash, (j, b), (i, a)) }; + found.push(pair); + } + } + } + } + found.sort_by_key(|(pa, pb, ..)| (*pa, *pb)); + for (_, _, clash, (i, a), (j, b)) in found { + out.push(Conflict { + chord, + a: (self.catalogue[i].command.clone(), self.public(i, a)), + b: (self.catalogue[j].command.clone(), self.public(j, b)), + kind: if clash { ConflictKind::Clash } else { ConflictKind::Shadow }, + in_defaults: self.defaults[i].contains(a) && self.defaults[j].contains(b), + }); + } + out.len() < MAX_CONFLICTS + } + + fn apply(&mut self, i: usize, list: Vec, new: &[Bound], policy: Policy) -> Result { + if list.len() > MAX_BINDINGS { + return Err(AssignError::TooMany); + } + if let Some((t, _)) = new.iter().find(|(t, _)| self.is_reserved(&t.chord)) { + return Err(AssignError::Reserved(*t)); + } + let home = &self.catalogue[i].scope; + if let Some((_, s)) = new.iter().find(|(_, s)| !self.usable_scope(s, home)) { + return Err(AssignError::UnknownScope(s.clone())); + } + let clashes = self.clashes(i, new); + let holders: Vec = + clashes.iter().map(|(j, b)| Holder { command: self.catalogue[*j].command.clone(), binding: self.public(*j, b) }).collect(); + let mut done = Assigned::default(); + let mut writes: Vec<(usize, Vec)> = Vec::new(); + match policy { + Policy::Refuse if !holders.is_empty() => return Err(AssignError::Taken(holders)), + Policy::Refuse => {} + Policy::Keep => done.clashes = holders, + Policy::Steal => { + let mut victims: Vec = Vec::new(); + let mut gone: HashMap> = HashMap::new(); + for (j, b) in &clashes { + let list = gone.entry(*j).or_default(); + if list.is_empty() { + victims.push(*j); + } + list.push(b); + } + for j in victims { + let gone = gone.get(&j).map(Vec::as_slice).unwrap_or_default(); + let rest: Vec = self.current[j].iter().filter(|o| !gone.contains(o)).cloned().collect(); + writes.push((j, rest)); + } + done.taken_from = holders; + } + } + writes.push((i, list)); + self.commit(writes)?; + Ok(done) + } + + /// Stores several commands' bindings, or none of them when the overrides lack room for the + /// new entries. + fn commit(&mut self, writes: Vec<(usize, Vec)>) -> Result<(), AssignError> { + let needed = writes.iter().filter(|(i, list)| self.stored[*i].is_none() && !self.is_default(*i, list)).count(); + if self.overrides.len() + needed > MAX_COMMANDS { + return Err(AssignError::Full); + } + for (i, list) in writes { + self.store(i, list); + } + Ok(()) + } + + /// Writes a command's bindings to the overrides, dropping the entry when they equal the + /// defaults. Texts of the old entry this build cannot use stay in it, after the bindings and up + /// to [`MAX_BINDINGS`], unless the command is left without bindings: an entry of only such + /// texts loads as the defaults. Callers check for room first ([`Keymap::commit`]); texts always + /// fit, since a scope name, `": "` and the longest double press stay under + /// [`MAX_BINDING_TEXT`]. + fn store(&mut self, i: usize, list: Vec) { + let mut kept = if list.is_empty() { Vec::new() } else { self.unusable_texts(i) }; + kept.truncate(MAX_BINDINGS.saturating_sub(list.len())); + if let Some(old) = self.stored[i].take() { + self.overrides.remove(&old); + } + if !kept.is_empty() || !self.is_default(i, &list) { + let home = &self.catalogue[i].scope; + let mut texts: Vec = list.iter().map(|b| self.public(i, b).text(home)).collect(); + texts.extend(kept); + if self.overrides.insert(self.keys[i].clone(), texts) { + self.stored[i] = Some(self.keys[i].clone()); + } + } + self.set_current(i, list); + } + + /// Whether `list` is the command's defaults, comparing effective triggers. + fn is_default(&self, i: usize, list: &[Bound]) -> bool { + let defaults = &self.defaults[i]; + list.len() == defaults.len() && list.iter().zip(defaults).all(|(a, b)| self.same(a, b)) + } + + /// Whether a stored binding of a command with home scope `home` works here. + fn usable(&self, binding: &Binding, home: &Scope) -> bool { + !self.is_reserved(&binding.trigger.chord) && self.usable_scope(binding.scope_or(home), home) + } + + /// The texts of a command's overrides entry this build cannot use: a key name from a newer + /// version, a scope this app lacks, a chord this platform reserves. + fn unusable_texts(&self, i: usize) -> Vec { + let Some(texts) = self.stored[i].as_deref().and_then(|key| self.overrides.get(key)) else { return Vec::new() }; + let home = &self.catalogue[i].scope; + texts.iter().filter(|t| !Binding::parse(t).is_ok_and(|b| self.usable(&b, home))).cloned().collect() + } + + fn set_current(&mut self, i: usize, list: Vec) { + self.unindex(i); + if let Some(c) = self.current.get_mut(i) { + *c = list; + } + self.index_command(i); + } + + fn index_command(&mut self, i: usize) { + let Some(list) = self.current.get(i) else { return }; + for (k, (t, _)) in list.iter().enumerate() { + self.by_trigger.entry(t.effective(self.platform)).or_default().insert((i, k)); + } + } + + fn unindex(&mut self, i: usize) { + let Some(list) = self.current.get(i) else { return }; + for (k, (t, _)) in list.iter().enumerate() { + let t = t.effective(self.platform); + if let Some(holders) = self.by_trigger.get_mut(&t) { + holders.remove(&(i, k)); + if holders.is_empty() { + self.by_trigger.remove(&t); + } + } + } + } + + fn rebuild_index(&mut self) { + self.by_trigger.clear(); + for i in 0..self.current.len() { + self.index_command(i); + } + } + + /// Recomputes every command's bindings from the defaults and the overrides. + fn reload(&mut self) { + self.stored.iter_mut().for_each(|s| *s = None); + self.current.clone_from(&self.defaults); + // Exact keys first, so a canonical entry wins over a hand-written variant of it. + let mut entries: Vec<(&str, &[String])> = self.overrides.iter().collect(); + entries.sort_by_key(|(k, _)| !self.index.contains_key(*k)); + let mut found: Vec<(usize, String, Vec)> = Vec::new(); + let mut taken: HashSet = HashSet::new(); + for (key, texts) in entries { + let Some(i) = self.resolve_key(key) else { continue }; + if !taken.insert(i) { + continue; + } + let home = &self.catalogue[i].scope; + let usable = texts.iter().filter_map(|t| Binding::parse(t).ok()).filter(|b| self.usable(b, home)); + let list = self.normalize(usable, home); + // An entry none of whose texts is usable here (a key name from a newer version, a scope + // this app lacks, a chord this platform reserves) keeps the defaults; `[]` means no + // shortcut. + let list = if list.is_empty() && !texts.is_empty() { self.defaults[i].clone() } else { list }; + found.push((i, key.to_string(), list)); + } + for (i, key, list) in found { + self.stored[i] = Some(key); + self.current[i] = list; + } + self.rebuild_index(); + } +} diff --git a/crates/keymap/src/keymap/overrides.rs b/crates/keymap/src/keymap/overrides.rs new file mode 100644 index 00000000..914b13e6 --- /dev/null +++ b/crates/keymap/src/keymap/overrides.rs @@ -0,0 +1,337 @@ +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; + +use super::{DropReason, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, Problem}; +use crate::command::MAX_KEY; + +// Load notes are kept for `Keymap::problems`; a hostile file must not grow them without bound. +const MAX_NOTES: usize = 64; + +/// The user's changes to the default bindings: binding key → the full list of binding texts +/// that replaces the command's defaults (`[]` = no shortcut). +/// +/// Stored as an array of `{ command, keys }` items in key order, e.g. `[[shortcuts]]` blocks in +/// craft-settings; the map form `{ "": [texts] }` loads too. Loading never fails: +/// entries for unknown commands and texts that do not parse are kept verbatim; values that are not +/// text, overlong keys and texts, repeated keys (the first entry stays) and entries past the caps +/// are dropped, and a value that is neither a list nor a map loads as no overrides. All of these +/// are reported by [`Keymap::problems`](super::Keymap::problems). +#[derive(Clone, Debug, Default)] +pub struct Overrides { + map: BTreeMap>, + notes: Vec, +} + +impl PartialEq for Overrides { + fn eq(&self, other: &Overrides) -> bool { + self.map == other.map + } +} + +impl Eq for Overrides {} + +impl Overrides { + pub const fn new() -> Overrides { + Overrides { map: BTreeMap::new(), notes: Vec::new() } + } + + pub fn get(&self, key: &str) -> Option<&[String]> { + self.map.get(key).map(Vec::as_slice) + } + + pub fn iter(&self) -> impl Iterator { + self.map.iter().map(|(k, v)| (k.as_str(), v.as_slice())) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Sets an entry verbatim. `false` (and nothing stored) for a key over [`MAX_KEY`] bytes, a + /// list over [`MAX_BINDINGS`], a text over [`MAX_BINDING_TEXT`] bytes, or a new key past + /// [`MAX_COMMANDS`] entries. + pub fn insert(&mut self, key: impl Into, texts: Vec) -> bool { + let key = key.into(); + let fits = key.len() <= MAX_KEY + && texts.len() <= MAX_BINDINGS + && texts.iter().all(|t| t.len() <= MAX_BINDING_TEXT) + && (self.map.contains_key(&key) || self.map.len() < MAX_COMMANDS); + if fits { + self.map.insert(key, texts); + } + fits + } + + pub fn remove(&mut self, key: &str) -> Option> { + self.map.remove(key) + } + + /// Removes every entry: all commands get their defaults. + pub fn clear(&mut self) { + self.map.clear(); + self.notes.clear(); + } + + /// What loading dropped. + pub fn load_notes(&self) -> &[Problem] { + &self.notes + } + + fn note(&mut self, problem: Problem) { + if self.notes.len() < MAX_NOTES { + self.notes.push(problem); + } + } +} + +// Written as an array of `{ command, keys }` items, not a map: craft-settings merges tables key +// by key and keeps keys a value no longer has, but replaces arrays whole, so a reset entry leaves +// the file only in this form. +impl Serialize for Overrides { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.map.iter().map(|(command, keys)| Item { command, keys })) + } +} + +#[derive(Serialize)] +struct Item<'a> { + command: &'a str, + keys: &'a [String], +} + +impl<'de> Deserialize<'de> for Overrides { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(OverridesVisitor) + } +} + +struct OverridesVisitor; + +/// Collects entries with the caps applied. +#[derive(Default)] +struct Builder { + out: Overrides, + ignored: usize, +} + +impl Builder { + /// Whether an entry for `key` can be stored; notes why not. + fn admits(&mut self, key: &str) -> bool { + if key.len() > MAX_KEY { + self.out.note(Problem::Dropped { key: clip(key, MAX_KEY), reason: DropReason::KeyTooLong }); + return false; + } + if self.out.map.len() >= MAX_COMMANDS && !self.out.map.contains_key(key) { + self.ignored += 1; + return false; + } + true + } + + /// Stores an entry; a second entry for the same key is ignored and noted. + fn add(&mut self, key: String, value: Lenient) { + let out = &mut self.out; + if out.map.contains_key(&key) { + out.note(Problem::DuplicateKey { key }); + return; + } + let texts = match value { + Lenient::List { texts, other, extra } => { + if other { + out.note(Problem::Dropped { key: key.clone(), reason: DropReason::NotText }); + } + if extra > 0 { + out.note(Problem::Dropped { key: key.clone(), reason: DropReason::TooManyBindings }); + } + texts + } + Lenient::Text(t) => vec![t], + Lenient::Other => { + out.note(Problem::Dropped { key, reason: DropReason::NotAList }); + return; + } + }; + let (texts, long): (Vec, Vec) = texts.into_iter().partition(|t| t.len() <= MAX_BINDING_TEXT); + if !long.is_empty() { + out.note(Problem::Dropped { key: key.clone(), reason: DropReason::TextTooLong }); + } + out.map.insert(key, texts); + } + + fn finish(mut self) -> Overrides { + if self.ignored > 0 { + let ignored = self.ignored; + self.out.note(Problem::TooManyCommands { ignored }); + } + self.out + } +} + +/// No overrides, with a note that the value was not a list or map. +fn not_a_list() -> Overrides { + let mut out = Overrides::new(); + out.note(Problem::Dropped { key: String::new(), reason: DropReason::NotAList }); + out +} + +macro_rules! not_a_list { + ($($name:ident: $t:ty),*) => { + $(fn $name(self, _: $t) -> Result { + Ok(not_a_list()) + })* + }; +} + +impl<'de> Visitor<'de> for OverridesVisitor { + type Value = Overrides; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list of `{ command, keys }` items or a map of binding keys to binding texts") + } + + not_a_list!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); + + fn visit_unit(self) -> Result { + Ok(not_a_list()) + } + + fn visit_none(self) -> Result { + Ok(not_a_list()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(OverridesVisitor) + } + + /// The array form, `[{ command = "…", keys = […] }, …]`. + fn visit_seq>(self, mut access: A) -> Result { + let mut b = Builder::default(); + while let Some(item) = access.next_element::()? { + let command = item.get("command").and_then(Value::as_str); + let keys = item.get("keys").map(Lenient::deserialize).and_then(Result::ok); + match (command, keys) { + (Some(command), Some(keys)) => { + if b.admits(command) { + b.add(command.to_string(), keys); + } + } + (command, _) => { + let key = clip(command.unwrap_or_default(), MAX_KEY); + b.out.note(Problem::Dropped { key, reason: DropReason::NotAList }); + } + } + } + Ok(b.finish()) + } + + /// The map form, binding key → texts (hand edits, older files). An empty text means no + /// shortcut, as in the apps that store one text per command and `""` for a removed shortcut. + fn visit_map>(self, mut access: A) -> Result { + let mut b = Builder::default(); + while let Some(key) = access.next_key::()? { + if b.admits(&key) { + let value = match access.next_value::()? { + Lenient::Text(t) if t.trim().is_empty() => Lenient::List { texts: Vec::new(), other: false, extra: 0 }, + Lenient::List { mut texts, other, extra } => { + texts.retain(|t| !t.trim().is_empty()); + Lenient::List { texts, other, extra } + } + value => value, + }; + b.add(key, value); + } else { + access.next_value::()?; + } + } + Ok(b.finish()) + } +} + +/// Cuts `s` to at most `max` bytes on a char boundary. +pub(crate) fn clip(s: &str, max: usize) -> String { + let mut end = s.len().min(max); + while !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +/// An entry's value as found: a list (texts kept up to the cap), one text, or anything else. +enum Lenient { + List { texts: Vec, other: bool, extra: usize }, + Text(String), + Other, +} + +impl<'de> Deserialize<'de> for Lenient { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(LenientVisitor) + } +} + +struct LenientVisitor; + +macro_rules! other { + ($($name:ident: $t:ty),*) => { + $(fn $name(self, _: $t) -> Result { + Ok(Lenient::Other) + })* + }; +} + +impl<'de> Visitor<'de> for LenientVisitor { + type Value = Lenient; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list of binding texts") + } + + fn visit_str(self, v: &str) -> Result { + Ok(Lenient::Text(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Lenient::Text(v)) + } + + other!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_bytes: &[u8], visit_char: char); + + fn visit_unit(self) -> Result { + Ok(Lenient::Other) + } + + fn visit_none(self) -> Result { + Ok(Lenient::Other) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(LenientVisitor) + } + + fn visit_map>(self, mut access: A) -> Result { + while access.next_entry::()?.is_some() {} + Ok(Lenient::Other) + } + + fn visit_seq>(self, mut access: A) -> Result { + let mut texts = Vec::new(); + let mut other = false; + let mut extra = 0usize; + while let Some(item) = access.next_element::()? { + match item { + Lenient::Text(t) if texts.len() < MAX_BINDINGS => texts.push(t), + Lenient::Text(_) => extra += 1, + _ => other = true, + } + } + Ok(Lenient::List { texts, other, extra }) + } +} diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs new file mode 100644 index 00000000..bebf33f7 --- /dev/null +++ b/crates/keymap/src/lib.rs @@ -0,0 +1,146 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Keyboard bindings for the Craft suite. +//! +//! [`chord`] holds the key and modifier types, the canonical text form that settings and control +//! protocols store, display text per platform, and a reader for other applications' key text. +//! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's +//! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), +//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] +//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module +//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's +//! key events. +//! +//! ``` +//! use craft_keymap::{Chord, Platform}; +//! +//! let chord = Chord::parse("⇧⌘Z").unwrap(); +//! assert_eq!(chord.to_string(), "Cmd+Shift+Z"); +//! assert_eq!(chord.display(Platform::Mac), "⇧⌘Z"); +//! assert_eq!(chord.display(Platform::Windows), "Ctrl+Shift+Z"); +//! ``` +//! +//! # A catalogue and dispatch +//! +//! The app lists every command it can bind. A command with parameters has the key +//! `window.panel {"panel":"layers"}` in settings and reports. Tools of one group share a key and +//! cycle. A binding can name a scope other than the command's home scope. +//! +//! ``` +//! use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Keymap, Kind, Lookup, Platform, Scope, Scopes}; +//! use serde_json::json; +//! +//! let key = |text: &str| Binding::parse(text).unwrap(); +//! let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); +//! let select = Kind::Tool { group: Some("select".into()) }; +//! let catalogue = vec![ +//! Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(key("Cmd+S")), +//! Bindable::new(layers.clone(), "Layers", Scope::app()).with_path(vec!["Window".into(), "Layers".into()]).with_default(key("F7")), +//! Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(select.clone()).with_default(key("M")), +//! Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(select).with_default(key("M")), +//! // Home scope: the Timeline panel, where it wins over the app's L. +//! Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(key("L")), +//! Bindable::new("layer.lock", "Lock Layer", Scope::app()).with_default(key("L")), +//! // An app command whose binding works only in the Text context. +//! Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), +//! ]; +//! let keymap = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); +//! assert_eq!(layers.key(), r#"window.panel {"panel":"layers"}"#); +//! +//! // Dispatch: the chord pressed, whether it is a double press, and what has the keyboard. +//! let press = |text: &str, active: &Active| keymap.lookup(&Chord::parse(text).unwrap(), false, active); +//! assert_eq!(press("F7", &Active::NONE), Lookup::Run(layers)); +//! assert_eq!(press("L", &Active::panel("Timeline")), Lookup::Run("timeline.loop".into())); +//! assert_eq!(press("L", &Active::NONE), Lookup::Run("layer.lock".into())); +//! assert_eq!(press("M", &Active::NONE), Lookup::Cycle(vec!["tool.marquee".into(), "tool.ellipse".into()])); +//! let editing = Active { contexts: &["Text"], typing: true, ..Active::NONE }; +//! assert_eq!(press("Cmd+B", &editing), Lookup::Run("type.bold".into())); +//! assert_eq!(press("M", &editing), Lookup::None, "plain keys go to the text field"); +//! ``` +//! +//! With the `egui` feature, `egui::Dispatcher` does this for a frame's key events. +//! +//! # Storing overrides +//! +//! [`Overrides`] is a field of the app's craft-settings type. It is stored as an array of +//! `{ command, keys }` tables, not as a table keyed by command: craft-settings merges tables key +//! by key with what the file holds, but replaces an array whole, so a reset command (an entry +//! removed) stays removed after saving. +//! +//! ```no_run +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +//! use craft_settings::{Location, Settings, SettingsStore}; +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] +//! #[serde(default)] +//! struct Prefs { +//! shortcuts: Overrides, +//! } +//! +//! impl Settings for Prefs { +//! const APP_ID: &'static str = "acmecraft"; +//! const SCHEMA_VERSION: u32 = 1; +//! } +//! +//! # let catalogue = Vec::new(); +//! let (mut store, _report) = SettingsStore::::open(Location::Auto); +//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); +//! // Entries for unknown commands and texts that do not parse are kept and listed here. +//! let _problems = keymap.problems(); +//! +//! // After each change in the shortcut editor: +//! keymap.assign(&"file.save".into(), Binding::parse("Cmd+Alt+S").unwrap(), Policy::Steal).ok(); +//! store.update(|p| p.shortcuts = keymap.overrides().clone()); +//! ``` +//! +//! # Importing a shortcut set +//! +//! The app's reader builds a [`ForeignSet`]; [`plan`] matches it without changing anything, so a +//! review UI can show [`ImportPlan::commands`] and the report first. +//! +//! ``` +//! use craft_keymap::{Bindable, Binding, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Platform}; +//! use craft_keymap::{Scope, Scopes, plan}; +//! +//! let save = Bindable::new("file.save", "Save", Scope::app()) +//! .with_path(vec!["File".into(), "Save".into()]) +//! .with_default(Binding::parse("Cmd+S").unwrap()); +//! let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows); +//! +//! let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); +//! let set = ForeignSet { +//! name: Some("My Set".into()), +//! entries: vec![ +//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), +//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), +//! ], +//! }; +//! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; +//! let planned = plan(&set, &keymap, &options); +//! assert_eq!(planned.commands.len(), 1); +//! +//! let report = planned.apply(&mut keymap); +//! assert_eq!(report.changed[0].to, ["Cmd+Alt+S"]); +//! assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File > Frobnicate", Some(13))); +//! assert_eq!(keymap.bindings(&"file.save".into()), [Binding::parse("Cmd+Alt+S").unwrap()]); +//! ``` + +pub mod chord; +pub mod command; +#[cfg(feature = "egui")] +pub mod egui; +pub mod import; +pub mod keymap; +pub mod reserved; +pub mod scope; + +pub use chord::foreign::Flavor; +pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; +pub use command::{CommandRef, MAX_KEY}; +pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan}; +pub use keymap::{ + AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, + MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, +}; +pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/reserved.rs b/crates/keymap/src/reserved.rs new file mode 100644 index 00000000..7dbf847b --- /dev/null +++ b/crates/keymap/src/reserved.rs @@ -0,0 +1,36 @@ +//! Chords the operating system takes before the app sees them. + +use crate::chord::{Chord, Platform}; + +// from photocraft crates/engine/src/prefs.rs `reserved_shortcut` +const MAC: &[&str] = &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"]; +const WINDOWS: &[&str] = &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"]; +// Linux and the BSDs: window switching and closing, and the virtual consoles. +const LINUX: &[&str] = &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", +]; + +/// The window switching, closing and session chords of `platform`. +pub fn system(platform: Platform) -> Vec { + let list = match platform { + Platform::Mac => MAC, + Platform::Windows => WINDOWS, + Platform::Linux => LINUX, + }; + list.iter().filter_map(|t| Chord::parse(t).ok()).collect() +} diff --git a/crates/keymap/src/scope.rs b/crates/keymap/src/scope.rs new file mode 100644 index 00000000..287413c9 --- /dev/null +++ b/crates/keymap/src/scope.rs @@ -0,0 +1,205 @@ +//! Where a binding works: the whole application, one panel, or a context such as text editing. + +use std::fmt; + +use serde::{Deserialize, Deserializer, Serialize}; + +/// Longest scope name, in bytes. +pub const MAX_SCOPE_NAME: usize = 64; + +const APP: &str = "Application"; + +/// The name of a scope. Never empty, at most [`MAX_SCOPE_NAME`] bytes, without `:`. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct Scope(String); + +impl Scope { + /// `None` for an empty name, one over [`MAX_SCOPE_NAME`] bytes, or one with `:` or a control + /// character. + pub fn new(name: impl Into) -> Option { + let name = name.into(); + let ok = !name.is_empty() && name.len() <= MAX_SCOPE_NAME && !name.chars().any(|c| c == ':' || c.is_control()); + ok.then_some(Scope(name)) + } + + /// The application scope, `Application`. + pub fn app() -> Scope { + Scope(APP.to_string()) + } + + pub fn is_app(&self) -> bool { + self.0 == APP + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Scope { + fn deserialize>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Scope::new(name).ok_or_else(|| serde::de::Error::custom("bad scope name")) + } +} + +/// What kind of scope a name is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ScopeKind { + Application, + /// Works while the panel has the keyboard. Names that are not registered count as panels. + Panel, + /// Works only while the context is active, in any panel. + Context, +} + +/// The scope registry: contexts, panels and the pairs of panels that share their shortcuts. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Scopes { + panels: Vec, + contexts: Vec, + shares: Vec<(String, String)>, + panel_fallthrough: bool, +} + +impl Default for Scopes { + fn default() -> Scopes { + Scopes { panels: Vec::new(), contexts: Vec::new(), shares: Vec::new(), panel_fallthrough: true } + } +} + +/// What has the keyboard: the focused panel, the active contexts, and whether a text field is +/// being typed in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Active<'a> { + pub panel: Option<&'a str>, + pub contexts: &'a [&'a str], + pub typing: bool, +} + +impl<'a> Active<'a> { + /// No panel focused, no context, not typing. + pub const NONE: Active<'static> = Active { panel: None, contexts: &[], typing: false }; + + pub const fn panel(panel: &'a str) -> Active<'a> { + Active { panel: Some(panel), contexts: &[], typing: false } + } +} + +impl Scopes { + pub fn new() -> Scopes { + Scopes::default() + } + + /// Registers a panel (used by [`Scopes::panels`]; unknown names rank as panels anyway). + pub fn with_panel(mut self, name: &str) -> Scopes { + if !self.panels.iter().any(|p| p == name) { + self.panels.push(name.to_string()); + } + self + } + + pub fn with_context(mut self, name: &str) -> Scopes { + if !self.contexts.iter().any(|c| c == name) { + self.contexts.push(name.to_string()); + } + self + } + + /// Two panels that show the same thing and share their shortcuts (EffectCraft's Timeline and + /// Composition). + pub fn with_share(mut self, a: &str, b: &str) -> Scopes { + if !self.shares(a, b) { + self.shares.push((a.to_string(), b.to_string())); + } + self + } + + /// Whether another panel's shortcut still runs when nothing nearer claims the chord. On by + /// default. + pub fn with_panel_fallthrough(mut self, on: bool) -> Scopes { + self.panel_fallthrough = on; + self + } + + pub fn panels(&self) -> &[String] { + &self.panels + } + + pub fn contexts(&self) -> &[String] { + &self.contexts + } + + pub fn panel_fallthrough(&self) -> bool { + self.panel_fallthrough + } + + /// Whether `scope` is the application or a registered panel, context or sharing panel. + pub fn knows(&self, scope: &Scope) -> bool { + let name = scope.as_str(); + scope.is_app() + || self.panels.iter().any(|p| p == name) + || self.contexts.iter().any(|c| c == name) + || self.shares.iter().any(|(a, b)| a == name || b == name) + } + + pub fn kind(&self, scope: &Scope) -> ScopeKind { + self.kind_of(scope.as_str()) + } + + fn kind_of(&self, name: &str) -> ScopeKind { + if name == APP { + ScopeKind::Application + } else if self.contexts.iter().any(|c| c == name) { + ScopeKind::Context + } else { + ScopeKind::Panel + } + } + + /// Whether panels `a` and `b` (in either order) share their shortcuts. + pub fn shares(&self, a: &str, b: &str) -> bool { + self.shares.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a)) + } + + /// How strongly a binding in `scope` claims a chord: lower wins, `None` does not run. An active + /// context 0, the focused panel 1, a panel sharing with it 2, the application 3, any other + /// panel 4 with fallthrough on. An inactive context never runs. + // from effectcraft#1266 `scope_rank` + pub fn rank(&self, scope: &Scope, active: &Active) -> Option { + let name = scope.as_str(); + match self.kind_of(name) { + ScopeKind::Context => active.contexts.contains(&name).then_some(0), + ScopeKind::Application => Some(3), + ScopeKind::Panel => match active.panel { + Some(p) if p == name => Some(1), + Some(p) if self.shares(p, name) => Some(2), + _ => self.panel_fallthrough.then_some(4), + }, + } + } + + /// Whether the same chord in `a` and `b` makes only one of them work: the same scope, two + /// panels that share, or two different contexts (both can be active at once). Other pairs that + /// can be active together shadow (`Some(false)`): a context over anything else, a panel over + /// the application. `None`: never active together (two unrelated panels). + pub(crate) fn relation(&self, a: &Scope, b: &Scope) -> Option { + if a == b { + return Some(true); + } + match (self.kind(a), self.kind(b)) { + (ScopeKind::Context, ScopeKind::Context) => Some(true), + (ScopeKind::Context, _) | (_, ScopeKind::Context) => Some(false), + (ScopeKind::Application, _) | (_, ScopeKind::Application) => Some(false), + (ScopeKind::Panel, ScopeKind::Panel) => self.shares(a.as_str(), b.as_str()).then_some(true), + } + } +} diff --git a/crates/keymap/tests/chord.rs b/crates/keymap/tests/chord.rs new file mode 100644 index 00000000..116b357e --- /dev/null +++ b/crates/keymap/tests/chord.rs @@ -0,0 +1,178 @@ +use craft_keymap::chord::foreign::{Flavor, ForeignError, ReadNote, read_foreign}; +use craft_keymap::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn mods(cmd: bool, ctrl: bool, alt: bool, shift: bool) -> Mods { + Mods { cmd, ctrl, alt, shift } +} + +#[test] +fn canonical_text_round_trips_for_every_key_and_modifier_set() { + for &key in Key::ALL { + for bits in 0..16u8 { + let c = Chord::new(mods(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0), key); + let text = c.to_string(); + assert_eq!(Chord::parse(&text), Ok(c), "{text}"); + assert_eq!(Trigger::parse(&format!("{text} {text}")), Ok(Trigger::double(c)), "{text} double"); + let json = serde_json::to_string(&Trigger::single(c)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Trigger::single(c)); + // macOS display text reads back as the same chord. + assert_eq!(Chord::parse(&c.display(Platform::Mac)), Ok(c), "{}", c.display(Platform::Mac)); + } + } + assert_eq!(chord("Cmd+Ctrl+Alt+Shift+K").to_string(), "Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(serde_json::to_string(&chord("shift+cmd+k")).unwrap(), "\"Cmd+Shift+K\""); +} + +#[test] +fn aliases_parse_to_canonical_chords() { + let cases = [ + ("cmd+shift+z", "Cmd+Shift+Z"), + ("Shift+Command+Z", "Cmd+Shift+Z"), + ("meta+z", "Cmd+Z"), + ("Super+Z", "Cmd+Z"), + ("mod+z", "Cmd+Z"), + ("⌘⇧Z", "Cmd+Shift+Z"), + ("⌃⌥⇧⌘K", "Cmd+Ctrl+Alt+Shift+K"), + ("⌘+K", "Cmd+K"), + ("⌘Shift+K", "Cmd+Shift+K"), + ("control+opt+left", "Ctrl+Alt+Left"), + ("ctl+option+ArrowUp", "Ctrl+Alt+Up"), + // craft-menu writes Ctrl before Cmd. + ("Ctrl+Cmd+Alt+Shift+M", "Cmd+Ctrl+Alt+Shift+M"), + ("Ctrl+Cmd+F", "Cmd+Ctrl+F"), + ("Cmd++", "Cmd++"), + ("⌘+", "Cmd++"), + ("+", "+"), + ("Cmd+Plus", "Cmd++"), + ("cmd+Equals", "Cmd+="), + ("Cmd+-", "Cmd+-"), + ("Cmd + K", "Cmd+K"), + ("esc", "Escape"), + ("return", "Enter"), + ("del", "Delete"), + ("pgup", "PageUp"), + ("pgdn", "PageDown"), + ("arrowdown", "Down"), + ("spacebar", "Space"), + ("ins", "Insert"), + ("f12", "F12"), + ("Num5", "5"), + ("Semicolon", ";"), + ("Questionmark", "?"), + ("OpenCurlyBracket", "{"), + ("Backtick", "`"), + ("⌘⌫", "Cmd+Backspace"), + ]; + for (text, canonical) in cases { + assert_eq!(chord(text).to_string(), canonical, "{text}"); + } +} + +#[test] +fn hostile_text_is_refused() { + let long = "Cmd+".repeat(MAX_CHORD_TEXT / 4) + "K"; + assert!(long.len() > MAX_CHORD_TEXT); + let cases: &[(&str, ParseError)] = &[ + ("", ParseError::Empty), + (" ", ParseError::Empty), + (&long, ParseError::TooLong), + ("+++", ParseError::UnknownModifier("+".into())), + ("++", ParseError::MissingKey), + ("Cmd+", ParseError::MissingKey), + ("Cmd", ParseError::MissingKey), + ("⌘", ParseError::MissingKey), + ("Shift+Alt", ParseError::MissingKey), + ("Cmd+Command+K", ParseError::DuplicateModifier("Command".into())), + ("⌘⌘K", ParseError::DuplicateModifier("⌘".into())), + ("Cmd+é", ParseError::UnknownKey("é".into())), + ("Ctrl+ж+K", ParseError::UnknownModifier("ж".into())), + ("Hyper+K", ParseError::UnknownModifier("Hyper".into())), + ("A B", ParseError::Sequence), + ("Cmd+K Cmd+J", ParseError::Sequence), + ("A A A", ParseError::UnknownKey("A A A".into())), + ]; + for (text, err) in cases { + assert_eq!(Trigger::parse(text).as_ref(), Err(err), "{text:?}"); + } + let wide = "⌘".repeat(30); + assert_eq!(Chord::parse(&wide), Err(ParseError::TooLong)); + assert!(serde_json::from_str::("\"Cmd+Nope\"").is_err()); +} + +#[test] +fn display_follows_the_platform() { + let c = chord("Cmd+Ctrl+Alt+Shift+K"); + assert_eq!(c.display(Platform::Mac), "⌃⌥⇧⌘K"); + assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K"); + assert_eq!(chord("Ctrl+Up").display(Platform::Mac), "⌃↑"); + assert_eq!(chord("Ctrl+Up").display(Platform::Linux), "Ctrl+Up"); + assert_eq!(chord("Cmd+Enter").display(Platform::Mac), "⌘⏎"); + assert_eq!(chord("Shift+Tab").display(Platform::Mac), "⇧⇥"); + assert_eq!(chord("Space").display(Platform::Mac), "Space"); + assert_eq!(chord("Alt+PageDown").display(Platform::Windows), "Alt+Page Down"); + assert_eq!(Trigger::parse("Cmd+K Cmd+K").unwrap().display(Platform::Mac), "⌘K ⌘K"); + assert_eq!(Trigger::parse("A A").unwrap().to_string(), "A A"); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let ctrl = chord("Ctrl+S"); + let cmd = chord("Cmd+S"); + assert_ne!(ctrl.effective(Platform::Mac), cmd.effective(Platform::Mac)); + for p in [Platform::Windows, Platform::Linux] { + assert_eq!(ctrl.effective(p), cmd); + assert_eq!(chord("Cmd+Ctrl+S").effective(p), cmd); + } + assert!(ctrl.has_command() && cmd.has_command()); + assert!(chord("Shift+B").is_plain() && !chord("Alt+B").is_plain()); + assert!(chord("Shift+F35").is_function_key() && !chord("F").is_function_key()); +} + +#[test] +fn foreign_windows_text() { + let read = |t: &str| read_foreign(t, Flavor::Windows); + let ok = |t: &str| read(t).unwrap_or_else(|e| panic!("{t}: {e}")); + assert_eq!(ok("Shift+Ctrl+S"), (Trigger::single(chord("Cmd+Shift+S")), vec![])); + assert_eq!(ok("Ctrl+,").0, Trigger::single(chord("Cmd+,"))); + assert_eq!(ok(",").0, Trigger::single(chord(","))); + assert_eq!(ok("Ctrl++").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Ctrl+-").0, Trigger::single(chord("Cmd+-"))); + assert_eq!(ok("Alt-F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl + S").0, Trigger::single(chord("Cmd+S"))); + assert_eq!(ok("Shift + Ctrl + S").0, Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl + +").0, Trigger::single(chord("Cmd++"))); + assert_eq!(ok("Alt - F4").0, Trigger::single(chord("Alt+F4"))); + assert_eq!(ok("Ctrl Page Down").0, Trigger::single(chord("Cmd+PageDown"))); + assert_eq!(ok("Shift+Up Arrow").0, Trigger::single(chord("Shift+Up"))); + assert_eq!(ok("Num +"), (Trigger::single(chord("+")), vec![ReadNote::Numpad])); + assert_eq!(ok("Ctrl+Num /"), (Trigger::single(chord("Cmd+/")), vec![ReadNote::Numpad])); + assert_eq!(ok("Numpad 5"), (Trigger::single(chord("5")), vec![ReadNote::Numpad])); + assert_eq!(ok("Num Enter"), (Trigger::single(chord("Enter")), vec![ReadNote::Numpad])); + assert_eq!(read("Win+E"), Err(ForeignError::UnsupportedModifier("Win".into()))); + assert_eq!(read("Meta+E"), Err(ForeignError::UnsupportedModifier("Meta".into()))); + assert_eq!(read(""), Err(ForeignError::Empty)); + assert_eq!(read(&"x".repeat(MAX_CHORD_TEXT + 1)), Err(ForeignError::TooLong)); + assert_eq!(read("Ctrl+Num Lock"), Err(ForeignError::UnknownKey("Num Lock".into()))); + assert_eq!(read("Alt"), Err(ForeignError::UnknownKey("Alt".into()))); +} + +#[test] +fn foreign_mac_text() { + let texts = ["Shift+Cmd+S", "Ctrl+Opt+Left"]; + assert_eq!(Flavor::detect(texts), Flavor::Mac); + assert_eq!(Flavor::detect(["Shift+Ctrl+S", "Alt+F4"]), Flavor::Windows); + assert_eq!(Flavor::detect(["⌥⌘K"]), Flavor::Mac); + let ok = |t: &str| read_foreign(t, Flavor::Mac).unwrap_or_else(|e| panic!("{t}: {e}")).0; + assert_eq!(ok("Shift+Cmd+S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("Ctrl+Opt+Left"), Trigger::single(chord("Ctrl+Alt+Left"))); + assert_eq!(ok("Control-Option-Space"), Trigger::single(chord("Ctrl+Alt+Space"))); + assert_eq!(ok("⇧⌘S"), Trigger::single(chord("Cmd+Shift+S"))); + assert_eq!(ok("⌃⌥⇞"), Trigger::single(chord("Ctrl+Alt+PageUp"))); + assert_eq!(ok("Cmd+,"), Trigger::single(chord("Cmd+,"))); + // In a Windows set the same ⌃ is the command modifier. + assert_eq!(read_foreign("⌃S", Flavor::Windows).map(|r| r.0), Ok(Trigger::single(chord("Cmd+S")))); +} diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs new file mode 100644 index 00000000..7e275aba --- /dev/null +++ b/crates/keymap/tests/egui.rs @@ -0,0 +1,290 @@ +#![cfg(feature = "egui")] + +use craft_keymap::egui::{Dispatcher, Hit, matches}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes}; +use egui::{Event, InputState, Key as EguiKey, Modifiers}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +#[test] +fn every_key_converts_both_ways() { + for &key in Key::ALL { + assert_eq!(Key::from_egui(key.to_egui()), Some(key), "{key}"); + } + for k in [EguiKey::Copy, EguiKey::Cut, EguiKey::Paste, EguiKey::BrowserBack, EguiKey::ShiftLeft, EguiKey::IntlBackslash] { + assert_eq!(Key::from_egui(k), None); + } +} + +#[test] +fn modifiers_and_shortcuts_follow_the_platform() { + let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + assert_eq!(Mods::from_egui(&ctrl, Platform::Windows), Mods::CMD); + assert_eq!(Mods::from_egui(&Modifiers::CTRL, Platform::Mac), Mods::CTRL); + assert_eq!(Mods::from_egui(&Modifiers::MAC_CMD, Platform::Mac), Mods::CMD); + + assert!(matches(&chord("Ctrl+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(matches(&chord("Cmd+S"), EguiKey::S, &ctrl, Platform::Windows)); + assert!(!matches(&chord("Cmd+S"), EguiKey::S, &Modifiers::CTRL, Platform::Mac)); + + let shortcut = chord("Cmd+Shift+Z").to_egui_shortcut(); + assert_eq!(shortcut.logical_key, EguiKey::Z); + assert!(Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE }.matches_exact(shortcut.modifiers)); +} + +#[test] +fn shifted_symbols_match_their_bindings() { + let p = Platform::Windows; + let shift = Modifiers::SHIFT; + let none = Modifiers::NONE; + let cmd = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + let cmd_shift = Modifiers { shift: true, ..cmd }; + + // Shift+base matches the shifted key egui reports, but the base key alone does not. + assert!(matches(&chord("Shift+;"), EguiKey::Colon, &shift, p)); + assert!(!matches(&chord("Shift+;"), EguiKey::Semicolon, &none, p)); + assert!(!matches(&chord(";"), EguiKey::Colon, &shift, p)); + assert!(matches(&chord("Shift+/"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord("Cmd+Shift+]"), EguiKey::CloseCurlyBracket, &cmd_shift, p)); + assert!(matches(&chord("Shift+1"), EguiKey::Exclamationmark, &shift, p)); + // A shifted symbol bound directly matches while Shift is held to type it. + assert!(matches(&chord("?"), EguiKey::Questionmark, &shift, p)); + assert!(matches(&chord(":"), EguiKey::Colon, &none, p)); + + // Zoom keys: `=` also matches `+`, with or without Shift; `+` matches Shift+`=`. + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd_shift, p)); + assert!(matches(&chord("Cmd+="), EguiKey::Equals, &cmd, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Equals, &cmd_shift, p)); + assert!(matches(&chord("Cmd++"), EguiKey::Plus, &cmd_shift, p)); + assert!(!matches(&chord("Cmd++"), EguiKey::Equals, &cmd, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Plus, &none, p)); + assert!(!matches(&chord("Cmd+="), EguiKey::Minus, &cmd, p)); +} + +const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + +fn dispatch_keymap() -> Keymap { + let app = |id: &str, keys: &[&str]| keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(Binding::parse(k).unwrap())); + let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) }); + let catalogue = vec![ + app("edit.copy", &["Cmd+C", "Cmd+Insert"]), + app("app.quit", &["Cmd+Alt+Q"]), + app("view.refresh", &["Cmd+Alt+F5"]), + app("file.save", &["Cmd+S"]), + app("view.zoomIn", &["="]), + app("help.find", &["Shift+;"]), + app("tool.brush", &["B"]), + app("app.loop", &["L"]), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(Binding::parse("L").unwrap()), + tool("tool.marquee"), + tool("tool.ellipse"), + app("reveal.one", &["U"]), + app("reveal.all", &["U U"]), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Windows) +} + +fn press(key: EguiKey, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers } +} + +fn frame(time: f64, modifiers: Modifiers, events: Vec) -> InputState { + let mut input = InputState::default(); + input.time = time; + input.modifiers = modifiers; + input.events = events; + input +} + +/// Dispatches one frame and returns the ids that ran and the events left. +fn run(d: &mut Dispatcher, km: &Keymap, active: &Active, mut input: InputState) -> (Vec, Vec) { + let hits = d.dispatch(&mut input, km, active); + (hits.into_iter().map(|h| h.command.id).collect(), input.events) +} + +#[test] +fn a_bound_press_runs_once_and_is_removed_with_its_text() { + let km = dispatch_keymap(); + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + Event::Key { key: EguiKey::B, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + press(EguiKey::Q, Modifiers::NONE), + Event::Text("q".into()), + ]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events)); + assert_eq!(ran, ["tool.brush"]); + // The release and the unbound Q with its text stay. + assert_eq!(left.len(), 3); + assert!(matches!(left[0], Event::Key { key: EguiKey::B, pressed: false, .. })); + assert!(matches!(left[1], Event::Key { key: EguiKey::Q, pressed: true, .. })); + assert_eq!(left[2], Event::Text("q".into())); +} + +#[test] +fn shifted_symbols_and_zoom_keys_reach_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, shift, vec![press(EguiKey::Colon, shift), Event::Text(":".into())])); + assert_eq!(ran, ["help.find"]); + assert!(left.is_empty()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![press(EguiKey::Plus, Modifiers::NONE)])); + assert_eq!(ran, ["view.zoomIn"]); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, shift, vec![press(EguiKey::Plus, shift)])); + assert_eq!(ran, ["view.zoomIn"]); +} + +#[test] +fn clipboard_events_run_their_chords_outside_text_fields() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![Event::Copy])); + assert_eq!(ran, ["edit.copy"]); + assert!(left.is_empty()); + + let typing = Active { typing: true, ..Active::NONE }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, CTRL, vec![Event::Copy])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Copy]); + + // The web build sends the key press and the Copy event it causes: one copy. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::C, CTRL), Event::Copy])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn clipboard_presses_stay_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // The web build's Cmd+C press and its Copy event both reach the text field. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::Insert, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); + assert_eq!(ran, ["edit.copy"]); +} + +#[test] +fn a_press_that_types_text_stays_with_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // A press the web build's text agent follows with the text it typed. + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into())]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, Modifiers::NONE, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); +} + +#[test] +fn ctrl_alt_on_a_printable_key_is_left_to_the_text_field() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + // AltGr+Q on a German layout types `@`; Windows reports it as Ctrl+Alt+Q, and egui-winit sends + // no Text event while Ctrl is held. + let alt_gr = Modifiers { alt: true, ..CTRL }; + let events = vec![press(EguiKey::Q, alt_gr)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, alt_gr, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + let (ran, _) = run(&mut d, &km, &typing, frame(2.0, alt_gr, vec![press(EguiKey::F5, alt_gr)])); + assert_eq!(ran, ["view.refresh"], "a key that types nothing"); + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, alt_gr, events)); + assert_eq!(ran, ["app.quit"], "outside a text field"); + assert!(left.is_empty()); + + // macOS has no AltGr: Cmd+Option+Q is a command chord while typing. + let mac = Keymap::new( + vec![Bindable::new("app.quit", "Quit", Scope::app()).with_default(Binding::parse("Cmd+Alt+Q").unwrap())], + Scopes::new(), + Platform::Mac, + ); + let cmd_opt = Modifiers { alt: true, mac_cmd: true, command: true, ..Modifiers::NONE }; + let (ran, _) = run(&mut Dispatcher::new(), &mac, &typing, frame(4.0, cmd_opt, vec![press(EguiKey::Q, cmd_opt)])); + assert_eq!(ran, ["app.quit"]); +} + +#[test] +fn typing_blocks_plain_keys_but_not_command_chords() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into()), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left.len(), 2); + assert_eq!(left[1], Event::Text("b".into())); +} + +#[test] +fn the_focused_panel_wins_and_tool_groups_cycle() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let l = || frame(1.0, Modifiers::NONE, vec![press(EguiKey::L, Modifiers::NONE)]); + assert_eq!(run(&mut d, &km, &Active::panel("Timeline"), l()).0, ["timeline.loop"]); + assert_eq!(run(&mut d, &km, &Active::NONE, l()).0, ["app.loop"]); + + let mut input = frame(2.0, Modifiers::NONE, vec![press(EguiKey::M, Modifiers::NONE)]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")]; + assert_eq!(hits, [Hit { command: tools[0].clone(), cycle: tools }]); +} + +#[test] +fn a_second_press_runs_the_double_binding() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let key = |d: &mut Dispatcher, k: EguiKey, t: f64| run(d, &km, &Active::NONE, frame(t, Modifiers::NONE, vec![press(k, Modifiers::NONE)])).0; + let u = |d: &mut Dispatcher, t: f64| key(d, EguiKey::U, t); + assert_eq!(u(&mut d, 1.0), ["reveal.one"]); + assert_eq!(u(&mut d, 1.3), ["reveal.all"]); + // The press after a double starts over. + assert_eq!(u(&mut d, 1.5), ["reveal.one"]); + assert_eq!(u(&mut d, 2.5), ["reveal.one"]); + // B in between breaks the pair. + key(&mut d, EguiKey::B, 2.6); + assert_eq!(u(&mut d, 2.7), ["reveal.one"]); +} + +#[test] +fn two_presses_in_one_frame_make_a_double() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let u = press(EguiKey::U, Modifiers::NONE); + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(4.0, Modifiers::NONE, vec![u.clone(), u])); + assert_eq!(ran, ["reveal.one", "reveal.all"]); + assert!(left.is_empty(), "{left:?}"); +} + +#[test] +fn key_repeats_run_only_when_turned_on() { + let km = Keymap::new( + vec![ + Bindable::new("nudge.right", "Nudge", Scope::app()).with_default(Binding::parse("Right").unwrap()), + Bindable::new("jump.right", "Jump", Scope::app()).with_default(Binding::parse("Right Right").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let held = |repeat: bool| Event::Key { key: EguiKey::ArrowRight, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + let events = || vec![held(false), held(true), held(true)]; + + let mut on = Dispatcher::new().with_repeat(true); + let (ran, left) = run(&mut on, &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right", "nudge.right", "nudge.right"]); + assert!(left.is_empty()); + // Repeats neither made nor broke a double: the next press within the window is one. + let (ran, _) = run(&mut on, &km, &Active::NONE, frame(1.3, Modifiers::NONE, vec![held(false)])); + assert_eq!(ran, ["jump.right"]); + + let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events())); + assert_eq!(ran, ["nudge.right"]); + assert_eq!(left, [held(true), held(true)]); +} diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs new file mode 100644 index 00000000..56d17315 --- /dev/null +++ b/crates/keymap/tests/import.rs @@ -0,0 +1,546 @@ +//! Import of synthetic shortcut sets written for these tests. + +use std::collections::BTreeMap; + +use craft_keymap::chord::foreign::ReadNote; +use craft_keymap::chord::foreign::{Flavor, ForeignError}; +use craft_keymap::import::{ + Failed, FailedChange, MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT, MAX_KEYS_PER_ENTRY, MAX_SUGGESTIONS, SkipReason, Skipped, UnmatchedReason, + normalize_label, +}; +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport, Keymap, Kind, MAX_COMMANDS, + NoneDefined, Overrides, Platform, Scope, Scopes, Start, plan, reserved, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn bindings(texts: &[&str]) -> Vec { + texts.iter().map(|t| b(t)).collect() +} + +fn command(id: impl Into, label: &str, path: &[&str], keys: &[&str]) -> Bindable { + let mut out = Bindable::new(id, label, Scope::app()); + if !path.is_empty() { + out = out.with_path(path.iter().map(|s| s.to_string()).collect()); + } + keys.iter().fold(out, |e, k| e.with_default(b(k))) +} + +fn panel(name: &str) -> CommandRef { + CommandRef::with_params("window.panel", json!({ "panel": name })) +} + +fn catalogue() -> Vec { + vec![ + command("file.new", "Document…", &["File", "New", "Document…"], &["Cmd+N"]), + command("dialog.newDocument", "New Document Dialog", &[], &["Cmd+N"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + command("file.save", "Save", &["File", "Save"], &["Cmd+S"]), + command("file.exit", "Exit", &["File", "Exit"], &[]), + command("edit.paste", "Paste", &["Edit", "Paste"], &["Cmd+V"]), + command("edit.clear", "Clear", &["Edit", "Clear"], &[]), + command("edit.duplicate", "Duplicate", &["Edit", "Duplicate"], &["Cmd+Alt+Shift+D"]), + command("edit.checkSpelling", "Check Spelling…", &["Edit", "Proofing", "Check Spelling…"], &[]), + command("edit.pen", "Pen", &["Edit", "Preferences", "Pen"], &[]), + command("app.palette", "Command Palette", &[], &["Cmd+Alt+K"]), + command(panel("layers"), "Layers", &["Window", "Layers"], &["F7"]), + command(panel("pages"), "Pages", &["Window", "Pages"], &["F12"]), + command("view.guides", "Guides", &["View", "Grids & Guides", "Guides"], &["Cmd+;"]), + command("view.goToPage", "Go to Page…", &["View", "Go to Page…"], &["Cmd+J"]), + command("view.zoomIn", "Zoom In", &["View", "Zoom In"], &["Cmd+="]), + command("object.textFrameOptions", "Text Frame Options…", &["Object", "Text Frame Options…"], &["Cmd+B"]), + command("type.break", "Break", &["Type", "Breaks", "Break"], &[]), + command("type.columnBreak", "Insert Break", &["Type", "Column", "Insert Break"], &[]), + command("type.frameBreak", "Insert Break", &["Type", "Frame", "Insert Break"], &[]), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]), + command("type.italic", "Italic", &["Type", "Style", "Italic"], &[]), + command("tool.pen", "Pen Tool", &[], &["P"]).with_kind(Kind::Tool { group: None }).with_alias("Pen"), + command("tool.type", "Type Tool", &[], &["T"]).with_kind(Kind::Tool { group: None }), + command("scripts.run", "Run Script", &[], &[]), + ] +} + +fn keymap(platform: Platform) -> Keymap { + Keymap::new(catalogue(), Scopes::new().with_context("Text"), platform).with_reserved(reserved::system(platform)) +} + +fn key(flavor: Flavor, context: &str, text: &str) -> ForeignKey { + ForeignKey::read(Some(context), text, flavor) +} + +fn entry(menu: Option<&str>, path: &[&str], keys: &[(&str, &str)], flavor: Flavor) -> ForeignEntry { + keys.iter().fold(ForeignEntry::new(menu, path.iter().copied()), |e, (c, t)| e.with_key(key(flavor, c, t))) +} + +fn win(menu: &str, path: &[&str], keys: &[(&str, &str)]) -> ForeignEntry { + entry(Some(menu), path, keys, Flavor::Windows) +} + +/// An InDesign-like Windows set, one entry per match rule or apply rule. +fn windows_set() -> ForeignSet { + let d = "Default"; + let entries = vec![ + win("File", &["New", "Document..."], &[(d, "Ctrl+N")]), + win("File", &["Close"], &[(d, "Ctrl+W"), (d, "Ctrl+F4")]), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]), + win("File", &["Exit"], &[(d, "Alt+F4")]), + win("Edit", &["Paste"], &[(d, "Ctrl+E")]), + win("Edit", &["Clear"], &[(d, "Ctrl+E")]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Duplicate"], &[]), + win("Edit", &["Spelling", "Check Spelling..."], &[(d, "Ctrl+Alt+K"), ("Text", "F7")]), + win("Edit", &["Frobnicate Everything"], &[(d, "Ctrl+F12")]), + win("Edit", &["Never Heard Of It"], &[]), + win("Window", &["Layers"], &[(d, "F7")]), + win("Window", &["Pages"], &[(d, "Shift+F12")]), + win("View", &["Grids & Guides", "SHOW/HIDE Guides"], &[(d, "Ctrl+Alt+;")]), + win("Layout", &["Go To Page"], &[(d, "Ctrl+Alt+J")]), + win("Object", &["Text Frame", "Options"], &[(d, "Ctrl+Alt+B")]), + win("Type", &["Insert", "Break"], &[(d, "Ctrl+Alt+Enter")]), + win("Type", &["Insert Break"], &[(d, "Shift+Enter")]), + win("Type", &["Style", "Bold"], &[("Text", "Shift+Ctrl+B")]), + win("Type", &["Style", "Italic"], &[(d, "Ctrl+I"), ("Table", "Ctrl+Alt+I")]), + win("Tools", &["Pen"], &[(d, "Shift+P")]).tool(), + win("Tools", &["Zoom In"], &[(d, "Z")]).tool(), + entry(None, &["Zoom In"], &[(d, "Ctrl+Alt+I")], Flavor::Windows), + entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), + win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), + ]; + ForeignSet { name: Some("My Set".into()), entries } +} + +fn numpad_set() -> ForeignSet { + ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)] } +} + +fn windows_options() -> ImportOptions { + let mut remembered = BTreeMap::new(); + remembered.insert(ForeignEntry::new(Some("Scripts"), ["Frobnicate"]).remember_key(), CommandRef::new("scripts.run")); + ImportOptions { + contexts: vec![("default".into(), ContextRule::Home), ("TEXT".into(), ContextRule::HomeWithoutHomeEntry)], + remembered, + ..ImportOptions::default() + } +} + +fn import_windows() -> (Keymap, ImportReport) { + let mut km = keymap(Platform::Windows); + let report = plan(&windows_set(), &km, &windows_options()).apply(&mut km); + (km, report) +} + +fn has(km: &Keymap, id: impl Into, texts: &[&str]) { + let c = id.into(); + assert_eq!(km.bindings(&c), bindings(texts), "{}", c.key()); +} + +#[test] +fn labels_normalise_for_matching() { + assert_eq!(normalize_label("Check Spelling..."), "check spelling"); + assert_eq!(normalize_label("Check Spelling…"), "check spelling"); + assert_eq!(normalize_label("Grids && Guides"), "grids guides"); + assert_eq!(normalize_label("SHOW/HIDE Guides"), "guides"); + assert_eq!(normalize_label("Show Hidden Characters"), "hidden characters"); + assert_eq!(normalize_label("Hide "), "hide", "nothing left after the prefix"); + assert_eq!(normalize_label("ÄRGER"), "ärger"); +} + +#[test] +fn every_match_step_finds_its_command() { + let (km, r) = import_windows(); + assert_eq!(r.set.as_deref(), Some("My Set")); + // 1: full path, with `&`, case, spacing and Show/Hide normalised. + has(&km, "view.guides", &["Cmd+Alt+;"]); + // 2: joined segments `Text Frame Options` in Object. + has(&km, "object.textFrameOptions", &["Cmd+Alt+B"]); + // 2 is ambiguous (two `Insert Break` in Type), 3 finds `Break`. + has(&km, "type.break", &["Cmd+Alt+Enter"]); + // 3: last segment in the menu, under another submenu. + has(&km, "edit.checkSpelling", &["Cmd+Alt+K"]); + // 4: the label anywhere; also with no menu for a one-segment path. + has(&km, "view.goToPage", &["Cmd+Alt+J"]); + has(&km, "view.zoomIn", &["Cmd+Alt+I"]); + // 0: a remembered answer. + has(&km, "scripts.run", &["Cmd+Alt+R"]); + // A command with parameters, by its path. + has(&km, panel("pages"), &["Shift+F12"]); + // A tool entry matches the tool, not the command with the same label. + has(&km, "tool.pen", &["Shift+P"]); + has(&km, "edit.pen", &[]); + + let unmatched: Vec<(&str, UnmatchedReason)> = r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect(); + assert_eq!( + unmatched, + [ + ("Edit > Frobnicate Everything", UnmatchedReason::NoMatch), + ("Type > Insert Break", UnmatchedReason::Ambiguous), + ("Type > Style > Italic", UnmatchedReason::Context), + ("Tools > Zoom In", UnmatchedReason::Tool), + ("Somewhere > Zoom In", UnmatchedReason::NoMatch), + ] + ); + assert_eq!(r.unmatched[2].keys, "Table: Ctrl+Alt+I"); + assert_eq!(r.unmatched_without_shortcut, 1); + assert_eq!(r.unreadable.len(), 1); + assert_eq!(r.unreadable[0].item, "File > Save"); + assert_eq!(r.unreadable[0].keys, "Default: Ctrl+Hyperspace"); + assert!(matches!(r.unreadable[0].reason, ForeignError::UnknownKey(_))); + assert!(r.suggestions.is_empty(), "not asked for"); + assert_eq!(r.truncated, 0); +} + +#[test] +fn the_set_wins_and_the_report_says_how() { + let (km, r) = import_windows(); + // Two keys for one command, both kept in order. + has(&km, "file.close", &["Cmd+W", "Cmd+F4"]); + // The Text context counts only without a Default key. + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "type.italic", &["Cmd+I"]); + has(&km, panel("layers"), &["F7"]); + // A later entry takes a chord from an earlier one; the earlier command keeps what it had. + has(&km, "edit.paste", &["Cmd+V"]); + has(&km, "edit.clear", &["Cmd+E"]); + let skipped: Vec<_> = r.skipped.iter().map(|s| (s.command.id.as_str(), s.shortcut.as_str(), s.kept_by.clone(), s.reason)).collect(); + assert_eq!( + skipped, + [("file.exit", "Alt+F4", None, SkipReason::Reserved), ("edit.paste", "Cmd+E", Some(CommandRef::new("edit.clear")), SkipReason::Taken)] + ); + // Taken from a command outside the set; the twin keeps its shared default. + has(&km, "app.palette", &[]); + has(&km, "dialog.newDocument", &["Cmd+N"]); + let cleared: Vec<_> = r.conflicts_cleared.iter().map(|c| (c.command.id.as_str(), c.label.as_str(), c.shortcut.as_str())).collect(); + assert_eq!(cleared, [("app.palette", "Command Palette", "Cmd+Alt+K")]); + // None defined clears; an unreadable key leaves the command as it was. + has(&km, "edit.duplicate", &[]); + has(&km, "file.save", &["Cmd+S"]); + // Bindings equal to the defaults leave no override. + let o = km.overrides(); + assert_eq!(o.get("file.new"), None); + assert_eq!(o.get(&panel("layers").key()), None); + assert_eq!(o.get("edit.duplicate"), Some(&[][..])); + assert_eq!(o.get("file.close"), Some(&["Cmd+W".to_string(), "Cmd+F4".to_string()][..])); + + let changed: Vec<&str> = r.changed.iter().map(|c| c.command.id.as_str()).collect(); + assert_eq!( + changed, + [ + "file.close", + "edit.clear", + "edit.duplicate", + "edit.checkSpelling", + "window.panel", + "view.guides", + "view.goToPage", + "object.textFrameOptions", + "type.break", + "type.bold", + "type.italic", + "tool.pen", + "view.zoomIn", + "scripts.run", + ] + ); + assert_eq!(r.imported, changed.len()); + let close = &r.changed[0]; + assert_eq!((close.label.as_str(), close.item.as_str()), ("Close", "File > Close")); + assert_eq!((close.from.clone(), close.to.clone()), (vec!["Cmd+W".to_string()], vec!["Cmd+W".to_string(), "Cmd+F4".to_string()])); +} + +#[test] +fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { + let mut km = keymap(Platform::Mac); + let mac = |menu: &str, path: &[&str], keys: &[(&str, &str)]| entry(Some(menu), path, keys, Flavor::Mac); + let set = ForeignSet { + name: Some("Mac".into()), + entries: vec![ + mac("File", &["Save"], &[("Default", "⇧⌘S")]), + mac("Edit", &["Paste"], &[("Default", "Cmd+Ctrl+V")]), + mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), + mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), + ], + }; + let text = Scope::new("Text").unwrap(); + let options = ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home), ("Text".into(), ContextRule::Scope(text))], + ..ImportOptions::default() + }; + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.save", &["Cmd+Shift+S"]); + has(&km, "edit.paste", &["Cmd+Ctrl+V"]); + has(&km, "type.italic", &["Text: Cmd+Shift+I"]); + has(&km, "type.bold", &["Cmd+B"]); + has(&km, "object.textFrameOptions", &[]); + assert_eq!(r.conflicts_cleared[0].command.id, "object.textFrameOptions"); + assert_eq!(r.unmatched.len(), 1); + assert_eq!((r.unmatched[0].keys.as_str(), r.unmatched[0].reason), ("Tables: Cmd+Opt+B", UnmatchedReason::Context)); + assert_eq!(r.changed.iter().find(|c| c.command.id == "type.italic").map(|c| c.to.clone()), Some(vec!["Text: Cmd+Shift+I".to_string()])); +} + +#[test] +fn start_and_none_defined_options() { + let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])] }; + let user = || { + let mut o = Overrides::new(); + o.insert("file.save", vec!["Cmd+Alt+S".into()]); + o + }; + let options = |start, none_defined| ImportOptions { + contexts: vec![("Default".into(), ContextRule::Home)], + start, + none_defined, + ..ImportOptions::default() + }; + + let mut km = keymap(Platform::Windows).with_overrides(user()); + plan(&set, &km, &options(Start::Current, NoneDefined::Keep)).apply(&mut km); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + + let mut km = keymap(Platform::Windows).with_overrides(user()); + let r = plan(&set, &km, &options(Start::Defaults, NoneDefined::Clear)).apply(&mut km); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "file.close", &["Cmd+Q"]); + has(&km, "edit.duplicate", &[]); + assert_eq!(r.imported, 2); +} + +#[test] +fn changes_that_find_the_overrides_full_are_reported() { + let set = ForeignSet { + name: None, + entries: vec![ + win("File", &["Close"], &[("Default", "Ctrl+J")]), + win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), + win("Edit", &["Duplicate"], &[]), + ], + }; + let full = || { + let mut o = Overrides::new(); + o.insert("file.close", vec!["Cmd+Q".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec!["Cmd+K".into()]); + } + assert_eq!(o.len(), MAX_COMMANDS); + o + }; + let options = |start| ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start, ..ImportOptions::default() }; + + // file.close has an entry to rewrite; the other changes each need a new one. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+J"]); + has(&km, "view.goToPage", &["Cmd+J"]); + has(&km, "file.save", &["Cmd+S"]); + has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); + let row = |id: &str, label: &str, change, shortcuts: &[&str]| Failed { + command: id.into(), + label: label.into(), + change, + shortcuts: shortcuts.iter().map(|s| s.to_string()).collect(), + }; + assert_eq!( + r.failed, + [ + row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), + row("edit.duplicate", "Duplicate", FailedChange::Clear, &[]), + row("view.goToPage", "Go to Page…", FailedChange::Unbind, &["Cmd+J"]), + ] + ); + assert_eq!(r.imported, 1); + assert!(r.conflicts_cleared.is_empty()); + let v = serde_json::to_value(&r).unwrap(); + assert_eq!( + v["failed"][0], + json!({"command": {"id": "file.save", "params": null}, "label": "Save", "change": "bind", "shortcuts": ["Cmd+Alt+S"]}) + ); + + // Starting from the defaults drops the entries, so everything fits. + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Defaults)).apply(&mut km); + assert!(r.failed.is_empty(), "{:?}", r.failed); + has(&km, "file.close", &["Cmd+J"]); + has(&km, "view.goToPage", &[]); + has(&km, "file.save", &["Cmd+Alt+S"]); + has(&km, "edit.duplicate", &[]); +} + +#[test] +fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { + let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])] }; + let options = ImportOptions { + contexts: vec![ + ("Default".into(), ContextRule::Home), + ("Story".into(), ContextRule::Scope(Scope::new("Story").unwrap())), + ("Text".into(), ContextRule::Scope(Scope::new("Text").unwrap())), + ], + ..ImportOptions::default() + }; + let mut km = keymap(Platform::Windows); + let r = plan(&set, &km, &options).apply(&mut km); + has(&km, "file.close", &["Cmd+Q", "Text: Cmd+L"]); + assert_eq!( + r.skipped, + [Skipped { + command: "file.close".into(), + item: "File > Close".into(), + line: None, + shortcut: "Story: Cmd+K".into(), + kept_by: None, + reason: SkipReason::UnknownScope + }] + ); + assert_eq!(serde_json::to_value(r.skipped[0].reason).unwrap(), "unknownScope"); +} + +#[test] +fn suggestions_are_reported_never_applied() { + let mut km = keymap(Platform::Windows); + let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let p = plan(&set, &km, &options); + assert!(p.commands.is_empty()); + let r = p.apply(&mut km); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].item, "Edit > Chek Spelling Now"); + assert!(r.suggestions[0].candidates.contains(&CommandRef::new("edit.checkSpelling")), "{:?}", r.suggestions); + has(&km, "edit.checkSpelling", &[]); + assert!(km.overrides().is_empty()); + assert_eq!(r.imported, 0); +} + +#[test] +fn hostile_sets_are_capped_and_counted() { + // A full catalogue whose labels share words with every unmatched entry below, so each + // suggestion scores every command. + let mut catalogue = catalogue(); + let filler = MAX_COMMANDS - catalogue.len(); + catalogue.extend((0..filler).map(|i| command(format!("filler.c{i}").as_str(), &format!("Filler Command {i}"), &[], &[]))); + let mut km = Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + // Odd entries have keys and long labels that match nothing, so they reach the suggestions: + // words in the whole label, or two words and symbols, which score high enough to suggest. + let wordy = "Filler Command ä€𝄞 ".repeat(MAX_IMPORT_TEXT / 8); + let symbols = format!("Filler Command {}", "€𝄞 ".repeat(MAX_IMPORT_TEXT)); + let filler_entry = |i: usize| match i % 4 { + 1 => win("Edit", &[&symbols], &[("Default", "Ctrl+F12")]), + 3 => win("Edit", &[&format!("{wordy}{i}")], &[("Default", "Ctrl+F12")]), + _ => win("Edit", &[&format!("Item {i}")], &[]), + }; + let mut entries: Vec = (0..MAX_IMPORT_ENTRIES + 500).map(filler_entry).collect(); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + entries.insert(0, big); + entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + let set = ForeignSet { name: Some(long.clone()), entries }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let started = std::time::Instant::now(); + let r = plan(&set, &km, &options).apply(&mut km); + assert!(started.elapsed() < std::time::Duration::from_secs(240), "{:?}", started.elapsed()); + // Read filler entries are those before the cap, after the two inserted ones. + let read = MAX_IMPORT_ENTRIES - 2; + let keyed = read / 2; + assert!(!r.suggestions.is_empty() && r.suggestions.len() <= MAX_SUGGESTIONS, "{}", r.suggestions.len()); + assert!(r.truncated >= 500 + 10 + keyed + 1 - MAX_SUGGESTIONS, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, read - keyed); +} + +#[test] +fn rows_carry_source_lines_and_approximated_keys() { + let mut km = keymap(Platform::Windows); + let d = "Default"; + let set = ForeignSet { + name: None, + entries: vec![ + win("Window", &["Layers"], &[(d, "Ctrl+Num 5"), (d, "Num 7")]).at_line(3), + win("Window", &["Layers"], &[(d, "Shift+F7")]).at_line(4), + win("Edit", &["Paste"], &[(d, "Ctrl+Num 5")]).at_line(5), + win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), + win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), + ], + }; + let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + // A keypad key binds the main key and is reported. Paste, a later entry, takes Cmd+5. + has(&km, panel("layers"), &["7", "Shift+F7"]); + has(&km, "edit.paste", &["Cmd+5"]); + let approximated: Vec<_> = r.approximated.iter().map(|a| (a.item.as_str(), a.line, a.keys.as_str(), a.note)).collect(); + assert_eq!( + approximated, + [ + ("Window > Layers", Some(3), "Default: Ctrl+Num 5, Default: Num 7", ReadNote::Numpad), + ("Edit > Paste", Some(5), "Default: Ctrl+Num 5", ReadNote::Numpad) + ] + ); + // Merged entries report the first one's line. + let layers = r.changed.iter().find(|c| c.command == panel("layers")).unwrap(); + assert_eq!(layers.line, Some(3)); + assert_eq!((r.skipped[0].item.as_str(), r.skipped[0].line), ("Window > Layers", Some(3))); + assert_eq!(r.unmatched[0].line, Some(8)); + assert_eq!(r.suggestions[0].line, Some(8)); + assert_eq!(r.unreadable[0].line, Some(9)); +} + +#[test] +fn report_json_uses_camel_case_names() { + let mut km = keymap(Platform::Windows); + let mut options = windows_options(); + options.suggest = true; + let r = plan(&windows_set(), &km, &options).apply(&mut km); + let v = serde_json::to_value(&r).unwrap(); + let names = |v: &serde_json::Value| v.as_object().unwrap().keys().cloned().collect::>(); + let mut top = names(&v); + top.sort(); + assert_eq!( + top, + [ + "approximated", + "changed", + "conflictsCleared", + "failed", + "imported", + "set", + "skipped", + "suggestions", + "truncated", + "unmatched", + "unmatchedWithoutShortcut", + "unreadable" + ] + ); + let sorted = |v: &serde_json::Value| { + let mut n = names(v); + n.sort(); + n + }; + assert_eq!(sorted(&v["changed"][0]), ["command", "from", "item", "label", "line", "to"]); + assert_eq!(sorted(&v["conflictsCleared"][0]), ["command", "label", "shortcut"]); + assert_eq!(sorted(&v["skipped"][1]), ["command", "item", "keptBy", "line", "reason", "shortcut"]); + assert_eq!(v["skipped"][1]["keptBy"], json!({"id": "edit.clear", "params": null})); + assert_eq!(v["skipped"][0]["reason"], "reserved"); + assert_eq!(sorted(&v["unmatched"][0]), ["item", "keys", "line", "reason"]); + let reasons: Vec<&str> = v["unmatched"].as_array().unwrap().iter().filter_map(|u| u["reason"].as_str()).collect(); + assert_eq!(reasons, ["noMatch", "ambiguous", "context", "tool", "noMatch"]); + assert_eq!(sorted(&v["unreadable"][0]), ["item", "keys", "line", "reason"]); + assert_eq!(sorted(&v["suggestions"][0]), ["candidates", "item", "line"]); + let approximated = ImportReport { approximated: plan(&numpad_set(), &km, &options).report.approximated, ..ImportReport::default() }; + let a = serde_json::to_value(&approximated).unwrap(); + assert_eq!(sorted(&a["approximated"][0]), ["item", "keys", "line", "note"]); + assert_eq!(a["approximated"][0]["note"], "numpad"); + assert_eq!(v["changed"][0]["from"], json!(["Cmd+W"])); +} diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs new file mode 100644 index 00000000..2e16997a --- /dev/null +++ b/crates/keymap/tests/keymap.rs @@ -0,0 +1,523 @@ +use std::time::{Duration, Instant}; + +use craft_keymap::{ + Active, AssignError, Bindable, Binding, Chord, CommandRef, ConflictKind, DropReason, Key, Keymap, Kind, Lookup, MAX_BINDINGS, MAX_COMMANDS, + MAX_CONFLICTS, Mods, Overrides, Platform, Policy, Problem, Scope, Scopes, Trigger, TypingRule, reserved, +}; +use serde_json::json; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn scope(name: &str) -> Scope { + Scope::new(name).unwrap() +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn cmd(id: &str) -> CommandRef { + CommandRef::new(id) +} + +fn app(id: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(b(k))) +} + +fn in_scope(id: &str, home: &str, keys: &[&str]) -> Bindable { + keys.iter().fold(Bindable::new(id, id, scope(home)), |e, k| e.with_default(b(k))) +} + +fn run(id: &str) -> Lookup { + Lookup::Run(cmd(id)) +} + +fn scopes() -> Scopes { + Scopes::new().with_panel("Timeline").with_panel("Composition").with_share("Timeline", "Composition").with_context("Text") +} + +fn press(km: &Keymap, text: &str, active: &Active) -> Lookup { + km.lookup(&chord(text), false, active) +} + +#[test] +fn command_keys_are_canonical_whatever_the_map_order() { + let c = CommandRef::with_params("window.panel", json!({"z": [1, "a b"], "panel": "layers"})); + assert_eq!(c.key(), r#"window.panel {"panel":"layers","z":[1,"a b"]}"#); + assert_eq!(CommandRef::parse_key(&c.key()), Some(c)); + assert_eq!(CommandRef::with_params("edit.copy", json!({})).key(), "edit.copy"); + assert_eq!(CommandRef::parse_key("x {bad"), None); + assert_eq!(CommandRef::parse_key(&"x".repeat(300)), None); +} + +#[test] +fn lookup_picks_the_nearest_scope() { + let catalogue = vec![ + app("edit.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + app("file.bake", &["Cmd+B"]), + in_scope("text.bold", "Text", &["Cmd+B", "Cmd+I"]), + in_scope("timeline.loop", "Timeline", &["L"]), + ]; + let km = Keymap::new(catalogue.clone(), scopes(), Platform::Mac); + let text = ["Text"]; + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Timeline")), run("timeline.copy")); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Composition")), run("timeline.copy"), "sharing panel"); + assert_eq!(press(&km, "Cmd+C", &Active::panel("Effects")), run("edit.copy")); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Timeline")), run("file.bake")); + assert_eq!(press(&km, "Cmd+B", &Active { panel: Some("Timeline"), contexts: &text, typing: false }), run("text.bold")); + assert_eq!(press(&km, "Cmd+I", &Active::NONE), Lookup::None, "inactive context"); + assert_eq!(press(&km, "L", &Active::panel("Effects")), run("timeline.loop"), "fallthrough"); + assert_eq!(km.lookup(&chord("L"), true, &Active::panel("Timeline")), Lookup::None, "double press is its own trigger"); + + let strict = Keymap::new(catalogue, scopes().with_panel_fallthrough(false), Platform::Mac); + assert_eq!(press(&strict, "L", &Active::panel("Effects")), Lookup::None); + assert_eq!(press(&strict, "L", &Active::panel("Timeline")), run("timeline.loop")); +} + +#[test] +fn typing_rules_decide_what_fires_in_a_text_field() { + let catalogue = vec![app("tool.brush", &["B"]), app("edit.copy", &["Cmd+C"]), app("help", &["F1"]), app("view.alt", &["Alt+V"])]; + let typing = Active { panel: None, contexts: &[], typing: true }; + let cases = [ + (TypingRule::PlainKeysWait, [false, true, true, true]), + (TypingRule::CommandKeysOnly, [false, true, true, false]), + (TypingRule::AllWait, [false, false, false, false]), + ]; + for (rule, fires) in cases { + let km = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows).with_typing_rule(rule); + for ((key, id), fire) in [("B", "tool.brush"), ("Cmd+C", "edit.copy"), ("F1", "help"), ("Alt+V", "view.alt")].into_iter().zip(fires) { + let want = if fire { run(id) } else { Lookup::None }; + assert_eq!(press(&km, key, &typing), want, "{rule:?} {key}"); + assert_eq!(press(&km, key, &Active::NONE), run(id), "{rule:?} {key} not typing"); + } + } +} + +#[test] +fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { + let catalogue = vec![ + app("text.bold", &["Text: Cmd+B", "Text: B"]), + app("file.bake", &["Cmd+B"]), + in_scope("timeline.cut", "Timeline", &["Cmd+K"]), + app("file.save", &["Cmd+S"]), + ]; + let editing = Active { panel: Some("Timeline"), contexts: &["Text"], typing: true }; + let elsewhere = Active { contexts: &[], ..editing }; + let km = Keymap::new(catalogue, scopes(), Platform::Mac).with_typing_rule(TypingRule::AllWait); + assert_eq!(press(&km, "Cmd+B", &editing), run("text.bold")); + assert_eq!(press(&km, "B", &editing), run("text.bold")); + assert_eq!(press(&km, "Cmd+K", &editing), Lookup::None, "panel bindings wait"); + assert_eq!(press(&km, "Cmd+S", &editing), Lookup::None, "app bindings wait"); + assert_eq!(press(&km, "Cmd+B", &elsewhere), Lookup::None, "the app binding waits when the context is not active"); +} + +#[test] +fn tools_of_one_group_cycle_and_keep_their_shared_key() { + let tool = |id: &str, group: &str| app(id, &["B"]).with_kind(Kind::Tool { group: Some(group.into()) }); + let mut km = + Keymap::new(vec![tool("tool.brush", "brush"), tool("tool.pencil", "brush"), tool("tool.mixer", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&km, "B", &Active::NONE), Lookup::Cycle(vec![cmd("tool.brush"), cmd("tool.pencil"), cmd("tool.mixer")])); + assert!(km.conflicts().is_empty()); + + km.clear(&cmd("tool.mixer")); + let done = km.assign(&cmd("tool.mixer"), b("B"), Policy::Steal).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("tool.brush")), vec![b("B")]); + + let mixed = Keymap::new(vec![tool("tool.brush", "brush"), app("layer.bake", &["B"]), tool("tool.pencil", "brush")], Scopes::new(), Platform::Mac); + assert_eq!(press(&mixed, "B", &Active::NONE), run("tool.brush")); +} + +#[test] +fn steal_keep_and_refuse() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), in_scope("timeline.copy", "Timeline", &[])]; + let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let dup = cmd("edit.duplicate"); + + let err = km.assign(&dup, b("Cmd+C"), Policy::Refuse).unwrap_err(); + let AssignError::Taken(holders) = err else { panic!("{err:?}") }; + assert_eq!(holders[0].command, cmd("edit.copy")); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D")]); + + let done = km.assign(&dup, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(done.clashes.len(), 1); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&dup), vec![b("Cmd+D"), b("Cmd+C")]); + + km.reset_all(); + let done = km.assign(&dup, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(done.taken_from[0].command, cmd("edit.copy")); + assert!(km.bindings(&cmd("edit.copy")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&[][..])); + assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.duplicate")); + + // A panel binding shadows the application one; nothing is taken. + let done = km.assign(&cmd("timeline.copy"), b("Cmd+D"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty() && done.clashes.is_empty()); + assert_eq!(km.assign(&cmd("nope"), b("Cmd+D"), Policy::Steal), Err(AssignError::UnknownCommand)); +} + +#[test] +fn twins_keep_their_shared_default_key() { + let mut km = Keymap::new(vec![app("file.close", &["Cmd+W"]), app("sequence.close", &["Cmd+W"])], Scopes::new(), Platform::Mac); + km.clear(&cmd("sequence.close")); + let done = km.assign(&cmd("sequence.close"), b("Cmd+W"), Policy::Refuse).unwrap(); + assert!(done.taken_from.is_empty()); + assert_eq!(km.bindings(&cmd("file.close")), vec![b("Cmd+W")]); + assert!(km.overrides().is_empty(), "back to the defaults"); +} + +#[test] +fn clearing_a_command_frees_its_panel_copies() { + // filmcraft#1333: New Bin holds Cmd+B in the application and in the Project panel. + let catalogue = vec![app("project.newBin", &["Cmd+B", "Project: Cmd+B"]), app("clip.bold", &[])]; + let mut km = Keymap::new(catalogue, Scopes::new().with_panel("Project"), Platform::Mac); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), run("project.newBin")); + assert!(km.clear(&cmd("project.newBin"))); + assert!(km.bindings(&cmd("project.newBin")).is_empty()); + assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), Lookup::None); + assert!(km.assign(&cmd("clip.bold"), b("Project: Cmd+B"), Policy::Refuse).is_ok()); +} + +#[test] +fn reserved_chords_are_refused() { + let catalogue = vec![app("view.next", &[])]; + let mut mac = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_reserved(reserved::system(Platform::Mac)); + let err = mac.assign(&cmd("view.next"), b("Cmd+Tab"), Policy::Steal).unwrap_err(); + assert_eq!(err, AssignError::Reserved(Trigger::parse("Cmd+Tab").unwrap())); + + let mut win = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + assert!(matches!(win.assign(&cmd("view.next"), b("Ctrl+Alt+Delete"), Policy::Steal), Err(AssignError::Reserved(_)))); + assert!(reserved::system(Platform::Linux).contains(&chord("Cmd+Alt+F12"))); + + let mut o = Overrides::new(); + o.insert("view.next", vec!["Alt+F4".into()]); + let linux = Keymap::new(catalogue, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert!(linux.bindings(&cmd("view.next")).is_empty()); + assert_eq!(linux.problems(), vec![Problem::Reserved { key: "view.next".into(), text: "Alt+F4".into() }]); +} + +#[test] +fn conflicts_tell_clashes_from_shadows() { + let catalogue = vec![ + app("edit.copy", &["Cmd+C"]), + app("color.copy", &["Cmd+C"]), + in_scope("timeline.copy", "Timeline", &["Cmd+C"]), + in_scope("text.cut", "Text", &["Cmd+X"]), + app("edit.cut", &["Cmd+X"]), + app("edit.find", &["Cmd+F"]), + in_scope("tab.find", "Effects", &["Cmd+F"]), + in_scope("render.find", "Render", &["Cmd+R"]), + in_scope("effects.render", "Effects", &["Cmd+R"]), + ]; + let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let summary = |km: &Keymap| -> Vec<(String, String, String, ConflictKind, bool)> { + km.conflicts().into_iter().map(|c| (c.chord.to_string(), c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect() + }; + let s = |a: &str, x: &str, y: &str, k, d| (a.to_string(), x.to_string(), y.to_string(), k, d); + let mut want = vec![ + s("Cmd+C", "edit.copy", "color.copy", ConflictKind::Clash, true), + s("Cmd+C", "edit.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+C", "color.copy", "timeline.copy", ConflictKind::Shadow, true), + s("Cmd+X", "text.cut", "edit.cut", ConflictKind::Shadow, true), + s("Cmd+F", "edit.find", "tab.find", ConflictKind::Shadow, true), + // Fallthrough: in a third panel the two tie. + s("Cmd+R", "render.find", "effects.render", ConflictKind::Shadow, true), + ]; + let sort = |mut v: Vec<(String, String, String, ConflictKind, bool)>| { + v.sort(); + v + }; + assert_eq!(sort(summary(&km)), sort(want.clone())); + + km.assign(&cmd("edit.find"), b("Cmd+D"), Policy::Steal).unwrap(); + km.assign(&cmd("edit.cut"), b("Cmd+D"), Policy::Keep).unwrap(); + want.push(s("Cmd+D", "edit.cut", "edit.find", ConflictKind::Clash, false)); + assert_eq!(sort(summary(&km)), sort(want.clone())); + + let order: Vec<_> = summary(&km).into_iter().map(|(c, a, b, ..)| (c, a, b)).collect(); + let chords: Vec = km.conflicts().iter().map(|c| c.chord).collect(); + assert!(chords.is_sorted(), "{order:?}"); + let c = |x: &str, y: &str| order.iter().position(|(_, a, b)| a == x && b == y); + assert!(c("edit.copy", "color.copy") < c("edit.copy", "timeline.copy"), "{order:?}"); + assert!(c("edit.copy", "timeline.copy") < c("color.copy", "timeline.copy"), "{order:?}"); + + let strict = Keymap::new(km.catalogue().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); + assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); +} + +/// 4096 commands, each with 16 bindings on one chord in 16 panels. +fn crowded(scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { + let catalogue: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); + } + (Keymap::new(catalogue, scopes, Platform::Mac), o) +} + +fn panels(n: usize) -> Scopes { + (1..=n).fold(Scopes::new(), |s, p| s.with_panel(&format!("P{p}"))) +} + +#[test] +fn crowded_chords_stay_fast() { + let limit = Duration::from_secs(8); + + // Panels the app never registered are not bound. + let (km, o) = crowded(Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: many conflicts, capped. + let (km, o) = crowded(panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + let km = km.with_overrides(o); + let t = Instant::now(); + assert_eq!(km.conflicts().len(), MAX_CONFLICTS); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); + + // Tools of one group: no conflicts, and no pair is compared. + let (km, o) = crowded(panels(MAX_BINDINGS), Kind::Tool { group: None }); + let km = km.with_overrides(o); + let t = Instant::now(); + assert!(km.conflicts().is_empty()); + assert!(t.elapsed() < limit, "{:?}", t.elapsed()); +} + +#[test] +fn assigning_many_commands_stays_fast() { + let catalogue: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[])).collect(); + let mut km = Keymap::new(catalogue, panels(MAX_BINDINGS), Platform::Mac); + let list: Vec = (1..=MAX_BINDINGS).map(|p| b(&format!("P{p}: K"))).collect(); + let t = Instant::now(); + for n in 0..MAX_COMMANDS { + let done = km.set_bindings(&cmd(&format!("c.{n}")), list.clone(), Policy::Steal).unwrap(); + assert_eq!(done.taken_from.len(), if n == 0 { 0 } else { MAX_BINDINGS }); + } + assert!(t.elapsed() < Duration::from_secs(8), "{:?}", t.elapsed()); + assert_eq!(km.bindings(&cmd(&format!("c.{}", MAX_COMMANDS - 1))), list); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(press(&km, "K", &Active::panel("P3")), run(&format!("c.{}", MAX_COMMANDS - 1))); +} + +#[test] +fn binding_text_round_trips_for_every_key() { + let panel = scope("Timeline"); + for &key in Key::ALL { + for mods in [Mods::NONE, Mods { cmd: true, ctrl: true, alt: true, shift: true }] { + let chord = Chord::new(mods, key); + for trigger in [Trigger::single(chord), Trigger::double(chord)] { + for binding in [Binding::new(trigger), Binding::in_scope(trigger, panel.clone())] { + let text = binding.to_string(); + assert_eq!(Binding::parse(&text).as_ref(), Ok(&binding), "{text}"); + } + } + } + } + assert_eq!(Binding::parse(": :"), Ok(Binding::new(Trigger::double(Chord::plain(Key::Colon))))); +} + +#[test] +fn edits_are_refused_when_the_overrides_are_full() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; + let mut o = Overrides::new(); + o.insert("edit.cut", vec!["Cmd+X".into()]); + for n in 1..MAX_COMMANDS { + o.insert(format!("junk.{n}"), vec![]); + } + assert_eq!(o.len(), MAX_COMMANDS); + let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(o.clone()); + let (copy, dup, cut) = (cmd("edit.copy"), cmd("edit.duplicate"), cmd("edit.cut")); + + assert_eq!(km.assign(&dup, b("Cmd+K"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.set_bindings(&dup, vec![], Policy::Steal), Err(AssignError::Full)); + assert!(!km.clear(©)); + assert!(!km.remove(©, &b("Cmd+C"))); + // Taking Cmd+C from edit.copy would need an entry for it too. + assert_eq!(km.assign(&cut, b("Cmd+C"), Policy::Steal), Err(AssignError::Full)); + assert_eq!(km.bindings(©), vec![b("Cmd+C")]); + assert_eq!(km.bindings(&cut), vec![b("Cmd+X")]); + assert_eq!(km.overrides(), &o); + + // Commands that have an entry still change, and back to the defaults frees one. + assert!(km.assign(&cut, b("Cmd+Shift+X"), Policy::Steal).is_ok()); + assert!(km.reset(&cut)); + assert!(km.assign(&dup, b("Cmd+K"), Policy::Steal).is_ok()); +} + +#[test] +fn bindings_in_unknown_scopes_are_refused() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Mac); + let copy = cmd("edit.copy"); + assert_eq!(km.assign(©, b("Nowhere: Cmd+K"), Policy::Steal), Err(AssignError::UnknownScope(scope("Nowhere")))); + assert!(km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).is_ok(), "a catalogue scope"); + assert!(km.assign(©, b("Text: Cmd+K"), Policy::Steal).is_ok(), "a registered scope"); + + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into(), "Cmd+J".into()]); + km.set_overrides(o); + assert_eq!(km.bindings(©), vec![b("Cmd+J")]); + assert_eq!(km.problems(), vec![Problem::UnknownScope { key: "edit.copy".into(), text: "Nowhere: Cmd+K".into() }]); +} + +#[test] +fn unreadable_entries_keep_the_defaults() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+Nope".into()]); + o.insert("edit.paste", vec![]); + let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string()][..]), "verbatim"); + assert!(km.reset(&cmd("edit.copy"))); + assert_eq!(km.overrides().get("edit.copy"), None); +} + +#[test] +fn entries_with_nothing_usable_keep_the_defaults() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.cut", &["Cmd+X"]), app("edit.paste", &["Cmd+V"]), app("edit.undo", &["Cmd+Z"])]; + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Nowhere: Cmd+K".into()]); + o.insert("edit.cut", vec!["Alt+F4".into()]); + o.insert("edit.paste", vec!["Nowhere: Cmd+K".into(), "Alt+F4".into(), "Cmd+J".into()]); + o.insert("edit.undo", vec![]); + let km = Keymap::new(catalogue, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")], "only an unknown scope"); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+X")], "only a reserved chord"); + assert_eq!(km.bindings(&cmd("edit.paste")), vec![b("Cmd+J")]); + assert!(km.bindings(&cmd("edit.undo")).is_empty()); +} + +#[test] +fn overrides_hold_only_what_differs_from_the_defaults() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let copy = cmd("edit.copy"); + km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).unwrap(); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+C".to_string(), "Timeline: Cmd+K".to_string()][..])); + assert_eq!(km.bindings(©)[1], Binding::in_scope(Trigger::parse("Cmd+K").unwrap(), scope("Timeline"))); + assert!(km.remove(©, &b("Timeline: Cmd+K"))); + assert!(km.overrides().is_empty()); + + km.set_bindings(&cmd("timeline.mark"), vec![b("Timeline: M")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "the home scope written out is the same binding"); + km.set_bindings(©, vec![b("Cmd+Shift+C")], Policy::Steal).unwrap(); + assert!(km.reset(©)); + assert!(km.overrides().is_empty()); +} + +#[test] +fn edits_keep_the_texts_this_build_cannot_use() { + let catalogue = vec![app("file.quit", &["Cmd+Q"]), app("file.close", &["Cmd+W"]), app("file.save", &["Cmd+S"])]; + let mut o = Overrides::new(); + o.insert("file.quit", vec!["Cmd+Q".into(), "Cmd+FutureKey".into(), "Nowhere: Cmd+J".into()]); + let mut km = Keymap::new(catalogue.clone(), scopes(), Platform::Mac).with_overrides(o); + let quit = cmd("file.quit"); + let texts = |km: &Keymap| km.overrides().get("file.quit").map(<[String]>::to_vec); + let strings = |t: &[&str]| Some(t.iter().map(|s| s.to_string()).collect::>()); + + km.assign(&quit, b("Cmd+W"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+Q", "Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"])); + km.assign(&cmd("file.save"), b("Cmd+Q"), Policy::Steal).unwrap(); + assert_eq!(texts(&km), strings(&["Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"]), "taken by a Steal"); + let reloaded = Keymap::new(catalogue, scopes(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(reloaded.bindings(&quit), vec![b("Cmd+W")]); + + // An entry of only such texts would load as the defaults, so a cleared command drops them. + assert!(km.clear(&quit)); + assert_eq!(texts(&km), strings(&[])); + assert!(km.reset(&quit)); + assert_eq!(texts(&km), None); +} + +#[test] +fn an_override_equal_to_the_defaults_on_this_platform_is_not_stored() { + let mut km = Keymap::new(vec![app("file.save", &["Cmd+S"])], Scopes::new(), Platform::Windows); + km.set_bindings(&cmd("file.save"), vec![b("Ctrl+S")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty()); + assert_eq!(press(&km, "Ctrl+S", &Active::NONE), run("file.save")); +} + +#[test] +fn an_empty_text_in_the_map_form_removes_the_shortcut() { + let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"]), app("edit.cut", &["Cmd+X"])]; + let o: Overrides = serde_json::from_str(r#"{"edit.copy": "", "edit.paste": [""], "edit.cut": "Cmd+K"}"#).unwrap(); + assert!(o.load_notes().is_empty(), "{:?}", o.load_notes()); + let km = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(o); + assert!(km.bindings(&cmd("edit.copy")).is_empty(), "a removed default stays removed"); + assert!(km.bindings(&cmd("edit.paste")).is_empty()); + assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+K")]); + assert!(km.problems().is_empty()); + + let saved = serde_json::to_string(km.overrides()).unwrap(); + let again = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(serde_json::from_str(&saved).unwrap()); + assert!(again.bindings(&cmd("edit.copy")).is_empty(), "{saved}"); +} + +#[test] +fn ctrl_folds_into_cmd_off_macos() { + let catalogue = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; + let win = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows); + let mac = Keymap::new(catalogue, Scopes::new(), Platform::Mac); + let conflicts = win.conflicts(); + assert_eq!(conflicts.len(), 1); + assert_eq!((conflicts[0].kind, conflicts[0].chord.to_string()), (ConflictKind::Clash, "Cmd+X".to_string())); + assert!(mac.conflicts().is_empty()); + assert_eq!(press(&win, "Ctrl+X", &Active::NONE), run("edit.cut")); + assert_eq!(press(&mac, "Ctrl+X", &Active::NONE), run("mac.cut")); +} + +#[test] +fn hostile_overrides_load_without_panic_and_report_problems() { + let mut doc = serde_json::Map::new(); + for i in 0..(MAX_COMMANDS + 100) { + doc.insert(format!("junk.{i}"), json!(["Cmd+Z"])); + } + doc.insert("edit.copy".into(), json!(["Cmd+Nope", "x".repeat(10_000), 7, "Shift+C"])); + doc.insert("a".into(), json!(["Shift+A"])); + doc.insert("é".repeat(300), json!([])); + doc.insert("b".into(), json!({"deep": [[[[1]]]]})); + let o: Overrides = serde_json::from_value(serde_json::Value::Object(doc)).unwrap(); + assert!(o.len() <= MAX_COMMANDS); + + let km = Keymap::new(vec![app("edit.copy", &["Cmd+C"]), app("a", &[])], Scopes::new(), Platform::Linux).with_overrides(o.clone()); + assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Shift+C")]); + assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string(), "Shift+C".to_string()][..]), "verbatim"); + let problems = km.problems(); + assert!(problems.iter().any(|p| matches!(p, Problem::TooManyCommands { .. }))); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::TextTooLong })); + assert!(problems.contains(&Problem::Dropped { key: "edit.copy".into(), reason: DropReason::NotText })); + assert!(problems.iter().any(|p| matches!(p, Problem::Dropped { reason: DropReason::KeyTooLong, .. }))); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope"))); + assert!(problems.iter().any(|p| matches!(p, Problem::UnknownCommand { key } if key == "junk.0"))); + + let text = serde_json::to_string(&o).unwrap(); + let again: Overrides = serde_json::from_str(&text).unwrap(); + assert_eq!(again, o); + assert!(text.starts_with(r#"[{"command":"a","keys":["Shift+A"]},"#), "{}", &text[..80]); + assert!(serde_json::from_str::(r#"{"x": {"y": 1}, "z": true, "w": null, "v": "Cmd+V"}"#).is_ok()); + let items: Overrides = + serde_json::from_str(r#"[{"command": "v", "keys": "Cmd+V"}, {"command": "x"}, {"keys": []}, 3, [], {"command": "y", "keys": [1]}]"#).unwrap(); + assert_eq!(items.iter().collect::>(), [("v", &["Cmd+V".to_string()][..]), ("y", &[][..])]); + assert_eq!(items.load_notes().len(), 5); + + let twice: Overrides = serde_json::from_str(r#"[{"command": "v", "keys": ["Cmd+V"]}, {"command": "v", "keys": []}]"#).unwrap(); + assert_eq!(twice.get("v"), Some(&["Cmd+V".to_string()][..]), "the first entry stays"); + assert_eq!(twice.load_notes(), [Problem::DuplicateKey { key: "v".into() }]); + for text in [r#""x""#, "5", "true", "null"] { + let o: Overrides = serde_json::from_str(text).unwrap(); + assert!(o.is_empty(), "{text}"); + assert_eq!(o.load_notes(), [Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{text}"); + } +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs new file mode 100644 index 00000000..c63c893b --- /dev/null +++ b/crates/keymap/tests/settings.rs @@ -0,0 +1,122 @@ +use std::time::Duration; + +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Scope, Scopes}; +use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct Prefs { + general: General, + shortcuts: Overrides, +} + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] +#[serde(default)] +struct General { + language: String, +} + +impl Settings for Prefs { + const APP_ID: &'static str = "keymaptest"; + const SCHEMA_VERSION: u32 = 1; +} + +fn open(dir: &std::path::Path) -> SettingsStore { + let probe = Probe { env: Box::new(|_| None), exe_dir: None, platform_config_dir: None }; + let options = OpenOptions::new(Location::Dir(dir.to_path_buf())).probe(probe).debounce(Duration::from_millis(10)); + let (store, report) = SettingsStore::open_with(options); + assert!(report.section_errors.is_empty(), "{report:?}"); + store +} + +fn keymap(overrides: Overrides) -> Keymap { + let catalogue = vec![ + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()), + Bindable::new("text.bold", "Bold", Scope::app()), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), + Bindable::new("edit.paste", "Paste", Scope::app()).with_default(Binding::parse("Cmd+V").unwrap()), + ]; + Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Mac).with_overrides(overrides) +} + +#[test] +fn overrides_round_trip_through_craft_settings() { + let tmp = tempfile::tempdir().unwrap(); + let mut km = keymap(Overrides::new()); + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + km.assign(&layers, Binding::parse("Cmd+Shift+L").unwrap(), Policy::Steal).unwrap(); + km.assign(&"text.bold".into(), Binding::parse("Text: Cmd+B").unwrap(), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + + let mut store = open(tmp.path()); + store.update(|p| p.shortcuts = km.overrides().clone()); + store.flush().unwrap(); + + // A hand edit: one text that does not parse, one value that is not a list. + let path = tmp.path().join("config.toml"); + let text = std::fs::read_to_string(&path).unwrap(); + let edit = r#"shortcuts = [{ command = "edit.paste", keys = ["Cmd+Nope"] }, { command = "edit.cut", keys = 5 }, "#; + assert!(text.contains("shortcuts = ["), "{text}"); + std::fs::write(&path, text.replacen("shortcuts = [", edit, 1)).unwrap(); + + let mut store = open(tmp.path()); + let mut km = keymap(store.get().shortcuts.clone()); + assert_eq!(km.bindings(&layers), vec![Binding::parse("Cmd+Shift+L").unwrap()]); + assert_eq!(km.bindings(&"text.bold".into()), vec![Binding::parse("Text: Cmd+B").unwrap()]); + assert!(km.bindings(&"edit.copy".into()).is_empty()); + assert_eq!(km.bindings(&"edit.paste".into()), vec![Binding::parse("Cmd+V").unwrap()], "nothing readable: the defaults"); + assert_eq!(km.overrides().get("edit.paste"), Some(&["Cmd+Nope".to_string()][..])); + let problems = km.problems(); + assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); + assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + + store.update(|p| p.general.language = "de".into()); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("keys = [\"Cmd+Nope\"]"), "{text}"); + assert!( + text.contains(r#"command = "window.panel {\"panel\":\"layers\"}""#) || text.contains(r#"command = 'window.panel {"panel":"layers"}'"#), + "{text}" + ); + assert_eq!(open(tmp.path()).get().shortcuts, store.get().shortcuts); + + // Removals reach the file. + km.reset(&layers); + store.update(|p| p.shortcuts = km.overrides().clone()); + store.flush().unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); + assert!(!line.contains("window.panel"), "{text}"); + let reloaded = open(tmp.path()); + assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); +} + +#[test] +fn hand_written_array_of_tables_and_map_forms_load() { + for text in [ + "schema_version = 1\n\n[[shortcuts]]\ncommand = \"edit.copy\"\nkeys = [\"Cmd+K\"]\n\n[[shortcuts]]\ncommand = \"edit.paste\"\nkeys = []\n", + "schema_version = 1\n\n[shortcuts]\n\"edit.copy\" = [\"Cmd+K\"]\n\"edit.paste\" = []\n", + ] { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let km = keymap(open(tmp.path()).get().shortcuts.clone()); + assert_eq!(km.bindings(&"edit.copy".into()), vec![Binding::parse("Cmd+K").unwrap()], "{text}"); + assert!(km.bindings(&"edit.paste".into()).is_empty(), "{text}"); + } +} + +#[test] +fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\nshortcuts = {value}\n\n[general]\nlanguage = \"de\"\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert!(store.get().shortcuts.is_empty(), "{value}"); + assert_eq!(store.get().general.language, "de", "{value}"); + let problems = keymap(store.get().shortcuts.clone()).problems(); + assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); + } +} From 4df9e5936a7ab0932e7e794dcc340696d3567d34 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:21:48 +0000 Subject: [PATCH 002/124] keymap: let apps decline dispatch hits and keep clipboard events Hit carries the trigger that matched and whether the press matched it exactly. Dispatcher::dispatch_filtered takes a predicate; a declined hit leaves its event and text in place. egui's Copy, Cut and Paste events stay with the app unless with_clipboard_events(true): egui-winit sends them in place of the clipboard presses, and a Paste event consumed as a hit lost its text. With them on, Hit::pasted holds that text, and a web clipboard press and the event it causes run once for every clipboard chord, not only Cmd+C/X/V. The Hit doc line moves from DOUBLE_PRESS to Hit. --- crates/keymap/README.md | 19 ++-- crates/keymap/src/egui.rs | 170 +++++++++++++++++++++++++++--------- crates/keymap/tests/egui.rs | 71 +++++++++++++-- 3 files changed, 206 insertions(+), 54 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 01b12ed8..526d0441 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -175,9 +175,18 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File ``` A press that runs a command is removed from egui's events, with the text it typed, so widgets - do not see it too. egui's Copy, Cut and Paste events count as Cmd+C, Cmd+X and Cmd+V outside - text fields. While a text field has the keyboard, clipboard events, clipboard key presses, - presses followed by text, and off macOS Ctrl+Alt with a letter, digit, punctuation or Space - stay with it: AltGr arrives as Ctrl+Alt on Windows and some Linux setups, and egui-winit sends - no text while Ctrl is held. + do not see it too. Each `Hit` carries the `trigger` that matched and `exact`, which is false + when the press matched through a stand-in (`:` for `Shift+;`, `+` for `=`). + `dispatch_filtered` takes a predicate that can decline a hit, for example a chord the native + menu has already handled: the press and its text then stay in egui's events, in their place. + While a text field has the keyboard, clipboard key presses, presses followed by text, and off + macOS Ctrl+Alt with a letter, digit, punctuation or Space stay with it: AltGr arrives as + Ctrl+Alt on Windows and some Linux setups, and egui-winit sends no text while Ctrl is held. `Dispatcher::new().with_repeat(true)` also runs commands on key repeats (held arrows). + + egui's Copy, Cut and Paste events stay with the app by default. On the desktop, egui-winit + sends them in place of the Cmd+C, Cmd+X and Cmd+V presses (Ctrl off macOS), so a binding on + those chords runs only with `with_clipboard_events(true)`. Then the events count as their + chords outside text fields, and a Paste event's text moves to `Hit::pasted`. An app whose + paste command asks egui for a paste gets a Paste event back, which runs that command again; + such an app keeps the default. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 2d783839..fd4039d5 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -3,7 +3,7 @@ use ::egui::{Event, InputState, Key as EguiKey, KeyboardShortcut, Modifiers}; -use crate::chord::{Chord, Key, Mods, Platform}; +use crate::chord::{Chord, Key, Mods, Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Keymap, Lookup}; use crate::scope::Active; @@ -135,10 +135,10 @@ fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { out } -/// A command a key press runs. /// Seconds within which a second press of the same chord counts as a double press. pub const DOUBLE_PRESS: f64 = 0.6; +/// A command a key press runs. #[derive(Clone, Debug, PartialEq)] pub struct Hit { /// The command to run; for tools sharing the chord, the first of `cycle`. @@ -146,15 +146,24 @@ pub struct Hit { /// Tools of one group sharing the chord, in catalogue order (the app picks the next one); /// empty for a plain command. pub cycle: Vec, + /// The chord whose binding matched, with `double` set when the double binding ran. + pub trigger: Trigger, + /// Whether `trigger` is the press itself. `false` when the press matched through a stand-in: + /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or + /// `+` and `=` standing for each other. + pub exact: bool, + /// The text of the Paste event that ran the command ([`Dispatcher::with_clipboard_events`]). + pub pasted: Option, } impl Hit { - fn from_lookup(lookup: Lookup) -> Option { - match lookup { - Lookup::None => None, - Lookup::Run(command) => Some(Hit { command, cycle: Vec::new() }), - Lookup::Cycle(cycle) => Some(Hit { command: cycle.first()?.clone(), cycle }), - } + fn from_lookup(lookup: Lookup, trigger: Trigger, exact: bool) -> Option { + let (command, cycle) = match lookup { + Lookup::None => return None, + Lookup::Run(command) => (command, Vec::new()), + Lookup::Cycle(cycle) => (cycle.first()?.clone(), cycle), + }; + Some(Hit { command, cycle, trigger, exact, pasted: None }) } } @@ -167,10 +176,16 @@ struct LastPress { /// Runs a keymap against egui's input, one call per frame before the UI reads its keys. /// -/// It reads key presses (repeats only when [`with_repeat`](Self::with_repeat) is on) and egui's -/// Copy, Cut and Paste events, which egui-winit sends in place of the Cmd+C, Cmd+X and Cmd+V -/// presses. A press that runs a command is removed from the events, together with the text it -/// typed when it has no command modifier, so no widget sees it too. Other events stay. +/// It reads key presses (repeats only when [`with_repeat`](Self::with_repeat) is on). A press +/// that runs a command is removed from the events, together with the text it typed when it has +/// no command modifier, so no widget sees it too. Other events stay. +/// +/// egui's Copy, Cut and Paste events stay with the app unless +/// [`with_clipboard_events`](Self::with_clipboard_events) is on. On the desktop, egui-winit sends +/// these events in place of the key presses for Cmd+C, Cmd+X and Cmd+V (Ctrl off macOS, plus +/// Shift+Delete, Ctrl+Insert and Shift+Insert on Windows), so bindings on those chords run only +/// with clipboard events on. The web build sends the key press and then the clipboard event; with +/// clipboard events off, such a press stays too. /// /// While [`Active::typing`], the text field keeps, without lookup: /// - clipboard events, and the clipboard key presses the web build sends with them (Cmd+C, Cmd+X, @@ -185,6 +200,7 @@ struct LastPress { pub struct Dispatcher { last: Option, repeat: bool, + clipboard: bool, } impl Dispatcher { @@ -199,11 +215,29 @@ impl Dispatcher { self } + /// Whether egui's Copy, Cut and Paste events count as the chords that cause them (Cmd+C, + /// Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift alone) outside text + /// fields. Off by default: the events stay in egui's input for the app's own handlers. With it + /// on, an event that runs a command is removed, and a Paste event's text moves to + /// [`Hit::pasted`]. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run + /// its paste command gets a Paste event back, which with this on runs that command again. + pub fn with_clipboard_events(mut self, on: bool) -> Dispatcher { + self.clipboard = on; + self + } + /// The commands this frame's key presses run with `active` focus, in press order. /// /// A chord pressed again within [`DOUBLE_PRESS`] runs its double binding when it has one; /// the first press has already run the single binding. The press after a double starts over. pub fn dispatch(&mut self, input: &mut InputState, keymap: &Keymap, active: &Active) -> Vec { + self.dispatch_filtered(input, keymap, active, |_| true) + } + + /// [`dispatch`](Self::dispatch), with `accept` deciding each hit before its event is removed. + /// A declined hit is left out of the result and its event, with any text it typed, stays in + /// egui's input in its place. A declined press still counts toward a double press. + pub fn dispatch_filtered(&mut self, input: &mut InputState, keymap: &Keymap, active: &Active, mut accept: impl FnMut(&Hit) -> bool) -> Vec { let platform = keymap.platform(); let now = input.time; let frame_mods = Mods::from_egui(&input.modifiers, platform); @@ -211,24 +245,31 @@ impl Dispatcher { let mut kept = Vec::with_capacity(input.events.len()); // The Text event egui sends after a key press belongs to that press. let mut drop_text = false; - // The web build sends both the Cmd+C key press and the Copy event it causes. - let mut clipboard_key = None; + // The web build sends both a clipboard key press and the clipboard event it causes. + let mut clipboard_press = None; let events = std::mem::take(&mut input.events); let typed = if active.typing { typed_text(&events) } else { Vec::new() }; + let causes_event = if self.clipboard { Vec::new() } else { clipboard_event_follows(&events, platform) }; for (i, event) in events.into_iter().enumerate() { match &event { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { drop_text = false; let held = Mods::from_egui(modifiers, platform); if let Some(key) = Key::from_egui(*key) { - if active.typing && (typed.get(i) == Some(&true) || is_clipboard_press(key, held) || may_be_alt_gr(key, held, platform)) { + let clip = clipboard_kind(key, held); + let keep = if active.typing { + typed.get(i) == Some(&true) || clip.is_some() || may_be_alt_gr(key, held, platform) + } else { + causes_event.get(i) == Some(&true) + }; + if keep { kept.push(event); continue; } - if held.cmd && matches!(key, Key::C | Key::X | Key::V) { - clipboard_key = Some(key); + if clip.is_some() { + clipboard_press = clip; } - if let Some(hit) = self.press(key, held, *repeat, now, keymap, active) { + if let Some(hit) = self.press(key, held, *repeat, now, keymap, active).filter(&mut accept) { hits.push(hit); drop_text = !(held.cmd || held.ctrl); continue; @@ -240,13 +281,18 @@ impl Dispatcher { continue; } // A focused text field copies and pastes on these events itself. - Event::Copy | Event::Cut | Event::Paste(_) if !active.typing => { - let (key, held) = clipboard_chord(&event, frame_mods); - if clipboard_key == Some(key) { - clipboard_key = None; - } else if let Some(hit) = self.press(key, held, false, now, keymap, active) { - hits.push(hit); - continue; + Event::Copy | Event::Cut | Event::Paste(_) if self.clipboard && !active.typing => { + let (key, held, clip) = clipboard_chord(&event, frame_mods); + if clipboard_press == Some(clip) { + clipboard_press = None; + } else if let Some(mut hit) = self.press(key, held, false, now, keymap, active) { + if let Event::Paste(text) = &event { + hit.pasted = Some(text.clone()); + } + if accept(&hit) { + hits.push(hit); + continue; + } } } _ => {} @@ -260,7 +306,10 @@ impl Dispatcher { fn press(&mut self, key: Key, held: Mods, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { let chords = candidates(key, held, keymap.platform()); let first = *chords.first()?; - let find = |double: bool| chords.iter().find_map(|c| Hit::from_lookup(keymap.lookup(c, double, active))); + let pressed = Chord::new(held, key); + let find = |double: bool| { + chords.iter().find_map(|&chord| Hit::from_lookup(keymap.lookup(&chord, double, active), Trigger { chord, double }, chord == pressed)) + }; if repeat { return find(false); } @@ -271,6 +320,33 @@ impl Dispatcher { } } +/// A clipboard event: Copy, Cut or Paste. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Clip { + Copy, + Cut, + Paste, +} + +/// For each event, whether it is a clipboard key press whose clipboard event comes later in the +/// frame, as the web build sends them. +fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec { + let mut out = vec![false; events.len()]; + let mut ahead = [false; 3]; + for (event, follows) in events.iter().zip(out.iter_mut()).rev() { + if let Some(seen) = event_clip(event).and_then(|c| ahead.get_mut(c as usize)) { + *seen = true; + } else if let Event::Key { key, pressed: true, modifiers, .. } = event { + let clip = Key::from_egui(*key).and_then(|k| clipboard_kind(k, Mods::from_egui(modifiers, platform))); + if let Some(seen) = clip.and_then(|c| ahead.get_mut(c as usize)) { + *follows = *seen; + *seen = false; + } + } + } + out +} + /// For each event, whether it is a key press followed by a Text event before the next press. fn typed_text(events: &[Event]) -> Vec { let mut out = vec![false; events.len()]; @@ -315,26 +391,38 @@ fn types_text(key: Key) -> bool { ) } -/// Whether a press is one a backend turns into a Copy, Cut or Paste event. -fn is_clipboard_press(key: Key, held: Mods) -> bool { - match key { - Key::C | Key::X | Key::V => held.cmd, - Key::Delete => held.shift, - Key::Insert => held.shift || held.cmd || held.ctrl, - _ => false, +fn event_clip(event: &Event) -> Option { + match event { + Event::Copy => Some(Clip::Copy), + Event::Cut => Some(Clip::Cut), + Event::Paste(_) => Some(Clip::Paste), + _ => None, } } -/// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also -/// sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { +/// The clipboard event a backend sends for a press, if any. +fn clipboard_kind(key: Key, held: Mods) -> Option { + match key { + Key::C if held.cmd => Some(Clip::Copy), + Key::X if held.cmd => Some(Clip::Cut), + Key::V if held.cmd => Some(Clip::Paste), + Key::Delete if held.shift => Some(Clip::Cut), + Key::Insert if held.shift => Some(Clip::Paste), + Key::Insert if held.cmd || held.ctrl => Some(Clip::Copy), + _ => None, + } +} + +/// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off +/// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert. +fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { let with_cmd = Mods { cmd: true, ..held }; let shift_only = held.shift && !held.cmd; match event { - Event::Cut if shift_only => (Key::Delete, held), - Event::Cut => (Key::X, with_cmd), - Event::Paste(_) if shift_only => (Key::Insert, held), - Event::Paste(_) => (Key::V, with_cmd), - _ => (Key::C, with_cmd), + Event::Cut if shift_only => (Key::Delete, held, Clip::Cut), + Event::Cut => (Key::X, with_cmd, Clip::Cut), + Event::Paste(_) if shift_only => (Key::Insert, held, Clip::Paste), + Event::Paste(_) => (Key::V, with_cmd, Clip::Paste), + _ => (Key::C, with_cmd, Clip::Copy), } } diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 7e275aba..23db9756 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -1,7 +1,7 @@ #![cfg(feature = "egui")] use craft_keymap::egui::{Dispatcher, Hit, matches}; -use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes, Trigger}; use egui::{Event, InputState, Key as EguiKey, Modifiers}; fn chord(text: &str) -> Chord { @@ -71,6 +71,7 @@ fn dispatch_keymap() -> Keymap { let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) }); let catalogue = vec![ app("edit.copy", &["Cmd+C", "Cmd+Insert"]), + app("edit.paste", &["Cmd+V", "Shift+Insert"]), app("app.quit", &["Cmd+Alt+Q"]), app("view.refresh", &["Cmd+Alt+F5"]), app("file.save", &["Cmd+S"]), @@ -139,21 +140,74 @@ fn shifted_symbols_and_zoom_keys_reach_their_bindings() { } #[test] -fn clipboard_events_run_their_chords_outside_text_fields() { +fn clipboard_events_stay_with_the_app_by_default() { let km = dispatch_keymap(); let mut d = Dispatcher::new(); - let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![Event::Copy])); + // egui-winit sends these in place of the Ctrl+C and Ctrl+V presses. + let events = vec![Event::Copy, Event::Paste("text".into())]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events.clone())); + assert!(ran.is_empty(), "{ran:?}"); + assert_eq!(left, events); + // The web build sends the press and then the event: both stay. + let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, events.clone())); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, events[..2]); + // A clipboard press with no clipboard event after it is an ordinary press. + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::Insert, CTRL)])); assert_eq!(ran, ["edit.copy"]); - assert!(left.is_empty()); +} + +#[test] +fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let mut input = frame(1.0, CTRL, vec![Event::Copy, Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.copy", None), ("edit.paste", Some("text"))]); + assert!(input.events.is_empty()); let typing = Active { typing: true, ..Active::NONE }; let (ran, left) = run(&mut d, &km, &typing, frame(2.0, CTRL, vec![Event::Copy])); assert!(ran.is_empty()); assert_eq!(left, [Event::Copy]); - // The web build sends the key press and the Copy event it causes: one copy. - let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::C, CTRL), Event::Copy])); - assert_eq!(ran, ["edit.copy"]); + // The web build sends the key press and the clipboard event it causes: one run each. + for (time, key, mods, event, id) in [ + (3.0, EguiKey::C, CTRL, Event::Copy, "edit.copy"), + (4.0, EguiKey::Insert, CTRL, Event::Copy, "edit.copy"), + (5.0, EguiKey::Insert, Modifiers::SHIFT, Event::Paste("text".into()), "edit.paste"), + ] { + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(time, mods, vec![press(key, mods), event])); + assert_eq!(ran, [id], "{key:?}"); + } +} + +#[test] +fn declined_hits_leave_their_events_in_order() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let shift = Modifiers::SHIFT; + let events = vec![ + press(EguiKey::B, Modifiers::NONE), + Event::Text("b".into()), + press(EguiKey::S, CTRL), + press(EguiKey::Colon, shift), + Event::Text(":".into()), + press(EguiKey::Equals, Modifiers::NONE), + ]; + let mut input = frame(1.0, Modifiers::NONE, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push((hit.command.id.clone(), hit.trigger.chord.to_string(), hit.exact)); + hit.command.id != "file.save" && hit.command.id != "tool.brush" + }); + let s = |id: &str, chord: &str, exact| (id.to_string(), chord.to_string(), exact); + assert_eq!(seen, [s("tool.brush", "B", true), s("file.save", "Cmd+S", true), s("help.find", "Shift+;", false), s("view.zoomIn", "=", true)]); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["help.find", "view.zoomIn"]); + assert_eq!(input.events, events[..3], "the declined B keeps its text, and Ctrl+S stays in its place"); } #[test] @@ -234,7 +288,8 @@ fn the_focused_panel_wins_and_tool_groups_cycle() { let mut input = frame(2.0, Modifiers::NONE, vec![press(EguiKey::M, Modifiers::NONE)]); let hits = d.dispatch(&mut input, &km, &Active::NONE); let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")]; - assert_eq!(hits, [Hit { command: tools[0].clone(), cycle: tools }]); + let trigger = Trigger::single(chord("M")); + assert_eq!(hits, [Hit { command: tools[0].clone(), cycle: tools, trigger, exact: true, pasted: None }]); } #[test] From 5208562c286fe41a6f702c73170ee27c73924992 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:22:49 +0000 Subject: [PATCH 003/124] keymap: store overrides as a map of one text per command when they fit Apps that stored { command: text } before read the field in older builds again. Overrides with several bindings for a command still serialize as the { command, keys } array. keymap::overrides_list always writes the array, for craft-settings files, where only an array lets a reset entry leave the file. --- crates/keymap/README.md | 19 ++++++----- crates/keymap/src/keymap.rs | 1 + crates/keymap/src/keymap/overrides.rs | 45 +++++++++++++++++++++++---- crates/keymap/tests/keymap.rs | 32 +++++++++++++++++++ crates/keymap/tests/settings.rs | 1 + 5 files changed, 85 insertions(+), 13 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 526d0441..74c72833 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -73,13 +73,17 @@ macOS it folds into `cmd` (`Chord::effective`). ## Storing overrides -`Overrides` is a field of the app's craft-settings type. It is stored as an array of -`{ command, keys }` tables (`shortcuts = [{ command = "file.save", keys = ["Cmd+Alt+S"] }]`, or -`[[shortcuts]]` blocks), not as a table keyed by command: craft-settings merges tables key by key -with what the file holds, but replaces an array whole, so a reset command (an entry removed) -stays removed after saving. The map form `{ "" = [keys] }` also loads; there a single -text counts as a one-item list, and `""` means no shortcut, as in apps that store one text per -command. +`Overrides` is a field of the app's settings type. It is stored as a map of one text per command +(`{ "file.save": "Cmd+Alt+S", "edit.copy": "" }`, `""` for no shortcut) while no command has more +than one binding, so apps that stored that map before keep reading it in older builds. Once a +command has several bindings it is stored as an array of `{ command, keys }` tables +(`[{ command = "file.save", keys = ["Cmd+Alt+S", "F12"] }]`). Both forms load, and in the map form +a list of texts loads too. + +In a craft-settings type, put `#[serde(with = "craft_keymap::keymap::overrides_list")]` on the +field, which always writes the array: craft-settings merges tables key by key with what the file +holds but replaces an array whole, so only the array lets a reset command (an entry removed) stay +removed after saving. Hand-written `[[shortcuts]]` blocks load as the array. Texts the keymap cannot use (a key name from a newer version, an unknown scope, a reserved chord) stay in the entry when the command is edited, unless the edit leaves it without bindings. @@ -93,6 +97,7 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] #[serde(default)] struct Prefs { + #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, } diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 6281868e..a55f50ac 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -12,6 +12,7 @@ use crate::command::{CommandRef, MAX_KEY}; use crate::scope::{Active, Scope, ScopeKind, Scopes}; pub use overrides::Overrides; +pub use overrides::list as overrides_list; /// Most commands a catalogue or an [`Overrides`] holds. pub const MAX_COMMANDS: usize = 4096; diff --git a/crates/keymap/src/keymap/overrides.rs b/crates/keymap/src/keymap/overrides.rs index 914b13e6..518581b5 100644 --- a/crates/keymap/src/keymap/overrides.rs +++ b/crates/keymap/src/keymap/overrides.rs @@ -14,8 +14,10 @@ const MAX_NOTES: usize = 64; /// The user's changes to the default bindings: binding key → the full list of binding texts /// that replaces the command's defaults (`[]` = no shortcut). /// -/// Stored as an array of `{ command, keys }` items in key order, e.g. `[[shortcuts]]` blocks in -/// craft-settings; the map form `{ "": [texts] }` loads too. Loading never fails: +/// Stored in key order as a map of one text per command (`{ "": "Cmd+K" }`, `""` for no +/// shortcut) while no entry has more than one text, and otherwise as an array of +/// `{ command, keys }` items. [`list`] always writes the array, for craft-settings files. Both +/// forms load, and in the map form a list of texts loads too. Loading never fails: /// entries for unknown commands and texts that do not parse are kept verbatim; values that are not /// text, overlong keys and texts, repeated keys (the first entry stays) and entries past the caps /// are dropped, and a value that is neither a list nor a map loads as no overrides. All of these @@ -92,12 +94,43 @@ impl Overrides { } } -// Written as an array of `{ command, keys }` items, not a map: craft-settings merges tables key -// by key and keeps keys a value no longer has, but replaces arrays whole, so a reset entry leaves -// the file only in this form. +// The map form is what apps that store one text per command read, so a file stays readable by +// their older builds while no command has more than one binding. impl Serialize for Overrides { fn serialize(&self, serializer: S) -> Result { - serializer.collect_seq(self.map.iter().map(|(command, keys)| Item { command, keys })) + if self.map.values().all(|keys| single(keys).is_some()) { + serializer.collect_map(self.map.iter().map(|(command, keys)| (command, single(keys).unwrap_or_default()))) + } else { + list::serialize(self, serializer) + } + } +} + +/// An entry's text in the map form: `""` for no shortcut. `None` for several texts, and for one +/// blank text, which the map form reads as no shortcut. +fn single(keys: &[String]) -> Option<&str> { + match keys { + [] => Some(""), + [text] if !text.trim().is_empty() => Some(text.as_str()), + _ => None, + } +} + +/// Serializes [`Overrides`] always as an array of `{ command, keys }` items, for +/// `#[serde(with = "craft_keymap::keymap::overrides_list")]`. craft-settings merges a table key +/// by key with what the file holds but replaces an array whole, so in a craft-settings file only +/// this form lets a reset command (an entry removed) stay removed after saving. +pub mod list { + use serde::{Deserialize, Deserializer, Serializer}; + + use super::{Item, Overrides}; + + pub fn serialize(overrides: &Overrides, serializer: S) -> Result { + serializer.collect_seq(overrides.map.iter().map(|(command, keys)| Item { command, keys })) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Overrides::deserialize(deserializer) } } diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 2e16997a..24d2ade8 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -465,6 +465,38 @@ fn an_empty_text_in_the_map_form_removes_the_shortcut() { assert!(again.bindings(&cmd("edit.copy")).is_empty(), "{saved}"); } +#[test] +fn overrides_write_the_map_form_unless_a_command_has_several_keys() { + let mut o = Overrides::new(); + o.insert("edit.copy", vec!["Cmd+K".into()]); + o.insert("edit.paste", vec![]); + let text = serde_json::to_string(&o).unwrap(); + assert_eq!(text, r#"{"edit.copy":"Cmd+K","edit.paste":""}"#, "older builds read one text per command"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + o.insert("edit.cut", vec!["Cmd+X".into(), "Shift+Delete".into()]); + let text = serde_json::to_string(&o).unwrap(); + assert!(text.starts_with(r#"[{"command":"edit.copy","keys":["Cmd+K"]},"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), o); + + // A blank text is kept verbatim (it loads as the defaults), which the map form cannot say. + let mut blank = Overrides::new(); + blank.insert("edit.copy", vec![" ".into()]); + let text = serde_json::to_string(&blank).unwrap(); + assert!(text.starts_with('['), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), blank); + + #[derive(serde::Serialize)] + struct Prefs { + #[serde(with = "craft_keymap::keymap::overrides_list")] + shortcuts: Overrides, + } + let mut single = Overrides::new(); + single.insert("edit.copy", vec!["Cmd+K".into()]); + let text = serde_json::to_string(&Prefs { shortcuts: single }).unwrap(); + assert_eq!(text, r#"{"shortcuts":[{"command":"edit.copy","keys":["Cmd+K"]}]}"#); +} + #[test] fn ctrl_folds_into_cmd_off_macos() { let catalogue = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index c63c893b..89c744bd 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -9,6 +9,7 @@ use serde_json::json; #[serde(default)] struct Prefs { general: General, + #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, } From be51941628f2f3069eac709cf67fa69411cf36a5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:23:26 +0000 Subject: [PATCH 004/124] keymap: add Keymap::conflicts_with and document the edit calls conflicts_with lists the commands that share one command's chords, with that command as a, without filtering every conflict. The README shows set_bindings with one binding for an editor with one shortcut per command; assign adds a binding. --- crates/keymap/README.md | 15 ++++++++++++++- crates/keymap/src/keymap.rs | 26 ++++++++++++++++++++++++++ crates/keymap/tests/keymap.rs | 22 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 74c72833..4f4e062e 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -113,10 +113,23 @@ let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::current()).with let _problems = keymap.problems(); // After each change in the shortcut editor: -keymap.assign(&"file.save".into(), Binding::parse("Cmd+Alt+S").unwrap(), Policy::Steal).ok(); +keymap.set_bindings(&"file.save".into(), vec![Binding::parse("Cmd+Alt+S").unwrap()], Policy::Steal).ok(); store.update(|p| p.shortcuts = keymap.overrides().clone()); ``` +## Editing + +- `set_bindings(&command, bindings, policy)` replaces a command's bindings. An editor with one + shortcut per command calls it with the one binding (`vec![binding]`); `assign` adds a binding + to those the command has. +- `remove` takes one binding away, `clear` leaves the command without shortcuts, `reset` gives it + its defaults back and `reset_all` does so for every command. +- `Policy` says what happens to other commands holding the chord: `Steal` takes it from them, + `Keep` lets both keep it and reports the clash, `Refuse` changes nothing and returns + `AssignError::Taken`. +- `conflicts()` lists every pair of commands on one chord; `conflicts_with(&command)` lists the + commands that share one command's chords, with that command as `a`. + ## Importing a shortcut set The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index a55f50ac..e57d7bf1 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -594,6 +594,32 @@ impl Keymap { out } + /// The conflicts [`Keymap::conflicts`] reports for one command's bindings, with that command + /// as `a`, in the order of its bindings, then catalogue order. Empty for an unknown command. + pub fn conflicts_with(&self, command: &CommandRef) -> Vec { + let Some(i) = self.find(command) else { return Vec::new() }; + let mut out = Vec::new(); + for a in &self.current[i] { + let chord = a.0.effective(self.platform); + let Some(holders) = self.by_trigger.get(&chord) else { continue }; + for &(j, k) in holders { + if j == i || self.same_group(i, j) { + continue; + } + let Some(b) = self.at(j, k) else { continue }; + let Some(clash) = self.conflict_relation(&a.1, &b.1) else { continue }; + out.push(Conflict { + chord, + a: (self.catalogue[i].command.clone(), self.public(i, a)), + b: (self.catalogue[j].command.clone(), self.public(j, b)), + kind: if clash { ConflictKind::Clash } else { ConflictKind::Shadow }, + in_defaults: self.defaults[i].contains(a) && self.defaults[j].contains(b), + }); + } + } + out + } + /// What is wrong with the stored overrides: load notes, unknown commands, texts that do not /// parse, reserved chords, unknown scopes, duplicate entries. pub fn problems(&self) -> Vec { diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 24d2ade8..558f37ff 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -246,6 +246,28 @@ fn conflicts_tell_clashes_from_shadows() { assert!(c("edit.copy", "color.copy") < c("edit.copy", "timeline.copy"), "{order:?}"); assert!(c("edit.copy", "timeline.copy") < c("color.copy", "timeline.copy"), "{order:?}"); + // One command's conflicts, with that command as `a`. + let with = |id: &str| -> Vec<(String, String, ConflictKind)> { + km.conflicts_with(&cmd(id)).into_iter().map(|c| (c.chord.to_string(), c.b.0.id, c.kind)).collect() + }; + let w = |chord: &str, other: &str, kind| (chord.to_string(), other.to_string(), kind); + assert_eq!(with("edit.copy"), [w("Cmd+C", "color.copy", ConflictKind::Clash), w("Cmd+C", "timeline.copy", ConflictKind::Shadow)]); + assert_eq!(with("edit.cut"), [w("Cmd+X", "text.cut", ConflictKind::Shadow), w("Cmd+D", "edit.find", ConflictKind::Clash)]); + assert!(with("nope").is_empty()); + for entry in km.catalogue() { + let id = entry.command.id.as_str(); + let mut direct: Vec<_> = km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); + let mut filtered: Vec<_> = km + .conflicts() + .into_iter() + .filter(|c| c.a.0.id == id || c.b.0.id == id) + .map(|c| if c.a.0.id == id { (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults) } else { (c.chord, c.b.0.id, c.a.0.id, c.kind, c.in_defaults) }) + .collect(); + direct.sort(); + filtered.sort(); + assert_eq!(direct, filtered, "{id}"); + } + let strict = Keymap::new(km.catalogue().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); } From 48c0e1b59d465f8ad6f209f194f310be9c9a5263 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:24:19 +0000 Subject: [PATCH 005/124] keymap: drop hand-written key variants when a command gets its defaults reset, and an edit that leaves a command with its defaults, removed only the entry the command read. A variant of its key (window.panel { "panel": "layers" }) stayed and took effect on the next load. Both remove every entry that resolves to the command. --- crates/keymap/src/keymap.rs | 29 +++++++++++++++++++++++------ crates/keymap/tests/keymap.rs | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index e57d7bf1..6f3f5dea 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -367,6 +367,9 @@ pub struct Keymap { by_trigger: HashMap>, /// The overrides entry each command reads, when it has one. stored: Vec>, + /// Whether the overrides hold a hand-written variant of a command's key + /// (`window.panel { "panel": "layers" }`); only loading adds them. + variants: bool, /// Home and default scopes of the catalogue. catalogue_scopes: HashSet, scopes: Scopes, @@ -388,6 +391,7 @@ impl Keymap { current: Vec::new(), by_trigger: HashMap::new(), stored: Vec::new(), + variants: false, catalogue_scopes: HashSet::new(), scopes, platform, @@ -560,9 +564,7 @@ impl Keymap { pub fn reset(&mut self, command: &CommandRef) -> bool { let Some(i) = self.find(command) else { return false }; // The whole entry goes, texts this build cannot use included. - if let Some(old) = self.stored[i].take() { - self.overrides.remove(&old); - } + self.remove_entries(i); self.set_current(i, self.defaults[i].clone()); true } @@ -874,9 +876,7 @@ impl Keymap { fn store(&mut self, i: usize, list: Vec) { let mut kept = if list.is_empty() { Vec::new() } else { self.unusable_texts(i) }; kept.truncate(MAX_BINDINGS.saturating_sub(list.len())); - if let Some(old) = self.stored[i].take() { - self.overrides.remove(&old); - } + self.remove_entries(i); if !kept.is_empty() || !self.is_default(i, &list) { let home = &self.catalogue[i].scope; let mut texts: Vec = list.iter().map(|b| self.public(i, b).text(home)).collect(); @@ -888,6 +888,21 @@ impl Keymap { self.set_current(i, list); } + /// Removes a command's overrides entries: the one it reads and hand-written variants of its + /// key, which would take effect on the next load. + fn remove_entries(&mut self, i: usize) { + if let Some(old) = self.stored[i].take() { + self.overrides.remove(&old); + } + if !self.variants { + return; + } + let keys: Vec = self.overrides.iter().map(|(k, _)| k).filter(|k| self.resolve_key(k) == Some(i)).map(str::to_string).collect(); + for key in keys { + self.overrides.remove(&key); + } + } + /// Whether `list` is the command's defaults, comparing effective triggers. fn is_default(&self, i: usize, list: &[Bound]) -> bool { let defaults = &self.defaults[i]; @@ -951,8 +966,10 @@ impl Keymap { entries.sort_by_key(|(k, _)| !self.index.contains_key(*k)); let mut found: Vec<(usize, String, Vec)> = Vec::new(); let mut taken: HashSet = HashSet::new(); + self.variants = false; for (key, texts) in entries { let Some(i) = self.resolve_key(key) else { continue }; + self.variants |= !self.index.contains_key(key); if !taken.insert(i) { continue; } diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 558f37ff..a8501a79 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -575,3 +575,26 @@ fn hostile_overrides_load_without_panic_and_report_problems() { assert_eq!(o.load_notes(), [Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{text}"); } } + +#[test] +fn reset_removes_hand_written_variants_of_a_key() { + let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); + let catalogue = vec![Bindable::new(layers.clone(), "Layers", Scope::app()).with_default(b("F7"))]; + let load = || { + let mut o = Overrides::new(); + o.insert(r#"window.panel {"panel":"layers"}"#, vec!["Cmd+L".into()]); + o.insert(r#"window.panel { "panel": "layers" }"#, vec!["Cmd+K".into()]); + Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(o) + }; + let mut km = load(); + assert_eq!(km.bindings(&layers), vec![b("Cmd+L")], "the canonical entry wins"); + km.reset(&layers); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + + // Setting the defaults drops the entries too. + let mut km = load(); + km.set_bindings(&layers, vec![b("F7")], Policy::Steal).unwrap(); + assert!(km.overrides().is_empty(), "{:?}", km.overrides()); + let again = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(km.overrides().clone()); + assert_eq!(again.bindings(&layers), vec![b("F7")]); +} From 66f22029ba71dad29dcda156f01f680763aa6659 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:25:06 +0000 Subject: [PATCH 006/124] keymap: format the conflicts_with test --- crates/keymap/tests/keymap.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index a8501a79..475109d4 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -256,12 +256,19 @@ fn conflicts_tell_clashes_from_shadows() { assert!(with("nope").is_empty()); for entry in km.catalogue() { let id = entry.command.id.as_str(); - let mut direct: Vec<_> = km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); + let mut direct: Vec<_> = + km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); let mut filtered: Vec<_> = km .conflicts() .into_iter() .filter(|c| c.a.0.id == id || c.b.0.id == id) - .map(|c| if c.a.0.id == id { (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults) } else { (c.chord, c.b.0.id, c.a.0.id, c.kind, c.in_defaults) }) + .map(|c| { + if c.a.0.id == id { + (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults) + } else { + (c.chord, c.b.0.id, c.a.0.id, c.kind, c.in_defaults) + } + }) .collect(); direct.sort(); filtered.sort(); From 6e136c9114d4461e791f0599c4976fc0c01e0048 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:34:27 +0000 Subject: [PATCH 007/124] keymap: match panel-menu items, aliases in menus, and Show/Hide only for toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ForeignEntry::in_panel_menu marks an item whose leading segments name a panel (InDesign's "Character: All Caps"); it also matches a catalogue path ending with its segments, then its last segment as a unique label. - Aliases count in the menu steps too, joined segments are tried as a label or alias anywhere, and normalize_label reads a colon as a space, so the alias "Preferences: General…" matches "Edit > Preferences > General...". - A leading Show, Hide or Show/Hide is dropped only for commands marked Bindable::toggle, and only after the names as written found nothing. - Keys in skipped contexts are left out of unmatched rows, so entries with only such keys no longer use up the suggestion cap. --- crates/keymap/README.md | 25 ++++- crates/keymap/src/import.rs | 190 ++++++++++++++++++++++++---------- crates/keymap/src/keymap.rs | 11 ++ crates/keymap/tests/import.rs | 139 ++++++++++++++++++++++++- 4 files changed, 300 insertions(+), 65 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 4f4e062e..2027a2f5 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -164,9 +164,23 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File ``` - Matching, most specific first, each step needing exactly one command: the user's remembered - answers, the full menu path, the joined submenu segments as a label in that menu, the label in - that menu, the label or an alias anywhere. Labels are compared after `import::normalize_label`. - Tool entries match only tools. + answers, the full menu path, the joined submenu segments as a label or alias in that menu, the + label in that menu, the joined segments as a label or alias anywhere, the label anywhere. An + entry with no menu and several segments matches only by its full path, unless the reader marks + it `in_panel_menu()`. Tool entries match only tools. +- Names are compared after `import::normalize_label`: case, `…`, `...`, `&`, colons and extra + spaces aside, so the alias `Preferences: General…` matches `Edit > Preferences > General...`. + Aliases (`Bindable::with_alias`) count wherever labels do. +- Panel menus: sources list panel-menu items with the panel's name in front (InDesign's Panel + Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas). + The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`. + Such an entry also matches a catalogue path that ends with its segments (with the command's + label or the path's last segment last), and its last segment alone as a label. +- Show/Hide: a source often lists one toggle as two items, `Show Rulers` and `Hide Rulers`. Names + are matched as written first. When no step finds a command, the steps run again on commands + marked `Bindable::toggle()`, with a leading `Show `, `Hide ` or `Show/Hide ` dropped from the + entry's label and from the command's label, aliases and path labels. A command not marked as a + toggle never matches through a dropped prefix, so `Hide Conveyor` does not match `Conveyor`. - `ImportOptions.contexts` says what each source context becomes (home scope, another scope, the home scope only without a home entry, or skipped). - The set wins: a chord it gives to a command is taken from commands outside it (except twins @@ -176,8 +190,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File - Keys read approximately (a numeric keypad key bound as the main key) are listed in `approximated`. - With `ImportOptions.suggest`, unmatched entries get fuzzy candidates (`import::namematch`); - they are reported, never applied. Only the first 200 unmatched entries get them, and only the - first 200 characters of a name are compared. + they are reported, never applied. Only the first 200 unmatched entries with keys get them, and + only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do + not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. ## Features diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 2af0ed4a..958db9e8 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -26,7 +26,7 @@ pub const MAX_IMPORT_TEXT: usize = 1024; /// Most path segments read per entry; the label (last segment) is always kept. pub const MAX_PATH_SEGMENTS: usize = 32; /// Most unmatched entries [`ImportOptions::suggest`] looks for commands for; the rest are counted -/// in [`ImportReport::truncated`]. +/// in [`ImportReport::truncated`]. Entries without keys get no suggestion and do not count. pub const MAX_SUGGESTIONS: usize = 200; /// A shortcut set as an app's reader found it. @@ -49,6 +49,12 @@ pub struct ForeignEntry { pub line: Option, /// The item selects a tool. pub is_tool: bool, + /// The entry belongs to a panel menu: its leading path segments name the panel, not a + /// submenu of [`ForeignEntry::menu`], which is usually `None`. Sources write such items as + /// `Character: All Caps` (InDesign's Panel Menus area, the panel-menu areas of Illustrator and + /// Photoshop sets); the reader splits them into segments. Such an entry also matches a + /// catalogue path that ends with its path, and its last segment alone. + pub panel_menu: bool, } impl ForeignEntry { @@ -63,6 +69,12 @@ impl ForeignEntry { self } + /// Marks the entry as an item of a panel menu ([`ForeignEntry::panel_menu`]). + pub fn in_panel_menu(mut self) -> ForeignEntry { + self.panel_menu = true; + self + } + /// Adds a key after the others. pub fn with_key(mut self, key: ForeignKey) -> ForeignEntry { self.keys.push(key); @@ -350,7 +362,8 @@ pub struct ImportReport { #[serde(default)] pub failed: Vec, pub unmatched: Vec, - /// Entries without keys that matched no command. + /// Entries that matched no command and have no keys, or keys only in + /// [`ContextRule::Skip`] contexts. pub unmatched_without_shortcut: usize, pub unreadable: Vec, pub approximated: Vec, @@ -360,13 +373,19 @@ pub struct ImportReport { pub truncated: usize, } -/// The matching form of a label or menu name: lower case, no `…`, `...` or `&`, single spaces, -/// and no leading `show/hide `, `show ` or `hide ` when something remains (sources name a toggle -/// by what choosing it does now). +/// The matching form of a label or menu name: lower case, no `…`, `...` or `&`, a colon read as +/// a space (so the alias `Preferences: General…` matches the path `Preferences > General...`), +/// single spaces. pub fn normalize_label(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_else(|| s.clone(), str::to_string) + let s = s.replace('…', "").replace("...", "").replace('&', "").replace(':', " "); + s.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// A normalised name without a leading `show/hide `, `show ` or `hide ` when something remains: +/// sources name a toggle by what choosing it does now. Only toggle commands +/// ([`Bindable::is_toggle`]) are matched by this form. +fn toggle_name(s: &str) -> String { + ["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).unwrap_or(s).to_string() } /// Matches `set` to the keymap's commands. Pure: the keymap is not changed. @@ -380,12 +399,25 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor let mut planned_at: HashMap = HashMap::new(); let mut to_suggest: Vec = Vec::new(); + let rule_of = |k: &ForeignKey| -> Option<&ContextRule> { + match &k.context { + None => Some(&ContextRule::Home), + Some(c) => { + let c = c.to_lowercase(); + contexts.iter().find(|(n, _)| *n == c).map(|(_, r)| *r) + } + } + }; + for (order, raw) in set.entries.iter().take(MAX_IMPORT_ENTRIES).enumerate() { let entry = capped(raw, &mut cut); let item = entry.item(); let found = index.find(&entry, entry.is_tool, &options.remembered); let Found::One(i) = found else { - if entry.keys.is_empty() { + // Keys in skipped contexts would bind nothing, so they neither make a row nor count + // toward the suggestion cap. + let keys: Vec<&ForeignKey> = entry.keys.iter().filter(|k| rule_of(k) != Some(&ContextRule::Skip)).collect(); + if keys.is_empty() { report.unmatched_without_shortcut += 1; continue; } @@ -402,19 +434,10 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor cut += 1; } } - report.unmatched.push(Unmatched { item, line: entry.line, keys: keys_text(entry.keys.iter()), reason }); + report.unmatched.push(Unmatched { item, line: entry.line, keys: keys_text(keys.into_iter()), reason }); continue; }; - let rule_of = |k: &ForeignKey| -> Option<&ContextRule> { - match &k.context { - None => Some(&ContextRule::Home), - Some(c) => { - let c = c.to_lowercase(); - contexts.iter().find(|(n, _)| *n == c).map(|(_, r)| *r) - } - } - }; let has_home = entry.keys.iter().any(|k| rule_of(k) == Some(&ContextRule::Home)); let mut unlisted = Vec::new(); let mut unreadable = Vec::new(); @@ -700,7 +723,7 @@ fn capped(e: &ForeignEntry, cut: &mut usize) -> ForeignEntry { }), }) .collect(); - ForeignEntry { menu: e.menu.as_deref().map(|m| cap_text(m, cut)), path, keys, line: e.line, is_tool: e.is_tool } + ForeignEntry { menu: e.menu.as_deref().map(|m| cap_text(m, cut)), path, keys, line: e.line, is_tool: e.is_tool, panel_menu: e.panel_menu } } enum Found { @@ -709,14 +732,15 @@ enum Found { None, } -/// The catalogue by normalised path and label, for the match steps. -struct Index { - tool: Vec, - /// Binding keys, for remembered answers. - keys: HashMap, +/// Catalogue names in one form, for the match steps. +#[derive(Default)] +struct Names { /// Normalised paths joined by U+001F. paths: HashMap>, - /// (top menu, label) at any depth of that menu. + /// The last two or more segments of paths, also with the label as the last one, joined by + /// U+001F: for panel-menu entries. + ends: HashMap>, + /// (top menu, label, alias or path label) at any depth of that menu. in_menu: HashMap<(String, String), Vec>, /// Labels, aliases and path labels anywhere. anywhere: HashMap>, @@ -729,26 +753,86 @@ fn add(map: &mut HashMap>, key: K, i: usi } } +impl Names { + /// Adds command `i`, its label, aliases and the last segment of each path passed through `form`. + fn add(&mut self, i: usize, b: &Bindable, form: fn(&str) -> String) { + let label = form(&normalize_label(&b.label)); + let names: Vec = std::iter::once(label.clone()).chain(b.aliases.iter().map(|a| form(&normalize_label(a)))).collect(); + for name in &names { + add(&mut self.anywhere, name.clone(), i); + } + for path in &b.paths { + let mut segs: Vec = path.iter().map(|s| normalize_label(s)).collect(); + if let [_, .., last] = segs.as_mut_slice() { + *last = form(last); + } + add(&mut self.paths, segs.join("\u{1f}"), i); + let Some(top) = segs.first() else { continue }; + for name in &names { + add(&mut self.in_menu, (top.clone(), name.clone()), i); + } + let [_, .., last] = segs.as_slice() else { continue }; + add(&mut self.in_menu, (top.clone(), last.clone()), i); + add(&mut self.anywhere, last.clone(), i); + let mut with_label = segs.clone(); + if let Some(l) = with_label.last_mut() { + *l = label.clone(); + } + for variant in [&segs, &with_label] { + for start in 0..variant.len().saturating_sub(1) { + if let Some(end) = variant.get(start..) { + add(&mut self.ends, end.join("\u{1f}"), i); + } + } + } + } + } + + /// The lists the match steps look up for an entry's normalised menu and path, most specific + /// first. + fn steps(&self, menu: Option<&String>, segs: &[String], panel_menu: bool) -> Vec>> { + let Some(last) = segs.last() else { return Vec::new() }; + let full = menu.into_iter().chain(segs).map(String::as_str).collect::>().join("\u{1f}"); + let mut steps = vec![self.paths.get(&full)]; + let joined = (segs.len() > 1).then(|| segs.join(" ")); + if let Some(m) = menu { + if let Some(j) = &joined { + steps.push(self.in_menu.get(&(m.clone(), j.clone()))); + } + steps.push(self.in_menu.get(&(m.clone(), last.clone()))); + } + if panel_menu && segs.len() > 1 { + steps.push(self.ends.get(&segs.join("\u{1f}"))); + } + if let Some(j) = &joined { + steps.push(self.anywhere.get(j)); + } + if menu.is_some() || segs.len() == 1 || panel_menu { + steps.push(self.anywhere.get(last)); + } + steps + } +} + +/// The catalogue by normalised path and label, for the match steps. +struct Index { + tool: Vec, + /// Binding keys, for remembered answers. + keys: HashMap, + names: Names, + /// Toggle commands by their names without a leading Show/Hide ([`toggle_name`]). + toggles: Names, +} + impl Index { fn new(keymap: &Keymap) -> Index { - let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), paths: HashMap::new(), in_menu: HashMap::new(), anywhere: HashMap::new() }; + let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), names: Names::default(), toggles: Names::default() }; for (i, b) in keymap.catalogue().iter().enumerate() { ix.tool.push(matches!(b.kind, Kind::Tool { .. })); ix.keys.insert(b.command.key(), i); - let label = normalize_label(&b.label); - add(&mut ix.anywhere, label.clone(), i); - for a in &b.aliases { - add(&mut ix.anywhere, normalize_label(a), i); - } - for path in &b.paths { - let segs: Vec = path.iter().map(|s| normalize_label(s)).collect(); - add(&mut ix.paths, segs.join("\u{1f}"), i); - let Some(top) = segs.first() else { continue }; - add(&mut ix.in_menu, (top.clone(), label.clone()), i); - if let [_, .., last] = segs.as_slice() { - add(&mut ix.in_menu, (top.clone(), last.clone()), i); - add(&mut ix.anywhere, last.clone(), i); - } + ix.names.add(i, b, str::to_string); + if b.is_toggle { + ix.toggles.add(i, b, toggle_name); } } ix @@ -763,7 +847,9 @@ impl Index { } } - /// The match steps in order; the first that finds exactly one command decides. + /// The match steps in order; the first that finds exactly one command decides. The steps run + /// on the names as written, then on toggle commands with the entry's label and theirs + /// without a leading Show/Hide. fn find(&self, entry: &ForeignEntry, tool: bool, remembered: &BTreeMap) -> Found { if let Some(c) = remembered.get(&entry.remember_key()) && let Some(&i) = self.keys.get(&c.key()) @@ -772,21 +858,15 @@ impl Index { return Found::One(i); } let segs: Vec = entry.path.iter().map(|s| normalize_label(s)).collect(); - let Some(last) = segs.last() else { return Found::None }; + let mut toggle_segs = segs.clone(); + if let Some(last) = toggle_segs.last_mut() { + *last = toggle_name(last); + } let menu = entry.menu.as_deref().map(normalize_label); - let full = menu.iter().chain(&segs).map(String::as_str).collect::>().join("\u{1f}"); - let mut steps = vec![self.paths.get(&full)]; - if let Some(m) = &menu { - if segs.len() > 1 { - steps.push(self.in_menu.get(&(m.clone(), segs.join(" ")))); - } - steps.push(self.in_menu.get(&(m.clone(), last.clone()))); - } - if menu.is_some() || segs.len() == 1 { - steps.push(self.anywhere.get(last)); - } + let steps = self.names.steps(menu.as_ref(), &segs, entry.panel_menu); + let toggle_steps = self.toggles.steps(menu.as_ref(), &toggle_segs, entry.panel_menu); let mut many = false; - for list in steps { + for list in steps.into_iter().chain(toggle_steps) { match self.pick(list, tool) { Found::One(i) => return Found::One(i), Found::Many => many = true, diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 6f3f5dea..d2a81ae9 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -146,6 +146,10 @@ pub struct Bindable { pub scope: Scope, #[serde(default)] pub kind: Kind, + /// The command shows and hides something (a panel, guides, rulers). Import matching reads a + /// foreign name without a leading `Show`, `Hide` or `Show/Hide` only for such commands. + #[serde(default)] + pub is_toggle: bool, #[serde(default)] pub defaults: Vec, } @@ -160,10 +164,17 @@ impl Bindable { aliases: Vec::new(), scope, kind: Kind::Command, + is_toggle: false, defaults: Vec::new(), } } + /// Marks the command as showing and hiding something ([`Bindable::is_toggle`]). + pub fn toggle(mut self) -> Bindable { + self.is_toggle = true; + self + } + pub fn with_kind(mut self, kind: Kind) -> Bindable { self.kind = kind; self diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 56d17315..635045ce 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -49,7 +49,7 @@ fn catalogue() -> Vec { command("app.palette", "Command Palette", &[], &["Cmd+Alt+K"]), command(panel("layers"), "Layers", &["Window", "Layers"], &["F7"]), command(panel("pages"), "Pages", &["Window", "Pages"], &["F12"]), - command("view.guides", "Guides", &["View", "Grids & Guides", "Guides"], &["Cmd+;"]), + command("view.guides", "Guides", &["View", "Grids & Guides", "Guides"], &["Cmd+;"]).toggle(), command("view.goToPage", "Go to Page…", &["View", "Go to Page…"], &["Cmd+J"]), command("view.zoomIn", "Zoom In", &["View", "Zoom In"], &["Cmd+="]), command("object.textFrameOptions", "Text Frame Options…", &["Object", "Text Frame Options…"], &["Cmd+B"]), @@ -143,9 +143,8 @@ fn labels_normalise_for_matching() { assert_eq!(normalize_label("Check Spelling..."), "check spelling"); assert_eq!(normalize_label("Check Spelling…"), "check spelling"); assert_eq!(normalize_label("Grids && Guides"), "grids guides"); - assert_eq!(normalize_label("SHOW/HIDE Guides"), "guides"); - assert_eq!(normalize_label("Show Hidden Characters"), "hidden characters"); - assert_eq!(normalize_label("Hide "), "hide", "nothing left after the prefix"); + assert_eq!(normalize_label("SHOW/HIDE Guides"), "show/hide guides"); + assert_eq!(normalize_label("Preferences: General…"), "preferences general"); assert_eq!(normalize_label("ÄRGER"), "ärger"); } @@ -153,7 +152,7 @@ fn labels_normalise_for_matching() { fn every_match_step_finds_its_command() { let (km, r) = import_windows(); assert_eq!(r.set.as_deref(), Some("My Set")); - // 1: full path, with `&`, case, spacing and Show/Hide normalised. + // 1: full path, with `&`, case and spacing normalised, and Show/Hide dropped for a toggle. has(&km, "view.guides", &["Cmd+Alt+;"]); // 2: joined segments `Text Frame Options` in Object. has(&km, "object.textFrameOptions", &["Cmd+Alt+B"]); @@ -544,3 +543,133 @@ fn report_json_uses_camel_case_names() { assert_eq!(a["approximated"][0]["note"], "numpad"); assert_eq!(v["changed"][0]["from"], json!(["Cmd+W"])); } + +fn import_into(catalogue: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { + let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Windows); + let report = plan(&ForeignSet { name: None, entries }, &km, options).apply(&mut km); + (km, report) +} + +fn default_context() -> ImportOptions { + ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() } +} + +fn unmatched(r: &ImportReport) -> Vec<(&str, UnmatchedReason)> { + r.unmatched.iter().map(|u| (u.item.as_str(), u.reason)).collect() +} + +#[test] +fn panel_menu_entries_match_by_path_end_or_label() { + let catalogue = vec![ + command("type.allCaps", "All Caps", &["Panels", "Character", "All Caps"], &[]), + command("pages.overrideAll", "Override All Parent Items", &["Pages", "Override All Parent Page Items"], &[]), + command("paragraph.justification", "Justification…", &[], &[]), + command("swatches.new", "New Swatch", &["Swatches", "New Swatch"], &[]), + command("gradient.new", "New Swatch", &["Gradient", "New Swatch"], &[]), + command("character.smallCaps", "Small Caps", &[], &[]).with_alias("Character: Small Caps"), + ]; + let d = "Default"; + let panel_entry = |path: &[&str], key: &str| entry(None, path, &[(d, key)], Flavor::Windows).in_panel_menu(); + let entries = vec![ + // The end of a path, label last. + panel_entry(&["Character", "All Caps"], "Ctrl+F1"), + // The end of a path whose last segment differs from the label. + panel_entry(&["Pages", "Override All Parent Page Items"], "Ctrl+F2"), + // The last segment alone, as one command's label. + panel_entry(&["Paragraph", "Justification..."], "Ctrl+F3"), + // The panel picks one of two commands with the label. + panel_entry(&["Swatches", "New Swatch"], "Ctrl+F4"), + // The joined segments as an alias. + panel_entry(&["Character", "Small Caps"], "Ctrl+F5"), + // No path end fits and the label alone fits two commands. + panel_entry(&["Color", "New Swatch"], "Ctrl+F6"), + // Without the mark, a path with no menu matches only a whole catalogue path. + entry(None, &["Character", "All Caps"], &[(d, "Ctrl+F7")], Flavor::Windows), + ]; + let (km, r) = import_into(catalogue, entries, &default_context()); + has(&km, "type.allCaps", &["Cmd+F1"]); + has(&km, "pages.overrideAll", &["Cmd+F2"]); + has(&km, "paragraph.justification", &["Cmd+F3"]); + has(&km, "swatches.new", &["Cmd+F4"]); + has(&km, "character.smallCaps", &["Cmd+F5"]); + has(&km, "gradient.new", &[]); + assert_eq!(unmatched(&r), [("Color > New Swatch", UnmatchedReason::Ambiguous), ("Character > All Caps", UnmatchedReason::NoMatch)]); +} + +#[test] +fn aliases_match_like_labels() { + let catalogue = vec![ + command("app.quit", "Quit DesignCraft", &["File", "Quit DesignCraft"], &[]).with_alias("Exit"), + command("app.preferences", "Preferences…", &["Edit", "Preferences…"], &[]).with_alias("Preferences: General…"), + command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]).with_alias("Apply bold"), + command("edit.paste", "Paste", &["Edit", "Paste"], &[]).with_alias("Einfügen"), + ]; + let d = "Default"; + let entries = vec![ + win("File", &["Exit"], &[(d, "Ctrl+Q")]), + win("Edit", &["Preferences", "General..."], &[(d, "Ctrl+K")]), + entry(None, &["Apply Bold"], &[(d, "Ctrl+Shift+B")], Flavor::Windows), + win("Bearbeiten", &["Einfügen"], &[(d, "Ctrl+Shift+V")]), + ]; + let (km, r) = import_into(catalogue, entries, &default_context()); + has(&km, "app.quit", &["Cmd+Q"]); + has(&km, "app.preferences", &["Cmd+K"]); + has(&km, "type.bold", &["Cmd+Shift+B"]); + has(&km, "edit.paste", &["Cmd+Shift+V"]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + +#[test] +fn show_and_hide_are_read_off_only_for_toggles() { + let catalogue = vec![ + command(panel("conveyor"), "Conveyor", &["Window", "Conveyor"], &[]), + command("view.rulers", "Rulers", &["View", "Rulers"], &[]).toggle(), + command("view.frameEdges", "Show Frame Edges", &["View", "Extras", "Show Frame Edges"], &[]).toggle(), + command("view.hiddenCharacters", "Show Hidden Characters", &["Type", "Show Hidden Characters"], &[]), + ]; + let d = "Default"; + let entries = vec![ + // Not a toggle: the name must fit as written. + win("View", &["Extras", "Hide Conveyor"], &[(d, "Ctrl+F1")]), + // One toggle for both of the source's items. + win("View", &["Show Rulers"], &[(d, "Ctrl+R")]), + win("View", &["Hide Rulers"], &[(d, "Ctrl+Shift+R")]), + win("View", &["Extras", "Hide Frame Edges"], &[(d, "Ctrl+H")]), + // Exact names match any command. + win("Type", &["Show Hidden Characters"], &[(d, "Ctrl+Alt+I")]), + win("Type", &["Hide Hidden Characters"], &[(d, "Ctrl+Alt+H")]), + ]; + let (km, r) = import_into(catalogue, entries, &default_context()); + has(&km, panel("conveyor"), &[]); + has(&km, "view.rulers", &["Cmd+R", "Cmd+Shift+R"]); + has(&km, "view.frameEdges", &["Cmd+H"]); + has(&km, "view.hiddenCharacters", &["Cmd+Alt+I"]); + assert_eq!( + unmatched(&r), + [("View > Extras > Hide Conveyor", UnmatchedReason::NoMatch), ("Type > Hide Hidden Characters", UnmatchedReason::NoMatch)] + ); +} + +#[test] +fn only_entries_with_keys_to_bind_count_toward_suggestions() { + let catalogue = vec![command("edit.checkSpelling", "Check Spelling…", &["Edit", "Check Spelling…"], &[])]; + let d = "Default"; + // Twice the cap of unmatched entries without keys, or with keys only in a skipped context, + // before one with a key. + let mut entries: Vec = (0..MAX_SUGGESTIONS) + .flat_map(|i| [win("Edit", &[&format!("Nothing {i}")], &[]), win("Edit", &[&format!("Tables {i}")], &[("Table", "Ctrl+T")])]) + .collect(); + entries.push(win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K"), ("Table", "Ctrl+T")])); + let options = ImportOptions { + contexts: vec![(d.into(), ContextRule::Home), ("Table".into(), ContextRule::Skip)], + suggest: true, + ..ImportOptions::default() + }; + let (_, r) = import_into(catalogue, entries, &options); + assert_eq!(r.unmatched_without_shortcut, 2 * MAX_SUGGESTIONS); + assert_eq!(r.unmatched.len(), 1); + assert_eq!(r.unmatched[0].keys, "Default: Ctrl+K", "keys in a skipped context are left out"); + assert_eq!(r.suggestions.len(), 1); + assert_eq!(r.suggestions[0].candidates, [CommandRef::new("edit.checkSpelling")]); + assert_eq!(r.truncated, 0); +} From 1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:45:45 +0000 Subject: [PATCH 008/124] keymap: keep panel-menu entries without keys from clearing commands The last segment of a panel-menu entry matches alone only when the panel name is a segment of some catalogue path. An entry without keys matched only as a panel-menu item leaves the command's shortcuts alone and is listed in ImportReport::matched_without_change. Tests cover an entry without keys next to one with keys for the same command, in both orders. --- crates/keymap/README.md | 9 +++- crates/keymap/src/import.rs | 96 ++++++++++++++++++++++++++--------- crates/keymap/tests/import.rs | 89 ++++++++++++++++++++++++++++++-- 3 files changed, 163 insertions(+), 31 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 2027a2f5..81b55ec9 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -175,7 +175,11 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas). The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`. Such an entry also matches a catalogue path that ends with its segments (with the command's - label or the path's last segment last), and its last segment alone as a label. + label or the path's last segment last), and its last segment alone as a label. The last + segment alone counts only when the first segment, the panel, is a segment of some catalogue + path, so `History: New Document` matches nothing in an app without a History panel. An entry + without keys that matched only these ways leaves the command's shortcuts alone and is listed + in `matched_without_change` (reason `PanelMenu`). - Show/Hide: a source often lists one toggle as two items, `Show Rulers` and `Hide Rulers`. Names are matched as written first. When no step finds a command, the steps run again on commands marked `Bindable::toggle()`, with a leading `Show `, `Hide ` or `Show/Hide ` dropped from the @@ -186,7 +190,8 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File - The set wins: a chord it gives to a command is taken from commands outside it (except twins sharing it as a default), a later entry takes a chord from an earlier one (as when the entries are applied in file order; the earlier key is listed in `skipped`), and an entry without keys - can clear its command. + can clear its command. It clears it only when no entry matched to the same command has keys, + whatever the order of the entries. - Keys read approximately (a numeric keypad key bound as the main key) are listed in `approximated`. - With `ImportOptions.suggest`, unmatched entries get fuzzy candidates (`import::namematch`); diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 958db9e8..4a3ff138 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -53,7 +53,9 @@ pub struct ForeignEntry { /// submenu of [`ForeignEntry::menu`], which is usually `None`. Sources write such items as /// `Character: All Caps` (InDesign's Panel Menus area, the panel-menu areas of Illustrator and /// Photoshop sets); the reader splits them into segments. Such an entry also matches a - /// catalogue path that ends with its path, and its last segment alone. + /// catalogue path that ends with its path, and its last segment alone when its first segment + /// is a segment of some catalogue path. Without keys, an entry matched only these ways + /// changes nothing ([`ImportReport::matched_without_change`]). pub panel_menu: bool, } @@ -153,7 +155,8 @@ pub enum Start { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NoneDefined { - /// The command loses its shortcuts. + /// The command loses its shortcuts, unless another entry matched to it has keys or the entry + /// matched only through a panel-menu rule ([`NoChangeReason::PanelMenu`]). #[default] Clear, /// The command keeps its shortcuts. @@ -195,7 +198,8 @@ pub struct Planned { pub item: String, pub line: Option, pub bindings: Vec, - /// Every matched entry had no keys. + /// Every matched entry had no keys, so [`NoneDefined::Clear`] clears the command. One entry + /// with keys, in any order, keeps an entry without keys from clearing them. pub none_defined: bool, } @@ -210,7 +214,8 @@ pub struct ImportPlan { pub commands: Vec, pub start: Start, pub none_defined: NoneDefined, - /// `unmatched`, `unreadable`, `approximated`, `suggestions` and the counts; + /// `unmatched`, `unreadable`, `approximated`, `suggestions`, `matched_without_change` and the + /// counts; /// [`ImportPlan::apply`] adds the rest. pub report: ImportReport, } @@ -327,6 +332,27 @@ pub struct Unreadable { pub reason: ForeignError, } +/// An entry without keys that matched a command and left its shortcuts alone. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MatchedWithoutChange { + pub command: CommandRef, + pub label: String, + pub item: String, + pub line: Option, + pub reason: NoChangeReason, +} + +/// Why a [`MatchedWithoutChange`] entry did not clear its command. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NoChangeReason { + /// The entry matched only through a panel-menu rule (a catalogue path ending with its + /// segments, or its last segment alone). Such a match is close enough to bind the entry's + /// keys, not to take a command's shortcuts away. + PanelMenu, +} + /// Likely commands for an unmatched entry, best first. Never applied. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -361,6 +387,9 @@ pub struct ImportReport { pub skipped: Vec, #[serde(default)] pub failed: Vec, + /// Entries without keys that matched a command and changed nothing. + #[serde(default)] + pub matched_without_change: Vec, pub unmatched: Vec, /// Entries that matched no command and have no keys, or keys only in /// [`ContextRule::Skip`] contexts. @@ -413,7 +442,7 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor let entry = capped(raw, &mut cut); let item = entry.item(); let found = index.find(&entry, entry.is_tool, &options.remembered); - let Found::One(i) = found else { + let Found::One(i, by_panel) = found else { // Keys in skipped contexts would bind nothing, so they neither make a row nor count // toward the suggestion cap. let keys: Vec<&ForeignKey> = entry.keys.iter().filter(|k| rule_of(k) != Some(&ContextRule::Skip)).collect(); @@ -423,7 +452,7 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor } let reason = match found { Found::Many => UnmatchedReason::Ambiguous, - _ if matches!(index.find(&entry, !entry.is_tool, &BTreeMap::new()), Found::One(_)) => UnmatchedReason::Tool, + _ if matches!(index.find(&entry, !entry.is_tool, &BTreeMap::new()), Found::One(..)) => UnmatchedReason::Tool, _ => UnmatchedReason::NoMatch, }; if options.suggest && reason != UnmatchedReason::Tool { @@ -438,6 +467,13 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor continue; }; + if by_panel && entry.keys.is_empty() { + let (command, label) = + keymap.catalogue().get(i).map(|b| (b.command.clone(), b.label.clone())).unwrap_or_else(|| (CommandRef::new(""), String::new())); + report.matched_without_change.push(MatchedWithoutChange { command, label, item, line: entry.line, reason: NoChangeReason::PanelMenu }); + continue; + } + let has_home = entry.keys.iter().any(|k| rule_of(k) == Some(&ContextRule::Home)); let mut unlisted = Vec::new(); let mut unreadable = Vec::new(); @@ -727,7 +763,8 @@ fn capped(e: &ForeignEntry, cut: &mut usize) -> ForeignEntry { } enum Found { - One(usize), + /// The command, and whether only a panel-menu step found it. + One(usize, bool), Many, None, } @@ -789,26 +826,30 @@ impl Names { } /// The lists the match steps look up for an entry's normalised menu and path, most specific - /// first. - fn steps(&self, menu: Option<&String>, segs: &[String], panel_menu: bool) -> Vec>> { + /// first, each with whether it is a panel-menu step. `panel` is `Some(known)` for a panel-menu + /// entry, `known` saying whether the catalogue has its panel; only then does its last segment + /// alone match. + fn steps(&self, menu: Option<&String>, segs: &[String], panel: Option) -> Vec<(Option<&Vec>, bool)> { let Some(last) = segs.last() else { return Vec::new() }; let full = menu.into_iter().chain(segs).map(String::as_str).collect::>().join("\u{1f}"); - let mut steps = vec![self.paths.get(&full)]; + let mut steps = vec![(self.paths.get(&full), false)]; let joined = (segs.len() > 1).then(|| segs.join(" ")); if let Some(m) = menu { if let Some(j) = &joined { - steps.push(self.in_menu.get(&(m.clone(), j.clone()))); + steps.push((self.in_menu.get(&(m.clone(), j.clone())), false)); } - steps.push(self.in_menu.get(&(m.clone(), last.clone()))); + steps.push((self.in_menu.get(&(m.clone(), last.clone())), false)); } - if panel_menu && segs.len() > 1 { - steps.push(self.ends.get(&segs.join("\u{1f}"))); + if panel.is_some() && segs.len() > 1 { + steps.push((self.ends.get(&segs.join("\u{1f}")), true)); } if let Some(j) = &joined { - steps.push(self.anywhere.get(j)); + steps.push((self.anywhere.get(j), false)); } - if menu.is_some() || segs.len() == 1 || panel_menu { - steps.push(self.anywhere.get(last)); + if menu.is_some() || segs.len() == 1 { + steps.push((self.anywhere.get(last), false)); + } else if panel == Some(true) { + steps.push((self.anywhere.get(last), true)); } steps } @@ -822,15 +863,18 @@ struct Index { names: Names, /// Toggle commands by their names without a leading Show/Hide ([`toggle_name`]). toggles: Names, + /// Every normalised segment of every catalogue path: the panels and menus the app has. + segments: HashSet, } impl Index { fn new(keymap: &Keymap) -> Index { - let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), names: Names::default(), toggles: Names::default() }; + let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), names: Names::default(), toggles: Names::default(), segments: HashSet::new() }; for (i, b) in keymap.catalogue().iter().enumerate() { ix.tool.push(matches!(b.kind, Kind::Tool { .. })); ix.keys.insert(b.command.key(), i); ix.names.add(i, b, str::to_string); + ix.segments.extend(b.paths.iter().flatten().map(|s| normalize_label(s))); if b.is_toggle { ix.toggles.add(i, b, toggle_name); } @@ -841,7 +885,7 @@ impl Index { fn pick(&self, list: Option<&Vec>, tool: bool) -> Found { let mut fits = list.into_iter().flatten().filter(|i| self.tool.get(**i) == Some(&tool)); match (fits.next(), fits.next()) { - (Some(i), None) => Found::One(*i), + (Some(i), None) => Found::One(*i, false), (Some(_), Some(_)) => Found::Many, _ => Found::None, } @@ -849,13 +893,14 @@ impl Index { /// The match steps in order; the first that finds exactly one command decides. The steps run /// on the names as written, then on toggle commands with the entry's label and theirs - /// without a leading Show/Hide. + /// without a leading Show/Hide. A panel-menu entry's last segment alone matches only when + /// its first segment, the panel, is a segment of some catalogue path. fn find(&self, entry: &ForeignEntry, tool: bool, remembered: &BTreeMap) -> Found { if let Some(c) = remembered.get(&entry.remember_key()) && let Some(&i) = self.keys.get(&c.key()) && self.tool.get(i) == Some(&tool) { - return Found::One(i); + return Found::One(i, false); } let segs: Vec = entry.path.iter().map(|s| normalize_label(s)).collect(); let mut toggle_segs = segs.clone(); @@ -863,12 +908,13 @@ impl Index { *last = toggle_name(last); } let menu = entry.menu.as_deref().map(normalize_label); - let steps = self.names.steps(menu.as_ref(), &segs, entry.panel_menu); - let toggle_steps = self.toggles.steps(menu.as_ref(), &toggle_segs, entry.panel_menu); + let panel = entry.panel_menu.then(|| segs.first().is_some_and(|p| self.segments.contains(p))); + let steps = self.names.steps(menu.as_ref(), &segs, panel); + let toggle_steps = self.toggles.steps(menu.as_ref(), &toggle_segs, panel); let mut many = false; - for list in steps.into_iter().chain(toggle_steps) { + for (list, by_panel) in steps.into_iter().chain(toggle_steps) { match self.pick(list, tool) { - Found::One(i) => return Found::One(i), + Found::One(i, _) => return Found::One(i, by_panel), Found::Many => many = true, Found::None => {} } diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 635045ce..a015c9a5 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -5,8 +5,8 @@ use std::collections::BTreeMap; use craft_keymap::chord::foreign::ReadNote; use craft_keymap::chord::foreign::{Flavor, ForeignError}; use craft_keymap::import::{ - Failed, FailedChange, MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT, MAX_KEYS_PER_ENTRY, MAX_SUGGESTIONS, SkipReason, Skipped, UnmatchedReason, - normalize_label, + Failed, FailedChange, MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT, MAX_KEYS_PER_ENTRY, MAX_SUGGESTIONS, NoChangeReason, SkipReason, Skipped, + UnmatchedReason, normalize_label, }; use craft_keymap::{ Bindable, Binding, CommandRef, ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport, Keymap, Kind, MAX_COMMANDS, @@ -513,6 +513,7 @@ fn report_json_uses_camel_case_names() { "conflictsCleared", "failed", "imported", + "matchedWithoutChange", "set", "skipped", "suggestions", @@ -567,6 +568,9 @@ fn panel_menu_entries_match_by_path_end_or_label() { command("swatches.new", "New Swatch", &["Swatches", "New Swatch"], &[]), command("gradient.new", "New Swatch", &["Gradient", "New Swatch"], &[]), command("character.smallCaps", "Small Caps", &[], &[]).with_alias("Character: Small Caps"), + // The app has Paragraph and Color panels. + command(panel("paragraph"), "Paragraph", &["Window", "Paragraph"], &[]), + command(panel("color"), "Color", &["Window", "Color"], &[]), ]; let d = "Default"; let panel_entry = |path: &[&str], key: &str| entry(None, path, &[(d, key)], Flavor::Windows).in_panel_menu(); @@ -575,8 +579,10 @@ fn panel_menu_entries_match_by_path_end_or_label() { panel_entry(&["Character", "All Caps"], "Ctrl+F1"), // The end of a path whose last segment differs from the label. panel_entry(&["Pages", "Override All Parent Page Items"], "Ctrl+F2"), - // The last segment alone, as one command's label. + // The last segment alone, as one command's label, for a panel the app has. panel_entry(&["Paragraph", "Justification..."], "Ctrl+F3"), + // The last segment alone matches nothing for a panel the app lacks. + panel_entry(&["History", "Justification..."], "Ctrl+F8"), // The panel picks one of two commands with the label. panel_entry(&["Swatches", "New Swatch"], "Ctrl+F4"), // The joined segments as an alias. @@ -593,7 +599,14 @@ fn panel_menu_entries_match_by_path_end_or_label() { has(&km, "swatches.new", &["Cmd+F4"]); has(&km, "character.smallCaps", &["Cmd+F5"]); has(&km, "gradient.new", &[]); - assert_eq!(unmatched(&r), [("Color > New Swatch", UnmatchedReason::Ambiguous), ("Character > All Caps", UnmatchedReason::NoMatch)]); + assert_eq!( + unmatched(&r), + [ + ("History > Justification...", UnmatchedReason::NoMatch), + ("Color > New Swatch", UnmatchedReason::Ambiguous), + ("Character > All Caps", UnmatchedReason::NoMatch) + ] + ); } #[test] @@ -673,3 +686,71 @@ fn only_entries_with_keys_to_bind_count_toward_suggestions() { assert_eq!(r.suggestions[0].candidates, [CommandRef::new("edit.checkSpelling")]); assert_eq!(r.truncated, 0); } + +fn pages_catalogue() -> Vec { + vec![ + command("app.newDocumentDialog", "New Document…", &["File", "New Document…"], &["Cmd+N"]), + command("layout.pages.insert", "Insert Pages…", &["Layout", "Pages", "Insert Pages…"], &["Cmd+Shift+P"]), + command("file.close", "Close", &["File", "Close"], &["Cmd+W"]), + ] +} + +#[test] +fn panel_menu_entries_without_keys_change_nothing() { + let none = |path: &[&str]| ForeignEntry::new(None, path.iter().copied()).in_panel_menu(); + let entries = vec![ + // The app has no History panel, so the last segment alone does not match. + none(&["History", "New Document"]), + // A path end matches, but without keys the command keeps its shortcuts. + none(&["Pages", "Insert Pages..."]), + // The same for the last segment alone, with a panel the app has. + none(&["Pages", "New Document"]), + ]; + let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + has(&km, "app.newDocumentDialog", &["Cmd+N"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert!(r.changed.is_empty(), "{:?}", r.changed); + assert_eq!(r.unmatched_without_shortcut, 1); + let rows: Vec<(&str, &str, NoChangeReason)> = + r.matched_without_change.iter().map(|m| (m.item.as_str(), m.command.id.as_str(), m.reason)).collect(); + assert_eq!( + rows, + [ + ("Pages > Insert Pages...", "layout.pages.insert", NoChangeReason::PanelMenu), + ("Pages > New Document", "app.newDocumentDialog", NoChangeReason::PanelMenu) + ] + ); + let v = serde_json::to_value(&r).unwrap(); + let mut fields: Vec = v["matchedWithoutChange"][0].as_object().unwrap().keys().cloned().collect(); + fields.sort(); + assert_eq!(fields, ["command", "item", "label", "line", "reason"]); + assert_eq!(v["matchedWithoutChange"][0]["reason"], "panelMenu"); +} + +#[test] +fn an_entry_without_keys_never_clears_keys_another_entry_gives() { + let d = "Default"; + let keyed = || win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Alt+P")]); + let keyless = || win("Layout", &["Insert Pages..."], &[]); + for entries in [vec![keyed(), keyless()], vec![keyless(), keyed()]] { + let (km, _) = import_into(pages_catalogue(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Alt+P"]); + } + + // A panel-menu entry without keys, and the menu entry with the command's keys. + let entries = vec![ + ForeignEntry::new(None, ["Pages", "Insert Pages..."]).in_panel_menu(), + win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Shift+P")]), + ]; + let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.matched_without_change.len(), 1); + + // The later entry still takes the chord from another command; the entry without keys does + // not clear the command that lost it. + let entries = vec![keyless(), keyed(), win("File", &["Close"], &[(d, "Ctrl+Alt+P")])]; + let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + has(&km, "file.close", &["Cmd+Alt+P"]); + has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); + assert_eq!(r.skipped.iter().map(|s| (s.command.id.as_str(), s.reason)).collect::>(), [("layout.pages.insert", SkipReason::Taken)]); +} From 362eb59fd26b01a5848a0cdf778486479b92e5c2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 11:59:53 +0000 Subject: [PATCH 009/124] keymap: keep an earlier entry's chord when the later entry finds the overrides full --- crates/keymap/src/import.rs | 172 ++++++++++++++++++++-------------- crates/keymap/tests/import.rs | 13 +++ 2 files changed, 115 insertions(+), 70 deletions(-) diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 4a3ff138..89e4b2f8 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -547,27 +547,115 @@ impl ImportPlan { /// [`ImportReport::skipped`]. /// /// A change that finds the overrides full is left out and reported in - /// [`ImportReport::failed`]; the others still apply. With [`Start::Defaults`] that cannot - /// happen. + /// [`ImportReport::failed`]; the others still apply, and a chord it would have taken from an + /// earlier entry stays with that entry. With [`Start::Defaults`] that cannot happen. pub fn apply(&self, keymap: &mut Keymap) -> ImportReport { - let mut report = self.report.clone(); let before: Vec> = self.commands.iter().map(|p| keymap.bindings(&p.command)).collect(); // Dropping every entry leaves room for all of them: each catalogue command takes at most one // entry, and the catalogue holds at most `MAX_COMMANDS`. if self.start == Start::Defaults { keymap.reset_all(); } - let home_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map_or_else(Scope::app, |b| b.scope.clone()); + let start = keymap.clone(); + // Commands whose bindings found the overrides full after taking a chord from an earlier + // entry. They are left out of the claims and the plan is applied again, so the earlier + // entry keeps the chord, as when the entries are applied in file order. + let mut dropped: HashMap = HashMap::new(); + 'attempt: loop { + let mut report = self.report.clone(); + let (accepted, displaced) = self.resolve(keymap, &dropped, &mut report); + let home_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map_or_else(Scope::app, |b| b.scope.clone()); + let label_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map(|b| b.label.clone()).unwrap_or_default(); + let mut decided: HashSet = HashSet::new(); + for (c, (p, list)) in self.commands.iter().zip(&accepted).enumerate() { + let failed = + |km: &Keymap, change, shortcuts| Failed { command: p.command.clone(), label: label_of(km, &p.command), change, shortcuts }; + if let Some(f) = dropped.get(&c) { + report.failed.push(f.clone()); + continue; + } + if !list.is_empty() { + let list = in_default_order(keymap, &p.command, list); + if keymap.set_bindings(&p.command, list.clone(), Policy::Keep).is_err() { + let home = home_of(keymap, &p.command); + let f = failed(keymap, FailedChange::Bind, list.iter().map(|b| b.text(&home)).collect()); + if displaced.contains(&c) { + dropped.insert(c, f); + *keymap = start.clone(); + continue 'attempt; + } + report.failed.push(f); + continue; + } + } else if p.none_defined && self.none_defined == NoneDefined::Clear { + if !keymap.clear(&p.command) { + report.failed.push(failed(keymap, FailedChange::Clear, Vec::new())); + continue; + } + } else { + continue; + } + decided.insert(p.command.key()); + } + for (p, list) in self.commands.iter().zip(&accepted) { + if list.is_empty() || !decided.contains(&p.command.key()) { + continue; + } + for h in keymap.clashing(&p.command, list) { + if decided.contains(&h.command.key()) { + continue; + } + let home = home_of(keymap, &h.command); + let label = label_of(keymap, &h.command); + let shortcut = h.binding.text(&home); + if keymap.remove(&h.command, &h.binding) { + report.conflicts_cleared.push(Cleared { command: h.command, label, shortcut }); + } else { + report.failed.push(Failed { command: h.command, label, change: FailedChange::Unbind, shortcuts: vec![shortcut] }); + } + } + } + + for (p, from) in self.commands.iter().zip(&before) { + if !decided.contains(&p.command.key()) { + continue; + } + let to = keymap.bindings(&p.command); + if to == *from { + continue; + } + let home = home_of(keymap, &p.command); + let texts = |l: &[Binding]| l.iter().map(|b| b.text(&home)).collect::>(); + report.changed.push(Changed { + command: p.command.clone(), + label: label_of(keymap, &p.command), + item: p.item.clone(), + line: p.line, + from: texts(from), + to: texts(&to), + }); + } + report.imported = report.changed.len(); + return report; + } + } + + /// Settles which command each claimed chord goes to, in entry order, leaving out the claims of + /// `dropped` commands. Returns each command's accepted bindings and the commands that took a + /// chord from an earlier entry; skipped keys go to `report`. + fn resolve(&self, keymap: &Keymap, dropped: &HashMap, report: &mut ImportReport) -> (Vec>, HashSet) { + let home_of = |c: &CommandRef| keymap.bindable(c).map_or_else(Scope::app, |b| b.scope.clone()); let mut claims: Vec<(usize, &PlannedBinding)> = self.commands.iter().enumerate().flat_map(|(c, p)| p.bindings.iter().map(move |b| (c, b))).collect(); claims.sort_by_key(|(_, b)| b.order); let platform = keymap.platform(); let mut taken: HashMap> = HashMap::new(); let mut accepted: Vec> = vec![Vec::new(); self.commands.len()]; + let mut displaced: HashSet = HashSet::new(); for (c, pb) in claims { let Some(p) = self.commands.get(c) else { continue }; - let shortcut = || pb.binding.text(&home_of(keymap, &p.command)); + let shortcut = || pb.binding.text(&home_of(&p.command)); let skip = |reason| Skipped { command: p.command.clone(), item: pb.item.clone(), line: pb.line, shortcut: shortcut(), kept_by: None, reason }; if keymap.is_reserved(&pb.binding.trigger.chord) { @@ -578,6 +666,9 @@ impl ImportPlan { report.skipped.push(skip(SkipReason::UnknownScope)); continue; } + if dropped.contains_key(&c) { + continue; + } let effective = pb.binding.trigger.effective(platform); let (scope, tools) = match keymap.bindable(&p.command) { Some(b) => (pb.binding.scope_or(&b.scope).clone(), if let Kind::Tool { group } = &b.kind { Some(group.clone()) } else { None }), @@ -596,6 +687,9 @@ impl ImportPlan { !clash }); } + if !lost.is_empty() { + displaced.insert(c); + } for (k, earlier) in lost { if let Some(list) = accepted.get_mut(k) { list.retain(|b| !std::ptr::eq(*b, earlier)); @@ -605,7 +699,7 @@ impl ImportPlan { command: other.command.clone(), item: earlier.item.clone(), line: earlier.line, - shortcut: earlier.binding.text(&home_of(keymap, &other.command)), + shortcut: earlier.binding.text(&home_of(&other.command)), kept_by: Some(p.command.clone()), reason: SkipReason::Taken, }); @@ -618,70 +712,8 @@ impl ImportPlan { list.push(pb); } } - let accepted: Vec> = accepted.into_iter().map(|l| l.into_iter().map(|b| b.binding.clone()).collect()).collect(); - - let label_of = |km: &Keymap, c: &CommandRef| km.bindable(c).map(|b| b.label.clone()).unwrap_or_default(); - let mut decided: HashSet = HashSet::new(); - for (p, list) in self.commands.iter().zip(&accepted) { - let failed = |km: &Keymap, change, shortcuts| Failed { command: p.command.clone(), label: label_of(km, &p.command), change, shortcuts }; - if !list.is_empty() { - let list = in_default_order(keymap, &p.command, list); - if keymap.set_bindings(&p.command, list.clone(), Policy::Keep).is_err() { - let home = home_of(keymap, &p.command); - report.failed.push(failed(keymap, FailedChange::Bind, list.iter().map(|b| b.text(&home)).collect())); - continue; - } - } else if p.none_defined && self.none_defined == NoneDefined::Clear { - if !keymap.clear(&p.command) { - report.failed.push(failed(keymap, FailedChange::Clear, Vec::new())); - continue; - } - } else { - continue; - } - decided.insert(p.command.key()); - } - - for (p, list) in self.commands.iter().zip(&accepted) { - if list.is_empty() || !decided.contains(&p.command.key()) { - continue; - } - for h in keymap.clashing(&p.command, list) { - if decided.contains(&h.command.key()) { - continue; - } - let home = home_of(keymap, &h.command); - let label = label_of(keymap, &h.command); - let shortcut = h.binding.text(&home); - if keymap.remove(&h.command, &h.binding) { - report.conflicts_cleared.push(Cleared { command: h.command, label, shortcut }); - } else { - report.failed.push(Failed { command: h.command, label, change: FailedChange::Unbind, shortcuts: vec![shortcut] }); - } - } - } - - for (p, from) in self.commands.iter().zip(before) { - if !decided.contains(&p.command.key()) { - continue; - } - let to = keymap.bindings(&p.command); - if to == from { - continue; - } - let home = home_of(keymap, &p.command); - let texts = |l: &[Binding]| l.iter().map(|b| b.text(&home)).collect::>(); - report.changed.push(Changed { - command: p.command.clone(), - label: label_of(keymap, &p.command), - item: p.item.clone(), - line: p.line, - from: texts(&from), - to: texts(&to), - }); - } - report.imported = report.changed.len(); - report + let accepted = accepted.into_iter().map(|l| l.into_iter().map(|b| b.binding.clone()).collect()).collect(); + (accepted, displaced) } } diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index a015c9a5..425f5d59 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -367,6 +367,19 @@ fn changes_that_find_the_overrides_full_are_reported() { has(&km, "view.goToPage", &[]); has(&km, "file.save", &["Cmd+Alt+S"]); has(&km, "edit.duplicate", &[]); + + // A later entry whose change finds the overrides full leaves the chord with the earlier one. + let set = ForeignSet { + name: None, + entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])], + }; + let mut km = keymap(Platform::Windows).with_overrides(full()); + let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); + has(&km, "file.close", &["Cmd+Alt+J"]); + has(&km, "file.save", &["Cmd+S"]); + assert_eq!(r.failed, [row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+J"])]); + assert!(r.skipped.is_empty(), "{:?}", r.skipped); + assert_eq!(r.imported, 1); } #[test] From 70c84ec76c094a78e9b03cfca91e810519ca048d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:24:18 +0000 Subject: [PATCH 010/124] keymap: named shortcut sets with JSON export and import, and the shortcut editor model Profiles keeps the user's sets beside the app's built-in ones: switch, duplicate, rename, delete, export one set as JSON and import it back, and import another application's set through an import plan as a new set. The editor model holds an edit session with undo and cancel, conflict queries for steal or keep, a chord recorder with double press, and search. --- crates/keymap/README.md | 83 +++++- crates/keymap/src/editor.rs | 254 +++++++++++++++++++ crates/keymap/src/egui.rs | 3 +- crates/keymap/src/lib.rs | 16 +- crates/keymap/src/profiles.rs | 437 ++++++++++++++++++++++++++++++++ crates/keymap/tests/editor.rs | 200 +++++++++++++++ crates/keymap/tests/profiles.rs | 172 +++++++++++++ crates/keymap/tests/settings.rs | 31 ++- 8 files changed, 1181 insertions(+), 15 deletions(-) create mode 100644 crates/keymap/src/editor.rs create mode 100644 crates/keymap/src/profiles.rs create mode 100644 crates/keymap/tests/editor.rs create mode 100644 crates/keymap/tests/profiles.rs diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 81b55ec9..0d4bd1a0 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -1,9 +1,9 @@ # Craft Keymap `craft-keymap` holds the data and logic for keyboard bindings in the Craft apps: chords and their -text forms, bindings from chords to commands, scopes, conflicts, user overrides, and an import -pipeline for other applications' shortcut sets. Each application supplies its command catalogue, -its file-format readers and its UI. +text forms, bindings from chords to commands, scopes, conflicts, user overrides, named sets, an +import pipeline for other applications' shortcut sets, and an editor model. Each application +supplies its command catalogue, its file-format readers and its UI. ## Boundaries @@ -90,7 +90,7 @@ stay in the entry when the command is edited, unless the edit leaves it without `Keymap::problems` lists them. ```rust -use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; use craft_settings::{Location, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; @@ -99,6 +99,7 @@ use serde::{Deserialize, Serialize}; struct Prefs { #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + shortcut_sets: Profiles, } impl Settings for Prefs { @@ -130,6 +131,42 @@ store.update(|p| p.shortcuts = keymap.overrides().clone()); - `conflicts()` lists every pair of commands on one chord; `conflicts_with(&command)` lists the commands that share one command's chords, with that command as `a`. +## Shortcut sets + +`Profiles` holds the user's named sets of overrides and which one is active. The app's built-in +sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change. +Put `Profiles` next to the `Overrides` in the app's settings type. The sets, and each set's +overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file. + +- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). +- `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, + `rename` and `delete` work on user sets only. +- `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a + built-in set makes a user copy named after `copy_name`, which becomes active. +- New names are made unique (`Custom 2`, `Custom 3`) and compare without case. A user set named + like a built-in added later is renamed by `reconcile`. +- `export(name)` writes one set as JSON + (`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into + a new set. A file from a newer version is refused (`ProfileError::NewerVersion`). +- `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a + copy of the keymap and keeps the result as a new set, so another application's set becomes one + the user can switch to and away from. + +```rust +use craft_keymap::{Overrides, Profiles}; + +let mut classic = Overrides::new(); +classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +let mut sets = Profiles::default(); +assert_eq!(sets.active_name(&builtins), "Craft"); + +sets.duplicate("Classic", "Mine", &builtins).unwrap(); +let text = sets.export("Mine", &builtins).unwrap(); +assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +``` + ## Importing a shortcut set The app's reader turns a file (an InDesign Show Set, an Illustrator `.kys`) into an @@ -199,6 +236,44 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. +## Editor + +`editor` is the model behind a shortcut dialog; the app draws it. + +- `EditSession::begin(&keymap)` when the dialog opens. Each change is one undo step: + `assign`, `replace` (record over one key of a command), `remove`, `clear`, `reset`, `reset_all`, + or any keymap edit through `session.edit(&mut keymap, |km| …)`. `undo`, `redo`, `cancel` (back + to the state at open) and `is_modified`. +- Conflicts: `editor::holders(&keymap, &command, &binding)` lists the commands that hold the key + while the user records; `Keymap::conflicts_with(&command)` marks the rows whose keys are + shared after a `Keep`. Assigning with `Policy::Refuse` returns `AssignError::Taken` and changes + nothing; the user's answer is a second call with `Policy::Steal` or `Policy::Keep`. +- `Recorder::press(chord, time)` gives `Single`, `Double` (the same chord again within + `DOUBLE_PRESS`, 0.6 s) or `Cancel` (Escape). +- `editor::search(&keymap, query, platform)` filters the catalogue by label, alias, menu path, + command id and key text, canonical or as shown on `platform`. + +```rust +use craft_keymap::editor::holders; +use craft_keymap::{AssignError, Binding, Chord, EditSession, Policy, Recorded, Recorder}; + +// `keymap` is the app's Keymap, `now` the input time in seconds. +let mut session = EditSession::begin(&keymap); +let mut recorder = Recorder::new(); +let save = "file.save".into(); +// The user presses Cmd+C in the shortcut field of Save. +if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { + let binding = Binding::new(trigger); + let _shown = holders(&keymap, &save, &binding); + if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { + // The user chose to take the key from Copy. + session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); + } +} +// OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +session.cancel(&mut keymap); +``` + ## Features - `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the diff --git a/crates/keymap/src/editor.rs b/crates/keymap/src/editor.rs new file mode 100644 index 00000000..2598552b --- /dev/null +++ b/crates/keymap/src/editor.rs @@ -0,0 +1,254 @@ +//! The model behind a shortcut editor: an edit session with undo, a chord recorder, conflict +//! queries and search. +//! +//! A shortcut dialog drives it like this: +//! +//! ``` +//! use craft_keymap::editor::holders; +//! use craft_keymap::{AssignError, Bindable, Binding, Chord, EditSession, Keymap, Platform, Policy, Recorded, Recorder, Scope, Scopes}; +//! +//! let catalogue = vec![ +//! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), +//! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), +//! ]; +//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::Mac); +//! let now = 12.5; +//! +//! let mut session = EditSession::begin(&keymap); +//! let mut recorder = Recorder::new(); +//! let save = "file.save".into(); +//! // The user presses Cmd+C in the shortcut field of Save. +//! if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap(), now) { +//! let binding = Binding::new(trigger); +//! assert_eq!(holders(&keymap, &save, &binding)[0].command, "edit.copy".into()); +//! if let Err(AssignError::Taken(_)) = session.assign(&mut keymap, &save, binding.clone(), Policy::Refuse) { +//! // The user chose to take the key from Copy. +//! session.assign(&mut keymap, &save, binding, Policy::Steal).ok(); +//! } +//! } +//! assert!(keymap.bindings(&"edit.copy".into()).is_empty()); +//! // OK: store `keymap.overrides()` (`Profiles::store` for sets). Cancel: +//! session.cancel(&mut keymap); +//! assert_eq!(keymap.bindings(&"edit.copy".into()), [Binding::parse("Cmd+C").unwrap()]); +//! ``` + +use std::collections::VecDeque; + +use crate::chord::{Chord, Key, Platform, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{AssignError, Assigned, Bindable, Binding, Holder, Keymap, Overrides, Policy}; + +/// Undo steps an [`EditSession`] keeps; older ones are dropped. +pub const MAX_UNDO: usize = 500; +/// Most bytes of undo snapshots an [`EditSession`] keeps (keys and texts, plus a fixed estimate +/// per entry and per text for their allocations); older steps are dropped, though the latest one +/// always stays. +pub const MAX_UNDO_BYTES: usize = 64 << 20; + +/// Seconds within which a second press of the same chord counts as a double press. +pub const DOUBLE_PRESS: f64 = 0.6; + +/// One opening of a shortcut editor: the overrides it started from, and undo and redo as +/// snapshots of [`Overrides`]. +#[derive(Clone, Debug)] +pub struct EditSession { + start: Overrides, + /// Snapshots with their [`size`]. + undo: VecDeque<(Overrides, usize)>, + undo_bytes: usize, + redo: Vec, +} + +/// Heap bytes a snapshot entry costs beyond its strings' bytes: the key's `String`, the list and +/// the map node, estimated. +const ENTRY_OVERHEAD: usize = size_of::() + 64; +/// Bytes a text costs beyond its own: its `String` and allocator rounding, estimated. +const TEXT_OVERHEAD: usize = size_of::() + 16; + +/// The estimated bytes of a snapshot, so many short texts count as more than their length. +fn size(o: &Overrides) -> usize { + o.iter().map(|(k, texts)| ENTRY_OVERHEAD + k.len() + texts.iter().map(|t| TEXT_OVERHEAD + t.len()).sum::()).sum() +} + +impl EditSession { + /// Starts a session on `keymap`'s current overrides; [`cancel`](Self::cancel) returns to them. + pub fn begin(keymap: &Keymap) -> EditSession { + EditSession { start: keymap.overrides().clone(), undo: VecDeque::new(), undo_bytes: 0, redo: Vec::new() } + } + + /// Runs one edit. When it changes the overrides it becomes an undo step and clears redo. + pub fn edit(&mut self, keymap: &mut Keymap, f: impl FnOnce(&mut Keymap) -> R) -> R { + let before = keymap.overrides().clone(); + let out = f(keymap); + if *keymap.overrides() != before { + self.push_undo(before); + self.redo.clear(); + } + out + } + + /// Adds a binding as one undo step. With [`Policy::Refuse`] a chord other commands hold gives + /// [`AssignError::Taken`] and changes nothing, so the dialog can ask whether to steal or keep; + /// the answer is a second call with [`Policy::Steal`] or [`Policy::Keep`]. + pub fn assign(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: Binding, policy: Policy) -> Result { + self.edit(keymap, |km| km.assign(command, binding, policy)) + } + + /// Replaces one binding of a command with another as one undo step, as when the user records + /// over a key in the list. Conflicts follow `policy` as in [`EditSession::assign`]. + pub fn replace( + &mut self, + keymap: &mut Keymap, + command: &CommandRef, + old: &Binding, + new: Binding, + policy: Policy, + ) -> Result { + let list: Vec = keymap.bindings(command).into_iter().map(|b| if b == *old { new.clone() } else { b }).collect(); + self.edit(keymap, |km| km.set_bindings(command, list, policy)) + } + + /// Removes one binding of a command as one undo step. + pub fn remove(&mut self, keymap: &mut Keymap, command: &CommandRef, binding: &Binding) -> bool { + self.edit(keymap, |km| km.remove(command, binding)) + } + + /// Leaves a command without bindings as one undo step. + pub fn clear(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.clear(command)) + } + + /// Gives a command its default bindings back as one undo step. + pub fn reset(&mut self, keymap: &mut Keymap, command: &CommandRef) -> bool { + self.edit(keymap, |km| km.reset(command)) + } + + /// Gives every command its defaults as one undo step. + pub fn reset_all(&mut self, keymap: &mut Keymap) { + self.edit(keymap, Keymap::reset_all); + } + + fn push_undo(&mut self, snapshot: Overrides) { + let bytes = size(&snapshot); + self.undo.push_back((snapshot, bytes)); + self.undo_bytes += bytes; + while self.undo.len() > 1 && (self.undo.len() > MAX_UNDO || self.undo_bytes > MAX_UNDO_BYTES) { + if let Some((_, b)) = self.undo.pop_front() { + self.undo_bytes -= b; + } + } + } + + /// Reverts the last edit; false when there is none. + pub fn undo(&mut self, keymap: &mut Keymap) -> bool { + let Some((previous, bytes)) = self.undo.pop_back() else { return false }; + self.undo_bytes -= bytes; + self.redo.push(keymap.overrides().clone()); + keymap.set_overrides(previous); + true + } + + /// Applies the last undone edit again; false when there is none. + pub fn redo(&mut self, keymap: &mut Keymap) -> bool { + let Some(next) = self.redo.pop() else { return false }; + self.push_undo(keymap.overrides().clone()); + keymap.set_overrides(next); + true + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// Restores the overrides the session started with and forgets its undo history. + pub fn cancel(&mut self, keymap: &mut Keymap) { + keymap.set_overrides(self.start.clone()); + self.undo.clear(); + self.undo_bytes = 0; + self.redo.clear(); + } + + /// Whether `keymap`'s overrides differ from those the session started with. + pub fn is_modified(&self, keymap: &Keymap) -> bool { + *keymap.overrides() != self.start + } +} + +/// The other commands whose bindings clash with `binding` given to `command`: what +/// [`Policy::Steal`] would take and [`Policy::Keep`] would leave clashing. Tools of the command's +/// group and twins sharing a default key are not listed. A dialog shows these while the user +/// records, before anything is assigned. +pub fn holders(keymap: &Keymap, command: &CommandRef, binding: &Binding) -> Vec { + keymap.clashing(command, std::slice::from_ref(binding)) +} + +/// What a press means to the recorder. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recorded { + Single(Trigger), + /// The chord just recorded, pressed again within [`DOUBLE_PRESS`]: it replaces that single. + Double(Trigger), + /// Escape without modifiers: stop recording. + Cancel, +} + +/// Turns the presses of a shortcut field into triggers. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + last: Option<(Chord, f64)>, +} + +impl Recorder { + pub fn new() -> Recorder { + Recorder::default() + } + + /// Records a press at `now_secs` (any monotonic clock, such as egui's `input.time`). + pub fn press(&mut self, chord: Chord, now_secs: f64) -> Recorded { + if chord.key == Key::Escape && chord.mods.is_none() { + self.last = None; + return Recorded::Cancel; + } + // After a double the next press starts over, so a third press is a single again. + if let Some((last, at)) = self.last.take() + && last == chord + && now_secs >= at + && now_secs - at < DOUBLE_PRESS + { + return Recorded::Double(Trigger::double(chord)); + } + self.last = Some((chord, now_secs)); + Recorded::Single(Trigger::single(chord)) + } + + /// Forgets the last press, so the next one cannot become a double. + pub fn reset(&mut self) { + self.last = None; + } +} + +/// The bindables matching `query`, in catalogue order: a case-insensitive substring of the label, +/// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display +/// text. An empty query matches everything. +pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> { + let q = query.trim().to_lowercase(); + keymap + .catalogue() + .iter() + .filter(|b| { + if q.is_empty() { + return true; + } + let hit = |s: &str| s.to_lowercase().contains(&q); + hit(&b.label) + || b.aliases.iter().any(|a| hit(a)) + || b.paths.iter().flatten().any(|s| hit(s)) + || hit(&b.command.id) + || keymap.bindings(&b.command).iter().any(|binding| hit(&binding.trigger.to_string()) || hit(&binding.trigger.display(platform))) + }) + .collect() +} diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fd4039d5..46511e29 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -135,8 +135,7 @@ fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { out } -/// Seconds within which a second press of the same chord counts as a double press. -pub const DOUBLE_PRESS: f64 = 0.6; +pub use crate::editor::DOUBLE_PRESS; /// A command a key press runs. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index bebf33f7..fd93f7bb 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,10 +6,11 @@ //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's //! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] -//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module -//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's -//! key events. +//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the +//! chords each operating system takes. [`import`] brings in other applications' shortcut sets. +//! [`editor`] is the model of a shortcut editor: undo, a chord recorder, conflict queries and +//! search. With the `egui` feature, the `egui` module converts to and from egui keys, matches key +//! presses against bindings and dispatches a frame's key events. //! //! ``` //! use craft_keymap::{Chord, Platform}; @@ -68,7 +69,7 @@ //! removed) stays removed after saving. //! //! ```no_run -//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; //! use craft_settings::{Location, Settings, SettingsStore}; //! use serde::{Deserialize, Serialize}; //! @@ -76,6 +77,7 @@ //! #[serde(default)] //! struct Prefs { //! shortcuts: Overrides, +//! shortcut_sets: Profiles, //! } //! //! impl Settings for Prefs { @@ -128,19 +130,23 @@ pub mod chord; pub mod command; +pub mod editor; #[cfg(feature = "egui")] pub mod egui; pub mod import; pub mod keymap; +pub mod profiles; pub mod reserved; pub mod scope; pub use chord::foreign::Flavor; pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; pub use command::{CommandRef, MAX_KEY}; +pub use editor::{DOUBLE_PRESS, EditSession, MAX_UNDO, MAX_UNDO_BYTES, Recorded, Recorder}; pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan}; pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, }; +pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs new file mode 100644 index 00000000..3f7e92f6 --- /dev/null +++ b/crates/keymap/src/profiles.rs @@ -0,0 +1,437 @@ +//! Named sets of overrides: the app's built-in sets (read only) and the user's own. +//! +//! ``` +//! use craft_keymap::{Overrides, Profiles}; +//! +//! let mut classic = Overrides::new(); +//! classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); +//! let builtins = [("Craft", Overrides::new()), ("Classic", classic)]; +//! let mut sets = Profiles::default(); +//! assert_eq!(sets.active_name(&builtins), "Craft"); +//! +//! sets.duplicate("Classic", "Mine", &builtins).unwrap(); +//! let text = sets.export("Mine", &builtins).unwrap(); +//! assert_eq!(sets.import(&text, &builtins), Ok("Mine 2".into())); +//! assert_eq!(sets.active_overrides(&builtins), sets.get("Mine", &builtins).unwrap()); +//! ``` + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Value, json}; + +use crate::import::{ImportPlan, ImportReport}; +use crate::keymap::{Keymap, Overrides}; + +/// Most user sets. +pub const MAX_SETS: usize = 64; +/// Longest set name, in chars. +pub const MAX_SET_NAME: usize = 64; +/// Largest export document [`Profiles::import`] reads, in bytes. +pub const MAX_EXPORT_BYTES: usize = 4 << 20; + +const FORMAT: &str = "craft-keymap"; +const VERSION: u64 = 1; +const IMPORTED: &str = "Imported"; + +/// The app's built-in sets: name and overrides against the catalogue defaults. +pub type Builtins<'a> = [(&'a str, Overrides)]; + +/// Why a set operation was refused. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProfileError { + /// Empty, over [`MAX_SET_NAME`] chars, or with control characters. + BadName, + NameTaken, + NotFound, + /// Built-in sets cannot be changed, renamed or deleted. + BuiltIn, + /// [`MAX_SETS`] user sets exist. + TooMany, + /// Not a JSON export document. + BadFile, + /// A `format` other than `craft-keymap`. + WrongFormat, + /// A `version` newer than this crate reads. + NewerVersion(u64), + /// Over [`MAX_EXPORT_BYTES`]. + TooLarge, +} + +impl fmt::Display for ProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + ProfileError::BadName => "bad set name", + ProfileError::NameTaken => "a set with this name exists", + ProfileError::NotFound => "no such set", + ProfileError::BuiltIn => "built-in sets are read only", + ProfileError::TooMany => "too many sets", + ProfileError::BadFile => "not a shortcut set file", + ProfileError::WrongFormat => "not a craft-keymap file", + ProfileError::NewerVersion(v) => return write!(f, "shortcut set file version {v} is newer than this app reads"), + ProfileError::TooLarge => "shortcut set file too large", + }; + f.write_str(s) + } +} + +impl std::error::Error for ProfileError {} + +/// The user's sets and which set is active. The built-in sets are passed to each call. +/// +/// An `active` name that matches no set means the first built-in set (or no overrides when the app +/// has none). +/// +/// A user set may carry a built-in's name, for instance after an update adds that built-in. Until +/// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call +/// `reconcile` after loading; every method that changes the profiles calls it first. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Profiles { + #[serde(deserialize_with = "lenient_name")] + pub active: String, + #[serde(serialize_with = "sets_as_list", deserialize_with = "capped_sets")] + pub sets: BTreeMap, +} + +static NO_OVERRIDES: Overrides = Overrides::new(); + +/// A name as given, trimmed; `None` when empty, too long or with control characters. +pub fn set_name(name: &str) -> Option { + let n = name.trim(); + let ok = !n.is_empty() && n.chars().count() <= MAX_SET_NAME && !n.chars().any(char::is_control); + ok.then(|| n.to_string()) +} + +impl Profiles { + /// Built-in names first, then the user's sets. + pub fn names(&self, builtins: &Builtins) -> Vec { + builtins.iter().map(|(n, _)| n.to_string()).chain(self.sets.keys().cloned()).collect() + } + + pub fn is_builtin(name: &str, builtins: &Builtins) -> bool { + builtins.iter().any(|(n, _)| *n == name) + } + + /// The name of the set in use. + pub fn active_name<'a>(&'a self, builtins: &'a Builtins) -> &'a str { + if self.sets.contains_key(&self.active) || Self::is_builtin(&self.active, builtins) { + &self.active + } else { + builtins.first().map_or("", |(n, _)| n) + } + } + + /// The overrides of the set in use, to hand to [`Keymap::set_overrides`](crate::Keymap::set_overrides). + pub fn active_overrides<'a>(&'a self, builtins: &'a Builtins) -> &'a Overrides { + self.get(self.active_name(builtins), builtins).unwrap_or(&NO_OVERRIDES) + } + + pub fn get<'a>(&'a self, name: &str, builtins: &'a Builtins) -> Option<&'a Overrides> { + self.sets.get(name).or_else(|| builtins.iter().find(|(n, _)| *n == name).map(|(_, o)| o)) + } + + /// Renames user sets whose name matches a built-in's (ignoring case) to a free name; `active` + /// follows. Returns whether anything was renamed. + pub fn reconcile(&mut self, builtins: &Builtins) -> bool { + let clashing: Vec = self.sets.keys().filter(|k| self.builtin_like(k, builtins)).cloned().collect(); + let mut renamed = false; + for old in clashing { + let Some(set) = self.sets.remove(&old) else { continue }; + // `unique` cannot fail here: a set was just removed, so there is room and the + // candidate names outnumber the sets. + let name = self.unique(&old, builtins).unwrap_or_else(|_| old.clone()); + if self.active == old { + self.active = name.clone(); + } + renamed |= name != old; + self.sets.insert(name, set); + } + renamed + } + + pub fn select(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if self.get(name, builtins).is_none() { + return Err(ProfileError::NotFound); + } + self.active = name.to_string(); + Ok(()) + } + + /// Saves edited overrides to the set in use. A built-in set is not changed: the edit goes to a + /// new set named after `copy_name` (made unique), which becomes active and is returned. The + /// new name is returned too when [`Profiles::reconcile`] renamed the set in use. + pub fn store(&mut self, overrides: Overrides, builtins: &Builtins, copy_name: &str) -> Result, ProfileError> { + let was = self.active_name(builtins).to_string(); + self.reconcile(builtins); + let active = self.active_name(builtins).to_string(); + if let Some(set) = self.sets.get_mut(&active) { + *set = overrides; + return Ok((active != was).then_some(active)); + } + if self.get(&active, builtins) == Some(&overrides) || (active.is_empty() && overrides.is_empty()) { + return Ok(None); + } + let base = set_name(copy_name).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins).map(Some) + } + + /// Saves `overrides` as a user set (replacing one of that name) and makes it active. + pub fn save_as(&mut self, name: &str, overrides: Overrides, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let name = set_name(name).ok_or(ProfileError::BadName)?; + if self.builtin_like(&name, builtins) { + return Err(ProfileError::BuiltIn); + } + if !self.sets.contains_key(&name) { + if self.sets.keys().any(|k| k.to_lowercase() == name.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + } + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + pub fn rename(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + if Self::is_builtin(from, builtins) { + return Err(ProfileError::BuiltIn); + } + let to = set_name(to).ok_or(ProfileError::BadName)?; + if !self.sets.contains_key(from) { + return Err(ProfileError::NotFound); + } + if to != from && (self.taken(&to, builtins) && to.to_lowercase() != from.to_lowercase()) { + return Err(ProfileError::NameTaken); + } + if let Some(set) = self.sets.remove(from) { + self.sets.insert(to.clone(), set); + } + if self.active == from { + self.active = to.clone(); + } + Ok(to) + } + + pub fn delete(&mut self, name: &str, builtins: &Builtins) -> Result<(), ProfileError> { + self.reconcile(builtins); + if Self::is_builtin(name, builtins) { + return Err(ProfileError::BuiltIn); + } + self.sets.remove(name).ok_or(ProfileError::NotFound)?; + if self.active == name { + self.active = builtins.first().map_or(String::new(), |(n, _)| n.to_string()); + } + Ok(()) + } + + /// Copies a set (built-in or user) to a new user set named `to` (made unique), which becomes + /// active. Returns its name. + pub fn duplicate(&mut self, from: &str, to: &str, builtins: &Builtins) -> Result { + self.reconcile(builtins); + let overrides = self.get(from, builtins).ok_or(ProfileError::NotFound)?.clone(); + let base = set_name(to).ok_or(ProfileError::BadName)?; + self.add(&base, overrides, builtins) + } + + /// A set as a JSON document: `{"format":"craft-keymap","version":1,"name":…,"overrides":…}`, + /// overrides as [`Overrides`] serializes them. + pub fn export(&self, name: &str, builtins: &Builtins) -> Result { + let overrides = self.get(name, builtins).ok_or(ProfileError::NotFound)?; + let doc = json!({"format": FORMAT, "version": VERSION, "name": name, "overrides": overrides}); + serde_json::to_string_pretty(&doc).map_err(|_| ProfileError::BadFile) + } + + /// Reads an exported set into a new user set (renamed when the name is taken) and makes it + /// active. Returns its name. + pub fn import(&mut self, text: &str, builtins: &Builtins) -> Result { + if text.len() > MAX_EXPORT_BYTES { + return Err(ProfileError::TooLarge); + } + let doc: Value = serde_json::from_str(text).map_err(|_| ProfileError::BadFile)?; + if doc.get("format").and_then(Value::as_str) != Some(FORMAT) { + return Err(ProfileError::WrongFormat); + } + match doc.get("version").and_then(Value::as_u64) { + Some(VERSION) => {} + Some(v) if v > VERSION => return Err(ProfileError::NewerVersion(v)), + _ => return Err(ProfileError::BadFile), + } + let overrides = match doc.get("overrides") { + Some(v @ (Value::Object(_) | Value::Array(_))) => Overrides::deserialize(v).map_err(|_| ProfileError::BadFile)?, + None => Overrides::new(), + Some(_) => return Err(ProfileError::BadFile), + }; + self.reconcile(builtins); + let base = doc.get("name").and_then(Value::as_str).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + self.add(&base, overrides, builtins) + } + + /// Applies an import plan to a copy of `keymap` (the keymap the plan was made from) and keeps + /// the resulting overrides as a new user set, which becomes active; `keymap` is not changed. + /// The set is named `name`, else the foreign set's name, else `Imported`, made unique. + /// Returns its name and the report of [`ImportPlan::apply`]. + pub fn import_plan( + &mut self, + plan: &ImportPlan, + keymap: &Keymap, + name: Option<&str>, + builtins: &Builtins, + ) -> Result<(String, ImportReport), ProfileError> { + self.reconcile(builtins); + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + let mut copy = keymap.clone(); + let report = plan.apply(&mut copy); + let base = name.or(plan.report.set.as_deref()).and_then(set_name).unwrap_or_else(|| IMPORTED.to_string()); + let name = self.add(&base, copy.overrides().clone(), builtins)?; + Ok((name, report)) + } + + /// Adds a user set under a unique name from `base` and makes it active. + fn add(&mut self, base: &str, overrides: Overrides, builtins: &Builtins) -> Result { + let name = self.unique(base, builtins)?; + self.sets.insert(name.clone(), overrides); + self.active = name.clone(); + Ok(name) + } + + fn builtin_like(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + builtins.iter().any(|(n, _)| n.to_lowercase() == lower) + } + + fn taken(&self, name: &str, builtins: &Builtins) -> bool { + let lower = name.to_lowercase(); + self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower) + } + + /// `base`, or `base 2`, `base 3`… cut to fit [`MAX_SET_NAME`]. + fn unique(&self, base: &str, builtins: &Builtins) -> Result { + if self.sets.len() >= MAX_SETS { + return Err(ProfileError::TooMany); + } + if !self.taken(base, builtins) { + return Ok(base.to_string()); + } + // `Custom 2` taken: try `Custom 3`, not `Custom 2 2`. + let base = match base.rsplit_once(' ') { + Some((stem, n)) if !stem.is_empty() && (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()) => stem, + _ => base, + }; + for n in 2..(MAX_SETS + builtins.len() + 3) { + let suffix = format!(" {n}"); + let keep = MAX_SET_NAME.saturating_sub(suffix.chars().count()); + let name: String = base.chars().take(keep).collect::().trim_end().to_string() + &suffix; + if !self.taken(&name, builtins) { + return Ok(name); + } + } + Err(ProfileError::NameTaken) + } +} + +fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let v = Value::deserialize(deserializer)?; + Ok(v.as_str().and_then(set_name).unwrap_or_default()) +} + +// An array of `{ name, overrides }` rather than a table, and each set's overrides in their array +// form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the +// file. +fn sets_as_list(sets: &BTreeMap, serializer: S) -> Result { + #[derive(Serialize)] + struct Set<'a> { + name: &'a str, + #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] + overrides: &'a Overrides, + } + serializer.collect_seq(sets.iter().map(|(name, overrides)| Set { name, overrides })) +} + +fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + deserializer.deserialize_any(SetsVisitor) +} + +struct SetsVisitor; + +// Anything other than a list or a map loads as no sets, so the rest of `Profiles` still loads. +macro_rules! no_sets { + ($($name:ident: $t:ty),*) => { + $(fn $name(self, _: $t) -> Result { + Ok(BTreeMap::new()) + })* + }; +} + +impl<'de> Visitor<'de> for SetsVisitor { + type Value = BTreeMap; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides") + } + + no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); + + fn visit_unit(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_none(self) -> Result { + Ok(BTreeMap::new()) + } + + fn visit_some>(self, deserializer: D) -> Result { + deserializer.deserialize_any(SetsVisitor) + } + + fn visit_seq>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(item) = access.next_element::()? { + let Some(name) = item.get("name").and_then(Value::as_str) else { continue }; + if !admits(&out, name) { + continue; + } + if let Some(o) = item.get("overrides").and_then(overrides_of) { + out.insert(name.to_string(), o); + } + } + Ok(out) + } + + fn visit_map>(self, mut access: A) -> Result { + let mut out = BTreeMap::new(); + while let Some(key) = access.next_key::()? { + if admits(&out, &key) { + if let Some(o) = overrides_of(&access.next_value::()?) { + out.insert(key, o); + } + } else { + access.next_value::()?; + } + } + Ok(out) + } +} + +/// A valid name, stored as written, with room under [`MAX_SETS`]. +fn admits(sets: &BTreeMap, name: &str) -> bool { + set_name(name).is_some_and(|n| n == name) && (sets.len() < MAX_SETS || sets.contains_key(name)) +} + +/// A set's overrides: a list or a map, anything else is no set. +fn overrides_of(v: &Value) -> Option { + match v { + Value::Object(_) | Value::Array(_) => Overrides::deserialize(v).ok(), + _ => None, + } +} diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs new file mode 100644 index 00000000..b33c9045 --- /dev/null +++ b/crates/keymap/tests/editor.rs @@ -0,0 +1,200 @@ +use craft_keymap::editor::{MAX_UNDO_BYTES, holders, search}; +use craft_keymap::{ + AssignError, Bindable, Binding, Chord, CommandRef, DOUBLE_PRESS, EditSession, Holder, Keymap, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, + MAX_UNDO, Overrides, Platform, Policy, Recorded, Recorder, Scope, Scopes, Trigger, +}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap(platform: Platform) -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), + Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), + Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), + Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), + ]; + Keymap::new(catalogue, Scopes::new(), platform) +} + +fn keys(km: &Keymap, id: &str) -> Vec { + km.bindings(&CommandRef::new(id)).iter().map(|b| b.to_string()).collect() +} + +#[test] +fn edits_undo_redo_and_cancel() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + + let assigned = session.edit(&mut km, |km| km.assign(&save, b("Cmd+Shift+S"), Policy::Steal)); + assert!(assigned.is_ok()); + session.edit(&mut km, |km| km.clear(&CommandRef::new("edit.copy"))); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + assert!(keys(&km, "edit.copy").is_empty()); + assert!(session.is_modified(&km)); + + // An edit that changes nothing is no undo step. + session.edit(&mut km, |km| km.reset(&CommandRef::new("tool.brush"))); + + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert!(!session.undo(&mut km)); + assert!(!session.is_modified(&km)); + + assert!(session.redo(&mut km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+Shift+S"]); + // A new edit drops what could be redone. + session.edit(&mut km, |km| km.clear(&CommandRef::new("tool.brush"))); + assert!(!session.redo(&mut km)); + + session.cancel(&mut km); + assert!(!session.is_modified(&km)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + assert_eq!(keys(&km, "tool.brush"), ["B"]); + assert!(!session.can_undo()); +} + +#[test] +fn undo_keeps_the_newest_steps() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + let brush = CommandRef::new("tool.brush"); + // Each edit changes the key: N, B, N, … (B is the default). + let edits = MAX_UNDO + 11; + for i in 0..edits { + let key = if i % 2 == 0 { "N" } else { "B" }; + assert!(session.edit(&mut km, |km| km.set_bindings(&brush, vec![b(key)], Policy::Keep)).is_ok()); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert_eq!(steps, MAX_UNDO); + // The 11 oldest steps are gone: undo stops after the 11th edit, which set N. + assert_eq!(keys(&km, "tool.brush"), ["N"]); + assert!(session.is_modified(&km)); +} + +#[test] +fn undo_history_is_capped_by_size() { + let mut km = keymap(Platform::Windows); + let mut session = EditSession::begin(&km); + // Full-size snapshots of entries for commands the catalogue does not have. + let big = |tag: char| { + let mut o = Overrides::new(); + for n in 0..MAX_COMMANDS { + assert!(o.insert(format!("junk.{n}"), vec![tag.to_string().repeat(MAX_BINDING_TEXT); MAX_BINDINGS])); + } + o + }; + let (a, b) = (big('a'), big('b')); + let string = size_of::(); + let per = a.iter().map(|(k, t)| string + 64 + k.len() + t.iter().map(|t| string + 16 + t.len()).sum::()).sum::(); + let edits = 2 * MAX_UNDO_BYTES / per + 4; + for n in 0..edits { + let next = if n % 2 == 0 { a.clone() } else { b.clone() }; + session.edit(&mut km, |km| km.set_overrides(next)); + } + let mut steps = 0; + while session.undo(&mut km) { + steps += 1; + } + assert!(steps >= 2 && steps <= MAX_UNDO_BYTES / per, "{steps} steps of {per} bytes"); +} + +#[test] +fn recorder_turns_presses_into_triggers() { + let mut r = Recorder::new(); + let a = chord("A"); + assert_eq!(r.press(a, 1.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 1.0 + DOUBLE_PRESS / 2.0), Recorded::Double(Trigger::double(a))); + // The press after a double starts over. + assert_eq!(r.press(a, 1.4), Recorded::Single(Trigger::single(a))); + // Too late for a double. + assert_eq!(r.press(a, 1.4 + DOUBLE_PRESS + 0.1), Recorded::Single(Trigger::single(a))); + // Another chord in between. + assert_eq!(r.press(chord("B"), 3.0), Recorded::Single(Trigger::single(chord("B")))); + assert_eq!(r.press(a, 3.1), Recorded::Single(Trigger::single(a))); + + assert_eq!(r.press(chord("Escape"), 3.2), Recorded::Cancel); + let shift_escape = chord("Shift+Escape"); + assert_eq!(r.press(shift_escape, 3.3), Recorded::Single(Trigger::single(shift_escape))); + // Two presses in one egui frame share its time. + assert_eq!(r.press(a, 4.0), Recorded::Single(Trigger::single(a))); + assert_eq!(r.press(a, 4.0), Recorded::Double(Trigger::double(a))); +} + +#[test] +fn search_reads_labels_aliases_paths_ids_and_keys() { + let ids = |km: &Keymap, q: &str, p: Platform| search(km, q, p).iter().map(|b| b.command.id.clone()).collect::>(); + let win = keymap(Platform::Windows); + let mac = keymap(Platform::Mac); + assert_eq!(ids(&win, "zoom", Platform::Windows), ["view.zoomIn"]); + assert_eq!(ids(&win, "KOPIEREN", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&win, "paint", Platform::Windows), ["tool.brush"]); + assert_eq!(ids(&win, "file.sa", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "ctrl+s", Platform::Windows), ["file.save"]); + assert_eq!(ids(&win, "cmd+c", Platform::Windows), ["edit.copy"]); + assert_eq!(ids(&mac, "⌘s", Platform::Mac), ["file.save"]); + assert_eq!(ids(&mac, "⌘s", Platform::Windows), Vec::::new()); + assert_eq!(ids(&mac, " ", Platform::Mac).len(), 4); +} + +#[test] +fn a_dialog_asks_before_stealing_and_undoes_each_answer() { + let mut km = keymap(Platform::Mac); + let mut session = EditSession::begin(&km); + let save = CommandRef::new("file.save"); + let copy = CommandRef::new("edit.copy"); + let held = vec![Holder { command: copy.clone(), binding: b("Cmd+C") }]; + + // While recording: who else has the key. + assert_eq!(holders(&km, &save, &b("Cmd+C")), held); + assert!(holders(&km, ©, &b("Cmd+C")).is_empty(), "its own key"); + assert_eq!(session.assign(&mut km, &save, b("Cmd+C"), Policy::Refuse), Err(AssignError::Taken(held.clone()))); + assert!(!session.can_undo(), "a refused assignment changes nothing"); + + // Keep: both hold it. + let kept = session.assign(&mut km, &save, b("Cmd+C"), Policy::Keep).unwrap(); + assert_eq!(kept.clashes, held); + assert_eq!(keys(&km, "edit.copy"), ["Cmd+C"]); + assert!(session.undo(&mut km)); + + // Steal: the other command loses it. + let stolen = session.assign(&mut km, &save, b("Cmd+C"), Policy::Steal).unwrap(); + assert_eq!(stolen.taken_from, held); + assert!(keys(&km, "edit.copy").is_empty()); + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + + // Recording over one key of the list replaces it in place. + session.replace(&mut km, &save, &b("Cmd+S"), b("Cmd+Shift+S"), Policy::Refuse).unwrap(); + assert_eq!(keys(&km, "file.save"), ["Cmd+Shift+S", "Cmd+C"]); + assert!(session.remove(&mut km, &save, &b("Cmd+C"))); + assert!(session.clear(&mut km, &CommandRef::new("tool.brush"))); + + assert!(session.reset(&mut km, &save)); + assert_eq!(keys(&km, "file.save"), ["Cmd+S"]); + session.reset_all(&mut km); + assert!(km.overrides().is_empty()); + assert!(!session.is_modified(&km)); + + // One undo per action, back to the steal. + for _ in 0..5 { + assert!(session.undo(&mut km)); + } + assert_eq!(keys(&km, "file.save"), ["Cmd+S", "Cmd+C"]); + assert!(session.undo(&mut km)); + assert!(!session.can_undo()); + + session.redo(&mut km); + session.cancel(&mut km); + assert!(km.overrides().is_empty(), "cancel restores the state at open"); +} diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs new file mode 100644 index 00000000..84be8f09 --- /dev/null +++ b/crates/keymap/tests/profiles.rs @@ -0,0 +1,172 @@ +use craft_keymap::{ + Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, + Policy, ProfileError, Profiles, Scope, Scopes, Start, plan, +}; +use serde_json::json; + +fn b(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn keymap() -> Keymap { + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), + Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), + Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), + Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Mac) +} + +#[test] +fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let mut edited = Overrides::new(); + edited.insert("edit.copy", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom".into()))); + assert_eq!(p.active_name(&builtins), "Custom"); + assert_eq!(p.store(Overrides::new(), &builtins, "Custom"), Ok(None), "a user set is edited in place"); + assert!(p.active_overrides(&builtins).is_empty()); + + p.select("Classic", &builtins).unwrap(); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.store(classic.clone(), &builtins, "Custom"), Ok(None), "no change, no copy"); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Custom 2".into()))); + assert_eq!(builtins[1].1, classic); + + let exported = p.export("Custom 2", &builtins).unwrap(); + assert_eq!(p.import(&exported, &builtins), Ok("Custom 3".into())); + assert_eq!(p.active_overrides(&builtins), &edited); + + assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); + assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); + assert_eq!(p.active, "Mine"); + assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); + p.delete("Mine", &builtins).unwrap(); + assert_eq!(p.active_name(&builtins), "Craft"); + + let wrong = exported.replace("craft-keymap", "other"); + assert_eq!(p.import(&wrong, &builtins), Err(ProfileError::WrongFormat)); + let newer = exported.replace("\"version\": 1", "\"version\": 2"); + assert_eq!(p.import(&newer, &builtins), Err(ProfileError::NewerVersion(2))); + + let text = serde_json::to_string(&p).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap(), p); + let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); + assert_eq!(junk.sets.keys().collect::>(), ["ok"]); + let junk: Profiles = + serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); + assert_eq!(junk.sets["ok"].get("a"), Some(&["A".to_string()][..])); + assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); +} + +#[test] +fn sets_that_are_not_a_list_or_map_load_as_none() { + for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { + let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert!(p.sets.is_empty(), "{text}"); + assert_eq!(p.active, "Mine", "{text}"); + } +} + +#[test] +fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { + let mut mine = Overrides::new(); + mine.insert("edit.copy", vec!["Cmd+K".into()]); + let mut p = Profiles::default(); + p.save_as("Classic", mine.clone(), &[("Craft", Overrides::new())]).unwrap(); + + // An update adds a built-in of that name. + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + assert_eq!(p.active_overrides(&builtins), &mine, "the user set is what the name reads"); + + let mut edited = mine.clone(); + edited.insert("edit.paste", vec![]); + assert_eq!(p.store(edited.clone(), &builtins, "Custom"), Ok(Some("Classic 2".into()))); + assert_eq!(p.active_name(&builtins), "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &edited); + assert_eq!(p.get("Classic", &builtins), Some(&classic)); + assert_eq!(builtins[1].1, classic); + + let mut q = Profiles::default(); + q.sets.insert("CRAFT".into(), mine.clone()); + q.active = "CRAFT".into(); + assert!(q.reconcile(&builtins)); + assert_eq!(q.active_name(&builtins), "CRAFT 2"); + assert_eq!(q.active_overrides(&builtins), &mine); + assert!(!q.reconcile(&builtins)); +} + +#[test] +fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { + let mut km = keymap(); + km.assign(&"file.save".into(), b("Cmd+Alt+S"), Policy::Steal).unwrap(); + km.assign(&CommandRef::with_params("window.panel", json!({"panel": "layers"})), b("Timeline: Cmd+L"), Policy::Steal).unwrap(); + km.clear(&"edit.copy".into()); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + p.save_as("Mine", km.overrides().clone(), &builtins).unwrap(); + + let text = p.export("Mine", &builtins).unwrap(); + assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); + assert_eq!(p.active, "Mine 2"); + + let mut again = keymap(); + again.set_overrides(p.active_overrides(&builtins).clone()); + for bindable in km.catalogue() { + assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); + } + assert_eq!(again.overrides(), km.overrides()); +} + +#[test] +fn duplicate_copies_built_in_and_user_sets() { + let mut classic = Overrides::new(); + classic.insert("edit.copy", vec!["Cmd+Shift+C".into()]); + let builtins = [("Craft", Overrides::new()), ("Classic", classic.clone())]; + let mut p = Profiles::default(); + + assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); + assert_eq!(p.active, "Classic 2"); + assert_eq!(p.active_overrides(&builtins), &classic); + assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); + assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); + assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); + assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); + + while p.sets.len() < MAX_SETS { + p.duplicate("Craft", "Spare", &builtins).unwrap(); + } + assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); +} + +#[test] +fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { + let km = keymap(); + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); + let set = ForeignSet { name: Some("Other App".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; + let planned = plan(&set, &km, &options); + let builtins = [("Craft", Overrides::new())]; + let mut p = Profiles::default(); + + let (name, report) = p.import_plan(&planned, &km, None, &builtins).unwrap(); + assert_eq!(name, "Other App"); + assert_eq!(report.changed.len(), 1); + assert!(km.overrides().is_empty()); + assert_eq!(p.active, "Other App"); + let mut imported = keymap(); + imported.set_overrides(p.active_overrides(&builtins).clone()); + assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); + + let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); + assert_eq!(name, "Craft 2", "a built-in's name is not reused"); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index 89c744bd..6d2351b6 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Scope, Scopes}; +use craft_keymap::{Bindable, Binding, CommandRef, DropReason, Keymap, Overrides, Platform, Policy, Problem, Profiles, Scope, Scopes}; use craft_settings::{Location, OpenOptions, Probe, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -11,6 +11,7 @@ struct Prefs { general: General, #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, + profiles: Profiles, } #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Debug)] @@ -52,7 +53,11 @@ fn overrides_round_trip_through_craft_settings() { km.clear(&"edit.copy".into()); let mut store = open(tmp.path()); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.save_as("Mine", km.overrides().clone(), &[]).unwrap(); + p.profiles.save_as("Spare", Overrides::new(), &[]).unwrap(); + }); store.flush().unwrap(); // A hand edit: one text that does not parse, one value that is not a list. @@ -72,6 +77,7 @@ fn overrides_round_trip_through_craft_settings() { let problems = km.problems(); assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); + assert_eq!(store.get().profiles.sets.len(), 2); store.update(|p| p.general.language = "de".into()); store.flush().unwrap(); @@ -85,13 +91,18 @@ fn overrides_round_trip_through_craft_settings() { // Removals reach the file. km.reset(&layers); - store.update(|p| p.shortcuts = km.overrides().clone()); + store.update(|p| { + p.shortcuts = km.overrides().clone(); + p.profiles.delete("Spare", &[]).unwrap(); + }); store.flush().unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let line = text.lines().find(|l| l.starts_with("shortcuts = ")).unwrap(); - assert!(!line.contains("window.panel"), "{text}"); + assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); let reloaded = open(tmp.path()); assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); + assert_eq!(reloaded.get().profiles.sets.keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); } #[test] @@ -121,3 +132,15 @@ fn a_shortcuts_value_that_is_not_a_list_loads_as_no_overrides() { assert_eq!(problems, vec![Problem::Dropped { key: String::new(), reason: DropReason::NotAList }], "{value}"); } } + +#[test] +fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { + for value in ["\"x\"", "5", "true"] { + let tmp = tempfile::tempdir().unwrap(); + let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); + std::fs::write(tmp.path().join("config.toml"), text).unwrap(); + let store = open(tmp.path()); + assert_eq!(store.get().profiles.active, "Mine", "{value}"); + assert!(store.get().profiles.sets.is_empty(), "{value}"); + } +} From b9bd4119cce2fb050d3475791587648a015e2f5b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:42:38 +0000 Subject: [PATCH 011/124] keymap: shortcuts dialog model behind the ui feature DialogState with every dialog action as a call on EditSession, Recorder, editor::holders and Profiles; the table rows (areas from paths, kind and scope, filters, search by label, translation or chord, grouping, sorting, a row cache); key capture while recording, including clipboard events, which removes the events it takes; the Text trait, I18nText and the list of strings the dialog looks up. Drawing comes separately. --- Cargo.lock | 3 + crates/keymap/Cargo.toml | 6 + crates/keymap/src/chord.rs | 4 +- crates/keymap/src/egui.rs | 6 +- crates/keymap/src/lib.rs | 2 + crates/keymap/src/ui/capture.rs | 95 +++ crates/keymap/src/ui/mod.rs | 281 +++++++ crates/keymap/src/ui/rows.rs | 628 +++++++++++++++ crates/keymap/src/ui/state.rs | 1305 +++++++++++++++++++++++++++++++ crates/keymap/src/ui/text.rs | 283 +++++++ 10 files changed, 2608 insertions(+), 5 deletions(-) create mode 100644 crates/keymap/src/ui/capture.rs create mode 100644 crates/keymap/src/ui/mod.rs create mode 100644 crates/keymap/src/ui/rows.rs create mode 100644 crates/keymap/src/ui/state.rs create mode 100644 crates/keymap/src/ui/text.rs diff --git a/Cargo.lock b/Cargo.lock index 7ae3ad37..76a76163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -622,8 +622,11 @@ dependencies = [ name = "craft-keymap" version = "0.1.0" dependencies = [ + "craft-i18n", "craft-settings", + "craft-ui", "egui", + "egui_kittest", "serde", "serde_json", "tempfile", diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 0e5e80f8..0363ae7b 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -14,10 +14,16 @@ workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" egui = { workspace = true, optional = true } +craft-ui = { workspace = true, optional = true } +craft-i18n = { workspace = true, optional = true } [dev-dependencies] craft-settings = { workspace = true } tempfile = "3" +egui = { workspace = true, features = ["default_fonts"] } +egui_kittest = "0.36" [features] egui = ["dep:egui"] +# The Keyboard Shortcuts dialog. +ui = ["egui", "dep:craft-ui", "dep:craft-i18n"] diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 740ccf21..b94357eb 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -178,7 +178,7 @@ impl Key { } /// The key as macOS menus show it: arrows and editing keys as glyphs. - fn mac_symbol(self) -> &'static str { + pub(crate) fn mac_symbol(self) -> &'static str { match self { Key::Up => "↑", Key::Down => "↓", @@ -194,7 +194,7 @@ impl Key { } /// The key as Windows and Linux show it. - fn text_name(self) -> &'static str { + pub(crate) fn text_name(self) -> &'static str { match self { Key::PageUp => "Page Up", Key::PageDown => "Page Down", diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 46511e29..a1eb5fc4 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -321,7 +321,7 @@ impl Dispatcher { /// A clipboard event: Copy, Cut or Paste. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Clip { +pub(crate) enum Clip { Copy, Cut, Paste, @@ -400,7 +400,7 @@ fn event_clip(event: &Event) -> Option { } /// The clipboard event a backend sends for a press, if any. -fn clipboard_kind(key: Key, held: Mods) -> Option { +pub(crate) fn clipboard_kind(key: Key, held: Mods) -> Option { match key { Key::C if held.cmd => Some(Clip::Copy), Key::X if held.cmd => Some(Clip::Cut), @@ -414,7 +414,7 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { /// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off /// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { +pub(crate) fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { let with_cmd = Mods { cmd: true, ..held }; let shift_only = held.shift && !held.cmd; match event { diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index fd93f7bb..a54f0756 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -138,6 +138,8 @@ pub mod keymap; pub mod profiles; pub mod reserved; pub mod scope; +#[cfg(feature = "ui")] +pub mod ui; pub use chord::foreign::Flavor; pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger}; diff --git a/crates/keymap/src/ui/capture.rs b/crates/keymap/src/ui/capture.rs new file mode 100644 index 00000000..a5fef16f --- /dev/null +++ b/crates/keymap/src/ui/capture.rs @@ -0,0 +1,95 @@ +//! Taking a frame's key input while the dialog records, before the app's dispatcher or egui's +//! widgets see it. + +use ::egui::{Event, EventFilter, Id, InputState, Ui}; + +use crate::chord::{Chord, Key, Mods, Platform}; +use crate::egui::{Clip, clipboard_chord, clipboard_kind}; + +/// The keys a recording widget keeps from egui's focus navigation, so Tab, arrows and Escape are +/// recorded instead of moving focus. +pub(crate) const FOCUS_LOCK: EventFilter = EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }; + +/// Holds egui's focus on `id` with [`FOCUS_LOCK`]; call each frame the widget records. +pub(crate) fn lock_focus(ui: &Ui, id: Id) { + ui.memory_mut(|m| m.set_focus_lock_filter(id, FOCUS_LOCK)); +} + +/// Removes every key, text, IME and clipboard event from `input` and returns the chords pressed, +/// in order. Repeats, releases and modifier-only presses give no chord. Copy, Cut and Paste +/// count as the presses that cause them (Cmd+C, Cmd+X, Cmd+V, or Shift+Delete and Shift+Insert), +/// once when a backend sends both the press and the event. +pub(crate) fn take(input: &mut InputState, platform: Platform) -> Vec { + let frame_mods = Mods::from_egui(&input.modifiers, platform); + let mut chords = Vec::new(); + let mut kept = Vec::with_capacity(input.events.len()); + let mut clipboard_press: Option = None; + for event in std::mem::take(&mut input.events) { + match &event { + Event::Key { key, pressed, repeat, modifiers, .. } => { + let Some(key) = Key::from_egui(*key) else { continue }; + if *pressed && !*repeat { + let held = Mods::from_egui(modifiers, platform); + if let Some(clip) = clipboard_kind(key, held) { + clipboard_press = Some(clip); + } + chords.push(Chord::new(held, key)); + } + } + Event::Copy | Event::Cut | Event::Paste(_) => { + let (key, held, clip) = clipboard_chord(&event, frame_mods); + if clipboard_press == Some(clip) { + clipboard_press = None; + } else { + chords.push(Chord::new(held, key)); + } + } + Event::Text(_) | Event::Ime(_) => {} + _ => kept.push(event), + } + } + input.events = kept; + chords +} + +#[cfg(test)] +mod tests { + use ::egui::{Context, Key as EguiKey, Modifiers, RawInput}; + + use super::*; + + fn run(events: Vec) -> (Vec, usize) { + let ctx = Context::default(); + let mut out = (Vec::new(), 0); + let mut full = ctx.run_ui(RawInput { events, ..RawInput::default() }, |ui| { + let chords = ui.ctx().input_mut(|i| take(i, Platform::Mac)); + out = (chords.iter().map(Chord::to_string).collect(), ui.ctx().input(|i| i.events.len())); + }); + full.textures_delta.clear(); + out + } + + fn press(key: EguiKey, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + #[test] + fn takes_presses_and_clipboard_events_and_leaves_nothing() { + let cmd = Modifiers::MAC_CMD; + let events = vec![ + press(EguiKey::E, cmd), + Event::Text("e".into()), + Event::Key { key: EguiKey::E, physical_key: None, pressed: false, repeat: false, modifiers: cmd }, + press(EguiKey::Tab, Modifiers::NONE), + // The web build: the press, then the event it causes, counted once. + press(EguiKey::C, cmd), + Event::Copy, + // egui-winit: only the event. + Event::Paste("x".into()), + Event::PointerGone, + ]; + let (chords, left) = run(events); + assert_eq!(chords, ["Cmd+E", "Tab", "Cmd+C", "Cmd+V"]); + assert_eq!(left, 1, "only the pointer event stays"); + } +} diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs new file mode 100644 index 00000000..038d36ea --- /dev/null +++ b/crates/keymap/src/ui/mod.rs @@ -0,0 +1,281 @@ +//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalogue with recording, +//! conflicts, undo, named sets and import review, over [`Keymap`] and optionally [`Profiles`]. +//! +//! The app keeps a [`DialogState`] while the dialog is open and calls [`ShortcutsDialog::show`] +//! (or [`ShortcutsDialog::ui`] inside its own container) every frame. While +//! [`DialogState::is_open`] is true the app skips its own key dispatch; the dialog takes the +//! frame's key events itself while it records. The app saves on [`Outcome::Ok`] (or on +//! [`Outcome::Changed`] for live saving) and drops the state once it is closed. + +use std::collections::BTreeMap; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::import::{ForeignSet, ImportOptions}; +use crate::keymap::{Bindable, Binding, Keymap}; +use crate::profiles::{Builtins, ProfileError, Profiles}; + +// The view draws with these; until it calls every action some stay unused. +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod capture; +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod rows; +#[expect(dead_code, reason = "actions and row fields the view calls")] +mod state; +mod text; + +pub use state::DialogState; +pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text}; + +use state::Env; + +/// What the dialog reports for a frame. +#[derive(Clone, Debug)] +pub enum Outcome { + /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. + Ok, + /// Closed with Cancel: the keymap and profiles are back as they were when it opened. + Cancel, + /// An edit this frame (for live menus and live saving). + Changed, + /// The user asked to import: the app opens a file of one of these formats and calls + /// [`ShortcutsDialog::import_json`] or [`ShortcutsDialog::review_import`]. + Import { formats: Vec<&'static str> }, + /// A set as craft-keymap JSON, for the app to write. + Export { name: String, json: String }, + /// A set in one of [`Options::export_formats`], for the app to write. + ExportAs { format: &'static str, name: String, keymap: Box }, + /// The shortcut summary, for the app to show, save or print. + Summary { name: String, html: String, text: String }, + /// Import answers the user gave (item → command), for the app to keep for later imports. + Remembered(BTreeMap), +} + +/// What happens when a recorded chord is held by other commands. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ConflictMode { + /// Ask with the conflict bar: Take It, Take It and Go to Conflict, Keep Both, Cancel. + #[default] + Ask, + /// Take the chord from the other commands, then say so. + Steal, + /// Keep both bindings, then say so. + Keep, +} + +/// An app's own Show entry. +#[derive(Clone, Copy, Debug)] +pub struct Area { + pub id: &'static str, + pub label: &'static str, + pub rows: fn(&Bindable) -> bool, +} + +/// A read-only row for a key outside the catalogue. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixedRow { + pub group: String, + pub label: String, + /// The keys as shown. + pub keys: String, + /// The trigger, when the row has one, so a catalogue binding on it can be pointed out. + pub chord: Option, +} + +/// A file format an app reads or writes itself. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Format { + pub id: &'static str, + pub label: &'static str, + pub extensions: &'static [&'static str], +} + +/// The dialog's colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DialogStyle { + pub accent: egui::Color32, + pub warning: egui::Color32, + pub dim: egui::Color32, + pub changed: egui::Color32, + pub app_key: egui::Color32, + pub scoped_key: egui::Color32, + pub both_key: egui::Color32, + pub modifier_key: egui::Color32, + pub free_key: egui::Color32, +} + +impl DialogStyle { + pub fn from_visuals(v: &egui::Visuals) -> DialogStyle { + let accent = v.selection.bg_fill; + DialogStyle { + accent, + warning: v.warn_fg_color, + dim: v.weak_text_color(), + changed: accent, + app_key: accent, + scoped_key: v.warn_fg_color, + both_key: v.error_fg_color, + modifier_key: v.widgets.active.bg_fill, + free_key: v.widgets.inactive.bg_fill, + } + } +} + +/// A rule an app adds for which bindings a command may get; `Err` carries the message shown. +pub type Accept = Box Result<(), String>>; +/// Called for every control once per frame with (automation id, rect, label). +pub type OnWidget = Box; + +/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field. +pub struct Options { + /// "Keyboard Shortcuts". + pub title: &'static str, + /// true; false shows the table read-only with Close and Summarize. + pub editable: bool, + /// "Custom": the name edits of a built-in set are saved under. + pub custom_set_name: &'static str, + /// Ask. + pub conflicts: ConflictMode, + /// true: Keep Both is offered. + pub allow_keep: bool, + /// false: a recorded chord waits [`DOUBLE_PRESS`](crate::DOUBLE_PRESS) for a second press. + pub double_press: bool, + /// false: Backspace or Delete without modifiers removes the binding being recorded over. + pub backspace_clears: bool, + /// false: Enter when idle is OK. + pub enter_is_ok: bool, + /// false: rows sort by translated label instead of catalogue order. + pub sort_by_label: bool, + /// false: Show as tabs instead of a dropdown. + pub area_tabs: bool, + /// The app's own Show entries. + pub areas: Vec, + /// false: the on-screen keyboard pane. + pub keyboard: bool, + pub fixed: Vec, + pub import_formats: Vec, + pub export_formats: Vec, + /// true: Summarize is offered. + pub summary: bool, + pub accept: Option, + pub on_widget: Option, + /// `None`: [`DialogStyle::from_visuals`]. + pub style: Option, +} + +impl Default for Options { + fn default() -> Options { + Options { + title: "Keyboard Shortcuts", + editable: true, + custom_set_name: "Custom", + conflicts: ConflictMode::Ask, + allow_keep: true, + double_press: false, + backspace_clears: false, + enter_is_ok: false, + sort_by_label: false, + area_tabs: false, + areas: Vec::new(), + keyboard: false, + fixed: Vec::new(), + import_formats: Vec::new(), + export_formats: Vec::new(), + summary: true, + accept: None, + on_widget: None, + style: None, + } + } +} + +/// The format id of craft-keymap's own JSON in [`Outcome::Import`]. +pub const JSON_FORMAT: &str = "craft-keymap"; + +/// The dialog for one frame: what it edits, borrowed, and how it looks. +pub struct ShortcutsDialog<'a> { + keymap: &'a mut Keymap, + text: &'a dyn Text, + sets: Option<(&'a mut Profiles, &'a Builtins<'a>)>, + options: Option<&'a Options>, +} + +impl<'a> ShortcutsDialog<'a> { + pub fn new(keymap: &'a mut Keymap, text: &'a dyn Text) -> Self { + ShortcutsDialog { keymap, text, sets: None, options: None } + } + + /// Shows and edits named sets. + pub fn sets(mut self, profiles: &'a mut Profiles, builtins: &'a Builtins<'a>) -> Self { + self.sets = Some((profiles, builtins)); + self + } + + pub fn options(mut self, options: &'a Options) -> Self { + self.options = Some(options); + self + } + + fn with_env(self, f: impl FnOnce(&mut Env) -> R) -> R { + let fallback; + let options = match self.options { + Some(o) => o, + None => { + fallback = Options::default(); + &fallback + } + }; + let mut env = Env { keymap: self.keymap, sets: self.sets, options, text: self.text }; + f(&mut env) + } + + /// A window with title, body and Cancel/OK. + pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { + let title = self.text.ui(self.options.map_or("Keyboard Shortcuts", |o| o.title)).into_owned(); + egui::Window::new(title).collapsible(false).show(ctx, |ui| self.ui(ui, state)).and_then(|r| r.inner).flatten() + } + + /// The body only, for a host's own modal, preferences page or backstage. + pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { + self.with_env(|env| { + state.begin(env); + state.input(env, ui.ctx()); + state.finish() + }) + } + + /// OK, for hosts that draw their own button. + pub fn ok(self, state: &mut DialogState) { + self.with_env(|env| { + state.begin(env); + state.ok(env); + }); + } + + /// Cancel, for hosts that draw their own button. + pub fn cancel(self, state: &mut DialogState) { + self.with_env(|env| { + state.begin(env); + state.cancel(env); + }); + } + + /// Reads a craft-keymap JSON set the app loaded after [`Outcome::Import`]. With sets it becomes + /// a new set, which is loaded; without, its overrides replace the keymap's as one undo step. + /// Returns the set's name. + pub fn import_json(self, state: &mut DialogState, text: &str) -> Result { + self.with_env(|env| { + state.begin(env); + state.import_json(env, text) + }) + } + + /// Opens the review of a foreign set the app read: what will change, what did not match and + /// why, with suggestions to pick from. + pub fn review_import(self, state: &mut DialogState, set: ForeignSet, options: ImportOptions) { + self.with_env(|env| { + state.begin(env); + state.review_import(env, set, options); + }); + } +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs new file mode 100644 index 00000000..1bc13261 --- /dev/null +++ b/crates/keymap/src/ui/rows.rs @@ -0,0 +1,628 @@ +//! The table's model: areas (Show), filters, search, group headers, sorting and the cached list of +//! lines the table draws. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use crate::chord::{Platform, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides}; +use crate::ui::Options; +use crate::ui::text::{Text, trigger_text}; + +/// One entry of the Show menu. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum AreaId { + All, + /// A top menu, `paths[0][0]`. + Menu(String), + Tools, + /// A panel scope, by name. + Panel(String), + /// Commands with no path that are not tools. + Other, + /// An app area from [`Options::areas`], by id. + App(&'static str), + /// [`Options::fixed`]. + Fixed, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AreaEntry { + pub id: AreaId, + pub label: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) enum Filter { + #[default] + All, + WithShortcut, + /// An overrides entry exists for the command. + Changed, + /// The command is in a [`ConflictKind::Clash`]. + Conflicts, +} + +impl Filter { + pub(crate) const ALL: [Filter; 4] = [Filter::All, Filter::WithShortcut, Filter::Changed, Filter::Conflicts]; + + pub(crate) fn label(self, text: &dyn Text) -> String { + match self { + Filter::All => text.ui("All"), + Filter::WithShortcut => text.ui("With Shortcut"), + Filter::Changed => text.ui("Changed"), + Filter::Conflicts => text.ui("Conflicts"), + } + .into_owned() + } +} + +/// A binding as a row shows it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Chip { + pub binding: Binding, + /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope. + pub text: String, + /// Not one of the command's defaults. + pub changed: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Row { + /// Position in the catalogue. + pub index: usize, + pub command: CommandRef, + /// [`CommandRef::key`], for automation ids and the overrides. + pub key: String, + /// The translated label. + pub label: String, + /// Translated path parts between the group header and the label, shown dim before it. + pub prefix: Vec, + pub chips: Vec, + /// An overrides entry exists. + pub changed: bool, + /// A tool sharing a key with another tool of its group. + pub cycles: bool, + /// In a clash. + pub conflict: bool, +} + +/// A read-only row from [`Options::fixed`]. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FixedLine { + pub label: String, + pub keys: String, + /// The label of a catalogue command now bound to the row's chord: drawn struck through. + pub now_used_by: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Line { + Header { name: String, collapsed: bool }, + Row(Row), + Fixed(FixedLine), +} + +/// The lines for one query, area, filter and set of overrides. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Built { + pub lines: Vec, + /// Command and fixed rows shown (headers not counted); 0 shows "No matching commands". + pub matches: usize, + /// Clash pairs in the whole keymap. + pub clashes: usize, + /// Shadow pairs in the whole keymap, shown as an information line. + pub shadows: usize, +} + +impl Built { + /// The line index of a command's row. + pub(crate) fn position(&self, command: &CommandRef) -> Option { + self.lines.iter().position(|l| matches!(l, Line::Row(r) if r.command == *command)) + } +} + +/// What the lines were built from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct View<'a> { + pub query: &'a str, + pub area: &'a AreaId, + pub filter: Filter, + pub collapsed: &'a BTreeSet, +} + +#[derive(Clone, Debug, PartialEq)] +struct Key { + query: String, + area: AreaId, + filter: Filter, + collapsed: BTreeSet, + overrides: Overrides, + platform: Platform, +} + +/// The built lines, rebuilt only when the view or the keymap's overrides change. +#[derive(Clone, Debug, Default)] +pub(crate) struct RowCache { + key: Option, + built: Built, +} + +impl RowCache { + pub(crate) fn get(&mut self, keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> &Built { + let fresh = self.key.as_ref().is_some_and(|k| { + k.query == view.query + && k.area == *view.area + && k.filter == view.filter + && k.collapsed == *view.collapsed + && k.platform == keymap.platform() + && k.overrides == *keymap.overrides() + }); + if !fresh { + self.built = build(keymap, options, text, view); + self.key = Some(Key { + query: view.query.to_string(), + area: view.area.clone(), + filter: view.filter, + collapsed: view.collapsed.clone(), + overrides: keymap.overrides().clone(), + platform: keymap.platform(), + }); + } + &self.built + } + + /// Forgets the lines, for a change the key does not cover (the interface language). + pub(crate) fn invalidate(&mut self) { + self.key = None; + } +} + +fn top_menu(b: &Bindable) -> Option<&str> { + b.paths.first().and_then(|p| p.first()).map(String::as_str) +} + +fn is_tool(b: &Bindable) -> bool { + matches!(b.kind, Kind::Tool { .. }) +} + +/// The Show entries, in catalogue order, leaving out areas with no rows. +pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { + let catalogue = keymap.catalogue(); + let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; + let mut menus: Vec<&str> = Vec::new(); + for m in catalogue.iter().filter_map(top_menu) { + if !menus.contains(&m) { + menus.push(m); + } + } + for m in menus { + let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); + out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); + } + if catalogue.iter().any(is_tool) { + out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); + } + let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); + for b in catalogue.iter().filter(|b| !b.scope.is_app()) { + if !panels.contains(&b.scope.as_str()) { + panels.push(b.scope.as_str()); + } + } + for p in panels.into_iter().filter(|p| catalogue.iter().any(|b| b.scope.as_str() == *p)) { + out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); + } + if catalogue.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); + } + for a in &options.areas { + out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() }); + } + if !options.fixed.is_empty() { + out.push(AreaEntry { id: AreaId::Fixed, label: text.ui("Fixed Keys").into_owned() }); + } + out +} + +fn panel_label(text: &dyn Text, panel: &str) -> String { + craft_i18n::fmt(&text.ui("{panel} Panel"), &[("panel", &text.segment(panel))]) +} + +fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool { + match area { + AreaId::All => true, + AreaId::Menu(m) => top_menu(b) == Some(m.as_str()), + AreaId::Tools => is_tool(b), + AreaId::Panel(p) => b.scope.as_str() == p, + AreaId::Other => top_menu(b).is_none() && !is_tool(b), + AreaId::App(id) => options.areas.iter().any(|a| a.id == *id && (a.rows)(b)), + AreaId::Fixed => false, + } +} + +/// The first id segment, capitalised: `layer.lock` → `Layer`. +fn id_group(id: &str) -> String { + let first = id.split('.').next().unwrap_or(id); + let mut chars = first.chars(); + chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default() +} + +/// A row's group header (`None`: no header) with its rank among groups, and how many path parts +/// the header stands for. +fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { + let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); + let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth)); + match area { + AreaId::All => menu(1).unwrap_or_else(|| { + if let Kind::Tool { .. } = b.kind { + (1, Some(text.ui("Tools").into_owned()), 0) + } else if !b.scope.is_app() { + (2, Some(panel_label(text, b.scope.as_str())), 0) + } else { + (3, Some(text.ui("Other").into_owned()), 0) + } + }), + AreaId::Menu(_) if submenus && path.len() >= 3 => (0, path.get(1).map(|s| text.segment(s).into_owned()), 2), + AreaId::Menu(_) => (0, None, 1), + AreaId::Tools => match &b.kind { + Kind::Tool { group: Some(g) } => (0, Some(text.segment(g).into_owned()), usize::MAX), + _ => (0, None, usize::MAX), + }, + AreaId::Other => (0, Some(id_group(&b.command.id)), usize::MAX), + AreaId::Panel(_) | AreaId::App(_) | AreaId::Fixed => menu(1).unwrap_or((0, None, usize::MAX)), + } +} + +/// What a query asks for: lowercase text, and the trigger it spells, if any. +struct Query { + text: String, + trigger: Option, +} + +impl Query { + fn new(q: &str, platform: Platform) -> Query { + let text = q.trim().to_lowercase(); + let trigger = Trigger::parse(q.trim()).ok().map(|t| t.effective(platform)); + Query { text, trigger } + } + + fn hit(&self, s: &str) -> bool { + s.to_lowercase().contains(&self.text) + } + + /// Label, aliases, path parts and id, as written and translated; then the bindings: the same + /// effective trigger when the query spells one, else their canonical or display text. A query + /// that spells a chord (`cmd+s`, `⌘S`) does not also match longer chords containing its text. + fn matches(&self, b: &Bindable, bindings: &[Binding], text: &dyn Text, platform: Platform) -> bool { + if self.text.is_empty() { + return true; + } + let named = self.hit(&b.label) + || self.hit(&text.command(b)) + || b.aliases.iter().any(|a| self.hit(a)) + || b.paths.iter().flatten().any(|s| self.hit(s) || self.hit(&text.segment(s))) + || self.hit(&b.command.id); + named + || match self.trigger { + Some(t) => bindings.iter().any(|x| x.trigger.effective(platform) == t), + None => bindings.iter().any(|x| { + self.hit(&x.trigger.to_string()) || self.hit(&x.trigger.display(platform)) || self.hit(&trigger_text(text, &x.trigger, platform)) + }), + } + } +} + +fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built { + let platform = keymap.platform(); + let catalogue = keymap.catalogue(); + let mut built = Built::default(); + let mut clashing: HashSet = HashSet::new(); + for c in keymap.conflicts() { + match c.kind { + ConflictKind::Clash => { + built.clashes += 1; + clashing.insert(c.a.0.key()); + clashing.insert(c.b.0.key()); + } + ConflictKind::Shadow => built.shadows += 1, + } + } + let query = Query::new(view.query, platform); + if *view.area == AreaId::Fixed { + build_fixed(keymap, options, text, view, &query, &mut built); + return built; + } + + let bindings: Vec> = catalogue.iter().map(|b| keymap.bindings(&b.command)).collect(); + // How many tools of a group hold each effective trigger. + let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new(); + for (b, list) in catalogue.iter().zip(&bindings) { + if let Kind::Tool { group } = &b.kind { + for x in list { + *tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1; + } + } + } + let submenus = match view.area { + AreaId::Menu(m) => catalogue.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + _ => false, + }; + + // Groups in first-seen order within their rank. + let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); + for (index, (b, list)) in catalogue.iter().zip(&bindings).enumerate() { + if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { + continue; + } + let key = b.command.key(); + let changed = keymap.overrides().get(&key).is_some(); + let conflict = clashing.contains(&key); + let keep = match view.filter { + Filter::All => true, + Filter::WithShortcut => !list.is_empty(), + Filter::Changed => changed, + Filter::Conflicts => conflict, + }; + if !keep { + continue; + } + let (rank, header, depth) = group(view.area, b, submenus, text); + let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); + let prefix = path.get(depth..path.len().saturating_sub(1)).unwrap_or_default().iter().map(|s| text.segment(s).into_owned()).collect(); + let defaults = &b.defaults; + let chips = list + .iter() + .map(|x| { + let mut t = trigger_text(text, &x.trigger, platform); + if x.scope_or(&b.scope) != &b.scope { + t = format!("{t} ({})", text.segment(x.scope_or(&b.scope).as_str())); + } + Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } + }) + .collect(); + let cycles = match &b.kind { + Kind::Tool { group } => list.iter().any(|x| tool_keys.get(&(group.as_deref(), x.trigger.effective(platform))).is_some_and(|n| *n > 1)), + Kind::Command => false, + }; + let row = Row { index, command: b.command.clone(), key, label: text.command(b).into_owned(), prefix, chips, changed, cycles, conflict }; + match groups.iter_mut().find(|(r, h, _)| *r == rank && *h == header) { + Some((_, _, rows)) => rows.push(row), + None => groups.push((rank, header, vec![row])), + } + } + groups.sort_by_key(|(rank, _, _)| *rank); + for (_, header, mut rows) in groups { + if options.sort_by_label { + rows.sort_by_cached_key(|r| r.label.to_lowercase()); + } + built.matches += rows.len(); + let collapsed = header.as_ref().is_some_and(|h| view.collapsed.contains(h)); + if let Some(name) = header { + built.lines.push(Line::Header { name, collapsed }); + } + if !collapsed { + built.lines.extend(rows.into_iter().map(Line::Row)); + } + } + built +} + +fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, query: &Query, built: &mut Built) { + if matches!(view.filter, Filter::Changed | Filter::Conflicts) { + return; + } + let platform = keymap.platform(); + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for f in &options.fixed { + if !query.text.is_empty() + && !(query.hit(&f.label) + || query.hit(&f.keys) + || query.hit(&f.group) + || query.trigger.is_some_and(|t| f.chord.is_some_and(|c| c.effective(platform) == t))) + { + continue; + } + let now_used_by = f.chord.and_then(|c| { + let c = c.effective(platform); + keymap + .catalogue() + .iter() + .find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c)) + .map(|b| text.command(b).into_owned()) + }); + let line = FixedLine { label: f.label.clone(), keys: f.keys.clone(), now_used_by }; + match groups.iter_mut().find(|(g, _)| *g == f.group) { + Some((_, lines)) => lines.push(line), + None => groups.push((&f.group, vec![line])), + } + } + for (name, lines) in groups { + built.matches += lines.len(); + let collapsed = view.collapsed.contains(name); + built.lines.push(Line::Header { name: name.to_string(), collapsed }); + if !collapsed { + built.lines.extend(lines.into_iter().map(Line::Fixed)); + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::borrow::Cow; + + use super::*; + use crate::keymap::Bindable; + use crate::scope::{Scope, Scopes}; + use crate::ui::text::English; + use crate::ui::{Area, FixedRow}; + + fn key(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|_| Binding::new(Trigger::single(crate::Chord::from(crate::Key::F35)))) + } + + /// The fixture of the dialog tests: Save, Save As, Export, Arrange › Bring to Front, two + /// tools sharing M, Timeline Loop, Bold in the Text context. + pub(crate) fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(key("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(key("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + } + + fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet) -> View<'a> { + View { query, area, filter, collapsed } + } + + fn labels(built: &Built) -> Vec { + built + .lines + .iter() + .map(|l| match l { + Line::Header { name, .. } => format!("# {name}"), + Line::Row(r) => { + let chips: Vec<&str> = r.chips.iter().map(|c| c.text.as_str()).collect(); + let cycles = if r.cycles { " (cycles)" } else { "" }; + format!("{}{}: {}{cycles}", r.prefix.iter().map(|p| format!("{p} › ")).collect::(), r.label, chips.join(", ")) + } + Line::Fixed(f) => format!("{} = {}{}", f.label, f.keys, f.now_used_by.as_ref().map(|l| format!(" (now {l})")).unwrap_or_default()), + }) + .collect() + } + + fn lines(km: &Keymap, options: &Options, text: &dyn Text, query: &str, area: AreaId, filter: Filter) -> Vec { + let none = BTreeSet::new(); + labels(&build(km, options, text, &view(query, &area, filter, &none))) + } + + #[test] + fn areas_follow_paths_kind_and_scope() { + let km = fixture(Platform::Mac); + let options = Options { areas: vec![Area { id: "type", label: "Type", rows: |b| b.command.id.starts_with("type.") }], ..Options::default() }; + let names: Vec = areas(&km, &options, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other", "Type"]); + + let all = lines(&km, &options, &English, "", AreaId::All, Filter::All); + assert_eq!( + all, + [ + "# File", + "Save: ⌘S", + "Save As…: ⇧⌘S", + "Export…: ⌘E", + "# Object", + "Arrange › Bring to Front: ", + "# Tools", + "Marquee Tool: M (cycles)", + "Elliptical Marquee Tool: M (cycles)", + "# Timeline Panel", + "Loop: L", + "# Other", + "Bold: ⌘B (Text)", + ] + ); + assert_eq!(lines(&km, &options, &English, "", AreaId::Menu("Object".into()), Filter::All), ["# Arrange", "Bring to Front: "]); + assert_eq!(lines(&km, &options, &English, "", AreaId::Panel("Timeline".into()), Filter::All), ["Loop: L"]); + assert_eq!(lines(&km, &options, &English, "", AreaId::Other, Filter::All), ["# Timeline", "Loop: L", "# Type", "Bold: ⌘B (Text)"]); + } + + #[test] + fn sort_by_label_and_collapsed_groups() { + let km = fixture(Platform::Mac); + let options = Options { sort_by_label: true, ..Options::default() }; + let area = AreaId::Menu("File".into()); + let none = BTreeSet::new(); + assert_eq!(labels(&build(&km, &options, &English, &view("", &area, Filter::All, &none))), ["Export…: ⌘E", "Save: ⌘S", "Save As…: ⇧⌘S"]); + let collapsed: BTreeSet = ["File".to_string()].into(); + let built = build(&km, &options, &English, &view("", &AreaId::All, Filter::All, &collapsed)); + assert_eq!(built.lines.first(), Some(&Line::Header { name: "File".into(), collapsed: true })); + assert!(matches!(built.lines.get(1), Some(Line::Header { .. }))); + } + + struct Spanish; + impl Text for Spanish { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + English.plural(n, one, other) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.export" { "Exportar…".into() } else { b.label.as_str().into() } + } + } + + #[test] + fn search_by_name_translation_or_chord() { + let km = fixture(Platform::Mac); + let o = Options::default(); + let find = |q: &str| -> Vec { + let none = BTreeSet::new(); + build(&km, &o, &Spanish, &view(q, &AreaId::All, Filter::All, &none)) + .lines + .into_iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r.key) } else { None }) + .collect() + }; + assert_eq!(find("save"), ["file.save", "file.saveAs"]); + assert_eq!(find("exportar"), ["file.export"]); + assert_eq!(find("cmd+s"), ["file.save"]); + assert_eq!(find("⌘S"), ["file.save"]); + assert_eq!(find("⇧⌘S"), ["file.saveAs"]); + assert_eq!(find("Text: Cmd+B"), Vec::::new(), "a binding text with a scope is not a trigger"); + assert_eq!(find("cmd+b"), ["type.bold"], "a trigger matches in any scope"); + assert_eq!(find("arrange"), ["object.front"]); + assert!(find("zzz").is_empty()); + } + + #[test] + fn filters_and_conflict_counts() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Windows); + let o = Options::default(); + km.assign(&"file.export".into(), key("Cmd+S"), crate::Policy::Keep)?; + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Changed), ["# File", "Export…: Ctrl+E, Ctrl+S"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: Ctrl+S", "Export…: Ctrl+E, Ctrl+S"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("Object".into()), Filter::WithShortcut), Vec::::new()); + let none = BTreeSet::new(); + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!((built.clashes, built.matches), (1, 8)); + Ok(()) + } + + #[test] + fn fixed_rows_strike_through_when_a_command_takes_their_chord() { + let km = fixture(Platform::Mac); + let fixed = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.and_then(|c| Trigger::parse(c).ok()), + }; + let o = Options { fixed: vec![fixed("Rate 1", "1", Some("1")), fixed("Loop", "L", Some("L"))], ..Options::default() }; + assert_eq!(lines(&km, &o, &English, "", AreaId::Fixed, Filter::All), ["# Rating", "Rate 1 = 1", "Loop = L (now Loop)"]); + } + + #[test] + fn cache_rebuilds_on_overrides_and_view_changes() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Mac); + let o = Options::default(); + let mut cache = RowCache::default(); + let none = BTreeSet::new(); + let area = AreaId::Menu("File".into()); + let before = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone(); + km.assign(&"file.save".into(), key("F2"), crate::Policy::Steal)?; + let after = cache.get(&km, &o, &English, &view("", &area, Filter::All, &none)).clone(); + assert_ne!(before, after); + assert_eq!(cache.get(&km, &o, &English, &view("save as", &area, Filter::All, &none)).matches, 1); + Ok(()) + } +} diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs new file mode 100644 index 00000000..ed3803b7 --- /dev/null +++ b/crates/keymap/src/ui/state.rs @@ -0,0 +1,1305 @@ +//! The dialog's persistent state and every action it takes, as calls on [`EditSession`], +//! [`Recorder`], [`editor::holders`] and [`Profiles`]. + +use std::collections::BTreeSet; + +use crate::chord::{Chord, Key, Trigger}; +use crate::command::CommandRef; +use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; +use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; +use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; +use crate::profiles::{Builtins, ProfileError, Profiles}; +use crate::scope::Scope; +use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; +use crate::ui::text::{Text, trigger_text}; +use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture}; + +/// What the dialog edits this frame. +pub(crate) struct Env<'e> { + pub keymap: &'e mut Keymap, + pub sets: Option<(&'e mut Profiles, &'e Builtins<'e>)>, + pub options: &'e Options, + pub text: &'e dyn Text, +} + +impl Env<'_> { + fn chord_text(&self, t: &Trigger) -> String { + trigger_text(self.text, t, self.keymap.platform()) + } + + fn label(&self, command: &CommandRef) -> String { + self.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.text.command(b).into_owned()) + } + + /// `label (Scope)` for each holder, joined. + fn holder_labels(&self, holders: &[Holder]) -> String { + let mut out: Vec = Vec::new(); + for h in holders { + let label = self.label(&h.command); + let s = match &h.binding.scope { + Some(scope) => format!("{label} ({})", self.text.segment(scope.as_str())), + None => label, + }; + if !out.contains(&s) { + out.push(s); + } + } + out.join(", ") + } + + fn ui(&self, source: &str, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&self.text.ui(source), args) + } +} + +/// Where a recording goes. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Target { + /// A row's shortcut cell: replaces `old`, or adds when `None`. Commits at once. + Cell { command: CommandRef, old: Option }, + /// The detail pane's New Shortcut field: the chord waits for Assign. + Field { command: CommandRef }, + /// Search by key: the chord's text becomes the query. + Search, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Recording { + pub target: Target, + /// With [`Options::double_press`]: a single press waiting [`DOUBLE_PRESS`] for its second press. + pub waiting: Option<(Trigger, f64)>, +} + +/// The New Shortcut field's chord, waiting for Assign. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FieldChord { + pub command: CommandRef, + pub binding: Binding, + /// Commands whose bindings would clash ("Currently assigned to"). + pub holders: Vec, + /// Commands with the same trigger in another scope that do not clash ("both work"). + pub shadows: Vec, +} + +/// The conflict bar, for one pending chord. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ConflictBar { + pub command: CommandRef, + pub old: Option, + pub binding: Binding, + pub holders: Vec, + /// "⚠ {chord} is used by {label}". + pub text: String, +} + +/// A line under the table after an action. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Message { + pub text: String, + /// Nothing changed: shown with ⚠. + pub refused: bool, + /// Offers Go to Conflict. + pub goto: Option, + /// Offers Undo. + pub undo: bool, +} + +/// What to do after the Save changes prompt. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Then { + Switch(String), + NewSet { name: String, based_on: Option }, + Import, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Prompt { + /// "Restore every default shortcut in this set?" + ResetAll, + /// "Delete the set “{set}”?" + DeleteSet(String), + /// "Save changes to “{set}”?" with Save, Don't Save and Cancel. + SaveChanges { set: String, then: Then }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Answer { + /// Save, or confirm. + Yes, + /// Don't Save. + No, + Cancel, +} + +/// A foreign set under review. +#[derive(Clone, Debug)] +pub(crate) struct Review { + pub set: ForeignSet, + pub options: ImportOptions, + pub plan: ImportPlan, + /// The new set's name. + pub name: String, + /// After Import as New Set: the set's name and the report. + pub done: Option<(String, ImportReport)>, +} + +#[derive(Clone, Debug)] +struct Opened { + profiles: Option, + overrides: Overrides, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + New, + Open, + Closed, +} + +/// The dialog's state between frames. Keep it while [`is_open`](Self::is_open) is true. +#[derive(Clone, Debug)] +pub struct DialogState { + phase: Phase, + opened: Option, + session: Option, + recorder: Recorder, + pub(crate) selected: Option, + /// Scroll the selected row into view on the next frame. + pub(crate) reveal: bool, + pub(crate) areas: Vec, + pub(crate) area: AreaId, + pub(crate) filter: Filter, + pub(crate) query: String, + pub(crate) collapsed: BTreeSet, + /// The detail pane's Context: `None` is the command's home scope. + pub(crate) context: Option, + pub(crate) recording: Option, + pub(crate) field: Option, + pub(crate) conflict: Option, + pub(crate) prompt: Option, + pub(crate) message: Option, + pub(crate) review: Option, + rows: RowCache, + changed: bool, + outcome: Option, +} + +impl Default for DialogState { + fn default() -> DialogState { + DialogState::new() + } +} + +impl DialogState { + /// A dialog about to open; it takes its snapshot on the first frame. + pub fn new() -> DialogState { + DialogState { + phase: Phase::New, + opened: None, + session: None, + recorder: Recorder::new(), + selected: None, + reveal: false, + areas: Vec::new(), + area: AreaId::All, + filter: Filter::All, + query: String::new(), + collapsed: BTreeSet::new(), + context: None, + recording: None, + field: None, + conflict: None, + prompt: None, + message: None, + review: None, + rows: RowCache::default(), + changed: false, + outcome: None, + } + } + + /// False after OK or Cancel: drop the state. + pub fn is_open(&self) -> bool { + self.phase != Phase::Closed + } + + /// While true the app also pauses native menu key equivalents. + pub fn is_recording(&self) -> bool { + self.recording.is_some() + } + + /// Selects a command and scrolls to it, as when the dialog opens from "Edit Shortcut…". + pub fn select(&mut self, command: &CommandRef) { + self.selected = Some(command.clone()); + self.reveal = true; + self.context = None; + self.field = None; + } + + // ---- Frame ---- + + /// The first frame: snapshot for Cancel, an edit session, the Show entries. + pub(crate) fn begin(&mut self, env: &mut Env) { + if self.phase != Phase::New { + return; + } + self.phase = Phase::Open; + self.opened = Some(Opened { profiles: env.sets.as_ref().map(|(p, _)| (**p).clone()), overrides: env.keymap.overrides().clone() }); + self.session = Some(EditSession::begin(env.keymap)); + self.areas = crate::ui::rows::areas(env.keymap, env.options, env.text); + } + + /// Takes this frame's key input while recording and commits a double-press wait that ended. + pub(crate) fn input(&mut self, env: &mut Env, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + self.tick(env, now); + if self.is_recording() { + let platform = env.keymap.platform(); + let chords = ctx.input_mut(|i| capture::take(i, platform)); + self.feed(env, &chords, now); + } + if let Some((_, at)) = self.recording.as_ref().and_then(|r| r.waiting) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((at + DOUBLE_PRESS - now).max(0.0))); + } + } + + /// The frame's outcome: a closing or file outcome first, else Changed after an edit. + pub(crate) fn finish(&mut self) -> Option { + let changed = std::mem::take(&mut self.changed); + self.outcome.take().or(changed.then_some(Outcome::Changed)) + } + + /// The table's lines for the current view. + pub(crate) fn lines(&mut self, env: &Env) -> &Built { + let view = View { query: &self.query, area: &self.area, filter: self.filter, collapsed: &self.collapsed }; + self.rows.get(env.keymap, env.options, env.text, &view) + } + + // ---- Selection and view ---- + + pub(crate) fn select_row(&mut self, command: &CommandRef) { + if self.selected.as_ref() != Some(command) { + self.selected = Some(command.clone()); + self.context = None; + self.field = None; + } + } + + pub(crate) fn set_area(&mut self, area: AreaId) { + self.area = area; + } + + pub(crate) fn set_filter(&mut self, filter: Filter) { + self.filter = filter; + } + + pub(crate) fn set_query(&mut self, query: String) { + self.query = query; + } + + pub(crate) fn toggle_group(&mut self, name: &str) { + if !self.collapsed.remove(name) { + self.collapsed.insert(name.to_string()); + } + } + + /// Selects a command and shows its row: area All, no search, no filter. + pub(crate) fn go_to(&mut self, command: &CommandRef) { + self.select_row(command); + self.area = AreaId::All; + self.query.clear(); + self.filter = Filter::All; + self.collapsed.clear(); + self.reveal = true; + } + + /// The Context entries for a command: its home scope (`None`, shown as Default), Application, + /// every panel and every context. + pub(crate) fn contexts(env: &Env, command: &CommandRef) -> Vec> { + let home = env.keymap.bindable(command).map(|b| b.scope.clone()); + let scopes = env.keymap.scopes(); + let mut out = vec![None]; + let names = std::iter::once(Scope::app()).chain(scopes.panels().iter().chain(scopes.contexts()).filter_map(|s| Scope::new(s.as_str()))); + for s in names { + if Some(&s) != home.as_ref() && !out.contains(&Some(s.clone())) { + out.push(Some(s)); + } + } + out + } + + pub(crate) fn set_context(&mut self, env: &Env, scope: Option) { + self.context = scope; + if let Some(f) = self.field.take() { + self.set_field(env, f.command, f.binding.trigger); + } + } + + // ---- Recording ---- + + /// Starts recording; drops the conflict bar and any message. + pub(crate) fn record(&mut self, env: &Env, target: Target) { + if !env.options.editable && target != Target::Search { + return; + } + self.conflict = None; + self.message = None; + self.recorder.reset(); + if let Target::Cell { command, .. } | Target::Field { command } = &target { + self.select_row(command); + } + self.recording = Some(Recording { target, waiting: None }); + } + + pub(crate) fn stop_recording(&mut self) { + self.recording = None; + self.recorder.reset(); + } + + /// Commits a single press whose double-press window has ended. + pub(crate) fn tick(&mut self, env: &mut Env, now: f64) { + let expired = self.recording.as_ref().and_then(|r| r.waiting).filter(|(_, at)| now < *at || now - *at >= DOUBLE_PRESS); + if let Some((trigger, _)) = expired { + self.commit_recorded(env, trigger); + } + } + + /// Feeds captured presses to the recorder. + pub(crate) fn feed(&mut self, env: &mut Env, chords: &[Chord], now: f64) { + for &chord in chords { + self.tick(env, now); + let Some(rec) = &mut self.recording else { return }; + if env.options.backspace_clears && chord.mods.is_none() && matches!(chord.key, Key::Backspace | Key::Delete) { + let target = rec.target.clone(); + self.stop_recording(); + match target { + Target::Cell { command, old: Some(old) } => self.remove(env, &command, &old), + Target::Field { .. } => self.field = None, + _ => {} + } + continue; + } + match self.recorder.press(chord, now) { + Recorded::Cancel => self.stop_recording(), + Recorded::Single(t) if env.options.double_press => rec.waiting = Some((t, now)), + Recorded::Single(t) | Recorded::Double(t) => self.commit_recorded(env, t), + } + } + } + + /// The recorded trigger is final: ends the recording and sends it where it belongs. + fn commit_recorded(&mut self, env: &mut Env, trigger: Trigger) { + let Some(rec) = self.recording.take() else { return }; + self.recorder.reset(); + match rec.target { + Target::Search => self.query = env.chord_text(&trigger), + Target::Cell { command, old } => { + let binding = match old.as_ref().and_then(|o| o.scope.clone()) { + Some(scope) => Binding::in_scope(trigger, scope), + None => Binding::new(trigger), + }; + self.commit(env, &command, old, binding); + } + Target::Field { command } => self.set_field(env, command, trigger), + } + } + + /// Puts a chord into the New Shortcut field with the chosen Context, and finds who holds it. + pub(crate) fn set_field(&mut self, env: &Env, command: CommandRef, trigger: Trigger) { + let binding = match &self.context { + Some(scope) => Binding::in_scope(trigger, scope.clone()), + None => Binding::new(trigger), + }; + let holders = editor::holders(env.keymap, &command, &binding); + let platform = env.keymap.platform(); + let want = trigger.effective(platform); + let mut shadows = Vec::new(); + for b in env.keymap.catalogue() { + if b.command == command { + continue; + } + for x in env.keymap.bindings(&b.command) { + if x.trigger.effective(platform) == want && !holders.iter().any(|h| h.command == b.command && h.binding == x) { + shadows.push(Holder { command: b.command.clone(), binding: x }); + } + } + } + self.field = Some(FieldChord { command, binding, holders, shadows }); + } + + /// Assign in the detail pane: commits the field's chord. + pub(crate) fn assign_field(&mut self, env: &mut Env) { + if let Some(f) = self.field.take() { + self.commit(env, &f.command, None, f.binding); + } + } + + /// Typed entry: the text as [`Binding::parse`] reads it, committed like a recorded chord. + /// `Err` is the parse error for under the field. + pub(crate) fn type_binding(&mut self, env: &mut Env, command: &CommandRef, text: &str) -> Result<(), String> { + let binding = Binding::parse(text).map_err(|e| e.to_string())?; + self.select_row(command); + if let Some(scope) = binding.scope.clone() { + self.context = Some(scope); + } + self.set_field(env, command.clone(), binding.trigger); + Ok(()) + } + + // ---- Commit and conflicts ---- + + /// Reserved chord, then the app's rule, then holders: assign at once, or by + /// [`Options::conflicts`]. + pub(crate) fn commit(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding) { + self.conflict = None; + self.message = None; + let chord = env.chord_text(&binding.trigger); + if env.keymap.is_reserved(&binding.trigger.chord) { + self.refuse(env.ui("{chord} is reserved by the system", &[("chord", &chord)])); + return; + } + if let (Some(accept), Some(b)) = (&env.options.accept, env.keymap.bindable(command)) + && let Err(text) = accept(b, &binding) + { + self.refuse(text); + return; + } + let holders = editor::holders(env.keymap, command, &binding); + if holders.is_empty() { + self.apply(env, command, old, binding, Policy::Steal); + return; + } + match env.options.conflicts { + ConflictMode::Ask => { + let text = env.ui("⚠ {chord} is used by {label}", &[("chord", &chord), ("label", &env.holder_labels(&holders))]); + self.conflict = Some(ConflictBar { command: command.clone(), old, binding, holders, text }); + } + ConflictMode::Steal => self.apply_and_say(env, command, old, binding, Policy::Steal), + ConflictMode::Keep => self.apply_and_say(env, command, old, binding, Policy::Keep), + } + } + + fn refuse(&mut self, text: String) { + self.message = Some(Message { text, refused: true, goto: None, undo: false }); + } + + fn apply(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) -> Option { + let km = &mut *env.keymap; + let session = self.session.get_or_insert_with(|| EditSession::begin(km)); + let result = match &old { + Some(o) => session.replace(km, command, o, binding, policy), + None => session.assign(km, command, binding, policy), + }; + match result { + Ok(a) => { + self.edited(env); + Some(a) + } + Err(e) => { + let text = assign_error(env, &e); + self.refuse(text); + None + } + } + } + + /// Applies with Steal or Keep and says what it did, with Go to Conflict (and Undo for Steal). + fn apply_and_say(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) { + let chord = env.chord_text(&binding.trigger); + let Some(a) = self.apply(env, command, old, binding, policy) else { return }; + let (holders, text) = + if policy == Policy::Steal { (&a.taken_from, "{chord} was removed from {label}") } else { (&a.clashes, "Also used by {label}") }; + if holders.is_empty() { + return; + } + let text = env.ui(text, &[("chord", &chord), ("label", &env.holder_labels(holders))]); + let goto = holders.first().map(|h| h.command.clone()); + self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal }); + } + + /// Take It. + pub(crate) fn take_it(&mut self, env: &mut Env) { + if let Some(c) = self.conflict.take() { + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Steal); + } + } + + /// Take It and Go to Conflict. + pub(crate) fn take_it_and_go(&mut self, env: &mut Env) { + if let Some(c) = self.conflict.take() { + let first = c.holders.first().map(|h| h.command.clone()); + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Steal); + if let Some(holder) = first { + self.go_to(&holder); + } + } + } + + /// Keep Both. + pub(crate) fn keep_both(&mut self, env: &mut Env) { + if !env.options.allow_keep { + return; + } + if let Some(c) = self.conflict.take() { + self.apply_and_say(env, &c.command, c.old, c.binding, Policy::Keep); + } + } + + /// ✕ on the conflict bar: nothing was applied, so nothing changes. + pub(crate) fn dismiss_conflict(&mut self) { + self.conflict = None; + } + + // ---- Edits ---- + + /// After any edit: drop the conflict bar, refresh the field's holders, report Changed. + fn edited(&mut self, env: &Env) { + self.changed = true; + self.conflict = None; + if let Some(f) = self.field.take() { + self.set_field(env, f.command, f.binding.trigger); + } + } + + /// Runs one session edit; true when it changed something. + fn edit(&mut self, env: &mut Env, f: impl FnOnce(&mut EditSession, &mut Keymap) -> bool) -> bool { + self.message = None; + let km = &mut *env.keymap; + let done = f(self.session.get_or_insert_with(|| EditSession::begin(km)), km); + if done { + self.edited(env); + } + done + } + + /// × on a chip, or Remove. + pub(crate) fn remove(&mut self, env: &mut Env, command: &CommandRef, binding: &Binding) { + self.edit(env, |s, km| s.remove(km, command, binding)); + } + + /// Clear: no shortcut. + pub(crate) fn clear(&mut self, env: &mut Env, command: &CommandRef) { + self.edit(env, |s, km| s.clear(km, command)); + } + + /// Use Default, ↺. + pub(crate) fn use_default(&mut self, env: &mut Env, command: &CommandRef) { + self.edit(env, |s, km| s.reset(km, command)); + } + + /// Whether Use Default does anything. + pub(crate) fn can_use_default(env: &Env, command: &CommandRef) -> bool { + env.keymap.overrides().get(&command.key()).is_some() + } + + /// Reset All…: asks first. + pub(crate) fn ask_reset_all(&mut self) { + self.prompt = Some(Prompt::ResetAll); + } + + fn reset_all(&mut self, env: &mut Env) { + self.edit(env, |s, km| { + let before = km.overrides().clone(); + s.reset_all(km); + *km.overrides() != before + }); + } + + pub(crate) fn undo(&mut self, env: &mut Env) { + self.edit(env, |s, km| s.undo(km)); + } + + pub(crate) fn redo(&mut self, env: &mut Env) { + self.edit(env, |s, km| s.redo(km)); + } + + pub(crate) fn can_undo(&self) -> bool { + self.session.as_ref().is_some_and(EditSession::can_undo) + } + + pub(crate) fn can_redo(&self) -> bool { + self.session.as_ref().is_some_and(EditSession::can_redo) + } + + // ---- OK, Cancel, Escape, Enter ---- + + /// OK: with sets, stores the overrides (a built-in's edits become a copy), then closes. + pub(crate) fn ok(&mut self, env: &mut Env) { + if let Err(e) = self.store(env) { + let text = profile_error(env, &e); + self.refuse(text); + return; + } + self.close(Outcome::Ok); + } + + /// Cancel, window ×, or Escape when idle: everything back as it was when the dialog opened. + pub(crate) fn cancel(&mut self, env: &mut Env) { + if let Some(opened) = self.opened.take() { + env.keymap.set_overrides(opened.overrides); + if let (Some((p, _)), Some(profiles)) = (env.sets.as_mut(), opened.profiles) { + **p = profiles; + } + } + self.close(Outcome::Cancel); + } + + fn close(&mut self, outcome: Outcome) { + self.phase = Phase::Closed; + self.recording = None; + self.conflict = None; + self.prompt = None; + self.outcome = Some(outcome); + } + + /// Escape when nothing records: closes the innermost open thing (conflict bar, prompt, import + /// review, the field's chord), else cancels. Rename fields and dropdowns are the view's. + pub(crate) fn escape(&mut self, env: &mut Env) { + if self.recording.is_some() { + self.stop_recording(); + } else if self.conflict.is_some() { + self.conflict = None; + } else if self.prompt.is_some() { + self.prompt = None; + } else if self.review.is_some() { + self.review = None; + } else if self.field.is_some() { + self.field = None; + } else { + self.cancel(env); + } + } + + /// Enter when idle and no text field has focus: OK with [`Options::enter_is_ok`]. + pub(crate) fn enter(&mut self, env: &mut Env) { + if env.options.enter_is_ok && self.recording.is_none() && self.prompt.is_none() { + self.ok(env); + } + } + + // ---- Sets ---- + + /// Whether the keymap's overrides differ from the stored active set. + pub(crate) fn is_modified(env: &Env) -> bool { + env.sets.as_ref().is_some_and(|(p, b)| p.active_overrides(b) != env.keymap.overrides()) + } + + fn custom_name(env: &Env) -> String { + env.text.ui(env.options.custom_set_name).into_owned() + } + + /// Stores the overrides in the active set; a built-in's edits become a new set. + fn store(&mut self, env: &mut Env) -> Result<(), ProfileError> { + let custom = Self::custom_name(env); + if let Some((p, b)) = env.sets.as_mut() + && p.active_overrides(b) != env.keymap.overrides() + { + p.store(env.keymap.overrides().clone(), b, &custom)?; + self.changed = true; + } + Ok(()) + } + + /// Makes `name` active and loads it, with a fresh undo history. + fn load_set(&mut self, env: &mut Env, name: &str) -> Result<(), ProfileError> { + let Some((p, b)) = env.sets.as_mut() else { return Err(ProfileError::NotFound) }; + p.select(name, b)?; + env.keymap.set_overrides(p.active_overrides(b).clone()); + self.session = Some(EditSession::begin(env.keymap)); + self.recording = None; + self.conflict = None; + self.field = None; + self.changed = true; + Ok(()) + } + + fn report(&mut self, env: &Env, result: Result<(), ProfileError>) { + match result { + Ok(()) => self.message = None, + Err(e) => self.refuse(profile_error(env, &e)), + } + } + + /// The set picker. With unsaved edits it asks to save them first. + pub(crate) fn switch_set(&mut self, env: &mut Env, name: &str) { + self.after_save_prompt(env, Then::Switch(name.to_string())); + } + + /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. + pub(crate) fn new_set(&mut self, env: &mut Env, name: &str, based_on: Option<&str>) { + let then = Then::NewSet { name: name.to_string(), based_on: based_on.map(str::to_string) }; + if based_on.is_none() { + self.run(env, then); + } else { + self.after_save_prompt(env, then); + } + } + + /// Import…: asks to save unsaved edits first, then asks the app for a file. + pub(crate) fn request_import(&mut self, env: &mut Env) { + self.after_save_prompt(env, Then::Import); + } + + fn after_save_prompt(&mut self, env: &mut Env, then: Then) { + if Self::is_modified(env) + && let Some((p, b)) = env.sets.as_ref() + { + self.prompt = Some(Prompt::SaveChanges { set: p.active_name(b).to_string(), then }); + return; + } + self.run(env, then); + } + + fn run(&mut self, env: &mut Env, then: Then) { + let result = match then { + Then::Switch(name) => self.load_set(env, &name), + Then::NewSet { name, based_on } => { + let made = match (env.sets.as_mut(), based_on) { + (None, _) => Err(ProfileError::NotFound), + (Some((p, b)), None) => p.save_as(&name, env.keymap.overrides().clone(), b), + (Some((p, b)), Some(from)) => p.duplicate(&from, &name, b), + }; + made.and_then(|n| self.load_set(env, &n)) + } + Then::Import => { + let formats = std::iter::once(JSON_FORMAT).chain(env.options.import_formats.iter().map(|f| f.id)).collect(); + self.outcome = Some(Outcome::Import { formats }); + Ok(()) + } + }; + self.report(env, result); + } + + /// Rename… (not for built-ins). + pub(crate) fn rename_set(&mut self, env: &mut Env, to: &str) { + let result = match env.sets.as_mut() { + Some((p, b)) => { + let from = p.active_name(b).to_string(); + p.rename(&from, to, b).map(|_| ()) + } + None => Err(ProfileError::NotFound), + }; + if result.is_ok() { + self.changed = true; + } + self.report(env, result); + } + + /// Delete: asks first. + pub(crate) fn ask_delete_set(&mut self, env: &Env) { + if let Some((p, b)) = env.sets.as_ref() { + let name = p.active_name(b); + if !Profiles::is_builtin(name, b) { + self.prompt = Some(Prompt::DeleteSet(name.to_string())); + } + } + } + + fn delete_set(&mut self, env: &mut Env, name: &str) { + let result = match env.sets.as_mut() { + Some((p, b)) => p.delete(name, b).map(|()| p.active_name(b).to_string()), + None => Err(ProfileError::NotFound), + }; + let result = result.and_then(|active| self.load_set(env, &active)); + self.report(env, result); + } + + /// Save: stores the edits; a built-in's become a copy. The undo history stays. + pub(crate) fn save_set(&mut self, env: &mut Env) { + let result = self.store(env); + self.report(env, result); + } + + /// Revert Set: back to the stored set, as one undo step. + pub(crate) fn revert_set(&mut self, env: &mut Env) { + let Some(stored) = env.sets.as_ref().map(|(p, b)| p.active_overrides(b).clone()) else { return }; + self.edit(env, |s, km| { + let before = km.overrides().clone(); + s.edit(km, |k| k.set_overrides(stored)); + *km.overrides() != before + }); + } + + /// Export ▾ → Keyboard Shortcuts (JSON): the set as shown, edits included. + pub(crate) fn export_set(&mut self, env: &mut Env) { + let custom = Self::custom_name(env); + let result = match env.sets.as_ref() { + Some((p, b)) => { + let mut tmp = (**p).clone(); + tmp.store(env.keymap.overrides().clone(), b, &custom).and_then(|_| { + let name = tmp.active_name(b).to_string(); + tmp.export(&name, b).map(|json| (name, json)) + }) + } + None => { + let mut tmp = Profiles::default(); + tmp.save_as(&custom, env.keymap.overrides().clone(), &[]).and_then(|name| tmp.export(&name, &[]).map(|json| (name, json))) + } + }; + match result { + Ok((name, json)) => self.outcome = Some(Outcome::Export { name, json }), + Err(e) => self.refuse(profile_error(env, &e)), + } + } + + /// Export ▾ → an app format. + pub(crate) fn export_as(&mut self, env: &Env, format: &'static str) { + let name = match env.sets.as_ref() { + Some((p, b)) => p.active_name(b).to_string(), + None => Self::custom_name(env), + }; + self.outcome = Some(Outcome::ExportAs { format, name, keymap: Box::new(env.keymap.clone()) }); + } + + /// A craft-keymap JSON set the app read: a new set, loaded; without sets its overrides replace + /// the keymap's as one undo step. + pub(crate) fn import_json(&mut self, env: &mut Env, text: &str) -> Result { + let result = match env.sets.as_mut() { + Some((p, b)) => p.import(text, b).and_then(|name| self.load_set(env, &name).map(|()| name)), + None => { + let mut tmp = Profiles::default(); + let name = tmp.import(text, &[]); + if name.is_ok() { + let overrides = tmp.active_overrides(&[]).clone(); + self.edit(env, |s, km| { + s.edit(km, |k| k.set_overrides(overrides)); + true + }); + } + name + } + }; + if let Err(e) = &result { + self.refuse(profile_error(env, e)); + } + result + } + + /// Answers the open prompt. + pub(crate) fn answer(&mut self, env: &mut Env, answer: Answer) { + let Some(prompt) = self.prompt.take() else { return }; + match (prompt, answer) { + (_, Answer::Cancel) => {} + (Prompt::ResetAll, Answer::Yes) => self.reset_all(env), + (Prompt::DeleteSet(name), Answer::Yes) => self.delete_set(env, &name), + (Prompt::SaveChanges { then, .. }, Answer::Yes) => match self.store(env) { + Ok(()) => self.run(env, then), + Err(e) => self.refuse(profile_error(env, &e)), + }, + (Prompt::SaveChanges { then, .. }, Answer::No) => self.run(env, then), + (Prompt::ResetAll | Prompt::DeleteSet(_), Answer::No) => {} + } + } + + // ---- Import review ---- + + pub(crate) fn review_import(&mut self, env: &mut Env, set: ForeignSet, options: ImportOptions) { + let plan = import::plan(&set, env.keymap, &options); + let name = plan.report.set.clone().unwrap_or_else(|| env.text.ui("Imported").into_owned()); + self.stop_recording(); + self.conflict = None; + self.review = Some(Review { set, options, plan, name, done: None }); + } + + /// Picks candidate `candidate` for suggestion `suggestion`, re-plans, and reports the answers + /// for the app to keep. + pub(crate) fn pick_suggestion(&mut self, env: &Env, suggestion: usize, candidate: usize) { + let Some(r) = &mut self.review else { return }; + let Some(s) = r.plan.report.suggestions.get(suggestion) else { return }; + let Some(command) = s.candidates.get(candidate).cloned() else { return }; + let Some(entry) = r.set.entries.iter().find(|e| e.item() == s.item && e.line == s.line) else { return }; + r.options.remembered.insert(entry.remember_key(), command); + r.plan = import::plan(&r.set, env.keymap, &r.options); + self.outcome = Some(Outcome::Remembered(r.options.remembered.clone())); + } + + /// Import as New Set (with sets), else applies the plan to the keymap as one undo step. + pub(crate) fn import_reviewed(&mut self, env: &mut Env) { + let Some(mut r) = self.review.take() else { return }; + let result = match env.sets.as_mut() { + Some((p, b)) => p.import_plan(&r.plan, env.keymap, Some(&r.name), b), + None => { + let plan = &r.plan; + let mut report = None; + self.edit(env, |s, km| { + report = Some(s.edit(km, |k| plan.apply(k))); + true + }); + Ok((r.name.clone(), report.unwrap_or_default())) + } + }; + match result { + Ok((name, report)) => { + if env.sets.is_some() + && let Err(e) = self.load_set(env, &name) + { + self.refuse(profile_error(env, &e)); + } + r.done = Some((name, report)); + } + Err(e) => self.refuse(profile_error(env, &e)), + } + self.review = Some(r); + } + + pub(crate) fn close_review(&mut self) { + self.review = None; + } +} + +fn assign_error(env: &Env, e: &AssignError) -> String { + match e { + AssignError::Reserved(t) => env.ui("{chord} is reserved by the system", &[("chord", &env.chord_text(t))]), + AssignError::TooMany => env.ui("A command can have at most {n} shortcuts", &[("n", &MAX_BINDINGS.to_string())]), + AssignError::UnknownScope(s) => env.ui("The scope {scope} is unknown", &[("scope", s.as_str())]), + AssignError::Full => env.ui("Too many shortcuts are assigned", &[]), + AssignError::UnknownCommand => env.ui("This command is unknown", &[]), + AssignError::Taken(holders) => env.ui("Also used by {label}", &[("label", &env.holder_labels(holders))]), + } +} + +fn profile_error(env: &Env, e: &ProfileError) -> String { + match e { + ProfileError::BadName => env.ui("The name is empty, too long or has control characters", &[]), + ProfileError::NameTaken => env.ui("A set with this name exists", &[]), + ProfileError::NotFound => env.ui("The set was not found", &[]), + ProfileError::BuiltIn => env.ui("Built-in sets cannot be changed", &[]), + ProfileError::TooMany => env.ui("There are too many sets", &[]), + ProfileError::BadFile => env.ui("This is not a shortcut set file", &[]), + ProfileError::WrongFormat => env.ui("This is not a craft-keymap file", &[]), + ProfileError::NewerVersion(v) => env.ui("This file is from a newer version (format {n})", &[("n", &v.to_string())]), + ProfileError::TooLarge => env.ui("The file is too large", &[]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Platform; + use crate::keymap::ConflictKind; + use crate::ui::rows::Line; + use crate::ui::rows::tests::fixture; + use crate::ui::text::English; + + struct Fx { + km: Keymap, + p: Profiles, + b: Vec<(&'static str, Overrides)>, + o: Options, + sets: bool, + } + + impl Fx { + fn new(o: Options) -> Fx { + Fx { km: fixture(Platform::Mac), p: Profiles::default(), b: vec![("Fixture Defaults", Overrides::new())], o, sets: true } + } + + fn env(&mut self) -> Env<'_> { + let sets = if self.sets { Some((&mut self.p, &self.b[..])) } else { None }; + Env { keymap: &mut self.km, sets, options: &self.o, text: &English } + } + + fn keys(&self, id: &str) -> Vec { + self.km.bindings(&id.into()).iter().map(ToString::to_string).collect() + } + } + + fn c(s: &str) -> Chord { + Chord::parse(s).unwrap_or(Chord::from(Key::F35)) + } + + fn b(s: &str) -> Binding { + Binding::parse(s).unwrap_or(Binding::new(Chord::from(Key::F35))) + } + + fn open(fx: &mut Fx) -> DialogState { + let mut st = DialogState::new(); + st.begin(&mut fx.env()); + st + } + + /// Records `chords` into `target` at time 0. + fn record(st: &mut DialogState, fx: &mut Fx, target: Target, chords: &[&str]) { + let mut env = fx.env(); + st.record(&env, target); + let chords: Vec = chords.iter().map(|s| c(s)).collect(); + st.feed(&mut env, &chords, 0.0); + } + + fn add(command: &str) -> Target { + Target::Cell { command: command.into(), old: None } + } + + #[test] + fn recording_takes_the_frames_keys_before_the_app() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.record(&fx.env(), Target::Cell { command: "file.save".into(), old: Some(b("Cmd+S")) }); + let ctx = egui::Context::default(); + let press = egui::Event::Key { key: egui::Key::E, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::MAC_CMD }; + let input = egui::RawInput { events: vec![press, egui::Event::Text("e".into())], ..egui::RawInput::default() }; + let mut left = usize::MAX; + let mut full = ctx.run_ui(input, |ui| { + st.input(&mut fx.env(), ui.ctx()); + left = ui.ctx().input(|i| i.events.len()); + }); + full.textures_delta.clear(); + assert_eq!(left, 0); + assert!(!st.is_recording()); + assert_eq!(st.conflict.as_ref().map(|c| c.text.as_str()), Some("⚠ ⌘E is used by Export…")); + assert_eq!(fx.keys("file.save"), ["Cmd+S"], "nothing changes before the answer"); + } + + #[test] + fn recording_takes_tab_and_shift_escape_and_escape_cancels_only_the_recording() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["Tab"]); + record(&mut st, &mut fx, add("object.front"), &["Shift+Escape"]); + assert_eq!(fx.keys("object.front"), ["Tab", "Shift+Escape"]); + + record(&mut st, &mut fx, add("object.front"), &["Escape"]); + assert!(!st.is_recording() && st.is_open()); + assert_eq!(fx.keys("object.front").len(), 2); + assert!(matches!(st.finish(), Some(Outcome::Changed))); + st.escape(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Cancel))); + assert!(!st.is_open()); + assert!(fx.keys("object.front").is_empty(), "Cancel restores the opening keymap"); + } + + #[test] + fn double_press_records_k_k_within_the_window() { + let mut fx = Fx::new(Options { double_press: true, ..Options::default() }); + let mut st = open(&mut fx); + let mut env = fx.env(); + st.record(&env, add("object.front")); + st.feed(&mut env, &[c("K")], 0.0); + assert!(st.is_recording(), "a single waits for a second press"); + st.feed(&mut env, &[c("K")], 0.3); + assert!(!st.is_recording()); + assert_eq!(fx.keys("object.front"), ["K K"]); + + let mut env = fx.env(); + st.record(&env, add("type.bold")); + st.feed(&mut env, &[c("K")], 1.0); + st.feed(&mut env, &[c("K")], 1.9); + assert_eq!(fx.keys("type.bold"), ["Text: Cmd+B", "K"]); + } + + #[test] + fn conflict_take_it_steals_and_undo_restores() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.conflict.as_ref().map(|c| c.text.as_str()), Some("⚠ ⌘S is used by Save")); + st.take_it(&mut fx.env()); + assert_eq!((fx.keys("file.save"), fx.keys("file.export")), (vec![], vec!["Cmd+E".to_string(), "Cmd+S".to_string()])); + assert!(st.message.as_ref().is_some_and(|m| m.undo && m.goto == Some("file.save".into()))); + st.undo(&mut fx.env()); + assert_eq!((fx.keys("file.save"), fx.keys("file.export")), (vec!["Cmd+S".to_string()], vec!["Cmd+E".to_string()])); + st.redo(&mut fx.env()); + assert!(fx.keys("file.save").is_empty()); + } + + #[test] + fn conflict_keep_both_lists_the_clash_and_cancel_changes_nothing() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + st.dismiss_conflict(); + assert_eq!(fx.keys("file.export"), ["Cmd+E"]); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + st.keep_both(&mut fx.env()); + let clashes = fx.km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + st.set_filter(Filter::Conflicts); + let env = fx.env(); + let rows: Vec = st.lines(&env).lines.iter().filter_map(|l| if let Line::Row(r) = l { Some(r.key.clone()) } else { None }).collect(); + assert_eq!(rows, ["file.save", "file.export"]); + } + + #[test] + fn go_to_conflict_selects_and_reveals_the_holder() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.set_area(AreaId::Tools); + st.set_query("marquee".into()); + record(&mut st, &mut fx, add("tool.marquee"), &["Cmd+S"]); + st.take_it_and_go(&mut fx.env()); + assert_eq!((&st.area, st.query.as_str(), st.selected.clone(), st.reveal), (&AreaId::All, "", Some("file.save".into()), true)); + } + + #[test] + fn steal_and_keep_modes_apply_and_say_so() { + let mut fx = Fx::new(Options { conflicts: ConflictMode::Steal, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.message.as_ref().map(|m| m.text.as_str()), Some("⌘S was removed from Save")); + let mut fx = Fx::new(Options { conflicts: ConflictMode::Keep, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("file.export"), &["Cmd+S"]); + assert_eq!(st.message.as_ref().map(|m| (m.text.as_str(), m.undo)), Some(("Also used by Save", false))); + assert_eq!(fx.keys("file.save"), ["Cmd+S"]); + } + + #[test] + fn reserved_chord_and_app_rule_refuse() { + let mut fx = Fx::new(Options { + accept: Some(Box::new(|bindable, binding| match bindable.kind { + crate::Kind::Tool { .. } if !binding.trigger.chord.mods.is_none() => Err("Tools take single keys".into()), + _ => Ok(()), + })), + ..Options::default() + }); + fx.km = fx.km.clone().with_reserved(vec![c("Cmd+Tab")]); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["Cmd+Tab"]); + assert_eq!(st.message.as_ref().map(|m| (m.text.as_str(), m.refused)), Some(("⌘⇥ is reserved by the system", true))); + record(&mut st, &mut fx, add("tool.marquee"), &["Cmd+J"]); + assert_eq!(st.message.as_ref().map(|m| m.text.as_str()), Some("Tools take single keys")); + assert!(fx.km.overrides().is_empty()); + assert!(st.finish().is_none()); + } + + #[test] + fn backspace_clears_the_binding_being_replaced() { + let mut fx = Fx::new(Options { backspace_clears: true, ..Options::default() }); + let mut st = open(&mut fx); + record(&mut st, &mut fx, Target::Cell { command: "file.save".into(), old: Some(b("Cmd+S")) }, &["Backspace"]); + assert!(fx.keys("file.save").is_empty()); + } + + #[test] + fn field_waits_for_assign_and_lists_holders() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, Target::Field { command: "file.save".into() }, &["Cmd+B"]); + let f = st.field.clone(); + assert_eq!(f.as_ref().map(|f| (f.holders.len(), f.shadows.len())), Some((0, 1)), "Bold's Text: Cmd+B only shadows"); + st.set_context(&fx.env(), Scope::new("Text")); + assert_eq!(st.field.as_ref().map(|f| f.holders.len()), Some(1), "in Text it clashes"); + st.assign_field(&mut fx.env()); + assert!(st.conflict.is_some()); + assert!(fx.km.overrides().is_empty()); + + st.set_context(&fx.env(), None); + assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "F7 F7"), Ok(())); + st.assign_field(&mut fx.env()); + assert_eq!(fx.keys("file.save"), ["Cmd+S", "F7 F7"]); + assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); + } + + #[test] + fn cancel_restores_keymap_and_sets() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let (km0, p0) = (fx.km.overrides().clone(), fx.p.clone()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.new_set(&mut fx.env(), "Other", None); + assert_eq!(fx.p.active, "Other"); + st.ask_delete_set(&fx.env()); + assert_eq!(st.prompt, Some(Prompt::DeleteSet("Other".into()))); + st.answer(&mut fx.env(), Answer::Yes); + assert!(!fx.p.sets.contains_key("Other")); + st.cancel(&mut fx.env()); + assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); + } + + #[test] + fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + assert!(DialogState::is_modified(&fx.env())); + st.ok(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Ok))); + assert_eq!(fx.p.active, "Custom"); + assert_eq!(fx.p.get("Custom", &fx.b).and_then(|o| o.get("object.front")), Some(&["F2".to_string()][..])); + } + + #[test] + fn switching_a_modified_set_asks_to_save() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.switch_set(&mut fx.env(), "Fixture Defaults"); + assert!(matches!(&st.prompt, Some(Prompt::SaveChanges { set, .. }) if set == "Mine")); + st.answer(&mut fx.env(), Answer::No); + assert_eq!(fx.p.active, "Fixture Defaults"); + assert!(fx.km.overrides().is_empty() && !st.can_undo(), "the set loads with a fresh undo history"); + assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); + } + + #[test] + fn set_export_import_round_trip() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.new_set(&mut fx.env(), "Name", None); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.export_set(&mut fx.env()); + let (name, json) = match st.finish() { + Some(Outcome::Export { name, json }) => (name, json), + _ => (String::new(), String::new()), + }; + assert_eq!(name, "Name"); + let edited = fx.km.overrides().clone(); + + let mut fresh = Fx::new(Options::default()); + fresh.p = fx.p.clone(); + let mut st = open(&mut fresh); + assert_eq!(st.import_json(&mut fresh.env(), &json), Ok("Name 2".into())); + assert_eq!(fresh.km.overrides(), &edited); + assert!(st.import_json(&mut fresh.env(), "{}").is_err()); + assert!(st.message.as_ref().is_some_and(|m| m.refused)); + + let mut no_sets = Fx::new(Options::default()); + no_sets.sets = false; + let mut st = open(&mut no_sets); + assert!(st.import_json(&mut no_sets.env(), &json).is_ok()); + assert_eq!(no_sets.km.overrides(), &edited); + assert!(st.can_undo()); + } + + #[test] + fn import_request_and_reset_all() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.request_import(&mut fx.env()); + assert!(matches!(st.finish(), Some(Outcome::Import { formats }) if formats == [JSON_FORMAT])); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.ask_reset_all(); + st.answer(&mut fx.env(), Answer::Yes); + assert!(fx.km.overrides().is_empty()); + st.undo(&mut fx.env()); + assert_eq!(fx.keys("object.front"), ["F2"]); + } + + #[test] + fn foreign_import_review_makes_a_new_set() { + use crate::import::{ContextRule, ForeignEntry, ForeignKey}; + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ], + }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + st.review_import(&mut fx.env(), set, options); + let r = st.review.as_ref(); + assert_eq!(r.map(|r| (r.name.as_str(), r.plan.commands.len(), r.plan.report.unmatched.len())), Some(("Theirs", 1, 1))); + assert!(fx.km.overrides().is_empty(), "review changes nothing"); + st.import_reviewed(&mut fx.env()); + assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.keys("file.save"), ["Cmd+Alt+S"]); + assert!(st.review.as_ref().is_some_and(|r| r.done.is_some())); + st.escape(&mut fx.env()); + assert!(st.review.is_none() && st.is_open()); + } +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs new file mode 100644 index 00000000..5eb12009 --- /dev/null +++ b/crates/keymap/src/ui/text.rs @@ -0,0 +1,283 @@ +//! The dialog's words: its own strings, command labels, menu path parts, key names and text +//! direction, all supplied by the app through [`Text`]. + +use std::borrow::Cow; + +use crate::chord::{Key, Platform, Trigger}; +use crate::keymap::Bindable; + +/// Where the dialog gets its words. Apps on craft-i18n pass [`I18nText`]; others implement the two +/// required methods with their own lookup. +pub trait Text { + /// The dialog's own strings, by English source text (listed in [`STRINGS`]). Placeholders + /// (`{chord}`, `{label}`, `{set}`, `{n}`) are filled after the lookup. + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str>; + /// A count's wording with `{n}` filled in. `one` and `other` are the English forms. + fn plural(&self, n: u64, one: &str, other: &str) -> String; + /// A command's label. + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + b.label.as_str().into() + } + /// A menu path part or a scope name. + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + s.into() + } + /// Extra lines for a command's tooltip. + fn hint(&self, _b: &Bindable) -> Option { + None + } + /// A key cap's name on `platform` (`Strg`, `Umschalt`); `None` keeps the crate's name. + fn key(&self, _k: Key, _p: Platform) -> Option { + None + } + /// Whether the interface language is written right to left. + fn rtl(&self) -> bool { + false + } + /// A label or message put into display order (the app's bidi reordering). Chord text is never + /// passed here. + fn visual(&self, s: &str) -> String { + s.to_string() + } +} + +/// [`Text`] over a craft-i18n catalog. +pub struct I18nText<'a> { + pub i18n: &'a craft_i18n::I18n, + pub lang: craft_i18n::Lang, +} + +impl Text for I18nText<'_> { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + self.i18n.trn(self.lang, n, one, other) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + self.i18n.tid(self.lang, &b.command.id, &b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + self.i18n.t(self.lang, s).into() + } + + fn key(&self, k: Key, p: Platform) -> Option { + // Glyphs (⌫, ↑) need no translation. + if p.is_mac() && k.mac_symbol() != k.text_name() { + return None; + } + self.i18n.get(self.lang, "key", k.text_name()).map(str::to_string) + } + + fn rtl(&self) -> bool { + self.i18n.rtl(self.lang) + } +} + +/// English [`Text`]: every string as written. +pub struct English; + +impl Text for English { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + craft_i18n::fmt(if n == 1 { one } else { other }, &[("n", &n.to_string())]) + } +} + +/// A trigger as `platform` shows it, with key names from [`Text::key`]. Modifiers come in the +/// order of [`Chord::display`](crate::Chord::display). +pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> String { + let key = trigger.chord.key; + let Some(name) = text.key(key, platform) else { return trigger.display(platform) }; + let base = if platform.is_mac() { key.mac_symbol() } else { key.text_name() }; + let one = trigger.chord.display(platform); + let one = match one.strip_suffix(base) { + Some(mods) => format!("{mods}{name}"), + None => one, + }; + if trigger.double { format!("{one} {one}") } else { one } +} + +/// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. +pub const PLURALS: &[(&str, &str)] = &[ + ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), + ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), + ("{n} command will change", "{n} commands will change"), + ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), +]; + +/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. +pub const STRINGS: &[&str] = &[ + // Window and footer. + "Keyboard Shortcuts", + "Custom", + "OK", + "Cancel", + "Close", + "Undo", + "Redo", + "Reset All…", + "Restore every default shortcut in this set?", + "Reset All", + // Header. + "Show:", + "Filter:", + "Search:", + "Search by key", + "All Commands", + "{menu} Menu", + "Tools", + "{panel} Panel", + "Other", + "Fixed Keys", + "All", + "With Shortcut", + "Changed", + "Conflicts", + "No matching commands", + "(cycles)", + // Table and detail pane. + "Command", + "Shortcut", + "Default", + "Application", + "Current shortcuts:", + "New shortcut:", + "press keys", + "{chord} (press again for a double press)", + "Type…", + "Context:", + "Assign", + "Use Default", + "Clear", + "Remove", + "Default: {chord}", + "None", + "Currently assigned to:", + "Not assigned", + "{label} ({scope}, both work)", + "Add a shortcut to {label}", + "Remove {chord} from {label}", + "Restore the default for {label}", + "{label}: {chord}", + "{label}: press keys", + "Now used by {label}", + // Messages and conflicts. + "⚠ {chord} is used by {label}", + "{chord} was removed from {label}", + "Also used by {label}", + "{chord} is reserved by the system", + "A command can have at most {n} shortcuts", + "The scope {scope} is unknown", + "Too many shortcuts are assigned", + "This command is unknown", + "Take It", + "Take It and Go to Conflict", + "Keep Both", + "Go to Conflict", + // Sets. + "Set:", + "(modified)", + "New Set…", + "Rename…", + "Delete", + "Save", + "Revert Set", + "Import…", + "Export", + "Summarize…", + "More", + "Name:", + "Based on:", + "Current shortcuts", + "Save changes to “{set}”?", + "Don't Save", + "Delete the set “{set}”?", + "Keyboard Shortcuts (JSON)", + "The name is empty, too long or has control characters", + "A set with this name exists", + "The set was not found", + "Built-in sets cannot be changed", + "There are too many sets", + "This is not a shortcut set file", + "This is not a craft-keymap file", + "This file is from a newer version (format {n})", + "The file is too large", + // Import review. + "Will change", + "Not matched", + "Unreadable", + "Approximated", + "Suggestions", + "numeric keypad key read as main key", + "Import as New Set", + "Imported", + "Copy Report", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::chord::Chord; + + struct German; + impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, _one: &str, other: &str) -> String { + other.replace("{n}", &n.to_string()) + } + fn key(&self, k: Key, _p: Platform) -> Option { + (k == Key::Delete).then(|| "Entf".to_string()) + } + } + + #[test] + fn trigger_text_uses_localized_key_names() -> Result<(), crate::ParseError> { + let t = Trigger::parse("Cmd+Shift+Delete")?; + assert_eq!(trigger_text(&German, &t, Platform::Windows), "Ctrl+Shift+Entf"); + assert_eq!(trigger_text(&German, &Trigger::parse("F7 F7")?, Platform::Windows), "F7 F7"); + assert_eq!(trigger_text(&English, &Trigger::single(Chord::parse("⇧⌘Z")?), Platform::Mac), "⇧⌘Z"); + Ok(()) + } + + /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. + #[test] + fn every_looked_up_string_is_listed() { + let sources = [include_str!("mod.rs"), include_str!("state.rs"), include_str!("rows.rs"), include_str!("capture.rs")]; + let mut missing = Vec::new(); + for src in sources { + let src = src.split("#[cfg(test)]").next().unwrap_or(src); + for (i, open) in src.match_indices(".ui(\"") { + let rest = &src[i + open.len()..]; + if let Some(end) = rest.find('"') { + let s = &rest[..end]; + if !STRINGS.contains(&s) { + missing.push(s.to_string()); + } + } + } + for (i, _) in src.match_indices(".plural(") { + let quoted: Vec<&str> = src[i..].split('"').skip(1).step_by(2).take(2).collect(); + if let [one, other] = quoted[..] + && !PLURALS.contains(&(one, other)) + { + missing.push(format!("{one}|{other}")); + } + } + } + assert!(missing.is_empty(), "not in STRINGS or PLURALS: {missing:?}"); + let mut sorted = STRINGS.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), STRINGS.len(), "STRINGS has duplicates"); + } +} From 39f343c47ba967411ab39ce346bd5bea7d4ea86b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:03:57 +0000 Subject: [PATCH 012/124] keymap: draw the shortcuts dialog: table, detail pane, conflict bar and footer - view.rs: header with Show (dropdown or tabs), Filter and Search with search by key; table and detail pane in a resizable split, the detail pane below the table under 720 pt; virtual rows with chips, + and the restore button; detail pane with current shortcuts, New Shortcut, typed entry, Context, Assign, Use Default and Clear; prompts, conflict bar, messages, clash, shadow and problem lines; Undo, Redo, Reset All, Cancel and OK. - Escape, Enter, Undo and Redo keys when idle; table keys (arrows, Home, End, Enter records, Delete removes); focus locks while recording. - Accessibility labels for every control, row selection, and on_widget automation ids. - style.rs: DialogStyle and the icon-button and divider styles. - show() sizes the window to the screen; closing it is Cancel. - tests/ui_dialog.rs: recording, conflicts, search, areas, display, labels, table keys, small windows, RTL. tests/ui_snapshot.rs (feature snapshot) writes PNGs when CRAFT_KEYMAP_SNAPSHOT_DIR is set. --- Cargo.lock | 1 + crates/keymap/Cargo.toml | 3 + crates/keymap/src/ui/mod.rs | 84 +- crates/keymap/src/ui/rows.rs | 6 + crates/keymap/src/ui/state.rs | 36 + crates/keymap/src/ui/style.rs | 54 ++ crates/keymap/src/ui/text.rs | 21 +- crates/keymap/src/ui/view.rs | 1257 +++++++++++++++++++++++++ crates/keymap/tests/ui_dialog.rs | 517 ++++++++++ crates/keymap/tests/ui_fixture/mod.rs | 24 + crates/keymap/tests/ui_snapshot.rs | 94 ++ 11 files changed, 2052 insertions(+), 45 deletions(-) create mode 100644 crates/keymap/src/ui/style.rs create mode 100644 crates/keymap/src/ui/view.rs create mode 100644 crates/keymap/tests/ui_dialog.rs create mode 100644 crates/keymap/tests/ui_fixture/mod.rs create mode 100644 crates/keymap/tests/ui_snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index 76a76163..69b01442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,6 +627,7 @@ dependencies = [ "craft-ui", "egui", "egui_kittest", + "image", "serde", "serde_json", "tempfile", diff --git a/crates/keymap/Cargo.toml b/crates/keymap/Cargo.toml index 0363ae7b..d7133d5a 100644 --- a/crates/keymap/Cargo.toml +++ b/crates/keymap/Cargo.toml @@ -22,8 +22,11 @@ craft-settings = { workspace = true } tempfile = "3" egui = { workspace = true, features = ["default_fonts"] } egui_kittest = "0.36" +image = { version = "0.25", default-features = false, features = ["png"] } [features] egui = ["dep:egui"] # The Keyboard Shortcuts dialog. ui = ["egui", "dep:craft-ui", "dep:craft-i18n"] +# Screenshots of the dialog for review (tests/ui_snapshot.rs; needs a GPU or software adapter). +snapshot = ["ui", "egui_kittest/wgpu"] diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 038d36ea..f09dc8e4 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -15,16 +15,15 @@ use crate::import::{ForeignSet, ImportOptions}; use crate::keymap::{Bindable, Binding, Keymap}; use crate::profiles::{Builtins, ProfileError, Profiles}; -// The view draws with these; until it calls every action some stay unused. -#[expect(dead_code, reason = "actions and row fields the view calls")] mod capture; -#[expect(dead_code, reason = "actions and row fields the view calls")] mod rows; -#[expect(dead_code, reason = "actions and row fields the view calls")] mod state; +mod style; mod text; +mod view; pub use state::DialogState; +pub use style::DialogStyle; pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text}; use state::Env; @@ -90,37 +89,6 @@ pub struct Format { pub extensions: &'static [&'static str], } -/// The dialog's colours. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct DialogStyle { - pub accent: egui::Color32, - pub warning: egui::Color32, - pub dim: egui::Color32, - pub changed: egui::Color32, - pub app_key: egui::Color32, - pub scoped_key: egui::Color32, - pub both_key: egui::Color32, - pub modifier_key: egui::Color32, - pub free_key: egui::Color32, -} - -impl DialogStyle { - pub fn from_visuals(v: &egui::Visuals) -> DialogStyle { - let accent = v.selection.bg_fill; - DialogStyle { - accent, - warning: v.warn_fg_color, - dim: v.weak_text_color(), - changed: accent, - app_key: accent, - scoped_key: v.warn_fg_color, - both_key: v.error_fg_color, - modifier_key: v.widgets.active.bg_fill, - free_key: v.widgets.inactive.bg_fill, - } - } -} - /// A rule an app adds for which bindings a command may get; `Err` carries the message shown. pub type Accept = Box Result<(), String>>; /// Called for every control once per frame with (automation id, rect, label). @@ -229,19 +197,37 @@ impl<'a> ShortcutsDialog<'a> { f(&mut env) } - /// A window with title, body and Cancel/OK. + /// A window with title, body and Cancel/OK, sized to the screen (at least 640×480 where the + /// screen allows, at most 1320×900). Closing it with × is Cancel. pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { - let title = self.text.ui(self.options.map_or("Keyboard Shortcuts", |o| o.title)).into_owned(); - egui::Window::new(title).collapsible(false).show(ctx, |ui| self.ui(ui, state)).and_then(|r| r.inner).flatten() + self.with_env(|env| { + let title = env.text.visual(&env.text.ui(env.options.title)); + let screen = ctx.content_rect().size(); + let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0)); + let min = egui::vec2(640.0, 480.0).min(max); + let mut open = true; + let mut out = None; + egui::Window::new(title) + .id(egui::Id::new("craft_keymap.shortcuts")) + .collapsible(false) + .resizable(true) + .open(&mut open) + .default_size((screen * 0.8).clamp(min, max)) + .min_size(min) + .max_size(max) + .show(ctx, |ui| out = frame(ui, env, state)); + if !open && state.is_open() { + state.cancel(env); + out = state.finish(); + } + out + }) } - /// The body only, for a host's own modal, preferences page or backstage. + /// The body only, for a host's own modal, preferences page or backstage. It fills the + /// available rectangle. pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { - self.with_env(|env| { - state.begin(env); - state.input(env, ui.ctx()); - state.finish() - }) + self.with_env(|env| frame(ui, env, state)) } /// OK, for hosts that draw their own button. @@ -279,3 +265,13 @@ impl<'a> ShortcutsDialog<'a> { }); } } + +/// One frame: take recorded keys before anything else sees them, draw, report. +fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option { + state.begin(env); + state.input(env, ui.ctx()); + if state.is_open() { + view::body(ui, env, state); + } + state.finish() +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 1bc13261..69b0afaa 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -172,7 +172,13 @@ impl RowCache { &self.built } + /// The lines last built. + pub(crate) fn built(&self) -> &Built { + &self.built + } + /// Forgets the lines, for a change the key does not cover (the interface language). + #[allow(dead_code, reason = "for hosts that switch the interface language")] pub(crate) fn invalidate(&mut self) { self.key = None; } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index ed3803b7..edfc7d33 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -105,6 +105,7 @@ pub(crate) struct Message { } /// What to do after the Save changes prompt. +#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Then { Switch(String), @@ -112,6 +113,7 @@ pub(crate) enum Then { Import, } +#[allow(dead_code, reason = "set row prompts")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Prompt { /// "Restore every default shortcut in this set?" @@ -132,6 +134,7 @@ pub(crate) enum Answer { } /// A foreign set under review. +#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug)] pub(crate) struct Review { pub set: ForeignSet, @@ -182,6 +185,20 @@ pub struct DialogState { rows: RowCache, changed: bool, outcome: Option, + // ---- The view's own state ---- + /// The table's share of the table/detail split. + pub(crate) split: f32, + /// The chip of the selected row that Enter and Delete act on. + pub(crate) chip: usize, + /// Typed entry in the detail pane, and the parse error shown under it. + pub(crate) typing: Option, + pub(crate) type_error: Option, + /// Height of the bars and footer under the table, measured last frame. + pub(crate) bottom_h: f32, + /// One of the dialog's text fields had focus at the end of last frame. + pub(crate) text_focus: bool, + /// The table lines drawn last frame. + pub(crate) visible: std::ops::Range, } impl Default for DialogState { @@ -215,6 +232,13 @@ impl DialogState { rows: RowCache::default(), changed: false, outcome: None, + split: 0.62, + chip: 0, + typing: None, + type_error: None, + bottom_h: 0.0, + text_focus: false, + visible: 0..0, } } @@ -275,6 +299,11 @@ impl DialogState { self.rows.get(env.keymap, env.options, env.text, &view) } + /// The table's lines as last built by [`lines`](Self::lines). + pub(crate) fn lines_cached(&self) -> &[crate::ui::rows::Line] { + &self.rows.built().lines + } + // ---- Selection and view ---- pub(crate) fn select_row(&mut self, command: &CommandRef) { @@ -282,6 +311,9 @@ impl DialogState { self.selected = Some(command.clone()); self.context = None; self.field = None; + self.chip = 0; + self.typing = None; + self.type_error = None; } } @@ -676,7 +708,11 @@ impl DialogState { self.ok(env); } } +} +// The set row and the import review draw with these (T3); until then some are unused. +#[allow(dead_code, reason = "set row and import review actions")] +impl DialogState { // ---- Sets ---- /// Whether the keymap's overrides differ from the stored active set. diff --git a/crates/keymap/src/ui/style.rs b/crates/keymap/src/ui/style.rs new file mode 100644 index 00000000..81d4a62d --- /dev/null +++ b/crates/keymap/src/ui/style.rs @@ -0,0 +1,54 @@ +//! The dialog's colours and the icon-button style built from them. + +use craft_ui::buttons::{ButtonVisuals, IconButtonStyle}; +use craft_ui::layout::ResizeHandleStyle; +use egui::{Color32, Stroke, Visuals}; + +/// The dialog's colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DialogStyle { + pub accent: Color32, + pub warning: Color32, + pub dim: Color32, + pub changed: Color32, + pub app_key: Color32, + pub scoped_key: Color32, + pub both_key: Color32, + pub modifier_key: Color32, + pub free_key: Color32, +} + +impl DialogStyle { + pub fn from_visuals(v: &Visuals) -> DialogStyle { + let accent = v.selection.bg_fill; + DialogStyle { + accent, + warning: v.warn_fg_color, + dim: v.weak_text_color(), + changed: accent, + app_key: accent, + scoped_key: v.warn_fg_color, + both_key: v.error_fg_color, + modifier_key: v.widgets.active.bg_fill, + free_key: v.widgets.inactive.bg_fill, + } + } +} + +/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colours of the app's visuals. +pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle { + let w = &v.widgets; + IconButtonStyle { + normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: w.inactive.fg_stroke.color }, + hovered: ButtonVisuals { fill: w.hovered.weak_bg_fill, stroke: w.hovered.bg_stroke, icon: w.hovered.fg_stroke.color }, + pressed: Some(ButtonVisuals { fill: w.active.weak_bg_fill, stroke: w.active.bg_stroke, icon: w.active.fg_stroke.color }), + selected: ButtonVisuals { fill: v.selection.bg_fill, stroke: Stroke::NONE, icon: v.selection.stroke.color }, + corner_radius: w.inactive.corner_radius, + focus_stroke: v.selection.stroke, + } +} + +/// The table/detail divider. +pub(crate) fn divider(v: &Visuals) -> ResizeHandleStyle { + ResizeHandleStyle { hovered: v.widgets.hovered.bg_fill, dragged: v.widgets.active.bg_fill, focused: v.selection.bg_fill } +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 5eb12009..80455b3d 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -111,6 +111,7 @@ pub const PLURALS: &[(&str, &str)] = &[ ("{n} command will change", "{n} commands will change"), ("{n} item without a shortcut", "{n} items without a shortcut"), ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), + ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), ]; /// Every source string the dialog looks up with [`Text::ui`], for the shared catalog. @@ -145,6 +146,9 @@ pub const STRINGS: &[&str] = &[ "(cycles)", // Table and detail pane. "Command", + "Commands", + "Detail pane size", + "Select a command to see and change its shortcuts", "Shortcut", "Default", "Application", @@ -182,6 +186,14 @@ pub const STRINGS: &[&str] = &[ "Take It and Go to Conflict", "Keep Both", "Go to Conflict", + "Cancel the new shortcut", + // Saved shortcuts that could not be used. + "{key}: no command has this name", + "{key}: “{text}” is not a shortcut ({error})", + "{key}: {text} is reserved by the system", + "{key}: the scope in “{text}” is unknown", + "{key}: listed more than once", + "{key}: part of the entry was dropped", // Sets. "Set:", "(modified)", @@ -252,7 +264,14 @@ mod tests { /// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed. #[test] fn every_looked_up_string_is_listed() { - let sources = [include_str!("mod.rs"), include_str!("state.rs"), include_str!("rows.rs"), include_str!("capture.rs")]; + let sources = [ + include_str!("mod.rs"), + include_str!("state.rs"), + include_str!("rows.rs"), + include_str!("capture.rs"), + include_str!("view.rs"), + include_str!("style.rs"), + ]; let mut missing = Vec::new(); for src in sources { let src = src.split("#[cfg(test)]").next().unwrap_or(src); diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs new file mode 100644 index 00000000..1618a4dd --- /dev/null +++ b/crates/keymap/src/ui/view.rs @@ -0,0 +1,1257 @@ +//! Drawing the dialog: header, table, detail pane, conflict bar, messages and footer. +//! +//! Widgets are created in focus order (§2.10 of the dialog plan): set row, Show, Filter, Search, +//! ⌨, table, detail pane, conflict bar, footer. egui's Tab order is creation order, so the bars +//! under the table are drawn last even though their height is needed first: the view uses last +//! frame's height and asks egui to discard a frame whose height changed. + +use std::ops::Range; + +use craft_ui::buttons::{IconButton, IconButtonStyle}; +use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; +use craft_ui::tabs::{self, Tab}; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, ComboBox, EventFilter, FontId, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, + ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, +}; + +use crate::chord::Trigger; +use crate::command::CommandRef; +use crate::keymap::{Binding, MAX_BINDINGS, Problem}; +use crate::scope::Scope; +use crate::ui::capture; +use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; +use crate::ui::style::{self, DialogStyle}; +use crate::ui::text::trigger_text; + +/// Below this width the detail pane goes under the table. +const NARROW: f32 = 720.0; +/// Smallest table or detail pane when dragging the divider. +const SPLIT_MIN: f32 = 120.0; +/// Icon buttons in rows and bars. +const ICON: f32 = 20.0; +/// Padding inside a chip. +const CHIP_PAD: f32 = 5.0; +const CHIP_GAP: f32 = 4.0; +/// The detail pane's New Shortcut field. +const FIELD_W: f32 = 160.0; + +/// One frame of the dialog body inside `ui`'s available rectangle. +pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { + let style = env.options.style.unwrap_or_else(|| DialogStyle::from_visuals(ui.visuals())); + let icons = style::icon_buttons(ui.visuals()); + let row_h = ui.text_style_height(&TextStyle::Body) + 6.0; + let base = ui.id().with("craft_keymap_shortcuts"); + let mut d = Draw { env, state, style, icons, base, row_h }; + d.keys(ui); + d.layout(ui); +} + +struct Draw<'s, 'e> { + env: &'s mut Env<'e>, + state: &'s mut DialogState, + style: DialogStyle, + icons: IconButtonStyle, + base: Id, + row_h: f32, +} + +fn fill(source: String, args: &[(&str, &str)]) -> String { + craft_i18n::fmt(&source, args) +} + +/// What a click in the table asked for, applied after the rows are drawn. +enum RowAction { + Select(CommandRef), + Record(CommandRef, Option, usize), + Reset(CommandRef), + Toggle(String), +} + +impl Draw<'_, '_> { + // ---- Text ---- + + /// The dialog's string, translated (logical order: for accessibility labels and `fill`). + fn ui(&self, source: &str) -> String { + self.env.text.ui(source).into_owned() + } + + /// Display order for drawn text. + fn vis(&self, s: &str) -> String { + self.env.text.visual(s) + } + + fn chord(&self, t: &Trigger) -> String { + trigger_text(self.env.text, t, self.env.keymap.platform()) + } + + fn scope_name(&self, s: &Scope) -> String { + if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } + } + + /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. + fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => format!("{chord} ({})", self.scope_name(s)), + _ => chord, + } + } + + fn label_of(&self, command: &CommandRef) -> String { + self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) + } + + /// "press keys", or the chord waiting for a second press. + fn recording_text(&self) -> String { + match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { + Some((t, _)) => fill(self.ui("{chord} (press again for a double press)"), &[("chord", &self.chord(t))]), + None => self.ui("press keys"), + } + } + + /// Reports a control to [`Options::on_widget`](crate::ui::Options::on_widget). + fn tag(&self, id: impl FnOnce() -> String, rect: Rect, label: &str) { + if let Some(f) = &self.env.options.on_widget { + f(&id(), rect, label); + } + } + + fn button(&self, ui: &mut Ui, enabled: bool, text: &str, id: &str) -> bool { + let r = ui.add_enabled(enabled, Button::new(self.vis(text))); + self.tag(|| id.to_string(), r.rect, text); + r.clicked() + } + + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) + } + + /// A row control: clickable and labelled but not a Tab stop (the table is one). + fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { + let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + let v = if !enabled { + self.icons.normal + } else if resp.is_pointer_button_down_on() { + self.icons.pressed.unwrap_or(self.icons.hovered) + } else if resp.hovered() { + self.icons.hovered + } else { + self.icons.normal + }; + if v.fill != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, self.icons.corner_radius, v.fill); + } + let color = if enabled { v.icon } else { self.style.dim.gamma_multiply(0.6) }; + paint(ui.painter(), rect, color); + resp + } + + /// Marks a text field of the dialog, so Escape, Enter, Undo and Delete leave it alone. + fn text_field(&mut self, r: &Response) { + if r.has_focus() { + self.state.text_focus = true; + } + } + + fn recording_target(&self) -> Option<&Target> { + self.state.recording.as_ref().map(|r: &Recording| &r.target) + } + + // ---- Keys when nothing records ---- + + /// Escape, Undo/Redo and Enter (§2.5). Keys while recording were taken by `DialogState::input`. + fn keys(&mut self, ui: &Ui) { + if self.state.is_recording() { + return; + } + let text_focus = self.state.text_focus; + let popup = egui::Popup::is_any_open(ui.ctx()); + if !text_focus && !popup && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Escape)) { + if self.state.typing.take().is_some() { + self.state.type_error = None; + } else { + self.state.escape(self.env); + } + return; + } + if text_focus || !self.env.options.editable { + return; + } + let mac = self.env.keymap.platform().is_mac(); + let (undo, redo) = ui.input_mut(|i| { + let redo = i.consume_key(Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::Z) || (!mac && i.consume_key(Modifiers::COMMAND, EguiKey::Y)); + let undo = !redo && i.consume_key(Modifiers::COMMAND, EguiKey::Z); + (undo, redo) + }); + if redo { + self.state.redo(self.env); + } else if undo { + self.state.undo(self.env); + } + if self.env.options.enter_is_ok + && self.state.prompt.is_none() + && ui.memory(|m| m.focused().is_none()) + && ui.input_mut(|i| i.consume_key(Modifiers::NONE, EguiKey::Enter)) + { + self.state.enter(self.env); + } + } + + // ---- Layout ---- + + fn layout(&mut self, ui: &mut Ui) { + let avail = ui.available_rect_before_wrap(); + let size = |v: f32, or: f32| if v.is_finite() { v.clamp(0.0, 4096.0) } else { or }; + let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); + self.state.text_focus = false; + let gap = ui.spacing().item_spacing.y; + ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { + self.sets_row(ui); + self.header(ui); + let rest = ui.available_rect_before_wrap(); + let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); + let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); + let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); + if !self.review(ui, mid) { + self.middle(ui, mid); + } + let top = (mid.max.y + gap).min(rest.max.y); + let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); + let used = + ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(Layout::top_down(Align::Min)), |ui| self.bottom(ui)).response.rect.height(); + if (used - self.state.bottom_h).abs() > 0.5 { + self.state.bottom_h = used; + ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); + } + }); + ui.advance_cursor_after_rect(full); + } + + /// The set row: drawn here, above the header, when the app passes sets (§2.6). + fn sets_row(&mut self, _ui: &mut Ui) {} + + /// The import review takes the place of the table and detail pane while it is open (§2.7). + /// Returns whether it drew. + fn review(&mut self, _ui: &mut Ui, _rect: Rect) -> bool { + false + } + + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { + ui.set_clip_rect(rect.intersect(ui.clip_rect())); + f(self, ui); + }); + } + + // ---- Header: Show, Filter, Search ---- + + fn area_key(id: &AreaId) -> String { + match id { + AreaId::All => "all".into(), + AreaId::Menu(m) => format!("menu.{m}"), + AreaId::Tools => "tools".into(), + AreaId::Panel(p) => format!("panel.{p}"), + AreaId::Other => "other".into(), + AreaId::App(a) => (*a).to_string(), + AreaId::Fixed => "fixed".into(), + } + } + + fn header(&mut self, ui: &mut Ui) { + if self.env.options.area_tabs { + self.area_tabs(ui); + } + ui.horizontal(|ui| { + if !self.env.options.area_tabs { + self.area_combo(ui); + } + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + let combo = ComboBox::from_id_salt(self.base.with("filter")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.filter".into(), combo.rect, &filter_label); + if let Some(f) = pick { + self.state.set_filter(f); + } + + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let width = (ui.available_width() - ICON - ui.spacing().item_spacing.x).clamp(60.0, 240.0); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } + }); + } + + fn area_combo(&mut self, ui: &mut Ui) { + let show = self.ui("Show:"); + let l = ui.label(self.vis(&show)); + let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); + let mut pick = None; + let areas = self.state.areas.clone(); + let combo = ComboBox::from_id_salt(self.base.with("area")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for a in &areas { + let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); + if r.clicked() { + pick = Some(a.id.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.area".into(), combo.rect, &show); + if let Some(a) = pick { + self.state.set_area(a); + } + } + + /// Show as tabs, with the ones that do not fit in a menu. + fn area_tabs(&mut self, ui: &mut Ui) { + let areas = self.state.areas.clone(); + let pad = ui.spacing().button_padding.x * 2.0; + let font = TextStyle::Button.resolve(ui.style()); + let natural: Vec = + areas.iter().map(|a| ui.painter().layout_no_wrap(self.vis(&a.label), font.clone(), Color32::WHITE).size().x + pad).collect(); + let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); + let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let mut x = rect.min.x; + let mut pick = None; + for &(i, w) in &fit.shown { + let Some(a) = areas.get(i) else { continue }; + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height())); + x += w; + let on = i == selected; + let id = self.base.with(("area", i)); + let text = self.vis(&a.label); + let accent = self.style.accent; + let resp = Tab::new(id, &a.label, on).show_at(ui, r, |ui, resp| { + let v = ui.visuals(); + let color = if on { v.strong_text_color() } else { v.text_color() }; + if resp.hovered() && !on { + ui.painter().rect_filled(r, 2.0, v.widgets.hovered.weak_bg_fill); + } + if on { + ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); + } + let g = ui.painter().layout(text, font.clone(), color, r.width() - 4.0); + ui.painter().galley(r.center() - g.size() / 2.0, g, color); + }); + self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); + if resp.clicked() { + pick = Some(a.id.clone()); + } + } + if !fit.overflow.is_empty() { + let r = Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height())); + let more = self.ui("More"); + ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { + ui.menu_button(self.vis(&more), |ui| { + for &i in &fit.overflow { + if let Some(a) = areas.get(i) + && ui.button(self.vis(&a.label)).clicked() + { + pick = Some(a.id.clone()); + } + } + }); + }); + } + if let Some(a) = pick { + self.state.set_area(a); + } + } + + // ---- Table and detail pane ---- + + fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let wide = mid.width() >= NARROW; + let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; + // Right to left: the detail pane goes on the left. + let swap = wide && self.env.text.rtl(); + let ratio = if swap { 1.0 - self.state.split } else { self.state.split }; + let extent = if wide { mid.width() } else { mid.height() }; + let Some(split) = split_rect(mid, axis, SplitSize::Ratio(ratio), 8.0, SPLIT_MIN.min(extent / 3.0)) else { return }; + let (table, detail) = if swap { (split.second, split.first) } else { (split.first, split.second) }; + self.child(ui, table, |d, ui| d.table(ui)); + let label = self.ui("Detail pane size"); + let resp = ResizeHandle::new(self.base.with("split"), axis, split.divider, &label, style::divider(ui.visuals())) + .value(split.first_size(), 0.0..=split.available()) + .show(ui); + let line = ui.visuals().widgets.noninteractive.bg_stroke; + let c = split.divider.center(); + let ends = if wide { + [pos2(c.x, split.divider.min.y), pos2(c.x, split.divider.max.y)] + } else { + [pos2(split.divider.min.x, c.y), pos2(split.divider.max.x, c.y)] + }; + ui.painter().line_segment(ends, line); + if resp.delta != 0.0 + && let Some(SplitSize::Ratio(r)) = split.resized(SplitSize::Ratio(ratio), resp.delta, SPLIT_MIN) + { + self.state.split = if swap { 1.0 - r } else { r }; + } + self.child(ui, detail, |d, ui| d.detail(ui)); + } + + /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. + fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { + let widest = self + .state + .lines_cached() + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some(r) } else { None }) + .max_by_key(|r| r.chips.iter().map(|c| c.text.chars().count() + 2).sum::()); + let font = TextStyle::Body.resolve(ui.style()); + let chips: f32 = widest.map_or(0.0, |r| { + r.chips + .iter() + .map(|c| ui.painter().layout_no_wrap(c.text.clone(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP) + .sum() + }); + (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) + } + + fn table(&mut self, ui: &mut Ui) { + let rect = ui.max_rect(); + let table_id = self.base.with("table"); + let focus = ui.interact(rect, table_id, Sense::focusable_noninteractive()); + let commands = self.ui("Commands"); + focus.widget_info(|| WidgetInfo::labeled(WidgetType::Other, true, &commands)); + let has_focus = focus.has_focus(); + if has_focus && !self.state.is_recording() { + self.table_keys(ui); + } + let recording_cell = matches!(self.recording_target(), Some(Target::Cell { .. })); + if recording_cell && focus.lost_focus() { + self.state.stop_recording(); + } + if has_focus { + let filter = if recording_cell { + capture::FOCUS_LOCK + } else { + EventFilter { vertical_arrows: true, horizontal_arrows: true, tab: false, escape: false } + }; + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, filter)); + } + + let selected = self.state.selected.clone(); + let built = self.state.lines(self.env); + let (count, matches) = (built.lines.len(), built.matches); + let position = selected.as_ref().and_then(|c| built.position(c)); + let row_h = self.row_h; + let shortcut_w = self.shortcut_width(ui, rect.width()); + + // Column titles. + let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); + let font = TextStyle::Body.resolve(ui.style()); + let dim = self.style.dim; + let cmd_title = self.vis(&self.ui("Command")); + let key_title = self.vis(&self.ui("Shortcut")); + ui.painter().text(pos2(head.min.x + 6.0, head.center().y), egui::Align2::LEFT_CENTER, cmd_title, font.clone(), dim); + ui.painter().text(pos2(head.max.x - shortcut_w - ICON - 4.0, head.center().y), egui::Align2::LEFT_CENTER, key_title, font, dim); + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); + + if matches == 0 { + ui.add_space(8.0); + ui.label(RichText::new(self.vis(&self.ui("No matching commands"))).color(dim)); + } + + let mut scroll = ScrollArea::vertical().id_salt(self.base.with("rows")).auto_shrink([false, false]); + if std::mem::take(&mut self.state.reveal) + && let Some(pos) = position + { + let inside = self.state.visible.start < pos && pos + 1 < self.state.visible.end; + if !inside { + let view = ui.available_height(); + scroll = scroll.vertical_scroll_offset((pos as f32 * row_h - (view - row_h) / 2.0).max(0.0)); + } + } + let mut action = None; + let mut shown = 0..0; + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + scroll.show_rows(ui, row_h, count, |ui, range| { + shown = range.clone(); + action = self.rows(ui, range, shortcut_w, has_focus); + }); + }); + self.state.visible = shown; + if has_focus { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } + + if let Some(a) = action { + focus.request_focus(); + match a { + RowAction::Select(c) => self.state.select_row(&c), + RowAction::Record(c, old, chip) => { + self.state.record(self.env, Target::Cell { command: c, old }); + self.state.chip = chip; + } + RowAction::Reset(c) => { + self.state.select_row(&c); + self.state.use_default(self.env, &c); + } + RowAction::Toggle(name) => self.state.toggle_group(&name), + } + if self.state.is_recording() { + ui.memory_mut(|m| m.set_focus_lock_filter(table_id, capture::FOCUS_LOCK)); + } + } + } + + /// Up/Down, Home/End, Left/Right between chips, Enter records, Delete removes (§2.10). + fn table_keys(&mut self, ui: &Ui) { + let keys = [ + EguiKey::ArrowUp, + EguiKey::ArrowDown, + EguiKey::Home, + EguiKey::End, + EguiKey::ArrowLeft, + EguiKey::ArrowRight, + EguiKey::Enter, + EguiKey::Delete, + ]; + let pressed = ui.input_mut(|i| keys.map(|k| i.consume_key(Modifiers::NONE, k))); + let [up, down, home, end, left, right, enter, delete] = pressed; + if !pressed.contains(&true) { + return; + } + let rows: Vec<(CommandRef, Vec)> = self + .state + .lines(self.env) + .lines + .iter() + .filter_map(|l| if let Line::Row(r) = l { Some((r.command.clone(), r.chips.iter().map(|c| c.binding.clone()).collect())) } else { None }) + .collect(); + let at = self.state.selected.as_ref().and_then(|s| rows.iter().position(|(c, _)| c == s)); + let to = if up { + Some(at.map_or(0, |i| i.saturating_sub(1))) + } else if down { + Some(at.map_or(0, |i| (i + 1).min(rows.len().saturating_sub(1)))) + } else if home { + Some(0) + } else if end { + Some(rows.len().saturating_sub(1)) + } else { + None + }; + if let Some((command, _)) = to.and_then(|i| rows.get(i)) { + self.state.select_row(command); + self.state.reveal = true; + return; + } + let Some((command, bindings)) = at.and_then(|i| rows.get(i)) else { return }; + let chip = self.state.chip.min(bindings.len().saturating_sub(1)); + if left { + self.state.chip = chip.saturating_sub(1); + } else if right { + self.state.chip = (chip + 1).min(bindings.len().saturating_sub(1)); + } else if enter && self.env.options.editable { + self.state.record(self.env, Target::Cell { command: command.clone(), old: bindings.get(chip).cloned() }); + self.state.chip = chip; + } else if delete + && self.env.options.editable + && let Some(b) = bindings.get(chip) + { + self.state.remove(self.env, command, b); + self.state.chip = chip.saturating_sub(1); + } + } + + fn rows(&mut self, ui: &mut Ui, range: Range, shortcut_w: f32, focused: bool) -> Option { + let lines: Vec = self.state.lines(self.env).lines.get(range).map(<[Line]>::to_vec).unwrap_or_default(); + let mut action = None; + for line in lines { + let (_, rect) = ui.allocate_space(vec2(ui.available_width(), self.row_h)); + let a = match &line { + Line::Header { name, collapsed } => self.group_header(ui, rect, name, *collapsed), + Line::Row(r) => self.row(ui, rect, r, shortcut_w, focused), + Line::Fixed(f) => { + self.fixed_row(ui, rect, f, shortcut_w); + None + } + }; + action = action.or(a); + } + action + } + + fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::CLICK); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); + let v = ui.visuals(); + let color = v.strong_text_color(); + let tri = Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0)); + let points = if collapsed { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + } else { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + }; + ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); + let font = TextStyle::Body.resolve(ui.style()); + let g = ui.painter().layout_job(truncated(&self.vis(name), font, color, rect.width() - 24.0)); + ui.painter().galley(pos2(rect.min.x + 20.0, rect.center().y - g.size().y / 2.0), g, color); + resp.clicked().then(|| RowAction::Toggle(name.to_string())) + } + + fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { + let editable = self.env.options.editable; + let selected = self.state.selected.as_ref() == Some(&r.command); + let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); + let mut a11y = r.label.clone(); + if r.cycles { + a11y = format!("{a11y} {}", self.ui("(cycles)")); + } + if r.changed { + a11y = format!("{a11y} ({})", self.ui("Changed")); + } + resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, selected, &a11y)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); + self.tag(|| format!("shortcuts.row.{}", r.key), rect, &r.label); + let v = ui.visuals(); + if selected { + let fill = if focused { v.selection.bg_fill } else { v.selection.bg_fill.gamma_multiply(0.5) }; + ui.painter().rect_filled(rect, 0.0, fill); + } else if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); + } + if r.changed { + ui.painter().rect_filled(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0)), 1.0, self.style.changed); + } + let text_color = if selected { v.strong_text_color() } else { v.text_color() }; + let strong = v.strong_text_color(); + let dim = self.style.dim; + let font = TextStyle::Body.resolve(ui.style()); + + // Label column. + let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); + let mut job = LayoutJob::default(); + if r.conflict { + job.append("⚠ ", 0.0, TextFormat::simple(font.clone(), self.style.warning)); + } + for p in &r.prefix { + job.append(&format!("{} › ", self.vis(p)), 0.0, TextFormat::simple(font.clone(), dim)); + } + job.append(&self.vis(&r.label), 0.0, TextFormat::simple(font.clone(), if r.changed { strong } else { text_color })); + if r.cycles { + job.append(&format!(" {}", self.vis(&self.ui("(cycles)"))), 0.0, TextFormat::simple(font.clone(), dim)); + } + job.wrap = TextWrapping::truncate_at_width(label_w); + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, text_color); + + // Shortcut column: chips, then "+". + let mut action = None; + let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); + let painter = ui.painter_at(col); + let mut x = col.min.x; + let rec = match self.recording_target() { + Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), + _ => None, + }; + let chip_h = rect.height() - 4.0; + let mut cells: Vec<(Option, String, bool)> = + r.chips.iter().map(|c| (Some(c.binding.clone()), c.text.clone(), rec.as_ref() == Some(&Some(c.binding.clone())))).collect(); + if rec == Some(None) { + cells.push((None, String::new(), true)); + } + for (n, (binding, text, recording)) in cells.iter().enumerate() { + let shown = if *recording { self.recording_text() } else { text.clone() }; + let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); + let w = g.size().x + 2.0 * CHIP_PAD; + let chip = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h)); + x += w + CHIP_GAP; + let changed = r.chips.get(n).is_some_and(|c| c.changed); + let stroke = if *recording { + Stroke::new(1.5, self.style.accent) + } else if changed { + Stroke::new(1.0, self.style.changed) + } else { + v.widgets.inactive.bg_stroke + }; + let current_chip = selected && focused && n == self.state.chip; + painter.rect(chip, 3.0, v.widgets.inactive.weak_bg_fill, if current_chip { v.selection.stroke } else { stroke }, StrokeKind::Inside); + painter.galley(pos2(chip.min.x + CHIP_PAD, chip.center().y - g.size().y / 2.0), g, v.text_color()); + let waiting = self.state.recording.as_ref().is_some_and(|r| r.waiting.is_some()); + let label = if *recording && waiting { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &shown)]) + } else if *recording { + fill(self.ui("{label}: press keys"), &[("label", &r.label)]) + } else { + fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) + }; + let resp = ui.interact(chip.intersect(col), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), binding.clone(), n)); + } + } + if cells.is_empty() { + // An empty cell to click for the first shortcut. + let cell = Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y)); + let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); + let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); + self.tag(|| format!("shortcuts.cell.{}.0", r.key), cell, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, 0)); + } + x = col.min.x; + } + if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { + let plus = Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); + self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); + if resp.clicked() { + action = Some(RowAction::Record(r.command.clone(), None, r.chips.len())); + } + } + if editable { + let reset = Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); + let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); + self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); + if resp.clicked() { + action = Some(RowAction::Reset(r.command.clone())); + } + } + + let clicked = resp.clicked(); + let mut tip = vec![self.vis(&r.label)]; + if let Some(b) = self.env.keymap.bindable(&r.command) { + if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { + tip.push(path.iter().map(|s| self.vis(&self.env.text.segment(s))).collect::>().join(" › ")); + } + tip.push(r.command.id.clone()); + if let Some(h) = self.env.text.hint(b) { + tip.push(self.vis(&h)); + } + } + resp.on_hover_text(tip.join("\n")); + action.or_else(|| clicked.then(|| RowAction::Select(r.command.clone()))) + } + + /// A read-only row from `Options::fixed`, struck through when a command took its chord. + fn fixed_row(&self, ui: &mut Ui, rect: Rect, f: &FixedLine, shortcut_w: f32) { + let v = ui.visuals(); + let font = TextStyle::Body.resolve(ui.style()); + let color = if f.now_used_by.is_some() { self.style.dim } else { v.text_color() }; + let mut fmt = TextFormat::simple(font.clone(), color); + if f.now_used_by.is_some() { + fmt.strikethrough = Stroke::new(1.0, color); + } + let mut job = LayoutJob::single_section(self.vis(&f.label), fmt.clone()); + job.wrap = TextWrapping::truncate_at_width((rect.width() - shortcut_w - ICON - 12.0).max(0.0)); + let g = ui.painter().layout_job(job); + ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); + let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); + ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); + if let Some(by) = &f.now_used_by { + let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{}: {} ({tip})", f.label, f.keys))); + resp.on_hover_text(self.vis(&tip)); + } + } + + fn detail(&mut self, ui: &mut Ui) { + ScrollArea::vertical().id_salt(self.base.with("detail")).auto_shrink([false, false]).show(ui, |ui| { + ui.add_space(4.0); + let Some(command) = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()) else { + ui.label(RichText::new(self.vis(&self.ui("Select a command to see and change its shortcuts"))).color(self.style.dim)); + return; + }; + self.detail_of(ui, &command); + }); + } + + fn detail_of(&mut self, ui: &mut Ui, command: &CommandRef) { + let Some(b) = self.env.keymap.bindable(command).cloned() else { return }; + let editable = self.env.options.editable; + let label = self.env.text.command(&b).into_owned(); + let home = Some(&b.scope); + let dim = self.style.dim; + ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); + let mut place: Vec = b.paths.first().map(|p| p.iter().map(|s| self.vis(&self.env.text.segment(s))).collect()).unwrap_or_default(); + place.retain(|s| !s.is_empty()); + let place = if place.is_empty() { b.command.id.clone() } else { format!("{} · {}", place.join(" › "), b.command.id) }; + ui.add(Label::new(RichText::new(place).color(dim)).wrap()); + if let Some(h) = self.env.text.hint(&b) { + ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); + } + ui.add_space(6.0); + + // Current shortcuts, each with Remove. + ui.label(self.vis(&self.ui("Current shortcuts:"))); + let bindings = self.env.keymap.bindings(command); + let defaults = self.env.keymap.default_bindings(command); + if bindings.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("None"))).color(dim)); + } + let mut remove = None; + for (n, binding) in bindings.iter().enumerate() { + let text = self.binding_text(binding, home); + ui.horizontal(|ui| { + ui.add_space(8.0); + let shown = if defaults.contains(binding) { format!("{text} ({})", self.vis(&self.ui("Default"))) } else { text.clone() }; + ui.label(shown); + if editable { + let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); + let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); + self.tag(|| format!("shortcuts.remove.{n}"), r.rect, &a11y); + if r.clicked() { + remove = Some(binding.clone()); + } + } + }); + } + if let Some(binding) = remove { + self.state.remove(self.env, command, &binding); + } + if !editable { + self.defaults_line(ui, &defaults, home); + return; + } + ui.add_space(6.0); + self.new_shortcut(ui, command, home); + self.context(ui, command); + self.holders(ui); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if self.button(ui, self.state.field.is_some(), &self.ui("Assign"), "shortcuts.assign") { + self.state.assign_field(self.env); + } + if self.button(ui, DialogState::can_use_default(self.env, command), &self.ui("Use Default"), "shortcuts.useDefault") { + self.state.use_default(self.env, command); + } + if self.button(ui, !bindings.is_empty(), &self.ui("Clear"), "shortcuts.clear") { + self.state.clear(self.env, command); + } + }); + self.defaults_line(ui, &defaults, home); + } + + fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { + let chords = + if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; + ui.add(Label::new(RichText::new(self.vis(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]))).color(self.style.dim)).wrap()); + } + + /// New Shortcut: a field that records when focused or clicked, or typed entry. + fn new_shortcut(&mut self, ui: &mut Ui, command: &CommandRef, home: Option<&Scope>) { + let title = self.ui("New shortcut:"); + let title_id = ui.label(self.vis(&title)).id; + let field_id = self.base.with("new"); + let recording = matches!(self.recording_target(), Some(Target::Field { command: c }) if c == command); + if let Some(mut typed) = self.state.typing.clone() { + let type_id = self.base.with("type"); + let edit = ui.add(TextEdit::singleline(&mut typed).id(type_id).desired_width(FIELD_W).hint_text("Cmd+Shift+K")); + let edit = edit.labelled_by(title_id); + // Focus once, on the frame after Type… was clicked. + if ui.data_mut(|d| d.remove_temp::(type_id)).is_some() { + edit.request_focus(); + } + self.text_field(&edit); + self.tag(|| "shortcuts.type".into(), edit.rect, &title); + let enter = edit.lost_focus() && ui.input(|i| i.key_pressed(EguiKey::Enter)); + self.state.typing = Some(typed.clone()); + if enter { + match self.state.type_binding(self.env, command, &typed) { + Ok(()) => { + self.state.typing = None; + self.state.type_error = None; + } + Err(e) => self.state.type_error = Some(e), + } + } + if let Some(e) = &self.state.type_error { + ui.add(Label::new(RichText::new(format!("⚠ {e}")).color(self.style.warning)).wrap()); + } + return; + } + ui.horizontal(|ui| { + let shown = if recording { + self.recording_text() + } else { + self.state.field.as_ref().filter(|f| f.command == *command).map_or_else(String::new, |f| self.binding_text(&f.binding, home)) + }; + let size = vec2(FIELD_W, ui.spacing().interact_size.y); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, field_id, Sense::click()); + let a11y = format!("{title} {}", if shown.is_empty() { self.ui("None") } else { shown.clone() }); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &a11y)); + let v = ui.visuals(); + let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; + ui.painter().rect(rect, 3.0, v.extreme_bg_color, stroke, StrokeKind::Inside); + let color = if recording { self.style.dim } else { v.text_color() }; + ui.painter().text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, shown, TextStyle::Body.resolve(ui.style()), color); + self.tag(|| "shortcuts.new".into(), rect, &title); + if !recording && (resp.clicked() || resp.gained_focus()) { + self.state.record(self.env, Target::Field { command: command.clone() }); + resp.request_focus(); + capture::lock_focus(ui, field_id); + } else if recording { + if resp.lost_focus() { + self.state.stop_recording(); + } else { + capture::lock_focus(ui, field_id); + } + } + if ui.link(self.vis(&self.ui("Type…"))).clicked() { + self.state.stop_recording(); + self.state.typing = Some(String::new()); + self.state.type_error = None; + let type_id = self.base.with("type"); + ui.data_mut(|d| d.insert_temp(type_id, true)); + } + }); + } + + fn context(&mut self, ui: &mut Ui, command: &CommandRef) { + let contexts = DialogState::contexts(self.env, command); + let name = |s: &Option| match s { + None => self.ui("Default"), + Some(s) => self.scope_name(s), + }; + let current = name(&self.state.context); + let names: Vec = contexts.iter().map(name).collect(); + let title = self.ui("Context:"); + let mut pick = None; + ui.horizontal(|ui| { + let l = ui.label(self.vis(&title)); + let combo = ComboBox::from_id_salt(self.base.with("context")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + for (s, n) in contexts.iter().zip(&names) { + if ui.selectable_label(*s == self.state.context, self.vis(n)).clicked() { + pick = Some(s.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.context".into(), combo.rect, &title); + }); + if let Some(s) = pick { + self.state.set_context(self.env, s); + } + } + + /// "Currently assigned to:" for the field's chord, with links to the holders. + fn holders(&mut self, ui: &mut Ui) { + let Some(f) = self.state.field.clone() else { return }; + if f.holders.is_empty() && f.shadows.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Not assigned"))).color(self.style.dim)); + return; + } + ui.label(self.vis(&self.ui("Currently assigned to:"))); + let mut go = None; + for h in &f.holders { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); + let text = match &h.binding.scope { + Some(s) if Some(s) != home.as_ref() => format!("{label} ({})", self.scope_name(s)), + _ => label, + }; + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(format!("⚠ {}", self.vis(&text))).clicked() { + go = Some(h.command.clone()); + } + }); + } + for h in &f.shadows { + let label = self.label_of(&h.command); + let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()).unwrap_or_else(Scope::app); + let scope = self.scope_name(h.binding.scope_or(&home)); + let text = fill(self.ui("{label} ({scope}, both work)"), &[("label", &label), ("scope", &scope)]); + ui.horizontal(|ui| { + ui.add_space(8.0); + if ui.link(self.vis(&text)).clicked() { + go = Some(h.command.clone()); + } + }); + } + if let Some(c) = go { + self.state.go_to(&c); + } + } + + // ---- Under the table ---- + + fn bottom(&mut self, ui: &mut Ui) { + self.prompt(ui); + self.conflict_bar(ui); + self.message(ui); + self.info(ui); + self.footer(ui); + } + + fn prompt(&mut self, ui: &mut Ui) { + let Some(p) = self.state.prompt.clone() else { return }; + let (text, yes, no) = match &p { + Prompt::ResetAll => (self.ui("Restore every default shortcut in this set?"), self.ui("Reset All"), None), + Prompt::DeleteSet(set) => (fill(self.ui("Delete the set “{set}”?"), &[("set", set)]), self.ui("Delete"), None), + Prompt::SaveChanges { set, .. } => { + (fill(self.ui("Save changes to “{set}”?"), &[("set", set)]), self.ui("Save"), Some(self.ui("Don't Save"))) + } + }; + ui.add(Label::new(RichText::new(self.vis(&text)).strong()).wrap()); + let mut answer = None; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &yes, "shortcuts.prompt.yes") { + answer = Some(Answer::Yes); + } + if let Some(no) = &no + && self.button(ui, true, no, "shortcuts.prompt.no") + { + answer = Some(Answer::No); + } + if self.button(ui, true, &self.ui("Cancel"), "shortcuts.prompt.cancel") { + answer = Some(Answer::Cancel); + } + }); + if let Some(a) = answer { + self.state.answer(self.env, a); + } + } + + fn conflict_bar(&mut self, ui: &mut Ui) { + let Some(c) = self.state.conflict.clone() else { return }; + ui.add(Label::new(RichText::new(self.vis(&c.text)).color(self.style.warning)).wrap()); + let mut act = 0; + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { + act = 1; + } + if self.button(ui, true, &self.ui("Take It and Go to Conflict"), "shortcuts.conflict.goto") { + act = 2; + } + if self.env.options.allow_keep && self.button(ui, true, &self.ui("Keep Both"), "shortcuts.conflict.keep") { + act = 3; + } + let label = self.ui("Cancel the new shortcut"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.conflict.cancel".into(), r.rect, &label); + if r.clicked() { + act = 4; + } + }); + match act { + 1 => self.state.take_it(self.env), + 2 => self.state.take_it_and_go(self.env), + 3 => self.state.keep_both(self.env), + 4 => self.state.dismiss_conflict(), + _ => {} + } + } + + fn message(&mut self, ui: &mut Ui) { + let Some(m) = self.state.message.clone() else { return }; + let mut act = 0; + ui.horizontal_wrapped(|ui| { + let text = if m.refused { format!("⚠ {}", self.vis(&m.text)) } else { self.vis(&m.text) }; + let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; + ui.add(Label::new(RichText::new(text).color(color)).wrap()); + if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { + act = 1; + } + if m.undo && self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.message.undo") { + act = 2; + } + }); + match (act, &m.goto) { + (1, Some(c)) => self.state.go_to(c), + (2, _) => self.state.undo(self.env), + _ => {} + } + } + + /// Clashes, shadows and problems in the keymap as a whole. + fn info(&mut self, ui: &mut Ui) { + let built = self.state.lines(self.env); + let (clashes, shadows) = (built.clashes, built.shadows); + let problems = self.env.keymap.problems(); + if clashes == 0 && shadows == 0 && problems.is_empty() { + return; + } + let mut conflicts = false; + ui.horizontal_wrapped(|ui| { + if clashes > 0 { + let n = clashes as u64; + let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); + conflicts = ui.link(format!("⚠ {}", self.vis(&text))).clicked(); + } + if shadows > 0 { + let n = shadows as u64; + let text = self.env.text.plural(n, "{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + }); + if conflicts { + self.state.set_filter(Filter::Conflicts); + } + if !problems.is_empty() { + let n = problems.len() as u64; + let title = self.env.text.plural(n, "{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"); + egui::CollapsingHeader::new(self.vis(&title)).id_salt(self.base.with("problems")).show(ui, |ui| { + ScrollArea::vertical().max_height(self.row_h * 4.0).show(ui, |ui| { + for p in &problems { + ui.add(Label::new(self.vis(&self.problem(p))).wrap()); + } + }); + }); + } + } + + fn problem(&self, p: &Problem) -> String { + match p { + Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), + Problem::BadBinding { key, text, error } => { + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error.to_string())]) + } + Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), + Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), + Problem::DuplicateKey { key } => fill(self.ui("{key}: listed more than once"), &[("key", key)]), + Problem::Dropped { key, .. } => fill(self.ui("{key}: part of the entry was dropped"), &[("key", key)]), + Problem::TooManyCommands { ignored } => { + self.env.text.plural(*ignored as u64, "{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands") + } + } + } + + fn footer(&mut self, ui: &mut Ui) { + let editable = self.env.options.editable; + let right: Vec<(String, &str)> = if editable { + vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] + } else { + vec![(self.ui("Close"), "shortcuts.ok")] + }; + ui.horizontal(|ui| { + if editable { + if self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + self.state.undo(self.env); + } + if self.button(ui, self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + self.state.redo(self.env); + } + if self.button(ui, !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + self.state.ask_reset_all(); + } + } + let gap = ui.spacing().item_spacing.x; + let pad = ui.spacing().button_padding.x * 2.0; + let min = ui.spacing().interact_size.x; + let width: f32 = right + .iter() + .map(|(t, _)| { + let g = WidgetText::from(self.vis(t)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(min) + gap + }) + .sum(); + ui.add_space((ui.available_width() - width).max(0.0)); + for (text, id) in &right { + if self.button(ui, true, text, id) { + if editable && *id == "shortcuts.ok" { + self.state.ok(self.env); + } else { + self.state.cancel(self.env); + } + } + } + }); + } +} + +fn truncated(text: &str, font: FontId, color: Color32, width: f32) -> LayoutJob { + let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color)); + job.wrap = TextWrapping::truncate_at_width(width.max(0.0)); + job +} + +// ---- Icons, drawn so they do not depend on the app's fonts ---- + +fn square(r: Rect, f: f32) -> Rect { + Rect::from_center_size(r.center(), Vec2::splat(r.width().min(r.height()) * f)) +} + +fn paint_plus(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.5); + let s = Stroke::new(1.5, c); + p.line_segment([r.center_top(), r.center_bottom()], s); + p.line_segment([r.left_center(), r.right_center()], s); +} + +fn paint_cross(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.45); + let s = Stroke::new(1.5, c); + p.line_segment([r.left_top(), r.right_bottom()], s); + p.line_segment([r.right_top(), r.left_bottom()], s); +} + +fn paint_reset(p: &Painter, r: Rect, c: Color32) { + let r = square(r, 0.55); + let (center, radius) = (r.center(), r.width() / 2.0); + let s = Stroke::new(1.5, c); + let at = |a: f32| center + radius * Vec2::angled(a); + let start = -std::f32::consts::FRAC_PI_2; + let points: Vec<_> = (0..=12).map(|i| at(start + i as f32 / 12.0 * 1.6 * std::f32::consts::PI)).collect(); + p.add(egui::Shape::line(points, s)); + let tip = at(start); + p.line_segment([tip, tip + vec2(radius * 0.6, -radius * 0.35)], s); + p.line_segment([tip, tip + vec2(radius * 0.5, radius * 0.45)], s); +} + +fn paint_keyboard(p: &Painter, r: Rect, c: Color32) { + let r = Rect::from_center_size(r.center(), vec2(r.width() * 0.7, r.height() * 0.5)); + p.rect_stroke(r, 2.0, Stroke::new(1.2, c), StrokeKind::Inside); + for row in 0..2 { + for col in 0..4 { + let x = r.min.x + r.width() * (col as f32 + 0.75) / 4.5; + let y = r.min.y + r.height() * (row as f32 + 1.0) / 4.0; + p.circle_filled(pos2(x, y), 0.9, c); + } + } + p.line_segment( + [pos2(r.min.x + r.width() * 0.3, r.max.y - r.height() * 0.22), pos2(r.max.x - r.width() * 0.3, r.max.y - r.height() * 0.22)], + Stroke::new(1.2, c), + ); +} diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs new file mode 100644 index 00000000..dc88b091 --- /dev/null +++ b/crates/keymap/tests/ui_dialog.rs @@ -0,0 +1,517 @@ +#![cfg(feature = "ui")] +//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, +//! platform display, accessibility labels and small windows. + +use std::borrow::Cow; + +use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger}; +use egui::accesskit::Role; +use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use ui_fixture::fixture; + +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, + text: Box, + window: bool, + outcomes: Vec, + /// A key press was still in the frame's input after the dialog ran. + keys_left: bool, +} + +impl Fx { + fn new(km: Keymap) -> Fx { + Fx { + km, + options: Options::default(), + state: DialogState::new(), + text: Box::new(English), + window: false, + outcomes: Vec::new(), + keys_left: false, + } + } + + fn bindings(&self, id: &str) -> Vec { + self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() + } + + fn cancelled(&self) -> bool { + self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) + } +} + +fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { + let mut h = Harness::builder().with_size(size).build_ui_state( + |ui, fx: &mut Fx| { + let Fx { km, options, state, text, window, outcomes, keys_left } = fx; + let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; + outcomes.extend(out); + *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); + }, + fx, + ); + h.run(); + h +} + +fn open(fx: Fx) -> Harness<'static, Fx> { + harness(fx, vec2(1000.0, 700.0)) +} + +fn mac() -> Harness<'static, Fx> { + open(Fx::new(fixture(Platform::Mac))) +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +fn press(h: &mut Harness<'_, Fx>, modifiers: Modifiers, key: EguiKey) { + h.key_press_modifiers(modifiers, key); + h.run(); +} + +fn raw_key(h: &mut Harness<'_, Fx>, key: EguiKey, time: f64) { + h.input_mut().time = Some(time); + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().time = Some(time + 0.01); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn has(h: &Harness<'_, Fx>, label: &str) -> bool { + h.query_by_label(label).is_some() +} + +fn table_focused(h: &Harness<'_, Fx>) -> bool { + h.get_by_label("Commands").is_focused() +} + +fn chord(text: &str, platform: Platform) -> String { + Trigger::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")).display(platform) +} + +// ---- Recording ---- + +#[test] +fn recording_captures_a_chord_another_command_would_run() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + assert!(has(&h, "Save: press keys")); + h.state_mut().keys_left = false; + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(!h.state().keys_left, "the dialog took the key press"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "nothing applied before an answer"); +} + +#[test] +fn recording_takes_tab_arrows_and_shift_escape() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert_eq!(h.state().bindings("file.save"), ["Tab"]); + assert!(table_focused(&h), "Tab did not move focus"); + + click(&mut h, &format!("Save: {}", chord("Tab", Platform::Mac))); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert_eq!(h.state().bindings("file.save"), ["Down"]); + + click(&mut h, &format!("Save: {}", chord("Down", Platform::Mac))); + press(&mut h, Modifiers::SHIFT, EguiKey::Escape); + assert_eq!(h.state().bindings("file.save"), ["Shift+Escape"]); + assert!(table_focused(&h)); + assert!(h.state().state.is_open()); +} + +#[test] +fn escape_cancels_recording_only() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().state.is_open()); + assert!(!h.state().state.is_recording()); + assert!(!h.state().cancelled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + press(&mut h, Modifiers::NONE, EguiKey::Escape); + assert!(h.state().cancelled()); + assert!(!h.state().state.is_open()); +} + +#[test] +fn copy_event_records_cmd_c() { + let mut h = mac(); + click(&mut h, "Save: ⌘S"); + h.event(Event::Copy); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+C"]); +} + +#[test] +fn double_press_records_k_k() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.double_press = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + raw_key(&mut h, EguiKey::K, 10.0); + assert!(has(&h, "Save: K (press again for a double press)")); + raw_key(&mut h, EguiKey::K, 10.3); + assert_eq!(h.state().bindings("file.save"), ["K K"]); + + click(&mut h, "Save: K K"); + raw_key(&mut h, EguiKey::K, 20.0); + raw_key(&mut h, EguiKey::K, 20.9); + assert_eq!(h.state().bindings("file.save"), ["K"]); +} + +#[test] +fn the_new_shortcut_field_waits_for_assign() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::COMMAND, EguiKey::E); + assert!(has(&h, "Currently assigned to:")); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Assign"); + assert!(has(&h, "⚠ ⌘E is used by Export…")); + click(&mut h, "Keep Both"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); +} + +// ---- Conflicts ---- + +#[test] +fn conflict_take_it_steals_and_undo_restores() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It"); + assert_eq!(h.state().bindings("file.save"), Vec::::new()); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + assert!(has(&h, "⌘S was removed from Save")); + + // The footer's Undo is after the message's. + let undo = h.get_all_by_label("Undo").last().map(|n| n.rect()); + let Some(undo) = undo else { panic!("no Undo") }; + h.get_all_by_label("Undo").filter(|n| n.rect() == undo).for_each(|n| n.click()); + h.run(); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]); + click(&mut h, "Redo"); + assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]); + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.export"), ["Cmd+E"], "Cmd+Z undoes when no text field has focus"); +} + +#[test] +fn conflict_keep_both_lists_the_clash() { + let mut h = mac(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Keep Both"); + let clashes = h.state().km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count(); + assert_eq!(clashes, 1); + assert!(has(&h, "Also used by Save")); + click(&mut h, "⚠ 1 shortcut is assigned more than once"); + assert!(has(&h, "Save: ⌘S")); + assert!(has(&h, "Export…: ⌘S")); + assert!(!has(&h, "Save As…: ⇧⌘S"), "the Conflicts filter shows only the clash"); +} + +#[test] +fn steal_mode_says_what_it_took() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.conflicts = ConflictMode::Steal; + let mut h = open(fx); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "⌘S was removed from Save")); + click(&mut h, "Go to Conflict"); + assert!(selected(&h, "Save (Changed)")); +} + +/// Whether a row with this label reports itself selected. +fn selected(h: &Harness<'_, Fx>, label: &str) -> bool { + h.get_all_by_label(label).any(|n| n.accesskit_node().is_selected() == Some(true)) +} + +#[test] +fn go_to_conflict_selects_and_reveals_the_holder() { + let mut h = mac(); + choose_area(&mut h, "Tools"); + search(&mut h, "marquee"); + assert!(!has(&h, "Save: ⌘S")); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + click(&mut h, "Take It and Go to Conflict"); + assert_eq!(h.state().bindings("tool.marquee"), ["Cmd+S"]); + assert!(selected(&h, "Save (Changed)"), "the holder is selected"); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), ""); + assert!(has(&h, "Export…: ⌘E"), "area All, no filter"); +} + +#[test] +fn reserved_chord_is_refused() { + let km = fixture(Platform::Mac).with_reserved(vec![Chord::parse("Cmd+Tab").unwrap_or_else(|e| panic!("{e}"))]); + let mut h = open(Fx::new(km)); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::COMMAND, EguiKey::Tab); + assert!(has(&h, &format!("⚠ {} is reserved by the system", chord("Cmd+Tab", Platform::Mac)))); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn app_rule_refuses() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.accept = Some(Box::new(|b, binding| { + if matches!(b.kind, Kind::Tool { .. }) && !binding.trigger.chord.mods.is_none() { + Err("Tools take keys without modifiers".into()) + } else { + Ok(()) + } + })); + let mut h = open(fx); + click(&mut h, "Marquee Tool: M"); + press(&mut h, Modifiers::COMMAND, EguiKey::K); + assert!(has(&h, "⚠ Tools take keys without modifiers")); + assert_eq!(h.state().bindings("tool.marquee"), ["M"]); +} + +// ---- Search, areas, display ---- + +fn search(h: &mut Harness<'_, Fx>, text: &str) { + let field = h.get_by_label("Search:"); + field.focus(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::A); + h.run(); + h.get_by_label("Search:").type_text(text); + h.run(); +} + +fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + h.get_by_role_and_label(Role::Button, area).click(); + h.run(); +} + +struct German; +impl Text for German { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if n == 1 { one } else { other }.replace("{n}", &n.to_string()) + } + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + if b.command.id == "file.save" { "Sichern".into() } else { b.label.as_str().into() } + } +} + +#[test] +fn search_by_name_translation_or_chord() { + let mut h = mac(); + search(&mut h, "save"); + assert!(has(&h, "Save: ⌘S") && has(&h, "Save As…: ⇧⌘S")); + assert!(!has(&h, "Export…: ⌘E")); + search(&mut h, "cmd+s"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "⌘S"); + assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); + search(&mut h, "zzz"); + assert!(has(&h, "No matching commands")); + + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::S); + assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "⇧⌘S"); + assert!(has(&h, "Save As…: ⇧⌘S") && !has(&h, "Save: ⌘S")); + + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(German); + let mut h = open(fx); + search(&mut h, "sichern"); + assert!(has(&h, "Sichern: ⌘S") && !has(&h, "Save As…: ⇧⌘S")); +} + +#[test] +fn areas_follow_paths_kind_and_scope() { + let mut h = mac(); + h.get_by_role_and_label(Role::ComboBox, "Show:").click(); + h.run(); + for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { + assert!(h.get_all_by_label(area).count() >= 1, "{area}"); + } + h.get_by_role_and_label(Role::Button, "Tools").click(); + h.run(); + assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); + assert!(!has(&h, "Save: ⌘S")); + choose_area(&mut h, "Timeline Panel"); + assert!(has(&h, "Loop: L") && !has(&h, "Marquee Tool: M")); +} + +#[test] +fn display_follows_the_keymap_platform() { + let h = mac(); + assert!(has(&h, "Save As…: ⇧⌘S")); + assert!(has(&h, "Bold: ⌘B (Text)"), "a scope other than home is named"); + let h = open(Fx::new(fixture(Platform::Windows))); + assert!(has(&h, "Save As…: Ctrl+Shift+S")); +} + +// ---- Labels, focus, small windows, RTL ---- + +#[test] +fn every_control_has_a_label() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, "Take It")); + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + if matches!(n.role(), Role::Button | Role::Tab | Role::TextInput | Role::ComboBox) { + seen += 1; + assert!(n.label().is_some_and(|l| !l.trim().is_empty()), "{:?} without a label at {:?}", n.role(), node.rect()); + } + } + assert!(seen > 20, "{seen} controls"); +} + +#[test] +fn table_keys_move_record_and_delete() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(table_focused(&h)); + press(&mut h, Modifiers::NONE, EguiKey::ArrowDown); + assert!(selected(&h, "Save As…")); + press(&mut h, Modifiers::NONE, EguiKey::Delete); + assert_eq!(h.state().bindings("file.saveAs"), Vec::::new()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(h.state().state.is_recording()); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); +} + +#[test] +fn small_window_keeps_ok_and_long_labels_truncate() { + let long = "A very long command label ".repeat(12); + let mut km = fixture(Platform::Mac); + let mut catalogue = km.catalogue().to_vec(); + catalogue.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut fx = Fx::new(km); + fx.window = true; + let size = vec2(480.0, 360.0); + let mut h = harness(fx, size); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + let ok = h.get_by_label("OK").rect(); + assert!(screen.contains_rect(ok), "OK at {ok:?}"); + let row = h.get_by_label(long.trim()); + assert!(row.rect().max.x <= size.x + 0.5, "the row stays inside: {:?}", row.rect()); + row.hover(); + h.run(); + assert!(h.query_all_by_label_contains(long.trim()).count() >= 2, "the tooltip has the full label"); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); +} + +#[test] +fn window_close_is_cancel() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.window = true; + let mut h = open(fx); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert_eq!(h.state().bindings("file.save"), ["F2"]); + click(&mut h, "Cancel"); + assert!(h.state().cancelled()); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); +} + +#[test] +fn automation_ids_are_reported() { + let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut fx = Fx::new(fixture(Platform::Mac)); + let sink = seen.clone(); + fx.options.on_widget = Some(Box::new(move |id, rect, label| { + assert!(rect.is_finite() && !label.is_empty(), "{id}"); + sink.borrow_mut().push(id.to_string()); + })); + let mut h = open(fx); + h.get_by_label("Save").click(); + h.run(); + let ids = seen.borrow(); + for id in [ + "shortcuts.area", + "shortcuts.filter", + "shortcuts.search", + "shortcuts.searchKey", + "shortcuts.row.file.save", + "shortcuts.cell.file.save.0", + "shortcuts.add.file.save", + "shortcuts.reset.file.save", + "shortcuts.remove.0", + "shortcuts.new", + "shortcuts.context", + "shortcuts.assign", + "shortcuts.clear", + "shortcuts.undo", + "shortcuts.redo", + "shortcuts.resetAll", + "shortcuts.cancel", + "shortcuts.ok", + ] { + assert!(ids.iter().any(|s| s == id), "{id} not reported"); + } +} + +#[test] +fn typed_entry_parses_and_shows_errors() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + click(&mut h, "Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); +} + +struct Rtl; +impl Text for Rtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + fn plural(&self, _n: u64, _one: &str, other: &str) -> String { + other.to_string() + } + fn rtl(&self) -> bool { + true + } +} + +#[test] +fn rtl_puts_the_detail_pane_left_and_keeps_chords() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.text = Box::new(Rtl); + let h = open(fx); + let table = h.get_by_label("Commands").rect(); + assert!(table.min.x > 300.0, "table on the right: {table:?}"); + assert!(has(&h, "Save As…: ⇧⌘S")); +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs new file mode 100644 index 00000000..3b4b7fd4 --- /dev/null +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -0,0 +1,24 @@ +//! The catalogue the dialog tests and screenshots use. + +use craft_keymap::{Bindable, Binding, Keymap, Kind, Platform, Scope, Scopes}; + +fn binding(text: &str) -> Binding { + Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +pub fn fixture(platform: Platform) -> Keymap { + let tool = Kind::Tool { group: Some("Marquee".into()) }; + let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); + let catalogue = vec![ + Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), + Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), + Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), + Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])), + Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")), + Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")), + Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), + Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), + ]; + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs new file mode 100644 index 00000000..a0dd1f54 --- /dev/null +++ b/crates/keymap/tests/ui_snapshot.rs @@ -0,0 +1,94 @@ +#![cfg(feature = "snapshot")] +//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With +//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×; +//! without it the test does nothing. +//! +//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` + +use std::path::PathBuf; + +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; +use craft_keymap::{Keymap, Platform}; +use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ui_fixture::fixture; + +mod ui_fixture; + +struct Fx { + km: Keymap, + options: Options, + state: DialogState, +} + +/// A named state of the dialog: window size and what to do before the picture. +struct Case { + name: &'static str, + size: Vec2, + platform: Platform, + act: fn(&mut Harness<'_, Fx>), +} + +fn click(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + +const CASES: &[Case] = &[ + Case { + name: "selected", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + act: |h| { + h.get_by_label("Save As…").click(); + h.run(); + }, + }, + Case { + name: "conflict", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + act: |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }, + }, + Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") }, + Case { + name: "narrow", + size: Vec2::new(560.0, 640.0), + platform: Platform::Mac, + act: |h| { + h.get_by_label("Save").click(); + h.run(); + }, + }, +]; + +#[test] +fn dialog_screenshots() { + let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + for case in CASES { + for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { + for scale in [1.0, 2.0] { + let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() }; + let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( + |ui, fx: &mut Fx| { + egui::Frame::central_panel(ui.style()).show(ui, |ui| { + ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state); + }); + }, + fx, + ); + h.run(); + (case.act)(&mut h); + let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); + let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name)); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } + } +} From d2b9f9183856bda1652eac855cabbcb332b3dbd1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:15:32 +0000 Subject: [PATCH 013/124] keymap: shortcuts dialog sets, import review and summary Set row with picker, New Set (based on current shortcuts or a set), Rename, Delete, Save, Revert Set, Export, Import and Summarize; actions that do not fit go into a More menu. The foreign import review lists what will change, suggestions to pick, unmatched, unreadable, approximated and matched-without-change entries, then the report of skipped, taken and failed changes with Copy Report. ui::summary writes the shortcut summary as HTML and plain text. --- crates/keymap/src/ui/mod.rs | 1 + crates/keymap/src/ui/review.rs | 323 +++++++++++++++++++++++++++++ crates/keymap/src/ui/rows.rs | 8 +- crates/keymap/src/ui/sets.rs | 217 +++++++++++++++++++ crates/keymap/src/ui/state.rs | 45 +++- crates/keymap/src/ui/summary.rs | 113 ++++++++++ crates/keymap/src/ui/text.rs | 41 ++++ crates/keymap/src/ui/view.rs | 40 ++-- crates/keymap/tests/ui_dialog.rs | 210 ++++++++++++++++++- crates/keymap/tests/ui_snapshot.rs | 60 +++++- 10 files changed, 1019 insertions(+), 39 deletions(-) create mode 100644 crates/keymap/src/ui/review.rs create mode 100644 crates/keymap/src/ui/sets.rs create mode 100644 crates/keymap/src/ui/summary.rs diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index f09dc8e4..5075b37e 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -19,6 +19,7 @@ mod capture; mod rows; mod state; mod style; +pub mod summary; mod text; mod view; diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs new file mode 100644 index 00000000..fe642cd6 --- /dev/null +++ b/crates/keymap/src/ui/review.rs @@ -0,0 +1,323 @@ +//! The foreign import review (§2.7 of the dialog plan). It takes the place of the table and +//! detail pane: what the set will change, what did not match and why, suggestions to pick from, +//! and after the import the report of what was skipped, taken or failed. + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; + +use super::{Draw, FIELD_W, fill}; +use crate::chord::foreign::{ForeignError, ReadNote}; +use crate::command::CommandRef; +use crate::import::{FailedChange, ImportPlan, ImportReport, NoChangeReason, SkipReason, UnmatchedReason}; + +/// One list row: the item, its line, its keys and a note. +struct Entry { + item: String, + line: Option, + keys: String, + note: String, + /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. + to: bool, +} + +enum Act { + Pick(usize, usize), + Import, + Close, + Copy(String), +} + +impl Draw<'_, '_> { + /// Draws the review in `rect` while one is open; returns whether it drew. + pub(super) fn review(&mut self, ui: &mut Ui, rect: Rect) -> bool { + let Some(r) = self.state.review.as_ref() else { return false }; + let (plan, name, done) = (r.plan.clone(), r.name.clone(), r.done.clone()); + let mut act = None; + self.child(ui, rect, |d, ui| { + act = match &done { + Some((set, report)) => d.after_report(ui, set, report), + None => d.plan_view(ui, &plan, name), + }; + }); + match act { + Some(Act::Pick(s, c)) => self.state.pick_suggestion(self.env, s, c), + Some(Act::Import) => self.state.import_reviewed(self.env), + Some(Act::Close) => self.state.close_review(), + Some(Act::Copy(text)) => ui.ctx().copy_text(text), + None => {} + } + true + } + + fn line_text(&self, line: Option) -> Option { + line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())])) + } + + /// "item · line n · keys — note" on one wrapping line, the item in the strong colour. + fn entry(&self, ui: &mut Ui, e: &Entry) { + let font = TextStyle::Body.resolve(ui.style()); + let strong = ui.visuals().strong_text_color(); + let dim = self.style.dim; + let mut job = LayoutJob::default(); + let mut add = |s: &str, color: Color32| job.append(s, 0.0, TextFormat::simple(font.clone(), color)); + add(&self.vis(&e.item), strong); + if let Some(line) = self.line_text(e.line) { + add(&format!(" · {}", self.vis(&line)), dim); + } + if !e.keys.is_empty() { + add(&format!(" · {}", e.keys), ui.visuals().text_color()); + } + if e.to { + add(&format!(" — {}", self.vis(&e.note)), ui.visuals().text_color()); + } else if !e.note.is_empty() { + add(&format!(" — {}", self.vis(&e.note)), dim); + } + ui.add(Label::new(job).wrap()); + } + + fn section(&self, ui: &mut Ui, title: &str, entries: &[Entry]) { + if entries.is_empty() { + return; + } + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{title} ({})", entries.len()))).strong()); + for e in entries { + self.entry(ui, e); + } + } + + fn unmatched_reason(&self, r: UnmatchedReason) -> String { + match r { + UnmatchedReason::NoMatch => self.ui("no command has this name"), + UnmatchedReason::Ambiguous => self.ui("more than one command fits"), + UnmatchedReason::Context => self.ui("in a context that is not imported"), + UnmatchedReason::Tool => self.ui("only a command of the other kind fits (tool or menu command)"), + } + } + + fn foreign_error(&self, e: &ForeignError) -> String { + match e { + ForeignError::Empty => self.ui("empty shortcut"), + ForeignError::TooLong => self.ui("the shortcut is too long"), + ForeignError::UnknownKey(k) => fill(self.ui("unknown key {key}"), &[("key", k)]), + ForeignError::UnsupportedModifier(m) => fill(self.ui("unsupported modifier {key}"), &[("key", m)]), + } + } + + fn read_note(&self, n: ReadNote) -> String { + match n { + ReadNote::Numpad => self.ui("numeric keypad key read as main key"), + } + } + + fn plan_view(&mut self, ui: &mut Ui, plan: &ImportPlan, mut name: String) -> Option { + let mut act = None; + let report = &plan.report; + let set = report.set.clone().unwrap_or_else(|| self.ui("Imported")); + ui.label(RichText::new(self.vis(&fill(self.ui("Import “{set}”"), &[("set", &set)]))).strong()); + + let t = self.env.text; + let mut counts = vec![t.plural(plan.commands.len() as u64, "{n} command will change", "{n} commands will change")]; + let more = [ + (report.unmatched.len(), t.plural(report.unmatched.len() as u64, "{n} item not matched", "{n} items not matched")), + ( + report.unmatched_without_shortcut, + t.plural(report.unmatched_without_shortcut as u64, "{n} item without a shortcut", "{n} items without a shortcut"), + ), + (report.unreadable.len(), t.plural(report.unreadable.len() as u64, "{n} item could not be read", "{n} items could not be read")), + (report.approximated.len(), t.plural(report.approximated.len() as u64, "{n} key read approximately", "{n} keys read approximately")), + (report.truncated, t.plural(report.truncated as u64, "{n} entry cut at a limit", "{n} entries cut at a limit")), + ]; + counts.extend(more.into_iter().filter(|(n, _)| *n > 0).map(|(_, s)| s)); + ui.label(RichText::new(self.vis(&counts.join(" · "))).color(self.style.dim)); + + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Set name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut name).id(self.base.with("import_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.import.name".into(), edit.rect, &name_label); + let import = if self.env.sets.is_some() { self.ui("Import as New Set") } else { self.ui("Import") }; + let valid = self.env.sets.is_none() || crate::profiles::set_name(&name).is_some(); + if self.button(ui, valid, &import, "shortcuts.import.ok") { + act = Some(Act::Import); + } + if self.button(ui, true, &self.ui("Cancel Import"), "shortcuts.import.cancel") { + act = Some(Act::Close); + } + }); + if let Some(r) = self.state.review.as_mut() { + r.name = name; + } + + ScrollArea::vertical().id_salt(self.base.with("review")).auto_shrink([false, false]).show(ui, |ui| { + let chord_list = |d: &Self, c: &crate::import::Planned| -> String { + let home = d.env.keymap.bindable(&c.command).map(|b| b.scope.clone()); + if c.bindings.is_empty() { + return d.ui("None"); + } + c.bindings.iter().map(|b| d.binding_text(&b.binding, home.as_ref())).collect::>().join(", ") + }; + let changes: Vec = plan + .commands + .iter() + .map(|c| Entry { + item: c.item.clone(), + line: c.line, + keys: String::new(), + note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chord_list(self, c))]), + to: true, + }) + .collect(); + self.section(ui, &self.ui("Will change"), &changes); + + if !report.suggestions.is_empty() { + ui.add_space(4.0); + ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); + for (si, s) in report.suggestions.iter().enumerate() { + ui.horizontal_wrapped(|ui| { + self.entry(ui, &Entry { item: s.item.clone(), line: s.line, keys: String::new(), note: String::new(), to: false }); + let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]); + let choose = self.ui("Choose a command…"); + let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| { + for (ci, c) in s.candidates.iter().enumerate() { + if ui.selectable_label(false, self.vis(&self.label_of(c))).clicked() { + act = Some(Act::Pick(si, ci)); + } + } + }); + let resp = combo.response.on_hover_text(self.vis(&label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, &label)); + self.tag(|| format!("shortcuts.import.suggestion.{si}"), resp.rect, &label); + }); + } + } + + let unmatched: Vec = report + .unmatched + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.unmatched_reason(u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Not matched"), &unmatched); + let unreadable: Vec = report + .unreadable + .iter() + .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.foreign_error(&u.reason), to: false }) + .collect(); + self.section(ui, &self.ui("Unreadable"), &unreadable); + let approximated: Vec = report + .approximated + .iter() + .map(|a| Entry { item: a.item.clone(), line: a.line, keys: a.keys.clone(), note: self.read_note(a.note), to: false }) + .collect(); + self.section(ui, &self.ui("Approximated"), &approximated); + let kept: Vec = report + .matched_without_change + .iter() + .map(|m| Entry { + item: m.item.clone(), + line: m.line, + keys: String::new(), + note: match m.reason { + NoChangeReason::PanelMenu => { + fill(self.ui("{label} keeps its shortcuts (matched by a panel menu name)"), &[("label", &self.label_of(&m.command))]) + } + }, + to: false, + }) + .collect(); + self.section(ui, &self.ui("Matched without change"), &kept); + }); + act + } + + fn skip_note(&self, reason: SkipReason, kept_by: Option<&CommandRef>) -> String { + match (reason, kept_by) { + (SkipReason::Taken, Some(c)) => fill(self.ui("taken by {label}"), &[("label", &self.label_of(c))]), + (SkipReason::Taken, None) => self.ui("taken by a later entry"), + (SkipReason::Reserved, _) => self.ui("reserved by the system"), + (SkipReason::UnknownScope, _) => self.ui("the scope is unknown"), + } + } + + fn failed_note(&self, change: FailedChange) -> String { + match change { + FailedChange::Bind => self.ui("the set's shortcuts could not be added"), + FailedChange::Clear => self.ui("the shortcuts could not be removed"), + FailedChange::Unbind => self.ui("the shortcut could not be taken from it"), + } + } + + /// The report's lists: skipped keys, shortcuts taken from other commands, failed changes. + fn report_lists(&self, report: &ImportReport) -> [(String, Vec); 3] { + let skipped = report + .skipped + .iter() + .map(|s| Entry { + item: s.item.clone(), + line: s.line, + keys: s.shortcut.clone(), + note: self.skip_note(s.reason, s.kept_by.as_ref()), + to: false, + }) + .collect(); + let cleared = report + .conflicts_cleared + .iter() + .map(|c| Entry { item: self.label_of(&c.command), line: None, keys: c.shortcut.clone(), note: String::new(), to: false }) + .collect(); + let failed = report + .failed + .iter() + .map(|f| Entry { item: self.label_of(&f.command), line: None, keys: f.shortcuts.join(", "), note: self.failed_note(f.change), to: false }) + .collect(); + [(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)] + } + + fn after_report(&mut self, ui: &mut Ui, set: &str, report: &ImportReport) -> Option { + let mut act = None; + let heading = fill(self.ui("Imported as “{set}”"), &[("set", set)]); + ui.label(RichText::new(self.vis(&heading)).strong()); + let changed = self.env.text.plural(report.imported as u64, "{n} command changed", "{n} commands changed"); + ui.label(RichText::new(self.vis(&changed)).color(self.style.dim)); + let lists = self.report_lists(report); + ui.horizontal_wrapped(|ui| { + if self.button(ui, true, &self.ui("Copy Report"), "shortcuts.import.copy") { + act = Some(Act::Copy(self.report_text(&heading, &changed, &lists))); + } + if self.button(ui, true, &self.ui("Done"), "shortcuts.import.done") { + act = Some(Act::Close); + } + }); + ScrollArea::vertical().id_salt(self.base.with("report")).auto_shrink([false, false]).show(ui, |ui| { + for (title, entries) in &lists { + self.section(ui, title, entries); + } + }); + act + } + + /// The report as plain text for Copy Report (logical order). + fn report_text(&self, heading: &str, changed: &str, lists: &[(String, Vec)]) -> String { + let mut out = format!("{heading}\n{changed}\n"); + for (title, entries) in lists.iter().filter(|(_, e)| !e.is_empty()) { + out.push_str(&format!("\n{title}\n")); + for e in entries { + let mut line = format!(" {}", e.item); + for part in [self.line_text(e.line).unwrap_or_default(), e.keys.clone()] { + if !part.is_empty() { + line.push_str(" · "); + line.push_str(&part); + } + } + if !e.note.is_empty() { + line.push_str(" — "); + line.push_str(&e.note); + } + out.push_str(&line); + out.push('\n'); + } + } + out + } +} diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 69b0afaa..1e07a516 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -176,12 +176,6 @@ impl RowCache { pub(crate) fn built(&self) -> &Built { &self.built } - - /// Forgets the lines, for a change the key does not cover (the interface language). - #[allow(dead_code, reason = "for hosts that switch the interface language")] - pub(crate) fn invalidate(&mut self) { - self.key = None; - } } fn top_menu(b: &Bindable) -> Option<&str> { @@ -255,7 +249,7 @@ fn id_group(id: &str) -> String { /// A row's group header (`None`: no header) with its rank among groups, and how many path parts /// the header stands for. -fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { +pub(crate) fn group(area: &AreaId, b: &Bindable, submenus: bool, text: &dyn Text) -> (u8, Option, usize) { let path = b.paths.first().map(Vec::as_slice).unwrap_or_default(); let menu = |depth| path.first().map(|m| (0, Some(text.segment(m).into_owned()), depth)); match area { diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs new file mode 100644 index 00000000..64e39f01 --- /dev/null +++ b/crates/keymap/src/ui/sets.rs @@ -0,0 +1,217 @@ +//! The set row (§2.6 of the dialog plan): the set picker, then New Set…, Rename…, Delete, Save, +//! Revert Set, Export, Import… and Summarize…; actions that do not fit go into a More menu. New +//! Set… and Rename… open an inline form under the row. + +use craft_ui::layout::fit_toolbar; +use egui::{ComboBox, Key as EguiKey, RichText, TextEdit, Ui}; + +use super::{Draw, FIELD_W}; +use crate::profiles::{Profiles, set_name}; +use crate::ui::state::{DialogState, SetForm, SetFormKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Act { + New, + Rename, + Delete, + Save, + Revert, + /// Export as craft-keymap JSON; with app formats the item is a menu. + Export, + ExportAs(&'static str), + Import, + Summarize, +} + +struct Item { + act: Act, + text: String, + id: &'static str, + enabled: bool, +} + +impl Draw<'_, '_> { + /// Drawn above the header when the app passes sets. + pub(super) fn sets_row(&mut self, ui: &mut Ui) { + let Some((p, b)) = self.env.sets.as_ref() else { return }; + let names = p.names(b); + let active = p.active_name(b).to_string(); + let builtin = Profiles::is_builtin(&active, b); + let modified = DialogState::is_modified(self.env); + let editable = self.env.options.editable; + let items = self.set_items(builtin, modified); + let mut act = None; + ui.add_enabled_ui(self.state.review.is_none(), |ui| { + ui.horizontal(|ui| { + let set_label = self.ui("Set:"); + let l = ui.label(self.vis(&set_label)); + let mut pick = None; + let combo = ui.add_enabled_ui(editable, |ui| { + ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == active, self.vis(n)).clicked() { + pick = Some(n.clone()); + } + } + }) + }); + let combo = combo.inner.response.labelled_by(l.id); + self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); + if modified { + ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); + } + if let Some(n) = pick.filter(|n| *n != active) { + self.state.set_form = None; + self.state.switch_set(self.env, &n); + } + act = self.set_toolbar(ui, &items); + }); + self.set_form(ui); + }); + if let Some(a) = act { + self.set_act(a, &active); + } + } + + fn set_items(&self, builtin: bool, modified: bool) -> Vec { + let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled }; + let mut items = Vec::new(); + if self.env.options.editable { + items.push(item(Act::New, "New Set…", "shortcuts.set.new", true)); + items.push(item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin)); + items.push(item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin)); + items.push(item(Act::Save, "Save", "shortcuts.set.save", modified)); + items.push(item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified)); + } + let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; + items.push(item(Act::Export, export, "shortcuts.set.export", true)); + if self.env.options.editable { + items.push(item(Act::Import, "Import…", "shortcuts.set.import", true)); + } + if self.env.options.summary { + items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true)); + } + items + } + + /// The actions that fit in the row's remaining width, then More with the rest. + fn set_toolbar(&self, ui: &mut Ui, items: &[Item]) -> Option { + let more = self.ui("More"); + let natural: Vec = items.iter().map(|i| self.button_width(ui, &i.text)).collect(); + let spacing = ui.spacing().item_spacing.x; + let (shown, overflow): (Vec, Vec) = match fit_toolbar(&natural, ui.available_width(), spacing, self.button_width(ui, &more)) { + Some(fit) => (fit.shown.iter().map(|&(i, _)| i).collect(), fit.overflow), + None => (Vec::new(), (0..items.len()).collect()), + }; + let mut act = None; + for i in shown { + act = act.or(self.set_item(ui, &items[i])); + } + if !overflow.is_empty() { + let r = ui.menu_button(self.vis(&more), |ui| { + for &i in &overflow { + act = act.or(self.set_item(ui, &items[i])); + } + }); + self.tag(|| "shortcuts.set.more".into(), r.response.rect, &more); + } + act + } + + fn set_item(&self, ui: &mut Ui, item: &Item) -> Option { + if item.act == Act::Export && !self.env.options.export_formats.is_empty() { + let mut act = None; + let r = ui.menu_button(self.vis(&item.text), |ui| { + if self.button(ui, true, &self.ui("Keyboard Shortcuts (JSON)"), "shortcuts.set.export.json") { + act = Some(Act::Export); + } + for f in &self.env.options.export_formats { + let label = self.env.text.ui(f.label).into_owned(); + if self.button(ui, true, &label, &format!("shortcuts.set.export.{}", f.id)) { + act = Some(Act::ExportAs(f.id)); + } + } + }); + self.tag(|| item.id.into(), r.response.rect, &item.text); + return act; + } + self.button(ui, item.enabled, &item.text, item.id).then_some(item.act) + } + + fn set_act(&mut self, act: Act, active: &str) { + let form = |kind, name: &str| Some(SetForm { kind, name: name.to_string(), focus: true }); + match act { + Act::New => self.state.set_form = form(SetFormKind::New { based_on: None }, ""), + Act::Rename => self.state.set_form = form(SetFormKind::Rename, active), + Act::Delete => self.state.ask_delete_set(self.env), + Act::Save => self.state.save_set(self.env), + Act::Revert => self.state.revert_set(self.env), + Act::Export => self.state.export_set(self.env), + Act::ExportAs(f) => self.state.export_as(self.env, f), + Act::Import => self.state.request_import(self.env), + Act::Summarize => self.state.summarize(self.env), + } + } + + /// New Set… (Name, Based on) or Rename… (Name), under the set row. + fn set_form(&mut self, ui: &mut Ui) { + let Some(mut form) = self.state.set_form.clone() else { return }; + let names = self.env.sets.as_ref().map(|(p, b)| p.names(b)).unwrap_or_default(); + let (mut submit, mut cancel) = (false, false); + ui.horizontal_wrapped(|ui| { + let name_label = self.ui("Name:"); + let l = ui.label(self.vis(&name_label)); + let edit = ui.add(TextEdit::singleline(&mut form.name).id(self.base.with("set_name")).desired_width(FIELD_W)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.setForm.name".into(), edit.rect, &name_label); + if std::mem::take(&mut form.focus) { + edit.request_focus(); + } + if edit.lost_focus() { + let (enter, escape) = ui.input(|i| (i.key_pressed(EguiKey::Enter), i.key_pressed(EguiKey::Escape))); + submit |= enter; + cancel |= escape; + } + if let SetFormKind::New { based_on } = &mut form.kind { + let based_label = self.ui("Based on:"); + let l = ui.label(self.vis(&based_label)); + let current = self.ui("Current shortcuts"); + let shown = based_on.clone().unwrap_or_else(|| current.clone()); + let combo = ComboBox::from_id_salt(self.base.with("based_on")).selected_text(self.vis(&shown)).show_ui(ui, |ui| { + if ui.selectable_label(based_on.is_none(), self.vis(¤t)).clicked() { + *based_on = None; + } + for n in &names { + if ui.selectable_label(based_on.as_ref() == Some(n), self.vis(n)).clicked() { + *based_on = Some(n.clone()); + } + } + }); + let combo = combo.response.labelled_by(l.id); + self.tag(|| "shortcuts.setForm.basedOn".into(), combo.rect, &based_label); + } + let ok = match form.kind { + SetFormKind::New { .. } => self.ui("Create"), + SetFormKind::Rename => self.ui("Rename"), + }; + submit |= self.button(ui, set_name(&form.name).is_some(), &ok, "shortcuts.setForm.ok"); + cancel |= self.button(ui, true, &self.ui("Cancel"), "shortcuts.setForm.cancel"); + }); + if cancel { + self.state.set_form = None; + return; + } + if !submit || set_name(&form.name).is_none() { + self.state.set_form = Some(form); + return; + } + self.state.message = None; + match &form.kind { + SetFormKind::New { based_on } => self.state.new_set(self.env, &form.name, based_on.as_deref()), + SetFormKind::Rename => self.state.rename_set(self.env, &form.name), + } + // A refused name keeps the form open for another try. + let refused = self.state.message.as_ref().is_some_and(|m| m.refused); + self.state.set_form = refused.then_some(form); + } +} diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index edfc7d33..71bf9ba6 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -12,7 +12,7 @@ use crate::profiles::{Builtins, ProfileError, Profiles}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; -use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture}; +use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture, summary}; /// What the dialog edits this frame. pub(crate) struct Env<'e> { @@ -105,7 +105,6 @@ pub(crate) struct Message { } /// What to do after the Save changes prompt. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Then { Switch(String), @@ -113,7 +112,6 @@ pub(crate) enum Then { Import, } -#[allow(dead_code, reason = "set row prompts")] #[derive(Clone, Debug, PartialEq)] pub(crate) enum Prompt { /// "Restore every default shortcut in this set?" @@ -134,7 +132,6 @@ pub(crate) enum Answer { } /// A foreign set under review. -#[allow(dead_code, reason = "set row and import review")] #[derive(Clone, Debug)] pub(crate) struct Review { pub set: ForeignSet, @@ -146,6 +143,24 @@ pub(crate) struct Review { pub done: Option<(String, ImportReport)>, } +/// The set row's inline form: New Set… or Rename…. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SetForm { + pub kind: SetFormKind, + pub name: String, + /// Focus the name field on the next frame. + pub focus: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SetFormKind { + /// `None`: based on the current shortcuts. + New { + based_on: Option, + }, + Rename, +} + #[derive(Clone, Debug)] struct Opened { profiles: Option, @@ -199,6 +214,7 @@ pub struct DialogState { pub(crate) text_focus: bool, /// The table lines drawn last frame. pub(crate) visible: std::ops::Range, + pub(crate) set_form: Option, } impl Default for DialogState { @@ -239,6 +255,7 @@ impl DialogState { bottom_h: 0.0, text_focus: false, visible: 0..0, + set_form: None, } } @@ -693,6 +710,8 @@ impl DialogState { self.conflict = None; } else if self.prompt.is_some() { self.prompt = None; + } else if self.set_form.is_some() { + self.set_form = None; } else if self.review.is_some() { self.review = None; } else if self.field.is_some() { @@ -710,8 +729,6 @@ impl DialogState { } } -// The set row and the import review draw with these (T3); until then some are unused. -#[allow(dead_code, reason = "set row and import review actions")] impl DialogState { // ---- Sets ---- @@ -911,6 +928,22 @@ impl DialogState { result } + /// Summarize…: every command with a shortcut, as HTML and plain text. + pub(crate) fn summarize(&mut self, env: &Env) { + let title = env.text.ui(env.options.title).into_owned(); + let (name, heading) = match env.sets.as_ref() { + Some((p, b)) => { + let name = p.active_name(b).to_string(); + let heading = env.ui("{title}: {set}", &[("title", &title), ("set", &name)]); + (name, heading) + } + None => (title.clone(), title), + }; + let html = summary::html(env.keymap, env.text, &heading); + let text = summary::text(env.keymap, env.text, &heading); + self.outcome = Some(Outcome::Summary { name, html, text }); + } + /// Answers the open prompt. pub(crate) fn answer(&mut self, env: &mut Env, answer: Answer) { let Some(prompt) = self.prompt.take() else { return }; diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs new file mode 100644 index 00000000..ef2cb56b --- /dev/null +++ b/crates/keymap/src/ui/summary.rs @@ -0,0 +1,113 @@ +//! The shortcut summary behind Summarize…: every command that has a shortcut, grouped as in the +//! dialog's All Commands view, as an HTML page and as plain text. Apps can also call these for a +//! cheat sheet outside the dialog. + +use std::fmt::Write as _; + +use crate::keymap::{Binding, Keymap}; +use crate::scope::Scope; +use crate::ui::rows::{AreaId, group}; +use crate::ui::text::{Text, trigger_text}; + +struct Group { + rank: u8, + name: String, + /// (label, shortcuts as shown) + rows: Vec<(String, Vec)>, +} + +fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> String { + let chord = trigger_text(text, &b.trigger, km.platform()); + match &b.scope { + Some(s) if s != home => { + let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() }; + format!("{chord} ({scope})") + } + _ => chord, + } +} + +/// Groups in the table's order; commands in catalogue order within a group. +fn groups(km: &Keymap, text: &dyn Text) -> Vec { + let mut out: Vec = Vec::new(); + for b in km.catalogue() { + let bindings = km.bindings(&b.command); + if bindings.is_empty() { + continue; + } + let keys = bindings.iter().map(|x| binding_text(km, text, x, &b.scope)).collect(); + let (rank, name, _) = group(&AreaId::All, b, false, text); + let name = name.unwrap_or_default(); + let row = (text.command(b).into_owned(), keys); + match out.iter_mut().find(|g| g.rank == rank && g.name == name) { + Some(g) => g.rows.push(row), + None => out.push(Group { rank, name, rows: vec![row] }), + } + } + out.sort_by_key(|g| g.rank); + out +} + +/// Text for HTML element content and attribute values. +pub fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// One line of plain text: control characters (line breaks, tabs) become spaces, so a label +/// cannot break the layout. +fn one_line(s: &str) -> String { + s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +const CSS: &str = "body{font-family:system-ui,sans-serif;margin:2em;color:#111;background:#fff}\ +h1{font-size:1.4em}h2{font-size:1.1em;margin-top:1.5em;border-bottom:1px solid #ccc}\ +table{border-collapse:collapse;width:100%}th,td{padding:.2em .6em;vertical-align:top}\ +th{font-weight:normal;text-align:start;width:60%}tr:nth-child(even){background:#f4f4f4}\ +kbd{font-family:inherit;border:1px solid #bbb;border-radius:3px;padding:0 .3em;margin-inline-end:.3em;white-space:nowrap}\ +@media print{body{margin:0}tr{break-inside:avoid}}"; + +/// A standalone HTML page titled `title`. Chords are marked left to right so a right-to-left page +/// keeps them as written. +pub fn html(km: &Keymap, text: &dyn Text, title: &str) -> String { + let dir = if text.rtl() { "rtl" } else { "ltr" }; + let title = escape_html(title); + let mut s = String::new(); + let _ = write!( + s, + "\n\n\n\n{title}\n\n\n\n

{title}

\n" + ); + for g in groups(km, text) { + let _ = writeln!(s, "

{}

\n", escape_html(&g.name)); + for (label, keys) in &g.rows { + let keys: String = keys.iter().map(|k| format!("{}", escape_html(k))).collect(); + let _ = writeln!(s, "", escape_html(label)); + } + s.push_str("
{}{keys}
\n"); + } + s.push_str("\n\n"); + s +} + +/// Plain text titled `title`: a line per group, then ` labelshortcuts` per command. +pub fn text(km: &Keymap, text: &dyn Text, title: &str) -> String { + let mut s = one_line(title); + s.push('\n'); + for g in groups(km, text) { + let _ = write!(s, "\n{}\n", one_line(&g.name)); + for (label, keys) in &g.rows { + let _ = writeln!(s, " {}\t{}", one_line(label), one_line(&keys.join(", "))); + } + } + s +} diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 80455b3d..ecca273d 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -110,6 +110,11 @@ pub const PLURALS: &[(&str, &str)] = &[ ("{n} saved shortcut could not be used", "{n} saved shortcuts could not be used"), ("{n} command will change", "{n} commands will change"), ("{n} item without a shortcut", "{n} items without a shortcut"), + ("{n} item not matched", "{n} items not matched"), + ("{n} item could not be read", "{n} items could not be read"), + ("{n} key read approximately", "{n} keys read approximately"), + ("{n} entry cut at a limit", "{n} entries cut at a limit"), + ("{n} command changed", "{n} commands changed"), ("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"), ("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"), ]; @@ -204,7 +209,11 @@ pub const STRINGS: &[&str] = &[ "Revert Set", "Import…", "Export", + "Export…", "Summarize…", + "{title}: {set}", + "Create", + "Rename", "More", "Name:", "Based on:", @@ -223,15 +232,44 @@ pub const STRINGS: &[&str] = &[ "This file is from a newer version (format {n})", "The file is too large", // Import review. + "Import “{set}”", + "Set name:", + "Import", + "Cancel Import", + "line {n}", "Will change", "Not matched", "Unreadable", "Approximated", "Suggestions", "numeric keypad key read as main key", + "Matched without change", + "Command for {item}", + "Choose a command…", + "no command has this name", + "more than one command fits", + "in a context that is not imported", + "only a command of the other kind fits (tool or menu command)", + "empty shortcut", + "the shortcut is too long", + "unknown key {key}", + "unsupported modifier {key}", + "{label} keeps its shortcuts (matched by a panel menu name)", "Import as New Set", "Imported", + "Imported as “{set}”", + "Skipped", + "Taken from other commands", + "Not changed", + "taken by {label}", + "taken by a later entry", + "reserved by the system", + "the scope is unknown", + "the set's shortcuts could not be added", + "the shortcuts could not be removed", + "the shortcut could not be taken from it", "Copy Report", + "Done", ]; #[cfg(test)] @@ -271,6 +309,9 @@ mod tests { include_str!("capture.rs"), include_str!("view.rs"), include_str!("style.rs"), + include_str!("sets.rs"), + include_str!("review.rs"), + include_str!("summary.rs"), ]; let mut missing = Vec::new(); for src in sources { diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 1618a4dd..dbb42d62 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -26,6 +26,11 @@ use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; use crate::ui::style::{self, DialogStyle}; use crate::ui::text::trigger_text; +#[path = "review.rs"] +mod review; +#[path = "sets.rs"] +mod sets; + /// Below this width the detail pane goes under the table. const NARROW: f32 = 720.0; /// Smallest table or detail pane when dragging the divider. @@ -125,6 +130,13 @@ impl Draw<'_, '_> { r.clicked() } + /// The width a [`Button`] with `text` takes. + fn button_width(&self, ui: &Ui, text: &str) -> f32 { + let pad = ui.spacing().button_padding.x * 2.0; + let g = WidgetText::from(self.vis(text)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); + (g.size().x + pad).max(ui.spacing().interact_size.x) + } + fn icon_button(&self, ui: &mut Ui, label: &str, selected: bool, paint: fn(&Painter, Rect, Color32)) -> Response { IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) } @@ -211,7 +223,8 @@ impl Draw<'_, '_> { let gap = ui.spacing().item_spacing.y; ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { self.sets_row(ui); - self.header(ui); + // Show, Filter and Search act on the table, which the import review replaces. + ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); let rest = ui.available_rect_before_wrap(); let bottom_h = self.state.bottom_h.clamp(0.0, rest.height()); let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); @@ -231,15 +244,6 @@ impl Draw<'_, '_> { ui.advance_cursor_after_rect(full); } - /// The set row: drawn here, above the header, when the app passes sets (§2.6). - fn sets_row(&mut self, _ui: &mut Ui) {} - - /// The import review takes the place of the table and detail pane while it is open (§2.7). - /// Returns whether it drew. - fn review(&mut self, _ui: &mut Ui, _rect: Rect) -> bool { - false - } - fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { ui.set_clip_rect(rect.intersect(ui.clip_rect())); @@ -1177,16 +1181,14 @@ impl Draw<'_, '_> { self.state.ask_reset_all(); } } + if self.env.sets.is_none() && self.env.options.summary { + let label = self.ui("Summarize…"); + if self.button(ui, true, &label, "shortcuts.summarize") { + self.state.summarize(self.env); + } + } let gap = ui.spacing().item_spacing.x; - let pad = ui.spacing().button_padding.x * 2.0; - let min = ui.spacing().interact_size.x; - let width: f32 = right - .iter() - .map(|(t, _)| { - let g = WidgetText::from(self.vis(t)).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, TextStyle::Button); - (g.size().x + pad).max(min) + gap - }) - .sum(); + let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); ui.add_space((ui.available_width() - width).max(0.0)); for (text, id) in &right { if self.button(ui, true, text, id) { diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index dc88b091..05c11036 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -1,11 +1,14 @@ #![cfg(feature = "ui")] //! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas, -//! platform display, accessibility labels and small windows. +//! platform display, accessibility labels, small windows, sets, import review and the summary. use std::borrow::Cow; use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; -use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{ + Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, + Platform, Profiles, Scope, Scopes, Trigger, +}; use egui::accesskit::Role; use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; use egui_kittest::Harness; @@ -20,11 +23,24 @@ struct Fx { state: DialogState, text: Box, window: bool, + /// Profiles and built-in sets, when the dialog shows sets. + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, outcomes: Vec, /// A key press was still in the frame's input after the dialog ran. keys_left: bool, } +/// The dialog of `fx` for a call outside the frame (import_json, review_import). +fn dialog(fx: &mut Fx) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, text, sets, .. } = fx; + let d = ShortcutsDialog::new(km, &**text).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) +} + impl Fx { fn new(km: Keymap) -> Fx { Fx { @@ -33,6 +49,7 @@ impl Fx { state: DialogState::new(), text: Box::new(English), window: false, + sets: None, outcomes: Vec::new(), keys_left: false, } @@ -50,8 +67,12 @@ impl Fx { fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> { let mut h = Harness::builder().with_size(size).build_ui_state( |ui, fx: &mut Fx| { - let Fx { km, options, state, text, window, outcomes, keys_left } = fx; + let Fx { km, options, state, text, window, sets, outcomes, keys_left } = fx; let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options); + let dialog = match sets { + Some((p, b)) => dialog.sets(p, b), + None => dialog, + }; let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) }; outcomes.extend(out); *keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. }))); @@ -515,3 +536,186 @@ fn rtl_puts_the_detail_pane_left_and_keeps_chords() { assert!(table.min.x > 300.0, "table on the right: {table:?}"); assert!(has(&h, "Save As…: ⇧⌘S")); } + +// ---- Sets ---- + +fn with_sets(profiles: Profiles) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((profiles, vec![("Fixture Defaults", Overrides::new())])); + open(fx) +} + +fn profiles<'h>(h: &'h Harness<'_, Fx>) -> &'h Profiles { + &h.state().sets.as_ref().expect("sets").0 +} + +/// The last control with this label: a prompt's button rather than the set row's. +fn click_last(h: &mut Harness<'_, Fx>, label: &str) { + h.get_all_by_label(label).last().unwrap_or_else(|| panic!("{label}")).click(); + h.run(); +} + +fn new_set(h: &mut Harness<'_, Fx>, name: &str) { + click(h, "New Set…"); + h.get_by_role_and_label(Role::TextInput, "Name:").type_text(name); + h.run(); + click(h, "Create"); +} + +#[test] +fn cancel_restores_keymap_and_sets() { + let mut p = Profiles::default(); + p.save_as("Mine", Overrides::new(), &[("Fixture Defaults", Overrides::new())]).expect("save_as"); + let before = p.clone(); + let mut h = with_sets(p); + click(&mut h, "Set:"); + click_last(&mut h, "Mine"); + assert_eq!(profiles(&h).active, "Mine"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + assert!(has(&h, "(modified)")); + new_set(&mut h, "Other"); + assert_eq!(profiles(&h).active, "Other"); + assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + click_last(&mut h, "Delete"); + assert!(!profiles(&h).sets.contains_key("Other")); + click(&mut h, "Cancel"); + assert!(h.state().cancelled()); + assert_eq!(profiles(&h), &before); + assert!(h.state().km.overrides().is_empty()); +} + +#[test] +fn ok_stores_edits_of_a_builtin_as_a_copy() { + let mut h = with_sets(Profiles::default()); + assert!(h.get_by_label("Rename…").accesskit_node().is_disabled(), "built-ins cannot be renamed"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + click(&mut h, "OK"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); + let p = profiles(&h); + assert_eq!(p.active, "Custom"); + assert_eq!(p.sets.get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); +} + +#[test] +fn set_export_import_round_trip() { + let mut h = with_sets(Profiles::default()); + new_set(&mut h, "Name"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + h.get_all_by_label("Export…").next().expect("the set row's Export…").click(); + h.run(); + let (name, json) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Export { name, json } = o { Some((name.clone(), json.clone())) } else { None }) + .expect("Outcome::Export"); + assert_eq!(name, "Name"); + let edited = h.state().km.overrides().clone(); + + let mut fresh = with_sets(profiles(&h).clone()); + let (d, state) = dialog(fresh.state_mut()); + assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); + fresh.run(); + assert_eq!(profiles(&fresh).active, "Name 2"); + assert_eq!(fresh.state().km.overrides(), &edited); + assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); +} + +fn foreign_set() -> ForeignSet { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + } +} + +fn review(h: &mut Harness<'_, Fx>) { + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = dialog(h.state_mut()); + d.review_import(state, foreign_set(), options); + h.run(); +} + +#[test] +fn foreign_import_review_lists_skipped_and_unreadable() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(has(&h, "Import “Theirs”")); + for (item, line) in [("File > Save", 3), ("File > Frobnicate", 4), ("File > Export…", 5), ("Object > Arrange > Bring to Front", 6)] { + let found = h.query_all_by_label_contains(item).any(|n| n.accesskit_node().value().is_some_and(|l| l.contains(&format!("line {line}")))); + assert!(found, "{item} with line {line}"); + } + assert!(h.query_by_label_contains("unknown key").is_some(), "the unreadable key says why"); + assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); + assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); + click(&mut h, "Import as New Set"); + assert_eq!(profiles(&h).active, "Theirs"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); + assert!(has(&h, "Imported as “Theirs”")); + click(&mut h, "Done"); + assert!(h.state().state.is_open() && has(&h, "Commands")); +} + +#[test] +fn picking_a_suggestion_replans_and_is_remembered() { + let mut h = with_sets(Profiles::default()); + review(&mut h); + assert!(h.query_by_label_contains("3 commands will change").is_some()); + click(&mut h, "Command for File > Sve Az"); + click_last(&mut h, "Save As…"); + let remembered = h.state().outcomes.iter().find_map(|o| if let Outcome::Remembered(m) = o { Some(m.clone()) } else { None }); + assert!(remembered.is_some_and(|m| m.values().any(|c| c.id == "file.saveAs"))); + assert!(h.query_by_label_contains("4 commands will change").is_some()); + assert!(h.query_by_label_contains("File > Sve Az · line 7 — Save As…: ").is_some(), "the pick moved to Will change"); + assert!(h.query_by_label("Command for File > Sve Az").is_none()); +} + +// ---- Summary ---- + +#[test] +fn summary_escapes_html_and_keeps_text_lines() { + let mut km = fixture(Platform::Mac); + let mut catalogue = km.catalogue().to_vec(); + let label = "\"Tom\" & 'Jerry'\nNext"; + catalogue.push( + Bindable::new("edit.tricky", label, Scope::app()) + .with_path(vec!["Edit".into(), "Tricky".into()]) + .with_default(craft_keymap::Binding::parse("F6").expect("F6")), + ); + km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut h = open(Fx::new(km)); + click(&mut h, "Summarize…"); + let (html, text) = h + .state() + .outcomes + .iter() + .find_map(|o| if let Outcome::Summary { html, text, .. } = o { Some((html.clone(), text.clone())) } else { None }) + .expect("Outcome::Summary"); + assert!(html.contains("<b>"Tom" & 'Jerry'</b>"), "{html}"); + assert!(!html.contains("")); + assert!(html.contains("⌘S")); + assert!(text.lines().any(|l| l == " \"Tom\" & 'Jerry' Next\tF6"), "{text}"); + assert!(text.lines().any(|l| l == " Save\t⌘S"), "{text}"); +} + +#[test] +fn narrow_set_row_moves_actions_into_more() { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(560.0, 600.0)); + assert!(h.query_by_label("Summarize…").is_none(), "Summarize… is in More"); + click(&mut h, "More"); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index a0dd1f54..b232f5dc 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; -use craft_keymap::{Keymap, Platform}; +use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -20,6 +20,19 @@ struct Fx { km: Keymap, options: Options, state: DialogState, + sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, +} + +impl Fx { + fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { + let Fx { km, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &English).options(options); + let d = match sets { + Some((p, b)) => d.sets(p, b), + None => d, + }; + (d, state) + } } /// A named state of the dialog: window size and what to do before the picture. @@ -27,9 +40,29 @@ struct Case { name: &'static str, size: Vec2, platform: Platform, + /// Show named sets. + sets: bool, act: fn(&mut Harness<'_, Fx>), } +fn foreign_review(h: &mut Harness<'_, Fx>) { + let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ + ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), + ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), + ForeignEntry::new(Some("File"), ["Export…"]).with_key(read("Ctrl+Blorp")).at_line(5), + ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), + ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), + ], + }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let (d, state) = h.state_mut().dialog(); + d.review_import(state, set, options); + h.run(); +} + fn click(h: &mut Harness<'_, Fx>, label: &str) { h.get_by_label(label).click(); h.run(); @@ -40,6 +73,7 @@ const CASES: &[Case] = &[ name: "selected", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save As…").click(); h.run(); @@ -49,22 +83,38 @@ const CASES: &[Case] = &[ name: "conflict", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { click(h, "Export…: ⌘E"); h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); h.run(); }, }, - Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") }, + Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, sets: false, act: |h| click(h, "Save: Ctrl+S") }, Case { name: "narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, + sets: false, act: |h| { h.get_by_label("Save").click(); h.run(); }, }, + Case { + name: "sets", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Mac, + sets: true, + act: |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }, + }, + Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, act: |h| click(h, "More") }, + Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, act: foreign_review }, ]; #[test] @@ -74,11 +124,13 @@ fn dialog_screenshots() { for case in CASES { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { - let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() }; + let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { - ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state); + let (d, state) = fx.dialog(); + d.ui(ui, state); }); }, fx, From 94eacba872c0160572596ab4a8494a93ca372b28 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:29:41 +0000 Subject: [PATCH 014/124] keymap: on-screen keyboard pane, fixed-row labels and read-only mode The keyboard pane (Options::keyboard) draws a US ANSI layout under the table and detail pane, coloured by who holds each key with the toggled and held modifiers, lists the clicked key's shortcuts and assigns key and modifiers to the selected command. Fixed rows always report a label, with "Now used by" when a command took their key. A read-only dialog has no set row, keeps Summarize in the footer and closes with shortcuts.close. --- crates/keymap/src/ui/keyboard.rs | 636 +++++++++++++++++++++++++++++ crates/keymap/src/ui/sets.rs | 41 +- crates/keymap/src/ui/state.rs | 3 + crates/keymap/src/ui/text.rs | 23 ++ crates/keymap/src/ui/view.rs | 35 +- crates/keymap/tests/ui_dialog.rs | 115 +++++- crates/keymap/tests/ui_snapshot.rs | 46 ++- 7 files changed, 864 insertions(+), 35 deletions(-) create mode 100644 crates/keymap/src/ui/keyboard.rs diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs new file mode 100644 index 00000000..0b53b2b8 --- /dev/null +++ b/crates/keymap/src/ui/keyboard.rs @@ -0,0 +1,636 @@ +//! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout +//! coloured by who holds each key with the chosen modifiers, modifier toggles that also follow the +//! held modifiers, the shortcuts of the clicked key, and Assign to Selected. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + Align, Button, Color32, CornerRadius, FontId, Layout, Rect, RichText, ScrollArea, Sense, Stroke, StrokeKind, Ui, WidgetInfo, WidgetType, pos2, + vec2, +}; + +use super::{Draw, fill}; +use crate::chord::{Chord, Key, Mods, Trigger}; +use crate::command::CommandRef; +use crate::keymap::{Binding, Holder, Keymap, Overrides}; +use crate::scope::Scope; + +/// The keyboard's width and height in key units: 15 for the main block, a gap, 3 for the +/// navigation cluster; six rows and the gap under the function row. +const UNITS_W: f32 = 18.5; +const UNITS_H: f32 = 6.25; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Mod { + Cmd, + Ctrl, + Alt, + Shift, +} + +impl Mod { + fn id(self) -> &'static str { + match self { + Mod::Cmd => "cmd", + Mod::Ctrl => "ctrl", + Mod::Alt => "alt", + Mod::Shift => "shift", + } + } + + fn get(self, m: Mods) -> bool { + match self { + Mod::Cmd => m.cmd, + Mod::Ctrl => m.ctrl, + Mod::Alt => m.alt, + Mod::Shift => m.shift, + } + } + + fn flip(self, m: &mut Mods) { + match self { + Mod::Cmd => m.cmd = !m.cmd, + Mod::Ctrl => m.ctrl = !m.ctrl, + Mod::Alt => m.alt = !m.alt, + Mod::Shift => m.shift = !m.shift, + } + } +} + +/// A keycap: a key, a modifier, a key the dialog does not bind (Caps Lock, Win), or a gap. +#[derive(Clone, Copy)] +enum Cap { + Key(Key), + Mod(Mod), + CapsLock, + /// Win on Windows, Super on Linux; a Mac has none. + System, + Gap, +} + +type Row = &'static [(Cap, f32)]; + +const fn k(key: Key) -> (Cap, f32) { + (Cap::Key(key), 1.0) +} + +const MAIN_ROWS: [Row; 5] = [ + &[ + (Cap::Key(Key::Backtick), 1.0), + k(Key::Num1), + k(Key::Num2), + k(Key::Num3), + k(Key::Num4), + k(Key::Num5), + k(Key::Num6), + k(Key::Num7), + k(Key::Num8), + k(Key::Num9), + k(Key::Num0), + k(Key::Minus), + k(Key::Equals), + (Cap::Key(Key::Backspace), 2.0), + ], + &[ + (Cap::Key(Key::Tab), 1.5), + k(Key::Q), + k(Key::W), + k(Key::E), + k(Key::R), + k(Key::T), + k(Key::Y), + k(Key::U), + k(Key::I), + k(Key::O), + k(Key::P), + k(Key::OpenBracket), + k(Key::CloseBracket), + (Cap::Key(Key::Backslash), 1.5), + ], + &[ + (Cap::CapsLock, 1.75), + k(Key::A), + k(Key::S), + k(Key::D), + k(Key::F), + k(Key::G), + k(Key::H), + k(Key::J), + k(Key::K), + k(Key::L), + k(Key::Semicolon), + k(Key::Quote), + (Cap::Key(Key::Enter), 2.25), + ], + &[ + (Cap::Mod(Mod::Shift), 2.25), + k(Key::Z), + k(Key::X), + k(Key::C), + k(Key::V), + k(Key::B), + k(Key::N), + k(Key::M), + k(Key::Comma), + k(Key::Period), + k(Key::Slash), + (Cap::Mod(Mod::Shift), 2.75), + ], + &[], +]; + +const FUNCTION_ROW: Row = &[ + k(Key::Escape), + (Cap::Gap, 1.0), + k(Key::F1), + k(Key::F2), + k(Key::F3), + k(Key::F4), + (Cap::Gap, 0.5), + k(Key::F5), + k(Key::F6), + k(Key::F7), + k(Key::F8), + (Cap::Gap, 0.5), + k(Key::F9), + k(Key::F10), + k(Key::F11), + k(Key::F12), +]; + +const MAC_BOTTOM: Row = &[ + (Cap::Mod(Mod::Ctrl), 1.5), + (Cap::Mod(Mod::Alt), 1.25), + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::Key(Key::Space), 6.5), + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::Mod(Mod::Alt), 1.25), + (Cap::Mod(Mod::Ctrl), 1.5), +]; + +/// Off macOS the Ctrl keys are the command modifier. +const PC_BOTTOM: Row = &[ + (Cap::Mod(Mod::Cmd), 1.5), + (Cap::System, 1.25), + (Cap::Mod(Mod::Alt), 1.5), + (Cap::Key(Key::Space), 6.5), + (Cap::Mod(Mod::Alt), 1.5), + (Cap::System, 1.25), + (Cap::Mod(Mod::Cmd), 1.5), +]; + +/// The navigation cluster: (row, column in units, key). Row 0 is the function row. +const NAV: &[(usize, f32, Key)] = &[ + (1, 0.0, Key::Insert), + (1, 1.0, Key::Home), + (1, 2.0, Key::PageUp), + (2, 0.0, Key::Delete), + (2, 1.0, Key::End), + (2, 2.0, Key::PageDown), + (4, 1.0, Key::Up), + (5, 0.0, Key::Left), + (5, 1.0, Key::Down), + (5, 2.0, Key::Right), +]; + +/// A binding on a key and the scope it works in. +#[derive(Clone, Debug)] +pub(crate) struct KeyHolder { + pub holder: Holder, + pub scope: Scope, +} + +/// Who holds a key with given modifiers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Class { + Free, + App, + Scoped, + Both, +} + +fn class(holders: &[KeyHolder]) -> Class { + match (holders.iter().any(|h| h.scope.is_app()), holders.iter().any(|h| !h.scope.is_app())) { + (false, false) => Class::Free, + (true, false) => Class::App, + (false, true) => Class::Scoped, + (true, true) => Class::Both, + } +} + +/// The pane's state between frames. +#[derive(Clone, Debug, Default)] +pub(crate) struct KeyboardState { + /// Hidden with the header's Keyboard toggle. + pub hidden: bool, + /// The modifiers toggled on; the held ones are added each frame. + pub mods: Mods, + /// The key whose shortcuts are listed. + pub key: Option, + /// Every binding by effective chord, rebuilt when the overrides change. + index: Option>, +} + +type Index = HashMap>; + +impl KeyboardState { + fn index(&mut self, keymap: &Keymap) -> Arc<(Overrides, Index)> { + if let Some(i) = self.index.as_ref().filter(|i| i.0 == *keymap.overrides()) { + return i.clone(); + } + let platform = keymap.platform(); + let mut map: HashMap> = HashMap::new(); + for b in keymap.catalogue() { + for binding in keymap.bindings(&b.command) { + let scope = binding.scope_or(&b.scope).clone(); + let holder = Holder { command: b.command.clone(), binding }; + map.entry(holder.binding.trigger.chord.effective(platform)).or_default().push(KeyHolder { holder, scope }); + } + } + let index = Arc::new((keymap.overrides().clone(), map)); + self.index = Some(index.clone()); + index + } +} + +fn mod_glyph(m: Mod) -> &'static str { + match m { + Mod::Cmd => "⌘", + Mod::Ctrl => "⌃", + Mod::Alt => "⌥", + Mod::Shift => "⇧", + } +} + +/// The English legend printed on a key: Apple's words on macOS, PC words elsewhere. +fn cap_name(key: Key, mac: bool) -> &'static str { + match (key, mac) { + (Key::Escape, true) => "esc", + (Key::Escape, false) => "Esc", + (Key::Backspace, true) => "delete", + (Key::Delete, true) => "fwd del", + (Key::Insert, true) => "ins", + (Key::Enter, true) => "return", + (Key::Tab, true) => "tab", + (Key::PageUp, true) => "page up", + (Key::PageDown, true) => "page down", + (Key::Home, true) => "home", + (Key::End, true) => "end", + (Key::Insert, false) => "Ins", + (Key::PageUp, false) => "PgUp", + (Key::PageDown, false) => "PgDn", + (Key::Delete, false) => "Del", + (Key::Space, true) => "", + (Key::Up | Key::Down | Key::Left | Key::Right, _) => key.mac_symbol(), + (k, _) => k.text_name(), + } +} + +fn union(a: Mods, b: Mods) -> Mods { + Mods { cmd: a.cmd || b.cmd, ctrl: a.ctrl || b.ctrl, alt: a.alt || b.alt, shift: a.shift || b.shift } +} + +/// Black or white, whichever reads better on `fill`. +fn ink(fill: Color32) -> Color32 { + let l = 0.299 * f32::from(fill.r()) + 0.587 * f32::from(fill.g()) + 0.114 * f32::from(fill.b()); + if l > 150.0 { Color32::from_gray(24) } else { Color32::from_gray(240) } +} + +/// What a frame of the pane asked for. +#[derive(Default)] +struct Clicks { + key: Option, + toggle: Option, + go: Option, + assign: bool, +} + +impl Draw<'_, '_> { + /// The pane's height for a middle area of `height`, or `None` when it is off or does not fit. + pub(super) fn keyboard_height(&self, height: f32) -> Option { + if !self.env.options.keyboard || self.state.keyboard.hidden { + return None; + } + let h = (height * 0.45).min(320.0).min(height - 160.0); + (h >= 120.0).then_some(h) + } + + /// The header's toggle that shows and hides the pane. + pub(super) fn keyboard_toggle(&mut self, ui: &mut Ui) { + if !self.env.options.keyboard { + return; + } + let label = self.ui("Keyboard"); + let r = ui.add(Button::selectable(!self.state.keyboard.hidden, self.vis(&label))); + self.tag(|| "shortcuts.keyboard".into(), r.rect, &label); + if r.clicked() { + self.state.keyboard.hidden = !self.state.keyboard.hidden; + } + } + + fn mods_shown(&self) -> &'static [Mod] { + if self.env.keymap.platform().is_mac() { &[Mod::Cmd, Mod::Ctrl, Mod::Alt, Mod::Shift] } else { &[Mod::Cmd, Mod::Alt, Mod::Shift] } + } + + /// A modifier's name, with its glyph on macOS. + fn mod_name(&self, m: Mod) -> String { + let word = self.mod_word(m); + if self.env.keymap.platform().is_mac() { format!("{} {word}", mod_glyph(m)) } else { word } + } + + /// The key's name on its cap: the app's localized name, else the glyph on macOS. + fn cap_legend(&self, key: Key) -> String { + let p = self.env.keymap.platform(); + self.env.text.key(key, p).unwrap_or_else(|| cap_name(key, p.is_mac()).to_string()) + } + + /// A modifier cap's word, without the glyph (which goes above it on macOS). + fn mod_word(&self, m: Mod) -> String { + match (m, self.env.keymap.platform().is_mac()) { + (Mod::Cmd, true) => self.ui("Command"), + (Mod::Ctrl, true) => self.ui("Control"), + (Mod::Alt, true) => self.ui("Option"), + (Mod::Cmd | Mod::Ctrl, false) => self.ui("Ctrl"), + (Mod::Alt, false) => self.ui("Alt"), + (Mod::Shift, _) => self.ui("Shift"), + } + } + + /// The key's spoken name: the app's localized name, else the name Windows and Linux show. + fn key_name(&self, key: Key) -> String { + self.env.text.key(key, self.env.keymap.platform()).unwrap_or_else(|| key.text_name().to_string()) + } + + /// The holder's label, with its scope when that is not Application. + fn holder_text(&self, h: &KeyHolder) -> String { + let label = self.label_of(&h.holder.command); + if h.scope.is_app() { label } else { format!("{label} ({})", self.scope_name(&h.scope)) } + } + + pub(super) fn keyboard_pane(&mut self, ui: &mut Ui) { + let platform = self.env.keymap.platform(); + let held = if self.state.recording.is_none() { ui.input(|i| Mods::from_egui(&i.modifiers, platform)) } else { Mods::NONE }; + let mut clicks = Clicks::default(); + let rect = ui.max_rect(); + ui.painter().hline(rect.x_range(), rect.min.y, ui.visuals().widgets.noninteractive.bg_stroke); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + let layout = if platform.is_mac() { self.ui("Keyboard: US (macOS)") } else { self.ui("Keyboard: US") }; + ui.label(RichText::new(self.vis(&layout)).color(self.style.dim)); + ui.add_space(8.0); + for &m in self.mods_shown() { + let name = self.mod_name(m); + let on = m.get(union(self.state.keyboard.mods, held)); + let r = ui.add(Button::selectable(on, self.vis(&name))); + self.tag(|| format!("shortcuts.mod.{}", m.id()), r.rect, &name); + if r.clicked() { + clicks.toggle = Some(m); + } + } + ui.add_space(12.0); + self.keyboard_legend(ui); + }); + let mods = union(self.state.keyboard.mods, held); + let body = ui.available_rect_before_wrap(); + let gap = 12.0; + let list_w = (body.width() * 0.3).clamp(160.0, 320.0).min(body.width() / 2.0); + let rtl = self.env.text.rtl(); + let (kb, list) = if rtl { + ( + Rect::from_min_max(pos2(body.min.x + list_w + gap, body.min.y), body.max), + Rect::from_min_max(body.min, pos2(body.min.x + list_w, body.max.y)), + ) + } else { + ( + Rect::from_min_max(body.min, pos2(body.max.x - list_w - gap, body.max.y)), + Rect::from_min_max(pos2(body.max.x - list_w, body.min.y), body.max), + ) + }; + self.keys_drawn(ui, kb, mods, &mut clicks); + self.child(ui, list, |d, ui| d.key_list(ui, mods, &mut clicks)); + ui.advance_cursor_after_rect(body); + + let kb_state = &mut self.state.keyboard; + if let Some(m) = clicks.toggle { + // A held modifier is on anyway; clicking it turns the toggle off. + if m.get(held) { + let mut off = kb_state.mods; + if m.get(off) { + m.flip(&mut off); + } + kb_state.mods = off; + } else { + m.flip(&mut kb_state.mods); + } + } + if let Some(key) = clicks.key { + kb_state.key = Some(key); + } + if let Some(c) = clicks.go { + self.state.go_to(&c); + } + if clicks.assign + && let (Some(command), Some(key)) = (self.state.selected.clone(), self.state.keyboard.key) + { + let trigger = Trigger::single(Chord { mods, key }); + let binding = match &self.state.context { + Some(scope) => Binding::in_scope(trigger, scope.clone()), + None => Binding::new(trigger), + }; + self.state.stop_recording(); + self.state.commit(self.env, &command, None, binding); + } + } + + fn keyboard_legend(&self, ui: &mut Ui) { + let s = &self.style; + for (color, text) in [ + (s.app_key, self.ui("Application")), + (s.scoped_key, self.ui("Panel or context")), + (s.both_key, self.ui("Both")), + (s.modifier_key, self.ui("Modifier on")), + ] { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, color); + ui.label(RichText::new(self.vis(&text)).color(self.style.dim)); + } + } + + /// The keycaps in `area`, scaled to fit. + fn keys_drawn(&mut self, ui: &mut Ui, area: Rect, mods: Mods, clicks: &mut Clicks) { + let mac = self.env.keymap.platform().is_mac(); + let u = (area.width() / UNITS_W).min(area.height() / UNITS_H).max(1.0); + let origin = area.min; + let row_y = |row: usize| origin.y + row as f32 * u + if row > 0 { 0.25 * u } else { 0.0 }; + let index = self.state.keyboard.index(self.env.keymap); + let index = &index.1; + let bottom = if mac { MAC_BOTTOM } else { PC_BOTTOM }; + let rows = std::iter::once(FUNCTION_ROW).chain(MAIN_ROWS[..4].iter().copied()).chain(std::iter::once(bottom)); + for (r, row) in rows.enumerate() { + let mut x = origin.x; + for &(cap, w) in row { + let rect = Rect::from_min_size(pos2(x, row_y(r)), vec2(w * u, u)); + x += w * u; + self.cap(ui, rect, cap, u, mods, index, clicks); + } + } + let nav_x = origin.x + 15.5 * u; + for &(r, col, key) in NAV { + let rect = Rect::from_min_size(pos2(nav_x + col * u, row_y(r)), vec2(u, u)); + self.cap(ui, rect, Cap::Key(key), u, mods, index, clicks); + } + } + + #[allow(clippy::too_many_arguments)] + fn cap(&mut self, ui: &mut Ui, rect: Rect, cap: Cap, u: f32, mods: Mods, index: &Index, clicks: &mut Clicks) { + if matches!(cap, Cap::Gap) { + return; + } + let r = rect.shrink((u * 0.05).clamp(1.0, 2.0)); + let font = FontId::proportional((u * 0.26).clamp(7.0, 12.0)); + let s = self.style; + let (bg, legend, title) = match cap { + Cap::Key(key) => { + let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice); + let bg = match class(holders) { + Class::Free => s.free_key, + Class::App => s.app_key, + Class::Scoped => s.scoped_key, + Class::Both => s.both_key, + }; + // The application-wide holder names the key; a scoped one when there is none. + let first = holders.iter().find(|h| h.scope.is_app()).or(holders.first()); + (bg, self.cap_legend(key), first.map(|h| self.label_of(&h.holder.command))) + } + Cap::Mod(m) => { + let glyph = self.env.keymap.platform().is_mac().then(|| mod_glyph(m).to_string()); + (if m.get(mods) { s.modifier_key } else { s.free_key }, self.mod_word(m), glyph) + } + Cap::CapsLock => (s.free_key.gamma_multiply(0.7), self.ui("Caps Lock"), None), + Cap::System => { + let name = if self.env.keymap.platform() == crate::chord::Platform::Windows { self.ui("Win") } else { self.ui("Super") }; + (s.free_key.gamma_multiply(0.7), name, None) + } + Cap::Gap => return, + }; + let ink = ink(bg); + ui.painter().rect_filled(r, CornerRadius::same(3), bg); + let pad = (u * 0.1).clamp(2.0, 4.0); + let text_w = (r.width() - 2.0 * pad).max(1.0); + let lines = |text: &str, rows: usize, color: Color32| { + let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font.clone(), color)); + job.wrap = TextWrapping { max_width: text_w, max_rows: rows, break_anywhere: false, overflow_character: Some('…') }; + ui.painter().layout_job(job) + }; + if let Some(t) = &title { + let rows = if r.height() > 4.0 * font.size { 2 } else { 1 }; + let g = lines(&self.vis(t), rows, ink); + ui.painter().galley(r.min + vec2(pad, pad * 0.5), g, ink); + } + // A cap without a command name has room for a two-line legend ("page up"). + let legend_rows = if title.is_none() && r.height() > 3.0 * font.size { 2 } else { 1 }; + let g = lines(&self.vis(&legend), legend_rows, ink.gamma_multiply(0.85)); + ui.painter().galley(pos2(r.min.x + pad, r.max.y - pad * 0.5 - g.size().y), g, ink); + + let Cap::Key(key) = cap else { + if let Cap::Mod(m) = cap { + // Modifier caps toggle like the buttons above; the buttons are their accessible form. + let resp = ui.interact(r, self.base.with(("kbmod", m.id(), r.min.x as i32)), Sense::CLICK); + if resp.clicked() { + clicks.toggle = Some(m); + } + if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); + } + } + return; + }; + let holders = index.get(&Chord { mods, key }.effective(self.env.keymap.platform())).map_or(&[][..], Vec::as_slice); + let chord = self.chord(&Trigger::single(Chord { mods, key })); + let spoken = if mods.is_none() { self.key_name(key) } else { chord }; + let mut names: Vec = Vec::new(); + for h in holders { + let t = self.holder_text(h); + if !names.contains(&t) { + names.push(t); + } + } + let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) }; + let selected = self.state.keyboard.key == Some(key); + let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::CLICK); + resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y)); + let description = match class(holders) { + Class::Free => self.ui("Not assigned"), + Class::App => self.ui("Application"), + Class::Scoped => self.ui("Panel or context"), + Class::Both => self.ui("Both"), + }; + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_description(description)); + self.tag(|| format!("shortcuts.key.{}", key.name()), r, &a11y); + let v = ui.visuals(); + if selected { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside); + } else if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); + } + if resp.clicked() { + clicks.key = Some(key); + } + if !names.is_empty() { + resp.on_hover_text(self.vis(&names.join("\n"))); + } + } + + /// The clicked key's shortcuts with any modifiers, and Assign to Selected. + fn key_list(&mut self, ui: &mut Ui, mods: Mods, clicks: &mut Clicks) { + let Some(key) = self.state.keyboard.key else { + ui.label(RichText::new(self.vis(&self.ui("Click a key to see its shortcuts"))).color(self.style.dim)); + return; + }; + let platform = self.env.keymap.platform(); + let title = fill(self.ui("Key: {key}"), &[("key", &self.key_name(key))]); + ui.label(RichText::new(self.vis(&title)).strong()); + if self.env.options.editable { + let chord = self.chord(&Trigger::single(Chord { mods, key })); + let selected = self.state.selected.clone().filter(|c| self.env.keymap.bindable(c).is_some()); + let label = self.ui("Assign to Selected"); + let r = ui.add_enabled(selected.is_some(), Button::new(self.vis(&label))); + self.tag(|| "shortcuts.assignKey".into(), r.rect, &label); + let r = match &selected { + Some(c) => r.on_hover_text(self.vis(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]))), + None => r.on_disabled_hover_text(self.vis(&self.ui("Select a command first"))), + }; + if r.clicked() { + clicks.assign = true; + } + } + let index = self.state.keyboard.index(self.env.keymap); + let mut entries: Vec<(Chord, KeyHolder)> = + index.1.iter().filter(|(c, _)| c.key == key).flat_map(|(c, hs)| hs.iter().map(|h| (*c, h.clone()))).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.holder.command.id.cmp(&b.1.holder.command.id))); + if entries.is_empty() { + ui.label(RichText::new(self.vis(&self.ui("Nothing uses this key"))).color(self.style.dim)); + return; + } + let current = Chord { mods, key }.effective(platform); + let chord_w = 72.0; + ScrollArea::vertical().id_salt(self.base.with("keylist")).auto_shrink([false, false]).show(ui, |ui| { + for (c, h) in &entries { + ui.horizontal(|ui| { + let mut text = RichText::new(self.chord(&h.holder.binding.trigger)); + if *c == current { + text = text.strong(); + } + ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), Layout::left_to_right(Align::Center), |ui| { + ui.label(text) + }); + if ui.link(self.vis(&self.holder_text(h))).clicked() { + clicks.go = Some(h.holder.command.clone()); + } + }); + } + }); + } +} diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index 64e39f01..1df11548 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -31,14 +31,16 @@ struct Item { } impl Draw<'_, '_> { - /// Drawn above the header when the app passes sets. + /// Drawn above the header when the app passes sets and the dialog is editable. pub(super) fn sets_row(&mut self, ui: &mut Ui) { + if !self.env.options.editable { + return; + } let Some((p, b)) = self.env.sets.as_ref() else { return }; let names = p.names(b); let active = p.active_name(b).to_string(); let builtin = Profiles::is_builtin(&active, b); let modified = DialogState::is_modified(self.env); - let editable = self.env.options.editable; let items = self.set_items(builtin, modified); let mut act = None; ui.add_enabled_ui(self.state.review.is_none(), |ui| { @@ -46,16 +48,14 @@ impl Draw<'_, '_> { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); let mut pick = None; - let combo = ui.add_enabled_ui(editable, |ui| { - ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { - for n in &names { - if ui.selectable_label(*n == active, self.vis(n)).clicked() { - pick = Some(n.clone()); - } + let combo = ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == active, self.vis(n)).clicked() { + pick = Some(n.clone()); } - }) + } }); - let combo = combo.inner.response.labelled_by(l.id); + let combo = combo.response.labelled_by(l.id); self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); if modified { ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); @@ -75,19 +75,16 @@ impl Draw<'_, '_> { fn set_items(&self, builtin: bool, modified: bool) -> Vec { let item = |act, text: &str, id, enabled| Item { act, text: self.ui(text), id, enabled }; - let mut items = Vec::new(); - if self.env.options.editable { - items.push(item(Act::New, "New Set…", "shortcuts.set.new", true)); - items.push(item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin)); - items.push(item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin)); - items.push(item(Act::Save, "Save", "shortcuts.set.save", modified)); - items.push(item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified)); - } let export = if self.env.options.export_formats.is_empty() { "Export…" } else { "Export" }; - items.push(item(Act::Export, export, "shortcuts.set.export", true)); - if self.env.options.editable { - items.push(item(Act::Import, "Import…", "shortcuts.set.import", true)); - } + let mut items = vec![ + item(Act::New, "New Set…", "shortcuts.set.new", true), + item(Act::Rename, "Rename…", "shortcuts.set.rename", !builtin), + item(Act::Delete, "Delete", "shortcuts.set.delete", !builtin), + item(Act::Save, "Save", "shortcuts.set.save", modified), + item(Act::Revert, "Revert Set", "shortcuts.set.revert", modified), + item(Act::Export, export, "shortcuts.set.export", true), + item(Act::Import, "Import…", "shortcuts.set.import", true), + ]; if self.env.options.summary { items.push(item(Act::Summarize, "Summarize…", "shortcuts.summarize", true)); } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 71bf9ba6..34edd13e 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -12,6 +12,7 @@ use crate::profiles::{Builtins, ProfileError, Profiles}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; +use crate::ui::view::keyboard::KeyboardState; use crate::ui::{ConflictMode, JSON_FORMAT, Options, Outcome, capture, summary}; /// What the dialog edits this frame. @@ -215,6 +216,7 @@ pub struct DialogState { /// The table lines drawn last frame. pub(crate) visible: std::ops::Range, pub(crate) set_form: Option, + pub(crate) keyboard: KeyboardState, } impl Default for DialogState { @@ -256,6 +258,7 @@ impl DialogState { text_focus: false, visible: 0..0, set_form: None, + keyboard: KeyboardState::default(), } } diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index ecca273d..6787c9fa 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -178,6 +178,28 @@ pub const STRINGS: &[&str] = &[ "{label}: {chord}", "{label}: press keys", "Now used by {label}", + // On-screen keyboard. + "Keyboard", + "Keyboard: US", + "Keyboard: US (macOS)", + "Control", + "Option", + "Shift", + "Ctrl", + "Alt", + "Caps Lock", + "Win", + "Super", + "Panel or context", + "Both", + "Modifier on", + "{chord}: {label}", + "Click a key to see its shortcuts", + "Key: {key}", + "Assign to Selected", + "Assign {chord} to {label}", + "Select a command first", + "Nothing uses this key", // Messages and conflicts. "⚠ {chord} is used by {label}", "{chord} was removed from {label}", @@ -308,6 +330,7 @@ mod tests { include_str!("rows.rs"), include_str!("capture.rs"), include_str!("view.rs"), + include_str!("keyboard.rs"), include_str!("style.rs"), include_str!("sets.rs"), include_str!("review.rs"), diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index dbb42d62..844c75aa 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -26,6 +26,8 @@ use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; use crate::ui::style::{self, DialogStyle}; use crate::ui::text::trigger_text; +#[path = "keyboard.rs"] +pub(crate) mod keyboard; #[path = "review.rs"] mod review; #[path = "sets.rs"] @@ -321,6 +323,7 @@ impl Draw<'_, '_> { capture::lock_focus(ui, r.id); } } + self.keyboard_toggle(ui); }); } @@ -405,7 +408,20 @@ impl Draw<'_, '_> { // ---- Table and detail pane ---- + /// The table and detail pane, with the keyboard pane under both when it is on. fn middle(&mut self, ui: &mut Ui, mid: Rect) { + let Some(kb_h) = self.keyboard_height(mid.height()) else { + self.table_and_detail(ui, mid); + return; + }; + let gap = ui.spacing().item_spacing.y; + let top = Rect::from_min_max(mid.min, pos2(mid.max.x, mid.max.y - kb_h - gap)); + let pane = Rect::from_min_max(pos2(mid.min.x, mid.max.y - kb_h), mid.max); + self.table_and_detail(ui, top); + self.child(ui, pane, |d, ui| d.keyboard_pane(ui)); + } + + fn table_and_detail(&mut self, ui: &mut Ui, mid: Rect) { let wide = mid.width() >= NARROW; let axis = if wide { SplitAxis::Horizontal } else { SplitAxis::Vertical }; // Right to left: the detail pane goes on the left. @@ -791,11 +807,15 @@ impl Draw<'_, '_> { ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); - if let Some(by) = &f.now_used_by { - let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); - let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{}: {} ({tip})", f.label, f.keys))); - resp.on_hover_text(self.vis(&tip)); + let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + match &f.now_used_by { + Some(by) => { + let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({tip})"))); + resp.on_hover_text(self.vis(&tip)); + } + None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } } @@ -1167,7 +1187,7 @@ impl Draw<'_, '_> { let right: Vec<(String, &str)> = if editable { vec![(self.ui("Cancel"), "shortcuts.cancel"), (self.ui("OK"), "shortcuts.ok")] } else { - vec![(self.ui("Close"), "shortcuts.ok")] + vec![(self.ui("Close"), "shortcuts.close")] }; ui.horizontal(|ui| { if editable { @@ -1181,7 +1201,8 @@ impl Draw<'_, '_> { self.state.ask_reset_all(); } } - if self.env.sets.is_none() && self.env.options.summary { + // With sets, Summarize… is in the set row, which a read-only dialog does not show. + if (self.env.sets.is_none() || !editable) && self.env.options.summary { let label = self.ui("Summarize…"); if self.button(ui, true, &label, "shortcuts.summarize") { self.state.summarize(self.env); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 05c11036..f48f3802 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; -use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text}; +use craft_keymap::ui::{ConflictMode, DialogState, English, FixedRow, Options, Outcome, ShortcutsDialog, Text}; use craft_keymap::{ Bindable, Chord, CommandRef, ConflictKind, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Kind, Overrides, Platform, Profiles, Scope, Scopes, Trigger, @@ -468,6 +468,7 @@ fn window_close_is_cancel() { fn automation_ids_are_reported() { let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; let sink = seen.clone(); fx.options.on_widget = Some(Box::new(move |id, rect, label| { assert!(rect.is_finite() && !label.is_empty(), "{id}"); @@ -496,6 +497,9 @@ fn automation_ids_are_reported() { "shortcuts.resetAll", "shortcuts.cancel", "shortcuts.ok", + "shortcuts.keyboard", + "shortcuts.mod.cmd", + "shortcuts.key.F7", ] { assert!(ids.iter().any(|s| s == id), "{id} not reported"); } @@ -719,3 +723,112 @@ fn narrow_set_row_moves_actions_into_more() { click(&mut h, "Summarize…"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { name, .. } if name == "Fixture Defaults"))); } + +// ---- On-screen keyboard, fixed rows, read-only ---- + +fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { + let mut fx = Fx::new(fixture(Platform::Mac)); + fx.options.keyboard = true; + options(&mut fx); + open(fx) +} + +/// What a key's colour means, as its accessibility description says it. +fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { + h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() +} + +#[test] +fn keyboard_colours_keys_and_assigns_to_the_selected_command() { + let mut h = with_keyboard(|_| {}); + assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); + assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); + assert_eq!(holders_class(&h, "F7"), "Not assigned"); + + click(&mut h, "⌘ Command"); + assert_eq!(holders_class(&h, "⌘S: Save"), "Application"); + assert_eq!(holders_class(&h, "⌘B: Bold (Text)"), "Panel or context"); + // Held modifiers add to the toggles. + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.run(); + assert!(has(&h, "⇧⌘S: Save As…")); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run(); + click(&mut h, "⌘ Command"); + + click(&mut h, "L: Loop (Timeline)"); + assert!(has(&h, "Key: L")); + assert!(h.get_by_label("Assign to Selected").accesskit_node().is_disabled(), "no command selected"); + click(&mut h, "Save"); + click(&mut h, "Assign to Selected"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "L"]); + assert_eq!(holders_class(&h, "L: Save, Loop (Timeline)"), "Both"); + + // A holder in the key list selects its command. + click(&mut h, "Loop (Timeline)"); + assert!(selected(&h, "Loop")); +} + +#[test] +fn fixed_rows_are_struck_through_when_a_command_takes_their_key() { + let mut fx = Fx::new(fixture(Platform::Mac)); + let row = |label: &str, keys: &str, chord: Option<&str>| FixedRow { + group: "Rating".into(), + label: label.into(), + keys: keys.into(), + chord: chord.map(|c| Trigger::parse(c).unwrap_or_else(|e| panic!("{c}: {e}"))), + }; + fx.options.fixed = vec![row("Rate 1", "1", Some("1")), row("Rate 2", "2", Some("2")), row("Pan", "Space (hold)", None)]; + let mut h = open(fx); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1")); + assert!(has(&h, "Pan: Space (hold)")); + + choose_area(&mut h, "All Commands"); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::Num1); + assert_eq!(h.state().bindings("file.save"), ["1"]); + choose_area(&mut h, "Fixed Keys"); + assert!(has(&h, "Rate 1: 1 (Now used by Save)")); + assert!(has(&h, "Rate 2: 2")); +} + +#[test] +fn read_only_has_no_edit_controls() { + let mut h = with_keyboard(|fx| { + fx.options.editable = false; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + }); + click(&mut h, "Save"); + click(&mut h, "Save: ⌘S"); + assert!(!has(&h, "Save: press keys"), "no recording"); + click(&mut h, "S"); + assert!(has(&h, "Key: S")); + for label in [ + "Undo", + "Redo", + "Reset All…", + "OK", + "Cancel", + "Set:", + "New Set…", + "Import…", + "Add a shortcut to Save", + "Restore the default for Save", + "Remove ⌘S from Save", + "New shortcut:", + "Context:", + "Assign", + "Use Default", + "Clear", + "Assign to Selected", + ] { + assert!(!has(&h, label), "{label} is shown"); + } + press(&mut h, Modifiers::COMMAND, EguiKey::Z); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + click(&mut h, "Summarize…"); + assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); + click(&mut h, "Close"); + assert!(h.state().cancelled()); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index b232f5dc..33491a41 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -42,6 +42,8 @@ struct Case { platform: Platform, /// Show named sets. sets: bool, + /// Show the on-screen keyboard. + keyboard: bool, act: fn(&mut Harness<'_, Fx>), } @@ -74,6 +76,7 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { h.get_by_label("Save As…").click(); h.run(); @@ -84,18 +87,27 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { click(h, "Export…: ⌘E"); h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); h.run(); }, }, - Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, sets: false, act: |h| click(h, "Save: Ctrl+S") }, + Case { + name: "recording", + size: Vec2::new(1000.0, 640.0), + platform: Platform::Windows, + sets: false, + keyboard: false, + act: |h| click(h, "Save: Ctrl+S"), + }, Case { name: "narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: false, + keyboard: false, act: |h| { h.get_by_label("Save").click(); h.run(); @@ -106,6 +118,7 @@ const CASES: &[Case] = &[ size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, + keyboard: false, act: |h| { click(h, "Save: ⌘S"); h.key_press(EguiKey::F2); @@ -113,19 +126,42 @@ const CASES: &[Case] = &[ click(h, "New Set…"); }, }, - Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, act: |h| click(h, "More") }, - Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, act: foreign_review }, + Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: |h| click(h, "More") }, + Case { + name: "keyboard", + size: Vec2::new(1100.0, 720.0), + platform: Platform::Mac, + sets: false, + keyboard: true, + act: |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }, + }, + Case { + name: "keyboard-windows", + size: Vec2::new(860.0, 640.0), + platform: Platform::Windows, + sets: false, + keyboard: true, + act: |h| click(h, "L: Loop (Timeline)"), + }, + Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: foreign_review }, ]; #[test] fn dialog_screenshots() { let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return }; std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); - for case in CASES { + // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. + let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); - let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new(), sets }; + let options = Options { keyboard: case.keyboard, ..Options::default() }; + let fx = Fx { km: fixture(case.platform), options, state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { From d67d96e9bba123a0598aa41899be33ee3868f941 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:03:20 +0000 Subject: [PATCH 015/124] keymap: shortcuts dialog right-to-left layout, long-label truncation, pseudo-locale test and README --- crates/keymap/README.md | 85 ++++- crates/keymap/src/ui/keyboard.rs | 9 +- crates/keymap/src/ui/review.rs | 99 ++++-- crates/keymap/src/ui/rows.rs | 6 +- crates/keymap/src/ui/sets.rs | 4 +- crates/keymap/src/ui/state.rs | 15 +- crates/keymap/src/ui/view.rs | 439 +++++++++++++++++++------- crates/keymap/tests/ui_dialog.rs | 70 +++- crates/keymap/tests/ui_fixture/mod.rs | 134 +++++++- crates/keymap/tests/ui_snapshot.rs | 243 +++++++++----- 10 files changed, 859 insertions(+), 245 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0d4bd1a0..732a2eaf 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -7,13 +7,15 @@ supplies its command catalogue, its file-format readers and its UI. ## Boundaries -- Data and logic only. The apps keep the command catalogue (ids, labels, menu paths, defaults), - the readers for other applications' files, the shortcut editor UI and running the commands. +- Data and logic, plus a shared Keyboard Shortcuts dialog behind the `ui` feature. The apps keep + the command catalogue (ids, labels, menu paths, defaults), the readers for other applications' + files, file dialogs and running the commands. - Never crashes: no panics, no `unsafe`. Loading settings never fails; what cannot be used is kept or reported (`Keymap::problems`, the import report). - Hostile input is capped: commands, bindings per command, text lengths, import entries, keys, path segments and fuzzy suggestions (the `MAX_*` constants). What is cut is counted. -- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature. +- Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature; `craft-ui` and + `craft-i18n` with the `ui` feature. ## A catalogue and dispatch @@ -274,6 +276,77 @@ if let Recorded::Single(trigger) = recorder.press(Chord::parse("Cmd+C").unwrap() session.cancel(&mut keymap); ``` +## Shortcuts dialog + +With the `ui` feature, `ui::ShortcutsDialog` draws the Keyboard Shortcuts dialog over a `Keymap` +and, optionally, `Profiles`: a table of the catalogue grouped by menu, recording, conflicts, undo, +named sets, import review and a summary. It edits the keymap in place, so menus show new keys at +once, and Cancel puts everything back as it was when the dialog opened. + +Adopt it in three steps: + +1. Build the `Keymap` from the catalogue and keep the built-in sets (`Builtins`), as above. +2. While the dialog is open, keep a `DialogState` and call `show` (a window) or `ui` (the body, + for the app's own modal or preferences page) every frame. +3. Act on the `Outcome` it returns, and drop the state when `is_open()` turns false. + +```rust,ignore +use craft_keymap::ui::{DialogState, I18nText, Options, Outcome, ShortcutsDialog}; + +// "Keyboard Shortcuts…" opens it; "Edit Shortcut…" also calls `state.select(&command)`. +self.shortcuts = Some(DialogState::new()); + +// Every frame, before the app's own dispatch: +if let Some(state) = &mut self.shortcuts { + let text = I18nText { i18n: &self.i18n, lang: self.lang }; + let dialog = ShortcutsDialog::new(&mut self.keymap, &text).options(&self.shortcut_options); + let dialog = dialog.sets(&mut self.settings.shortcut_sets, &BUILTINS); + match dialog.show(ctx, state) { + Some(Outcome::Ok) => self.save_settings(), + Some(Outcome::Changed) => self.rebuild_menus(), + Some(Outcome::Import { formats }) => self.pick_import_file(formats), + Some(Outcome::Export { name, json }) => self.save_file(&name, json), + Some(Outcome::Summary { name, html, .. }) => self.show_summary(&name, html), + Some(Outcome::Remembered(answers)) => self.settings.import_answers.extend(answers), + _ => {} + } + if !state.is_open() { + self.shortcuts = None; + } +} +``` + +Outcomes: + +| Outcome | The app | +|---|---| +| `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | +| `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | +| `Changed` | Updates its menus, or saves at once if it saves live. | +| `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | +| `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | +| `Summary { name, html, text }` | Shows, saves or prints the summary. | +| `Remembered(answers)` | Keeps the user's import answers for the next import (`ImportOptions::remembered`). | + +The crate never opens a file dialog or touches the file system. + +Key dispatch while the dialog is open: + +- Skip `Dispatcher::dispatch` while `DialogState::is_open()` is true. The dialog takes the + frame's key, text and clipboard events itself while it records, so the chord the user presses + reaches the recorder and runs nothing. +- A native macOS menu handles its key equivalents before egui sees them, so Cmd+Q, Cmd+, and + the other menu chords never reach the recorder. While `is_recording()` is true, pause them: + feed them to egui as key presses, or rebuild the menu without accelerators. + +Words come from `ui::Text`. Apps on craft-i18n pass `I18nText`; others implement `ui` and +`plural` over their own lookup, and `rtl` and `visual` (the app's bidi reordering) for +right-to-left languages, which mirror the layout. Chord text is never reordered. `ui::STRINGS` +and `ui::PLURALS` list every string the dialog looks up, for the translation catalogue. +`Options` sets the conflict mode, double-press recording, area tabs, the on-screen keyboard, +read-only rows for keys outside the catalogue, the app's import and export formats, and an +`accept` rule for which keys a command may get. + ## Features - `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the @@ -303,3 +376,9 @@ session.cancel(&mut keymap); chords outside text fields, and a Paste event's text moves to `Hit::pasted`. An app whose paste command asks egui for a paste gets a Paste event back, which runs that command again; such an app keeps the default. + +- `ui`: the Shortcuts dialog (see above). Implies `egui`. +- `snapshot` (development): `tests/ui_snapshot.rs` writes PNGs of the dialog to + `CRAFT_KEYMAP_SNAPSHOT_DIR` for review. egui's default fonts lack ⇧ ⌥ ⌃ and the arrows; the + test adds the font in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans when it is installed, and BIZ + UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR` (⇧ ⌘ and arrows only). diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 0b53b2b8..9483ad26 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -599,7 +599,9 @@ impl Draw<'_, '_> { let r = ui.add_enabled(selected.is_some(), Button::new(self.vis(&label))); self.tag(|| "shortcuts.assignKey".into(), r.rect, &label); let r = match &selected { - Some(c) => r.on_hover_text(self.vis(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]))), + Some(c) => r.on_hover_text( + self.vis_chord(&fill(self.ui("Assign {chord} to {label}"), &[("chord", &chord), ("label", &self.label_of(c))]), &chord), + ), None => r.on_disabled_hover_text(self.vis(&self.ui("Select a command first"))), }; if r.clicked() { @@ -616,6 +618,7 @@ impl Draw<'_, '_> { } let current = Chord { mods, key }.effective(platform); let chord_w = 72.0; + let chord_layout = if self.env.text.rtl() { Layout::right_to_left(Align::Center) } else { Layout::left_to_right(Align::Center) }; ScrollArea::vertical().id_salt(self.base.with("keylist")).auto_shrink([false, false]).show(ui, |ui| { for (c, h) in &entries { ui.horizontal(|ui| { @@ -623,9 +626,7 @@ impl Draw<'_, '_> { if *c == current { text = text.strong(); } - ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), Layout::left_to_right(Align::Center), |ui| { - ui.label(text) - }); + ui.allocate_ui_with_layout(vec2(chord_w, ui.spacing().interact_size.y), chord_layout, |ui| ui.label(text)); if ui.link(self.vis(&self.holder_text(h))).clicked() { clicks.go = Some(h.holder.command.clone()); } diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index fe642cd6..c7823b5d 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -3,7 +3,7 @@ //! and after the import the report of what was skipped, taken or failed. use egui::text::{LayoutJob, TextFormat}; -use egui::{Color32, ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; +use egui::{ComboBox, Label, Rect, RichText, ScrollArea, TextEdit, TextStyle, Ui}; use super::{Draw, FIELD_W, fill}; use crate::chord::foreign::{ForeignError, ReadNote}; @@ -18,6 +18,8 @@ struct Entry { note: String, /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. to: bool, + /// Chord text inside the note, kept out of bidi reordering. + note_chord: String, } enum Act { @@ -58,19 +60,21 @@ impl Draw<'_, '_> { let font = TextStyle::Body.resolve(ui.style()); let strong = ui.visuals().strong_text_color(); let dim = self.style.dim; - let mut job = LayoutJob::default(); - let mut add = |s: &str, color: Color32| job.append(s, 0.0, TextFormat::simple(font.clone(), color)); - add(&self.vis(&e.item), strong); + let text = ui.visuals().text_color(); + let mut parts = vec![(self.vis(&e.item), strong)]; if let Some(line) = self.line_text(e.line) { - add(&format!(" · {}", self.vis(&line)), dim); + parts.extend([(" · ".to_string(), dim), (self.vis(&line), dim)]); } if !e.keys.is_empty() { - add(&format!(" · {}", e.keys), ui.visuals().text_color()); + parts.extend([(" · ".to_string(), dim), (e.keys.clone(), text)]); } - if e.to { - add(&format!(" — {}", self.vis(&e.note)), ui.visuals().text_color()); - } else if !e.note.is_empty() { - add(&format!(" — {}", self.vis(&e.note)), dim); + if !e.note.is_empty() { + let color = if e.to { text } else { dim }; + parts.extend([(" — ".to_string(), color), (self.vis_chord(&e.note, &e.note_chord), color)]); + } + let mut job = LayoutJob::default(); + for (s, color) in self.ordered(parts) { + job.append(&s, 0.0, TextFormat::simple(font.clone(), color)); } ui.add(Label::new(job).wrap()); } @@ -161,12 +165,16 @@ impl Draw<'_, '_> { let changes: Vec = plan .commands .iter() - .map(|c| Entry { - item: c.item.clone(), - line: c.line, - keys: String::new(), - note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chord_list(self, c))]), - to: true, + .map(|c| { + let chords = chord_list(self, c); + Entry { + item: c.item.clone(), + line: c.line, + keys: String::new(), + note: fill(self.ui("{label}: {chord}"), &[("label", &self.label_of(&c.command)), ("chord", &chords)]), + to: true, + note_chord: chords, + } }) .collect(); self.section(ui, &self.ui("Will change"), &changes); @@ -176,7 +184,17 @@ impl Draw<'_, '_> { ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); for (si, s) in report.suggestions.iter().enumerate() { ui.horizontal_wrapped(|ui| { - self.entry(ui, &Entry { item: s.item.clone(), line: s.line, keys: String::new(), note: String::new(), to: false }); + self.entry( + ui, + &Entry { + item: s.item.clone(), + line: s.line, + keys: String::new(), + note: String::new(), + to: false, + note_chord: String::new(), + }, + ); let label = fill(self.ui("Command for {item}"), &[("item", &s.item)]); let choose = self.ui("Choose a command…"); let combo = ComboBox::from_id_salt(self.base.with(("suggestion", si))).selected_text(self.vis(&choose)).show_ui(ui, |ui| { @@ -196,19 +214,40 @@ impl Draw<'_, '_> { let unmatched: Vec = report .unmatched .iter() - .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.unmatched_reason(u.reason), to: false }) + .map(|u| Entry { + item: u.item.clone(), + line: u.line, + keys: u.keys.clone(), + note: self.unmatched_reason(u.reason), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Not matched"), &unmatched); let unreadable: Vec = report .unreadable .iter() - .map(|u| Entry { item: u.item.clone(), line: u.line, keys: u.keys.clone(), note: self.foreign_error(&u.reason), to: false }) + .map(|u| Entry { + item: u.item.clone(), + line: u.line, + keys: u.keys.clone(), + note: self.foreign_error(&u.reason), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Unreadable"), &unreadable); let approximated: Vec = report .approximated .iter() - .map(|a| Entry { item: a.item.clone(), line: a.line, keys: a.keys.clone(), note: self.read_note(a.note), to: false }) + .map(|a| Entry { + item: a.item.clone(), + line: a.line, + keys: a.keys.clone(), + note: self.read_note(a.note), + to: false, + note_chord: String::new(), + }) .collect(); self.section(ui, &self.ui("Approximated"), &approximated); let kept: Vec = report @@ -224,6 +263,7 @@ impl Draw<'_, '_> { } }, to: false, + note_chord: String::new(), }) .collect(); self.section(ui, &self.ui("Matched without change"), &kept); @@ -259,17 +299,32 @@ impl Draw<'_, '_> { keys: s.shortcut.clone(), note: self.skip_note(s.reason, s.kept_by.as_ref()), to: false, + note_chord: String::new(), }) .collect(); let cleared = report .conflicts_cleared .iter() - .map(|c| Entry { item: self.label_of(&c.command), line: None, keys: c.shortcut.clone(), note: String::new(), to: false }) + .map(|c| Entry { + item: self.label_of(&c.command), + line: None, + keys: c.shortcut.clone(), + note: String::new(), + to: false, + note_chord: String::new(), + }) .collect(); let failed = report .failed .iter() - .map(|f| Entry { item: self.label_of(&f.command), line: None, keys: f.shortcuts.join(", "), note: self.failed_note(f.change), to: false }) + .map(|f| Entry { + item: self.label_of(&f.command), + line: None, + keys: f.shortcuts.join(", "), + note: self.failed_note(f.change), + to: false, + note_chord: String::new(), + }) .collect(); [(self.ui("Skipped"), skipped), (self.ui("Taken from other commands"), cleared), (self.ui("Not changed"), failed)] } diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 1e07a516..93804092 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -61,7 +61,7 @@ impl Filter { #[derive(Clone, Debug, PartialEq)] pub(crate) struct Chip { pub binding: Binding, - /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope. + /// Trigger text, plus ` (Scope)` for a scope other than the command's home scope (display order). pub text: String, /// Not one of the command's defaults. pub changed: bool, @@ -375,7 +375,9 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu .map(|x| { let mut t = trigger_text(text, &x.trigger, platform); if x.scope_or(&b.scope) != &b.scope { - t = format!("{t} ({})", text.segment(x.scope_or(&b.scope).as_str())); + let scope = format!("({})", text.segment(x.scope_or(&b.scope).as_str())); + // Display order; the chord itself is never reordered. + t = if text.rtl() { format!("{} {t}", text.visual(&scope)) } else { format!("{t} {scope}") }; } Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } }) diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index 1df11548..a18343a5 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -48,15 +48,13 @@ impl Draw<'_, '_> { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); let mut pick = None; - let combo = ComboBox::from_id_salt(self.base.with("set")).selected_text(self.vis(&active)).show_ui(ui, |ui| { + self.combo(ui, "set", &l, &set_label, &active, |ui| { for n in &names { if ui.selectable_label(*n == active, self.vis(n)).clicked() { pick = Some(n.clone()); } } }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.set".into(), combo.rect, &set_label); if modified { ui.label(RichText::new(self.vis(&self.ui("(modified)"))).color(self.style.dim)); } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 34edd13e..3c5f87b8 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -103,6 +103,8 @@ pub(crate) struct Message { pub goto: Option, /// Offers Undo. pub undo: bool, + /// The chord text inside `text`, which is never put through `Text::visual`. + pub chord: Option, } /// What to do after the Save changes prompt. @@ -507,7 +509,7 @@ impl DialogState { self.message = None; let chord = env.chord_text(&binding.trigger); if env.keymap.is_reserved(&binding.trigger.chord) { - self.refuse(env.ui("{chord} is reserved by the system", &[("chord", &chord)])); + self.refuse_chord(env.ui("{chord} is reserved by the system", &[("chord", &chord)]), chord); return; } if let (Some(accept), Some(b)) = (&env.options.accept, env.keymap.bindable(command)) @@ -532,10 +534,15 @@ impl DialogState { } fn refuse(&mut self, text: String) { - self.message = Some(Message { text, refused: true, goto: None, undo: false }); + self.message = Some(Message { text, refused: true, goto: None, undo: false, chord: None }); + } + + fn refuse_chord(&mut self, text: String, chord: String) { + self.message = Some(Message { text, refused: true, goto: None, undo: false, chord: Some(chord) }); } fn apply(&mut self, env: &mut Env, command: &CommandRef, old: Option, binding: Binding, policy: Policy) -> Option { + let chord = env.chord_text(&binding.trigger); let km = &mut *env.keymap; let session = self.session.get_or_insert_with(|| EditSession::begin(km)); let result = match &old { @@ -549,7 +556,7 @@ impl DialogState { } Err(e) => { let text = assign_error(env, &e); - self.refuse(text); + self.refuse_chord(text, chord); None } } @@ -566,7 +573,7 @@ impl DialogState { } let text = env.ui(text, &[("chord", &chord), ("label", &env.holder_labels(holders))]); let goto = holders.first().map(|h| h.command.clone()); - self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal }); + self.message = Some(Message { text, refused: false, goto, undo: policy == Policy::Steal, chord: Some(chord) }); } /// Take It. diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 844c75aa..05c430ef 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -6,14 +6,16 @@ //! frame's height and asks egui to discard a frame whose height changed. use std::ops::Range; +use std::sync::Arc; use craft_ui::buttons::{IconButton, IconButtonStyle}; use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; use craft_ui::tabs::{self, Tab}; +use egui::Galley; use egui::text::{LayoutJob, TextFormat, TextWrapping}; use egui::{ - Align, Button, Color32, ComboBox, EventFilter, FontId, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, - ScrollArea, Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, + Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, ScrollArea, + Sense, Stroke, StrokeKind, TextEdit, TextStyle, Ui, UiBuilder, Vec2, WidgetInfo, WidgetText, WidgetType, pos2, vec2, }; use crate::chord::Trigger; @@ -44,6 +46,11 @@ const CHIP_PAD: f32 = 5.0; const CHIP_GAP: f32 = 4.0; /// The detail pane's New Shortcut field. const FIELD_W: f32 = 160.0; +/// The widest Show, Filter or Set combo box; longer names are cut with "…". +const COMBO_MAX: f32 = 200.0; +/// Search field width. +const SEARCH_MIN: f32 = 120.0; +const SEARCH_MAX: f32 = 240.0; /// One frame of the dialog body inside `ui`'s available rectangle. pub(crate) fn body(ui: &mut Ui, env: &mut Env, state: &mut DialogState) { @@ -90,6 +97,93 @@ impl Draw<'_, '_> { self.env.text.visual(s) } + fn rtl(&self) -> bool { + self.env.text.rtl() + } + + /// Parts in logical order, each already in display order, put into display order: reversed in + /// a right-to-left interface, where the path separator ` › ` becomes ` ‹ `. + fn ordered(&self, mut parts: Vec<(String, T)>) -> Vec<(String, T)> { + if self.rtl() { + parts.reverse(); + for (s, _) in &mut parts { + if s == PATH_SEP { + *s = " ‹ ".into(); + } + } + } + parts + } + + /// [`ordered`](Self::ordered) parts as one string. + fn joined(&self, parts: Vec) -> String { + self.ordered(parts.into_iter().map(|s| (s, ())).collect()).into_iter().map(|(s, ())| s).collect() + } + + /// Display-order `parts` on one line within `width`, cut with "…" when they do not fit: at + /// the end, or at the start in a right-to-left interface, where the logical end of the text is + /// on the left. Returns the galley and whether it was cut. + fn one_line(&self, painter: &Painter, parts: &[(String, TextFormat)], width: f32) -> (Arc, bool) { + let width = width.max(0.0); + let job = |skip: usize| { + let mut job = LayoutJob::default(); + if let (true, Some((_, f))) = (skip > 0, parts.first()) { + job.append("…", 0.0, f.clone()); + } + let mut left = skip; + for (s, f) in parts { + let n = s.chars().count(); + if left >= n { + left -= n; + continue; + } + let start = s.char_indices().nth(left).map_or(s.len(), |(i, _)| i); + left = 0; + job.append(&s[start..], 0.0, f.clone()); + } + job + }; + let full = painter.layout_job(job(0)); + if full.size().x <= width + 0.5 { + return (full, false); + } + if !self.rtl() { + let mut cut = job(0); + cut.wrap = TextWrapping::truncate_at_width(width); + return (painter.layout_job(cut), true); + } + // The fewest characters to drop from the start so that "…" and the rest fit. + let total: usize = parts.iter().map(|(s, _)| s.chars().count()).sum(); + let (mut lo, mut hi) = (1, total); + while lo < hi { + let mid = (lo + hi) / 2; + if painter.layout_job(job(mid)).size().x <= width + 0.5 { hi = mid } else { lo = mid + 1 } + } + (painter.layout_job(job(lo)), true) + } + + /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes + /// through [`Text::visual`](crate::ui::Text::visual). + fn vis_chord(&self, s: &str, chord: &str) -> String { + match s.find(chord).filter(|_| !chord.is_empty()) { + None => self.vis(s), + Some(i) => { + let (before, rest) = s.split_at(i); + self.joined(vec![self.vis(before), chord.to_string(), self.vis(&rest[chord.len()..])]) + } + } + } + + /// `text` after a ⚠, in display order. + fn warned(&self, text: String) -> String { + self.joined(vec!["⚠".into(), " ".into(), text]) + } + + /// Horizontal placement inside `rect`, mirrored in a right-to-left interface. + fn mirror(&self, rect: Rect) -> Mirror { + Mirror { on: self.rtl(), sum: rect.min.x + rect.max.x } + } + fn chord(&self, t: &Trigger) -> String { trigger_text(self.env.text, t, self.env.keymap.platform()) } @@ -98,6 +192,15 @@ impl Draw<'_, '_> { if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } } + /// [`binding_text`](Self::binding_text) in display order. + fn binding_shown(&self, b: &Binding, home: Option<&Scope>) -> String { + let chord = self.chord(&b.trigger); + match &b.scope { + Some(s) if Some(s) != home => self.joined(vec![chord, " ".into(), self.vis(&format!("({})", self.scope_name(s)))]), + _ => chord, + } + } + /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { let chord = self.chord(&b.trigger); @@ -107,6 +210,18 @@ impl Draw<'_, '_> { } } + /// A menu path in display order: "File › Export", "Export ‹ File" right to left. + fn path_text(&self, path: &[String]) -> String { + let mut parts = Vec::new(); + for (i, s) in path.iter().enumerate() { + if i > 0 { + parts.push(PATH_SEP.to_string()); + } + parts.push(self.vis(&self.env.text.segment(s))); + } + self.joined(parts) + } + fn label_of(&self, command: &CommandRef) -> String { self.env.keymap.bindable(command).map_or_else(|| command.id.clone(), |b| self.env.text.command(b).into_owned()) } @@ -114,7 +229,10 @@ impl Draw<'_, '_> { /// "press keys", or the chord waiting for a second press. fn recording_text(&self) -> String { match self.state.recording.as_ref().and_then(|r| r.waiting.as_ref()) { - Some((t, _)) => fill(self.ui("{chord} (press again for a double press)"), &[("chord", &self.chord(t))]), + Some((t, _)) => { + let chord = self.chord(t); + self.vis_chord(&fill(self.ui("{chord} (press again for a double press)"), &[("chord", &chord)]), &chord) + } None => self.ui("press keys"), } } @@ -223,7 +341,8 @@ impl Draw<'_, '_> { let full = Rect::from_min_size(avail.min, vec2(size(avail.width(), 800.0), size(avail.height(), 600.0))); self.state.text_focus = false; let gap = ui.spacing().item_spacing.y; - ui.scope_builder(UiBuilder::new().max_rect(full).layout(Layout::top_down(Align::Min)), |ui| { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(full).layout(column), |ui| { self.sets_row(ui); // Show, Filter and Search act on the table, which the import review replaces. ui.add_enabled_ui(self.state.review.is_none(), |ui| self.header(ui)); @@ -236,8 +355,7 @@ impl Draw<'_, '_> { } let top = (mid.max.y + gap).min(rest.max.y); let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); - let used = - ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(Layout::top_down(Align::Min)), |ui| self.bottom(ui)).response.rect.height(); + let used = ui.scope_builder(UiBuilder::new().max_rect(bottom).layout(column), |ui| self.bottom(ui)).response.rect.height(); if (used - self.state.bottom_h).abs() > 0.5 { self.state.bottom_h = used; ui.ctx().request_discard("shortcuts dialog: height of the bars under the table"); @@ -246,8 +364,15 @@ impl Draw<'_, '_> { ui.advance_cursor_after_rect(full); } + /// Top to bottom, aligned to the reading side. In a right-to-left interface egui's + /// `horizontal` rows inside such a column also run right to left. + fn column(&self) -> Layout { + Layout::top_down(if self.env.text.rtl() { Align::Max } else { Align::Min }) + } + fn child(&mut self, ui: &mut Ui, rect: Rect, f: impl FnOnce(&mut Self, &mut Ui)) { - ui.scope_builder(UiBuilder::new().max_rect(rect).layout(Layout::top_down(Align::Min)), |ui| { + let column = self.column(); + ui.scope_builder(UiBuilder::new().max_rect(rect).layout(column), |ui| { ui.set_clip_rect(rect.intersect(ui.clip_rect())); f(self, ui); }); @@ -271,60 +396,98 @@ impl Draw<'_, '_> { if self.env.options.area_tabs { self.area_tabs(ui); } + // Show and Filter, then Search, ⌨ and Keyboard; Search goes to a row of its own when the + // two do not fit side by side. + let gap = ui.spacing().item_spacing.x; + let text_w = |d: &Self, ui: &Ui, s: &str| d.button_width(ui, s) - ui.spacing().button_padding.x * 2.0; + let mut first = text_w(self, ui, &self.ui("Filter:")) + COMBO_MAX + 2.0 * gap; + if !self.env.options.area_tabs { + first += text_w(self, ui, &self.ui("Show:")) + COMBO_MAX + 2.0 * gap; + } + let toggle = if self.env.options.keyboard { self.button_width(ui, &self.ui("Keyboard")) + gap } else { 0.0 }; + let second = text_w(self, ui, &self.ui("Search:")) + SEARCH_MIN + ICON + toggle + 3.0 * gap; + let one_row = first + second <= ui.available_width(); ui.horizontal(|ui| { if !self.env.options.area_tabs { self.area_combo(ui); } - let filter_label = self.ui("Filter:"); - let l = ui.label(self.vis(&filter_label)); - let current = self.state.filter.label(self.env.text); - let mut pick = None; - let combo = ComboBox::from_id_salt(self.base.with("filter")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { - for f in Filter::ALL { - let label = f.label(self.env.text); - if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { - pick = Some(f); - } - } - }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.filter".into(), combo.rect, &filter_label); - if let Some(f) = pick { - self.state.set_filter(f); + self.filter_combo(ui); + if one_row { + self.search(ui, toggle); } - - let search_label = self.ui("Search:"); - let l = ui.label(self.vis(&search_label)); - let mut q = self.state.query.clone(); - let width = (ui.available_width() - ICON - ui.spacing().item_spacing.x).clamp(60.0, 240.0); - let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); - self.text_field(&edit); - self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); - if edit.changed() { - self.state.set_query(q); - } - - let key_label = self.ui("Search by key"); - let searching = self.recording_target() == Some(&Target::Search); - let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); - self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); - if r.clicked() { - if searching { - self.state.stop_recording(); - } else { - self.state.record(self.env, Target::Search); - r.request_focus(); - } - } - if self.recording_target() == Some(&Target::Search) { - if r.lost_focus() { - self.state.stop_recording(); - } else if r.has_focus() || r.clicked() { - capture::lock_focus(ui, r.id); - } - } - self.keyboard_toggle(ui); }); + if !one_row { + ui.horizontal(|ui| self.search(ui, toggle)); + } + } + + /// A combo box at most [`COMBO_MAX`] wide, its text cut with "…" and shown in full on hover. + fn combo(&self, ui: &mut Ui, salt: &str, label: &Response, title: &str, current: &str, items: impl FnOnce(&mut Ui)) -> Response { + let full = self.vis(current); + let natural = self.button_width(ui, &full) + ui.spacing().icon_width + ui.spacing().icon_spacing; + let width = natural.clamp(ui.spacing().combo_width, COMBO_MAX); + let r = ui + .allocate_ui(vec2(width, ui.spacing().interact_size.y), |ui| { + ComboBox::from_id_salt(self.base.with(salt)).width(width).truncate().selected_text(full.clone()).show_ui(ui, items).response + }) + .inner + .labelled_by(label.id); + self.tag(|| format!("shortcuts.{salt}"), r.rect, title); + if natural > width { r.on_hover_text(full) } else { r } + } + + fn filter_combo(&mut self, ui: &mut Ui) { + let filter_label = self.ui("Filter:"); + let l = ui.label(self.vis(&filter_label)); + let current = self.state.filter.label(self.env.text); + let mut pick = None; + self.combo(ui, "filter", &l, &filter_label, ¤t, |ui| { + for f in Filter::ALL { + let label = f.label(self.env.text); + if ui.selectable_label(self.state.filter == f, self.vis(&label)).clicked() { + pick = Some(f); + } + } + }); + if let Some(f) = pick { + self.state.set_filter(f); + } + } + + /// Search, search by key and the keyboard pane's toggle (`toggle` wide, 0 without it). + fn search(&mut self, ui: &mut Ui, toggle: f32) { + let search_label = self.ui("Search:"); + let l = ui.label(self.vis(&search_label)); + let mut q = self.state.query.clone(); + let gap = ui.spacing().item_spacing.x; + let width = (ui.available_width() - ICON - toggle - 2.0 * gap).clamp(SEARCH_MIN.min(ui.available_width()), SEARCH_MAX); + let edit = ui.add(TextEdit::singleline(&mut q).id(self.base.with("search")).desired_width(width)).labelled_by(l.id); + self.text_field(&edit); + self.tag(|| "shortcuts.search".into(), edit.rect, &search_label); + if edit.changed() { + self.state.set_query(q); + } + + let key_label = self.ui("Search by key"); + let searching = self.recording_target() == Some(&Target::Search); + let r = self.icon_button(ui, &key_label, searching, paint_keyboard).on_hover_text(self.vis(&key_label)); + self.tag(|| "shortcuts.searchKey".into(), r.rect, &key_label); + if r.clicked() { + if searching { + self.state.stop_recording(); + } else { + self.state.record(self.env, Target::Search); + r.request_focus(); + } + } + if self.recording_target() == Some(&Target::Search) { + if r.lost_focus() { + self.state.stop_recording(); + } else if r.has_focus() || r.clicked() { + capture::lock_focus(ui, r.id); + } + } + self.keyboard_toggle(ui); } fn area_combo(&mut self, ui: &mut Ui) { @@ -333,7 +496,7 @@ impl Draw<'_, '_> { let current = self.state.areas.iter().find(|a| a.id == self.state.area).map_or_else(String::new, |a| a.label.clone()); let mut pick = None; let areas = self.state.areas.clone(); - let combo = ComboBox::from_id_salt(self.base.with("area")).selected_text(self.vis(¤t)).show_ui(ui, |ui| { + self.combo(ui, "area", &l, &show, ¤t, |ui| { for a in &areas { let r = ui.selectable_label(a.id == self.state.area, self.vis(&a.label)); self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), r.rect, &a.label); @@ -342,8 +505,6 @@ impl Draw<'_, '_> { } } }); - let combo = combo.response.labelled_by(l.id); - self.tag(|| "shortcuts.area".into(), combo.rect, &show); if let Some(a) = pick { self.state.set_area(a); } @@ -359,11 +520,12 @@ impl Draw<'_, '_> { let selected = areas.iter().position(|a| a.id == self.state.area).unwrap_or(0); let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), ui.spacing().interact_size.y + 4.0), Sense::hover()); let fit = tabs::fit(&natural, selected, rect.width(), 48.0, ICON + 8.0); + let m = self.mirror(rect); let mut x = rect.min.x; let mut pick = None; for &(i, w) in &fit.shown { let Some(a) = areas.get(i) else { continue }; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height())); + let r = m.rect(Rect::from_min_size(pos2(x, rect.min.y), vec2(w, rect.height()))); x += w; let on = i == selected; let id = self.base.with(("area", i)); @@ -378,7 +540,7 @@ impl Draw<'_, '_> { if on { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(2.0, accent)); } - let g = ui.painter().layout(text, font.clone(), color, r.width() - 4.0); + let (g, _) = self.one_line(ui.painter(), &[(text, TextFormat::simple(font.clone(), color))], r.width() - 4.0); ui.painter().galley(r.center() - g.size() / 2.0, g, color); }); self.tag(|| format!("shortcuts.area.{}", Self::area_key(&a.id)), resp.rect, &a.label); @@ -387,7 +549,7 @@ impl Draw<'_, '_> { } } if !fit.overflow.is_empty() { - let r = Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height())); + let r = m.rect(Rect::from_min_size(pos2(rect.max.x - fit.overflow_width, rect.min.y), vec2(fit.overflow_width, rect.height()))); let more = self.ui("More"); ui.scope_builder(UiBuilder::new().max_rect(r), |ui| { ui.menu_button(self.vis(&more), |ui| { @@ -453,19 +615,14 @@ impl Draw<'_, '_> { /// Width of the shortcut column: the widest row of chips plus "+", within 40 % of the table. fn shortcut_width(&self, ui: &Ui, width: f32) -> f32 { - let widest = self + let font = TextStyle::Body.resolve(ui.style()); + let chip_w = |text: &str| ui.painter().layout_no_wrap(text.to_string(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP; + let chips = self .state .lines_cached() .iter() - .filter_map(|l| if let Line::Row(r) = l { Some(r) } else { None }) - .max_by_key(|r| r.chips.iter().map(|c| c.text.chars().count() + 2).sum::()); - let font = TextStyle::Body.resolve(ui.style()); - let chips: f32 = widest.map_or(0.0, |r| { - r.chips - .iter() - .map(|c| ui.painter().layout_no_wrap(c.text.clone(), font.clone(), Color32::WHITE).size().x + 2.0 * CHIP_PAD + CHIP_GAP) - .sum() - }); + .filter_map(|l| if let Line::Row(r) = l { Some(r.chips.iter().map(|c| chip_w(&c.text)).sum::()) } else { None }) + .fold(0.0, f32::max); (chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4) } @@ -503,10 +660,13 @@ impl Draw<'_, '_> { let (head, _) = ui.allocate_exact_size(vec2(rect.width(), row_h), Sense::hover()); let font = TextStyle::Body.resolve(ui.style()); let dim = self.style.dim; - let cmd_title = self.vis(&self.ui("Command")); - let key_title = self.vis(&self.ui("Shortcut")); - ui.painter().text(pos2(head.min.x + 6.0, head.center().y), egui::Align2::LEFT_CENTER, cmd_title, font.clone(), dim); - ui.painter().text(pos2(head.max.x - shortcut_w - ICON - 4.0, head.center().y), egui::Align2::LEFT_CENTER, key_title, font, dim); + let m = self.mirror(head); + let key_x = head.max.x - shortcut_w - ICON - 4.0; + let titles = [(self.ui("Command"), head.min.x + 6.0, key_x - head.min.x - 12.0), (self.ui("Shortcut"), key_x, shortcut_w)]; + for (title, x, w) in titles { + let (g, _) = self.one_line(ui.painter(), &[(self.vis(&title), TextFormat::simple(font.clone(), dim))], w); + ui.painter().galley(pos2(m.x(x, g.size().x), head.center().y - g.size().y / 2.0), g, dim); + } ui.painter().line_segment([head.left_bottom(), head.right_bottom()], ui.visuals().widgets.noninteractive.bg_stroke); if matches == 0 { @@ -640,17 +800,22 @@ impl Draw<'_, '_> { resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); let v = ui.visuals(); let color = v.strong_text_color(); - let tri = Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0)); - let points = if collapsed { - vec![tri.left_top(), tri.right_center(), tri.left_bottom()] - } else { - vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + let m = self.mirror(rect); + let tri = m.rect(Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), Vec2::splat(8.0))); + let points = match (collapsed, m.on) { + (true, false) => vec![tri.left_top(), tri.right_center(), tri.left_bottom()], + (true, true) => vec![tri.right_top(), tri.right_bottom(), tri.left_center()], + (false, _) => vec![tri.left_top(), tri.right_top(), tri.center_bottom()], }; ui.painter().add(egui::Shape::convex_polygon(points, color, Stroke::NONE)); let font = TextStyle::Body.resolve(ui.style()); - let g = ui.painter().layout_job(truncated(&self.vis(name), font, color, rect.width() - 24.0)); - ui.painter().galley(pos2(rect.min.x + 20.0, rect.center().y - g.size().y / 2.0), g, color); - resp.clicked().then(|| RowAction::Toggle(name.to_string())) + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(name), TextFormat::simple(font, color))], rect.width() - 24.0); + ui.painter().galley(pos2(m.x(rect.min.x + 20.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let clicked = resp.clicked(); + if cut { + resp.on_hover_text(self.vis(name)); + } + clicked.then(|| RowAction::Toggle(name.to_string())) } fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option { @@ -674,8 +839,9 @@ impl Draw<'_, '_> { } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, v.widgets.hovered.weak_bg_fill); } + let m = self.mirror(rect); if r.changed { - ui.painter().rect_filled(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0)), 1.0, self.style.changed); + ui.painter().rect_filled(m.rect(Rect::from_min_size(rect.min + vec2(1.0, 3.0), vec2(3.0, rect.height() - 6.0))), 1.0, self.style.changed); } let text_color = if selected { v.strong_text_color() } else { v.text_color() }; let strong = v.strong_text_color(); @@ -684,25 +850,25 @@ impl Draw<'_, '_> { // Label column. let label_w = (rect.width() - shortcut_w - ICON - 12.0).max(0.0); - let mut job = LayoutJob::default(); + let fmt = |c: Color32| TextFormat::simple(font.clone(), c); + let mut parts = Vec::new(); if r.conflict { - job.append("⚠ ", 0.0, TextFormat::simple(font.clone(), self.style.warning)); + parts.extend([("⚠".to_string(), fmt(self.style.warning)), (" ".into(), fmt(dim))]); } for p in &r.prefix { - job.append(&format!("{} › ", self.vis(p)), 0.0, TextFormat::simple(font.clone(), dim)); + parts.extend([(self.vis(p), fmt(dim)), (PATH_SEP.into(), fmt(dim))]); } - job.append(&self.vis(&r.label), 0.0, TextFormat::simple(font.clone(), if r.changed { strong } else { text_color })); + parts.push((self.vis(&r.label), fmt(if r.changed { strong } else { text_color }))); if r.cycles { - job.append(&format!(" {}", self.vis(&self.ui("(cycles)"))), 0.0, TextFormat::simple(font.clone(), dim)); + parts.extend([(" ".into(), fmt(dim)), (self.vis(&self.ui("(cycles)")), fmt(dim))]); } - job.wrap = TextWrapping::truncate_at_width(label_w); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, text_color); + let (g, _) = self.one_line(ui.painter(), &self.ordered(parts), label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, text_color); // Shortcut column: chips, then "+". let mut action = None; let col = Rect::from_min_max(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.min.y), pos2(rect.max.x - ICON - 4.0, rect.max.y)); - let painter = ui.painter_at(col); + let painter = ui.painter_at(m.rect(col)); let mut x = col.min.x; let rec = match self.recording_target() { Some(Target::Cell { command, old }) if *command == r.command => Some(old.clone()), @@ -718,7 +884,7 @@ impl Draw<'_, '_> { let shown = if *recording { self.recording_text() } else { text.clone() }; let g = painter.layout_no_wrap(shown.clone(), font.clone(), text_color); let w = g.size().x + 2.0 * CHIP_PAD; - let chip = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h)); + let chip = m.rect(Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(w, chip_h))); x += w + CHIP_GAP; let changed = r.chips.get(n).is_some_and(|c| c.changed); let stroke = if *recording { @@ -739,7 +905,8 @@ impl Draw<'_, '_> { } else { fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", text)]) }; - let resp = ui.interact(chip.intersect(col), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); + let resp = + ui.interact(chip.intersect(m.rect(col)), self.base.with(("cell", &r.key, n)), if editable { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); self.tag(|| format!("shortcuts.cell.{}.{n}", r.key), chip, &label); if resp.clicked() { @@ -748,7 +915,7 @@ impl Draw<'_, '_> { } if cells.is_empty() { // An empty cell to click for the first shortcut. - let cell = Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y)); + let cell = m.rect(Rect::from_min_max(pos2(col.min.x, rect.min.y), pos2(col.max.x - ICON - CHIP_GAP, rect.max.y))); let label = fill(self.ui("{label}: {chord}"), &[("label", &r.label), ("chord", &self.ui("None"))]); let resp = ui.interact(cell, self.base.with(("cell", &r.key, 0)), if editable { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, editable, &label)); @@ -759,7 +926,7 @@ impl Draw<'_, '_> { x = col.min.x; } if editable && r.chips.len() < MAX_BINDINGS && rec != Some(None) { - let plus = Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let plus = m.rect(Rect::from_min_size(pos2(x.min(col.max.x - ICON), rect.center().y - ICON / 2.0), Vec2::splat(ICON))); let label = fill(self.ui("Add a shortcut to {label}"), &[("label", &r.label)]); let resp = self.row_button(ui, plus, self.base.with(("add", &r.key)), &label, true, paint_plus); self.tag(|| format!("shortcuts.add.{}", r.key), plus, &label); @@ -768,7 +935,7 @@ impl Draw<'_, '_> { } } if editable { - let reset = Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON)); + let reset = m.rect(Rect::from_min_size(pos2(rect.max.x - ICON - 2.0, rect.center().y - ICON / 2.0), Vec2::splat(ICON))); let label = fill(self.ui("Restore the default for {label}"), &[("label", &r.label)]); let resp = self.row_button(ui, reset, self.base.with(("reset", &r.key)), &label, r.changed, paint_reset); self.tag(|| format!("shortcuts.reset.{}", r.key), reset, &label); @@ -781,7 +948,7 @@ impl Draw<'_, '_> { let mut tip = vec![self.vis(&r.label)]; if let Some(b) = self.env.keymap.bindable(&r.command) { if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { - tip.push(path.iter().map(|s| self.vis(&self.env.text.segment(s))).collect::>().join(" › ")); + tip.push(self.path_text(path)); } tip.push(r.command.id.clone()); if let Some(h) = self.env.text.hint(b) { @@ -801,22 +968,29 @@ impl Draw<'_, '_> { if f.now_used_by.is_some() { fmt.strikethrough = Stroke::new(1.0, color); } - let mut job = LayoutJob::single_section(self.vis(&f.label), fmt.clone()); - job.wrap = TextWrapping::truncate_at_width((rect.width() - shortcut_w - ICON - 12.0).max(0.0)); - let g = ui.painter().layout_job(job); - ui.painter().galley(pos2(rect.min.x + 8.0, rect.center().y - g.size().y / 2.0), g, color); - let keys = ui.painter().layout_job(LayoutJob::single_section(f.keys.clone(), fmt)); - ui.painter().galley(pos2(rect.max.x - shortcut_w - ICON - 4.0, rect.center().y - keys.size().y / 2.0), keys, color); + let m = self.mirror(rect); + let label_w = rect.width() - shortcut_w - ICON - 12.0; + let (g, cut) = self.one_line(ui.painter(), &[(self.vis(&f.label), fmt.clone())], label_w); + ui.painter().galley(pos2(m.x(rect.min.x + 8.0, g.size().x), rect.center().y - g.size().y / 2.0), g, color); + let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); + ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + let mut tip = Vec::new(); + if cut { + tip.push(self.vis(&f.label)); + } match &f.now_used_by { Some(by) => { - let tip = fill(self.ui("Now used by {label}"), &[("label", by)]); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({tip})"))); - resp.on_hover_text(self.vis(&tip)); + let used = fill(self.ui("Now used by {label}"), &[("label", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({used})"))); + tip.push(self.vis(&used)); } None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } + if !tip.is_empty() { + resp.on_hover_text(tip.join("\n")); + } } fn detail(&mut self, ui: &mut Ui) { @@ -837,9 +1011,9 @@ impl Draw<'_, '_> { let home = Some(&b.scope); let dim = self.style.dim; ui.add(Label::new(RichText::new(self.vis(&label)).strong().size(ui.text_style_height(&TextStyle::Body) * 1.15)).wrap()); - let mut place: Vec = b.paths.first().map(|p| p.iter().map(|s| self.vis(&self.env.text.segment(s))).collect()).unwrap_or_default(); - place.retain(|s| !s.is_empty()); - let place = if place.is_empty() { b.command.id.clone() } else { format!("{} · {}", place.join(" › "), b.command.id) }; + let path: Vec = b.paths.first().map(|p| p.iter().filter(|s| !s.is_empty()).cloned().collect()).unwrap_or_default(); + let place = + if path.is_empty() { b.command.id.clone() } else { self.joined(vec![self.path_text(&path), " · ".into(), b.command.id.clone()]) }; ui.add(Label::new(RichText::new(place).color(dim)).wrap()); if let Some(h) = self.env.text.hint(&b) { ui.add(Label::new(RichText::new(self.vis(&h)).color(dim)).wrap()); @@ -858,7 +1032,11 @@ impl Draw<'_, '_> { let text = self.binding_text(binding, home); ui.horizontal(|ui| { ui.add_space(8.0); - let shown = if defaults.contains(binding) { format!("{text} ({})", self.vis(&self.ui("Default"))) } else { text.clone() }; + let shown = if defaults.contains(binding) { + self.joined(vec![self.binding_shown(binding, home), " ".into(), self.vis(&format!("({})", self.ui("Default")))]) + } else { + self.binding_shown(binding, home) + }; ui.label(shown); if editable { let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); @@ -899,7 +1077,8 @@ impl Draw<'_, '_> { fn defaults_line(&self, ui: &mut Ui, defaults: &[Binding], home: Option<&Scope>) { let chords = if defaults.is_empty() { self.ui("None") } else { defaults.iter().map(|d| self.binding_text(d, home)).collect::>().join(", ") }; - ui.add(Label::new(RichText::new(self.vis(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]))).color(self.style.dim)).wrap()); + let text = self.vis_chord(&fill(self.ui("Default: {chord}"), &[("chord", &chords)]), &chords); + ui.add(Label::new(RichText::new(text).color(self.style.dim)).wrap()); } /// New Shortcut: a field that records when focused or clicked, or typed entry. @@ -1017,7 +1196,7 @@ impl Draw<'_, '_> { }; ui.horizontal(|ui| { ui.add_space(8.0); - if ui.link(format!("⚠ {}", self.vis(&text))).clicked() { + if ui.link(self.warned(self.vis(&text))).clicked() { go = Some(h.command.clone()); } }); @@ -1080,7 +1259,8 @@ impl Draw<'_, '_> { fn conflict_bar(&mut self, ui: &mut Ui) { let Some(c) = self.state.conflict.clone() else { return }; - ui.add(Label::new(RichText::new(self.vis(&c.text)).color(self.style.warning)).wrap()); + let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); + ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); let mut act = 0; ui.horizontal_wrapped(|ui| { if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { @@ -1112,7 +1292,8 @@ impl Draw<'_, '_> { let Some(m) = self.state.message.clone() else { return }; let mut act = 0; ui.horizontal_wrapped(|ui| { - let text = if m.refused { format!("⚠ {}", self.vis(&m.text)) } else { self.vis(&m.text) }; + let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); + let text = if m.refused { self.warned(text) } else { text }; let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; ui.add(Label::new(RichText::new(text).color(color)).wrap()); if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { @@ -1142,7 +1323,7 @@ impl Draw<'_, '_> { if clashes > 0 { let n = clashes as u64; let text = self.env.text.plural(n, "{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"); - conflicts = ui.link(format!("⚠ {}", self.vis(&text))).clicked(); + conflicts = ui.link(self.warned(self.vis(&text))).clicked(); } if shadows > 0 { let n = shadows as u64; @@ -1224,10 +1405,26 @@ impl Draw<'_, '_> { } } -fn truncated(text: &str, font: FontId, color: Color32, width: f32) -> LayoutJob { - let mut job = LayoutJob::single_section(text.to_string(), TextFormat::simple(font, color)); - job.wrap = TextWrapping::truncate_at_width(width.max(0.0)); - job +/// Separator between menu path parts, mirrored by [`Draw::ordered`]. +const PATH_SEP: &str = " › "; + +/// Places rectangles laid out left to right inside a span, mirrored in a right-to-left interface. +#[derive(Clone, Copy)] +struct Mirror { + on: bool, + /// The span's left plus right edge. + sum: f32, +} + +impl Mirror { + fn rect(self, r: Rect) -> Rect { + if self.on { Rect::from_x_y_ranges((self.sum - r.max.x)..=(self.sum - r.min.x), r.y_range()) } else { r } + } + + /// The left edge of something `width` wide whose left-to-right left edge is `x`. + fn x(self, x: f32, width: f32) -> f32 { + if self.on { self.sum - x - width } else { x } + } } // ---- Icons, drawn so they do not depend on the app's fonts ---- diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index f48f3802..5ddf5800 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -13,7 +13,7 @@ use egui::accesskit::Role; use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; -use ui_fixture::fixture; +use ui_fixture::{Pseudo, fixture, with_long_label}; mod ui_fixture; @@ -538,7 +538,8 @@ fn rtl_puts_the_detail_pane_left_and_keeps_chords() { let h = open(fx); let table = h.get_by_label("Commands").rect(); assert!(table.min.x > 300.0, "table on the right: {table:?}"); - assert!(has(&h, "Save As…: ⇧⌘S")); + let chip = h.get_by_label("Save As…: ⇧⌘S").rect(); + assert!(chip.center().x < table.center().x, "shortcut column on the table's left: {chip:?} in {table:?}"); } // ---- Sets ---- @@ -832,3 +833,68 @@ fn read_only_has_no_edit_controls() { click(&mut h, "Close"); assert!(h.state().cancelled()); } + +// ---- Pseudo locale ---- + +/// Text a test put there or read from a foreign file, which no lookup translates. +const RAW: &[&str] = &["Theirs", "Frobnicate", "Sve Az", "Blorp", "Num", "Name 2"]; + +/// Every control inside the `width` px dialog, and every label with words marked by `Pseudo`. +fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { + let mut seen = 0; + for node in h.root().children_recursive() { + let n = node.accesskit_node(); + let Some(label) = n.label() else { continue }; + seen += 1; + let r = node.rect(); + assert!(r.min.x >= -1.0 && r.max.x <= width + 1.0, "{step}: {:?} {label:?} at {r:?} leaves the {width} px dialog", n.role()); + let mut rest = String::new(); + let mut depth = 0usize; + for c in label.chars() { + match c { + '⟦' => depth += 1, + '⟧' => depth = depth.saturating_sub(1), + _ if depth == 0 => rest.push(c), + _ => {} + } + } + for raw in RAW { + rest = rest.replace(raw, ""); + } + let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); + assert!(!words, "{step}: {label:?} has text that did not go through Text"); + } + assert!(seen > 15, "{step}: {seen} labelled nodes"); +} + +#[test] +fn pseudo_locale_marks_every_string_and_fits() { + let p = Pseudo::mark; + for rtl in [false, true] { + for width in [560.0, 1100.0] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); + } + } +} diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs index 3b4b7fd4..efcc82b1 100644 --- a/crates/keymap/tests/ui_fixture/mod.rs +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -1,6 +1,12 @@ //! The catalogue the dialog tests and screenshots use. -use craft_keymap::{Bindable, Binding, Keymap, Kind, Platform, Scope, Scopes}; +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::rc::Rc; + +use craft_keymap::ui::{PLURALS, STRINGS, Text}; +use craft_keymap::{Bindable, Binding, Key, Keymap, Kind, Platform, Scope, Scopes}; fn binding(text: &str) -> Binding { Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) @@ -22,3 +28,129 @@ pub fn fixture(platform: Platform) -> Keymap { ]; Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } + +/// `km` with a command whose label and menu path are far too long for a row. +#[allow(dead_code)] +pub fn with_long_label(km: Keymap) -> Keymap { + let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); + let mut catalogue = km.catalogue().to_vec(); + catalogue.insert( + 3, + Bindable::new("object.distribute", long.as_str(), Scope::app()) + .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) + .with_default(binding("Cmd+Alt+Shift+D")) + .with_default(binding("Ctrl+Alt+Shift+F12")), + ); + Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) +} + +/// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and +/// in `⟦…⟧`, so text drawn without the marks never went through [`Text`]. Placeholders stay as +/// they are. Labels and path parts are marked too. Looked-up dialog strings missing from +/// `STRINGS` or `PLURALS` are collected in `unlisted`. +#[allow(dead_code)] +#[derive(Default)] +pub struct Pseudo { + pub unlisted: Rc>>, + /// Lay the dialog out right to left. + pub rtl: bool, +} + +#[allow(dead_code)] +impl Pseudo { + pub fn mark(source: &str) -> String { + let mut out = String::from("⟦\u{200F}"); + let mut in_placeholder = false; + let mut letters = 0; + for c in source.chars() { + match c { + '{' => in_placeholder = true, + '}' => in_placeholder = false, + _ => {} + } + let accented = match c { + _ if in_placeholder => c, + 'a' => 'á', + 'e' => 'ë', + 'i' => 'ï', + 'o' => 'ö', + 'u' => 'ü', + 'A' => 'Å', + 'E' => 'É', + 'O' => 'Ø', + _ => c, + }; + if !in_placeholder && c.is_alphabetic() { + letters += 1; + } + out.push(accented); + } + out.extend(std::iter::repeat_n('~', letters * 2 / 5)); + out.push_str("\u{200F}⟧"); + out + } +} + +impl Text for Pseudo { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + if !STRINGS.contains(&source) { + self.unlisted.borrow_mut().insert(source.to_string()); + } + Pseudo::mark(source).into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + if !PLURALS.contains(&(one, other)) { + self.unlisted.borrow_mut().insert(format!("{one}|{other}")); + } + Pseudo::mark(if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> { + Pseudo::mark(&b.label).into() + } + + fn segment<'a>(&'a self, s: &'a str) -> Cow<'a, str> { + Pseudo::mark(s).into() + } + + /// Key names spelled out in words (Escape, Space), on every platform. + fn key(&self, k: Key, _p: Platform) -> Option { + let name = k.to_string(); + name.chars().any(|c| c.is_ascii_lowercase()).then(|| Pseudo::mark(&name)) + } + + fn rtl(&self) -> bool { + self.rtl + } +} + +/// English laid out right to left, to look at the mirrored layout. +#[allow(dead_code)] +pub struct EnglishRtl; + +impl Text for EnglishRtl { + fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> { + source.into() + } + + fn plural(&self, n: u64, one: &str, other: &str) -> String { + (if n == 1 { one } else { other }).replace("{n}", &n.to_string()) + } + + fn rtl(&self) -> bool { + true + } + + /// The words stay in English order; spaces at either end change sides, as in a right-to-left + /// paragraph. + fn visual(&self, s: &str) -> String { + let core = s.trim(); + if core.is_empty() { + return s.to_string(); + } + let start = s.len() - s.trim_start().len(); + let end = s.trim_end().len(); + format!("{}{core}{}", &s[end..], &s[..start]) + } +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index 33491a41..a6176af7 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -4,20 +4,26 @@ //! without it the test does nothing. //! //! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot` +//! +//! egui's default fonts have no ⇧ ⌥ ⌃ or arrows. Apps load their own fonts; here the pictures add +//! fallback fonts when they are found: the file in `CRAFT_KEYMAP_SNAPSHOT_FONT`, DejaVu Sans +//! from the usual Linux path, and BIZ UDPGothic from a craft-fonts checkout in `CRAFT_FONTS_DIR` +//! (⇧ ⌘ and arrows, not ⌥ ⌃). Without any of them those glyphs show as boxes. use std::path::PathBuf; -use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog}; +use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog, Text}; use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Overrides, Platform, Profiles}; use egui::{Key as EguiKey, Modifiers, Theme, Vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use ui_fixture::fixture; +use ui_fixture::{EnglishRtl, Pseudo, fixture, with_long_label}; mod ui_fixture; struct Fx { km: Keymap, + text: Box, options: Options, state: DialogState, sets: Option<(Profiles, Vec<(&'static str, Overrides)>)>, @@ -25,8 +31,8 @@ struct Fx { impl Fx { fn dialog(&mut self) -> (ShortcutsDialog<'_>, &mut DialogState) { - let Fx { km, options, state, sets } = self; - let d = ShortcutsDialog::new(km, &English).options(options); + let Fx { km, text, options, state, sets } = self; + let d = ShortcutsDialog::new(km, &**text).options(options); let d = match sets { Some((p, b)) => d.sets(p, b), None => d, @@ -44,9 +50,71 @@ struct Case { sets: bool, /// Show the on-screen keyboard. keyboard: bool, + words: Words, + /// Add a command with a very long label and name the user set at length. + long: bool, act: fn(&mut Harness<'_, Fx>), } +#[derive(Clone, Copy)] +enum Words { + English, + /// English laid out right to left. + Rtl, + /// The pseudo translation of the tests, left to right. + Pseudo, +} + +impl Case { + const fn new(name: &'static str, width: f32, height: f32, act: fn(&mut Harness<'_, Fx>)) -> Case { + Case { name, size: Vec2::new(width, height), platform: Platform::Mac, sets: false, keyboard: false, words: Words::English, long: false, act } + } + + const fn windows(mut self) -> Case { + self.platform = Platform::Windows; + self + } + + const fn sets(mut self) -> Case { + self.sets = true; + self + } + + const fn keyboard(mut self) -> Case { + self.keyboard = true; + self + } + + const fn words(mut self, words: Words) -> Case { + self.words = words; + self + } + + const fn long(mut self) -> Case { + self.long = true; + self + } +} + +/// Fallback fonts with the key glyphs, when this machine has them. +fn fonts() -> egui::FontDefinitions { + let mut defs = egui::FontDefinitions::default(); + let mut files: Vec = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_FONT").map(PathBuf::from).into_iter().collect(); + files.push("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf".into()); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR") { + files.push(PathBuf::from(dir).join("fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf")); + } + for (i, file) in files.iter().enumerate() { + let Ok(bytes) = std::fs::read(file) else { continue }; + let name = format!("fallback-{i}"); + defs.font_data.insert(name.clone(), std::sync::Arc::new(egui::FontData::from_owned(bytes))); + for family in defs.families.values_mut() { + family.push(name.clone()); + } + } + defs +} + fn foreign_review(h: &mut Harness<'_, Fx>) { let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows); let set = ForeignSet { @@ -70,84 +138,75 @@ fn click(h: &mut Harness<'_, Fx>, label: &str) { h.run(); } +fn select(h: &mut Harness<'_, Fx>, label: &str) { + h.get_by_label(label).click(); + h.run(); +} + const CASES: &[Case] = &[ - Case { - name: "selected", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - h.get_by_label("Save As…").click(); - h.run(); - }, - }, - Case { - name: "conflict", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - click(h, "Export…: ⌘E"); - h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); - h.run(); - }, - }, - Case { - name: "recording", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Windows, - sets: false, - keyboard: false, - act: |h| click(h, "Save: Ctrl+S"), - }, - Case { - name: "narrow", - size: Vec2::new(560.0, 640.0), - platform: Platform::Mac, - sets: false, - keyboard: false, - act: |h| { - h.get_by_label("Save").click(); - h.run(); - }, - }, - Case { - name: "sets", - size: Vec2::new(1000.0, 640.0), - platform: Platform::Mac, - sets: true, - keyboard: false, - act: |h| { - click(h, "Save: ⌘S"); - h.key_press(EguiKey::F2); - h.run(); - click(h, "New Set…"); - }, - }, - Case { name: "sets-narrow", size: Vec2::new(560.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: |h| click(h, "More") }, - Case { - name: "keyboard", - size: Vec2::new(1100.0, 720.0), - platform: Platform::Mac, - sets: false, - keyboard: true, - act: |h| { - click(h, "Save As…"); - click(h, "⌘ Command"); - click(h, "⌘S: Save"); - }, - }, - Case { - name: "keyboard-windows", - size: Vec2::new(860.0, 640.0), - platform: Platform::Windows, - sets: false, - keyboard: true, - act: |h| click(h, "L: Loop (Timeline)"), - }, - Case { name: "review", size: Vec2::new(1000.0, 640.0), platform: Platform::Mac, sets: true, keyboard: false, act: foreign_review }, + Case::new("selected", 1000.0, 640.0, |h| select(h, "Save As…")), + Case::new("conflict", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }), + Case::new("recording", 1000.0, 640.0, |h| click(h, "Save: Ctrl+S")).windows(), + Case::new("narrow", 560.0, 640.0, |h| select(h, "Save")), + Case::new("sets", 1000.0, 640.0, |h| { + click(h, "Save: ⌘S"); + h.key_press(EguiKey::F2); + h.run(); + click(h, "New Set…"); + }) + .sets(), + Case::new("sets-narrow", 560.0, 640.0, |h| click(h, "More")).sets(), + Case::new("keyboard", 1100.0, 720.0, |h| { + click(h, "Save As…"); + click(h, "⌘ Command"); + click(h, "⌘S: Save"); + }) + .keyboard(), + Case::new("keyboard-windows", 860.0, 640.0, |h| click(h, "L: Loop (Timeline)")).windows().keyboard(), + Case::new("review", 1000.0, 640.0, foreign_review).sets(), + Case::new("long", 1000.0, 640.0, |h| { + let state = &mut h.state_mut().state; + state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("long-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long(), + Case::new("rtl", 1000.0, 640.0, |h| { + click(h, "Export…: ⌘E"); + h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S); + h.run(); + }) + .sets() + .long() + .words(Words::Rtl), + Case::new("rtl-narrow", 560.0, 640.0, |h| select(h, "Bold")).words(Words::Rtl).long(), + Case::new("rtl-keyboard", 1100.0, 720.0, |h| click(h, "L: Loop (Timeline)")).keyboard().words(Words::Rtl), + Case::new("rtl-review", 1000.0, 640.0, foreign_review).sets().words(Words::Rtl), + Case::new("pseudo", 1100.0, 720.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("file.save")); + h.run(); + }) + .sets() + .keyboard() + .long() + .words(Words::Pseudo), + Case::new("pseudo-narrow", 560.0, 640.0, |h| { + h.state_mut().state.select(&craft_keymap::CommandRef::from("object.distribute")); + h.run(); + }) + .sets() + .long() + .words(Words::Pseudo), ]; #[test] @@ -156,12 +215,29 @@ fn dialog_screenshots() { std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); // `CRAFT_KEYMAP_SNAPSHOT_ONLY=keyboard` renders only the cases whose name starts with it. let only = std::env::var("CRAFT_KEYMAP_SNAPSHOT_ONLY").unwrap_or_default(); + let fonts = fonts(); for case in CASES.iter().filter(|c| c.name.starts_with(&only)) { for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { for scale in [1.0, 2.0] { - let sets = case.sets.then(|| (Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let sets = case.sets.then(|| { + let mut p = Profiles::default(); + let builtins = vec![("Fixture Defaults", Overrides::new())]; + if case.long { + let name = "My Shortcuts for Long Evenings of Retouching Photographs"; + if let Err(e) = p.save_as(name, Overrides::new(), &builtins).and_then(|n| p.select(&n, &builtins)) { + panic!("{name}: {e}"); + } + } + (p, builtins) + }); let options = Options { keyboard: case.keyboard, ..Options::default() }; - let fx = Fx { km: fixture(case.platform), options, state: DialogState::new(), sets }; + let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; + let text: Box = match case.words { + Words::English => Box::new(English), + Words::Rtl => Box::new(EnglishRtl), + Words::Pseudo => Box::new(Pseudo::default()), + }; + let fx = Fx { km, text, options, state: DialogState::new(), sets }; let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state( |ui, fx: &mut Fx| { egui::Frame::central_panel(ui.style()).show(ui, |ui| { @@ -171,6 +247,7 @@ fn dialog_screenshots() { }, fx, ); + h.ctx.set_fonts(fonts.clone()); h.run(); (case.act)(&mut h); let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name)); From 3e344373ef6209b78d8d9753092e4eecb0d8a772 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 016/124] keymap: put overrides_list on the craft-settings example in the crate docs --- crates/keymap/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index a54f0756..0fe2107b 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -63,10 +63,11 @@ //! //! # Storing overrides //! -//! [`Overrides`] is a field of the app's craft-settings type. It is stored as an array of -//! `{ command, keys }` tables, not as a table keyed by command: craft-settings merges tables key -//! by key with what the file holds, but replaces an array whole, so a reset command (an entry -//! removed) stays removed after saving. +//! [`Overrides`] is a field of the app's craft-settings type, marked +//! `#[serde(with = "craft_keymap::keymap::overrides_list")]` so that it is stored as an array of +//! `{ command, keys }` tables. craft-settings merges tables key by key with what the file holds, +//! but replaces an array whole, so a reset command (an entry removed) stays removed after saving. +//! Without the attribute, overrides with at most one text per command are written as a table. //! //! ```no_run //! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; @@ -76,6 +77,7 @@ //! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] //! #[serde(default)] //! struct Prefs { +//! #[serde(with = "craft_keymap::keymap::overrides_list")] //! shortcuts: Overrides, //! shortcut_sets: Profiles, //! } From 093238b065af1924e9052ef3456a45fd1c6df6d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 017/124] keymap: rename refuses a name another set holds in a different case --- crates/keymap/src/profiles.rs | 6 +++++- crates/keymap/tests/profiles.rs | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index 3f7e92f6..e19687da 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -209,7 +209,11 @@ impl Profiles { if !self.sets.contains_key(from) { return Err(ProfileError::NotFound); } - if to != from && (self.taken(&to, builtins) && to.to_lowercase() != from.to_lowercase()) { + // A settings file can hold names that differ only in case, so a case-only rename still + // checks the other sets. + let lower = to.to_lowercase(); + let clash = self.sets.keys().any(|k| k != from && k.to_lowercase() == lower); + if to != from && (clash || (self.builtin_like(&to, builtins) && lower != from.to_lowercase())) { return Err(ProfileError::NameTaken); } if let Some(set) = self.sets.remove(from) { diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 84be8f09..c23770a6 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -66,6 +66,17 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); } +#[test] +fn rename_keeps_a_set_whose_name_differs_only_in_case() { + let builtins = [("Craft", Overrides::new())]; + let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); + assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); + assert_eq!(p.sets["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); + p.delete("mine", &builtins).unwrap(); + assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); +} + #[test] fn sets_that_are_not_a_list_or_map_load_as_none() { for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { From 2baaf87ecf7a185096626c2adbdb2cf6e883332f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:14:15 +0000 Subject: [PATCH 018/124] keymap: Don't Save before Import reloads the stored set --- crates/keymap/src/ui/state.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 3c5f87b8..34802241 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -965,7 +965,11 @@ impl DialogState { Ok(()) => self.run(env, then), Err(e) => self.refuse(profile_error(env, &e)), }, - (Prompt::SaveChanges { then, .. }, Answer::No) => self.run(env, then), + // Don't Save reloads the stored set first, so an import does not start from the edits. + (Prompt::SaveChanges { set, then }, Answer::No) => match self.load_set(env, &set) { + Ok(()) => self.run(env, then), + Err(e) => self.refuse(profile_error(env, &e)), + }, (Prompt::ResetAll | Prompt::DeleteSet(_), Answer::No) => {} } } @@ -1312,6 +1316,27 @@ mod tests { assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); } + #[test] + fn dont_save_before_import_drops_the_edits() { + use crate::import::{ContextRule, ForeignEntry, ForeignKey}; + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.request_import(&mut fx.env()); + st.answer(&mut fx.env(), Answer::No); + assert!(matches!(st.finish(), Some(Outcome::Import { .. }))); + assert!(fx.km.overrides().is_empty() && !st.can_undo()); + let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); + let set = ForeignSet { name: Some("Theirs".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))] }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; + st.review_import(&mut fx.env(), set, options); + st.import_reviewed(&mut fx.env()); + assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.get("Theirs", &fx.b).and_then(|o| o.get("object.front")), None); + assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); + } + #[test] fn set_export_import_round_trip() { let mut fx = Fx::new(Options::default()); From 53f16925cfd410b847c485c6fbd1132036090266 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:15:36 +0000 Subject: [PATCH 019/124] keymap: put overrides_list on the craft-settings example in the crate docs --- crates/keymap/src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index bebf33f7..f669e6d7 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -62,10 +62,11 @@ //! //! # Storing overrides //! -//! [`Overrides`] is a field of the app's craft-settings type. It is stored as an array of -//! `{ command, keys }` tables, not as a table keyed by command: craft-settings merges tables key -//! by key with what the file holds, but replaces an array whole, so a reset command (an entry -//! removed) stays removed after saving. +//! [`Overrides`] is a field of the app's craft-settings type, marked +//! `#[serde(with = "craft_keymap::keymap::overrides_list")]` so that it is stored as an array of +//! `{ command, keys }` tables. craft-settings merges tables key by key with what the file holds, +//! but replaces an array whole, so a reset command (an entry removed) stays removed after saving. +//! Without the attribute, overrides with at most one text per command are written as a table. //! //! ```no_run //! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; @@ -75,6 +76,7 @@ //! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] //! #[serde(default)] //! struct Prefs { +//! #[serde(with = "craft_keymap::keymap::overrides_list")] //! shortcuts: Overrides, //! } //! From 4a721df085b82f1d64e229b941c64c12aca44564 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:47:23 +0000 Subject: [PATCH 020/124] keymap: state that chord reservations are opt-in through with_reserved --- crates/keymap/README.md | 3 +++ crates/keymap/src/keymap.rs | 3 ++- crates/keymap/src/lib.rs | 6 ++++-- crates/keymap/src/reserved.rs | 6 +++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 81b55ec9..1f2d1b75 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -13,6 +13,9 @@ its file-format readers and its UI. kept or reported (`Keymap::problems`, the import report). - Hostile input is capped: commands, bindings per command, text lengths, import entries, keys, path segments and fuzzy suggestions (the `MAX_*` constants). What is cut is counted. +- Reserved chords are opt-in. A keymap reserves no chord unless the app passes a list to + `Keymap::with_reserved`, e.g. `reserved::system(platform)` for the operating system's window + switching and closing chords. A reserved chord cannot be assigned or bound. - Dependencies: `serde` and `serde_json`; `egui` with the `egui` feature. ## A catalogue and dispatch diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index d2a81ae9..4ceb76d1 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -434,7 +434,8 @@ impl Keymap { self } - /// Chords that cannot be assigned, e.g. [`reserved::system`](crate::reserved::system). + /// Chords that cannot be assigned or bound, e.g. [`reserved::system`](crate::reserved::system). + /// Without this call the keymap reserves no chord. pub fn with_reserved(mut self, chords: Vec) -> Keymap { self.reserved = chords; self.reload(); diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index f669e6d7..e673848c 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -6,8 +6,10 @@ //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's //! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), -//! editing and conflicts. [`reserved`] lists the chords each operating system takes. [`import`] -//! brings in other applications' shortcut sets. With the `egui` feature, the `egui` module +//! editing and conflicts. [`reserved`] lists the chords each operating system takes; a keymap +//! reserves no chord unless the app opts in with [`Keymap::with_reserved`], e.g. +//! `with_reserved(reserved::system(platform))`. [`import`] brings in other applications' shortcut +//! sets. With the `egui` feature, the `egui` module //! converts to and from egui keys, matches key presses against bindings and dispatches a frame's //! key events. //! diff --git a/crates/keymap/src/reserved.rs b/crates/keymap/src/reserved.rs index 7dbf847b..4d64d36a 100644 --- a/crates/keymap/src/reserved.rs +++ b/crates/keymap/src/reserved.rs @@ -1,4 +1,7 @@ //! Chords the operating system takes before the app sees them. +//! +//! A keymap reserves these only when the app passes them to +//! [`Keymap::with_reserved`](crate::Keymap::with_reserved). use crate::chord::{Chord, Platform}; @@ -25,7 +28,8 @@ const LINUX: &[&str] = &[ "Cmd+Alt+F12", ]; -/// The window switching, closing and session chords of `platform`. +/// The window switching, closing and session chords of `platform`, for +/// [`Keymap::with_reserved`](crate::Keymap::with_reserved). pub fn system(platform: Platform) -> Vec { let list = match platform { Platform::Mac => MAC, From a160c1cdeca75bb45375f4810344138b9df0d91e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:47:24 +0000 Subject: [PATCH 021/124] ci: add keymap workflow for core, egui dispatch, macOS, Windows and WASM --- .github/workflows/keymap.yml | 66 ++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/keymap.yml diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml new file mode 100644 index 00000000..97b1124e --- /dev/null +++ b/.github/workflows/keymap.yml @@ -0,0 +1,66 @@ +name: Keymap + +on: + push: + branches: [main] + paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + pull_request: + paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: keymap-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + timeout-minutes: 15 + name: Keymap (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + key: craft-keymap-linux + - run: cargo fmt -p craft-keymap --check + - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings + - run: cargo clippy -p craft-keymap --all-targets --all-features --locked -- -D warnings + # Doctests run once, in the all-features step. + - name: Test core + run: cargo test -p craft-keymap --no-default-features --lib --tests --locked + - name: Test egui dispatch + run: cargo test -p craft-keymap --all-features --locked + native: + timeout-minutes: 15 + name: Keymap (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: craft-keymap-${{ matrix.os }} + - run: cargo test -p craft-keymap --all-features --locked + wasm: + timeout-minutes: 10 + name: Keymap WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: craft-keymap-wasm + - run: cargo check -p craft-keymap --all-features --locked --target wasm32-unknown-unknown From 86fb06a0816e22cf9b8056d05b2a562c1f5a9a54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:58:45 +0000 Subject: [PATCH 022/124] keymap: report Changed after Import when the save prompt changed the set --- crates/keymap/README.md | 2 +- crates/keymap/src/ui/mod.rs | 9 +++++++-- crates/keymap/src/ui/state.rs | 29 ++++++++++++++++++++++++++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 732a2eaf..a564b245 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -322,7 +322,7 @@ Outcomes: |---|---| | `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | | `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | -| `Changed` | Updates its menus, or saves at once if it saves live. | +| `Changed` | Updates its menus, or saves at once if it saves live. When a frame also has another outcome, `Changed` comes on the next frame. | | `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | | `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | | `Summary { name, html, text }` | Shows, saves or prints the summary. | diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 5075b37e..dbe9e0a3 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -36,7 +36,8 @@ pub enum Outcome { Ok, /// Closed with Cancel: the keymap and profiles are back as they were when it opened. Cancel, - /// An edit this frame (for live menus and live saving). + /// An edit (for live menus and live saving). When a frame also has another outcome, such as + /// `Import` after the Save changes prompt, `Changed` follows on the next frame. Changed, /// The user asked to import: the app opens a file of one of these formats and calls /// [`ShortcutsDialog::import_json`] or [`ShortcutsDialog::review_import`]. @@ -274,5 +275,9 @@ fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option Option { - let changed = std::mem::take(&mut self.changed); - self.outcome.take().or(changed.then_some(Outcome::Changed)) + self.outcome.take().or_else(|| std::mem::take(&mut self.changed).then_some(Outcome::Changed)) + } + + /// Whether an edit waits for the next [`finish`](Self::finish). + pub(crate) fn change_pending(&self) -> bool { + self.changed } /// The table's lines for the current view. @@ -708,6 +713,8 @@ impl DialogState { self.recording = None; self.conflict = None; self.prompt = None; + // Ok and Cancel cover every edit. + self.changed = false; self.outcome = Some(outcome); } @@ -1337,6 +1344,22 @@ mod tests { assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); } + #[test] + fn save_prompt_before_import_reports_changed_after_import() { + for answer in [Answer::No, Answer::Yes] { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + assert!(matches!(st.finish(), Some(Outcome::Changed))); + st.request_import(&mut fx.env()); + st.answer(&mut fx.env(), answer); + assert!(matches!(st.finish(), Some(Outcome::Import { .. })), "{answer:?}"); + assert!(matches!(st.finish(), Some(Outcome::Changed)), "{answer:?}"); + assert!(st.finish().is_none()); + } + } + #[test] fn set_export_import_round_trip() { let mut fx = Fx::new(Options::default()); From 0b34fdb1527cad02df8361936d72e34938dfe05d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:30:56 +0000 Subject: [PATCH 023/124] keymap: split the hostile import test into a fast cap test and an ignored worst-case timing test --- crates/keymap/tests/import.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 425f5d59..e89428b6 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -428,6 +428,40 @@ fn suggestions_are_reported_never_applied() { #[test] fn hostile_sets_are_capped_and_counted() { + let mut km = keymap(Platform::Windows); + let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); + let mut big = ForeignEntry::new(Some(long.as_str()), std::iter::repeat_n(long.clone(), 100)); + for _ in 0..MAX_KEYS_PER_ENTRY + 10 { + let trigger = Err(ForeignError::UnknownKey(long.clone())); + big = big.with_key(ForeignKey { context: Some(long.clone()), text: long.clone(), trigger, notes: vec![ReadNote::Numpad; 1000] }); + } + // Unmatched entries with keys, more than the suggestions take. + let near = win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+F12")]); + let mut entries: Vec = std::iter::repeat_n(near, MAX_SUGGESTIONS + 10).collect(); + entries.push(big); + entries.push(win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); + // Entries without keys fill the set to the cap and 500 past it. + let keyless = MAX_IMPORT_ENTRIES - entries.len(); + entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); + let set = ForeignSet { name: Some(long.clone()), entries }; + let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; + let r = plan(&set, &km, &options).apply(&mut km); + assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); + // The set name, entries and keys past the caps, and suggestions past the cap (with the big entry's). + assert!(r.truncated >= 1 + 500 + 10 + 11, "{}", r.truncated); + assert_eq!(r.set.as_ref().map(|s| s.chars().count()), Some(MAX_IMPORT_TEXT)); + let row = r.unmatched.iter().find(|u| u.item.starts_with('ä')).unwrap(); + assert!(row.item.chars().count() <= MAX_IMPORT_TEXT * 34, "item of capped segments"); + assert!(row.keys.chars().count() <= (2 * MAX_IMPORT_TEXT + 4) * MAX_KEYS_PER_ENTRY); + assert_eq!(r.unreadable.len(), 1, "Paste's keys"); + has(&km, "edit.paste", &["Cmd+V"]); + // Entries past the cap are left out. + assert_eq!(r.unmatched_without_shortcut, keyless); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] +fn hostile_sets_finish_in_time() { // A full catalogue whose labels share words with every unmatched entry below, so each // suggestion scores every command. let mut catalogue = catalogue(); From c519b7a856f4c7987c4a90c50bb1ffa9faa9e3e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:37:38 +0000 Subject: [PATCH 024/124] keymap: split crowded_chords_stay_fast into a small cap test and an ignored worst-case timing test --- crates/keymap/tests/keymap.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 475109d4..502c3ced 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -279,11 +279,11 @@ fn conflicts_tell_clashes_from_shadows() { assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); } -/// 4096 commands, each with 16 bindings on one chord in 16 panels. -fn crowded(scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { - let catalogue: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); +/// `commands` commands, each with 16 bindings on one chord in 16 panels. +fn crowded(commands: usize, scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { + let catalogue: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); let mut o = Overrides::new(); - for n in 0..MAX_COMMANDS { + for n in 0..commands { assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); } (Keymap::new(catalogue, scopes, Platform::Mac), o) @@ -294,11 +294,31 @@ fn panels(n: usize) -> Scopes { } #[test] +fn crowded_chords_are_capped() { + // Panels the app never registered are not bound. + let (km, o) = crowded(2, Scopes::new(), Kind::Command); + let km = km.with_overrides(o); + assert!(km.conflicts().is_empty()); + assert!(km.bindings(&cmd("c.0")).is_empty()); + assert_eq!(km.overrides().get("c.0").map(<[String]>::len), Some(MAX_BINDINGS), "kept verbatim"); + assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); + + // Registered panels that never share: 32 commands make 16 × 496 pairs, capped. + let (km, o) = crowded(32, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + assert_eq!(km.with_overrides(o).conflicts().len(), MAX_CONFLICTS); + + // Tools of one group: no conflicts. + let (km, o) = crowded(32, panels(MAX_BINDINGS), Kind::Tool { group: None }); + assert!(km.with_overrides(o).conflicts().is_empty()); +} + +#[test] +#[ignore = "worst-case timing; run with --ignored"] fn crowded_chords_stay_fast() { let limit = Duration::from_secs(8); // Panels the app never registered are not bound. - let (km, o) = crowded(Scopes::new(), Kind::Command); + let (km, o) = crowded(MAX_COMMANDS, Scopes::new(), Kind::Command); let km = km.with_overrides(o); let t = Instant::now(); assert!(km.conflicts().is_empty()); @@ -308,14 +328,14 @@ fn crowded_chords_stay_fast() { assert!(km.problems().contains(&Problem::UnknownScope { key: "c.0".into(), text: "P1: K".into() })); // Registered panels that never share: many conflicts, capped. - let (km, o) = crowded(panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS).with_panel_fallthrough(false), Kind::Command); let km = km.with_overrides(o); let t = Instant::now(); assert_eq!(km.conflicts().len(), MAX_CONFLICTS); assert!(t.elapsed() < limit, "{:?}", t.elapsed()); // Tools of one group: no conflicts, and no pair is compared. - let (km, o) = crowded(panels(MAX_BINDINGS), Kind::Tool { group: None }); + let (km, o) = crowded(MAX_COMMANDS, panels(MAX_BINDINGS), Kind::Tool { group: None }); let km = km.with_overrides(o); let t = Instant::now(); assert!(km.conflicts().is_empty()); From c030b2ff8818f74eb74c72250f4aac8dd94db1ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:40:29 +0000 Subject: [PATCH 025/124] ci: test the keymap workflow with the ui feature (the dialog) instead of all features --- .github/workflows/keymap.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 97b1124e..133cbe35 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -30,12 +30,13 @@ jobs: key: craft-keymap-linux - run: cargo fmt -p craft-keymap --check - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings - - run: cargo clippy -p craft-keymap --all-targets --all-features --locked -- -D warnings - # Doctests run once, in the all-features step. + - run: cargo clippy -p craft-keymap --all-targets --features ui --locked -- -D warnings + # Doctests run once, in the `ui` step. `snapshot` (dialog screenshots) needs a GPU and is + # left out; `ui` covers `egui`. - name: Test core run: cargo test -p craft-keymap --no-default-features --lib --tests --locked - - name: Test egui dispatch - run: cargo test -p craft-keymap --all-features --locked + - name: Test egui dispatch and the shortcuts dialog + run: cargo test -p craft-keymap --features ui --locked native: timeout-minutes: 15 name: Keymap (${{ matrix.os }}) @@ -50,7 +51,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: craft-keymap-${{ matrix.os }} - - run: cargo test -p craft-keymap --all-features --locked + - run: cargo test -p craft-keymap --features ui --locked wasm: timeout-minutes: 10 name: Keymap WASM @@ -63,4 +64,4 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: craft-keymap-wasm - - run: cargo check -p craft-keymap --all-features --locked --target wasm32-unknown-unknown + - run: cargo check -p craft-keymap --features ui --locked --target wasm32-unknown-unknown From baaf3061b057aeaf83e7a225bd4e440240c9a006 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:40:29 +0000 Subject: [PATCH 026/124] keymap: run the pseudo-locale dialog test once per direction, each at one width --- crates/keymap/tests/ui_dialog.rs | 51 ++++++++++++++++---------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 5ddf5800..5f0ee994 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -870,31 +870,30 @@ fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { #[test] fn pseudo_locale_marks_every_string_and_fits() { let p = Pseudo::mark; - for rtl in [false, true] { - for width in [560.0, 1100.0] { - let unlisted = std::rc::Rc::default(); - let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); - fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); - fx.options.keyboard = true; - fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); - let mut h = harness(fx, vec2(width, 720.0)); - let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); - check_pseudo(&h, width, &step("opened")); - h.state_mut().state.select(&CommandRef::from("object.distribute")); - h.run(); - check_pseudo(&h, width, &step("long label selected")); - let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); - click(&mut h, &export); - press(&mut h, Modifiers::COMMAND, EguiKey::S); - assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); - check_pseudo(&h, width, &step("conflict")); - click(&mut h, &p("Keep Both")); - check_pseudo(&h, width, &step("kept both")); - click(&mut h, &p("Keyboard")); - check_pseudo(&h, width, &step("keyboard hidden")); - review(&mut h); - check_pseudo(&h, width, &step("review")); - assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); - } + // Each direction at one width: the narrow layout left to right, the wide one right to left. + for (rtl, width) in [(false, 560.0), (true, 1100.0)] { + let unlisted = std::rc::Rc::default(); + let mut fx = Fx::new(with_long_label(fixture(Platform::Mac))); + fx.text = Box::new(Pseudo { unlisted: std::rc::Rc::clone(&unlisted), rtl }); + fx.options.keyboard = true; + fx.sets = Some((Profiles::default(), vec![("Fixture Defaults", Overrides::new())])); + let mut h = harness(fx, vec2(width, 720.0)); + let step = |s: &str| format!("{} {width}: {s}", if rtl { "rtl" } else { "ltr" }); + check_pseudo(&h, width, &step("opened")); + h.state_mut().state.select(&CommandRef::from("object.distribute")); + h.run(); + check_pseudo(&h, width, &step("long label selected")); + let export = craft_i18n::fmt(&p("{label}: {chord}"), &[("label", &p("Export…")), ("chord", "⌘E")]); + click(&mut h, &export); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(has(&h, &p("Take It")), "{}", step("conflict bar")); + check_pseudo(&h, width, &step("conflict")); + click(&mut h, &p("Keep Both")); + check_pseudo(&h, width, &step("kept both")); + click(&mut h, &p("Keyboard")); + check_pseudo(&h, width, &step("keyboard hidden")); + review(&mut h); + check_pseudo(&h, width, &step("review")); + assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); } } From 3ef24a785b0e6d00e6c26ef1e3230ba52bf0fd51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:49:05 +0000 Subject: [PATCH 027/124] keymap: egui::platform gives the browser's operating system on the web --- crates/keymap/src/chord.rs | 3 ++- crates/keymap/src/egui.rs | 25 +++++++++++++++++++++++-- crates/keymap/tests/egui.rs | 11 ++++++++++- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 740ccf21..43dcf8ac 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -22,7 +22,8 @@ pub enum Platform { } impl Platform { - /// The platform this build runs on. Web builds count as Linux. + /// The platform this build runs on. Web builds count as Linux; with the `egui` feature, + /// `egui::platform` gives the browser's operating system. pub const fn current() -> Platform { if cfg!(target_os = "macos") { Platform::Mac diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fd4039d5..ca8b3192 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -1,13 +1,34 @@ //! Conversions between the crate's keys and egui's, matching egui key presses to chords, and a -//! [`Dispatcher`] that turns a frame's key events into the commands they run. +//! [`Dispatcher`] that turns a frame's key events into the commands they run. [`platform`] gives +//! the platform to build a keymap for, the browser's operating system included on the web. -use ::egui::{Event, InputState, Key as EguiKey, KeyboardShortcut, Modifiers}; +use ::egui::os::OperatingSystem; +use ::egui::{Context, Event, InputState, Key as EguiKey, KeyboardShortcut, Modifiers}; use crate::chord::{Chord, Key, Mods, Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Keymap, Lookup}; use crate::scope::Active; +/// The platform to build a keymap for in this context. Unlike [`Platform::current`], a web build +/// gets the browser's operating system, which eframe reads from the user agent, so a Mac browser +/// shows and matches Cmd shortcuts. eframe sets it before the first frame; an app that builds its +/// keymap earlier rebuilds it when this differs. +pub fn platform(ctx: &Context) -> Platform { + Platform::from(ctx.os()) +} + +/// iOS counts as Mac; Android and unknown systems count as Linux. +impl From for Platform { + fn from(os: OperatingSystem) -> Platform { + match os { + OperatingSystem::Mac | OperatingSystem::IOS => Platform::Mac, + OperatingSystem::Windows => Platform::Windows, + OperatingSystem::Nix | OperatingSystem::Android | OperatingSystem::Unknown => Platform::Linux, + } + } +} + // Both matches are generated from one list so that a key added to `Key` without an egui // counterpart fails to compile. macro_rules! egui_keys { diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 23db9756..5487c807 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -1,6 +1,6 @@ #![cfg(feature = "egui")] -use craft_keymap::egui::{Dispatcher, Hit, matches}; +use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes, Trigger}; use egui::{Event, InputState, Key as EguiKey, Modifiers}; @@ -8,6 +8,15 @@ fn chord(text: &str) -> Chord { Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) } +#[test] +fn a_browser_on_a_mac_gets_the_mac_keymap() { + let ctx = egui::Context::default(); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15")); + assert_eq!(platform(&ctx), Platform::Mac); + ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")); + assert_eq!(platform(&ctx), Platform::Windows); +} + #[test] fn every_key_converts_both_ways() { for &key in Key::ALL { From 1b329b9422c0e05a7e5dbd09edb4ba9e53a92621 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:39:41 +0000 Subject: [PATCH 028/124] keymap: a web clipboard press takes its clipboard event and the pasted text with it --- crates/keymap/README.md | 4 ++- crates/keymap/src/egui.rs | 71 +++++++++++++++++++++---------------- crates/keymap/tests/egui.rs | 25 +++++++++++++ 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 1f2d1b75..70308245 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -228,6 +228,8 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File egui's Copy, Cut and Paste events stay with the app by default. On the desktop, egui-winit sends them in place of the Cmd+C, Cmd+X and Cmd+V presses (Ctrl off macOS), so a binding on those chords runs only with `with_clipboard_events(true)`. Then the events count as their - chords outside text fields, and a Paste event's text moves to `Hit::pasted`. An app whose + chords outside text fields, and a Paste event's text moves to `Hit::pasted`. The web build + sends the key press and then the event; the press runs the command and takes the event, with + its text, along. An app whose paste command asks egui for a paste gets a Paste event back, which runs that command again; such an app keeps the default. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index ca8b3192..70330db6 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -173,7 +173,8 @@ pub struct Hit { /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or /// `+` and `=` standing for each other. pub exact: bool, - /// The text of the Paste event that ran the command ([`Dispatcher::with_clipboard_events`]). + /// The text of the Paste event that ran the command, or on the web the Paste event that came + /// with the press that ran it ([`Dispatcher::with_clipboard_events`]). pub pasted: Option, } @@ -240,7 +241,8 @@ impl Dispatcher { /// Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift alone) outside text /// fields. Off by default: the events stay in egui's input for the app's own handlers. With it /// on, an event that runs a command is removed, and a Paste event's text moves to - /// [`Hit::pasted`]. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run + /// [`Hit::pasted`]. On the web, the key press runs the command and its clipboard event goes + /// with it the same way; when the press runs nothing, or the hit is declined, both stay. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run /// its paste command gets a Paste event back, which with this on runs that command again. pub fn with_clipboard_events(mut self, on: bool) -> Dispatcher { self.clipboard = on; @@ -266,11 +268,12 @@ impl Dispatcher { let mut kept = Vec::with_capacity(input.events.len()); // The Text event egui sends after a key press belongs to that press. let mut drop_text = false; - // The web build sends both a clipboard key press and the clipboard event it causes. - let mut clipboard_press = None; let events = std::mem::take(&mut input.events); let typed = if active.typing { typed_text(&events) } else { Vec::new() }; - let causes_event = if self.clipboard { Vec::new() } else { clipboard_event_follows(&events, platform) }; + // The web build sends both a clipboard key press and the clipboard event it causes. The + // press decides: its event goes with it when it runs a command and stays otherwise. + let causes = clipboard_event_follows(&events, platform); + let mut caused_taken: Vec> = vec![None; events.len()]; for (i, event) in events.into_iter().enumerate() { match &event { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { @@ -281,16 +284,22 @@ impl Dispatcher { let keep = if active.typing { typed.get(i) == Some(&true) || clip.is_some() || may_be_alt_gr(key, held, platform) } else { - causes_event.get(i) == Some(&true) + !self.clipboard && causes[i].is_some() }; if keep { kept.push(event); continue; } - if clip.is_some() { - clipboard_press = clip; + let caused = causes[i].clone(); + let hit = self.press(key, held, *repeat, now, keymap, active).map(|mut hit| { + hit.pasted = caused.as_ref().and_then(|(_, text)| text.clone()); + hit + }); + let hit = hit.filter(&mut accept); + if let Some((j, _)) = caused { + caused_taken[j] = Some(hit.is_some()); } - if let Some(hit) = self.press(key, held, *repeat, now, keymap, active).filter(&mut accept) { + if let Some(hit) = hit { hits.push(hit); drop_text = !(held.cmd || held.ctrl); continue; @@ -303,9 +312,11 @@ impl Dispatcher { } // A focused text field copies and pastes on these events itself. Event::Copy | Event::Cut | Event::Paste(_) if self.clipboard && !active.typing => { - let (key, held, clip) = clipboard_chord(&event, frame_mods); - if clipboard_press == Some(clip) { - clipboard_press = None; + let (key, held) = clipboard_chord(&event, frame_mods); + if let Some(taken) = caused_taken[i] { + if taken { + continue; + } } else if let Some(mut hit) = self.press(key, held, false, now, keymap, active) { if let Event::Paste(text) = &event { hit.pasted = Some(text.clone()); @@ -349,19 +360,19 @@ enum Clip { Paste, } -/// For each event, whether it is a clipboard key press whose clipboard event comes later in the -/// frame, as the web build sends them. -fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec { - let mut out = vec![false; events.len()]; - let mut ahead = [false; 3]; - for (event, follows) in events.iter().zip(out.iter_mut()).rev() { +/// For each clipboard key press whose clipboard event comes later in the frame, as the web build +/// sends them: the index of that event, and the text of a Paste event. +fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec)>> { + let mut out = vec![None; events.len()]; + let mut ahead: [Option; 3] = [None; 3]; + for (i, event) in events.iter().enumerate().rev() { if let Some(seen) = event_clip(event).and_then(|c| ahead.get_mut(c as usize)) { - *seen = true; + *seen = Some(i); } else if let Event::Key { key, pressed: true, modifiers, .. } = event { let clip = Key::from_egui(*key).and_then(|k| clipboard_kind(k, Mods::from_egui(modifiers, platform))); - if let Some(seen) = clip.and_then(|c| ahead.get_mut(c as usize)) { - *follows = *seen; - *seen = false; + if let Some(j) = clip.and_then(|c| ahead.get_mut(c as usize)).and_then(Option::take) { + let text = if let Some(Event::Paste(text)) = events.get(j) { Some(text.clone()) } else { None }; + out[i] = Some((j, text)); } } } @@ -434,16 +445,16 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { } } -/// The press a clipboard event stands for, with the modifiers held, and the event's kind. Off -/// macOS egui-winit also sends Cut for Shift+Delete and Paste for Shift+Insert. -fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods, Clip) { +/// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also +/// sends Cut for Shift+Delete and Paste for Shift+Insert. +fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { let with_cmd = Mods { cmd: true, ..held }; let shift_only = held.shift && !held.cmd; match event { - Event::Cut if shift_only => (Key::Delete, held, Clip::Cut), - Event::Cut => (Key::X, with_cmd, Clip::Cut), - Event::Paste(_) if shift_only => (Key::Insert, held, Clip::Paste), - Event::Paste(_) => (Key::V, with_cmd, Clip::Paste), - _ => (Key::C, with_cmd, Clip::Copy), + Event::Cut if shift_only => (Key::Delete, held), + Event::Cut => (Key::X, with_cmd), + Event::Paste(_) if shift_only => (Key::Insert, held), + Event::Paste(_) => (Key::V, with_cmd), + _ => (Key::C, with_cmd), } } diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 5487c807..567a852e 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -193,6 +193,31 @@ fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { } } +#[test] +fn a_web_paste_carries_its_text_and_takes_its_event() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + // The web build sends the Cmd+V press and then the Paste event with the clipboard text. + let events = vec![press(EguiKey::V, CTRL), Event::Paste("text".into()), press(EguiKey::C, CTRL), Event::Copy]; + let mut input = frame(1.0, CTRL, events.clone()); + let mut seen = Vec::new(); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| { + seen.push(hit.pasted.clone()); + true + }); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.paste", Some("text")), ("edit.copy", None)]); + assert_eq!(seen, [Some("text".to_string()), None], "the filter sees the pasted text"); + assert!(input.events.is_empty(), "{:?}", input.events); + + // A declined press leaves the press and its event in place, and the event does not run again. + let mut input = frame(2.0, CTRL, events.clone()); + let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| hit.command.id != "edit.paste"); + let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect(); + assert_eq!(ran, ["edit.copy"]); + assert_eq!(input.events, events[..2]); +} + #[test] fn declined_hits_leave_their_events_in_order() { let km = dispatch_keymap(); From 9b7102e2972f20a1280d7d2a4e49c827b64059a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:39:59 +0000 Subject: [PATCH 029/124] keymap: New Set refuses a name that is taken The set form's New Set replaced a user set of the same name (save_as) or quietly made the name unique (duplicate). It now refuses a name that matches a built-in or user set, ignoring case and surrounding spaces, the way Rename does, and keeps the form open. The check runs before the prompt to save edits and again after it. --- crates/keymap/src/profiles.rs | 3 ++- crates/keymap/src/ui/state.rs | 41 +++++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index e19687da..8642f0f8 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -314,7 +314,8 @@ impl Profiles { builtins.iter().any(|(n, _)| n.to_lowercase() == lower) } - fn taken(&self, name: &str, builtins: &Builtins) -> bool { + /// Whether `name` matches a built-in or user set, ignoring case. + pub(crate) fn taken(&self, name: &str, builtins: &Builtins) -> bool { let lower = name.to_lowercase(); self.builtin_like(name, builtins) || self.sets.keys().any(|k| k.to_lowercase() == lower) } diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 960419dd..b188ad0e 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -8,7 +8,7 @@ use crate::command::CommandRef; use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; -use crate::profiles::{Builtins, ProfileError, Profiles}; +use crate::profiles::{Builtins, ProfileError, Profiles, set_name}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; use crate::ui::text::{Text, trigger_text}; @@ -795,8 +795,13 @@ impl DialogState { self.after_save_prompt(env, Then::Switch(name.to_string())); } - /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. + /// New Set…: a copy of the current shortcuts (`based_on: None`) or of a set. A name that is + /// taken (ignoring case) is refused, before any prompt to save. pub(crate) fn new_set(&mut self, env: &mut Env, name: &str, based_on: Option<&str>) { + if Self::name_taken(env, name) { + self.refuse(profile_error(env, &ProfileError::NameTaken)); + return; + } let then = Then::NewSet { name: name.to_string(), based_on: based_on.map(str::to_string) }; if based_on.is_none() { self.run(env, then); @@ -824,7 +829,10 @@ impl DialogState { let result = match then { Then::Switch(name) => self.load_set(env, &name), Then::NewSet { name, based_on } => { + // Saving edits to a built-in first can have made a set of this name. + let taken = Self::name_taken(env, &name); let made = match (env.sets.as_mut(), based_on) { + _ if taken => Err(ProfileError::NameTaken), (None, _) => Err(ProfileError::NotFound), (Some((p, b)), None) => p.save_as(&name, env.keymap.overrides().clone(), b), (Some((p, b)), Some(from)) => p.duplicate(&from, &name, b), @@ -840,6 +848,11 @@ impl DialogState { self.report(env, result); } + fn name_taken(env: &Env, name: &str) -> bool { + let (Some((p, b)), Some(name)) = (env.sets.as_ref(), set_name(name)) else { return false }; + p.taken(&name, b) + } + /// Rename… (not for built-ins). pub(crate) fn rename_set(&mut self, env: &mut Env, to: &str) { let result = match env.sets.as_mut() { @@ -1280,6 +1293,30 @@ mod tests { assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); } + #[test] + fn new_set_refuses_a_taken_name_and_keeps_the_set() { + let mut fx = Fx::new(Options::default()); + let mut st = open(&mut fx); + st.new_set(&mut fx.env(), "Mine", None); + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.save_set(&mut fx.env()); + let kept = fx.p.sets["Mine"].clone(); + assert!(!kept.is_empty()); + st.switch_set(&mut fx.env(), "Fixture Defaults"); + for name in ["Mine", " mine ", "fixture defaults"] { + for based_on in [None, Some("Fixture Defaults")] { + st.new_set(&mut fx.env(), name, based_on); + assert!(st.message.as_ref().is_some_and(|m| m.refused), "{name:?} from {based_on:?}"); + assert_eq!((fx.p.sets.len(), fx.p.active.as_str()), (1, "Fixture Defaults")); + assert_eq!(fx.p.sets["Mine"], kept); + } + } + st.new_set(&mut fx.env(), "Other", None); + st.rename_set(&mut fx.env(), "MINE"); + assert!(st.message.as_ref().is_some_and(|m| m.refused)); + assert_eq!(fx.p.sets["Mine"], kept); + } + #[test] fn cancel_restores_keymap_and_sets() { let mut fx = Fx::new(Options::default()); From 75a42cb95fc7cab183d86b3449ca4d6c8bddd1c4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 02:40:57 +0000 Subject: [PATCH 030/124] keymap: an import change that cannot take a chord from another command leaves both commands as they were --- crates/keymap/src/import.rs | 29 +++++++++++++++++++++-------- crates/keymap/tests/import.rs | 11 ++++++----- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 89e4b2f8..6cafe2b0 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -279,8 +279,7 @@ pub struct Failed { pub command: CommandRef, pub label: String, pub change: FailedChange, - /// The set's shortcuts for [`FailedChange::Bind`], the one left on the command for - /// [`FailedChange::Unbind`]; empty for [`FailedChange::Clear`]. + /// The set's shortcuts for [`FailedChange::Bind`]; empty for [`FailedChange::Clear`]. pub shortcuts: Vec, } @@ -288,13 +287,11 @@ pub struct Failed { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum FailedChange { - /// Giving the command the set's bindings. + /// Giving the command the set's bindings, or taking one of them from another command. Bind, /// Leaving the command without shortcuts: the set has it without keys and /// [`NoneDefined::Clear`] applies. Clear, - /// Taking a chord the set gives to another command; both commands keep it. - Unbind, } /// Why keys of an entry were not used. @@ -548,7 +545,8 @@ impl ImportPlan { /// /// A change that finds the overrides full is left out and reported in /// [`ImportReport::failed`]; the others still apply, and a chord it would have taken from an - /// earlier entry stays with that entry. With [`Start::Defaults`] that cannot happen. + /// earlier entry stays with that entry. That includes taking a chord from another command: + /// both commands keep the bindings they had. With [`Start::Defaults`] that cannot happen. pub fn apply(&self, keymap: &mut Keymap) -> ImportReport { let before: Vec> = self.commands.iter().map(|p| keymap.bindings(&p.command)).collect(); // Dropping every entry leaves room for all of them: each catalogue command takes at most one @@ -598,7 +596,10 @@ impl ImportPlan { decided.insert(p.command.key()); } - for (p, list) in self.commands.iter().zip(&accepted) { + // A command that cannot take a chord from another command is dropped, so both keep + // the bindings they had. + let mut unbind_failed = false; + for (c, (p, list)) in self.commands.iter().zip(&accepted).enumerate() { if list.is_empty() || !decided.contains(&p.command.key()) { continue; } @@ -612,10 +613,22 @@ impl ImportPlan { if keymap.remove(&h.command, &h.binding) { report.conflicts_cleared.push(Cleared { command: h.command, label, shortcut }); } else { - report.failed.push(Failed { command: h.command, label, change: FailedChange::Unbind, shortcuts: vec![shortcut] }); + let home = home_of(keymap, &p.command); + let list = in_default_order(keymap, &p.command, list); + let shortcuts = list.iter().map(|b| b.text(&home)).collect(); + dropped.insert( + c, + Failed { command: p.command.clone(), label: label_of(keymap, &p.command), change: FailedChange::Bind, shortcuts }, + ); + unbind_failed = true; + break; } } } + if unbind_failed { + *keymap = start.clone(); + continue 'attempt; + } for (p, from) in self.commands.iter().zip(&before) { if !decided.contains(&p.command.key()) { diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index e89428b6..586e02df 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -330,10 +330,11 @@ fn changes_that_find_the_overrides_full_are_reported() { }; let options = |start| ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start, ..ImportOptions::default() }; - // file.close has an entry to rewrite; the other changes each need a new one. + // file.close has an entry to rewrite, but taking Cmd+J from view.goToPage needs a new one, so + // both stay as they were. The other changes each need a new entry too. let mut km = keymap(Platform::Windows).with_overrides(full()); let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); - has(&km, "file.close", &["Cmd+J"]); + has(&km, "file.close", &["Cmd+Q"]); has(&km, "view.goToPage", &["Cmd+J"]); has(&km, "file.save", &["Cmd+S"]); has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); @@ -346,16 +347,16 @@ fn changes_that_find_the_overrides_full_are_reported() { assert_eq!( r.failed, [ + row("file.close", "Close", FailedChange::Bind, &["Cmd+J"]), row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), row("edit.duplicate", "Duplicate", FailedChange::Clear, &[]), - row("view.goToPage", "Go to Page…", FailedChange::Unbind, &["Cmd+J"]), ] ); - assert_eq!(r.imported, 1); + assert_eq!(r.imported, 0); assert!(r.conflicts_cleared.is_empty()); let v = serde_json::to_value(&r).unwrap(); assert_eq!( - v["failed"][0], + v["failed"][1], json!({"command": {"id": "file.save", "params": null}, "label": "Save", "change": "bind", "shortcuts": ["Cmd+Alt+S"]}) ); From 890660444cb87c4eb1ccff12a5be6813d996d40e Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:24:56 +0000 Subject: [PATCH 031/124] keymap: use US spelling --- README.md | 2 +- crates/keymap/README.md | 18 +++---- crates/keymap/src/egui.rs | 2 +- crates/keymap/src/import.rs | 36 ++++++------- crates/keymap/src/keymap.rs | 96 ++++++++++++++++----------------- crates/keymap/src/lib.rs | 12 ++--- crates/keymap/tests/egui.rs | 4 +- crates/keymap/tests/import.rs | 50 ++++++++--------- crates/keymap/tests/keymap.rs | 92 +++++++++++++++---------------- crates/keymap/tests/settings.rs | 4 +- 10 files changed, 158 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 166c3b9e..0e2b540f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ". +// `catalog` is the app's Vec. let (mut store, _report) = SettingsStore::::open(Location::Auto); -let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); +let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); // Entries for unknown commands and texts that do not parse are kept and listed here. let _problems = keymap.problems(); @@ -177,9 +177,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File - Panel menus: sources list panel-menu items with the panel's name in front (InDesign's Panel Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas). The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`. - Such an entry also matches a catalogue path that ends with its segments (with the command's + Such an entry also matches a catalog path that ends with its segments (with the command's label or the path's last segment last), and its last segment alone as a label. The last - segment alone counts only when the first segment, the panel, is a segment of some catalogue + segment alone counts only when the first segment, the panel, is a segment of some catalog path, so `History: New Document` matches nothing in an app without a History panel. An entry without keys that matched only these ways leaves the command's shortcuts alone and is listed in `matched_without_change` (reason `PanelMenu`). diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 70330db6..fd5d2794 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -164,7 +164,7 @@ pub const DOUBLE_PRESS: f64 = 0.6; pub struct Hit { /// The command to run; for tools sharing the chord, the first of `cycle`. pub command: CommandRef, - /// Tools of one group sharing the chord, in catalogue order (the app picks the next one); + /// Tools of one group sharing the chord, in catalog order (the app picks the next one); /// empty for a plain command. pub cycle: Vec, /// The chord whose binding matched, with `double` set when the double binding ran. diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 6cafe2b0..daf8b81e 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -53,8 +53,8 @@ pub struct ForeignEntry { /// submenu of [`ForeignEntry::menu`], which is usually `None`. Sources write such items as /// `Character: All Caps` (InDesign's Panel Menus area, the panel-menu areas of Illustrator and /// Photoshop sets); the reader splits them into segments. Such an entry also matches a - /// catalogue path that ends with its path, and its last segment alone when its first segment - /// is a segment of some catalogue path. Without keys, an entry matched only these ways + /// catalog path that ends with its path, and its last segment alone when its first segment + /// is a segment of some catalog path. Without keys, an entry matched only these ways /// changes nothing ([`ImportReport::matched_without_change`]). pub panel_menu: bool, } @@ -94,7 +94,7 @@ impl ForeignEntry { self.menu.iter().chain(&self.path).map(String::as_str).collect::>().join(" > ") } - /// The key of [`ImportOptions::remembered`] for this entry: the normalised menu and path + /// The key of [`ImportOptions::remembered`] for this entry: the normalized menu and path /// joined by U+001F. pub fn remember_key(&self) -> String { self.menu.iter().chain(&self.path).map(|s| normalize_label(s)).collect::>().join("\u{1f}") @@ -344,7 +344,7 @@ pub struct MatchedWithoutChange { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum NoChangeReason { - /// The entry matched only through a panel-menu rule (a catalogue path ending with its + /// The entry matched only through a panel-menu rule (a catalog path ending with its /// segments, or its last segment alone). Such a match is close enough to bind the entry's /// keys, not to take a command's shortcuts away. PanelMenu, @@ -407,7 +407,7 @@ pub fn normalize_label(s: &str) -> String { s.split_whitespace().collect::>().join(" ").to_lowercase() } -/// A normalised name without a leading `show/hide `, `show ` or `hide ` when something remains: +/// A normalized name without a leading `show/hide `, `show ` or `hide ` when something remains: /// sources name a toggle by what choosing it does now. Only toggle commands /// ([`Bindable::is_toggle`]) are matched by this form. fn toggle_name(s: &str) -> String { @@ -466,7 +466,7 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor if by_panel && entry.keys.is_empty() { let (command, label) = - keymap.catalogue().get(i).map(|b| (b.command.clone(), b.label.clone())).unwrap_or_else(|| (CommandRef::new(""), String::new())); + keymap.catalog().get(i).map(|b| (b.command.clone(), b.label.clone())).unwrap_or_else(|| (CommandRef::new(""), String::new())); report.matched_without_change.push(MatchedWithoutChange { command, label, item, line: entry.line, reason: NoChangeReason::PanelMenu }); continue; } @@ -511,7 +511,7 @@ pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> Impor } let at = *planned_at.entry(i).or_insert_with(|| { - let command = keymap.catalogue().get(i).map(|b| b.command.clone()).unwrap_or_else(|| CommandRef::new("")); + let command = keymap.catalog().get(i).map(|b| b.command.clone()).unwrap_or_else(|| CommandRef::new("")); commands.push(Planned { command, item: item.clone(), line, bindings: Vec::new(), none_defined: true }); commands.len() - 1 }); @@ -549,8 +549,8 @@ impl ImportPlan { /// both commands keep the bindings they had. With [`Start::Defaults`] that cannot happen. pub fn apply(&self, keymap: &mut Keymap) -> ImportReport { let before: Vec> = self.commands.iter().map(|p| keymap.bindings(&p.command)).collect(); - // Dropping every entry leaves room for all of them: each catalogue command takes at most one - // entry, and the catalogue holds at most `MAX_COMMANDS`. + // Dropping every entry leaves room for all of them: each catalog command takes at most one + // entry, and the catalog holds at most `MAX_COMMANDS`. if self.start == Start::Defaults { keymap.reset_all(); } @@ -814,10 +814,10 @@ enum Found { None, } -/// Catalogue names in one form, for the match steps. +/// Catalog names in one form, for the match steps. #[derive(Default)] struct Names { - /// Normalised paths joined by U+001F. + /// Normalized paths joined by U+001F. paths: HashMap>, /// The last two or more segments of paths, also with the label as the last one, joined by /// U+001F: for panel-menu entries. @@ -870,9 +870,9 @@ impl Names { } } - /// The lists the match steps look up for an entry's normalised menu and path, most specific + /// The lists the match steps look up for an entry's normalized menu and path, most specific /// first, each with whether it is a panel-menu step. `panel` is `Some(known)` for a panel-menu - /// entry, `known` saying whether the catalogue has its panel; only then does its last segment + /// entry, `known` saying whether the catalog has its panel; only then does its last segment /// alone match. fn steps(&self, menu: Option<&String>, segs: &[String], panel: Option) -> Vec<(Option<&Vec>, bool)> { let Some(last) = segs.last() else { return Vec::new() }; @@ -900,7 +900,7 @@ impl Names { } } -/// The catalogue by normalised path and label, for the match steps. +/// The catalog by normalized path and label, for the match steps. struct Index { tool: Vec, /// Binding keys, for remembered answers. @@ -908,14 +908,14 @@ struct Index { names: Names, /// Toggle commands by their names without a leading Show/Hide ([`toggle_name`]). toggles: Names, - /// Every normalised segment of every catalogue path: the panels and menus the app has. + /// Every normalized segment of every catalog path: the panels and menus the app has. segments: HashSet, } impl Index { fn new(keymap: &Keymap) -> Index { let mut ix = Index { tool: Vec::new(), keys: HashMap::new(), names: Names::default(), toggles: Names::default(), segments: HashSet::new() }; - for (i, b) in keymap.catalogue().iter().enumerate() { + for (i, b) in keymap.catalog().iter().enumerate() { ix.tool.push(matches!(b.kind, Kind::Tool { .. })); ix.keys.insert(b.command.key(), i); ix.names.add(i, b, str::to_string); @@ -939,7 +939,7 @@ impl Index { /// The match steps in order; the first that finds exactly one command decides. The steps run /// on the names as written, then on toggle commands with the entry's label and theirs /// without a leading Show/Hide. A panel-menu entry's last segment alone matches only when - /// its first segment, the panel, is a segment of some catalogue path. + /// its first segment, the panel, is a segment of some catalog path. fn find(&self, entry: &ForeignEntry, tool: bool, remembered: &BTreeMap) -> Found { if let Some(c) = remembered.get(&entry.remember_key()) && let Some(&i) = self.keys.get(&c.key()) @@ -978,7 +978,7 @@ struct ToSuggest { /// Fuzzy candidates for unmatched entries, among commands of the same kind. fn suggest(keymap: &Keymap, entries: &[ToSuggest]) -> Vec { - let pool = |tool: bool| -> Vec<&Bindable> { keymap.catalogue().iter().filter(|b| matches!(b.kind, Kind::Tool { .. }) == tool).collect() }; + let pool = |tool: bool| -> Vec<&Bindable> { keymap.catalog().iter().filter(|b| matches!(b.kind, Kind::Tool { .. }) == tool).collect() }; let matcher = |pool: &[&Bindable]| { let candidates: Vec = pool .iter() diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 4ceb76d1..4c3f23fc 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -1,4 +1,4 @@ -//! A command catalogue with default bindings, the user's overrides, dispatch and conflicts. +//! A command catalog with default bindings, the user's overrides, dispatch and conflicts. pub(crate) mod overrides; @@ -14,7 +14,7 @@ use crate::scope::{Active, Scope, ScopeKind, Scopes}; pub use overrides::Overrides; pub use overrides::list as overrides_list; -/// Most commands a catalogue or an [`Overrides`] holds. +/// Most commands a catalog or an [`Overrides`] holds. pub const MAX_COMMANDS: usize = 4096; /// Most bindings per command. pub const MAX_BINDINGS: usize = 16; @@ -226,7 +226,7 @@ impl TypingRule { pub enum Lookup { None, Run(CommandRef), - /// Tools of one group share the chord: the app picks the next one (catalogue order). + /// Tools of one group share the chord: the app picks the next one (catalog order). Cycle(Vec), } @@ -272,7 +272,7 @@ pub enum AssignError { TooMany, /// Other commands hold the chord ([`Policy::Refuse`]). Taken(Vec), - /// A scope that is neither registered in [`Scopes`] nor used by the catalogue. + /// A scope that is neither registered in [`Scopes`] nor used by the catalog. UnknownScope(Scope), /// The change needs a new [`Overrides`] entry and it already holds [`MAX_COMMANDS`]. Full, @@ -311,13 +311,13 @@ pub enum DropReason { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Problem { - /// No command in the catalogue has this key; the entry is kept. + /// No command in the catalog has this key; the entry is kept. UnknownCommand { key: String }, /// A text that does not parse; the entry keeps it. BadBinding { key: String, text: String, error: BindingError }, /// A reserved chord; not bound. Reserved { key: String, text: String }, - /// A scope that is neither registered in [`Scopes`] nor used by the catalogue; not bound. + /// A scope that is neither registered in [`Scopes`] nor used by the catalog; not bound. UnknownScope { key: String, text: String }, /// Another entry names the same command; this one is ignored. DuplicateKey { key: String }, @@ -366,10 +366,10 @@ struct Group<'a> { /// one's command and binding. type Pair<'a> = (usize, usize, bool, (usize, &'a Bound), (usize, &'a Bound)); -/// The catalogue, the scope registry, the platform and the user's overrides. +/// The catalog, the scope registry, the platform and the user's overrides. #[derive(Clone, Debug)] pub struct Keymap { - catalogue: Vec, + catalog: Vec, keys: Vec, index: HashMap, defaults: Vec>, @@ -381,8 +381,8 @@ pub struct Keymap { /// Whether the overrides hold a hand-written variant of a command's key /// (`window.panel { "panel": "layers" }`); only loading adds them. variants: bool, - /// Home and default scopes of the catalogue. - catalogue_scopes: HashSet, + /// Home and default scopes of the catalog. + catalog_scopes: HashSet, scopes: Scopes, platform: Platform, typing: TypingRule, @@ -393,9 +393,9 @@ pub struct Keymap { impl Keymap { /// A keymap with default bindings. Entries past [`MAX_COMMANDS`], with a key over /// [`MAX_KEY`] bytes, or repeating an earlier key are left out. - pub fn new(catalogue: Vec, scopes: Scopes, platform: Platform) -> Keymap { + pub fn new(catalog: Vec, scopes: Scopes, platform: Platform) -> Keymap { let mut km = Keymap { - catalogue: Vec::new(), + catalog: Vec::new(), keys: Vec::new(), index: HashMap::new(), defaults: Vec::new(), @@ -403,27 +403,27 @@ impl Keymap { by_trigger: HashMap::new(), stored: Vec::new(), variants: false, - catalogue_scopes: HashSet::new(), + catalog_scopes: HashSet::new(), scopes, platform, typing: TypingRule::default(), reserved: Vec::new(), overrides: Overrides::new(), }; - for b in catalogue { + for b in catalog { let key = b.command.key(); - if km.catalogue.len() >= MAX_COMMANDS || key.len() > MAX_KEY || km.index.contains_key(&key) { + if km.catalog.len() >= MAX_COMMANDS || key.len() > MAX_KEY || km.index.contains_key(&key) { continue; } let defaults = km.normalize(b.defaults.iter().cloned(), &b.scope); - km.catalogue_scopes.insert(b.scope.clone()); - km.catalogue_scopes.extend(defaults.iter().map(|(_, s)| s.clone())); - km.index.insert(key.clone(), km.catalogue.len()); + km.catalog_scopes.insert(b.scope.clone()); + km.catalog_scopes.extend(defaults.iter().map(|(_, s)| s.clone())); + km.index.insert(key.clone(), km.catalog.len()); km.keys.push(key); km.current.push(defaults.clone()); km.defaults.push(defaults); km.stored.push(None); - km.catalogue.push(b); + km.catalog.push(b); } km.rebuild_index(); km @@ -468,12 +468,12 @@ impl Keymap { self.typing } - pub fn catalogue(&self) -> &[Bindable] { - &self.catalogue + pub fn catalog(&self) -> &[Bindable] { + &self.catalog } pub fn bindable(&self, command: &CommandRef) -> Option<&Bindable> { - self.find(command).map(|i| &self.catalogue[i]) + self.find(command).map(|i| &self.catalog[i]) } /// The effective bindings of a command; empty for an unknown one. @@ -492,9 +492,9 @@ impl Keymap { } /// Whether bindings may use `scope`: the application, a scope registered in [`Scopes`], or a - /// home or default scope of the catalogue. + /// home or default scope of the catalog. pub fn knows_scope(&self, scope: &Scope) -> bool { - scope.is_app() || self.scopes.knows(scope) || self.catalogue_scopes.contains(scope) + scope.is_app() || self.scopes.knows(scope) || self.catalog_scopes.contains(scope) } /// The command `chord` (pressed twice when `double`) runs with `active` focus. @@ -502,7 +502,7 @@ impl Keymap { let want = Trigger { chord: chord.effective(self.platform), double }; let held = active.typing && !self.typing.fires(&want.chord); let Some(holders) = self.by_trigger.get(&want) else { return Lookup::None }; - // Each command's nearest rank; `holders` is in catalogue order. + // Each command's nearest rank; `holders` is in catalog order. let mut ranks: Vec<(usize, u8)> = Vec::new(); for &(i, k) in holders { let Some(r) = self.at(i, k).and_then(|(_, s)| self.scopes.rank(s, active)) else { continue }; @@ -519,12 +519,12 @@ impl Keymap { let winners: Vec = ranks.iter().filter(|(_, r)| *r == best).map(|(i, _)| *i).collect(); match winners.as_slice() { [] => Lookup::None, - [i] => Lookup::Run(self.catalogue[*i].command.clone()), + [i] => Lookup::Run(self.catalog[*i].command.clone()), [first, rest @ ..] => { if rest.iter().all(|j| self.same_group(*first, *j)) { - Lookup::Cycle(winners.iter().map(|i| self.catalogue[*i].command.clone()).collect()) + Lookup::Cycle(winners.iter().map(|i| self.catalog[*i].command.clone()).collect()) } else { - Lookup::Run(self.catalogue[*first].command.clone()) + Lookup::Run(self.catalog[*first].command.clone()) } } } @@ -548,7 +548,7 @@ impl Keymap { if bindings.len() > MAX_BINDINGS { return Err(AssignError::TooMany); } - let home = self.catalogue[i].scope.clone(); + let home = self.catalog[i].scope.clone(); let list = self.normalize(bindings, &home); let new: Vec = list.iter().filter(|b| !self.current[i].iter().any(|c| self.same(c, b))).cloned().collect(); self.apply(i, list, &new, policy) @@ -588,14 +588,14 @@ impl Keymap { } /// Pairs of commands on one chord, at most [`MAX_CONFLICTS`], in chord order, then in - /// catalogue order. Tools of one group and bindings of panels that are never active together + /// catalog order. Tools of one group and bindings of panels that are never active together /// are left out; with panel fallthrough two unrelated panels on one chord tie in a third panel, /// which is reported as a [`ConflictKind::Shadow`]. /// /// When the cap cuts a chord's pairs, the pairs kept are the first found comparing the /// chord's holders grouped by scope and tool group, group by group in the order each group - /// first appears in the catalogue; the kept pairs are still returned in chord order, then - /// catalogue order. + /// first appears in the catalog; the kept pairs are still returned in chord order, then + /// catalog order. pub fn conflicts(&self) -> Vec { let mut chords: Vec<(&Trigger, &BTreeSet<(usize, usize)>)> = self.by_trigger.iter().filter(|(_, h)| h.len() > 1).collect(); chords.sort_by_key(|(t, _)| **t); @@ -609,7 +609,7 @@ impl Keymap { } /// The conflicts [`Keymap::conflicts`] reports for one command's bindings, with that command - /// as `a`, in the order of its bindings, then catalogue order. Empty for an unknown command. + /// as `a`, in the order of its bindings, then catalog order. Empty for an unknown command. pub fn conflicts_with(&self, command: &CommandRef) -> Vec { let Some(i) = self.find(command) else { return Vec::new() }; let mut out = Vec::new(); @@ -624,8 +624,8 @@ impl Keymap { let Some(clash) = self.conflict_relation(&a.1, &b.1) else { continue }; out.push(Conflict { chord, - a: (self.catalogue[i].command.clone(), self.public(i, a)), - b: (self.catalogue[j].command.clone(), self.public(j, b)), + a: (self.catalog[i].command.clone(), self.public(i, a)), + b: (self.catalog[j].command.clone(), self.public(j, b)), kind: if clash { ConflictKind::Clash } else { ConflictKind::Shadow }, in_defaults: self.defaults[i].contains(a) && self.defaults[j].contains(b), }); @@ -647,7 +647,7 @@ impl Keymap { out.push(Problem::DuplicateKey { key: key.to_string() }); continue; } - let home = &self.catalogue[i].scope; + let home = &self.catalog[i].scope; for text in texts { let (key, text) = (key.to_string(), text.clone()); match Binding::parse(&text) { @@ -666,7 +666,7 @@ impl Keymap { pub(crate) fn clashing(&self, command: &CommandRef, bindings: &[Binding]) -> Vec { let Some(i) = self.find(command) else { return Vec::new() }; let new: Vec = bindings.iter().map(|b| self.bound(i, b)).collect(); - self.clashes(i, &new).into_iter().map(|(j, b)| Holder { command: self.catalogue[j].command.clone(), binding: self.public(j, &b) }).collect() + self.clashes(i, &new).into_iter().map(|(j, b)| Holder { command: self.catalog[j].command.clone(), binding: self.public(j, &b) }).collect() } /// Whether binding `x` of command `a` and binding `y` of another command `b` clash, by the @@ -694,11 +694,11 @@ impl Keymap { } fn bound(&self, i: usize, binding: &Binding) -> Bound { - (binding.trigger, binding.scope_or(&self.catalogue[i].scope).clone()) + (binding.trigger, binding.scope_or(&self.catalog[i].scope).clone()) } fn public(&self, i: usize, (trigger, scope): &Bound) -> Binding { - let scope = (*scope != self.catalogue[i].scope).then(|| scope.clone()); + let scope = (*scope != self.catalog[i].scope).then(|| scope.clone()); Binding { trigger: *trigger, scope } } @@ -724,7 +724,7 @@ impl Keymap { } fn same_group(&self, i: usize, j: usize) -> bool { - match (&self.catalogue[i].kind, &self.catalogue[j].kind) { + match (&self.catalog[i].kind, &self.catalog[j].kind) { (Kind::Tool { group: a }, Kind::Tool { group: b }) => a == b, _ => false, } @@ -774,7 +774,7 @@ impl Keymap { let mut group_of: HashMap<(&Scope, Option<&Option>), usize> = HashMap::new(); for (pos, &(i, k)) in holders.iter().enumerate() { let Some(b) = self.at(i, k) else { continue }; - let tools = match &self.catalogue[i].kind { + let tools = match &self.catalog[i].kind { Kind::Tool { group } => Some(group), _ => None, }; @@ -814,8 +814,8 @@ impl Keymap { for (_, _, clash, (i, a), (j, b)) in found { out.push(Conflict { chord, - a: (self.catalogue[i].command.clone(), self.public(i, a)), - b: (self.catalogue[j].command.clone(), self.public(j, b)), + a: (self.catalog[i].command.clone(), self.public(i, a)), + b: (self.catalog[j].command.clone(), self.public(j, b)), kind: if clash { ConflictKind::Clash } else { ConflictKind::Shadow }, in_defaults: self.defaults[i].contains(a) && self.defaults[j].contains(b), }); @@ -830,13 +830,13 @@ impl Keymap { if let Some((t, _)) = new.iter().find(|(t, _)| self.is_reserved(&t.chord)) { return Err(AssignError::Reserved(*t)); } - let home = &self.catalogue[i].scope; + let home = &self.catalog[i].scope; if let Some((_, s)) = new.iter().find(|(_, s)| !self.usable_scope(s, home)) { return Err(AssignError::UnknownScope(s.clone())); } let clashes = self.clashes(i, new); let holders: Vec = - clashes.iter().map(|(j, b)| Holder { command: self.catalogue[*j].command.clone(), binding: self.public(*j, b) }).collect(); + clashes.iter().map(|(j, b)| Holder { command: self.catalog[*j].command.clone(), binding: self.public(*j, b) }).collect(); let mut done = Assigned::default(); let mut writes: Vec<(usize, Vec)> = Vec::new(); match policy { @@ -890,7 +890,7 @@ impl Keymap { kept.truncate(MAX_BINDINGS.saturating_sub(list.len())); self.remove_entries(i); if !kept.is_empty() || !self.is_default(i, &list) { - let home = &self.catalogue[i].scope; + let home = &self.catalog[i].scope; let mut texts: Vec = list.iter().map(|b| self.public(i, b).text(home)).collect(); texts.extend(kept); if self.overrides.insert(self.keys[i].clone(), texts) { @@ -930,7 +930,7 @@ impl Keymap { /// version, a scope this app lacks, a chord this platform reserves. fn unusable_texts(&self, i: usize) -> Vec { let Some(texts) = self.stored[i].as_deref().and_then(|key| self.overrides.get(key)) else { return Vec::new() }; - let home = &self.catalogue[i].scope; + let home = &self.catalog[i].scope; texts.iter().filter(|t| !Binding::parse(t).is_ok_and(|b| self.usable(&b, home))).cloned().collect() } @@ -985,7 +985,7 @@ impl Keymap { if !taken.insert(i) { continue; } - let home = &self.catalogue[i].scope; + let home = &self.catalog[i].scope; let usable = texts.iter().filter_map(|t| Binding::parse(t).ok()).filter(|b| self.usable(b, home)); let list = self.normalize(usable, home); // An entry none of whose texts is usable here (a key name from a newer version, a scope diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index e673848c..9fbcfa2e 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -5,7 +5,7 @@ //! [`chord`] holds the key and modifier types, the canonical text form that settings and control //! protocols store, display text per platform, and a reader for other applications' key text. //! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's -//! catalogue with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), +//! catalog with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]), //! editing and conflicts. [`reserved`] lists the chords each operating system takes; a keymap //! reserves no chord unless the app opts in with [`Keymap::with_reserved`], e.g. //! `with_reserved(reserved::system(platform))`. [`import`] brings in other applications' shortcut @@ -22,7 +22,7 @@ //! assert_eq!(chord.display(Platform::Windows), "Ctrl+Shift+Z"); //! ``` //! -//! # A catalogue and dispatch +//! # A catalog and dispatch //! //! The app lists every command it can bind. A command with parameters has the key //! `window.panel {"panel":"layers"}` in settings and reports. Tools of one group share a key and @@ -35,7 +35,7 @@ //! let key = |text: &str| Binding::parse(text).unwrap(); //! let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); //! let select = Kind::Tool { group: Some("select".into()) }; -//! let catalogue = vec![ +//! let catalog = vec![ //! Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(key("Cmd+S")), //! Bindable::new(layers.clone(), "Layers", Scope::app()).with_path(vec!["Window".into(), "Layers".into()]).with_default(key("F7")), //! Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(select.clone()).with_default(key("M")), @@ -46,7 +46,7 @@ //! // An app command whose binding works only in the Text context. //! Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), //! ]; -//! let keymap = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); +//! let keymap = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); //! assert_eq!(layers.key(), r#"window.panel {"panel":"layers"}"#); //! //! // Dispatch: the chord pressed, whether it is a double press, and what has the keyboard. @@ -87,9 +87,9 @@ //! const SCHEMA_VERSION: u32 = 1; //! } //! -//! # let catalogue = Vec::new(); +//! # let catalog = Vec::new(); //! let (mut store, _report) = SettingsStore::::open(Location::Auto); -//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); +//! let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone()); //! // Entries for unknown commands and texts that do not parse are kept and listed here. //! let _problems = keymap.problems(); //! diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 567a852e..04a4ee51 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -78,7 +78,7 @@ const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE fn dispatch_keymap() -> Keymap { let app = |id: &str, keys: &[&str]| keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(Binding::parse(k).unwrap())); let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) }); - let catalogue = vec![ + let catalog = vec![ app("edit.copy", &["Cmd+C", "Cmd+Insert"]), app("edit.paste", &["Cmd+V", "Shift+Insert"]), app("app.quit", &["Cmd+Alt+Q"]), @@ -94,7 +94,7 @@ fn dispatch_keymap() -> Keymap { app("reveal.one", &["U"]), app("reveal.all", &["U U"]), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Windows) + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Windows) } fn press(key: EguiKey, modifiers: Modifiers) -> Event { diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 586e02df..e1d72833 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -34,7 +34,7 @@ fn panel(name: &str) -> CommandRef { CommandRef::with_params("window.panel", json!({ "panel": name })) } -fn catalogue() -> Vec { +fn catalog() -> Vec { vec![ command("file.new", "Document…", &["File", "New", "Document…"], &["Cmd+N"]), command("dialog.newDocument", "New Document Dialog", &[], &["Cmd+N"]), @@ -65,7 +65,7 @@ fn catalogue() -> Vec { } fn keymap(platform: Platform) -> Keymap { - Keymap::new(catalogue(), Scopes::new().with_context("Text"), platform).with_reserved(reserved::system(platform)) + Keymap::new(catalog(), Scopes::new().with_context("Text"), platform).with_reserved(reserved::system(platform)) } fn key(flavor: Flavor, context: &str, text: &str) -> ForeignKey { @@ -139,7 +139,7 @@ fn has(km: &Keymap, id: impl Into, texts: &[&str]) { } #[test] -fn labels_normalise_for_matching() { +fn labels_normalize_for_matching() { assert_eq!(normalize_label("Check Spelling..."), "check spelling"); assert_eq!(normalize_label("Check Spelling…"), "check spelling"); assert_eq!(normalize_label("Grids && Guides"), "grids guides"); @@ -152,7 +152,7 @@ fn labels_normalise_for_matching() { fn every_match_step_finds_its_command() { let (km, r) = import_windows(); assert_eq!(r.set.as_deref(), Some("My Set")); - // 1: full path, with `&`, case and spacing normalised, and Show/Hide dropped for a toggle. + // 1: full path, with `&`, case and spacing normalized, and Show/Hide dropped for a toggle. has(&km, "view.guides", &["Cmd+Alt+;"]); // 2: joined segments `Text Frame Options` in Object. has(&km, "object.textFrameOptions", &["Cmd+Alt+B"]); @@ -463,12 +463,12 @@ fn hostile_sets_are_capped_and_counted() { #[test] #[ignore = "worst-case timing; run with --ignored"] fn hostile_sets_finish_in_time() { - // A full catalogue whose labels share words with every unmatched entry below, so each + // A full catalog whose labels share words with every unmatched entry below, so each // suggestion scores every command. - let mut catalogue = catalogue(); - let filler = MAX_COMMANDS - catalogue.len(); - catalogue.extend((0..filler).map(|i| command(format!("filler.c{i}").as_str(), &format!("Filler Command {i}"), &[], &[]))); - let mut km = Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + let mut catalog = catalog(); + let filler = MAX_COMMANDS - catalog.len(); + catalog.extend((0..filler).map(|i| command(format!("filler.c{i}").as_str(), &format!("Filler Command {i}"), &[], &[]))); + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); let long: String = "ä€𝄞 x".repeat(MAX_IMPORT_TEXT); // Odd entries have keys and long labels that match nothing, so they reach the suggestions: // words in the whole label, or two words and symbols, which score high enough to suggest. @@ -593,8 +593,8 @@ fn report_json_uses_camel_case_names() { assert_eq!(v["changed"][0]["from"], json!(["Cmd+W"])); } -fn import_into(catalogue: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { - let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Windows); +fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); let report = plan(&ForeignSet { name: None, entries }, &km, options).apply(&mut km); (km, report) } @@ -609,7 +609,7 @@ fn unmatched(r: &ImportReport) -> Vec<(&str, UnmatchedReason)> { #[test] fn panel_menu_entries_match_by_path_end_or_label() { - let catalogue = vec![ + let catalog = vec![ command("type.allCaps", "All Caps", &["Panels", "Character", "All Caps"], &[]), command("pages.overrideAll", "Override All Parent Items", &["Pages", "Override All Parent Page Items"], &[]), command("paragraph.justification", "Justification…", &[], &[]), @@ -637,10 +637,10 @@ fn panel_menu_entries_match_by_path_end_or_label() { panel_entry(&["Character", "Small Caps"], "Ctrl+F5"), // No path end fits and the label alone fits two commands. panel_entry(&["Color", "New Swatch"], "Ctrl+F6"), - // Without the mark, a path with no menu matches only a whole catalogue path. + // Without the mark, a path with no menu matches only a whole catalog path. entry(None, &["Character", "All Caps"], &[(d, "Ctrl+F7")], Flavor::Windows), ]; - let (km, r) = import_into(catalogue, entries, &default_context()); + let (km, r) = import_into(catalog, entries, &default_context()); has(&km, "type.allCaps", &["Cmd+F1"]); has(&km, "pages.overrideAll", &["Cmd+F2"]); has(&km, "paragraph.justification", &["Cmd+F3"]); @@ -659,7 +659,7 @@ fn panel_menu_entries_match_by_path_end_or_label() { #[test] fn aliases_match_like_labels() { - let catalogue = vec![ + let catalog = vec![ command("app.quit", "Quit DesignCraft", &["File", "Quit DesignCraft"], &[]).with_alias("Exit"), command("app.preferences", "Preferences…", &["Edit", "Preferences…"], &[]).with_alias("Preferences: General…"), command("type.bold", "Bold", &["Type", "Style", "Bold"], &[]).with_alias("Apply bold"), @@ -672,7 +672,7 @@ fn aliases_match_like_labels() { entry(None, &["Apply Bold"], &[(d, "Ctrl+Shift+B")], Flavor::Windows), win("Bearbeiten", &["Einfügen"], &[(d, "Ctrl+Shift+V")]), ]; - let (km, r) = import_into(catalogue, entries, &default_context()); + let (km, r) = import_into(catalog, entries, &default_context()); has(&km, "app.quit", &["Cmd+Q"]); has(&km, "app.preferences", &["Cmd+K"]); has(&km, "type.bold", &["Cmd+Shift+B"]); @@ -682,7 +682,7 @@ fn aliases_match_like_labels() { #[test] fn show_and_hide_are_read_off_only_for_toggles() { - let catalogue = vec![ + let catalog = vec![ command(panel("conveyor"), "Conveyor", &["Window", "Conveyor"], &[]), command("view.rulers", "Rulers", &["View", "Rulers"], &[]).toggle(), command("view.frameEdges", "Show Frame Edges", &["View", "Extras", "Show Frame Edges"], &[]).toggle(), @@ -700,7 +700,7 @@ fn show_and_hide_are_read_off_only_for_toggles() { win("Type", &["Show Hidden Characters"], &[(d, "Ctrl+Alt+I")]), win("Type", &["Hide Hidden Characters"], &[(d, "Ctrl+Alt+H")]), ]; - let (km, r) = import_into(catalogue, entries, &default_context()); + let (km, r) = import_into(catalog, entries, &default_context()); has(&km, panel("conveyor"), &[]); has(&km, "view.rulers", &["Cmd+R", "Cmd+Shift+R"]); has(&km, "view.frameEdges", &["Cmd+H"]); @@ -713,7 +713,7 @@ fn show_and_hide_are_read_off_only_for_toggles() { #[test] fn only_entries_with_keys_to_bind_count_toward_suggestions() { - let catalogue = vec![command("edit.checkSpelling", "Check Spelling…", &["Edit", "Check Spelling…"], &[])]; + let catalog = vec![command("edit.checkSpelling", "Check Spelling…", &["Edit", "Check Spelling…"], &[])]; let d = "Default"; // Twice the cap of unmatched entries without keys, or with keys only in a skipped context, // before one with a key. @@ -726,7 +726,7 @@ fn only_entries_with_keys_to_bind_count_toward_suggestions() { suggest: true, ..ImportOptions::default() }; - let (_, r) = import_into(catalogue, entries, &options); + let (_, r) = import_into(catalog, entries, &options); assert_eq!(r.unmatched_without_shortcut, 2 * MAX_SUGGESTIONS); assert_eq!(r.unmatched.len(), 1); assert_eq!(r.unmatched[0].keys, "Default: Ctrl+K", "keys in a skipped context are left out"); @@ -735,7 +735,7 @@ fn only_entries_with_keys_to_bind_count_toward_suggestions() { assert_eq!(r.truncated, 0); } -fn pages_catalogue() -> Vec { +fn pages_catalog() -> Vec { vec![ command("app.newDocumentDialog", "New Document…", &["File", "New Document…"], &["Cmd+N"]), command("layout.pages.insert", "Insert Pages…", &["Layout", "Pages", "Insert Pages…"], &["Cmd+Shift+P"]), @@ -754,7 +754,7 @@ fn panel_menu_entries_without_keys_change_nothing() { // The same for the last segment alone, with a panel the app has. none(&["Pages", "New Document"]), ]; - let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + let (km, r) = import_into(pages_catalog(), entries, &default_context()); has(&km, "app.newDocumentDialog", &["Cmd+N"]); has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); assert!(r.changed.is_empty(), "{:?}", r.changed); @@ -781,7 +781,7 @@ fn an_entry_without_keys_never_clears_keys_another_entry_gives() { let keyed = || win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Alt+P")]); let keyless = || win("Layout", &["Insert Pages..."], &[]); for entries in [vec![keyed(), keyless()], vec![keyless(), keyed()]] { - let (km, _) = import_into(pages_catalogue(), entries, &default_context()); + let (km, _) = import_into(pages_catalog(), entries, &default_context()); has(&km, "layout.pages.insert", &["Cmd+Alt+P"]); } @@ -790,14 +790,14 @@ fn an_entry_without_keys_never_clears_keys_another_entry_gives() { ForeignEntry::new(None, ["Pages", "Insert Pages..."]).in_panel_menu(), win("Layout", &["Pages", "Insert Pages..."], &[(d, "Ctrl+Shift+P")]), ]; - let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + let (km, r) = import_into(pages_catalog(), entries, &default_context()); has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); assert_eq!(r.matched_without_change.len(), 1); // The later entry still takes the chord from another command; the entry without keys does // not clear the command that lost it. let entries = vec![keyless(), keyed(), win("File", &["Close"], &[(d, "Ctrl+Alt+P")])]; - let (km, r) = import_into(pages_catalogue(), entries, &default_context()); + let (km, r) = import_into(pages_catalog(), entries, &default_context()); has(&km, "file.close", &["Cmd+Alt+P"]); has(&km, "layout.pages.insert", &["Cmd+Shift+P"]); assert_eq!(r.skipped.iter().map(|s| (s.command.id.as_str(), s.reason)).collect::>(), [("layout.pages.insert", SkipReason::Taken)]); diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 502c3ced..3a8a2483 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -54,14 +54,14 @@ fn command_keys_are_canonical_whatever_the_map_order() { #[test] fn lookup_picks_the_nearest_scope() { - let catalogue = vec![ + let catalog = vec![ app("edit.copy", &["Cmd+C"]), in_scope("timeline.copy", "Timeline", &["Cmd+C"]), app("file.bake", &["Cmd+B"]), in_scope("text.bold", "Text", &["Cmd+B", "Cmd+I"]), in_scope("timeline.loop", "Timeline", &["L"]), ]; - let km = Keymap::new(catalogue.clone(), scopes(), Platform::Mac); + let km = Keymap::new(catalog.clone(), scopes(), Platform::Mac); let text = ["Text"]; assert_eq!(press(&km, "Cmd+C", &Active::NONE), run("edit.copy")); assert_eq!(press(&km, "Cmd+C", &Active::panel("Timeline")), run("timeline.copy")); @@ -73,14 +73,14 @@ fn lookup_picks_the_nearest_scope() { assert_eq!(press(&km, "L", &Active::panel("Effects")), run("timeline.loop"), "fallthrough"); assert_eq!(km.lookup(&chord("L"), true, &Active::panel("Timeline")), Lookup::None, "double press is its own trigger"); - let strict = Keymap::new(catalogue, scopes().with_panel_fallthrough(false), Platform::Mac); + let strict = Keymap::new(catalog, scopes().with_panel_fallthrough(false), Platform::Mac); assert_eq!(press(&strict, "L", &Active::panel("Effects")), Lookup::None); assert_eq!(press(&strict, "L", &Active::panel("Timeline")), run("timeline.loop")); } #[test] fn typing_rules_decide_what_fires_in_a_text_field() { - let catalogue = vec![app("tool.brush", &["B"]), app("edit.copy", &["Cmd+C"]), app("help", &["F1"]), app("view.alt", &["Alt+V"])]; + let catalog = vec![app("tool.brush", &["B"]), app("edit.copy", &["Cmd+C"]), app("help", &["F1"]), app("view.alt", &["Alt+V"])]; let typing = Active { panel: None, contexts: &[], typing: true }; let cases = [ (TypingRule::PlainKeysWait, [false, true, true, true]), @@ -88,7 +88,7 @@ fn typing_rules_decide_what_fires_in_a_text_field() { (TypingRule::AllWait, [false, false, false, false]), ]; for (rule, fires) in cases { - let km = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows).with_typing_rule(rule); + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_typing_rule(rule); for ((key, id), fire) in [("B", "tool.brush"), ("Cmd+C", "edit.copy"), ("F1", "help"), ("Alt+V", "view.alt")].into_iter().zip(fires) { let want = if fire { run(id) } else { Lookup::None }; assert_eq!(press(&km, key, &typing), want, "{rule:?} {key}"); @@ -99,7 +99,7 @@ fn typing_rules_decide_what_fires_in_a_text_field() { #[test] fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { - let catalogue = vec![ + let catalog = vec![ app("text.bold", &["Text: Cmd+B", "Text: B"]), app("file.bake", &["Cmd+B"]), in_scope("timeline.cut", "Timeline", &["Cmd+K"]), @@ -107,7 +107,7 @@ fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { ]; let editing = Active { panel: Some("Timeline"), contexts: &["Text"], typing: true }; let elsewhere = Active { contexts: &[], ..editing }; - let km = Keymap::new(catalogue, scopes(), Platform::Mac).with_typing_rule(TypingRule::AllWait); + let km = Keymap::new(catalog, scopes(), Platform::Mac).with_typing_rule(TypingRule::AllWait); assert_eq!(press(&km, "Cmd+B", &editing), run("text.bold")); assert_eq!(press(&km, "B", &editing), run("text.bold")); assert_eq!(press(&km, "Cmd+K", &editing), Lookup::None, "panel bindings wait"); @@ -134,8 +134,8 @@ fn tools_of_one_group_cycle_and_keep_their_shared_key() { #[test] fn steal_keep_and_refuse() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), in_scope("timeline.copy", "Timeline", &[])]; - let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), in_scope("timeline.copy", "Timeline", &[])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); let dup = cmd("edit.duplicate"); let err = km.assign(&dup, b("Cmd+C"), Policy::Refuse).unwrap_err(); @@ -174,8 +174,8 @@ fn twins_keep_their_shared_default_key() { #[test] fn clearing_a_command_frees_its_panel_copies() { // filmcraft#1333: New Bin holds Cmd+B in the application and in the Project panel. - let catalogue = vec![app("project.newBin", &["Cmd+B", "Project: Cmd+B"]), app("clip.bold", &[])]; - let mut km = Keymap::new(catalogue, Scopes::new().with_panel("Project"), Platform::Mac); + let catalog = vec![app("project.newBin", &["Cmd+B", "Project: Cmd+B"]), app("clip.bold", &[])]; + let mut km = Keymap::new(catalog, Scopes::new().with_panel("Project"), Platform::Mac); assert_eq!(press(&km, "Cmd+B", &Active::panel("Project")), run("project.newBin")); assert!(km.clear(&cmd("project.newBin"))); assert!(km.bindings(&cmd("project.newBin")).is_empty()); @@ -185,25 +185,25 @@ fn clearing_a_command_frees_its_panel_copies() { #[test] fn reserved_chords_are_refused() { - let catalogue = vec![app("view.next", &[])]; - let mut mac = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_reserved(reserved::system(Platform::Mac)); + let catalog = vec![app("view.next", &[])]; + let mut mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_reserved(reserved::system(Platform::Mac)); let err = mac.assign(&cmd("view.next"), b("Cmd+Tab"), Policy::Steal).unwrap_err(); assert_eq!(err, AssignError::Reserved(Trigger::parse("Cmd+Tab").unwrap())); - let mut win = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); + let mut win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows).with_reserved(reserved::system(Platform::Windows)); assert!(matches!(win.assign(&cmd("view.next"), b("Ctrl+Alt+Delete"), Policy::Steal), Err(AssignError::Reserved(_)))); assert!(reserved::system(Platform::Linux).contains(&chord("Cmd+Alt+F12"))); let mut o = Overrides::new(); o.insert("view.next", vec!["Alt+F4".into()]); - let linux = Keymap::new(catalogue, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + let linux = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); assert!(linux.bindings(&cmd("view.next")).is_empty()); assert_eq!(linux.problems(), vec![Problem::Reserved { key: "view.next".into(), text: "Alt+F4".into() }]); } #[test] fn conflicts_tell_clashes_from_shadows() { - let catalogue = vec![ + let catalog = vec![ app("edit.copy", &["Cmd+C"]), app("color.copy", &["Cmd+C"]), in_scope("timeline.copy", "Timeline", &["Cmd+C"]), @@ -214,7 +214,7 @@ fn conflicts_tell_clashes_from_shadows() { in_scope("render.find", "Render", &["Cmd+R"]), in_scope("effects.render", "Effects", &["Cmd+R"]), ]; - let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); let summary = |km: &Keymap| -> Vec<(String, String, String, ConflictKind, bool)> { km.conflicts().into_iter().map(|c| (c.chord.to_string(), c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect() }; @@ -254,7 +254,7 @@ fn conflicts_tell_clashes_from_shadows() { assert_eq!(with("edit.copy"), [w("Cmd+C", "color.copy", ConflictKind::Clash), w("Cmd+C", "timeline.copy", ConflictKind::Shadow)]); assert_eq!(with("edit.cut"), [w("Cmd+X", "text.cut", ConflictKind::Shadow), w("Cmd+D", "edit.find", ConflictKind::Clash)]); assert!(with("nope").is_empty()); - for entry in km.catalogue() { + for entry in km.catalog() { let id = entry.command.id.as_str(); let mut direct: Vec<_> = km.conflicts_with(&entry.command).into_iter().map(|c| (c.chord, c.a.0.id, c.b.0.id, c.kind, c.in_defaults)).collect(); @@ -275,18 +275,18 @@ fn conflicts_tell_clashes_from_shadows() { assert_eq!(direct, filtered, "{id}"); } - let strict = Keymap::new(km.catalogue().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); + let strict = Keymap::new(km.catalog().to_vec(), scopes().with_panel_fallthrough(false), Platform::Mac); assert!(!strict.conflicts().iter().any(|c| c.chord.to_string() == "Cmd+R")); } /// `commands` commands, each with 16 bindings on one chord in 16 panels. fn crowded(commands: usize, scopes: Scopes, kind: Kind) -> (Keymap, Overrides) { - let catalogue: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); + let catalog: Vec = (0..commands).map(|n| app(&format!("c.{n}"), &[]).with_kind(kind.clone())).collect(); let mut o = Overrides::new(); for n in 0..commands { assert!(o.insert(format!("c.{n}"), (1..=MAX_BINDINGS).map(|p| format!("P{p}: K")).collect())); } - (Keymap::new(catalogue, scopes, Platform::Mac), o) + (Keymap::new(catalog, scopes, Platform::Mac), o) } fn panels(n: usize) -> Scopes { @@ -344,8 +344,8 @@ fn crowded_chords_stay_fast() { #[test] fn assigning_many_commands_stays_fast() { - let catalogue: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[])).collect(); - let mut km = Keymap::new(catalogue, panels(MAX_BINDINGS), Platform::Mac); + let catalog: Vec = (0..MAX_COMMANDS).map(|n| app(&format!("c.{n}"), &[])).collect(); + let mut km = Keymap::new(catalog, panels(MAX_BINDINGS), Platform::Mac); let list: Vec = (1..=MAX_BINDINGS).map(|p| b(&format!("P{p}: K"))).collect(); let t = Instant::now(); for n in 0..MAX_COMMANDS { @@ -377,14 +377,14 @@ fn binding_text_round_trips_for_every_key() { #[test] fn edits_are_refused_when_the_overrides_are_full() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; let mut o = Overrides::new(); o.insert("edit.cut", vec!["Cmd+X".into()]); for n in 1..MAX_COMMANDS { o.insert(format!("junk.{n}"), vec![]); } assert_eq!(o.len(), MAX_COMMANDS); - let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(o.clone()); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o.clone()); let (copy, dup, cut) = (cmd("edit.copy"), cmd("edit.duplicate"), cmd("edit.cut")); assert_eq!(km.assign(&dup, b("Cmd+K"), Policy::Steal), Err(AssignError::Full)); @@ -405,11 +405,11 @@ fn edits_are_refused_when_the_overrides_are_full() { #[test] fn bindings_in_unknown_scopes_are_refused() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; - let mut km = Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Mac); + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac); let copy = cmd("edit.copy"); assert_eq!(km.assign(©, b("Nowhere: Cmd+K"), Policy::Steal), Err(AssignError::UnknownScope(scope("Nowhere")))); - assert!(km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).is_ok(), "a catalogue scope"); + assert!(km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).is_ok(), "a catalog scope"); assert!(km.assign(©, b("Text: Cmd+K"), Policy::Steal).is_ok(), "a registered scope"); let mut o = Overrides::new(); @@ -421,11 +421,11 @@ fn bindings_in_unknown_scopes_are_refused() { #[test] fn unreadable_entries_keep_the_defaults() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"])]; + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"])]; let mut o = Overrides::new(); o.insert("edit.copy", vec!["Cmd+Nope".into()]); o.insert("edit.paste", vec![]); - let mut km = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(o); + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(o); assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")]); assert!(km.bindings(&cmd("edit.paste")).is_empty()); assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+Nope".to_string()][..]), "verbatim"); @@ -435,13 +435,13 @@ fn unreadable_entries_keep_the_defaults() { #[test] fn entries_with_nothing_usable_keep_the_defaults() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.cut", &["Cmd+X"]), app("edit.paste", &["Cmd+V"]), app("edit.undo", &["Cmd+Z"])]; + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.cut", &["Cmd+X"]), app("edit.paste", &["Cmd+V"]), app("edit.undo", &["Cmd+Z"])]; let mut o = Overrides::new(); o.insert("edit.copy", vec!["Nowhere: Cmd+K".into()]); o.insert("edit.cut", vec!["Alt+F4".into()]); o.insert("edit.paste", vec!["Nowhere: Cmd+K".into(), "Alt+F4".into(), "Cmd+J".into()]); o.insert("edit.undo", vec![]); - let km = Keymap::new(catalogue, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); + let km = Keymap::new(catalog, Scopes::new(), Platform::Linux).with_reserved(reserved::system(Platform::Linux)).with_overrides(o); assert_eq!(km.bindings(&cmd("edit.copy")), vec![b("Cmd+C")], "only an unknown scope"); assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+X")], "only a reserved chord"); assert_eq!(km.bindings(&cmd("edit.paste")), vec![b("Cmd+J")]); @@ -450,8 +450,8 @@ fn entries_with_nothing_usable_keep_the_defaults() { #[test] fn overrides_hold_only_what_differs_from_the_defaults() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; - let mut km = Keymap::new(catalogue, scopes(), Platform::Mac); + let catalog = vec![app("edit.copy", &["Cmd+C"]), in_scope("timeline.mark", "Timeline", &["M"])]; + let mut km = Keymap::new(catalog, scopes(), Platform::Mac); let copy = cmd("edit.copy"); km.assign(©, b("Timeline: Cmd+K"), Policy::Steal).unwrap(); assert_eq!(km.overrides().get("edit.copy"), Some(&["Cmd+C".to_string(), "Timeline: Cmd+K".to_string()][..])); @@ -468,10 +468,10 @@ fn overrides_hold_only_what_differs_from_the_defaults() { #[test] fn edits_keep_the_texts_this_build_cannot_use() { - let catalogue = vec![app("file.quit", &["Cmd+Q"]), app("file.close", &["Cmd+W"]), app("file.save", &["Cmd+S"])]; + let catalog = vec![app("file.quit", &["Cmd+Q"]), app("file.close", &["Cmd+W"]), app("file.save", &["Cmd+S"])]; let mut o = Overrides::new(); o.insert("file.quit", vec!["Cmd+Q".into(), "Cmd+FutureKey".into(), "Nowhere: Cmd+J".into()]); - let mut km = Keymap::new(catalogue.clone(), scopes(), Platform::Mac).with_overrides(o); + let mut km = Keymap::new(catalog.clone(), scopes(), Platform::Mac).with_overrides(o); let quit = cmd("file.quit"); let texts = |km: &Keymap| km.overrides().get("file.quit").map(<[String]>::to_vec); let strings = |t: &[&str]| Some(t.iter().map(|s| s.to_string()).collect::>()); @@ -480,7 +480,7 @@ fn edits_keep_the_texts_this_build_cannot_use() { assert_eq!(texts(&km), strings(&["Cmd+Q", "Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"])); km.assign(&cmd("file.save"), b("Cmd+Q"), Policy::Steal).unwrap(); assert_eq!(texts(&km), strings(&["Cmd+W", "Cmd+FutureKey", "Nowhere: Cmd+J"]), "taken by a Steal"); - let reloaded = Keymap::new(catalogue, scopes(), Platform::Mac).with_overrides(km.overrides().clone()); + let reloaded = Keymap::new(catalog, scopes(), Platform::Mac).with_overrides(km.overrides().clone()); assert_eq!(reloaded.bindings(&quit), vec![b("Cmd+W")]); // An entry of only such texts would load as the defaults, so a cleared command drops them. @@ -500,17 +500,17 @@ fn an_override_equal_to_the_defaults_on_this_platform_is_not_stored() { #[test] fn an_empty_text_in_the_map_form_removes_the_shortcut() { - let catalogue = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"]), app("edit.cut", &["Cmd+X"])]; + let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.paste", &["Cmd+V"]), app("edit.cut", &["Cmd+X"])]; let o: Overrides = serde_json::from_str(r#"{"edit.copy": "", "edit.paste": [""], "edit.cut": "Cmd+K"}"#).unwrap(); assert!(o.load_notes().is_empty(), "{:?}", o.load_notes()); - let km = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(o); + let km = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o); assert!(km.bindings(&cmd("edit.copy")).is_empty(), "a removed default stays removed"); assert!(km.bindings(&cmd("edit.paste")).is_empty()); assert_eq!(km.bindings(&cmd("edit.cut")), vec![b("Cmd+K")]); assert!(km.problems().is_empty()); let saved = serde_json::to_string(km.overrides()).unwrap(); - let again = Keymap::new(catalogue, Scopes::new(), Platform::Mac).with_overrides(serde_json::from_str(&saved).unwrap()); + let again = Keymap::new(catalog, Scopes::new(), Platform::Mac).with_overrides(serde_json::from_str(&saved).unwrap()); assert!(again.bindings(&cmd("edit.copy")).is_empty(), "{saved}"); } @@ -548,9 +548,9 @@ fn overrides_write_the_map_form_unless_a_command_has_several_keys() { #[test] fn ctrl_folds_into_cmd_off_macos() { - let catalogue = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; - let win = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Windows); - let mac = Keymap::new(catalogue, Scopes::new(), Platform::Mac); + let catalog = vec![app("edit.cut", &["Cmd+X"]), app("mac.cut", &["Ctrl+X"])]; + let win = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + let mac = Keymap::new(catalog, Scopes::new(), Platform::Mac); let conflicts = win.conflicts(); assert_eq!(conflicts.len(), 1); assert_eq!((conflicts[0].kind, conflicts[0].chord.to_string()), (ConflictKind::Clash, "Cmd+X".to_string())); @@ -606,12 +606,12 @@ fn hostile_overrides_load_without_panic_and_report_problems() { #[test] fn reset_removes_hand_written_variants_of_a_key() { let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"})); - let catalogue = vec![Bindable::new(layers.clone(), "Layers", Scope::app()).with_default(b("F7"))]; + let catalog = vec![Bindable::new(layers.clone(), "Layers", Scope::app()).with_default(b("F7"))]; let load = || { let mut o = Overrides::new(); o.insert(r#"window.panel {"panel":"layers"}"#, vec!["Cmd+L".into()]); o.insert(r#"window.panel { "panel": "layers" }"#, vec!["Cmd+K".into()]); - Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(o) + Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(o) }; let mut km = load(); assert_eq!(km.bindings(&layers), vec![b("Cmd+L")], "the canonical entry wins"); @@ -622,6 +622,6 @@ fn reset_removes_hand_written_variants_of_a_key() { let mut km = load(); km.set_bindings(&layers, vec![b("F7")], Policy::Steal).unwrap(); assert!(km.overrides().is_empty(), "{:?}", km.overrides()); - let again = Keymap::new(catalogue.clone(), Scopes::new(), Platform::Mac).with_overrides(km.overrides().clone()); + let again = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac).with_overrides(km.overrides().clone()); assert_eq!(again.bindings(&layers), vec![b("F7")]); } diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index 89c744bd..c05c8448 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -33,13 +33,13 @@ fn open(dir: &std::path::Path) -> SettingsStore { } fn keymap(overrides: Overrides) -> Keymap { - let catalogue = vec![ + let catalog = vec![ Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()), Bindable::new("text.bold", "Bold", Scope::app()), Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), Bindable::new("edit.paste", "Paste", Scope::app()).with_default(Binding::parse("Cmd+V").unwrap()), ]; - Keymap::new(catalogue, Scopes::new().with_context("Text"), Platform::Mac).with_overrides(overrides) + Keymap::new(catalog, Scopes::new().with_context("Text"), Platform::Mac).with_overrides(overrides) } #[test] From 4674ab7c85d0f63c09ce8cd462831835d2708fc8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:28:24 +0000 Subject: [PATCH 032/124] keymap: use US spelling in the shortcuts dialog --- crates/keymap/src/editor.rs | 8 +++--- crates/keymap/src/profiles.rs | 2 +- crates/keymap/src/ui/keyboard.rs | 4 +-- crates/keymap/src/ui/mod.rs | 8 +++--- crates/keymap/src/ui/review.rs | 4 +-- crates/keymap/src/ui/rows.rs | 36 +++++++++++++-------------- crates/keymap/src/ui/state.rs | 2 +- crates/keymap/src/ui/style.rs | 6 ++--- crates/keymap/src/ui/summary.rs | 4 +-- crates/keymap/src/ui/view.rs | 2 +- crates/keymap/tests/editor.rs | 6 ++--- crates/keymap/tests/profiles.rs | 6 ++--- crates/keymap/tests/ui_dialog.rs | 30 +++++++++++----------- crates/keymap/tests/ui_fixture/mod.rs | 12 ++++----- 14 files changed, 65 insertions(+), 65 deletions(-) diff --git a/crates/keymap/src/editor.rs b/crates/keymap/src/editor.rs index 2598552b..e71f687c 100644 --- a/crates/keymap/src/editor.rs +++ b/crates/keymap/src/editor.rs @@ -7,11 +7,11 @@ //! use craft_keymap::editor::holders; //! use craft_keymap::{AssignError, Bindable, Binding, Chord, EditSession, Keymap, Platform, Policy, Recorded, Recorder, Scope, Scopes}; //! -//! let catalogue = vec![ +//! let catalog = vec![ //! Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), //! Bindable::new("edit.copy", "Copy", Scope::app()).with_default(Binding::parse("Cmd+C").unwrap()), //! ]; -//! let mut keymap = Keymap::new(catalogue, Scopes::new(), Platform::Mac); +//! let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::Mac); //! let now = 12.5; //! //! let mut session = EditSession::begin(&keymap); @@ -231,13 +231,13 @@ impl Recorder { } } -/// The bindables matching `query`, in catalogue order: a case-insensitive substring of the label, +/// The bindables matching `query`, in catalog order: a case-insensitive substring of the label, /// an alias, a menu path segment, the command id, or a binding's canonical or `platform` display /// text. An empty query matches everything. pub fn search<'a>(keymap: &'a Keymap, query: &str, platform: Platform) -> Vec<&'a Bindable> { let q = query.trim().to_lowercase(); keymap - .catalogue() + .catalog() .iter() .filter(|b| { if q.is_empty() { diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index 8642f0f8..baa4b4b9 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -36,7 +36,7 @@ const FORMAT: &str = "craft-keymap"; const VERSION: u64 = 1; const IMPORTED: &str = "Imported"; -/// The app's built-in sets: name and overrides against the catalogue defaults. +/// The app's built-in sets: name and overrides against the catalog defaults. pub type Builtins<'a> = [(&'a str, Overrides)]; /// Why a set operation was refused. diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 9483ad26..32e92ad6 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -1,5 +1,5 @@ //! The on-screen keyboard pane (§2.9 of the dialog plan, `Options::keyboard`): a US ANSI layout -//! coloured by who holds each key with the chosen modifiers, modifier toggles that also follow the +//! colored by who holds each key with the chosen modifiers, modifier toggles that also follow the //! held modifiers, the shortcuts of the clicked key, and Assign to Selected. use std::collections::HashMap; @@ -242,7 +242,7 @@ impl KeyboardState { } let platform = keymap.platform(); let mut map: HashMap> = HashMap::new(); - for b in keymap.catalogue() { + for b in keymap.catalog() { for binding in keymap.bindings(&b.command) { let scope = binding.scope_or(&b.scope).clone(); let holder = Holder { command: b.command.clone(), binding }; diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index dbe9e0a3..cd476f37 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -1,4 +1,4 @@ -//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalogue with recording, +//! The Keyboard Shortcuts dialog (feature `ui`): a table of the catalog with recording, //! conflicts, undo, named sets and import review, over [`Keymap`] and optionally [`Profiles`]. //! //! The app keeps a [`DialogState`] while the dialog is open and calls [`ShortcutsDialog::show`] @@ -72,14 +72,14 @@ pub struct Area { pub rows: fn(&Bindable) -> bool, } -/// A read-only row for a key outside the catalogue. +/// A read-only row for a key outside the catalog. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FixedRow { pub group: String, pub label: String, /// The keys as shown. pub keys: String, - /// The trigger, when the row has one, so a catalogue binding on it can be pointed out. + /// The trigger, when the row has one, so a catalog binding on it can be pointed out. pub chord: Option, } @@ -114,7 +114,7 @@ pub struct Options { pub backspace_clears: bool, /// false: Enter when idle is OK. pub enter_is_ok: bool, - /// false: rows sort by translated label instead of catalogue order. + /// false: rows sort by translated label instead of catalog order. pub sort_by_label: bool, /// false: Show as tabs instead of a dropdown. pub area_tabs: bool, diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index f0523404..006d9405 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -16,7 +16,7 @@ struct Entry { line: Option, keys: String, note: String, - /// The note is what the item becomes (Will change), drawn in the text colour, not dimmed. + /// The note is what the item becomes (Will change), drawn in the text color, not dimmed. to: bool, /// Chord text inside the note, kept out of bidi reordering. note_chord: String, @@ -55,7 +55,7 @@ impl Draw<'_, '_> { line.map(|n| fill(self.ui("line {n}"), &[("n", &n.to_string())])) } - /// "item · line n · keys — note" on one wrapping line, the item in the strong colour. + /// "item · line n · keys — note" on one wrapping line, the item in the strong color. fn entry(&self, ui: &mut Ui, e: &Entry) { let font = TextStyle::Body.resolve(ui.style()); let strong = ui.visuals().strong_text_color(); diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 93804092..5bc3e0c2 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -69,7 +69,7 @@ pub(crate) struct Chip { #[derive(Clone, Debug, PartialEq)] pub(crate) struct Row { - /// Position in the catalogue. + /// Position in the catalog. pub index: usize, pub command: CommandRef, /// [`CommandRef::key`], for automation ids and the overrides. @@ -92,7 +92,7 @@ pub(crate) struct Row { pub(crate) struct FixedLine { pub label: String, pub keys: String, - /// The label of a catalogue command now bound to the row's chord: drawn struck through. + /// The label of a catalog command now bound to the row's chord: drawn struck through. pub now_used_by: Option, } @@ -186,12 +186,12 @@ fn is_tool(b: &Bindable) -> bool { matches!(b.kind, Kind::Tool { .. }) } -/// The Show entries, in catalogue order, leaving out areas with no rows. +/// The Show entries, in catalog order, leaving out areas with no rows. pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { - let catalogue = keymap.catalogue(); + let catalog = keymap.catalog(); let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; let mut menus: Vec<&str> = Vec::new(); - for m in catalogue.iter().filter_map(top_menu) { + for m in catalog.iter().filter_map(top_menu) { if !menus.contains(&m) { menus.push(m); } @@ -200,19 +200,19 @@ pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec< let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); } - if catalogue.iter().any(is_tool) { + if catalog.iter().any(is_tool) { out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); } let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); - for b in catalogue.iter().filter(|b| !b.scope.is_app()) { + for b in catalog.iter().filter(|b| !b.scope.is_app()) { if !panels.contains(&b.scope.as_str()) { panels.push(b.scope.as_str()); } } - for p in panels.into_iter().filter(|p| catalogue.iter().any(|b| b.scope.as_str() == *p)) { + for p in panels.into_iter().filter(|p| catalog.iter().any(|b| b.scope.as_str() == *p)) { out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); } - if catalogue.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + if catalog.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); } for a in &options.areas { @@ -240,7 +240,7 @@ fn in_area(area: &AreaId, b: &Bindable, options: &Options) -> bool { } } -/// The first id segment, capitalised: `layer.lock` → `Layer`. +/// The first id segment, capitalized: `layer.lock` → `Layer`. fn id_group(id: &str) -> String { let first = id.split('.').next().unwrap_or(id); let mut chars = first.chars(); @@ -314,7 +314,7 @@ impl Query { fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Built { let platform = keymap.platform(); - let catalogue = keymap.catalogue(); + let catalog = keymap.catalog(); let mut built = Built::default(); let mut clashing: HashSet = HashSet::new(); for c in keymap.conflicts() { @@ -333,10 +333,10 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu return built; } - let bindings: Vec> = catalogue.iter().map(|b| keymap.bindings(&b.command)).collect(); + let bindings: Vec> = catalog.iter().map(|b| keymap.bindings(&b.command)).collect(); // How many tools of a group hold each effective trigger. let mut tool_keys: HashMap<(Option<&str>, Trigger), usize> = HashMap::new(); - for (b, list) in catalogue.iter().zip(&bindings) { + for (b, list) in catalog.iter().zip(&bindings) { if let Kind::Tool { group } = &b.kind { for x in list { *tool_keys.entry((group.as_deref(), x.trigger.effective(platform))).or_default() += 1; @@ -344,13 +344,13 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu } } let submenus = match view.area { - AreaId::Menu(m) => catalogue.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + AreaId::Menu(m) => catalog.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), _ => false, }; // Groups in first-seen order within their rank. let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); - for (index, (b, list)) in catalogue.iter().zip(&bindings).enumerate() { + for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() { if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { continue; } @@ -427,7 +427,7 @@ fn build_fixed(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View, let now_used_by = f.chord.and_then(|c| { let c = c.effective(platform); keymap - .catalogue() + .catalog() .iter() .find(|b| keymap.bindings(&b.command).iter().any(|x| x.trigger.effective(platform) == c)) .map(|b| text.command(b).into_owned()) @@ -468,7 +468,7 @@ pub(crate) mod tests { let tool = Kind::Tool { group: Some("Marquee".into()) }; let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(key("Cmd+S")), Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(key("Cmd+Shift+S")), Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(key("Cmd+E")), @@ -478,7 +478,7 @@ pub(crate) mod tests { Bindable::new("timeline.loop", "Loop", timeline).with_default(key("L")), Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } fn view<'a>(query: &'a str, area: &'a AreaId, filter: Filter, collapsed: &'a BTreeSet) -> View<'a> { diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index b188ad0e..fbb544a6 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -473,7 +473,7 @@ impl DialogState { let platform = env.keymap.platform(); let want = trigger.effective(platform); let mut shadows = Vec::new(); - for b in env.keymap.catalogue() { + for b in env.keymap.catalog() { if b.command == command { continue; } diff --git a/crates/keymap/src/ui/style.rs b/crates/keymap/src/ui/style.rs index 81d4a62d..5abfc97c 100644 --- a/crates/keymap/src/ui/style.rs +++ b/crates/keymap/src/ui/style.rs @@ -1,10 +1,10 @@ -//! The dialog's colours and the icon-button style built from them. +//! The dialog's colors and the icon-button style built from them. use craft_ui::buttons::{ButtonVisuals, IconButtonStyle}; use craft_ui::layout::ResizeHandleStyle; use egui::{Color32, Stroke, Visuals}; -/// The dialog's colours. +/// The dialog's colors. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DialogStyle { pub accent: Color32, @@ -35,7 +35,7 @@ impl DialogStyle { } } -/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colours of the app's visuals. +/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colors of the app's visuals. pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle { let w = &v.widgets; IconButtonStyle { diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs index ef2cb56b..8b4248ca 100644 --- a/crates/keymap/src/ui/summary.rs +++ b/crates/keymap/src/ui/summary.rs @@ -27,10 +27,10 @@ fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> Stri } } -/// Groups in the table's order; commands in catalogue order within a group. +/// Groups in the table's order; commands in catalog order within a group. fn groups(km: &Keymap, text: &dyn Text) -> Vec { let mut out: Vec = Vec::new(); - for b in km.catalogue() { + for b in km.catalog() { let bindings = km.bindings(&b.command); if bindings.is_empty() { continue; diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 05c430ef..8e5799cb 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -261,7 +261,7 @@ impl Draw<'_, '_> { IconButton::new(label, Vec2::splat(ICON), &self.icons).selected(selected).show(ui, |ui, rect, color| paint(ui.painter(), rect, color)) } - /// A row control: clickable and labelled but not a Tab stop (the table is one). + /// A row control: clickable and labeled but not a Tab stop (the table is one). fn row_button(&self, ui: &mut Ui, rect: Rect, id: Id, label: &str, enabled: bool, paint: fn(&Painter, Rect, Color32)) -> Response { let resp = ui.interact(rect, id, if enabled { Sense::CLICK } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); diff --git a/crates/keymap/tests/editor.rs b/crates/keymap/tests/editor.rs index b33c9045..02a44052 100644 --- a/crates/keymap/tests/editor.rs +++ b/crates/keymap/tests/editor.rs @@ -13,13 +13,13 @@ fn b(text: &str) -> Binding { } fn keymap(platform: Platform) -> Keymap { - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_default(b("Cmd+S")).with_path(vec!["File".into(), "Save".into()]), Bindable::new("edit.copy", "Copy", Scope::app()).with_default(b("Cmd+C")).with_alias("Kopieren"), Bindable::new("view.zoomIn", "Zoom In", Scope::app()).with_default(b("Cmd+=")), Bindable::new("tool.brush", "Brush", Scope::app()).with_default(b("B")).with_path(vec!["Tools".into(), "Paint".into()]), ]; - Keymap::new(catalogue, Scopes::new(), platform) + Keymap::new(catalog, Scopes::new(), platform) } fn keys(km: &Keymap, id: &str) -> Vec { @@ -87,7 +87,7 @@ fn undo_keeps_the_newest_steps() { fn undo_history_is_capped_by_size() { let mut km = keymap(Platform::Windows); let mut session = EditSession::begin(&km); - // Full-size snapshots of entries for commands the catalogue does not have. + // Full-size snapshots of entries for commands the catalog does not have. let big = |tag: char| { let mut o = Overrides::new(); for n in 0..MAX_COMMANDS { diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index c23770a6..348420df 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -9,13 +9,13 @@ fn b(text: &str) -> Binding { } fn keymap() -> Keymap { - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(b("Cmd+S")), Bindable::new("edit.copy", "Copy", Scope::app()).with_path(vec!["Edit".into(), "Copy".into()]).with_default(b("Cmd+C")), Bindable::new(CommandRef::with_params("window.panel", json!({"panel": "layers"})), "Layers", Scope::app()).with_default(b("F7")), Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(b("L")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline"), Platform::Mac) + Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Mac) } #[test] @@ -132,7 +132,7 @@ fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { let mut again = keymap(); again.set_overrides(p.active_overrides(&builtins).clone()); - for bindable in km.catalogue() { + for bindable in km.catalog() { assert_eq!(again.bindings(&bindable.command), km.bindings(&bindable.command), "{}", bindable.command.key()); } assert_eq!(again.overrides(), km.overrides()); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 5f0ee994..4ff8bf91 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -59,7 +59,7 @@ impl Fx { self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect() } - fn cancelled(&self) -> bool { + fn canceled(&self) -> bool { self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel)) } } @@ -162,10 +162,10 @@ fn escape_cancels_recording_only() { press(&mut h, Modifiers::NONE, EguiKey::Escape); assert!(h.state().state.is_open()); assert!(!h.state().state.is_recording()); - assert!(!h.state().cancelled()); + assert!(!h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); press(&mut h, Modifiers::NONE, EguiKey::Escape); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert!(!h.state().state.is_open()); } @@ -432,9 +432,9 @@ fn table_keys_move_record_and_delete() { fn small_window_keeps_ok_and_long_labels_truncate() { let long = "A very long command label ".repeat(12); let mut km = fixture(Platform::Mac); - let mut catalogue = km.catalogue().to_vec(); - catalogue.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); - km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + let mut catalog = km.catalog().to_vec(); + catalog.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()])); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); let mut fx = Fx::new(km); fx.window = true; let size = vec2(480.0, 360.0); @@ -460,7 +460,7 @@ fn window_close_is_cancel() { press(&mut h, Modifiers::NONE, EguiKey::F2); assert_eq!(h.state().bindings("file.save"), ["F2"]); click(&mut h, "Cancel"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); } @@ -587,7 +587,7 @@ fn cancel_restores_keymap_and_sets() { click_last(&mut h, "Delete"); assert!(!profiles(&h).sets.contains_key("Other")); click(&mut h, "Cancel"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); assert_eq!(profiles(&h), &before); assert!(h.state().km.overrides().is_empty()); } @@ -691,14 +691,14 @@ fn picking_a_suggestion_replans_and_is_remembered() { #[test] fn summary_escapes_html_and_keeps_text_lines() { let mut km = fixture(Platform::Mac); - let mut catalogue = km.catalogue().to_vec(); + let mut catalog = km.catalog().to_vec(); let label = "\"Tom\" & 'Jerry'\nNext"; - catalogue.push( + catalog.push( Bindable::new("edit.tricky", label, Scope::app()) .with_path(vec!["Edit".into(), "Tricky".into()]) .with_default(craft_keymap::Binding::parse("F6").expect("F6")), ); - km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); + km = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac); let mut h = open(Fx::new(km)); click(&mut h, "Summarize…"); let (html, text) = h @@ -734,13 +734,13 @@ fn with_keyboard(options: impl FnOnce(&mut Fx)) -> Harness<'static, Fx> { open(fx) } -/// What a key's colour means, as its accessibility description says it. +/// What a key's color means, as its accessibility description says it. fn holders_class(h: &Harness<'_, Fx>, key: &str) -> String { h.get_by_role_and_label(Role::Button, key).accesskit_node().description().unwrap_or_default().to_string() } #[test] -fn keyboard_colours_keys_and_assigns_to_the_selected_command() { +fn keyboard_colors_keys_and_assigns_to_the_selected_command() { let mut h = with_keyboard(|_| {}); assert_eq!(holders_class(&h, "M: Marquee Tool, Elliptical Marquee Tool"), "Application"); assert_eq!(holders_class(&h, "L: Loop (Timeline)"), "Panel or context"); @@ -831,7 +831,7 @@ fn read_only_has_no_edit_controls() { click(&mut h, "Summarize…"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Summary { .. }))); click(&mut h, "Close"); - assert!(h.state().cancelled()); + assert!(h.state().canceled()); } // ---- Pseudo locale ---- @@ -864,7 +864,7 @@ fn check_pseudo(h: &Harness<'_, Fx>, width: f32, step: &str) { let words = rest.split(|c: char| !c.is_ascii_lowercase()).any(|w| w.len() > 1); assert!(!words, "{step}: {label:?} has text that did not go through Text"); } - assert!(seen > 15, "{step}: {seen} labelled nodes"); + assert!(seen > 15, "{step}: {seen} labeled nodes"); } #[test] diff --git a/crates/keymap/tests/ui_fixture/mod.rs b/crates/keymap/tests/ui_fixture/mod.rs index efcc82b1..f2f930cc 100644 --- a/crates/keymap/tests/ui_fixture/mod.rs +++ b/crates/keymap/tests/ui_fixture/mod.rs @@ -1,4 +1,4 @@ -//! The catalogue the dialog tests and screenshots use. +//! The catalog the dialog tests and screenshots use. use std::borrow::Cow; use std::cell::RefCell; @@ -16,7 +16,7 @@ pub fn fixture(platform: Platform) -> Keymap { let tool = Kind::Tool { group: Some("Marquee".into()) }; let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app); - let catalogue = vec![ + let catalog = vec![ Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")), Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")), Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")), @@ -26,22 +26,22 @@ pub fn fixture(platform: Platform) -> Keymap { Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")), Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")), ]; - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), platform) } /// `km` with a command whose label and menu path are far too long for a row. #[allow(dead_code)] pub fn with_long_label(km: Keymap) -> Keymap { let long = "Distribute Selected Objects Evenly Along the Active Guide Path and Keep Their Spacing".to_string(); - let mut catalogue = km.catalogue().to_vec(); - catalogue.insert( + let mut catalog = km.catalog().to_vec(); + catalog.insert( 3, Bindable::new("object.distribute", long.as_str(), Scope::app()) .with_path(vec!["Object".into(), "Arrange and Distribute Along a Path".into(), long.clone()]) .with_default(binding("Cmd+Alt+Shift+D")) .with_default(binding("Ctrl+Alt+Shift+F12")), ); - Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) + Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), km.platform()) } /// A pseudo translation: every string about 40 % longer, accented, between right-to-left marks and From 9b0182a3e0af0cad054451d47913d2e510f21aff Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 04:18:35 +0000 Subject: [PATCH 033/124] keymap: a held web clipboard key with repeats off leaves its clipboard events in place --- crates/keymap/src/egui.rs | 6 ++++++ crates/keymap/tests/egui.rs | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fd5d2794..c89447d8 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -306,6 +306,12 @@ impl Dispatcher { } } } + // A repeat that does not run leaves its clipboard event in place with it. + Event::Key { pressed: true, repeat: true, .. } => { + if let Some((j, _)) = causes[i] { + caused_taken[j] = Some(false); + } + } Event::Text(_) if drop_text => { drop_text = false; continue; diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 04a4ee51..b91e380f 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -218,6 +218,18 @@ fn a_web_paste_carries_its_text_and_takes_its_event() { assert_eq!(input.events, events[..2]); } +#[test] +fn a_held_web_paste_runs_once_when_repeats_are_off() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new().with_clipboard_events(true); + let repeat = Event::Key { key: EguiKey::V, physical_key: Some(EguiKey::V), pressed: true, repeat: true, modifiers: CTRL }; + let paste = || Event::Paste("text".into()); + let events = vec![press(EguiKey::V, CTRL), paste(), repeat.clone(), paste()]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events)); + assert_eq!(ran, ["edit.paste"]); + assert_eq!(left, [repeat, paste()], "the repeat and its event stay in egui's input"); +} + #[test] fn declined_hits_leave_their_events_in_order() { let km = dispatch_keymap(); From 2abdcd53ab34c095f96b8280021b533634ba3ff3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:07:36 +0000 Subject: [PATCH 034/124] keymap: give Tab back to focus navigation after the New Shortcut field or Search by key records --- crates/keymap/src/ui/capture.rs | 7 +++++++ crates/keymap/src/ui/view.rs | 4 ++++ crates/keymap/tests/ui_dialog.rs | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/crates/keymap/src/ui/capture.rs b/crates/keymap/src/ui/capture.rs index a5fef16f..612458fc 100644 --- a/crates/keymap/src/ui/capture.rs +++ b/crates/keymap/src/ui/capture.rs @@ -15,6 +15,13 @@ pub(crate) fn lock_focus(ui: &Ui, id: Id) { ui.memory_mut(|m| m.set_focus_lock_filter(id, FOCUS_LOCK)); } +/// Gives Tab, arrows and Escape back to egui's focus navigation. egui keeps a focused widget's +/// filter until focus moves, so a widget that recorded calls this each frame it has focus and +/// does not record. +pub(crate) fn unlock_focus(ui: &Ui, id: Id) { + ui.memory_mut(|m| m.set_focus_lock_filter(id, EventFilter::default())); +} + /// Removes every key, text, IME and clipboard event from `input` and returns the chords pressed, /// in order. Repeats, releases and modifier-only presses give no chord. Copy, Cut and Paste /// count as the presses that cause them (Cmd+C, Cmd+X, Cmd+V, or Shift+Delete and Shift+Insert), diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 8e5799cb..7bebed9f 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -486,6 +486,8 @@ impl Draw<'_, '_> { } else if r.has_focus() || r.clicked() { capture::lock_focus(ui, r.id); } + } else if r.has_focus() { + capture::unlock_focus(ui, r.id); } self.keyboard_toggle(ui); } @@ -1140,6 +1142,8 @@ impl Draw<'_, '_> { } else { capture::lock_focus(ui, field_id); } + } else if resp.has_focus() { + capture::unlock_focus(ui, field_id); } if ui.link(self.vis(&self.ui("Type…"))).clicked() { self.state.stop_recording(); diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 4ff8bf91..8df42773 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -210,6 +210,41 @@ fn the_new_shortcut_field_waits_for_assign() { assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]); } +/// Presses Tab until the control labeled `label` has focus; false after `max` presses. +fn tab_to(h: &mut Harness<'_, Fx>, label: &str, max: usize) -> bool { + for _ in 0..max { + press(h, Modifiers::NONE, EguiKey::Tab); + if h.get_by_label(label).is_focused() { + return true; + } + } + false +} + +#[test] +fn tab_leaves_the_new_shortcut_field_after_recording() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + click(&mut h, "New shortcut: None"); + press(&mut h, Modifiers::NONE, EguiKey::F7); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("New shortcut: F7").is_focused()); + assert!(tab_to(&mut h, "Assign", 6), "Tab moves on to Assign"); + assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "Tab is not recorded"); +} + +#[test] +fn tab_leaves_search_by_key_after_recording() { + let mut h = mac(); + click(&mut h, "Search by key"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert!(!h.state().state.is_recording()); + assert!(h.get_by_label("Search by key").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Tab); + assert!(table_focused(&h), "Tab moves on to the table"); +} + // ---- Conflicts ---- #[test] From 0da8ea8a9f12f76ec9527b0aaa3e1943eac8161d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:07:49 +0000 Subject: [PATCH 035/124] keymap: run the workflow on craft-settings changes too --- .github/workflows/keymap.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 97b1124e..29bddad6 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -3,9 +3,9 @@ name: Keymap on: push: branches: [main] - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] pull_request: - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/settings/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] workflow_dispatch: permissions: From 056a709d0fc1579f70ee08974ddf42ca130d3dde Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:08:43 +0000 Subject: [PATCH 036/124] keymap: refuse scope names with outer whitespace, which binding text trims --- crates/keymap/src/scope.rs | 12 ++++++++---- crates/keymap/tests/keymap.rs | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/scope.rs b/crates/keymap/src/scope.rs index 287413c9..26b94e49 100644 --- a/crates/keymap/src/scope.rs +++ b/crates/keymap/src/scope.rs @@ -9,17 +9,21 @@ pub const MAX_SCOPE_NAME: usize = 64; const APP: &str = "Application"; -/// The name of a scope. Never empty, at most [`MAX_SCOPE_NAME`] bytes, without `:`. +/// The name of a scope. Never empty, at most [`MAX_SCOPE_NAME`] bytes, without `:` or outer +/// whitespace. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] #[serde(transparent)] pub struct Scope(String); impl Scope { - /// `None` for an empty name, one over [`MAX_SCOPE_NAME`] bytes, or one with `:` or a control - /// character. + /// `None` for an empty name, one over [`MAX_SCOPE_NAME`] bytes, one with `:` or a control + /// character, or one with whitespace at either end, which binding text would lose. pub fn new(name: impl Into) -> Option { let name = name.into(); - let ok = !name.is_empty() && name.len() <= MAX_SCOPE_NAME && !name.chars().any(|c| c == ':' || c.is_control()); + let ok = !name.is_empty() + && name.len() <= MAX_SCOPE_NAME + && name.trim() == name + && !name.chars().any(|c| c == ':' || c.is_control()); ok.then_some(Scope(name)) } diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 3a8a2483..74ba8d6e 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -375,6 +375,15 @@ fn binding_text_round_trips_for_every_key() { assert_eq!(Binding::parse(": :"), Ok(Binding::new(Trigger::double(Chord::plain(Key::Colon))))); } +#[test] +fn scope_names_with_outer_whitespace_are_refused() { + for name in [" Timeline", "Timeline ", "\u{a0}Timeline", ""] { + assert_eq!(Scope::new(name), None, "{name:?}"); + } + assert_eq!(Scope::new("Time line").map(|s| s.to_string()).as_deref(), Some("Time line")); + assert_eq!(Binding::parse(" Timeline : K").map(|b| b.scope), Ok(Some(scope("Timeline")))); +} + #[test] fn edits_are_refused_when_the_overrides_are_full() { let catalog = vec![app("edit.copy", &["Cmd+C"]), app("edit.duplicate", &["Cmd+D"]), app("edit.cut", &[])]; From 0039a5c8662e4a5eb52ff950b5cf93bcbd0b6c96 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:08:46 +0000 Subject: [PATCH 037/124] keymap: report Changed after a Cancel that undid edits, so apps rebuild their menus --- crates/keymap/README.md | 5 +++-- crates/keymap/src/ui/mod.rs | 13 ++++++++++--- crates/keymap/src/ui/state.rs | 18 ++++++++++++++++-- crates/keymap/tests/ui_dialog.rs | 3 +++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 97da4352..2b156f6f 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -306,6 +306,7 @@ if let Some(state) = &mut self.shortcuts { let dialog = dialog.sets(&mut self.settings.shortcut_sets, &BUILTINS); match dialog.show(ctx, state) { Some(Outcome::Ok) => self.save_settings(), + // After each edit, and after a Cancel that undid edits. Some(Outcome::Changed) => self.rebuild_menus(), Some(Outcome::Import { formats }) => self.pick_import_file(formats), Some(Outcome::Export { name, json }) => self.save_file(&name, json), @@ -324,8 +325,8 @@ Outcomes: | Outcome | The app | |---|---| | `Ok` | Saves `Profiles` (with sets) or `keymap.overrides()`. | -| `Cancel` | Nothing to do: the keymap and the sets are as they were at open. | -| `Changed` | Updates its menus, or saves at once if it saves live. When a frame also has another outcome, `Changed` comes on the next frame. | +| `Cancel` | Nothing to save: the keymap and the sets are as they were at open. When Cancel undid edits, `Changed` follows. | +| `Changed` | Updates its menus, or saves at once if it saves live. It comes after each edit and after a Cancel that undid edits. When a frame also has another outcome, `Changed` comes on the next frame; `is_open()` stays true until it has. | | `Import { formats }` | Opens a file of one of the formats, then calls `import_json` (craft-keymap JSON, `ui::JSON_FORMAT`) or reads it into a `ForeignSet` and calls `review_import`. | | `Export { name, json }`, `ExportAs { format, name, keymap }` | Writes the set where the user chooses (a download on the web). | | `Summary { name, html, text }` | Shows, saves or prints the summary. | diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index cd476f37..cf73737f 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -34,7 +34,8 @@ use state::Env; pub enum Outcome { /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. Ok, - /// Closed with Cancel: the keymap and profiles are back as they were when it opened. + /// Closed with Cancel: the keymap and profiles are back as they were when it opened. When that + /// undid edits, `Changed` follows on the next frame. Cancel, /// An edit (for live menus and live saving). When a frame also has another outcome, such as /// `Import` after the Save changes prompt, `Changed` follows on the next frame. @@ -203,6 +204,9 @@ impl<'a> ShortcutsDialog<'a> { /// screen allows, at most 1320×900). Closing it with × is Cancel. pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option { self.with_env(|env| { + if !state.is_showing() { + return state.finish(); + } let title = env.text.visual(&env.text.ui(env.options.title)); let screen = ctx.content_rect().size(); let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0)); @@ -218,10 +222,13 @@ impl<'a> ShortcutsDialog<'a> { .min_size(min) .max_size(max) .show(ctx, |ui| out = frame(ui, env, state)); - if !open && state.is_open() { + if !open && state.is_showing() { state.cancel(env); out = state.finish(); } + if state.change_pending() { + ctx.request_repaint(); + } out }) } @@ -272,7 +279,7 @@ impl<'a> ShortcutsDialog<'a> { fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option { state.begin(env); state.input(env, ui.ctx()); - if state.is_open() { + if state.is_showing() { view::body(ui, env, state); } let out = state.finish(); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index fbb544a6..d9461647 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -264,8 +264,15 @@ impl DialogState { } } - /// False after OK or Cancel: drop the state. + /// False once the dialog has closed with OK or Cancel and reported its last outcome: drop the + /// state then. After a Cancel that undid edits it stays true for the frame that reports + /// [`Outcome::Changed`]. pub fn is_open(&self) -> bool { + self.phase != Phase::Closed || self.changed + } + + /// Whether the dialog is drawn: not yet closed with OK or Cancel. + pub(crate) fn is_showing(&self) -> bool { self.phase != Phase::Closed } @@ -698,14 +705,20 @@ impl DialogState { } /// Cancel, window ×, or Escape when idle: everything back as it was when the dialog opened. + /// When that undoes edits, Changed follows, so the app rebuilds its menus (and saves, if it + /// saved the edits live). pub(crate) fn cancel(&mut self, env: &mut Env) { + let mut undone = false; if let Some(opened) = self.opened.take() { + undone = *env.keymap.overrides() != opened.overrides; env.keymap.set_overrides(opened.overrides); if let (Some((p, _)), Some(profiles)) = (env.sets.as_mut(), opened.profiles) { + undone |= **p != profiles; **p = profiles; } } self.close(Outcome::Cancel); + self.changed = undone; } fn close(&mut self, outcome: Outcome) { @@ -1167,8 +1180,9 @@ mod tests { assert!(matches!(st.finish(), Some(Outcome::Changed))); st.escape(&mut fx.env()); assert!(matches!(st.finish(), Some(Outcome::Cancel))); - assert!(!st.is_open()); assert!(fx.keys("object.front").is_empty(), "Cancel restores the opening keymap"); + assert!(st.is_open() && matches!(st.finish(), Some(Outcome::Changed)), "Changed follows, for the app's menus"); + assert!(!st.is_open()); } #[test] diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 8df42773..38a77e28 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -497,6 +497,9 @@ fn window_close_is_cancel() { click(&mut h, "Cancel"); assert!(h.state().canceled()); assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]); + let after: Vec = h.state().outcomes.iter().skip_while(|o| !matches!(o, Outcome::Cancel)).map(|o| matches!(o, Outcome::Changed)).collect(); + assert_eq!(after, [false, true], "Changed follows Cancel, which undid an edit"); + assert!(!h.state().state.is_open()); } #[test] From f4b04e4ad179eccfe7086def84640a235092315c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:09:01 +0000 Subject: [PATCH 038/124] keymap: a double press needs both presses in the same panel --- crates/keymap/src/egui.rs | 15 ++++++++++----- crates/keymap/tests/egui.rs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index c89447d8..67438a75 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -189,11 +189,13 @@ impl Hit { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct LastPress { chord: Chord, at: f64, ran_double: bool, + /// The focused panel: a press in another panel never completes the pair. + panel: Option, } /// Runs a keymap against egui's input, one call per frame before the UI reads its keys. @@ -251,8 +253,9 @@ impl Dispatcher { /// The commands this frame's key presses run with `active` focus, in press order. /// - /// A chord pressed again within [`DOUBLE_PRESS`] runs its double binding when it has one; - /// the first press has already run the single binding. The press after a double starts over. + /// A chord pressed again within [`DOUBLE_PRESS`] in the same panel runs its double binding + /// when it has one; the first press has already run the single binding. The press after a + /// double starts over. pub fn dispatch(&mut self, input: &mut InputState, keymap: &Keymap, active: &Active) -> Vec { self.dispatch_filtered(input, keymap, active, |_| true) } @@ -351,9 +354,11 @@ impl Dispatcher { if repeat { return find(false); } - let again = self.last.is_some_and(|l| l.chord == first && !l.ran_double && now >= l.at && now - l.at < DOUBLE_PRESS); + let again = self.last.as_ref().is_some_and(|l| { + l.chord == first && !l.ran_double && l.panel.as_deref() == active.panel && now >= l.at && now - l.at < DOUBLE_PRESS + }); let double = if again { find(true) } else { None }; - self.last = Some(LastPress { chord: first, at: now, ran_double: double.is_some() }); + self.last = Some(LastPress { chord: first, at: now, ran_double: double.is_some(), panel: active.panel.map(str::to_owned) }); double.or_else(|| find(false)) } } diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index b91e380f..4cba357d 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -354,6 +354,24 @@ fn a_second_press_runs_the_double_binding() { assert_eq!(u(&mut d, 2.7), ["reveal.one"]); } +#[test] +fn a_double_press_needs_both_presses_in_one_panel() { + let km = Keymap::new( + vec![ + Bindable::new("x.one", "One", Scope::new("X").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.one", "One", Scope::new("Y").unwrap()).with_default(Binding::parse("A").unwrap()), + Bindable::new("y.two", "Two", Scope::new("Y").unwrap()).with_default(Binding::parse("A A").unwrap()), + ], + Scopes::new().with_panel("X").with_panel("Y").with_panel_fallthrough(false), + Platform::Windows, + ); + let mut d = Dispatcher::new(); + let a = |d: &mut Dispatcher, panel, t| run(d, &km, &Active::panel(panel), frame(t, Modifiers::NONE, vec![press(EguiKey::A, Modifiers::NONE)])).0; + assert_eq!(a(&mut d, "X", 1.0), ["x.one"]); + assert_eq!(a(&mut d, "Y", 1.2), ["y.one"]); + assert_eq!(a(&mut d, "Y", 1.4), ["y.two"]); +} + #[test] fn two_presses_in_one_frame_make_a_double() { let km = dispatch_keymap(); From 339aaa41dfc29756cdbffe021973b1d27ca8a3d5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:09:14 +0000 Subject: [PATCH 039/124] keymap: show that an app with sets stores only Profiles and loads the active set --- crates/keymap/README.md | 26 +++++++++++++++++++++++--- crates/keymap/src/lib.rs | 7 +++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 2b156f6f..f5ff51eb 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -95,7 +95,7 @@ stay in the entry when the command is edited, unless the edit leaves it without `Keymap::problems` lists them. ```rust -use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; +use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; use craft_settings::{Location, Settings, SettingsStore}; use serde::{Deserialize, Serialize}; @@ -104,7 +104,6 @@ use serde::{Deserialize, Serialize}; struct Prefs { #[serde(with = "craft_keymap::keymap::overrides_list")] shortcuts: Overrides, - shortcut_sets: Profiles, } impl Settings for Prefs { @@ -140,9 +139,30 @@ store.update(|p| p.shortcuts = keymap.overrides().clone()); `Profiles` holds the user's named sets of overrides and which one is active. The app's built-in sets (`&[(&str, Overrides)]`, the first one the default) are passed to each call and never change. -Put `Profiles` next to the `Overrides` in the app's settings type. The sets, and each set's +An app with sets stores only `Profiles`, in place of the `Overrides` field above: the keymap's +overrides are the active set's, and the dialog's OK stores them there. The sets, and each set's overrides, are stored as arrays, so a deleted set or entry leaves a craft-settings file. +```rust +use craft_keymap::{Keymap, Platform, Profiles, Scopes}; +use craft_settings::{Location, SettingsStore}; + +#[derive(Serialize, Deserialize, Default, Clone, PartialEq)] +#[serde(default)] +struct Prefs { + shortcut_sets: Profiles, +} + +// `builtins` is the app's &[(&str, Overrides)], the first set the default. +let (mut store, _report) = SettingsStore::::open(Location::Auto); +let mut sets = store.get().shortcut_sets.clone(); +sets.reconcile(&builtins); +let keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(sets.active_overrides(&builtins).clone()); + +// The dialog edits `sets` and `keymap`. On Outcome::Ok (or Changed, to save live): +store.update(|p| p.shortcut_sets = sets.clone()); +``` + - `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). - `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, `rename` and `delete` work on user sets only. diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 7a943fd5..513df955 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -72,7 +72,7 @@ //! Without the attribute, overrides with at most one text per command are written as a table. //! //! ```no_run -//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Profiles, Scopes}; +//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes}; //! use craft_settings::{Location, Settings, SettingsStore}; //! use serde::{Deserialize, Serialize}; //! @@ -81,7 +81,6 @@ //! struct Prefs { //! #[serde(with = "craft_keymap::keymap::overrides_list")] //! shortcuts: Overrides, -//! shortcut_sets: Profiles, //! } //! //! impl Settings for Prefs { @@ -100,6 +99,10 @@ //! store.update(|p| p.shortcuts = keymap.overrides().clone()); //! ``` //! +//! An app with named sets stores only [`Profiles`] in place of the `Overrides` field and builds +//! the keymap from the active set: +//! `with_overrides(prefs.shortcut_sets.active_overrides(&builtins).clone())`. +//! //! # Importing a shortcut set //! //! The app's reader builds a [`ForeignSet`]; [`plan`] matches it without changing anything, so a From fd2386b2ba9381a26e3bce3f72bea88e17ae32e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:10:58 +0000 Subject: [PATCH 040/124] keymap: hold the set row, table and edit actions while a prompt or the import review is open Switching sets closes an open prompt, deleting a set reloads only when it was the active one, and deleting the last set with no built-ins loads no overrides. --- crates/keymap/src/ui/sets.rs | 2 +- crates/keymap/src/ui/state.rs | 69 +++++++++++++++++++++++++++++--- crates/keymap/src/ui/view.rs | 22 ++++++---- crates/keymap/tests/ui_dialog.rs | 18 +++++++++ 4 files changed, 98 insertions(+), 13 deletions(-) diff --git a/crates/keymap/src/ui/sets.rs b/crates/keymap/src/ui/sets.rs index a18343a5..7b4cd479 100644 --- a/crates/keymap/src/ui/sets.rs +++ b/crates/keymap/src/ui/sets.rs @@ -43,7 +43,7 @@ impl Draw<'_, '_> { let modified = DialogState::is_modified(self.env); let items = self.set_items(builtin, modified); let mut act = None; - ui.add_enabled_ui(self.state.review.is_none(), |ui| { + ui.add_enabled_ui(!self.state.modal(), |ui| { ui.horizontal(|ui| { let set_label = self.ui("Set:"); let l = ui.label(self.vis(&set_label)); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index d9461647..c53a12d2 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -787,13 +787,26 @@ impl DialogState { fn load_set(&mut self, env: &mut Env, name: &str) -> Result<(), ProfileError> { let Some((p, b)) = env.sets.as_mut() else { return Err(ProfileError::NotFound) }; p.select(name, b)?; - env.keymap.set_overrides(p.active_overrides(b).clone()); + self.load_active(env); + Ok(()) + } + + /// Loads the active set (no overrides when none is left), with a fresh undo history. + fn load_active(&mut self, env: &mut Env) { + if let Some((p, b)) = env.sets.as_ref() { + env.keymap.set_overrides(p.active_overrides(b).clone()); + } self.session = Some(EditSession::begin(env.keymap)); self.recording = None; self.conflict = None; self.field = None; + self.prompt = None; self.changed = true; - Ok(()) + } + + /// A prompt or the import review is open: the set row, the table and the edit actions wait. + pub(crate) fn modal(&self) -> bool { + self.prompt.is_some() || self.review.is_some() } fn report(&mut self, env: &Env, result: Result<(), ProfileError>) { @@ -805,6 +818,7 @@ impl DialogState { /// The set picker. With unsaved edits it asks to save them first. pub(crate) fn switch_set(&mut self, env: &mut Env, name: &str) { + self.prompt = None; self.after_save_prompt(env, Then::Switch(name.to_string())); } @@ -891,13 +905,22 @@ impl DialogState { } } + /// Deletes a user set. Deleting the active set loads the one that becomes active, or no + /// overrides when no set is left. fn delete_set(&mut self, env: &mut Env, name: &str) { let result = match env.sets.as_mut() { - Some((p, b)) => p.delete(name, b).map(|()| p.active_name(b).to_string()), + Some((p, b)) => { + let was_active = p.active_name(b) == name; + p.delete(name, b).map(|()| was_active) + } None => Err(ProfileError::NotFound), }; - let result = result.and_then(|active| self.load_set(env, &active)); - self.report(env, result); + match result { + Ok(true) => self.load_active(env), + Ok(false) => self.changed = true, + Err(_) => {} + } + self.report(env, result.map(|_| ())); } /// Save: stores the edits; a built-in's become a copy. The undo history stays. @@ -1348,6 +1371,42 @@ mod tests { assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); } + #[test] + fn a_stale_delete_prompt_neither_reloads_nor_drops_edits() { + let mut fx = Fx::new(Options::default()); + fx.p.save_as("Mine", Overrides::new(), &fx.b).ok(); + fx.p.save_as("Other", Overrides::new(), &fx.b).ok(); + let mut st = open(&mut fx); + st.ask_delete_set(&fx.env()); + assert!(st.modal()); + st.switch_set(&mut fx.env(), "Mine"); + assert_eq!((st.prompt.clone(), fx.p.active.as_str()), (None, "Mine"), "switching closes the prompt"); + + record(&mut st, &mut fx, add("object.front"), &["F2"]); + st.prompt = Some(Prompt::DeleteSet("Other".into())); + st.answer(&mut fx.env(), Answer::Yes); + assert!(!fx.p.sets.contains_key("Other")); + assert_eq!((fx.p.active.as_str(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); + assert!(st.can_undo()); + } + + #[test] + fn deleting_the_last_set_without_builtins_loads_no_overrides() { + let mut fx = Fx::new(Options::default()); + fx.b.clear(); + let mut mine = Overrides::new(); + mine.insert("object.front", vec!["F2".into()]); + fx.p.save_as("Mine", mine.clone(), &fx.b).ok(); + fx.km.set_overrides(mine); + let mut st = open(&mut fx); + st.ask_delete_set(&fx.env()); + st.answer(&mut fx.env(), Answer::Yes); + assert!(st.message.is_none(), "{:?}", st.message); + assert!(fx.p.sets.is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); + st.ok(&mut fx.env()); + assert!(fx.p.sets.is_empty(), "OK stores no copy of the deleted set"); + } + #[test] fn ok_stores_edits_of_a_builtin_as_a_copy() { let mut fx = Fx::new(Options::default()); diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 7bebed9f..373bbb82 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -310,7 +310,7 @@ impl Draw<'_, '_> { } return; } - if text_focus || !self.env.options.editable { + if text_focus || !self.env.options.editable || self.state.modal() { return; } let mac = self.env.keymap.platform().is_mac(); @@ -351,7 +351,13 @@ impl Draw<'_, '_> { let mid_bottom = (rest.max.y - bottom_h - gap).max(rest.min.y); let mid = Rect::from_min_max(rest.min, pos2(rest.max.x, mid_bottom)); if !self.review(ui, mid) { - self.middle(ui, mid); + let prompt = self.state.prompt.is_some(); + ui.scope(|ui| { + if prompt { + ui.disable(); + } + self.middle(ui, mid); + }); } let top = (mid.max.y + gap).min(rest.max.y); let bottom = Rect::from_min_max(pos2(rest.min.x, top), rest.max); @@ -1303,7 +1309,7 @@ impl Draw<'_, '_> { if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { act = 1; } - if m.undo && self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.message.undo") { + if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { act = 2; } }); @@ -1374,15 +1380,16 @@ impl Draw<'_, '_> { } else { vec![(self.ui("Close"), "shortcuts.close")] }; + let idle = !self.state.modal(); ui.horizontal(|ui| { if editable { - if self.button(ui, self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { + if self.button(ui, idle && self.state.can_undo(), &self.ui("Undo"), "shortcuts.undo") { self.state.undo(self.env); } - if self.button(ui, self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { + if self.button(ui, idle && self.state.can_redo(), &self.ui("Redo"), "shortcuts.redo") { self.state.redo(self.env); } - if self.button(ui, !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { + if self.button(ui, idle && !self.env.keymap.overrides().is_empty(), &self.ui("Reset All…"), "shortcuts.resetAll") { self.state.ask_reset_all(); } } @@ -1397,7 +1404,8 @@ impl Draw<'_, '_> { let width: f32 = right.iter().map(|(t, _)| self.button_width(ui, t) + gap).sum(); ui.add_space((ui.available_width() - width).max(0.0)); for (text, id) in &right { - if self.button(ui, true, text, id) { + // OK waits for the open prompt or review; Cancel and Close never do. + if self.button(ui, idle || *id != "shortcuts.ok", text, id) { if editable && *id == "shortcuts.ok" { self.state.ok(self.env); } else { diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 38a77e28..aed56d40 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -630,6 +630,24 @@ fn cancel_restores_keymap_and_sets() { assert!(h.state().km.overrides().is_empty()); } +#[test] +fn an_open_prompt_holds_the_set_row_table_and_edit_actions() { + let mut h = with_sets(Profiles::default()); + click(&mut h, "Save: ⌘S"); + press(&mut h, Modifiers::NONE, EguiKey::F2); + new_set(&mut h, "Other"); + click(&mut h, "Delete"); + assert!(has(&h, "Delete the set “Other”?")); + for label in ["Set:", "New Set…", "Save: F2", "Undo", "Reset All…", "OK"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label}"); + } + assert!(h.get_all_by_label("Cancel").all(|n| !n.accesskit_node().is_disabled()), "the prompt's and the dialog's"); + h.get_all_by_label("Cancel").next().expect("the prompt's Cancel").click(); + h.run(); + assert!(h.state().state.is_open()); + assert!(!h.get_by_label("OK").accesskit_node().is_disabled()); +} + #[test] fn ok_stores_edits_of_a_builtin_as_a_copy() { let mut h = with_sets(Profiles::default()); From 79747aeaae6c0ffcf79851078486ed5fca77baf9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:11:21 +0000 Subject: [PATCH 041/124] keymap: remove the Text event a bound press typed, also when the web build sends it first --- crates/keymap/src/egui.rs | 87 ++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 67438a75..f1d127fa 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -220,11 +220,20 @@ struct LastPress { /// Windows and some Linux setups report AltGr as Ctrl+Alt, so AltGr+Q (`@` on a German layout) /// arrives as Ctrl+Alt+Q, and egui-winit sends no Text event while Ctrl is held, so the press /// cannot be told from a command chord. Ctrl+Alt with other keys (F-keys, arrows) still runs. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct Dispatcher { last: Option, repeat: bool, clipboard: bool, + /// Whether a press's Text event comes before it when no text field has focus, as eframe's + /// web build sends them. + text_first: bool, +} + +impl Default for Dispatcher { + fn default() -> Dispatcher { + Dispatcher { last: None, repeat: false, clipboard: false, text_first: cfg!(target_arch = "wasm32") } + } } impl Dispatcher { @@ -269,10 +278,9 @@ impl Dispatcher { let frame_mods = Mods::from_egui(&input.modifiers, platform); let mut hits = Vec::new(); let mut kept = Vec::with_capacity(input.events.len()); - // The Text event egui sends after a key press belongs to that press. - let mut drop_text = false; let events = std::mem::take(&mut input.events); - let typed = if active.typing { typed_text(&events) } else { Vec::new() }; + let text_of = text_of_press(&events, self.text_first && !active.typing); + let mut drop = vec![false; events.len()]; // The web build sends both a clipboard key press and the clipboard event it causes. The // press decides: its event goes with it when it runs a command and stays otherwise. let causes = clipboard_event_follows(&events, platform); @@ -280,17 +288,16 @@ impl Dispatcher { for (i, event) in events.into_iter().enumerate() { match &event { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { - drop_text = false; let held = Mods::from_egui(modifiers, platform); if let Some(key) = Key::from_egui(*key) { let clip = clipboard_kind(key, held); let keep = if active.typing { - typed.get(i) == Some(&true) || clip.is_some() || may_be_alt_gr(key, held, platform) + text_of[i].is_some() || clip.is_some() || may_be_alt_gr(key, held, platform) } else { !self.clipboard && causes[i].is_some() }; if keep { - kept.push(event); + kept.push((i, event)); continue; } let caused = causes[i].clone(); @@ -304,7 +311,9 @@ impl Dispatcher { } if let Some(hit) = hit { hits.push(hit); - drop_text = !(held.cmd || held.ctrl); + if let Some(j) = text_of[i].filter(|_| !(held.cmd || held.ctrl)) { + drop[j] = true; + } continue; } } @@ -315,10 +324,6 @@ impl Dispatcher { caused_taken[j] = Some(false); } } - Event::Text(_) if drop_text => { - drop_text = false; - continue; - } // A focused text field copies and pastes on these events itself. Event::Copy | Event::Cut | Event::Paste(_) if self.clipboard && !active.typing => { let (key, held) = clipboard_chord(&event, frame_mods); @@ -338,9 +343,9 @@ impl Dispatcher { } _ => {} } - kept.push(event); + kept.push((i, event)); } - input.events = kept; + input.events = kept.into_iter().filter(|(i, _)| !drop[*i]).map(|(_, e)| e).collect(); hits } @@ -390,17 +395,29 @@ fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec Vec { - let mut out = vec![false; events.len()]; - let mut text_ahead = false; - for (event, typed) in events.iter().zip(out.iter_mut()).rev() { - match event { - Event::Text(t) if !t.is_empty() => text_ahead = true, - Event::Key { pressed: true, .. } => { - *typed = text_ahead; - text_ahead = false; +/// For each key press, the index of the Text event it typed. egui-winit, and eframe's web text +/// agent while a text field has focus, send the Text after the press, before the next one. +/// With `text_first`, eframe's web build with no text field focused, the Text comes right before +/// its press, and only for a press without Ctrl or Cmd. +fn text_of_press(events: &[Event], text_first: bool) -> Vec> { + let is_text = |e: Option<&Event>| matches!(e, Some(Event::Text(t)) if !t.is_empty()); + let mut out = vec![None; events.len()]; + if text_first { + for (i, event) in events.iter().enumerate().skip(1) { + if let Event::Key { pressed: true, modifiers, .. } = event + && !(modifiers.ctrl || modifiers.command || modifiers.mac_cmd) + && is_text(events.get(i - 1)) + { + out[i] = Some(i - 1); } + } + return out; + } + let mut text_ahead = None; + for (i, event) in events.iter().enumerate().rev() { + match event { + Event::Key { pressed: true, .. } => out[i] = text_ahead.take(), + _ if is_text(Some(event)) => text_ahead = Some(i), _ => {} } } @@ -469,3 +486,25 @@ fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { _ => (Key::C, with_cmd), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Bindable, Binding, Scope, Scopes}; + + #[test] + fn web_text_before_its_press_goes_with_that_press() { + let km = Keymap::new( + vec![Bindable::new("tool.marquee", "Marquee", Scope::app()).with_default(Binding::parse("M").unwrap())], + Scopes::new(), + Platform::Windows, + ); + let press = |key| Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let mut input = InputState::default(); + input.events = vec![Event::Text("m".into()), press(EguiKey::M), Event::Text("q".into()), press(EguiKey::Q)]; + let mut d = Dispatcher { text_first: true, ..Dispatcher::new() }; + let hits = d.dispatch(&mut input, &km, &Active::NONE); + assert_eq!(hits.len(), 1); + assert_eq!(input.events, [Event::Text("q".into()), press(EguiKey::Q)]); + } +} From 5160611bad7f29cc6a6fc40c79ee4a041ab41731 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:11:46 +0000 Subject: [PATCH 042/124] keymap: while typing, a Ctrl+Alt press runs unless it typed text in the frame --- crates/keymap/README.md | 6 +++--- crates/keymap/src/egui.rs | 39 +++++-------------------------------- crates/keymap/tests/egui.rs | 27 ++++++++++--------------- 3 files changed, 18 insertions(+), 54 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 9cc767dd..0b1c0181 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -220,9 +220,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File when the press matched through a stand-in (`:` for `Shift+;`, `+` for `=`). `dispatch_filtered` takes a predicate that can decline a hit, for example a chord the native menu has already handled: the press and its text then stay in egui's events, in their place. - While a text field has the keyboard, clipboard key presses, presses followed by text, and off - macOS Ctrl+Alt with a letter, digit, punctuation or Space stay with it: AltGr arrives as - Ctrl+Alt on Windows and some Linux setups, and egui-winit sends no text while Ctrl is held. + While a text field has the keyboard, clipboard key presses and presses that typed text in the + same frame stay with it. That covers AltGr, which a browser on Windows reports as Ctrl+Alt + followed by the text; a Ctrl+Alt press that types nothing is a command chord. `Dispatcher::new().with_repeat(true)` also runs commands on key repeats (held arrows). egui's Copy, Cut and Paste events stay with the app by default. On the desktop, egui-winit diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index f1d127fa..4947ad72 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -214,12 +214,10 @@ struct LastPress { /// While [`Active::typing`], the text field keeps, without lookup: /// - clipboard events, and the clipboard key presses the web build sends with them (Cmd+C, Cmd+X, /// Cmd+V, Shift+Delete, Shift+Insert, Ctrl+Insert); -/// - a press followed by a Text event before the next press, since it typed that text (the web -/// build's text agent can send these); -/// - off macOS, a printable key (letter, digit, punctuation, Space) pressed with Ctrl and Alt. -/// Windows and some Linux setups report AltGr as Ctrl+Alt, so AltGr+Q (`@` on a German layout) -/// arrives as Ctrl+Alt+Q, and egui-winit sends no Text event while Ctrl is held, so the press -/// cannot be told from a command chord. Ctrl+Alt with other keys (F-keys, arrows) still runs. +/// - a press with a Text event of its own in the frame, since it typed that text. This covers +/// AltGr: the browser on Windows reports AltGr+Q (`@` on a German layout) as Ctrl+Alt+Q, and +/// the web build's text agent sends the `@` after it. egui-winit reports AltGr with neither Ctrl +/// nor Alt. A Ctrl+Alt press without text is a command chord. #[derive(Clone, Debug)] pub struct Dispatcher { last: Option, @@ -292,7 +290,7 @@ impl Dispatcher { if let Some(key) = Key::from_egui(*key) { let clip = clipboard_kind(key, held); let keep = if active.typing { - text_of[i].is_some() || clip.is_some() || may_be_alt_gr(key, held, platform) + text_of[i].is_some() || clip.is_some() } else { !self.clipboard && causes[i].is_some() }; @@ -424,33 +422,6 @@ fn text_of_press(events: &[Event], text_first: bool) -> Vec> { out } -/// Whether a press could be AltGr typing a character: Ctrl+Alt off macOS on a key that types. -fn may_be_alt_gr(key: Key, held: Mods, platform: Platform) -> bool { - !platform.is_mac() && held.cmd && held.alt && types_text(key) -} - -/// Letters, digits, punctuation and Space. -fn types_text(key: Key) -> bool { - !key.is_function_key() - && !matches!( - key, - Key::Escape - | Key::Tab - | Key::Backspace - | Key::Enter - | Key::Insert - | Key::Delete - | Key::Home - | Key::End - | Key::PageUp - | Key::PageDown - | Key::Up - | Key::Down - | Key::Left - | Key::Right - ) -} - fn event_clip(event: &Event) -> Option { match event { Event::Copy => Some(Clip::Copy), diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 4cba357d..4b31b9bf 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -82,6 +82,7 @@ fn dispatch_keymap() -> Keymap { app("edit.copy", &["Cmd+C", "Cmd+Insert"]), app("edit.paste", &["Cmd+V", "Shift+Insert"]), app("app.quit", &["Cmd+Alt+Q"]), + app("type.pageNumber", &["Cmd+Alt+Shift+N"]), app("view.refresh", &["Cmd+Alt+F5"]), app("file.save", &["Cmd+S"]), app("view.zoomIn", &["="]), @@ -283,31 +284,23 @@ fn a_press_that_types_text_stays_with_the_text_field() { } #[test] -fn ctrl_alt_on_a_printable_key_is_left_to_the_text_field() { +fn ctrl_alt_is_left_to_the_text_field_only_when_it_types() { let km = dispatch_keymap(); let mut d = Dispatcher::new(); let typing = Active { typing: true, ..Active::NONE }; - // AltGr+Q on a German layout types `@`; Windows reports it as Ctrl+Alt+Q, and egui-winit sends - // no Text event while Ctrl is held. + // AltGr+Q on a German layout types `@`: the browser on Windows reports Ctrl+Alt+Q and the text + // agent sends the text after it. let alt_gr = Modifiers { alt: true, ..CTRL }; - let events = vec![press(EguiKey::Q, alt_gr)]; + let events = vec![press(EguiKey::Q, alt_gr), Event::Text("@".into())]; let (ran, left) = run(&mut d, &km, &typing, frame(1.0, alt_gr, events.clone())); assert!(ran.is_empty(), "{ran:?}"); assert_eq!(left, events); - let (ran, _) = run(&mut d, &km, &typing, frame(2.0, alt_gr, vec![press(EguiKey::F5, alt_gr)])); - assert_eq!(ran, ["view.refresh"], "a key that types nothing"); - let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, alt_gr, events)); - assert_eq!(ran, ["app.quit"], "outside a text field"); + // A real Ctrl+Alt chord types nothing, so it runs while typing. + let all = Modifiers { shift: true, ..alt_gr }; + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, all, vec![press(EguiKey::N, all)])); + assert_eq!(ran, ["type.pageNumber"]); assert!(left.is_empty()); - - // macOS has no AltGr: Cmd+Option+Q is a command chord while typing. - let mac = Keymap::new( - vec![Bindable::new("app.quit", "Quit", Scope::app()).with_default(Binding::parse("Cmd+Alt+Q").unwrap())], - Scopes::new(), - Platform::Mac, - ); - let cmd_opt = Modifiers { alt: true, mac_cmd: true, command: true, ..Modifiers::NONE }; - let (ran, _) = run(&mut Dispatcher::new(), &mac, &typing, frame(4.0, cmd_opt, vec![press(EguiKey::Q, cmd_opt)])); + let (ran, _) = run(&mut d, &km, &typing, frame(3.0, alt_gr, vec![press(EguiKey::Q, alt_gr)])); assert_eq!(ran, ["app.quit"]); } From 423524c1bce69621850b271b221959cb30fedee2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:18:05 +0000 Subject: [PATCH 043/124] keymap: clipboard chords with more modifiers run their bindings with clipboard events off --- crates/keymap/README.md | 22 +++++---- crates/keymap/src/egui.rs | 93 +++++++++++++++++++++++-------------- crates/keymap/tests/egui.rs | 35 ++++++++++++++ 3 files changed, 106 insertions(+), 44 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0b1c0181..d9d6f225 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -220,16 +220,20 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File when the press matched through a stand-in (`:` for `Shift+;`, `+` for `=`). `dispatch_filtered` takes a predicate that can decline a hit, for example a chord the native menu has already handled: the press and its text then stay in egui's events, in their place. - While a text field has the keyboard, clipboard key presses and presses that typed text in the + While a text field has the keyboard, plain clipboard key presses and presses that typed text in the same frame stay with it. That covers AltGr, which a browser on Windows reports as Ctrl+Alt followed by the text; a Ctrl+Alt press that types nothing is a command chord. `Dispatcher::new().with_repeat(true)` also runs commands on key repeats (held arrows). - egui's Copy, Cut and Paste events stay with the app by default. On the desktop, egui-winit - sends them in place of the Cmd+C, Cmd+X and Cmd+V presses (Ctrl off macOS), so a binding on - those chords runs only with `with_clipboard_events(true)`. Then the events count as their - chords outside text fields, and a Paste event's text moves to `Hit::pasted`. The web build - sends the key press and then the event; the press runs the command and takes the event, with - its text, along. An app whose - paste command asks egui for a paste gets a Paste event back, which runs that command again; - such an app keeps the default. + On the desktop, egui-winit sends egui's Copy, Cut and Paste events in place of the key press + for any chord of Cmd with C, X or V (Ctrl off macOS); the web build sends the press and then + the event. For the plain chords Cmd+C, Cmd+X and Cmd+V (and Cmd+Insert, Shift+Delete, + Shift+Insert), the events stay with the app by default, so a binding on those chords runs only + with `with_clipboard_events(true)`. Then the events count as their chords outside text fields, + and a Paste event's text moves to `Hit::pasted`. On the web, the press runs the command and + takes the event, with its text, along. An app whose paste command asks egui for a paste gets a + Paste event back, which runs that command again; such an app keeps the default. A clipboard + event with more modifiers held (Cmd+Shift+V, Cmd+Alt+C) always counts as its chord, also while + typing, and stays when that chord has no binding. egui-winit sends no Paste event when the + clipboard is empty or holds an image, so a binding on a Cmd+V chord cannot run then on the + desktop. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index 4947ad72..a42e7d9d 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -173,8 +173,8 @@ pub struct Hit { /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or /// `+` and `=` standing for each other. pub exact: bool, - /// The text of the Paste event that ran the command, or on the web the Paste event that came - /// with the press that ran it ([`Dispatcher::with_clipboard_events`]). + /// The text of the Paste event that ran the command, or on the web of the Paste event that + /// came with the press that ran it. pub pasted: Option, } @@ -204,16 +204,21 @@ struct LastPress { /// that runs a command is removed from the events, together with the text it typed when it has /// no command modifier, so no widget sees it too. Other events stay. /// -/// egui's Copy, Cut and Paste events stay with the app unless -/// [`with_clipboard_events`](Self::with_clipboard_events) is on. On the desktop, egui-winit sends -/// these events in place of the key presses for Cmd+C, Cmd+X and Cmd+V (Ctrl off macOS, plus -/// Shift+Delete, Ctrl+Insert and Shift+Insert on Windows), so bindings on those chords run only -/// with clipboard events on. The web build sends the key press and then the clipboard event; with -/// clipboard events off, such a press stays too. +/// On the desktop, egui-winit sends egui's Copy, Cut and Paste events in place of the key press +/// for any chord of Cmd with C, X or V (Ctrl off macOS; on Windows also Shift+Delete, Ctrl+Insert +/// and Shift+Insert). The web build sends the key press and then the event. For the plain +/// clipboard chords (Cmd+C, Cmd+X, Cmd+V, Cmd+Insert, Shift+Delete, Shift+Insert), the events and +/// their presses stay with the app unless [`with_clipboard_events`](Self::with_clipboard_events) +/// is on, so bindings on those chords run only with it on. An event with more modifiers held +/// counts as its chord, read from the frame's modifiers (Cmd+Shift+V), and runs that chord's +/// binding; without one, it stays. On Windows, egui-winit also sends Cut for Ctrl+Shift+Delete +/// and Copy for Ctrl+Shift+Insert, which read as Cmd+Shift+X and Cmd+Shift+C. +/// +/// egui-winit sends no Paste event when the clipboard is empty or holds no text (an image), so a +/// binding on Cmd+V, or on Cmd and V with more modifiers, cannot run then on the desktop. /// /// While [`Active::typing`], the text field keeps, without lookup: -/// - clipboard events, and the clipboard key presses the web build sends with them (Cmd+C, Cmd+X, -/// Cmd+V, Shift+Delete, Shift+Insert, Ctrl+Insert); +/// - the plain clipboard chords' events and presses (other clipboard chords are looked up); /// - a press with a Text event of its own in the frame, since it typed that text. This covers /// AltGr: the browser on Windows reports AltGr+Q (`@` on a German layout) as Ctrl+Alt+Q, and /// the web build's text agent sends the `@` after it. egui-winit reports AltGr with neither Ctrl @@ -246,13 +251,14 @@ impl Dispatcher { self } - /// Whether egui's Copy, Cut and Paste events count as the chords that cause them (Cmd+C, - /// Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift alone) outside text - /// fields. Off by default: the events stay in egui's input for the app's own handlers. With it - /// on, an event that runs a command is removed, and a Paste event's text moves to - /// [`Hit::pasted`]. On the web, the key press runs the command and its clipboard event goes - /// with it the same way; when the press runs nothing, or the hit is declined, both stay. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run - /// its paste command gets a Paste event back, which with this on runs that command again. + /// Whether egui's Copy, Cut and Paste events for the plain clipboard chords count as those + /// chords (Cmd+C, Cmd+X, Cmd+V; Shift+Delete and Shift+Insert for Cut and Paste with Shift + /// alone) outside text fields. Off by default: the events stay in egui's input for the app's + /// own handlers. With it on, an event that runs a command is removed, and a Paste event's text + /// moves to [`Hit::pasted`]. On the web, the key press runs the command and its clipboard + /// event goes with it the same way; when the press runs nothing, or the hit is declined, both + /// stay. An app that asks egui for a paste (`ViewportCommand::RequestPaste`) to run its paste + /// command gets a Paste event back, which with this on runs that command again. pub fn with_clipboard_events(mut self, on: bool) -> Dispatcher { self.clipboard = on; self @@ -288,13 +294,16 @@ impl Dispatcher { Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { let held = Mods::from_egui(modifiers, platform); if let Some(key) = Key::from_egui(*key) { - let clip = clipboard_kind(key, held); + let plain_clip = is_plain_clipboard(key, held); let keep = if active.typing { - text_of[i].is_some() || clip.is_some() + text_of[i].is_some() || plain_clip } else { - !self.clipboard && causes[i].is_some() + !self.clipboard && plain_clip && causes[i].is_some() }; if keep { + if let Some((j, _)) = causes[i] { + caused_taken[j] = Some(false); + } kept.push((i, event)); continue; } @@ -322,23 +331,26 @@ impl Dispatcher { caused_taken[j] = Some(false); } } - // A focused text field copies and pastes on these events itself. - Event::Copy | Event::Cut | Event::Paste(_) if self.clipboard && !active.typing => { - let (key, held) = clipboard_chord(&event, frame_mods); - if let Some(taken) = caused_taken[i] { - if taken { - continue; - } - } else if let Some(mut hit) = self.press(key, held, false, now, keymap, active) { - if let Event::Paste(text) = &event { - hit.pasted = Some(text.clone()); - } - if accept(&hit) { - hits.push(hit); - continue; + // Desktop clipboard events, and web ones whose press stayed or ran nothing. + Event::Copy | Event::Cut | Event::Paste(_) => match caused_taken[i] { + Some(true) => continue, + Some(false) => {} + None => { + let (key, held) = clipboard_chord(&event, frame_mods); + // A focused text field copies and pastes on the plain chords itself. + let look = !is_plain_clipboard(key, held) || (self.clipboard && !active.typing); + let hit = if look { self.press(key, held, false, now, keymap, active) } else { None }; + if let Some(mut hit) = hit { + if let Event::Paste(text) = &event { + hit.pasted = Some(text.clone()); + } + if accept(&hit) { + hits.push(hit); + continue; + } } } - } + }, _ => {} } kept.push((i, event)); @@ -444,6 +456,17 @@ fn clipboard_kind(key: Key, held: Mods) -> Option { } } +/// Whether a chord is one of the plain clipboard chords, which egui's text fields act on: Cmd+C, +/// Cmd+X, Cmd+V, Cmd+Insert, Shift+Delete and Shift+Insert. +fn is_plain_clipboard(key: Key, held: Mods) -> bool { + match key { + Key::C | Key::X | Key::V => held == Mods::CMD, + Key::Insert => held == Mods::CMD || held == Mods::SHIFT, + Key::Delete => held == Mods::SHIFT, + _ => false, + } +} + /// The press a clipboard event stands for, with the modifiers held. Off macOS egui-winit also /// sends Cut for Shift+Delete and Paste for Shift+Insert. fn clipboard_chord(event: &Event, held: Mods) -> (Key, Mods) { diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 4b31b9bf..b1fb7630 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -81,6 +81,8 @@ fn dispatch_keymap() -> Keymap { let catalog = vec![ app("edit.copy", &["Cmd+C", "Cmd+Insert"]), app("edit.paste", &["Cmd+V", "Shift+Insert"]), + app("edit.pasteInPlace", &["Cmd+Shift+V"]), + app("edit.copyMerged", &["Cmd+Shift+C"]), app("app.quit", &["Cmd+Alt+Q"]), app("type.pageNumber", &["Cmd+Alt+Shift+N"]), app("view.refresh", &["Cmd+Alt+F5"]), @@ -168,6 +170,39 @@ fn clipboard_events_stay_with_the_app_by_default() { assert_eq!(ran, ["edit.copy"]); } +#[test] +fn clipboard_chords_with_more_modifiers_run_their_bindings() { + let km = dispatch_keymap(); + let mut d = Dispatcher::new(); + let typing = Active { typing: true, ..Active::NONE }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + // The desktop: egui-winit sends the Paste event in place of Ctrl+Shift+V. + let mut input = frame(1.0, ctrl_shift, vec![Event::Paste("text".into())]); + let hits = d.dispatch(&mut input, &km, &Active::NONE); + let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect(); + assert_eq!(ran, [("edit.pasteInPlace", Some("text"))]); + assert!(input.events.is_empty()); + let (ran, left) = run(&mut d, &km, &typing, frame(2.0, ctrl_shift, vec![Event::Copy])); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty()); + // Ctrl+Shift+X has no binding: the event stays. + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, ctrl_shift, vec![Event::Cut])); + assert!(ran.is_empty()); + assert_eq!(left, [Event::Cut]); + + // The web: the press decides for itself and its event, also while typing. + for (time, active) in [(4.0, &Active::NONE), (5.0, &typing)] { + let events = vec![press(EguiKey::C, ctrl_shift), Event::Copy]; + let (ran, left) = run(&mut d, &km, active, frame(time, ctrl_shift, events)); + assert_eq!(ran, ["edit.copyMerged"]); + assert!(left.is_empty(), "{left:?}"); + } + let events = vec![press(EguiKey::X, ctrl_shift), Event::Cut]; + let (ran, left) = run(&mut d, &km, &Active::NONE, frame(6.0, ctrl_shift, events.clone())); + assert!(ran.is_empty()); + assert_eq!(left, events); +} + #[test] fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() { let km = dispatch_keymap(); From 501e2cf1fc87c3785a6bb4fc7e18163d2c2711b7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:21:54 +0000 Subject: [PATCH 044/124] keymap: while typing, text-editing chords go to the text field unless an active context binds them --- crates/keymap/README.md | 7 ++- crates/keymap/src/egui.rs | 4 ++ crates/keymap/src/keymap.rs | 95 ++++++++++++++++++++++++++++++++--- crates/keymap/src/lib.rs | 2 +- crates/keymap/tests/egui.rs | 17 +++++++ crates/keymap/tests/keymap.rs | 29 +++++++++++ 6 files changed, 146 insertions(+), 8 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index d9d6f225..15e812db 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -57,7 +57,12 @@ assert_eq!(press("M", &editing), Lookup::None, "plain keys go to the text field" While a text field has the keyboard (`Active::typing`), the keymap's `TypingRule` decides which app and panel bindings wait: plain keys (the default), everything without the command modifier, -or everything. Bindings in an active context always fire, so `Text: Cmd+B` works while typing. +or everything. Under every rule, the platform's text-editing chords (`text_editing_chords`) go +to the field too: undo and redo, select all, and moving and deleting by word, line and text +(Cmd+Z, Alt+Left and Cmd+Backspace on macOS; Ctrl+Z, Ctrl+Y, Ctrl+Left and Ctrl+Home +elsewhere). Other command chords, such as Cmd+S, still fire. Bindings in an active context +always fire, so `Text: Cmd+B` works while typing, and a `Text: Cmd+Z` binding takes undo from +the field. With the `egui` feature, `egui::Dispatcher` does this for a frame's key events (see Features). diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index a42e7d9d..fcf113b1 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -223,6 +223,10 @@ struct LastPress { /// AltGr: the browser on Windows reports AltGr+Q (`@` on a German layout) as Ctrl+Alt+Q, and /// the web build's text agent sends the `@` after it. egui-winit reports AltGr with neither Ctrl /// nor Alt. A Ctrl+Alt press without text is a command chord. +/// +/// Other presses are looked up; the keymap's [`TypingRule`](crate::TypingRule) and +/// [`text_editing_chords`](crate::text_editing_chords) leave plain keys and the text-editing chords +/// (Cmd+Z, word moves) to the field. #[derive(Clone, Debug)] pub struct Dispatcher { last: Option, diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 4c3f23fc..1dfcb239 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -7,7 +7,7 @@ use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::chord::{Chord, ParseError, Platform, Trigger}; +use crate::chord::{Chord, Key, Mods, ParseError, Platform, Trigger}; use crate::command::{CommandRef, MAX_KEY}; use crate::scope::{Active, Scope, ScopeKind, Scopes}; @@ -196,8 +196,9 @@ impl Bindable { } } -/// Which chords work while a text field has the keyboard. Bindings in an active context always -/// work: a `Text: Cmd+B` binding is meant for typing. +/// Which chords work while a text field has the keyboard. Under every rule, the +/// [`text_editing_chords`] go to the text field too. Bindings in an active context always work: a +/// `Text: Cmd+B` binding is meant for typing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TypingRule { @@ -211,8 +212,11 @@ pub enum TypingRule { } impl TypingRule { - /// Whether `chord` may fire while typing. - pub fn fires(self, chord: &Chord) -> bool { + /// Whether `chord` may fire while typing on `platform`. + pub fn fires(self, chord: &Chord, platform: Platform) -> bool { + if text_editing_chords(platform).contains(&chord.effective(platform)) { + return false; + } match self { TypingRule::PlainKeysWait => !chord.is_plain() || chord.is_function_key(), TypingRule::CommandKeysOnly => chord.has_command() || chord.is_function_key(), @@ -221,6 +225,85 @@ impl TypingRule { } } +const CMD: Mods = Mods::CMD; +const CMD_SHIFT: Mods = Mods { shift: true, ..Mods::CMD }; +const ALT: Mods = Mods::ALT; +const ALT_SHIFT: Mods = Mods { shift: true, ..Mods::ALT }; +const CTRL: Mods = Mods::CTRL; + +// The chords egui's TextEdit acts on that follow the platform's conventions. egui also takes +// Ctrl+H, Ctrl+K, Ctrl+U and Ctrl+W everywhere, but off macOS those are app commands. +const MAC_EDITING: &[Chord] = &[ + // Undo, redo, select all. + Chord::new(CMD, Key::Z), + Chord::new(CMD_SHIFT, Key::Z), + Chord::new(CMD, Key::A), + // Words. + Chord::new(ALT, Key::Left), + Chord::new(ALT, Key::Right), + Chord::new(ALT_SHIFT, Key::Left), + Chord::new(ALT_SHIFT, Key::Right), + Chord::new(ALT, Key::Backspace), + Chord::new(ALT, Key::Delete), + // Line start and end. + Chord::new(CMD, Key::Left), + Chord::new(CMD, Key::Right), + Chord::new(CMD_SHIFT, Key::Left), + Chord::new(CMD_SHIFT, Key::Right), + Chord::new(CMD, Key::Backspace), + Chord::new(CMD, Key::Delete), + // Text start and end. + Chord::new(CMD, Key::Up), + Chord::new(CMD, Key::Down), + Chord::new(CMD_SHIFT, Key::Up), + Chord::new(CMD_SHIFT, Key::Down), + // The Control keys of Cocoa text fields: line start and end, character and line moves, + // deletion. + Chord::new(CTRL, Key::A), + Chord::new(CTRL, Key::E), + Chord::new(CTRL, Key::B), + Chord::new(CTRL, Key::F), + Chord::new(CTRL, Key::P), + Chord::new(CTRL, Key::N), + Chord::new(CTRL, Key::H), + Chord::new(CTRL, Key::K), + Chord::new(CTRL, Key::U), + Chord::new(CTRL, Key::W), +]; + +const PC_EDITING: &[Chord] = &[ + // Undo, redo, select all. + Chord::new(CMD, Key::Z), + Chord::new(CMD, Key::Y), + Chord::new(CMD_SHIFT, Key::Z), + Chord::new(CMD, Key::A), + // Words. + Chord::new(CMD, Key::Left), + Chord::new(CMD, Key::Right), + Chord::new(CMD_SHIFT, Key::Left), + Chord::new(CMD_SHIFT, Key::Right), + Chord::new(CMD, Key::Backspace), + Chord::new(CMD, Key::Delete), + // Text start and end. + Chord::new(CMD, Key::Home), + Chord::new(CMD, Key::End), + Chord::new(CMD_SHIFT, Key::Home), + Chord::new(CMD_SHIFT, Key::End), + Chord::new(CMD, Key::Up), + Chord::new(CMD, Key::Down), + Chord::new(CMD_SHIFT, Key::Up), + Chord::new(CMD_SHIFT, Key::Down), +]; + +/// The chords with modifiers that edit text on `platform`, as effective chords: undo, redo, +/// select all, and moving and deleting by word, line and text. While typing they go to the text +/// field under every [`TypingRule`]; only a binding in an active context takes one. Plain keys +/// (arrows, Home, End, Backspace, Delete, with or without Shift) are not listed, since every rule +/// holds them already. The `egui` dispatcher keeps the clipboard chords for the field itself. +pub fn text_editing_chords(platform: Platform) -> &'static [Chord] { + if platform.is_mac() { MAC_EDITING } else { PC_EDITING } +} + /// What a key press runs. #[derive(Clone, Debug, PartialEq)] pub enum Lookup { @@ -500,7 +583,7 @@ impl Keymap { /// The command `chord` (pressed twice when `double`) runs with `active` focus. pub fn lookup(&self, chord: &Chord, double: bool, active: &Active) -> Lookup { let want = Trigger { chord: chord.effective(self.platform), double }; - let held = active.typing && !self.typing.fires(&want.chord); + let held = active.typing && !self.typing.fires(&want.chord, self.platform); let Some(holders) = self.by_trigger.get(&want) else { return Lookup::None }; // Each command's nearest rank; `holders` is in catalog order. let mut ranks: Vec<(usize, u8)> = Vec::new(); diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 9fbcfa2e..9816ecda 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -145,6 +145,6 @@ pub use command::{CommandRef, MAX_KEY}; pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan}; pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, - MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, + MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, text_editing_chords, }; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index b1fb7630..1e185ba8 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -351,6 +351,23 @@ fn typing_blocks_plain_keys_but_not_command_chords() { assert_eq!(left[1], Event::Text("b".into())); } +#[test] +fn undo_goes_to_the_text_field_and_save_still_runs() { + let km = Keymap::new( + vec![ + Bindable::new("edit.undo", "Undo", Scope::app()).with_default(Binding::parse("Cmd+Z").unwrap()), + Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()), + ], + Scopes::new(), + Platform::Windows, + ); + let typing = Active { typing: true, ..Active::NONE }; + let events = vec![press(EguiKey::Z, CTRL), press(EguiKey::S, CTRL)]; + let (ran, left) = run(&mut Dispatcher::new(), &km, &typing, frame(1.0, CTRL, events)); + assert_eq!(ran, ["file.save"]); + assert_eq!(left, [press(EguiKey::Z, CTRL)]); +} + #[test] fn the_focused_panel_wins_and_tool_groups_cycle() { let km = dispatch_keymap(); diff --git a/crates/keymap/tests/keymap.rs b/crates/keymap/tests/keymap.rs index 74ba8d6e..58a904b0 100644 --- a/crates/keymap/tests/keymap.rs +++ b/crates/keymap/tests/keymap.rs @@ -97,6 +97,35 @@ fn typing_rules_decide_what_fires_in_a_text_field() { } } +#[test] +fn text_editing_chords_go_to_the_text_field_under_every_rule() { + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("file.save", &["Cmd+S"]), app("nav.word", &["Alt+Left"]), app("nav.start", &["Cmd+Home"])]; + let typing = Active { typing: true, ..Active::NONE }; + for rule in [TypingRule::PlainKeysWait, TypingRule::CommandKeysOnly] { + for platform in [Platform::Mac, Platform::Windows] { + let km = Keymap::new(catalog.clone(), Scopes::new(), platform).with_typing_rule(rule); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None, "{rule:?} {platform:?}"); + assert_eq!(press(&km, "Cmd+Z", &Active::NONE), run("edit.undo")); + assert_eq!(press(&km, "Cmd+S", &typing), run("file.save"), "{rule:?} {platform:?}"); + } + } + // Word and line keys follow the platform. + let mac = Keymap::new(catalog.clone(), Scopes::new(), Platform::Mac); + let windows = Keymap::new(catalog.clone(), Scopes::new(), Platform::Windows); + assert_eq!(press(&mac, "Alt+Left", &typing), Lookup::None); + assert_eq!(press(&windows, "Alt+Left", &typing), run("nav.word")); + assert_eq!(press(&windows, "Ctrl+Home", &typing), Lookup::None); + assert_eq!(press(&mac, "Cmd+Home", &typing), run("nav.start")); + + // A binding in an active context takes the chord from the field. + let catalog = vec![app("edit.undo", &["Cmd+Z"]), app("text.undo", &["Text: Cmd+Z"])]; + let km = Keymap::new(catalog, scopes(), Platform::Windows); + let editing = Active { contexts: &["Text"], typing: true, ..Active::NONE }; + assert_eq!(press(&km, "Cmd+Z", &editing), run("text.undo")); + assert_eq!(press(&km, "Cmd+Z", &typing), Lookup::None); + assert!(!TypingRule::PlainKeysWait.fires(&chord("Ctrl+Z"), Platform::Linux)); +} + #[test] fn bindings_of_an_active_context_fire_while_typing_under_every_rule() { let catalog = vec![ From ad79cbcf3152ace45f1defdecc2bcdebf24694ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:29:42 +0000 Subject: [PATCH 045/124] keymap: translate shortcut parse errors and give translators whole sentences with placeholders --- crates/keymap/src/ui/keyboard.rs | 2 +- crates/keymap/src/ui/review.rs | 5 +-- crates/keymap/src/ui/rows.rs | 8 ++--- crates/keymap/src/ui/state.rs | 29 ++++++++++++---- crates/keymap/src/ui/summary.rs | 2 +- crates/keymap/src/ui/text.rs | 26 ++++++++++++-- crates/keymap/src/ui/view.rs | 59 ++++++++++++-------------------- crates/keymap/tests/ui_dialog.rs | 8 +++++ 8 files changed, 86 insertions(+), 53 deletions(-) diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 32e92ad6..0d0bc2a5 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -366,7 +366,7 @@ impl Draw<'_, '_> { /// The holder's label, with its scope when that is not Application. fn holder_text(&self, h: &KeyHolder) -> String { let label = self.label_of(&h.holder.command); - if h.scope.is_app() { label } else { format!("{label} ({})", self.scope_name(&h.scope)) } + if h.scope.is_app() { label } else { fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(&h.scope))]) } } pub(super) fn keyboard_pane(&mut self, ui: &mut Ui) { diff --git a/crates/keymap/src/ui/review.rs b/crates/keymap/src/ui/review.rs index 006d9405..61ef3da7 100644 --- a/crates/keymap/src/ui/review.rs +++ b/crates/keymap/src/ui/review.rs @@ -84,7 +84,7 @@ impl Draw<'_, '_> { return; } ui.add_space(4.0); - ui.label(RichText::new(self.vis(&format!("{title} ({})", entries.len()))).strong()); + ui.label(RichText::new(self.vis(&fill(self.ui("{title} ({n})"), &[("title", title), ("n", &entries.len().to_string())]))).strong()); for e in entries { self.entry(ui, e); } @@ -181,7 +181,8 @@ impl Draw<'_, '_> { if !report.suggestions.is_empty() { ui.add_space(4.0); - ui.label(RichText::new(self.vis(&format!("{} ({})", self.ui("Suggestions"), report.suggestions.len()))).strong()); + let title = fill(self.ui("Suggestions ({n})"), &[("n", &report.suggestions.len().to_string())]); + ui.label(RichText::new(self.vis(&title)).strong()); for (si, s) in report.suggestions.iter().enumerate() { ui.horizontal_wrapped(|ui| { self.entry( diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 5bc3e0c2..2f85cf31 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -7,7 +7,7 @@ use crate::chord::{Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Bindable, Binding, ConflictKind, Keymap, Kind, Overrides}; use crate::ui::Options; -use crate::ui::text::{Text, trigger_text}; +use crate::ui::text::{Text, trigger_text, visual_around}; /// One entry of the Show menu. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -375,9 +375,9 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu .map(|x| { let mut t = trigger_text(text, &x.trigger, platform); if x.scope_or(&b.scope) != &b.scope { - let scope = format!("({})", text.segment(x.scope_or(&b.scope).as_str())); - // Display order; the chord itself is never reordered. - t = if text.rtl() { format!("{} {t}", text.visual(&scope)) } else { format!("{t} {scope}") }; + let scope = text.segment(x.scope_or(&b.scope).as_str()); + let s = craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &t), ("scope", &scope)]); + t = visual_around(text, &s, &t); } Chip { binding: x.clone(), text: t, changed: !defaults.contains(x) } }) diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index c53a12d2..701ba546 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -3,11 +3,11 @@ use std::collections::BTreeSet; -use crate::chord::{Chord, Key, Trigger}; +use crate::chord::{Chord, Key, ParseError, Trigger}; use crate::command::CommandRef; use crate::editor::{self, DOUBLE_PRESS, EditSession, Recorded, Recorder}; use crate::import::{self, ForeignSet, ImportOptions, ImportPlan, ImportReport}; -use crate::keymap::{AssignError, Assigned, Binding, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; +use crate::keymap::{AssignError, Assigned, Binding, BindingError, Holder, Keymap, MAX_BINDINGS, Overrides, Policy}; use crate::profiles::{Builtins, ProfileError, Profiles, set_name}; use crate::scope::Scope; use crate::ui::rows::{AreaEntry, AreaId, Built, Filter, RowCache, View}; @@ -38,7 +38,7 @@ impl Env<'_> { for h in holders { let label = self.label(&h.command); let s = match &h.binding.scope { - Some(scope) => format!("{label} ({})", self.text.segment(scope.as_str())), + Some(scope) => self.ui("{label} ({scope})", &[("label", &label), ("scope", &self.text.segment(scope.as_str()))]), None => label, }; if !out.contains(&s) { @@ -501,9 +501,9 @@ impl DialogState { } /// Typed entry: the text as [`Binding::parse`] reads it, committed like a recorded chord. - /// `Err` is the parse error for under the field. + /// `Err` is the parse error for under the field, translated. pub(crate) fn type_binding(&mut self, env: &mut Env, command: &CommandRef, text: &str) -> Result<(), String> { - let binding = Binding::parse(text).map_err(|e| e.to_string())?; + let binding = Binding::parse(text).map_err(|e| binding_error(env, &e))?; self.select_row(command); if let Some(scope) = binding.scope.clone() { self.context = Some(scope); @@ -1097,6 +1097,23 @@ fn assign_error(env: &Env, e: &AssignError) -> String { } } +/// Why a binding text does not parse, translated. +pub(crate) fn binding_error(env: &Env, e: &BindingError) -> String { + match e { + BindingError::TooLong => env.ui("the shortcut is too long", &[]), + BindingError::Scope => env.ui("the scope name is not valid", &[]), + BindingError::Trigger(e) => match e { + ParseError::Empty => env.ui("empty shortcut", &[]), + ParseError::TooLong => env.ui("the shortcut is too long", &[]), + ParseError::MissingKey => env.ui("the shortcut has no key", &[]), + ParseError::UnknownKey(k) => env.ui("unknown key {key}", &[("key", k)]), + ParseError::UnknownModifier(m) => env.ui("unknown modifier {key}", &[("key", m)]), + ParseError::DuplicateModifier(m) => env.ui("modifier {key} given twice", &[("key", m)]), + ParseError::Sequence => env.ui("key sequences are not supported", &[]), + }, + } +} + fn profile_error(env: &Env, e: &ProfileError) -> String { match e { ProfileError::BadName => env.ui("The name is empty, too long or has control characters", &[]), @@ -1327,7 +1344,7 @@ mod tests { assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "F7 F7"), Ok(())); st.assign_field(&mut fx.env()); assert_eq!(fx.keys("file.save"), ["Cmd+S", "F7 F7"]); - assert!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope").is_err()); + assert_eq!(st.type_binding(&mut fx.env(), &"file.save".into(), "Cmd+Nope"), Err("unknown key Nope".into())); } #[test] diff --git a/crates/keymap/src/ui/summary.rs b/crates/keymap/src/ui/summary.rs index 8b4248ca..01603962 100644 --- a/crates/keymap/src/ui/summary.rs +++ b/crates/keymap/src/ui/summary.rs @@ -21,7 +21,7 @@ fn binding_text(km: &Keymap, text: &dyn Text, b: &Binding, home: &Scope) -> Stri match &b.scope { Some(s) if s != home => { let scope = if s.is_app() { text.ui("Application").into_owned() } else { text.segment(s.as_str()).into_owned() }; - format!("{chord} ({scope})") + craft_i18n::fmt(&text.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &scope)]) } _ => chord, } diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index 2550e21e..d248c5c5 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -104,6 +104,15 @@ pub fn trigger_text(text: &dyn Text, trigger: &Trigger, platform: Platform) -> S if trigger.double { format!("{one} {one}") } else { one } } +/// `s` in display order with `chord`, text inside it, left as it is: chord text never goes +/// through [`Text::visual`]. Right to left, the parts before and after the chord change sides. +pub(crate) fn visual_around(text: &dyn Text, s: &str, chord: &str) -> String { + let Some(i) = s.find(chord).filter(|_| !chord.is_empty()) else { return text.visual(s) }; + let (before, rest) = s.split_at(i); + let (before, after) = (text.visual(before), text.visual(&rest[chord.len()..])); + if text.rtl() { format!("{after}{chord}{before}") } else { format!("{before}{chord}{after}") } +} + /// Plural forms the dialog looks up, as `one|other`, the key of craft-i18n's `@plural` rows. pub const PLURALS: &[(&str, &str)] = &[ ("{n} shortcut is assigned more than once", "{n} shortcuts are assigned more than once"), @@ -149,6 +158,8 @@ pub const STRINGS: &[&str] = &[ "Conflicts", "No matching commands", "(cycles)", + "{label} (cycles)", + "{label} (Changed)", // Table and detail pane. "Command", "Commands", @@ -156,9 +167,13 @@ pub const STRINGS: &[&str] = &[ "Select a command to see and change its shortcuts", "Shortcut", "Default", + "{chord} (Default)", + "{chord} ({scope})", + "{label} ({scope})", "Application", "Current shortcuts:", "New shortcut:", + "New shortcut: {chord}", "press keys", "{chord} (press again for a double press)", "Type…", @@ -166,7 +181,6 @@ pub const STRINGS: &[&str] = &[ "Assign", "Use Default", "Clear", - "Remove", "Default: {chord}", "None", "Currently assigned to:", @@ -178,6 +192,13 @@ pub const STRINGS: &[&str] = &[ "{label}: {chord}", "{label}: press keys", "Now used by {label}", + "{label}: {chord} (Now used by {holder})", + // Typed shortcuts and saved texts that do not parse. + "the shortcut has no key", + "unknown modifier {key}", + "modifier {key} given twice", + "key sequences are not supported", + "the scope name is not valid", // On-screen keyboard. "Keyboard", "Keyboard: US", @@ -263,7 +284,8 @@ pub const STRINGS: &[&str] = &[ "Not matched", "Unreadable", "Approximated", - "Suggestions", + "{title} ({n})", + "Suggestions ({n})", "numeric keypad key read as main key", "Matched without change", "Command for {item}", diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 373bbb82..155ee2e9 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -24,9 +24,9 @@ use crate::keymap::{Binding, MAX_BINDINGS, Problem}; use crate::scope::Scope; use crate::ui::capture; use crate::ui::rows::{AreaId, Filter, FixedLine, Line, Row}; -use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target}; +use crate::ui::state::{Answer, DialogState, Env, Prompt, Recording, Target, binding_error}; use crate::ui::style::{self, DialogStyle}; -use crate::ui::text::trigger_text; +use crate::ui::text::{trigger_text, visual_around}; #[path = "keyboard.rs"] pub(crate) mod keyboard; @@ -165,13 +165,7 @@ impl Draw<'_, '_> { /// `s` in display order with `chord`, text inside it, left as it is: chord text never goes /// through [`Text::visual`](crate::ui::Text::visual). fn vis_chord(&self, s: &str, chord: &str) -> String { - match s.find(chord).filter(|_| !chord.is_empty()) { - None => self.vis(s), - Some(i) => { - let (before, rest) = s.split_at(i); - self.joined(vec![self.vis(before), chord.to_string(), self.vis(&rest[chord.len()..])]) - } - } + visual_around(self.env.text, s, chord) } /// `text` after a ⚠, in display order. @@ -192,20 +186,11 @@ impl Draw<'_, '_> { if s.is_app() { self.ui("Application") } else { self.env.text.segment(s.as_str()).into_owned() } } - /// [`binding_text`](Self::binding_text) in display order. - fn binding_shown(&self, b: &Binding, home: Option<&Scope>) -> String { - let chord = self.chord(&b.trigger); - match &b.scope { - Some(s) if Some(s) != home => self.joined(vec![chord, " ".into(), self.vis(&format!("({})", self.scope_name(s)))]), - _ => chord, - } - } - - /// A binding as text, with ` (Scope)` when its scope is not the command's home scope. + /// A binding as text, with its scope when that is not the command's home scope. fn binding_text(&self, b: &Binding, home: Option<&Scope>) -> String { let chord = self.chord(&b.trigger); match &b.scope { - Some(s) if Some(s) != home => format!("{chord} ({})", self.scope_name(s)), + Some(s) if Some(s) != home => fill(self.ui("{chord} ({scope})"), &[("chord", &chord), ("scope", &self.scope_name(s))]), _ => chord, } } @@ -832,10 +817,10 @@ impl Draw<'_, '_> { let resp = ui.interact(rect, self.base.with(("row", &r.key)), Sense::CLICK); let mut a11y = r.label.clone(); if r.cycles { - a11y = format!("{a11y} {}", self.ui("(cycles)")); + a11y = fill(self.ui("{label} (cycles)"), &[("label", &a11y)]); } if r.changed { - a11y = format!("{a11y} ({})", self.ui("Changed")); + a11y = fill(self.ui("{label} (Changed)"), &[("label", &a11y)]); } resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, selected, &a11y)); ui.ctx().accesskit_node_builder(resp.id, |node| node.set_selected(selected)); @@ -983,18 +968,20 @@ impl Draw<'_, '_> { let (keys, _) = self.one_line(ui.painter(), &[(f.keys.clone(), fmt)], shortcut_w); ui.painter().galley(pos2(m.x(rect.max.x - shortcut_w - ICON - 4.0, keys.size().x), rect.center().y - keys.size().y / 2.0), keys, color); let resp = ui.interact(rect, self.base.with(("fixed", &f.label, &f.keys)), Sense::hover()); - let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); let mut tip = Vec::new(); if cut { tip.push(self.vis(&f.label)); } match &f.now_used_by { Some(by) => { - let used = fill(self.ui("Now used by {label}"), &[("label", by)]); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, format!("{label} ({used})"))); - tip.push(self.vis(&used)); + let label = fill(self.ui("{label}: {chord} (Now used by {holder})"), &[("label", &f.label), ("chord", &f.keys), ("holder", by)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); + tip.push(self.vis(&fill(self.ui("Now used by {label}"), &[("label", by)]))); + } + None => { + let label = fill(self.ui("{label}: {chord}"), &[("label", &f.label), ("chord", &f.keys)]); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)); } - None => resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, &label)), } if !tip.is_empty() { resp.on_hover_text(tip.join("\n")); @@ -1040,12 +1027,8 @@ impl Draw<'_, '_> { let text = self.binding_text(binding, home); ui.horizontal(|ui| { ui.add_space(8.0); - let shown = if defaults.contains(binding) { - self.joined(vec![self.binding_shown(binding, home), " ".into(), self.vis(&format!("({})", self.ui("Default")))]) - } else { - self.binding_shown(binding, home) - }; - ui.label(shown); + let shown = if defaults.contains(binding) { fill(self.ui("{chord} (Default)"), &[("chord", &text)]) } else { text.clone() }; + ui.label(self.vis_chord(&shown, &self.chord(&binding.trigger))); if editable { let a11y = fill(self.ui("Remove {chord} from {label}"), &[("chord", &text), ("label", &label)]); let r = self.icon_button(ui, &a11y, false, paint_cross).on_hover_text(self.vis(&a11y)); @@ -1117,7 +1100,7 @@ impl Draw<'_, '_> { } } if let Some(e) = &self.state.type_error { - ui.add(Label::new(RichText::new(format!("⚠ {e}")).color(self.style.warning)).wrap()); + ui.add(Label::new(RichText::new(self.warned(self.vis(e))).color(self.style.warning)).wrap()); } return; } @@ -1130,7 +1113,8 @@ impl Draw<'_, '_> { let size = vec2(FIELD_W, ui.spacing().interact_size.y); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); let resp = ui.interact(rect, field_id, Sense::click()); - let a11y = format!("{title} {}", if shown.is_empty() { self.ui("None") } else { shown.clone() }); + let value = if shown.is_empty() { self.ui("None") } else { shown.clone() }; + let a11y = fill(self.ui("New shortcut: {chord}"), &[("chord", &value)]); resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &a11y)); let v = ui.visuals(); let stroke = if recording || resp.has_focus() { v.selection.stroke } else { v.widgets.inactive.bg_stroke }; @@ -1201,7 +1185,7 @@ impl Draw<'_, '_> { let label = self.label_of(&h.command); let home = self.env.keymap.bindable(&h.command).map(|b| b.scope.clone()); let text = match &h.binding.scope { - Some(s) if Some(s) != home.as_ref() => format!("{label} ({})", self.scope_name(s)), + Some(s) if Some(s) != home.as_ref() => fill(self.ui("{label} ({scope})"), &[("label", &label), ("scope", &self.scope_name(s))]), _ => label, }; ui.horizontal(|ui| { @@ -1361,7 +1345,8 @@ impl Draw<'_, '_> { match p { Problem::UnknownCommand { key } => fill(self.ui("{key}: no command has this name"), &[("key", key)]), Problem::BadBinding { key, text, error } => { - fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error.to_string())]) + let error = binding_error(self.env, error); + fill(self.ui("{key}: “{text}” is not a shortcut ({error})"), &[("key", key), ("text", text), ("error", &error)]) } Problem::Reserved { key, text } => fill(self.ui("{key}: {text} is reserved by the system"), &[("key", key), ("text", text)]), Problem::UnknownScope { key, text } => fill(self.ui("{key}: the scope in “{text}” is unknown"), &[("key", key), ("text", text)]), diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index aed56d40..64500ac2 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -549,9 +549,17 @@ fn typed_entry_parses_and_shows_errors() { h.get_by_label("Save").click(); h.run(); click(&mut h, "Type…"); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Nope"); + h.run(); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "⚠ unknown key Nope")); + h.get_by_role_and_label(Role::TextInput, "New shortcut:").focus(); + h.run(); + press(&mut h, Modifiers::COMMAND, EguiKey::A); h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K"); h.run(); press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "⚠ unknown key Nope")); click(&mut h, "Assign"); assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]); } From 6d96b4f684b61c1aac28fc9fe4804d8505cffb40 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:29:50 +0000 Subject: [PATCH 046/124] keymap: mark public enums and report structs that will grow non_exhaustive --- crates/keymap/src/chord.rs | 3 +++ crates/keymap/src/chord/foreign.rs | 1 + crates/keymap/src/egui.rs | 1 + crates/keymap/src/import.rs | 17 +++++++++++++++++ crates/keymap/src/keymap.rs | 10 ++++++++++ crates/keymap/tests/egui.rs | 3 ++- crates/keymap/tests/import.rs | 28 +++++++++------------------- 7 files changed, 43 insertions(+), 20 deletions(-) diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 43dcf8ac..23b1c41c 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -15,6 +15,7 @@ pub const MAX_CHORD_TEXT: usize = 64; /// The platform a keymap is used on. It decides display text and whether `ctrl` folds into `cmd`. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum Platform { Mac, Windows, @@ -45,6 +46,7 @@ macro_rules! keys { /// /// Digits are the main row; numeric keypad keys map to the same keys. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + #[non_exhaustive] pub enum Key { $($variant),* } @@ -265,6 +267,7 @@ pub struct Chord { /// Why chord or trigger text was refused. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ParseError { Empty, /// Longer than [`MAX_CHORD_TEXT`]. diff --git a/crates/keymap/src/chord/foreign.rs b/crates/keymap/src/chord/foreign.rs index b7e58a35..0c3a2a73 100644 --- a/crates/keymap/src/chord/foreign.rs +++ b/crates/keymap/src/chord/foreign.rs @@ -48,6 +48,7 @@ pub enum ReadNote { /// Why foreign key text could not be read. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ForeignError { Empty, /// Longer than [`MAX_CHORD_TEXT`]. diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index fcf113b1..d50797d6 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -161,6 +161,7 @@ pub const DOUBLE_PRESS: f64 = 0.6; /// A command a key press runs. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub struct Hit { /// The command to run; for tools sharing the chord, the first of `cycle`. pub command: CommandRef, diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index daf8b81e..a387ad67 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -181,6 +181,7 @@ pub struct ImportOptions { /// One planned binding, with the entry it came from. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct PlannedBinding { pub binding: Binding, pub item: String, @@ -192,6 +193,7 @@ pub struct PlannedBinding { /// What the set says about one command, over every entry matched to it. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Planned { pub command: CommandRef, /// The first entry matched to it, and its line. @@ -209,6 +211,7 @@ pub struct Planned { /// [`plan`] and keeps its caps. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct ImportPlan { /// In the order of their first entry. pub commands: Vec, @@ -223,6 +226,7 @@ pub struct ImportPlan { /// A command whose bindings the import changed. Shortcuts are binding texts. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Changed { pub command: CommandRef, pub label: String, @@ -236,6 +240,7 @@ pub struct Changed { /// A binding taken from a command outside the set. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Cleared { pub command: CommandRef, pub label: String, @@ -245,6 +250,7 @@ pub struct Cleared { /// Why a key of the set was not bound. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum SkipReason { /// A later entry of the set takes the chord. Taken, @@ -258,6 +264,7 @@ pub enum SkipReason { /// A key of the set that was not bound. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Skipped { pub command: CommandRef, pub item: String, @@ -275,6 +282,7 @@ pub struct Skipped { /// [`AssignError::Full`]: crate::AssignError::Full #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Failed { pub command: CommandRef, pub label: String, @@ -286,6 +294,7 @@ pub struct Failed { /// What a [`Failed`] change was. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum FailedChange { /// Giving the command the set's bindings, or taking one of them from another command. Bind, @@ -297,6 +306,7 @@ pub enum FailedChange { /// Why keys of an entry were not used. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum UnmatchedReason { NoMatch, /// Several commands fit and no match step picked one. @@ -310,6 +320,7 @@ pub enum UnmatchedReason { /// An entry with keys that was not used, or keys of a matched entry in unlisted contexts. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Unmatched { pub item: String, pub line: Option, @@ -321,6 +332,7 @@ pub struct Unmatched { /// Keys of a matched entry that could not be read. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Unreadable { pub item: String, pub line: Option, @@ -332,6 +344,7 @@ pub struct Unreadable { /// An entry without keys that matched a command and left its shortcuts alone. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct MatchedWithoutChange { pub command: CommandRef, pub label: String, @@ -343,6 +356,7 @@ pub struct MatchedWithoutChange { /// Why a [`MatchedWithoutChange`] entry did not clear its command. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum NoChangeReason { /// The entry matched only through a panel-menu rule (a catalog path ending with its /// segments, or its last segment alone). Such a match is close enough to bind the entry's @@ -353,6 +367,7 @@ pub enum NoChangeReason { /// Likely commands for an unmatched entry, best first. Never applied. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Suggestion { pub item: String, pub line: Option, @@ -363,6 +378,7 @@ pub struct Suggestion { /// the main key of the same name. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Approximated { pub item: String, pub line: Option, @@ -375,6 +391,7 @@ pub struct Approximated { /// `line` the entry's [`ForeignEntry::line`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct ImportReport { pub set: Option, /// Commands of the set whose bindings changed. diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 1dfcb239..fff50a2e 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -26,6 +26,7 @@ pub const MAX_CONFLICTS: usize = 4096; /// What a binding does when pressed. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum Kind { #[default] Command, @@ -44,6 +45,7 @@ pub struct Binding { /// Why binding text was refused. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum BindingError { TooLong, /// The part before `": "` is not a scope name. @@ -201,6 +203,7 @@ impl Bindable { /// `Text: Cmd+B` binding is meant for typing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum TypingRule { /// Plain chords (no cmd, ctrl or alt) go to the text; function keys still fire. #[default] @@ -306,6 +309,7 @@ pub fn text_editing_chords(platform: Platform) -> &'static [Chord] { /// What a key press runs. #[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] pub enum Lookup { None, Run(CommandRef), @@ -337,6 +341,7 @@ pub struct Holder { /// The result of an assignment. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Assigned { /// Bindings removed from other commands ([`Policy::Steal`]). pub taken_from: Vec, @@ -347,6 +352,7 @@ pub struct Assigned { /// Why an assignment was refused. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum AssignError { UnknownCommand, /// The system takes this chord. @@ -379,6 +385,7 @@ impl std::error::Error for AssignError {} /// Why loading dropped part of an [`Overrides`] entry. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum DropReason { KeyTooLong, /// The value is not a list of texts. @@ -393,6 +400,7 @@ pub enum DropReason { /// Something wrong in the stored overrides. Load reports these instead of failing. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum Problem { /// No command in the catalog has this key; the entry is kept. UnknownCommand { key: String }, @@ -413,6 +421,7 @@ pub enum Problem { /// How two bindings on one chord relate. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ConflictKind { /// Only one of the two can run. Clash, @@ -423,6 +432,7 @@ pub enum ConflictKind { /// Two commands on one chord. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub struct Conflict { /// The effective trigger on the keymap's platform. pub chord: Trigger, diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index 1e185ba8..c1452e47 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -380,7 +380,8 @@ fn the_focused_panel_wins_and_tool_groups_cycle() { let hits = d.dispatch(&mut input, &km, &Active::NONE); let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")]; let trigger = Trigger::single(chord("M")); - assert_eq!(hits, [Hit { command: tools[0].clone(), cycle: tools, trigger, exact: true, pasted: None }]); + let got: Vec<_> = hits.iter().map(|h: &Hit| (&h.command, &h.cycle, h.trigger, h.exact, h.pasted.as_deref())).collect(); + assert_eq!(got, [(&tools[0], &tools, trigger, true, None)]); } #[test] diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index e1d72833..c9c67dc3 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -338,14 +338,12 @@ fn changes_that_find_the_overrides_full_are_reported() { has(&km, "view.goToPage", &["Cmd+J"]); has(&km, "file.save", &["Cmd+S"]); has(&km, "edit.duplicate", &["Cmd+Alt+Shift+D"]); - let row = |id: &str, label: &str, change, shortcuts: &[&str]| Failed { - command: id.into(), - label: label.into(), - change, - shortcuts: shortcuts.iter().map(|s| s.to_string()).collect(), + let rows = |failed: &[Failed]| -> Vec<(String, String, FailedChange, Vec)> { + failed.iter().map(|f| (f.command.id.clone(), f.label.clone(), f.change, f.shortcuts.clone())).collect() }; + let row = |id: &str, label: &str, change, shortcuts: &[&str]| (id.to_string(), label.to_string(), change, shortcuts.iter().map(|s| s.to_string()).collect::>()); assert_eq!( - r.failed, + rows(&r.failed), [ row("file.close", "Close", FailedChange::Bind, &["Cmd+J"]), row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+S"]), @@ -378,7 +376,7 @@ fn changes_that_find_the_overrides_full_are_reported() { let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); has(&km, "file.close", &["Cmd+Alt+J"]); has(&km, "file.save", &["Cmd+S"]); - assert_eq!(r.failed, [row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+J"])]); + assert_eq!(rows(&r.failed), [row("file.save", "Save", FailedChange::Bind, &["Cmd+Alt+J"])]); assert!(r.skipped.is_empty(), "{:?}", r.skipped); assert_eq!(r.imported, 1); } @@ -397,17 +395,8 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { let mut km = keymap(Platform::Windows); let r = plan(&set, &km, &options).apply(&mut km); has(&km, "file.close", &["Cmd+Q", "Text: Cmd+L"]); - assert_eq!( - r.skipped, - [Skipped { - command: "file.close".into(), - item: "File > Close".into(), - line: None, - shortcut: "Story: Cmd+K".into(), - kept_by: None, - reason: SkipReason::UnknownScope - }] - ); + let skipped: Vec<_> = r.skipped.iter().map(|s: &Skipped| (s.command.id.as_str(), s.item.as_str(), s.line, s.shortcut.as_str(), s.kept_by.clone(), s.reason)).collect(); + assert_eq!(skipped, [("file.close", "File > Close", None, "Story: Cmd+K", None, SkipReason::UnknownScope)]); assert_eq!(serde_json::to_value(r.skipped[0].reason).unwrap(), "unknownScope"); } @@ -586,7 +575,8 @@ fn report_json_uses_camel_case_names() { assert_eq!(reasons, ["noMatch", "ambiguous", "context", "tool", "noMatch"]); assert_eq!(sorted(&v["unreadable"][0]), ["item", "keys", "line", "reason"]); assert_eq!(sorted(&v["suggestions"][0]), ["candidates", "item", "line"]); - let approximated = ImportReport { approximated: plan(&numpad_set(), &km, &options).report.approximated, ..ImportReport::default() }; + let mut approximated = ImportReport::default(); + approximated.approximated = plan(&numpad_set(), &km, &options).report.approximated; let a = serde_json::to_value(&approximated).unwrap(); assert_eq!(sorted(&a["approximated"][0]), ["item", "keys", "line", "note"]); assert_eq!(a["approximated"][0]["note"], "numpad"); From 9aaf9dfd3882c12b6aab5604cc2567fc3b64127b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:46:00 +0000 Subject: [PATCH 047/124] keymap: format, and keep the dispatcher's Default clear of clippy's derive lint --- crates/keymap/src/egui.rs | 19 ++++++++++--------- crates/keymap/src/scope.rs | 5 +---- crates/keymap/tests/import.rs | 10 ++++++++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index d50797d6..c40bcea8 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -238,9 +238,12 @@ pub struct Dispatcher { text_first: bool, } +/// eframe's web build sends a press's Text event first when no text field has focus. +const TEXT_FIRST: bool = cfg!(target_arch = "wasm32"); + impl Default for Dispatcher { fn default() -> Dispatcher { - Dispatcher { last: None, repeat: false, clipboard: false, text_first: cfg!(target_arch = "wasm32") } + Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST } } } @@ -300,11 +303,8 @@ impl Dispatcher { let held = Mods::from_egui(modifiers, platform); if let Some(key) = Key::from_egui(*key) { let plain_clip = is_plain_clipboard(key, held); - let keep = if active.typing { - text_of[i].is_some() || plain_clip - } else { - !self.clipboard && plain_clip && causes[i].is_some() - }; + let keep = + if active.typing { text_of[i].is_some() || plain_clip } else { !self.clipboard && plain_clip && causes[i].is_some() }; if keep { if let Some((j, _)) = causes[i] { caused_taken[j] = Some(false); @@ -374,9 +374,10 @@ impl Dispatcher { if repeat { return find(false); } - let again = self.last.as_ref().is_some_and(|l| { - l.chord == first && !l.ran_double && l.panel.as_deref() == active.panel && now >= l.at && now - l.at < DOUBLE_PRESS - }); + let again = self + .last + .as_ref() + .is_some_and(|l| l.chord == first && !l.ran_double && l.panel.as_deref() == active.panel && now >= l.at && now - l.at < DOUBLE_PRESS); let double = if again { find(true) } else { None }; self.last = Some(LastPress { chord: first, at: now, ran_double: double.is_some(), panel: active.panel.map(str::to_owned) }); double.or_else(|| find(false)) diff --git a/crates/keymap/src/scope.rs b/crates/keymap/src/scope.rs index 26b94e49..90b29e1e 100644 --- a/crates/keymap/src/scope.rs +++ b/crates/keymap/src/scope.rs @@ -20,10 +20,7 @@ impl Scope { /// character, or one with whitespace at either end, which binding text would lose. pub fn new(name: impl Into) -> Option { let name = name.into(); - let ok = !name.is_empty() - && name.len() <= MAX_SCOPE_NAME - && name.trim() == name - && !name.chars().any(|c| c == ':' || c.is_control()); + let ok = !name.is_empty() && name.len() <= MAX_SCOPE_NAME && name.trim() == name && !name.chars().any(|c| c == ':' || c.is_control()); ok.then_some(Scope(name)) } diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index c9c67dc3..dd66bcb6 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -341,7 +341,9 @@ fn changes_that_find_the_overrides_full_are_reported() { let rows = |failed: &[Failed]| -> Vec<(String, String, FailedChange, Vec)> { failed.iter().map(|f| (f.command.id.clone(), f.label.clone(), f.change, f.shortcuts.clone())).collect() }; - let row = |id: &str, label: &str, change, shortcuts: &[&str]| (id.to_string(), label.to_string(), change, shortcuts.iter().map(|s| s.to_string()).collect::>()); + let row = |id: &str, label: &str, change, shortcuts: &[&str]| { + (id.to_string(), label.to_string(), change, shortcuts.iter().map(|s| s.to_string()).collect::>()) + }; assert_eq!( rows(&r.failed), [ @@ -395,7 +397,11 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { let mut km = keymap(Platform::Windows); let r = plan(&set, &km, &options).apply(&mut km); has(&km, "file.close", &["Cmd+Q", "Text: Cmd+L"]); - let skipped: Vec<_> = r.skipped.iter().map(|s: &Skipped| (s.command.id.as_str(), s.item.as_str(), s.line, s.shortcut.as_str(), s.kept_by.clone(), s.reason)).collect(); + let skipped: Vec<_> = r + .skipped + .iter() + .map(|s: &Skipped| (s.command.id.as_str(), s.item.as_str(), s.line, s.shortcut.as_str(), s.kept_by.clone(), s.reason)) + .collect(); assert_eq!(skipped, [("file.close", "File > Close", None, "Story: Cmd+K", None, SkipReason::UnknownScope)]); assert_eq!(serde_json::to_value(r.skipped[0].reason).unwrap(), "unknownScope"); } From 60daca37700a72aa145aaeb4670558018af962e1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:46:56 +0000 Subject: [PATCH 048/124] keymap: name the selected row to screen readers, announce conflicts and messages, and make group headers and key caps Tab stops --- crates/keymap/src/ui/keyboard.rs | 4 +++- crates/keymap/src/ui/view.rs | 29 +++++++++++++++++++++++----- crates/keymap/tests/ui_dialog.rs | 33 +++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/keymap/src/ui/keyboard.rs b/crates/keymap/src/ui/keyboard.rs index 0d0bc2a5..f01ed522 100644 --- a/crates/keymap/src/ui/keyboard.rs +++ b/crates/keymap/src/ui/keyboard.rs @@ -559,7 +559,7 @@ impl Draw<'_, '_> { } let a11y = if names.is_empty() { spoken } else { fill(self.ui("{chord}: {label}"), &[("chord", &spoken), ("label", &names.join(", "))]) }; let selected = self.state.keyboard.key == Some(key); - let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::CLICK); + let resp = ui.interact(r, self.base.with(("key", key.name())), Sense::click()); resp.widget_info(|| WidgetInfo::selected(WidgetType::Button, true, selected, &a11y)); let description = match class(holders) { Class::Free => self.ui("Not assigned"), @@ -572,6 +572,8 @@ impl Draw<'_, '_> { let v = ui.visuals(); if selected { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, v.strong_text_color()), StrokeKind::Outside); + } else if resp.has_focus() { + ui.painter().rect_stroke(r, CornerRadius::same(3), v.selection.stroke, StrokeKind::Outside); } else if resp.hovered() { ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, ink), StrokeKind::Inside); } diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 155ee2e9..57b32420 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -12,6 +12,7 @@ use craft_ui::buttons::{IconButton, IconButtonStyle}; use craft_ui::layout::{ResizeHandle, SplitAxis, SplitSize, split_rect}; use craft_ui::tabs::{self, Tab}; use egui::Galley; +use egui::accesskit::Live; use egui::text::{LayoutJob, TextFormat, TextWrapping}; use egui::{ Align, Button, Color32, ComboBox, EventFilter, Id, Key as EguiKey, Label, Layout, Modifiers, Painter, Rect, Response, RichText, ScrollArea, @@ -686,13 +687,24 @@ impl Draw<'_, '_> { action = self.rows(ui, range, shortcut_w, has_focus); }); }); + // Screen readers follow the selected row while the table has focus. + let row_id = position.filter(|p| shown.contains(p)).and_then(|p| match self.state.lines_cached().get(p) { + Some(Line::Row(r)) => Some(self.base.with(("row", &r.key))), + _ => None, + }); + if let Some(id) = row_id { + ui.ctx().accesskit_node_builder(table_id, |node| node.set_active_descendant(id.accesskit_id())); + } self.state.visible = shown; if has_focus { ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); } if let Some(a) = action { - focus.request_focus(); + // A group header is a Tab stop of its own and keeps focus. + if !matches!(a, RowAction::Toggle(_)) { + focus.request_focus(); + } match a { RowAction::Select(c) => self.state.select_row(&c), RowAction::Record(c, old, chip) => { @@ -788,9 +800,14 @@ impl Draw<'_, '_> { action } + /// A group's header; a Tab stop, toggled with Enter or Space. fn group_header(&self, ui: &mut Ui, rect: Rect, name: &str, collapsed: bool) -> Option { - let resp = ui.interact(rect, self.base.with(("group", name)), Sense::CLICK); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, name)); + let resp = ui.interact(rect, self.base.with(("group", name)), Sense::click()); + resp.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, true, name)); + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_expanded(!collapsed)); + if resp.has_focus() { + ui.painter().rect_stroke(rect, 2.0, ui.visuals().selection.stroke, StrokeKind::Inside); + } let v = ui.visuals(); let color = v.strong_text_color(); let m = self.mirror(rect); @@ -1254,7 +1271,8 @@ impl Draw<'_, '_> { fn conflict_bar(&mut self, ui: &mut Ui) { let Some(c) = self.state.conflict.clone() else { return }; let text = self.vis_chord(&c.text, &self.chord(&c.binding.trigger)); - ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(self.style.warning)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); let mut act = 0; ui.horizontal_wrapped(|ui| { if self.button(ui, true, &self.ui("Take It"), "shortcuts.conflict.take") { @@ -1289,7 +1307,8 @@ impl Draw<'_, '_> { let text = self.vis_chord(&m.text, m.chord.as_deref().unwrap_or_default()); let text = if m.refused { self.warned(text) } else { text }; let color = if m.refused { self.style.warning } else { ui.visuals().text_color() }; - ui.add(Label::new(RichText::new(text).color(color)).wrap()); + let r = ui.add(Label::new(RichText::new(text).color(color)).wrap()); + ui.ctx().accesskit_node_builder(r.id, |node| node.set_live(Live::Polite)); if m.goto.is_some() && self.button(ui, true, &self.ui("Go to Conflict"), "shortcuts.message.goto") { act = 1; } diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 64500ac2..702ab84f 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -114,8 +114,9 @@ fn has(h: &Harness<'_, Fx>, label: &str) -> bool { h.query_by_label(label).is_some() } +/// The table itself has focus (a screen reader sees its selected row, the active descendant). fn table_focused(h: &Harness<'_, Fx>) -> bool { - h.get_by_label("Commands").is_focused() + h.get_by_label("Commands").accesskit_node().is_focused_in_tree() } fn chord(text: &str, platform: Platform) -> String { @@ -359,7 +360,8 @@ fn search(h: &mut Harness<'_, Fx>, text: &str) { fn choose_area(h: &mut Harness<'_, Fx>, area: &str) { h.get_by_role_and_label(Role::ComboBox, "Show:").click(); h.run(); - h.get_by_role_and_label(Role::Button, area).click(); + // The popup's item, after a group header of the same name. + h.get_all_by_role_and_label(Role::Button, area).last().unwrap_or_else(|| panic!("{area}")).click(); h.run(); } @@ -409,7 +411,7 @@ fn areas_follow_paths_kind_and_scope() { for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] { assert!(h.get_all_by_label(area).count() >= 1, "{area}"); } - h.get_by_role_and_label(Role::Button, "Tools").click(); + h.get_all_by_role_and_label(Role::Button, "Tools").last().expect("the Tools item").click(); h.run(); assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M")); assert!(!has(&h, "Save: ⌘S")); @@ -463,6 +465,31 @@ fn table_keys_move_record_and_delete() { assert_eq!(h.state().bindings("file.saveAs"), ["F7"]); } +#[test] +fn group_headers_take_focus_and_toggle_with_enter() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + assert!(tab_to(&mut h, "File", 3)); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(!has(&h, "Export…: ⌘E"), "File is collapsed"); + assert!(h.get_by_label("File").is_focused()); + press(&mut h, Modifiers::NONE, EguiKey::Enter); + assert!(has(&h, "Export…: ⌘E")); +} + +#[test] +fn table_names_the_selected_row_and_messages_are_live() { + let mut h = mac(); + h.get_by_label("Save").click(); + h.run(); + let row = h.get_by_role_and_label(Role::Button, "Save").accesskit_node().id(); + assert_eq!(h.get_by_label("Commands").accesskit_node().active_descendant().map(|n| n.id()), Some(row)); + click(&mut h, "Export…: ⌘E"); + press(&mut h, Modifiers::COMMAND, EguiKey::S); + assert_eq!(h.get_by_label("⚠ ⌘S is used by Save").accesskit_node().live(), egui::accesskit::Live::Polite); +} + #[test] fn small_window_keeps_ok_and_long_labels_truncate() { let long = "A very long command label ".repeat(12); From eff8b5253dcbeb978a9d5bdedd2759818f07d040 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:46:56 +0000 Subject: [PATCH 049/124] keymap: run CI on craft-ui and craft-i18n changes and lint the snapshot code --- .github/workflows/keymap.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/keymap.yml b/.github/workflows/keymap.yml index 133cbe35..2e42b4ef 100644 --- a/.github/workflows/keymap.yml +++ b/.github/workflows/keymap.yml @@ -3,9 +3,9 @@ name: Keymap on: push: branches: [main] - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] pull_request: - paths: ["crates/keymap/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] + paths: ["crates/keymap/**", "crates/ui/**", "crates/i18n/**", "Cargo.toml", "Cargo.lock", ".github/workflows/keymap.yml"] workflow_dispatch: permissions: @@ -30,9 +30,10 @@ jobs: key: craft-keymap-linux - run: cargo fmt -p craft-keymap --check - run: cargo clippy -p craft-keymap --all-targets --no-default-features --locked -- -D warnings - - run: cargo clippy -p craft-keymap --all-targets --features ui --locked -- -D warnings - # Doctests run once, in the `ui` step. `snapshot` (dialog screenshots) needs a GPU and is - # left out; `ui` covers `egui`. + # `snapshot` covers `ui` and `egui` and compiles the dialog screenshots, which need a GPU + # to run, so the test steps leave them out. + - run: cargo clippy -p craft-keymap --all-targets --features snapshot --locked -- -D warnings + # Doctests run once, in the `ui` step. - name: Test core run: cargo test -p craft-keymap --no-default-features --lib --tests --locked - name: Test egui dispatch and the shortcuts dialog From f501cae73bd141eb499934befb993a906cc5c390 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:49:51 +0000 Subject: [PATCH 050/124] keymap: settle the public API before release Outcome, ConflictMode, ProfileError and Options are non_exhaustive, with Options::with for options built in one expression. Profiles keeps its fields private behind active() and sets(), lists the sets loading dropped in problems(), and stores a format version (a store without one reads as version 1). --- crates/keymap/README.md | 7 +- crates/keymap/src/lib.rs | 2 +- crates/keymap/src/profiles.rs | 200 +++++++++++++++++++++++------ crates/keymap/src/ui/mod.rs | 15 ++- crates/keymap/src/ui/state.rs | 30 ++--- crates/keymap/tests/profiles.rs | 54 ++++++-- crates/keymap/tests/settings.rs | 10 +- crates/keymap/tests/ui_dialog.rs | 14 +- crates/keymap/tests/ui_snapshot.rs | 2 +- 9 files changed, 249 insertions(+), 85 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index f5ff51eb..ec3b55a9 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -163,7 +163,8 @@ let keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overr store.update(|p| p.shortcut_sets = sets.clone()); ``` -- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`). +- `names`, `get`, `active_name`, `active_overrides` (hand them to `Keymap::set_overrides`); + `sets` (the user's sets) and `active` (the stored name, which `active_name` resolves to a set). - `select` switches sets; `duplicate(from, to)` copies a built-in or user set; `save_as`, `rename` and `delete` work on user sets only. - `store(overrides, builtins, copy_name)` saves the editor's result to the active set. Editing a @@ -173,6 +174,10 @@ store.update(|p| p.shortcut_sets = sets.clone()); - `export(name)` writes one set as JSON (`{"format":"craft-keymap","version":1,"name":…,"overrides":…}`); `import(text)` reads it into a new set. A file from a newer version is refused (`ProfileError::NewerVersion`). +- Loading never fails. `problems()` lists what loading dropped: sets with a bad or repeated name, + overrides that are neither a list nor a map, sets past `MAX_SETS`, and a `version` newer than + this crate writes. The app shows them before the next save writes the sets without them. + Stored `Profiles` carry `version = 1`; a file without it reads as version 1. - `import_plan(&plan, &keymap, name)` applies an import plan (see Importing a shortcut set) to a copy of the keymap and keeps the result as a new set, so another application's set becomes one the user can switch to and away from. diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 513df955..35c7a35f 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -157,5 +157,5 @@ pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, }; -pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles}; +pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles, SetProblem}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/src/profiles.rs b/crates/keymap/src/profiles.rs index baa4b4b9..df9634db 100644 --- a/crates/keymap/src/profiles.rs +++ b/crates/keymap/src/profiles.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use std::fmt; use serde::de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde::ser::SerializeStruct; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; @@ -34,6 +35,10 @@ pub const MAX_EXPORT_BYTES: usize = 4 << 20; const FORMAT: &str = "craft-keymap"; const VERSION: u64 = 1; +/// The version stored [`Profiles`] carry; stored profiles without one read as version 1. +const STORE_VERSION: u64 = 1; +// Load notes are kept for `Profiles::problems`; a hostile file must not grow them without bound. +const MAX_NOTES: usize = 64; const IMPORTED: &str = "Imported"; /// The app's built-in sets: name and overrides against the catalog defaults. @@ -42,6 +47,7 @@ pub type Builtins<'a> = [(&'a str, Overrides)]; /// Why a set operation was refused. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] +#[non_exhaustive] pub enum ProfileError { /// Empty, over [`MAX_SET_NAME`] chars, or with control characters. BadName, @@ -80,23 +86,51 @@ impl fmt::Display for ProfileError { impl std::error::Error for ProfileError {} +/// Something loading dropped from stored [`Profiles`]; [`Profiles::problems`] lists them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum SetProblem { + /// A set without a valid name: none, empty, over [`MAX_SET_NAME`] chars, with control + /// characters or with spaces around it. `name` is what was stored, cut to [`MAX_SET_NAME`] + /// chars. + BadName { name: String }, + /// A second set of this name; the first one stays. + Duplicate { name: String }, + /// Overrides that are neither a list nor a map. + BadOverrides { name: String }, + /// Sets past [`MAX_SETS`]. + TooMany { ignored: usize }, + /// Stored by a newer craft-keymap; what this version reads is loaded. + NewerVersion(u64), +} + /// The user's sets and which set is active. The built-in sets are passed to each call. /// -/// An `active` name that matches no set means the first built-in set (or no overrides when the app +/// An active name that matches no set means the first built-in set (or no overrides when the app /// has none). /// /// A user set may carry a built-in's name, for instance after an update adds that built-in. Until /// [`Profiles::reconcile`] renames it, the user set is the one that name reads and writes. Call /// `reconcile` after loading; every method that changes the profiles calls it first. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] +/// +/// Stored as `{ version, active, sets }`. Loading never fails: what it drops is listed by +/// [`Profiles::problems`], and the next save writes the profiles without it. +#[derive(Clone, Debug, Default)] pub struct Profiles { - #[serde(deserialize_with = "lenient_name")] - pub active: String, - #[serde(serialize_with = "sets_as_list", deserialize_with = "capped_sets")] - pub sets: BTreeMap, + active: String, + sets: BTreeMap, + notes: Vec, } +impl PartialEq for Profiles { + fn eq(&self, other: &Profiles) -> bool { + self.active == other.active && self.sets == other.sets + } +} + +impl Eq for Profiles {} + static NO_OVERRIDES: Overrides = Overrides::new(); /// A name as given, trimmed; `None` when empty, too long or with control characters. @@ -107,6 +141,22 @@ pub fn set_name(name: &str) -> Option { } impl Profiles { + /// The stored active name, which may match no set; [`active_name`](Self::active_name) is the + /// set in use. + pub fn active(&self) -> &str { + &self.active + } + + /// The user's sets. + pub fn sets(&self) -> &BTreeMap { + &self.sets + } + + /// What loading dropped. + pub fn problems(&self) -> &[SetProblem] { + &self.notes + } + /// Built-in names first, then the user's sets. pub fn names(&self, builtins: &Builtins) -> Vec { builtins.iter().map(|(n, _)| n.to_string()).chain(self.sets.keys().cloned()).collect() @@ -345,25 +395,105 @@ impl Profiles { } } +impl Serialize for Profiles { + fn serialize(&self, serializer: S) -> Result { + let mut s = serializer.serialize_struct("Profiles", 3)?; + s.serialize_field("version", &STORE_VERSION)?; + s.serialize_field("active", &self.active)?; + s.serialize_field("sets", &SetList(&self.sets))?; + s.end() + } +} + +impl<'de> Deserialize<'de> for Profiles { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize, Default)] + #[serde(default)] + struct Stored { + #[serde(deserialize_with = "lenient_version")] + version: Option, + #[serde(deserialize_with = "lenient_name")] + active: String, + #[serde(deserialize_with = "capped_sets")] + sets: Loaded, + } + let Stored { version, active, sets: Loaded { sets, mut notes, .. } } = Stored::deserialize(deserializer)?; + if let Some(v) = version.filter(|v| *v > STORE_VERSION) { + notes.insert(0, SetProblem::NewerVersion(v)); + notes.truncate(MAX_NOTES); + } + Ok(Profiles { active, sets, notes }) + } +} + fn lenient_name<'de, D: Deserializer<'de>>(deserializer: D) -> Result { let v = Value::deserialize(deserializer)?; Ok(v.as_str().and_then(set_name).unwrap_or_default()) } +fn lenient_version<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Ok(Value::deserialize(deserializer)?.as_u64()) +} + // An array of `{ name, overrides }` rather than a table, and each set's overrides in their array // form, so that craft-settings (which replaces arrays whole) drops a deleted set or entry from the // file. -fn sets_as_list(sets: &BTreeMap, serializer: S) -> Result { - #[derive(Serialize)] - struct Set<'a> { - name: &'a str, - #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] - overrides: &'a Overrides, +struct SetList<'a>(&'a BTreeMap); + +impl Serialize for SetList<'_> { + fn serialize(&self, serializer: S) -> Result { + #[derive(Serialize)] + struct Set<'a> { + name: &'a str, + #[serde(serialize_with = "crate::keymap::overrides_list::serialize")] + overrides: &'a Overrides, + } + serializer.collect_seq(self.0.iter().map(|(name, overrides)| Set { name, overrides })) } - serializer.collect_seq(sets.iter().map(|(name, overrides)| Set { name, overrides })) } -fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { +/// The sets that loaded, and notes on the ones that did not. +#[derive(Default)] +struct Loaded { + sets: BTreeMap, + notes: Vec, + ignored: usize, +} + +impl Loaded { + fn note(&mut self, problem: SetProblem) { + if self.notes.len() < MAX_NOTES { + self.notes.push(problem); + } + } + + /// Keeps a set with a valid name, stored as written, that is new and fits under [`MAX_SETS`]; + /// notes why not otherwise. + fn add(&mut self, name: &str, overrides: Option) { + let shown = || name.chars().take(MAX_SET_NAME).collect::(); + if !set_name(name).is_some_and(|n| n == name) { + self.note(SetProblem::BadName { name: shown() }); + } else if self.sets.contains_key(name) { + self.note(SetProblem::Duplicate { name: shown() }); + } else if self.sets.len() >= MAX_SETS { + self.ignored += 1; + } else if let Some(o) = overrides { + self.sets.insert(name.to_string(), o); + } else { + self.note(SetProblem::BadOverrides { name: shown() }); + } + } + + fn done(mut self) -> Loaded { + if self.ignored > 0 { + let ignored = std::mem::take(&mut self.ignored); + self.note(SetProblem::TooMany { ignored }); + } + self + } +} + +fn capped_sets<'de, D: Deserializer<'de>>(deserializer: D) -> Result { deserializer.deserialize_any(SetsVisitor) } @@ -373,13 +503,13 @@ struct SetsVisitor; macro_rules! no_sets { ($($name:ident: $t:ty),*) => { $(fn $name(self, _: $t) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) })* }; } impl<'de> Visitor<'de> for SetsVisitor { - type Value = BTreeMap; + type Value = Loaded; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a list of `{ name, overrides }` items or a map of set names to overrides") @@ -388,11 +518,11 @@ impl<'de> Visitor<'de> for SetsVisitor { no_sets!(visit_bool: bool, visit_i64: i64, visit_u64: u64, visit_i128: i128, visit_u128: u128, visit_f64: f64, visit_str: &str, visit_bytes: &[u8], visit_char: char); fn visit_unit(self) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) } fn visit_none(self) -> Result { - Ok(BTreeMap::new()) + Ok(Loaded::default()) } fn visit_some>(self, deserializer: D) -> Result { @@ -400,39 +530,29 @@ impl<'de> Visitor<'de> for SetsVisitor { } fn visit_seq>(self, mut access: A) -> Result { - let mut out = BTreeMap::new(); + let mut out = Loaded::default(); while let Some(item) = access.next_element::()? { - let Some(name) = item.get("name").and_then(Value::as_str) else { continue }; - if !admits(&out, name) { - continue; - } - if let Some(o) = item.get("overrides").and_then(overrides_of) { - out.insert(name.to_string(), o); - } + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + out.add(name, item.get("overrides").and_then(overrides_of)); } - Ok(out) + Ok(out.done()) } fn visit_map>(self, mut access: A) -> Result { - let mut out = BTreeMap::new(); + let mut out = Loaded::default(); while let Some(key) = access.next_key::()? { - if admits(&out, &key) { - if let Some(o) = overrides_of(&access.next_value::()?) { - out.insert(key, o); - } - } else { + if out.sets.len() >= MAX_SETS && !out.sets.contains_key(&key) { access.next_value::()?; + out.ignored += 1; + } else { + let overrides = overrides_of(&access.next_value::()?); + out.add(&key, overrides); } } - Ok(out) + Ok(out.done()) } } -/// A valid name, stored as written, with room under [`MAX_SETS`]. -fn admits(sets: &BTreeMap, name: &str) -> bool { - set_name(name).is_some_and(|n| n == name) && (sets.len() < MAX_SETS || sets.contains_key(name)) -} - /// A set's overrides: a list or a map, anything else is no set. fn overrides_of(v: &Value) -> Option { match v { diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index cf73737f..d0da3424 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -31,6 +31,7 @@ use state::Env; /// What the dialog reports for a frame. #[derive(Clone, Debug)] +#[non_exhaustive] pub enum Outcome { /// Closed with OK: save `Profiles` (with sets) or `keymap.overrides()`. Ok, @@ -55,6 +56,7 @@ pub enum Outcome { /// What happens when a recorded chord is held by other commands. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] pub enum ConflictMode { /// Ask with the conflict bar: Take It, Take It and Go to Conflict, Keep Both, Cancel. #[default] @@ -97,7 +99,9 @@ pub type Accept = Box Result<(), String>>; /// Called for every control once per frame with (automation id, rect, label). pub type OnWidget = Box; -/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field. +/// How the dialog looks and behaves. [`Options::default`] gives the defaults noted on each field; +/// set fields on it, or with [`Options::with`]. +#[non_exhaustive] pub struct Options { /// "Keyboard Shortcuts". pub title: &'static str, @@ -160,6 +164,15 @@ impl Default for Options { } } +impl Options { + /// `self` with `f` applied, for options built in one expression: + /// `Options::default().with(|o| o.keyboard = true)`. + pub fn with(mut self, f: impl FnOnce(&mut Options)) -> Options { + f(&mut self); + self + } +} + /// The format id of craft-keymap's own JSON in [`Outcome::Import`]. pub const JSON_FORMAT: &str = "craft-keymap"; diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 701ba546..83678353 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -1354,21 +1354,21 @@ mod tests { st.new_set(&mut fx.env(), "Mine", None); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.save_set(&mut fx.env()); - let kept = fx.p.sets["Mine"].clone(); + let kept = fx.p.sets()["Mine"].clone(); assert!(!kept.is_empty()); st.switch_set(&mut fx.env(), "Fixture Defaults"); for name in ["Mine", " mine ", "fixture defaults"] { for based_on in [None, Some("Fixture Defaults")] { st.new_set(&mut fx.env(), name, based_on); assert!(st.message.as_ref().is_some_and(|m| m.refused), "{name:?} from {based_on:?}"); - assert_eq!((fx.p.sets.len(), fx.p.active.as_str()), (1, "Fixture Defaults")); - assert_eq!(fx.p.sets["Mine"], kept); + assert_eq!((fx.p.sets().len(), fx.p.active()), (1, "Fixture Defaults")); + assert_eq!(fx.p.sets()["Mine"], kept); } } st.new_set(&mut fx.env(), "Other", None); st.rename_set(&mut fx.env(), "MINE"); assert!(st.message.as_ref().is_some_and(|m| m.refused)); - assert_eq!(fx.p.sets["Mine"], kept); + assert_eq!(fx.p.sets()["Mine"], kept); } #[test] @@ -1379,11 +1379,11 @@ mod tests { let mut st = open(&mut fx); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.new_set(&mut fx.env(), "Other", None); - assert_eq!(fx.p.active, "Other"); + assert_eq!(fx.p.active(), "Other"); st.ask_delete_set(&fx.env()); assert_eq!(st.prompt, Some(Prompt::DeleteSet("Other".into()))); st.answer(&mut fx.env(), Answer::Yes); - assert!(!fx.p.sets.contains_key("Other")); + assert!(!fx.p.sets().contains_key("Other")); st.cancel(&mut fx.env()); assert_eq!((fx.km.overrides(), &fx.p), (&km0, &p0)); } @@ -1397,13 +1397,13 @@ mod tests { st.ask_delete_set(&fx.env()); assert!(st.modal()); st.switch_set(&mut fx.env(), "Mine"); - assert_eq!((st.prompt.clone(), fx.p.active.as_str()), (None, "Mine"), "switching closes the prompt"); + assert_eq!((st.prompt.clone(), fx.p.active()), (None, "Mine"), "switching closes the prompt"); record(&mut st, &mut fx, add("object.front"), &["F2"]); st.prompt = Some(Prompt::DeleteSet("Other".into())); st.answer(&mut fx.env(), Answer::Yes); - assert!(!fx.p.sets.contains_key("Other")); - assert_eq!((fx.p.active.as_str(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); + assert!(!fx.p.sets().contains_key("Other")); + assert_eq!((fx.p.active(), fx.keys("object.front")), ("Mine", vec!["F2".to_string()]), "the active set keeps its edits"); assert!(st.can_undo()); } @@ -1419,9 +1419,9 @@ mod tests { st.ask_delete_set(&fx.env()); st.answer(&mut fx.env(), Answer::Yes); assert!(st.message.is_none(), "{:?}", st.message); - assert!(fx.p.sets.is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); + assert!(fx.p.sets().is_empty() && fx.km.overrides().is_empty() && !st.can_undo()); st.ok(&mut fx.env()); - assert!(fx.p.sets.is_empty(), "OK stores no copy of the deleted set"); + assert!(fx.p.sets().is_empty(), "OK stores no copy of the deleted set"); } #[test] @@ -1432,7 +1432,7 @@ mod tests { assert!(DialogState::is_modified(&fx.env())); st.ok(&mut fx.env()); assert!(matches!(st.finish(), Some(Outcome::Ok))); - assert_eq!(fx.p.active, "Custom"); + assert_eq!(fx.p.active(), "Custom"); assert_eq!(fx.p.get("Custom", &fx.b).and_then(|o| o.get("object.front")), Some(&["F2".to_string()][..])); } @@ -1445,7 +1445,7 @@ mod tests { st.switch_set(&mut fx.env(), "Fixture Defaults"); assert!(matches!(&st.prompt, Some(Prompt::SaveChanges { set, .. }) if set == "Mine")); st.answer(&mut fx.env(), Answer::No); - assert_eq!(fx.p.active, "Fixture Defaults"); + assert_eq!(fx.p.active(), "Fixture Defaults"); assert!(fx.km.overrides().is_empty() && !st.can_undo(), "the set loads with a fresh undo history"); assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty), "Don't Save keeps the stored set"); } @@ -1466,7 +1466,7 @@ mod tests { let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); st.import_reviewed(&mut fx.env()); - assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.active(), "Theirs"); assert_eq!(fx.p.get("Theirs", &fx.b).and_then(|o| o.get("object.front")), None); assert!(fx.p.get("Mine", &fx.b).is_some_and(Overrides::is_empty)); } @@ -1550,7 +1550,7 @@ mod tests { assert_eq!(r.map(|r| (r.name.as_str(), r.plan.commands.len(), r.plan.report.unmatched.len())), Some(("Theirs", 1, 1))); assert!(fx.km.overrides().is_empty(), "review changes nothing"); st.import_reviewed(&mut fx.env()); - assert_eq!(fx.p.active, "Theirs"); + assert_eq!(fx.p.active(), "Theirs"); assert_eq!(fx.keys("file.save"), ["Cmd+Alt+S"]); assert!(st.review.as_ref().is_some_and(|r| r.done.is_some())); st.escape(&mut fx.env()); diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 348420df..05183b11 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -1,6 +1,6 @@ use craft_keymap::{ Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, - Policy, ProfileError, Profiles, Scope, Scopes, Start, plan, + Policy, ProfileError, SetProblem, Profiles, Scope, Scopes, Start, plan, }; use serde_json::json; @@ -46,7 +46,7 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { assert_eq!(p.delete("Classic", &builtins), Err(ProfileError::BuiltIn)); assert_eq!(p.rename("Custom 3", "classic", &builtins), Err(ProfileError::NameTaken)); assert_eq!(p.rename("Custom 3", "Mine", &builtins), Ok("Mine".into())); - assert_eq!(p.active, "Mine"); + assert_eq!(p.active(), "Mine"); assert_eq!(p.save_as(&"x".repeat(65), Overrides::new(), &builtins), Err(ProfileError::BadName)); p.delete("Mine", &builtins).unwrap(); assert_eq!(p.active_name(&builtins), "Craft"); @@ -59,10 +59,10 @@ fn profiles_copy_built_ins_on_edit_and_round_trip_as_json() { let text = serde_json::to_string(&p).unwrap(); assert_eq!(serde_json::from_str::(&text).unwrap(), p); let junk: Profiles = serde_json::from_str(r#"{"active": 5, "sets": {"": {}, "ok": {"a": ["A"]}, "bad": 3}}"#).unwrap(); - assert_eq!(junk.sets.keys().collect::>(), ["ok"]); + assert_eq!(junk.sets().keys().collect::>(), ["ok"]); let junk: Profiles = serde_json::from_str(r#"{"sets": [{"name": "ok", "overrides": [{"command": "a", "keys": ["A"]}, 4]}, {"name": ""}, 7]}"#).unwrap(); - assert_eq!(junk.sets["ok"].get("a"), Some(&["A".to_string()][..])); + assert_eq!(junk.sets()["ok"].get("a"), Some(&["A".to_string()][..])); assert!(text.contains(r#""overrides":[{"command":"edit.copy","keys":[]}]"#), "{text}"); } @@ -71,7 +71,7 @@ fn rename_keeps_a_set_whose_name_differs_only_in_case() { let builtins = [("Craft", Overrides::new())]; let mut p: Profiles = serde_json::from_str(r#"{"sets": {"Mine": {"a": "A"}, "mine": {"b": "B"}}}"#).unwrap(); assert_eq!(p.rename("Mine", "mine", &builtins), Err(ProfileError::NameTaken)); - assert_eq!(p.sets["mine"].get("b"), Some(&["B".to_string()][..])); + assert_eq!(p.sets()["mine"].get("b"), Some(&["B".to_string()][..])); assert_eq!(p.rename("Mine", "MINE", &builtins), Err(ProfileError::NameTaken)); p.delete("mine", &builtins).unwrap(); assert_eq!(p.rename("Mine", "MINE", &builtins), Ok("MINE".into())); @@ -81,8 +81,8 @@ fn rename_keeps_a_set_whose_name_differs_only_in_case() { fn sets_that_are_not_a_list_or_map_load_as_none() { for text in [r#"{"active": "Mine", "sets": 5}"#, r#"{"active": "Mine", "sets": null}"#, r#"{"active": "Mine", "sets": "x"}"#] { let p: Profiles = serde_json::from_str(text).unwrap_or_else(|e| panic!("{text}: {e}")); - assert!(p.sets.is_empty(), "{text}"); - assert_eq!(p.active, "Mine", "{text}"); + assert!(p.sets().is_empty(), "{text}"); + assert_eq!(p.active(), "Mine", "{text}"); } } @@ -107,9 +107,7 @@ fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { assert_eq!(p.get("Classic", &builtins), Some(&classic)); assert_eq!(builtins[1].1, classic); - let mut q = Profiles::default(); - q.sets.insert("CRAFT".into(), mine.clone()); - q.active = "CRAFT".into(); + let mut q: Profiles = serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); assert!(q.reconcile(&builtins)); assert_eq!(q.active_name(&builtins), "CRAFT 2"); assert_eq!(q.active_overrides(&builtins), &mine); @@ -128,7 +126,7 @@ fn an_exported_set_imports_as_a_new_set_with_the_same_keymap() { let text = p.export("Mine", &builtins).unwrap(); assert_eq!(p.import(&text, &builtins), Ok("Mine 2".into())); - assert_eq!(p.active, "Mine 2"); + assert_eq!(p.active(), "Mine 2"); let mut again = keymap(); again.set_overrides(p.active_overrides(&builtins).clone()); @@ -146,14 +144,14 @@ fn duplicate_copies_built_in_and_user_sets() { let mut p = Profiles::default(); assert_eq!(p.duplicate("Classic", "Classic", &builtins), Ok("Classic 2".into())); - assert_eq!(p.active, "Classic 2"); + assert_eq!(p.active(), "Classic 2"); assert_eq!(p.active_overrides(&builtins), &classic); assert_eq!(p.duplicate("Classic 2", " Mine ", &builtins), Ok("Mine".into())); assert_eq!(p.duplicate("Mine", "mine", &builtins), Ok("mine 2".into()), "names compare without case"); assert_eq!(p.duplicate("Nope", "X", &builtins), Err(ProfileError::NotFound)); assert_eq!(p.duplicate("Craft", "", &builtins), Err(ProfileError::BadName)); - while p.sets.len() < MAX_SETS { + while p.sets().len() < MAX_SETS { p.duplicate("Craft", "Spare", &builtins).unwrap(); } assert_eq!(p.duplicate("Craft", "Spare", &builtins), Err(ProfileError::TooMany)); @@ -173,7 +171,7 @@ fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { assert_eq!(name, "Other App"); assert_eq!(report.changed.len(), 1); assert!(km.overrides().is_empty()); - assert_eq!(p.active, "Other App"); + assert_eq!(p.active(), "Other App"); let mut imported = keymap(); imported.set_overrides(p.active_overrides(&builtins).clone()); assert_eq!(imported.bindings(&"file.save".into()), [b("Cmd+Shift+S")]); @@ -181,3 +179,31 @@ fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { let (name, _) = p.import_plan(&planned, &km, Some("Craft"), &builtins).unwrap(); assert_eq!(name, "Craft 2", "a built-in's name is not reused"); } + +#[test] +fn loading_lists_the_sets_it_drops_and_the_store_has_a_version() { + let text = r#"{"version": 2, "active": "ok", "sets": [ + {"name": "ok", "overrides": {}}, {"name": " pad ", "overrides": {}}, {"name": "ok", "overrides": {"a": "A"}}, {"name": "bad", "overrides": 3} + ]}"#; + let p: Profiles = serde_json::from_str(text).unwrap(); + assert_eq!(p.sets().keys().collect::>(), ["ok"]); + assert!(p.sets()["ok"].is_empty(), "the first of two sets with one name stays"); + let name = |n: &str| n.to_string(); + assert_eq!( + p.problems(), + [ + SetProblem::NewerVersion(2), + SetProblem::BadName { name: name(" pad ") }, + SetProblem::Duplicate { name: name("ok") }, + SetProblem::BadOverrides { name: name("bad") } + ] + ); + + let many: Vec<_> = (0..MAX_SETS + 3).map(|i| json!({"name": format!("s{i}"), "overrides": {}})).collect(); + let p: Profiles = serde_json::from_value(json!({ "sets": many })).unwrap(); + assert_eq!((p.sets().len(), p.problems()), (MAX_SETS, &[SetProblem::TooMany { ignored: 3 }][..])); + + assert_eq!(serde_json::to_value(&p).unwrap()["version"], 1); + let unversioned: Profiles = serde_json::from_str(r#"{"active": "x", "sets": []}"#).unwrap(); + assert!(unversioned.problems().is_empty()); +} diff --git a/crates/keymap/tests/settings.rs b/crates/keymap/tests/settings.rs index ac76296e..2361693e 100644 --- a/crates/keymap/tests/settings.rs +++ b/crates/keymap/tests/settings.rs @@ -77,7 +77,7 @@ fn overrides_round_trip_through_craft_settings() { let problems = km.problems(); assert!(problems.contains(&Problem::Dropped { key: "edit.cut".into(), reason: DropReason::NotAList }), "{problems:?}"); assert!(problems.iter().any(|p| matches!(p, Problem::BadBinding { text, .. } if text == "Cmd+Nope")), "{problems:?}"); - assert_eq!(store.get().profiles.sets.len(), 2); + assert_eq!(store.get().profiles.sets().len(), 2); store.update(|p| p.general.language = "de".into()); store.flush().unwrap(); @@ -101,8 +101,8 @@ fn overrides_round_trip_through_craft_settings() { assert!(!line.contains("window.panel") && !text.contains("Spare"), "{text}"); let reloaded = open(tmp.path()); assert!(keymap(reloaded.get().shortcuts.clone()).bindings(&layers).is_empty()); - assert_eq!(reloaded.get().profiles.sets.keys().collect::>(), ["Mine"]); - assert_eq!(reloaded.get().profiles.sets["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); + assert_eq!(reloaded.get().profiles.sets().keys().collect::>(), ["Mine"]); + assert_eq!(reloaded.get().profiles.sets()["Mine"].get("text.bold"), Some(&["Text: Cmd+B".to_string()][..])); } #[test] @@ -140,7 +140,7 @@ fn a_sets_value_that_is_not_a_list_keeps_the_rest_of_the_profiles() { let text = format!("schema_version = 1\n\n[profiles]\nactive = \"Mine\"\nsets = {value}\n"); std::fs::write(tmp.path().join("config.toml"), text).unwrap(); let store = open(tmp.path()); - assert_eq!(store.get().profiles.active, "Mine", "{value}"); - assert!(store.get().profiles.sets.is_empty(), "{value}"); + assert_eq!(store.get().profiles.active(), "Mine", "{value}"); + assert!(store.get().profiles.sets().is_empty(), "{value}"); } } diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 702ab84f..52d6ee78 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -648,17 +648,17 @@ fn cancel_restores_keymap_and_sets() { let mut h = with_sets(p); click(&mut h, "Set:"); click_last(&mut h, "Mine"); - assert_eq!(profiles(&h).active, "Mine"); + assert_eq!(profiles(&h).active(), "Mine"); click(&mut h, "Save: ⌘S"); press(&mut h, Modifiers::NONE, EguiKey::F2); assert!(has(&h, "(modified)")); new_set(&mut h, "Other"); - assert_eq!(profiles(&h).active, "Other"); + assert_eq!(profiles(&h).active(), "Other"); assert_eq!(h.state().bindings("file.save"), ["F2"], "based on the current shortcuts"); click(&mut h, "Delete"); assert!(has(&h, "Delete the set “Other”?")); click_last(&mut h, "Delete"); - assert!(!profiles(&h).sets.contains_key("Other")); + assert!(!profiles(&h).sets().contains_key("Other")); click(&mut h, "Cancel"); assert!(h.state().canceled()); assert_eq!(profiles(&h), &before); @@ -692,8 +692,8 @@ fn ok_stores_edits_of_a_builtin_as_a_copy() { click(&mut h, "OK"); assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok))); let p = profiles(&h); - assert_eq!(p.active, "Custom"); - assert_eq!(p.sets.get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); + assert_eq!(p.active(), "Custom"); + assert_eq!(p.sets().get("Custom").and_then(|o| o.get("file.save")), Some(&["F2".to_string()][..])); } #[test] @@ -717,7 +717,7 @@ fn set_export_import_round_trip() { let (d, state) = dialog(fresh.state_mut()); assert_eq!(d.import_json(state, &json), Ok("Name 2".into())); fresh.run(); - assert_eq!(profiles(&fresh).active, "Name 2"); + assert_eq!(profiles(&fresh).active(), "Name 2"); assert_eq!(fresh.state().km.overrides(), &edited); assert_eq!(fresh.get_by_label("Set:").accesskit_node().value().as_deref(), Some("Name 2"), "the picker shows it"); } @@ -756,7 +756,7 @@ fn foreign_import_review_lists_skipped_and_unreadable() { assert!(h.query_by_label_contains("numeric keypad key read as main key").is_some()); assert!(h.state().km.overrides().is_empty(), "the review changes nothing"); click(&mut h, "Import as New Set"); - assert_eq!(profiles(&h).active, "Theirs"); + assert_eq!(profiles(&h).active(), "Theirs"); assert_eq!(h.state().bindings("file.save"), ["Cmd+Alt+S"]); assert!(has(&h, "Imported as “Theirs”")); click(&mut h, "Done"); diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index a6176af7..9b90383a 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -230,7 +230,7 @@ fn dialog_screenshots() { } (p, builtins) }); - let options = Options { keyboard: case.keyboard, ..Options::default() }; + let options = Options::default().with(|o| o.keyboard = case.keyboard); let km = if case.long { with_long_label(fixture(case.platform)) } else { fixture(case.platform) }; let text: Box = match case.words { Words::English => Box::new(English), From b778a764afcde120f6bbbc992836e2d1288924d8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:52:45 +0000 Subject: [PATCH 051/124] keymap: format the profiles test --- crates/keymap/tests/profiles.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index 05183b11..ce94abfd 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -1,6 +1,6 @@ use craft_keymap::{ Bindable, Binding, CommandRef, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, MAX_SETS, Overrides, Platform, - Policy, ProfileError, SetProblem, Profiles, Scope, Scopes, Start, plan, + Policy, ProfileError, Profiles, Scope, Scopes, SetProblem, Start, plan, }; use serde_json::json; @@ -107,7 +107,8 @@ fn a_user_set_named_like_a_new_built_in_keeps_its_edits() { assert_eq!(p.get("Classic", &builtins), Some(&classic)); assert_eq!(builtins[1].1, classic); - let mut q: Profiles = serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); + let mut q: Profiles = + serde_json::from_value(json!({"active": "CRAFT", "sets": [{"name": "CRAFT", "overrides": {"edit.copy": "Cmd+K"}}]})).unwrap(); assert!(q.reconcile(&builtins)); assert_eq!(q.active_name(&builtins), "CRAFT 2"); assert_eq!(q.active_overrides(&builtins), &mine); From 0bff6f4c9608d3e507615f1ac21146c21e5c03b9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:48:54 +0000 Subject: [PATCH 052/124] keymap ui: commands that share a default key are no conflict in the dialog The dialog's conflict count, Conflicts filter and row warnings leave out pairs whose commands both have the chord among their defaults, as Keymap::clashing does. --- crates/keymap/src/keymap.rs | 7 +++++++ crates/keymap/src/ui/rows.rs | 26 +++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index fff50a2e..8dc9fd94 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -774,6 +774,13 @@ impl Keymap { t == by.0.effective(self.platform) && !self.twins(i, j, t) && self.scopes.relation(&bx.1, &by.1) == Some(true) } + /// Whether both commands of `conflict` have its chord among their defaults, so they share it by + /// design and [`Keymap::clashing`] leaves them out. + pub(crate) fn twins_in(&self, conflict: &Conflict) -> bool { + let (Some(i), Some(j)) = (self.find(&conflict.a.0), self.find(&conflict.b.0)) else { return false }; + self.twins(i, j, conflict.chord) + } + fn find(&self, command: &CommandRef) -> Option { self.index.get(&command.key()).copied() } diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 2f85cf31..7f8658d6 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -317,7 +317,7 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu let catalog = keymap.catalog(); let mut built = Built::default(); let mut clashing: HashSet = HashSet::new(); - for c in keymap.conflicts() { + for c in keymap.conflicts().into_iter().filter(|c| !keymap.twins_in(c)) { match c.kind { ConflictKind::Clash => { built.clashes += 1; @@ -600,6 +600,30 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn commands_sharing_a_default_key_are_no_conflict() -> Result<(), crate::AssignError> { + let menu = Bindable::new("app.characterPanel", "Character", Scope::app()).with_path(vec!["Window".into(), "Character".into()]); + let catalog = vec![ + menu.with_default(key("Cmd+T")), + Bindable::new("panel.character", "Character Panel", Scope::app()).with_default(key("Cmd+T")), + Bindable::new("file.save", "Save", Scope::app()).with_default(key("Cmd+S")), + ]; + let mut km = Keymap::new(catalog, Scopes::new(), Platform::Mac); + let o = Options::default(); + let none = BTreeSet::new(); + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!((built.clashes, built.shadows), (0, 0)); + assert!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts).is_empty()); + + km.assign(&"file.save".into(), key("Cmd+T"), crate::Policy::Keep)?; + let built = build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)); + assert_eq!(built.clashes, 2, "Save clashes with both twins"); + let flagged: Vec = + built.lines.iter().filter_map(|l| if let Line::Row(r) = l { r.conflict.then(|| r.key.clone()) } else { None }).collect(); + assert_eq!(flagged, ["app.characterPanel", "panel.character", "file.save"]); + Ok(()) + } + #[test] fn fixed_rows_strike_through_when_a_command_takes_their_chord() { let km = fixture(Platform::Mac); From 02ba09ee9ace4cf2d6c59cd7d3eaa5e8c78a945d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:53:13 +0000 Subject: [PATCH 053/124] keymap ui: Options::visible hides catalog entries from the table Hidden entries keep their bindings and still count in conflicts; they are left out of the rows, search and the Show entries. --- crates/keymap/README.md | 5 +++-- crates/keymap/src/ui/mod.rs | 5 +++++ crates/keymap/src/ui/rows.rs | 41 ++++++++++++++++++++++++++++-------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 0984465b..472c2883 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -378,8 +378,9 @@ Words come from `ui::Text`. Apps on craft-i18n pass `I18nText`; others implement right-to-left languages, which mirror the layout. Chord text is never reordered. `ui::STRINGS` and `ui::PLURALS` list every string the dialog looks up, for the translation catalog. `Options` sets the conflict mode, double-press recording, area tabs, the on-screen keyboard, -read-only rows for keys outside the catalog, the app's import and export formats, and an -`accept` rule for which keys a command may get. +read-only rows for keys outside the catalog, the app's import and export formats, an `accept` +rule for which keys a command may get, and a `visible` rule for which catalog entries the table +lists (hidden entries keep their keys and still count in conflicts). ## Features diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index d0da3424..1eb67ab0 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -125,6 +125,10 @@ pub struct Options { pub area_tabs: bool, /// The app's own Show entries. pub areas: Vec, + /// `None`: every catalog entry is listed. Otherwise the table, its Show entries and search + /// list only the entries it returns true for; the others keep their bindings and still count + /// in conflicts. + pub visible: Option bool>, /// false: the on-screen keyboard pane. pub keyboard: bool, pub fixed: Vec, @@ -152,6 +156,7 @@ impl Default for Options { sort_by_label: false, area_tabs: false, areas: Vec::new(), + visible: None, keyboard: false, fixed: Vec::new(), import_formats: Vec::new(), diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 7f8658d6..11825af0 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -186,12 +186,18 @@ fn is_tool(b: &Bindable) -> bool { matches!(b.kind, Kind::Tool { .. }) } +/// The catalog entries the table lists ([`Options::visible`]). +fn listed<'k>(keymap: &'k Keymap, options: &Options) -> impl Iterator + Clone { + let visible = options.visible; + keymap.catalog().iter().filter(move |b| visible.is_none_or(|f| f(b))) +} + /// The Show entries, in catalog order, leaving out areas with no rows. pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec { - let catalog = keymap.catalog(); + let catalog = listed(keymap, options); let mut out = vec![AreaEntry { id: AreaId::All, label: text.ui("All Commands").into_owned() }]; let mut menus: Vec<&str> = Vec::new(); - for m in catalog.iter().filter_map(top_menu) { + for m in catalog.clone().filter_map(top_menu) { if !menus.contains(&m) { menus.push(m); } @@ -200,22 +206,22 @@ pub(crate) fn areas(keymap: &Keymap, options: &Options, text: &dyn Text) -> Vec< let label = craft_i18n::fmt(&text.ui("{menu} Menu"), &[("menu", &text.segment(m))]); out.push(AreaEntry { id: AreaId::Menu(m.to_string()), label }); } - if catalog.iter().any(is_tool) { + if catalog.clone().any(is_tool) { out.push(AreaEntry { id: AreaId::Tools, label: text.ui("Tools").into_owned() }); } let mut panels: Vec<&str> = keymap.scopes().panels().iter().map(String::as_str).collect(); - for b in catalog.iter().filter(|b| !b.scope.is_app()) { + for b in catalog.clone().filter(|b| !b.scope.is_app()) { if !panels.contains(&b.scope.as_str()) { panels.push(b.scope.as_str()); } } - for p in panels.into_iter().filter(|p| catalog.iter().any(|b| b.scope.as_str() == *p)) { + for p in panels.into_iter().filter(|p| catalog.clone().any(|b| b.scope.as_str() == *p)) { out.push(AreaEntry { id: AreaId::Panel(p.to_string()), label: panel_label(text, p) }); } - if catalog.iter().any(|b| top_menu(b).is_none() && !is_tool(b)) { + if catalog.clone().any(|b| top_menu(b).is_none() && !is_tool(b)) { out.push(AreaEntry { id: AreaId::Other, label: text.ui("Other").into_owned() }); } - for a in &options.areas { + for a in options.areas.iter().filter(|a| catalog.clone().any(a.rows)) { out.push(AreaEntry { id: AreaId::App(a.id), label: text.segment(a.label).into_owned() }); } if !options.fixed.is_empty() { @@ -344,14 +350,15 @@ fn build(keymap: &Keymap, options: &Options, text: &dyn Text, view: &View) -> Bu } } let submenus = match view.area { - AreaId::Menu(m) => catalog.iter().any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), + AreaId::Menu(m) => listed(keymap, options).any(|b| top_menu(b) == Some(m.as_str()) && b.paths.first().is_some_and(|p| p.len() >= 3)), _ => false, }; // Groups in first-seen order within their rank. let mut groups: Vec<(u8, Option, Vec)> = Vec::new(); for (index, (b, list)) in catalog.iter().zip(&bindings).enumerate() { - if !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { + let hidden = options.visible.is_some_and(|f| !f(b)); + if hidden || !in_area(view.area, b, options) || !query.matches(b, list, text, platform) { continue; } let key = b.command.key(); @@ -600,6 +607,22 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn hidden_entries_are_not_listed_but_still_conflict() -> Result<(), crate::AssignError> { + let mut km = fixture(Platform::Mac); + let o = Options { visible: Some(|b| b.command.id != "file.export" && !b.command.id.starts_with("timeline.")), ..Options::default() }; + let names: Vec = areas(&km, &o, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "File Menu", "Object Menu", "Tools", "Other"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::Menu("File".into()), Filter::All), ["Save: ⌘S", "Save As…: ⇧⌘S"]); + assert!(lines(&km, &o, &English, "export", AreaId::All, Filter::All).is_empty()); + + km.assign(&"file.save".into(), key("Cmd+E"), crate::Policy::Keep)?; + let none = BTreeSet::new(); + assert_eq!(build(&km, &o, &English, &view("", &AreaId::All, Filter::All, &none)).clashes, 1); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::Conflicts), ["# File", "Save: ⌘S, ⌘E"]); + Ok(()) + } + #[test] fn commands_sharing_a_default_key_are_no_conflict() -> Result<(), crate::AssignError> { let menu = Bindable::new("app.characterPanel", "Character", Scope::app()).with_path(vec!["Window".into(), "Character".into()]); From 636fc8b8504555cab7354deb8d062fa6fc88d61d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 20:59:43 +0000 Subject: [PATCH 054/124] keymap: import-only paths on Bindable Bindable::import_paths (with_import_path) are read by import matching like menu paths, and make no areas or groups in the dialog, so a tool's ["Tools", label] path no longer shows a Tools Menu entry. --- crates/keymap/README.md | 4 +++- crates/keymap/src/import.rs | 10 +++++++--- crates/keymap/src/keymap.rs | 11 +++++++++++ crates/keymap/src/ui/rows.rs | 15 +++++++++++++++ crates/keymap/tests/import.rs | 14 ++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 472c2883..b9291572 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -242,7 +242,9 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File it `in_panel_menu()`. Tool entries match only tools. - Names are compared after `import::normalize_label`: case, `…`, `...`, `&`, colons and extra spaces aside, so the alias `Preferences: General…` matches `Edit > Preferences > General...`. - Aliases (`Bindable::with_alias`) count wherever labels do. + Aliases (`Bindable::with_alias`) count wherever labels do, and import paths + (`Bindable::with_import_path`, such as `["Tools", label]` for a tool) wherever menu paths do; + the dialog makes no areas or groups from import paths. - Panel menus: sources list panel-menu items with the panel's name in front (InDesign's Panel Menus area writes `Character: All Caps`; Illustrator and Photoshop sets have similar areas). The reader splits the name into segments and marks the entry `ForeignEntry::in_panel_menu()`. diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index a387ad67..5277c9c8 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -860,7 +860,7 @@ impl Names { for name in &names { add(&mut self.anywhere, name.clone(), i); } - for path in &b.paths { + for path in b.paths.iter().chain(&b.import_paths) { let mut segs: Vec = path.iter().map(|s| normalize_label(s)).collect(); if let [_, .., last] = segs.as_mut_slice() { *last = form(last); @@ -936,7 +936,7 @@ impl Index { ix.tool.push(matches!(b.kind, Kind::Tool { .. })); ix.keys.insert(b.command.key(), i); ix.names.add(i, b, str::to_string); - ix.segments.extend(b.paths.iter().flatten().map(|s| normalize_label(s))); + ix.segments.extend(b.paths.iter().chain(&b.import_paths).flatten().map(|s| normalize_label(s))); if b.is_toggle { ix.toggles.add(i, b, toggle_name); } @@ -1000,7 +1000,11 @@ fn suggest(keymap: &Keymap, entries: &[ToSuggest]) -> Vec { let candidates: Vec = pool .iter() .map(|b| { - let labels = std::iter::once(&b.label).chain(&b.aliases).chain(b.paths.iter().filter_map(|p| p.last())).cloned().collect(); + let labels = std::iter::once(&b.label) + .chain(&b.aliases) + .chain(b.paths.iter().chain(&b.import_paths).filter_map(|p| p.last())) + .cloned() + .collect(); namematch::Candidate { id: b.command.id.clone(), labels } }) .collect(); diff --git a/crates/keymap/src/keymap.rs b/crates/keymap/src/keymap.rs index 8dc9fd94..8c026945 100644 --- a/crates/keymap/src/keymap.rs +++ b/crates/keymap/src/keymap.rs @@ -141,6 +141,10 @@ pub struct Bindable { /// Menu paths, the main one first; used by search and import. #[serde(default)] pub paths: Vec>, + /// Paths only import matching reads: where foreign sets file the command and the app's menus + /// do not, such as `["Tools", label]` for a tool. They make no areas or groups in the dialog. + #[serde(default)] + pub import_paths: Vec>, /// Other names: labels in other UI languages, older names. #[serde(default)] pub aliases: Vec, @@ -163,6 +167,7 @@ impl Bindable { command: command.into(), label: label.into(), paths: Vec::new(), + import_paths: Vec::new(), aliases: Vec::new(), scope, kind: Kind::Command, @@ -192,6 +197,12 @@ impl Bindable { self } + /// Adds a path only import matching reads ([`Bindable::import_paths`]). + pub fn with_import_path(mut self, path: Vec) -> Bindable { + self.import_paths.push(path); + self + } + pub fn with_alias(mut self, alias: impl Into) -> Bindable { self.aliases.push(alias.into()); self diff --git a/crates/keymap/src/ui/rows.rs b/crates/keymap/src/ui/rows.rs index 11825af0..6b17b67f 100644 --- a/crates/keymap/src/ui/rows.rs +++ b/crates/keymap/src/ui/rows.rs @@ -607,6 +607,21 @@ pub(crate) mod tests { Ok(()) } + #[test] + fn import_paths_make_no_areas_or_groups() { + let tool = |id: &str, label: &str| { + Bindable::new(id, label, Scope::app()) + .with_kind(Kind::Tool { group: None }) + .with_import_path(vec!["Tools".into(), label.into()]) + .with_default(key("P")) + }; + let km = Keymap::new(vec![tool("tool.pen", "Pen Tool")], Scopes::new(), Platform::Mac); + let o = Options::default(); + let names: Vec = areas(&km, &o, &English).into_iter().map(|a| a.label).collect(); + assert_eq!(names, ["All Commands", "Tools"]); + assert_eq!(lines(&km, &o, &English, "", AreaId::All, Filter::All), ["# Tools", "Pen Tool: P"]); + } + #[test] fn hidden_entries_are_not_listed_but_still_conflict() -> Result<(), crate::AssignError> { let mut km = fixture(Platform::Mac); diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index dd66bcb6..77346e14 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -676,6 +676,20 @@ fn aliases_match_like_labels() { assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); } +#[test] +fn import_paths_match_like_menu_paths() { + let catalog = vec![ + command("object.color", "Apply Color", &["Object", "Apply Color"], &[]), + command("color.applyColor", "Apply Color", &[], &[]).with_import_path(vec!["Tools".into(), "Apply Color".into()]), + ]; + let d = "Default"; + let entries = vec![win("Tools", &["Apply Color"], &[(d, "Ctrl+,")])]; + let (km, r) = import_into(catalog, entries, &default_context()); + has(&km, "color.applyColor", &["Cmd+,"]); + has(&km, "object.color", &[]); + assert!(r.unmatched.is_empty(), "{:?}", r.unmatched); +} + #[test] fn show_and_hide_are_read_off_only_for_toggles() { let catalog = vec![ From a03f6ab0c3efbad9eac65180b05c2796deaf93df Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:05:56 +0000 Subject: [PATCH 055/124] keymap ui: app errors in the dialog, and the reader's dropped entries DialogState::show_error shows an app's message under the table until the user dismisses it (a new Dismiss button on every message) or the next action replaces it. ForeignSet::truncated carries the entries the app's reader dropped; plan counts them in ImportReport::truncated, which the import review shows. --- crates/keymap/README.md | 7 ++++++- crates/keymap/src/import.rs | 9 ++++++--- crates/keymap/src/lib.rs | 1 + crates/keymap/src/ui/state.rs | 14 +++++++++++++- crates/keymap/src/ui/text.rs | 1 + crates/keymap/src/ui/view.rs | 7 +++++++ crates/keymap/tests/import.rs | 31 ++++++++++++++++++++++-------- crates/keymap/tests/profiles.rs | 6 +++++- crates/keymap/tests/ui_dialog.rs | 11 +++++++++++ crates/keymap/tests/ui_snapshot.rs | 1 + 10 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index b9291572..d8500ea1 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -226,6 +226,7 @@ let set = ForeignSet { ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; let planned = plan(&set, &keymap, &options); @@ -272,6 +273,8 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File they are reported, never applied. Only the first 200 unmatched entries with keys get them, and only the first 200 characters of a name are compared. Keys in `ContextRule::Skip` contexts do not count: an unmatched entry with only such keys counts in `unmatched_without_shortcut`. +- `truncated` counts what was cut at the caps (`MAX_IMPORT_ENTRIES` and the others), plus the + entries the app's reader dropped at its own limits, which it passes as `ForeignSet.truncated`. ## Editor @@ -364,7 +367,9 @@ Outcomes: | `Summary { name, html, text }` | Shows, saves or prints the summary. | | `Remembered(answers)` | Keeps the user's import answers for the next import (`ImportOptions::remembered`). | -The crate never opens a file dialog or touches the file system. +The crate never opens a file dialog or touches the file system. When the app's own step fails +while the dialog is open (a file it cannot read), `state.show_error(text)` shows the translated +message under the table until the user dismisses it or the next action replaces it. Key dispatch while the dialog is open: diff --git a/crates/keymap/src/import.rs b/crates/keymap/src/import.rs index 5277c9c8..560eeb88 100644 --- a/crates/keymap/src/import.rs +++ b/crates/keymap/src/import.rs @@ -34,6 +34,9 @@ pub const MAX_SUGGESTIONS: usize = 200; pub struct ForeignSet { pub name: Option, pub entries: Vec, + /// Entries the reader dropped at its own limits; [`plan`] counts them in + /// [`ImportReport::truncated`]. + pub truncated: usize, } /// One item of a foreign set. @@ -411,8 +414,8 @@ pub struct ImportReport { pub unreadable: Vec, pub approximated: Vec, pub suggestions: Vec, - /// Entries, keys, path segments, bindings and text fields cut at the caps, and unmatched - /// entries past [`MAX_SUGGESTIONS`] that got no suggestion. + /// Entries, keys, path segments, bindings and text fields cut at the caps, unmatched entries + /// past [`MAX_SUGGESTIONS`] that got no suggestion, and [`ForeignSet::truncated`]. pub truncated: usize, } @@ -435,7 +438,7 @@ fn toggle_name(s: &str) -> String { pub fn plan(set: &ForeignSet, keymap: &Keymap, options: &ImportOptions) -> ImportPlan { let mut cut = 0usize; let mut report = ImportReport { set: set.name.as_deref().map(|n| cap_text(n, &mut cut)), ..ImportReport::default() }; - cut += set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES); + cut = cut.saturating_add(set.entries.len().saturating_sub(MAX_IMPORT_ENTRIES)).saturating_add(set.truncated); let index = Index::new(keymap); let contexts: Vec<(String, &ContextRule)> = options.contexts.iter().map(|(n, r)| (n.to_lowercase(), r)).collect(); let mut commands: Vec = Vec::new(); diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index c13aab47..fb9a86a9 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -124,6 +124,7 @@ //! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12), //! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13), //! ], +//! truncated: 0, //! }; //! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; //! let planned = plan(&set, &keymap, &options); diff --git a/crates/keymap/src/ui/state.rs b/crates/keymap/src/ui/state.rs index 83678353..7b2a59c3 100644 --- a/crates/keymap/src/ui/state.rs +++ b/crates/keymap/src/ui/state.rs @@ -289,6 +289,13 @@ impl DialogState { self.field = None; } + /// Shows `text`, already translated, as a warning under the table until the user dismisses it + /// or the next action's message replaces it: for the app's own errors while the dialog is + /// open, such as an import file it could not read. + pub fn show_error(&mut self, text: impl Into) { + self.message = Some(Message { text: text.into(), refused: true, goto: None, undo: false, chord: None }); + } + // ---- Frame ---- /// The first frame: snapshot for Cancel, an edit session, the Show entries. @@ -1462,7 +1469,11 @@ mod tests { assert!(matches!(st.finish(), Some(Outcome::Import { .. }))); assert!(fx.km.overrides().is_empty() && !st.can_undo()); let read = |text: &str| ForeignKey::read(Some("Default"), text, crate::Flavor::Windows); - let set = ForeignSet { name: Some("Theirs".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))] }; + let set = ForeignSet { + name: Some("Theirs".into()), + entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S"))], + truncated: 0, + }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); st.import_reviewed(&mut fx.env()); @@ -1543,6 +1554,7 @@ mod tests { ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(3), ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(4), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() }; st.review_import(&mut fx.env(), set, options); diff --git a/crates/keymap/src/ui/text.rs b/crates/keymap/src/ui/text.rs index d248c5c5..12dd0941 100644 --- a/crates/keymap/src/ui/text.rs +++ b/crates/keymap/src/ui/text.rs @@ -235,6 +235,7 @@ pub const STRINGS: &[&str] = &[ "Keep Both", "Go to Conflict", "Cancel the new shortcut", + "Dismiss", // Saved shortcuts that could not be used. "{key}: no command has this name", "{key}: “{text}” is not a shortcut ({error})", diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 57b32420..40ac6861 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -1315,10 +1315,17 @@ impl Draw<'_, '_> { if m.undo && self.button(ui, self.state.can_undo() && !self.state.modal(), &self.ui("Undo"), "shortcuts.message.undo") { act = 2; } + let label = self.ui("Dismiss"); + let r = self.icon_button(ui, &label, false, paint_cross).on_hover_text(self.vis(&label)); + self.tag(|| "shortcuts.message.dismiss".into(), r.rect, &label); + if r.clicked() { + act = 3; + } }); match (act, &m.goto) { (1, Some(c)) => self.state.go_to(c), (2, _) => self.state.undo(self.env), + (3, _) => self.state.message = None, _ => {} } } diff --git a/crates/keymap/tests/import.rs b/crates/keymap/tests/import.rs index 77346e14..e87f89e7 100644 --- a/crates/keymap/tests/import.rs +++ b/crates/keymap/tests/import.rs @@ -110,11 +110,11 @@ fn windows_set() -> ForeignSet { entry(None, &["Somewhere", "Zoom In"], &[(d, "Ctrl+F11")], Flavor::Windows), win("Scripts", &["Frobnicate"], &[(d, "Ctrl+Alt+R")]), ]; - ForeignSet { name: Some("My Set".into()), entries } + ForeignSet { name: Some("My Set".into()), entries, truncated: 0 } } fn numpad_set() -> ForeignSet { - ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)] } + ForeignSet { name: None, entries: vec![win("Window", &["Layers"], &[("Default", "Num 7")]).at_line(2)], truncated: 0 } } fn windows_options() -> ImportOptions { @@ -262,6 +262,7 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { mac("Type", &["Style", "Italic"], &[("Text", "Shift+Cmd+I")]), mac("Type", &["Style", "Bold"], &[("Default", "Cmd+B"), ("Tables", "Cmd+Opt+B")]), ], + truncated: 0, }; let text = Scope::new("Text").unwrap(); let options = ImportOptions { @@ -282,7 +283,8 @@ fn a_mac_set_reads_its_modifiers_and_scopes_text_keys() { #[test] fn start_and_none_defined_options() { - let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])] }; + let set = + ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")]), win("Edit", &["Duplicate"], &[])], truncated: 0 }; let user = || { let mut o = Overrides::new(); o.insert("file.save", vec!["Cmd+Alt+S".into()]); @@ -318,6 +320,7 @@ fn changes_that_find_the_overrides_full_are_reported() { win("File", &["Save"], &[("Default", "Ctrl+Alt+S")]), win("Edit", &["Duplicate"], &[]), ], + truncated: 0, }; let full = || { let mut o = Overrides::new(); @@ -373,6 +376,7 @@ fn changes_that_find_the_overrides_full_are_reported() { let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Alt+J")]), win("File", &["Save"], &[("Default", "Ctrl+Alt+J")])], + truncated: 0, }; let mut km = keymap(Platform::Windows).with_overrides(full()); let r = plan(&set, &km, &options(Start::Current)).apply(&mut km); @@ -385,7 +389,11 @@ fn changes_that_find_the_overrides_full_are_reported() { #[test] fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { - let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])] }; + let set = ForeignSet { + name: None, + entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q"), ("Story", "Ctrl+K"), ("Text", "Ctrl+L")])], + truncated: 0, + }; let options = ImportOptions { contexts: vec![ ("Default".into(), ContextRule::Home), @@ -409,7 +417,7 @@ fn keys_in_unknown_scopes_are_skipped_and_the_rest_apply() { #[test] fn suggestions_are_reported_never_applied() { let mut km = keymap(Platform::Windows); - let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])] }; + let set = ForeignSet { name: None, entries: vec![win("Edit", &["Chek Spelling Now"], &[("Default", "Ctrl+K")])], truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let p = plan(&set, &km, &options); assert!(p.commands.is_empty()); @@ -422,6 +430,12 @@ fn suggestions_are_reported_never_applied() { assert_eq!(r.imported, 0); } +#[test] +fn entries_the_reader_dropped_count_as_truncated() { + let set = ForeignSet { name: None, entries: vec![win("File", &["Close"], &[("Default", "Ctrl+Q")])], truncated: 7 }; + assert_eq!(plan(&set, &keymap(Platform::Windows), &default_context()).report.truncated, 7); +} + #[test] fn hostile_sets_are_capped_and_counted() { let mut km = keymap(Platform::Windows); @@ -439,7 +453,7 @@ fn hostile_sets_are_capped_and_counted() { // Entries without keys fill the set to the cap and 500 past it. let keyless = MAX_IMPORT_ENTRIES - entries.len(); entries.extend(std::iter::repeat_n(ForeignEntry::new(None, Vec::::new()), keyless + 500)); - let set = ForeignSet { name: Some(long.clone()), entries }; + let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); assert_eq!(r.suggestions.len(), MAX_SUGGESTIONS); @@ -482,7 +496,7 @@ fn hostile_sets_finish_in_time() { } entries.insert(0, big); entries.insert(1, win("Edit", &["Paste"], &[("Default", &long), ("Default", "Ctrl+\u{0}"), ("Default", "Win+E")])); - let set = ForeignSet { name: Some(long.clone()), entries }; + let set = ForeignSet { name: Some(long.clone()), entries, truncated: 0 }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let started = std::time::Instant::now(); let r = plan(&set, &km, &options).apply(&mut km); @@ -515,6 +529,7 @@ fn rows_carry_source_lines_and_approximated_keys() { win("Edit", &["Chek Spelling Now"], &[(d, "Ctrl+K")]).at_line(8), win("File", &["Save"], &[(d, "Ctrl+Hyperspace")]).at_line(9), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![(d.into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let r = plan(&set, &km, &options).apply(&mut km); @@ -591,7 +606,7 @@ fn report_json_uses_camel_case_names() { fn import_into(catalog: Vec, entries: Vec, options: &ImportOptions) -> (Keymap, ImportReport) { let mut km = Keymap::new(catalog, Scopes::new(), Platform::Windows); - let report = plan(&ForeignSet { name: None, entries }, &km, options).apply(&mut km); + let report = plan(&ForeignSet { name: None, entries, truncated: 0 }, &km, options).apply(&mut km); (km, report) } diff --git a/crates/keymap/tests/profiles.rs b/crates/keymap/tests/profiles.rs index ce94abfd..d9ce152c 100644 --- a/crates/keymap/tests/profiles.rs +++ b/crates/keymap/tests/profiles.rs @@ -162,7 +162,11 @@ fn duplicate_copies_built_in_and_user_sets() { fn a_foreign_set_imports_as_a_new_set_and_leaves_the_keymap_alone() { let km = keymap(); let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Mac); - let set = ForeignSet { name: Some("Other App".into()), entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))] }; + let set = ForeignSet { + name: Some("Other App".into()), + entries: vec![ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Cmd+Shift+S"))], + truncated: 0, + }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], start: Start::Defaults, ..ImportOptions::default() }; let planned = plan(&set, &km, &options); let builtins = [("Craft", Overrides::new())]; diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 52d6ee78..d393a609 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -733,6 +733,7 @@ fn foreign_set() -> ForeignSet { ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), ], + truncated: 0, } } @@ -988,3 +989,13 @@ fn pseudo_locale_marks_every_string_and_fits() { assert!(unlisted.borrow().is_empty(), "looked up but not in STRINGS or PLURALS: {:?}", unlisted.borrow()); } } + +#[test] +fn an_app_error_shows_until_dismissed() { + let mut h = mac(); + h.state_mut().state.show_error("The file could not be read"); + h.run(); + assert!(has(&h, "⚠ The file could not be read")); + click(&mut h, "Dismiss"); + assert!(!has(&h, "⚠ The file could not be read")); +} diff --git a/crates/keymap/tests/ui_snapshot.rs b/crates/keymap/tests/ui_snapshot.rs index 9b90383a..ba27ab05 100644 --- a/crates/keymap/tests/ui_snapshot.rs +++ b/crates/keymap/tests/ui_snapshot.rs @@ -126,6 +126,7 @@ fn foreign_review(h: &mut Harness<'_, Fx>) { ForeignEntry::new(Some("Object"), ["Arrange", "Bring to Front"]).with_key(read("Num 7")).at_line(6), ForeignEntry::new(Some("File"), ["Sve Az"]).with_key(read("Ctrl+F9")).at_line(7), ], + truncated: 0, }; let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], suggest: true, ..ImportOptions::default() }; let (d, state) = h.state_mut().dialog(); From 1aef62823a4a3466d2289b97142ee8bc7d486a30 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:06:07 +0000 Subject: [PATCH 056/124] keymap ui: no command id in the row tooltip; say who draws the title The detail pane already shows the command id. Options::title is drawn by show(); with ui() the host's container draws its own title. --- crates/keymap/src/ui/mod.rs | 7 ++++--- crates/keymap/src/ui/view.rs | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/keymap/src/ui/mod.rs b/crates/keymap/src/ui/mod.rs index 1eb67ab0..866b1024 100644 --- a/crates/keymap/src/ui/mod.rs +++ b/crates/keymap/src/ui/mod.rs @@ -103,7 +103,8 @@ pub type OnWidget = Box; /// set fields on it, or with [`Options::with`]. #[non_exhaustive] pub struct Options { - /// "Keyboard Shortcuts". + /// "Keyboard Shortcuts": the window title [`ShortcutsDialog::show`] draws, and the summary's + /// heading. [`ShortcutsDialog::ui`] draws no title; the host's container shows its own. pub title: &'static str, /// true; false shows the table read-only with Close and Summarize. pub editable: bool, @@ -251,8 +252,8 @@ impl<'a> ShortcutsDialog<'a> { }) } - /// The body only, for a host's own modal, preferences page or backstage. It fills the - /// available rectangle. + /// The body only, for a host's own modal, preferences page or backstage, which draws the + /// title. It fills the available rectangle. pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option { self.with_env(|env| frame(ui, env, state)) } diff --git a/crates/keymap/src/ui/view.rs b/crates/keymap/src/ui/view.rs index 40ac6861..ae8ba041 100644 --- a/crates/keymap/src/ui/view.rs +++ b/crates/keymap/src/ui/view.rs @@ -960,7 +960,6 @@ impl Draw<'_, '_> { if let Some(path) = b.paths.first().filter(|p| !p.is_empty()) { tip.push(self.path_text(path)); } - tip.push(r.command.id.clone()); if let Some(h) = self.env.text.hint(b) { tip.push(self.vis(&h)); } From 5a8f119a70d59c050b98bde02a0a998f9ff37985 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:30:40 +0000 Subject: [PATCH 057/124] keymap ui: test that one pointer click on another row's chip records --- crates/keymap/tests/ui_dialog.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index d393a609..35c69914 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -999,3 +999,19 @@ fn an_app_error_shows_until_dismissed() { click(&mut h, "Dismiss"); assert!(!has(&h, "⚠ The file could not be read")); } + +#[test] +fn one_pointer_click_on_a_chip_of_another_row_records() { + let mut h = mac(); + click(&mut h, "Save"); + let c = h.get_by_label("Export…: ⌘E").rect().center(); + let at = egui::pos2(c.x as f32, c.y as f32); + h.event(Event::PointerMoved(at)); + h.run(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run(); + assert!(has(&h, "Export…: press keys")); +} From f9a65e813f5cb7986fc80b11bc0abfd410f1e224 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 21:42:25 +0000 Subject: [PATCH 058/124] keymap ui: the chip-click test uses the node's rect as is --- crates/keymap/tests/ui_dialog.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/keymap/tests/ui_dialog.rs b/crates/keymap/tests/ui_dialog.rs index 35c69914..751d60ed 100644 --- a/crates/keymap/tests/ui_dialog.rs +++ b/crates/keymap/tests/ui_dialog.rs @@ -1004,8 +1004,7 @@ fn an_app_error_shows_until_dismissed() { fn one_pointer_click_on_a_chip_of_another_row_records() { let mut h = mac(); click(&mut h, "Save"); - let c = h.get_by_label("Export…: ⌘E").rect().center(); - let at = egui::pos2(c.x as f32, c.y as f32); + let at = h.get_by_label("Export…: ⌘E").rect().center(); h.event(Event::PointerMoved(at)); h.run(); for pressed in [true, false] { From 903dbd5fa342c4df1cd9fe81982b0440ae007f4b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:38:22 +0000 Subject: [PATCH 059/124] i18n: scripts, direction and regional Han per language tag scripts(tag) lists the ISO 15924 scripts a language's interface text is written in, direction(tag) follows the main script, and han(tag) gives the regional Han forms (SC, TC, HK, JP, KR) for picking a CJK face. I18n::rtl uses direction. --- crates/i18n/README.md | 8 +- crates/i18n/src/lib.rs | 11 +- crates/i18n/src/scripts.rs | 363 +++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 crates/i18n/src/scripts.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..cde3621d 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -60,8 +60,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `plural_form_counts` holds the same counts. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". -- **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and - `yi`; putting text into display order is the app's job. +- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right + to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional + forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ + `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the + language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..84295a16 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod scripts; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use scripts::{Direction, Han, Script, direction, han, scripts}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; @@ -199,12 +202,10 @@ impl I18n { self.locale(lang).and_then(|l| l.rows.get("@id")?.get("language.name")).map_or_else(|| self.code(lang), String::as_str) } - /// Whether the language is written right to left. Putting text into display order is the - /// app's job. + /// Whether the language is written right to left ([`direction`]). Putting text into display + /// order is the app's job. pub fn rtl(&self, lang: Lang) -> bool { - let code = self.code(lang); - let language = code.split('-').next().unwrap_or(code); - matches!(language, "ar" | "ckb" | "dv" | "fa" | "he" | "ps" | "sd" | "ug" | "ur" | "yi") + direction(self.code(lang)) == Direction::Rtl } /// The language for a system locale tag (`fr_CA.UTF-8`, `zh-Hant-MO`); `C` and `POSIX` are diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs new file mode 100644 index 00000000..ecd2f8dd --- /dev/null +++ b/crates/i18n/src/scripts.rs @@ -0,0 +1,363 @@ +//! The scripts a language is written in, its direction, and which regional forms its Han +//! characters take, for picking fonts and laying out the interface. + +/// A writing system, by its ISO 15924 code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Script { + Arab, + Armn, + Beng, + Cyrl, + Deva, + Ethi, + Geor, + Grek, + Gujr, + Guru, + Hang, + Hani, + Hebr, + Hira, + Kana, + Khmr, + Knda, + Laoo, + Latn, + Mlym, + Mong, + Mymr, + Orya, + Sinh, + Syrc, + Taml, + Telu, + Thaa, + Thai, + Tibt, +} + +impl Script { + /// The ISO 15924 code, `Latn`. + pub fn code(self) -> &'static str { + match self { + Script::Arab => "Arab", + Script::Armn => "Armn", + Script::Beng => "Beng", + Script::Cyrl => "Cyrl", + Script::Deva => "Deva", + Script::Ethi => "Ethi", + Script::Geor => "Geor", + Script::Grek => "Grek", + Script::Gujr => "Gujr", + Script::Guru => "Guru", + Script::Hang => "Hang", + Script::Hani => "Hani", + Script::Hebr => "Hebr", + Script::Hira => "Hira", + Script::Kana => "Kana", + Script::Khmr => "Khmr", + Script::Knda => "Knda", + Script::Laoo => "Laoo", + Script::Latn => "Latn", + Script::Mlym => "Mlym", + Script::Mong => "Mong", + Script::Mymr => "Mymr", + Script::Orya => "Orya", + Script::Sinh => "Sinh", + Script::Syrc => "Syrc", + Script::Taml => "Taml", + Script::Telu => "Telu", + Script::Thaa => "Thaa", + Script::Thai => "Thai", + Script::Tibt => "Tibt", + } + } + + /// Whether text in this script runs right to left. + pub fn rtl(self) -> bool { + matches!(self, Script::Arab | Script::Hebr | Script::Syrc | Script::Thaa) + } +} + +/// Which way a language's text, and an interface in it, runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Direction { + Ltr, + Rtl, +} + +/// The regional glyph forms Han characters take, as in the Noto CJK face names (SC, TC, HK, JP, +/// KR). The same code point is drawn differently in each, so a font stack picks its Han face by +/// this, not by script. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Han { + /// Simplified Chinese, mainland China and Singapore (SC). + Simplified, + /// Traditional Chinese, Taiwan (TC). + Traditional, + /// Traditional Chinese, Hong Kong and Macau (HK). + HongKong, + /// Japanese kanji (JP). + Japanese, + /// Korean hanja (KR). + Korean, +} + +/// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → +/// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A +/// script subtag wins over the language's usual script. Latin product names and units appear in +/// every language, so Latn is listed only where it is the language's own script. A language not +/// in the table is taken to be written in Latin. +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +pub fn scripts(tag: &str) -> &'static [Script] { + use Script::*; + let tag = Subtags::parse(tag); + if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { + return s; + } + match tag.language.as_str() { + "ja" => &[Hani, Hira, Kana], + "ko" => &[Hang, Hani], + "zh" | "yue" | "wuu" => &[Hani], + "be" | "bg" | "ba" | "ce" | "cv" | "kk" | "ky" | "mk" | "mn" | "ru" | "sah" | "sr" | "tg" | "tt" | "uk" => &[Cyrl], + "el" => &[Grek], + "ar" | "ckb" | "fa" | "ks" | "ps" | "sd" | "ug" | "ur" => &[Arab], + "he" | "yi" => &[Hebr], + "syr" => &[Syrc], + "dv" => &[Thaa], + "ka" => &[Geor], + "hy" => &[Armn], + "hi" | "mr" | "ne" | "sa" | "kok" | "mai" | "bho" => &[Deva], + "bn" | "as" | "mni" => &[Beng], + "pa" => &[Guru], + "gu" => &[Gujr], + "or" => &[Orya], + "ta" => &[Taml], + "te" => &[Telu], + "kn" => &[Knda], + "ml" => &[Mlym], + "si" => &[Sinh], + "th" => &[Thai], + "lo" => &[Laoo], + "km" => &[Khmr], + "my" => &[Mymr], + "am" | "ti" => &[Ethi], + "bo" | "dz" => &[Tibt], + _ => &[Latn], + } +} + +/// The direction of a language tag's text: right to left when its main script is. +pub fn direction(tag: &str) -> Direction { + match scripts(tag).first() { + Some(s) if s.rtl() => Direction::Rtl, + _ => Direction::Ltr, + } +} + +/// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → +/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; +/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the +/// choice to the font stack. +pub fn han(tag: &str) -> Option { + let tag = Subtags::parse(tag); + let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); + match (tag.language.as_str(), tag.script.as_deref()) { + ("ja", _) => Some(Han::Japanese), + ("ko", _) => Some(Han::Korean), + ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), + ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. + ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), + ("zh" | "wuu", _) => Some(match tag.region.as_deref() { + Some("tw") => Han::Traditional, + _ if hong_kong => Han::HongKong, + _ => Han::Simplified, + }), + _ => None, + } +} + +/// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for +/// one not in [`Script`], so the language's own scripts apply. +fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match subtag { + "hans" | "hant" | "hani" => &[Hani], + "jpan" => &[Hani, Hira, Kana], + "kore" => &[Hang, Hani], + "arab" => &[Arab], + "armn" => &[Armn], + "beng" => &[Beng], + "cyrl" => &[Cyrl], + "deva" => &[Deva], + "ethi" => &[Ethi], + "geor" => &[Geor], + "grek" => &[Grek], + "gujr" => &[Gujr], + "guru" => &[Guru], + "hang" => &[Hang], + "hebr" => &[Hebr], + "khmr" => &[Khmr], + "knda" => &[Knda], + "laoo" => &[Laoo], + "latn" => &[Latn], + "mlym" => &[Mlym], + "mong" => &[Mong], + "mymr" => &[Mymr], + "orya" => &[Orya], + "sinh" => &[Sinh], + "syrc" => &[Syrc], + "taml" => &[Taml], + "telu" => &[Telu], + "thaa" => &[Thaa], + "thai" => &[Thai], + "tibt" => &[Tibt], + _ => return None, + }) +} + +/// A language tag's language, script and region subtags, lowercase. +struct Subtags { + language: String, + script: Option, + region: Option, +} + +impl Subtags { + /// `zh-Hant-HK`, `zh_TW.UTF-8` or `sr_RS@latin` (glibc's script modifier). + fn parse(tag: &str) -> Subtags { + let tag = tag.trim().to_ascii_lowercase(); + let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or("").to_owned(); + let mut script = match tag.split_once('@').map(|(_, m)| m) { + Some("latin") => Some("latn".to_owned()), + Some("cyrillic") => Some("cyrl".to_owned()), + _ => None, + }; + let mut region = None; + for part in parts { + if part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) { + script.get_or_insert_with(|| part.to_owned()); + } else if super::is_region(part) { + region = Some(part.to_owned()); + break; + } + } + Subtags { language, script, region } + } +} + +#[cfg(test)] +mod tests { + use super::Script::*; + use super::*; + + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. + #[test] + fn shipped_locales() { + for (tag, want, han_forms) in [ + ("en", &[Latn][..], None), + ("ar", &[Arab], None), + ("bg", &[Cyrl], None), + ("cs", &[Latn], None), + ("da", &[Latn], None), + ("de", &[Latn], None), + ("el", &[Grek], None), + ("es", &[Latn], None), + ("es-419", &[Latn], None), + ("et", &[Latn], None), + ("fi", &[Latn], None), + ("fr", &[Latn], None), + ("hr", &[Latn], None), + ("hu", &[Latn], None), + ("id", &[Latn], None), + ("it", &[Latn], None), + ("ja", &[Hani, Hira, Kana], Some(Han::Japanese)), + ("ka", &[Geor], None), + ("kk", &[Cyrl], None), + ("ko", &[Hang, Hani], Some(Han::Korean)), + ("lt", &[Latn], None), + ("nl", &[Latn], None), + ("pl", &[Latn], None), + ("pt", &[Latn], None), + ("pt-br", &[Latn], None), + ("pt_BR", &[Latn], None), + ("ru", &[Cyrl], None), + ("sl", &[Latn], None), + ("sr", &[Cyrl], None), + ("sr-latn", &[Latn], None), + ("sv", &[Latn], None), + ("te", &[Telu], None), + ("tr", &[Latn], None), + ("uk", &[Cyrl], None), + ("vi", &[Latn], None), + ("zh", &[Hani], Some(Han::Simplified)), + ("zh-cn", &[Hani], Some(Han::Simplified)), + ("zh-CN", &[Hani], Some(Han::Simplified)), + ("zh-hans", &[Hani], Some(Han::Simplified)), + ("zh-Hans", &[Hani], Some(Han::Simplified)), + ("zh-hant", &[Hani], Some(Han::Traditional)), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + assert_eq!(han(tag), han_forms, "{tag}"); + let rtl = tag == "ar"; + assert_eq!(direction(tag), if rtl { Direction::Rtl } else { Direction::Ltr }, "{tag}"); + } + } + + #[test] + fn regional_han() { + for (tag, want) in [ + ("zh-TW", Han::Traditional), + ("zh_TW.UTF-8", Han::Traditional), + ("zh-Hant-TW", Han::Traditional), + ("zh-HK", Han::HongKong), + ("zh-MO", Han::HongKong), + ("zh-Hant-HK", Han::HongKong), + ("zh-hant-hk", Han::HongKong), + ("zh-Hans-HK", Han::Simplified), + ("zh-SG", Han::Simplified), + ("zh-Hans-SG", Han::Simplified), + ("yue", Han::HongKong), + ("yue-Hans", Han::Simplified), + ("ja-JP", Han::Japanese), + ("ko_KR.UTF-8", Han::Korean), + ] { + assert_eq!(han(tag), Some(want), "{tag}"); + } + assert_eq!(han("en"), None); + assert_eq!(han(""), None); + } + + #[test] + fn script_subtag_wins() { + for (tag, want) in [ + ("sr-Latn-RS", &[Latn][..]), + ("sr_RS@latin", &[Latn]), + ("sr-Cyrl", &[Cyrl]), + ("sr_RS.UTF-8", &[Cyrl]), + ("az-Cyrl", &[Cyrl]), + ("pa-Arab", &[Arab]), + ("pa", &[Guru]), + ("mn-Mong", &[Mong]), + ("sd-Deva", &[Deva]), + ("fr-Zyyy", &[Latn]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-Arab"), Direction::Rtl); + assert_eq!(direction("sd-Deva"), Direction::Ltr); + assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); + } + + /// The usual scripts tag matching relies on agree with the table. + #[test] + fn agrees_with_usual_script() { + for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + let usual = crate::usual_script(language).and_then(subtag_scripts); + assert_eq!(usual, Some(scripts(language)), "{language}"); + } + } +} From e896187c6c3a88c759035edaf4e6900f358bdc20 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:40:00 +0000 Subject: [PATCH 060/124] i18n: isolate inserted values in fmt and trn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt wraps each value in FSI…PDI and trn wraps {n} in LRI…PDI, so a value's direction can't reorder the message around it. ltr() isolates numbers and paths left to right; a value that already is one isolate goes in unchanged, and a stray PDI can't close the isolate early. fmt_plain and strip_isolates are for text that leaves the interface. --- crates/i18n/README.md | 7 ++ crates/i18n/src/lib.rs | 142 +++++++++++++++++++++++++++++++++++------ 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..a34ce183 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -49,6 +49,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. +- **Isolation:** `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` + wraps `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, or a + Hebrew name in an English one, can't reorder the words around it. Wrap numbers, paths and codes + in `ltr` before passing them; a value that already is one isolate goes in as it is, and a stray + PDI in a value can't close its isolate early. egui draws the marks as nothing. For text that + leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or + `strip_isolates`. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case) are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..a394aef5 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -158,8 +158,9 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in. `one` and `other` are the English forms; - /// they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as a left-to-right isolate ([`ltr`]). `one` + /// and `other` are the English forms; they are also the key of the `@plural` row, and the + /// fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); @@ -175,7 +176,7 @@ impl I18n { forms.split('|').nth(index) }) .unwrap_or(if n == 1 { one } else { other }); - let text = fmt(text, &[("n", &n.to_string())]); + let text = fmt(text, &[("n", <r(&n.to_string()))]); if lang == Lang::PSEUDO { return marked(&text); } @@ -261,9 +262,88 @@ impl I18n { } } -/// Fill `{name}` placeholders from `args` in one pass. Unknown placeholders stay as written, and -/// inserted values are never read as templates, so a file name containing `{n}` stays intact. +/// Fill `{name}` placeholders from `args` in one pass, each value wrapped in a first-strong +/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name +/// in an Arabic message stays where the translator put it. A value that already is one isolate +/// goes in as it is; pass numbers, paths and codes through [`ltr`]. Unknown placeholders stay as +/// written, and inserted values are never read as templates, so a file name containing `{n}` +/// stays intact. pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { + fill(template, args, true) +} + +/// [`fmt`] without isolates, for text that goes where the marks would get in the way: file +/// names, logs, terminals, text compared or parsed later. +pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { + fill(template, args, false) +} + +const LRI: char = '\u{2066}'; +const RLI: char = '\u{2067}'; +const FSI: char = '\u{2068}'; +const PDI: char = '\u{2069}'; + +/// `value` as a first-strong isolate (FSI…PDI): it takes the direction of its first strong +/// letter and can't reorder the text around it. What [`fmt`] does to each value. +pub fn isolate(value: &str) -> String { + wrap(FSI, value) +} + +/// `value` as a left-to-right isolate (LRI…PDI), for numbers, paths, file sizes, timecodes and +/// codes, which read left to right in every language. In an RTL first-strong isolate, the +/// segments of `/home/مستخدم/file.txt` would run right to left. +pub fn ltr(value: &str) -> String { + wrap(LRI, value) +} + +/// `text` without isolate marks (LRI, RLI, FSI, PDI): a [`fmt`] or [`I18n::trn`] result made fit +/// for a file name, a log or a comparison. +pub fn strip_isolates(text: &str) -> String { + text.chars().filter(|c| !matches!(*c, LRI | RLI | FSI | PDI)).collect() +} + +/// `value` between `initiator` and PDI. A stray PDI in the value would close the isolate early, so +/// it is dropped; isolates the value leaves open are closed. +fn wrap(initiator: char, value: &str) -> String { + let mut out = String::with_capacity(value.len() + 6); + out.push(initiator); + let mut open = 0_usize; + for c in value.chars() { + match c { + LRI | RLI | FSI => open += 1, + PDI if open == 0 => continue, + PDI => open -= 1, + _ => {} + } + out.push(c); + } + for _ in 0..=open { + out.push(PDI); + } + out +} + +/// Whether `value` is exactly one isolate: an initiator, then the PDI that closes it last. +fn is_one_isolate(value: &str) -> bool { + let mut depth = 0_usize; + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + LRI | RLI | FSI => depth += 1, + PDI if depth > 0 => { + depth -= 1; + if depth == 0 { + return chars.peek().is_none(); + } + } + _ if depth == 0 => return false, + _ => {} + } + } + false +} + +fn fill(template: &str, args: &[(&str, &str)], isolated: bool) -> String { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some((before, after_open)) = rest.split_once('{') { @@ -274,7 +354,8 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { break; }; match args.iter().find(|(k, _)| *k == name) { - Some((_, value)) => out.push_str(value), + Some((_, value)) if !isolated || value.is_empty() || is_one_isolate(value) => out.push_str(value), + Some((_, value)) => out.push_str(&isolate(value)), None => { out.push('{'); out.push_str(name); @@ -485,17 +566,42 @@ mod tests { #[test] fn fmt_fills_once() { - assert_eq!(fmt("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); + assert_eq!(fmt_plain("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); assert_eq!(fmt("{z} {", &[]), "{z} {"); } + #[test] + fn inserted_values_are_isolated() { + let saved = fmt("تم حفظ {name} في {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/سارة/docs"))]); + assert_eq!(saved, "تم حفظ \u{2068}Report 2.pdf\u{2069} في \u{2066}/home/سارة/docs\u{2069}."); + assert_eq!(strip_isolates(&saved), "تم حفظ Report 2.pdf في /home/سارة/docs."); + assert_eq!(fmt_plain("{a}", &[("a", "x")]), "x"); + assert_eq!(fmt("[{a}]", &[("a", "")]), "[]"); + } + + #[test] + fn a_value_cannot_escape_its_isolate() { + assert_eq!(isolate("a\u{2069}b"), "\u{2068}ab\u{2069}"); + assert_eq!(isolate("a\u{2067}b"), "\u{2068}a\u{2067}b\u{2069}\u{2069}"); + let one = ltr("12"); + assert_eq!(fmt("{n}", &[("n", &one)]), one); + // Two isolates side by side are not one, so they get wrapped. + assert_eq!(fmt("{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); + } + + #[test] + fn counts_are_left_to_right_isolates() { + let i = all(P).0; + assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} page", "{n} pages"), "\u{2066}2\u{2069} pages"); + } + #[test] fn one_other() { let (i, problems) = all(P); assert!(problems.is_empty(), "{problems:?}"); let fr = lang(&i, "fr"); - assert_eq!(i.trn(fr, 0, "{n} page", "{n} pages"), "0 page"); - assert_eq!(i.trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); + assert_eq!(strip_isolates(&i.trn(fr, 0, "{n} page", "{n} pages")), "0 page"); + assert_eq!(strip_isolates(&i.trn(fr, 2, "{n} page", "{n} pages")), "2 pages"); } #[test] @@ -503,27 +609,27 @@ mod tests { let i = all(P).0; let ru = lang(&i, "ru"); for (n, want) in [(1, "1 файл"), (3, "3 файла"), (5, "5 файлов"), (11, "11 файлов"), (21, "21 файл")] { - assert_eq!(i.trn(ru, n, "{n} file", "{n} files"), want); + assert_eq!(strip_isolates(&i.trn(ru, n, "{n} file", "{n} files")), want); } } #[test] fn one_form_ja() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files"), "7 個"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files")), "7 個"); } #[test] fn english_fallback() { let i = all(P).0; - assert_eq!(i.trn(Lang::EN, 1, "{n} file", "{n} files"), "1 file"); - assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs"), "2 xs"); + assert_eq!(strip_isolates(&i.trn(Lang::EN, 1, "{n} file", "{n} files")), "1 file"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs")), "2 xs"); } #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages")), "1000000 pages"); } static L: &Embedded = @@ -590,7 +696,7 @@ mod tests { assert_eq!(i.t(Lang::PSEUDO, "A\nB"), "⟦A⟧\n⟦B⟧"); assert_eq!(i.tc(Lang::PSEUDO, "menu", "Open"), "⟦Open⟧"); assert_eq!(i.tid(Lang::PSEUDO, "file.save", "Save"), "⟦Save⟧"); - assert_eq!(i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs"), "⟦2 xs⟧"); + assert_eq!(strip_isolates(&i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs")), "⟦2 xs⟧"); assert!(!i.languages().contains(&Lang::PSEUDO)); assert_eq!(i.from_code("pseudo"), cfg!(debug_assertions).then_some(Lang::PSEUDO)); assert_eq!(i.get(Lang::PSEUDO, "key", "Esc"), Some("⟦Esc⟧")); @@ -669,9 +775,9 @@ mod tests { #[test] fn european_portuguese_zero_is_plural() { let i = all(PT).0; - assert_eq!(i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files"), "0 ficheiros"); - assert_eq!(i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files"), "1 ficheiro"); - assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files")), "0 ficheiros"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files")), "1 ficheiro"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files")), "0 arquivo"); } /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog From ce70b2ede876a87f2abc8af95e39e6ce801fd224 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:41:41 +0000 Subject: [PATCH 061/124] i18n: formatting region apart from the interface language Region holds the number and time locales, metric or US measurement, A4 or Letter, the decimal separator, and the grouping separator and clock when the system sets them. Region::system reads Windows' regional format with the user's changes, Apple's current locale, the POSIX LC_* variables each on their own, and the preferred language on the web and Android. Region::from_setting takes an app's saved choice ("auto" or a tag). Ported from WordCraft's Region (storytold/wordcraft#857, #1494). Co-authored-by: Dmitrii Khlapov Co-authored-by: Brandon Thomas --- Cargo.lock | 2 + crates/i18n/Cargo.toml | 8 + crates/i18n/README.md | 10 + crates/i18n/src/lib.rs | 3 + crates/i18n/src/region.rs | 464 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 crates/i18n/src/region.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..11eebe64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,8 +657,10 @@ name = "craft-i18n" version = "0.1.0" dependencies = [ "intl_pluralrules", + "objc2-foundation 0.3.2", "sys-locale", "unic-langid", + "windows-registry", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775..bfff93ce 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -15,5 +15,13 @@ sys-locale = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } +# The regional format (Region::system); safe registry reads, MIT OR Apache-2.0. +[target.'cfg(windows)'.dependencies] +windows-registry = "0.6" + +# The current locale (Region::system); already in the apps' Cargo.lock through eframe. +[target.'cfg(target_vendor = "apple")'.dependencies] +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] } + [lints] workspace = true diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..d916bbbb 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -68,6 +68,16 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. +- **Formatting region:** `Region` holds how numbers, times and measurements are written, apart + from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 + or Letter, the decimal separator, and the grouping separator and clock when the system sets + them. `Region::system()` reads it once: Windows' regional format with the user's changes + (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), + `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and `LC_PAPER` each on its own over + `LANG` on Unix, the preferred language on the web and Android. `Region::from_setting` takes + the app's saved choice (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's + territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. Ported from WordCraft's + `Region`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..9e407419 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod region; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use region::{HourCycle, Measurement, Paper, Region}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs new file mode 100644 index 00000000..f9bb4720 --- /dev/null +++ b/crates/i18n/src/region.rs @@ -0,0 +1,464 @@ +//! The formatting region: how numbers, times and measurements are written, apart from the +//! interface language. An English interface in Poland still writes `2,54 cm` on A4. +//! +//! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): +//! `paperSize` US-Letter and `measurementSystem` US. The decimal separators follow CLDR's number +//! symbols for the common locales; the system's own separator wins where the platform has one. + +use std::sync::OnceLock; + +/// Territories whose default paper is US Letter (CLDR `paperSize type="US-Letter"`). +const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", "PH", "PR", "SV", "US", "VE"]; + +/// Territories that measure in inches (CLDR `measurementSystem type="US"`). +const INCHES: &[&str] = &["LR", "MM", "US"]; + +/// Languages whose numbers use a decimal comma in most of their territories. +const COMMA_LANGUAGES: &[&str] = &[ + "af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it", "ka", + "kk", "ky", "lb", "lt", "lv", "mk", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk", "uz", "vi", +]; + +/// Territories where those languages use a decimal point after all. +const POINT_EXCEPTIONS: &[(&str, &[&str])] = + &[("de", &["CH", "LI"]), ("it", &["CH"]), ("es", &["CU", "DO", "GT", "HN", "MX", "NI", "PA", "PE", "PR", "SV", "US", "419"])]; + +/// Territories where English is written with a decimal comma: most of continental Europe, South +/// Africa and much of South America. +const COMMA_TERRITORIES: &[&str] = &[ + "AR", "AT", "BE", "BG", "BO", "BR", "CL", "CO", "CZ", "DE", "DK", "EC", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "ID", "IS", "IT", "LT", "LU", + "LV", "NL", "NO", "PL", "PT", "PY", "RO", "RS", "RU", "SE", "SI", "SK", "TR", "UA", "UY", "VE", "VN", "ZA", +]; + +/// How lengths, distances and weights are measured. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Measurement { + /// Centimetres, kilometres and kilograms (CLDR `metric` and `UK`). + Metric, + /// Inches, miles and pounds (CLDR `US`). + Us, +} + +/// The paper a new document starts on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Paper { + A4, + Letter, +} + +/// A clock the user chose, where the system says so apart from the locale. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum HourCycle { + /// 1 to 12 with AM and PM. + H12, + /// 0 to 23. + H23, +} + +/// The conventions numbers, times and measurements follow. Read it with [`Region::system`] or, +/// for the user's saved choice, [`Region::from_setting`]; the fields are public so an app can +/// keep a separate choice of, say, measurement. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Region { + /// The locale numbers are written in, BCP 47 (`pl-PL`, `sr-Latn-RS`). + pub numbers: String, + /// The locale dates and times are written in, BCP 47. + pub time: String, + pub measurement: Measurement, + pub paper: Paper, + /// The decimal separator (`,` in Poland). + pub decimal: char, + /// The grouping separator the system sets (Windows, macOS); `None` follows the locale. + pub grouping: Option, + /// The clock the system sets (Windows, macOS); `None` follows the locale. + pub hour_cycle: Option, +} + +impl Default for Region { + /// `en-US`: decimal point, Letter, inches. + fn default() -> Self { + Region::from_parts(&Tag::default()) + } +} + +impl Region { + /// The region a BCP 47 or POSIX locale tag names (`pl-PL`, `de_AT.UTF-8@euro`, `zh-Hans-CN`, + /// `en-US-u-ms-metric`, Apple's `en_US@rg=plzzzz`). The Unicode extensions `ms` (measurement), + /// `hc` (clock) and `rg` (region override) are read. An empty or unreadable tag, `C` and + /// `POSIX` are `en-US`; a bare `en` is the United States, any other bare language has no + /// territory. + pub fn from_tag(tag: &str) -> Region { + Region::from_parts(&Tag::parse(tag).unwrap_or_default()) + } + + /// The region for an app's saved region setting: `""` or `auto` (any case) is + /// [`Region::system`], anything else a locale tag for [`Region::from_tag`]. + pub fn from_setting(setting: &str) -> Region { + let setting = setting.trim(); + if setting.is_empty() || setting.eq_ignore_ascii_case("auto") { Region::system() } else { Region::from_tag(setting) } + } + + /// The system's region, read once, then cached: + /// + /// - Windows: the regional format and its customised separators, measurement system, paper + /// and short time format (Settings › Time & language › Language & region). + /// - macOS and iOS: the current locale (its identifier carries the Region setting), its + /// separators and metric setting, and its hour format. + /// - Linux and the BSDs: `LC_ALL`, else each of `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and + /// `LC_PAPER` on its own, else `LANG`. + /// - The web and Android: the preferred language, the only locale they expose. + pub fn system() -> Region { + static SYSTEM: OnceLock = OnceLock::new(); + SYSTEM.get_or_init(read_system).clone() + } + + fn from_parts(tag: &Tag) -> Region { + let territory = tag.territory.as_str(); + let language = tag.language.as_str(); + let measurement = tag.measurement.unwrap_or(if INCHES.contains(&territory) { Measurement::Us } else { Measurement::Metric }); + let decimal = if COMMA_LANGUAGES.contains(&language) { + if POINT_EXCEPTIONS.iter().any(|(l, ts)| *l == language && ts.contains(&territory)) { '.' } else { ',' } + } else if language == "en" && COMMA_TERRITORIES.contains(&territory) { + ',' + } else { + '.' + }; + Region { + numbers: tag.bcp47(), + time: tag.bcp47(), + measurement, + paper: if LETTER.contains(&territory) { Paper::Letter } else { Paper::A4 }, + decimal, + grouping: None, + hour_cycle: tag.hour_cycle, + } + } +} + +/// The parts of a locale tag the region uses. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tag { + /// Lower case (`pl`). + language: String, + /// Title case (`Latn`); empty when the tag has none. + script: String, + /// Upper case (`PL`, or a UN M.49 number like `419`); empty when the tag has none. + territory: String, + measurement: Option, + hour_cycle: Option, +} + +impl Default for Tag { + fn default() -> Self { + Tag { language: "en".into(), script: String::new(), territory: "US".into(), measurement: None, hour_cycle: None } + } +} + +impl Tag { + fn parse(tag: &str) -> Option { + // Locale tags are short; anything long is junk and only its start is read. + let tag: String = tag.chars().take(64).collect(); + let (tag, modifier) = tag.split_once('@').unwrap_or((&tag, "")); + let tag = tag.split('.').next().unwrap_or_default().trim(); + if tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { + return None; + } + let mut parts = tag.split(['-', '_']); + let language = parts.next().unwrap_or_default(); + if !(2..=3).contains(&language.len()) || !language.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + let mut out = + Tag { language: language.to_ascii_lowercase(), script: String::new(), territory: String::new(), measurement: None, hour_cycle: None }; + let mut extension = None; + for p in parts.by_ref() { + // A singleton starts the extensions (`-u-…`, `-x-…`). + if p.len() == 1 { + extension = Some(p.to_ascii_lowercase()); + break; + } + if out.territory.is_empty() && out.script.is_empty() && p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) { + out.script = title_case(p); + } else if out.territory.is_empty() && is_territory(p) { + out.territory = p.to_ascii_uppercase(); + } + } + let mut keywords: Vec<(String, String)> = Vec::new(); + if extension.as_deref() == Some("u") { + let mut key = String::new(); + for p in parts { + if p.len() == 1 { + break; + } + if p.len() == 2 { + key = p.to_ascii_lowercase(); + } else if !key.is_empty() { + keywords.push((std::mem::take(&mut key), p.to_ascii_lowercase())); + } + } + } + // Apple's and ICU's `@rg=plzzzz;ms=metric` keywords, and glibc's `@latin` script modifier. + for m in modifier.split(';') { + match m.split_once('=') { + Some((k, v)) => keywords.push((k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())), + None if m.eq_ignore_ascii_case("latin") => out.script = "Latn".into(), + None if m.eq_ignore_ascii_case("cyrillic") => out.script = "Cyrl".into(), + None => {} + } + } + for (key, value) in keywords { + match (key.as_str(), value.as_str()) { + ("ms", "metric" | "uksystem") => out.measurement = Some(Measurement::Metric), + ("ms", "ussystem") => out.measurement = Some(Measurement::Us), + ("hc", "h11" | "h12") => out.hour_cycle = Some(HourCycle::H12), + ("hc", "h23" | "h24") => out.hour_cycle = Some(HourCycle::H23), + ("rg", v) if v.len() == 6 && v.ends_with("zzzz") && v.get(..2).is_some_and(is_territory) => { + out.territory = v.get(..2).unwrap_or_default().to_ascii_uppercase(); + } + _ => {} + } + } + if out.territory.is_empty() && out.language == "en" { + out.territory = "US".into(); + } + Some(out) + } + + fn bcp47(&self) -> String { + [self.language.as_str(), &self.script, &self.territory].into_iter().filter(|p| !p.is_empty()).collect::>().join("-") + } +} + +fn is_territory(p: &str) -> bool { + (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())) +} + +fn title_case(p: &str) -> String { + let lower = p.to_ascii_lowercase(); + let mut chars = lower.chars(); + chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default() +} + +/// A locale variable that names a locale (not empty, `C` or `POSIX`). +#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] +fn usable_locale(v: &str) -> bool { + let v = v.trim(); + !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") +} + +/// The region from the POSIX locale variables `var` reads and, when neither `LC_ALL` nor the +/// variable for a convention nor `LANG` is set, the platform's preferred locale. +#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] +fn from_posix(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { + let var = |name: &str| var(name).filter(|v| usable_locale(v)); + if let Some(all) = var("LC_ALL") { + return Region::from_tag(&all); + } + let lang = var("LANG").or_else(platform).unwrap_or_default(); + let tag = |name: &str| var(name).unwrap_or_else(|| lang.clone()); + let time = Region::from_tag(&tag("LC_TIME")); + Region { + time: time.time, + hour_cycle: time.hour_cycle, + measurement: Region::from_tag(&tag("LC_MEASUREMENT")).measurement, + paper: Region::from_tag(&tag("LC_PAPER")).paper, + ..Region::from_tag(&tag("LC_NUMERIC")) + } +} + +/// The region from the values under `HKEY_CURRENT_USER\Control Panel\International` that `value` +/// reads, where Windows keeps the regional format and the user's changes to it. `LocaleName` is +/// the format (`pl-PL`), else the platform's preferred locale. +#[cfg(any(test, windows))] +fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { + let mut region = Region::from_tag(&value("LocaleName").or_else(platform).unwrap_or_default()); + if let Some(c) = value("sDecimal").as_deref().and_then(single_char) { + region.decimal = c; + } + if let Some(s) = value("sThousand") { + region.grouping = single_char(&s); + } + match value("iMeasure").as_deref().map(str::trim) { + Some("0") => region.measurement = Measurement::Metric, + Some("1") => region.measurement = Measurement::Us, + _ => {} + } + // DMPAPER_LETTER and DMPAPER_A4; other sizes keep the territory's. + match value("iPaperSize").as_deref().map(str::trim) { + Some("1") => region.paper = Paper::Letter, + Some("9") => region.paper = Paper::A4, + _ => {} + } + region.hour_cycle = value("sShortTime").as_deref().and_then(hour_cycle_of).or(region.hour_cycle); + region +} + +/// The region from Apple's current locale: its identifier (`en_PL`, `en_GB@rg=uszzzz`), its +/// metric setting and separators, and the hour format its `j` skeleton gives (`HH`, `h a`). +#[cfg(any(test, target_vendor = "apple"))] +fn from_apple(identifier: &str, metric: bool, decimal: &str, grouping: &str, hours: Option<&str>) -> Region { + let mut region = Region::from_tag(identifier); + region.measurement = if metric { Measurement::Metric } else { Measurement::Us }; + if let Some(c) = single_char(decimal) { + region.decimal = c; + } + region.grouping = single_char(grouping); + region.hour_cycle = hours.and_then(hour_cycle_of).or(region.hour_cycle); + region +} + +/// A separator that is one character; Windows allows up to three, which the region can't carry. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn single_char(s: &str) -> Option { + let mut chars = s.chars(); + let c = chars.next()?; + chars.next().is_none().then_some(c) +} + +/// The clock a time pattern (`HH:mm`, `h:mm tt`, `h 'Uhr'`) uses; quoted literals are skipped. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn hour_cycle_of(pattern: &str) -> Option { + let mut quoted = false; + for c in pattern.chars() { + match c { + '\'' => quoted = !quoted, + 'H' | 'k' if !quoted => return Some(HourCycle::H23), + 'h' | 'K' if !quoted => return Some(HourCycle::H12), + _ => {} + } + } + None +} + +#[cfg(windows)] +fn read_system() -> Region { + let key = windows_registry::CURRENT_USER.open(r"Control Panel\International").ok(); + from_windows(|name| key.as_ref()?.get_string(name).ok(), sys_locale::get_locale) +} + +#[cfg(target_vendor = "apple")] +fn read_system() -> Region { + use objc2_foundation::{NSDateFormatter, NSLocale, NSString}; + let locale = NSLocale::currentLocale(); + let hours = NSDateFormatter::dateFormatFromTemplate_options_locale(&NSString::from_str("j"), 0, Some(&locale)).map(|f| f.to_string()); + from_apple( + &locale.localeIdentifier().to_string(), + locale.usesMetricSystem(), + &locale.decimalSeparator().to_string(), + &locale.groupingSeparator().to_string(), + hours.as_deref(), + ) +} + +#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))] +fn read_system() -> Region { + from_posix(|name| std::env::var(name).ok(), sys_locale::get_locale) +} + +#[cfg(not(any(windows, unix)))] +fn read_system() -> Region { + Region::from_tag(&sys_locale::get_locale().unwrap_or_default()) +} + +#[cfg(target_os = "android")] +fn read_system() -> Region { + Region::from_tag(&sys_locale::get_locale().unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn conventions(r: &Region) -> (Paper, Measurement, char) { + (r.paper, r.measurement, r.decimal) + } + + #[test] + fn reads_locale_tags() { + assert_eq!(Region::from_tag("pl-PL").numbers, "pl-PL"); + assert_eq!(Region::from_tag("de_AT.UTF-8@euro").numbers, "de-AT"); + assert_eq!(Region::from_tag("zh-hans-cn").numbers, "zh-Hans-CN"); + assert_eq!(Region::from_tag("sr_RS@latin").numbers, "sr-Latn-RS"); + assert_eq!(Region::from_tag("es-419").numbers, "es-419"); + assert_eq!(Region::from_tag("fr").numbers, "fr"); + assert_eq!(Region::from_tag("en").numbers, "en-US", "bare English is the US"); + for junk in ["", "C", "POSIX", "C.UTF-8", "1234", "x", "--", "ünïcode-ÄÄ", &"a".repeat(10_000)] { + assert_eq!(Region::from_tag(junk), Region::default(), "{junk:?}"); + } + } + + #[test] + fn unicode_extensions_and_apple_keywords() { + let r = Region::from_tag("en-US-u-ms-metric-hc-h23"); + assert_eq!((r.numbers.as_str(), r.measurement, r.hour_cycle), ("en-US", Measurement::Metric, Some(HourCycle::H23))); + assert_eq!(Region::from_tag("en-GB-u-hc-h12").hour_cycle, Some(HourCycle::H12)); + assert_eq!(Region::from_tag("de-DE-x-ms-ussystem").measurement, Measurement::Metric, "private use isn't read"); + let r = Region::from_tag("en_GB@rg=uszzzz"); + assert_eq!((r.numbers.as_str(), r.paper, r.measurement), ("en-US", Paper::Letter, Measurement::Us)); + assert_eq!(Region::from_tag("en_US@rg=plzzzz;ms=metric").numbers, "en-PL"); + } + + /// Paper and measurement follow the territory, numbers the language and territory. + #[test] + fn conventions_follow_the_territory() { + assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us, '.')); + assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-DE")), (Paper::A4, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric, '.')); + assert_eq!(Region::from_tag("de-CH").decimal, '.'); + assert_eq!(Region::from_tag("es-MX").paper, Paper::Letter); + assert_eq!(Region::from_tag("ja-JP").paper, Paper::A4); + } + + #[test] + fn a_saved_setting_overrides_the_system() { + assert_eq!(Region::from_setting(" pl-PL "), Region::from_tag("pl-PL")); + assert_eq!(Region::from_setting("AUTO"), Region::system()); + assert_eq!(Region::from_setting(""), Region::system()); + } + + #[test] + fn posix_variables_set_each_convention() { + let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); + let none = || None; + let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_NUMERIC", "pl_PL.UTF-8"), ("LC_TIME", "en_GB.UTF-8"), ("LC_PAPER", "de_DE")]), none); + assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", ',')); + assert_eq!((r.paper, r.measurement), (Paper::A4, Measurement::Us), "paper from LC_PAPER, measurement from LANG"); + assert_eq!(from_posix(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US"), ("LC_PAPER", "de_DE")]), none).paper, Paper::Letter); + assert_eq!(from_posix(env(&[("LANG", "en_US"), ("LC_MEASUREMENT", "it_IT")]), none).measurement, Measurement::Metric); + assert_eq!(from_posix(env(&[("LANG", "C.UTF-8"), ("LC_ALL", "")]), || Some("it-IT".into())).numbers, "it-IT"); + assert_eq!(from_posix(env(&[]), none), Region::default()); + } + + #[test] + fn windows_regional_format_and_its_changes() { + let reg = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); + let r = from_windows( + reg(&[("LocaleName", "pl-PL"), ("sDecimal", ","), ("sThousand", "\u{a0}"), ("iMeasure", "0"), ("sShortTime", "HH:mm")]), + || None, + ); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", ',', Some('\u{a0}'), Some(HourCycle::H23))); + // An English format in the US with the user's metric and A4 choices and a decimal comma. + let r = from_windows( + reg(&[("LocaleName", "en-US"), ("sDecimal", ","), ("iMeasure", "0"), ("iPaperSize", "9"), ("sShortTime", "h:mm tt")]), + || None, + ); + assert_eq!((r.decimal, r.measurement, r.paper, r.hour_cycle), (',', Measurement::Metric, Paper::A4, Some(HourCycle::H12))); + let r = from_windows(reg(&[("sDecimal", "dec"), ("sThousand", "")]), || Some("de-DE".into())); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping), ("de-DE", ',', None), "a separator longer than one character keeps the locale's"); + } + + #[test] + fn apple_locale_values() { + let r = from_apple("en_PL", true, ",", "\u{a0}", Some("HH")); + assert_eq!( + (r.numbers.as_str(), r.paper, r.decimal, r.grouping, r.hour_cycle), + ("en-PL", Paper::A4, ',', Some('\u{a0}'), Some(HourCycle::H23)) + ); + let r = from_apple("en_US", false, ".", ",", Some("h a")); + assert_eq!((r.measurement, r.hour_cycle), (Measurement::Us, Some(HourCycle::H12))); + assert_eq!(from_apple("de_DE", true, ",", ".", Some("H 'Uhr'")).hour_cycle, Some(HourCycle::H23)); + assert_eq!(hour_cycle_of("'h' mm"), None, "quoted letters are text"); + } +} From 90a5999a1dfb1b94722bb7557e0c79bf898e79d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:44:16 +0000 Subject: [PATCH 062/124] i18n: parse_number for typed and pasted numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads any Unicode decimal digits, the locale's decimal mark plus ٫, grouping by the locale's other mark, spaces, apostrophes or ٬ (threes or Indian 2-2-3), and -/−/+ signs, ignoring bidi marks. Input that fits no single reading is an error, not a guess. --- crates/i18n/README.md | 6 + crates/i18n/src/lib.rs | 3 + crates/i18n/src/number.rs | 316 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 crates/i18n/src/number.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..6bb44dc2 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -62,6 +62,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and `yi`; putting text into display order is the app's job. +- **Reading numbers:** `parse_number(text, locale)` reads what people type or paste: any Unicode + decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one + set; the locale's decimal mark (`.` or `,`) and `٫` everywhere; grouping by the locale's other + mark, spaces (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, + `−` or `+`; bidi marks ignored. Input that doesn't fit is an error, never a guess: `1,234` is + 1234 in English, 1.234 in German and an error for a locale whose decimal mark isn't known. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..4ceaf2c2 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod number; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use number::{NumberError, parse_number}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs new file mode 100644 index 00000000..dfeb2e74 --- /dev/null +++ b/crates/i18n/src/number.rs @@ -0,0 +1,316 @@ +//! Reading numbers people type or paste, in any digits and with their locale's separators. + +use std::fmt; + +/// Why [`parse_number`] rejected a text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumberError { + /// No digits: empty, or only spaces and a sign. + Empty, + /// A character that isn't a digit, sign, decimal mark or grouping mark. + Unexpected(char), + /// Digits from more than one set (`1٢`). + MixedDigits, + /// A decimal or grouping mark where the locale puts none (`1,5` in English, `1.5` in German), + /// or one that could be read either way (`1,234` when the locale's decimal mark is unknown). + Separator, + /// Too large for an `f64`. + TooLarge, +} + +impl fmt::Display for NumberError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NumberError::Empty => write!(f, "no digits"), + NumberError::Unexpected(c) => write!(f, "unexpected character {c:?}"), + NumberError::MixedDigits => write!(f, "digits from more than one script"), + NumberError::Separator => write!(f, "misplaced or ambiguous decimal or grouping mark"), + NumberError::TooLarge => write!(f, "number too large"), + } + } +} + +impl std::error::Error for NumberError {} + +/// The number in `text`, as someone using `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes +/// it. +/// +/// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, +/// full-width, …), all from one set. +/// - Decimal mark: the locale's `.` or `,`; `٫` in every locale. A locale this crate doesn't know +/// takes either, when only one reading fits (`1,5`, `1.234,5`). +/// - Grouping: the locale's other mark of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, `’` +/// and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). Never in +/// the fraction. +/// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. +/// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a number +/// formatted for an RTL interface reads back. +/// +/// Anything else is an error rather than a guess: `1,234` is 1234 in English, 1.234 in German and +/// [`NumberError::Separator`] for an unknown locale. +pub fn parse_number(text: &str, locale: &str) -> Result { + let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); + let mut rest = text.trim(); + let negative = match rest.chars().next() { + Some(c @ ('-' | '−' | '-' | '+' | '+')) => { + rest = rest.get(c.len_utf8()..).unwrap_or(""); + matches!(c, '-' | '−' | '-') + } + _ => false, + }; + let mut tokens = Vec::with_capacity(rest.len()); + let mut zero = None; + for c in rest.chars() { + let token = match c { + '.' | '.' => Token::Point, + ',' | ',' => Token::Comma, + '٫' => Token::Decimal, + '٬' => Token::Group('٬'), + '\'' | '’' => Token::Group('\''), + ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), + _ => { + let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; + if *zero.get_or_insert(z) != z { + return Err(NumberError::MixedDigits); + } + Token::Digit(d) + } + }; + tokens.push(token); + } + if zero.is_none() { + return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); + } + let digits = match decimal_mark(locale) { + Some(point) => read(&tokens, point), + None => match (read(&tokens, true), read(&tokens, false)) { + (Some(a), Some(b)) if a != b => None, + (a, b) => a.or(b), + }, + } + .ok_or(NumberError::Separator)?; + let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; + if !value.is_finite() { + return Err(NumberError::TooLarge); + } + Ok(if negative { -value } else { value }) +} + +#[derive(Clone, Copy, PartialEq)] +enum Token { + Digit(u8), + Point, + Comma, + /// `٫`, a decimal mark in every locale. + Decimal, + /// A grouping mark in every locale, by kind (space, apostrophe, `٬`). + Group(char), +} + +/// The ASCII number `tokens` spell when the decimal mark is `.` (`point`) or `,`; `None` when the +/// marks don't fit that reading. +fn read(tokens: &[Token], point: bool) -> Option { + let tokens: Vec = tokens + .iter() + .map(|t| match t { + Token::Point if point => Token::Decimal, + Token::Point => Token::Group('.'), + Token::Comma if point => Token::Group(','), + Token::Comma => Token::Decimal, + t => *t, + }) + .collect(); + let (int, frac) = match tokens.iter().position(|t| *t == Token::Decimal) { + Some(i) => (tokens.get(..i)?, tokens.get(i + 1..)?), + None => (&tokens[..], &[][..]), + }; + let ascii = + |part: &[Token]| -> Option { part.iter().map(|t| if let Token::Digit(d) = t { Some(char::from(b'0' + d)) } else { None }).collect() }; + let frac = ascii(frac)?; + let mut group = None; + let mut groups = Vec::new(); + for chunk in int.split(|t| matches!(t, Token::Group(_))) { + groups.push(ascii(chunk)?); + } + for t in int { + if let Token::Group(g) = t + && *group.get_or_insert(*g) != *g + { + return None; + } + } + if let [first, middle @ .., last] = groups.as_slice() + && group.is_some() + { + let width = middle.first().map_or(3, String::len); + let first_max = if width == 2 { 2 } else { 3 }; + let fits = (1..=first_max).contains(&first.len()) + && !first.starts_with('0') + && last.len() == 3 + && middle.iter().all(|g| g.len() == width) + && (2..=3).contains(&width); + if !fits { + return None; + } + } + let int = groups.concat(); + if int.is_empty() && frac.is_empty() { + return None; + } + Some(format!("{}.{}", if int.is_empty() { "0" } else { &int }, if frac.is_empty() { "0" } else { &frac })) +} + +/// Whether `locale` writes decimals with `.` (`Some(true)`) or `,` (`Some(false)`); `None` for a +/// language not in the table (CLDR's decimal symbol for its Latin digits). +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +fn decimal_mark(locale: &str) -> Option { + let locale = locale.trim().to_ascii_lowercase(); + let base = locale.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or(""); + let region = parts.find(|p| super::is_region(p)).unwrap_or(""); + Some(match (language, region) { + ("de" | "it", "ch" | "li") => true, + ("es", "419" | "mx" | "us" | "pr" | "do" | "gt" | "hn" | "ni" | "pa" | "pe" | "sv") => true, + ("en", "za" | "150" | "at" | "be" | "de" | "dk" | "fi" | "nl" | "se" | "si") => false, + ("ar", "dz" | "eh" | "ly" | "ma" | "tn") => false, + ( + "af" | "az" | "be" | "bg" | "bs" | "ca" | "cs" | "da" | "de" | "el" | "eo" | "es" | "et" | "eu" | "fi" | "fo" | "fr" | "fy" | "gl" | "hr" + | "hu" | "hy" | "id" | "is" | "it" | "ka" | "kk" | "km" | "ky" | "lb" | "lo" | "lt" | "lv" | "mk" | "nb" | "nl" | "nn" | "no" | "pl" + | "pt" | "ro" | "ru" | "sk" | "sl" | "sq" | "sr" | "sv" | "tg" | "tk" | "tr" | "tt" | "uk" | "uz" | "vi", + _, + ) => false, + ( + "am" | "ar" | "as" | "bn" | "bo" | "ckb" | "cy" | "dv" | "en" | "fa" | "fil" | "ga" | "gsw" | "gu" | "he" | "hi" | "ja" | "kn" | "ko" + | "ks" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "ne" | "or" | "pa" | "ps" | "rm" | "sd" | "si" | "sw" | "ta" | "te" | "th" | "tl" + | "ug" | "ur" | "yue" | "zh" | "zu", + _, + ) => true, + _ => return None, + }) +} + +/// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode +/// 16.0. +const ZEROS: [u32; 76] = [ + 0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0, + 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0, + 0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950, + 0x11BF0, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, + 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0, +]; + +/// `c`'s digit set (its zero) and value. +fn digit(c: char) -> Option<(u32, u8)> { + let c = u32::from(c); + let zero = match ZEROS.binary_search(&c) { + Ok(i) => ZEROS.get(i), + Err(i) => ZEROS.get(i.checked_sub(1)?), + }?; + let value = c - zero; + u8::try_from(value).ok().filter(|v| *v < 10).map(|v| (*zero, v)) +} + +/// Marks bidi formatting adds around numbers: LRM, RLM, ALM, embeddings, overrides and isolates. +fn is_bidi_mark(c: char) -> bool { + matches!(c, '\u{200e}' | '\u{200f}' | '\u{061c}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_digit_set() { + for zero in ZEROS { + let d = |n: u32| char::from_u32(zero + n).unwrap(); + let all: String = (0..10).rev().map(d).collect(); + assert_eq!(parse_number(&all, "en"), Ok(9_876_543_210.0), "{zero:X}"); + let grouped = format!("-{}{}{},{}{}{}.{}", d(1), d(2), d(3), d(4), d(5), d(6), d(7)); + assert_eq!(parse_number(&grouped, "en"), Ok(-123_456.7), "{zero:X}"); + assert!((0..10).all(|n| d(n).is_numeric()), "{zero:X}"); + } + assert_eq!(digit('\u{1D800}'), None); + assert_eq!(digit('/'), None); + } + + #[test] + fn locale_forms() { + for (text, locale, want) in [ + ("1,234.5", "en", 1234.5), + ("1.234,5", "de", 1234.5), + ("1 234,5", "fr", 1234.5), + ("1\u{202f}234,5", "fr", 1234.5), + ("1\u{a0}234,5", "ru", 1234.5), + ("1’234.5", "de-CH", 1234.5), + ("1'234.5", "de_CH.UTF-8", 1234.5), + ("1,234", "en", 1234.0), + ("1,234", "de", 1.234), + ("1.234", "pt-br", 1234.0), + ("1,5", "es", 1.5), + ("1.5", "es-419", 1.5), + ("١٬٢٣٤٫٥", "ar", 1234.5), + ("١٢٫٥", "ar", 12.5), + ("12.5", "ar", 12.5), + ("12,5", "ar-MA", 12.5), + ("۱۲٫۵", "fa", 12.5), + ("−۴۲", "fa", -42.0), + ("123,456.7", "ja", 123_456.7), + ("-5", "ja", -5.0), + ("१२,३४,५६७.८९", "hi", 1_234_567.89), + ("১২,৩৪,৫৬৭", "bn", 1_234_567.0), + ("౧,౨౩,౪౫౬", "te", 123_456.0), + ("๑,๒๓๔.๕", "th", 1234.5), + ("1,234,567", "en", 1_234_567.0), + ("−3,5", "sv", -3.5), + ("+7", "en", 7.0), + (".5", "en", 0.5), + ("5.", "en", 5.0), + (" 42 ", "en", 42.0), + ("\u{2066}-1,5\u{2069}", "de", -1.5), + ("\u{061c}-١٢", "ar", -12.0), + ("1,5", "xx", 1.5), + ("1.234,5", "", 1234.5), + ("1,234.5", "xx", 1234.5), + ("1.234.567", "xx", 1_234_567.0), + ("12", "xx", 12.0), + ] { + assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); + } + } + + #[test] + fn errors_instead_of_guesses() { + for (text, locale, want) in [ + ("", "en", NumberError::Empty), + (" ", "en", NumberError::Empty), + ("-", "en", NumberError::Empty), + (",", "en", NumberError::Separator), + ("1,234", "xx", NumberError::Separator), + ("1,5", "en", NumberError::Separator), + ("1.5", "de", NumberError::Separator), + ("1.2.3", "en", NumberError::Separator), + ("12,34", "en", NumberError::Separator), + ("1234,567", "en", NumberError::Separator), + ("1,,234", "en", NumberError::Separator), + ("1,234,", "en", NumberError::Separator), + ("1.234,5", "en", NumberError::Separator), + ("1 234'567", "en", NumberError::Separator), + ("1.234 567", "de", NumberError::Separator), + ("0.123,4", "de", NumberError::Separator), + ("1.5,2", "en", NumberError::Separator), + ("123,45,678", "hi", NumberError::Separator), + ("12٫3٫4", "ar", NumberError::Separator), + ("1٫5.2", "ar", NumberError::Separator), + ("1٢", "ar", NumberError::MixedDigits), + ("12abc", "en", NumberError::Unexpected('a')), + ("1e5", "en", NumberError::Unexpected('e')), + ("--5", "en", NumberError::Unexpected('-')), + ("5-", "en", NumberError::Unexpected('-')), + ("- 5", "en", NumberError::Separator), + ] { + assert_eq!(parse_number(text, locale), Err(want), "{text:?} in {locale}"); + } + assert_eq!(parse_number(&"9".repeat(400), "en"), Err(NumberError::TooLarge)); + } +} From 3fcaddbdaeaefffb89563228d70d9743ad89d0aa Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:44:53 +0000 Subject: [PATCH 063/124] i18n: file sizes and measures with translatable unit names I18n::file_size writes a byte count in steps of 1024 (KB or KiB) or 1000 (KB), with decimals per unit, no decimals from a given value up, a smallest and largest unit, and bytes as B or in words. I18n::measure writes any value with a unit template such as "{n} px". Unit names are catalog rows in context `unit`, keyed by the English template, so a translation can change the symbol and its spacing. --- crates/i18n/README.md | 8 ++ crates/i18n/src/lib.rs | 3 + crates/i18n/src/size.rs | 242 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 crates/i18n/src/size.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..f3daff2c 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -68,6 +68,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. +- **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize` + picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`), + the decimals per unit, no decimals from a given value up, the smallest and largest unit (below + the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals, + "{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English + template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold) + is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are + ASCII digits with a decimal point. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..30eb6972 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod size; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use size::{FileSize, SizeUnit}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs new file mode 100644 index 00000000..1466672b --- /dev/null +++ b/crates/i18n/src/size.rs @@ -0,0 +1,242 @@ +//! File sizes and other measures with a unit, in the interface language. +//! +//! Unit names are catalog rows in context `unit`, keyed by the English template with the +//! number as `{n}` (`{n} MB`, `{n} px`, `{n}%`), so a translation can change the symbol and its +//! spacing (`{n} Mo`, `{n} %`). They are looked up with [`I18n::get`], never the plain row: a +//! plain `B` row means bold. A byte count in words is the `@plural` row `{n} byte|{n} bytes`. + +use crate::{I18n, Lang, fmt}; + +/// The context unit rows are in. +const CONTEXT: &str = "unit"; + +/// A file-size unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SizeUnit { + Byte, + Kilo, + Mega, + Giga, + Tera, +} + +impl SizeUnit { + const ALL: [SizeUnit; 5] = [SizeUnit::Byte, SizeUnit::Kilo, SizeUnit::Mega, SizeUnit::Giga, SizeUnit::Tera]; +} + +/// How [`I18n::file_size`] writes a byte count: the step between units, their names, and the +/// digits after the decimal point. Start from [`FileSize::BINARY`], [`FileSize::IEC`] or +/// [`FileSize::DECIMAL`] and adjust: +/// +/// ``` +/// use craft_i18n::{FileSize, SizeUnit}; +/// // `12 MB`, `3.4 MB`, `640 KB`: decimal steps, whole kilobytes, no decimals from 10 MB. +/// let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FileSize { + step: u64, + iec: bool, + /// Per unit, in [`SizeUnit::ALL`] order; bytes are always whole. + decimals: [u8; 5], + whole_from: u32, + smallest: SizeUnit, + largest: SizeUnit, + byte_symbol: bool, +} + +impl FileSize { + /// Steps of 1024 named KB, MB, GB and TB, as Windows Explorer shows sizes; one decimal, bytes + /// in words. + pub const BINARY: FileSize = FileSize { + step: 1024, + iec: false, + decimals: [0, 1, 1, 1, 1], + whole_from: 0, + smallest: SizeUnit::Byte, + largest: SizeUnit::Tera, + byte_symbol: false, + }; + /// Steps of 1024 named KiB, MiB, GiB and TiB. + pub const IEC: FileSize = FileSize { iec: true, ..FileSize::BINARY }; + /// Steps of 1000 named KB, MB, GB and TB, as macOS shows sizes and as download and disk sizes + /// are sold. + pub const DECIMAL: FileSize = FileSize { step: 1000, ..FileSize::BINARY }; + + /// `n` digits after the decimal point in every unit above bytes. + pub const fn decimals(mut self, n: u8) -> FileSize { + self.decimals = [0, n, n, n, n]; + self + } + + /// `n` digits after the decimal point in `unit` (`0` for whole kilobytes, `2` for gigabytes). + pub const fn unit_decimals(mut self, unit: SizeUnit, n: u8) -> FileSize { + if !matches!(unit, SizeUnit::Byte) { + self.decimals[unit as usize] = n; + } + self + } + + /// No decimals from `n` of a unit up: `whole_from(10)` writes `9.5 MB` and `12 MB`. `0`, the + /// default, keeps them. + pub const fn whole_from(mut self, n: u32) -> FileSize { + self.whole_from = n; + self + } + + /// The smallest unit shown. Sizes below it are written in it, rounded up, so a file that + /// isn't empty never reads `0 KB`. + pub const fn smallest(mut self, unit: SizeUnit) -> FileSize { + self.smallest = unit; + self + } + + /// The largest unit shown; larger sizes are counted in it (`2048 GB`). + pub const fn largest(mut self, unit: SizeUnit) -> FileSize { + self.largest = unit; + self + } + + /// Bytes as the symbol `B` (`512 B`) instead of words (`512 bytes`). + pub const fn byte_symbol(mut self) -> FileSize { + self.byte_symbol = true; + self + } + + /// The unit `bytes` is written in and the number in it, in English. + fn parts(&self, bytes: u64) -> (SizeUnit, String) { + let (smallest, largest) = (self.smallest.min(self.largest), self.largest); + let mut unit = smallest; + for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) { + if bytes as f64 >= self.scale(u) { + unit = u; + } + } + loop { + let value = bytes as f64 / self.scale(unit); + let mut decimals = self.decimals.get(unit as usize).copied().unwrap_or(0); + if unit == SizeUnit::Byte || (self.whole_from > 0 && value >= f64::from(self.whole_from)) { + decimals = 0; + } + let factor = 10f64.powi(i32::from(decimals)); + let rounded = if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() } / factor; + // 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB. + if rounded >= self.step as f64 + && let Some(&next) = SizeUnit::ALL.get(unit as usize + 1) + && next <= largest + { + unit = next; + continue; + } + return (unit, number(rounded, usize::from(decimals))); + } + } + + fn scale(&self, unit: SizeUnit) -> f64 { + (self.step as f64).powi(unit as i32) + } + + /// The English template for `unit`, which is also its catalog key. + fn template(&self, unit: SizeUnit) -> &'static str { + match (unit, self.iec) { + (SizeUnit::Byte, _) => "{n} B", + (SizeUnit::Kilo, false) => "{n} KB", + (SizeUnit::Mega, false) => "{n} MB", + (SizeUnit::Giga, false) => "{n} GB", + (SizeUnit::Tera, false) => "{n} TB", + (SizeUnit::Kilo, true) => "{n} KiB", + (SizeUnit::Mega, true) => "{n} MiB", + (SizeUnit::Giga, true) => "{n} GiB", + (SizeUnit::Tera, true) => "{n} TiB", + } + } +} + +impl I18n { + /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`). + pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String { + let (unit, n) = style.parts(bytes); + if unit == SizeUnit::Byte && !style.byte_symbol { + return self.trn(lang, bytes, "{n} byte", "{n} bytes"); + } + self.unit_text(lang, style.template(unit), &n) + } + + /// `value` with `decimals` digits after the decimal point in the unit template `unit`, an + /// English catalog key with the number as `{n}` (`{n} px`, `{n}%`, `{n} dB`). + pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String { + self.unit_text(lang, unit, &number(value, decimals)) + } + + fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String { + fmt(self.get(lang, CONTEXT, template).unwrap_or(template), &[("n", n)]) + } +} + +/// `value` with `decimals` digits after the decimal point, in ASCII digits with a decimal point. +/// The one place sizes and measures write numbers, for craft-i18n's locale number formatting +/// (digits, separators) to take over. +fn number(value: f64, decimals: usize) -> String { + format!("{value:.decimals$}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Embedded; + + static E: &Embedded = &[( + "fr", + "unit\t{n} KB\t{n} Ko\nunit\t{n} MB\t{n} Mo\nunit\t{n} B\t{n} o\nunit\t{n}%\t{n} %\n\tB\tG\n@plural\t{n} byte|{n} bytes\t{n} octet|{n} octets\n", + "", + )]; + + fn en(bytes: u64, style: FileSize) -> String { + let (i18n, _) = I18n::new(&[], &[]); + i18n.file_size(Lang::EN, bytes, style) + } + + #[test] + fn binary_decimal_and_iec_steps() { + assert_eq!(en(512, FileSize::BINARY), "512 bytes"); + assert_eq!(en(1, FileSize::BINARY), "1 byte"); + assert_eq!(en(1536, FileSize::BINARY), "1.5 KB"); + assert_eq!(en(1536, FileSize::IEC), "1.5 KiB"); + assert_eq!(en(1500, FileSize::DECIMAL), "1.5 KB"); + assert_eq!(en(3 << 30, FileSize::BINARY), "3.0 GB"); + assert_eq!(en(5 << 40, FileSize::IEC.decimals(0)), "5 TiB"); + assert_eq!(en(512, FileSize::BINARY.byte_symbol()), "512 B"); + } + + #[test] + fn precision_and_unit_range() { + // Rounding up to the next unit's step moves to that unit. + assert_eq!(en(1_048_575, FileSize::BINARY), "1.0 MB"); + assert_eq!(en(999_999, FileSize::DECIMAL.decimals(0)), "1 MB"); + assert_eq!(en(1_610_612_736, FileSize::BINARY.unit_decimals(SizeUnit::Giga, 2)), "1.50 GB"); + // Lightcraft's denoise sizes: whole kilobytes, at least 1, no decimals from 10 MB. + let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10); + assert_eq!(en(1, style), "1 KB"); + assert_eq!(en(0, style), "0 KB"); + assert_eq!(en(640_400, style), "640 KB"); + assert_eq!(en(3_400_000, style), "3.4 MB"); + assert_eq!(en(12_600_000, style), "13 MB"); + assert_eq!(en(5_000_000_000, style), "5000 MB"); + // Only sizes below the smallest unit round up. + assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); + assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); + } + + #[test] + fn unit_names_are_catalog_rows() { + let (i18n, problems) = I18n::new(E, &["fr"]); + assert!(problems.is_empty(), "{problems:?}"); + let fr = i18n.from_code("fr").unwrap(); + assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1.5 Ko"); + assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets"); + assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)"); + assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3.0 GB", "a missing row is English"); + assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %"); + assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12.3 px"); + } +} From 69a4154fb803a5d7d654e9dc5f298a6556e3715a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:56:28 +0000 Subject: [PATCH 064/124] craft-i18n: search folding and locale sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Folder` folds text for case- and accent-insensitive search (Turkish and Azerbaijani i by locale, ß, final sigma, ligatures, full-width forms) and maps matches back to the original text for highlighting. `Collator` (feature `collate`) sorts lists in the locale's order with digits compared by value, on ICU4X with collation data for the shipped locales only. The Turkish i and sigma handling come from PdfCraft's `fold_case`. Part of #198. Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com> Co-authored-by: nevicia2010-hub --- ATTRIBUTION.md | 1 + Cargo.lock | 133 ++++++++++ crates/i18n/Cargo.toml | 22 +- crates/i18n/README.md | 10 + crates/i18n/data/collation.postcard | Bin 0 -> 749171 bytes crates/i18n/data/generate-collation.sh | 15 ++ crates/i18n/src/collate.rs | 191 ++++++++++++++ crates/i18n/src/fold.rs | 330 +++++++++++++++++++++++++ crates/i18n/src/lib.rs | 7 + 9 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 crates/i18n/data/collation.postcard create mode 100755 crates/i18n/data/generate-collation.sh create mode 100644 crates/i18n/src/collate.rs create mode 100644 crates/i18n/src/fold.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 34cd2ee2..f3b1ed66 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ author, source and license. Library code and original assets are under the repos | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | | `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) | +| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | Original assets are covered by the repository licence. diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..7f23f26b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -656,6 +665,14 @@ version = "0.1.0" name = "craft-i18n" version = "0.1.0" dependencies = [ + "icu_collator", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "intl_pluralrules", "sys-locale", "unic-langid", @@ -1091,6 +1108,18 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1845,6 +1874,24 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "serde", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1853,6 +1900,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1867,11 +1915,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -1882,7 +1951,11 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", + "serde", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1903,6 +1976,7 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", + "serde", "zerotrie", "zerovec", ] @@ -1921,6 +1995,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1928,6 +2005,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2248,6 +2349,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3079,12 +3183,26 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4842,6 +4960,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5787,6 +5911,12 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" @@ -5985,8 +6115,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775..547e06f7 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-i18n" -description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale." +description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names, the system locale, search folding and locale sorting." version.workspace = true edition.workspace = true license.workspace = true @@ -11,9 +11,29 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +icu_normalizer = "~2.3" +icu_properties = "~2.3" +# Generated data fits one ICU4X minor version; data/generate-collation.sh names its datagen. +icu_collator = { version = "~2.3.1", default-features = false, features = ["serde"], optional = true } +icu_provider = { version = "~2.3", features = ["serde"], optional = true } +icu_provider_blob = { version = "~2.3", features = ["alloc"], optional = true } +icu_provider_adapters = { version = "~2.3", optional = true } +icu_locale_core = { version = "2.3", optional = true } +icu_locale_fallback = { version = "~2.3", features = ["compiled_data"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } +[features] +# Locale sorting (`collate`), with ICU4X collation data for the suite's locales embedded. +collate = [ + "dep:icu_collator", + "dep:icu_locale_core", + "dep:icu_locale_fallback", + "dep:icu_provider", + "dep:icu_provider_adapters", + "dep:icu_provider_blob", +] + [lints] workspace = true diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df337971..77e3d996 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -72,3 +72,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a complete catalog. +- **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and + accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the + original text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold + to their plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`; invisible format + characters (soft hyphens, joiners, bidi isolates) are dropped. Turkish and Azerbaijani fold `I` + to `ı` when accents count; ignoring accents, `I`, `i`, `İ` and `ı` all match. +- **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships, + with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` / + `-u-co-stroke` for Chinese. The data (750 KB) is regenerated by `data/generate-collation.sh`; + the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped). diff --git a/crates/i18n/data/collation.postcard b/crates/i18n/data/collation.postcard new file mode 100644 index 0000000000000000000000000000000000000000..0ea89d72b5b70cff1b93486909e2f2515ae38509 GIT binary patch literal 749171 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ⅇlr@YkgCzMe39Gllj6%>s98}rKmhR@%~4yF?fB7 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 l(wvpL*&!>&=d4srw~p)mv4(EF=a|-< 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 HgYW+Vx5n1r literal 0 HcmV?d00001 diff --git a/crates/i18n/data/generate-collation.sh b/crates/i18n/data/generate-collation.sh new file mode 100755 index 00000000..cc7bbd3d --- /dev/null +++ b/crates/i18n/data/generate-collation.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n +# ships (its `locales/` directory) plus English: each locale's default order, and Chinese +# pinyin and stroke orders. +# Needs icu4x-datagen at the version of the icu_collator dependency: +# cargo install icu4x-datagen --version 2.3.0 --locked +set -eu +cd "$(dirname "$0")" +icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication maximal \ + --cldr-tag 48.2.1 --icuexport-tag release-78.1rc \ + --attribute-filter 'collator=/^(stroke|pinyin)?$/' \ + --markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \ + CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \ + --locales en ar bg cs da de el es et fi fr hr hu id it ja ka kk ko lt nl pl pt-BR ru sl sr \ + sr-Latn sv te tr uk vi zh-Hans zh-Hant diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs new file mode 100644 index 00000000..d73b1efc --- /dev/null +++ b/crates/i18n/src/collate.rs @@ -0,0 +1,191 @@ +//! Locale sorting for lists people see: fonts, layers, presets, files. +//! +//! ICU4X collation with data embedded for the locales craft-i18n ships (regenerated by +//! `data/generate-collation.sh`). A locale without data of its own sorts in the Unicode root +//! order, which suits most languages that have no tailoring. + +use std::cmp::Ordering; + +use icu_collator::CollatorPreferences; +use icu_collator::options::CollatorOptions; +use icu_collator::preferences::CollationNumericOrdering; +use icu_collator::provider::{ + CollationDiacriticsV1, CollationJamoV1, CollationMetadataV1, CollationReorderingV1, CollationRootV1, CollationSpecialPrimariesV1, + CollationTailoringV1, +}; +use icu_locale_core::Locale; +use icu_locale_fallback::LocaleFallbacker; +use icu_normalizer::provider::{NormalizerNfdDataV1, NormalizerNfdTablesV1}; +use icu_provider::buf::AsDeserializingBufferProvider; +use icu_provider::prelude::*; +use icu_provider_adapters::fallback::LocaleFallbackProvider; +use icu_provider_blob::BlobDataProvider; + +/// Compares text in one locale's order, with runs of digits by their value: `Layer 2` before +/// `Layer 10`. +#[derive(Debug)] +pub struct Collator(Option); + +impl Collator { + /// Sorting for `locale`, a language code such as `sv`, `pt-br` or `zh-hant` (any case, `-` + /// or `_`). A BCP 47 collation type picks another order where there is one: + /// `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. + pub fn new(locale: &str) -> Collator { + let mut prefs: CollatorPreferences = Locale::try_from_str(&locale.replace('_', "-")).unwrap_or(Locale::UNKNOWN).into(); + prefs.numeric_ordering = Some(CollationNumericOrdering::True); + let options = CollatorOptions::default(); + let collator = Data::new().ok().and_then(|data| { + icu_collator::Collator::try_new_unstable(&data, prefs, options) + .or_else(|_| icu_collator::Collator::try_new_unstable(&data, CollatorPreferences::default(), options)) + .ok() + }); + Collator(collator) + } + + /// `a` against `b`. Text that differs only in case or accents still has an order, so a + /// sort is stable across runs. Without collation data (never with the embedded data) this is + /// code point order. + pub fn compare(&self, a: &str, b: &str) -> Ordering { + match &self.0 { + Some(c) => c.as_borrowed().compare(a, b), + None => a.cmp(b), + } + } + + /// Sort `items` by the text `key` gives each one. + pub fn sort_by_key(&self, items: &mut [T], key: impl Fn(&T) -> &str) { + items.sort_by(|a, b| self.compare(key(a), key(b))); + } +} + +/// The embedded collation data, with locale fallback, and the decomposition tables the collator +/// also needs taken from `icu_normalizer`'s compiled data rather than embedded a second time. +struct Data(LocaleFallbackProvider); + +impl Data { + fn new() -> Result { + let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/collation.postcard"))?; + Ok(Data(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned()))) + } +} + +macro_rules! from_blob { + ($($marker:ty),*) => {$( + impl DataProvider<$marker> for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + self.0.as_deserializing().load(req) + } + } + )*}; +} + +from_blob!( + CollationRootV1, + CollationTailoringV1, + CollationDiacriticsV1, + CollationJamoV1, + CollationMetadataV1, + CollationReorderingV1, + CollationSpecialPrimariesV1 +); + +impl DataProvider for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + icu_normalizer::provider::Baked.load(req) + } +} + +impl DataProvider for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + icu_normalizer::provider::Baked.load(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sorted<'a>(locale: &str, items: &[&'a str]) -> Vec<&'a str> { + let mut v = items.to_vec(); + Collator::new(locale).sort_by_key(&mut v, |s| s); + v + } + + #[test] + fn every_shipped_locale_has_its_data() { + for locale in [ + "en", + "ar", + "bg", + "cs", + "da", + "de", + "el", + "es", + "et", + "fi", + "fr", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "kk", + "ko", + "lt", + "nl", + "pl", + "pt-br", + "ru", + "sl", + "sr", + "sr-latn", + "sv", + "te", + "tr", + "uk", + "vi", + "zh-hans", + "zh-hant", + "not a locale", + ] { + assert!(Collator::new(locale).0.is_some(), "{locale}"); + } + } + + #[test] + fn digits_sort_by_value() { + let layers = ["Layer 10", "Layer 2", "layer 1", "Layer 1", "Layer 2b", "Layer 02"]; + assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 2", "Layer 02", "Layer 2b", "Layer 10"]); + assert_eq!(sorted("de", &["Ebene ٣", "Ebene 10", "Ebene 2"]), ["Ebene 2", "Ebene ٣", "Ebene 10"]); + } + + #[test] + fn locales_sort_their_own_letters() { + let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"]; + assert_eq!(sorted("sv", &words), ["apa", "oxe", "zebra", "år", "äpple", "öl"]); + assert_eq!(sorted("de", &words), ["apa", "äpple", "år", "öl", "oxe", "zebra"]); + assert_eq!(sorted("de", &["Muller", "Müller", "Mueller", "Mahler"]), ["Mahler", "Mueller", "Muller", "Müller"]); + assert_eq!( + sorted("tr", &["şeker", "sabun", "ılık", "iğne", "çay", "cam", "uzun", "üzüm"]), + ["cam", "çay", "ılık", "iğne", "sabun", "şeker", "uzun", "üzüm"] + ); + assert_eq!(sorted("en", &["ılık", "iğne", "cam", "çay"]), ["cam", "çay", "iğne", "ılık"]); + assert_eq!(sorted("es", &["ñu", "nube", "oso"]), ["nube", "ñu", "oso"]); + // Japanese: Latin, then kana in gojūon order (hiragana and katakana together), then kanji. + assert_eq!(sorted("ja", &["漢字", "カメラ", "あめ", "いぬ", "Zoom"]), ["Zoom", "あめ", "いぬ", "カメラ", "漢字"]); + } + + #[test] + fn chinese_sorts_by_pinyin_or_stroke() { + // 北 běi (5 strokes), 上 shàng (3), 天 tiān (4). + let cities = ["天津", "北京", "上海"]; + let pinyin = ["北京", "上海", "天津"]; + let stroke = ["上海", "天津", "北京"]; + assert_eq!(sorted("zh-hans", &cities), pinyin); + assert_eq!(sorted("zh-hant", &cities), stroke); + assert_eq!(sorted("zh-hans-u-co-stroke", &cities), stroke); + assert_eq!(sorted("zh-hant-u-co-pinyin", &cities), pinyin); + } +} diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs new file mode 100644 index 00000000..19289fe3 --- /dev/null +++ b/crates/i18n/src/fold.rs @@ -0,0 +1,330 @@ +//! Case- and accent-insensitive matching for search fields, with a map back to the original text +//! for highlighting. +//! +//! [`Folder::fold`] turns text into a search key: compatibility forms decomposed (`fi` → `fi`, +//! full-width `A` → `a`, `㎏` → `kg`), lower case, `ß` → `ss`, final `ς` → `σ`, and with accents +//! ignored (the default) combining accents, Hebrew points and Arabic vowel marks dropped and the +//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`). +//! Invisible format characters (soft hyphens, zero-width joiners, bidi isolates) are dropped too. +//! +//! Dotted and dotless i follow the locale when accents count. In Turkish and Azerbaijani, `I` +//! folds to `ı` and `İ` to `i`; elsewhere both fold to `i` and `ı` stays a letter of its own. +//! Ignoring accents, all four fold to `i`, so `istanbul` finds `İSTANBUL` and `irmak` finds +//! `IRMAK` and `ırmak`. Plain `str::to_lowercase` follows no locale and turns `İ` into `i` plus a +//! combining dot, which nothing typed would match. +//! +//! The Turkish i handling and sigma folding come from PdfCraft's `fold_case` (snipeTR, +//! nevicia2010-hub). + +use std::ops::Range; + +use icu_normalizer::DecomposingNormalizerBorrowed; +use icu_properties::CodePointSetData; +use icu_properties::props::GraphemeExtend; + +/// Folds text for one locale. Cheap to build and copy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Folder { + turkic: bool, + accents: bool, +} + +impl Folder { + /// Case- and accent-insensitive folding for `locale`, a language code such as `tr`, `pt-br` + /// or `zh-hant` (any case, `-` or `_`). + pub fn new(locale: &str) -> Folder { + let language = locale.split(['-', '_']).next().unwrap_or_default(); + Folder { turkic: language.eq_ignore_ascii_case("tr") || language.eq_ignore_ascii_case("az"), accents: false } + } + + /// Accents count: `é` no longer matches `e`, and a match can't end on a letter whose accent it + /// leaves out. + pub fn keep_accents(self) -> Folder { + Folder { accents: true, ..self } + } + + /// `s` folded, with the map back to `s`. + pub fn fold(&self, s: &str) -> Folded { + let nfkd = DecomposingNormalizerBorrowed::new_nfkd(); + let extend = CodePointSetData::new::(); + let mut text = String::with_capacity(s.len()); + let mut starts = Vec::new(); + // A cluster is a character and the combining marks after it, folded and mapped as one. + let mut cluster_start = 0; + for (i, c) in s.char_indices().skip(1) { + if !extend.contains(c) { + starts.push((text.len(), cluster_start)); + self.fold_cluster(&nfkd.normalize(&s[cluster_start..i]), &mut text); + cluster_start = i; + } + } + if !s.is_empty() { + starts.push((text.len(), cluster_start)); + self.fold_cluster(&nfkd.normalize(&s[cluster_start..]), &mut text); + } + starts.push((text.len(), s.len())); + Folded { text, starts } + } + + /// `s` folded, without the map: a key to compare or store. + pub fn key(&self, s: &str) -> String { + self.fold(s).text + } + + /// Whether `haystack` contains `needle`, both folded. + pub fn contains(&self, haystack: &str, needle: &str) -> bool { + self.fold(haystack).find(&self.fold(needle)).is_some() + } + + /// One cluster, already in NFKD. + fn fold_cluster(&self, cluster: &str, out: &mut String) { + let mut chars = cluster.chars().peekable(); + while let Some(c) = chars.next() { + match c { + // `İ` decomposes to `I` and a combining dot above: the dot of a lower-case `i`. + 'I' if chars.peek() == Some(&'\u{307}') => { + chars.next(); + out.push('i'); + } + 'I' if self.turkic && self.accents => out.push('ı'), + 'ß' | 'ẞ' => out.push_str("ss"), + 'Σ' | 'σ' | 'ς' => out.push('σ'), + c if ignorable(c) || (!self.accents && accent(c)) => {} + c => { + for l in c.to_lowercase() { + match base_letter(l) { + Some(base) if !self.accents => out.push_str(base), + _ => out.push(l), + } + } + } + } + } + } +} + +/// The base of a lower-case letter whose diacritic is built in rather than combining. +fn base_letter(c: char) -> Option<&'static str> { + Some(match c { + 'ı' => "i", + 'ø' => "o", + 'ł' => "l", + 'đ' => "d", + 'ħ' => "h", + 'ŧ' => "t", + 'æ' => "ae", + 'œ' => "oe", + _ => return None, + }) +} + +/// Invisible characters that only steer layout: soft hyphen, Arabic letter mark, the kashida +/// that stretches Arabic words, Mongolian variation selectors, zero-width space and joiners, direction marks, embeddings and isolates, +/// word joiner, variation selectors, byte order mark and tags. +fn ignorable(c: char) -> bool { + matches!(c, + '\u{AD}' | '\u{61C}' | '\u{640}' | '\u{180B}'..='\u{180F}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{E0000}'..='\u{E0FFF}') +} + +/// Marks that searching without accents ignores: Latin, Greek and Cyrillic combining diacritics, +/// Hebrew points and cantillation, Arabic vowel marks. +/// Kana voicing marks, Indic and Thai vowel signs are letters' parts, not accents, and stay. +fn accent(c: char) -> bool { + matches!(c, + '\u{300}'..='\u{36F}' | '\u{1AB0}'..='\u{1AFF}' | '\u{1DC0}'..='\u{1DFF}' | '\u{20D0}'..='\u{20FF}' + | '\u{FE20}'..='\u{FE2F}' + | '\u{591}'..='\u{5BD}' | '\u{5BF}' | '\u{5C1}' | '\u{5C2}' | '\u{5C4}' | '\u{5C5}' | '\u{5C7}' + | '\u{610}'..='\u{61A}' | '\u{64B}'..='\u{65F}' | '\u{670}' | '\u{6D6}'..='\u{6DC}' + | '\u{6DF}'..='\u{6E4}' | '\u{6E7}' | '\u{6E8}' | '\u{6EA}'..='\u{6ED}') +} + +/// Folded text and where each part came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Folded { + text: String, + /// One (folded offset, source offset) per source cluster, in order, then both lengths. A + /// cluster folds to the text up to the next entry's folded offset, possibly none. + starts: Vec<(usize, usize)>, +} + +impl Folded { + /// The folded text. + pub fn as_str(&self) -> &str { + &self.text + } + + /// The source byte range that folded bytes `range` came from, widened to whole source + /// characters with their accents: `ss` in the folding of `Straße` maps back to `ß`, `e` in + /// that of `é` to the whole `é`. An empty range maps to an empty one. + pub fn source_range(&self, range: Range) -> Range { + // The last cluster starting at or before `at` is the one holding it: clusters that fold to + // nothing share their offset with the next one. + let cluster = |at: usize| self.starts.partition_point(|&(f, _)| f <= at).saturating_sub(1); + if range.is_empty() { + let s = self.starts[cluster(range.start)].1; + return s..s; + } + let first = cluster(range.start); + let last = cluster(range.end - 1); + self.starts[first].1..self.starts[(last + 1).min(self.starts.len() - 1)].1 + } + + /// The first match of `needle` (folded the same way), as a byte range of the source text. + /// An empty needle matches at the start. + pub fn find(&self, needle: &Folded) -> Option> { + self.matches(needle).next() + } + + /// Every non-overlapping match of `needle` (folded the same way), as byte ranges of the + /// source text. A match may start or end inside an expansion (`ss` of `ß`, `fi` of `fi`) but + /// not leave out a mark that follows a letter, so `re` doesn't match `ré` when accents count. + pub fn matches<'a>(&'a self, needle: &'a Folded) -> impl Iterator> + 'a { + let extend = CodePointSetData::new::(); + let starts_mark = move |at: usize| self.text[at..].chars().next().is_some_and(|c| extend.contains(c)); + let n = needle.text.as_str(); + let mut from = 0; + let mut done = false; + std::iter::from_fn(move || { + if done { + return None; + } + if n.is_empty() { + done = true; + return Some(0..0); + } + while let Some(i) = self.text.get(from..).and_then(|t| t.find(n)) { + let start = from + i; + let end = start + n.len(); + if !starts_mark(start) && !starts_mark(end) { + from = end; + return Some(self.source_range(start..end)); + } + from = start + self.text[start..].chars().next().map_or(1, char::len_utf8); + } + done = true; + None + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(locale: &str, s: &str) -> String { + Folder::new(locale).key(s) + } + + fn key_accents(locale: &str, s: &str) -> String { + Folder::new(locale).keep_accents().key(s) + } + + #[test] + fn folding_table() { + let nfd = |s: &str| DecomposingNormalizerBorrowed::new_nfd().normalize(s).into_owned(); + // (locale, input, ignoring accents, keeping accents) + let table = [ + ("en", "Hello WORLD", "hello world".to_owned(), "hello world".to_owned()), + ("en", "İSTANBUL", "istanbul".into(), "istanbul".into()), + ("tr", "İSTANBUL", "istanbul".into(), "istanbul".into()), + ("en", "IRMAK ırmak", "irmak irmak".into(), "irmak ırmak".into()), + ("tr", "IRMAK ırmak", "irmak irmak".into(), "ırmak ırmak".into()), + ("az", "İIıi", "iiii".into(), "iııi".into()), + ("tr-TR", "Şişli", "sisli".into(), nfd("şişli")), + ("de", "Straße STRAẞE", "strasse strasse".into(), "strasse strasse".into()), + ("el", "ΟΔΥΣΣΕΥΣ οδυσσευς", "οδυσσευσ οδυσσευσ".into(), "οδυσσευσ οδυσσευσ".into()), + ("el", "Ἀθῆναι", "αθηναι".into(), nfd("ἀθῆναι")), + ("fr", "Œuvre ÉTÉ", "oeuvre ete".into(), nfd("œuvre été")), + ("da", "Ærø", "aero".into(), nfd("ærø")), + ("pl", "Łódź", "lodz".into(), nfd("łódź")), + ("vi", "Đà Nẵng", "da nang".into(), nfd("đà nẵng")), + ("en", "final ff", "final ff".into(), "final ff".into()), + ("ja", "ABC123 ガ", nfd("abc123 ガ"), nfd("abc123 ガ")), + ("ar", "مُحَمَّد أحمـــد", nfd("محمد احمد"), nfd("مُحَمَّد أحمد")), + ("he", "שָׁלוֹם", "שלום".into(), nfd("שָׁלוֹם")), + ("en", "soft\u{AD}ware \u{2068}name\u{2069}", "software name".into(), "software name".into()), + ("ru", "Ёлка", "елка".into(), nfd("ёлка")), + ]; + for (locale, input, ignoring, keeping) in table { + assert_eq!(key(locale, input), ignoring, "{locale} {input}"); + assert_eq!(key_accents(locale, input), keeping, "{locale} {input}, accents kept"); + } + } + + #[test] + fn turkish_i_depends_on_locale_when_accents_count() { + let tr = Folder::new("tr").keep_accents(); + let en = Folder::new("en").keep_accents(); + assert!(tr.contains("IRMAK", "ırmak") && !tr.contains("IRMAK", "irmak")); + assert!(en.contains("IRMAK", "irmak") && !en.contains("IRMAK", "ırmak")); + assert!(tr.contains("İSTANBUL", "istanbul") && en.contains("İSTANBUL", "istanbul")); + for locale in ["tr", "en"] { + let f = Folder::new(locale); + assert!(f.contains("IRMAK", "ırmak") && f.contains("ırmak", "IRMAK") && f.contains("İzmir", "izmir")); + } + } + + #[test] + fn matches_map_back_to_the_source() { + let f = Folder::new("en"); + fn find<'a>(f: Folder, haystack: &'a str, needle: &str) -> Option<&'a str> { + f.fold(haystack).find(&f.fold(needle)).map(|r| &haystack[r]) + } + let find = |haystack, needle| find(f, haystack, needle); + assert_eq!(find("Große Straße", "strasse"), Some("Straße")); + assert_eq!(find("Große Straße", "SS"), Some("ß")); + assert_eq!(find("Große Straße", "sse"), Some("ße")); + assert_eq!(find("Café crème", "creme"), Some("crème")); + assert_eq!(find("Cafe\u{301} noir", "cafe"), Some("Cafe\u{301}")); + assert_eq!(find("File 2", "file 2"), Some("File 2")); + assert_eq!(find("the final cut", "fin"), Some("fin")); + assert_eq!(find("Layer \u{2068}İSTANBUL\u{2069}", "istanbul"), Some("İSTANBUL")); + assert_eq!(find("abc", "x"), None); + assert_eq!(find("abc", ""), Some("")); + + let all: Vec<_> = f.fold("Ab ab AB").matches(&f.fold("ab")).collect(); + assert_eq!(all, [0..2, 3..5, 6..8]); + } + + #[test] + fn accents_count_only_whole_letters() { + let f = Folder::new("fr").keep_accents(); + assert!(!f.contains("ré", "re")); + assert!(f.contains("ré", "RÉ") && f.contains("re\u{301}", "ré")); + assert!(f.contains("ré", "é")); + // Kana voicing marks stay even with accents ignored: か doesn't find が. + assert!(!Folder::new("ja").contains("が", "か")); + } + + #[test] + fn source_ranges_round_trip() { + let f = Folder::new("de"); + for s in ["Straße", "ffi A", "e\u{301}\u{AD}x", "", "İı", "\u{301}a"] { + let folded = f.fold(s); + let text = folded.as_str(); + // A leading mark folds to nothing and has no part in any match. + let whole = if s.starts_with('\u{301}') { 2..s.len() } else { 0..s.len() }; + assert_eq!(folded.source_range(0..text.len()), whole, "{s:?}"); + // Every folded character maps to a non-empty source range that folds back to text + // containing it. + for (i, c) in text.char_indices() { + let r = folded.source_range(i..i + c.len_utf8()); + assert!(!r.is_empty() && f.key(&s[r.clone()]).contains(c), "{s:?} {i} {r:?}"); + } + } + } + + #[test] + fn folding_is_idempotent() { + // A folded query folds to itself, so a stored key can be searched with a fresh fold. + for locale in ["en", "tr"] { + for f in [Folder::new(locale), Folder::new(locale).keep_accents()] { + for c in ('\0'..='\u{FFFF}').chain('\u{1D400}'..='\u{1D7FF}') { + let once = f.key(c.encode_utf8(&mut [0; 4])); + assert_eq!(f.key(&once), once, "{locale} {:?} U+{:04X}", f, c as u32); + } + } + } + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4ef..806644f8 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +#[cfg(feature = "collate")] +mod collate; +mod fold; + +#[cfg(feature = "collate")] +pub use collate::Collator; +pub use fold::{Folded, Folder}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; From 86248331bb75626a398dc6b15321ae6760b12930 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:55:33 +0000 Subject: [PATCH 065/124] keymap: read presses by the keyboard layout Matching, the recorder and labels share one rule (craft-libs#198, track 4): letters by the layout's Latin letter, or by position on layouts that are not Latin; digits as typed, else by position; punctuation as typed, without the Shift the layout needs for it. Layout holds whether the layout is Latin and its characters at punctuation positions, reported by the platform or learned from typing. KeyInput holds what the toolkit reports for one press, so a toolkit that knows the virtual-key code, the macOS command table or the Shift level can pass it on. --- crates/keymap/README.md | 31 ++++- crates/keymap/src/chord.rs | 94 ++++++++++++- crates/keymap/src/egui.rs | 139 +++++++++--------- crates/keymap/src/layout.rs | 255 ++++++++++++++++++++++++++++++++++ crates/keymap/src/lib.rs | 5 +- crates/keymap/tests/egui.rs | 36 ++++- crates/keymap/tests/layout.rs | 157 +++++++++++++++++++++ 7 files changed, 637 insertions(+), 80 deletions(-) create mode 100644 crates/keymap/src/layout.rs create mode 100644 crates/keymap/tests/layout.rs diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 15e812db..f4719461 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -79,6 +79,30 @@ macOS it folds into `cmd` (`Chord::effective`). - `chord::foreign::read_foreign` reads key text written by other applications (Windows or Mac modifier names, numeric keypad keys). +## Keyboard layouts + +[`layout`](src/layout.rs) decides which chord a key press means, the same way for matching +(`Layout::candidates`), the shortcut recorder (`Layout::resolve`) and labels +(`Chord::display_for`): + +1. Letters A–Z: the layout's Latin letter for the key. On a layout that is not Latin (Russian, + Arabic, Hebrew, Greek, Thai), the key's position; on a Latin layout (Dvorak, AZERTY), the + letter typed. Arabic Ctrl+Shift+D is Ctrl+Shift+D, not the `]` it types. +2. Digits: the digit typed, else the key's position (AZERTY's `&é"` row). A digit-row key that + types punctuation also matches its digit when the punctuation has no binding. +3. Punctuation: the character typed, without Shift when the layout needs Shift for it: German + `/` is ⇧7, so ⇧⌘7 runs ⌘/. +4. Whether a layout is Latin is decided for the layout, not per key. +5. Labels show letters and digits by name, and a punctuation key as the character the layout + types at its position when that character has no key of its own (`Ctrl+Ü` for Ctrl+[ on + German). + +A `KeyInput` holds what the toolkit reports for one press: modifiers, the key typed, the +physical key, and, when the platform reports them, the layout's Latin letter (Windows +virtual-key code, macOS ⌘ table, Linux Latin layout group) and whether Shift was needed. A +`Layout` holds whether the layout is Latin, as the platform reports it or as plain typing shows +it (`Layout::observe`), and the characters it types at punctuation positions. + ## Storing overrides `Overrides` is a field of the app's settings type. It is stored as a map of one text per command @@ -209,9 +233,10 @@ assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File ## Features -- `egui`: conversions to and from egui keys and modifiers; `egui::matches`, which accepts the - shifted key egui reports for a Shift binding (`Shift+;` matches `:`); and `egui::Dispatcher`, - which runs a frame's key presses against the keymap: +- `egui`: conversions to and from egui keys and modifiers; `KeyInput::from_egui`; + `egui::matches`, which accepts the shifted key egui reports for a Shift binding (`Shift+;` + matches `:`); and `egui::Dispatcher`, which runs a frame's key presses against the keymap, + reading them by the layout it learns from typing or is given (`with_layout`, `layout()`): ```rust,ignore let hits = ctx.input_mut(|i| dispatcher.dispatch(i, &keymap, &active)); diff --git a/crates/keymap/src/chord.rs b/crates/keymap/src/chord.rs index 23b1c41c..66651de3 100644 --- a/crates/keymap/src/chord.rs +++ b/crates/keymap/src/chord.rs @@ -7,6 +7,8 @@ use std::fmt; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use crate::layout::Layout; + pub mod foreign; /// The longest chord or trigger text [`Chord::parse`] and [`Trigger::parse`] accept, in bytes. @@ -138,6 +140,77 @@ impl Key { .or_else(|| KEY_ALIASES.iter().find(|(alias, _)| alias.eq_ignore_ascii_case(name)).map(|&(_, k)| k)) } + /// `A` to `Z`. + pub const fn is_letter(self) -> bool { + matches!( + self, + Key::A + | Key::B + | Key::C + | Key::D + | Key::E + | Key::F + | Key::G + | Key::H + | Key::I + | Key::J + | Key::K + | Key::L + | Key::M + | Key::N + | Key::O + | Key::P + | Key::Q + | Key::R + | Key::S + | Key::T + | Key::U + | Key::V + | Key::W + | Key::X + | Key::Y + | Key::Z + ) + } + + /// `0` to `9`. + pub const fn is_digit(self) -> bool { + matches!(self, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9) + } + + /// Punctuation and symbol keys: `-`, `=`, `+`, brackets, `\`, `;`, `'`, `` ` ``, `,`, `.`, + /// `/` and the shifted symbols `:`, `|`, `?`, `!`, `{`, `}`. + pub const fn is_symbol(self) -> bool { + matches!( + self, + Key::Minus + | Key::Equals + | Key::Plus + | Key::OpenBracket + | Key::CloseBracket + | Key::Backslash + | Key::Semicolon + | Key::Quote + | Key::Backtick + | Key::Comma + | Key::Period + | Key::Slash + | Key::Colon + | Key::Pipe + | Key::Questionmark + | Key::Exclamationmark + | Key::OpenCurlyBracket + | Key::CloseCurlyBracket + ) + } + + /// The letter, digit or symbol key for a typed character, ignoring case. + pub fn from_char(c: char) -> Option { + let mut buf = [0; 4]; + let text: &str = c.encode_utf8(&mut buf); + Key::ALL.iter().copied().find(|k| (k.is_letter() || k.is_digit() || k.is_symbol()) && k.name().eq_ignore_ascii_case(text)) + } + /// `F1` to `F35`. pub const fn is_function_key(self) -> bool { matches!( @@ -430,6 +503,17 @@ impl Chord { /// Text for menus and settings: `⌃⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere. pub fn display(&self, platform: Platform) -> String { + self.display_key(platform, None) + } + + /// [`display`](Self::display) for the keys of `layout`: a punctuation key shows the character + /// the layout types at its position when that character has no key of its own (`Ctrl+Ü` for + /// Ctrl+[ on German). Letters and digits show as in `display`. + pub fn display_for(&self, platform: Platform, layout: &Layout) -> String { + self.display_key(platform, layout.key_label(self.key).as_deref()) + } + + fn display_key(&self, platform: Platform, key_text: Option<&str>) -> String { let m = self.mods.effective(platform); let mut s = String::new(); if platform.is_mac() { @@ -438,14 +522,14 @@ impl Chord { s.push_str(glyph); } } - s.push_str(self.key.mac_symbol()); + s.push_str(key_text.unwrap_or(self.key.mac_symbol())); } else { for (on, name) in [(m.cmd, "Ctrl+"), (m.alt, "Alt+"), (m.shift, "Shift+")] { if on { s.push_str(name); } } - s.push_str(self.key.text_name()); + s.push_str(key_text.unwrap_or(self.key.text_name())); } s } @@ -554,6 +638,12 @@ impl Trigger { let one = self.chord.display(platform); if self.double { format!("{one} {one}") } else { one } } + + /// [`Chord::display_for`], twice with a space between for a double press. + pub fn display_for(&self, platform: Platform, layout: &Layout) -> String { + let one = self.chord.display_for(platform, layout); + if self.double { format!("{one} {one}") } else { one } + } } impl From for Trigger { diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index c40bcea8..b85b60e0 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -8,6 +8,7 @@ use ::egui::{Context, Event, InputState, Key as EguiKey, KeyboardShortcut, Modif use crate::chord::{Chord, Key, Mods, Platform, Trigger}; use crate::command::CommandRef; use crate::keymap::{Keymap, Lookup}; +use crate::layout::{KeyInput, Layout}; use crate::scope::Active; /// The platform to build a keymap for in this context. Unlike [`Platform::current`], a web build @@ -89,22 +90,22 @@ impl Chord { } } -/// Base keys and the key egui reports for them with Shift held on a US layout. -const SHIFTED: &[(Key, Key)] = &[ - (Key::Semicolon, Key::Colon), - (Key::Slash, Key::Questionmark), - (Key::Backslash, Key::Pipe), - (Key::OpenBracket, Key::OpenCurlyBracket), - (Key::CloseBracket, Key::CloseCurlyBracket), - (Key::Equals, Key::Plus), - (Key::Num1, Key::Exclamationmark), -]; - -fn without_shift(m: Mods) -> Mods { - Mods { shift: false, ..m } +impl KeyInput { + /// What an egui key press reports: the logical key as typed and the physical key. egui + /// reports no Latin letter and no Shift level, so [`Layout`] infers them; a toolkit that + /// reports them fills them in here. `None` for events other than key presses and releases. + pub fn from_egui(event: &Event, platform: Platform) -> Option { + let Event::Key { key, physical_key, modifiers, .. } = event else { return None }; + let mut input = KeyInput::new(Mods::from_egui(modifiers, platform)); + input.typed = Key::from_egui(*key); + input.physical = physical_key.and_then(Key::from_egui); + Some(input) + } } -/// Whether a key press egui reported triggers `binding` on `platform`. +/// Whether a key press egui reported triggers `binding` on `platform`, on a layout about which +/// nothing is known, for a press whose physical key is unknown. [`Layout::matches`] takes the +/// whole press and the layout. /// /// Besides an exact match: /// - Shift with a base key matches the shifted key egui reports (`Shift+;` matches `:`). @@ -113,47 +114,7 @@ fn without_shift(m: Mods) -> Mods { /// `Shift+=`, so zoom keys work with and without Shift. pub fn matches(binding: &Chord, key: EguiKey, mods: &Modifiers, platform: Platform) -> bool { let Some(pressed) = Key::from_egui(key) else { return false }; - matches_press(binding, pressed, Mods::from_egui(mods, platform), platform) -} - -fn matches_press(binding: &Chord, pressed: Key, held: Mods, platform: Platform) -> bool { - let want = binding.effective(platform); - if pressed == want.key && held == want.mods { - return true; - } - let same_but_shift = without_shift(held) == without_shift(want.mods); - if !same_but_shift { - return false; - } - let shifted_of = |base: Key| SHIFTED.iter().find(|(b, _)| *b == base).map(|&(_, s)| s); - let is_shifted_symbol = SHIFTED.iter().any(|&(_, s)| s == want.key); - match (want.key, pressed) { - (Key::Equals, Key::Plus) => !want.mods.shift || held.shift, - (Key::Plus, Key::Equals) => held.shift && !want.mods.shift, - (w, p) if w == p => is_shifted_symbol && !want.mods.shift && held.shift, - (w, p) => want.mods.shift && held.shift && shifted_of(w) == Some(p), - } -} - -/// The chords a press can stand for, most specific first: the press itself, then those that keep -/// Shift (`Shift+;` for `:`), then those without it (`=` for `+`). Each one [`matches`] the press. -fn candidates(pressed: Key, held: Mods, platform: Platform) -> Vec { - let base = SHIFTED.iter().find(|&&(_, s)| s == pressed).map(|&(b, _)| b); - let swap = match pressed { - Key::Plus => Some(Key::Equals), - Key::Equals => Some(Key::Plus), - _ => None, - }; - let mut out = Vec::with_capacity(6); - for mods in [held, without_shift(held)] { - for key in [Some(pressed), base, swap].into_iter().flatten() { - let chord = Chord::new(mods, key); - if !out.contains(&chord) && matches_press(&chord, pressed, held, platform) { - out.push(chord); - } - } - } - out + Layout::new().matches(binding, &KeyInput::new(Mods::from_egui(mods, platform)).with_typed(pressed), platform) } /// Seconds within which a second press of the same chord counts as a double press. @@ -170,9 +131,10 @@ pub struct Hit { pub cycle: Vec, /// The chord whose binding matched, with `double` set when the double binding ran. pub trigger: Trigger, - /// Whether `trigger` is the press itself. `false` when the press matched through a stand-in: - /// the shifted key for a Shift binding (`:` for `Shift+;`), Shift held to type a symbol, or - /// `+` and `=` standing for each other. + /// Whether `trigger` is the chord the press means ([`Layout::resolve`]), the one the shortcut + /// recorder records. `false` when the press matched through a stand-in: a Shift binding on the + /// US base key (`Shift+;` for `:`), `+` and `=` standing for each other, or a digit-row + /// position's digit. pub exact: bool, /// The text of the Paste event that ran the command, or on the web of the Paste event that /// came with the press that ran it. @@ -236,6 +198,7 @@ pub struct Dispatcher { /// Whether a press's Text event comes before it when no text field has focus, as eframe's /// web build sends them. text_first: bool, + layout: Layout, } /// eframe's web build sends a press's Text event first when no text field has focus. @@ -243,7 +206,7 @@ const TEXT_FIRST: bool = cfg!(target_arch = "wasm32"); impl Default for Dispatcher { fn default() -> Dispatcher { - Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST } + Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST, layout: Layout::new() } } } @@ -272,6 +235,24 @@ impl Dispatcher { self } + /// Starts from what the platform reports about the keyboard layout. The dispatcher also + /// learns from what plain presses type ([`Layout::observe`]). + pub fn with_layout(mut self, layout: Layout) -> Dispatcher { + self.layout = layout; + self + } + + /// The keyboard layout as known so far, for the shortcut recorder and labels + /// ([`Chord::display_for`]). + pub fn layout(&self) -> &Layout { + &self.layout + } + + /// For reporting a layout switch ([`Layout::set_latin`]). + pub fn layout_mut(&mut self) -> &mut Layout { + &mut self.layout + } + /// The commands this frame's key presses run with `active` focus, in press order. /// /// A chord pressed again within [`DOUBLE_PRESS`] in the same panel runs its double binding @@ -292,17 +273,19 @@ impl Dispatcher { let mut kept = Vec::with_capacity(input.events.len()); let events = std::mem::take(&mut input.events); let text_of = text_of_press(&events, self.text_first && !active.typing); + self.learn(&events, &text_of, platform); let mut drop = vec![false; events.len()]; // The web build sends both a clipboard key press and the clipboard event it causes. The // press decides: its event goes with it when it runs a command and stays otherwise. - let causes = clipboard_event_follows(&events, platform); + let causes = clipboard_event_follows(&events, &self.layout, platform); let mut caused_taken: Vec> = vec![None; events.len()]; for (i, event) in events.into_iter().enumerate() { match &event { - Event::Key { key, pressed: true, repeat, modifiers, .. } if !*repeat || self.repeat => { - let held = Mods::from_egui(modifiers, platform); - if let Some(key) = Key::from_egui(*key) { - let plain_clip = is_plain_clipboard(key, held); + Event::Key { pressed: true, repeat, .. } if !*repeat || self.repeat => { + let input = KeyInput::from_egui(&event, platform).unwrap_or_default(); + let held = input.mods; + if let Some(chord) = self.layout.resolve(&input) { + let plain_clip = is_plain_clipboard(chord.key, chord.mods); let keep = if active.typing { text_of[i].is_some() || plain_clip } else { !self.clipboard && plain_clip && causes[i].is_some() }; if keep { @@ -313,7 +296,7 @@ impl Dispatcher { continue; } let caused = causes[i].clone(); - let hit = self.press(key, held, *repeat, now, keymap, active).map(|mut hit| { + let hit = self.press(&input, *repeat, now, keymap, active).map(|mut hit| { hit.pasted = caused.as_ref().and_then(|(_, text)| text.clone()); hit }); @@ -344,7 +327,7 @@ impl Dispatcher { let (key, held) = clipboard_chord(&event, frame_mods); // A focused text field copies and pastes on the plain chords itself. let look = !is_plain_clipboard(key, held) || (self.clipboard && !active.typing); - let hit = if look { self.press(key, held, false, now, keymap, active) } else { None }; + let hit = if look { self.press(&KeyInput::new(held).with_typed(key), false, now, keymap, active) } else { None }; if let Some(mut hit) = hit { if let Event::Paste(text) = &event { hit.pasted = Some(text.clone()); @@ -364,12 +347,21 @@ impl Dispatcher { hits } - fn press(&mut self, key: Key, held: Mods, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { - let chords = candidates(key, held, keymap.platform()); + /// Teaches the layout what this frame's presses typed. + fn learn(&mut self, events: &[Event], text_of: &[Option], platform: Platform) { + for (event, text) in events.iter().zip(text_of) { + let Some(Event::Text(text)) = text.and_then(|j| events.get(j)) else { continue }; + if let Some(KeyInput { mods, physical: Some(physical), .. }) = KeyInput::from_egui(event, platform) { + self.layout.observe(physical, mods, text); + } + } + } + + fn press(&mut self, input: &KeyInput, repeat: bool, now: f64, keymap: &Keymap, active: &Active) -> Option { + let chords = self.layout.candidates(input); let first = *chords.first()?; - let pressed = Chord::new(held, key); let find = |double: bool| { - chords.iter().find_map(|&chord| Hit::from_lookup(keymap.lookup(&chord, double, active), Trigger { chord, double }, chord == pressed)) + chords.iter().find_map(|&chord| Hit::from_lookup(keymap.lookup(&chord, double, active), Trigger { chord, double }, chord == first)) }; if repeat { return find(false); @@ -394,14 +386,15 @@ enum Clip { /// For each clipboard key press whose clipboard event comes later in the frame, as the web build /// sends them: the index of that event, and the text of a Paste event. -fn clipboard_event_follows(events: &[Event], platform: Platform) -> Vec)>> { +fn clipboard_event_follows(events: &[Event], layout: &Layout, platform: Platform) -> Vec)>> { let mut out = vec![None; events.len()]; let mut ahead: [Option; 3] = [None; 3]; for (i, event) in events.iter().enumerate().rev() { if let Some(seen) = event_clip(event).and_then(|c| ahead.get_mut(c as usize)) { *seen = Some(i); - } else if let Event::Key { key, pressed: true, modifiers, .. } = event { - let clip = Key::from_egui(*key).and_then(|k| clipboard_kind(k, Mods::from_egui(modifiers, platform))); + } else if let Event::Key { pressed: true, .. } = event { + let chord = KeyInput::from_egui(event, platform).and_then(|input| layout.resolve(&input)); + let clip = chord.and_then(|c| clipboard_kind(c.key, c.mods)); if let Some(j) = clip.and_then(|c| ahead.get_mut(c as usize)).and_then(Option::take) { let text = if let Some(Event::Paste(text)) = events.get(j) { Some(text.clone()) } else { None }; out[i] = Some((j, text)); diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs new file mode 100644 index 00000000..3beacce3 --- /dev/null +++ b/crates/keymap/src/layout.rs @@ -0,0 +1,255 @@ +//! Which chord a key press means on the active keyboard layout, and the layout's own characters +//! for labels. +//! +//! One rule decides matching ([`Layout::candidates`]), recording ([`Layout::resolve`]) and labels +//! ([`Chord::display_for`]): +//! 1. **Letters A–Z:** the layout's Latin letter for the key, [`KeyInput::latin`] when the +//! platform reports it. Without it, on a layout that is not Latin, the key's position (the +//! Russian `с` key is C, the Hebrew `/` key is Q); on a Latin layout (Dvorak, AZERTY), the +//! letter typed. +//! 2. **Digits:** the digit typed, else the key's position (AZERTY's `&é"` row types no digit +//! without Shift). A digit-row press that types punctuation also matches its position's digit, +//! after the punctuation: AZERTY's `-` key runs Ctrl+- and, when that has no binding, Ctrl+6. +//! 3. **Punctuation:** the character typed, without Shift when the layout needs Shift for it +//! (German `/` is ⇧7, so ⇧⌘7 is ⌘/; US `:` is ⇧;, so ⇧; is `:`). A Shift binding on a US +//! base key also matches the same press (`Shift+;` matches `:`), and `=` and `+` stand for +//! each other so zoom keys work with and without Shift. +//! 4. **Whether a layout is Latin** is decided for the whole layout ([`Layout::is_latin`]), never +//! per key: Dvorak's `'` key on the Q position stays `'`. +//! 5. **Labels** show letters and digits by name, and a punctuation key as the character the +//! layout types at its position when that character has no key of its own: Ctrl+[ is `Ctrl+Ü` +//! on German and `Ctrl+Х` on Russian. +//! +//! [`KeyInput`] carries what the toolkit reports for one press; toolkits that learn more from the +//! platform fill more of it. [`Layout`] holds what is known about the layout itself, reported by +//! the platform or learned from what plain typing produces ([`Layout::observe`]). + +use std::collections::BTreeMap; + +use crate::chord::{Chord, Key, Mods, Platform}; + +/// What the toolkit reports about one key press. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct KeyInput { + /// The modifiers held, read for the platform as [`Mods`] describes. + pub mods: Mods, + /// The key for the character the layout typed with `mods` held, when there is one (egui's + /// logical key). + pub typed: Option, + /// The key at the same position on a US QWERTY keyboard (winit's physical key, the web's + /// `code`). + pub physical: Option, + /// The layout's Latin letter for this key, when the platform reports one: the virtual-key + /// code on Windows, the layout's ⌘ table on macOS, the first Latin layout group on Linux, the + /// key code on the web when the layout is not Latin. Keys other than letters are ignored. + pub latin: Option, + /// Whether the layout needs Shift to type `typed`, when the platform reports it. Otherwise + /// Shift counts as needed when `typed` differs from `physical`, and without `physical` when + /// `typed` is a shifted symbol on a US layout (`:`, `?`, `+`). Only punctuation drops Shift. + pub shift_needed: Option, +} + +impl KeyInput { + pub const fn new(mods: Mods) -> KeyInput { + KeyInput { mods, typed: None, physical: None, latin: None, shift_needed: None } + } + + pub const fn with_typed(mut self, key: Key) -> KeyInput { + self.typed = Some(key); + self + } + + pub const fn with_physical(mut self, key: Key) -> KeyInput { + self.physical = Some(key); + self + } + + pub const fn with_latin(mut self, letter: Key) -> KeyInput { + self.latin = Some(letter); + self + } + + pub const fn with_shift_needed(mut self, needed: bool) -> KeyInput { + self.shift_needed = Some(needed); + self + } + + fn needs_shift(&self, typed: Key) -> bool { + // A shifted symbol is no key position (egui's numeric keypad Plus is the one physical + // key among them), so it says nothing about the Shift level. + self.shift_needed.unwrap_or(match self.physical { + Some(physical) if us_base(physical).is_none() => physical != typed, + _ => us_base(typed).is_some(), + }) + } +} + +/// The active keyboard layout, as far as the platform reports it or typing has shown it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Layout { + reported_latin: Option, + seen_latin: Option, + /// The character typed without modifiers at each punctuation position. + chars: BTreeMap, +} + +/// US base keys and the key egui reports for them with Shift held. +const SHIFTED: &[(Key, Key)] = &[ + (Key::Semicolon, Key::Colon), + (Key::Slash, Key::Questionmark), + (Key::Backslash, Key::Pipe), + (Key::OpenBracket, Key::OpenCurlyBracket), + (Key::CloseBracket, Key::CloseCurlyBracket), + (Key::Equals, Key::Plus), + (Key::Num1, Key::Exclamationmark), +]; + +fn us_base(shifted: Key) -> Option { + SHIFTED.iter().find(|&&(_, s)| s == shifted).map(|&(b, _)| b) +} + +const fn without_shift(m: Mods) -> Mods { + Mods { shift: false, ..m } +} + +/// Letters of the Latin script: ASCII, Latin-1, Latin Extended-A and -B, Latin Extended +/// Additional. +fn is_latin_letter(c: char) -> bool { + c.is_alphabetic() && matches!(c, 'A'..='Z' | 'a'..='z' | '\u{00C0}'..='\u{024F}' | '\u{1E00}'..='\u{1EFF}') +} + +impl Layout { + /// Nothing known yet: the layout counts as Latin until the platform or typing says otherwise. + pub fn new() -> Layout { + Layout::default() + } + + /// Whether the layout is Latin, as the platform reports it. + pub fn with_latin(mut self, latin: bool) -> Layout { + self.reported_latin = Some(latin); + self + } + + /// Whether the layout is Latin, as the platform reports it; `None` when it cannot tell, so + /// that typing decides. Call again when the user switches layouts. + pub fn set_latin(&mut self, latin: Option) { + self.reported_latin = latin; + } + + /// Whether letters are read as typed (rule 1): what the platform reported, else what a letter + /// key typed last, else true. + pub fn is_latin(&self) -> bool { + self.reported_latin.or(self.seen_latin).unwrap_or(true) + } + + /// The character the layout types without modifiers at the punctuation position `physical`. + pub fn with_char(mut self, physical: Key, typed: char) -> Layout { + self.set_char(physical, typed); + self + } + + /// See [`with_char`](Self::with_char). Positions other than punctuation are ignored. + pub fn set_char(&mut self, physical: Key, typed: char) { + if physical.is_symbol() { + self.chars.insert(physical, typed); + } + } + + /// The character known to be typed without modifiers at the punctuation position `physical`. + pub fn char_at(&self, physical: Key) -> Option { + self.chars.get(&physical).copied() + } + + /// Learns from a press at `physical` with `mods` held that typed `text`: a letter typed at a + /// letter position tells whether the layout is Latin, and a character typed without + /// modifiers at a punctuation position is that position's label. Presses with Cmd, Ctrl or + /// Alt, and text longer than one character, teach nothing. + pub fn observe(&mut self, physical: Key, mods: Mods, text: &str) { + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return }; + if mods.cmd || mods.ctrl || mods.alt { + return; + } + if physical.is_letter() { + if c.is_alphabetic() { + self.seen_latin = Some(is_latin_letter(c)); + } + } else if !mods.shift && !c.is_control() { + self.set_char(physical, c); + } + } + + /// The chord a press means, as the shortcut recorder records it and lookups try first. + /// `None` when the press names no key. + pub fn resolve(&self, input: &KeyInput) -> Option { + let held = input.mods; + if let Some(letter) = input.latin.filter(|k| k.is_letter()) { + return Some(Chord::new(held, letter)); + } + if !self.is_latin() + && let Some(position) = input.physical.filter(|k| k.is_letter()) + { + return Some(Chord::new(held, position)); + } + let key = input.typed.or(input.physical)?; + let drop_shift = held.shift && key.is_symbol() && input.needs_shift(key); + Some(Chord::new(if drop_shift { without_shift(held) } else { held }, key)) + } + + /// The chords a press can run, most specific first: [`resolve`](Self::resolve)'s chord, the + /// same press written as Shift with a US base key (`Shift+;` for `:`), `=` for `+` and `+` + /// for Shift+`=`, and on the digit row the position's digit. + pub fn candidates(&self, input: &KeyInput) -> Vec { + let Some(first) = self.resolve(input) else { return Vec::new() }; + let mut out = Vec::with_capacity(4); + let mut push = |chord: Chord| { + if !out.contains(&chord) { + out.push(chord); + } + }; + push(first); + if input.mods.shift + && !first.mods.shift + && let Some(base) = us_base(first.key) + { + push(Chord::new(input.mods, base)); + } + match first.key { + Key::Plus => push(Chord::new(first.mods, Key::Equals)), + Key::Equals if first.mods.shift => push(Chord::new(without_shift(first.mods), Key::Plus)), + _ => {} + } + if let Some(digit) = input.physical.filter(|k| k.is_digit()) + && first.key.is_symbol() + { + push(Chord::new(input.mods, digit)); + } + out + } + + /// Whether a press runs `binding` on `platform`: one of its [`candidates`](Self::candidates) + /// is the binding. + pub fn matches(&self, binding: &Chord, input: &KeyInput, platform: Platform) -> bool { + let want = binding.effective(platform); + self.candidates(input).iter().any(|c| c.effective(platform) == want) + } + + /// Rule 5: the layout's character for the punctuation key `key`, when pressing that position + /// means `key` and the layout types `key`'s own character nowhere else that is known. + pub(crate) fn key_label(&self, key: Key) -> Option { + let typed = self.char_at(key)?; + if Key::from_char(typed).is_some() { + return None; + } + let own = key.name().chars().next()?; + if self.chars.values().any(|&c| c == own) { + return None; + } + let mut upper = typed.to_uppercase(); + Some(match (upper.next(), upper.next()) { + (Some(u), None) => u.to_string(), + _ => typed.to_string(), + }) + } +} diff --git a/crates/keymap/src/lib.rs b/crates/keymap/src/lib.rs index 9816ecda..7493a998 100644 --- a/crates/keymap/src/lib.rs +++ b/crates/keymap/src/lib.rs @@ -9,7 +9,8 @@ //! editing and conflicts. [`reserved`] lists the chords each operating system takes; a keymap //! reserves no chord unless the app opts in with [`Keymap::with_reserved`], e.g. //! `with_reserved(reserved::system(platform))`. [`import`] brings in other applications' shortcut -//! sets. With the `egui` feature, the `egui` module +//! sets. [`layout`] decides which chord a key press means on the user's keyboard layout, for +//! matching, recording and labels. With the `egui` feature, the `egui` module //! converts to and from egui keys, matches key presses against bindings and dispatches a frame's //! key events. //! @@ -136,6 +137,7 @@ pub mod command; pub mod egui; pub mod import; pub mod keymap; +pub mod layout; pub mod reserved; pub mod scope; @@ -147,4 +149,5 @@ pub use keymap::{ AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT, MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, text_editing_chords, }; +pub use layout::{KeyInput, Layout}; pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes}; diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index c1452e47..c7332b91 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -1,7 +1,7 @@ #![cfg(feature = "egui")] use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; -use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; use egui::{Event, InputState, Key as EguiKey, Modifiers}; fn chord(text: &str) -> Chord { @@ -453,3 +453,37 @@ fn key_repeats_run_only_when_turned_on() { assert_eq!(ran, ["nudge.right"]); assert_eq!(left, [held(true), held(true)]); } + +#[test] +fn the_dispatcher_reads_presses_by_the_layout() { + let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap()); + let km = Keymap::new( + vec![app("edit.duplicate", "Cmd+Shift+D"), app("tab.next", "Cmd+Shift+]"), app("edit.comment", "Cmd+/")], + Scopes::new(), + Platform::Windows, + ); + let at = |key, physical, modifiers| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers }; + let ctrl_shift = Modifiers { shift: true, ..CTRL }; + + // Arabic 101: typing ي on the D key shows the layout is not Latin, so ⌃⇧D (which types ]) is + // Ctrl+Shift+D. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::D, EguiKey::D, Modifiers::NONE), Event::Text("ي".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert!(!d.layout().is_latin()); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); + + // German: ⇧7 types /, so Ctrl+Shift+7 runs Ctrl+/; the ü key's label is learned from typing. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::OpenBracket, EguiKey::OpenBracket, Modifiers::NONE), Event::Text("ü".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + assert_eq!(chord("Cmd+[").display_for(Platform::Windows, d.layout()), "Ctrl+Ü"); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)])); + assert_eq!(ran, ["edit.comment"]); + + // A layout the platform reports applies from the first press. + let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); + let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); + assert_eq!(ran, ["edit.duplicate"]); +} diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs new file mode 100644 index 00000000..31f344f3 --- /dev/null +++ b/crates/keymap/tests/layout.rs @@ -0,0 +1,157 @@ +use craft_keymap::{Chord, Key, KeyInput, Layout, Mods, Platform}; + +fn chord(text: &str) -> Chord { + Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +/// A press as egui reports it at the US position `physical`: the modifiers held and the logical +/// key, which is the physical key when the character typed has no key (`ф`, `é`, `ข`). +fn at(reported: &str, physical: Key) -> KeyInput { + let c = chord(reported); + KeyInput::new(c.mods).with_typed(c.key).with_physical(physical) +} + +fn texts(chords: &[Chord]) -> Vec { + chords.iter().map(Chord::to_string).collect() +} + +#[test] +fn each_layout_resolves_presses_by_the_rule() { + let latin = Layout::new(); + let other = Layout::new().with_latin(false); + let rows: &[(&str, &Layout, KeyInput, &str)] = &[ + // Russian: letters type Cyrillic, which egui reports as the physical key. + ("Russian ⌘С", &other, at("Cmd+C", Key::C), "Cmd+C"), + ("Russian ⌘Х", &other, at("Cmd+[", Key::OpenBracket), "Cmd+["), + ("Russian / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + ("Russian ⇧7 types ?", &other, at("Shift+?", Key::Num7), "?"), + // Arabic 101: ⇧D types ]. + ("Arabic ⌃⇧D", &other, at("Cmd+Shift+]", Key::D), "Cmd+Shift+D"), + ("Arabic ⌃V", &other, at("Cmd+V", Key::V), "Cmd+V"), + // Hebrew: Q types /, W types ', the / position types .. + ("Hebrew ⌃Q", &other, at("Cmd+/", Key::Q), "Cmd+Q"), + ("Hebrew ⌃W", &other, at("Cmd+'", Key::W), "Cmd+W"), + ("Hebrew / key types .", &other, at("Cmd+.", Key::Slash), "Cmd+."), + // Greek: Q types ;. + ("Greek ⌃Q", &other, at("Cmd+;", Key::Q), "Cmd+Q"), + ("Greek ⌃Z", &other, at("Cmd+Z", Key::Z), "Cmd+Z"), + // Thai Kedmanee: 2 types /, - types ข, D types ก. + ("Thai 2 key types /", &other, at("Cmd+/", Key::Num2), "Cmd+/"), + ("Thai - key types ข", &other, at("Cmd+-", Key::Minus), "Cmd+-"), + ("Thai ⌃D", &other, at("Cmd+D", Key::D), "Cmd+D"), + // AZERTY. + ("AZERTY A", &latin, at("Cmd+A", Key::Q), "Cmd+A"), + ("AZERTY M", &latin, at("Cmd+M", Key::Semicolon), "Cmd+M"), + ("AZERTY M key types ,", &latin, at("Cmd+,", Key::M), "Cmd+,"), + ("AZERTY & key", &latin, at("Cmd+1", Key::Num1), "Cmd+1"), + ("AZERTY ⇧& types 1", &latin, at("Cmd+Shift+1", Key::Num1), "Cmd+Shift+1"), + ("AZERTY 6 key types -", &latin, at("Cmd+-", Key::Num6), "Cmd+-"), + ("AZERTY ⇧; types .", &latin, at("Shift+.", Key::Comma), "."), + // Dvorak. + ("Dvorak O", &latin, at("Cmd+O", Key::S), "Cmd+O"), + ("Dvorak ⇧O keeps Shift", &latin, at("Cmd+Shift+O", Key::S), "Cmd+Shift+O"), + ("Dvorak Q key types '", &latin, at("Cmd+'", Key::Q), "Cmd+'"), + ("Dvorak W key types ,", &latin, at("Cmd+,", Key::W), "Cmd+,"), + // QWERTZ (German). + ("QWERTZ ⇧⌘7 types /", &latin, at("Cmd+Shift+/", Key::Num7), "Cmd+/"), + ("QWERTZ Z", &latin, at("Cmd+Z", Key::Y), "Cmd+Z"), + ("QWERTZ ⇧ß types ?", &latin, at("Cmd+Shift+?", Key::Minus), "Cmd+?"), + ("QWERTZ + key", &latin, at("Cmd++", Key::CloseBracket), "Cmd++"), + ("QWERTZ ⇧, types ;", &latin, at("Cmd+Shift+;", Key::Comma), "Cmd+;"), + // US. + ("US ⇧;", &latin, at("Shift+:", Key::Semicolon), ":"), + ("US ⇧⌘=", &latin, at("Cmd+Shift++", Key::Equals), "Cmd++"), + ("US ⇧- types _, which has no key", &latin, at("Shift+-", Key::Minus), "Shift+-"), + // macOS "Dvorak – QWERTY ⌘": with ⌘ held the layout's ⌘ table is QWERTY, while the + // logical key egui reports is Dvorak's K. + ("Dvorak – QWERTY ⌘ ⌘V", &latin, at("Cmd+K", Key::V).with_latin(Key::V), "Cmd+V"), + // A Shift level the platform reports wins over the inference. + ("reported Shift level", &latin, at("Cmd+Shift+/", Key::Num7).with_shift_needed(false), "Cmd+Shift+/"), + ]; + for (name, layout, input, want) in rows { + assert_eq!(layout.resolve(input).map(|c| c.to_string()).as_deref(), Some(*want), "{name}"); + } +} + +#[test] +fn reported_cases_match_only_the_chord_the_press_means() { + let p = Platform::Windows; + let arabic = Layout::new().with_latin(false); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + assert!(arabic.matches(&chord("Ctrl+Shift+D"), &ctrl_shift_d, p)); + assert!(!arabic.matches(&chord("Ctrl+Shift+]"), &ctrl_shift_d, p), "Arabic Ctrl+Shift+D must not run Ctrl+Shift+]"); + + let qwerty_cmd = at("Cmd+K", Key::V).with_latin(Key::V); + assert!(Layout::new().matches(&chord("Cmd+V"), &qwerty_cmd, Platform::Mac)); + assert!(!Layout::new().matches(&chord("Cmd+K"), &qwerty_cmd, Platform::Mac), "⌘V must not open the command palette"); + + let german = Layout::new(); + let slash = at("Cmd+Shift+/", Key::Num7); + assert_eq!(texts(&german.candidates(&slash)), ["Cmd+/", "Cmd+Shift+7"]); + assert!(german.matches(&chord("⌘/"), &slash, Platform::Mac)); + // A US-written ⌘? binding is reached with German's ⇧ß, not with ⇧7. + assert!(!german.matches(&chord("Cmd+Shift+/"), &slash, p)); + assert_eq!(texts(&german.candidates(&at("Cmd+Shift+?", Key::Minus))), ["Cmd+?", "Cmd+Shift+/"]); + // German Ctrl+; is Ctrl+Shift+, and must not run a Ctrl+Shift+, (font size) binding. + let semicolon = at("Cmd+Shift+;", Key::Comma); + assert!(german.matches(&chord("Ctrl+;"), &semicolon, p)); + assert!(!german.matches(&chord("Ctrl+Shift+,"), &semicolon, p)); + + // AZERTY's 6 key types -: Ctrl+- first, then Ctrl+6. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+-", Key::Num6))), ["Cmd+-", "Cmd+6"]); + // Zoom keys: + also runs =, and Shift+= runs +. + assert_eq!(texts(&Layout::new().candidates(&at("Cmd+Shift++", Key::Equals))), ["Cmd++", "Cmd+Shift+=", "Cmd+="]); + assert_eq!(texts(&Layout::new().candidates(&KeyInput::new(Mods::SHIFT).with_typed(Key::Equals))), ["Shift+=", "+"]); +} + +#[test] +fn typing_decides_whether_the_layout_is_latin() { + let mut layout = Layout::new(); + assert!(layout.is_latin(), "nothing known counts as Latin"); + let ctrl_shift_d = at("Cmd+Shift+]", Key::D); + // Read as punctuation until the layout is known: ] needs Shift on the D key. + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+]"))); + + layout.observe(Key::D, Mods::NONE, "ي"); + assert!(!layout.is_latin()); + assert_eq!(layout.resolve(&ctrl_shift_d), Some(chord("Cmd+Shift+D"))); + + // Punctuation on a letter key says nothing: Hebrew Q and Dvorak Q both type a symbol. + layout.observe(Key::Q, Mods::NONE, "/"); + assert!(!layout.is_latin()); + layout.observe(Key::A, Mods::SHIFT, "A"); + assert!(layout.is_latin()); + layout.observe(Key::W, Mods::NONE, "ς"); + assert!(!layout.is_latin()); + layout.observe(Key::S, Mods::CMD, "s"); + assert!(!layout.is_latin(), "a command chord teaches nothing"); + layout.observe(Key::S, Mods::NONE, "š"); + assert!(layout.is_latin(), "Latin letters beyond ASCII count as Latin"); + + // What the platform reports wins. + let mut reported = Layout::new().with_latin(false); + reported.observe(Key::A, Mods::NONE, "a"); + assert!(!reported.is_latin()); + reported.set_latin(None); + assert!(reported.is_latin()); +} + +#[test] +fn labels_show_the_layouts_own_punctuation() { + let mut german = Layout::new().with_char(Key::OpenBracket, 'ü').with_char(Key::Slash, '-').with_char(Key::CloseBracket, '+'); + german.observe(Key::Minus, Mods::NONE, "ß"); + let label = |layout: &Layout, text: &str, p| chord(text).display_for(p, layout); + assert_eq!(label(&german, "Cmd+[", Platform::Windows), "Ctrl+Ü"); + assert_eq!(label(&german, "Cmd+[", Platform::Mac), "⌘Ü"); + // German types - at the / position, so Ctrl+- shows -; its + key types a key of its own. + assert_eq!(label(&german, "Cmd+-", Platform::Windows), "Ctrl+-"); + assert_eq!(label(&german, "Cmd+]", Platform::Windows), "Ctrl+]"); + assert_eq!(label(&german, "Cmd+Z", Platform::Windows), "Ctrl+Z"); + assert_eq!(label(&german, "Cmd+1", Platform::Windows), "Ctrl+1"); + + let russian = Layout::new().with_latin(false).with_char(Key::OpenBracket, 'х'); + assert_eq!(label(&russian, "Cmd+[", Platform::Linux), "Ctrl+Х"); + assert_eq!(label(&russian, "Cmd+C", Platform::Linux), "Ctrl+C"); + assert_eq!(craft_keymap::Trigger::double(chord("[")).display_for(Platform::Linux, &russian), "Х Х"); + assert_eq!(label(&Layout::new(), "Cmd+[", Platform::Linux), "Ctrl+["); +} From cf36f3cb215323845809c9688db681af2d835af9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:14:23 +0000 Subject: [PATCH 066/124] i18n: numbers for a region on ICU4X, locale digits in counts and sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format writes integers, decimals and percentages for a Region on ICU4X's decimal formatter: the locale's digits (Arabic-Indic in Egypt, Persian in Iran) or Latin ones on request, its separators and grouping sizes (Indian 12,34,567), CLDR's percent patterns, and the region's own separators where the system sets them. I18n::format holds the one trn, file_size and measure use, the system region's until set_format. Plural rules move from intl_pluralrules to ICU4X, and trn_decimal picks the form from the digits shown (1.0 files, 1,5 файла). The CLDR data (decimal symbols, digits, plural rules, percent patterns) covers the shipped languages and their regional variants; data/generate.sh rebuilds it. --- Cargo.lock | 170 +++++++++++++++--- crates/i18n/Cargo.toml | 14 +- crates/i18n/README.md | 29 ++- crates/i18n/data/generate.sh | 23 +++ crates/i18n/data/locales.rs | 79 +++++++++ crates/i18n/data/numbers.postcard | Bin 0 -> 5664 bytes crates/i18n/data/percent.py | 64 +++++++ crates/i18n/src/format.rs | 286 ++++++++++++++++++++++++++++++ crates/i18n/src/lib.rs | 148 +++++++++++++--- crates/i18n/src/size.rs | 49 ++--- 10 files changed, 777 insertions(+), 85 deletions(-) create mode 100755 crates/i18n/data/generate.sh create mode 100644 crates/i18n/data/locales.rs create mode 100644 crates/i18n/data/numbers.postcard create mode 100644 crates/i18n/data/percent.py create mode 100644 crates/i18n/src/format.rs diff --git a/Cargo.lock b/Cargo.lock index 11eebe64..7fc992df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -656,11 +665,18 @@ version = "0.1.0" name = "craft-i18n" version = "0.1.0" dependencies = [ - "intl_pluralrules", + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "objc2-foundation 0.3.2", "sys-locale", - "unic-langid", "windows-registry", + "writeable", ] [[package]] @@ -1232,6 +1248,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1861,6 +1888,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -1869,11 +1913,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -1894,6 +1959,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -1923,6 +2014,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1930,6 +2024,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -1997,15 +2115,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2250,6 +2359,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3081,12 +3193,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4717,24 +4841,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5794,6 +5900,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11rb" @@ -5987,8 +6096,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index bfff93ce..3daaca10 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-i18n" -description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale." +description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, numbers for a region, language names and the system locale." version.workspace = true edition.workspace = true license.workspace = true @@ -8,8 +8,16 @@ rust-version.workspace = true repository.workspace = true [dependencies] -intl_pluralrules = "=7.0.2" -unic-langid = "0.9" +# Numbers and plural rules, on CLDR data for the shipped locales (data/numbers.postcard). +fixed_decimal = { version = "0.7.2", default-features = false } +icu_decimal = { version = "2.3", default-features = false, features = ["serde"] } +icu_locale_core = { version = "2.3", default-features = false } +icu_locale_fallback = { version = "2.3", default-features = false, features = ["compiled_data"] } +icu_plurals = { version = "2.3", default-features = false, features = ["serde"] } +icu_provider = { version = "2.3", default-features = false, features = ["sync"] } +icu_provider_adapters = { version = "2.3", default-features = false } +icu_provider_blob = { version = "2.3", default-features = false } +writeable = "0.6" sys-locale = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 5d37b72e..bb48bbad 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -45,7 +45,8 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l → `pt`, `zh-hant-hk` → `zh-hant`); `sr-latn` never falls back to Cyrillic `sr`. A missing translation never errors. - **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an - `@id` command id, else the English label), `trn` (a count, `@plural` rows, `{n}` filled), `get` + `@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows, + `{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. @@ -54,10 +55,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before they ship. - **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories - whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the - last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` - `pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and - `plural_form_counts` holds the same counts. + whole numbers 0 to 1000 reach, from ICU4X's CLDR rules; `plural_form_counts` holds the counts + craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural + operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers + never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`), + else the last. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` + `pt`'s. A language outside the baked data (below) has no plural rules and shows English forms. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and @@ -74,8 +77,20 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals, "{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold) - is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are - ASCII digits with a decimal point. + is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are written + by `I18n::format`. +- **Numbers:** `Format` writes numbers for a `Region` on ICU4X's decimal formatter: `integer` + (`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)` + (`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's + digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin + digits, a decimal or grouping separator the region sets replaces the locale's (English in Poland: + `1.234,5`). `I18n::format` is the format `trn`, `file_size` and `measure` use: the system + region's until `I18n::set_format` (the user's region setting or digit choice). +- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 6 KB) and + percent patterns (`data/locales.rs`) cover the languages the catalogs and apps ship, with their + regional variants; `data/generate.sh` lists them and rebuilds both from CLDR. Add a language + there when a catalog adds one. The ICU4X code and data add about 90 KB to a stripped release + build and 80 KB to wasm. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, the decimal separator, and the grouping separator and clock when the system sets diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh new file mode 100755 index 00000000..695a698a --- /dev/null +++ b/crates/i18n/data/generate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Regenerates the number and plural data in this directory from Unicode CLDR, for the languages +# the craft-i18n catalogs and the apps ship (each with its regional variants). +# +# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X version in Cargo.toml) and +# python3. Downloads CLDR's JSON release once into $CACHE. +set -euo pipefail + +CLDR=48.2.1 +LANGUAGES=(en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt ru sl sr sv te tr uk vi zh) +HERE=$(cd "$(dirname "$0")" && pwd) +CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data} +mkdir -p "$CACHE" + +# `sr-Latn` and `zh-Hant` are named as well: their CLDR parent is the root, not `sr` or `zh`. +TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ + --cldr-tag "$CLDR" \ + --markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \ + --locales "${LANGUAGES[@]}" sr-Latn zh-Hant \ + --out "$HERE/numbers.postcard" + +ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip +python3 -I "$HERE/percent.py" "$ZIP" "$CLDR" "${LANGUAGES[@]}" > "$HERE/locales.rs" diff --git a/crates/i18n/data/locales.rs b/crates/i18n/data/locales.rs new file mode 100644 index 00000000..a2ad6e72 --- /dev/null +++ b/crates/i18n/data/locales.rs @@ -0,0 +1,79 @@ +// @generated by generate.sh from Unicode CLDR 48.2.1 (Unicode License). + +/// The languages the baked number and plural data cover, each with its regional variants. +pub(crate) const LANGUAGES: &[&str] = &["en", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fa", "fi", "fr", "hi", "hr", "hu", "id", "it", "ja", "ka", "kk", "ko", "lt", "nl", "pl", "pt", "ru", "sl", "sr", "sv", "te", "tr", "uk", "vi", "zh"]; + +/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin +/// digits or the locale's own, text before the number, text after it. A locale missing here +/// takes its parent's by dropping trailing subtags, then `{n}%`. +pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ + ("ar", true, "", "\u{200e}%\u{200e}"), + ("ar-BH", false, "", "\u{66a}\u{61c}"), + ("ar-DJ", false, "", "\u{66a}\u{61c}"), + ("ar-EG", false, "", "\u{66a}\u{61c}"), + ("ar-ER", false, "", "\u{66a}\u{61c}"), + ("ar-IL", false, "", "\u{66a}\u{61c}"), + ("ar-IQ", false, "", "\u{66a}\u{61c}"), + ("ar-JO", false, "", "\u{66a}\u{61c}"), + ("ar-KM", false, "", "\u{66a}\u{61c}"), + ("ar-KW", false, "", "\u{66a}\u{61c}"), + ("ar-LB", false, "", "\u{66a}\u{61c}"), + ("ar-MR", false, "", "\u{66a}\u{61c}"), + ("ar-OM", false, "", "\u{66a}\u{61c}"), + ("ar-PS", false, "", "\u{66a}\u{61c}"), + ("ar-QA", false, "", "\u{66a}\u{61c}"), + ("ar-SA", false, "", "\u{66a}\u{61c}"), + ("ar-SA", true, "", "\u{66a}"), + ("ar-SD", false, "", "\u{66a}\u{61c}"), + ("ar-SO", false, "", "\u{66a}\u{61c}"), + ("ar-SO", true, "", "\u{66a}"), + ("ar-SS", false, "", "\u{66a}\u{61c}"), + ("ar-SY", false, "", "\u{66a}\u{61c}"), + ("ar-TD", false, "", "\u{66a}\u{61c}"), + ("ar-YE", false, "", "\u{66a}\u{61c}"), + ("cs", true, "", "\u{a0}%"), + ("da", true, "", "\u{a0}%"), + ("de", true, "", "\u{a0}%"), + ("de-CH", true, "", "%"), + ("de-LI", true, "", "%"), + ("en-AT", true, "", "\u{a0}%"), + ("en-DE", true, "", "\u{a0}%"), + ("en-DK", true, "", "\u{a0}%"), + ("en-FI", true, "", "\u{a0}%"), + ("en-NO", true, "", "\u{a0}%"), + ("en-SE", true, "", "\u{a0}%"), + ("es", true, "", "\u{a0}%"), + ("es-419", true, "", "%"), + ("es-AR", true, "", "%"), + ("es-BO", true, "", "%"), + ("es-BR", true, "", "%"), + ("es-BZ", true, "", "%"), + ("es-CL", true, "", "%"), + ("es-CO", true, "", "%"), + ("es-CR", true, "", "%"), + ("es-CU", true, "", "%"), + ("es-DO", true, "", "%"), + ("es-EC", true, "", "%"), + ("es-GT", true, "", "%"), + ("es-HN", true, "", "%"), + ("es-MX", true, "", "%"), + ("es-NI", true, "", "%"), + ("es-PA", true, "", "%"), + ("es-PE", true, "", "%"), + ("es-PR", true, "", "%"), + ("es-PY", true, "", "%"), + ("es-SV", true, "", "%"), + ("es-US", true, "", "%"), + ("es-UY", true, "", "%"), + ("es-VE", true, "", "%"), + ("fa", false, "", "\u{66a}"), + ("fi", true, "", "\u{a0}%"), + ("fr", true, "", "\u{a0}%"), + ("fr-CH", true, "", "%"), + ("hr", true, "", "\u{a0}%"), + ("lt", true, "", "\u{a0}%"), + ("ru", true, "", "\u{a0}%"), + ("sl", true, "", "\u{a0}%"), + ("sv", true, "", "\u{a0}%"), + ("tr", true, "%", ""), +]; diff --git a/crates/i18n/data/numbers.postcard b/crates/i18n/data/numbers.postcard new file mode 100644 index 0000000000000000000000000000000000000000..53764d4cc9c73c84627d11699de9e5f564923c92 GIT binary patch literal 5664 zcmcJTTTola8OP7rBgEC%*sdMNvSJg!iH!)()wy&WA%sMP5FoB@XHwZvqacJw2-j|> zjbwwtsgtblCP^FLu&gSSD1_J!w6zaVr&(ZF0zuoLdJZzYsb#kwjZ{;pX=~= zcl)~iJ$ru4aHVP0D;<|MGiU1x>At5;&+h1*nlIRDEcfw}`5k(Lpmq6NJ9zPYm$9te zT=|Tx%I>JCb=J2C@k>;$EZq|wN6!D@Z)vOVfAW+&+TA zetS)cGo{9uQZsEVcQ{+}@*gTsY5F!QUn{g>#ST~&vkNT1LJzGn{V}ayZr)V%dHl+%IwyQ@(wG=RKpzYO^=D#2a&! zdcE4p3#QB#n;jjMKIk`Tu5&UCFMy$&#-#0a6jk|5F^k^E?*>+5cQ&-dhRa>HT!~hc zh^dS%+mNJuUT~w_TxG9z)W_$h&TIqPbdzR#4aQRZDRX5tMO33qn~c^Uaekw{P_tUMlMi zS`WR<9xzt$Qhn@^Z{kIUkC7WnBj!jO)zq@GbSurH=cj!y4h~(4Oq(kzpQ%#zuZYKg zZWznyH-U~@Mph1{8mnJRcf2ea-KUKe)it$?`_=OzJ1=VBIvi%~Y^}0dM99Td}!bE!FCC123xJMF>CT_W663uUX4C(285z_PF@^Dq8mj zzR^%-y{O zuElC_Z@{Ai*}`MJ@?RA%?7_%WWj1>QMrc773)=A;Q_c&Fyl~Rv+rvuveF#5YW~#C` zOWAAnMy}iE^BC;dZ(1ncUuDe_Ds9RUK*={5WMhTY%r{l?O>&zrlW)-b+dMt|UdSfA z>EsSC25IH_Zmw&`0n!^X+g)CFYuiqK|D@IKtnHL-F1(KPiCVAR?Z3w$TkT71$<;Wp z3(jqU>@CTSvZG~Dz~nYd@`pd)>2CpzHs`Jln@S&i^1ItAZQFma%k6u4SFrmK-udE- z{ZsTZ7nv07-``KiUt|aSdnet#9z47h#NLa60at)FT4cdsz~ATT$EJxd95B?>5A+GJQ>WaRAYb?+mRm;$N&Za5wk~_!O8$ zK{yR2z)euY1mQe*RuY7d!C{Rc+yJfUcolpI{sA(zg0K-dz>mP|;0E{-tWFn%ZD1F8 z8C(W)AU#769so_?c@P30fq#R0G6lf`_JIia3-}U=6)GMey(Ea+; zWlIyE<-`*nu;|!@jA0-Qa6MiTAN5-vV0F>}%7`?oGUB&9ke(qg*O2E#1_Q15KnY$! zro4Leh!ff_UHXCKLZ?V}?Hv2Z49b>cau-VIO_6nQ5%H{%WF39Hgv$+Fou9waA zvAKh6uAj{fu(?Ch&7q4R1TKLv_!WqNH^4CXH5id@hTw(Zh2VwYh2VwYh2VwYh2VwY zh2e$ah2e$ah2e$ah2e$ah2e$aMc_r?Mc_r?Mc_r?Mc_r?Mc_r?4Z|CTHwt`fH$Hx^*QW-Fo*h?uVq$UwswN$EDBvhVl6T z?h)zM)n7jj5=Br=!1*iy8v7d?q zRP<8ON5w%Z`l%S8;t+}Hnh#bB-|GA3yZn~OKnw_QmR z7ivnru7)L?l9XO*O_EbgUoa)M5V@pSiJV%KG3?v1N*- zh4oorB(5!!B#kCb``N(J&Bf|k?oh|_)Etx)$G^H^*gU0ky3#o@@SQ5zN=ee<3;rjs z89sS!^Gsys4f-FXh_F(l$;ime`YiC00W3`6w)%K7Byp8io0XNF0~do*k3`E_YRSo6 MXi-POcP0$ ... +""" + +import json +import re +import sys +import zipfile + + +def rust(s): + return '"' + "".join(c if " " <= c <= "~" and c not in '"\\' else f"\\u{{{ord(c):x}}}" for c in s) + '"' + + +def affixes(numbers, numsys): + pattern = numbers[f"percentFormats-numberSystem-{numsys}"]["standard"].split(";")[0].replace("'", "") + sign = numbers[f"symbols-numberSystem-{numsys}"]["percentSign"] + number = re.search(r"[#0,.]+", pattern) + return pattern[: number.start()].replace("%", sign), pattern[number.end() :].replace("%", sign) + + +def main(): + path, version, languages = sys.argv[1], sys.argv[2], sys.argv[3:] + found = {} + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name) + if not m or m.group(1).split("-")[0] not in languages: + continue + locale = m.group(1) + numbers = json.loads(z.read(name))["main"][locale]["numbers"] + found[(locale, True)] = affixes(numbers, "latn") + default = numbers["defaultNumberingSystem"] + if default != "latn": + found[(locale, False)] = affixes(numbers, default) + + # Kept only where the lookup by dropping trailing subtags would find something else. + kept = {} + for (locale, latin), value in sorted(found.items(), key=lambda e: (e[0][0].count("-"), e[0])): + parts = locale.split("-") + inherited = ("", "%") + for n in range(len(parts) - 1, 0, -1): + if ("-".join(parts[:n]), latin) in kept: + inherited = kept[("-".join(parts[:n]), latin)] + break + if value != inherited: + kept[(locale, latin)] = value + + print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).") + print() + print("/// The languages the baked number and plural data cover, each with its regional variants.") + print("pub(crate) const LANGUAGES: &[&str] = &[" + ", ".join(rust(l) for l in languages) + "];") + print() + print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin") + print("/// digits or the locale's own, text before the number, text after it. A locale missing here") + print("/// takes its parent's by dropping trailing subtags, then `{n}%`.") + print("pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[") + for (locale, latin), (before, after) in sorted(kept.items()): + print(f" ({rust(locale)}, {str(latin).lower()}, {rust(before)}, {rust(after)}),") + print("];") + + +main() diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs new file mode 100644 index 00000000..07dfd70c --- /dev/null +++ b/crates/i18n/src/format.rs @@ -0,0 +1,286 @@ +//! Numbers, percentages and counts written for a formatting [`Region`], on ICU4X's decimal +//! formatter with CLDR data for the shipped languages (`data/generate.sh`). + +use std::fmt; + +use fixed_decimal::{Decimal, Sign}; +use icu_decimal::{DecimalFormatter, parts}; +use icu_locale_core::Locale; +use icu_locale_core::extensions::unicode::{key, value}; +use writeable::{Part, PartsWrite, Writeable}; + +use crate::Region; +use crate::data::{self, PERCENT}; + +/// Which digits numbers are written in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Digits { + /// The locale's own: Arabic-Indic in Egypt (`١٢٣`), Persian in Iran (`۱۲۳`), else `0`–`9`. + #[default] + Locale, + /// `0`–`9` everywhere, for users who read Arabic or Persian with Latin digits. + Latin, +} + +/// How numbers are written in a formatting region: digits, decimal and grouping separators, +/// grouping sizes (`1,234,567`, Indian `12,34,567`) and the percent sign. Build it once per region +/// and keep it; [`crate::I18n`] holds one for counts, sizes and measures. +#[derive(Debug)] +pub struct Format { + /// `None` only if the data doesn't load: numbers are then written as Rust writes them. + decimal: Option, + /// The region's separators where the system set them apart from the locale's. + group: Option, + point: Option, + percent: (&'static str, &'static str), +} + +impl Format { + /// The format for `region`'s number locale. With Latin digits, a decimal or grouping separator + /// the region sets (Windows' and macOS's own settings, English in continental Europe) replaces + /// the locale's. + pub fn new(region: &Region, digits: Digits) -> Format { + let mut locale = Locale::try_from_str(®ion.numbers).unwrap_or(Locale::UNKNOWN); + if digits == Digits::Latin { + locale.extensions.unicode.keywords.set(key!("nu"), value!("latn")); + } + let decimal = data::provider().and_then(|p| DecimalFormatter::try_new_with_buffer_provider(p, (&locale).into(), Default::default()).ok()); + let mut format = Format { decimal, group: None, point: None, percent: ("", "%") }; + // The locale's own separators and digits, read from a number that shows them all. + let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); + let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); + if latin { + let point = region.decimal.to_string(); + if probe.point != point { + // `1,234.5` with a decimal comma becomes `1.234,5`, never `1,234,5`. + if probe.group == point && region.grouping.is_none() { + format.group = Some(probe.point.clone()); + } + format.point = Some(point); + } + if let Some(g) = region.grouping.map(String::from).filter(|g| *g != probe.group) { + format.group = Some(g); + } + } + format.percent = percent_affixes(®ion.numbers, latin); + format + } + + /// The format for a locale tag (`de-CH`, `ar-EG`), with the locale's own digits. + pub fn from_tag(tag: &str) -> Format { + Format::new(&Region::from_tag(tag), Digits::Locale) + } + + /// A whole number: `1,234,567`, `1.234.567`, `١٬٢٣٤٬٥٦٧`. + pub fn integer(&self, n: impl Into) -> String { + self.write(&Decimal::from(n.into())) + } + + /// `value` rounded to `decimals` digits after the decimal separator. + /// Infinities and NaN are written as Rust writes them. + pub fn number(&self, value: f64, decimals: usize) -> String { + match to_decimal(value, decimals) { + Some(d) => self.write(&d), + None => value.to_string(), + } + } + + /// `ratio` as a percentage with `decimals` digits after the decimal separator: `0.5` is + /// `50%`, `50 %` (French, German), `%50` (Turkish), `٥٠٪؜` (Egyptian Arabic). + pub fn percent(&self, ratio: f64, decimals: usize) -> String { + let Some(d) = to_decimal(ratio * 100.0, decimals) else { + return format!("{}%", ratio * 100.0); + }; + let p = self.parts(&d); + format!("{}{}{}{}", p.sign, self.percent.0, p.text, self.percent.1) + } + + /// `d` as text, the sign in place. + pub(crate) fn write(&self, d: &Decimal) -> String { + let p = self.parts(d); + p.sign + &p.text + } + + fn parts(&self, d: &Decimal) -> Written { + let mut sink = Sink { group: self.group.as_deref(), point: self.point.as_deref(), into: Target::Text, w: Written::default() }; + let written = match &self.decimal { + Some(f) => f.format(d).write_to_parts(&mut sink), + None => fmt::Write::write_str(&mut sink, &d.to_string()), + }; + if written.is_err() { + return Written { text: d.to_string(), ..Written::default() }; + } + sink.w + } +} + +/// A formatted number taken apart: its sign, the rest, and the separators and integer digits as +/// the locale wrote them. +#[derive(Default)] +struct Written { + sign: String, + text: String, + integer: String, + group: String, + point: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Target { + Text, + Sign, + /// A separator the region replaces; the locale's goes to `group` or `point` only. + Replaced, +} + +/// Writes the formatter's parts into [`Written`], swapping in the region's separators. +struct Sink<'a> { + group: Option<&'a str>, + point: Option<&'a str>, + into: Target, + w: Written, +} + +impl fmt::Write for Sink<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + match self.into { + Target::Text => self.w.text.push_str(s), + Target::Sign => self.w.sign.push_str(s), + Target::Replaced => {} + } + Ok(()) + } +} + +impl PartsWrite for Sink<'_> { + type SubPartsWrite = Self; + + fn with_part(&mut self, part: Part, mut f: impl FnMut(&mut Self) -> fmt::Result) -> fmt::Result { + let outer = self.into; + let start = self.w.text.len(); + let replacement = match part { + p if p == parts::GROUP => self.group, + p if p == parts::DECIMAL => self.point, + _ => None, + }; + if let Some(r) = replacement { + self.w.text.push_str(r); + self.into = Target::Replaced; + } else if part == parts::MINUS_SIGN || part == parts::PLUS_SIGN { + self.into = Target::Sign; + } + let result = f(self); + self.into = outer; + if replacement.is_none() { + let written = self.w.text.get(start..).unwrap_or_default(); + let slot = match part { + p if p == parts::GROUP => &mut self.w.group, + p if p == parts::DECIMAL => &mut self.w.point, + p if p == parts::INTEGER => &mut self.w.integer, + _ => return result, + }; + if slot.is_empty() { + *slot = written.to_owned(); + } + } + result + } +} + +/// `value` rounded to `decimals` digits after the point; `None` for infinities and NaN. A value +/// that rounds to zero has no sign. +pub(crate) fn to_decimal(value: f64, decimals: usize) -> Option { + if !value.is_finite() { + return None; + } + let mut d = Decimal::try_from_str(&format!("{value:.decimals$}")).ok()?; + if d.absolute.is_zero() { + d.sign = Sign::None; + } + Some(d) +} + +/// The percent affixes for a number locale (`ar-EG`), found by dropping trailing subtags. +fn percent_affixes(tag: &str, latin: bool) -> (&'static str, &'static str) { + let mut tag = tag; + loop { + if let Some((_, _, before, after)) = PERCENT.iter().find(|(l, lat, _, _)| *lat == latin && l.eq_ignore_ascii_case(tag)) { + return (before, after); + } + match tag.rsplit_once('-') { + Some((parent, _)) => tag = parent, + None => return ("", "%"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn latin(tag: &str) -> Format { + Format::new(&Region::from_tag(tag), Digits::Latin) + } + + #[test] + fn locale_digits_and_separators() { + for (tag, integer, number) in [ + ("en", "1,234,567", "1,234.5"), + ("ja", "1,234,567", "1,234.5"), + ("de", "1.234.567", "1.234,5"), + ("fr", "1\u{202f}234\u{202f}567", "1\u{202f}234,5"), + ("hi", "12,34,567", "1,234.5"), + ("ar-EG", "١٬٢٣٤٬٥٦٧", "١٬٢٣٤٫٥"), + ("fa", "۱٬۲۳۴٬۵۶۷", "۱٬۲۳۴٫۵"), + ] { + let f = Format::from_tag(tag); + assert_eq!((f.integer(1_234_567), f.number(1234.5, 1)), (integer.into(), number.into()), "{tag}"); + } + // CLDR writes Arabic without a region in Latin digits. + assert_eq!(Format::from_tag("ar").number(1234.5, 1), "1,234.5"); + } + + #[test] + fn forced_latin_digits() { + assert_eq!(latin("ar-EG").number(1234.5, 1), "1,234.5"); + assert_eq!(latin("fa").integer(1_234_567), "1,234,567"); + assert_eq!(latin("de").number(1234.5, 1), "1.234,5", "a Latin locale is unchanged"); + } + + #[test] + fn region_separators() { + // English in Poland writes a decimal comma; the grouping moves out of its way. + assert_eq!(Format::from_tag("en-PL").number(1234.5, 1), "1.234,5"); + let mut region = Region::from_tag("de-DE"); + region.grouping = Some('\u{a0}'); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234,5"); + region.decimal = '.'; + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234.5"); + // A system's separators don't apply to Arabic-Indic digits. + let mut region = Region::from_tag("ar-EG"); + region.grouping = Some(','); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "١٬٢٣٤٫٥"); + } + + #[test] + fn percentages() { + for (tag, want) in + [("en", "50%"), ("de", "50\u{a0}%"), ("fr", "12,5\u{a0}%"), ("tr", "%50"), ("ar-EG", "٥٠٪\u{61c}"), ("fa", "۵۰٪"), ("hi", "50%")] + { + let decimals = if tag == "fr" { 1 } else { 0 }; + let ratio = if tag == "fr" { 0.125 } else { 0.5 }; + assert_eq!(Format::from_tag(tag).percent(ratio, decimals), want, "{tag}"); + } + assert_eq!(Format::from_tag("tr").percent(-0.2, 0), "-%20", "the sign leads the pattern"); + assert_eq!(latin("fa").percent(0.5, 0), "50%"); + } + + #[test] + fn signs_and_odd_values() { + let en = Format::from_tag("en"); + assert_eq!(en.number(-1234.5, 0), "-1,234"); + assert_eq!(en.number(-0.04, 1), "0.0", "no sign on zero"); + assert_eq!(en.number(f64::INFINITY, 1), "inf"); + assert_eq!(en.integer(-5_i64), "-5"); + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 73e794f9..910041ed 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,17 +6,41 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; -mod size; +mod format; mod region; +mod size; + +/// The CLDR data for the shipped languages, from `data/generate.sh`. +mod data { + use std::sync::OnceLock; + + use icu_locale_fallback::LocaleFallbacker; + use icu_provider_adapters::fallback::LocaleFallbackProvider; + use icu_provider_blob::BlobDataProvider; + + include!("../data/locales.rs"); + + /// The number and plural data, read once; `None` if the blob doesn't load. + pub(crate) fn provider() -> Option<&'static LocaleFallbackProvider> { + static PROVIDER: OnceLock>> = OnceLock::new(); + PROVIDER + .get_or_init(|| { + let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/numbers.postcard")).ok()?; + Some(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned())) + }) + .as_ref() + } +} use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::{Mutex, OnceLock, PoisonError}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; -use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; -use unic_langid::LanguageIdentifier; +use icu_locale_core::Locale as IcuLocale; +use icu_plurals::{PluralCategory, PluralOperands, PluralRules}; -pub use size::{FileSize, SizeUnit}; +pub use format::{Digits, Format}; pub use region::{HourCycle, Measurement, Paper, Region}; +pub use size::{FileSize, SizeUnit}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. @@ -57,6 +81,8 @@ pub struct I18n { pseudo: Mutex>, /// [`I18n::system_lang`], read once. system: OnceLock, + /// [`I18n::format`]; the system region's until [`I18n::set_format`]. + format: RwLock>>, } impl I18n { @@ -107,7 +133,7 @@ impl I18n { l.chain = chain; } } - (I18n { locales, pseudo: Mutex::default(), system: OnceLock::new() }, problems) + (I18n { locales, pseudo: Mutex::default(), system: OnceLock::new(), format: RwLock::default() }, problems) } /// The language with this code (any case): `en`, an offered locale, or in debug builds @@ -163,9 +189,25 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in. `one` and `other` are the English forms; - /// they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers. `one` and + /// `other` are the English forms; they are also the key of the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { + let format = self.format(); + self.plural(lang, PluralOperands::from(n), n == 1, &format.integer(n), one, other) + } + + /// [`I18n::trn`] for a count with `decimals` digits after the decimal separator (`1.5 files`). + /// The plural form follows the digits shown, so `1.0` takes English `other`. + pub fn trn_decimal(&self, lang: Lang, n: f64, decimals: usize, one: &str, other: &str) -> String { + let format = self.format(); + let Some(d) = format::to_decimal(n, decimals) else { + return fmt(other, &[("n", &n.to_string())]); + }; + let english_one = d.to_string() == "1"; + self.plural(lang, PluralOperands::from(&d), english_one, &format.write(&d), one, other) + } + + fn plural(&self, lang: Lang, operands: PluralOperands, english_one: bool, n: &str, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); let text = chain @@ -174,19 +216,40 @@ impl I18n { let locale = self.locales.get(i)?; let forms = locale.rows.get("@plural")?.get(&key)?; let (rules, categories) = locale.plural.as_ref()?; - let category = rules.select(n).ok()?; - // A category whole numbers up to 1000 never reach (French `many`) takes the last form. - let index = categories.iter().position(|c| *c == category).unwrap_or(categories.len().saturating_sub(1)); + let category = rules.category_for(operands); + // A category whole numbers up to 1000 never reach has no form of its own. Russian + // and Ukrainian decimals (`other`) take the `few` form (`1,5 файла`); any other + // (French `many` for a million) takes the last. + let index = categories + .iter() + .position(|c| *c == category) + .or_else(|| categories.iter().position(|c| *c == PluralCategory::Few)) + .unwrap_or(categories.len().saturating_sub(1)); forms.split('|').nth(index) }) - .unwrap_or(if n == 1 { one } else { other }); - let text = fmt(text, &[("n", &n.to_string())]); + .unwrap_or(if english_one { one } else { other }); + let text = fmt(text, &[("n", n)]); if lang == Lang::PSEUDO { return marked(&text); } text } + /// How counts, file sizes and measures write numbers: [`Region::system`]'s format with the + /// locale's digits until [`I18n::set_format`]. + pub fn format(&self) -> Arc { + if let Some(f) = self.format.read().unwrap_or_else(PoisonError::into_inner).as_ref() { + return Arc::clone(f); + } + let mut slot = self.format.write().unwrap_or_else(PoisonError::into_inner); + Arc::clone(slot.get_or_insert_with(|| Arc::new(Format::new(&Region::system(), Digits::Locale)))) + } + + /// Write numbers as `format` does from now on: the user's region setting, or Latin digits. + pub fn set_format(&self, format: Format) { + *self.format.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(format)); + } + /// The languages to offer: English first, then the offered locales by code. Never the /// pseudo locale. pub fn languages(&self) -> Vec { @@ -371,15 +434,17 @@ fn bare(code: &str) -> Locale { /// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order /// (zero, one, two, few, many, other): one `@plural` form each. Regional and scripted codes fall /// back to the language's rules. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other -/// than `pt-br` takes `pt-PT`'s. +/// than `pt-br` takes `pt-PT`'s. `None` for a language the baked data doesn't cover. fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { let code = if code == "pt" || (code.starts_with("pt-") && code != "pt-br") { "pt-pt" } else { code }; - let id: LanguageIdentifier = code.parse().ok()?; - let language = LanguageIdentifier::from_parts(id.language, None, None, &[]); - let rules = PluralRules::create(id, PluralRuleType::CARDINAL).or_else(|_| PluralRules::create(language, PluralRuleType::CARDINAL)).ok()?; + let locale = IcuLocale::try_from_str(code).ok()?; + if !data::LANGUAGES.contains(&locale.id.language.as_str()) { + return None; + } + let rules = PluralRules::try_new_cardinal_with_buffer_provider(data::provider()?, (&locale).into()).ok()?; let mut categories = Vec::new(); for n in 0..=1000_u32 { - let c = rules.select(n).ok()?; + let c = rules.category_for(n); if !categories.contains(&c) { categories.push(c); } @@ -390,12 +455,12 @@ fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { fn rank(c: &PluralCategory) -> u8 { match c { - PluralCategory::ZERO => 0, - PluralCategory::ONE => 1, - PluralCategory::TWO => 2, - PluralCategory::FEW => 3, - PluralCategory::MANY => 4, - PluralCategory::OTHER => 5, + PluralCategory::Zero => 0, + PluralCategory::One => 1, + PluralCategory::Two => 2, + PluralCategory::Few => 3, + PluralCategory::Many => 4, + PluralCategory::Other => 5, } } @@ -486,6 +551,7 @@ mod tests { ("fr", "@plural\t{n} page|{n} pages\t{n} page|{n} pages\n", ""), ("ru", "@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n", ""), ("ja", "@plural\t{n} file|{n} files\t{n} 個\n", ""), + ("ar", "@plural\t{n} file|{n} files\t{n} ملفات|{n} ملف واحد|{n} ملفان|{n} ملفات|{n} ملفًا|{n} ملف\n", ""), ]; #[test] @@ -528,7 +594,39 @@ mod tests { #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); + i.set_format(Format::from_tag("fr")); + assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1\u{202f}000\u{202f}000 pages"); + } + + #[test] + fn counts_follow_the_region() { + let i = all(P).0; + i.set_format(Format::from_tag("ar-EG")); + assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "١٬٢٣٤ ملفًا"); + i.set_format(Format::new(&Region::from_tag("ar-EG"), Digits::Latin)); + assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1,234 ملفًا"); + // An English interface with a German region. + i.set_format(Format::from_tag("de-DE")); + assert_eq!(i.trn(Lang::EN, 12_345, "{n} file", "{n} files"), "12.345 files"); + } + + #[test] + fn decimal_counts() { + let i = all(P).0; + i.set_format(Format::from_tag("en")); + assert_eq!(i.trn_decimal(Lang::EN, 1.5, 1, "{n} file", "{n} files"), "1.5 files"); + assert_eq!(i.trn_decimal(Lang::EN, 1.0, 1, "{n} file", "{n} files"), "1.0 files", "visible decimals are plural"); + assert_eq!(i.trn_decimal(Lang::EN, 1.0, 0, "{n} file", "{n} files"), "1 file"); + // Russian decimals are `other`, which integers never reach: the `few` form. + i.set_format(Format::from_tag("ru")); + let ru = lang(&i, "ru"); + assert_eq!(i.trn_decimal(ru, 1.5, 1, "{n} file", "{n} files"), "1,5 файла"); + assert_eq!(i.trn_decimal(ru, 21.0, 0, "{n} file", "{n} files"), "21 файл"); + // Arabic decimals are `other`. + i.set_format(Format::from_tag("ar-EG")); + let ar = lang(&i, "ar"); + assert_eq!(i.trn_decimal(ar, 2.5, 1, "{n} file", "{n} files"), "٢٫٥ ملف"); + assert_eq!(i.trn_decimal(ar, 3.0, 0, "{n} file", "{n} files"), "٣ ملفات"); } static L: &Embedded = diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs index 1466672b..4dad2b57 100644 --- a/crates/i18n/src/size.rs +++ b/crates/i18n/src/size.rs @@ -103,8 +103,8 @@ impl FileSize { self } - /// The unit `bytes` is written in and the number in it, in English. - fn parts(&self, bytes: u64) -> (SizeUnit, String) { + /// The unit `bytes` is written in, the number in it and its digits after the point. + fn parts(&self, bytes: u64) -> (SizeUnit, f64, usize) { let (smallest, largest) = (self.smallest.min(self.largest), self.largest); let mut unit = smallest; for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) { @@ -128,7 +128,7 @@ impl FileSize { unit = next; continue; } - return (unit, number(rounded, usize::from(decimals))); + return (unit, rounded, usize::from(decimals)); } } @@ -153,19 +153,21 @@ impl FileSize { } impl I18n { - /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`). + /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`), the + /// number as [`I18n::format`] writes it (`1,5 Mo`). pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String { - let (unit, n) = style.parts(bytes); + let (unit, n, decimals) = style.parts(bytes); if unit == SizeUnit::Byte && !style.byte_symbol { return self.trn(lang, bytes, "{n} byte", "{n} bytes"); } - self.unit_text(lang, style.template(unit), &n) + self.unit_text(lang, style.template(unit), &self.format().number(n, decimals)) } - /// `value` with `decimals` digits after the decimal point in the unit template `unit`, an - /// English catalog key with the number as `{n}` (`{n} px`, `{n}%`, `{n} dB`). + /// `value` with `decimals` digits after the decimal separator in the unit template `unit`, an + /// English catalog key with the number as `{n}` (`{n} px`, `{n} dB`), the number as + /// [`I18n::format`] writes it. Percentages are [`crate::Format::percent`]. pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String { - self.unit_text(lang, unit, &number(value, decimals)) + self.unit_text(lang, unit, &self.format().number(value, decimals)) } fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String { @@ -173,17 +175,10 @@ impl I18n { } } -/// `value` with `decimals` digits after the decimal point, in ASCII digits with a decimal point. -/// The one place sizes and measures write numbers, for craft-i18n's locale number formatting -/// (digits, separators) to take over. -fn number(value: f64, decimals: usize) -> String { - format!("{value:.decimals$}") -} - #[cfg(test)] mod tests { use super::*; - use crate::Embedded; + use crate::{Embedded, Format}; static E: &Embedded = &[( "fr", @@ -193,12 +188,14 @@ mod tests { fn en(bytes: u64, style: FileSize) -> String { let (i18n, _) = I18n::new(&[], &[]); + i18n.set_format(Format::from_tag("en-US")); i18n.file_size(Lang::EN, bytes, style) } #[test] fn binary_decimal_and_iec_steps() { assert_eq!(en(512, FileSize::BINARY), "512 bytes"); + assert_eq!(en(1023, FileSize::BINARY), "1,023 bytes"); assert_eq!(en(1, FileSize::BINARY), "1 byte"); assert_eq!(en(1536, FileSize::BINARY), "1.5 KB"); assert_eq!(en(1536, FileSize::IEC), "1.5 KiB"); @@ -221,7 +218,7 @@ mod tests { assert_eq!(en(640_400, style), "640 KB"); assert_eq!(en(3_400_000, style), "3.4 MB"); assert_eq!(en(12_600_000, style), "13 MB"); - assert_eq!(en(5_000_000_000, style), "5000 MB"); + assert_eq!(en(5_000_000_000, style), "5,000 MB"); // Only sizes below the smallest unit round up. assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); @@ -232,11 +229,21 @@ mod tests { let (i18n, problems) = I18n::new(E, &["fr"]); assert!(problems.is_empty(), "{problems:?}"); let fr = i18n.from_code("fr").unwrap(); - assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1.5 Ko"); + i18n.set_format(Format::from_tag("fr-FR")); + assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1,5 Ko"); assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets"); assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)"); - assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3.0 GB", "a missing row is English"); + assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3,0 GB", "a missing row is English"); assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %"); - assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12.3 px"); + assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12,3 px", "numbers follow the region, not the language"); + } + + #[test] + fn locale_digits() { + let (i18n, _) = I18n::new(&[], &[]); + i18n.set_format(Format::from_tag("ar-EG")); + assert_eq!(i18n.file_size(Lang::EN, 1536, FileSize::BINARY), "١٫٥ KB"); + i18n.set_format(Format::from_tag("hi")); + assert_eq!(i18n.file_size(Lang::EN, 1_234_567, FileSize::DECIMAL.largest(SizeUnit::Byte).byte_symbol()), "12,34,567 B"); } } From 75b97a9192da103a0e0c6f782de307b42b7b9ec1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:42:51 +0000 Subject: [PATCH 067/124] i18n: non_exhaustive Script and Han; regions that imply a script pa-PK, uz-AF and az-IR are written in Arabic and sd-IN in Devanagari, as CLDR's likely subtags give them. A script subtag still wins. --- crates/i18n/README.md | 10 +++---- crates/i18n/src/scripts.rs | 59 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index cde3621d..ac6146b5 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -61,11 +61,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its - interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right - to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional - forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ - `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the - language's code; putting text into display order is the app's job. + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), + `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` + gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → + SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ecd2f8dd..037b3d40 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -3,6 +3,7 @@ /// A writing system, by its ISO 15924 code. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] pub enum Script { Arab, Armn, @@ -90,6 +91,7 @@ pub enum Direction { /// KR). The same code point is drawn differently in each, so a font stack picks its Han face by /// this, not by script. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum Han { /// Simplified Chinese, mainland China and Singapore (SC). Simplified, @@ -105,9 +107,10 @@ pub enum Han { /// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → /// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A -/// script subtag wins over the language's usual script. Latin product names and units appear in -/// every language, so Latn is listed only where it is the language's own script. A language not -/// in the table is taken to be written in Latin. +/// script subtag wins, then a region that implies a script (`pa-PK` → Arab), then the language's +/// usual script. Latin product names and units appear in every language, so Latn is listed only +/// where it is the language's own script. A language not in the table is taken to be written in +/// Latin. // ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. pub fn scripts(tag: &str) -> &'static [Script] { use Script::*; @@ -115,6 +118,9 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } + if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + return s; + } match tag.language.as_str() { "ja" => &[Hani, Hira, Kana], "ko" => &[Hang, Hani], @@ -178,6 +184,21 @@ pub fn han(tag: &str) -> Option { } } +/// The scripts a language takes in a region where CLDR's likely subtags give it a script other +/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +// ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. +fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match (language, region) { + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], + ("sd", "in") => &[Deva], + _ => return None, + }) +} + /// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for /// one not in [`Script`], so the language's own scripts apply. fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { @@ -352,6 +373,38 @@ mod tests { assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); } + /// CLDR likely subtags: a region can switch a language to another script. + #[test] + fn region_implies_script() { + for (tag, want) in [ + ("pa-PK", &[Arab][..]), + ("pa_PK.UTF-8", &[Arab]), + ("pa-IN", &[Guru]), + ("uz-AF", &[Arab]), + ("uz-UZ", &[Latn]), + ("uz-CN", &[Cyrl]), + ("az-IR", &[Arab]), + ("az-AZ", &[Latn]), + ("sd-IN", &[Deva]), + ("sd-PK", &[Arab]), + ("ks-IN", &[Arab]), + ("ms-CC", &[Arab]), + ("ms-BN", &[Latn]), + ("sr-ME", &[Latn]), + ("kk-CN", &[Arab]), + ("ug-KZ", &[Cyrl]), + ("pa-Guru-PK", &[Guru]), + ("sd-Arab-IN", &[Arab]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-PK"), Direction::Rtl); + assert_eq!(direction("uz-AF"), Direction::Rtl); + assert_eq!(direction("az-IR"), Direction::Rtl); + assert_eq!(direction("sd-IN"), Direction::Ltr); + assert_eq!(direction("ug-KZ"), Direction::Ltr); + } + /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { From 705e91e183ab3b63bc5d3aacf25ae6ab7e9d0cc6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:27 +0000 Subject: [PATCH 068/124] i18n: isolate only text with a right-to-left letter fmt and trn add isolates only when the template or an inserted value has a strong RTL character (bidi class R or AL). Text with none comes out as before, and a value's own isolate from ltr or isolate is taken off. --- Cargo.lock | 1 + crates/i18n/Cargo.toml | 1 + crates/i18n/README.md | 8 ++- crates/i18n/src/lib.rs | 124 ++++++++++++++++++++++++++--------------- 4 files changed, 85 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..ebc4055f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "intl_pluralrules", "sys-locale", "unic-langid", + "unicode-bidi", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775..8283943f 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +unicode-bidi = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } diff --git a/crates/i18n/README.md b/crates/i18n/README.md index a34ce183..951a6782 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -49,12 +49,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. -- **Isolation:** `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` +- **Isolation:** when a message or a value inserted into it has a right-to-left letter (bidi + class R or AL), `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` wraps `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, or a Hebrew name in an English one, can't reorder the words around it. Wrap numbers, paths and codes in `ltr` before passing them; a value that already is one isolate goes in as it is, and a stray - PDI in a value can't close its isolate early. egui draws the marks as nothing. For text that - leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or + PDI in a value can't close its isolate early. Text with no right-to-left letter comes out + without marks, as if `fmt` didn't isolate at all. egui draws the marks as nothing. For text + that leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or `strip_isolates`. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index a394aef5..8fde98ad 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -12,6 +12,7 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +use unicode_bidi::{BidiClass, bidi_class}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. @@ -158,9 +159,9 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in as a left-to-right isolate ([`ltr`]). `one` - /// and `other` are the English forms; they are also the key of the `@plural` row, and the - /// fallback. + /// A count's wording in `lang`, `{n}` filled in, as a left-to-right isolate ([`ltr`]) when the + /// wording has a right-to-left letter. `one` and `other` are the English forms; they are also + /// the key of the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); @@ -262,18 +263,20 @@ impl I18n { } } -/// Fill `{name}` placeholders from `args` in one pass, each value wrapped in a first-strong -/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name -/// in an Arabic message stays where the translator put it. A value that already is one isolate -/// goes in as it is; pass numbers, paths and codes through [`ltr`]. Unknown placeholders stay as -/// written, and inserted values are never read as templates, so a file name containing `{n}` -/// stays intact. +/// Fill `{name}` placeholders from `args` in one pass. When the template or an inserted value +/// has a right-to-left letter (Hebrew, Arabic and the like), each value goes in as a first-strong +/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name in +/// an Arabic message stays where the translator put it. A value that already is one isolate goes +/// in as it is; pass numbers, paths and codes through [`ltr`]. Text with no right-to-left letter +/// gets no marks, and a value's own isolate is taken off. Unknown placeholders stay as written, +/// and inserted values are never read as templates, so a file name containing `{n}` stays intact. pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { fill(template, args, true) } /// [`fmt`] without isolates, for text that goes where the marks would get in the way: file -/// names, logs, terminals, text compared or parsed later. +/// names, logs, terminals, text compared or parsed later. A value that is one isolate goes in +/// without it. pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { fill(template, args, false) } @@ -284,14 +287,16 @@ const FSI: char = '\u{2068}'; const PDI: char = '\u{2069}'; /// `value` as a first-strong isolate (FSI…PDI): it takes the direction of its first strong -/// letter and can't reorder the text around it. What [`fmt`] does to each value. +/// letter and can't reorder the text around it. What [`fmt`] does to each value in text with a +/// right-to-left letter. pub fn isolate(value: &str) -> String { wrap(FSI, value) } /// `value` as a left-to-right isolate (LRI…PDI), for numbers, paths, file sizes, timecodes and /// codes, which read left to right in every language. In an RTL first-strong isolate, the -/// segments of `/home/مستخدم/file.txt` would run right to left. +/// segments of `/home/مستخدم/file.txt` would run right to left. [`fmt`] takes the isolate off +/// again in text with no right-to-left letter. pub fn ltr(value: &str) -> String { wrap(LRI, value) } @@ -323,6 +328,22 @@ fn wrap(initiator: char, value: &str) -> String { out } +/// Whether `text` has a strong right-to-left character (bidi class R or AL): the text that needs +/// isolates. +fn has_rtl(text: &str) -> bool { + !text.is_ascii() && text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL)) +} + +/// `value` without its isolate, if it is exactly one. +fn unwrap_isolate(value: &str) -> &str { + if is_one_isolate(value) { + // The initiator and PDI are three bytes each in UTF-8. + value.get(3..value.len() - 3).unwrap_or(value) + } else { + value + } +} + /// Whether `value` is exactly one isolate: an initiator, then the PDI that closes it last. fn is_one_isolate(value: &str) -> bool { let mut depth = 0_usize; @@ -344,27 +365,32 @@ fn is_one_isolate(value: &str) -> bool { } fn fill(template: &str, args: &[(&str, &str)], isolated: bool) -> String { - let mut out = String::with_capacity(template.len()); + let mut pieces: Vec<(&str, bool)> = Vec::new(); let mut rest = template; while let Some((before, after_open)) = rest.split_once('{') { - out.push_str(before); + pieces.push((before, false)); let Some((name, after_close)) = after_open.split_once('}') else { - out.push('{'); + pieces.push(("{", false)); rest = after_open; break; }; match args.iter().find(|(k, _)| *k == name) { - Some((_, value)) if !isolated || value.is_empty() || is_one_isolate(value) => out.push_str(value), - Some((_, value)) => out.push_str(&isolate(value)), - None => { - out.push('{'); - out.push_str(name); - out.push('}'); - } + Some((_, value)) => pieces.push((value, true)), + None => pieces.extend([("{", false), (name, false), ("}", false)]), } rest = after_close; } - out.push_str(rest); + pieces.push((rest, false)); + let isolated = isolated && pieces.iter().any(|(text, _)| has_rtl(text)); + let mut out = String::with_capacity(template.len()); + for (text, value) in pieces { + match (value, isolated) { + (false, _) => out.push_str(text), + (true, false) => out.push_str(unwrap_isolate(text)), + (true, true) if text.is_empty() || is_one_isolate(text) => out.push_str(text), + (true, true) => out.push_str(&isolate(text)), + } + } out } @@ -562,21 +588,33 @@ mod tests { ("fr", "@plural\t{n} page|{n} pages\t{n} page|{n} pages\n", ""), ("ru", "@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n", ""), ("ja", "@plural\t{n} file|{n} files\t{n} 個\n", ""), + ("fa", "@plural\t{n} page|{n} pages\t{n} صفحه|{n} صفحه\n", ""), ]; #[test] fn fmt_fills_once() { - assert_eq!(fmt_plain("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); + assert_eq!(fmt("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); assert_eq!(fmt("{z} {", &[]), "{z} {"); } #[test] - fn inserted_values_are_isolated() { + fn left_to_right_text_gets_no_marks() { + assert_eq!(fmt("Saved {name} in {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/sara"))]), "Saved Report 2.pdf in /home/sara."); + assert_eq!(fmt("{a}", &[("a", "x"), ("b", "שלום")]), "x", "an unused value doesn't count"); + assert_eq!(fmt_plain("{a}", &[("a", &isolate("x"))]), "x"); + let i = all(P).0; + assert_eq!(i.trn(Lang::EN, 2, "{n} page", "{n} pages"), "2 pages"); + } + + #[test] + fn right_to_left_text_isolates_values() { let saved = fmt("تم حفظ {name} في {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/سارة/docs"))]); assert_eq!(saved, "تم حفظ \u{2068}Report 2.pdf\u{2069} في \u{2066}/home/سارة/docs\u{2069}."); assert_eq!(strip_isolates(&saved), "تم حفظ Report 2.pdf في /home/سارة/docs."); - assert_eq!(fmt_plain("{a}", &[("a", "x")]), "x"); - assert_eq!(fmt("[{a}]", &[("a", "")]), "[]"); + assert_eq!(fmt("Opened {name}.", &[("name", "שלום.txt")]), "Opened \u{2068}שלום.txt\u{2069}."); + assert_eq!(fmt("[{a}] {b}", &[("a", ""), ("b", "ש")]), "[] \u{2068}ש\u{2069}"); + let i = all(P).0; + assert_eq!(i.trn(lang(&i, "fa"), 3, "{n} page", "{n} pages"), "\u{2066}3\u{2069} صفحه"); } #[test] @@ -584,15 +622,9 @@ mod tests { assert_eq!(isolate("a\u{2069}b"), "\u{2068}ab\u{2069}"); assert_eq!(isolate("a\u{2067}b"), "\u{2068}a\u{2067}b\u{2069}\u{2069}"); let one = ltr("12"); - assert_eq!(fmt("{n}", &[("n", &one)]), one); + assert_eq!(fmt("ש {n}", &[("n", &one)]), format!("ש {one}")); // Two isolates side by side are not one, so they get wrapped. - assert_eq!(fmt("{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); - } - - #[test] - fn counts_are_left_to_right_isolates() { - let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} page", "{n} pages"), "\u{2066}2\u{2069} pages"); + assert_eq!(fmt("ש{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "ש\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); } #[test] @@ -600,8 +632,8 @@ mod tests { let (i, problems) = all(P); assert!(problems.is_empty(), "{problems:?}"); let fr = lang(&i, "fr"); - assert_eq!(strip_isolates(&i.trn(fr, 0, "{n} page", "{n} pages")), "0 page"); - assert_eq!(strip_isolates(&i.trn(fr, 2, "{n} page", "{n} pages")), "2 pages"); + assert_eq!(i.trn(fr, 0, "{n} page", "{n} pages"), "0 page"); + assert_eq!(i.trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); } #[test] @@ -609,27 +641,27 @@ mod tests { let i = all(P).0; let ru = lang(&i, "ru"); for (n, want) in [(1, "1 файл"), (3, "3 файла"), (5, "5 файлов"), (11, "11 файлов"), (21, "21 файл")] { - assert_eq!(strip_isolates(&i.trn(ru, n, "{n} file", "{n} files")), want); + assert_eq!(i.trn(ru, n, "{n} file", "{n} files"), want); } } #[test] fn one_form_ja() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files")), "7 個"); + assert_eq!(i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files"), "7 個"); } #[test] fn english_fallback() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(Lang::EN, 1, "{n} file", "{n} files")), "1 file"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs")), "2 xs"); + assert_eq!(i.trn(Lang::EN, 1, "{n} file", "{n} files"), "1 file"); + assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs"), "2 xs"); } #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages")), "1000000 pages"); + assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); } static L: &Embedded = @@ -696,7 +728,7 @@ mod tests { assert_eq!(i.t(Lang::PSEUDO, "A\nB"), "⟦A⟧\n⟦B⟧"); assert_eq!(i.tc(Lang::PSEUDO, "menu", "Open"), "⟦Open⟧"); assert_eq!(i.tid(Lang::PSEUDO, "file.save", "Save"), "⟦Save⟧"); - assert_eq!(strip_isolates(&i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs")), "⟦2 xs⟧"); + assert_eq!(i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs"), "⟦2 xs⟧"); assert!(!i.languages().contains(&Lang::PSEUDO)); assert_eq!(i.from_code("pseudo"), cfg!(debug_assertions).then_some(Lang::PSEUDO)); assert_eq!(i.get(Lang::PSEUDO, "key", "Esc"), Some("⟦Esc⟧")); @@ -775,9 +807,9 @@ mod tests { #[test] fn european_portuguese_zero_is_plural() { let i = all(PT).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files")), "0 ficheiros"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files")), "1 ficheiro"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files")), "0 arquivo"); + assert_eq!(i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files"), "0 ficheiros"); + assert_eq!(i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files"), "1 ficheiro"); + assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); } /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog From 3e4cca97375628e1c5b007240b5b6fc29e4f13ca Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:34 +0000 Subject: [PATCH 069/124] i18n: Region separators only where the system sets them; C in LC_* is en-US Region::decimal and grouping are None unless Windows' or macOS's own settings set them, so the number locale's CLDR data decides otherwise. A cleared Windows sThousand is no grouping. LC_ALL=C and LC_NUMERIC=C (and the other LC_* variables) mean the C locale; only LANG=C gives way to the preferred language. --- crates/i18n/README.md | 6 +- crates/i18n/src/region.rs | 117 +++++++++++++++++--------------------- 2 files changed, 56 insertions(+), 67 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d916bbbb..7520f4ae 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -70,11 +70,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l picks `sr`. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 - or Letter, the decimal separator, and the grouping separator and clock when the system sets - them. `Region::system()` reads it once: Windows' regional format with the user's changes + or Letter, and the decimal and grouping separators (or no grouping) and clock when the system + sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and `LC_PAPER` each on its own over - `LANG` on Unix, the preferred language on the web and Android. `Region::from_setting` takes + `LANG` on Unix (`C` there is `en-US`; `LANG=C` gives way to the preferred language), the preferred language on the web and Android. `Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. Ported from WordCraft's `Region`. diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs index f9bb4720..bbebe4c9 100644 --- a/crates/i18n/src/region.rs +++ b/crates/i18n/src/region.rs @@ -2,8 +2,9 @@ //! interface language. An English interface in Poland still writes `2,54 cm` on A4. //! //! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): -//! `paperSize` US-Letter and `measurementSystem` US. The decimal separators follow CLDR's number -//! symbols for the common locales; the system's own separator wins where the platform has one. +//! `paperSize` US-Letter and `measurementSystem` US. The separators numbers are written with +//! follow the number locale's CLDR data ([`crate::Format`]); the region carries them only where +//! the system sets its own. use std::sync::OnceLock; @@ -13,23 +14,6 @@ const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", /// Territories that measure in inches (CLDR `measurementSystem type="US"`). const INCHES: &[&str] = &["LR", "MM", "US"]; -/// Languages whose numbers use a decimal comma in most of their territories. -const COMMA_LANGUAGES: &[&str] = &[ - "af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it", "ka", - "kk", "ky", "lb", "lt", "lv", "mk", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk", "uz", "vi", -]; - -/// Territories where those languages use a decimal point after all. -const POINT_EXCEPTIONS: &[(&str, &[&str])] = - &[("de", &["CH", "LI"]), ("it", &["CH"]), ("es", &["CU", "DO", "GT", "HN", "MX", "NI", "PA", "PE", "PR", "SV", "US", "419"])]; - -/// Territories where English is written with a decimal comma: most of continental Europe, South -/// Africa and much of South America. -const COMMA_TERRITORIES: &[&str] = &[ - "AR", "AT", "BE", "BG", "BO", "BR", "CL", "CO", "CZ", "DE", "DK", "EC", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "ID", "IS", "IT", "LT", "LU", - "LV", "NL", "NO", "PL", "PT", "PY", "RO", "RS", "RU", "SE", "SI", "SK", "TR", "UA", "UY", "VE", "VN", "ZA", -]; - /// How lengths, distances and weights are measured. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Measurement { @@ -66,16 +50,17 @@ pub struct Region { pub time: String, pub measurement: Measurement, pub paper: Paper, - /// The decimal separator (`,` in Poland). - pub decimal: char, - /// The grouping separator the system sets (Windows, macOS); `None` follows the locale. - pub grouping: Option, + /// The decimal separator the system sets (Windows, macOS); `None` follows the locale. + pub decimal: Option, + /// The grouping separator the system sets (Windows, macOS): `Some(None)` for none at all + /// (`1234567`); `None` follows the locale. + pub grouping: Option>, /// The clock the system sets (Windows, macOS); `None` follows the locale. pub hour_cycle: Option, } impl Default for Region { - /// `en-US`: decimal point, Letter, inches. + /// `en-US`: Letter, inches. fn default() -> Self { Region::from_parts(&Tag::default()) } @@ -114,21 +99,13 @@ impl Region { fn from_parts(tag: &Tag) -> Region { let territory = tag.territory.as_str(); - let language = tag.language.as_str(); let measurement = tag.measurement.unwrap_or(if INCHES.contains(&territory) { Measurement::Us } else { Measurement::Metric }); - let decimal = if COMMA_LANGUAGES.contains(&language) { - if POINT_EXCEPTIONS.iter().any(|(l, ts)| *l == language && ts.contains(&territory)) { '.' } else { ',' } - } else if language == "en" && COMMA_TERRITORIES.contains(&territory) { - ',' - } else { - '.' - }; Region { numbers: tag.bcp47(), time: tag.bcp47(), measurement, paper: if LETTER.contains(&territory) { Paper::Letter } else { Paper::A4 }, - decimal, + decimal: None, grouping: None, hour_cycle: tag.hour_cycle, } @@ -239,22 +216,23 @@ fn title_case(p: &str) -> String { chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default() } -/// A locale variable that names a locale (not empty, `C` or `POSIX`). +/// Whether a locale variable names the C locale (`C`, `C.UTF-8`, `POSIX`). #[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] -fn usable_locale(v: &str) -> bool { +fn is_c_locale(v: &str) -> bool { let v = v.trim(); - !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") + v.eq_ignore_ascii_case("c") || v.eq_ignore_ascii_case("posix") || v.starts_with("C.") } -/// The region from the POSIX locale variables `var` reads and, when neither `LC_ALL` nor the -/// variable for a convention nor `LANG` is set, the platform's preferred locale. +/// The region from the POSIX locale variables `var` reads. An empty variable is unset; `C` in +/// `LC_ALL` or an `LC_*` variable is the C locale, written as `en-US`. `LANG` set to `C` or unset +/// gives way to the platform's preferred locale. #[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] fn from_posix(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { - let var = |name: &str| var(name).filter(|v| usable_locale(v)); + let var = |name: &str| var(name).filter(|v| !v.trim().is_empty()); if let Some(all) = var("LC_ALL") { return Region::from_tag(&all); } - let lang = var("LANG").or_else(platform).unwrap_or_default(); + let lang = var("LANG").filter(|v| !is_c_locale(v)).or_else(platform).unwrap_or_default(); let tag = |name: &str| var(name).unwrap_or_else(|| lang.clone()); let time = Region::from_tag(&tag("LC_TIME")); Region { @@ -272,12 +250,8 @@ fn from_posix(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> O #[cfg(any(test, windows))] fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { let mut region = Region::from_tag(&value("LocaleName").or_else(platform).unwrap_or_default()); - if let Some(c) = value("sDecimal").as_deref().and_then(single_char) { - region.decimal = c; - } - if let Some(s) = value("sThousand") { - region.grouping = single_char(&s); - } + region.decimal = value("sDecimal").as_deref().and_then(single_char); + region.grouping = value("sThousand").as_deref().and_then(grouping); match value("iMeasure").as_deref().map(str::trim) { Some("0") => region.measurement = Measurement::Metric, Some("1") => region.measurement = Measurement::Us, @@ -299,14 +273,19 @@ fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() fn from_apple(identifier: &str, metric: bool, decimal: &str, grouping: &str, hours: Option<&str>) -> Region { let mut region = Region::from_tag(identifier); region.measurement = if metric { Measurement::Metric } else { Measurement::Us }; - if let Some(c) = single_char(decimal) { - region.decimal = c; - } - region.grouping = single_char(grouping); + region.decimal = single_char(decimal); + region.grouping = self::grouping(grouping); region.hour_cycle = hours.and_then(hour_cycle_of).or(region.hour_cycle); region } +/// A grouping separator the system sets: none at all when empty (Windows' `sThousand` the user +/// cleared), else one character. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn grouping(s: &str) -> Option> { + if s.is_empty() { Some(None) } else { single_char(s).map(Some) } +} + /// A separator that is one character; Windows allows up to three, which the region can't carry. #[cfg(any(test, windows, target_vendor = "apple"))] fn single_char(s: &str) -> Option { @@ -369,8 +348,8 @@ fn read_system() -> Region { mod tests { use super::*; - fn conventions(r: &Region) -> (Paper, Measurement, char) { - (r.paper, r.measurement, r.decimal) + fn conventions(r: &Region) -> (Paper, Measurement) { + (r.paper, r.measurement) } #[test] @@ -398,15 +377,15 @@ mod tests { assert_eq!(Region::from_tag("en_US@rg=plzzzz;ms=metric").numbers, "en-PL"); } - /// Paper and measurement follow the territory, numbers the language and territory. + /// Paper and measurement follow the territory; separators are left to the number locale. #[test] fn conventions_follow_the_territory() { - assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us, '.')); - assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-DE")), (Paper::A4, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric, '.')); - assert_eq!(Region::from_tag("de-CH").decimal, '.'); + assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric)); + assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us)); + assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric)); + assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric)); + let r = Region::from_tag("de-CH"); + assert_eq!((r.decimal, r.grouping), (None, None)); assert_eq!(Region::from_tag("es-MX").paper, Paper::Letter); assert_eq!(Region::from_tag("ja-JP").paper, Paper::A4); } @@ -423,12 +402,17 @@ mod tests { let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); let none = || None; let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_NUMERIC", "pl_PL.UTF-8"), ("LC_TIME", "en_GB.UTF-8"), ("LC_PAPER", "de_DE")]), none); - assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", ',')); + assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", None)); assert_eq!((r.paper, r.measurement), (Paper::A4, Measurement::Us), "paper from LC_PAPER, measurement from LANG"); assert_eq!(from_posix(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US"), ("LC_PAPER", "de_DE")]), none).paper, Paper::Letter); assert_eq!(from_posix(env(&[("LANG", "en_US"), ("LC_MEASUREMENT", "it_IT")]), none).measurement, Measurement::Metric); assert_eq!(from_posix(env(&[("LANG", "C.UTF-8"), ("LC_ALL", "")]), || Some("it-IT".into())).numbers, "it-IT"); assert_eq!(from_posix(env(&[]), none), Region::default()); + // `C` in an LC_* variable is the C locale's conventions, not an unset variable. + let r = from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_NUMERIC", "C"), ("LC_TIME", "C.UTF-8")]), none); + assert_eq!((r.numbers.as_str(), r.time.as_str(), r.paper), ("en-US", "en-US", Paper::A4)); + assert_eq!(from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_ALL", "C")]), none), Region::default()); + assert_eq!(from_posix(env(&[("LANG", "C"), ("LC_NUMERIC", "")]), || Some("it-IT".into())).numbers, "it-IT"); } #[test] @@ -438,15 +422,20 @@ mod tests { reg(&[("LocaleName", "pl-PL"), ("sDecimal", ","), ("sThousand", "\u{a0}"), ("iMeasure", "0"), ("sShortTime", "HH:mm")]), || None, ); - assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", ',', Some('\u{a0}'), Some(HourCycle::H23))); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23))); // An English format in the US with the user's metric and A4 choices and a decimal comma. let r = from_windows( reg(&[("LocaleName", "en-US"), ("sDecimal", ","), ("iMeasure", "0"), ("iPaperSize", "9"), ("sShortTime", "h:mm tt")]), || None, ); - assert_eq!((r.decimal, r.measurement, r.paper, r.hour_cycle), (',', Measurement::Metric, Paper::A4, Some(HourCycle::H12))); + assert_eq!( + (r.decimal, r.grouping, r.measurement, r.paper, r.hour_cycle), + (Some(','), None, Measurement::Metric, Paper::A4, Some(HourCycle::H12)) + ); let r = from_windows(reg(&[("sDecimal", "dec"), ("sThousand", "")]), || Some("de-DE".into())); - assert_eq!((r.numbers.as_str(), r.decimal, r.grouping), ("de-DE", ',', None), "a separator longer than one character keeps the locale's"); + assert_eq!((r.numbers.as_str(), r.decimal), ("de-DE", None), "a separator longer than one character keeps the locale's"); + assert_eq!(r.grouping, Some(None), "an empty grouping separator is no grouping"); + assert_eq!(from_windows(reg(&[("sThousand", "abc")]), || None).grouping, None); } #[test] @@ -454,7 +443,7 @@ mod tests { let r = from_apple("en_PL", true, ",", "\u{a0}", Some("HH")); assert_eq!( (r.numbers.as_str(), r.paper, r.decimal, r.grouping, r.hour_cycle), - ("en-PL", Paper::A4, ',', Some('\u{a0}'), Some(HourCycle::H23)) + ("en-PL", Paper::A4, Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23)) ); let r = from_apple("en_US", false, ".", ",", Some("h a")); assert_eq!((r.measurement, r.hour_cycle), (Measurement::Us, Some(HourCycle::H12))); From c1f64180c99b50e397f30fa38fba9b5e45bb9ff3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:52 +0000 Subject: [PATCH 070/124] i18n: check whole_from after rounding 9,960,000 bytes in DECIMAL with whole_from(10) rounds to 10.0 MB, which is written 10 MB. --- crates/i18n/src/size.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs index 1466672b..be724bde 100644 --- a/crates/i18n/src/size.rs +++ b/crates/i18n/src/size.rs @@ -114,12 +114,17 @@ impl FileSize { } loop { let value = bytes as f64 / self.scale(unit); - let mut decimals = self.decimals.get(unit as usize).copied().unwrap_or(0); - if unit == SizeUnit::Byte || (self.whole_from > 0 && value >= f64::from(self.whole_from)) { + let round = |decimals: u8| { + let factor = 10f64.powi(i32::from(decimals)); + (if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() }) / factor + }; + let mut decimals = if unit == SizeUnit::Byte { 0 } else { self.decimals.get(unit as usize).copied().unwrap_or(0) }; + let mut rounded = round(decimals); + // After rounding, so 9.96 MB, which rounds to 10.0, is written 10 MB. + if self.whole_from > 0 && rounded >= f64::from(self.whole_from) { decimals = 0; + rounded = round(0); } - let factor = 10f64.powi(i32::from(decimals)); - let rounded = if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() } / factor; // 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB. if rounded >= self.step as f64 && let Some(&next) = SizeUnit::ALL.get(unit as usize + 1) @@ -221,6 +226,8 @@ mod tests { assert_eq!(en(640_400, style), "640 KB"); assert_eq!(en(3_400_000, style), "3.4 MB"); assert_eq!(en(12_600_000, style), "13 MB"); + assert_eq!(en(9_960_000, style), "10 MB", "whole once rounded to 10"); + assert_eq!(en(9_940_000, style), "9.9 MB"); assert_eq!(en(5_000_000_000, style), "5000 MB"); // Only sizes below the smallest unit round up. assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); From 44dbe322265ec67f0834362a468a395ce0faebc4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:45:40 +0000 Subject: [PATCH 071/124] i18n: glibc names in Collator; fa and hi collation; Persian letters and digits in folding Collator::new takes sv_SE.UTF-8 and sr_RS@latin. Folding maps every script's decimal digits to ASCII and, ignoring accents, Persian keheh, Farsi yeh, alef maksura, teh marbuta and heh with hamza to the Arabic letters. The collation data grows 4.8 KB (465 bytes gzipped). --- crates/i18n/README.md | 16 ++++--- crates/i18n/data/collation.postcard | Bin 749171 -> 753949 bytes crates/i18n/data/generate-collation.sh | 8 ++-- crates/i18n/src/collate.rs | 44 +++++++++++++++-- crates/i18n/src/fold.rs | 63 +++++++++++++++++++++++-- 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 77e3d996..996d9268 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -73,12 +73,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l translation. `looked_up()` lists the source texts looked up so far, for an audit against a complete catalog. - **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and - accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the - original text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold - to their plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`; invisible format - characters (soft hyphens, joiners, bidi isolates) are dropped. Turkish and Azerbaijani fold `I` - to `ı` when accents count; ignoring accents, `I`, `i`, `İ` and `ı` all match. + accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the original + text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold to their + plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`, digits of every script to ASCII + (`Layer ١٢` finds `layer 12`); ignoring accents, Persian and Arabic letter forms match (`ک` and + `ك`, `ی` and `ي`, `ة` and `ه`, `أ` and `ا`); invisible format characters (soft hyphens, joiners, + bidi isolates) are dropped. Turkish and Azerbaijani fold `I` to `ı` when accents count; ignoring + accents, `I`, `i`, `İ` and `ı` all match. - **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships, - with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` / - `-u-co-stroke` for Chinese. The data (750 KB) is regenerated by `data/generate-collation.sh`; + Persian and Hindi, with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` + / `-u-co-stroke` for Chinese. The data (754 KB) is regenerated by `data/generate-collation.sh`; the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped). diff --git a/crates/i18n/data/collation.postcard b/crates/i18n/data/collation.postcard index 0ea89d72b5b70cff1b93486909e2f2515ae38509..7eabc2f2f834412c2870fe1438d9b0e83d076a7f 100644 GIT binary patch delta 1501 zcmah{ZD?C%6h7zPyt!$c*``a^G)cSGjZxcWbRTTcjn1xm^Hrs`orMafiEFR79qE>S z;16dT)zrh+Llg zoO9myp7-4Md4B)i^WKX)cwOi_QG(;}Jj9^^)0n|a;mF|7v%@2?vGEDfTw=9XZ`sze zJI3t_w;kmVgtZiNSRgxD2304@pqfxa`0T{&Tf)F&SfMT3S}h?s4a-oD+D*YH z%$Lrh*^1gR@vez6m!hHBt_^Io2pQC7eEs{FZ%$bR00Sw^TwA%ylcx zY6LK8(-EBt2seBV|E=OkB@kF>_|xPFLKmg}Ums zJAz|&s!I^883afPlA01FmHzb31`CZW162T~-L!vrHq`Z?Sn`PIhDF4?f;Pokaw^>b zXI`cA@J@(z)rVjdrr=G|?<1Ip%Wwm3!=F%wPHe(f3}CV%#Oj5|^>5m?wu7G@HTTvd z_dlN3;E(^%-o*SXiuUB@t)$?deY#kN`Z^oR>oR4B*#A4gNB8a}1e{7T{;ODZP;M?Z zWNbawn=`h*xR%d5iaC?}=;Hg76kWKVsw>58MUmLLLn)Hi9lEC6@6a_R?@+R26B04cWd9d2n)vb#~-kd1t2V z5HW3$m*kNl!NN;+$PN(*iCUJkQBV+j2tkmJ9=du91YbJ*PXEh)`BC9;U!&Z^Eja>J z;BXI;_!{5f5B-iYRkOJ5xNJ7Ti?CZkS=*DZt6)Jf^Uy&GZ`>Xl9ETX>s2moF60 zUl<;_dVQ8ZsWxm+r|j2hW;+;H8rqF(+DzRxXFbmgOpcy*ag=%j?xK&VE4jgZ@yyvv zC6`mX&#*gVQbK4Q0DobR`8E- zvix@pVDbgR;s(O2O@wz_2p>KoY=1)dwvF)XGs5l{gg-k72LZyrCW6tF;rs12NQR?O zs>ETb7KfL@vM_9|1=2(bd;M0T=KsDqu%_ISq?MI%Z1fxJ{RR%zo|aoENP;Ycgf<~8 PM1-giTQ9fTf4)Bka1)rK diff --git a/crates/i18n/data/generate-collation.sh b/crates/i18n/data/generate-collation.sh index cc7bbd3d..35f605eb 100755 --- a/crates/i18n/data/generate-collation.sh +++ b/crates/i18n/data/generate-collation.sh @@ -1,7 +1,7 @@ #!/bin/sh # Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n -# ships (its `locales/` directory) plus English: each locale's default order, and Chinese -# pinyin and stroke orders. +# ships (its `locales/` directory) plus English, Persian and Hindi: each locale's default order, +# and Chinese pinyin and stroke orders. # Needs icu4x-datagen at the version of the icu_collator dependency: # cargo install icu4x-datagen --version 2.3.0 --locked set -eu @@ -11,5 +11,5 @@ icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication --attribute-filter 'collator=/^(stroke|pinyin)?$/' \ --markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \ CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \ - --locales en ar bg cs da de el es et fi fr hr hu id it ja ka kk ko lt nl pl pt-BR ru sl sr \ - sr-Latn sv te tr uk vi zh-Hans zh-Hant + --locales en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt-BR ru sl \ + sr sr-Latn sv te tr uk vi zh-Hans zh-Hant diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs index d73b1efc..408f40b1 100644 --- a/crates/i18n/src/collate.rs +++ b/crates/i18n/src/collate.rs @@ -28,10 +28,10 @@ pub struct Collator(Option); impl Collator { /// Sorting for `locale`, a language code such as `sv`, `pt-br` or `zh-hant` (any case, `-` - /// or `_`). A BCP 47 collation type picks another order where there is one: - /// `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. + /// or `_`, glibc forms such as `sv_SE.UTF-8` and `sr_RS@latin`). A BCP 47 collation type + /// picks another order where there is one: `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. pub fn new(locale: &str) -> Collator { - let mut prefs: CollatorPreferences = Locale::try_from_str(&locale.replace('_', "-")).unwrap_or(Locale::UNKNOWN).into(); + let mut prefs: CollatorPreferences = Locale::try_from_str(&bcp47(locale)).unwrap_or(Locale::UNKNOWN).into(); prefs.numeric_ordering = Some(CollationNumericOrdering::True); let options = CollatorOptions::default(); let collator = Data::new().ok().and_then(|data| { @@ -58,6 +58,25 @@ impl Collator { } } +/// `locale` as a BCP 47 tag: `_` → `-`, a glibc codeset dropped and its script modifier made a +/// subtag (`sr_RS.UTF-8@latin` → `sr-latn-RS`). +fn bcp47(locale: &str) -> String { + let locale = locale.trim(); + let mut parts: Vec<&str> = locale.split(['.', '@']).next().unwrap_or_default().split(['-', '_']).collect(); + let script = match locale.split_once('@').map(|(_, m)| m.to_ascii_lowercase()).as_deref() { + Some("latin") => Some("latn"), + Some("cyrillic") => Some("cyrl"), + _ => None, + }; + let has_script = parts.get(1).is_some_and(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic())); + if let Some(script) = script + && !has_script + { + parts.insert(1.min(parts.len()), script); + } + parts.join("-") +} + /// The embedded collation data, with locale fallback, and the decomposition tables the collator /// also needs taken from `icu_normalizer`'s compiled data rather than embedded a second time. struct Data(LocaleFallbackProvider); @@ -123,8 +142,10 @@ mod tests { "el", "es", "et", + "fa", "fi", "fr", + "hi", "hr", "hu", "id", @@ -177,6 +198,23 @@ mod tests { assert_eq!(sorted("ja", &["漢字", "カメラ", "あめ", "いぬ", "Zoom"]), ["Zoom", "あめ", "いぬ", "カメラ", "漢字"]); } + #[test] + fn glibc_locale_names() { + let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"]; + assert_eq!(sorted("sv_SE.UTF-8", &words), sorted("sv", &words)); + assert_ne!(sorted("sv_SE.UTF-8", &words), sorted("en", &words)); + let cities = ["天津", "北京", "上海"]; + assert_eq!(sorted("zh_TW.UTF-8", &cities), sorted("zh-hant", &cities)); + assert_eq!(sorted("zh_CN.UTF-8", &cities), sorted("zh-hans", &cities)); + // Serbian Cyrillic sorts Cyrillic first; Latin Serbian sorts Latin first, `lj` after `lu`. + let mixed = ["ljubav", "lula", "абв"]; + assert_eq!(sorted("sr_RS.UTF-8", &mixed), ["абв", "ljubav", "lula"]); + assert_eq!(sorted("sr_RS.UTF-8@latin", &mixed), ["lula", "ljubav", "абв"]); + assert_eq!(bcp47("sr_RS.UTF-8@latin"), "sr-latn-RS"); + assert_eq!(bcp47("sr_Latn_RS@latin"), "sr-Latn-RS"); + assert_eq!(bcp47("de_DE@euro"), "de-DE"); + } + #[test] fn chinese_sorts_by_pinyin_or_stroke() { // 北 běi (5 strokes), 上 shàng (3), 天 tiān (4). diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs index 19289fe3..3a3330b1 100644 --- a/crates/i18n/src/fold.rs +++ b/crates/i18n/src/fold.rs @@ -4,8 +4,10 @@ //! [`Folder::fold`] turns text into a search key: compatibility forms decomposed (`fi` → `fi`, //! full-width `A` → `a`, `㎏` → `kg`), lower case, `ß` → `ss`, final `ς` → `σ`, and with accents //! ignored (the default) combining accents, Hebrew points and Arabic vowel marks dropped and the -//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`). -//! Invisible format characters (soft hyphens, zero-width joiners, bidi isolates) are dropped too. +//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`), and +//! Persian letter forms mapped to the Arabic ones (`ک` → `ك`, `ی` → `ي`, `ة` → `ه`). Decimal +//! digits of every script fold to ASCII (`١٢` → `12`). Invisible format characters (soft +//! hyphens, zero-width joiners, bidi isolates) are dropped too. //! //! Dotted and dotless i follow the locale when accents count. In Turkish and Azerbaijani, `I` //! folds to `ı` and `İ` to `i`; elsewhere both fold to `i` and `ı` stays a letter of its own. @@ -33,7 +35,7 @@ impl Folder { /// Case- and accent-insensitive folding for `locale`, a language code such as `tr`, `pt-br` /// or `zh-hant` (any case, `-` or `_`). pub fn new(locale: &str) -> Folder { - let language = locale.split(['-', '_']).next().unwrap_or_default(); + let language = locale.split(['-', '_', '.', '@']).next().unwrap_or_default(); Folder { turkic: language.eq_ignore_ascii_case("tr") || language.eq_ignore_ascii_case("az"), accents: false } } @@ -87,7 +89,13 @@ impl Folder { out.push('i'); } 'I' if self.turkic && self.accents => out.push('ı'), + // Persian heh with hamza above (ۀ) decomposes to ae (ە) and hamza. + '\u{6D5}' if !self.accents && chars.peek() == Some(&'\u{654}') => { + chars.next(); + out.push('ه'); + } 'ß' | 'ẞ' => out.push_str("ss"), + c if let Some(d) = ascii_digit(c) => out.push(d), 'Σ' | 'σ' | 'ς' => out.push('σ'), c if ignorable(c) || (!self.accents && accent(c)) => {} c => { @@ -103,9 +111,15 @@ impl Folder { } } -/// The base of a lower-case letter whose diacritic is built in rather than combining. +/// The base of a lower-case letter whose diacritic is built in rather than combining, and the +/// Arabic form of a letter that Persian and Urdu write differently: keheh (ک) is kaf (ك), Farsi +/// yeh (ی) and alef maksura (ى) are yeh (ي), teh marbuta (ة) is heh (ه), alef wasla (ٱ) is alef. fn base_letter(c: char) -> Option<&'static str> { Some(match c { + 'ک' => "ك", + 'ی' | 'ى' => "ي", + 'ة' => "ه", + 'ٱ' => "ا", 'ı' => "i", 'ø' => "o", 'ł' => "l", @@ -139,6 +153,27 @@ fn accent(c: char) -> bool { | '\u{6DF}'..='\u{6E4}' | '\u{6E7}' | '\u{6E8}' | '\u{6EA}'..='\u{6ED}') } +/// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode +/// 16.0. +const ZEROS: [u32; 76] = [ + 0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0, + 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0, + 0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950, + 0x11BF0, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, + 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0, +]; + +/// A decimal digit of any script as its ASCII digit: `١٢` and `१२` search as `12`. +fn ascii_digit(c: char) -> Option { + let c = u32::from(c); + let zero = match ZEROS.binary_search(&c) { + Ok(i) => ZEROS.get(i), + Err(i) => ZEROS.get(i.checked_sub(1)?), + }?; + let value = c - zero; + if value < 10 { char::from_digit(value, 10) } else { None } +} + /// Folded text and where each part came from. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Folded { @@ -245,6 +280,10 @@ mod tests { ("he", "שָׁלוֹם", "שלום".into(), nfd("שָׁלוֹם")), ("en", "soft\u{AD}ware \u{2068}name\u{2069}", "software name".into(), "software name".into()), ("ru", "Ёлка", "елка".into(), nfd("ёлка")), + ("ar", "Layer ١٢ ۳۴ १२", "layer 12 34 12".into(), "layer 12 34 12".into()), + ("fa", "کتاب یک", "كتاب يك".into(), "کتاب یک".into()), + ("fa", "خانۀ", "خانه".into(), nfd("خانۀ")), + ("ar", "مدرسة على إسلام آمن", "مدرسه علي اسلام امن".into(), nfd("مدرسة على إسلام آمن")), ]; for (locale, input, ignoring, keeping) in table { assert_eq!(key(locale, input), ignoring, "{locale} {input}"); @@ -287,6 +326,22 @@ mod tests { assert_eq!(all, [0..2, 3..5, 6..8]); } + #[test] + fn persian_and_arabic_letters_match() { + let f = Folder::new("fa"); + // Persian keheh and Farsi yeh against Arabic kaf and yeh, and hamza forms of alef. + assert!(f.contains("كتاب عربي", "کتاب عربی")); + assert!(f.contains("کتاب عربی", "كتاب عربي")); + assert!(f.contains("أحمد إبراهيم", "احمد ابراهیم")); + assert!(f.contains("مدرسة", "مدرسه")); + assert_eq!(f.fold("Layer ١٢").find(&f.fold("layer 12")), Some(0.."Layer ١٢".len())); + assert!(Folder::new("en").keep_accents().contains("Layer ١٢", "layer 12")); + let digits = ZEROS.iter().map(|&zero| char::from_u32(zero + 7).unwrap()); + assert!(digits.clone().all(|d| d.is_numeric() && ascii_digit(d) == Some('7'))); + assert_eq!(ascii_digit('\u{1D800}'), None); + assert_eq!(ascii_digit('/'), None); + } + #[test] fn accents_count_only_whole_letters() { let f = Folder::new("fr").keep_accents(); From d0acf36588bf191f8b7d0e1643fad509ed71ebd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:47:51 +0000 Subject: [PATCH 072/124] keymap: Shift presses egui reports as their position run nothing on moved layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dvorak's ⇧, types <, which egui has no key for, so egui reports the press as W and Ctrl+Shift+, ran Ctrl+Shift+W. Once typing shows the layout's letters are not on QWERTY positions, such a press resolves to nothing unless the position is known to type its own letter. --- crates/keymap/README.md | 5 ++++- crates/keymap/src/layout.rs | 32 ++++++++++++++++++++++++++++++-- crates/keymap/tests/layout.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index f4719461..3d19cd31 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -93,7 +93,10 @@ macOS it folds into `cmd` (`Chord::effective`). 3. Punctuation: the character typed, without Shift when the layout needs Shift for it: German `/` is ⇧7, so ⇧⌘7 runs ⌘/. 4. Whether a layout is Latin is decided for the layout, not per key. -5. Labels show letters and digits by name, and a punctuation key as the character the layout +5. With Shift held on a Latin layout whose letters are known to be moved (Dvorak), a press that + egui reports as the letter at its own position, because the character typed has no key + (Dvorak's ⇧, types `<`), runs nothing: Ctrl+Shift+, never runs Ctrl+Shift+W. +6. Labels show letters and digits by name, and a punctuation key as the character the layout types at its position when that character has no key of its own (`Ctrl+Ü` for Ctrl+[ on German). diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs index 3beacce3..0605db17 100644 --- a/crates/keymap/src/layout.rs +++ b/crates/keymap/src/layout.rs @@ -16,7 +16,12 @@ //! each other so zoom keys work with and without Shift. //! 4. **Whether a layout is Latin** is decided for the whole layout ([`Layout::is_latin`]), never //! per key: Dvorak's `'` key on the Q position stays `'`. -//! 5. **Labels** show letters and digits by name, and a punctuation key as the character the +//! 5. **No position fallback for unnamed characters on moved layouts:** egui reports a character +//! that has no key (Dvorak's ⇧, types `<`) as the key's position, W. With Shift held on a +//! Latin layout whose letters are known to sit elsewhere than on QWERTY, such a press at a +//! letter position resolves to nothing unless that position is known to type its own letter, +//! so Ctrl+Shift+, never runs Ctrl+Shift+W. +//! 6. **Labels** show letters and digits by name, and a punctuation key as the character the //! layout types at its position when that character has no key of its own: Ctrl+[ is `Ctrl+Ü` //! on German and `Ctrl+Х` on Russian. //! @@ -92,6 +97,8 @@ pub struct Layout { seen_latin: Option, /// The character typed without modifiers at each punctuation position. chars: BTreeMap, + /// The character, lower case, typed without Cmd, Ctrl or Alt at each letter position. + letters: BTreeMap, } /// US base keys and the key egui reports for them with Shift held. @@ -175,6 +182,9 @@ impl Layout { if c.is_alphabetic() { self.seen_latin = Some(is_latin_letter(c)); } + if !c.is_control() { + self.letters.insert(physical, c.to_lowercase().next().unwrap_or(c)); + } } else if !mods.shift && !c.is_control() { self.set_char(physical, c); } @@ -192,11 +202,29 @@ impl Layout { { return Some(Chord::new(held, position)); } + if held.shift + && let Some(position) = input.physical.filter(|k| k.is_letter()) + && input.typed.is_none_or(|typed| typed == position) + && !self.types_own_letter(position) + { + return None; + } let key = input.typed.or(input.physical)?; let drop_shift = held.shift && key.is_symbol() && input.needs_shift(key); Some(Chord::new(if drop_shift { without_shift(held) } else { held }, key)) } + /// Rule 5: whether a press reported as the letter at its own position can be that letter. + /// Yes when the position is known to type it, or nothing is known to sit elsewhere than on + /// QWERTY; otherwise the press likely typed a character egui has no key for. + fn types_own_letter(&self, position: Key) -> bool { + let own = |key: Key, c: char| Key::from_char(c) == Some(key); + match self.letters.get(&position) { + Some(&c) => own(position, c), + None => self.letters.iter().all(|(&key, &c)| own(key, c)), + } + } + /// The chords a press can run, most specific first: [`resolve`](Self::resolve)'s chord, the /// same press written as Shift with a US base key (`Shift+;` for `:`), `=` for `+` and `+` /// for Shift+`=`, and on the digit row the position's digit. @@ -235,7 +263,7 @@ impl Layout { self.candidates(input).iter().any(|c| c.effective(platform) == want) } - /// Rule 5: the layout's character for the punctuation key `key`, when pressing that position + /// Rule 6: the layout's character for the punctuation key `key`, when pressing that position /// means `key` and the layout types `key`'s own character nowhere else that is known. pub(crate) fn key_label(&self, key: Key) -> Option { let typed = self.char_at(key)?; diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs index 31f344f3..e143a353 100644 --- a/crates/keymap/tests/layout.rs +++ b/crates/keymap/tests/layout.rs @@ -136,6 +136,34 @@ fn typing_decides_whether_the_layout_is_latin() { assert!(reported.is_latin()); } +#[test] +fn dvorak_shift_punctuation_never_runs_its_position() { + // Dvorak's , key sits on W and types < with Shift, which egui has no key for, so egui + // reports the press as W. + let ctrl_shift_lt = at("Cmd+Shift+W", Key::W); + let mut dvorak = Layout::new(); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "nothing known yet"); + + dvorak.observe(Key::S, Mods::NONE, "o"); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), None, "letters are known to be moved"); + assert!(!dvorak.matches(&chord("Ctrl+Shift+W"), &ctrl_shift_lt, Platform::Windows)); + dvorak.observe(Key::W, Mods::NONE, ","); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), None); + // Without Shift, and for letters typed where they sit (Dvorak's A) or elsewhere, presses + // resolve as before. + assert_eq!(dvorak.resolve(&at("Cmd+W", Key::W)), Some(chord("Cmd+W"))); + dvorak.observe(Key::A, Mods::NONE, "a"); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+A", Key::A)), Some(chord("Cmd+Shift+A"))); + assert_eq!(dvorak.resolve(&at("Cmd+Shift+O", Key::S)), Some(chord("Cmd+Shift+O"))); + + // QWERTY: a position typing its own letter keeps the position fallback. + let mut qwerty = Layout::new(); + qwerty.observe(Key::S, Mods::NONE, "s"); + qwerty.observe(Key::W, Mods::SHIFT, "W"); + assert_eq!(qwerty.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W"))); + assert_eq!(qwerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); +} + #[test] fn labels_show_the_layouts_own_punctuation() { let mut german = Layout::new().with_char(Key::OpenBracket, 'ü').with_char(Key::Slash, '-').with_char(Key::CloseBracket, '+'); From 233e0b7aa0f2d3b1718241df143246dab641e849 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:49:38 +0000 Subject: [PATCH 073/124] i18n: plural rules by language, CLDR data for every locale, trn_plain Plural rules are looked up by language alone: CLDR's parent of sr-Latn and zh-Hant is the root, whose only category is other, so sr-latn rows were rejected. The baked data covers every CLDR locale (10 KB): plural rules for he, ro, nb, ca, sk, th, ms and the rest, and decimal symbols for nb-NO, sk-SK, bn-BD, ar-MA and English in Europe. Format applies a Region's separators only where the system set them, and an empty grouping separator writes none. trn_plain writes counts in 0-9 without grouping, for documents and file names. reset_format goes back to the system region. trn_decimal and Format write infinities and NaN as CLDR does and take the other form. Tests that don't set a format use en-US, not the machine's region. The ICU4X crates are pinned to ~2.3, the minor version the data is for. --- crates/i18n/Cargo.toml | 17 ++-- crates/i18n/README.md | 42 ++++---- crates/i18n/data/generate.sh | 15 ++- crates/i18n/data/{percent.py => locales.py} | 18 ++-- crates/i18n/data/locales.rs | 69 ++++++++++++- crates/i18n/data/numbers.postcard | Bin 5664 -> 9553 bytes crates/i18n/src/format.rs | 68 +++++++++---- crates/i18n/src/lib.rs | 103 +++++++++++++++----- 8 files changed, 248 insertions(+), 84 deletions(-) rename crates/i18n/data/{percent.py => locales.py} (75%) diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 3daaca10..88e52d02 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -8,15 +8,16 @@ rust-version.workspace = true repository.workspace = true [dependencies] -# Numbers and plural rules, on CLDR data for the shipped locales (data/numbers.postcard). +# Numbers and plural rules, on CLDR data (data/numbers.postcard) generated for one ICU4X minor +# version, hence `~2.3`. fixed_decimal = { version = "0.7.2", default-features = false } -icu_decimal = { version = "2.3", default-features = false, features = ["serde"] } -icu_locale_core = { version = "2.3", default-features = false } -icu_locale_fallback = { version = "2.3", default-features = false, features = ["compiled_data"] } -icu_plurals = { version = "2.3", default-features = false, features = ["serde"] } -icu_provider = { version = "2.3", default-features = false, features = ["sync"] } -icu_provider_adapters = { version = "2.3", default-features = false } -icu_provider_blob = { version = "2.3", default-features = false } +icu_decimal = { version = "~2.3", default-features = false, features = ["serde"] } +icu_locale_core = { version = "~2.3", default-features = false } +icu_locale_fallback = { version = "~2.3", default-features = false, features = ["compiled_data"] } +icu_plurals = { version = "~2.3", default-features = false, features = ["serde"] } +icu_provider = { version = "~2.3", default-features = false, features = ["sync"] } +icu_provider_adapters = { version = "~2.3", default-features = false } +icu_provider_blob = { version = "~2.3", default-features = false } writeable = "0.6" sys-locale = "0.3" diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d9f97cb7..63eb7184 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -46,8 +46,7 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l translation never errors. - **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an `@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows, - `{n}` filled), `get` - (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row + `{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, @@ -59,8 +58,9 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`), - else the last. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` - `pt`'s. A language outside the baked data (below) has no plural rules and shows English forms. + else the last. Rules are per language, so `sr-latn` and `zh-hant` take `sr`'s and `zh`'s. CLDR's + `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. A language + CLDR has no plural rules for shows English forms. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and @@ -83,24 +83,28 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)` (`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin - digits, a decimal or grouping separator the region sets replaces the locale's (English in Poland: - `1.234,5`). `I18n::format` is the format `trn`, `file_size` and `measure` use: the system - region's until `I18n::set_format` (the user's region setting or digit choice). -- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 6 KB) and - percent patterns (`data/locales.rs`) cover the languages the catalogs and apps ship, with their - regional variants; `data/generate.sh` lists them and rebuilds both from CLDR. Add a language - there when a catalog adds one. The ICU4X code and data add about 90 KB to a stripped release - build and 80 KB to wasm. + digits, a decimal or grouping separator the system sets (Windows' and macOS's own settings) + replaces the locale's. `I18n::format` is the format `trn`, `file_size` and `measure` use: the + system region's until `I18n::set_format` (the user's region setting or digit choice), and again + after `I18n::reset_format`. A count in `trn` is therefore grouped and in the region's digits + (`1.234 Dateien`, `١٬٢٣٤ ملفًا`); for names written into documents or files (`Copy 1234`), use + `trn_plain`, which writes `0`–`9` without grouping. +- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 10 KB) and + percent patterns (`data/locales.rs`) cover every CLDR locale; `data/generate.sh` rebuilds both + from CLDR. The ICU4X crates are pinned to one minor version (`~2.3`), the one the data is + generated for. The ICU4X code and data add about 90 KB to a stripped release build and 80 KB to + wasm. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, and the decimal and grouping separators (or no grouping) and clock when the system - sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: Windows' regional format with the user's changes - (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), - `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and `LC_PAPER` each on its own over - `LANG` on Unix (`C` there is `en-US`; `LANG=C` gives way to the preferred language), the preferred language on the web and Android. `Region::from_setting` takes - the app's saved choice (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's - territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. Ported from WordCraft's - `Region`. + sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: + Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's + current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, + `LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is `en-US`; + `LANG=C` gives way to the preferred language), the preferred language on the web and Android. + `Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and + measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. + Ported from WordCraft's `Region`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh index 695a698a..91b6ab94 100755 --- a/crates/i18n/data/generate.sh +++ b/crates/i18n/data/generate.sh @@ -1,23 +1,22 @@ #!/usr/bin/env bash -# Regenerates the number and plural data in this directory from Unicode CLDR, for the languages -# the craft-i18n catalogs and the apps ship (each with its regional variants). +# Regenerates the number and plural data in this directory from Unicode CLDR, for every CLDR +# locale: 10 KB, of which CLDR's modern-coverage locales alone would be 7 KB. Lookups fall back +# at run time, so a locale whose data matches its parent's is left out. # -# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X version in Cargo.toml) and -# python3. Downloads CLDR's JSON release once into $CACHE. +# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X minor version in Cargo.toml) +# and python3. Downloads CLDR's JSON release once into $CACHE. set -euo pipefail CLDR=48.2.1 -LANGUAGES=(en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt ru sl sr sv te tr uk vi zh) HERE=$(cd "$(dirname "$0")" && pwd) CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data} mkdir -p "$CACHE" -# `sr-Latn` and `zh-Hant` are named as well: their CLDR parent is the root, not `sr` or `zh`. TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ --cldr-tag "$CLDR" \ --markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \ - --locales "${LANGUAGES[@]}" sr-Latn zh-Hant \ + --locales full --deduplication maximal \ --out "$HERE/numbers.postcard" ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip -python3 -I "$HERE/percent.py" "$ZIP" "$CLDR" "${LANGUAGES[@]}" > "$HERE/locales.rs" +python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs" diff --git a/crates/i18n/data/percent.py b/crates/i18n/data/locales.py similarity index 75% rename from crates/i18n/data/percent.py rename to crates/i18n/data/locales.py index 3c00bb44..337bcfb9 100644 --- a/crates/i18n/data/percent.py +++ b/crates/i18n/data/locales.py @@ -1,6 +1,7 @@ -"""Writes locales.rs: the covered languages and the percent affixes from CLDR's JSON release. +"""Writes locales.rs from CLDR's JSON release: the languages with plural rules and every locale's +percent affixes. -Usage: percent.py ... +Usage: locales.py """ import json @@ -21,12 +22,14 @@ def affixes(numbers, numsys): def main(): - path, version, languages = sys.argv[1], sys.argv[2], sys.argv[3:] + path, version = sys.argv[1], sys.argv[2] found = {} with zipfile.ZipFile(path) as z: + plurals = json.loads(z.read("cldr-core/supplemental/plurals.json"))["supplemental"]["plurals-type-cardinal"] + languages = sorted(l for l in plurals if l != "root" and "-" not in l) for name in z.namelist(): m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name) - if not m or m.group(1).split("-")[0] not in languages: + if not m or m.group(1) == "root": continue locale = m.group(1) numbers = json.loads(z.read(name))["main"][locale]["numbers"] @@ -49,8 +52,11 @@ def main(): print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).") print() - print("/// The languages the baked number and plural data cover, each with its regional variants.") - print("pub(crate) const LANGUAGES: &[&str] = &[" + ", ".join(rust(l) for l in languages) + "];") + print("/// The languages CLDR has plural rules for; the baked data has them all.") + print("pub(crate) const PLURAL_LANGUAGES: &[&str] = &[") + for i in range(0, len(languages), 16): + print(" " + ", ".join(rust(l) for l in languages[i : i + 16]) + ",") + print("];") print() print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin") print("/// digits or the locale's own, text before the number, text after it. A locale missing here") diff --git a/crates/i18n/data/locales.rs b/crates/i18n/data/locales.rs index a2ad6e72..205a9661 100644 --- a/crates/i18n/data/locales.rs +++ b/crates/i18n/data/locales.rs @@ -1,7 +1,22 @@ // @generated by generate.sh from Unicode CLDR 48.2.1 (Unicode License). -/// The languages the baked number and plural data cover, each with its regional variants. -pub(crate) const LANGUAGES: &[&str] = &["en", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fa", "fi", "fr", "hi", "hr", "hu", "id", "it", "ja", "ka", "kk", "ko", "lt", "nl", "pl", "pt", "ru", "sl", "sr", "sv", "te", "tr", "uk", "vi", "zh"]; +/// The languages CLDR has plural rules for; the baked data has them all. +pub(crate) const PLURAL_LANGUAGES: &[&str] = &[ + "af", "ak", "am", "an", "ar", "ars", "as", "asa", "ast", "az", "bal", "be", "bem", "bez", "bg", "bho", + "blo", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ce", "ceb", "cgg", "chr", "ckb", "cs", "csw", "cv", + "cy", "da", "de", "doi", "dsb", "dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff", + "fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guw", "gv", "ha", "haw", "he", + "hi", "hnj", "hr", "hsb", "hu", "hy", "ia", "id", "ie", "ig", "ii", "io", "is", "it", "iu", "ja", + "jbo", "jgo", "jmc", "jv", "jw", "ka", "kab", "kaj", "kcg", "kde", "kea", "kk", "kkj", "kl", "km", "kn", + "ko", "kok", "ks", "ksb", "ksh", "ku", "kw", "ky", "lag", "lb", "lg", "lij", "lkt", "lld", "ln", "lo", + "lt", "lv", "mas", "mg", "mgo", "mk", "ml", "mn", "mo", "mr", "ms", "mt", "my", "nah", "naq", "nb", + "nd", "ne", "nl", "nn", "nnh", "no", "nqo", "nr", "nso", "ny", "nyn", "om", "or", "os", "osa", "pa", + "pap", "pcm", "pl", "prg", "ps", "pt", "rm", "ro", "rof", "ru", "rwk", "sah", "saq", "sat", "sc", "scn", + "sd", "sdh", "se", "seh", "ses", "sg", "sgs", "sh", "shi", "si", "sk", "sl", "sma", "smi", "smj", "smn", + "sms", "sn", "so", "sq", "sr", "ss", "ssy", "st", "su", "sv", "sw", "syr", "ta", "te", "teo", "th", + "ti", "tig", "tk", "tl", "tn", "to", "tpi", "tr", "ts", "tzm", "ug", "uk", "und", "ur", "uz", "ve", + "vec", "vi", "vo", "vun", "wa", "wae", "wo", "xh", "xog", "yi", "yo", "yue", "zh", "zu", +]; /// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin /// digits or the locale's own, text before the number, text after it. A locale missing here @@ -31,11 +46,27 @@ pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ ("ar-SY", false, "", "\u{66a}\u{61c}"), ("ar-TD", false, "", "\u{66a}\u{61c}"), ("ar-YE", false, "", "\u{66a}\u{61c}"), + ("az-Arab", false, "", "\u{66a}"), + ("bas", true, "", "\u{a0}%"), + ("be", true, "", "\u{a0}%"), + ("bgn", false, "", "\u{66a}"), + ("bgn", true, "", "\u{66a}"), + ("blo", true, "%\u{a0}", ""), + ("br", true, "", "\u{a0}%"), + ("bua", true, "", "\u{a0}%"), + ("ca", true, "", "\u{a0}%"), + ("ce", true, "", "\u{a0}%"), + ("ckb", false, "", "\u{a0}\u{66a}"), ("cs", true, "", "\u{a0}%"), + ("cv", true, "", "\u{a0}%"), ("da", true, "", "\u{a0}%"), ("de", true, "", "\u{a0}%"), ("de-CH", true, "", "%"), ("de-LI", true, "", "%"), + ("dsb", true, "", "\u{a0}%"), + ("dua", true, "", "\u{a0}%"), + ("dz", false, "", "\u{a0}%"), + ("dz", true, "", "\u{a0}%"), ("en-AT", true, "", "\u{a0}%"), ("en-DE", true, "", "\u{a0}%"), ("en-DK", true, "", "\u{a0}%"), @@ -66,14 +97,48 @@ pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ ("es-US", true, "", "%"), ("es-UY", true, "", "%"), ("es-VE", true, "", "%"), + ("eu", true, "%\u{a0}", ""), ("fa", false, "", "\u{66a}"), ("fi", true, "", "\u{a0}%"), + ("fo", true, "", "\u{a0}%"), ("fr", true, "", "\u{a0}%"), ("fr-CH", true, "", "%"), + ("gl", true, "", "\u{a0}%"), + ("gsw", true, "", "\u{a0}%"), ("hr", true, "", "\u{a0}%"), + ("hsb", true, "", "\u{a0}%"), + ("ht", true, "", "\u{a0}%"), + ("kl", true, "", "\u{a0}%"), + ("ks", false, "", "\u{66a}"), + ("ksh", true, "", "\u{a0}%"), + ("ku", true, "%", ""), + ("ku-Arab", true, "", "%"), + ("lb", true, "", "\u{a0}%"), + ("lrc", false, "", "\u{66a}"), ("lt", true, "", "\u{a0}%"), + ("mk", true, "", "\u{a0}%"), + ("mzn", false, "", "\u{66a}"), + ("nb", true, "", "\u{a0}%"), + ("nds", true, "", "\u{a0}%"), + ("nn", true, "", "\u{a0}%"), + ("no", true, "", "\u{a0}%"), + ("oc", true, "", "\u{202f}%"), + ("pa-Arab", false, "", "\u{66a}"), + ("ps", false, "", "\u{66a}"), + ("qu", true, "", "\u{a0}%"), + ("rn", true, "", "\u{a0}%"), + ("ro", true, "", "\u{a0}%"), ("ru", true, "", "\u{a0}%"), + ("sd", false, "", "\u{66a}\u{61c}"), + ("sdh", false, "", "\u{66a}\u{61c}"), + ("se", true, "", "\u{a0}%"), + ("sk", true, "", "\u{a0}%"), ("sl", true, "", "\u{a0}%"), + ("smn", true, "", "\u{a0}%"), ("sv", true, "", "\u{a0}%"), + ("tk", true, "", "\u{a0}%"), ("tr", true, "%", ""), + ("tt", true, "", "\u{a0}%"), + ("uz-Arab", false, "", "\u{66a}"), + ("zgh", true, "", "\u{a0}%"), ]; diff --git a/crates/i18n/data/numbers.postcard b/crates/i18n/data/numbers.postcard index 53764d4cc9c73c84627d11699de9e5f564923c92..5ee84e037e3467ca23300e955e2ba8ea6846436b 100644 GIT binary patch literal 9553 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 A*Z=?k literal 5664 zcmcJTTTola8OP7rBgEC%*sdMNvSJg!iH!)()wy&WA%sMP5FoB@XHwZvqacJw2-j|> zjbwwtsgtblCP^FLu&gSSD1_J!w6zaVr&(ZF0zuoLdJZzYsb#kwjZ{;pX=~= zcl)~iJ$ru4aHVP0D;<|MGiU1x>At5;&+h1*nlIRDEcfw}`5k(Lpmq6NJ9zPYm$9te zT=|Tx%I>JCb=J2C@k>;$EZq|wN6!D@Z)vOVfAW+&+TA zetS)cGo{9uQZsEVcQ{+}@*gTsY5F!QUn{g>#ST~&vkNT1LJzGn{V}ayZr)V%dHl+%IwyQ@(wG=RKpzYO^=D#2a&! zdcE4p3#QB#n;jjMKIk`Tu5&UCFMy$&#-#0a6jk|5F^k^E?*>+5cQ&-dhRa>HT!~hc zh^dS%+mNJuUT~w_TxG9z)W_$h&TIqPbdzR#4aQRZDRX5tMO33qn~c^Uaekw{P_tUMlMi zS`WR<9xzt$Qhn@^Z{kIUkC7WnBj!jO)zq@GbSurH=cj!y4h~(4Oq(kzpQ%#zuZYKg zZWznyH-U~@Mph1{8mnJRcf2ea-KUKe)it$?`_=OzJ1=VBIvi%~Y^}0dM99Td}!bE!FCC123xJMF>CT_W663uUX4C(285z_PF@^Dq8mj zzR^%-y{O zuElC_Z@{Ai*}`MJ@?RA%?7_%WWj1>QMrc773)=A;Q_c&Fyl~Rv+rvuveF#5YW~#C` zOWAAnMy}iE^BC;dZ(1ncUuDe_Ds9RUK*={5WMhTY%r{l?O>&zrlW)-b+dMt|UdSfA z>EsSC25IH_Zmw&`0n!^X+g)CFYuiqK|D@IKtnHL-F1(KPiCVAR?Z3w$TkT71$<;Wp z3(jqU>@CTSvZG~Dz~nYd@`pd)>2CpzHs`Jln@S&i^1ItAZQFma%k6u4SFrmK-udE- z{ZsTZ7nv07-``KiUt|aSdnet#9z47h#NLa60at)FT4cdsz~ATT$EJxd95B?>5A+GJQ>WaRAYb?+mRm;$N&Za5wk~_!O8$ zK{yR2z)euY1mQe*RuY7d!C{Rc+yJfUcolpI{sA(zg0K-dz>mP|;0E{-tWFn%ZD1F8 z8C(W)AU#769so_?c@P30fq#R0G6lf`_JIia3-}U=6)GMey(Ea+; zWlIyE<-`*nu;|!@jA0-Qa6MiTAN5-vV0F>}%7`?oGUB&9ke(qg*O2E#1_Q15KnY$! zro4Leh!ff_UHXCKLZ?V}?Hv2Z49b>cau-VIO_6nQ5%H{%WF39Hgv$+Fou9waA zvAKh6uAj{fu(?Ch&7q4R1TKLv_!WqNH^4CXH5id@hTw(Zh2VwYh2VwYh2VwYh2VwY zh2e$ah2e$ah2e$ah2e$ah2e$aMc_r?Mc_r?Mc_r?Mc_r?Mc_r?4Z|CTHwt`fH$Hx^*QW-Fo*h?uVq$UwswN$EDBvhVl6T z?h)zM)n7jj5=Br=!1*iy8v7d?q zRP<8ON5w%Z`l%S8;t+}Hnh#bB-|GA3yZn~OKnw_QmR z7ivnru7)L?l9XO*O_EbgUoa)M5V@pSiJV%KG3?v1N*- zh4oorB(5!!B#kCb``N(J&Bf|k?oh|_)Etx)$G^H^*gU0ky3#o@@SQ5zN=ee<3;rjs z89sS!^Gsys4f-FXh_F(l$;ime`YiC00W3`6w)%K7Byp8io0XNF0~do*k3`E_YRSo6 MXi-POcP0$, - /// The region's separators where the system set them apart from the locale's. + /// The system's separators where they differ from the locale's; an empty `group` is none. group: Option, point: Option, percent: (&'static str, &'static str), @@ -37,8 +37,7 @@ pub struct Format { impl Format { /// The format for `region`'s number locale. With Latin digits, a decimal or grouping separator - /// the region sets (Windows' and macOS's own settings, English in continental Europe) replaces - /// the locale's. + /// the region carries (Windows' and macOS's own settings) replaces the locale's. pub fn new(region: &Region, digits: Digits) -> Format { let mut locale = Locale::try_from_str(®ion.numbers).unwrap_or(Locale::UNKNOWN); if digits == Digits::Latin { @@ -50,15 +49,14 @@ impl Format { let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); if latin { - let point = region.decimal.to_string(); - if probe.point != point { + if let Some(point) = region.decimal.map(String::from).filter(|p| *p != probe.point) { // `1,234.5` with a decimal comma becomes `1.234,5`, never `1,234,5`. if probe.group == point && region.grouping.is_none() { format.group = Some(probe.point.clone()); } format.point = Some(point); } - if let Some(g) = region.grouping.map(String::from).filter(|g| *g != probe.group) { + if let Some(g) = region.grouping.map(|g| g.map(String::from).unwrap_or_default()).filter(|g| *g != probe.group) { format.group = Some(g); } } @@ -76,12 +74,12 @@ impl Format { self.write(&Decimal::from(n.into())) } - /// `value` rounded to `decimals` digits after the decimal separator. - /// Infinities and NaN are written as Rust writes them. + /// `value` rounded to `decimals` digits after the decimal separator; infinities and NaN are + /// `∞`, `-∞` and `NaN`. pub fn number(&self, value: f64, decimals: usize) -> String { match to_decimal(value, decimals) { Some(d) => self.write(&d), - None => value.to_string(), + None => non_finite(value).to_owned(), } } @@ -89,7 +87,7 @@ impl Format { /// `50%`, `50 %` (French, German), `%50` (Turkish), `٥٠٪؜` (Egyptian Arabic). pub fn percent(&self, ratio: f64, decimals: usize) -> String { let Some(d) = to_decimal(ratio * 100.0, decimals) else { - return format!("{}%", ratio * 100.0); + return format!("{}{}{}", self.percent.0, non_finite(ratio), self.percent.1); }; let p = self.parts(&d); format!("{}{}{}{}", p.sign, self.percent.0, p.text, self.percent.1) @@ -200,6 +198,15 @@ pub(crate) fn to_decimal(value: f64, decimals: usize) -> Option { Some(d) } +/// CLDR's root symbols for a value that isn't finite. +fn non_finite(value: f64) -> &'static str { + match value { + f64::INFINITY => "∞", + f64::NEG_INFINITY => "-∞", + _ => "NaN", + } +} + /// The percent affixes for a number locale (`ar-EG`), found by dropping trailing subtags. fn percent_affixes(tag: &str, latin: bool) -> (&'static str, &'static str) { let mut tag = tag; @@ -248,17 +255,39 @@ mod tests { } #[test] - fn region_separators() { - // English in Poland writes a decimal comma; the grouping moves out of its way. - assert_eq!(Format::from_tag("en-PL").number(1234.5, 1), "1.234,5"); + fn cldr_separators_for_every_region() { + for (tag, want) in [ + ("en-PL", "1.234.567,5"), + ("en-DE", "1.234.567,5"), + ("ar-MA", "1.234.567,5"), + ("de-CH", "1'234'567.5"), + ("nb-NO", "1\u{a0}234\u{a0}567,5"), + ("sk-SK", "1\u{a0}234\u{a0}567,5"), + ("bn-BD", "১২,৩৪,৫৬৭.৫"), + ("he-IL", "1,234,567.5"), + ("km", "1,234,567.5"), + ("lo", "1.234.567,5"), + ] { + assert_eq!(Format::from_tag(tag).number(1_234_567.5, 1), want, "{tag}"); + } + } + + #[test] + fn system_separators() { + // A decimal comma the system sets moves the grouping out of its way. + let mut region = Region::from_tag("en-US"); + region.decimal = Some(','); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1.234,5"); let mut region = Region::from_tag("de-DE"); - region.grouping = Some('\u{a0}'); + region.grouping = Some(Some('\u{a0}')); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234,5"); - region.decimal = '.'; + region.decimal = Some('.'); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234.5"); + region.grouping = Some(None); + assert_eq!(Format::new(®ion, Digits::Locale).integer(1_234_567), "1234567", "no grouping"); // A system's separators don't apply to Arabic-Indic digits. let mut region = Region::from_tag("ar-EG"); - region.grouping = Some(','); + region.grouping = Some(Some(',')); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "١٬٢٣٤٫٥"); } @@ -280,7 +309,10 @@ mod tests { let en = Format::from_tag("en"); assert_eq!(en.number(-1234.5, 0), "-1,234"); assert_eq!(en.number(-0.04, 1), "0.0", "no sign on zero"); - assert_eq!(en.number(f64::INFINITY, 1), "inf"); + assert_eq!(en.number(f64::INFINITY, 1), "∞"); + assert_eq!(en.number(f64::NEG_INFINITY, 1), "-∞"); + assert_eq!(en.number(f64::NAN, 1), "NaN"); + assert_eq!(Format::from_tag("de").percent(f64::NAN, 0), "NaN\u{a0}%"); assert_eq!(en.integer(-5_i64), "-5"); } } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 910041ed..a3924fe9 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -3,6 +3,11 @@ //! An app embeds the [craft-i18n](https://github.com/benandez/craft-i18n) catalogs at build time //! as an [`Embedded`] static, builds one [`I18n`] from it, and looks English source text up in a //! [`Lang`]. A missing translation is the English itself; nothing here fails. +//! +//! Counts ([`I18n::trn`]), file sizes and measures write their numbers as the formatting +//! [`Region`] does ([`I18n::format`]): grouped (`1.234`, `12,34,567`) and in the region's digits +//! (`١٬٢٣٤`). Text that goes into documents or file names should use [`I18n::trn_plain`], which +//! writes `0`–`9` without grouping. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; @@ -10,7 +15,7 @@ mod format; mod region; mod size; -/// The CLDR data for the shipped languages, from `data/generate.sh`. +/// The CLDR number and plural data for every CLDR locale, from `data/generate.sh`. mod data { use std::sync::OnceLock; @@ -189,25 +194,34 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers. `one` and - /// `other` are the English forms; they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers: grouped and + /// in the region's digits. `one` and `other` are the English forms; they are also the key of + /// the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let format = self.format(); - self.plural(lang, PluralOperands::from(n), n == 1, &format.integer(n), one, other) + self.plural(lang, Some(PluralOperands::from(n)), n == 1, &format.integer(n), one, other) + } + + /// [`I18n::trn`] with `{n}` in `0`–`9` without grouping (`Copy 1234`), for text that goes into + /// documents, file names or anything read back later. + pub fn trn_plain(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { + self.plural(lang, Some(PluralOperands::from(n)), n == 1, &n.to_string(), one, other) } /// [`I18n::trn`] for a count with `decimals` digits after the decimal separator (`1.5 files`). - /// The plural form follows the digits shown, so `1.0` takes English `other`. + /// The plural form follows the digits shown, so `1.0` takes English `other`; infinities and NaN + /// (`∞`, `NaN`) take `other` too. pub fn trn_decimal(&self, lang: Lang, n: f64, decimals: usize, one: &str, other: &str) -> String { let format = self.format(); let Some(d) = format::to_decimal(n, decimals) else { - return fmt(other, &[("n", &n.to_string())]); + return self.plural(lang, None, false, &format.number(n, decimals), one, other); }; let english_one = d.to_string() == "1"; - self.plural(lang, PluralOperands::from(&d), english_one, &format.write(&d), one, other) + self.plural(lang, Some(PluralOperands::from(&d)), english_one, &format.write(&d), one, other) } - fn plural(&self, lang: Lang, operands: PluralOperands, english_one: bool, n: &str, one: &str, other: &str) -> String { + /// `operands` `None` is the `other` category. + fn plural(&self, lang: Lang, operands: Option, english_one: bool, n: &str, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); let text = chain @@ -216,7 +230,7 @@ impl I18n { let locale = self.locales.get(i)?; let forms = locale.rows.get("@plural")?.get(&key)?; let (rules, categories) = locale.plural.as_ref()?; - let category = rules.category_for(operands); + let category = operands.map_or(PluralCategory::Other, |o| rules.category_for(o)); // A category whole numbers up to 1000 never reach has no form of its own. Russian // and Ukrainian decimals (`other`) take the `few` form (`1,5 файла`); any other // (French `many` for a million) takes the last. @@ -236,7 +250,7 @@ impl I18n { } /// How counts, file sizes and measures write numbers: [`Region::system`]'s format with the - /// locale's digits until [`I18n::set_format`]. + /// locale's digits until [`I18n::set_format`], and again after [`I18n::reset_format`]. pub fn format(&self) -> Arc { if let Some(f) = self.format.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return Arc::clone(f); @@ -250,6 +264,11 @@ impl I18n { *self.format.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(format)); } + /// Write numbers as the system region does again, undoing [`I18n::set_format`]. + pub fn reset_format(&self) { + *self.format.write().unwrap_or_else(PoisonError::into_inner) = None; + } + /// The languages to offer: English first, then the offered locales by code. Never the /// pseudo locale. pub fn languages(&self) -> Vec { @@ -432,15 +451,18 @@ fn bare(code: &str) -> Locale { } /// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order -/// (zero, one, two, few, many, other): one `@plural` form each. Regional and scripted codes fall -/// back to the language's rules. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other -/// than `pt-br` takes `pt-PT`'s. `None` for a language the baked data doesn't cover. +/// (zero, one, two, few, many, other): one `@plural` form each. The rules are looked up by +/// language alone: CLDR's parent of `sr-Latn` and `zh-Hant` is the root, whose only category is +/// `other`. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other than `pt-br` takes +/// `pt-PT`'s. `None` for a language CLDR has no plural rules for. fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { - let code = if code == "pt" || (code.starts_with("pt-") && code != "pt-br") { "pt-pt" } else { code }; - let locale = IcuLocale::try_from_str(code).ok()?; - if !data::LANGUAGES.contains(&locale.id.language.as_str()) { + let id = IcuLocale::try_from_str(code).ok()?.id; + let language = id.language.as_str(); + if !data::PLURAL_LANGUAGES.contains(&language) { return None; } + let brazil = id.region.is_some_and(|r| r.as_str() == "BR"); + let locale = IcuLocale::try_from_str(if language == "pt" && !brazil { "pt-PT" } else { language }).ok()?; let rules = PluralRules::try_new_cardinal_with_buffer_provider(data::provider()?, (&locale).into()).ok()?; let mut categories = Vec::new(); for n in 0..=1000_u32 { @@ -475,10 +497,12 @@ mod tests { ("de", "\tBad\n@nope\tA\tB\n\tUndo\tRückgängig\n", ""), ]; - /// `embedded`, offering every locale in it. + /// `embedded`, offering every locale in it, with `en-US` numbers rather than the machine's. fn all(embedded: &'static Embedded) -> (I18n, Vec) { let codes: Vec<&str> = embedded.iter().map(|(c, _, _)| *c).collect(); - I18n::new(embedded, &codes) + let (i, problems) = I18n::new(embedded, &codes); + i.set_format(Format::from_tag("en-US")); + (i, problems) } fn i18n() -> I18n { @@ -610,6 +634,20 @@ mod tests { assert_eq!(i.trn(Lang::EN, 12_345, "{n} file", "{n} files"), "12.345 files"); } + #[test] + fn plain_counts_and_the_system_format() { + let i = all(P).0; + i.set_format(Format::from_tag("ar-EG")); + assert_eq!(i.trn_plain(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1234 ملفًا"); + i.set_format(Format::from_tag("de-DE")); + assert_eq!(i.trn_plain(Lang::EN, 12_345, "copy {n}", "copy {n}"), "copy 12345"); + i.reset_format(); + assert_eq!( + i.trn(Lang::EN, 5, "{n} file", "{n} files"), + fmt("{n} files", &[("n", &Format::new(&Region::system(), Digits::Locale).integer(5))]) + ); + } + #[test] fn decimal_counts() { let i = all(P).0; @@ -627,6 +665,9 @@ mod tests { let ar = lang(&i, "ar"); assert_eq!(i.trn_decimal(ar, 2.5, 1, "{n} file", "{n} files"), "٢٫٥ ملف"); assert_eq!(i.trn_decimal(ar, 3.0, 0, "{n} file", "{n} files"), "٣ ملفات"); + // Not a number: the translated `other` form. + assert_eq!(i.trn_decimal(ru, f64::NAN, 1, "{n} file", "{n} files"), "NaN файла"); + assert_eq!(i.trn_decimal(Lang::EN, f64::INFINITY, 1, "{n} file", "{n} files"), "∞ files"); } static L: &Embedded = @@ -777,15 +818,31 @@ mod tests { assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); } - /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog - /// whose `@plural` rows list any other. + /// The counts craft-i18n's checker expects, which rejects a catalog whose `@plural` rows list + /// any other: every directory in craft-i18n's `locales/`, then languages it may add. #[test] fn plural_form_counts() { - for (locale, n) in - [("en", 2), ("fr", 2), ("ja", 1), ("vi", 1), ("ru", 3), ("pl", 3), ("cs", 3), ("sl", 4), ("ar", 6), ("pt", 2), ("pt-br", 2), ("tr", 2)] - { + #[rustfmt::skip] + let catalogs = [ + ("ar", 6), ("bg", 2), ("cs", 3), ("da", 2), ("de", 2), ("el", 2), ("es", 2), ("et", 2), + ("fi", 2), ("fr", 2), ("hr", 3), ("hu", 2), ("id", 1), ("it", 2), ("ja", 1), ("ka", 2), + ("kk", 2), ("ko", 1), ("lt", 3), ("nl", 2), ("pl", 3), ("pt-br", 2), ("ru", 3), ("sl", 4), + ("sr", 3), ("sr-latn", 3), ("sv", 2), ("te", 2), ("tr", 2), ("uk", 3), ("vi", 1), + ("zh-hans", 1), ("zh-hant", 1), + ]; + let more = [("en", 2), ("pt", 2), ("he", 3), ("ro", 3), ("nb", 2), ("ca", 2), ("sk", 3), ("th", 1), ("ms", 1), ("fa", 2), ("hi", 2)]; + for (locale, n) in catalogs.into_iter().chain(more) { assert_eq!(plural_rules(locale).map(|(_, c)| c.len()), Some(n), "{locale}"); } + assert!(plural_rules("tlh").is_none(), "no CLDR rules"); + } + + #[test] + fn serbian_latin_plurals() { + let i = all(&[("sr-latn", "@plural\t{n} file|{n} files\t{n} fajl|{n} fajla|{n} fajlova\n", "")]).0; + let sr = lang(&i, "sr-latn"); + let files = |n| i.trn(sr, n, "{n} file", "{n} files"); + assert_eq!((files(1), files(3), files(5)), ("1 fajl".into(), "3 fajla".into(), "5 fajlova".into())); } #[test] From 9c75eda59812f54a5db837eb50534905eb15a316 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:49:41 +0000 Subject: [PATCH 074/124] keymap: block the position fallback only at positions seen typing something else AZERTY's Ctrl+Shift+E works before an e has been typed. --- crates/keymap/README.md | 7 ++++--- crates/keymap/src/layout.rs | 20 ++++++++------------ crates/keymap/tests/layout.rs | 9 +++++++-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/keymap/README.md b/crates/keymap/README.md index 3d19cd31..71d88a4d 100644 --- a/crates/keymap/README.md +++ b/crates/keymap/README.md @@ -93,9 +93,10 @@ macOS it folds into `cmd` (`Chord::effective`). 3. Punctuation: the character typed, without Shift when the layout needs Shift for it: German `/` is ⇧7, so ⇧⌘7 runs ⌘/. 4. Whether a layout is Latin is decided for the layout, not per key. -5. With Shift held on a Latin layout whose letters are known to be moved (Dvorak), a press that - egui reports as the letter at its own position, because the character typed has no key - (Dvorak's ⇧, types `<`), runs nothing: Ctrl+Shift+, never runs Ctrl+Shift+W. +5. With Shift held on a Latin layout, a press that egui reports as the letter at its own + position, because the character typed has no key (Dvorak's ⇧, types `<`), runs nothing when + that position has been seen typing something other than its letter: once a `,` has been + typed, Ctrl+Shift+, never runs Ctrl+Shift+W. 6. Labels show letters and digits by name, and a punctuation key as the character the layout types at its position when that character has no key of its own (`Ctrl+Ü` for Ctrl+[ on German). diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs index 0605db17..ce486a5d 100644 --- a/crates/keymap/src/layout.rs +++ b/crates/keymap/src/layout.rs @@ -16,11 +16,11 @@ //! each other so zoom keys work with and without Shift. //! 4. **Whether a layout is Latin** is decided for the whole layout ([`Layout::is_latin`]), never //! per key: Dvorak's `'` key on the Q position stays `'`. -//! 5. **No position fallback for unnamed characters on moved layouts:** egui reports a character +//! 5. **No position fallback for unnamed characters on moved keys:** egui reports a character //! that has no key (Dvorak's ⇧, types `<`) as the key's position, W. With Shift held on a -//! Latin layout whose letters are known to sit elsewhere than on QWERTY, such a press at a -//! letter position resolves to nothing unless that position is known to type its own letter, -//! so Ctrl+Shift+, never runs Ctrl+Shift+W. +//! Latin layout, such a press at a letter position that has been seen typing something other +//! than its letter resolves to nothing, so Ctrl+Shift+, never runs Ctrl+Shift+W once a `,` +//! has been typed. //! 6. **Labels** show letters and digits by name, and a punctuation key as the character the //! layout types at its position when that character has no key of its own: Ctrl+[ is `Ctrl+Ü` //! on German and `Ctrl+Х` on Russian. @@ -214,15 +214,11 @@ impl Layout { Some(Chord::new(if drop_shift { without_shift(held) } else { held }, key)) } - /// Rule 5: whether a press reported as the letter at its own position can be that letter. - /// Yes when the position is known to type it, or nothing is known to sit elsewhere than on - /// QWERTY; otherwise the press likely typed a character egui has no key for. + /// Rule 5: whether a press reported as the letter at its own position can be that letter: + /// no only when the position has been seen typing something else, so the press likely typed + /// a character egui has no key for. fn types_own_letter(&self, position: Key) -> bool { - let own = |key: Key, c: char| Key::from_char(c) == Some(key); - match self.letters.get(&position) { - Some(&c) => own(position, c), - None => self.letters.iter().all(|(&key, &c)| own(key, c)), - } + self.letters.get(&position).is_none_or(|&c| Key::from_char(c) == Some(position)) } /// The chords a press can run, most specific first: [`resolve`](Self::resolve)'s chord, the diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs index e143a353..1786f89a 100644 --- a/crates/keymap/tests/layout.rs +++ b/crates/keymap/tests/layout.rs @@ -145,10 +145,10 @@ fn dvorak_shift_punctuation_never_runs_its_position() { assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "nothing known yet"); dvorak.observe(Key::S, Mods::NONE, "o"); - assert_eq!(dvorak.resolve(&ctrl_shift_lt), None, "letters are known to be moved"); - assert!(!dvorak.matches(&chord("Ctrl+Shift+W"), &ctrl_shift_lt, Platform::Windows)); + assert_eq!(dvorak.resolve(&ctrl_shift_lt), Some(chord("Cmd+Shift+W")), "the W position is unseen"); dvorak.observe(Key::W, Mods::NONE, ","); assert_eq!(dvorak.resolve(&ctrl_shift_lt), None); + assert!(!dvorak.matches(&chord("Ctrl+Shift+W"), &ctrl_shift_lt, Platform::Windows)); // Without Shift, and for letters typed where they sit (Dvorak's A) or elsewhere, presses // resolve as before. assert_eq!(dvorak.resolve(&at("Cmd+W", Key::W)), Some(chord("Cmd+W"))); @@ -156,6 +156,11 @@ fn dvorak_shift_punctuation_never_runs_its_position() { assert_eq!(dvorak.resolve(&at("Cmd+Shift+A", Key::A)), Some(chord("Cmd+Shift+A"))); assert_eq!(dvorak.resolve(&at("Cmd+Shift+O", Key::S)), Some(chord("Cmd+Shift+O"))); + // AZERTY: A is moved, but the unseen E position still runs Ctrl+Shift+E. + let mut azerty = Layout::new(); + azerty.observe(Key::Q, Mods::NONE, "a"); + assert_eq!(azerty.resolve(&at("Cmd+Shift+E", Key::E)), Some(chord("Cmd+Shift+E"))); + // QWERTY: a position typing its own letter keeps the position fallback. let mut qwerty = Layout::new(); qwerty.observe(Key::S, Mods::NONE, "s"); From f2ab6dc7f3df71ad523a465e0ab5b0483ee323d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:49:46 +0000 Subject: [PATCH 075/124] i18n: rewrap the Region README paragraph --- crates/i18n/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 7520f4ae..85f75c8b 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -71,13 +71,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, and the decimal and grouping separators (or no grouping) and clock when the system - sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: Windows' regional format with the user's changes - (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), - `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and `LC_PAPER` each on its own over - `LANG` on Unix (`C` there is `en-US`; `LANG=C` gives way to the preferred language), the preferred language on the web and Android. `Region::from_setting` takes - the app's saved choice (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's - territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. Ported from WordCraft's - `Region`. + sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: + Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's + current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, + `LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is `en-US`; + `LANG=C` gives way to the preferred language), the preferred language on the web and Android. + `Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and + measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. + Ported from WordCraft's `Region`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a From b010389df25cbb0b0425b48e4c19d554e6ca8d91 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:54:08 +0000 Subject: [PATCH 076/124] i18n: Format::parse on the format's own marks; lone decimal marks Format::parse reads what a Format writes: its digits, its decimal and grouping separators (the system's included) and the locale's Latin marks in an Arabic-Indic format, plus every digit set. parse_number is Format::from_tag(locale).parse, so the decimal marks come from CLDR's data alone and the hand-made table is gone. A lone . in a comma locale is the decimal mark when it can't be grouping (1.5, 0.250, 1234.5, .5); 1.234 and 1.234.567 stay grouped in German. The same holds for , in a point locale. Percent signs and units are documented as the caller's to strip. --- crates/i18n/README.md | 14 +- crates/i18n/src/format.rs | 14 +- crates/i18n/src/number.rs | 271 ++++++++++++++++++++++++-------------- 3 files changed, 191 insertions(+), 108 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d2cc7fda..e486ae02 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -65,12 +65,16 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and `yi`; putting text into display order is the app's job. -- **Reading numbers:** `parse_number(text, locale)` reads what people type or paste: any Unicode +- **Reading numbers:** `Format::parse(text)` reads what the format writes (its digits, decimal + and grouping separators, the system's included) and what people type or paste: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one - set; the locale's decimal mark (`.` or `,`) and `٫` everywhere; grouping by the locale's other - mark, spaces (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, - `−` or `+`; bidi marks ignored. Input that doesn't fit is an error, never a guess: `1,234` is - 1234 in English, 1.234 in German and an error for a locale whose decimal mark isn't known. + set; `٫` as a decimal mark everywhere; grouping by the locale's other mark of `.` and `,`, spaces + (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, `−` or `+`; + bidi marks ignored. A lone `.` in a comma locale is decimal when it can't be grouping (`1.5`, + `0.250`, `1234.5`), while `1.234` stays 1234 in German; the same holds for `,` in a point + locale. Percent signs and units (`50 %`, `12 px`) are not read: strip them first. Other input + that doesn't fit is an error, never a guess. `parse_number(text, locale)` is the same for + `Format::from_tag(locale)`. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs index 6def7d18..bcb20d05 100644 --- a/crates/i18n/src/format.rs +++ b/crates/i18n/src/format.rs @@ -11,6 +11,7 @@ use writeable::{Part, PartsWrite, Writeable}; use crate::Region; use crate::data::{self, PERCENT}; +use crate::number::Marks; /// Which digits numbers are written in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -33,6 +34,8 @@ pub struct Format { group: Option, point: Option, percent: (&'static str, &'static str), + /// The marks [`Format::parse`] reads. + pub(crate) marks: Marks, } impl Format { @@ -44,7 +47,7 @@ impl Format { locale.extensions.unicode.keywords.set(key!("nu"), value!("latn")); } let decimal = data::provider().and_then(|p| DecimalFormatter::try_new_with_buffer_provider(p, (&locale).into(), Default::default()).ok()); - let mut format = Format { decimal, group: None, point: None, percent: ("", "%") }; + let mut format = Format { decimal, group: None, point: None, percent: ("", "%"), marks: Marks::default() }; // The locale's own separators and digits, read from a number that shows them all. let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); @@ -61,6 +64,15 @@ impl Format { } } format.percent = percent_affixes(®ion.numbers, latin); + let point = format.point.clone().unwrap_or(probe.point); + let group = format.group.clone().unwrap_or(probe.group); + // Latin digits typed into an Arabic-Indic format take the locale's Latin marks. + let comma = match (latin, digits) { + (true, _) => point == ",", + (false, Digits::Locale) => !Format::new(region, Digits::Latin).marks.point, + (false, Digits::Latin) => false, + }; + format.marks = Marks::new(&point, &group, !comma); format } diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs index dfeb2e74..3de9c988 100644 --- a/crates/i18n/src/number.rs +++ b/crates/i18n/src/number.rs @@ -2,7 +2,9 @@ use std::fmt; -/// Why [`parse_number`] rejected a text. +use crate::Format; + +/// Why [`Format::parse`] rejected a text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NumberError { /// No digits: empty, or only spaces and a sign. @@ -11,8 +13,8 @@ pub enum NumberError { Unexpected(char), /// Digits from more than one set (`1٢`). MixedDigits, - /// A decimal or grouping mark where the locale puts none (`1,5` in English, `1.5` in German), - /// or one that could be read either way (`1,234` when the locale's decimal mark is unknown). + /// A decimal or grouping mark where the locale puts none (`1.234,5` in English, `1,2.5` in + /// German). Separator, /// Too large for an `f64`. TooLarge, @@ -32,68 +34,103 @@ impl fmt::Display for NumberError { impl std::error::Error for NumberError {} -/// The number in `text`, as someone using `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes -/// it. -/// -/// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, -/// full-width, …), all from one set. -/// - Decimal mark: the locale's `.` or `,`; `٫` in every locale. A locale this crate doesn't know -/// takes either, when only one reading fits (`1,5`, `1.234,5`). -/// - Grouping: the locale's other mark of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, `’` -/// and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). Never in -/// the fraction. -/// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. -/// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a number -/// formatted for an RTL interface reads back. -/// -/// Anything else is an error rather than a guess: `1,234` is 1234 in English, 1.234 in German and -/// [`NumberError::Separator`] for an unknown locale. -pub fn parse_number(text: &str, locale: &str) -> Result { - let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); - let mut rest = text.trim(); - let negative = match rest.chars().next() { - Some(c @ ('-' | '−' | '-' | '+' | '+')) => { - rest = rest.get(c.len_utf8()..).unwrap_or(""); - matches!(c, '-' | '−' | '-') - } - _ => false, - }; - let mut tokens = Vec::with_capacity(rest.len()); - let mut zero = None; - for c in rest.chars() { - let token = match c { - '.' | '.' => Token::Point, - ',' | ',' => Token::Comma, - '٫' => Token::Decimal, - '٬' => Token::Group('٬'), - '\'' | '’' => Token::Group('\''), - ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), - _ => { - let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; - if *zero.get_or_insert(z) != z { - return Err(NumberError::MixedDigits); - } - Token::Digit(d) - } +/// The marks a [`Format`] writes, as [`Format::parse`] reads them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Marks { + /// `.` is the decimal mark and `,` grouping; else the other way round. For a format in + /// Arabic-Indic digits, as the locale writes Latin ones. + pub(crate) point: bool, + /// The format's own decimal and grouping marks when they are neither `.` nor `,` (`٫`, `'`, a + /// mark the system sets). + decimal: Option, + group: Option, +} + +impl Default for Marks { + fn default() -> Self { + Marks { point: true, decimal: None, group: None } + } +} + +impl Marks { + pub(crate) fn new(decimal: &str, group: &str, point: bool) -> Marks { + let own = |s: &str| { + let mut chars = s.chars(); + chars.next().filter(|c| !matches!(c, '.' | ',') && chars.next().is_none()) }; - tokens.push(token); + Marks { point, decimal: own(decimal), group: own(group) } } - if zero.is_none() { - return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); +} + +impl Format { + /// The number in `text`: what this format writes, and what people type or paste. + /// + /// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, + /// Thai, full-width, …), all from one set. + /// - Decimal mark: the format's, `٫` in every format, and the locale's `.` or `,` when Latin + /// digits are typed into an Arabic-Indic format. A lone `.` in a comma locale is the decimal + /// mark when it can't be grouping (`1.5`, `0.250`, `1234.5`, `.5`), but `1.234` and + /// `1.234.567` stay grouped; the same holds for `,` in a point locale. + /// - Grouping: the format's, the other of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, + /// `’` and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). + /// Never in the fraction. + /// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. + /// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a + /// number formatted for an RTL interface reads back. + /// + /// Percent signs, units and the space before a unit (`50 %`, `12 px`) are not read; strip them + /// first. Anything else is an error rather than a guess. + pub fn parse(&self, text: &str) -> Result { + let marks = self.marks; + let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); + let mut rest = text.trim(); + let negative = match rest.chars().next() { + Some(c @ ('-' | '−' | '-' | '+' | '+')) => { + rest = rest.get(c.len_utf8()..).unwrap_or(""); + matches!(c, '-' | '−' | '-') + } + _ => false, + }; + let mut tokens = Vec::with_capacity(rest.len()); + let mut zero = None; + for c in rest.chars() { + let token = match c { + c if Some(c) == marks.decimal => Token::Decimal, + c if Some(c) == marks.group => Token::Group(c), + '.' | '.' => Token::Point, + ',' | ',' => Token::Comma, + '٫' => Token::Decimal, + '٬' => Token::Group('٬'), + '\'' | '’' => Token::Group('\''), + ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), + _ => { + let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; + if *zero.get_or_insert(z) != z { + return Err(NumberError::MixedDigits); + } + Token::Digit(d) + } + }; + tokens.push(token); + } + if zero.is_none() { + return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); + } + let point = if lone_decimal(&tokens, marks.point) { !marks.point } else { marks.point }; + let digits = read(&tokens, point).ok_or(NumberError::Separator)?; + let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; + if !value.is_finite() { + return Err(NumberError::TooLarge); + } + Ok(if negative { -value } else { value }) } - let digits = match decimal_mark(locale) { - Some(point) => read(&tokens, point), - None => match (read(&tokens, true), read(&tokens, false)) { - (Some(a), Some(b)) if a != b => None, - (a, b) => a.or(b), - }, - } - .ok_or(NumberError::Separator)?; - let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; - if !value.is_finite() { - return Err(NumberError::TooLarge); - } - Ok(if negative { -value } else { value }) +} + +/// The number in `text` as `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes it: +/// [`Format::parse`] for [`Format::from_tag`]. Build the [`Format`] once to read many numbers. A +/// locale CLDR doesn't know reads as CLDR's root does (`1,234.5`). +pub fn parse_number(text: &str, locale: &str) -> Result { + Format::from_tag(locale).parse(text) } #[derive(Clone, Copy, PartialEq)] @@ -101,12 +138,26 @@ enum Token { Digit(u8), Point, Comma, - /// `٫`, a decimal mark in every locale. + /// A decimal mark in every reading: `٫`, or the format's own. Decimal, - /// A grouping mark in every locale, by kind (space, apostrophe, `٬`). + /// A grouping mark in every reading, by kind (space, apostrophe, `٬`, the format's own). Group(char), } +/// Whether the locale's grouping mark of `.` and `,` (`.` when `point` is false) is the decimal +/// mark here because it can't be grouping: it is the only mark of the two, there is one of it, and +/// the digits before it are none, start with 0 or are more than three, or the digits after it +/// aren't three. +fn lone_decimal(tokens: &[Token], point: bool) -> bool { + let (group, decimal) = if point { (Token::Comma, Token::Point) } else { (Token::Point, Token::Comma) }; + if tokens.contains(&decimal) || tokens.contains(&Token::Decimal) || tokens.iter().filter(|t| **t == group).count() != 1 { + return false; + } + let Some(at) = tokens.iter().position(|t| *t == group) else { return false }; + let (int, frac) = (tokens.get(..at).unwrap_or_default(), tokens.get(at + 1..).unwrap_or_default()); + int.is_empty() || int.first() == Some(&Token::Digit(0)) || int.len() > 3 || frac.len() != 3 +} + /// The ASCII number `tokens` spell when the decimal mark is `.` (`point`) or `,`; `None` when the /// marks don't fit that reading. fn read(tokens: &[Token], point: bool) -> Option { @@ -160,36 +211,6 @@ fn read(tokens: &[Token], point: bool) -> Option { Some(format!("{}.{}", if int.is_empty() { "0" } else { &int }, if frac.is_empty() { "0" } else { &frac })) } -/// Whether `locale` writes decimals with `.` (`Some(true)`) or `,` (`Some(false)`); `None` for a -/// language not in the table (CLDR's decimal symbol for its Latin digits). -// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. -fn decimal_mark(locale: &str) -> Option { - let locale = locale.trim().to_ascii_lowercase(); - let base = locale.split(['.', '@']).next().unwrap_or("").replace('_', "-"); - let mut parts = base.split('-').filter(|p| !p.is_empty()); - let language = parts.next().unwrap_or(""); - let region = parts.find(|p| super::is_region(p)).unwrap_or(""); - Some(match (language, region) { - ("de" | "it", "ch" | "li") => true, - ("es", "419" | "mx" | "us" | "pr" | "do" | "gt" | "hn" | "ni" | "pa" | "pe" | "sv") => true, - ("en", "za" | "150" | "at" | "be" | "de" | "dk" | "fi" | "nl" | "se" | "si") => false, - ("ar", "dz" | "eh" | "ly" | "ma" | "tn") => false, - ( - "af" | "az" | "be" | "bg" | "bs" | "ca" | "cs" | "da" | "de" | "el" | "eo" | "es" | "et" | "eu" | "fi" | "fo" | "fr" | "fy" | "gl" | "hr" - | "hu" | "hy" | "id" | "is" | "it" | "ka" | "kk" | "km" | "ky" | "lb" | "lo" | "lt" | "lv" | "mk" | "nb" | "nl" | "nn" | "no" | "pl" - | "pt" | "ro" | "ru" | "sk" | "sl" | "sq" | "sr" | "sv" | "tg" | "tk" | "tr" | "tt" | "uk" | "uz" | "vi", - _, - ) => false, - ( - "am" | "ar" | "as" | "bn" | "bo" | "ckb" | "cy" | "dv" | "en" | "fa" | "fil" | "ga" | "gsw" | "gu" | "he" | "hi" | "ja" | "kn" | "ko" - | "ks" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "ne" | "or" | "pa" | "ps" | "rm" | "sd" | "si" | "sw" | "ta" | "te" | "th" | "tl" - | "ug" | "ur" | "yue" | "zh" | "zu", - _, - ) => true, - _ => return None, - }) -} - /// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode /// 16.0. const ZEROS: [u32; 76] = [ @@ -219,6 +240,7 @@ fn is_bidi_mark(c: char) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::{Digits, Region}; #[test] fn every_digit_set() { @@ -269,11 +291,12 @@ mod tests { (" 42 ", "en", 42.0), ("\u{2066}-1,5\u{2069}", "de", -1.5), ("\u{061c}-١٢", "ar", -12.0), - ("1,5", "xx", 1.5), - ("1.234,5", "", 1234.5), ("1,234.5", "xx", 1234.5), - ("1.234.567", "xx", 1_234_567.0), ("12", "xx", 12.0), + ("1,5", "en-PL", 1.5), + ("1.234,5", "en-DE", 1234.5), + ("1.234,5", "lo", 1234.5), + ("1,234.5", "km", 1234.5), ] { assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); } @@ -286,12 +309,7 @@ mod tests { (" ", "en", NumberError::Empty), ("-", "en", NumberError::Empty), (",", "en", NumberError::Separator), - ("1,234", "xx", NumberError::Separator), - ("1,5", "en", NumberError::Separator), - ("1.5", "de", NumberError::Separator), ("1.2.3", "en", NumberError::Separator), - ("12,34", "en", NumberError::Separator), - ("1234,567", "en", NumberError::Separator), ("1,,234", "en", NumberError::Separator), ("1,234,", "en", NumberError::Separator), ("1.234,5", "en", NumberError::Separator), @@ -312,5 +330,54 @@ mod tests { assert_eq!(parse_number(text, locale), Err(want), "{text:?} in {locale}"); } assert_eq!(parse_number(&"9".repeat(400), "en"), Err(NumberError::TooLarge)); + for unit in ["50 %", "50%", "12 px", "1,5 MB"] { + assert!(parse_number(unit, "de").is_err(), "{unit}: units are the caller's to strip"); + } + } + + /// A lone mark that can't be grouping is the decimal mark; one that can stays grouping. + #[test] + fn lone_marks() { + for (text, locale, want) in [ + ("1.5", "de", 1.5), + ("0.250", "de", 0.25), + ("1234.5", "de", 1234.5), + (".5", "de", 0.5), + ("-12.34", "ru", -12.34), + ("1.234", "de", 1234.0), + ("1.234.567", "de", 1_234_567.0), + ("1,5", "en", 1.5), + ("0,5", "en", 0.5), + ("12,34", "en", 12.34), + ("1234,567", "en", 1234.567), + ("1,234", "en", 1234.0), + ("12,345", "en", 12345.0), + ] { + assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); + } + } + + /// What a format writes reads back, in its digits and with its separators, the system's + /// included. + #[test] + fn reads_what_format_writes() { + let mut formats: Vec = ["en", "de", "de-CH", "fr", "hi", "ar-EG", "fa", "bn-BD", "ar-MA", "en-PL", "nb", "pt-PT", "sv"] + .iter() + .flat_map(|t| [Format::from_tag(t), Format::new(&Region::from_tag(t), Digits::Latin)]) + .collect(); + for (decimal, grouping) in [(Some(','), None), (Some('.'), Some(Some('\u{a0}'))), (None, Some(None)), (Some('·'), Some(Some('_')))] { + let mut region = Region::from_tag("en-US"); + region.decimal = decimal; + region.grouping = grouping; + formats.push(Format::new(®ion, Digits::Locale)); + } + for f in &formats { + for value in [0.0, 1.5, -1234.25, 1_234_567.89, 12.0, 1234.0] { + let text = f.number(value, 2); + assert_eq!(f.parse(&text), Ok(value), "{text:?} ({f:?})"); + } + let text = f.integer(1_234_567); + assert_eq!(f.parse(&text), Ok(1_234_567.0), "{text:?}"); + } } } From 502f7997167d3602920b63ac73be9f89c409ab17 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:54:12 +0000 Subject: [PATCH 077/124] i18n: han and catalog matching follow region-implied scripts han(zh-US), zh-AU and the other regions CLDR writes in Traditional characters give Traditional, and pa-PK picks a pa-arab catalog, never Gurmukhi pa, as pa-Arab-PK does. --- crates/i18n/README.md | 10 ++++----- crates/i18n/src/lib.rs | 30 +++++++++++++++++++++++-- crates/i18n/src/scripts.rs | 46 +++++++++++++++++++++----------------- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index ac6146b5..4bf29ace 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -64,14 +64,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → - SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and - Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → - `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script - is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` - picks `sr`. + Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → `zh-hant`, + `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never + crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. + A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 84295a16..6c841d1e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -292,7 +292,9 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { /// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when /// there is no `pt`. A glibc script modifier (`sr_RS@latin`) becomes the script subtag. With a /// script (`sr-Latn-RS`), a code without one is tried only when that is its language's usual -/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. +/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. A region that +/// implies a script (CLDR likely subtags) counts as that script after the tag as written: +/// `pa-PK` → `pa-pk`, `pa-arab-pk`, `pa-arab`. fn candidates(tag: &str) -> Vec { let tag = tag.trim().to_ascii_lowercase(); let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); @@ -309,6 +311,16 @@ fn candidates(tag: &str) -> Vec { parts.insert(1, s); } let script = parts.get(1).copied().filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())); + // Chinese picks its script by region below, keeping plain `zh` as the last resort. + if script.is_none() + && primary != "zh" + && let Some(implied) = parts.get(1).filter(|r| is_region(r)).and_then(|r| scripts::region_script(primary, r)) + { + let mut out = vec![parts.join("-")]; + parts.insert(1, implied); + out.extend(candidates(&parts.join("-"))); + return out; + } let mut out: Vec = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect(); if let Some(s) = script && usual_script(primary).is_some_and(|usual| usual != s) @@ -316,7 +328,8 @@ fn candidates(tag: &str) -> Vec { out.retain(|c| c.split('-').any(|p| p == s)); } if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) { - let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" }; + let hant = parts.iter().skip(1).any(|p| scripts::region_script("zh", p) == Some("hant")); + let script = if hant { "zh-hant" } else { "zh-hans" }; out.insert(out.len().saturating_sub(1), script.to_owned()); } if primary == "pt" && !out.iter().any(|c| c == "pt-br") { @@ -565,6 +578,8 @@ mod tests { ("zh", Some("zh-hans")), ("zh-Hant-MO", Some("zh-hant")), ("zh_CN", Some("zh-hans")), + ("zh-US", Some("zh-hant")), + ("zh_SG", Some("zh-hans")), ("C", Some("en")), ("POSIX", Some("en")), ("en_US", Some("en")), @@ -576,6 +591,17 @@ mod tests { } } + #[test] + fn region_implies_script() { + assert_eq!(candidates("pa-PK"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa_PK.UTF-8"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa-IN"), ["pa-in", "pa"]); + assert_eq!(candidates("sd-IN"), ["sd-in", "sd-deva-in", "sd-deva"]); + assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); + assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); + assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + } + #[test] fn match_tags_takes_first_match() { let i = all(L).0; diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 037b3d40..56a1ba1f 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -118,7 +118,7 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } - if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + if let Some(s) = tag.region.as_deref().and_then(|region| region_script(&tag.language, region)).and_then(subtag_scripts) { return s; } match tag.language.as_str() { @@ -162,39 +162,38 @@ pub fn direction(tag: &str) -> Direction { } /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → -/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; -/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the -/// choice to the font stack. +/// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice +/// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - match (tag.language.as_str(), tag.script.as_deref()) { + let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. - ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), - ("zh" | "wuu", _) => Some(match tag.region.as_deref() { - Some("tw") => Han::Traditional, - _ if hong_kong => Han::HongKong, - _ => Han::Simplified, - }), + ("yue", _) => Some(Han::HongKong), + ("zh" | "wuu", _) => Some(Han::Simplified), _ => None, } } -/// The scripts a language takes in a region where CLDR's likely subtags give it a script other -/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +/// The script subtag of a language in a region where CLDR's likely subtags give it a script +/// other than its usual one: `pa-PK` → `arab` (`pa-Arab-PK`), `zh-TW` → `hant`. // ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. -fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { - use Script::*; +pub(crate) fn region_script(language: &str, region: &str) -> Option<&'static str> { Some(match (language, region) { - ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], - ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], - ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], - ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], - ("sd", "in") => &[Deva], + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => "arab", + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => "arab", + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => "cyrl", + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => "latn", + ("sd", "in") => "deva", + ("zh", "au" | "bn" | "gb" | "gf" | "hk" | "id" | "mo" | "pa" | "pf" | "ph" | "sr" | "th" | "tw" | "us" | "vn") => "hant", + ("yue", "cn") => "hans", _ => return None, }) } @@ -340,6 +339,13 @@ mod tests { ("zh-hant-hk", Han::HongKong), ("zh-Hans-HK", Han::Simplified), ("zh-SG", Han::Simplified), + ("zh-US", Han::Traditional), + ("zh_AU.UTF-8", Han::Traditional), + ("zh-GB", Han::Traditional), + ("zh-Hans-US", Han::Simplified), + ("zh-MY", Han::Simplified), + ("yue-CN", Han::Simplified), + ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), ("yue-Hans", Han::Simplified), From 56bbf840a7ff71acd7f75f2bc5318df5ed8b3129 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:08 +0000 Subject: [PATCH 078/124] i18n: strip every isolate mark in fmt_plain --- crates/i18n/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 8fde98ad..2935b88f 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -275,10 +275,10 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { } /// [`fmt`] without isolates, for text that goes where the marks would get in the way: file -/// names, logs, terminals, text compared or parsed later. A value that is one isolate goes in -/// without it. +/// names, logs, terminals, text compared or parsed later. Every isolate mark is taken off, in +/// the template and in the values. pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { - fill(template, args, false) + strip_isolates(&fill(template, args, false)) } const LRI: char = '\u{2066}'; @@ -602,6 +602,7 @@ mod tests { assert_eq!(fmt("Saved {name} in {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/sara"))]), "Saved Report 2.pdf in /home/sara."); assert_eq!(fmt("{a}", &[("a", "x"), ("b", "שלום")]), "x", "an unused value doesn't count"); assert_eq!(fmt_plain("{a}", &[("a", &isolate("x"))]), "x"); + assert_eq!(fmt_plain("\u{2066}{a} {b}", &[("a", "x\u{2069}"), ("b", &isolate(<r("y")))]), "x y"); let i = all(P).0; assert_eq!(i.trn(Lang::EN, 2, "{n} page", "{n} pages"), "2 pages"); } From b7d5394802298329b2541a070a60f4f234023122 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:29 +0000 Subject: [PATCH 079/124] i18n: a grouping dot stays grouping beside a system decimal mark --- crates/i18n/src/format.rs | 2 +- crates/i18n/src/number.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs index bcb20d05..a0ac4cda 100644 --- a/crates/i18n/src/format.rs +++ b/crates/i18n/src/format.rs @@ -68,7 +68,7 @@ impl Format { let group = format.group.clone().unwrap_or(probe.group); // Latin digits typed into an Arabic-Indic format take the locale's Latin marks. let comma = match (latin, digits) { - (true, _) => point == ",", + (true, _) => point == "," || group == ".", (false, Digits::Locale) => !Format::new(region, Digits::Latin).marks.point, (false, Digits::Latin) => false, }; diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs index 3de9c988..0b44850e 100644 --- a/crates/i18n/src/number.rs +++ b/crates/i18n/src/number.rs @@ -365,8 +365,14 @@ mod tests { .iter() .flat_map(|t| [Format::from_tag(t), Format::new(&Region::from_tag(t), Digits::Latin)]) .collect(); - for (decimal, grouping) in [(Some(','), None), (Some('.'), Some(Some('\u{a0}'))), (None, Some(None)), (Some('·'), Some(Some('_')))] { - let mut region = Region::from_tag("en-US"); + for (tag, decimal, grouping) in [ + ("en-US", Some(','), None), + ("en-US", Some('.'), Some(Some('\u{a0}'))), + ("en-US", None, Some(None)), + ("en-US", Some('·'), Some(Some('_'))), + ("de-DE", Some('·'), None), + ] { + let mut region = Region::from_tag(tag); region.decimal = decimal; region.grouping = grouping; formats.push(Format::new(®ion, Digits::Locale)); From 31b5f953ee28d691162f2933c9aa7e88a80cc1e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:44 +0000 Subject: [PATCH 080/124] i18n: plain wording for where Region separators come from --- crates/i18n/src/region.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs index bbebe4c9..72342836 100644 --- a/crates/i18n/src/region.rs +++ b/crates/i18n/src/region.rs @@ -2,9 +2,9 @@ //! interface language. An English interface in Poland still writes `2,54 cm` on A4. //! //! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): -//! `paperSize` US-Letter and `measurementSystem` US. The separators numbers are written with -//! follow the number locale's CLDR data ([`crate::Format`]); the region carries them only where -//! the system sets its own. +//! `paperSize` US-Letter and `measurementSystem` US. Decimal and grouping separators come from +//! the number locale's CLDR data ([`crate::Format`]); the region carries its own only where the +//! system sets them. use std::sync::OnceLock; From c9013e08c7752a12c14f228e2d45ebf452c96a5b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:36 +0000 Subject: [PATCH 081/124] i18n: Hani and yue-Hant pick Han forms by region; usual scripts for kk, ky, tg, ku, ms, ug --- crates/i18n/src/lib.rs | 9 ++++++--- crates/i18n/src/scripts.rs | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 6c841d1e..db13b6db 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -358,10 +358,10 @@ fn marked(text: &str) -> String { // ponytail: the multi-script languages a Craft app could plausibly ship; add a row with the locale. fn usual_script(language: &str) -> Option<&'static str> { Some(match language { - "be" | "mn" | "sr" => "cyrl", + "be" | "kk" | "ky" | "mn" | "sr" | "tg" => "cyrl", "zh" => "hans", - "az" | "bs" | "ha" | "uz" => "latn", - "ks" | "sd" => "arab", + "az" | "bs" | "ha" | "ku" | "ms" | "uz" => "latn", + "ks" | "sd" | "ug" => "arab", "pa" => "guru", _ => return None, }) @@ -600,6 +600,9 @@ mod tests { assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + assert_eq!(candidates("kk-CN"), ["kk-cn", "kk-arab-cn", "kk-arab"]); + assert_eq!(candidates("kk-KZ"), ["kk-kz", "kk"]); + assert_eq!(candidates("ug-KZ"), ["ug-kz", "ug-cyrl-kz", "ug-cyrl"]); } #[test] diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 56a1ba1f..ebe324f5 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -163,18 +163,21 @@ pub fn direction(tag: &str) -> Direction { /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters -/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → /// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice /// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + // `Hani` names Han without saying which forms, so the region still decides. + let script = tag.script.as_deref().filter(|s| *s != "hani"); + let script = script.or_else(|| region_script(&tag.language, tag.region.as_deref()?)); match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), - ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + ("yue", Some("hant")) => Some(if tag.region.as_deref() == Some("tw") { Han::Traditional } else { Han::HongKong }), + ("zh" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. ("yue", _) => Some(Han::HongKong), ("zh" | "wuu", _) => Some(Han::Simplified), @@ -348,6 +351,10 @@ mod tests { ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), + ("yue-Hant", Han::HongKong), + ("yue-Hant-TW", Han::Traditional), + ("zh-Hani-HK", Han::HongKong), + ("zh-Hani", Han::Simplified), ("yue-Hans", Han::Simplified), ("ja-JP", Han::Japanese), ("ko_KR.UTF-8", Han::Korean), @@ -414,7 +421,7 @@ mod tests { /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { - for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + for language in ["be", "kk", "ky", "mn", "sr", "tg", "zh", "az", "bs", "ha", "ku", "ms", "uz", "ks", "sd", "ug", "pa"] { let usual = crate::usual_script(language).and_then(subtag_scripts); assert_eq!(usual, Some(scripts(language)), "{language}"); } From 540dafb9c35f609a7401f24f1a00b3b8d7e2e2c9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:07 +0000 Subject: [PATCH 082/124] i18n: generate.sh keeps locales.rs when locales.py fails --- crates/i18n/data/generate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh index 91b6ab94..2fac15d7 100755 --- a/crates/i18n/data/generate.sh +++ b/crates/i18n/data/generate.sh @@ -19,4 +19,5 @@ TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ --out "$HERE/numbers.postcard" ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip -python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs" +python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs.tmp" +mv "$HERE/locales.rs.tmp" "$HERE/locales.rs" From 61db8b71b58d9fc33a5a6907a8829c8d760bdc7a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:40 +0000 Subject: [PATCH 083/124] i18n: break collation ties by code point --- crates/i18n/src/collate.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs index 408f40b1..8d849299 100644 --- a/crates/i18n/src/collate.rs +++ b/crates/i18n/src/collate.rs @@ -42,12 +42,13 @@ impl Collator { Collator(collator) } - /// `a` against `b`. Text that differs only in case or accents still has an order, so a - /// sort is stable across runs. Without collation data (never with the embedded data) this is - /// code point order. + /// `a` against `b`. Text that differs only in case or accents still has an order, and text + /// the collation counts as equal (`Layer 2` and `Layer 02`) is in code point order, so a sort + /// doesn't depend on the input order. Without collation data (never with the embedded data) + /// this is code point order. pub fn compare(&self, a: &str, b: &str) -> Ordering { match &self.0 { - Some(c) => c.as_borrowed().compare(a, b), + Some(c) => c.as_borrowed().compare(a, b).then_with(|| a.cmp(b)), None => a.cmp(b), } } @@ -178,7 +179,9 @@ mod tests { #[test] fn digits_sort_by_value() { let layers = ["Layer 10", "Layer 2", "layer 1", "Layer 1", "Layer 2b", "Layer 02"]; - assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 2", "Layer 02", "Layer 2b", "Layer 10"]); + assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 02", "Layer 2", "Layer 2b", "Layer 10"]); + let reversed: Vec<&str> = layers.iter().rev().copied().collect(); + assert_eq!(sorted("en", &reversed), sorted("en", &layers)); assert_eq!(sorted("de", &["Ebene ٣", "Ebene 10", "Ebene 2"]), ["Ebene 2", "Ebene ٣", "Ebene 10"]); } From 365f7672b59b211481b3fbbf3faf5691ce9e67f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:40 +0000 Subject: [PATCH 084/124] i18n: don't match part of a Hangul syllable --- crates/i18n/src/fold.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs index 3a3330b1..aa662fa7 100644 --- a/crates/i18n/src/fold.rs +++ b/crates/i18n/src/fold.rs @@ -21,8 +21,8 @@ use std::ops::Range; use icu_normalizer::DecomposingNormalizerBorrowed; -use icu_properties::CodePointSetData; -use icu_properties::props::GraphemeExtend; +use icu_properties::props::{GraphemeExtend, HangulSyllableType}; +use icu_properties::{CodePointMapData, CodePointSetData}; /// Folds text for one locale. Cheap to build and copy. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -216,7 +216,14 @@ impl Folded { /// not leave out a mark that follows a letter, so `re` doesn't match `ré` when accents count. pub fn matches<'a>(&'a self, needle: &'a Folded) -> impl Iterator> + 'a { let extend = CodePointSetData::new::(); - let starts_mark = move |at: usize| self.text[at..].chars().next().is_some_and(|c| extend.contains(c)); + let hangul = CodePointMapData::::new(); + // The vowel and final consonant of a decomposed Hangul syllable continue it, like a mark. + let starts_mark = move |at: usize| { + self.text[at..] + .chars() + .next() + .is_some_and(|c| extend.contains(c) || matches!(hangul.get(c), HangulSyllableType::VowelJamo | HangulSyllableType::TrailingJamo)) + }; let n = needle.text.as_str(); let mut from = 0; let mut done = false; @@ -350,6 +357,10 @@ mod tests { assert!(f.contains("ré", "é")); // Kana voicing marks stay even with accents ignored: か doesn't find が. assert!(!Folder::new("ja").contains("が", "か")); + // Nor does a Hangul syllable without its final consonant: 하 doesn't find 한. + let ko = Folder::new("ko"); + assert!(!ko.contains("한국", "하") && !ko.keep_accents().contains("한국", "하")); + assert!(ko.contains("한국", "한") && ko.contains("하늘", "하")); } #[test] From 1a0e7e7bbf7b6dfa1b804bd4d951fca812f7bb40 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:08:30 +0000 Subject: [PATCH 085/124] keymap: learn nothing from presses while right Alt is held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui-winit reports AltGr with neither Ctrl nor Alt, so AltGr text read as plain typing: German AltGr+M (µ) marked the layout as not Latin and Ctrl+Z resolved by position as Ctrl+Y. µ, ª and º no longer count as a sign of another script. --- crates/keymap/src/egui.rs | 13 ++++++++++++- crates/keymap/src/layout.rs | 3 ++- crates/keymap/tests/egui.rs | 22 +++++++++++++++++++++- crates/keymap/tests/layout.rs | 2 ++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/keymap/src/egui.rs b/crates/keymap/src/egui.rs index b85b60e0..95a02114 100644 --- a/crates/keymap/src/egui.rs +++ b/crates/keymap/src/egui.rs @@ -199,6 +199,9 @@ pub struct Dispatcher { /// web build sends them. text_first: bool, layout: Layout, + /// Whether right Alt is held. egui-winit reports AltGr with neither Ctrl nor Alt, so its + /// presses would otherwise teach the layout as plain ones. + right_alt: bool, } /// eframe's web build sends a press's Text event first when no text field has focus. @@ -206,7 +209,7 @@ const TEXT_FIRST: bool = cfg!(target_arch = "wasm32"); impl Default for Dispatcher { fn default() -> Dispatcher { - Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST, layout: Layout::new() } + Dispatcher { last: None, repeat: false, clipboard: false, text_first: TEXT_FIRST, layout: Layout::new(), right_alt: false } } } @@ -350,6 +353,14 @@ impl Dispatcher { /// Teaches the layout what this frame's presses typed. fn learn(&mut self, events: &[Event], text_of: &[Option], platform: Platform) { for (event, text) in events.iter().zip(text_of) { + match event { + Event::Key { physical_key: Some(EguiKey::AltRight), pressed, .. } => self.right_alt = *pressed, + Event::WindowFocused(false) => self.right_alt = false, + _ => {} + } + if self.right_alt { + continue; + } let Some(Event::Text(text)) = text.and_then(|j| events.get(j)) else { continue }; if let Some(KeyInput { mods, physical: Some(physical), .. }) = KeyInput::from_egui(event, platform) { self.layout.observe(physical, mods, text); diff --git a/crates/keymap/src/layout.rs b/crates/keymap/src/layout.rs index ce486a5d..74f2c2f5 100644 --- a/crates/keymap/src/layout.rs +++ b/crates/keymap/src/layout.rs @@ -179,7 +179,8 @@ impl Layout { return; } if physical.is_letter() { - if c.is_alphabetic() { + // ª, µ and º are Latin-1 symbols on Latin layouts, no sign of another script. + if c.is_alphabetic() && !matches!(c, 'ª' | 'µ' | 'º') { self.seen_latin = Some(is_latin_letter(c)); } if !c.is_control() { diff --git a/crates/keymap/tests/egui.rs b/crates/keymap/tests/egui.rs index c7332b91..85f4ea48 100644 --- a/crates/keymap/tests/egui.rs +++ b/crates/keymap/tests/egui.rs @@ -1,7 +1,7 @@ #![cfg(feature = "egui")] use craft_keymap::egui::{Dispatcher, Hit, matches, platform}; -use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; +use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, KeyInput, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger}; use egui::{Event, InputState, Key as EguiKey, Modifiers}; fn chord(text: &str) -> Chord { @@ -482,6 +482,26 @@ fn the_dispatcher_reads_presses_by_the_layout() { let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)])); assert_eq!(ran, ["edit.comment"]); + // German on egui-winit: AltGr+M types µ and AltGr+] types ~ with no modifiers reported, so + // presses while right Alt is held teach nothing and Ctrl+Z stays Ctrl+Z on the Y position. + let mut d = Dispatcher::new(); + let typed = vec![at(EguiKey::Plus, EguiKey::CloseBracket, Modifiers::NONE), Event::Text("+".into())]; + run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed)); + let right_alt = + |pressed| Event::Key { key: EguiKey::AltRight, physical_key: Some(EguiKey::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE }; + run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![right_alt(true)])); + let alt_graph = vec![ + at(EguiKey::M, EguiKey::M, Modifiers::NONE), + Event::Text("µ".into()), + at(EguiKey::CloseBracket, EguiKey::CloseBracket, Modifiers::NONE), + Event::Text("~".into()), + right_alt(false), + ]; + run(&mut d, &km, &Active::NONE, frame(3.0, Modifiers::NONE, alt_graph)); + assert!(d.layout().is_latin()); + assert_eq!(d.layout().char_at(Key::CloseBracket), Some('+')); + assert_eq!(d.layout().resolve(&KeyInput::new(Mods::CMD).with_typed(Key::Z).with_physical(Key::Y)), Some(chord("Cmd+Z"))); + // A layout the platform reports applies from the first press. let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false)); let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)])); diff --git a/crates/keymap/tests/layout.rs b/crates/keymap/tests/layout.rs index 1786f89a..9d4f440c 100644 --- a/crates/keymap/tests/layout.rs +++ b/crates/keymap/tests/layout.rs @@ -127,6 +127,8 @@ fn typing_decides_whether_the_layout_is_latin() { assert!(!layout.is_latin(), "a command chord teaches nothing"); layout.observe(Key::S, Mods::NONE, "š"); assert!(layout.is_latin(), "Latin letters beyond ASCII count as Latin"); + layout.observe(Key::M, Mods::NONE, "µ"); + assert!(layout.is_latin(), "Latin-1 symbols that count as letters say nothing"); // What the platform reports wins. let mut reported = Layout::new().with_latin(false); From 2ff3f1165410a75d9051e9679446b8711f8a097b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:13:49 +0000 Subject: [PATCH 086/124] i18n: base_direction of a paragraph from its first strong character --- Cargo.lock | 1 + crates/i18n/Cargo.toml | 1 + crates/i18n/src/lib.rs | 2 +- crates/i18n/src/scripts.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..ebc4055f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "intl_pluralrules", "sys-locale", "unic-langid", + "unicode-bidi", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775..8283943f 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +unicode-bidi = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index db13b6db..ca955f85 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; -pub use scripts::{Direction, Han, Script, direction, han, scripts}; +pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ebe324f5..1ae0f29b 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -161,6 +161,18 @@ pub fn direction(tag: &str) -> Direction { } } +/// The direction of a paragraph of text, from its first strong character (UAX #9 rules P2 and +/// P3): the contents of isolates (LRI, RLI or FSI up to the matching PDI) are skipped, and the +/// search stops at the first paragraph separator. `None` when the paragraph has no strong +/// character; P3 then reads it as left to right, and an app may use the interface direction. +pub fn base_direction(text: &str) -> Option { + match unicode_bidi::get_base_direction(text) { + unicode_bidi::Direction::Ltr => Some(Direction::Ltr), + unicode_bidi::Direction::Rtl => Some(Direction::Rtl), + unicode_bidi::Direction::Mixed => None, + } +} + /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters /// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → @@ -277,6 +289,20 @@ mod tests { use super::Script::*; use super::*; + #[test] + fn base_direction_of_text() { + assert_eq!(base_direction("Hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("123 שלום"), Some(Direction::Rtl)); + assert_eq!(base_direction("(مرحبا) hi"), Some(Direction::Rtl)); + // The isolate's Hebrew is skipped; the Latin after it decides. + assert_eq!(base_direction("\u{2067}שלום\u{2069} hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("\u{2068}abc\u{2069} שלום"), Some(Direction::Rtl)); + // Only the first paragraph counts. + assert_eq!(base_direction("123\nשלום"), None); + assert_eq!(base_direction("1920 × 1080"), None); + assert_eq!(base_direction(""), None); + } + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. #[test] fn shipped_locales() { From b37040cb9358ea3361aca0ff512ba347d7b2e2bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:16:08 +0000 Subject: [PATCH 087/124] Add craft-fonts-build: embed craft-fonts faces from an app's build.rs --- Cargo.lock | 14 ++ Cargo.toml | 1 + README.md | 3 + crates/fonts-build/Cargo.toml | 14 ++ crates/fonts-build/README.md | 73 ++++++ crates/fonts-build/src/embed.rs | 228 ++++++++++++++++++ crates/fonts-build/src/lib.rs | 108 +++++++++ crates/fonts-build/src/manifest.rs | 103 ++++++++ crates/fonts-build/src/tests.rs | 139 +++++++++++ crates/fonts-build/tests/consumer/Cargo.toml | 18 ++ crates/fonts-build/tests/consumer/build.rs | 16 ++ crates/fonts-build/tests/consumer/src/lib.rs | 7 + .../fixture/craft-fonts/fonts/manifest.txt | 10 + .../craft-fonts/fonts/sans-arabic/OFL.txt | 1 + .../fonts/sans-arabic/SansArabic.ttf | 1 + .../craft-fonts/fonts/sans-cjk/OFL.txt | 1 + .../craft-fonts/fonts/sans-cjk/SansCJK.ttc | 1 + .../fixture/craft-fonts/fonts/sans-jp/OFL.txt | 1 + .../craft-fonts/fonts/sans-jp/SansJP-Bold.ttf | 1 + .../fonts/sans-jp/SansJP-Regular.ttf | 1 + .../craft-fonts/fonts/sans-thai/OFL.txt | 1 + .../craft-fonts/fonts/sans-thai/SansThai.ttf | 1 + crates/fonts-build/tests/generated.rs | 16 ++ 23 files changed, 759 insertions(+) create mode 100644 crates/fonts-build/Cargo.toml create mode 100644 crates/fonts-build/README.md create mode 100644 crates/fonts-build/src/embed.rs create mode 100644 crates/fonts-build/src/lib.rs create mode 100644 crates/fonts-build/src/manifest.rs create mode 100644 crates/fonts-build/src/tests.rs create mode 100644 crates/fonts-build/tests/consumer/Cargo.toml create mode 100644 crates/fonts-build/tests/consumer/build.rs create mode 100644 crates/fonts-build/tests/consumer/src/lib.rs create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/manifest.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/SansArabic.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Bold.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Regular.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/SansThai.ttf create mode 100644 crates/fonts-build/tests/generated.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9de428..35c43199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,20 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ca53f61a..77042f9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } +craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-media = { path = "crates/media" } craft-print = { path = "crates/print" } diff --git a/README.md b/README.md index 3289aba6..3272416e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` names face `n` of a font +collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the +eighth are ignored. diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs new file mode 100644 index 00000000..bfa37d63 --- /dev/null +++ b/crates/fonts-build/src/embed.rs @@ -0,0 +1,228 @@ +//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. + +use std::ffi::OsString; +use std::fmt::{self, Write as _}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches}; + +const DIR_VAR: &str = "CRAFT_FONTS_DIR"; +const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED"; +const EMPTY_TABLE: &str = "&[]\n"; + +/// Embed every face in `CRAFT_FONTS_DIR`'s manifest into `out_file` (relative paths are under +/// `OUT_DIR`). Shorthand for `Embed::new().write(out_file)`. +pub fn embed(out_file: impl AsRef) -> Result, Error> { + Embed::new().write(out_file) +} + +/// Whether the build script is building for wasm32 (web builds usually embed fewer faces). +pub fn target_is_wasm() -> bool { + std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32") +} + +type Filter = Box bool>; + +/// Which faces to embed, and from where. [`Embed::write`] does the work. +#[derive(Default)] +pub struct Embed { + scripts: Option>, + filter: Option, + dir: Option, + required: Option, +} + +impl Embed { + /// Every face, from `CRAFT_FONTS_DIR`, required when `CRAFT_FONTS_REQUIRED` is set. + pub fn new() -> Self { + Self::default() + } + + /// Embed only faces with at least one of these script tags (see + /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED`, each tag must match a face. + pub fn scripts(mut self, tags: &[&str]) -> Self { + self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect()); + self + } + + /// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]). + pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self { + self.filter = Some(Box::new(keep)); + self + } + + /// Read this checkout instead of `CRAFT_FONTS_DIR`. + pub fn dir(mut self, dir: impl Into) -> Self { + self.dir = Some(dir.into()); + self + } + + /// Override `CRAFT_FONTS_REQUIRED`. + pub fn required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } + + /// Write the face table to `out_file` and print the `cargo::` lines; returns the faces + /// embedded. + /// + /// Without a checkout the table is empty. A checkout that can't be read is a + /// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written + /// in every case, so on an error the build fails only on the message the caller prints. + pub fn write(&self, out_file: impl AsRef) -> Result, Error> { + let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") }; + let mut cargo = Vec::new(); + let result = self.run(&env, out_file.as_ref(), &mut cargo); + for line in cargo { + println!("{line}"); + } + result + } + + pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec) -> Result, Error> { + cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}")); + cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}")); + let out = match (out_file.is_absolute(), &env.out_dir) { + (true, _) => out_file.to_path_buf(), + (false, Some(out_dir)) => Path::new(out_dir).join(out_file), + (false, None) => return Err(Error::NoOutDir), + }; + let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| !v.is_empty() && v != "0")); + let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from)); + + let selected = match &dir { + None => Err(Error::NotSet), + Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }), + }; + let (table, faces) = match selected { + Ok(faces) => (table(&faces), faces.into_iter().map(|(entry, _)| entry).collect()), + Err(e) if required => { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()), + Err(e) => { + cargo.push(format!("cargo::warning=building without craft-fonts: {e}")); + (EMPTY_TABLE.to_owned(), Vec::new()) + } + }; + write(&out, &table)?; + Ok(faces) + } + + /// The wanted manifest entries with their canonical file paths. + fn select(&self, dir: &Path, cargo: &mut Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + cargo.push(format!("cargo::rerun-if-changed={}", manifest.display())); + let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?; + let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?; + let mut faces = Vec::new(); + for entry in entries { + let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); + if !wanted || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + continue; + } + let file = dir.join(&entry.file); + // `include_bytes!` resolves relative paths from the including source file. + let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?; + cargo.push(format!("cargo::rerun-if-changed={}", path.display())); + faces.push((entry, path)); + } + Ok(faces) + } + + fn check_required(&self, dir: &Path, faces: Vec<(ManifestEntry, PathBuf)>) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + if faces.is_empty() { + return Err(Error::NoFaces { manifest }); + } + let missing: Vec = self + .scripts + .iter() + .flatten() + .filter(|tag| !faces.iter().any(|(entry, _)| entry.scripts.iter().any(|s| script_matches(s, tag)))) + .cloned() + .collect(); + if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) } + } +} + +/// The environment a build script sees, separated out for tests. +pub(crate) struct Env { + pub dir: Option, + pub required: Option, + pub out_dir: Option, +} + +fn table(faces: &[(ManifestEntry, PathBuf)]) -> String { + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for (entry, path) in faces { + let scripts: Vec = entry.scripts.iter().map(|s| format!("{s:?}")).collect(); + let _ = writeln!( + src, + " EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},", + entry.family, + entry.style, + scripts.join(", "), + entry.index, + path.display().to_string(), + ); + } + src.push_str("]\n"); + src +} + +fn write(path: &Path, contents: &str) -> Result<(), Error> { + std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source }) +} + +/// Why the faces could not be embedded. Print it as `cargo::error={error}`. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// `CRAFT_FONTS_REQUIRED` is set but `CRAFT_FONTS_DIR` is unset or empty. + NotSet, + /// The manifest or a font file it lists can't be read. + Read { path: PathBuf, source: io::Error }, + /// The manifest has a malformed line. + Parse { manifest: PathBuf, error: ParseError }, + /// Required, but no face in the manifest was selected. + NoFaces { manifest: PathBuf }, + /// Required, but no face in the manifest has these requested script tags. + MissingScripts { manifest: PathBuf, scripts: Vec }, + /// The generated table can't be written. + Write { path: PathBuf, source: io::Error }, + /// `out_file` is relative and `OUT_DIR` is unset (not running as a build script). + NoOutDir, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSet => { + write!(f, "{REQUIRED_VAR} is set but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout") + } + Self::Read { path, source } => { + write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display()) + } + Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()), + Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR} is set but no face in {} was selected", manifest.display()), + Self::MissingScripts { manifest, scripts } => { + write!(f, "{REQUIRED_VAR} is set but {} has no face for {}", manifest.display(), scripts.join(", ")) + } + Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()), + Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } | Self::Write { source, .. } => Some(source), + Self::Parse { error, .. } => Some(error), + _ => None, + } + } +} diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs new file mode 100644 index 00000000..1b8d1363 --- /dev/null +++ b/crates/fonts-build/src/lib.rs @@ -0,0 +1,108 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's +//! `build.rs`. +//! +//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for +//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead: +//! +//! - **Unset or empty:** the table is empty and the app uses its own and system fonts. +//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`. +//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table. +//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds): a missing or unreadable checkout, no selected +//! face, or a requested script without a face is an [`Error`]. +//! +//! Add this crate both as a build-dependency (for [`embed`]) and as a dependency (for +//! [`EmbeddedFace`]). In `build.rs`'s `main`: +//! +//! ```no_run +//! println!("cargo::rerun-if-changed=build.rs"); +//! let wasm = craft_fonts_build::target_is_wasm(); +//! let embedded = craft_fonts_build::Embed::new() +//! // Web builds have a size budget: embed only the Japanese UI face there. +//! .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular")) +//! .write("craft_fonts.rs"); +//! if let Err(e) = embedded { +//! println!("cargo::error={e}"); +//! } +//! ``` +//! +//! and in the crate: +//! +//! ```ignore +//! use craft_fonts_build::EmbeddedFace; +//! +//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +//! ``` +//! +//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with +//! the same fields and that name in scope works too. + +mod embed; +mod manifest; +#[cfg(test)] +mod tests; + +pub use embed::{Embed, Error, embed, target_is_wasm}; +pub use manifest::{ManifestEntry, ParseError, parse_manifest}; + +/// A craft-fonts face embedded by [`embed`]: one line of `fonts/manifest.txt`. +#[derive(Clone, Copy)] +pub struct EmbeddedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// Style, e.g. `"Regular"` or `"Bold"`. + pub style: &'static str, + /// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a + /// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code). + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); `0` for a single font. + pub index: u32, + pub bytes: &'static [u8], +} + +impl EmbeddedFace { + /// `"