mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
LRC-LIB-KEYWORDS: the Keywording box shows the selection's keywords
The chips at the top of the Keywords panel showed the active photo's
keywords, while adding and removing applied to every selected photo:
with several selected they misreported what the selection had. They
are now the keywords of every selected photo, each once whatever its
case (`panels::keywording`, cached per selection), and one only some
of them have is marked with an asterisk ("On 1 of 2 selected photos").
Only the × removes a keyword, from every selected photo: a click on
the chip itself used to remove it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2e016f73a6
commit
4cb024164e
@@ -213,5 +213,6 @@
|
||||
"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"
|
||||
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
|
||||
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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{}"
|
||||
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
|
||||
"On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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}"
|
||||
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
|
||||
"On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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{}"
|
||||
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
|
||||
"On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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}"
|
||||
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
|
||||
"On {have} of {of} selected photos": "На {have} из {of} выбранных фото"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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}"
|
||||
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
|
||||
"On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张"
|
||||
}
|
||||
|
||||
@@ -213,5 +213,6 @@
|
||||
"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}"
|
||||
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
|
||||
"On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張"
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ mod tests_keymap;
|
||||
#[cfg(test)]
|
||||
mod tests_keyword_list;
|
||||
#[cfg(test)]
|
||||
mod tests_keywording;
|
||||
#[cfg(test)]
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_library_problem;
|
||||
@@ -1078,6 +1080,8 @@ mod drop_tests {
|
||||
#[derive(Default)]
|
||||
pub struct Caches {
|
||||
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
|
||||
/// The Keywording box's chips, by library revision and selection.
|
||||
keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>,
|
||||
/// The Keyword List's tick boxes, by library revision and selection.
|
||||
keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>,
|
||||
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
|
||||
@@ -1166,6 +1170,22 @@ impl Caches {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The selection's keywords (the Keywording box's chips).
|
||||
pub(crate) fn keyword_chips(
|
||||
&mut self,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
selection: &[lightcraft_catalog::PhotoId],
|
||||
) -> std::sync::Arc<Vec<panels::keywording::Chip>> {
|
||||
let key = key_of(selection);
|
||||
match &self.keyword_chips {
|
||||
Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(),
|
||||
_ => {
|
||||
let c = std::sync::Arc::new(panels::keywording::chips(cat, selection));
|
||||
self.keyword_chips = Some((cat.revision, key, c.clone()));
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How many selected photos have each keyword (the Keyword List's tick boxes).
|
||||
pub(crate) fn keyword_ticks(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The Keywording box (the top of the right panel's Keywords): the keywords of the selected photos,
|
||||
//! not only the active one's, as Lightroom Classic shows them. A keyword only some of the selected
|
||||
//! photos have is marked as such.
|
||||
|
||||
use lightcraft_catalog::{Catalog, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// The selection's keywords as chips: the name (right-click for its menu), and × to take it off
|
||||
/// every selected photo. One only some of them have is marked with an asterisk.
|
||||
pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let selection = app.session.selection.ids.clone();
|
||||
let chips = app.caches.keyword_chips(&app.session.catalog, &selection);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for chip in chips.iter() {
|
||||
let frame = egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.field_border)).corner_radius(10.0).inner_margin(egui::Margin {
|
||||
left: 8,
|
||||
right: 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
});
|
||||
let shown = frame.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let partial = !chip.on_all();
|
||||
let name = format!("{}{}", chip.path.replace('|', " › "), if partial { " *" } else { "" });
|
||||
let color = if partial { t.text_dim } else { t.text_label };
|
||||
let label = ui.add(egui::Label::new(egui::RichText::new(name).color(color)).sense(egui::Sense::click()));
|
||||
register(ui.ctx(), format!("keywordChip:{}", chip.path), label.rect);
|
||||
let label = if partial {
|
||||
register(ui.ctx(), format!("keywordChipPartial:{}", chip.path), label.rect);
|
||||
label.on_hover_text(crate::i18n::tr_format!("On {have} of {of} selected photos", have = chip.have, of = chip.of))
|
||||
} else {
|
||||
label.on_hover_text(chip.path.replace('|', " › "))
|
||||
};
|
||||
let x = ui.add(egui::Button::new(egui::RichText::new("×").color(t.text_dim)).frame(false));
|
||||
register(ui.ctx(), format!("keywordChipRemove:{}", chip.path), x.rect);
|
||||
if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]}));
|
||||
}
|
||||
label
|
||||
});
|
||||
let k = chip.path.clone();
|
||||
shown.inner.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr("Remove from Photo")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() {
|
||||
let _ = app.run("library.filter", json!({"keyword": k}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": k}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A keyword of the selection, as a chip shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Chip {
|
||||
/// The keyword, spelled as the library spells it.
|
||||
pub path: String,
|
||||
/// Selected photos that have it.
|
||||
pub have: usize,
|
||||
/// Selected photos.
|
||||
pub of: usize,
|
||||
}
|
||||
|
||||
impl Chip {
|
||||
/// Every selected photo has it.
|
||||
pub fn on_all(&self) -> bool {
|
||||
self.have >= self.of
|
||||
}
|
||||
}
|
||||
|
||||
/// The keywords of the selected `photos`, each once whatever its case, with how many of them have
|
||||
/// it; by name.
|
||||
pub(crate) fn chips(catalog: &Catalog, photos: &[PhotoId]) -> Vec<Chip> {
|
||||
use lightcraft_catalog::keywords::clean;
|
||||
// by lower-case keyword: the photos with it (each once)
|
||||
let mut have: std::collections::BTreeMap<String, (String, usize)> = Default::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in photos {
|
||||
let Some(p) = catalog.photo(*id) else { continue };
|
||||
seen.clear();
|
||||
for k in &p.meta.keywords {
|
||||
let k = clean(k);
|
||||
if k.is_empty() || !seen.insert(k.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
have.entry(k.to_lowercase()).or_insert_with(|| (k.clone(), 0)).1 += 1;
|
||||
}
|
||||
}
|
||||
have.into_values().map(|(k, n)| Chip { path: catalog.keyword_path(&k).unwrap_or(k), have: n, of: photos.len() }).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn library(keywords: &[&[&str]]) -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for k in keywords {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01");
|
||||
p.meta.keywords = k.iter().map(|s| s.to_string()).collect();
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
(c, ids)
|
||||
}
|
||||
|
||||
fn shown(chips: &[Chip]) -> Vec<(&str, usize, usize)> {
|
||||
chips.iter().map(|c| (c.path.as_str(), c.have, c.of)).collect()
|
||||
}
|
||||
|
||||
/// The chips are the keywords of every selected photo, by name, each saying how many of the
|
||||
/// selected photos have it.
|
||||
#[test]
|
||||
fn the_chips_are_the_selections_keywords() {
|
||||
let (c, ids) = library(&[&["travel|Italy", "beach"], &["beach", "Weddings"], &["sea"]]);
|
||||
assert_eq!(shown(&chips(&c, &ids[..2])), [("beach", 2, 2), ("travel|Italy", 1, 2), ("Weddings", 1, 2)]);
|
||||
assert!(chips(&c, &ids[..2])[0].on_all() && !chips(&c, &ids[..2])[1].on_all());
|
||||
assert_eq!(shown(&chips(&c, &ids[2..])), [("sea", 1, 1)]);
|
||||
assert!(chips(&c, &[]).is_empty());
|
||||
}
|
||||
|
||||
/// A keyword two photos spell differently is one chip, spelled as the library spells it.
|
||||
#[test]
|
||||
fn a_keyword_spelled_two_ways_is_one_chip() {
|
||||
let (c, ids) = library(&[&["Beach"], &["beach"], &["BEACH", "beach"]]);
|
||||
assert_eq!(shown(&chips(&c, &ids)), [("Beach", 3, 3)]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod filterbar;
|
||||
pub mod grid;
|
||||
pub mod keymap;
|
||||
pub mod keyword_list;
|
||||
pub mod keywording;
|
||||
pub mod left;
|
||||
pub mod library_problem;
|
||||
pub mod masking;
|
||||
|
||||
@@ -783,31 +783,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(kid, text));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in &p.meta.keywords {
|
||||
let r =
|
||||
ui.add(egui::Button::new(egui::RichText::new(format!("{} ×", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0));
|
||||
register(ui.ctx(), format!("keywordChip:{k}"), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
r.context_menu(|ui| {
|
||||
if ui.button(crate::i18n::tr("Remove from Photo")).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Show Photos with Keyword")).clicked() {
|
||||
let _ = app.run("library.filter", json!({"keyword": k}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Rename Keyword…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: k.clone(), to: k.clone() });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Delete Keyword")).clicked() {
|
||||
let _ = app.run("keyword.delete", json!({"keyword": k}));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
super::keywording::chip_row(app, ui);
|
||||
ui.add_space(10.0);
|
||||
keyword_set(app, ui, &p.meta.keywords);
|
||||
ui.add_space(8.0);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The Keywording box at the top of the right panel's Keywords: the selection's keywords, driven
|
||||
//! through the control channel.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// The Library grid with the Keywords panel open and two photos selected: the first with beach and
|
||||
/// Weddings, the second with beach.
|
||||
fn two_selected() -> (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 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"})),
|
||||
("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "keywords": ["beach", "Weddings"]}})),
|
||||
("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["beach"]}})),
|
||||
("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})),
|
||||
] {
|
||||
let r = h.request(method, params, T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
}
|
||||
h.settle(SETTLE);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn ask(h: &mut Headless, method: &str, params: serde_json::Value) {
|
||||
let r = h.request(method, params.clone(), T);
|
||||
assert_eq!(r["ok"], true, "{method} {params}: {r}");
|
||||
h.settle(SETTLE);
|
||||
}
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
fn keywords_of(h: &Headless, id: u64) -> Vec<String> {
|
||||
h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone()
|
||||
}
|
||||
|
||||
/// The chips are the keywords of every selected photo, not only the active one's; a keyword only
|
||||
/// some of them have is marked.
|
||||
#[test]
|
||||
fn the_chips_are_the_selections_keywords() {
|
||||
let (h, _) = two_selected();
|
||||
assert!(
|
||||
has(&h, "keywordChip:beach") && has(&h, "keywordChip:Weddings"),
|
||||
"{:?}",
|
||||
h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip")).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(has(&h, "keywordChipPartial:Weddings"), "only one of the two has Weddings");
|
||||
assert!(!has(&h, "keywordChipPartial:beach"), "both have beach");
|
||||
}
|
||||
|
||||
/// Only the × removes a keyword, from every selected photo; a click on the chip itself doesn't.
|
||||
#[test]
|
||||
fn only_the_cross_removes_a_keyword() {
|
||||
let (mut h, ids) = two_selected();
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:beach"}));
|
||||
assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"beach".to_string())), "a click on the chip keeps it");
|
||||
ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:beach"}));
|
||||
assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"beach".to_string())), "× took it off both");
|
||||
}
|
||||
Reference in New Issue
Block a user