LR-BEHAV-TEXTFIELD: Return confirms the one-field dialogs

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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 13:28:19 -04:00
co-authored by Claude Opus 5.5
parent 9f001c5a53
commit e92095a57a
4 changed files with 67 additions and 28 deletions
+44
View File
@@ -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).
+17 -24
View File
@@ -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::<Vec<_>>().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<String> = 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<V: PartialEq + Copy>(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)
}
+1 -1
View File
@@ -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) |
+5 -3
View File
@@ -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.