From ece59f161cdafa8e8f0697062ec7b4867e243db6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 09:16:29 -0400 Subject: [PATCH] LR-VER-PANEL: Versions panel with Named/Auto tabs, thumbnails and hover previews Each version is a row with a live thumbnail of its look, name, date and a marker when the photo has exactly those settings. Resting on a version previews it in the loupe (nothing changes); click restores, double-click renames, right-click: update with current settings, rename, set as before, delete. Named / Auto tabs with counts. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 22 +++++++ crates/ui-egui/src/panels/right.rs | 100 ++++++++++++++++++++++++----- docs/parity.md | 8 +-- 3 files changed, 110 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 5895d3e5..caec678b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -706,6 +706,28 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Versions panel: resting on a version previews it in the loupe without changing the photo; + /// a click restores it. + #[test] + fn versions_preview_on_hover_and_restore_on_click() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t); + h.request("ui.set", json!({"view": "detail", "right": "versions"}), t); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": 1.0}})); + exec(&mut h, "version.create", json!({"name": "Bright"})); + exec(&mut h, "develop.set", json!({"values": {"light.exposure": -1.0}})); + h.settle(SETTLE); + let exposure = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert_eq!(h.request("ui.hoverWidget", json!({"id": "version:0"}), t)["ok"], true); + h.step(); + assert_eq!(h.app.hover_preview.as_ref().map(|p| p.settings.light.exposure), Some(1.0)); + assert_eq!(exposure(&h), -1.0, "hovering changes nothing"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "version:0"}), t)["ok"], true); + h.settle(Duration::from_secs(5)); + assert_eq!(exposure(&h), 1.0); + } + /// ⌘Q (File → Quit LightCraft) closes the window. #[test] fn cmd_q_quits() { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index a40b767a..2c702654 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -610,19 +610,69 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "Versions"); + let t = Tokens::get(ui.ctx()); padded(ui, |ui| { if text_button(ui, "versionCreate", "Create Version", false).clicked() { let _ = app.run("version.create", json!({})); } ui.add_space(8.0); - for (i, v) in p.versions.iter().enumerate() { - ui.horizontal(|ui| { - let b = ui.button(&v.name).on_hover_text(&v.created); - register(ui.ctx(), format!("version:{i}"), b.rect); - if b.clicked() { - let _ = app.run("version.restore", json!({"index": i})); - } - b.context_menu(|ui| { + // Named (made by you) and Auto (made by LightCraft) versions + let tab_id = egui::Id::new("versions-tab"); + let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); + let named_n = p.versions.iter().filter(|v| !v.auto).count(); + let items = [("Named", "named"), ("Auto", "auto")]; + let labels = [format!("Named ({named_n})"), format!("Auto ({})", p.versions.len() - named_n)]; + let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); + if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { + auto = i == 1; + ui.data_mut(|d| d.insert_temp(tab_id, auto)); + } + ui.add_space(6.0); + let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); + if shown.is_empty() { + let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; + ui.label(egui::RichText::new(msg).color(t.text_dim)); + } + // newest first + for (i, v) in shown.into_iter().rev() { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + register(ui.ctx(), format!("version:{i}"), r); + let current = *v.settings == *p.develop; + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + // thumbnail of the version's look + let tr = Rect::from_min_size(r.min + vec2(4.0, 5.0), vec2(72.0, 48.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, &v.settings, 160) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, crate::panels::presets::cover_uv(tr, tex.size), egui::Color32::WHITE); + } + let x = tr.right() + 10.0; + ui.painter().with_clip_rect(r.with_max_x(r.right() - 24.0)).text( + pos2(x, r.top() + 18.0), + Align2::LEFT_CENTER, + &v.name, + t.semibold(13.0), + t.text, + ); + let painter = ui.painter().with_clip_rect(r); + painter.text(pos2(x, r.top() + 38.0), Align2::LEFT_CENTER, short_time(&v.created), t.font(11.0), t.text_dim); + if current { + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(14.0, 14.0)), Icon::Check, t.accent); + } + // resting on a version shows it in the loupe (nothing is changed) + if resp.hovered() && !current { + app.hover_preview = Some(crate::HoverPreview { label: format!("Version: {}", v.name), settings: (*v.settings).clone() }); + } + if resp.double_clicked() { + crate::panels::dialogs::prompt(app, "Rename Version", "Version name", &v.name, "version.rename", json!({"index": i}), "name"); + } else if resp.clicked() && !current { + let _ = app.run("version.restore", json!({"index": i})); + } + resp.on_hover_text(if current { "The photo has these settings" } else { "Click to restore · double-click to rename" }).context_menu( + |ui| { if ui.button("Restore").clicked() { let _ = app.run("version.restore", json!({"index": i})); } @@ -639,18 +689,27 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Delete").clicked() { let _ = app.run("version.delete", json!({"index": i})); } - }); - if ui.small_button("×").clicked() { - let _ = app.run("version.delete", json!({"index": i})); - } - }); - } - if p.versions.is_empty() { - ui.label("No versions yet."); + }, + ); } }); } +/// "Sep 30, 2026, 12:00 PM" from an ISO time. +fn short_time(iso: &str) -> String { + let long = lightcraft_catalog::dates::display_time(iso); + // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped + let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); + let date = match date.split_once(' ') { + Some((m, rest)) => format!("{} {rest}", m.chars().take(3).collect::()), + None => date.to_string(), + }; + match time.and_then(|t| t.rsplit_once(' ')) { + Some((hms, ampm)) => format!("{date}, {} {ampm}", hms.rsplit_once(':').map_or(hms, |x| x.0)), + None => date, + } +} + fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let Some(p) = app.session.catalog.photo(id).cloned() else { return }; header(ui, "History"); @@ -686,3 +745,12 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); } + +#[cfg(test)] +mod tests { + #[test] + fn short_times() { + assert_eq!(super::short_time("2026-09-30T12:00:05"), "Sep 30, 2026, 12:00 PM"); + assert_eq!(super::short_time("2026-01-02"), "Jan 2, 2026"); + } +} diff --git a/docs/parity.md b/docs/parity.md index 8bf34399..ab838371 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -33,7 +33,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | | K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) | | L. Presets (PRE) | 5 | 1 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | -| M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | +| M. Versions & history (VER) | 4 | 0 | 1 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 4 | 1 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | | O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 69 | 8 | 8 | 8 | 46/47 (98%) | 18/23 (78%) | | Z. Keyboard shortcuts (desktop) | 63 | 12 | 5 | 1 | 49/52 (94%) | 12/23 (52%) | | Lightroom Classic extras | 8 | 20 | 51 | 9 | — | 7/21 (33%) | -| **Total** | 309 | 63 | 128 | 37 | 193/198 (97%) | 97/144 (67%) | +| **Total** | 310 | 62 | 128 | 37 | 193/198 (97%) | 98/144 (68%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 81.2% of 144 · P2 17.7% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **68.2%** of 500 in-scope rows — P0 98.7% of 198 · P1 81.6% of 144 · P2 17.7% of 158. ## Top gaps @@ -350,7 +350,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-VER-CREATE | Create version | P1 | ✅ | `cmd:version.create` | | -| LR-VER-PANEL | Versions panel | P1 | 🟡 | `cmd:panel.versions`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update` | rename, update, set as before; no hover preview, named/auto tabs | +| LR-VER-PANEL | Versions panel | P1 | ✅ | `cmd:panel.versions`, `cmd:version.create`, `cmd:version.restore`, `cmd:version.delete`, `cmd:version.rename`, `cmd:version.update`, `crates/ui-egui/src/panels/right.rs` (`versions`) | Named / Auto tabs, a thumbnail of each version's look, name and date, current marker; resting on a version previews it in the loupe; click restores, double-click renames; row menu (update, rename, set as before, delete). No automatic versions are created yet (LR-VER-AUTO) | | LR-VER-AUTO | Automatic versions | P2 | ⬜ | | `auto` flag reserved in the model | | LR-VER-HISTORY | Edit history | P1 | ✅ | `cmd:panel.activity`, `cmd:history.list`, `cmd:history.restore` | | | LR-VER-UNDO | Undo / redo | P0 | ✅ | `cmd:edit.undo`, `cmd:edit.redo` | |