diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3db3766b2..058735f02 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1169,7 +1169,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { params["keyword"] = json!(keyword); params["name"] = json!(name); - app.run("keyword.edit", params) + let r = app.run("keyword.edit", params); + if r.is_ok() { + let to = match keyword.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + crate::panels::keyword_list::follow(app, keyword, &to); + } + r } None => { params["name"] = json!(name); @@ -1180,7 +1188,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), - Dialog::MoveKeyword { keyword, parent } => app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})), + Dialog::MoveKeyword { keyword, parent } => { + let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); + if r.is_ok() { + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 5b92c4b50..2e5274dc5 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -89,6 +89,30 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// After the keyword `from` became `to` (renamed, moved or merged), the list's pick and open +/// levels follow it, and the levels containing it open so that it stays in sight. +pub(crate) fn follow(app: &mut LightcraftApp, from: &str, to: &str) { + use lightcraft_catalog::keywords::{is_under, reparent}; + let to = app.session.catalog.keyword_path(to).unwrap_or_else(|| to.to_string()); + if let Some(k) = app.ui.keyword_list_selected.clone() + && is_under(&k, from) + { + app.ui.keyword_list_selected = Some(reparent(&k, from, &to)); + } + for o in &mut app.ui.keyword_list_open { + if is_under(o, from) { + *o = reparent(o, from, &to).to_lowercase(); + } + } + let levels: Vec<&str> = to.split('|').collect(); + for n in 1..levels.len() { + let parent = levels.get(..n).map(|l| l.join("|").to_lowercase()).unwrap_or_default(); + if !app.ui.keyword_list_open.iter().any(|o| same(o, &parent)) { + app.ui.keyword_list_open.push(parent); + } + } +} + /// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible /// (scrolled) area, so a drop never lands on a row or title hidden under the top bar. fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option) -> bool { @@ -125,8 +149,9 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par app.ui.dialog = Some(Dialog::MoveKeyword { keyword: keyword.to_string(), parent }); return; } - if let Err(e) = app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { - app.toast(ctx, e); + match app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { + Ok(_) => follow(app, keyword, &to), + Err(e) => app.toast(ctx, e), } } diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 1602b4c17..6790ccff1 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -230,7 +230,7 @@ fn dragging_a_keyword_nests_it_and_back() { drag(&mut h, "keywordRow:Lisbon", "keywordRow:Portugal"); assert_eq!(keywords_of(&h, ids[0]), ["Portugal|Lisbon"]); assert!(h.app.ui.dragging_keyword.is_none(), "the drag ended"); - ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Portugal"})); + assert!(has(&h, "keywordRow:Portugal|Lisbon"), "Portugal opened to show it"); drag(&mut h, "keywordRow:Portugal|Lisbon", "keywordList:topLevel"); assert_eq!(keywords_of(&h, ids[0]), ["Lisbon"]); } @@ -319,3 +319,27 @@ fn return_in_synonyms_creates_the_keyword() { assert_eq!(h.app.ui.dialog, None); assert_eq!(info(&mut h, "Weddings")["synonyms"], json!(["marriage"])); } + +/// The picked keyword and the open levels follow a rename or a move made in the list: renamed, +/// Events stays open and picked; dropped into Portugal, Lisbon stays in sight and picked. +#[test] +fn the_list_follows_its_keywords() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Events"})); + // a moment later: the triangle's click and the row's two aren't a triple click + for _ in 0..40 { + h.step(); + } + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "count": 2})); + ask(&mut h, "ui.text", json!({"text": "Occasions"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(has(&h, "keywordRow:Occasions|Weddings"), "still open"); + assert_eq!(h.app.ui.keyword_list_selected.as_deref(), Some("Occasions"), "still picked"); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Lisbon"]})); + run(&mut h, "keyword.create", json!({"name": "Portugal", "parent": null})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Lisbon"})); + drag(&mut h, "keywordRow:Lisbon", "keywordRow:Portugal"); + assert!(has(&h, "keywordRow:Portugal|Lisbon"), "its new parent opened"); + assert_eq!(h.app.ui.keyword_list_selected.as_deref(), Some("Portugal|Lisbon")); +}