mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-19 02:10:25 -05:00
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:
1 parent
deff6b6d06
commit
478f328cc0
9 files changed
+155
-11
No files matched your search
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 | ⬜ | | |
|
||||
|
||||
Reference in new issue
Block a user