diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 616cfb0f..4382160a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -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, - 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::(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 { - 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) { diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index d7436ff1..f23ae9bd 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -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"); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0fe2e592..0d1944b5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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::>().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) {