From d1ec84386376936fb6bddb72e13bf6a41b8955d2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 13:19:40 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: Rename Keyword's name field is a shared text field It gets the Cut / Copy / Paste / Select All menu and opens with the name selected, so typing replaces it. Return now renames: the field took the focus back on every frame, so egui never reported the focus Return gives up and only the OK button renamed. The field still keeps the focus, except while its own menu has it; Esc still cancels the dialog. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 40 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 15 ++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 7b0659c09..f324cb48f 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1560,6 +1560,46 @@ mod tests { h.settle(SETTLE); } + /// Rename Keyword's name field is a shared text field. It opens with the name selected, so + /// typing replaces it, and Return renames; right-click ▸ Paste works too; Esc cancels the + /// dialog and renames nothing. + #[test] + fn the_rename_keyword_field_selects_pastes_and_cancels() { + 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": ["travel"]}}), t); + let keywords = |h: &Headless| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(first)).unwrap().meta.keywords.clone(); + let open = |h: &mut Headless, name: &str| { + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: name.into(), to: name.into() }); + h.settle(SETTLE); + }; + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params.clone(), t); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); + }; + // typing replaces the selected name + open(&mut h, "travel"); + ask(&mut h, "ui.text", json!({"text": "trips"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"trips".to_string()) && !keywords(&h).contains(&"travel".to_string()), "{:?}", keywords(&h)); + assert_eq!(h.app.ui.dialog, None); + // the menu's Paste + open(&mut h, "trips"); + h.view.clipboard = "weddings".into(); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordName:paste"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(keywords(&h).contains(&"weddings".to_string()), "{:?}", keywords(&h)); + // Esc cancels + open(&mut h, "weddings"); + ask(&mut h, "ui.text", json!({"text": "parties"})); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + assert_eq!(h.app.ui.dialog, None, "Esc closed the dialog"); + assert!(keywords(&h).contains(&"weddings".to_string()), "renamed nothing: {:?}", keywords(&h)); + } + /// 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 fa2e6ad9e..fb119f6c6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -410,10 +410,17 @@ 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(); - let r = ui.add(egui::TextEdit::singleline(to).hint_text(crate::i18n::tr("New name")).desired_width(f32::INFINITY)); - crate::widgets::register(ui.ctx(), "field:keywordName", r.rect); - r.request_focus(); - if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // 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) { confirm = true; } ui.label(