LR-PRE-PANEL + LR-PRE-CREATE: preset hover preview in the loupe, optional preset thumbnails, Create Preset group checklist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:54:14 -04:00
co-authored by Claude Opus 5.5
parent 62a2860c7e
commit 32dd3a9588
7 changed files with 184 additions and 61 deletions
+50
View File
@@ -385,6 +385,56 @@ mod tests {
h.settle(SETTLE);
}
/// Presets column: resting on a preset previews it in the loupe without a history entry;
/// thumbnails are variant renders; Create Preset includes only the checked groups.
#[test]
fn preset_hover_previews_and_create_preset_groups() {
let mut h = demo([1400.0, 900.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail"}), t);
h.request("engine.execute", json!({"command": "panel.presets"}), t);
h.settle(SETTLE);
let id = h.app.session.active().unwrap();
let photo = |h: &Headless| h.app.session.catalog.photo(id).unwrap().clone();
let before = photo(&h);
h.request("ui.hoverWidget", json!({"id": "preset:lc.bw-high-contrast"}), t);
h.settle(SETTLE);
h.step();
assert_eq!(h.app.hover_preview.as_ref().map(|p| p.label.as_str()), Some("Preset: High Contrast B&W"));
assert_eq!(h.app.loupe_shown.map(|l| l.1), Some("hover"));
let after = photo(&h);
assert_eq!(after.develop, before.develop);
assert_eq!(after.history.len(), before.history.len(), "no history entry while hovering");
assert!(h.app.session.undo.is_empty());
// thumbnails: variant textures for the visible presets
h.app.ui.preset_thumbs = true;
h.request("ui.move", json!({"x": 5, "y": 500}), t);
h.settle(SETTLE);
h.step();
assert!(h.app.hover_preview.is_none());
assert!(h.app.renderer.variant_textures() >= 5, "{}", h.app.renderer.variant_textures());
// Create Preset with a group checklist: untick Light, keep Effects
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t);
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "effects.clarity", "value": 25}}), t);
h.app.ui.dialog = Some(crate::state::Dialog::create_preset());
h.step();
h.step();
let r = h.request("ui.clickWidget", json!({"id": "presetInclude:light"}), t);
assert_eq!(r["ok"], true, "{r}");
match &h.app.ui.dialog {
Some(crate::state::Dialog::CreatePreset { groups, .. }) => {
assert!(!groups.iter().any(|g| g == "light") && groups.iter().any(|g| g == "effects"), "{groups:?}");
assert!(!groups.iter().any(|g| g == "crop" || g == "masks"), "crop and masks off by default");
}
d => panic!("dialog: {d:?}"),
}
let r = h.request("ui.dialog.confirm", json!({}), t);
assert_eq!(r["ok"], true, "{r}");
let p = h.app.session.presets.iter().find(|p| !p.builtin).expect("created").clone();
assert!(p.settings.get("light").is_none() && p.settings["effects"]["clarity"] == 25.0, "{}", p.settings);
h.settle(SETTLE);
}
/// The filter bar drives `library.filter` and saves the view as a smart album.
#[test]
fn filter_bar_filters_and_saves_a_smart_album() {
+1 -1
View File
@@ -339,7 +339,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"dialog.createPreset" => {
app.ui.dialog = Some(Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
app.ui.dialog = Some(Dialog::create_preset());
Ok(Value::Null)
}
"dialog.copySettings" => {
+44 -20
View File
@@ -77,26 +77,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
confirm = true;
}
}
Dialog::CreatePreset { name, group } => {
Dialog::CreatePreset { name, group, groups } => {
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim));
}
Dialog::CopySettings { groups } => {
ui.columns(2, |cols| {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
let mut on = groups.contains(&key);
if cols[i % 2].checkbox(&mut on, g.label()).changed() {
if on {
groups.push(key);
} else {
groups.retain(|x| *x != key);
}
}
}
});
ui.label(egui::RichText::new("Settings to include").color(t.text_dim));
group_checklist(ui, "presetInclude", groups);
}
Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups),
Dialog::Export { opts, long_edge, limit_kb, dir } => {
use lightcraft_engine::export::{Anchor as P, ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S};
let n = app.session.selection.ids.len().max(1);
@@ -282,9 +269,14 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})),
Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})),
Dialog::CreatePreset { name, group } => {
app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group}))
}
Dialog::CreatePreset { name, group, groups } => app.run(
"preset.create",
json!({
"name": if name.trim().is_empty() { "My Preset" } else { name },
"group": if group.trim().is_empty() { "User Presets" } else { group },
"groups": groups,
}),
),
Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})),
Dialog::Export { opts, long_edge, limit_kb, dir } => app.run(
"app.export",
@@ -301,6 +293,38 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
// ------------------------------------------------------------------------------------------ dialog widgets
/// Two columns of settings-group checkboxes (Copy Settings, Create Preset) plus All / None.
fn group_checklist(ui: &mut egui::Ui, tag: &str, groups: &mut Vec<String>) {
let key_of = |g: &SettingsGroup| serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
ui.columns(2, |cols| {
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
let key = key_of(g);
let mut on = groups.contains(&key);
let r = cols[i % 2].checkbox(&mut on, g.label());
crate::widgets::register(&r.ctx, format!("{tag}:{key}"), r.rect);
if r.changed() {
if on {
groups.push(key);
} else {
groups.retain(|x| *x != key);
}
}
}
});
ui.horizontal(|ui| {
let all = ui.small_button("All");
crate::widgets::register(ui.ctx(), format!("{tag}:all"), all.rect);
if all.clicked() {
*groups = SettingsGroup::ALL.iter().map(key_of).collect();
}
let none = ui.small_button("None");
crate::widgets::register(ui.ctx(), format!("{tag}:none"), none.rect);
if none.clicked() {
groups.clear();
}
});
}
/// Width of the label column in dialogs.
const LABEL_W: f32 = 78.0;
+57 -13
View File
@@ -1,16 +1,21 @@
//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview,
//! amount slider, create/favourite, import/export of preset files.
//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview
//! (the loupe shows the photo with the preset while the pointer rests on it; nothing is
//! committed), optional live thumbnails, amount slider, create/favourite, import/export of preset
//! files.
use std::collections::BTreeMap;
use egui::{Align2, Rect, Sense, pos2, vec2};
use lightcraft_develop::{ControlSpec, Section, Track};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use lightcraft_develop::{ControlSpec, Preset, Section, Track};
use serde_json::json;
use crate::LightcraftApp;
use crate::icons::{Icon, paint};
use crate::theme::Tokens;
use crate::widgets::{divider, icon_button, register, slider};
use crate::{HoverPreview, LightcraftApp};
/// Long edge of preset thumbnails (px).
const THUMB_EDGE: usize = 128;
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -29,10 +34,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
);
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked()
{
app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
app.ui.dialog = Some(crate::state::Dialog::create_preset());
}
let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options");
egui::Popup::menu(&more).show(|ui| {
let r = ui.checkbox(&mut app.ui.preset_thumbs, "Show Thumbnails");
register(ui.ctx(), "presetMenu:thumbnails", r.rect);
ui.separator();
if ui.button("Import Presets…").clicked() {
let _ = app.run("file.importPresets", json!({}));
}
@@ -66,10 +74,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
divider(ui);
}
let active = app.session.active();
let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone());
let thumbs = app.ui.preset_thumbs && active.is_some();
let row_h = if thumbs { 54.0 } else { 26.0 };
egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
let mut groups: BTreeMap<String, Vec<(String, String, bool)>> = BTreeMap::new();
let mut groups: BTreeMap<String, Vec<Preset>> = BTreeMap::new();
for p in &app.session.presets {
groups.entry(p.group.clone()).or_default().push((p.id.clone(), p.name.clone(), p.favorite));
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
let open_id = egui::Id::new(("preset-group", &g));
@@ -94,16 +106,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if !open {
continue;
}
for (pid, name, fav) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
for pr in items {
let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let active = last.as_ref().is_some_and(|(l, _)| *l == pid);
if active {
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
if is_last {
ui.painter().rect_filled(r, 0.0, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7));
}
ui.painter().text(pos2(r.left() + 40.0, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None };
let mut text_x = r.left() + 40.0;
if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) {
let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0));
ui.painter().rect_filled(tr, 2.0, t.canvas);
if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE)
&& let Some(tex) = app.renderer.variant(job)
{
ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE);
}
text_x = tr.right() + 10.0;
}
ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
if fav {
paint(
ui.painter(),
@@ -112,6 +137,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
t.star,
);
}
// resting on a preset previews it in the loupe (no history entry)
if resp.hovered()
&& let Some(s) = look
{
app.hover_preview = Some(HoverPreview { label: format!("Preset: {name}"), settings: s });
}
if resp.clicked() {
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
@@ -131,3 +162,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
});
});
}
/// The part of a `size` image that covers `r` (centre crop), as texture coordinates.
pub fn cover_uv(r: Rect, size: [usize; 2]) -> Rect {
let a = size[0] as f32 / size[1].max(1) as f32;
let ra = r.width() / r.height().max(1.0);
if a > ra {
let f = ra / a;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
} else {
let f = a / ra;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
}
}
+1 -13
View File
@@ -8,6 +8,7 @@ use lightcraft_develop::DevelopSettings;
use lightcraft_engine::presets::{PROFILES, ProfileInfo, profile, profile_groups};
use serde_json::json;
use super::presets::cover_uv;
use crate::icons::{Icon, paint};
use crate::state::RightPanel;
use crate::theme::Tokens;
@@ -92,19 +93,6 @@ fn with_profile(d: &DevelopSettings, p: &ProfileInfo) -> DevelopSettings {
s
}
/// The part of a `size` image that covers `r` (centre crop), as texture coordinates.
fn cover_uv(r: Rect, size: [usize; 2]) -> Rect {
let a = size[0] as f32 / size[1].max(1) as f32;
let ra = r.width() / r.height().max(1.0);
if a > ra {
let f = ra / a;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
} else {
let f = a / ra;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
}
}
#[allow(clippy::too_many_arguments)]
fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: PhotoId, tag: &str, p: &'static ProfileInfo, cell: Rect, th: f32) {
let t = Tokens::get(ui.ctx());
+19
View File
@@ -85,6 +85,8 @@ pub struct UiState {
pub right: RightPanel,
/// Presets column open (opens to the left of the Edit panel).
pub presets: bool,
/// Presets column: show a live thumbnail of the photo with each preset.
pub preset_thumbs: bool,
pub filmstrip: bool,
pub zoom: Zoom,
/// Pan offset of the loupe when zoomed (image-normalized centre).
@@ -142,6 +144,19 @@ pub struct UiState {
pub dialog: Option<Dialog>,
}
/// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything
/// but crop, masks, spots and red eye.
pub fn default_preset_groups() -> Vec<String> {
lightcraft_develop::SettingsGroup::default_copy().iter().filter_map(|g| serde_json::to_value(g).ok()?.as_str().map(str::to_string)).collect()
}
impl Dialog {
/// A fresh Create Preset dialog.
pub fn create_preset() -> Dialog {
Dialog::CreatePreset { name: String::new(), group: "User Presets".into(), groups: default_preset_groups() }
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Dialog {
@@ -161,9 +176,12 @@ pub enum Dialog {
NewSmartAlbum {
name: String,
},
/// `groups`: the settings groups the preset includes (`SettingsGroup` ids).
CreatePreset {
name: String,
group: String,
#[serde(default = "default_preset_groups")]
groups: Vec<String>,
},
CopySettings {
groups: Vec<String>,
@@ -190,6 +208,7 @@ impl Default for UiState {
left_panel: false,
right: RightPanel::Edit,
presets: false,
preset_thumbs: false,
filmstrip: true,
zoom: Zoom::Fit,
pan: (0.5, 0.5),
+12 -14
View File
@@ -64,21 +64,19 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i
4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
feather/opacity sliders, `[`/`]` size keys. Medium effort.
5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset;
live preview while hovering presets/profiles/versions. Medium effort.
7. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
8. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
6. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
7. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
XMP prefs, cache, GPU. Medium effort.
9. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
8. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
album; browse folders before adding. Medium effort.
10. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
9. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
`crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi.
11. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
10. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
permissive pure-Rust decoder yet). High value, high effort (clean sources needed).
12. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
13. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
14. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
15. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
11. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
12. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
13. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
14. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
## Shortcuts: conflicts and missing bindings
@@ -357,8 +355,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-PRE-PANEL | Presets panel | P0 | 🟡 | `cmd:panel.presets`, `cmd:preset.apply`, `crates/ui-egui/src/panels/presets.rs` | grouped list with amount; no hover preview |
| LR-PRE-CREATE | Create preset | P0 | 🟡 | `cmd:dialog.createPreset`, `cmd:preset.create` (`groups`) | dialog has no per-group checkboxes (fixed subset) |
| 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` | no rename, update-with-current, move group, hide groups |
| LR-PRE-AMOUNT | Preset amount | P1 | ✅ | `cmd:preset.apply` (`amount` 0–200) | |
| LR-PRE-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |
@@ -495,7 +493,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`) | covers ratings, albums, deletes, edits |
| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ⬜ | | presets, profiles, versions |
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/detail.rs` | presets, profile menu and profile browser preview in the loupe; versions do not |
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
| LR-BEHAV-BG-TASKS | Background tasks | P0 | 🟡 | `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; no progress popover for import/export |
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |