keymap: draw the shortcuts dialog: table, detail pane, conflict bar and footer

- view.rs: header with Show (dropdown or tabs), Filter and Search with search
  by key; table and detail pane in a resizable split, the detail pane below
  the table under 720 pt; virtual rows with chips, + and the restore button;
  detail pane with current shortcuts, New Shortcut, typed entry, Context,
  Assign, Use Default and Clear; prompts, conflict bar, messages, clash,
  shadow and problem lines; Undo, Redo, Reset All, Cancel and OK.
- Escape, Enter, Undo and Redo keys when idle; table keys (arrows, Home,
  End, Enter records, Delete removes); focus locks while recording.
- Accessibility labels for every control, row selection, and on_widget
  automation ids.
- style.rs: DialogStyle and the icon-button and divider styles.
- show() sizes the window to the screen; closing it is Cancel.
- tests/ui_dialog.rs: recording, conflicts, search, areas, display, labels,
  table keys, small windows, RTL. tests/ui_snapshot.rs (feature snapshot)
  writes PNGs when CRAFT_KEYMAP_SNAPSHOT_DIR is set.
This commit is contained in:
aiongg committed 2026-10-19 12:03:23 +00:00
1 parent b9bd4119cc
commit 39f343c47b
11 files changed
+2052 -45

No files matched your search

