diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b2517907..458eae4ab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,8 @@ mod tests_dialog_fit; #[cfg(test)] mod tests_dialog_primary; #[cfg(test)] +mod tests_dialog_tab; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] mod tests_edit_layout; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 894ad2bd5..c24f6da7c 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -2244,13 +2244,15 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) - PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } } -/// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens -/// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc -/// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the -/// focus Return gives up, and Return did nothing. +/// A dialog's one text field: it takes the focus when the dialog opens, and opens with its text +/// selected, so typing replaces it. It takes the focus back only while nothing else has it: taking +/// it back every frame would keep Tab from ever reaching the dialog's buttons and check boxes. +/// True when Return confirms the dialog; Esc cancels the dialog (`show`). fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); - if !r.editing { + // (and takes it back when nothing has it: after a click on the dialog's background or a check box, + // or when Return gave it up on a refusal; a control Tab reached keeps it) + if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) { r.response.request_focus(); } r.ending == Some(crate::text_field::Ending::Return) diff --git a/crates/ui-egui/src/tests_dialog_tab.rs b/crates/ui-egui/src/tests_dialog_tab.rs new file mode 100644 index 000000000..5ffb99580 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_tab.rs @@ -0,0 +1,153 @@ +//! A dialog's name field takes the keyboard focus once, when the dialog opens: Tab then carries it +//! on to the dialog's other controls (a check box, Cancel, OK) and Shift+Tab back, instead of the +//! field taking it back every frame. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo_with_selection() -> Headless { + let mut session = lightcraft_engine::Session::with_demo(); + session.selection.ids = session.catalog.photos().take(2).map(|p| p.id).collect(); + let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +/// The registered widget (`field:…`, `check:…`, `button:…`) that has the keyboard focus. +fn focused(h: &Headless) -> Option { + let id = h.view.ctx.memory(|m| m.focused())?; + let rect = h.view.ctx.read_response(id)?.rect; + h.app.widgets.iter().find(|(_, r)| *r == rect).map(|(w, _)| w.clone()) +} + +fn key(h: &mut Headless, params: serde_json::Value) { + let r = h.request("ui.key", params, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Open `dialog`, then Tab through it; the focus order, starting with the field it opens on. +fn tab_order(dialog: Dialog) -> Vec { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(dialog); + for _ in 0..4 { + h.step(); + } + let mut order = vec![focused(&h).unwrap_or_default()]; + for _ in 0..4 { + key(&mut h, json!({"key": "Tab"})); + order.push(focused(&h).unwrap_or_default()); + } + order +} + +#[test] +fn new_album_opens_on_its_name_and_tab_reaches_the_check_box_cancel_and_ok() { + let order = tab_order(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + assert_eq!(order[..4], ["field:albumName", "check:albumIncludeSelected", "button:dialogCancel", "button:dialogOk"], "{order:?}"); +} + +#[test] +fn new_smart_album_opens_on_its_name_and_tab_reaches_cancel_and_ok() { + let order = tab_order(Dialog::NewSmartAlbum { name: "Rejected".into(), parent: None }); + let (cancel, ok) = (order.iter().position(|w| w == "button:dialogCancel"), order.iter().position(|w| w == "button:dialogOk")); + assert_eq!(order[0], "field:smartAlbumName", "{order:?}"); + assert!(cancel.is_some() && ok.is_some() && cancel < ok, "Cancel then OK are reached by Tab: {order:?}"); +} + +#[test] +fn save_metadata_preset_opens_on_its_name_and_tab_reaches_cancel_and_ok() { + let prompt = Dialog::TextPrompt { + title: "Save Metadata Preset".into(), + hint: "Preset name".into(), + value: String::new(), + command: "metadata.savePreset".into(), + params: json!({}), + key: "name".into(), + }; + let order = tab_order(prompt); + assert_eq!(order[..3], ["field:prompt", "button:dialogCancel", "button:dialogOk"], "{order:?}"); +} + +#[test] +fn shift_tab_goes_back_from_ok_to_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + for _ in 0..3 { + key(&mut h, json!({"key": "Tab"})); + } + assert_eq!(focused(&h).as_deref(), Some("button:dialogOk")); + for _ in 0..3 { + key(&mut h, json!({"key": "Tab", "shift": true})); + } + assert_eq!(focused(&h).as_deref(), Some("field:albumName")); +} + +#[test] +fn the_name_still_takes_typing_and_enter_after_opening() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.text", json!({"text": "Holiday"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + key(&mut h, json!({"key": "Enter"})); + h.settle(T); + assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog"); + assert!(h.app.session.catalog.albums().any(|a| a.name == "Holiday"), "the typed name made the album"); +} + +#[test] +fn escape_still_cancels_after_tabbing_away_from_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + key(&mut h, json!({"key": "Tab"})); + key(&mut h, json!({"key": "Escape"})); + assert!(h.app.ui.dialog.is_none(), "Esc closed the dialog"); +} + +#[test] +fn a_click_on_a_check_box_leaves_return_confirming_the_name() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: true }); + for _ in 0..4 { + h.step(); + } + let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + key(&mut h, json!({"key": "Enter"})); + h.settle(T); + assert!(h.app.ui.dialog.is_none(), "Enter confirmed the dialog after the click"); + assert!(h.app.session.catalog.albums().any(|a| a.name == "Trip")); +} + +#[test] +fn a_refused_return_leaves_the_name_focused_for_the_correction() { + let mut h = demo_with_selection(); + h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + key(&mut h, json!({"key": "Enter"})); + h.step(); + assert!(h.app.ui.dialog.is_some(), "an empty name is refused and the dialog stays"); + assert_eq!(focused(&h).as_deref(), Some("field:albumName"), "the field has the focus again"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 21140ae26..d162a5ab4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LR-BEHAV-ACCESS: the New Album, New Smart Album and name-prompt dialogs give their name field the focus once, so Tab reaches their check box, Cancel and OK; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged | | 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) | | 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) | +| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | diff --git a/docs/whats-new.md b/docs/whats-new.md index cffff83e2..0404e3fc1 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-23 + +### Tab leaves the name field in New Album, New Smart Album and the name prompts +- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the + keyboard focus when the dialog opens, and Tab now moves on to the check box, Cancel and OK (Shift+Tab goes back). + Before, the field took the focus back every frame, so only Return and Esc got you out of it. Return still + confirms and Esc still cancels. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges