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 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 09:16:29 -04:00
1 parent af2af2a2f5
commit ece59f161c
3 files changed
+110 -20

No files matched your search

+22
View File
@@ -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() {
+84 -16
View File
@@ -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::<String>()),
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");
}
}
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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` | |