diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 8308573863..ff94268a8b 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -240,6 +240,17 @@ fn has_person(p: &Photo, name: &str) -> bool { } impl Filter { + /// The labels that `label` and `labels` let through together (they AND), in `labels`' order; + /// `None` when neither is set, empty when they exclude each other. The chip and the filter + /// bar's buttons show this, so they match what is filtered. + pub fn labels_shown(&self) -> Option> { + match (self.label, self.labels.is_empty()) { + (None, true) => None, + (Some(l), true) => Some(vec![l]), + (l, false) => Some(self.labels.iter().copied().filter(|x| l.is_none_or(|l| l == *x)).collect()), + } + } + /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { self.describe_in(None) diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 82bdca4578..b2eb8cb949 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -314,6 +314,29 @@ fn smart_album_rule_sets() { assert!(a.to_string().contains("rating is ≥ 4"), "{a}"); } +/// `label` and `labels` set together (only through `library.filter`, or a filter saved with the +/// single label) AND, and the chip says what they let through, not their union. +#[test] +fn filter_label_and_labels_together_chip() { + use lightcraft_catalog::ColorLabel; + let mut s = crate::Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + for (id, l) in ids.iter().zip(["red", "yellow", "green"]) { + s.execute("photo.label", &json!({"ids": [id], "label": l})).unwrap(); + } + let reds = s.catalog.photos().filter(|p| p.in_library() && p.label == Some(ColorLabel::Red)).count(); + let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip"); + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "red"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), reds); + assert_eq!(chip(&s).label, "Label: Red"); + // nothing passes both: the chip names both constraints + s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "green"]})).unwrap(); + assert_eq!(s.visible_cloned().len(), 0); + assert_eq!(chip(&s).label, "Label: Red and (Yellow or Green)"); + s.execute("library.filter", &chip(&s).clear).unwrap(); + assert_eq!((s.filter.label, s.filter.labels.len()), (None, 0)); +} + /// The filter bar's label multi-select: any of the chosen labels. #[test] fn filter_any_of_several_labels() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 0d5cde3cf3..dbfb0fade6 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(fl) = f.flag { add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null})); } - let mut labels = f.labels.clone(); - labels.extend(f.label.filter(|l| !labels.contains(l))); - if !labels.is_empty() { - let names: Vec = labels.iter().map(|l| cat.label_name(*l)).collect(); - add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []})); + if let Some(shown) = f.labels_shown() { + let or = |ls: &[lightcraft_catalog::ColorLabel]| ls.iter().map(|l| cat.label_name(*l)).collect::>().join(" or "); + // empty: `label` and `labels` exclude each other, so name both constraints + let names = if shown.is_empty() { + // parenthesised when plural, so "Red and Yellow or Green" cannot read as "(Red and Yellow) or Green" + let plural = if f.labels.len() > 1 { format!("({})", or(&f.labels)) } else { or(&f.labels) }; + format!("{} and {plural}", f.label.map(|l| cat.label_name(l)).unwrap_or_default()) + } else { + or(&shown) + }; + add(format!("Label: {names}"), json!({"label": Null, "labels": []})); } if let Some(k) = f.kind { let n = match k { diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 5981a09a2d..6456a66241 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -2276,10 +2276,12 @@ "or drop a .onnx file on the window": "tai pudota .onnx-tiedosto ikkunaan", "or drop photos and folders into this window": "tai pudota kuvia ja kansioita tähän ikkunaan", "or drop photos into this window": "tai pudota kuvia tähän ikkunaan", + "pick": "valittu kuva", "photo has its denoised picture.": "kuvalla on kohinanpoistettu kuvansa.", "photo waiting": "kuva odottaa", "photos have their denoised picture.": "kuvilla on kohinanpoistetut kuvansa.", "photos waiting": "kuvaa odottaa", + "reject": "hylätty", "saved in": "tallennettu kohteeseen", "second": "sekunti", "starts using it for AI Denoise": "alkaa käyttää sitä tekoälykohinanpoistoon", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a5d05edacf..f3d8d94cb8 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2020,6 +2020,30 @@ mod tests { assert!(deep.is_some_and(|n| n > 500), "scrolled rows: {later:?}"); } + /// With `label` and `labels` both set (a saved single-label filter, or an agent), the filter + /// bar lights only the labels that pass both, and a click starts from those, not their union. + #[test] + fn filter_bar_labels_from_label_and_labels() { + use lightcraft_catalog::ColorLabel; + let mut h = demo([1400.0, 800.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "view.filterBar"}), t); + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["red", "yellow"]})).unwrap(); + // a frame so the bar is laid out for the new filter before the click + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + assert_eq!(r["ok"], true, "{r}"); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); + // nothing passes both: nothing is lit, so a click shows just that label + h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); + h.step(); + h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); + let f = h.app.session.filter.clone(); + assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); + h.settle(SETTLE); + } + /// The filter bar drives `library.filter` and saves the view as a smart album. #[test] fn filter_bar_filters_and_saves_a_smart_album() { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c9ee3878c7..9ba1cd5afb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1149,6 +1149,20 @@ mod tests { let no = format!(" {}", bool_text_in(Locale::De, false)); assert!(rules_label(&rules, &catalog).ends_with(&no), "{}", rules_label(&rules, &catalog)); } + let both = lightcraft_catalog::Filter { + label: Some(lightcraft_catalog::ColorLabel::Red), + labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Red], + ..Default::default() + }; + let name = |label| color_label(&app.session.catalog, label); + let (red, blue, green) = + (name(lightcraft_catalog::ColorLabel::Red), name(lightcraft_catalog::ColorLabel::Blue), name(lightcraft_catalog::ColorLabel::Green)); + let (label, and, or) = (tr("Label"), tr("and"), tr("or")); + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} {blue}")); + let both = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Blue, lightcraft_catalog::ColorLabel::Green], ..both }; + assert_eq!(filter_label(&both, &app.session.catalog), format!("{label}: {red} {and} ({blue} {or} {green})")); let filter = lightcraft_catalog::Filter { labels: vec![lightcraft_catalog::ColorLabel::Red, lightcraft_catalog::ColorLabel::Blue], ..Default::default() diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index 3a8f0ce8e7..e6945db013 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -41,10 +41,16 @@ pub(crate) fn display_label(chip: &FilterChip, filter: &lightcraft_catalog::Filt return format!("{}: {}", tr("Rules"), rules_label(rules, catalog)); } if chip.clear.get("label").is_some() { - let mut labels = filter.labels.clone(); - labels.extend(filter.label.filter(|label| !labels.contains(label))); - let names = labels.iter().map(|label| crate::i18n::color_label(catalog, *label)).collect::>(); - return format!("{}: {}", tr("Label"), names.join(&format!(" {} ", tr("or")))); + let shown = filter.labels_shown().unwrap_or_default(); + let name = |label: &lightcraft_catalog::ColorLabel| crate::i18n::color_label(catalog, *label); + let or = |labels: &[lightcraft_catalog::ColorLabel]| labels.iter().map(name).collect::>().join(&format!(" {} ", tr("or"))); + let names = if shown.is_empty() { + let plural = if filter.labels.len() > 1 { format!("({})", or(&filter.labels)) } else { or(&filter.labels) }; + format!("{} {} {plural}", filter.label.as_ref().map(name).unwrap_or_default(), tr("and")) + } else { + or(&shown) + }; + return format!("{}: {names}", tr("Label")); } if chip.clear.get("dateFrom").is_some() || chip.clear.get("dateTo").is_some() { return match (&filter.date_from, &filter.date_to) { diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 8b524df115..592baf32f9 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -158,13 +158,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // colour labels caption(ui, "Label"); - // several labels can be on at once (any of them matches) - let mut chosen: Vec = f.labels.clone(); - if let Some(l) = f.label - && !chosen.contains(&l) - { - chosen.push(l); - } + // several labels can be on at once (any of them matches); lit are those `label` and `labels` both let through + let chosen: Vec = f.labels_shown().unwrap_or_default(); for l in ColorLabel::ALL { let on = chosen.contains(&l); let name = format!("{l:?}").to_lowercase();