LR-PROF-DROPDOWN: profile favourites and recent (persisted with the library), grouped profile menu, amount slider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 11:23:26 -04:00
co-authored by Claude Opus 5.5
parent 9c4526129e
commit 5720cf333d
8 changed files with 159 additions and 15 deletions
+18 -2
View File
@@ -265,10 +265,26 @@ pub fn specs() -> Vec<CommandSpec> {
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();
+9 -1
View File
@@ -120,7 +120,15 @@ pub fn specs() -> Vec<CommandSpec> {
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"))?;
+6
View File
@@ -81,6 +81,10 @@ pub struct Session {
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
pub profile_recent: Vec<String>,
/// 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(),
+13 -3
View File
@@ -72,6 +72,9 @@ pub struct LibraryStores {
struct PresetsFile {
user: Vec<Preset>,
favorites: Vec<String>,
/// Favourite profiles, and the recently applied ones (newest first).
profile_favorites: Vec<String>,
profile_recent: Vec<String>,
}
#[derive(Default, Serialize, Deserialize)]
@@ -103,10 +106,13 @@ struct PrefsFile {
last_export: Option<serde_json::Value>,
}
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::<PrefsFile>(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}");
+27
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>(),
})).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
+26
View File
@@ -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);
}
+59 -8
View File
@@ -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());
+1 -1
View File
@@ -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 | ⬜ | | |