From e92095a57a2b31090d70c35704e36c90aa2b1f84 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 13:28:19 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: Return confirms the one-field dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Album, Rename Album, New Smart Album, Merge Keywords and the text prompts (rename a preset or a version, …) had Rename Keyword's bug: their field took the focus back on every frame, so egui never reported the focus Return gives up, and Return did nothing; only OK confirmed. All six now use one `dialog_field`: a shared text field that keeps the focus except while its own menu has it, opens with its text selected and reports Return. They get the Cut / Copy / Paste / Select All menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 44 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 41 +++++++++++--------------- docs/parity.md | 2 +- docs/whats-new.md | 8 +++-- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f324cb48f..49c1e48d7 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1600,6 +1600,50 @@ mod tests { assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); } + /// Return confirms the one-field dialogs: New Album, Rename Album, New Smart Album, Merge + /// Keywords and a text prompt (here: renaming an album). Their name opens selected, so typing + /// replaces it. Return did nothing: the field took the focus back on every frame, so egui never + /// reported the focus Return gives up. + #[test] + fn return_confirms_the_one_field_dialogs() { + use crate::state::Dialog; + let mut h = demo([1200.0, 800.0]); + let t = Duration::from_secs(10); + let first = h.app.session.visible_cloned()[0].0; + h.request("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [first], "addKeywords": ["holiday"]}}), t); + let album = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).map(|a| a.id.0); + let garden = album(&h, "Garden").expect("the demo's Garden album"); + let confirm = |h: &mut Headless, dialog: Dialog, typed: &str| { + h.app.ui.dialog = Some(dialog); + h.settle(SETTLE); + for (method, params) in [("ui.text", json!({"text": typed})), ("ui.key", json!({"key": "Enter"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); + }; + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None }, "Weddings"); + assert!(album(&h, "Weddings").is_some(), "New Album"); + confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); + assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); + confirm(&mut h, Dialog::NewSmartAlbum { name: "Smart Album".into(), parent: None }, "Everything"); + assert!(album(&h, "Everything").is_some(), "New Smart Album"); + confirm(&mut h, Dialog::MergeKeywords { from: vec!["holiday".into()], into: String::new() }, "travel"); + let keywords = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + assert!(keywords.contains(&"travel".to_string()) && !keywords.contains(&"holiday".to_string()), "Merge Keywords: {keywords:?}"); + let prompt = Dialog::TextPrompt { + title: "Rename Album".into(), + hint: "Name".into(), + value: "Flowers".into(), + command: "album.rename".into(), + params: json!({"id": garden}), + key: "name".into(), + }; + confirm(&mut h, prompt, "Botanical"); + assert_eq!(album(&h, "Botanical"), Some(garden), "the text prompt"); + } + /// The sidebar's file-system checks run on worker threads and add rows when they land, which /// moves every row below them: `busy()` counts them, so `settle` waits for them before a test /// reads widget positions (a click aimed at a stale rect hits the neighbouring row). diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fb119f6c6..2473996ac 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -260,9 +260,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::NewSmartAlbum { name, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); - r.request_focus(); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if dialog_field(ui, "field:smartAlbumName", name, "Name") { confirm = true; } let rules = app.session.view_rules(); @@ -410,17 +408,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::RenameKeyword { from, to } => { let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); - // the name, selected so typing replaces it; Esc cancels the dialog (below) - let r = crate::text_field::TextField::singleline("field:keywordName", to) - .hint(crate::i18n::tr("New name")) - .width(f32::INFINITY) - .select_on_focus(true) - .show(ui); - // the field keeps the focus, but leaves it to its own menu - if !r.editing { - r.response.request_focus(); - } - if r.ending == Some(crate::text_field::Ending::Return) { + if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } ui.label( @@ -431,10 +419,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::MergeKeywords { from, into } => { ui.label(egui::RichText::new(crate::i18n::tr_format!("Replace {} with:", from.iter().map(|f| format!("“{f}”")).collect::>().join(", "))).color(t.text_label)); - let r = ui.add(egui::TextEdit::singleline(into).hint_text(crate::i18n::tr("Keyword")).desired_width(f32::INFINITY)); - crate::widgets::register(ui.ctx(), "field:keywordInto", r.rect); - r.request_focus(); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if dialog_field(ui, "field:keywordInto", into, "Keyword") { confirm = true; } let options: Vec = app @@ -453,16 +438,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } Dialog::TextPrompt { value, hint, .. } => { - let r = ui.add(egui::TextEdit::singleline(value).hint_text(crate::i18n::tr(hint.as_str())).desired_width(f32::INFINITY)); - r.request_focus(); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if dialog_field(ui, "field:prompt", value, hint) { confirm = true; } } Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { - let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); - r.request_focus(); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if dialog_field(ui, "field:albumName", name, "Name") { confirm = true; } } @@ -1302,3 +1283,15 @@ fn choices(ui: &mut egui::Ui, label: &str, id: &str, option } }); } + +/// 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. +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 { + r.response.request_focus(); + } + r.ending == Some(crate::text_field::Ending::Return) +} diff --git a/docs/parity.md b/docs/parity.md index 8dff3b7d6..8b879922e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -525,7 +525,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | -| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), Rename Keyword and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | +| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | diff --git a/docs/whats-new.md b/docs/whats-new.md index d466e5afc..38a48e9cb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -3,9 +3,11 @@ ## October 2026 ### Text fields -- Right-click Search Photos, a slider's typed value, the Info panel's fields, Rename Keyword or the name field under - unnamed faces for Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in - the Info panel it no longer saves it). Return renames a keyword. More fields will follow. +- Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename + Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for + Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it + no longer saves it). Return in those dialogs now confirms them, and their name opens selected. More fields will + follow. - On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit Settings.