diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dda757ad3..0e1b653d5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,6 +151,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 439a15a1a..b4b753d68 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -82,6 +82,8 @@ impl AppliedByPhoto { } } +/// The Presets panel: the Favorites section of starred presets on top (issue #1999), then the +/// groups of built-in and user presets, each row applying its preset on click. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { @@ -166,12 +168,14 @@ 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); + // outside the `presetGroup:` ids, which a user group of any name can take + register(ui.ctx(), if favorites { "presetFavorites".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)), @@ -188,11 +192,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } - crate::widgets::context_menu(&resp, |ui| { - if ui.button(crate::i18n::tr("Export Group…")).clicked() { - let _ = app.run("file.exportPresets", json!({"group": g})); - } - }); + if !favorites { + crate::widgets::context_menu(&resp, |ui| { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { + let _ = app.run("file.exportPresets", json!({"group": g})); + } + }); + } if !open { continue; } @@ -205,7 +211,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 = marked.as_deref() == Some(pid.as_str()); if is_last { register(ui.ctx(), "presetApplied", r); @@ -326,6 +333,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, } @@ -340,12 +349,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..0dc9900ea --- /dev/null +++ b/crates/ui-egui/src/tests_preset_favorites.rs @@ -0,0 +1,81 @@ +//! 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; + +/// A headless app on the demo library with the Presets panel open and laid out. +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 +} + +/// Where the widget registered as `id` was drawn in the last frame, if it was. +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 (the Favorites header is `presetFavorites`). +fn first_group(h: &Headless) -> String { + let groups = h.app.widgets.iter().filter(|(w, _)| w.starts_with("presetGroup:") || w == "presetFavorites"); + groups.min_by(|a, b| a.1.top().total_cmp(&b.1.top())).map(|(w, _)| w.clone()).unwrap() +} + +/// Starring two presets makes a Favorites section the first header, listing them in list order +/// right under it, while each stays in its own group too. +#[test] +fn starred_presets_are_listed_in_favorites_on_top() { + let mut h = app(); + assert!(rect(&h, "presetFavorites").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), "presetFavorites"); + let header = rect(&h, "presetFavorites").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, "presetFavorites").is_none()); +} + +/// A user group named "Favorites" or "favorites" keeps its own header, apart from the section's: a +/// click aimed at either id lands on the right row (the widget lookup takes the last match). +#[test] +fn user_groups_named_favorites_keep_their_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(); + for group in ["Favorites", "favorites"] { + h.app.run("preset.create", json!({"name": format!("Mine in {group}"), "group": group})).unwrap(); + } + for _ in 0..3 { + h.step(); + } + let section = rect(&h, "presetFavorites").unwrap(); + for group in ["Favorites", "favorites"] { + let id = format!("presetGroup:{group}"); + let user = rect(&h, &id).unwrap_or_else(|| panic!("the user's {group:?} group is listed")); + assert_ne!(user, section, "{id} is its own row"); + assert_eq!(h.app.widgets.iter().filter(|(w, _)| *w == id).count(), 1, "{id} names one row"); + } + assert_eq!(h.app.widgets.iter().filter(|(w, _)| w == "presetFavorites").count(), 1); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 63043fec6..0f4cf7a2b 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; the preset applied to a photo stays marked on returning to it until the photo is edited further (issue #2392); 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) |