From 431186a24e4ebb32a18e7f0ca1e22cd4d1ded41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20L?= Date: Sun, 4 Oct 2026 10:53:25 +0200 Subject: [PATCH] Library: show active filters as removable chips and count matching of total photos (#25) A filter set from the sidebar (By Date), the search field or the filter bar narrows the current source, but nothing said so: a folder with a hidden date filter looked empty. - Chips under the grid header, one per active filter, each with an X that clears only that filter; "+N more" lists the ones that do not fit; Clear all. - The header counts "N of M photos" (matching of the source's total) while a filter is on; the total is cached per source/catalog revision. - A badge on the top-bar filter icon counts the active filters, filter bar open or not. - An empty result says no photos match the active filters, and how many the source holds, instead of implying the folder is empty. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/lib.rs | 25 ++++++- crates/engine/src/tests_organize.rs | 23 ++++++ crates/engine/src/view.rs | 93 ++++++++++++++++++++++++ crates/ui-egui/src/panels/chips.rs | 109 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/grid.rs | 14 +++- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/topbar.rs | 12 +++ docs/parity.md | 2 +- 8 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/panels/chips.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 982b74af..4c6a3fdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -37,7 +37,7 @@ use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort}; use lightcraft_develop::DevelopSettings; pub use media::{RenderJob, SourceLevel}; use serde_json::Value; -pub use view::{Browse, LibrarySource, Selection}; +pub use view::{Browse, FilterChip, LibrarySource, Selection, filter_chips}; pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline}; #[derive(Debug, thiserror::Error)] @@ -83,6 +83,8 @@ pub struct Session { /// The grid order for the current source/filter/sort (cached by catalog revision). visible: Vec, visible_key: Option<(u64, String)>, + /// Photos in the current source with no filter on (cached like `visible`). + total: Option<((u64, String), usize)>, pub undo: Vec, pub redo: Vec, pub interaction: Option, @@ -174,6 +176,7 @@ impl Session { selection: Selection::default(), visible: Vec::new(), visible_key: None, + total: None, undo: Vec::new(), redo: Vec::new(), interaction: None, @@ -512,6 +515,26 @@ impl Session { &self.visible } + /// Photos in the current source (folder, album, …) before the filter bar and search narrow + /// them; `None` where that is not a separate number (Missing Photos). + pub fn source_total(&mut self) -> Option { + if self.source == LibrarySource::Missing { + return None; + } + let key = (self.catalog.revision, format!("{:?}|{:?}", self.source, self.browse)); + if self.total.as_ref().map(|t| &t.0) != Some(&key) { + let mut f = self.source.to_filter(&Filter::default(), &self.catalog); + if self.source == LibrarySource::Folder { + let b = self.browse.clone().unwrap_or_default(); + f.folder = Some(b.path); + f.subfolders = b.subfolders; + } + let n = self.catalog.query(&f, &self.sort).len(); + self.total = Some((key, n)); + } + self.total.as_ref().map(|t| t.1) + } + pub fn visible_cloned(&mut self) -> Vec { self.visible().to_vec() } diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index f40511f8..d7436ff1 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -200,3 +200,26 @@ fn album_source_reports_in_library_state_and_persists_the_view() { assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored too"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { + let dir = temp_dir("chips"); + let mut s = open(&dir); + let all = s.visible_cloned().len(); + assert!(all > 1); + assert_eq!(s.source_total(), Some(all)); + s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap(); + // the total is the source's, whatever the filter hides + assert_eq!(s.source_total(), Some(all)); + let chips = crate::filter_chips(&s.filter, &s.catalog); + let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect(); + assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]); + // clearing the date chip leaves the other two filters alone + s.execute("library.filter", &chips[2].clear).unwrap(); + assert_eq!(s.filter.date, None); + assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x")); + for c in crate::filter_chips(&s.filter, &s.catalog) { + s.execute("library.filter", &c.clear).unwrap(); + } + assert_eq!(s.filter, Default::default()); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 8047225f..b64ff04a 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -108,3 +108,96 @@ impl Selection { self.active = Some(id); } } + +/// One active filter, shown as a removable chip above the grid. +#[derive(Clone, Debug, PartialEq)] +pub struct FilterChip { + pub label: String, + /// `library.filter` patch that clears just this filter. + pub clear: serde_json::Value, +} + +/// The chips for every constraint in the filter bar / search / sidebar (not the source itself). +pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { + use lightcraft_catalog::{MediaKind, RatingOp}; + use serde_json::{Value::Null, json}; + let mut v = Vec::new(); + let mut add = |label: String, clear: serde_json::Value| v.push(FilterChip { label, clear }); + if !f.text.trim().is_empty() { + add(format!("Search: “{}”", f.text.trim()), json!({"text": ""})); + } + if f.rating > 0 { + let op = match f.rating_op { + RatingOp::AtLeast => "≥", + RatingOp::Exactly => "=", + RatingOp::AtMost => "≤", + }; + add(format!("Rating {op} {}★", f.rating), json!({"rating": 0})); + } + 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(k) = f.kind { + let n = match k { + MediaKind::Image => "Photos", + MediaKind::Raw => "Raw", + MediaKind::Video => "Videos", + }; + add(format!("Type: {n}"), json!({"kind": Null})); + } + if let Some(m) = &f.merged { + let n = match m.as_str() { + "hdr" => "HDR", + "panorama" => "Panoramas", + "hdrPanorama" => "HDR panoramas", + _ => "Merged", + }; + add(format!("Type: {n}"), json!({"merged": Null})); + } + if let Some(e) = f.edited { + add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null})); + } + for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] { + if let Some(x) = val { + add(format!("{name}: {x}"), json!({key: Null})); + } + } + if let Some(d) = &f.date { + add(format!("Date: {}", date_label(d)), json!({"date": Null})); + } + if let Some(d) = &f.imported { + add(format!("Imported: {}", date_label(d)), json!({"imported": Null})); + } + match (&f.date_from, &f.date_to) { + (Some(a), Some(b)) => add(format!("Captured {a} – {b}"), json!({"dateFrom": Null, "dateTo": Null})), + (Some(a), None) => add(format!("Captured from {a}"), json!({"dateFrom": Null})), + (None, Some(b)) => add(format!("Captured until {b}"), json!({"dateTo": Null})), + _ => {} + } + if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { + add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null})); + } + if !f.only.is_empty() { + add(format!("Only {} photos", f.only.len()), json!({"only": []})); + } + v +} + +/// `2026-01-16` → "January 16, 2026"; `2026-01` → "January 2026"; anything else as is. +fn date_label(d: &str) -> String { + const MONTHS: [&str; 12] = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + let parts: Vec<&str> = d.split('-').collect(); + let month = parts.get(1).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]); + match (parts.as_slice(), month) { + ([y, _, day], Some(m)) => format!("{m} {}, {y}", day.trim_start_matches('0')), + ([y, _], Some(m)) => format!("{m} {y}"), + _ => d.to_string(), + } +} diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs new file mode 100644 index 00000000..a5aae79a --- /dev/null +++ b/crates/ui-egui/src/panels/chips.rs @@ -0,0 +1,109 @@ +//! Active-filter chips under the grid header: one removable chip per constraint (search, rating, +//! date, keyword…), "+N more" for what does not fit, and "Clear all". The count next to them says +//! how many of the source's photos match, so an empty grid never looks like an empty folder. + +use egui::{Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_engine::FilterChip; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +pub const HEIGHT: f32 = 32.0; +const GAP: f32 = 6.0; +/// Room kept for "+N more" and "Clear all". +const TAIL: f32 = 170.0; + +/// "14 of 120 photos" when a filter narrows the source, else "14 photos". +pub fn count_text(matching: usize, total: Option, filtering: bool) -> String { + match total { + Some(t) if filtering && t != matching => format!("{matching} of {t} photos"), + _ => format!("{matching} photos"), + } +} + +/// Width of a chip with `label` (text + the × button). +fn chip_width(ui: &egui::Ui, label: &str, font: egui::FontId) -> f32 { + ui.painter().layout_no_wrap(label.to_string(), font, egui::Color32::WHITE).size().x + 12.0 + 22.0 +} + +/// Draws the strip; does nothing without chips. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { + if chips.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEIGHT), Sense::hover()); + ui.painter().rect_filled(bar, 0.0, t.canvas); + register(ui.ctx(), "filterchips", bar); + let font = t.font(12.0); + let right = bar.right() - 16.0; + let mut x = bar.left() + 20.0; + let mut shown = 0; + for (i, c) in chips.iter().enumerate() { + let w = chip_width(ui, &c.label, font.clone()); + // always show the first chip; keep room for the tail unless this is the last one that fits + let room = right - x - if i + 1 == chips.len() { 90.0 } else { TAIL }; + if i > 0 && w > room { + break; + } + let r = Rect::from_min_size(pos2(x, bar.center().y - 11.0), vec2(w.min(right - x), 22.0)); + ui.painter().rect(r, 11.0, t.field, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), StrokeKind::Inside); + let text_r = Rect::from_min_max(r.min, pos2(r.right() - 22.0, r.max.y)); + ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &c.label, font.clone(), t.text); + let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); + let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text("Remove this filter"); + register(ui.ctx(), format!("chip:{i}"), xr); + let col = if resp.hovered() { t.text } else { t.text_dim }; + let m = 3.5; + let (a, b) = (xr.center() - vec2(m, m), xr.center() + vec2(m, m)); + ui.painter().line_segment([a, b], Stroke::new(1.3, col)); + ui.painter().line_segment([pos2(a.x, b.y), pos2(b.x, a.y)], Stroke::new(1.3, col)); + if resp.clicked() { + let _ = app.run("library.filter", c.clear.clone()); + if c.clear.get("text").is_some() { + app.ui.search.clear(); + } + } + x = r.right() + GAP; + shown = i + 1; + } + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(x, bar.top() + 4.0), pos2(right, bar.bottom() - 4.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 8.0; + let hidden = &chips[shown..]; + if !hidden.is_empty() { + let more = crate::widgets::text_button(&mut child, "chipsMore", &format!("+{} more", hidden.len()), false); + egui::Popup::menu(&more).show(|ui| { + for c in hidden { + if ui.button(format!("{} ✕", c.label)).on_hover_text("Remove this filter").clicked() { + let _ = app.run("library.filter", c.clear.clone()); + if c.clear.get("text").is_some() { + app.ui.search.clear(); + } + } + } + }); + } + if crate::widgets::text_button(&mut child, "chipsClearAll", "Clear all", false).clicked() { + app.ui.search.clear(); + let _ = app.run("library.clearFilter", json!({})); + } +} + +#[cfg(test)] +mod tests { + use super::count_text; + + #[test] + fn count_says_matching_of_total_only_when_a_filter_narrows() { + assert_eq!(count_text(0, Some(14), true), "0 of 14 photos"); + assert_eq!(count_text(14, Some(14), true), "14 photos"); + assert_eq!(count_text(5, Some(14), false), "5 photos"); + assert_eq!(count_text(3, None, true), "3 photos"); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6d4f98f..53749bb6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -32,7 +32,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().rect_filled(hr, 0.0, t.canvas); let sel_n = app.session.selection.ids.len(); - let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) }; + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + let total = app.session.source_total(); + let counted = super::chips::count_text(ids.len(), total, !chips.is_empty()); + let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted }; match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt), None => { @@ -41,12 +44,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); } } + super::chips::show(app, ui, &chips); if app.ui.filter_bar { super::filterbar::show(app, ui); } app.canvas_rect = Some(ui.max_rect()); if ids.is_empty() { - if app.session.source == lightcraft_engine::LibrarySource::Folder { + if !chips.is_empty() { + let body = match total { + Some(n) if n > 0 => format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog)), + _ => "Remove a filter above, or choose Clear all".to_string(), + }; + super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body); + } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); } else if app.session.filter != Default::default() { super::empty_message(ui, ui.max_rect(), "No matching photos", "Change the filter, or clear it (View → Clear Filters)"); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 21833936..06f1bf90 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Window regions and panels. pub mod bottombar; +pub mod chips; pub mod compare; pub mod crop_overlay; pub mod detail; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 3ce3a4e5..dc0b0638 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -94,6 +94,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { t.icon }, ); + // badge: how many filters are on, even with the filter bar closed + let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len(); + if active > 0 { + let c = fr.right_top() + vec2(-3.0, 8.0); + ui.painter().circle_filled(c, 7.0, t.accent); + ui.painter().text(c, Align2::CENTER_CENTER, active.to_string(), t.semibold(9.5), t.canvas); + } + let fresp = fresp.on_hover_text(if active > 0 { + format!("Filter bar — {active} active filter{}", if active == 1 { "" } else { "s" }) + } else { + "Filter bar".into() + }); if fresp.clicked() { let _ = app.run("view.filterBar", json!({})); } diff --git a/docs/parity.md b/docs/parity.md index 4f45ebbd..7880dda8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -120,7 +120,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | -| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with counts; a row filters by that date | +| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) |