Merge pull request #45 from GlemNL/fix/issue-25-active-filters

Show active filters as chips and count matching of total photos
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-04 10:19:04 -04:00
commit 4b863e9d47
8 files changed
+275 -4

No files matched your search

+24 -1
View File
@@ -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<PhotoId>,
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<UndoEntry>,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
@@ -176,6 +178,7 @@ impl Session {
selection: Selection::default(),
visible: Vec::new(),
visible_key: None,
total: None,
undo: Vec::new(),
redo: Vec::new(),
interaction: None,
@@ -515,6 +518,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<usize> {
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<PhotoId> {
self.visible().to_vec()
}
+23
View File
@@ -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());
}
+93
View File
@@ -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<FilterChip> {
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<String> = 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::<usize>().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(),
}
}
+109
View File
@@ -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<usize>, 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");
}
}
+12 -2
View File
@@ -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)");
+1
View File
@@ -1,6 +1,7 @@
//! Window regions and panels.
pub mod bottombar;
pub mod chips;
pub mod compare;
pub mod crop_overlay;
pub mod detail;
+12
View File
@@ -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!({}));
}
+1 -1
View File
@@ -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`) |