367 lines
16 KiB
Rust
367 lines
16 KiB
Rust
//! 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 mut img = super::detail::fit_rect(img_area, aspect, zoom, native, ppp, app.ui.pan);
|
|
if matches!(app.ui.view, ViewMode::Compare | ViewMode::Reference) {
|
|
if super::detail::navigate_gesture(app, ui, &resp, img_area, img, native) {
|
|
img = super::detail::fit_rect(img_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
|
} else if resp.dragged() {
|
|
super::detail::pan_image(app, img_area, img, resp.drag_delta());
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
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.0) } else { Zoom::Fit };
|
|
}
|
|
resp.context_menu(|ui| {
|
|
if ui.button(crate::i18n::tr("Swap")).clicked() {
|
|
let _ = swap(app);
|
|
}
|
|
if ui.button(crate::i18n::tr("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(crate::i18n::tr("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 {
|
|
crate::i18n::tr_format!("Showing {n} of {}", app.session.selection.ids.len(), n = n)
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
/// Reference view: the reference photo (left, fixed) beside the active photo (right) — the one
|
|
/// the Edit panel works on, so a look can be matched by eye.
|
|
pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|
let canvas = area_and_filmstrip(app, ui);
|
|
let reference = app.ui.reference.map(PhotoId).filter(|r| app.session.catalog.photo(*r).is_some());
|
|
let (Some(r), Some(active)) = (reference, app.session.active()) else {
|
|
super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)");
|
|
return;
|
|
};
|
|
let area = canvas.shrink(18.0);
|
|
let half = (area.width() - 16.0) / 2.0;
|
|
let zoom = app.ui.zoom;
|
|
let left = Rect::from_min_size(area.min, vec2(half, area.height()));
|
|
let right = Rect::from_min_size(pos2(area.right() - half, area.top()), vec2(half, area.height()));
|
|
let (_, lresp) = photo_tile(app, ui, r, Slot::Compare(0), left, "Reference", zoom);
|
|
let (img, _) = photo_tile(app, ui, active, Slot::Compare(1), right, "Active", zoom);
|
|
app.image_rect = Some(img);
|
|
lresp.context_menu(|ui| {
|
|
if ui.button(crate::i18n::tr("Clear Reference")).clicked() {
|
|
app.ui.reference = None;
|
|
app.ui.view = ViewMode::Detail;
|
|
}
|
|
});
|
|
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));
|
|
}
|