Files
craft-libs/crates/keymap/tests/egui.rs
T
aiongg 315af428c7 keymap: a key whose Latin layout character is no letter runs no letter binding
The suite's egui reports, per key pressed, the character it types on the
Latin layout shortcuts follow (InputState::latin_layout_char). A letter
makes the press that letter; any other character means the key has no
Latin letter, so egui's position fallback no longer turns it into one: on
Dvorak + Russian the key that types й is Dvorak's ', not Ctrl+Q. Without a
report (crates.io egui) the key's position still decides.
2026-10-21 18:05:18 +00:00

574 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg(feature = "egui")]
use craft_keymap::egui::{Dispatcher, Hit, matches, platform};
use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Key, KeyInput, Keymap, Kind, Layout, Mods, Platform, Scope, Scopes, Trigger};
use egui::{Event, InputState, Key as EguiKey, Modifiers};
fn chord(text: &str) -> Chord {
Chord::parse(text).unwrap_or_else(|e| panic!("{text}: {e}"))
}
#[test]
fn a_browser_on_a_mac_gets_the_mac_keymap() {
let ctx = egui::Context::default();
ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15"));
assert_eq!(platform(&ctx), Platform::Mac);
ctx.set_os(egui::os::OperatingSystem::from_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"));
assert_eq!(platform(&ctx), Platform::Windows);
}
#[test]
fn every_key_converts_both_ways() {
for &key in Key::ALL {
assert_eq!(Key::from_egui(key.to_egui()), Some(key), "{key}");
}
for k in [EguiKey::Copy, EguiKey::Cut, EguiKey::Paste, EguiKey::BrowserBack, EguiKey::ShiftLeft, EguiKey::IntlBackslash] {
assert_eq!(Key::from_egui(k), None);
}
}
#[test]
fn modifiers_and_shortcuts_follow_the_platform() {
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
assert_eq!(Mods::from_egui(&ctrl, Platform::Windows), Mods::CMD);
assert_eq!(Mods::from_egui(&Modifiers::CTRL, Platform::Mac), Mods::CTRL);
assert_eq!(Mods::from_egui(&Modifiers::MAC_CMD, Platform::Mac), Mods::CMD);
assert!(matches(&chord("Ctrl+S"), EguiKey::S, &ctrl, Platform::Windows));
assert!(matches(&chord("Cmd+S"), EguiKey::S, &ctrl, Platform::Windows));
assert!(!matches(&chord("Cmd+S"), EguiKey::S, &Modifiers::CTRL, Platform::Mac));
let shortcut = chord("Cmd+Shift+Z").to_egui_shortcut();
assert_eq!(shortcut.logical_key, EguiKey::Z);
assert!(Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE }.matches_exact(shortcut.modifiers));
}
#[test]
fn shifted_symbols_match_their_bindings() {
let p = Platform::Windows;
let shift = Modifiers::SHIFT;
let none = Modifiers::NONE;
let cmd = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
let cmd_shift = Modifiers { shift: true, ..cmd };
// Shift+base matches the shifted key egui reports, but the base key alone does not.
assert!(matches(&chord("Shift+;"), EguiKey::Colon, &shift, p));
assert!(!matches(&chord("Shift+;"), EguiKey::Semicolon, &none, p));
assert!(!matches(&chord(";"), EguiKey::Colon, &shift, p));
assert!(matches(&chord("Shift+/"), EguiKey::Questionmark, &shift, p));
assert!(matches(&chord("Cmd+Shift+]"), EguiKey::CloseCurlyBracket, &cmd_shift, p));
assert!(matches(&chord("Shift+1"), EguiKey::Exclamationmark, &shift, p));
// A shifted symbol bound directly matches while Shift is held to type it.
assert!(matches(&chord("?"), EguiKey::Questionmark, &shift, p));
assert!(matches(&chord(":"), EguiKey::Colon, &none, p));
// Zoom keys: `=` also matches `+`, with or without Shift; `+` matches Shift+`=`.
assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd, p));
assert!(matches(&chord("Cmd+="), EguiKey::Plus, &cmd_shift, p));
assert!(matches(&chord("Cmd+="), EguiKey::Equals, &cmd, p));
assert!(matches(&chord("Cmd++"), EguiKey::Equals, &cmd_shift, p));
assert!(matches(&chord("Cmd++"), EguiKey::Plus, &cmd_shift, p));
assert!(!matches(&chord("Cmd++"), EguiKey::Equals, &cmd, p));
assert!(!matches(&chord("Cmd+="), EguiKey::Plus, &none, p));
assert!(!matches(&chord("Cmd+="), EguiKey::Minus, &cmd, p));
}
const CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
fn dispatch_keymap() -> Keymap {
let app = |id: &str, keys: &[&str]| keys.iter().fold(Bindable::new(id, id, Scope::app()), |e, k| e.with_default(Binding::parse(k).unwrap()));
let tool = |id: &str| app(id, &["M"]).with_kind(Kind::Tool { group: Some("select".into()) });
let catalog = vec![
app("edit.copy", &["Cmd+C", "Cmd+Insert"]),
app("edit.paste", &["Cmd+V", "Shift+Insert"]),
app("edit.pasteInPlace", &["Cmd+Shift+V"]),
app("edit.copyMerged", &["Cmd+Shift+C"]),
app("app.quit", &["Cmd+Alt+Q"]),
app("type.pageNumber", &["Cmd+Alt+Shift+N"]),
app("view.refresh", &["Cmd+Alt+F5"]),
app("file.save", &["Cmd+S"]),
app("view.zoomIn", &["="]),
app("help.find", &["Shift+;"]),
app("tool.brush", &["B"]),
app("app.loop", &["L"]),
Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(Binding::parse("L").unwrap()),
tool("tool.marquee"),
tool("tool.ellipse"),
app("reveal.one", &["U"]),
app("reveal.all", &["U U"]),
];
Keymap::new(catalog, Scopes::new().with_panel("Timeline"), Platform::Windows)
}
fn press(key: EguiKey, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers }
}
fn frame(time: f64, modifiers: Modifiers, events: Vec<Event>) -> InputState {
let mut input = InputState::default();
input.time = time;
input.modifiers = modifiers;
input.events = events;
input
}
/// Dispatches one frame and returns the ids that ran and the events left.
fn run(d: &mut Dispatcher, km: &Keymap, active: &Active, mut input: InputState) -> (Vec<String>, Vec<Event>) {
let hits = d.dispatch(&mut input, km, active);
(hits.into_iter().map(|h| h.command.id).collect(), input.events)
}
#[test]
fn a_bound_press_runs_once_and_is_removed_with_its_text() {
let km = dispatch_keymap();
let events = vec![
press(EguiKey::B, Modifiers::NONE),
Event::Text("b".into()),
Event::Key { key: EguiKey::B, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
press(EguiKey::Q, Modifiers::NONE),
Event::Text("q".into()),
];
let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events));
assert_eq!(ran, ["tool.brush"]);
// The release and the unbound Q with its text stay.
assert_eq!(left.len(), 3);
assert!(matches!(left[0], Event::Key { key: EguiKey::B, pressed: false, .. }));
assert!(matches!(left[1], Event::Key { key: EguiKey::Q, pressed: true, .. }));
assert_eq!(left[2], Event::Text("q".into()));
}
#[test]
fn shifted_symbols_and_zoom_keys_reach_their_bindings() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let shift = Modifiers::SHIFT;
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, shift, vec![press(EguiKey::Colon, shift), Event::Text(":".into())]));
assert_eq!(ran, ["help.find"]);
assert!(left.is_empty());
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![press(EguiKey::Plus, Modifiers::NONE)]));
assert_eq!(ran, ["view.zoomIn"]);
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, shift, vec![press(EguiKey::Plus, shift)]));
assert_eq!(ran, ["view.zoomIn"]);
}
#[test]
fn clipboard_events_stay_with_the_app_by_default() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
// egui-winit sends these in place of the Ctrl+C and Ctrl+V presses.
let events = vec![Event::Copy, Event::Paste("text".into())];
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events.clone()));
assert!(ran.is_empty(), "{ran:?}");
assert_eq!(left, events);
// The web build sends the press and then the event: both stay.
let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::S, CTRL)];
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, events.clone()));
assert_eq!(ran, ["file.save"]);
assert_eq!(left, events[..2]);
// A clipboard press with no clipboard event after it is an ordinary press.
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(3.0, CTRL, vec![press(EguiKey::Insert, CTRL)]));
assert_eq!(ran, ["edit.copy"]);
}
#[test]
fn clipboard_chords_with_more_modifiers_run_their_bindings() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let typing = Active { typing: true, ..Active::NONE };
let ctrl_shift = Modifiers { shift: true, ..CTRL };
// The desktop: egui-winit sends the Paste event in place of Ctrl+Shift+V.
let mut input = frame(1.0, ctrl_shift, vec![Event::Paste("text".into())]);
let hits = d.dispatch(&mut input, &km, &Active::NONE);
let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect();
assert_eq!(ran, [("edit.pasteInPlace", Some("text"))]);
assert!(input.events.is_empty());
let (ran, left) = run(&mut d, &km, &typing, frame(2.0, ctrl_shift, vec![Event::Copy]));
assert_eq!(ran, ["edit.copyMerged"]);
assert!(left.is_empty());
// Ctrl+Shift+X has no binding: the event stays.
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(3.0, ctrl_shift, vec![Event::Cut]));
assert!(ran.is_empty());
assert_eq!(left, [Event::Cut]);
// The web: the press decides for itself and its event, also while typing.
for (time, active) in [(4.0, &Active::NONE), (5.0, &typing)] {
let events = vec![press(EguiKey::C, ctrl_shift), Event::Copy];
let (ran, left) = run(&mut d, &km, active, frame(time, ctrl_shift, events));
assert_eq!(ran, ["edit.copyMerged"]);
assert!(left.is_empty(), "{left:?}");
}
let events = vec![press(EguiKey::X, ctrl_shift), Event::Cut];
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(6.0, ctrl_shift, events.clone()));
assert!(ran.is_empty());
assert_eq!(left, events);
}
#[test]
fn clipboard_events_run_their_chords_outside_text_fields_when_turned_on() {
let km = dispatch_keymap();
let mut d = Dispatcher::new().with_clipboard_events(true);
let mut input = frame(1.0, CTRL, vec![Event::Copy, Event::Paste("text".into())]);
let hits = d.dispatch(&mut input, &km, &Active::NONE);
let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect();
assert_eq!(ran, [("edit.copy", None), ("edit.paste", Some("text"))]);
assert!(input.events.is_empty());
let typing = Active { typing: true, ..Active::NONE };
let (ran, left) = run(&mut d, &km, &typing, frame(2.0, CTRL, vec![Event::Copy]));
assert!(ran.is_empty());
assert_eq!(left, [Event::Copy]);
// The web build sends the key press and the clipboard event it causes: one run each.
for (time, key, mods, event, id) in [
(3.0, EguiKey::C, CTRL, Event::Copy, "edit.copy"),
(4.0, EguiKey::Insert, CTRL, Event::Copy, "edit.copy"),
(5.0, EguiKey::Insert, Modifiers::SHIFT, Event::Paste("text".into()), "edit.paste"),
] {
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(time, mods, vec![press(key, mods), event]));
assert_eq!(ran, [id], "{key:?}");
}
}
#[test]
fn a_web_paste_carries_its_text_and_takes_its_event() {
let km = dispatch_keymap();
let mut d = Dispatcher::new().with_clipboard_events(true);
// The web build sends the Cmd+V press and then the Paste event with the clipboard text.
let events = vec![press(EguiKey::V, CTRL), Event::Paste("text".into()), press(EguiKey::C, CTRL), Event::Copy];
let mut input = frame(1.0, CTRL, events.clone());
let mut seen = Vec::new();
let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| {
seen.push(hit.pasted.clone());
true
});
let ran: Vec<_> = hits.iter().map(|h| (h.command.id.as_str(), h.pasted.as_deref())).collect();
assert_eq!(ran, [("edit.paste", Some("text")), ("edit.copy", None)]);
assert_eq!(seen, [Some("text".to_string()), None], "the filter sees the pasted text");
assert!(input.events.is_empty(), "{:?}", input.events);
// A declined press leaves the press and its event in place, and the event does not run again.
let mut input = frame(2.0, CTRL, events.clone());
let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| hit.command.id != "edit.paste");
let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect();
assert_eq!(ran, ["edit.copy"]);
assert_eq!(input.events, events[..2]);
}
#[test]
fn a_held_web_paste_runs_once_when_repeats_are_off() {
let km = dispatch_keymap();
let mut d = Dispatcher::new().with_clipboard_events(true);
let repeat = Event::Key { key: EguiKey::V, physical_key: Some(EguiKey::V), pressed: true, repeat: true, modifiers: CTRL };
let paste = || Event::Paste("text".into());
let events = vec![press(EguiKey::V, CTRL), paste(), repeat.clone(), paste()];
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, events));
assert_eq!(ran, ["edit.paste"]);
assert_eq!(left, [repeat, paste()], "the repeat and its event stay in egui's input");
}
#[test]
fn declined_hits_leave_their_events_in_order() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let shift = Modifiers::SHIFT;
let events = vec![
press(EguiKey::B, Modifiers::NONE),
Event::Text("b".into()),
press(EguiKey::S, CTRL),
press(EguiKey::Colon, shift),
Event::Text(":".into()),
press(EguiKey::Equals, Modifiers::NONE),
];
let mut input = frame(1.0, Modifiers::NONE, events.clone());
let mut seen = Vec::new();
let hits = d.dispatch_filtered(&mut input, &km, &Active::NONE, |hit| {
seen.push((hit.command.id.clone(), hit.trigger.chord.to_string(), hit.exact));
hit.command.id != "file.save" && hit.command.id != "tool.brush"
});
let s = |id: &str, chord: &str, exact| (id.to_string(), chord.to_string(), exact);
assert_eq!(seen, [s("tool.brush", "B", true), s("file.save", "Cmd+S", true), s("help.find", "Shift+;", false), s("view.zoomIn", "=", true)]);
let ran: Vec<_> = hits.iter().map(|h| h.command.id.as_str()).collect();
assert_eq!(ran, ["help.find", "view.zoomIn"]);
assert_eq!(input.events, events[..3], "the declined B keeps its text, and Ctrl+S stays in its place");
}
#[test]
fn clipboard_presses_stay_with_the_text_field() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let typing = Active { typing: true, ..Active::NONE };
// The web build's Cmd+C press and its Copy event both reach the text field.
let events = vec![press(EguiKey::C, CTRL), Event::Copy, press(EguiKey::Insert, CTRL)];
let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events.clone()));
assert!(ran.is_empty(), "{ran:?}");
assert_eq!(left, events);
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![press(EguiKey::Insert, CTRL)]));
assert_eq!(ran, ["edit.copy"]);
}
#[test]
fn a_press_that_types_text_stays_with_the_text_field() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let typing = Active { typing: true, ..Active::NONE };
// A press the web build's text agent follows with the text it typed.
let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into())];
let (ran, left) = run(&mut d, &km, &typing, frame(1.0, Modifiers::NONE, events.clone()));
assert!(ran.is_empty(), "{ran:?}");
assert_eq!(left, events);
}
#[test]
fn ctrl_alt_is_left_to_the_text_field_only_when_it_types() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let typing = Active { typing: true, ..Active::NONE };
// AltGr+Q on a German layout types `@`: the browser on Windows reports Ctrl+Alt+Q and the text
// agent sends the text after it.
let alt_gr = Modifiers { alt: true, ..CTRL };
let events = vec![press(EguiKey::Q, alt_gr), Event::Text("@".into())];
let (ran, left) = run(&mut d, &km, &typing, frame(1.0, alt_gr, events.clone()));
assert!(ran.is_empty(), "{ran:?}");
assert_eq!(left, events);
// A real Ctrl+Alt chord types nothing, so it runs while typing.
let all = Modifiers { shift: true, ..alt_gr };
let (ran, left) = run(&mut d, &km, &typing, frame(2.0, all, vec![press(EguiKey::N, all)]));
assert_eq!(ran, ["type.pageNumber"]);
assert!(left.is_empty());
let (ran, _) = run(&mut d, &km, &typing, frame(3.0, alt_gr, vec![press(EguiKey::Q, alt_gr)]));
assert_eq!(ran, ["app.quit"]);
}
#[test]
fn typing_blocks_plain_keys_but_not_command_chords() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let typing = Active { typing: true, ..Active::NONE };
let events = vec![press(EguiKey::B, Modifiers::NONE), Event::Text("b".into()), press(EguiKey::S, CTRL)];
let (ran, left) = run(&mut d, &km, &typing, frame(1.0, CTRL, events));
assert_eq!(ran, ["file.save"]);
assert_eq!(left.len(), 2);
assert_eq!(left[1], Event::Text("b".into()));
}
#[test]
fn undo_goes_to_the_text_field_and_save_still_runs() {
let km = Keymap::new(
vec![
Bindable::new("edit.undo", "Undo", Scope::app()).with_default(Binding::parse("Cmd+Z").unwrap()),
Bindable::new("file.save", "Save", Scope::app()).with_default(Binding::parse("Cmd+S").unwrap()),
],
Scopes::new(),
Platform::Windows,
);
let typing = Active { typing: true, ..Active::NONE };
let events = vec![press(EguiKey::Z, CTRL), press(EguiKey::S, CTRL)];
let (ran, left) = run(&mut Dispatcher::new(), &km, &typing, frame(1.0, CTRL, events));
assert_eq!(ran, ["file.save"]);
assert_eq!(left, [press(EguiKey::Z, CTRL)]);
}
#[test]
fn the_focused_panel_wins_and_tool_groups_cycle() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let l = || frame(1.0, Modifiers::NONE, vec![press(EguiKey::L, Modifiers::NONE)]);
assert_eq!(run(&mut d, &km, &Active::panel("Timeline"), l()).0, ["timeline.loop"]);
assert_eq!(run(&mut d, &km, &Active::NONE, l()).0, ["app.loop"]);
let mut input = frame(2.0, Modifiers::NONE, vec![press(EguiKey::M, Modifiers::NONE)]);
let hits = d.dispatch(&mut input, &km, &Active::NONE);
let tools = vec![CommandRef::new("tool.marquee"), CommandRef::new("tool.ellipse")];
let trigger = Trigger::single(chord("M"));
let got: Vec<_> = hits.iter().map(|h: &Hit| (&h.command, &h.cycle, h.trigger, h.exact, h.pasted.as_deref())).collect();
assert_eq!(got, [(&tools[0], &tools, trigger, true, None)]);
}
#[test]
fn a_second_press_runs_the_double_binding() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let key = |d: &mut Dispatcher, k: EguiKey, t: f64| run(d, &km, &Active::NONE, frame(t, Modifiers::NONE, vec![press(k, Modifiers::NONE)])).0;
let u = |d: &mut Dispatcher, t: f64| key(d, EguiKey::U, t);
assert_eq!(u(&mut d, 1.0), ["reveal.one"]);
assert_eq!(u(&mut d, 1.3), ["reveal.all"]);
// The press after a double starts over.
assert_eq!(u(&mut d, 1.5), ["reveal.one"]);
assert_eq!(u(&mut d, 2.5), ["reveal.one"]);
// B in between breaks the pair.
key(&mut d, EguiKey::B, 2.6);
assert_eq!(u(&mut d, 2.7), ["reveal.one"]);
}
#[test]
fn a_double_press_needs_both_presses_in_one_panel() {
let km = Keymap::new(
vec![
Bindable::new("x.one", "One", Scope::new("X").unwrap()).with_default(Binding::parse("A").unwrap()),
Bindable::new("y.one", "One", Scope::new("Y").unwrap()).with_default(Binding::parse("A").unwrap()),
Bindable::new("y.two", "Two", Scope::new("Y").unwrap()).with_default(Binding::parse("A A").unwrap()),
],
Scopes::new().with_panel("X").with_panel("Y").with_panel_fallthrough(false),
Platform::Windows,
);
let mut d = Dispatcher::new();
let a = |d: &mut Dispatcher, panel, t| run(d, &km, &Active::panel(panel), frame(t, Modifiers::NONE, vec![press(EguiKey::A, Modifiers::NONE)])).0;
assert_eq!(a(&mut d, "X", 1.0), ["x.one"]);
assert_eq!(a(&mut d, "Y", 1.2), ["y.one"]);
assert_eq!(a(&mut d, "Y", 1.4), ["y.two"]);
}
#[test]
fn two_presses_in_one_frame_make_a_double() {
let km = dispatch_keymap();
let mut d = Dispatcher::new();
let u = press(EguiKey::U, Modifiers::NONE);
let (ran, left) = run(&mut d, &km, &Active::NONE, frame(4.0, Modifiers::NONE, vec![u.clone(), u]));
assert_eq!(ran, ["reveal.one", "reveal.all"]);
assert!(left.is_empty(), "{left:?}");
}
#[test]
fn key_repeats_run_only_when_turned_on() {
let km = Keymap::new(
vec![
Bindable::new("nudge.right", "Nudge", Scope::app()).with_default(Binding::parse("Right").unwrap()),
Bindable::new("jump.right", "Jump", Scope::app()).with_default(Binding::parse("Right Right").unwrap()),
],
Scopes::new(),
Platform::Windows,
);
let held = |repeat: bool| Event::Key { key: EguiKey::ArrowRight, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE };
let events = || vec![held(false), held(true), held(true)];
let mut on = Dispatcher::new().with_repeat(true);
let (ran, left) = run(&mut on, &km, &Active::NONE, frame(1.0, Modifiers::NONE, events()));
assert_eq!(ran, ["nudge.right", "nudge.right", "nudge.right"]);
assert!(left.is_empty());
// Repeats neither made nor broke a double: the next press within the window is one.
let (ran, _) = run(&mut on, &km, &Active::NONE, frame(1.3, Modifiers::NONE, vec![held(false)]));
assert_eq!(ran, ["jump.right"]);
let (ran, left) = run(&mut Dispatcher::new(), &km, &Active::NONE, frame(1.0, Modifiers::NONE, events()));
assert_eq!(ran, ["nudge.right"]);
assert_eq!(left, [held(true), held(true)]);
}
#[test]
fn the_dispatcher_reads_presses_by_the_layout() {
let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap());
let km = Keymap::new(
vec![app("edit.duplicate", "Cmd+Shift+D"), app("tab.next", "Cmd+Shift+]"), app("edit.comment", "Cmd+/")],
Scopes::new(),
Platform::Windows,
);
let at = |key, physical, modifiers| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers };
let ctrl_shift = Modifiers { shift: true, ..CTRL };
// Arabic 101: typing ي on the D key shows the layout is not Latin, so ⌃⇧D (which types ]) is
// Ctrl+Shift+D.
let mut d = Dispatcher::new();
let typed = vec![at(EguiKey::D, EguiKey::D, Modifiers::NONE), Event::Text("ي".into())];
run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed));
assert!(!d.layout().is_latin());
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)]));
assert_eq!(ran, ["edit.duplicate"]);
// German: ⇧7 types /, so Ctrl+Shift+7 runs Ctrl+/; the ü key's label is learned from typing.
let mut d = Dispatcher::new();
let typed = vec![at(EguiKey::OpenBracket, EguiKey::OpenBracket, Modifiers::NONE), Event::Text("ü".into())];
run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed));
assert_eq!(chord("Cmd+[").display_for(Platform::Windows, d.layout()), "Ctrl+Ü");
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, ctrl_shift, vec![at(EguiKey::Slash, EguiKey::Num7, ctrl_shift)]));
assert_eq!(ran, ["edit.comment"]);
// German on egui-winit: AltGr+M types µ and AltGr+] types ~ with no modifiers reported, so
// presses while right Alt is held teach nothing and Ctrl+Z stays Ctrl+Z on the Y position.
let mut d = Dispatcher::new();
let typed = vec![at(EguiKey::Plus, EguiKey::CloseBracket, Modifiers::NONE), Event::Text("+".into())];
run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, typed));
let right_alt =
|pressed| Event::Key { key: EguiKey::AltRight, physical_key: Some(EguiKey::AltRight), pressed, repeat: false, modifiers: Modifiers::NONE };
run(&mut d, &km, &Active::NONE, frame(2.0, Modifiers::NONE, vec![right_alt(true)]));
let alt_graph = vec![
at(EguiKey::M, EguiKey::M, Modifiers::NONE),
Event::Text("µ".into()),
at(EguiKey::CloseBracket, EguiKey::CloseBracket, Modifiers::NONE),
Event::Text("~".into()),
right_alt(false),
];
run(&mut d, &km, &Active::NONE, frame(3.0, Modifiers::NONE, alt_graph));
assert!(d.layout().is_latin());
assert_eq!(d.layout().char_at(Key::CloseBracket), Some('+'));
assert_eq!(d.layout().resolve(&KeyInput::new(Mods::CMD).with_typed(Key::Z).with_physical(Key::Y)), Some(chord("Cmd+Z")));
// A layout the platform reports applies from the first press.
let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false));
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, ctrl_shift, vec![at(EguiKey::CloseBracket, EguiKey::D, ctrl_shift)]));
assert_eq!(ran, ["edit.duplicate"]);
}
#[test]
fn letters_follow_the_latin_layout_from_egui_winit_to_the_dispatcher() {
let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap());
let km = Keymap::new(vec![app("d", "Cmd+D"), app("e", "Cmd+E")], Scopes::new(), Platform::Linux);
let at = |key, modifiers| Event::Key { key, physical_key: Some(EguiKey::D), pressed: true, repeat: false, modifiers };
// The QWERTY D key, as the suite's egui-winit reports it: on a layout that is not Latin, the
// logical key is the Latin layout's letter (Dvorak types e there), and the text is Cyrillic.
// (layout, logical key, text typed, platform report, command Ctrl runs)
let cases = [
("Dvorak + Russian", EguiKey::E, "в", Some(false), "e"),
("QWERTY + Russian", EguiKey::D, "в", Some(false), "d"),
("Dvorak", EguiKey::E, "e", Some(true), "e"),
("QWERTY", EguiKey::D, "d", Some(true), "d"),
];
for (name, key, text, latin, expected) in cases {
// The layout learned from typing.
let mut d = Dispatcher::new();
run(&mut d, &km, &Active::NONE, frame(1.0, Modifiers::NONE, vec![at(key, Modifiers::NONE), Event::Text(text.into())]));
assert_eq!(Some(d.layout().is_latin()), latin, "{name}");
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(2.0, CTRL, vec![at(key, CTRL)]));
assert_eq!(ran, [expected], "{name}, learned");
// The layout the platform reports, before anything is typed.
let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(latin == Some(true)));
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(key, CTRL)]));
assert_eq!(ran, [expected], "{name}, reported");
}
}
/// `input` with the Latin layout characters the suite's egui-winit reports per physical key, set
/// through serde because crates.io egui has no such field; `None` there.
fn with_latin_layout_chars(mut input: InputState, chars: &[(EguiKey, char)]) -> Option<InputState> {
let mut raw = serde_json::to_value(&input.raw).unwrap();
raw.get("latin_layout_chars")?;
raw["latin_layout_chars"] = serde_json::to_value(chars).unwrap();
input.raw = serde_json::from_value(raw).unwrap();
Some(input)
}
#[test]
fn a_key_without_a_latin_letter_runs_no_letter_binding() {
let app = |id: &str, key: &str| Bindable::new(id, id, Scope::app()).with_default(Binding::parse(key).unwrap());
let km = Keymap::new(vec![app("quit", "Cmd+Q"), app("quote", "Cmd+'"), app("j", "Cmd+J")], Scopes::new(), Platform::Linux);
let at = |key, physical| Event::Key { key, physical_key: Some(physical), pressed: true, repeat: false, modifiers: CTRL };
// Dvorak + Russian. The QWERTY Q key types й, and Dvorak ' there: egui-winit reports the key
// as ', and egui without a key for the character as its position, Q. The QWERTY C key types
// с, and Dvorak j.
let latin = [(EguiKey::Q, '\''), (EguiKey::C, 'j')];
let cases = [(at(EguiKey::Quote, EguiKey::Q), "quote"), (at(EguiKey::Q, EguiKey::Q), "quote"), (at(EguiKey::J, EguiKey::C), "j")];
for (press, expected) in cases {
let mut d = Dispatcher::new().with_layout(Layout::new().with_latin(false));
let Some(input) = with_latin_layout_chars(frame(1.0, CTRL, vec![press.clone()]), &latin) else {
// crates.io egui reports no Latin layout, so the position decides: Ctrl+Q.
let (ran, _) = run(&mut d, &km, &Active::NONE, frame(1.0, CTRL, vec![at(EguiKey::Q, EguiKey::Q)]));
assert_eq!(ran, ["quit"]);
return;
};
let (ran, _) = run(&mut d, &km, &Active::NONE, input);
assert_eq!(ran, [expected], "{press:?}");
}
}