From 4ce0aad79cb06ceededdfa7034668152bbaf73fd Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sun, 11 Oct 2026 20:26:54 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: the Keywording box says what a removal touched With several photos selected the box says how many its keywords are of. A chip's x is a target as tall as the chip and 22 pt wide (it was 14 pt square) that lights up on its own, and the chip is 4 pt narrower for it. Taking a keyword off several photos, by the x or the chip's menu, says which keyword left how many photos and that Edit > Undo puts it back; a refused removal says why. Chips and their x show the keyboard focus, and the Add keyword box says in its tooltip that commas separate keywords. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 15 +++++ crates/ui-egui/src/panels/keywording.rs | 81 ++++++++++++++++++++----- crates/ui-egui/src/panels/right.rs | 5 ++ crates/ui-egui/src/tests_keywording.rs | 78 ++++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 3 + 6 files changed, 169 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 386cf021e..9915ac03f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1513,6 +1513,8 @@ pub struct Caches { keyword_names: Option<(u64, u64, std::sync::Arc>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc>)>, + /// How many photos of the library the selection is, by library revision and selection. + selected_photos: Option<(u64, u64, usize)>, /// The Keywording box's chips grouped by the keyword they are inside, by library revision and /// selection. keyword_groups: Option<(u64, u64, std::sync::Arc>)>, @@ -1659,6 +1661,19 @@ impl Caches { } } } + /// How many photos the selection is: each once, those the library has (what the Keywording + /// box's "N of M" counts). + pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { + let key = key_of(selection); + match self.selected_photos { + Some((r, k, n)) if r == cat.revision && k == key => n, + _ => { + let n = panels::keywording::distinct(cat, selection).len(); + self.selected_photos = Some((cat.revision, key, n)); + n + } + } + } /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). pub(crate) fn keyword_groups( &mut self, diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 7cc810bb8..f9b05e238 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -54,17 +54,22 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// Chips on a line of the grouped box are this far apart. const GAP: f32 = 6.0; -/// The room a chip's × takes at its end. -const CROSS_W: f32 = 18.0; +/// The × at a chip's end is a target this wide, and as tall as the chip. +const CROSS_W: f32 = 22.0; + +/// The room a chip needs besides its name: the space before it and, with a ×, the ×'s target (the +/// × sits in the middle of it, which leaves the name its space), else the space after it. +fn chip_edges(cross: bool) -> f32 { + 8.0 + if cross { CROSS_W } else { 8.0 } +} /// What a chip shows of `name` in a row `row_w` wide (cut short to fit it), and the chip's width. fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) -> (String, f32) { let font = Tokens::get(ui.ctx()).font(13.0); - let cross_w = if cross { CROSS_W } else { 0.0 }; - let room = (row_w - 16.0 - cross_w).max(24.0); + let room = (row_w - chip_edges(cross)).max(24.0); let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x; let shown = chip_label(name, partial, room, measure); - let w = measure(&shown) + 16.0 + cross_w; + let w = measure(&shown) + chip_edges(cross); (shown, w) } @@ -73,7 +78,7 @@ fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) /// a row `row_w` wide) and, with `cross`, a × at its end. A `partial` one (only some of the /// selected photos) is dimmed and marked with an asterisk. Returns the name's response and the ×'s. fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, row_w: f32) -> (egui::Response, Option) { - use egui::{Align2, Rect, Sense, pos2, vec2}; + use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; let t = Tokens::get(ui.ctx()); let font = t.font(13.0); let color = if partial { t.text_dim } else { t.text_label }; @@ -87,24 +92,57 @@ fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, r let full = path.replace('|', " › "); let resp = ui.interact(body, id, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full)); + // the ×: the whole end of the chip, so that it is hard to miss and easy to tell from the name let x = cross.then(|| { - let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); + let xr = Rect::from_min_max(pos2(rect.right() - cross_w, rect.top()), rect.max); let xresp = ui.interact(xr, id.with("remove"), Sense::click()); let said = crate::i18n::tr_format!("Remove “{name}”", name = full); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said)); xresp }); - ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - if resp.hovered() || x.as_ref().is_some_and(egui::Response::hovered) { - ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); + let p = ui.painter(); + p.rect_stroke(rect, 10.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if resp.hovered() { + p.rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); } - ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + p.text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font, color); if let Some(x) = &x { - ui.painter().text(x.rect.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if x.hovered() { t.text } else { t.text_dim }); + // under the pointer the × lights up on its own: this click removes, the name's doesn't + if x.hovered() { + p.rect_filled(x.rect.shrink(1.0), CornerRadius { nw: 0, sw: 0, ne: 10, se: 10 }, t.hover); + } + let (c, arm) = (x.rect.center(), 3.5); + let stroke = Stroke::new(1.5, if x.hovered() || x.has_focus() { t.text } else { t.text_dim }); + p.line_segment([c + vec2(-arm, -arm), c + vec2(arm, arm)], stroke); + p.line_segment([c + vec2(-arm, arm), c + vec2(arm, -arm)], stroke); + if x.has_focus() { + p.rect_stroke(x.rect, CornerRadius { nw: 4, sw: 4, ne: 10, se: 10 }, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + } + // reached with Tab, a chip shows where the keyboard is + if resp.has_focus() { + p.rect_stroke(rect, 10.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } (resp, x) } +/// Take the chip's keyword off the selected photos. Several photos losing it at once is said, with +/// the way back (one photo's chip going is enough); a refusal is said too. +fn remove(app: &mut LightcraftApp, ctx: &egui::Context, chip: &Chip) { + match app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})) { + Ok(_) if chip.have > 1 => { + let name = chip.path.replace('|', " › "); + app.toast_for( + ctx, + crate::i18n::tr_format!("Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", name = name, n = chip.have), + 6.0, + ); + } + Ok(_) => {} + Err(e) => app.toast_error(ctx, e), + } +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -117,7 +155,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { let _ = app.run("photo.setMeta", json!({"addKeywords": [chip.path]})); } if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos")) { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})); + remove(app, ui.ctx(), chip); } if item(ui, "show", crate::i18n::tr("Show Photos with Keyword")) { super::left::browse_all_photos(app, true); @@ -129,6 +167,21 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { } } +/// With several photos selected, how many the box's keywords are of (`keywordScope`): what a × +/// or a keyword typed here acts on. +pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let selection = app.session.targets(&serde_json::Value::Null); + let photos = app.caches.selected_photos(&app.session.catalog, &selection); + if photos < 2 { + return; + } + let t = Tokens::get(ui.ctx()); + let said = crate::i18n::tr_format!("Keywords of {n} selected photos", n = photos); + let r = ui.label(egui::RichText::new(said).size(11.5).color(t.text_label)); + register(ui.ctx(), "keywordScope", r.rect); + ui.add_space(4.0); +} + /// 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. Whole names, or /// grouped by the keyword they are inside (Settings ▸ Interface). @@ -161,7 +214,7 @@ fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Respo if let Some(x) = x { register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); + remove(app, ui.ctx(), c); } } // one tooltip: the whole name (it may be cut short), and how many have it diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index c55385c72..ecebd010f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -901,6 +901,10 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); let mut drawn = Rect::NOTHING; + if offers.is_none() { + // (not over the offers) + r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); + } if let Some(o) = &offers { let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at); drawn = rect; @@ -936,6 +940,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(8.0); super::keywording::view_switch(app, ui); ui.add_space(4.0); + super::keywording::scope(app, ui); if app.ui.keywording_view == crate::state::KeywordingView::Keywords { super::keywording::chip_row(app, ui); } else { diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index 1ce27c746..839760af3 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -505,3 +505,81 @@ fn the_highlight_stays_on_the_list() { assert!(all_have(&h, &ids, &lit[0]), "the last offer, {}, was given", lit[0]); assert!(none_has(&h, &ids, "por")); } + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +// Given several photos selected, the box says how many its keywords are of; with one photo there +// is nothing to say. +#[test] +fn the_box_says_how_many_photos_it_is_for() { + let (mut h, ids) = two_selected(); + assert!(has(&h, "keywordScope"), "two photos are selected"); + assert!(rect(&h, "keywordScope").bottom() <= rect(&h, "keywordChip:beach").top(), "above the chips it is about"); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})); + assert!(!has(&h, "keywordScope"), "one photo"); +} + +// The × is a target as tall as its chip and as wide as that, apart from the name next to it. +#[test] +fn the_cross_is_as_tall_as_its_chip() { + let (h, _) = two_selected(); + let (name, cross) = (rect(&h, "keywordChip:beach"), rect(&h, "keywordChipRemove:beach")); + assert!(cross.height() >= 22.0 && cross.width() >= 22.0, "{cross:?}"); + assert_eq!(cross.height(), name.height()); + assert!(cross.left() >= name.right() - 0.5, "beside the name, not over it: {cross:?} {name:?}"); +} + +// Given a keyword on several selected photos, +// when its × takes it off them, +// then a message names the keyword and how many photos lost it and says how to get it back, and +// Undo does. Taking a keyword off one photo says nothing: the chip going is enough. +#[test] +fn taking_a_keyword_off_several_photos_says_so_and_undo_puts_it_back() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:Weddings"})); + assert_eq!(toast(&h), "", "only one of the two had Weddings"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:beach"})); + assert!(none_has(&h, &ids, "beach")); + let said = toast(&h); + assert!(said.contains("beach") && said.contains('2') && said.contains("Undo"), "{said}"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo", "params": {}})); + assert!(all_have(&h, &ids, "beach"), "one undo step puts it back on both"); + + // the menu's Remove from Selected Photos says the same + h.app.ui.toast = None; + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:beach", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:remove"})); + assert!(none_has(&h, &ids, "beach") && toast(&h).contains("beach"), "{}", toast(&h)); +} + +// Names of any length and script stay inside the panel in both layouts, and nothing breaks: a +// name of 400 letters, one of emoji and right-to-left text, levels of one letter. +#[test] +fn any_name_fits_the_panel() { + let (mut h, ids) = two_selected(); + let long = "x".repeat(400); + let keywords: Vec = + vec![long.clone(), format!("a|{long}"), "🙂|العربية|שלום".into(), "🙂|العربية|مرحبا".into(), "a|b|c|d|e|f|g|h".into(), "İ|ı".into()]; + give(&mut h, &ids, &keywords); + for layout in [crate::state::KeywordChips::Paths, crate::state::KeywordChips::Grouped] { + h.app.ui.settings.keyword_chips = layout; + h.settle(SETTLE); + let panel = rect(&h, "panel:right_panel"); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert_eq!(chips.len(), keywords.len(), "{layout:?}"); + for (id, r) in &chips { + assert!( + r.left() >= panel.left() && r.right() <= panel.right(), + "{layout:?}: {} inside the panel: {r:?}", + id.chars().take(40).collect::() + ); + assert!(r.width() > 0.0 && r.height() < 30.0); + } + } + // and typing them offers them without trouble + typed(&mut h, &"x".repeat(300)); + typed(&mut h, "🙂"); + assert!(count(&h, "kwComplete") >= 1); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6af41a7ca..11a4170ea 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -704,7 +704,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`), and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:`, at most eight, those every selected photo has left out): ↓ / ↑ move through them (`kwCompleteAt:`), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people diff --git a/docs/whats-new.md b/docs/whats-new.md index 08b9c8fe0..5f4f55dd9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -213,6 +213,9 @@ give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed. A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer becomes a keyword unnoticed. The suggestions further down stay put while you type. +- With several photos selected, the Keywords panel says how many photos its keywords belong to. The × on a keyword + is a larger target that lights up on its own, and taking a keyword off several photos at once now says which + keyword left how many photos, and that Edit ▸ Undo puts it back. ### HDR and panorama merges of CR2 keep their colour - Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and