Files
lightcraft/crates/ui-egui/src/tests_keymap.rs
T

570 lines
27 KiB
Rust

//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires
//! its command and not the old one's, the editor records a key press without running it, Esc
//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`.
use std::time::Duration;
use serde_json::json;
use crate::headless::Headless;
use crate::state::{Dialog, ViewMode};
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(20);
const SETTLE: Duration = Duration::from_secs(120);
fn demo() -> Headless {
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
let r = h.request("ui.set", json!({"view": "detail"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
h
}
fn key(h: &mut Headless, k: &str, shift: bool) {
let r = h.request("ui.key", json!({"key": k, "shift": shift}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
}
fn click(h: &mut Headless, id: &str) {
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
}
#[test]
fn rebound_shortcut_fires_and_the_old_key_does_not() {
let mut h = demo();
let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap();
assert_eq!(r["shortcut"], "Shift+K", "{r}");
// the old key (N) no longer opens Survey…
key(&mut h, "N", false);
assert_eq!(h.app.ui.view, ViewMode::Detail);
// …the new one does, and menus show it
key(&mut h, "K", true);
assert_eq!(h.app.ui.view, ViewMode::Survey);
let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap();
assert_eq!(entry.shortcut.as_deref(), Some("Shift+K"));
// the keymap is saved with the UI state
let saved = serde_json::to_string(&h.app.ui).unwrap();
let back = serde_json::from_str::<crate::UiState>(&saved).unwrap();
assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K"));
}
#[test]
fn the_editor_records_a_key_and_esc_cancels_without_closing() {
let mut h = demo();
h.app.run("app.shortcuts", json!({})).unwrap();
h.step();
h.step();
// the list is longer than the dialog: narrow it so the row is on screen
click(&mut h, "field:shortcutsSearch");
assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true);
h.step();
h.step();
// record Shift+J for Compare: the key press is taken, not run
click(&mut h, "button:shortcut-view.compare");
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare"));
key(&mut h, "J", true);
assert_eq!(h.app.recording_shortcut, None);
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
assert_eq!(h.app.ui.view, ViewMode::Detail);
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
// Esc while recording cancels; the dialog stays open and nothing changes
click(&mut h, "button:shortcut-view.compare");
key(&mut h, "Escape", false);
assert_eq!(h.app.recording_shortcut, None);
assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts)));
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J"));
// the next Esc closes it; the new key works
key(&mut h, "Escape", false);
assert!(h.app.ui.dialog.is_none());
key(&mut h, "J", true);
assert_eq!(h.app.ui.view, ViewMode::Compare);
// Reset All restores the declared keys
h.app.run("app.resetShortcuts", json!({})).unwrap();
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C"));
}
#[test]
fn a_key_given_to_a_command_overrides_a_fixed_key() {
let mut h = demo();
let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
// 3 (rate ★★★) now opens Survey instead
h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap();
key(&mut h, "3", false);
assert_eq!(h.app.ui.view, ViewMode::Survey);
let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating);
assert_eq!(before, after, "the rating key must not fire too");
}
/// A declared key that yields to another command's saved key can be taken back with ↺.
#[test]
fn restore_takes_back_a_declared_key_another_command_holds() {
use crate::shortcuts::shortcut_of;
let mut h = demo();
h.app.ui.settings.keymap.insert("photo.increaseRating".into(), "P".into());
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), None);
h.app.run("app.shortcuts", json!({})).unwrap();
h.step();
h.step();
click(&mut h, "check:shortcuts.showAll");
click(&mut h, "field:shortcutsSearch");
assert_eq!(h.request("ui.text", json!({"text": "flag as pick"}), T)["ok"], true);
h.step();
h.step();
click(&mut h, "button:shortcutReset-photo.pick");
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.pick"), Some("P"));
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), None);
assert!(h.app.ui.settings.keymap.is_empty(), "{:?}", h.app.ui.settings.keymap);
}
/// Open the editor, filtered to `search`, and start recording for `id`.
fn record(h: &mut Headless, search: &str, id: &str) {
if h.app.ui.dialog.is_none() {
h.app.run("app.shortcuts", json!({})).unwrap();
h.step();
h.step();
click(h, "field:shortcutsSearch");
assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true);
h.step();
h.step();
}
click(h, &format!("button:shortcut-{id}"));
assert_eq!(h.app.recording_shortcut.as_deref(), Some(id));
}
/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut
/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored.
#[test]
fn modifiers_wait_for_their_key_and_clipboard_keys_record() {
let mut h = demo();
record(&mut h, "survey", "view.survey");
let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone");
let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
assert_eq!(h.app.recording_shortcut, None);
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K"));
// ⌘⌥C: the windowing layer sends a Copy event with the modifiers held
record(&mut h, "survey", "view.survey");
let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND };
h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m });
h.app.synthetic.push(egui::Event::Copy);
h.step();
h.step();
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C"));
// a saved modifier-only shortcut (from the bug) means no shortcut
assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None);
}
/// On Windows and Linux the windowing layer turns Ctrl+C / Ctrl+V into clipboard events, not key
/// presses: outside a text field they still copy the edit settings, and with ⇧ open Paste Selected
/// Settings.
#[test]
fn clipboard_events_run_the_copy_and_paste_shortcuts() {
let mut h = demo();
assert!(h.app.session.clipboard.is_none());
h.events.push(egui::Event::Copy);
h.settle(SETTLE);
assert!(h.app.session.clipboard.is_some(), "Ctrl+C copied the edit settings");
let shift_cmd = egui::Modifiers::SHIFT | egui::Modifiers::COMMAND;
h.events.extend([egui::Event::ModifiersChanged(shift_cmd), egui::Event::Paste("text".into())]);
h.settle(SETTLE);
assert!(matches!(h.app.ui.dialog, Some(Dialog::PasteSettings { .. })), "⇧Ctrl+V: {:?}", h.app.ui.dialog);
}
/// Windows also pastes with ⇧Insert and cuts with ⇧Delete: the clipboard events they become are
/// the plain Paste / Cut, not ⇧Ctrl+V (Paste Selected Settings).
#[test]
fn shift_insert_pastes_plainly() {
let mut h = demo();
h.events.push(egui::Event::Copy);
h.settle(SETTLE);
let undo = h.app.session.undo.len();
h.events.extend([egui::Event::ModifiersChanged(egui::Modifiers::SHIFT), egui::Event::Paste("text".into())]);
h.settle(SETTLE);
assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings");
assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted");
}
/// ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V) copy and paste metadata, as in Lightroom Classic's Library,
/// both as key presses and as the clipboard events Windows and Linux turn them into; the edit
/// settings clipboard is left alone.
#[test]
fn cmd_alt_shift_c_and_v_copy_and_paste_metadata() {
let mut h = demo();
let ids: Vec<_> = h.app.session.visible().iter().copied().take(2).collect();
let (a, b) = (ids[0], ids[1]);
let title = |h: &Headless, id| h.app.session.catalog.photo(id).unwrap().meta.title.clone();
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
h.app.run("photo.setMeta", json!({"title": "Dawn", "creator": "Me"})).unwrap();
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap();
let chord = json!({"cmd": true, "alt": true, "shift": true});
let press = |h: &mut Headless, k: &str| {
let mut p = chord.clone();
p["key"] = json!(k);
let r = h.request("ui.key", p, T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
};
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
press(&mut h, "C");
assert_eq!(h.app.session.meta_clipboard.as_ref().map(|m| m["title"].clone()), Some(json!("Dawn")), "the key copied the metadata");
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
press(&mut h, "V");
assert_eq!(title(&h, b), "Dawn", "the key pasted the metadata");
assert_eq!(h.app.session.catalog.photo(b).unwrap().meta.creator, "Me");
h.app.session.meta_clipboard = None;
h.app.run("photo.setMeta", json!({"title": "Other"})).unwrap();
let all = egui::Modifiers::COMMAND | egui::Modifiers::ALT | egui::Modifiers::SHIFT;
h.app.run("library.select", json!({"ids": [a.0]})).unwrap();
h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Copy]);
h.settle(SETTLE);
assert!(h.app.session.meta_clipboard.is_some(), "the Copy event with ⌥⇧ held copied the metadata");
h.app.run("library.select", json!({"ids": [b.0]})).unwrap();
h.events.extend([egui::Event::ModifiersChanged(all), egui::Event::Paste("text".into())]);
h.settle(SETTLE);
assert_eq!(title(&h, b), "Dawn", "the Paste event with ⌥⇧ held pasted the metadata");
assert!(h.app.session.clipboard.is_none(), "no edit settings were copied");
assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings");
}
/// The opening release can carry both physical shortcut keys and the replacement text.
#[test]
fn opening_numeric_release_keeps_keys_in_the_editor() {
use egui::{Event, Key, Modifiers, PointerButton};
for terminal in [Key::Enter, Key::Escape] {
let mut h = demo();
h.app.run("photo.rate", json!({"rating": 0})).unwrap();
h.app.run("develop.set", json!({"values": {"light.exposure": 0.7}})).unwrap();
h.app.run("app.setShortcut", json!({"id": "photo.increaseRating", "shortcut": "Enter"})).unwrap();
let r = h.request("ui.set", json!({"view": "detail", "right": "edit", "openSections": ["light"], "histogram": false}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
assert!(h.app.fonts_ready);
assert!(h.app.recording_shortcut.is_none() && h.app.ui.name_edit.is_none());
assert!(h.app.native_shortcuts.is_empty() && h.app.synthetic.is_empty() && h.events.is_empty());
assert!(!h.view.ctx.egui_wants_keyboard_input());
assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), Some("Enter"));
let active = h.app.session.active().unwrap();
let selection = h.app.session.selection.ids.clone();
let (view, right, tool) = (h.app.ui.view, h.app.ui.right, h.app.ui.tool.clone());
assert!(h.app.ui.dialog.is_none());
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0);
assert!((h.app.session.develop_of(active).unwrap().light.exposure - 0.7).abs() < 1e-9);
let (undo, redo) = (h.app.session.undo.len(), h.app.session.redo.len());
let pos = h.app.widgets.iter().rev().find(|(id, _)| id == "sliderValue:light.exposure").unwrap().1.center();
h.events.push(Event::PointerMoved(pos));
h.step();
h.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
assert!(h.view.ctx.egui_wants_keyboard_input(), "the press focuses the editor before app shortcuts run");
h.events.extend([
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
Event::Key { key: Key::Num2, physical_key: Some(Key::Num2), pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Text("2".into()),
Event::Key { key: terminal, physical_key: Some(terminal), pressed: true, repeat: false, modifiers: Modifiers::NONE },
]);
h.step();
// Observe the consuming frame directly, then exactly one empty frame: no request drains.
for next_frame in [false, true] {
if next_frame {
assert!(h.events.is_empty() && h.app.synthetic.is_empty());
h.step();
}
let expected = if terminal == Key::Enter { 2.0 } else { 0.7 };
assert!((h.app.session.develop_of(active).unwrap().light.exposure - expected).abs() < 1e-9, "{terminal:?}, next={next_frame}");
assert_eq!(h.app.session.undo.len(), undo + usize::from(terminal == Key::Enter), "{terminal:?}, next={next_frame}");
assert_eq!(h.app.session.redo.len(), redo);
assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0, "neither Num2 nor the saved Enter shortcut leaked");
assert_eq!(h.app.session.active(), Some(active));
assert_eq!(h.app.session.selection.ids, selection);
assert_eq!((h.app.ui.view, h.app.ui.right, &h.app.ui.tool), (view, right, &tool));
assert!(h.app.ui.dialog.is_none());
assert!(!h.view.ctx.egui_wants_keyboard_input());
if next_frame {
assert!(h.app.widgets.iter().all(|(id, _)| id != "sliderField:light.exposure"), "the editor is gone");
assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderValue:light.exposure"));
}
}
}
}
/// 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>, 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<std::sync::atomic::AtomicUsize> {
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 key of Open Library. Visualize HDR has none by default: ⇧H cycles the Tool Overlay
/// (issue #224); the user can give Visualize HDR a key in the keymap.
#[test]
fn open_library_and_visualize_hdr_keys_are_declared() {
use crate::shortcuts::{Keymap, parse, shortcut_of};
let keymap = Keymap::new();
assert_eq!(shortcut_of(&keymap, "app.openLibrary"), Some("Cmd+O"));
assert!(parse("Cmd+O").is_some());
assert_eq!(shortcut_of(&keymap, "view.visualizeHdr"), None);
}
/// 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);
}
/// 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");
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);
}
/// The crop aspect lock is a first-class row of Help - Keyboard Shortcuts: A, listed in the
/// editor like any other command, sharing the key with Visualize Spots as a contextual pair.
#[test]
fn lock_aspect_ratio_is_a_listed_row_on_a() {
use crate::shortcuts::{CONTEXTUAL, Keymap, bindable, shortcut_of};
let b = bindable().iter().find(|b| b.id == "crop.lockAspect").expect("crop.lockAspect is mappable");
assert_eq!(b.label, "Lock Aspect Ratio");
assert_eq!(b.default, Some("A"));
assert_eq!(shortcut_of(&Keymap::new(), "crop.lockAspect"), Some("A"));
assert!(CONTEXTUAL.contains(&("view.visualizeSpots", "crop.lockAspect")), "A is disambiguated by context");
// the editor lists it: the row is on screen and records keys for it
let mut h = demo();
record(&mut h, "lock", "crop.lockAspect");
key(&mut h, "Escape", false);
assert_eq!(shortcut_of(&h.app.ui.settings.keymap, "crop.lockAspect"), Some("A"), "Esc cancels the recording: A stays");
key(&mut h, "Escape", false);
assert!(h.app.ui.dialog.is_none());
}
/// A is the aspect lock while the Crop panel is open — Visualize Spots, the other A, must not take
/// the panel or turn its view on — and outside the panel A opens Visualize Spots as usual. The lock
/// is the tool's own (a fresh photo starts locked to its shape and the toggle may only record
/// "free", leaving the raw develop aspect untouched), so the test reads `crop_tool_aspect`.
#[test]
fn a_is_the_aspect_lock_under_crop_and_visualize_spots_elsewhere() {
use crate::state::RightPanel;
let mut h = demo();
let aspect = |h: &Headless| h.app.crop_tool_aspect();
let spots_before = h.app.ui.visualize_spots;
h.app.run("panel.crop", json!({})).unwrap();
h.step();
h.step();
assert_eq!(h.app.ui.right, RightPanel::Crop);
let before = aspect(&h);
key(&mut h, "A", false);
assert_eq!(h.app.ui.right, RightPanel::Crop, "Visualize Spots must not take the panel");
assert_eq!(h.app.ui.visualize_spots, spots_before, "nor turn its view on");
assert_ne!(aspect(&h), before, "A toggles the aspect lock");
key(&mut h, "A", false);
assert_eq!(aspect(&h), before, "A toggles it back");
// outside the panel the same key is Visualize Spots again
h.app.ui.right = RightPanel::None;
key(&mut h, "A", false);
assert_eq!(h.app.ui.right, RightPanel::Remove, "A opens Remove with the visualization");
assert!(h.app.ui.visualize_spots, "Visualize Spots is on");
assert_eq!(aspect(&h), before, "and the aspect is untouched");
}