mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
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:
1 parent
b9bd4119cc
commit
39f343c47b
11 files changed
+2052
-45
No files matched your search
Generated
+1
@@ -627,6 +627,7 @@ dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user