From debd2ee2ecff4a1db6a0d0c4909d77045bbc9c2a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:29:57 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: reorganize keywords by drag and drop In the Keyword List: - dragging a keyword onto another nests it there, with the keywords below it and its photos; dropping it on the list's title (which says "Drop here for the top level" during the drag) takes it to the top level; - a keyword is never outlined, nor dropped, inside itself or one of its children; - where a keyword of its name is already, a dialog asks before merging the two; - photos dragged from the grid onto a keyword get it. The dragged keyword's name follows the pointer; the drag ends with the button's release, wherever it is. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/panels/dialogs.rs | 15 +++ crates/ui-egui/src/panels/keyword_list.rs | 107 +++++++++++++++++++- crates/ui-egui/src/state.rs | 10 ++ crates/ui-egui/src/tests_keyword_list.rs | 68 ++++++++++++- 18 files changed, 246 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 9b42ac14..d4ae3983 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "In „{parent}“ einfügen", "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", - "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…" + "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", + "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", + "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa456e1e..cdd96f9d 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1908,5 +1908,7 @@ "Edit Keyword Tag…": "Stichwort bearbeiten…", "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", - "Delete Keyword…": "Stichwort löschen…" + "Delete Keyword…": "Stichwort löschen…", + "Drop here for the top level": "Hier ablegen für die oberste Ebene", + "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 5be99a4a..72bec020 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Poner dentro de “{parent}”", "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", - "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…" + "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?", + "There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?", + "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 476fc8e1..814e0c79 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "Editar palabra clave…", "Put New Keywords Inside This Keyword": "Poner las nuevas palabras clave dentro de esta", "Purge Unused Keywords": "Eliminar palabras clave no usadas", - "Delete Keyword…": "Eliminar palabra clave…" + "Delete Keyword…": "Eliminar palabra clave…", + "Drop here for the top level": "Soltar aquí para el nivel superior", + "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 587a8f42..dd6060b6 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "「{parent}」の中に入れる", "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", - "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…" + "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", + "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?", + "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", + "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f165ecf2..b073b344 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "キーワードタグを編集…", "Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる", "Purge Unused Keywords": "使用されていないキーワードを削除", - "Delete Keyword…": "キーワードを削除…" + "Delete Keyword…": "キーワードを削除…", + "Drop here for the top level": "ここにドロップすると最上位に移動", + "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4181dc25..ef31845f 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Colocar dentro de “{parent}”", "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", - "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…" + "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?", + "There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?", + "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 5da1ec6b..3e366b9e 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "Editar palavra-chave…", "Put New Keywords Inside This Keyword": "Colocar novas palavras-chave dentro desta", "Purge Unused Keywords": "Excluir palavras-chave não usadas", - "Delete Keyword…": "Excluir palavra-chave…" + "Delete Keyword…": "Excluir palavra-chave…", + "Drop here for the top level": "Solte aqui para o nível superior", + "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 6a64e071..f1d4ca2f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Поместить в «{parent}»", "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", - "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…" + "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", + "“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?", + "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", + "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3ec2d67f..cd3bd491 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1426,5 +1426,7 @@ "Edit Keyword Tag…": "Изменить ключевое слово…", "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", - "Delete Keyword…": "Удалить ключевое слово…" + "Delete Keyword…": "Удалить ключевое слово…", + "Drop here for the top level": "Отпустите здесь для верхнего уровня", + "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 8034165b..f01ccdf7 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "放入“{parent}”中", "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}", - "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…" + "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?", + "There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?", + "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b556d5fc..b6e09076 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "编辑关键字标记…", "Put New Keywords Inside This Keyword": "将新关键字放入此关键字中", "Purge Unused Keywords": "清除未使用的关键字", - "Delete Keyword…": "删除关键字…" + "Delete Keyword…": "删除关键字…", + "Drop here for the top level": "拖放到此处以移至顶层", + "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 368fe1a9..8a4b6455 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "放入「{parent}」中", "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}", - "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…" + "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…", + "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?", + "There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?", + "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 8e76acca..af97aac4 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "編輯關鍵字標記…", "Put New Keywords Inside This Keyword": "將新關鍵字放入此關鍵字中", "Purge Unused Keywords": "清除未使用的關鍵字", - "Delete Keyword…": "刪除關鍵字…" + "Delete Keyword…": "刪除關鍵字…", + "Drop here for the top level": "拖放到此處以移至最上層", + "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。" } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 766cd772..199a9858 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -85,6 +85,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", + Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -767,6 +768,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), id, r.rect); } } + Dialog::MoveKeyword { keyword, parent } => { + let name = keyword.rsplit('|').next().unwrap_or(keyword); + match parent { + Some(p) => ui.label(crate::i18n::tr_format!( + "“{parent}” has a “{name}” already. Merge the two?", + parent = p.replace('|', " › "), + name = name + )), + None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), + }; + ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); + } Dialog::DeleteKeyword { keyword, count } => { let name = keyword.replace('|', " › "); ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); @@ -887,6 +900,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::MoveKeyword { .. } => "Merge", Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", @@ -1162,6 +1176,7 @@ 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::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 e31d8e99..04ad6a7f 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -20,7 +20,23 @@ const INDENT: f32 = 14.0; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::divider(ui); - super::right::header(ui, "Keyword List"); + // the title, where a dragged keyword goes back to the top level + let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + register(ui.ctx(), "keywordList:topLevel", title); + let dragging = app.ui.dragging_keyword.clone(); + let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); + let over_title = pointer.is_some_and(|p| title.contains(p)); + if dragging.is_some() && over_title { + ui.painter().rect_stroke(title.shrink2(vec2(16.0, 6.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + let heading = if dragging.is_some() { crate::i18n::tr("Drop here for the top level") } else { crate::i18n::tr("Keyword List") }; + ui.painter().text(pos2(title.left() + 24.0, title.center().y + 2.0), Align2::LEFT_CENTER, heading, t.semibold(15.0), t.text); + if released + && over_title + && let Some(k) = dragging.clone() + { + drop_keyword(app, ui.ctx(), &k, None); + } let fid = egui::Id::new("keyword-list-filter"); let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); ui.horizontal(|ui| { @@ -61,14 +77,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { row(app, ui, r, &selection, filter.trim().is_empty()); } ui.add_space(12.0); + // the drag ends with the button's release, wherever it is + if app.ui.dragging_keyword.is_some() { + let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down())); + if released || !down { + app.ui.dragging_keyword = None; + } else if let (Some(k), Some(p)) = (app.ui.dragging_keyword.clone(), pointer) { + drag_label(ui.ctx(), &k, p); + } + } +} + +/// The dragged keyword's name, following the pointer. +fn drag_label(ctx: &egui::Context, keyword: &str, at: egui::Pos2) { + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + let name = keyword.rsplit('|').next().unwrap_or(keyword).to_string(); + egui::Area::new(egui::Id::new("drag-keyword")).order(egui::Order::Tooltip).interactable(false).fixed_pos(at + vec2(14.0, 10.0)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(Color32::WHITE).font(t.semibold(12.0))); + }); + }); +} + +/// Drop the dragged `keyword` inside `parent` (`None`: the top level). Where one of its name is +/// already, ask before merging the two; where it is already, nothing happens. +fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, parent: Option<&str>) { + app.ui.dragging_keyword = None; + let cat = &app.session.catalog; + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let parent = parent.map(|p| cat.keyword_path(p).unwrap_or_else(|| p.to_string())); + if parent.as_deref().is_some_and(|p| lightcraft_catalog::keywords::is_under(p, keyword)) { + return; + } + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + if same(&to, keyword) { + return; + } + if cat.has_keyword(&to) { + 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); + } } /// One keyword's row: the triangle, the tick box, the name, the count, and on hover the arrow. /// `can_fold`: the triangle opens and closes the level (not while a filter opens it). fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], can_fold: bool) { let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); + if resp.drag_started() { + app.ui.dragging_keyword = Some(r.path.clone()); + } + drop_target(app, ui, rect, &r.path); let picked = app.ui.keyword_list_selected.as_deref().is_some_and(|k| same(k, &r.path)); let inner = Rect::from_min_max(rect.min + vec2(16.0, 0.0), rect.max - vec2(16.0, 0.0)); if picked { @@ -166,6 +230,45 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId resp.context_menu(|ui| menu(app, ui, &r.path, selection)); } +/// A row while a keyword or photos are dragged: outlined under the pointer when the drop would +/// do something (a keyword never goes inside itself), and a release there does it: nests the +/// keyword inside this one, or gives this keyword to the photos. +fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect, path: &str) { + let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); + if !pointer.is_some_and(|p| rect.contains(p)) { + return; + } + let t = Tokens::get(ui.ctx()); + let outline = |ui: &egui::Ui| ui.painter().rect_stroke(rect.shrink2(vec2(16.0, 1.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + if let Some(k) = app.ui.dragging_keyword.clone() { + if lightcraft_catalog::keywords::is_under(path, &k) { + return; + } + outline(ui); + if released { + drop_keyword(app, ui.ctx(), &k, Some(path)); + } + } else if let Some(ids) = app.ui.dragging_photos.clone() { + outline(ui); + if released { + let n = ids.len(); + app.ui.dragging_photos = None; + match app.run("photo.setMeta", json!({"ids": ids, "addKeywords": [path]})) { + Ok(_) => app.toast( + ui.ctx(), + crate::i18n::tr_format!( + "Added “{keyword}” to {n} photo{}", + if n == 1 { "" } else { "s" }, + keyword = path.replace('|', " › "), + n = n + ), + ), + Err(e) => app.toast(ui.ctx(), e), + } + } + } +} + /// A keyword's context menu. fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[PhotoId]) { let name = path.replace('|', " › "); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 373ecfd3..20fddc3b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -454,6 +454,9 @@ pub struct UiState { /// The keyword picked in the Keyword List (− deletes it, Edit edits it). #[serde(skip)] pub keyword_list_selected: Option, + /// A keyword being dragged in the Keyword List (onto another to nest it). + #[serde(skip)] + pub dragging_keyword: Option, /// A running slideshow (full screen): seconds per photo, when the next one is due (egui /// time), paused. #[serde(skip)] @@ -580,6 +583,12 @@ pub enum Dialog { /// A new keyword is given to the selected photos. add_to_selected: bool, }, + /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: + /// asks before merging the two. + MoveKeyword { + keyword: String, + parent: Option, + }, /// Delete a keyword from every photo and the keyword list, after asking. DeleteKeyword { keyword: String, @@ -788,6 +797,7 @@ impl Default for UiState { keyword_painter: None, keyword_list_open: Vec::new(), keyword_list_selected: None, + dragging_keyword: None, info_overlay: InfoOverlay::Off, navigator: true, settings: AppSettings::default(), diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 3b4b5668..4d91290e 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -13,8 +13,13 @@ const SETTLE: Duration = Duration::from_secs(120); /// The demo library in the Library grid with the Keywords panel open, two photos selected. fn keywords_panel() -> (Headless, Vec) { + keywords_panel_at([1400.0, 1000.0]) +} + +/// [`keywords_panel`] in a window of `size` (tall enough, every keyword row is on screen). +fn keywords_panel_at(size: [f32; 2]) -> (Headless, Vec) { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); - let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let mut h = Headless::new(app, size, 1.0); let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); for (method, params) in [ ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), @@ -204,3 +209,64 @@ fn a_keywords_menu_offers_its_actions() { assert!(!h.app.session.catalog.has_keyword("Events")); assert_eq!(h.app.session.keyword_parent, None, "gone with it"); } + +fn center(h: &Headless, id: &str) -> egui::Pos2 { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {id} on screen")) +} + +/// Drag the widget `from` and drop it on the widget `onto`. +fn drag(h: &mut Headless, from: &str, onto: &str) { + let to = center(h, onto); + ask(h, "ui.dragWidget", json!({"id": from, "toX": to.x, "toY": to.y, "steps": 12})); +} + +/// Dragging a keyword onto another nests it there, with its photos; dropping it on the Keyword +/// List's title takes it back to the top level. +#[test] +fn dragging_a_keyword_nests_it_and_back() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Lisbon"]})); + run(&mut h, "keyword.create", json!({"name": "Portugal"})); + 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"})); + drag(&mut h, "keywordRow:Portugal|Lisbon", "keywordList:topLevel"); + assert_eq!(keywords_of(&h, ids[0]), ["Lisbon"]); +} + +/// A keyword can't be dropped inside itself or one of its own children: nothing changes. +#[test] +fn a_keyword_cant_be_dropped_inside_itself() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Portugal|Lisbon"]})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Portugal"})); + let undo = h.app.session.undo.len(); + drag(&mut h, "keywordRow:Portugal", "keywordRow:Portugal|Lisbon"); + assert_eq!((keywords_of(&h, ids[0]), h.app.session.undo.len()), (vec!["Portugal|Lisbon".to_string()], undo)); + assert_eq!(h.app.ui.dialog, None); +} + +/// Dropping a keyword where one of that name is already (Italy has a Rome) asks before merging +/// the two; Merge does it. +#[test] +fn dropping_onto_a_taken_name_asks_before_merging() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Rome"]})); + run(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "keywords": ["Italy|Rome"]})); + drag(&mut h, "keywordRow:Rome", "keywordRow:Italy"); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MoveKeyword { .. })), "{:?}", h.app.ui.dialog); + assert_eq!(keywords_of(&h, ids[0]), ["Rome"], "nothing yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert_eq!((keywords_of(&h, ids[0]), keywords_of(&h, ids[1])), (vec!["Italy|Rome".to_string()], vec!["Italy|Rome".to_string()])); +} + +/// Photos dragged from the grid onto a keyword get it. +#[test] +fn dropping_photos_on_a_keyword_tags_them() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "keyword.create", json!({"name": "Portugal"})); + drag(&mut h, &format!("thumb:{}", ids[0]), "keywordRow:Portugal"); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Portugal".to_string())), "the selection got it"); + assert!(h.app.ui.dragging_photos.is_none()); +}