From 68e9d61aa662d467cef76ff5eeaec71636eea565 Mon Sep 17 00:00:00 2001 From: eliets2 <287273517+eliets2@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:16:51 +0300 Subject: [PATCH] Tests for the grid navigation keys, Cmd+O and Shift+H Cover Home/End and Shift+Left/Right in both grids (ends of the view, the loupe, the user's own binding winning), Cmd+O asking for a folder once, Shift+H toggling the HDR view, and the new keys staying out of the way while a text field has the keyboard, while the full-screen preview covers the grid, and while a dialog is open. --- crates/ui-egui/src/tests_keymap.rs | 233 +++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 1b04a46bd..45c3ba7de 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -237,3 +237,236 @@ fn opening_numeric_release_keeps_keys_in_the_editor() { } } } + +/// Press `key` with the named modifiers held (`cmd`, `shift`, `alt`, `ctrl`). +fn press(h: &mut Headless, key: &str, mods: &[&str]) { + let mut p = json!({ "key": key }); + for m in mods { + p[*m] = json!(true); + } + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +fn view(h: &mut Headless, p: serde_json::Value) { + let r = h.request("ui.set", p, T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Make `id` the only selected, active photo. +fn select(h: &mut Headless, id: lightcraft_catalog::PhotoId) { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// The demo library in Photo Grid with its middle photo selected: the photos in view, and that one. +fn grid_demo() -> (Headless, Vec, lightcraft_catalog::PhotoId) { + let mut h = demo(); + view(&mut h, json!({ "view": "photoGrid" })); + let vis = h.app.session.visible_cloned(); + assert!(vis.len() > 2, "{vis:?}"); + let middle = vis[vis.len() / 2]; + select(&mut h, middle); + (h, vis, middle) +} + +/// Count how often the folder picker is asked for; every ask is cancelled. +fn count_folder_picks(h: &mut Headless) -> std::sync::Arc { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = calls.clone(); + h.app.services.pick_folder = Some(Box::new(move || { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + calls +} + +/// The declared keys of the two commands these tests bind. +#[test] +fn open_library_and_visualize_hdr_keys_are_declared() { + use crate::shortcuts::{Keymap, parse, shortcut_of}; + + let keymap = Keymap::new(); + for (id, want) in [("view.visualizeHdr", "Shift+H"), ("app.openLibrary", "Cmd+O")] { + assert_eq!(shortcut_of(&keymap, id), Some(want), "{id}"); + assert!(parse(want).is_some(), "{want} does not parse"); + } +} + +/// Opening a library uses the same folder picker as File > Open Library. Cancelling it +/// leaves the current photo and library intact. +#[test] +fn cmd_o_opens_the_library_picker_once() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + let active = h.app.session.active(); + let photos = h.app.session.catalog.len(); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "one picker for Cmd+O"); + assert_eq!(h.app.session.active(), active); + assert_eq!(h.app.session.catalog.len(), photos); +} + +/// Shift+H toggles View > Visualize HDR; Cmd+Shift+H (Histogram) is untouched. +#[test] +fn shift_h_toggles_the_hdr_view() { + let mut h = demo(); + assert!(!h.app.ui.hdr_visualize); + let histogram = h.app.ui.histogram; + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + assert_eq!(h.app.ui.histogram, histogram, "Shift+H leaves the Histogram unchanged"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize, "Shift+H turns it back off"); +} + +/// Home / End make the first / last photo of the view the active one, only in the library grids, +/// and yield to a key the user gave to another command. +#[test] +fn home_and_end_select_the_ends_of_the_grid() { + let (mut h, vis, _) = grid_demo(); + let (first, last) = (vis[0], *vis.last().unwrap()); + + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "Home goes to the first photo"); + assert_eq!(h.app.session.selection.ids, vec![first], "and selects only it"); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), Some(last), "End goes to the last photo"); + assert_eq!(h.app.session.selection.ids, vec![last]); + + view(&mut h, json!({ "view": "squareGrid" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "the Square Grid has the keys too"); + + select(&mut h, last); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(last), "Home is a grid key"); + + view(&mut h, json!({ "view": "photoGrid" })); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Home"})).unwrap(); + assert_eq!(r["shortcut"], "Home", "{r}"); + press(&mut h, "Home", &[]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Home binding fires"); + assert_eq!(h.app.session.active(), Some(last), "the grid key stayed out of the way"); +} + +/// Shift+Left / Shift+Right in a grid add the previous / next photo of the view to the selection and +/// make it active, stop at either end, stay out of the loupe and yield to the user's keymap. +#[test] +fn shift_arrows_extend_the_grid_selection() { + let (mut h, vis, middle) = grid_demo(); + let at = vis.iter().position(|p| *p == middle).unwrap(); + let (previous, next) = (vis[at - 1], vis[at + 1]); + + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next], "Shift+Right adds the next photo"); + assert_eq!(h.app.session.active(), Some(next), "the added photo becomes active"); + // Shift+Left steps back onto a photo that is already selected: the selection stays, the active moves + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next]); + assert_eq!(h.app.session.active(), Some(middle)); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next, previous], "Shift+Left adds the previous photo"); + assert_eq!(h.app.session.active(), Some(previous)); + + // the ends of the view do nothing + select(&mut h, vis[0]); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "Shift+Left on the first photo is a no-op"); + let last = *vis.last().unwrap(); + select(&mut h, last); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![last], "Shift+Right on the last photo is a no-op"); + + // outside a grid it is not the extension key + select(&mut h, middle); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle], "the loupe has no grid extension key"); + + // a key the user gave to a command wins + view(&mut h, json!({ "view": "photoGrid" })); + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+Right"})).unwrap(); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Shift+Right binding fires"); + assert_eq!(h.app.session.selection.ids, vec![middle], "the grid key stayed out of the way"); +} + +/// A text field with the keyboard focus keeps every one of these keys. +#[test] +fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() { + let (mut h, vis, middle) = grid_demo(); + let calls = count_folder_picks(&mut h); + let picks = || calls.load(std::sync::atomic::Ordering::SeqCst); + let keys: [(&str, &[&str]); 6] = [("Home", &[]), ("End", &[]), ("Right", &["shift"]), ("Left", &["shift"]), ("O", &["cmd"]), ("H", &["shift"])]; + + // with no text field focused, each of them acts + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), vis.last().copied()); + select(&mut h, middle); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends"); + press(&mut h, "O", &["cmd"]); + assert_eq!(picks(), 1, "Cmd+O asks for a folder"); + press(&mut h, "H", &["shift"]); + assert!(h.app.ui.hdr_visualize, "Shift+H turns Visualize HDR on"); + press(&mut h, "H", &["shift"]); + assert!(!h.app.ui.hdr_visualize); + select(&mut h, middle); + + // with the search box focused, none does + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the search box has the keyboard"); + let before = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + for (key, mods) in keys { + press(&mut h, key, mods); + let after = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + assert_eq!(after, before, "{mods:?}+{key} reached the shortcuts while the search box had the focus"); + } + assert!(h.app.ui.dialog.is_none()); +} + +/// Home / End and Shift+Left / Shift+Right belong to the grid: while the full-screen preview covers it +/// they leave the selection alone. +#[test] +fn grid_navigation_keys_do_nothing_in_the_full_screen_preview() { + let (mut h, vis, middle) = grid_demo(); + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the grid is the view behind the preview"); + for (key, mods) in [("Home", &[][..]), ("End", &[][..]), ("Right", &["shift"][..]), ("Left", &["shift"][..])] { + press(&mut h, key, mods); + assert_eq!(h.app.session.active(), Some(middle), "{mods:?}+{key} moved the active photo in the full-screen preview"); + assert_eq!(h.app.session.selection.ids, vec![middle], "{mods:?}+{key} changed the selection in the full-screen preview"); + } + // back in the grid the keys work again + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(!h.app.ui.fullscreen); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); +} + +/// A dialog owns the keyboard: Cmd+O does not ask for a folder behind it. +#[test] +fn cmd_o_does_not_fire_while_a_dialog_is_open() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0, "Cmd+O reached the shortcuts behind a dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // with the dialog closed the same key asks for a folder + h.app.ui.dialog = None; + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); +}