LR-FILT-SAVED + KEY-COMMIT: filter presets, Return commits a tool

- filter.savePreset / applyPreset / presets / deletePreset: the filter bar's settings saved
  under a name with the library; filter bar → Presets (apply, save current, delete).
- Return (tool.done) closes Crop / Remove / Red Eye / Masking back to the Edit panel.
- Parity: zoom while cropping verified (works), hover previews now include versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 16:41:04 -04:00
1 parent deff6b6d06
commit 478f328cc0
9 files changed
+155 -11

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
//! Filter presets: the filter bar's settings saved under a name ("Five stars", "Unedited raws")
//! and applied in one step. Saved with the library.
use lightcraft_catalog::Filter;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, str_param};
use crate::{Result, Session};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FilterPreset {
pub name: String,
pub filter: Filter,
}
fn list(s: &Session) -> Value {
json!(s.filter_presets)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.savePreset";
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();
if s.filter == Filter::default() {
return Err(bad(C, "the filter is empty: set some filters first"));
}
let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };
match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) {
Some(f) => *f = preset,
None => s.filter_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.applyPreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;
s.filter = f.filter.clone();
Ok(json!({"photos": s.visible().len()}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "filter.deletePreset";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
let n = s.filter_presets.len();
s.filter_presets.retain(|f| !f.name.eq_ignore_ascii_case(name));
if n == s.filter_presets.len() {
return Err(bad(C, format!("no filter preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "filter.presets", "Filter Presets", [], None, "{} → [{name, filter}]", always, |s, _| Ok(list(s))),
cmd!(
"filter.savePreset",
"Save Filter Preset",
[],
None,
"{name} — the current filter (library.filter), saved with the library",
always,
save
),
cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),
cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete),
]
}
+2
View File
@@ -10,6 +10,7 @@ mod color;
mod develop;
mod edit;
mod export;
pub mod filters;
mod keywords;
mod library;
mod manage;
@@ -127,6 +128,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(browse::specs());
v.extend(missing::specs());
v.extend(metadata::specs());
v.extend(filters::specs());
v
})
}
+3
View File
@@ -116,6 +116,8 @@ pub struct Session {
pub export_presets: Vec<export::ExportPreset>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
/// import state). See `cmd/before.rs`.
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
@@ -166,6 +168,7 @@ impl Session {
last_export: None,
export_presets: Vec::new(),
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
before: Default::default(),
import_probes: Default::default(),
import_defaults: import::ImportDefaults::default(),
+4
View File
@@ -110,6 +110,8 @@ struct PrefsFile {
export_presets: Vec<crate::export::ExportPreset>,
/// Metadata presets.
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
/// Filter presets.
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
/// Develop defaults for imported photos.
import: crate::import::ImportDefaults,
/// Thumbnail disk cache budget (MB, 0 = default).
@@ -177,6 +179,7 @@ impl Session {
self.last_export = prefs.last_export;
self.export_presets = prefs.export_presets;
self.metadata_presets = prefs.metadata_presets;
self.filter_presets = prefs.filter_presets;
self.import_defaults = prefs.import;
self.cache_mb = prefs.cache_mb;
// view state
@@ -290,6 +293,7 @@ impl Session {
last_export: self.last_export.clone(),
export_presets: self.export_presets.clone(),
metadata_presets: self.metadata_presets.clone(),
filter_presets: self.filter_presets.clone(),
import: self.import_defaults.clone(),
cache_mb: self.cache_mb,
})
+15
View File
@@ -406,3 +406,18 @@ fn stacks_move_and_split() {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.stack_of(before[3]).unwrap().photos.len(), 2);
}
#[test]
fn filter_presets_save_and_apply() {
let mut s = demo();
assert!(s.execute("filter.savePreset", &json!({"name": "Empty"})).is_err(), "nothing to save");
s.execute("library.filter", &json!({"rating": 4})).unwrap();
let four = s.visible_cloned().len();
s.execute("filter.savePreset", &json!({"name": "Best"})).unwrap();
s.execute("library.clearFilter", &json!({})).unwrap();
assert!(s.visible_cloned().len() > four);
let r = s.execute("filter.applyPreset", &json!({"name": "best"})).unwrap();
assert_eq!(r["photos"].as_u64(), Some(four as u64));
s.execute("filter.deletePreset", &json!({"name": "Best"})).unwrap();
assert!(s.filter_presets.is_empty());
}
+14
View File
@@ -748,6 +748,20 @@ mod tests {
assert_ne!(h.app.session.active(), Some(first), "advanced");
}
/// Return commits a tool panel back to Edit; elsewhere it does nothing.
#[test]
fn return_commits_the_crop_tool() {
let mut h = demo([1200.0, 800.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.crop"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Crop);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit);
h.request("ui.key", json!({"key": "Enter"}), t);
assert_eq!(h.app.ui.right, crate::state::RightPanel::Edit, "no-op outside tools");
}
/// ⌘Q (File → Quit LightCraft) closes the window.
#[test]
fn cmd_q_quits() {
+11
View File
@@ -42,6 +42,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("tool.done", "Done", Some("Enter"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
@@ -461,6 +462,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(Value::Null)
}
"tool.done" => {
// Return commits a tool panel (crop, remove, red eye, masking): back to Edit
use RightPanel::*;
if app.ui.dialog.is_none() && matches!(app.ui.right, Crop | Remove | RedEye | Masking) {
let _ = app.session.end_interaction();
app.ui.tool.clear();
app.ui.right = Edit;
}
Ok(Value::Null)
}
"panel.close" => {
app.ui.right = RightPanel::None;
app.ui.presets = false;
+24
View File
@@ -253,4 +253,28 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.search.clear();
let _ = app.run("library.clearFilter", json!({}));
}
// filter presets: apply one, save the current filter, delete
let presets = ui.button(egui::RichText::new("Presets ▾").font(t.font(12.5)));
register(ui.ctx(), "button:filterPresets", presets.rect);
egui::Popup::menu(&presets).show(|ui| {
let names: Vec<String> = app.session.filter_presets.iter().map(|f| f.name.clone()).collect();
for n in &names {
let r = ui.button(n);
if r.clicked() {
let _ = app.run("filter.applyPreset", json!({"name": n}));
}
r.context_menu(|ui| {
if ui.button("Delete Preset").clicked() {
let _ = app.run("filter.deletePreset", json!({"name": n}));
}
});
}
if names.is_empty() {
ui.label(egui::RichText::new("No filter presets yet").color(t.text_dim));
}
ui.separator();
if ui.add_enabled(filtering, egui::Button::new("Save Current Filter as Preset…")).clicked() {
crate::panels::dialogs::prompt(app, "Save Filter Preset", "Preset name", "", "filter.savePreset", json!({}), "name");
}
});
}
+11 -11
View File
@@ -24,11 +24,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| A. Import (IMP) | 7 | 2 | 4 | 1 | 3/4 (75%) | 3/4 (75%) |
| B. Library management (LIB) | 19 | 0 | 6 | 2 | 9/9 (100%) | 9/9 (100%) |
| C. Views & navigation (VIEW) | 14 | 0 | 3 | 0 | 9/9 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) |
| D. Search & filter (FILT) | 8 | 2 | 3 | 0 | 4/4 (100%) | 3/4 (75%) |
| E. Metadata (META) | 4 | 2 | 0 | 0 | 2/2 (100%) | 1/2 (50%) |
| F. Edit panel — global adjustments (EDIT) | 40 | 2 | 6 | 1 | 28/28 (100%) | 12/14 (86%) |
| G. Profiles (PROF) | 6 | 0 | 4 | 0 | 3/3 (100%) | 2/3 (67%) |
| H. Crop & rotate (CROP) | 8 | 1 | 0 | 1 | 6/6 (100%) | 2/3 (67%) |
| H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) |
| I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) |
| J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) |
| K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) |
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 12 | 1 | 4 | 1 | 8/8 (100%) | 4/5 (80%) |
| X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) |
| Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) |
| Z. Keyboard shortcuts (desktop) | 65 | 10 | 5 | 1 | 49/52 (94%) | 14/23 (61%) |
| Z. Keyboard shortcuts (desktop) | 66 | 9 | 5 | 1 | 49/52 (94%) | 15/23 (65%) |
| Lightroom Classic extras | 12 | 17 | 50 | 9 | — | 11/21 (52%) |
| **Total** | 319 | 54 | 127 | 37 | 193/198 (97%) | 107/144 (74%) |
| **Total** | 323 | 51 | 126 | 37 | 193/198 (97%) | 110/144 (76%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 85.1% of 144 · P2 17.7% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **69.7%** of 500 in-scope rows — P0 98.7% of 198 · P1 86.1% of 144 · P2 18.4% of 158.
<!-- /parity:summary -->
## Top gaps
@@ -183,7 +183,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | |
| LR-FILT-CULL | Culling-score filters | P2 | ⬜ | | |
| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ⬜ | | |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |
## E. Metadata (META)
@@ -277,7 +277,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
| LR-CROP-ZOOM | Zoom while cropping | P1 | 🟡 | | unverified |
| LR-CROP-ZOOM | Zoom while cropping | P1 | ✅ | `cmd:view.zoom100`, `cmd:view.zoomIn`, `crates/ui-egui/src/panels/detail.rs` | zoom levels apply with the crop tool open (verified: 100% shows the photo at native size with the crop frame) |
| LR-CROP-GENEXPAND | Generative expand | OOS | 🚫 | | |
## I. Remove / healing (REM)
@@ -474,7 +474,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits |
| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/detail.rs` | presets, profile menu and profile browser preview in the loupe; versions do not |
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe |
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre |
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
@@ -675,7 +675,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained
| KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | |
| KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | |
| KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back` | |
| KEY-COMMIT | Commit tool — Return | P1 | 🟡 | | edits apply live; no explicit commit step |
| KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) |
| KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching |
| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel |
@@ -697,7 +697,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-COLLECTIONS | Collections & sets | P1 | 🟡 | `cmd:album.create` | albums + folders; no smart / quick / target collections |
| LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | 🟡 | `cmd:album.createSmart`, `crates/catalog/src/query.rs` | the filter fields + date range; no rule editor, any/none groups or operators beyond ≥/=/≤ |
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album; no filter presets, lock, or multi-select columns |
| LRC-LIB-FILTERBAR | Library filter bar | P1 | 🟡 | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; no lock or multi-select columns |
| LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down |
| LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' |
| LRC-LIB-LABELS | Colour-label sets | P1 | ⬜ | | |