Browse / github.com/storytold/lightcraft / patch
LR-BEHAV-TEXTFIELD: an edit in progress is saved however its field is left, except Esc
LR-BEHAV-TEXTFIELD: an edit in progress is saved however its field is left, except Esc Off `main`. Supersedes #3078, #3084 and #3095, and the tab-click half of #2691. @THEtheChad diagnosed this on #3084: those three PRs, and #2691's tab half, are one bug reached by different routes, and a fix at the shared widget covers them all. This PR is that fix. ## Problem A text box that saves when its edit ends (Copyright and Creator in Settings, the slideshow's title text, the Info panel's fields…) reported the end of the edit only from inside its own `show()`. egui reports a lost focus only to a widget that is drawn. So any route that stopped drawing the box in the middle of an edit
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nledford (imported from pull request #3156, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +1170 −135 in 21 files
- Review
- pending
- Shared
- 2026-10-28
- Id
74d79094c08140a508d8a421
How it went
No other operator has reported using it yet.
Diff
raw · apps/lightcraft/src/main.rs crates/engine/src/cmd/library/specs.rs crates/engine/src/tests.rs crates/ui-egui/src/app.rs crates/ui-egui/src/app/frame.rs crates/ui-egui/src/dialogs/settings/import.rs crates/ui-egui/src/dialogs/settings/libraries.rs crates/ui-egui/src/dialogs/settings/slide_overlays.rs crates/ui-egui/src/dialogs/settings/slide_templates.rs crates/ui-egui/src/dialogs/settings/slide_titles.rs crates/ui-egui/src/headless.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panels/mask_effects.rs crates/ui-egui/src/panels/right/info.rs crates/ui-egui/src/tests.rs crates/ui-egui/src/tests/slideshow.rs crates/ui-egui/src/tests/text_field_saves.rs crates/ui-egui/src/widgets/text_field.rs crates/ui-egui/src/widgets/text_field/tests.rs docs/parity-checklist.md docs/whats-new.md
@@ -83,7 +83,7 @@ impl eframe::App for App { #[cfg(target_os = "macos")] if let Some(m) = self.2.as_mut() && m.update(&mut self.0, ctx)- && lightcraft_ui_egui::panels::notices::may_close(&mut self.0)+ && self.0.may_close_after_saving_edits(ctx) { // Quit from the menu bar: save exactly what closing the window saves (settings and // library, after the unsaved-changes check; otherwise its prompt shows), then end the@@ -680,99 +680,113 @@ pub fn specs() -> Vec<CommandSpec> { Ok(json!({"deleted": t.len()})) }), // ---- metadata- cmd!(- "photo.setMeta",- "Edit Info",- [],- None,- "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",- has_selection,- |s, p| {- let strs =- |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());- let targets = s.targets(p)?;- // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear- let gps: Option<Option<(f64, f64)>> = match p.get("gps") {- None => None,- Some(Value::Null) => Some(None),- Some(Value::String(t)) if t.trim().is_empty() => Some(None),- Some(Value::String(t)) => Some(Some(- parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,- )),- Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {- (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),- _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),- },- Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),- };- let status = match str_param(p, "copyrightStatus") {- Some(t) => Some(- CopyrightStatus::parse(t)- .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,- ),- None => None,- };- let mut ops = Vec::new();- for id in targets {- let Some(ph) = s.catalog.photo(id) else { continue };- let mut m = ph.meta.clone();- if let Some(g) = gps {- m.gps = g;- }- if let Some(st) = status {- m.copyright_status = st;+ // `ids` names the photos without selecting them: a swept text edit (Info panel) is saved to+ // the photo it was typed for, whatever selection the change that hid the box left+ CommandSpec {+ explicit_targets: true,+ ..cmd!(+ "photo.setMeta",+ "Edit Info",+ [],+ None,+ "{ids?, title?, caption?, altText?, extendedDescription?, copyright?, copyrightStatus?: unknown|copyrighted|publicDomain, usageTerms?, copyrightUrl?, creator?, location?, city?, state?, country?, gps?: \"lat, lon\" | [lat, lon] | null, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",+ has_selection,+ |s, p| {+ let strs = |k: &str| {+ p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>())+ };+ let targets = s.targets(p)?;+ if targets.is_empty() {+ // (a call that names photos skipped the selection check; an empty list names none)+ has_selection(s).map_err(|why| crate::EngineError::Disabled("photo.setMeta".into(), why))?; }- for (k, field) in [- ("title", &mut m.title),- ("caption", &mut m.caption),- ("copyright", &mut m.copyright),- ("usageTerms", &mut m.usage_terms),- ("copyrightUrl", &mut m.copyright_url),- ("creator", &mut m.creator),- ("location", &mut m.location),- ("city", &mut m.city),- ("state", &mut m.state),- ("country", &mut m.country),- ("altText", &mut m.alt_text),- ("extendedDescription", &mut m.extended_description),- ] {- if let Some(v) = str_param(p, k) {- *field = v.to_string();+ // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear+ let gps: Option<Option<(f64, f64)>> = match p.get("gps") {+ None => None,+ Some(Value::Null) => Some(None),+ Some(Value::String(t)) if t.trim().is_empty() => Some(None),+ Some(Value::String(t)) => Some(Some(+ parse_gps(t).ok_or_else(|| bad("photo.setMeta", format!("can't read `{t}` as coordinates (e.g. 51.5072, -0.1276)")))?,+ )),+ Some(Value::Array(a)) if a.len() == 2 => match (a[0].as_f64(), a[1].as_f64()) {+ (Some(la), Some(lo)) if la.abs() <= 90.0 && lo.abs() <= 180.0 => Some(Some((la, lo))),+ _ => return Err(bad("photo.setMeta", "gps is [latitude, longitude] in degrees")),+ },+ Some(_) => return Err(bad("photo.setMeta", "gps is \"lat, lon\", [lat, lon] or null")),+ };+ let status = match str_param(p, "copyrightStatus") {+ Some(t) => Some(+ CopyrightStatus::parse(t)+ .ok_or_else(|| bad("photo.setMeta", format!("copyrightStatus `{t}`: unknown, copyrighted or publicDomain")))?,+ ),+ None => None,+ };+ let mut ops = Vec::new();+ for id in targets {+ let Some(ph) = s.catalog.photo(id) else { continue };+ let mut m = ph.meta.clone();+ if let Some(g) = gps {+ m.gps = g; }- }- // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case- use lightcraft_catalog::keywords::{clean, same};- let add = |m: &mut lightcraft_catalog::Meta, k: &str| {- let k = clean(k);- if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {- m.keywords.push(k);+ if let Some(st) = status {+ m.copyright_status = st; }- };- if let Some(k) = strs("keywords") {- m.keywords.clear();- for k in k {+ for (k, field) in [+ ("title", &mut m.title),+ ("caption", &mut m.caption),+ ("copyright", &mut m.copyright),+ ("usageTerms", &mut m.usage_terms),+ ("copyrightUrl", &mut m.copyright_url),+ ("creator", &mut m.creator),+ ("location", &mut m.location),+ ("city", &mut m.city),+ ("state", &mut m.state),+ ("country", &mut m.country),+ ("altText", &mut m.alt_text),+ ("extendedDescription", &mut m.extended_description),+ ] {+ if let Some(v) = str_param(p, k) {+ *field = v.to_string();+ }+ }+ // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case+ use lightcraft_catalog::keywords::{clean, same};+ let add = |m: &mut lightcraft_catalog::Meta, k: &str| {+ let k = clean(k);+ if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) {+ m.keywords.push(k);+ }+ };+ if let Some(k) = strs("keywords") {+ m.keywords.clear();+ for k in k {+ add(&mut m, &k);+ }+ }+ for k in strs("addKeywords").unwrap_or_default() { add(&mut m, &k); }+ for k in strs("removeKeywords").unwrap_or_default() {+ let k = clean(&k);+ m.keywords.retain(|x| !same(&clean(x), &k));+ }+ ops.push(Op::SetMeta { id, meta: Box::new(m) }); }- for k in strs("addKeywords").unwrap_or_default() {- add(&mut m, &k);+ let n = ops.len();+ // (named photos that are gone leave nothing to commit: no empty undo step)+ if n > 0 {+ s.commit("Edit Info", Op::Batch { ops })?; }- for k in strs("removeKeywords").unwrap_or_default() {- let k = clean(&k);- m.keywords.retain(|x| !same(&clean(x), &k));+ // keywords just added become the Recent Keywords set+ let added: Vec<String> =+ strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();+ if n > 0 && !added.is_empty() {+ crate::cmd::keywords::note_recent(s, &added); }- ops.push(Op::SetMeta { id, meta: Box::new(m) });+ Ok(json!({"changed": n})) }- let n = ops.len();- s.commit("Edit Info", Op::Batch { ops })?;- // keywords just added become the Recent Keywords set- let added: Vec<String> = strs("addKeywords").unwrap_or_default().into_iter().chain(strs("keywords").unwrap_or_default()).collect();- if n > 0 && !added.is_empty() {- crate::cmd::keywords::note_recent(s, &added);- }- Ok(json!({"changed": n}))- }- ),+ )+ }, // ---- albums cmd!( "album.create",@@ -374,6 +374,23 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +#[test]+fn editing_info_of_named_photos_needs_no_selection() {+ let mut s = demo();+ let first = s.visible_cloned()[0];+ // (what Select by leaves when nothing matches)+ s.selection = Selection::default();+ s.execute("photo.setMeta", &json!({"ids": [first], "caption": "Evening"})).unwrap();+ assert_eq!(s.catalog.photo(first).unwrap().meta.caption, "Evening");+ // photos that are gone change nothing and leave no empty undo step+ let undo = s.undo.len();+ assert_eq!(s.execute("photo.setMeta", &json!({"ids": [999_999], "caption": "Gone"})).unwrap()["changed"], 0);+ assert_eq!(s.undo.len(), undo);+ // without photos named it still acts on the selection, and there is none+ assert!(s.execute("photo.setMeta", &json!({"caption": "Nowhere"})).is_err());+ assert!(s.execute("photo.setMeta", &json!({"ids": [], "caption": "Nowhere"})).is_err());+}+ /// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight /// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new /// values when it changed, and a plain dab when there is nothing to connect to.@@ -39,6 +39,8 @@ impl LightcraftApp { quit_prompt: None, notice_input: Default::default(), quit_confirmed: false,+ egui_ctx: None,+ edit_saves_refused: (0, Vec::new()), control_rx: None, pending_screenshots: vec![], pending_gestures: vec![],@@ -542,6 +544,9 @@ impl LightcraftApp { /// `i.time`, events, keys or dropped files. pub fn logic(&mut self, ctx: &egui::Context) { i18n::set_language(self.ui.language);+ if self.egui_ctx.is_none() {+ self.egui_ctx = Some(ctx.clone());+ } // The fork's AccessKit root language and bidi runs follow the locale; presentation only. // Compare against the cached string: `Context::language()` allocates on every call, and // this runs on every UI pass and every hidden logic tick.@@ -43,7 +43,7 @@ impl LightcraftApp { pub(super) fn logic_background(&mut self, ctx: &egui::Context) { panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first- if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {+ if ctx.input(|i| i.viewport().close_requested()) && !self.may_close_after_saving_edits(ctx) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } let now = self.tick_now();@@ -267,6 +267,9 @@ impl LightcraftApp { return; } self.shown_library = Some(generation);+ // an edit still recorded names photos of the library that was left (their ids are local to+ // it): it is dropped, never run against this one+ drop(crate::text_field::take_all(ctx)); if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path);@@ -314,6 +317,20 @@ impl LightcraftApp { } self.widgets = std::mem::take(&mut self.next_widgets); self.widget_hits = std::mem::take(&mut self.next_widget_hits);+ // a text edit whose field was not drawn in this pass ended there: every way of leaving an+ // edit saves except Esc (an Esc that came with the pass gives it up)+ let left = crate::text_field::take_left_behind(ctx);+ if !ctx.input(|i| i.key_pressed(egui::Key::Escape))+ && let Err(e) = self.run_saves(ctx, left)+ {+ // (the box is gone with its text: all that is left is to say so)+ self.toast_error(ctx, e);+ }+ // (a refusal is remembered only while the text it refused is still there to be offered again)+ // (not in the pass of the refusal itself: a box drawn before it records again in the next)+ if self.edit_saves_refused.0 < ctx.cumulative_pass_nr() && !crate::text_field::any_unfinished(ctx) {+ self.edit_saves_refused.1.clear();+ } panels::notices::finish_ui_pass(self, ctx); panels::library_problem::finish_ui_pass(self, ctx); self.ui_passes = self.ui_passes.wrapping_add(1);@@ -327,6 +344,65 @@ impl LightcraftApp { } } + /// Save the text edits under way, whether their fields are drawn or not: the window is closing+ /// (or Quit was chosen), before the question about unsaved changes, which they may add to.+ /// A save the engine refuses is an `Err` with its message, and the caller stops: the fields+ /// still hold their text and record the saves again. Offered again unchanged, once the error+ /// was shown, that text is given up (`Ok`): the person asked to leave twice.+ pub fn save_unfinished_edits(&mut self, ctx: &egui::Context) -> Result<(), String> {+ let saves = crate::text_field::take_all(ctx);+ // (the same text offered again, after the error was shown: the person asked to leave twice;+ // text put right or typed since is a new edit and is saved)+ let refused = std::mem::take(&mut self.edit_saves_refused).1;+ if !saves.is_empty() && refused == saves {+ return Ok(());+ }+ let saved = self.run_saves(ctx, saves.clone());+ if saved.is_err() {+ self.edit_saves_refused = (ctx.cumulative_pass_nr(), saves);+ }+ saved+ }++ /// The one way to the question "may the app close now?", for the window's close button and+ /// the menu bar's Quit alike: the edits under way are saved first (they may be the change the+ /// unsaved-changes question is about), then [`panels::notices::may_close`] asks. A save the+ /// engine refuses keeps the app open with the error shown. `false`: stay open.+ pub fn may_close_after_saving_edits(&mut self, ctx: &egui::Context) -> bool {+ match self.save_unfinished_edits(ctx) {+ Ok(()) => panels::notices::may_close(self),+ Err(e) => {+ self.toast_error(ctx, e);+ false+ }+ }+ }++ /// [`Self::save_unfinished_edits`] where no frame is at hand: before a library closes (Open+ /// Library, New Library), whose photos the text was typed for.+ pub(crate) fn save_edits_before_library_closes(&mut self) -> Result<(), String> {+ match self.egui_ctx.clone() {+ Some(ctx) => self.save_unfinished_edits(&ctx),+ None => Ok(()),+ }+ }++ /// Run the saves fields declared ([`crate::text_field::Save`]), all of them; the first+ /// refusal's message is the `Err`.+ fn run_saves(&mut self, ctx: &egui::Context, saves: Vec<crate::text_field::Save>) -> Result<(), String> {+ if saves.is_empty() {+ return Ok(());+ }+ let mut refused = Ok(());+ for save in saves {+ if let Err(e) = self.run(save.command, save.params) {+ refused = refused.and(Err(e));+ }+ }+ ctx.request_repaint();+ refused+ }+ /// Presentation only: the window's panels, dialogs and overlays. pub(super) fn draw_window(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone();@@ -77,14 +77,21 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) [("copyright", "Copyright", "© 2026 Your Name", d.copyright.clone()), ("creator", "Creator", "Your Name", d.creator.clone())] { row(ui, t, label, |ui| {- let id = egui::Id::unique(("settingsMeta", key));+ let id = egui::Id::unique(("settingsMeta", key, app.session.library_generation())); let mut text: String = ui.data(|m| m.get_temp(id)).unwrap_or(value.clone());- let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(hint_text).desired_width(240.0));- register(ui, format!("field:settings.{key}"), r.rect);- if r.lost_focus() && text.trim() != value {- let _ = app.run("library.preferences", json!({"import": {key: text.trim()}}));+ let widget = format!("field:settings.{key}");+ let r = crate::text_field::TextField::singleline(&widget, &mut text)+ .hint(hint_text)+ .width(240.0)+ .saves_with(|typed| {+ let typed = typed.trim();+ (typed != value).then(|| crate::text_field::Save { command: "library.preferences", params: json!({"import": {key: typed}}) })+ })+ .show(ui);+ if let Some(save) = r.save {+ let _ = app.run(save.command, save.params); }- if r.has_focus() {+ if r.editing { ui.data_mut(|m| m.insert_temp(id, text)); } else { ui.data_mut(|m| m.remove::<String>(id));@@ -171,11 +178,19 @@ pub(super) fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) } }); row(ui, t, crate::i18n::tr("Album"), |ui| {- let id = egui::Id::unique("auto-album");+ let id = egui::Id::unique(("auto-album", app.session.library_generation())); let mut name = ui.data(|m| m.get_temp::<String>(id)).unwrap_or_else(|| d.auto_album.clone().unwrap_or_default());- let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(crate::i18n::tr("None")).desired_width(180.0));- if r.lost_focus() {- let _ = app.run("library.autoImport", json!({"album": name.trim()}));+ let current = d.auto_album.clone().unwrap_or_default();+ let r = crate::text_field::TextField::singleline("field:settingsAutoAlbum", &mut name)+ .hint(crate::i18n::tr("None"))+ .width(180.0)+ .saves_with(|typed| {+ let typed = typed.trim();+ (typed != current).then(|| crate::text_field::Save { command: "library.autoImport", params: json!({"album": typed}) })+ })+ .show(ui);+ if let Some(save) = r.save {+ let _ = app.run(save.command, save.params); } ui.data_mut(|m| m.insert_temp(id, name)); });@@ -67,6 +67,8 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> // the library being left goes in the list too (also when it was opened at launch) let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned());+ // an edit under way in a box (the Info panel's, Settings') is for this library's photos: saved first+ app.save_edits_before_library_closes()?; app.session.close_library().map_err(|e| e.to_string())?; let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None;@@ -165,6 +167,8 @@ pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> if dir.is_file() { return Err(format!("{}: is a file; a library is a folder", dir.display())); }+ // (before the folder is made: a refusal to save the text typed stops the switch, and leaves nothing behind)+ app.save_edits_before_library_closes()?; if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); }@@ -71,17 +71,21 @@ fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, lab /// A one-line text field (`field:{id}`), saved when the edit ends. fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) {- let mem = egui::Id::unique(("slideshow-overlay-field", id));+ let mem = egui::Id::unique(("slideshow-overlay-field", id, app.session.library_generation())); let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); let widget = format!("field:{id}");- let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui);+ let r = TextField::singleline(&widget, &mut s)+ .hint(crate::i18n::tr(hint))+ .width(ui.available_width().clamp(80.0, 260.0))+ .saves_with(|typed| (typed != value).then(|| crate::text_field::Save { command: "slideshow.overlays", params: wrap(typed) }))+ .show(ui); if r.editing { ui.data_mut(|d| d.insert_temp(mem, s.clone())); } else { ui.data_mut(|d| d.remove::<String>(mem)); }- if r.committed() && s != value {- set(app, wrap(&s));+ if let Some(save) = r.save {+ set(app, save.params); } } @@ -98,14 +98,18 @@ fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Te let r = TextField::singleline("field:slideshowTemplateRename", &mut text) .width(ui.available_width().clamp(80.0, 260.0)) .select_on_focus(true)+ .saves_with(|typed| {+ (typed.trim() != tpl.name)+ .then(|| crate::text_field::Save { command: "slideshow.templates.rename", params: json!({"id": id, "name": typed}) })+ }) .show(ui); if !r.editing && r.ending.is_none() { r.response.request_focus(); } if r.committed() { set_pending(&ctx, None);- if text.trim() != tpl.name {- act(app, &ctx, "slideshow.templates.rename", json!({"id": id, "name": text}), |_| String::new());+ if let Some(save) = r.save {+ act(app, &ctx, save.command, save.params, |_| String::new()); } } else if r.cancelled() { set_pending(&ctx, None);@@ -89,17 +89,24 @@ fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: & /// The screen's text field (`field:{id}`), saved when the edit ends. fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) {- let mem = egui::Id::unique(("slideshow-title-field", id));+ let mem = egui::Id::unique(("slideshow-title-field", id, app.session.library_generation())); let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); let widget = format!("field:{id}");- let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui);+ let r = TextField::singleline(&widget, &mut s)+ .hint(crate::i18n::tr("Title text"))+ .width(ui.available_width().clamp(80.0, 260.0))+ .saves_with(|typed| {+ (typed != value).then(|| crate::text_field::Save { command: "slideshow.titles", params: json!({ t.id(): { "text": typed } }) })+ })+ .show(ui); if r.editing { ui.data_mut(|d| d.insert_temp(mem, s.clone())); } else { ui.data_mut(|d| d.remove::<String>(mem)); }- if r.committed() && s != value {- set(app, ui.ctx(), t, "text", json!(s));+ if let Some(save) = r.save {+ let _ = app.run(save.command, save.params);+ set_preview_shows(ui.ctx(), Some(t)); } } @@ -269,6 +269,9 @@ pub struct Headless { /// create a prompt after the host input was already filtered for the previous frame. #[cfg(test)] after_raw_input_hook: Option<Box<dyn FnMut(&mut LightcraftApp)>>,+ /// Test-only: the next frame carries the window's close request ([`Self::request_close`]).+ #[cfg(test)]+ close_next: bool, } /// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it@@ -311,6 +314,8 @@ impl Headless { last_cursor: egui::CursorIcon::Default, #[cfg(test)] after_raw_input_hook: None,+ #[cfg(test)]+ close_next: false, } } @@ -322,6 +327,13 @@ impl Headless { self.after_raw_input_hook = Some(Box::new(hook)); } + /// The next frame carries the window's close request, as the close button (or the host's Quit)+ /// sends it.+ #[cfg(test)]+ pub(crate) fn request_close(&mut self) {+ self.close_next = true;+ }+ pub fn frames(&self) -> u64 { self.frames }@@ -352,6 +364,10 @@ impl Headless { self.app.set_simulated_clock(Some(self.time)); self.app.raw_input_hook(&self.view.ctx, &mut raw); #[cfg(test)]+ if std::mem::take(&mut self.close_next) {+ raw.viewports.entry(ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);+ }+ #[cfg(test)] if let Some(mut hook) = self.after_raw_input_hook.take() { hook(&mut self.app); }@@ -382,7 +398,9 @@ impl Headless { self.window_commands.push(c); } // recorded, not carried out: the simulated pointer moves only when a test moves it- ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c),+ ViewportCommand::CancelClose | ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => {+ self.window_commands.push(c)+ } _ => {} } }@@ -164,6 +164,14 @@ pub struct LightcraftApp { pub(crate) notice_input: panels::notices::PromptInput, /// Quit Anyway was chosen: the window may close with unsaved changes. pub quit_confirmed: bool,+ /// The window's egui context, as of the last frame: for work that runs outside a frame's draw+ /// but must reach the widgets' memory (saving a text edit under way before a library closes).+ egui_ctx: Option<egui::Context>,+ /// The saves the engine refused the last time the text edits under way were saved before+ /// leaving (close, Quit, another library): the same text offered again is given up.+ /// With the pass the refusal was in: a box drawn earlier in that same pass records its save+ /// again only in the next one.+ edit_saves_refused: (u64, Vec<crate::text_field::Save>), control_rx: Option<Receiver<ControlRequest>>, pending_screenshots: Vec<PendingShot>, /// Synthetic-input control requests waiting for a fresh layout and delivery (plan §4.5).@@ -502,7 +502,9 @@ pub fn manage_body( let q = search.trim().to_lowercase(); let effects = app.session.mask_effects.clone(); let mut toggle_star: Option<(String, bool)> = None;- let mut commit_rename: Option<(String, String)> = None;+ // a rename the edit ended with: the name it gives, and the save the field declared (none when+ // the name is empty or unchanged)+ let mut commit_rename: Option<(String, Option<crate::text_field::Save>)> = None; egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { for (group, ix) in fx::menu_order(&effects) { let list: Vec<&MaskEffect> = ix@@ -532,14 +534,23 @@ pub fn manage_body( toggle_star = Some((e.name.clone(), !e.favorite)); } if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) {- let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui);+ // (saved if the dialog goes away mid-edit too; the commit below does the rest)+ let r = crate::text_field::TextField::singleline("field:effectRename", text)+ .width(220.0)+ .select_on_focus(true)+ .saves_with(|typed| {+ let to = typed.trim();+ (!to.is_empty() && to != old.as_str())+ .then(|| crate::text_field::Save { command: "mask.renameEffect", params: json!({"name": old, "to": to}) })+ })+ .show(ui); if !r.editing && r.ending.is_none() { r.response.request_focus(); } if r.cancelled() { *rename = None; } else if r.committed() {- commit_rename = Some((old.clone(), text.trim().to_string()));+ commit_rename = Some((text.trim().to_string(), r.save)); } return; }@@ -583,10 +594,10 @@ pub fn manage_body( if let Some((name, on)) = toggle_star { let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on})); }- if let Some((old, to)) = commit_rename {+ if let Some((to, save)) = commit_rename { *rename = None;- if !to.is_empty() && to != old {- match app.run("mask.renameEffect", json!({"name": old, "to": to})) {+ if let Some(save) = save {+ match app.run(save.command, save.params) { Ok(_) => *selected = vec![to], Err(e) => app.toast_error(ui.ctx(), e), }@@ -138,9 +138,22 @@ pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let gps = if gps_mixed { String::new() } else { m.gps.map(|(la, lo)| format!("{la:.6}, {lo:.6}")).unwrap_or_default() }; let gid = egui::Id::unique("info-gps"); ui.label(egui::RichText::new("GPS").size(11.5).color(t.text_dim));- let mut edit: FieldEdit = ui.data(|d| d.get_temp(gid)).unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets));+ let library = app.session.library_generation();+ let mut edit = ui+ .data(|d| d.get_temp::<FieldEdit>(gid))+ .filter(|e| e.library == library)+ .unwrap_or_else(|| FieldEdit::new(&gps, gps_mixed, &targets, library)); let hint = crate::i18n::tr(if gps_mixed { MIXED } else { "latitude, longitude" });- let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text).hint(hint).label("GPS").width(f32::INFINITY).show(ui);+ let (ids, start, clears) = (edit.ids.clone(), edit.start.clone(), edit.clears());+ let r = crate::text_field::TextField::singleline("field:gps", &mut edit.text)+ .saves_with(|text| {+ let text = text.trim();+ (text != start || clears).then(|| crate::text_field::Save { command: "photo.setMeta", params: json!({"ids": ids, "gps": text}) })+ })+ .hint(hint)+ .label("GPS")+ .width(f32::INFINITY)+ .show(ui); edit.typed |= edit.text != edit.start; if r.editing { ui.data_mut(|d| d.insert_temp(gid, edit.clone()));@@ -148,10 +161,8 @@ pub(super) fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.data_mut(|d| d.remove::<FieldEdit>(gid)); } // Return or leaving saves; Esc gives the edit up- let text = edit.text.trim();- if r.committed()- && (text != edit.start || edit.clears())- && let Err(e) = app.run("photo.setMeta", json!({"ids": edit.ids, "gps": text}))+ if let Some(save) = r.save+ && let Err(e) = app.run(save.command, save.params) { app.toast(ui.ctx(), e); }@@ -309,12 +320,15 @@ struct FieldEdit { mixed: bool, /// Something was typed or deleted since. typed: bool,+ /// The library the edit started in: photo ids are local to it, so an edit from another library+ /// is not carried over (it would save to whatever photos have the same ids here).+ library: u64, } impl FieldEdit {- fn new(value: &str, mixed: bool, targets: &[PhotoId]) -> Self {+ fn new(value: &str, mixed: bool, targets: &[PhotoId], library: u64) -> Self { let ids = targets.iter().map(|id| id.0).collect();- FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false }+ FieldEdit { text: value.to_string(), start: value.to_string(), ids, mixed, typed: false, library } } /// A Mixed field that was typed in and left empty: it clears the field on every selected photo.@@ -443,7 +457,11 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::unique(("info-field", key)); let value = if mixed { "" } else { value };- let mut edit: FieldEdit = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| FieldEdit::new(value, mixed, targets));+ let library = app.session.library_generation();+ let mut edit =+ ui.data(|d| d.get_temp::<FieldEdit>(id)).filter(|e| e.library == library).unwrap_or_else(|| FieldEdit::new(value, mixed, targets, library));+ // (saved when the edit ends, however it ends but Esc; also if the field is not drawn again)+ let (ids, start, clears) = (edit.ids.clone(), edit.start.clone(), edit.clears()); let widget = format!("field:{key}"); let field = if lines > 1 { crate::text_field::TextField::multiline(&widget, &mut edit.text).rows(lines)@@ -451,7 +469,13 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str crate::text_field::TextField::singleline(&widget, &mut edit.text) }; let field = if mixed { field.hint(crate::i18n::tr(MIXED)) } else { field };- let r = field.label(crate::i18n::tr(label)).width(f32::INFINITY).show(ui);+ let r = field+ .saves_with(|text| {+ (text != start || clears).then(|| crate::text_field::Save { command: "photo.setMeta", params: json!({"ids": ids, key: text}) })+ })+ .label(crate::i18n::tr(label))+ .width(f32::INFINITY)+ .show(ui); edit.typed |= edit.text != edit.start; // the typed text outlives frames while it is edited (the photo's value is drawn otherwise) if r.editing {@@ -460,8 +484,8 @@ fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str ui.data_mut(|d| d.remove::<FieldEdit>(id)); } // Return (one line) or leaving saves; Esc gives the edit up- if r.committed() && (edit.text != edit.start || edit.clears()) {- let _ = app.run("photo.setMeta", json!({"ids": edit.ids, key: edit.text}));+ if let Some(save) = r.save {+ let _ = app.run(save.command, save.params); } ui.add_space(6.0); }@@ -75,6 +75,7 @@ mod switch_library; mod sync; mod tallies; mod temp_slider;+mod text_field_saves; mod titlebar; mod tool_overlay; mod unsaved;@@ -1013,3 +1013,75 @@ fn the_slide_preview_stays_in_view_while_the_templates_are_browsed() { assert!(preview.bottom() <= room.top() + 1.0, "{size:?}: above the page, not in it: {preview:?} {room:?}"); } }++/// Press the primary button at `at`, step a frame, release it, step a frame: a mouse's click, whose+/// press and release reach the app in separate frames (the control channel's `ui.click` sends both+/// in one).+fn press_and_release(h: &mut Headless, at: egui::Pos2) {+ let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };+ h.events.extend([egui::Event::PointerMoved(at), button(true)]);+ h.step();+ h.events.push(button(false));+ h.step();+ for _ in 0..3 {+ h.step();+ }+}++/// The Intro title's text field with `typed` typed into it, Settings open on Slideshow.+fn typing_in_the_intro_text(typed: &str) -> Headless {+ let mut h = grid();+ let first = h.app.session.visible_cloned()[0];+ run(&mut h, "library.select", json!({"ids": [first.0]}));+ run(&mut h, "slideshow.titles", json!({"intro": {"enabled": true, "content": "text"}, "ending": {"enabled": true, "content": "text"}}));+ run(&mut h, "view.slideshowOptions", json!({}));+ reveal(&mut h, "field:slideshowIntroText");+ let r = h.request("ui.clickWidget", json!({"id": "field:slideshowIntroText"}), T);+ assert_eq!(r["ok"], true, "{r}");+ let r = h.request("ui.text", json!({"text": typed}), T);+ assert_eq!(r["ok"], true, "{r}");+ for _ in 0..3 {+ h.step();+ }+ assert!(!h.app.session.slide_titles.intro.text.contains(typed), "the title is saved when the edit ends, not as it is typed");+ h+}++/// A click on another Settings tab (press and release in separate frames) keeps what was typed in a+/// title's text box: the page switched on the release, the frame in which egui takes the focus away+/// from the box, so the box was not drawn again to say its edit ended.+#[test]+fn a_click_on_another_settings_tab_keeps_the_text_typed_in_a_title_box() {+ let mut h = typing_in_the_intro_text("Rome");+ let tab = latest_widget_rect(&h, "button:settingsTab-general").expect("the General tab");+ press_and_release(&mut h, tab.center());+ assert!(latest_widget_rect(&h, "field:slideshowIntroText").is_none(), "the page is General's now");+ assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { ref tab }) if tab == "general"), "{:?}", h.app.ui.dialog);+ assert!(latest_widget_rect(&h, "check:settings.autoAdvance").is_some(), "General's page is drawn");+ assert!(h.app.session.slide_titles.intro.text.contains("Rome"), "the typed title was dropped: {:?}", h.app.session.slide_titles.intro.text);+ h.settle(SETTLE);+}++/// A click inside the box goes on editing: nothing is saved yet.+#[test]+fn a_click_in_the_title_box_being_edited_keeps_editing() {+ let mut h = typing_in_the_intro_text("Rome");+ let field = latest_widget_rect(&h, "field:slideshowIntroText").expect("the field");+ press_and_release(&mut h, field.center());+ assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:slideshowIntroText"))), "still being edited");+ assert!(!h.app.session.slide_titles.intro.text.contains("Rome"), "not saved before the edit ends");+ h.settle(SETTLE);+}++/// A click that only moves the focus to another box of the page saves the first box's edit, and the+/// second box is being edited.+#[test]+fn a_click_in_another_box_of_the_page_saves_the_first_and_edits_the_second() {+ let mut h = typing_in_the_intro_text("Rome");+ reveal(&mut h, "field:slideshowEndingText");+ let second = latest_widget_rect(&h, "field:slideshowEndingText").expect("the ending's field");+ press_and_release(&mut h, second.center());+ assert!(h.app.session.slide_titles.intro.text.contains("Rome"), "{:?}", h.app.session.slide_titles.intro.text);+ assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:slideshowEndingText"))), "the second box has the focus");+ h.settle(SETTLE);+}new file mode 100644@@ -0,0 +1,500 @@+//! An edit in a text box ends exactly once, and every way of leaving it except Esc saves, including+//! the box no longer being drawn: a tab clicked, a command that opens another tab or dialog, a panel+//! hidden by a click, the window closing or Quit.+//!+//! Before: a box reported the end of an edit only from inside its own `show()`, and egui reports a+//! lost focus only to a widget that is drawn, so any route that stopped drawing the box mid-edit+//! dropped the text.++use std::time::Duration;++use serde_json::{Value, json};++use crate::headless::Headless;+use crate::state::{Dialog, RightPanel};+use crate::{LightcraftApp, Services};++const T: Duration = Duration::from_secs(20);++fn demo() -> Headless {+ let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()), [1200.0, 900.0], 1.0);+ // let the first renders finish (a render still running at exit can crash in GPU teardown)+ h.settle(T);+ h+}++fn steps(h: &mut Headless) {+ for _ in 0..4 {+ h.step();+ }+}++/// The command `app.settings` for `tab`, as a menu item or the control channel runs it.+fn show(h: &mut Headless, tab: &str) {+ let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": tab}}), T);+ assert_eq!(r["ok"], true, "app.settings {tab}: {r}");+ h.settle(T);+ steps(h);+}++fn click(h: &mut Headless, id: &str, button: &str) {+ let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), T);+ assert_eq!(r["ok"], true, "click {id}: {r}");+ steps(h);+}++fn type_into(h: &mut Headless, id: &str, text: &str) {+ click(h, id, "left");+ let r = h.request("ui.text", json!({"text": text}), T);+ assert_eq!(r["ok"], true, "type into {id}: {r}");+ steps(h);+}++fn key(h: &mut Headless, key: egui::Key) {+ for pressed in [true, false] {+ h.events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE });+ }+ steps(h);+}++fn rect(h: &Headless, id: &str) -> egui::Rect {+ h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}"))+}++fn has(h: &Headless, id: &str) -> bool {+ h.app.widgets.iter().any(|(w, _)| w == id)+}++/// Press the primary button at `at`, step a frame, release it, step a frame: a mouse's click, whose+/// press and release reach the app in separate frames (the control channel's `ui.click` sends both+/// in one).+fn press_and_release(h: &mut Headless, at: egui::Pos2) {+ let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };+ h.events.extend([egui::Event::PointerMoved(at), button(true)]);+ h.step();+ h.events.push(button(false));+ h.step();+ steps(h);+}++fn settings_open(h: &Headless, tab: &str) -> bool {+ h.app.ui.dialog == Some(Dialog::Settings { tab: tab.into() })+}++fn says(h: &Headless, part: &str) -> bool {+ h.painted_text().iter().any(|text| text.contains(part))+}++/// What Settings ▸ Import holds for `key` (`copyright`, `creator`).+fn import(h: &mut Headless, key: &str) -> Value {+ h.app.run("library.preferences", json!({})).unwrap()["import"][key].clone()+}++/// Given Settings open on Import with `typed` typed into Copyright and not yet ended.+fn typing_in_copyright(typed: &str) -> Headless {+ let mut h = demo();+ show(&mut h, "import");+ type_into(&mut h, "field:settings.copyright", typed);+ assert!(says(&h, typed), "the typing shows in the box");+ assert_eq!(import(&mut h, "copyright"), json!(""), "nothing is saved while the edit is under way");+ h+}++/// A click on another Settings tab (press and release in separate frames, with no box menu open)+/// saves what was typed, and the clicked tab's page is the one drawn.+#[test]+fn a_click_on_another_settings_tab_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ let tab = rect(&h, "button:settingsTab-general");+ press_and_release(&mut h, tab.center());+ assert!(settings_open(&h, "general"), "{:?}", h.app.ui.dialog);+ assert!(!has(&h, "field:settings.copyright"), "General's page is drawn, not Import's");+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text typed in Copyright was dropped when the tab was clicked");+}++/// The menu-open case: the box's own menu is open when the tab is pressed and released.+#[test]+fn a_click_on_a_tab_with_the_boxs_menu_open_saves_the_text() {+ let mut h = typing_in_copyright("Example Name");+ click(&mut h, "field:settings.copyright", "right");+ assert!(egui::Popup::is_any_open(&h.view.ctx), "the box's menu is open");+ let tab = rect(&h, "button:settingsTab-general");+ press_and_release(&mut h, tab.center());+ assert!(settings_open(&h, "general"), "{:?}", h.app.ui.dialog);+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text was dropped: the menu was open when the tab was clicked");+}++/// A command that opens another tab (a menu item, a shortcut, `app.settings` on the control+/// channel) is applied before the frame is drawn: the page with the box is never drawn again.+#[test]+fn a_command_that_opens_another_settings_tab_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ show(&mut h, "general");+ assert!(settings_open(&h, "general"), "{:?}", h.app.ui.dialog);+ assert!(!says(&h, "Copyright") && says(&h, "Language"), "General's page is drawn: {:?}", h.painted_text());+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text typed in Copyright was dropped when a command opened another tab");+}++#[test]+fn creator_is_saved_when_a_command_opens_another_tab() {+ let mut h = demo();+ show(&mut h, "import");+ type_into(&mut h, "field:settings.creator", "Example Creator");+ show(&mut h, "faces");+ assert!(settings_open(&h, "faces"), "{:?}", h.app.ui.dialog);+ assert_eq!(import(&mut h, "creator"), json!("Example Creator"), "the text typed in Creator was dropped");+}++#[test]+fn slideshow_options_chosen_from_a_menu_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ let r = h.request("engine.execute", json!({"command": "view.slideshowOptions", "params": {}}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.settle(T);+ steps(&mut h);+ assert!(settings_open(&h, "slideshow"), "{:?}", h.app.ui.dialog);+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text typed in Copyright was dropped");+}++/// The gap in a fix that listed what a box with the focus takes in: the box's own menu is open, so+/// the focus is on the menu, when the command arrives.+#[test]+fn a_command_with_the_boxs_menu_open_saves_the_text() {+ let mut h = typing_in_copyright("Example Name");+ click(&mut h, "field:settings.copyright", "right");+ assert!(egui::Popup::is_any_open(&h.view.ctx), "the box's menu is open");+ show(&mut h, "general");+ assert!(settings_open(&h, "general"), "{:?}", h.app.ui.dialog);+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text was dropped: the menu was open when the command came");+}++/// A command for the tab that is open ends no edit.+#[test]+fn a_command_for_the_tab_already_open_ends_no_edit() {+ let mut h = typing_in_copyright("Example Name");+ show(&mut h, "import");+ assert!(says(&h, "Example Name"), "the typing stays in the box");+ assert_eq!(import(&mut h, "copyright"), json!(""), "an unfinished edit is not saved");+}++/// A command that replaces the dialog (View ▸ Slideshow Options from Settings is one; so is any+/// dialog's command) takes the box away too.+#[test]+fn a_command_that_replaces_the_dialog_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ let r = h.request("engine.execute", json!({"command": "dialog.newAlbum", "params": {}}), T);+ steps(&mut h);+ assert!(+ h.app.ui.dialog.as_ref().is_some_and(|d| !matches!(d, Dialog::Settings { .. })),+ "another dialog replaced Settings: {r} {:?}",+ h.app.ui.dialog+ );+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text was dropped when the dialog was replaced");+}++/// Closing the window with an edit in progress saves it, before the question about unsaved changes.+#[test]+fn closing_the_window_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ h.request_close();+ h.step();+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text was dropped when the window was closed");+}++/// Quit (the host's ⌘Q, which asks `may_close` itself rather than closing the viewport) saves it too.+#[test]+fn quitting_saves_the_text_typed_in_a_box() {+ let mut h = typing_in_copyright("Example Name");+ let ctx = h.view.ctx.clone();+ assert!(h.app.may_close_after_saving_edits(&ctx), "nothing is left to ask about once the text is saved");+ assert_eq!(import(&mut h, "copyright"), json!("Example Name"), "the text was dropped when Quit was chosen");+}++/// The Info panel of a selected photo, its Title box typed in and not yet ended.+fn typing_in_title(typed: &str) -> (Headless, u64) {+ let mut h = demo();+ let first = h.app.session.visible_cloned()[0].0;+ let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first]}}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.app.ui.right = RightPanel::Info;+ h.settle(T);+ steps(&mut h);+ // (the demo photo has a title already: start from none, so typing gives exactly `typed`)+ h.app.run("photo.setMeta", json!({"ids": [first], "title": ""})).unwrap();+ h.settle(T);+ steps(&mut h);+ assert_eq!(title(&h, first), "", "no title yet");+ type_into(&mut h, "field:title", typed);+ assert_eq!(title(&h, first), "", "nothing is saved while the edit is under way");+ (h, first)+}++fn title(h: &Headless, photo: u64) -> String {+ h.app.session.catalog.photo(lightcraft_catalog::PhotoId(photo)).unwrap().meta.title.clone()+}++/// A click that hides the Info panel (its button in the tool strip, drawn before the panel) saves+/// the text typed in one of its boxes.+#[test]+fn hiding_the_info_panel_saves_the_text_typed_in_one_of_its_boxes() {+ let (mut h, photo) = typing_in_title("Rome");+ let button = rect(&h, "icon:info");+ press_and_release(&mut h, button.center());+ assert_eq!(h.app.ui.right, RightPanel::None, "the Info panel is hidden");+ assert!(!has(&h, "field:title"), "the box is not drawn");+ assert_eq!(title(&h, photo), "Rome", "the text typed in the Info panel was dropped when the panel was hidden");+}++/// A command that switches the view (here the control channel's, applied before layout) does.+#[test]+fn a_command_that_changes_the_view_saves_the_text_typed_in_an_info_box() {+ let (mut h, photo) = typing_in_title("Rome");+ let r = h.request("engine.execute", json!({"command": "panel.info", "params": {}}), T);+ assert_eq!(r["ok"], true, "{r}");+ steps(&mut h);+ assert!(!has(&h, "field:title"), "the box is not drawn");+ assert_eq!(title(&h, photo), "Rome", "the text typed in the Info panel was dropped by the command");+}++/// A change that leaves no active photo (Select by ▸ Rejects with none rejected, from the menu bar+/// or the control channel) hides the Info panel before the sweep saves: the save goes to the photo+/// the edit was for, whatever the selection the change left.+#[test]+fn a_change_that_leaves_no_active_photo_still_saves_the_text_typed_in_an_info_box() {+ let (mut h, photo) = typing_in_title("Rome");+ // (nothing rejected, so Select by ▸ Rejects selects nothing; the demo library rejects some)+ let all: Vec<u64> = h.app.session.visible_cloned().iter().map(|id| id.0).collect();+ h.app.run("photo.unflag", json!({"ids": all})).unwrap();+ steps(&mut h);+ assert!(has(&h, "field:title"), "the box is still drawn, its edit under way");+ let r = h.request("engine.execute", json!({"command": "library.selectBy", "params": {"flag": "reject"}}), T);+ assert_eq!(r["ok"], true, "{r}");+ steps(&mut h);+ assert!(h.app.session.selected().is_empty(), "the change left nothing selected and no active photo");+ assert!(!has(&h, "field:title"), "the Info panel is not drawn");+ assert_eq!(title(&h, photo), "Rome", "the text typed in the Info panel was lost: the save was refused with no photos selected");+}++#[test]+fn closing_the_window_saves_the_text_typed_in_an_info_box() {+ let (mut h, photo) = typing_in_title("Rome");+ h.request_close();+ h.step();+ assert_eq!(title(&h, photo), "Rome", "the text typed in the Info panel was dropped when the window was closed");+}++/// Esc still gives the edit up: the text is back and nothing is saved.+#[test]+fn esc_still_discards_the_edit() {+ let mut h = typing_in_copyright("Example Name");+ key(&mut h, egui::Key::Escape);+ assert_eq!(import(&mut h, "copyright"), json!(""), "Esc gave the edit up: nothing is saved");+ show(&mut h, "general");+ assert_eq!(import(&mut h, "copyright"), json!(""), "and the tab's end finds nothing to save");+}++/// Esc that arrives with the command that takes the box away is still an Esc.+#[test]+fn esc_with_the_command_that_hides_the_box_still_discards() {+ let (mut h, photo) = typing_in_title("Rome");+ for pressed in [true, false] {+ h.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE });+ }+ let r = h.request("engine.execute", json!({"command": "panel.info", "params": {}}), T);+ assert_eq!(r["ok"], true, "{r}");+ steps(&mut h);+ assert!(!has(&h, "field:title"), "the box is not drawn");+ assert_eq!(title(&h, photo), "", "Esc gave the edit up");+}++/// Return ends the edit and saves; a box that stays drawn saves once, however long it stays.+#[test]+fn a_box_that_stays_drawn_saves_exactly_once() {+ for ending in ["return", "click away"] {+ let (mut h, photo) = typing_in_title("Rome");+ let undo = h.app.session.undo.len();+ if ending == "return" {+ key(&mut h, egui::Key::Enter);+ } else {+ // (the empty room at the bottom of the Info panel: no control there)+ let at = egui::pos2(h.size.x - 20.0, h.size.y - 60.0);+ press_and_release(&mut h, at);+ }+ assert_eq!(title(&h, photo), "Rome", "{ending}: saved");+ for _ in 0..10 {+ h.step();+ }+ assert_eq!(h.app.session.undo.len(), undo + 1, "{ending}: one save, however many frames follow");+ h.request_close();+ h.step();+ assert_eq!(h.app.session.undo.len(), undo + 1, "{ending}: and the window closing finds nothing left to save");+ }+}++/// A dialog's own box, where Return means OK and Cancel must not save, doesn't opt in: Cancel+/// leaves nothing behind.+#[test]+fn cancel_does_not_save_the_text_typed_in_a_dialog_box() {+ let mut h = demo();+ h.app.ui.dialog = Some(Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: false });+ steps(&mut h);+ let albums = h.app.session.catalog.albums().count();+ type_into(&mut h, "field:albumName", "Never Made");+ click(&mut h, "button:dialogCancel", "left");+ assert!(h.app.ui.dialog.is_none(), "the dialog is closed");+ h.request_close();+ h.step();+ assert_eq!(h.app.session.catalog.albums().count(), albums, "Cancel made no album");+}++/// Another library opened (a menu item, the control channel) with an Info box mid-edit: the text+/// is saved to the library it was typed in, and photo ids being local to a library, never run+/// against the one that was opened.+#[test]+fn a_library_switch_mid_edit_saves_to_the_old_library_and_writes_nothing_to_the_new() {+ let root = std::env::temp_dir().join(format!("lc-tf-switch-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&root);+ let (a, b) = (root.join("a"), root.join("b"));+ // (a second library with photos of its own, whose ids are the first's)+ let mut other = lightcraft_engine::Session::new();+ other.open_library(&b, true).unwrap();+ other.close_library().unwrap();+ drop(other);+ let mut session = lightcraft_engine::Session::new();+ session.open_library(&a, true).unwrap();+ let mut h = Headless::new(LightcraftApp::new(session, Services::default()), [1200.0, 900.0], 1.0);+ h.settle(T);+ let first = h.app.session.visible_cloned()[0].0;+ let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first]}}), T);+ assert_eq!(r["ok"], true, "{r}");+ h.app.run("photo.setMeta", json!({"ids": [first], "title": ""})).unwrap();+ h.app.ui.right = RightPanel::Info;+ h.settle(T);+ steps(&mut h);+ type_into(&mut h, "field:title", "Rome");+ assert_eq!(title(&h, first), "", "nothing is saved while the edit is under way");+ h.app.run("app.openLibrary", json!({"path": b.to_string_lossy()})).unwrap();+ h.settle(T);+ steps(&mut h);+ h.request_close();+ h.step();+ let titles: Vec<String> =+ h.app.session.visible_cloned().iter().filter_map(|id| h.app.session.catalog.photo(*id)).map(|p| p.meta.title.clone()).collect();+ assert!(!titles.is_empty() && titles.iter().all(|t| t != "Rome"), "the text typed in the old library was written into the new one: {titles:?}");+ h.app.run("app.openLibrary", json!({"path": a.to_string_lossy()})).unwrap();+ h.settle(T);+ assert_eq!(title(&h, first), "Rome", "the text typed in the old library was dropped when it was closed");+ let _ = h.app.session.close_library();+ let _ = std::fs::remove_dir_all(&root);+}++/// An Info box with text the engine refuses (a place that is no coordinates), typed and not ended.+fn typing_refused_gps() -> Headless {+ let (mut h, _) = typing_in_title("Rome");+ // (tall enough that the GPS box is on screen without scrolling the panel)+ let r = h.request("ui.resize", json!({"width": 1200, "height": 2400}), T);+ assert_eq!(r["ok"], true, "{r}");+ steps(&mut h);+ type_into(&mut h, "field:gps", "not a place");+ h+}++/// A save the engine refuses (invalid GPS text) does not let the window close: the box keeps the+/// text and the error is shown. Closing again, after the error was shown, gives the text up.+#[test]+fn a_refused_save_stops_the_window_closing_and_a_second_close_discards() {+ let mut h = typing_refused_gps();+ h.request_close();+ h.step();+ assert!(h.window_commands.contains(&egui::ViewportCommand::CancelClose), "the close was not canceled: the refused text would be lost");+ assert!(h.app.ui.toast.is_some(), "the error is shown");+ assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:gps"))), "the box still holds the text");+ h.window_commands.clear();+ h.request_close();+ h.step();+ assert!(!h.window_commands.contains(&egui::ViewportCommand::CancelClose), "closing again after the error was shown gives the text up");+}++/// The shared close path (`may_close_after_saving_edits`, which the close button and the macOS Quit+/// both call; `main.rs` can't run headless): a refused save keeps the app open with the error+/// shown, and the second try gives the text up.+#[test]+fn a_refused_save_stops_quit_and_a_second_quit_discards() {+ let mut h = typing_refused_gps();+ let ctx = h.view.ctx.clone();+ assert!(!h.app.may_close_after_saving_edits(&ctx), "Quit went ahead with refused text typed");+ assert!(h.app.ui.toast.is_some(), "the error is shown");+ steps(&mut h);+ assert!(h.app.may_close_after_saving_edits(&ctx), "Quit again, after the error was shown, gives the text up");+}++/// The same for Open Library: it refuses, saying why, and the library stays; the second try goes ahead.+#[test]+fn a_refused_save_stops_a_library_switch_and_a_second_try_discards() {+ let mut h = typing_refused_gps();+ let dir = std::env::temp_dir().join(format!("lc-tf-refused-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ let generation = h.app.session.library_generation();+ let first = h.app.run("app.openLibrary", json!({"path": dir.to_string_lossy()}));+ assert!(first.is_err(), "the switch went ahead with refused text typed: {first:?}");+ assert_eq!(h.app.session.library_generation(), generation, "the library stays open");+ steps(&mut h);+ assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:gps"))), "the box still holds the text");+ let second = h.app.run("app.openLibrary", json!({"path": dir.to_string_lossy()}));+ assert!(second.is_ok(), "the second try gives the text up: {second:?}");+ assert_ne!(h.app.session.library_generation(), generation, "and the library was switched");+ let _ = h.app.session.close_library();+ let _ = std::fs::remove_dir_all(&dir);+}++/// Text put right after a refusal is a new edit: closing saves it and lets the window close in one step.+#[test]+fn text_put_right_after_a_refusal_is_saved_and_the_window_closes() {+ let mut h = typing_refused_gps();+ h.request_close();+ h.step();+ assert!(h.window_commands.contains(&egui::ViewportCommand::CancelClose), "refused: the window stays");+ h.window_commands.clear();+ for pressed in [true, false] {+ h.events.push(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND });+ }+ steps(&mut h);+ let r = h.request("ui.text", json!({"text": "46.0, 7.0"}), T);+ assert_eq!(r["ok"], true, "{r}");+ steps(&mut h);+ h.request_close();+ h.step();+ assert!(!h.window_commands.contains(&egui::ViewportCommand::CancelClose), "a save that succeeds lets the window close");+ let photo = h.app.session.visible_cloned()[0];+ assert!(h.app.session.catalog.photo(photo).unwrap().meta.gps.is_some(), "the corrected place was saved");+}++/// Open Library… from its folder dialog is answered in the middle of a pass, after the box was+/// drawn (`pick::poll` runs the command): the second try must still give the refused text up.+#[test]+fn a_refused_save_is_given_up_when_the_switch_comes_from_inside_a_pass() {+ let mut h = typing_refused_gps();+ let dir = std::env::temp_dir().join(format!("lc-tf-inpass-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ let senders = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));+ let kept = senders.clone();+ h.app.services.picker = Some(Box::new(move |_| {+ let (tx, rx) = std::sync::mpsc::channel();+ kept.lock().unwrap().push(tx);+ Ok(rx)+ }));+ let generation = h.app.session.library_generation();+ let choose = |h: &mut Headless| {+ h.app.run("app.openLibrary", json!({})).unwrap();+ let tx = senders.lock().unwrap().pop().expect("the folder dialog was asked");+ tx.send(vec![dir.to_string_lossy().into_owned()]).unwrap();+ steps(h);+ };+ choose(&mut h);+ assert_eq!(h.app.session.library_generation(), generation, "refused the first time: the library stays");+ assert!(h.view.ctx.memory(|m| m.has_focus(crate::text_field::id("field:gps"))), "the box still holds the text");+ choose(&mut h);+ assert_ne!(h.app.session.library_generation(), generation, "given up the second time: the library switched");+ let _ = h.app.session.close_library();+ let _ = std::fs::remove_dir_all(&dir);+}@@ -7,6 +7,12 @@ //! ended from [`TextFieldResponse::ending`]; what an ending means (save, apply, close) is the //! caller's. Each field's widget id is unique on screen: its state is keyed by it. //!+//! An edit ends exactly once, and every way of leaving it except Esc saves, including the field no+//! longer being drawn (egui reports a lost focus only to a widget that is drawn). A field that+//! saves when its edit ends declares the save with [`TextField::saves_with`]; the app runs what is+//! left recorded when a pass ends, and when the window closes (`LightcraftApp::finish_ui_pass`,+//! `LightcraftApp::save_unfinished_edits`).+//! //! New text boxes use it rather than `egui::TextEdit`. //! //! The keyboard's ⌘X / ⌘C / ⌘V / ⌘A and undo are egui's own, and the app's shortcuts step aside@@ -25,12 +31,23 @@ pub enum Ending { Escape, } +/// The command an edit saves when it ends, as a field declares it ([`TextField::saves_with`]).+#[derive(Clone, Debug, PartialEq)]+pub struct Save {+ pub command: &'static str,+ pub params: serde_json::Value,+}+ pub struct TextFieldResponse { pub response: Response, /// Set on the frame an edit ends. pub ending: Option<Ending>, /// An edit is under way: the field has the focus, or its menu does. pub editing: bool,+ /// What the field declared with [`TextField::saves_with`], for the frame the edit ended with+ /// the text kept (Return or leaving the field): run it, rather than deciding again from+ /// `committed()`. `None` on every other frame, and when the text needs no save.+ pub save: Option<Save>, } impl TextFieldResponse {@@ -55,6 +72,7 @@ pub struct TextField<'a> { width: Option<f32>, select_on_focus: bool, begin_edit: bool,+ saves: Option<SaveWith<'a>>, frame: bool, font: Option<egui::FontId>, color: Option<egui::Color32>,@@ -74,6 +92,59 @@ pub(crate) fn was_editing(ctx: &egui::Context, widget: &str) -> bool { ctx.data(|d| d.get_temp::<Memo>(id(widget).with("memo")).is_some_and(|memo| memo.before.is_some() && memo.pass.checked_add(1) == Some(pass))) } +/// A field's declaration of its save ([`TextField::saves_with`]).+type SaveWith<'a> = Box<dyn FnOnce(&str) -> Option<Save> + 'a>;++/// The saves of edits under way, in the order they were recorded: the field's id, the pass it+/// was last drawn in, and what it saves.+#[derive(Clone, Default)]+struct Unfinished(Vec<(egui::Id, u64, Save)>);++fn unfinished_id() -> egui::Id {+ egui::Id::unique("lc-text-field-unfinished")+}++/// Record (or, with `None`, forget) the save of the field `id` drawn in `pass`. Only an entry from+/// the pass just before is forgotten: an older one means the field was not drawn since (a pass+/// egui discarded, a panel switched away), so it is for the sweep to run, not for the field to drop.+fn record(ctx: &egui::Context, id: egui::Id, pass: u64, save: Option<Save>) {+ ctx.data_mut(|d| {+ let list = &mut d.get_temp_mut_or_default::<Unfinished>(unfinished_id()).0;+ match (list.iter().position(|(other, ..)| *other == id), save) {+ (Some(at), Some(save)) => list[at] = (id, pass, save),+ (None, Some(save)) => list.push((id, pass, save)),+ (Some(at), None) => {+ if list[at].1.checked_add(1) == Some(pass) {+ list.remove(at);+ }+ }+ (None, None) => {}+ }+ });+}++/// Take the saves of the edits whose field was not drawn in the pass that is ending: each went+/// away mid-edit, and what is taken is no longer recorded.+pub(crate) fn take_left_behind(ctx: &egui::Context) -> Vec<Save> {+ let pass = ctx.cumulative_pass_nr();+ ctx.data_mut(|d| {+ let list = &mut d.get_temp_mut_or_default::<Unfinished>(unfinished_id()).0;+ let (gone, kept): (Vec<_>, Vec<_>) = std::mem::take(list).into_iter().partition(|(_, at, _)| *at != pass);+ *list = kept;+ gone.into_iter().map(|(.., save)| save).collect()+ })+}++/// Whether any edit with a declared save is under way (or was left behind and not swept yet).+pub(crate) fn any_unfinished(ctx: &egui::Context) -> bool {+ ctx.data(|d| d.get_temp::<Unfinished>(unfinished_id()).is_some_and(|u| !u.0.is_empty()))+}++/// Take every save of an edit under way, drawn or not (the window is closing).+pub(crate) fn take_all(ctx: &egui::Context) -> Vec<Save> {+ ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::<Unfinished>(unfinished_id()).0).into_iter().map(|(.., save)| save).collect())+}+ impl<'a> TextField<'a> { /// A one-line field (Return ends the edit) registered as `widget`. pub fn singleline(widget: &'a str, text: &'a mut String) -> Self {@@ -86,6 +157,7 @@ impl<'a> TextField<'a> { width: None, select_on_focus: false, begin_edit: false,+ saves: None, frame: true, font: None, color: None,@@ -100,6 +172,19 @@ impl<'a> TextField<'a> { TextField { multiline: true, ..Self::singleline(widget, text) } } + /// Declare what the field saves when its edit ends, from the text then: `None` when there is+ /// nothing to save (unchanged, or the text is not meant). The rule: an edit ends exactly once,+ /// and every way of leaving it except Esc saves, including the field no longer being drawn+ /// (a tab, panel or dialog that replaces it mid-edit, a command, Quit). While the edit is+ /// under way the save is recorded; the frame the edit ends in the field shows it as+ /// [`TextFieldResponse::save`] (and forgets it), and one that is still recorded when the field+ /// was not drawn is run by [`take_left_behind`] / [`take_all`]. Leave it off where Return+ /// means OK and Cancel must not save (a dialog's own fields).+ pub fn saves_with(mut self, save: impl FnOnce(&str) -> Option<Save> + 'a) -> Self {+ self.saves = Some(Box::new(save));+ self+ }+ /// Grey text shown while the field is empty. pub fn hint(mut self, hint: impl Into<String>) -> Self { self.hint = Some(hint.into());@@ -334,7 +419,13 @@ impl<'a> TextField<'a> { } let editing = ending.is_none() && (focused || memo.away || memo.menu_open || memo.action.is_some()); ui.data_mut(|d| d.insert_temp(id.with("memo"), memo));- TextFieldResponse { response, ending, editing }+ // the save the field declares from its text now: recorded while the edit is under way (to+ // be run if the field is not drawn again), handed over once, with the text kept, when it ends+ let kept = matches!(ending, Some(Ending::Return | Ending::Away));+ let declared = self.saves.filter(|_| editing || kept).and_then(|saves| saves(self.text));+ let save = if kept { declared.clone() } else { None };+ record(ui.ctx(), id, pass, declared.filter(|_| editing));+ TextFieldResponse { response, ending, editing, save } } } @@ -19,6 +19,15 @@ struct Rig { endings: Vec<Ending>, widgets: Vec<(String, Rect)>, time: f64,+ /// The field declares a save (`saves_with`): the text other than `start`.+ declares: bool,+ start: String,+ /// The saves the field handed over on the frame its edit ended.+ saved: Vec<Save>,+ /// The saves the sweep at the end of each pass took, because their field was not drawn in it.+ swept: Vec<Save>,+ /// The app's sweep runs at the end of each pass (off: the pass is one egui throws away).+ sweep: bool, } impl Rig {@@ -33,6 +42,11 @@ impl Rig { endings: vec![], widgets: vec![], time: 0.0,+ declares: false,+ start: text.to_string(),+ saved: vec![],+ swept: vec![],+ sweep: true, }; rig.frame(vec![]); rig@@ -49,16 +63,28 @@ impl Rig { let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); raw.focused = self.window_focused; let (text, select, shown, endings, editing) = (&mut self.text, self.select_on_focus, self.shown, &mut self.endings, &mut self.editing);+ let (declares, start, saved, swept, sweep) = (self.declares, &self.start, &mut self.saved, &mut self.swept, self.sweep); self.view.run(raw, |ui| { ui.add_space(20.0);- if !shown {- return;+ if shown {+ let start = start.as_str();+ let field = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select);+ let field = if declares {+ field.saves_with(|typed| (typed != start).then(|| Save { command: "city.set", params: serde_json::json!({ "city": typed }) }))+ } else {+ field+ };+ let r = field.show(ui);+ endings.extend(r.ending);+ saved.extend(r.save);+ *editing = r.editing;+ if focus_after {+ ui.memory_mut(|m| m.request_focus(id(FIELD)));+ } }- let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui);- endings.extend(r.ending);- *editing = r.editing;- if focus_after {- ui.memory_mut(|m| m.request_focus(id(FIELD)));+ // (the app's sweep at the end of each pass)+ if sweep {+ swept.extend(take_left_behind(ui.ctx())); } }); self.widgets = crate::widgets::take_registry(&self.view.ctx);@@ -420,3 +446,126 @@ fn a_drag_into_a_field_that_selects_on_focus_selects_what_it_covers() { rig.type_text("X"); assert!(rig.text.starts_with("Lisbon") && rig.text.ends_with('X') && rig.text != "X", "{:?}", rig.text); }++/// A rig whose field declares its save (`saves_with`), with `Lisbon` as the text before the edit.+fn saving_rig() -> Rig {+ let mut rig = Rig::new("Lisbon");+ rig.declares = true;+ rig.select_all_by_keys();+ rig+}++fn city(text: &str) -> Save {+ Save { command: "city.set", params: serde_json::json!({ "city": text }) }+}++/// A field that stays drawn hands its save over on the frame its edit ends, once, and leaves+/// nothing for the sweep or for the window closing.+#[test]+fn a_field_that_stays_drawn_hands_over_its_save_once() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ assert!(rig.saved.is_empty(), "nothing before the edit ends");+ rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary);+ for _ in 0..4 {+ rig.frame(vec![]);+ }+ assert_eq!(rig.saved, vec![city("Rome")], "one save, with the text as it ended");+ assert!(rig.swept.is_empty(), "and nothing for the sweep");+ assert!(take_all(&rig.view.ctx).is_empty(), "or for the window closing");+}++/// Return ends the edit the same way.+#[test]+fn return_hands_over_the_save() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ rig.key(Key::Enter, Modifiers::NONE);+ assert_eq!(rig.saved, vec![city("Rome")]);+ assert!(take_all(&rig.view.ctx).is_empty());+}++/// A field not drawn again mid-edit (its panel or dialog went) leaves its save for the sweep,+/// which takes it once.+#[test]+fn a_field_not_drawn_mid_edit_leaves_its_save_for_the_sweep() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ assert!(rig.swept.is_empty(), "still drawn: not left behind");+ rig.shown = false;+ rig.frame(vec![]);+ assert!(rig.saved.is_empty(), "the field was not there to hand it over");+ assert_eq!(rig.swept, vec![city("Rome")], "the text as it was when the field went");+ rig.frame(vec![]);+ assert_eq!(rig.swept, vec![city("Rome")], "taken once");+ assert!(take_all(&rig.view.ctx).is_empty(), "and not again at close");+}++/// The same with the field's own menu open (the focus is on the menu) when the field goes.+#[test]+fn a_field_not_drawn_with_its_menu_open_leaves_its_save_for_the_sweep() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ rig.click(FIELD, PointerButton::Secondary);+ rig.shown = false;+ rig.frame(vec![]);+ assert_eq!(rig.swept, vec![city("Rome")]);+}++/// The window closing saves every edit under way, drawn or not.+#[test]+fn closing_takes_the_save_of_a_field_still_drawn() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ assert_eq!(take_all(&rig.view.ctx), vec![city("Rome")]);+}++/// Esc gives the edit up: nothing is handed over or left.+#[test]+fn escape_leaves_no_save() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ rig.key(Key::Escape, Modifiers::NONE);+ assert_eq!(rig.text, "Lisbon");+ assert!(rig.saved.is_empty());+ assert!(take_all(&rig.view.ctx).is_empty(), "nothing left to save");+}++/// An edit that changed nothing declares nothing.+#[test]+fn an_unchanged_text_has_no_save() {+ let mut rig = Rig::new("Lisbon");+ rig.declares = true;+ rig.click(FIELD, PointerButton::Primary);+ assert!(take_all(&rig.view.ctx).is_empty(), "nothing typed, nothing recorded");+ rig.click_at(egui::pos2(300.0, 250.0), PointerButton::Primary);+ assert!(rig.saved.is_empty());+}++/// A field that does not opt in (a dialog's, where Cancel must not save) records nothing.+#[test]+fn a_field_that_does_not_opt_in_leaves_nothing() {+ let mut rig = Rig::new("Lisbon");+ rig.select_all_by_keys();+ rig.type_text("Rome");+ rig.shown = false;+ rig.frame(vec![]);+ assert!(rig.swept.is_empty());+ assert!(take_all(&rig.view.ctx).is_empty());+}++/// A pass egui throws away (a notice or library window's first frame) draws no field and runs no+/// sweep: the field, drawn again in the pass after, must leave its save for the sweep, not drop it.+#[test]+fn a_save_is_kept_across_a_pass_that_was_thrown_away() {+ let mut rig = saving_rig();+ rig.type_text("Rome");+ rig.shown = false;+ rig.sweep = false;+ rig.frame(vec![]);+ rig.shown = true;+ rig.sweep = true;+ rig.frame(vec![]);+ assert!(rig.saved.is_empty(), "the field was not in an edit to hand it over");+ assert_eq!(rig.swept, vec![city("Rome")], "the sweep ran it");+}@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; 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; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; 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-27 · **Change:** minor (LR-BEHAV-TEXTFIELD: an edit in progress is saved however its field is left but Esc; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; 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; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; 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@@ -477,7 +477,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels.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.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests/empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. Files dragged over the window dim it and a card says what dropping will do; a drop of photos or folders opens the same import review as File ▸ Import | | 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/widgets/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/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-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/widgets/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/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; an edit in progress is saved however its field is left but Esc, including the field no longer being drawn (a Settings tab clicked or opened by a command, a panel hidden, another dialog, closing the window, Quit): a field declares its save (`TextField::saves_with`) and one sweep at the end of each pass (`finish_ui_pass`) and the close request run what is left; used by the Info panel's boxes, Settings' Copyright, Creator and Auto Import album, the slideshow title, overlay and template-name boxes, and Manage Effects' rename; 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/widgets/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/it/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/it/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests/canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Dialogs with one name field (New Album, New Smart Album, Save Metadata Preset, the renames) take the focus once when they open, so Tab and Shift+Tab reach their check box, Cancel and OK, and Return and Esc still confirm and cancel (`crates/ui-egui/src/tests/dialog_tab.rs`). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | 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.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian`, `cmd:app.language.polish`, `cmd:app.language.czech`, `cmd:app.language.italian`, `cmd:app.language.swedish`, `cmd:app.language.finnish`, `cmd:app.language.indonesian`, `cmd:app.language.vietnamese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 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 (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |+| 2026-10-27 | minor | LR-BEHAV-TEXTFIELD: an edit in a text box that saves when it ends is saved however the box is left but Esc, including the box no longer being drawn (a Settings tab clicked or opened by a command, the Info panel hidden, another dialog, closing the window, Quit); Settings' Copyright, Creator and Auto Import album use the shared box; status unchanged (🟡) |@@ -1,5 +1,19 @@ # What's new in LightCraft +## 2026-10-27++### Text typed in a box is kept however you leave it+- Text typed in a box that saves when you finish (the Info panel's fields, Copyright and Creator in Settings, the Auto+ Import album, the slideshow's title, overlay and template-name boxes, and Manage Effect Presets' rename) is now saved+ however you leave the box, except with Esc, which still gives it up. Before, it was kept only while the box was still+ on screen when you left it: a click on another Settings tab, a menu item or command that opened another tab or dialog,+ hiding the Info panel, opening another library, and closing the window or quitting could drop what you had typed.+- If the text can't be saved (a place that isn't coordinates, say), the window, Quit or the other library stays where it+ was, the box keeps the text and the error is shown. Closing, quitting or switching again, with that text still in the+ box, gives it up.+- Copyright, Creator and the Auto Import album in Settings now have the same right-click Cut, Copy, Paste and Select+ All menu as the other text boxes, and Esc in them gives the text back.+ ## 2026-10-24 ### Relative-date filters and smart albums follow the clock