keymap: shortcuts dialog right-to-left layout, long-label truncation, pseudo-locale test and README

This commit is contained in:
aiongg committed 2026-10-19 12:03:23 +00:00
1 parent 94eacba872
commit d67d96e9bb
10 files changed
+859 -245

No files matched your search

+82 -3
View File
@@ -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).
+5 -4
View File
@@ -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());
}
+77 -22
View File
@@ -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<Entry> = 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<Entry> = 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<Entry> = 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<Entry> = 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<Entry> = 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)]
}
+4 -2
View File
@@ -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) }
})
+1 -3
View File
@@ -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));
}
+11 -4
View File
@@ -103,6 +103,8 @@ pub(crate) struct Message {
pub goto: Option<CommandRef>,
/// Offers Undo.
pub undo: bool,
/// The chord text inside `text`, which is never put through `Text::visual`.
pub chord: Option<String>,
}
/// 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: Binding, policy: Policy) -> Option<Assigned> {
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.
+318 -121
View File
@@ -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<T>(&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>) -> 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<Galley>, 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(&current)).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, &current, |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(&current)).show_ui(ui, |ui| {
self.combo(ui, "area", &l, &show, &current, |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::<usize>());
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::<f32>()) } 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<RowAction> {
@@ -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::<Vec<_>>().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<String> = 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<String> = 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::<Vec<_>>().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 ----
+68 -2
View File
@@ -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());
}
}
}
+133 -1
View File
@@ -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<RefCell<BTreeSet<String>>>,
/// 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<String> {
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])
}
}
+160 -83
View File
@@ -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<dyn Text>,
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<PathBuf> = 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<dyn Text> = 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));