From 5720cf333db28ff03a8c238cc4656ed7c488b633 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 11:23:26 -0400 Subject: [PATCH] LR-PROF-DROPDOWN: profile favourites and recent (persisted with the library), grouped profile menu, amount slider Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/develop.rs | 20 ++++++++- crates/engine/src/cmd/query.rs | 10 ++++- crates/engine/src/lib.rs | 6 +++ crates/engine/src/library.rs | 16 +++++-- crates/engine/src/presets.rs | 27 ++++++++++++ crates/engine/src/tests_library.rs | 26 ++++++++++++ crates/ui-egui/src/panels/edit.rs | 67 ++++++++++++++++++++++++++---- docs/parity.md | 2 +- 8 files changed, 159 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 7b805ae2a..32ce552fc 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -265,10 +265,26 @@ pub fn specs() -> Vec { return Err(bad("develop.profile", format!("unknown profile `{id}`"))); } edit(s, "develop.profile", "Profile", |d| { - d.profile.id = id; + d.profile.id = id.clone(); d.profile.amount = f64_or(p, "amount", d.profile.amount).clamp(0.0, 200.0); Ok(()) - }) + })?; + s.note_profile_used(&id); + ok() + }), + cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| { + let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string(); + if crate::presets::profile(&id).is_none() { + return Err(bad("profile.favorite", format!("unknown profile `{id}`"))); + } + let on = s.profile_favorites.contains(&id); + let want = bool_or(p, "favorite", !on); + if want && !on { + s.profile_favorites.push(id); + } else if !want { + s.profile_favorites.retain(|f| *f != id); + } + Ok(json!({"favorite": want})) }), cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| { let ch = str_param(p, "channel").unwrap_or("master").to_string(); diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 5348c3519..1e05169b0 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -120,7 +120,15 @@ pub fn specs() -> Vec { cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| { Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect())) }), - cmd!(query "profiles.list", "List Profiles", [], None, "{}", always, |_, _| Ok(serde_json::to_value(crate::presets::PROFILES).unwrap_or_default())), + cmd!(query "profiles.list", "List Profiles", [], None, "{} — every profile with its group and favourite flag", always, |s, _| { + Ok(Value::Array( + crate::presets::PROFILES + .iter() + .map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": s.profile_favorites.iter().any(|f| f == p.id)})) + .collect(), + )) + }), + cmd!(query "profiles.menu", "Profile Menu", [], None, "{} — favourites, recent (newest first) and groups", always, |s, _| Ok(s.profile_menu())), cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| { let id = photo_arg(s, p, "history.list")?; let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 89b28fef9..8d1899b0b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -81,6 +81,10 @@ pub struct Session { /// Groups last used for Copy (Lightroom remembers them). pub copy_groups: Vec, pub presets: Vec, + /// Favourite profile ids (persisted with the library, like preset favourites). + pub profile_favorites: Vec, + /// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]). + pub profile_recent: Vec, /// Executed commands (actions / debugging / replay). pub journal: Vec<(String, Value)>, /// Ops applied since the last `drain_log` (for persistence). @@ -121,6 +125,8 @@ impl Session { clipboard: None, copy_groups: lightcraft_develop::SettingsGroup::default_copy(), presets: presets::builtin(), + profile_favorites: Vec::new(), + profile_recent: Vec::new(), journal: Vec::new(), pending_log: Vec::new(), media: media::MediaCache::default(), diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 5225a00a5..8b8a99824 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -72,6 +72,9 @@ pub struct LibraryStores { struct PresetsFile { user: Vec, favorites: Vec, + /// Favourite profiles, and the recently applied ones (newest first). + profile_favorites: Vec, + profile_recent: Vec, } #[derive(Default, Serialize, Deserialize)] @@ -103,10 +106,13 @@ struct PrefsFile { last_export: Option, } -fn presets_json(presets: &[Preset]) -> String { +fn presets_json(s: &Session) -> String { + let presets = &s.presets; let f = PresetsFile { user: presets.iter().filter(|p| !p.builtin).cloned().collect(), favorites: presets.iter().filter(|p| p.builtin && p.favorite).map(|p| p.id.clone()).collect(), + profile_favorites: s.profile_favorites.clone(), + profile_recent: s.profile_recent.clone(), }; serde_json::to_string_pretty(&f).unwrap_or_default() } @@ -151,6 +157,9 @@ impl Session { self.presets.push(u); } } + let known = |id: &String| crate::presets::profile(id).is_some(); + self.profile_favorites = f.profile_favorites.into_iter().filter(known).collect(); + self.profile_recent = f.profile_recent.into_iter().filter(known).take(crate::presets::RECENT_PROFILES).collect(); } // preferences let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); @@ -170,7 +179,7 @@ impl Session { { self.selection = Selection::single(*first); } - let presets_written = presets_json(&self.presets); + let presets_written = presets_json(self); if on_disk { self.media.attach_disk_cache(&dir.join("thumbs")); } @@ -196,7 +205,8 @@ impl Session { if lib.journal.wants_snapshot() && self.interaction.is_none() { lib.journal.snapshot(&self.catalog)?; } - let presets = presets_json(&self.presets); + let presets = presets_json(self); + let Some(lib) = self.library.as_mut() else { return Ok(()) }; if presets != lib.presets_written { if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { log::error!("library: presets: {e}"); diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index a9c06086b..c51361534 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -297,6 +297,33 @@ pub fn profile(id: &str) -> Option<&'static ProfileInfo> { PROFILES.iter().find(|p| p.id == id) } +/// How many recently used profiles the profile menu lists. +pub const RECENT_PROFILES: usize = 5; + +impl Session { + /// Remember `id` as the most recently applied profile. + pub fn note_profile_used(&mut self, id: &str) { + self.profile_recent.retain(|p| p != id); + self.profile_recent.insert(0, id.to_string()); + self.profile_recent.truncate(RECENT_PROFILES); + } + + /// The profile menu: favourites, recent, then every group (`profiles.menu`). + pub fn profile_menu(&self) -> Value { + let item = + |p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)}); + let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id)).map(item).collect::>(); + json!({ + "favorites": list(&self.profile_favorites), + "recent": list(&self.profile_recent), + "groups": profile_groups().into_iter().map(|g| json!({ + "name": g, + "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::>(), + })).collect::>(), + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index e3e757530..bd3397ca2 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -126,3 +126,29 @@ fn new_library_without_seed_is_empty_and_compacts() { assert_eq!(s2.catalog.albums().count(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn profile_favorites_and_recent_survive_reopen() { + let dir = temp_dir("profiles"); + let mut s = open(&dir, true); + s.execute("profile.favorite", &json!({"id": "lc.bw.sepia"})).unwrap(); + s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap(); + s.execute("profile.favorite", &json!({"id": "lc.vivid"})).unwrap(); // toggled off again + assert!(s.execute("profile.favorite", &json!({"id": "nope"})).is_err()); + for id in ["lc.neutral", "lc.vivid", "lc.film.cool-fade", "lc.mono", "lc.cine.teal-amber", "lc.muted.bleached", "lc.vivid"] { + s.execute("develop.profile", &json!({"id": id})).unwrap(); + } + let want_recent = ["lc.vivid", "lc.muted.bleached", "lc.cine.teal-amber", "lc.mono", "lc.film.cool-fade"]; + assert_eq!(s.profile_recent, want_recent, "newest first, deduplicated, at most 5"); + drop(s); // no clean close: every command persisted already + let mut s = open(&dir, false); + assert_eq!(s.profile_favorites, ["lc.bw.sepia"]); + assert_eq!(s.profile_recent, want_recent); + let menu = s.execute("profiles.menu", &json!({})).unwrap(); + assert_eq!(menu["favorites"][0]["name"], "Sepia Tone"); + assert_eq!(menu["recent"].as_array().unwrap().len(), 5); + assert_eq!(menu["groups"][0]["name"], "Basic"); + let list = s.execute("profiles.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|p| p["id"] == "lc.bw.sepia" && p["favorite"] == true)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 20d63a35c..7592ac197 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -104,17 +104,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim)); - let name = lightcraft_engine::presets::PROFILES.iter().find(|p| p.id == d.profile.id).map(|p| p.name).unwrap_or("Color"); + let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); let r = crate::widgets::dropdown(ui, "profile", name, t.font(15.0), t.text_label); - egui::Popup::menu(&r).show(|ui| { - for p in lightcraft_engine::presets::PROFILES { - if ui.selectable_label(p.id == d.profile.id, p.name).clicked() { - let _ = app.run("develop.profile", json!({"id": p.id})); - } - } - }); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); }); }); + if d.profile.id != "lc.color" { + control(app, ui, &d, "profile.amount", true); + ui.add_space(6.0); + } divider(ui); section(app, ui, &d, "light", "Light", |app, ui, d| { @@ -284,6 +282,59 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(40.0); } +/// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and +/// Browse…. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + use lightcraft_engine::presets::{PROFILES, profile, profile_groups}; + let t = Tokens::get(ui.ctx()); + ui.set_min_width(200.0); + let cur = d.profile.id.as_str(); + let mut pick: Option<&'static str> = None; + let item = |ui: &mut egui::Ui, key: &str, p: &'static lightcraft_engine::presets::ProfileInfo, pick: &mut Option<&'static str>| { + let r = ui.selectable_label(p.id == cur, p.name); + register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); + if r.clicked() { + *pick = Some(p.id); + } + }; + let heading = |ui: &mut egui::Ui, s: &str| { + ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim)); + }; + for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { + let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect(); + if list.is_empty() { + continue; + } + heading(ui, title); + for p in list { + item(ui, key, p, &mut pick); + } + ui.separator(); + } + for g in profile_groups() { + let r = ui.menu_button(g, |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p, &mut pick); + } + }); + register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + } + ui.separator(); + if let Some(p) = profile(cur) { + let fav = app.session.profile_favorites.iter().any(|f| f == p.id); + let label = if fav { format!("Remove “{}” from Favorites", p.name) } else { format!("Add “{}” to Favorites", p.name) }; + let r = ui.button(label); + register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); + if r.clicked() { + let _ = app.run("profile.favorite", json!({"id": p.id})); + } + } + if let Some(id) = pick { + let _ = app.run("develop.profile", json!({"id": id})); + } +} + fn sub_title(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); diff --git a/docs/parity.md b/docs/parity.md index ec67acc64..301bb7eb5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -292,7 +292,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `crates/ui-egui/src/panels/edit.rs` | no favourites / recent / browse entry | +| LR-PROF-DROPDOWN | Profile menu | P0 | 🟡 | `cmd:develop.profile`, `cmd:profiles.list`, `cmd:profiles.menu`, `cmd:profile.favorite`, `crates/ui-egui/src/panels/edit.rs` | Favorites, Recent (last 5), group submenus, favourite toggle; favourites/recent persist with the library; Amount slider under the menu for non-default profiles; no Browse… entry yet | | LR-PROF-BROWSER | Profile browser | P1 | ⬜ | | | | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |