mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
1 parent
af2af2a2f5
commit
ece59f161c
3 files changed
+110
-20
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
Reference in new issue
Block a user