From 1e52736703e8ea8a5e0d27e20471834bf4fbd57c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:05:24 -0400 Subject: [PATCH] =?UTF-8?q?M1.15:=20keyboard-first=20culling=20=E2=80=94?= =?UTF-8?q?=20Compare,=20Survey,=20Auto=20Advance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare view (Shift+C): select | candidate side by side with synced zoom and pan; arrows move the candidate, rating/flag/label keys act on the active (candidate) photo, swap / make-select. Survey view (N): the selection tiled, keys act on the active photo, hover x removes from the survey. Auto Advance setting (Photo menu, bottom bar toggle) and Shift+P/X/U flag-and-advance. Escape returns to Detail. Headless keyboard test. Fix: render-job keys include the photo id, so a view slot never shows one photo's result as current for another with identical settings/size. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 13 +- crates/ui-egui/src/headless.rs | 54 ++++ crates/ui-egui/src/icons.rs | 11 +- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/menus.rs | 21 ++ crates/ui-egui/src/panels/bottombar.rs | 28 +- crates/ui-egui/src/panels/compare.rs | 339 +++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 4 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/shortcuts.rs | 32 ++- crates/ui-egui/src/state.rs | 10 + 11 files changed, 508 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/panels/compare.rs diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1c0a4070..869357ea 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -320,7 +320,14 @@ impl crate::Session { let source = self.media.source_ref(&p, level); let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() }; let request = RenderRequest { max_w, max_h, quality: Quality::Full, apply_crop }; - let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60; + // the photo id is part of the key: two photos with the same settings and size must not + // share a result (a view slot showing photo A would otherwise look current for photo B) + let key = settings.hash64() + ^ ((max_w as u64) << 40) + ^ ((max_h as u64) << 20) + ^ (apply_crop as u64) + ^ (level as u64) << 60 + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15); let cache = thumb_bucket.map(|b| { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) @@ -396,5 +403,9 @@ mod tests { let loupe = s.render_job(p.id, 1600, 1600, false, true).unwrap(); assert_eq!(loupe.level, SourceLevel::Preview); assert_ne!(loupe.key, s.render_job(p.id, 1600, 1600, false, true).unwrap().draft().key); + // two photos with identical settings and size never share a result key + let plain: Vec<_> = s.catalog.photos().filter(|p| !p.is_edited()).map(|p| p.id).take(2).collect(); + let (a, b) = (s.render_job(plain[0], 1600, 1600, false, true).unwrap(), s.render_job(plain[1], 1600, 1600, false, true).unwrap()); + assert_ne!(a.key, b.key); } } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ccac06f2..9def0930 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -283,6 +283,60 @@ mod tests { assert_eq!(diff, 0, "{diff} pixels differ between two runs"); } + /// Keyboard culling: Compare (rating keys hit the candidate, arrows move it, auto-advance), + /// Survey (keys hit the active photo only), and auto-advance in Detail. + #[test] + fn compare_survey_and_auto_advance_by_keyboard() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let rating = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().rating; + let flag = |h: &Headless, id: u64| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().flag; + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0], vis[1]]}}), t); + let r = h.request("engine.execute", json!({"command": "view.compare"}), t); + assert_eq!(r["result"], json!({"select": vis[0], "candidate": vis[1]}), "{r}"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); + // rating applies to the candidate (active) only + let before = rating(&h, vis[0]); + h.request("ui.key", json!({"key": "2"}), t); + assert_eq!((rating(&h, vis[0]), rating(&h, vis[1])), (before, 2)); + // arrows move the candidate; the select stays + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("ui.key", json!({"key": "left"}), t); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[1]))); + // Shift+X: reject and advance to the next candidate + h.request("ui.key", json!({"key": "x", "shift": true}), t); + assert_eq!(flag(&h, vis[1]), lightcraft_catalog::Flag::Reject); + assert_eq!(h.app.ui.compare, Some((vis[0], vis[2]))); + h.request("engine.execute", json!({"command": "compare.swap"}), t); + assert_eq!(h.app.ui.compare, Some((vis[2], vis[0]))); + h.request("engine.execute", json!({"command": "compare.makeSelect"}), t); + assert_eq!(h.app.ui.compare.map(|c| c.0), Some(vis[0])); + // Survey: the selection tiled; keys hit the active photo, auto-advance walks the survey + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[3], vis[4], vis[5]], "active": vis[3]}}), t); + let r = h.request("engine.execute", json!({"command": "view.survey"}), t); + assert_eq!(r["result"]["photos"], 3); + h.request("engine.execute", json!({"command": "view.autoAdvance"}), t); + assert!(h.app.ui.auto_advance); + h.request("ui.key", json!({"key": "5"}), t); + h.request("ui.key", json!({"key": "p"}), t); + assert_eq!((rating(&h, vis[3]), flag(&h, vis[4])), (5, lightcraft_catalog::Flag::Pick)); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[5])); + assert_eq!(h.app.session.selection.ids.len(), 3, "the survey keeps its selection"); + let img = h.snapshot(Duration::from_secs(20)); + assert_eq!(img.size, [1000, 700]); + // Detail: auto-advance moves to the next photo + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[6]]}}), t); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "u"}), t); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(vis[7])); + // Escape leaves the culling views for Detail + h.request("ui.set", json!({"view": "survey"}), t); + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.view, crate::state::ViewMode::Detail); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a871b0d1..3980861d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,6 +29,7 @@ pub enum Icon { GridSquare, Single, Compare, + Survey, Sort, Star, StarFilled, @@ -242,9 +243,15 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5); } Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5), + Survey => { + pen.rect(2.5, 3.5, 9.0, 9.0, 1.0); + pen.rect(11.0, 3.5, 17.5, 9.0, 1.0); + pen.rect(2.5, 11.0, 9.0, 16.5, 1.0); + pen.rect(11.0, 11.0, 17.5, 16.5, 1.0); + } Compare => { - pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5); - pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5); + pen.rect_fill(2.0, 5.0, 8.8, 15.0, 0.5); + pen.rect_fill(11.2, 5.0, 18.0, 15.0, 0.5); } Sort => { pen.line(&[(3.0, 5.0), (17.0, 5.0)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05013ce1..a0309967 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -329,10 +329,13 @@ impl LightcraftApp { } panels::bottombar::show(self, ui); let t = theme::Tokens::get(&ctx); - let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg }; + let bg = + if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey) { t.canvas } else { t.grid_bg }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view { state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui), - state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui), + state::ViewMode::Detail => panels::detail::show(self, ui), + state::ViewMode::Compare => panels::compare::show_compare(self, ui), + state::ViewMode::Survey => panels::compare::show_survey(self, ui), }); panels::dialogs::show(self, &ctx); panels::toast(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8048fa06..35785e70 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -14,6 +14,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.photoGrid", "Photo Grid", Some("G"), "View"), ("view.squareGrid", "Square Grid", Some("Shift+G"), "View"), ("view.detail", "Detail", Some("D"), "View"), + ("view.compare", "Compare", Some("Shift+C"), "View"), + ("view.survey", "Survey", Some("N"), "View"), + ("compare.swap", "Swap Compare Photos", None, "View"), + ("compare.makeSelect", "Make Candidate the Select", None, "View"), + ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), @@ -98,11 +103,25 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.survey" => { + app.ui.view = ViewMode::Survey; + Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) + } + "compare.swap" => crate::panels::compare::swap(app), + "compare.makeSelect" => crate::panels::compare::make_select(app), + "view.autoAdvance" => { + app.ui.auto_advance = !app.ui.auto_advance; + app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + Ok(json!({"autoAdvance": app.ui.auto_advance})) + } "view.back" => { if app.ui.dialog.is_some() { app.ui.dialog = None; } else if !app.ui.tool.is_empty() { app.ui.tool.clear(); + } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { + app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { app.ui.view = ViewMode::PhotoGrid; } @@ -373,6 +392,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "view.compare" => app.session.catalog.len() > 1, + "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, _ => true, } } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index eb508ff2..3c09bf8d 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -23,6 +23,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"), ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), ] { if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { let _ = app.run(&format!("view.{id}"), json!({})); @@ -45,7 +47,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { - let _ = app.run("photo.rate", json!({"rating": r})); + let mut params = json!({"rating": r}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.rate", params); } pill_ui.add_space(6.0); let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); @@ -64,7 +68,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; paint(pill_ui.painter(), r.shrink(3.0), icon, c); if resp.clicked() { - let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }})); + let mut params = json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}); + crate::panels::compare::target_active(app, &mut params); + let _ = app.run("photo.flag", params); } } // copy / paste settings @@ -102,7 +108,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); child.spacing_mut().item_spacing.x = 6.0; - if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) + && icon_button( + &mut child, + "autoAdvance", + Icon::ChevronRight, + vec2(30.0, 30.0), + app.ui.auto_advance, + true, + "Auto Advance after rating or flagging", + ) + .clicked() + { + let _ = app.run("view.autoAdvance", json!({})); + } + if app.ui.view == ViewMode::Survey { + // no zoom or thumbnail size in Survey + } else if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { if icon_button( &mut child, "beforeAfter", diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs new file mode 100644 index 00000000..ef38fb1f --- /dev/null +++ b/crates/ui-egui/src/panels/compare.rs @@ -0,0 +1,339 @@ +//! Culling views: **Compare** (the "select" and a "candidate" side by side, with synced zoom and +//! pan) and **Survey** (the selected photos tiled). Rating, flag and label keys act on the active +//! photo only here, and with Auto Advance on they move to the next candidate / photo. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::{Flag, PhotoId}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::{Icon, paint}; +use crate::render::Slot; +use crate::state::{ViewMode, Zoom}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Most photos a survey shows. +pub const SURVEY_MAX: usize = 48; + +/// Compare or Survey. +pub fn culling(app: &LightcraftApp) -> bool { + matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) +} + +/// The (select, candidate) pair, repaired or chosen if needed: the active photo against the next +/// selected photo, else against its neighbour in the view. +pub fn compare_pair(app: &mut LightcraftApp) -> Option<(PhotoId, PhotoId)> { + let exists = |app: &LightcraftApp, id: PhotoId| app.session.catalog.photo(id).is_some_and(|p| !p.deleted); + if let Some((a, b)) = app.ui.compare { + let (a, b) = (PhotoId(a), PhotoId(b)); + if a != b && exists(app, a) && exists(app, b) { + return Some((a, b)); + } + } + let vis = app.session.visible_cloned(); + let sel = app.session.active().or(vis.first().copied())?; + let cand = app.session.selection.ids.iter().copied().find(|x| *x != sel).or_else(|| { + let i = vis.iter().position(|x| *x == sel)?; + vis.get(i + 1).or(i.checked_sub(1).and_then(|j| vis.get(j))).copied() + })?; + app.ui.compare = Some((sel.0, cand.0)); + Some((sel, cand)) +} + +/// Enter Compare with the current selection (the active photo becomes the select). +pub fn enter_compare(app: &mut LightcraftApp) -> Result { + app.ui.compare = None; + let (a, b) = compare_pair(app).ok_or("Compare needs at least two photos")?; + app.ui.view = ViewMode::Compare; + // the candidate is active: rating/flag keys judge it against the select + select_pair(app, a, b, b); + Ok(json!({"select": a.0, "candidate": b.0})) +} + +fn select_pair(app: &mut LightcraftApp, a: PhotoId, b: PhotoId, active: PhotoId) { + app.ui.compare = Some((a.0, b.0)); + let _ = app.session.execute("library.select", &json!({"ids": [a.0, b.0], "active": active.0})); +} + +/// Move the candidate `d` photos through the view (skipping the select); it becomes active. +pub fn compare_step(app: &mut LightcraftApp, d: isize) -> Result { + let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?; + let vis: Vec = app.session.visible_cloned().into_iter().filter(|x| *x != sel).collect(); + let Some(i) = vis.iter().position(|x| *x == cand).or(if vis.is_empty() { None } else { Some(0) }) else { return Ok(Value::Null) }; + let j = (i as isize + d).clamp(0, vis.len() as isize - 1) as usize; + select_pair(app, sel, vis[j], vis[j]); + Ok(json!({"candidate": vis[j].0})) +} + +pub fn swap(app: &mut LightcraftApp) -> Result { + let (a, b) = compare_pair(app).ok_or("nothing to compare")?; + let active = app.session.active().unwrap_or(b); + select_pair(app, b, a, active); + Ok(Value::Null) +} + +/// The candidate becomes the select; the next photo becomes the candidate. +pub fn make_select(app: &mut LightcraftApp) -> Result { + let (_, b) = compare_pair(app).ok_or("nothing to compare")?; + let vis = app.session.visible_cloned(); + let i = vis.iter().position(|x| *x == b).unwrap_or(0); + let next = vis.iter().skip(i + 1).chain(vis.iter().take(i).rev()).copied().find(|x| *x != b).ok_or("no next photo")?; + select_pair(app, b, next, next); + Ok(json!({"select": b.0, "candidate": next.0})) +} + +/// The photos a survey shows: the selection (in view order), or the active photo alone. +pub fn survey_photos(app: &mut LightcraftApp) -> Vec { + let vis = app.session.visible_cloned(); + let sel = &app.session.selection; + let mut v: Vec = vis.iter().copied().filter(|x| sel.contains(*x)).collect(); + // selected photos hidden in the view (e.g. inside a collapsed stack) come last + let hidden: Vec = sel.ids.iter().copied().filter(|x| !v.contains(x) && app.session.catalog.photo(*x).is_some()).collect(); + v.extend(hidden); + if v.is_empty() { + v.extend(app.session.active()); + } + v.truncate(SURVEY_MAX); + v +} + +/// Move the active photo `d` steps within the survey. +pub fn survey_step(app: &mut LightcraftApp, d: isize) -> Result { + let v = survey_photos(app); + if v.is_empty() { + return Ok(Value::Null); + } + let i = app.session.selection.active.and_then(|a| v.iter().position(|x| *x == a)).unwrap_or(0); + let j = (i as isize + d).clamp(0, v.len() as isize - 1) as usize; + app.session.selection.active = Some(v[j]); + Ok(json!({"active": v[j].0})) +} + +/// After rating/flagging with Auto Advance: next candidate (Compare), next photo in the survey, +/// else the next photo in the view. +pub fn advance(app: &mut LightcraftApp) { + let _ = match app.ui.view { + ViewMode::Compare => compare_step(app, 1), + ViewMode::Survey => survey_step(app, 1), + _ => app.session.execute("library.next", &json!({})).map_err(|e| e.to_string()), + }; +} + +/// In the culling views, point a photo command at the active photo only (not the whole selection). +pub fn target_active(app: &LightcraftApp, params: &mut Value) { + if culling(app) + && params.get("ids").is_none() + && let Some(a) = app.session.active() + { + params["ids"] = json!([a.0]); + } +} + +fn area_and_filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui) -> Rect { + let t = Tokens::get(ui.ctx()); + let full = ui.max_rect(); + let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 }; + let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h)); + app.canvas_rect = Some(canvas); + if app.ui.filmstrip { + super::detail::filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); + } + canvas +} + +/// Draw one photo fitted into `area` (rendered at its display size into `slot`), with its caption +/// strip below. Returns the image rect and the click/drag response. +fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slot, area: Rect, label: &str, zoom: Zoom) -> (Rect, egui::Response) { + let t = Tokens::get(ui.ctx()); + let ppp = ui.ctx().pixels_per_point(); + let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; + let caption_h = 26.0; + let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &photo.develop, + true, + photo.embedded_lens.as_ref(), + ); + let aspect = frame.aspect() as f32; + let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; + let img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan); + let want = (img.width().max(img.height()).min(img_area.width().max(img_area.height()) * 4.0) * ppp).min(2560.0) as usize; + let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, true) { + app.renderer.request(slot, job, 60); + } + if let Some(job) = app.session.thumb_job(id, 256) { + app.renderer.request(Slot::Thumb(id), job, 30); + } + let p = ui.painter_at(img_area); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + if let Some(tex) = app.renderer.textures.get(&slot).filter(|x| x.photo == id) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) { + p.image(tex.tex.id(), img, uv, Color32::WHITE); + } else { + p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38)); + } + if photo.flag == Flag::Reject { + p.rect_filled(img, 0.0, Color32::from_black_alpha(110)); + } + let active = app.session.selection.active == Some(id); + if active { + p.rect_stroke(img.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + } + // caption: label · file name · stars · flag + // caption right under the image (at the bottom when zoomed in) + let cap_top = (img.bottom() + 2.0).min(img_area.bottom()); + let cap = Rect::from_min_max(pos2(img.left().max(area.left()), cap_top), pos2(area.right(), cap_top + caption_h)); + let pt = ui.painter(); + let mut x = cap.left() + 4.0; + if !label.is_empty() { + let g = pt.layout_no_wrap(label.to_string(), t.semibold(12.0), if active { t.text } else { t.text_label }); + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g.clone(), t.text); + x += g.size().x + 10.0; + } + let name = photo.copy_name.as_ref().map(|c| format!("{} ({c})", photo.file_name)).unwrap_or(photo.file_name.clone()); + let g = pt.layout_no_wrap(name, t.font(11.5), t.text_dim); + let name_w = g.size().x; + if x + name_w + 110.0 < cap.right() { + pt.galley(pos2(x, cap.center().y - g.size().y / 2.0), g, t.text_dim); + x += name_w + 10.0; + } + for i in 0..5u8 { + let filled = i < photo.rating; + let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); + if r.right() < cap.right() { + paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + } + } + x += 5.0 * 13.0 + 4.0; + let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); + match photo.flag { + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), + _ => {} + } + register(ui.ctx(), format!("cull:{}", id.0), img); + (img, resp) +} + +fn slot_index(s: Slot) -> u8 { + match s { + Slot::Compare(i) => i, + _ => 255, + } +} + +/// Drag-to-pan when zoomed in (shared by both compare panes: zoom and pan are synced). +fn pan(app: &mut LightcraftApp, resp: &egui::Response, img: Rect) { + if app.ui.zoom != Zoom::Fit && resp.dragged() { + let d = resp.drag_delta(); + let (px, py) = app.ui.pan; + app.ui.pan = ((px - d.x / img.width().max(1.0)).clamp(0.0, 1.0), (py - d.y / img.height().max(1.0)).clamp(0.0, 1.0)); + } +} + +pub fn show_compare(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = area_and_filmstrip(app, ui); + let Some((sel, cand)) = compare_pair(app) else { + super::empty_message(ui, canvas, "Nothing to compare", "Select two photos, then choose View → Compare (Shift+C)"); + return; + }; + let area = canvas.shrink(18.0); + let half = (area.width() - 16.0) / 2.0; + let panes = [ + (sel, Rect::from_min_size(area.min, vec2(half, area.height())), "Select", 0u8), + (cand, Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height())), "Candidate", 1u8), + ]; + let zoom = app.ui.zoom; + for (id, r, label, i) in panes { + let (img, resp) = photo_tile(app, ui, id, Slot::Compare(i), r, label, zoom); + if i == 1 { + app.image_rect = Some(img); + } + pan(app, &resp, img); + if resp.clicked() { + select_pair(app, sel, cand, id); + } + if resp.double_clicked() { + app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(100) } else { Zoom::Fit }; + } + resp.context_menu(|ui| { + if ui.button("Swap").clicked() { + let _ = swap(app); + } + if ui.button("Make Candidate the Select").clicked() { + let _ = make_select(app); + } + ui.separator(); + super::grid::context_menu(app, ui, id); + }); + } + let mid = area.center().x; + ui.painter().line_segment([pos2(mid, area.top()), pos2(mid, area.bottom())], Stroke::new(1.0, Tokens::get(ui.ctx()).divider)); +} + +/// Columns for `n` tiles of aspect ~3:2 in `area`, maximizing the tile size. +pub fn survey_columns(n: usize, area: Rect) -> usize { + let mut best = (1, 0.0f32); + for cols in 1..=n.max(1) { + let rows = n.div_ceil(cols); + let w = area.width() / cols as f32; + let h = area.height() / rows as f32; + let s = w.min(h * 1.5); + if s > best.1 { + best = (cols, s); + } + } + best.0 +} + +pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let canvas = area_and_filmstrip(app, ui); + let photos = survey_photos(app); + if photos.is_empty() { + super::empty_message(ui, canvas, "Nothing to survey", "Select photos, then choose View → Survey (N)"); + return; + } + let area = canvas.shrink(14.0); + let cols = survey_columns(photos.len(), area); + let rows = photos.len().div_ceil(cols); + let (cw, ch) = (area.width() / cols as f32, area.height() / rows as f32); + for (i, id) in photos.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let cell = Rect::from_min_size(pos2(area.left() + c as f32 * cw, area.top() + r as f32 * ch), vec2(cw, ch)).shrink(6.0); + let (img, resp) = photo_tile(app, ui, *id, Slot::Compare(i as u8), cell, "", Zoom::Fit); + if app.session.selection.active == Some(*id) { + app.image_rect = Some(img); + } + if resp.clicked() { + app.session.selection.active = Some(*id); + } + if resp.double_clicked() { + let _ = app.run("library.select", json!({"ids": [id.0]})); + app.ui.view = ViewMode::Detail; + } + // remove from the survey (deselect) on hover + if resp.hovered() && photos.len() > 1 { + let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); + let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text("Remove from survey"); + ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); + paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); + if xresp.clicked() { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "toggle"})); + } + } + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); + } + let n = photos.len(); + let msg = + if app.session.selection.ids.len() > SURVEY_MAX { format!("Showing {n} of {}", app.session.selection.ids.len()) } else { String::new() }; + if !msg.is_empty() { + ui.painter().text(pos2(canvas.right() - 16.0, canvas.top() + 10.0), Align2::RIGHT_TOP, msg, t.font(12.0), t.text_dim); + } +} diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 222ad21d..818dca64 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -71,7 +71,7 @@ impl CanvasMap { } } -fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { +pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect { let (aw, ah) = (area.width(), area.height()); let (w, h) = match zoom { Zoom::Fit => { @@ -652,7 +652,7 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo // ------------------------------------------------------------------------ filmstrip -fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { +pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 66a6eb07..daab0083 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,6 +1,7 @@ //! Window regions and panels. pub mod bottombar; +pub mod compare; pub mod detail; pub mod dialogs; pub mod edit; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 193a36c5..a3487c3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -80,15 +80,43 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } + // Shift+P/X/U: flag and advance + for (id, key) in [("photo.pick", Key::P), ("photo.reject", Key::X), ("photo.unflag", Key::U)] { + if matches(i, Modifiers::SHIFT, key) { + fire.push(format!("flagadv:{id}")); + } + } }); + use crate::panels::compare; + // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or + // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) + let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| { + compare::target_active(app, &mut params); + let ok = app.run(id, params).is_ok(); + if ok && (advance || app.ui.auto_advance) { + compare::advance(app); + } + }; for f in fire { if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); - let _ = app.run("photo.rate", json!({"rating": n.parse::().unwrap_or(0), "advance": adv == "1"})); + cull(app, "photo.rate", json!({"rating": n.parse::().unwrap_or(0)}), adv == "1"); let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) }; app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { - let _ = app.run("photo.label", json!({"label": l})); + cull(app, "photo.label", json!({"label": l}), false); + } else if let Some(id) = f.strip_prefix("flagadv:") { + cull(app, id, json!({}), true); + } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { + let d = if f == "library.next" { 1 } else { -1 }; + let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { + cull(app, &f, json!({}), false); + match f.as_str() { + "photo.pick" => app.toast(ctx, "Flagged as Pick"), + "photo.reject" => app.toast(ctx, "Flagged as Reject"), + _ => app.toast(ctx, "Unflagged"), + } } else { // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c2cf3c71..52367fa4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -10,7 +10,10 @@ pub enum ViewMode { PhotoGrid, SquareGrid, Detail, + /// Two photos side by side (select | candidate), synced zoom. Compare, + /// The selected photos tiled. + Survey, } /// The right-hand tool/panel shown next to the tool strip. @@ -107,6 +110,11 @@ pub struct UiState { pub brush_flow: f32, pub brush_erase: bool, pub remove_size: f32, + /// Culling: after a rating, flag or colour-label key, move to the next photo. + pub auto_advance: bool, + /// Compare view: (select, candidate) photo ids. + #[serde(skip)] + pub compare: Option<(u64, u64)>, /// Transient toast text and its expiry (seconds of app time). #[serde(skip)] pub toast: Option<(String, f64)>, @@ -183,6 +191,8 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, remove_size: 0.02, + auto_advance: false, + compare: None, toast: None, status: String::new(), dialog: None,