From 6dd3f160bca1e78c73ab02d46cc754b3a075ffe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:20:04 +0000 Subject: [PATCH] #1999: starred presets in a Favorites section on top of the Presets panel As in the Profile browser, starred presets are listed in a Favorites section above the groups, and stay in their own group too. The section has its own open state and widget ids, so a user group named "Favorites" and the same preset listed twice don't clash. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/presets.rs | 37 +++++++---- crates/ui-egui/src/tests_preset_favorites.rs | 67 ++++++++++++++++++++ docs/parity-checklist.md | 2 +- 4 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/tests_preset_favorites.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..a694ca1f7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,6 +134,8 @@ mod tests_open_lightroom; #[cfg(test)] mod tests_panels; #[cfg(test)] +mod tests_preset_favorites; +#[cfg(test)] mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index ca0243cc5..321cbdb5c 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -127,12 +127,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // imported whole is 10 000+ presets, and building every row each frame took seconds. let visible = ui.clip_rect().expand(row_h); for grp in groups.iter() { - let (g, builtin) = (grp.name.clone(), grp.builtin); - let open_id = egui::Id::new(("preset-group", &g)); + let (g, builtin, favorites) = (grp.name.clone(), grp.builtin, grp.favorites); + // the Favorites section opens and closes apart from a user group named "Favorites" + let open_id = egui::Id::new(("preset-group", favorites, &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); crate::widgets::announce(&resp, g.to_string()); - register(ui.ctx(), format!("presetGroup:{g}"), r); + register(ui.ctx(), if favorites { "presetGroup:favorites".to_string() } else { format!("presetGroup:{g}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), @@ -149,11 +150,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } - resp.context_menu(|ui| { - if ui.button(crate::i18n::tr("Export Group…")).clicked() { - let _ = app.run("file.exportPresets", json!({"group": g})); - } - }); + if !favorites { + resp.context_menu(|ui| { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { + let _ = app.run("file.exportPresets", json!({"group": g})); + } + }); + } if !open { continue; } @@ -166,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); crate::widgets::announce(&resp, name.clone()); - register(ui.ctx(), format!("preset:{pid}"), r); + // a starred preset is listed twice: in Favorites and in its group + register(ui.ctx(), if favorites { format!("presetFavorite:{pid}") } else { format!("preset:{pid}") }, r); let is_last = last.as_ref().is_some_and(|l| l.id == pid); if is_last { ui.painter().rect_filled(r, 0.0, t.tool_active); @@ -280,6 +284,8 @@ struct Group { builtin: bool, /// Holds a user preset (a Move to Group target). user: bool, + /// The Favorites section on top (issue #1999): the starred presets, each also in its own group. + favorites: bool, items: Vec, } @@ -294,12 +300,21 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc> { } let mut by_name: BTreeMap<&str, Group> = BTreeMap::new(); for (i, p) in presets.iter().enumerate() { - let g = by_name.entry(&p.group).or_insert_with(|| Group { name: p.group.clone(), builtin: false, user: false, items: Vec::new() }); + let g = by_name.entry(&p.group).or_insert_with(|| Group { + name: p.group.clone(), + builtin: false, + user: false, + favorites: false, + items: Vec::new(), + }); g.builtin |= p.builtin; g.user |= !p.builtin; g.items.push(i); } - let groups = Arc::new(by_name.into_values().collect::>()); + // starred presets first, in their own section, as Profiles does + let starred: Vec = presets.iter().enumerate().filter(|(_, p)| p.favorite).map(|(i, _)| i).collect(); + let favorites = (!starred.is_empty()).then(|| Group { name: "Favorites".into(), builtin: true, user: false, favorites: true, items: starred }); + let groups = Arc::new(favorites.into_iter().chain(by_name.into_values()).collect::>()); ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone()))); groups } diff --git a/crates/ui-egui/src/tests_preset_favorites.rs b/crates/ui-egui/src/tests_preset_favorites.rs new file mode 100644 index 000000000..eef735031 --- /dev/null +++ b/crates/ui-egui/src/tests_preset_favorites.rs @@ -0,0 +1,67 @@ +//! Issue #1999: starred presets are listed in a Favorites section at the top of the Presets panel, +//! as starred profiles are in the Profile browser, and stay in their own group too. + +use crate::{LightcraftApp, Services, headless::Headless}; +use serde_json::json; + +fn app() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.app.run("panel.presets", json!({})).unwrap(); + for _ in 0..5 { + h.step(); + } + h +} + +fn rect(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +/// The topmost preset group header on screen. +fn first_group(h: &Headless) -> String { + let groups = h.app.widgets.iter().filter(|(w, _)| w.starts_with("presetGroup:")); + groups.min_by(|a, b| a.1.top().total_cmp(&b.1.top())).map(|(w, _)| w.clone()).unwrap() +} + +#[test] +fn starred_presets_are_listed_in_favorites_on_top() { + let mut h = app(); + assert!(rect(&h, "presetGroup:favorites").is_none(), "no section while nothing is starred"); + let first = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + let last = h.app.session.presets.last().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": last})).unwrap(); + h.app.run("preset.favorite", json!({"id": first})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert_eq!(first_group(&h), "presetGroup:favorites"); + let header = rect(&h, "presetGroup:favorites").unwrap(); + // in list order, right under the header + let a = rect(&h, &format!("presetFavorite:{first}")).unwrap(); + let b = rect(&h, &format!("presetFavorite:{last}")).unwrap(); + assert!(header.bottom() <= a.top() && a.top() < b.top(), "{header:?} {a:?} {b:?}"); + // still in its own group + assert!(rect(&h, &format!("preset:{first}")).is_some()); + // unstarring the last one removes the section + for id in [&first, &last] { + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + assert!(rect(&h, "presetGroup:favorites").is_none()); +} + +#[test] +fn a_user_group_named_favorites_keeps_its_own_header() { + let mut h = app(); + let id = h.app.session.presets.first().map(|p| p.id.clone()).unwrap(); + h.app.run("preset.favorite", json!({"id": id})).unwrap(); + h.app.run("preset.create", json!({"name": "Mine", "group": "Favorites"})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert!(rect(&h, "presetGroup:favorites").is_some()); + assert!(rect(&h, "presetGroup:Favorites").is_some(), "the user's group is still there"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 95dcd2aef..1fe58aa14 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -321,7 +321,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-PRE-PANEL | Presets panel | P0 | ✅ | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; resting on a preset previews it in the loupe (no history entry); optional live thumbnails (⋯ → Show Thumbnails) | | LR-PRE-CREATE | Create preset | P0 | ✅ | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | name, group and a checklist of settings groups (crop, masks, remove, red eye off by default; All / None) | -| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); no hiding of groups | +| LR-PRE-MANAGE | Manage presets | P1 | ✅ | `cmd:preset.delete`, `cmd:preset.favorite`, `cmd:preset.import`, `cmd:preset.export`, `cmd:preset.rename`, `cmd:preset.update`, `cmd:preset.move` | rename, update with current settings, move to a group (existing or new); starred presets are listed in a Favorites section on top, as in the Profile browser (issue #1999, `crates/ui-egui/src/tests_preset_favorites.rs`); no hiding of groups | | LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | | | LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | | LR-PRE-PREMIUM | Built-in presets (own) | P2 | ✅ | `crates/engine/src/presets.rs` | 41 own-authored presets in 10 groups (Color, Film, B&W incl. toners, Portrait, Landscape, Urban, Food, Seasons, Vintage, Style) |