Merge PR #2032: #2032 ae8e8053d #1999: starred presets in a Favorites section on top of the Presets pa

This commit is contained in:
francoisgn
2026-10-23 16:38:37 +03:00
4 changed files with 113 additions and 12 deletions
+2
View File
@@ -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;
+29 -11
View File
@@ -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<usize>,
}
@@ -340,12 +349,21 @@ fn grouped(ctx: &egui::Context, presets: &PresetList) -> Arc<Vec<Group>> {
}
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::<Vec<_>>());
// starred presets first, in their own section, as Profiles does
let starred: Vec<usize> = 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::<Vec<_>>());
ctx.data_mut(|d| d.insert_temp(id, (presets.generation(), groups.clone())));
groups
}
@@ -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<egui::Rect> {
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);
}
+1 -1
View File
@@ -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) |