This commit is contained in:
Brandon Thomas
2026-10-24 10:20:13 -04:00
8 changed files with 97 additions and 16 deletions
+11
View File
@@ -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<Vec<ColorLabel>> {
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)
+23
View File
@@ -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<u64> = 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() {
+11 -5
View File
@@ -161,11 +161,17 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
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<String> = 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::<Vec<_>>().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 {
+2
View File
@@ -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",
+24
View File
@@ -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() {
+14
View File
@@ -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()
+10 -4
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>().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) {
+2 -7
View File
@@ -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<ColorLabel> = 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<ColorLabel> = f.labels_shown().unwrap_or_default();
for l in ColorLabel::ALL {
let on = chosen.contains(&l);
let name = format!("{l:?}").to_lowercase();