Generated
+1
View File
@@ -627,6 +627,7 @@ dependencies = [
"craft-ui",
"egui",
"egui_kittest",
"image",
"serde",
"serde_json",
"tempfile",
+3
View File
@@ -22,8 +22,11 @@ craft-settings = { workspace = true }
tempfile = "3"
egui = { workspace = true, features = ["default_fonts"] }
egui_kittest = "0.36"
image = { version = "0.25", default-features = false, features = ["png"] }
[features]
egui = ["dep:egui"]
# The Keyboard Shortcuts dialog.
ui = ["egui", "dep:craft-ui", "dep:craft-i18n"]
# Screenshots of the dialog for review (tests/ui_snapshot.rs; needs a GPU or software adapter).
snapshot = ["ui", "egui_kittest/wgpu"]
+40 -44
View File
@@ -15,16 +15,15 @@ use crate::import::{ForeignSet, ImportOptions};
use crate::keymap::{Bindable, Binding, Keymap};
use crate::profiles::{Builtins, ProfileError, Profiles};
// The view draws with these; until it calls every action some stay unused.
#[expect(dead_code, reason = "actions and row fields the view calls")]
mod capture;
#[expect(dead_code, reason = "actions and row fields the view calls")]
mod rows;
#[expect(dead_code, reason = "actions and row fields the view calls")]
mod state;
mod style;
mod text;
mod view;
pub use state::DialogState;
pub use style::DialogStyle;
pub use text::{English, I18nText, PLURALS, STRINGS, Text, trigger_text};
use state::Env;
@@ -90,37 +89,6 @@ pub struct Format {
pub extensions: &'static [&'static str],
}
/// The dialog's colours.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DialogStyle {
pub accent: egui::Color32,
pub warning: egui::Color32,
pub dim: egui::Color32,
pub changed: egui::Color32,
pub app_key: egui::Color32,
pub scoped_key: egui::Color32,
pub both_key: egui::Color32,
pub modifier_key: egui::Color32,
pub free_key: egui::Color32,
}
impl DialogStyle {
pub fn from_visuals(v: &egui::Visuals) -> DialogStyle {
let accent = v.selection.bg_fill;
DialogStyle {
accent,
warning: v.warn_fg_color,
dim: v.weak_text_color(),
changed: accent,
app_key: accent,
scoped_key: v.warn_fg_color,
both_key: v.error_fg_color,
modifier_key: v.widgets.active.bg_fill,
free_key: v.widgets.inactive.bg_fill,
}
}
}
/// A rule an app adds for which bindings a command may get; `Err` carries the message shown.
pub type Accept = Box<dyn Fn(&Bindable, &Binding) -> Result<(), String>>;
/// Called for every control once per frame with (automation id, rect, label).
@@ -229,19 +197,37 @@ impl<'a> ShortcutsDialog<'a> {
f(&mut env)
}
/// A window with title, body and Cancel/OK.
/// A window with title, body and Cancel/OK, sized to the screen (at least 640×480 where the
/// screen allows, at most 1320×900). Closing it with × is Cancel.
pub fn show(self, ctx: &egui::Context, state: &mut DialogState) -> Option<Outcome> {
let title = self.text.ui(self.options.map_or("Keyboard Shortcuts", |o| o.title)).into_owned();
egui::Window::new(title).collapsible(false).show(ctx, |ui| self.ui(ui, state)).and_then(|r| r.inner).flatten()
self.with_env(|env| {
let title = env.text.visual(&env.text.ui(env.options.title));
let screen = ctx.content_rect().size();
let max = egui::vec2(1320.0, 900.0).min(screen - egui::vec2(16.0, 16.0)).max(egui::vec2(240.0, 180.0));
let min = egui::vec2(640.0, 480.0).min(max);
let mut open = true;
let mut out = None;
egui::Window::new(title)
.id(egui::Id::new("craft_keymap.shortcuts"))
.collapsible(false)
.resizable(true)
.open(&mut open)
.default_size((screen * 0.8).clamp(min, max))
.min_size(min)
.max_size(max)
.show(ctx, |ui| out = frame(ui, env, state));
if !open && state.is_open() {
state.cancel(env);
out = state.finish();
}
out
})
}
/// The body only, for a host's own modal, preferences page or backstage.
/// The body only, for a host's own modal, preferences page or backstage. It fills the
/// available rectangle.
pub fn ui(self, ui: &mut egui::Ui, state: &mut DialogState) -> Option<Outcome> {
self.with_env(|env| {
state.begin(env);
state.input(env, ui.ctx());
state.finish()
})
self.with_env(|env| frame(ui, env, state))
}
/// OK, for hosts that draw their own button.
@@ -279,3 +265,13 @@ impl<'a> ShortcutsDialog<'a> {
});
}
}
/// One frame: take recorded keys before anything else sees them, draw, report.
fn frame(ui: &mut egui::Ui, env: &mut Env, state: &mut DialogState) -> Option<Outcome> {
state.begin(env);
state.input(env, ui.ctx());
if state.is_open() {
view::body(ui, env, state);
}
state.finish()
}
+6
View File
@@ -172,7 +172,13 @@ impl RowCache {
&self.built
}
/// The lines last built.
pub(crate) fn built(&self) -> &Built {
&self.built
}
/// Forgets the lines, for a change the key does not cover (the interface language).
#[allow(dead_code, reason = "for hosts that switch the interface language")]
pub(crate) fn invalidate(&mut self) {
self.key = None;
}
+36
View File
@@ -105,6 +105,7 @@ pub(crate) struct Message {
}
/// What to do after the Save changes prompt.
#[allow(dead_code, reason = "set row and import review")]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Then {
Switch(String),
@@ -112,6 +113,7 @@ pub(crate) enum Then {
Import,
}
#[allow(dead_code, reason = "set row prompts")]
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Prompt {
/// "Restore every default shortcut in this set?"
@@ -132,6 +134,7 @@ pub(crate) enum Answer {
}
/// A foreign set under review.
#[allow(dead_code, reason = "set row and import review")]
#[derive(Clone, Debug)]
pub(crate) struct Review {
pub set: ForeignSet,
@@ -182,6 +185,20 @@ pub struct DialogState {
rows: RowCache,
changed: bool,
outcome: Option<Outcome>,
// ---- The view's own state ----
/// The table's share of the table/detail split.
pub(crate) split: f32,
/// The chip of the selected row that Enter and Delete act on.
pub(crate) chip: usize,
/// Typed entry in the detail pane, and the parse error shown under it.
pub(crate) typing: Option<String>,
pub(crate) type_error: Option<String>,
/// Height of the bars and footer under the table, measured last frame.
pub(crate) bottom_h: f32,
/// One of the dialog's text fields had focus at the end of last frame.
pub(crate) text_focus: bool,
/// The table lines drawn last frame.
pub(crate) visible: std::ops::Range<usize>,
}
impl Default for DialogState {
@@ -215,6 +232,13 @@ impl DialogState {
rows: RowCache::default(),
changed: false,
outcome: None,
split: 0.62,
chip: 0,
typing: None,
type_error: None,
bottom_h: 0.0,
text_focus: false,
visible: 0..0,
}
}
@@ -275,6 +299,11 @@ impl DialogState {
self.rows.get(env.keymap, env.options, env.text, &view)
}
/// The table's lines as last built by [`lines`](Self::lines).
pub(crate) fn lines_cached(&self) -> &[crate::ui::rows::Line] {
&self.rows.built().lines
}
// ---- Selection and view ----
pub(crate) fn select_row(&mut self, command: &CommandRef) {
@@ -282,6 +311,9 @@ impl DialogState {
self.selected = Some(command.clone());
self.context = None;
self.field = None;
self.chip = 0;
self.typing = None;
self.type_error = None;
}
}
@@ -676,7 +708,11 @@ impl DialogState {
self.ok(env);
}
}
}
// The set row and the import review draw with these (T3); until then some are unused.
#[allow(dead_code, reason = "set row and import review actions")]
impl DialogState {
// ---- Sets ----
/// Whether the keymap's overrides differ from the stored active set.
+54
View File
@@ -0,0 +1,54 @@
//! The dialog's colours and the icon-button style built from them.
use craft_ui::buttons::{ButtonVisuals, IconButtonStyle};
use craft_ui::layout::ResizeHandleStyle;
use egui::{Color32, Stroke, Visuals};
/// The dialog's colours.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DialogStyle {
pub accent: Color32,
pub warning: Color32,
pub dim: Color32,
pub changed: Color32,
pub app_key: Color32,
pub scoped_key: Color32,
pub both_key: Color32,
pub modifier_key: Color32,
pub free_key: Color32,
}
impl DialogStyle {
pub fn from_visuals(v: &Visuals) -> DialogStyle {
let accent = v.selection.bg_fill;
DialogStyle {
accent,
warning: v.warn_fg_color,
dim: v.weak_text_color(),
changed: accent,
app_key: accent,
scoped_key: v.warn_fg_color,
both_key: v.error_fg_color,
modifier_key: v.widgets.active.bg_fill,
free_key: v.widgets.inactive.bg_fill,
}
}
}
/// Icon buttons (+, ×, ↺, ⌨, ✕) in the colours of the app's visuals.
pub(crate) fn icon_buttons(v: &Visuals) -> IconButtonStyle {
let w = &v.widgets;
IconButtonStyle {
normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: w.inactive.fg_stroke.color },
hovered: ButtonVisuals { fill: w.hovered.weak_bg_fill, stroke: w.hovered.bg_stroke, icon: w.hovered.fg_stroke.color },
pressed: Some(ButtonVisuals { fill: w.active.weak_bg_fill, stroke: w.active.bg_stroke, icon: w.active.fg_stroke.color }),
selected: ButtonVisuals { fill: v.selection.bg_fill, stroke: Stroke::NONE, icon: v.selection.stroke.color },
corner_radius: w.inactive.corner_radius,
focus_stroke: v.selection.stroke,
}
}
/// The table/detail divider.
pub(crate) fn divider(v: &Visuals) -> ResizeHandleStyle {
ResizeHandleStyle { hovered: v.widgets.hovered.bg_fill, dragged: v.widgets.active.bg_fill, focused: v.selection.bg_fill }
}
+20 -1
View File
@@ -111,6 +111,7 @@ pub const PLURALS: &[(&str, &str)] = &[
("{n} command will change", "{n} commands will change"),
("{n} item without a shortcut", "{n} items without a shortcut"),
("{n} shortcut also works in another scope", "{n} shortcuts also work in another scope"),
("{n} entry was ignored: too many commands", "{n} entries were ignored: too many commands"),
];
/// Every source string the dialog looks up with [`Text::ui`], for the shared catalog.
@@ -145,6 +146,9 @@ pub const STRINGS: &[&str] = &[
"(cycles)",
// Table and detail pane.
"Command",
"Commands",
"Detail pane size",
"Select a command to see and change its shortcuts",
"Shortcut",
"Default",
"Application",
@@ -182,6 +186,14 @@ pub const STRINGS: &[&str] = &[
"Take It and Go to Conflict",
"Keep Both",
"Go to Conflict",
"Cancel the new shortcut",
// Saved shortcuts that could not be used.
"{key}: no command has this name",
"{key}: “{text}” is not a shortcut ({error})",
"{key}: {text} is reserved by the system",
"{key}: the scope in “{text}” is unknown",
"{key}: listed more than once",
"{key}: part of the entry was dropped",
// Sets.
"Set:",
"(modified)",
@@ -252,7 +264,14 @@ mod tests {
/// Every `ui("…")` and `plural(n, "…", "…")` literal in the dialog's sources is listed.
#[test]
fn every_looked_up_string_is_listed() {
let sources = [include_str!("mod.rs"), include_str!("state.rs"), include_str!("rows.rs"), include_str!("capture.rs")];
let sources = [
include_str!("mod.rs"),
include_str!("state.rs"),
include_str!("rows.rs"),
include_str!("capture.rs"),
include_str!("view.rs"),
include_str!("style.rs"),
];
let mut missing = Vec::new();
for src in sources {
let src = src.split("#[cfg(test)]").next().unwrap_or(src);
File diff suppressed because it is too large. Load diff
+517
View File
@@ -0,0 +1,517 @@
#![cfg(feature = "ui")]
//! The Keyboard Shortcuts dialog driven through egui_kittest: recording, conflicts, search, areas,
//! platform display, accessibility labels and small windows.
use std::borrow::Cow;
use craft_keymap::ui::{ConflictMode, DialogState, English, Options, Outcome, ShortcutsDialog, Text};
use craft_keymap::{Bindable, Chord, CommandRef, ConflictKind, Keymap, Kind, Platform, Scope, Scopes, Trigger};
use egui::accesskit::Role;
use egui::{Event, Key as EguiKey, Modifiers, Vec2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use ui_fixture::fixture;
mod ui_fixture;
struct Fx {
km: Keymap,
options: Options,
state: DialogState,
text: Box<dyn Text>,
window: bool,
outcomes: Vec<Outcome>,
/// A key press was still in the frame's input after the dialog ran.
keys_left: bool,
}
impl Fx {
fn new(km: Keymap) -> Fx {
Fx {
km,
options: Options::default(),
state: DialogState::new(),
text: Box::new(English),
window: false,
outcomes: Vec::new(),
keys_left: false,
}
}
fn bindings(&self, id: &str) -> Vec<String> {
self.km.bindings(&CommandRef::from(id)).iter().map(|b| b.to_string()).collect()
}
fn cancelled(&self) -> bool {
self.outcomes.iter().any(|o| matches!(o, Outcome::Cancel))
}
}
fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> {
let mut h = Harness::builder().with_size(size).build_ui_state(
|ui, fx: &mut Fx| {
let Fx { km, options, state, text, window, outcomes, keys_left } = fx;
let dialog = ShortcutsDialog::new(km, text.as_ref()).options(options);
let out = if *window { dialog.show(ui.ctx(), state) } else { dialog.ui(ui, state) };
outcomes.extend(out);
*keys_left |= ui.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, .. })));
},
fx,
);
h.run();
h
}
fn open(fx: Fx) -> Harness<'static, Fx> {
harness(fx, vec2(1000.0, 700.0))
}
fn mac() -> Harness<'static, Fx> {
open(Fx::new(fixture(Platform::Mac)))
}
fn click(h: &mut Harness<'_, Fx>, label: &str) {
h.get_by_label(label).click();
h.run();
}
fn press(h: &mut Harness<'_, Fx>, modifiers: Modifiers, key: EguiKey) {
h.key_press_modifiers(modifiers, key);
h.run();
}
fn raw_key(h: &mut Harness<'_, Fx>, key: EguiKey, time: f64) {
h.input_mut().time = Some(time);
h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
h.input_mut().time = Some(time + 0.01);
h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
fn has(h: &Harness<'_, Fx>, label: &str) -> bool {
h.query_by_label(label).is_some()
}
fn table_focused(h: &Harness<'_, Fx>) -> bool {
h.get_by_label("Commands").is_focused()
}
fn chord(text: &str, platform: Platform) -> String {
Trigger::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")).display(platform)
}
// ---- Recording ----
#[test]
fn recording_captures_a_chord_another_command_would_run() {
let mut h = mac();
click(&mut h, "Save: ⌘S");
assert!(has(&h, "Save: press keys"));
h.state_mut().keys_left = false;
press(&mut h, Modifiers::COMMAND, EguiKey::E);
assert!(!h.state().keys_left, "the dialog took the key press");
assert!(has(&h, "⚠ ⌘E is used by Export…"));
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"], "nothing applied before an answer");
}
#[test]
fn recording_takes_tab_arrows_and_shift_escape() {
let mut h = mac();
click(&mut h, "Save: ⌘S");
press(&mut h, Modifiers::NONE, EguiKey::Tab);
assert_eq!(h.state().bindings("file.save"), ["Tab"]);
assert!(table_focused(&h), "Tab did not move focus");
click(&mut h, &format!("Save: {}", chord("Tab", Platform::Mac)));
press(&mut h, Modifiers::NONE, EguiKey::ArrowDown);
assert_eq!(h.state().bindings("file.save"), ["Down"]);
click(&mut h, &format!("Save: {}", chord("Down", Platform::Mac)));
press(&mut h, Modifiers::SHIFT, EguiKey::Escape);
assert_eq!(h.state().bindings("file.save"), ["Shift+Escape"]);
assert!(table_focused(&h));
assert!(h.state().state.is_open());
}
#[test]
fn escape_cancels_recording_only() {
let mut h = mac();
click(&mut h, "Save: ⌘S");
press(&mut h, Modifiers::NONE, EguiKey::Escape);
assert!(h.state().state.is_open());
assert!(!h.state().state.is_recording());
assert!(!h.state().cancelled());
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]);
press(&mut h, Modifiers::NONE, EguiKey::Escape);
assert!(h.state().cancelled());
assert!(!h.state().state.is_open());
}
#[test]
fn copy_event_records_cmd_c() {
let mut h = mac();
click(&mut h, "Save: ⌘S");
h.event(Event::Copy);
h.run();
assert_eq!(h.state().bindings("file.save"), ["Cmd+C"]);
}
#[test]
fn double_press_records_k_k() {
let mut fx = Fx::new(fixture(Platform::Mac));
fx.options.double_press = true;
let mut h = open(fx);
click(&mut h, "Save: ⌘S");
raw_key(&mut h, EguiKey::K, 10.0);
assert!(has(&h, "Save: K (press again for a double press)"));
raw_key(&mut h, EguiKey::K, 10.3);
assert_eq!(h.state().bindings("file.save"), ["K K"]);
click(&mut h, "Save: K K");
raw_key(&mut h, EguiKey::K, 20.0);
raw_key(&mut h, EguiKey::K, 20.9);
assert_eq!(h.state().bindings("file.save"), ["K"]);
}
#[test]
fn the_new_shortcut_field_waits_for_assign() {
let mut h = mac();
h.get_by_label("Save").click();
h.run();
click(&mut h, "New shortcut: None");
press(&mut h, Modifiers::COMMAND, EguiKey::E);
assert!(has(&h, "Currently assigned to:"));
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]);
click(&mut h, "Assign");
assert!(has(&h, "⚠ ⌘E is used by Export…"));
click(&mut h, "Keep Both");
assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+E"]);
}
// ---- Conflicts ----
#[test]
fn conflict_take_it_steals_and_undo_restores() {
let mut h = mac();
click(&mut h, "Export…: ⌘E");
press(&mut h, Modifiers::COMMAND, EguiKey::S);
click(&mut h, "Take It");
assert_eq!(h.state().bindings("file.save"), Vec::<String>::new());
assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]);
assert!(has(&h, "⌘S was removed from Save"));
// The footer's Undo is after the message's.
let undo = h.get_all_by_label("Undo").last().map(|n| n.rect());
let Some(undo) = undo else { panic!("no Undo") };
h.get_all_by_label("Undo").filter(|n| n.rect() == undo).for_each(|n| n.click());
h.run();
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]);
assert_eq!(h.state().bindings("file.export"), ["Cmd+E"]);
click(&mut h, "Redo");
assert_eq!(h.state().bindings("file.export"), ["Cmd+S"]);
press(&mut h, Modifiers::COMMAND, EguiKey::Z);
assert_eq!(h.state().bindings("file.export"), ["Cmd+E"], "Cmd+Z undoes when no text field has focus");
}
#[test]
fn conflict_keep_both_lists_the_clash() {
let mut h = mac();
click(&mut h, "Export…: ⌘E");
press(&mut h, Modifiers::COMMAND, EguiKey::S);
click(&mut h, "Keep Both");
let clashes = h.state().km.conflicts().into_iter().filter(|c| c.kind == ConflictKind::Clash).count();
assert_eq!(clashes, 1);
assert!(has(&h, "Also used by Save"));
click(&mut h, "⚠ 1 shortcut is assigned more than once");
assert!(has(&h, "Save: ⌘S"));
assert!(has(&h, "Export…: ⌘S"));
assert!(!has(&h, "Save As…: ⇧⌘S"), "the Conflicts filter shows only the clash");
}
#[test]
fn steal_mode_says_what_it_took() {
let mut fx = Fx::new(fixture(Platform::Mac));
fx.options.conflicts = ConflictMode::Steal;
let mut h = open(fx);
click(&mut h, "Export…: ⌘E");
press(&mut h, Modifiers::COMMAND, EguiKey::S);
assert!(has(&h, "⌘S was removed from Save"));
click(&mut h, "Go to Conflict");
assert!(selected(&h, "Save (Changed)"));
}
/// Whether a row with this label reports itself selected.
fn selected(h: &Harness<'_, Fx>, label: &str) -> bool {
h.get_all_by_label(label).any(|n| n.accesskit_node().is_selected() == Some(true))
}
#[test]
fn go_to_conflict_selects_and_reveals_the_holder() {
let mut h = mac();
choose_area(&mut h, "Tools");
search(&mut h, "marquee");
assert!(!has(&h, "Save: ⌘S"));
click(&mut h, "Marquee Tool: M");
press(&mut h, Modifiers::COMMAND, EguiKey::S);
click(&mut h, "Take It and Go to Conflict");
assert_eq!(h.state().bindings("tool.marquee"), ["Cmd+S"]);
assert!(selected(&h, "Save (Changed)"), "the holder is selected");
assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "");
assert!(has(&h, "Export…: ⌘E"), "area All, no filter");
}
#[test]
fn reserved_chord_is_refused() {
let km = fixture(Platform::Mac).with_reserved(vec![Chord::parse("Cmd+Tab").unwrap_or_else(|e| panic!("{e}"))]);
let mut h = open(Fx::new(km));
click(&mut h, "Save: ⌘S");
press(&mut h, Modifiers::COMMAND, EguiKey::Tab);
assert!(has(&h, &format!("⚠ {} is reserved by the system", chord("Cmd+Tab", Platform::Mac))));
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]);
}
#[test]
fn app_rule_refuses() {
let mut fx = Fx::new(fixture(Platform::Mac));
fx.options.accept = Some(Box::new(|b, binding| {
if matches!(b.kind, Kind::Tool { .. }) && !binding.trigger.chord.mods.is_none() {
Err("Tools take keys without modifiers".into())
} else {
Ok(())
}
}));
let mut h = open(fx);
click(&mut h, "Marquee Tool: M");
press(&mut h, Modifiers::COMMAND, EguiKey::K);
assert!(has(&h, "⚠ Tools take keys without modifiers"));
assert_eq!(h.state().bindings("tool.marquee"), ["M"]);
}
// ---- Search, areas, display ----
fn search(h: &mut Harness<'_, Fx>, text: &str) {
let field = h.get_by_label("Search:");
field.focus();
h.run();
h.key_press_modifiers(Modifiers::COMMAND, EguiKey::A);
h.run();
h.get_by_label("Search:").type_text(text);
h.run();
}
fn choose_area(h: &mut Harness<'_, Fx>, area: &str) {
h.get_by_role_and_label(Role::ComboBox, "Show:").click();
h.run();
h.get_by_role_and_label(Role::Button, area).click();
h.run();
}
struct German;
impl Text for German {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, n: u64, one: &str, other: &str) -> String {
if n == 1 { one } else { other }.replace("{n}", &n.to_string())
}
fn command<'a>(&'a self, b: &'a Bindable) -> Cow<'a, str> {
if b.command.id == "file.save" { "Sichern".into() } else { b.label.as_str().into() }
}
}
#[test]
fn search_by_name_translation_or_chord() {
let mut h = mac();
search(&mut h, "save");
assert!(has(&h, "Save: ⌘S") && has(&h, "Save As…: ⇧⌘S"));
assert!(!has(&h, "Export…: ⌘E"));
search(&mut h, "cmd+s");
assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S"));
search(&mut h, "⌘S");
assert!(has(&h, "Save: ⌘S") && !has(&h, "Save As…: ⇧⌘S"));
search(&mut h, "zzz");
assert!(has(&h, "No matching commands"));
click(&mut h, "Search by key");
press(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, EguiKey::S);
assert_eq!(h.get_by_label("Search:").value().unwrap_or_default(), "⇧⌘S");
assert!(has(&h, "Save As…: ⇧⌘S") && !has(&h, "Save: ⌘S"));
let mut fx = Fx::new(fixture(Platform::Mac));
fx.text = Box::new(German);
let mut h = open(fx);
search(&mut h, "sichern");
assert!(has(&h, "Sichern: ⌘S") && !has(&h, "Save As…: ⇧⌘S"));
}
#[test]
fn areas_follow_paths_kind_and_scope() {
let mut h = mac();
h.get_by_role_and_label(Role::ComboBox, "Show:").click();
h.run();
for area in ["All Commands", "File Menu", "Object Menu", "Tools", "Timeline Panel", "Other"] {
assert!(h.get_all_by_label(area).count() >= 1, "{area}");
}
h.get_by_role_and_label(Role::Button, "Tools").click();
h.run();
assert!(has(&h, "Marquee Tool: M") && has(&h, "Elliptical Marquee Tool: M"));
assert!(!has(&h, "Save: ⌘S"));
choose_area(&mut h, "Timeline Panel");
assert!(has(&h, "Loop: L") && !has(&h, "Marquee Tool: M"));
}
#[test]
fn display_follows_the_keymap_platform() {
let h = mac();
assert!(has(&h, "Save As…: ⇧⌘S"));
assert!(has(&h, "Bold: ⌘B (Text)"), "a scope other than home is named");
let h = open(Fx::new(fixture(Platform::Windows)));
assert!(has(&h, "Save As…: Ctrl+Shift+S"));
}
// ---- Labels, focus, small windows, RTL ----
#[test]
fn every_control_has_a_label() {
let mut h = mac();
h.get_by_label("Save").click();
h.run();
click(&mut h, "Export…: ⌘E");
press(&mut h, Modifiers::COMMAND, EguiKey::S);
assert!(has(&h, "Take It"));
let mut seen = 0;
for node in h.root().children_recursive() {
let n = node.accesskit_node();
if matches!(n.role(), Role::Button | Role::Tab | Role::TextInput | Role::ComboBox) {
seen += 1;
assert!(n.label().is_some_and(|l| !l.trim().is_empty()), "{:?} without a label at {:?}", n.role(), node.rect());
}
}
assert!(seen > 20, "{seen} controls");
}
#[test]
fn table_keys_move_record_and_delete() {
let mut h = mac();
h.get_by_label("Save").click();
h.run();
assert!(table_focused(&h));
press(&mut h, Modifiers::NONE, EguiKey::ArrowDown);
assert!(selected(&h, "Save As…"));
press(&mut h, Modifiers::NONE, EguiKey::Delete);
assert_eq!(h.state().bindings("file.saveAs"), Vec::<String>::new());
press(&mut h, Modifiers::NONE, EguiKey::Enter);
assert!(h.state().state.is_recording());
press(&mut h, Modifiers::NONE, EguiKey::F7);
assert_eq!(h.state().bindings("file.saveAs"), ["F7"]);
}
#[test]
fn small_window_keeps_ok_and_long_labels_truncate() {
let long = "A very long command label ".repeat(12);
let mut km = fixture(Platform::Mac);
let mut catalogue = km.catalogue().to_vec();
catalogue.insert(0, Bindable::new("file.long", long.trim(), Scope::app()).with_path(vec!["File".into(), long.trim().into()]));
km = Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac);
let mut fx = Fx::new(km);
fx.window = true;
let size = vec2(480.0, 360.0);
let mut h = harness(fx, size);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
let ok = h.get_by_label("OK").rect();
assert!(screen.contains_rect(ok), "OK at {ok:?}");
let row = h.get_by_label(long.trim());
assert!(row.rect().max.x <= size.x + 0.5, "the row stays inside: {:?}", row.rect());
row.hover();
h.run();
assert!(h.query_all_by_label_contains(long.trim()).count() >= 2, "the tooltip has the full label");
click(&mut h, "OK");
assert!(h.state().outcomes.iter().any(|o| matches!(o, Outcome::Ok)));
}
#[test]
fn window_close_is_cancel() {
let mut fx = Fx::new(fixture(Platform::Mac));
fx.window = true;
let mut h = open(fx);
click(&mut h, "Save: ⌘S");
press(&mut h, Modifiers::NONE, EguiKey::F2);
assert_eq!(h.state().bindings("file.save"), ["F2"]);
click(&mut h, "Cancel");
assert!(h.state().cancelled());
assert_eq!(h.state().bindings("file.save"), ["Cmd+S"]);
}
#[test]
fn automation_ids_are_reported() {
let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
let mut fx = Fx::new(fixture(Platform::Mac));
let sink = seen.clone();
fx.options.on_widget = Some(Box::new(move |id, rect, label| {
assert!(rect.is_finite() && !label.is_empty(), "{id}");
sink.borrow_mut().push(id.to_string());
}));
let mut h = open(fx);
h.get_by_label("Save").click();
h.run();
let ids = seen.borrow();
for id in [
"shortcuts.area",
"shortcuts.filter",
"shortcuts.search",
"shortcuts.searchKey",
"shortcuts.row.file.save",
"shortcuts.cell.file.save.0",
"shortcuts.add.file.save",
"shortcuts.reset.file.save",
"shortcuts.remove.0",
"shortcuts.new",
"shortcuts.context",
"shortcuts.assign",
"shortcuts.clear",
"shortcuts.undo",
"shortcuts.redo",
"shortcuts.resetAll",
"shortcuts.cancel",
"shortcuts.ok",
] {
assert!(ids.iter().any(|s| s == id), "{id} not reported");
}
}
#[test]
fn typed_entry_parses_and_shows_errors() {
let mut h = mac();
h.get_by_label("Save").click();
h.run();
click(&mut h, "Type…");
h.get_by_role_and_label(Role::TextInput, "New shortcut:").type_text("Cmd+Shift+K");
h.run();
press(&mut h, Modifiers::NONE, EguiKey::Enter);
click(&mut h, "Assign");
assert_eq!(h.state().bindings("file.save"), ["Cmd+S", "Cmd+Shift+K"]);
}
struct Rtl;
impl Text for Rtl {
fn ui<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
source.into()
}
fn plural(&self, _n: u64, _one: &str, other: &str) -> String {
other.to_string()
}
fn rtl(&self) -> bool {
true
}
}
#[test]
fn rtl_puts_the_detail_pane_left_and_keeps_chords() {
let mut fx = Fx::new(fixture(Platform::Mac));
fx.text = Box::new(Rtl);
let h = open(fx);
let table = h.get_by_label("Commands").rect();
assert!(table.min.x > 300.0, "table on the right: {table:?}");
assert!(has(&h, "Save As…: ⇧⌘S"));
}
+24
View File
@@ -0,0 +1,24 @@
//! The catalogue the dialog tests and screenshots use.
use craft_keymap::{Bindable, Binding, Keymap, Kind, Platform, Scope, Scopes};
fn binding(text: &str) -> Binding {
Binding::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"))
}
pub fn fixture(platform: Platform) -> Keymap {
let tool = Kind::Tool { group: Some("Marquee".into()) };
let path = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let timeline = Scope::new("Timeline").unwrap_or_else(Scope::app);
let catalogue = vec![
Bindable::new("file.save", "Save", Scope::app()).with_path(path(&["File", "Save"])).with_default(binding("Cmd+S")),
Bindable::new("file.saveAs", "Save As…", Scope::app()).with_path(path(&["File", "Save As…"])).with_default(binding("Cmd+Shift+S")),
Bindable::new("file.export", "Export…", Scope::app()).with_path(path(&["File", "Export…"])).with_default(binding("Cmd+E")),
Bindable::new("object.front", "Bring to Front", Scope::app()).with_path(path(&["Object", "Arrange", "Bring to Front"])),
Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(tool.clone()).with_default(binding("M")),
Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(tool).with_default(binding("M")),
Bindable::new("timeline.loop", "Loop", timeline).with_default(binding("L")),
Bindable::new("type.bold", "Bold", Scope::app()).with_default(binding("Text: Cmd+B")),
];
Keymap::new(catalogue, Scopes::new().with_panel("Timeline").with_context("Text"), platform)
}
+94
View File
@@ -0,0 +1,94 @@
#![cfg(feature = "snapshot")]
//! Screenshots of the Keyboard Shortcuts dialog for review, not a golden comparison. With
//! `CRAFT_KEYMAP_SNAPSHOT_DIR` set, writes PNGs of a few states in light and dark at 1× and 2×;
//! without it the test does nothing.
//!
//! `CRAFT_KEYMAP_SNAPSHOT_DIR=/tmp/shots cargo test -p craft-keymap --features snapshot --test ui_snapshot`
use std::path::PathBuf;
use craft_keymap::ui::{DialogState, English, Options, ShortcutsDialog};
use craft_keymap::{Keymap, Platform};
use egui::{Key as EguiKey, Modifiers, Theme, Vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ui_fixture::fixture;
mod ui_fixture;
struct Fx {
km: Keymap,
options: Options,
state: DialogState,
}
/// A named state of the dialog: window size and what to do before the picture.
struct Case {
name: &'static str,
size: Vec2,
platform: Platform,
act: fn(&mut Harness<'_, Fx>),
}
fn click(h: &mut Harness<'_, Fx>, label: &str) {
h.get_by_label(label).click();
h.run();
}
const CASES: &[Case] = &[
Case {
name: "selected",
size: Vec2::new(1000.0, 640.0),
platform: Platform::Mac,
act: |h| {
h.get_by_label("Save As…").click();
h.run();
},
},
Case {
name: "conflict",
size: Vec2::new(1000.0, 640.0),
platform: Platform::Mac,
act: |h| {
click(h, "Export…: ⌘E");
h.key_press_modifiers(Modifiers::COMMAND, EguiKey::S);
h.run();
},
},
Case { name: "recording", size: Vec2::new(1000.0, 640.0), platform: Platform::Windows, act: |h| click(h, "Save: Ctrl+S") },
Case {
name: "narrow",
size: Vec2::new(560.0, 640.0),
platform: Platform::Mac,
act: |h| {
h.get_by_label("Save").click();
h.run();
},
},
];
#[test]
fn dialog_screenshots() {
let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return };
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
for case in CASES {
for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] {
for scale in [1.0, 2.0] {
let fx = Fx { km: fixture(case.platform), options: Options::default(), state: DialogState::new() };
let mut h = Harness::builder().with_size(case.size).with_pixels_per_point(scale).with_theme(theme).wgpu().build_ui_state(
|ui, fx: &mut Fx| {
egui::Frame::central_panel(ui.style()).show(ui, |ui| {
ShortcutsDialog::new(&mut fx.km, &English).options(&fx.options).ui(ui, &mut fx.state);
});
},
fx,
);
h.run();
(case.act)(&mut h);
let image = h.render().unwrap_or_else(|e| panic!("render {}: {e}", case.name));
let path = dir.join(format!("shortcuts-{}-{theme_name}@{scale}x.png", case.name));
image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
}
}
}
}