M1.15: keyboard-first culling — Compare, Survey, Auto Advance

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 10:05:24 -04:00
parent 0571d8e438
commit 1e52736703
11 changed files with 508 additions and 12 deletions

View File

@ -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);
}
}

View File

@ -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<u64> = 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]);

View File

@ -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)]);

View File

@ -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);

View File

@ -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<Re
app.ui.view = ViewMode::Detail;
Ok(Value::Null)
}
"view.compare" => 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,
}
}

View File

@ -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",

View File

@ -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<Value, String> {
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<Value, String> {
let (sel, cand) = compare_pair(app).ok_or("nothing to compare")?;
let vis: Vec<PhotoId> = 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<Value, String> {
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<Value, String> {
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<PhotoId> {
let vis = app.session.visible_cloned();
let sel = &app.session.selection;
let mut v: Vec<PhotoId> = 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<PhotoId> = 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<Value, String> {
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);
}
}

View File

@ -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));

View File

@ -1,6 +1,7 @@
//! Window regions and panels.
pub mod bottombar;
pub mod compare;
pub mod detail;
pub mod dialogs;
pub mod edit;

View File

@ -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::<u8>().unwrap_or(0), "advance": adv == "1"}));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().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 {

View File

@ -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,