Files
Marcos Paulo 5e9b5d3cfb
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
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>
2026-10-21 02:31:52 -03:00

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");
}