5e9b5d3cfb
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! Menu mnemonics, keyboard focus and Vim motions, driven with real key events through `egui_kittest` frames.
|
|
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
|
use egui_kittest::kittest::{NodeT, Queryable};
|
|
use notescraft_ui_egui::{fonts, menu_keys, motion, tabs, theme::Tokens};
|
|
use std::cell::Cell;
|
|
|
|
/// Rows of the test menu; `Duplicate` is disabled.
|
|
const ITEMS: [(&str, bool); 4] = [("Open", true), ("Rename", true), ("Duplicate", false), ("Close", true)];
|
|
|
|
struct Fixture {
|
|
tokens: Tokens,
|
|
vim: bool,
|
|
warmed: bool,
|
|
chosen: Vec<&'static str>,
|
|
opener: Cell<Pos2>,
|
|
}
|
|
|
|
fn tokens() -> Tokens {
|
|
Tokens::new(notescraft_themes::builtin("catppuccin-mocha").expect("theme"))
|
|
}
|
|
|
|
/// The opener button plus its context menu, the way the app builds its popups.
|
|
fn draw(ui: &mut egui::Ui, fx: &mut Fixture) {
|
|
if !fx.warmed {
|
|
fonts::install(ui.ctx());
|
|
fx.warmed = true;
|
|
return;
|
|
}
|
|
motion::set_enabled(ui.ctx(), false);
|
|
menu_keys::set_vim(ui.ctx(), fx.vim);
|
|
let opener = ui.button("Opener");
|
|
fx.opener.set(opener.rect.center());
|
|
egui::Popup::context_menu(&opener).show(|ui| {
|
|
menu_keys::name_menu(ui, "Test menu");
|
|
for (label, enabled) in ITEMS {
|
|
let chosen = ui.add_enabled_ui(enabled, |ui| menu_keys::row(ui, &fx.tokens, &menu_keys::Row::new(label, "x", false))).inner;
|
|
if chosen {
|
|
fx.chosen.push(label);
|
|
ui.close();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn harness(vim: bool) -> egui_kittest::Harness<'static, Fixture> {
|
|
let mut harness = egui_kittest::Harness::builder()
|
|
.with_size(egui::vec2(480.0, 400.0))
|
|
.build_ui_state(draw, Fixture { tokens: tokens(), vim, warmed: false, chosen: Vec::new(), opener: Cell::new(Pos2::ZERO) });
|
|
harness.run();
|
|
harness
|
|
}
|
|
|
|
fn open_menu(harness: &mut egui_kittest::Harness<'static, Fixture>) {
|
|
harness.get_by_label("Opener").click_secondary();
|
|
harness.run();
|
|
}
|
|
|
|
fn press(harness: &mut egui_kittest::Harness<'static, Fixture>, key: Key) {
|
|
harness.key_press(key);
|
|
harness.run();
|
|
}
|
|
|
|
fn focused(harness: &egui_kittest::Harness<'static, Fixture>) -> Option<&'static str> {
|
|
ITEMS.iter().map(|(label, _)| *label).find(|label| harness.query_by_label(label).is_some_and(|node| node.is_focused()))
|
|
}
|
|
|
|
#[test]
|
|
fn opening_focuses_the_first_enabled_row_and_names_the_menu() {
|
|
let mut harness = harness(false);
|
|
open_menu(&mut harness);
|
|
assert_eq!(focused(&harness), Some("Open"));
|
|
let menu = harness.query_all_by_role(egui::accesskit::Role::Menu).next();
|
|
assert!(menu.is_some_and(|menu| menu.accesskit_node().label().as_deref() == Some("Test menu")), "menu container has role Menu and a name");
|
|
}
|
|
|
|
#[test]
|
|
fn items_are_menu_items_with_their_key_as_description() {
|
|
let mut harness = harness(false);
|
|
open_menu(&mut harness);
|
|
let rename = harness.get_by_label("Rename");
|
|
assert_eq!(rename.accesskit_node().role(), egui::accesskit::Role::MenuItem);
|
|
assert_eq!(rename.accesskit_node().description().as_deref(), Some("Key R"));
|
|
assert_eq!(rename.accesskit_node().data().keyboard_shortcut(), Some("R"));
|
|
assert!(harness.get_by_label("Duplicate").accesskit_node().is_disabled(), "disabled rows are announced as disabled");
|
|
}
|
|
|
|
#[test]
|
|
fn pressing_the_letter_runs_the_row() {
|
|
let mut harness = harness(false);
|
|
open_menu(&mut harness);
|
|
press(&mut harness, Key::R);
|
|
assert_eq!(harness.state().chosen, vec!["Rename"]);
|
|
assert!(harness.query_by_label("Rename").is_none(), "the menu closed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_disabled_row_ignores_its_letter() {
|
|
let mut harness = harness(false);
|
|
open_menu(&mut harness);
|
|
press(&mut harness, Key::D);
|
|
assert_eq!(harness.state().chosen, Vec::<&str>::new());
|
|
assert!(harness.query_by_label("Rename").is_some(), "the menu stays open");
|
|
}
|
|
|
|
#[test]
|
|
fn tab_cycles_inside_the_menu_and_never_escapes() {
|
|
let mut harness = harness(false);
|
|
open_menu(&mut harness);
|
|
let mut seen = Vec::new();
|
|
for _ in 0..4 {
|
|
press(&mut harness, Key::Tab);
|
|
seen.push(focused(&harness));
|
|
}
|
|
assert_eq!(seen, vec![Some("Rename"), Some("Close"), Some("Open"), Some("Rename")], "disabled rows are skipped, focus wraps");
|
|
harness.key_press_modifiers(Modifiers::SHIFT, Key::Tab);
|
|
harness.run();
|
|
assert_eq!(focused(&harness), Some("Open"));
|
|
}
|
|
|
|
#[test]
|
|
fn escape_closes_and_returns_focus_to_the_opener() {
|
|
let mut harness = harness(false);
|
|
harness.get_by_label("Opener").focus();
|
|
harness.run();
|
|
open_menu(&mut harness);
|
|
assert_eq!(focused(&harness), Some("Open"));
|
|
press(&mut harness, Key::Escape);
|
|
harness.run();
|
|
assert!(harness.query_by_label("Rename").is_none(), "the menu closed");
|
|
assert!(harness.get_by_label("Opener").is_focused(), "focus is back on the control that opened the menu");
|
|
}
|
|
|
|
#[test]
|
|
fn vim_keys_move_and_activate() {
|
|
let mut harness = harness(true);
|
|
open_menu(&mut harness);
|
|
press(&mut harness, Key::J);
|
|
assert_eq!(focused(&harness), Some("Rename"));
|
|
press(&mut harness, Key::J);
|
|
assert_eq!(focused(&harness), Some("Close"), "j skips the disabled row");
|
|
press(&mut harness, Key::K);
|
|
assert_eq!(focused(&harness), Some("Rename"));
|
|
harness.key_press_modifiers(Modifiers::SHIFT, Key::G);
|
|
harness.run();
|
|
assert_eq!(focused(&harness), Some("Close"), "G jumps to the last row");
|
|
press(&mut harness, Key::G);
|
|
assert_eq!(focused(&harness), Some("Close"), "one g waits for the second");
|
|
press(&mut harness, Key::G);
|
|
assert_eq!(focused(&harness), Some("Open"), "gg jumps to the first row");
|
|
press(&mut harness, Key::J);
|
|
press(&mut harness, Key::L);
|
|
assert_eq!(harness.state().chosen, vec!["Rename"], "l activates the focused row");
|
|
}
|
|
|
|
#[test]
|
|
fn vim_h_closes_the_menu_and_ctrl_n_p_work_without_vim() {
|
|
let mut harness = harness(true);
|
|
open_menu(&mut harness);
|
|
press(&mut harness, Key::H);
|
|
assert!(harness.query_by_label("Rename").is_none(), "h closes like Esc");
|
|
let mut plain = self::harness(false);
|
|
open_menu(&mut plain);
|
|
plain.key_press_modifiers(Modifiers::CTRL, Key::N);
|
|
plain.run();
|
|
assert_eq!(focused(&plain), Some("Rename"));
|
|
plain.key_press_modifiers(Modifiers::CTRL, Key::P);
|
|
plain.run();
|
|
assert_eq!(focused(&plain), Some("Open"));
|
|
press(&mut plain, Key::ArrowDown);
|
|
assert_eq!(focused(&plain), Some("Rename"));
|
|
press(&mut plain, Key::End);
|
|
assert_eq!(focused(&plain), Some("Close"));
|
|
press(&mut plain, Key::Home);
|
|
assert_eq!(focused(&plain), Some("Open"));
|
|
}
|
|
|
|
#[test]
|
|
fn vim_mode_letters_j_k_h_l_g_are_never_mnemonics() {
|
|
// "Link" would be L in a plain menu; in Vim mode its key is another letter and l only moves.
|
|
let letters = menu_keys::assign(&[("Link", None), ("Gamma", None), ("Hello", None), ("Kilo", None)], menu_keys::reserved_for(true));
|
|
for letter in letters.iter().flatten() {
|
|
assert!(!menu_keys::VIM_RESERVED.contains(letter));
|
|
}
|
|
}
|
|
|
|
fn texts(shape: &egui::Shape, out: &mut Vec<String>) {
|
|
match shape {
|
|
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)),
|
|
egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Runs the fixture on a bare context and returns every text the last frame painted.
|
|
fn painted(vim: bool) -> Vec<String> {
|
|
let ctx = egui::Context::default();
|
|
let mut fx = Fixture { tokens: tokens(), vim, warmed: true, chosen: Vec::new(), opener: Cell::new(Pos2::ZERO) };
|
|
fonts::install(&ctx);
|
|
let input = |events: Vec<Event>| egui::RawInput {
|
|
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(480.0, 400.0))),
|
|
events,
|
|
..Default::default()
|
|
};
|
|
let mut first = ctx.run_ui(input(Vec::new()), |ui| draw(ui, &mut fx));
|
|
first.textures_delta.clear();
|
|
let at = fx.opener.get();
|
|
let click = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE };
|
|
let mut second = ctx.run_ui(input(vec![Event::PointerMoved(at), click(true), click(false)]), |ui| draw(ui, &mut fx));
|
|
second.textures_delta.clear();
|
|
let mut output = ctx.run_ui(input(Vec::new()), |ui| draw(ui, &mut fx));
|
|
output.textures_delta.clear();
|
|
let mut out = Vec::new();
|
|
output.shapes.iter().for_each(|shape| texts(&shape.shape, &mut out));
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn keycaps_are_drawn_beside_every_row() {
|
|
let painted = painted(false);
|
|
for cap in ["O", "R", "D", "C"] {
|
|
assert!(painted.iter().any(|text| text == cap), "keycap {cap} missing in {painted:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tab_menu_close_runs_from_its_key() {
|
|
let mut tab_set = tabs::Tabs::new();
|
|
tab_set.open("a.md");
|
|
let tokens = tokens();
|
|
let closed: Cell<Option<String>> = Cell::new(None);
|
|
let mut warmed = false;
|
|
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 300.0)).build_ui(|ui| {
|
|
if !warmed {
|
|
fonts::install(ui.ctx());
|
|
warmed = true;
|
|
return;
|
|
}
|
|
motion::set_enabled(ui.ctx(), false);
|
|
if let Some(path) = tabs::show(ui, &tokens, &tab_set, &[]).closed {
|
|
closed.set(Some(path));
|
|
}
|
|
});
|
|
harness.run();
|
|
let at = Pos2::new(40.0, 24.0);
|
|
harness.event(Event::PointerMoved(at));
|
|
harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE });
|
|
harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
|
harness.run();
|
|
assert!(harness.query_by_label("Close").is_some(), "the tab menu opened");
|
|
harness.key_press(Key::C);
|
|
harness.run();
|
|
assert_eq!(closed.take().as_deref(), Some("a.md"));
|
|
}
|
|
|
|
/// Renders a tab context menu (the same rows code as the Explorer menu) to the PNG named by `MENU_KEYS_PNG`:
|
|
/// `MENU_KEYS_PNG=/tmp/menu.png cargo test --test menu_keys -- --ignored`.
|
|
#[test]
|
|
#[ignore = "writes a screenshot for the design review"]
|
|
fn tab_menu_screenshot() {
|
|
let Ok(out) = std::env::var("MENU_KEYS_PNG") else { return };
|
|
let tokens = tokens();
|
|
let mut tab_set = tabs::Tabs::new();
|
|
tab_set.open("a.md");
|
|
let mut warmed = false;
|
|
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(420.0, 420.0)).build_ui(|ui| {
|
|
if !warmed {
|
|
fonts::install(ui.ctx());
|
|
warmed = true;
|
|
return;
|
|
}
|
|
motion::set_enabled(ui.ctx(), false);
|
|
let _ = tabs::show(ui, &tokens, &tab_set, &[]);
|
|
});
|
|
harness.run();
|
|
let at = Pos2::new(40.0, 24.0);
|
|
harness.event(Event::PointerMoved(at));
|
|
harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE });
|
|
harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE });
|
|
harness.run();
|
|
let image = harness.render().expect("render");
|
|
image.save(out).expect("png saved");
|
|
}
|