Filters: open a library unfiltered, and show the active filters in the loupe

A date, keyword or person left over from the last session silently hid photos
behind a small badge, so view.json no longer stores or restores the filter (the
source, sort and selection still come back). In the loupe a small pill at the
canvas' top left names the active filters (the filmstrip and Next / Previous
follow them) and clears them on a click; it sits in the canvas margin, so the
photo does not move.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Voshart 2026-10-05 20:14:56 -04:00
parent e2e6db0f14
commit e5848aa1b2
3 changed files with 55 additions and 11 deletions

View File

@ -4,7 +4,7 @@
//! ```text
//! LightCraft Library/
//! catalog.snap catalog.log (lightcraft-catalog journal)
//! presets.json view.json (user presets + favourites; last source/filter/sort/selection)
//! presets.json view.json (user presets + favourites; last source/sort/selection)
//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export)
//! thumbs/ (rendered thumbnail cache, safe to delete)
//! Originals/ (photos imported with "copy into library")
@ -102,7 +102,8 @@ struct PresetsFile {
struct ViewFile {
source: LibrarySource,
browse: Option<crate::Browse>,
filter: lightcraft_catalog::Filter,
// No filter: a library opens unfiltered. A date, keyword or person left over from the last session
// would silently hide photos, with only a small badge to say so.
sort: lightcraft_catalog::Sort,
selection: Selection,
}
@ -346,7 +347,6 @@ impl Session {
if let Some(v) = settings.read::<ViewFile>(files.as_mut(), "view.json") {
self.source = v.source;
self.browse = v.browse;
self.filter = v.filter;
self.sort = v.sort;
self.selection = v.selection;
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
@ -494,17 +494,11 @@ impl Session {
}
fn view_json(&self) -> Vec<u8> {
let view = ViewFile {
source: self.source,
browse: self.browse.clone(),
filter: self.filter.clone(),
sort: self.sort,
selection: self.selection.clone(),
};
let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() };
serde_json::to_vec_pretty(&view).unwrap_or_default()
}
/// Save the view state (source, filter, sort, selection) if it changed since it was last
/// Save the view state (source, sort, selection) if it changed since it was last
/// written. Native hosts get this from [`Session::close_library`]; the browser host calls it
/// periodically, since a tab can be closed without notice.
pub fn save_view(&mut self) {

View File

@ -201,6 +201,25 @@ fn album_source_reports_in_library_state_and_persists_the_view() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A library opens unfiltered: the source and sort come back, a filter from the last session does not
/// (it would hide photos behind a small badge).
#[test]
fn filters_are_not_restored_when_a_library_reopens() {
let dir = temp_dir("filter-not-restored");
let mut s = open(&dir);
let all = s.visible_cloned().len();
s.execute("library.sort", &json!({"key": "fileName"})).unwrap();
s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap();
assert!(s.visible_cloned().len() < all);
s.close_library().unwrap();
drop(s);
let mut s2 = open(&dir);
assert_eq!(s2.filter, lightcraft_catalog::Filter::default());
assert_eq!(s2.visible_cloned().len(), all);
assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn filter_chips_clear_one_filter_and_total_ignores_the_filter() {
let dir = temp_dir("chips");

View File

@ -333,6 +333,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let map = CanvasMap::new(&frame, img_rect);
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
info_overlay(app, &p, canvas, &photo);
if !fullscreen {
filter_pill(app, ui, canvas);
}
if app.ui.face_boxes
&& let Some(index) = region_overlay(ui, &p, &map, &photo)
{
@ -476,6 +479,34 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
remove
}
/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous
/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the
/// canvas margin, so the photo does not move.
fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
if chips.is_empty() {
return;
}
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let label = chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225));
let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0));
let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0));
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
register(ui.ctx(), "loupe:filters", r);
p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 }));
let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y));
p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0);
let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Click to clear these filters").clicked() {
let _ = app.run("library.clearFilter", json!({}));
}
}
/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so;
/// hovering it explains (registered as `notice:previewOnly:loupe`).
fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {