LRC-LIB-KEYWORDS: the Keyword List follows its keywords

After a rename (Edit Keyword Tag), a drag or a merge made in the list,
the picked keyword still named the old path (the highlight went and −
was greyed out for a keyword that was there) and the renamed levels
closed. The pick and the open levels now follow the keyword, and the
levels containing it open, so a keyword dropped into another stays in
sight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 14:58:45 -04:00
co-authored by Claude Opus 5.5
parent 92730c9246
commit 23ce79009d
3 changed files with 70 additions and 5 deletions
+18 -2
View File
@@ -1169,7 +1169,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Some(keyword) => {
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<serde_jso
}
}
Dialog::DeleteKeyword { keyword, .. } => 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),
+27 -2
View File
@@ -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<egui::Pos2>) -> 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),
}
}
+25 -1
View File
@@ -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"));
}