Files
photocraft/crates/ui-egui/src/menu_keys_tests.rs
T

282 lines
13 KiB
Rust

//! The menu bar from the keyboard (#4988): Alt / F10 focus the bar, Alt+letter opens a menu,
//! letters run rows, and the shortcuts wait while the bar has the keyboard.
use crate::PhotocraftApp;
use crate::menu_keys::Keys;
use crate::menu_nav::{Nav, is_open};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
app.sync_views();
app
}
/// The menu bar alone, as `menu_nav_tests` drives it.
fn bar() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).build_ui_state(
|ui, app| {
crate::menus::menu_bar(app, ui);
},
app(),
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(3);
h
}
fn key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.key_press(k);
h.run_steps(3);
}
fn alt_key(h: &mut Harness<'static, PhotocraftApp>, k: egui::Key) {
h.event_modifiers(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::ALT }, egui::Modifiers::ALT);
h.run_steps(3);
}
/// A tap on Alt: down, then up with nothing else pressed between.
fn alt_tap(h: &mut Harness<'static, PhotocraftApp>) {
h.key_down(egui::Key::AltLeft);
h.run_steps(2);
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
}
fn rows(h: &Harness<'static, PhotocraftApp>, level: usize) -> Vec<crate::menu_nav::Row> {
Nav::current(&h.ctx).rows.get(level).cloned().unwrap_or_default()
}
#[test]
fn alt_focuses_the_bar_arrows_move_and_down_opens_the_lit_menu() {
let mut h = bar();
assert_eq!(Keys::current(&h.ctx).focus, None);
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "Alt puts the keyboard on File");
assert!(crate::menu_keys::is_focused(&h.ctx), "the shortcuts wait for the bar");
assert!(!is_open(&h.ctx), "no menu opens yet");
key(&mut h, egui::Key::ArrowRight);
assert_eq!(Keys::current(&h.ctx).focus, Some(1), "→ moves to Edit");
key(&mut h, egui::Key::ArrowLeft);
key(&mut h, egui::Key::ArrowLeft);
assert_eq!(Keys::current(&h.ctx).focus, Some(9), "← wraps round to Help");
key(&mut h, egui::Key::ArrowRight);
key(&mut h, egui::Key::ArrowRight);
assert_eq!(Keys::current(&h.ctx).focus, Some(1));
key(&mut h, egui::Key::ArrowDown);
h.run_steps(2);
assert!(is_open(&h.ctx), "↓ opens the lit menu");
assert!(h.query_by_label_contains("Undo").is_some(), "Edit is open");
assert_eq!(Keys::current(&h.ctx).focus, None, "the focus moves into the menu");
let first = rows(&h, 0).iter().position(|r| r.enabled).unwrap();
assert_eq!(Nav::current(&h.ctx).highlighted(0), Some(first), "its first row is lit, as on Windows");
assert!(Keys::current(&h.ctx).underline, "a menu opened from the keyboard shows its mnemonics");
key(&mut h, egui::Key::Escape);
assert!(!is_open(&h.ctx) && Keys::current(&h.ctx).focus.is_none(), "Esc leaves the menu and the bar");
}
/// Alt+wheel zooms; letting go of Alt after it is no tap, so Space (the hand tool) can't then
/// open File and run New….
#[test]
fn alt_with_a_wheel_scroll_or_a_lost_focus_is_no_tap() {
let mut h = bar();
h.key_down(egui::Key::AltLeft);
h.run_steps(2);
h.event_modifiers(
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::ALT,
},
egui::Modifiers::ALT,
);
h.run_steps(2);
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
assert_eq!(Keys::current(&h.ctx).focus, None, "Alt+scroll leaves the bar alone");
key(&mut h, egui::Key::Space);
assert!(!is_open(&h.ctx), "Space opens no menu");
h.key_down(egui::Key::AltLeft);
h.run_steps(2);
h.event(egui::Event::WindowFocused(false));
h.event(egui::Event::WindowFocused(true));
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
assert_eq!(Keys::current(&h.ctx).focus, None, "Alt that went to the desktop leaves the bar alone");
}
#[test]
fn a_second_alt_or_esc_or_a_click_takes_the_keyboard_off_the_bar() {
let mut h = bar();
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, Some(0));
alt_tap(&mut h);
assert_eq!(Keys::current(&h.ctx).focus, None, "a second tap leaves");
key(&mut h, egui::Key::F10);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar too");
key(&mut h, egui::Key::Escape);
assert_eq!(Keys::current(&h.ctx).focus, None);
assert!(!crate::menu_keys::is_focused(&h.ctx));
key(&mut h, egui::Key::F10);
let at = egui::pos2(600.0, 300.0);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert_eq!(Keys::current(&h.ctx).focus, None, "a click anywhere leaves");
}
#[test]
fn alt_with_a_letter_opens_that_menu_and_enter_opens_the_lit_one() {
let mut h = bar();
alt_key(&mut h, egui::Key::E);
h.run_steps(2);
assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "Alt+E opens Edit");
alt_key(&mut h, egui::Key::F);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "Alt+F switches to File while a menu is open");
key(&mut h, egui::Key::Escape);
assert!(!is_open(&h.ctx));
key(&mut h, egui::Key::F10);
key(&mut h, egui::Key::Enter);
h.run_steps(2);
assert!(h.query_by_label_contains("Open…").is_some(), "Enter opens the lit File menu");
key(&mut h, egui::Key::Escape);
key(&mut h, egui::Key::F10);
key(&mut h, egui::Key::V);
h.run_steps(2);
assert!(is_open(&h.ctx) && h.query_by_label_contains("Zoom In").is_some(), "a letter on the focused bar opens that menu (V = View)");
}
#[test]
fn a_letter_in_an_open_menu_runs_the_row_with_that_mnemonic_or_opens_its_submenu() {
let mut h = bar();
alt_key(&mut h, egui::Key::V);
h.run_steps(2);
assert!(h.query_by_label_contains("Zoom In").is_some(), "View is open");
let level = rows(&h, 0);
let zoom_in = level.iter().find(|r| r.command.as_deref() == Some("view.zoomIn")).expect("Zoom In row");
let letter = zoom_in.mnemonic.expect("Zoom In has a mnemonic");
assert!(level.iter().filter(|r| r.mnemonic == Some(letter)).count() == 1, "mnemonics are unique within a level");
let zoom = h.state().ui.views[0].zoom;
key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap());
h.run_steps(3);
assert!(h.state().ui.views[0].zoom > zoom, "the letter ran Zoom In ({letter})");
assert!(!is_open(&h.ctx), "and closed the menu");
// A submenu's letter opens it, with its first row lit.
alt_key(&mut h, egui::Key::V);
h.run_steps(2);
let level = rows(&h, 0);
let sub = level.iter().find(|r| r.command.is_none() && r.enabled && r.mnemonic.is_some()).expect("an enabled submenu with a mnemonic");
let letter = sub.mnemonic.unwrap();
key(&mut h, egui::Key::from_name(&letter.to_string()).unwrap());
h.run_steps(3);
assert!(!rows(&h, 1).is_empty(), "the submenu opened ({letter})");
assert_eq!(Nav::current(&h.ctx).level, 1);
}
#[test]
fn mnemonics_are_underlined_only_with_alt_or_a_keyboard_opened_menu() {
let mut h = bar();
h.get_by_label("File").click();
h.run_steps(4);
assert!(is_open(&h.ctx));
assert!(!Nav::current(&h.ctx).underline, "a menu opened with the mouse shows no underlines");
h.key_down(egui::Key::AltLeft);
h.run_steps(3);
assert!(Nav::current(&h.ctx).underline, "while Alt is held they show");
h.key_up(egui::Key::AltLeft);
h.run_steps(3);
// The tap closed the menu (as on Windows); opening from the keyboard underlines.
assert!(!is_open(&h.ctx), "an Alt tap closes an open menu");
alt_key(&mut h, egui::Key::F);
h.run_steps(2);
assert!(is_open(&h.ctx) && Nav::current(&h.ctx).underline);
assert!(rows(&h, 0).iter().any(|r| r.mnemonic.is_some()), "rows carry their letters");
}
#[test]
fn underlined_text_marks_the_mnemonic_once_and_keeps_the_prefix() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
let t = crate::menu_keys::mnemonic_text(ui, "✔ ", "Save As…", Some('A'), None);
let egui::WidgetText::LayoutJob(job) = t else { panic!("a mnemonic makes a layout job") };
assert_eq!(job.text, "✔ Save As…");
let underlined: Vec<&str> =
job.sections.iter().filter(|s| s.format.underline != egui::Stroke::NONE).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert_eq!(underlined, ["a"], "the first occurrence, case-insensitively");
let t = crate::menu_keys::mnemonic_text(ui, "", "Edit", None, None);
assert!(matches!(t, egui::WidgetText::RichText(_)), "without a mnemonic, plain text");
let t = crate::menu_keys::mnemonic_text(ui, "", "Édition", Some('D'), None);
let egui::WidgetText::LayoutJob(job) = t else { panic!() };
assert_eq!(job.sections.len(), 3, "multi-byte text before the letter is kept whole");
});
out.textures_delta.clear();
}
/// The whole app: with the bar focused a letter opens a menu instead of picking a tool, and the
/// arrows don't nudge the layer.
#[test]
fn the_shortcuts_wait_while_the_keyboard_is_on_the_bar() {
let mut h = Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = app();
app.services.native_menu = None;
app
});
h.run_steps(4);
let tool = h.state().ui.tool;
key(&mut h, egui::Key::F10);
assert_eq!(Keys::current(&h.ctx).focus, Some(0), "F10 focuses the bar");
key(&mut h, egui::Key::E);
h.run_steps(2);
assert_eq!(h.state().ui.tool, tool, "E did not pick the Eraser");
assert!(is_open(&h.ctx) && h.query_by_label_contains("Undo").is_some(), "E opened Edit");
key(&mut h, egui::Key::Escape);
h.run_steps(2);
assert!(!is_open(&h.ctx));
h.key_press(egui::Key::E);
h.run_steps(3);
assert_ne!(h.state().ui.tool, tool, "off the bar, E picks the Eraser again");
}
/// Checked, unchecked and plain rows (and submenu headers) start their label at the same x, in
/// any script: the checkmark column has a fixed width instead of padding characters (#5705).
#[test]
fn every_menu_row_starts_its_label_after_one_checkmark_column() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
let start = |checked: bool, label: &str, mnemonic: Option<char>| {
let galley = crate::menu_keys::menu_row_text(ui, checked, label, mnemonic).into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
egui::TextStyle::Button,
);
let first = label.chars().next().unwrap_or(' ');
let row = galley.rows.first().expect("one row");
let glyph = row.glyphs.iter().find(|g| g.chr == first).expect("the label's first glyph");
assert!(galley.text().ends_with(label), "the label is shown whole: {:?}", galley.text());
glyph.pos.x
};
let gutter = crate::menu_keys::check_gutter(ui);
assert!(gutter > 0.0, "the column has room for the mark");
for label in ["Lock Guides", "锁定参考线", "清除参考线"] {
let checked = start(true, label, None);
let unchecked = start(false, label, None);
assert!((checked - unchecked).abs() < 0.01, "{label}: checked {checked} vs unchecked {unchecked}");
assert!((unchecked - gutter).abs() < 1.0, "{label}: the label starts right after the column ({unchecked} vs {gutter})");
}
let underlined = start(false, "Clear Guides", Some('C'));
let plain = start(false, "Clear Guides", None);
assert!((underlined - plain).abs() < 0.01, "an underlined mnemonic doesn't move the label");
let underlined_mid = start(true, "Lock Guides", Some('G'));
assert!((underlined_mid - plain).abs() < 0.01, "nor a mnemonic after the first letter");
let t = crate::menu_keys::menu_row_text(ui, true, "Rulers", None);
assert_eq!(t.text(), "✔ Rulers", "a checked row keeps its mark (and its accessible label)");
});
out.textures_delta.clear();
}