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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-09 14:29:57 -04:00
parent f1c513c20c
commit debd2ee2ec
No known key found for this signature in database
18 changed files with 246 additions and 17 deletions

View File

@ -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"
}

View File

@ -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."
}

View File

@ -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{}"
}

View File

@ -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."
}

View File

@ -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}"
}

View File

@ -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つのキーワードにまとめられます。"
}

View File

@ -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{}"
}

View File

@ -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."
}

View File

@ -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}"
}

View File

@ -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.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом."
}

View File

@ -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}"
}

View File

@ -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.": "它们的照片和下级关键字将合并到同一个关键字下。"
}

View File

@ -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}"
}

View File

@ -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.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。"
}

View File

@ -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<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::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),

View File

@ -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('|', " › ");

View File

@ -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<String>,
/// A keyword being dragged in the Keyword List (onto another to nest it).
#[serde(skip)]
pub dragging_keyword: Option<String>,
/// 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<String>,
},
/// 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(),

View File

@ -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<u64>) {
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<u64>) {
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<u64> = 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());
}