mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
8 files changed
+275
-4
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
//! Window regions and panels.
|
||||
|
||||
pub mod bottombar;
|
||||
pub mod chips;
|
||||
pub mod compare;
|
||||
pub mod crop_overlay;
|
||||
pub mod detail;
|
||||
|
||||
@@ -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
@@ -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`) |
|
||||
|
||||
Reference in new issue
Block a user