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));