mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 13:02:25 +00:00
keymap: shortcuts dialog right-to-left layout, long-label truncation, pseudo-locale test and README
This commit is contained in:
1 parent
94eacba872
commit
d67d96e9bb
10 files changed
+859
-245
No files matched your search
+82
-3
@@ -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).
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¤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::<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 ----
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user