mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
refactor(lightcraft-ui-egui): split panels/detail.rs into panels/detail/
This commit is contained in:
+37
-2742
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,582 @@
|
||||
use super::geometry::CanvasMap;
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------------ crop
|
||||
|
||||
pub(super) fn crop_overlay(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
resp: &egui::Response,
|
||||
map: &CanvasMap,
|
||||
frame: &Frame,
|
||||
d: &DevelopSettings,
|
||||
id: PhotoId,
|
||||
) {
|
||||
// the selected guide belongs to this photo and to the Guided Upright tool, and to a guide
|
||||
// that still exists (a Delete after the last one leaves the selection behind)
|
||||
if app.ui.tool != "guidedUpright" || app.ui.selected_guide.is_some_and(|(photo, i)| photo != id.0 || i >= d.geometry.guides.len()) {
|
||||
app.ui.selected_guide = None;
|
||||
}
|
||||
if app.ui.tool == "guidedUpright" {
|
||||
guided_overlay(app, ui, resp, map, frame, d, id);
|
||||
return;
|
||||
}
|
||||
if app.ui.tool == "straighten" {
|
||||
straighten_overlay(app, ui, resp, false);
|
||||
return;
|
||||
}
|
||||
// hold ⌘ and drag: draw a straighten line without leaving the crop tool
|
||||
let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command);
|
||||
if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) {
|
||||
straighten_overlay(app, ui, resp, true);
|
||||
return;
|
||||
}
|
||||
if resp.hovered() && ui.input(|i| i.modifiers.command) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
let quad = frame_crop_quad(d, frame);
|
||||
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
|
||||
let p = ui.painter();
|
||||
// darken outside the crop (the box's corners in screen order: a flipped photo shows its
|
||||
// top-left corner elsewhere)
|
||||
let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v);
|
||||
if pts.len() == 4 {
|
||||
p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150)));
|
||||
}
|
||||
p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
// overlay guides
|
||||
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
|
||||
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
|
||||
// (u, v) across / down the (rotated) crop frame
|
||||
let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v;
|
||||
let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3]));
|
||||
for line in crate::panels::crop_overlay::paths(app.ui.crop_overlay, app.ui.crop_overlay_orient, cw, ch) {
|
||||
p.add(egui::Shape::line(line.into_iter().map(at).collect(), guide));
|
||||
}
|
||||
// handles: 0..3 corners, 4..7 edges (top, right, bottom, left)
|
||||
let handles: Vec<Pos2> = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect();
|
||||
for (i, h) in handles.iter().enumerate() {
|
||||
register(ui, format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0)));
|
||||
let s = if i < 4 { 12.0 } else { 9.0 };
|
||||
p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE);
|
||||
p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE);
|
||||
}
|
||||
// the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move
|
||||
if crop_keys(app, ui, map, &handles, &pts, d, frame) {
|
||||
return;
|
||||
}
|
||||
let inside = |q: Pos2| {
|
||||
let n = map.norm(q);
|
||||
let s = to_straight(n, d.crop.geometry.angle, frame);
|
||||
d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y))
|
||||
};
|
||||
// a crop drag cut short (the tool closed before the release) leaves its gesture behind: with no
|
||||
// button held it is over
|
||||
if matches!(app.gesture, Some(Gesture::CropRotate { .. } | Gesture::CropHandle { .. }))
|
||||
&& !resp.drag_stopped()
|
||||
&& !ui.input(|i| i.pointer.any_down())
|
||||
{
|
||||
app.gesture = None;
|
||||
}
|
||||
// while rotating, the pointer may leave the canvas: it still shows rotation and the angle
|
||||
let rotating = matches!(app.gesture, Some(Gesture::CropRotate { .. }));
|
||||
let pointer = resp.hover_pos().or_else(|| if rotating { ui.input(|i| i.pointer.latest_pos()) } else { None });
|
||||
// with ⌘ held a drag draws a level line (the straighten crosshair, set above), not a rotation
|
||||
let straightening = !rotating && ui.input(|i| i.modifiers.command);
|
||||
if let Some(hq) = pointer.filter(|_| !straightening) {
|
||||
// a move or resize drag keeps its pointer wherever it goes, even past the box
|
||||
let held = match app.gesture {
|
||||
Some(Gesture::CropHandle { handle, .. }) => Some(usize::from(handle)),
|
||||
_ => None,
|
||||
};
|
||||
let near = held.or_else(|| handles.iter().position(|h| h.distance(hq) < 12.0)).filter(|_| !rotating);
|
||||
let cursor = match near {
|
||||
Some(0 | 2) => egui::CursorIcon::ResizeNwSe,
|
||||
Some(1 | 3) => egui::CursorIcon::ResizeNeSw,
|
||||
Some(4 | 6) => egui::CursorIcon::ResizeVertical,
|
||||
Some(5 | 7) => egui::CursorIcon::ResizeHorizontal,
|
||||
// 8: the whole box
|
||||
Some(_) => egui::CursorIcon::Move,
|
||||
None if inside(hq) && !rotating => egui::CursorIcon::Move,
|
||||
// a drag here rotates: no system cursor shows that, so draw a curved double arrow
|
||||
None => {
|
||||
rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq)));
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
};
|
||||
ui.ctx().set_cursor_icon(cursor);
|
||||
}
|
||||
// the angle next to the pointer, drawn once this frame's rotation is applied (below)
|
||||
let readout_at = pointer.filter(|_| rotating);
|
||||
let mut shown_angle = d.crop.geometry.angle;
|
||||
// double-click inside the crop box applies the crop (same as Return / Done)
|
||||
if resp.double_clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& inside(q)
|
||||
&& !handles.iter().any(|h| h.distance(q) < 12.0)
|
||||
{
|
||||
let _ = app.run("tool.done", json!({}));
|
||||
return;
|
||||
}
|
||||
// egui reports a drag only once the pointer has moved a few px: the zone is where the button
|
||||
// went down, where the cursor showed it, not where the pointer is by then
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos())
|
||||
{
|
||||
if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null))
|
||||
{
|
||||
app.ui.status = e;
|
||||
let _ = app.session.cancel_interaction();
|
||||
return;
|
||||
}
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) {
|
||||
Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
||||
None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
||||
None => {
|
||||
let c = map.screen(Point::new(0.5, 0.5));
|
||||
Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() }
|
||||
}
|
||||
});
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
match app.gesture.clone() {
|
||||
Some(Gesture::CropHandle { handle, start, angle }) => {
|
||||
// The crop model decides what the drag does (anchor, aspect lock, image bounds): see
|
||||
// `lightcraft_geom::drag_crop`; the UI only reports where the pointer started and is.
|
||||
let n = to_straight(map.norm(q), angle, frame);
|
||||
let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n);
|
||||
// Shift keeps the current proportions for this drag only; the Lock toggle is untouched.
|
||||
let keep_ratio = ui.input(|i| i.modifiers.shift);
|
||||
let _ = app.run(
|
||||
"crop.drag",
|
||||
json!({"handle": handle, "from": [orig.x, orig.y], "to": [n.x, n.y], "start": [start.x0, start.y0, start.x1, start.y1], "keepRatio": keep_ratio}),
|
||||
);
|
||||
}
|
||||
Some(Gesture::CropRotate { start_angle, a0 }) => {
|
||||
let c = map.screen(Point::new(0.5, 0.5));
|
||||
let a = (q - c).angle();
|
||||
let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0);
|
||||
shown_angle = (ang * 100.0).round() / 100.0;
|
||||
let _ = app.run("crop.straighten", json!({"angle": shown_angle}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
if let Some(at) = readout_at {
|
||||
// the canvas (the loupe's own rect), not the photo: rotating happens in the margin around it
|
||||
angle_readout(ui, at, shown_angle, resp.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7
|
||||
/// edges (top, right, bottom, left), 8 the whole box.
|
||||
const CROP_HANDLE_NAMES: [&str; 9] = [
|
||||
"Crop top-left corner",
|
||||
"Crop top-right corner",
|
||||
"Crop bottom-right corner",
|
||||
"Crop bottom-left corner",
|
||||
"Crop top edge",
|
||||
"Crop right edge",
|
||||
"Crop bottom edge",
|
||||
"Crop left edge",
|
||||
"Crop box",
|
||||
];
|
||||
|
||||
/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one
|
||||
/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the
|
||||
/// frame is over (the crop was applied).
|
||||
fn crop_keys(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
map: &CanvasMap,
|
||||
handles: &[Pos2],
|
||||
pts: &[Pos2],
|
||||
d: &DevelopSettings,
|
||||
frame: &Frame,
|
||||
) -> bool {
|
||||
let r = d.crop.geometry.rect;
|
||||
let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false };
|
||||
let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 };
|
||||
let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1));
|
||||
for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() {
|
||||
let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue };
|
||||
let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at);
|
||||
if keys.activated {
|
||||
let _ = app.run("tool.done", json!({}));
|
||||
return true;
|
||||
}
|
||||
let Some((dx, dy)) = keys.delta else { continue };
|
||||
// the drag runs in the straightened crop frame, as a pointer drag from the handle would;
|
||||
// the arrows follow the screen
|
||||
let angle = d.crop.geometry.angle;
|
||||
let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height());
|
||||
let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame));
|
||||
let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null));
|
||||
if let Err(e) = started {
|
||||
app.ui.status = e;
|
||||
let _ = app.session.cancel_interaction();
|
||||
return false;
|
||||
}
|
||||
let _ = app.run(
|
||||
"crop.drag",
|
||||
json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}),
|
||||
);
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The crop angle as the rotation readout shows it: like the Straighten value, in degrees.
|
||||
pub(crate) fn crop_angle_label(angle: f64) -> String {
|
||||
let shown =
|
||||
lightcraft_develop::controls::find("crop.angle").map_or_else(|| format!("{angle:.2}"), |spec| crate::widgets::shown_value(spec, angle));
|
||||
// the slider's format turns a rounded −0.00 into "0": every zero reads "0.00", the angle's
|
||||
// usual two decimals
|
||||
let shown = if shown == "0" { "0.00".to_string() } else { shown };
|
||||
format!("{shown}°")
|
||||
}
|
||||
|
||||
/// Where the angle readout of `size` goes for a pointer `at`: below right of it, flipped to the
|
||||
/// left / above where that would leave `bounds`, then kept inside them.
|
||||
pub(crate) fn readout_rect(at: Pos2, size: egui::Vec2, bounds: Rect) -> Rect {
|
||||
// without a real pointer or bounds (NaN, an empty rect) there is nothing to keep it in; and
|
||||
// f32::clamp panics on NaN, so only finite numbers get there
|
||||
if !at.is_finite() || !bounds.is_finite() || !bounds.is_positive() {
|
||||
let at = if at.is_finite() { at } else { bounds.min.max(Pos2::ZERO) };
|
||||
let at = if at.is_finite() { at } else { Pos2::ZERO };
|
||||
return Rect::from_min_size(at + vec2(18.0, 14.0), size);
|
||||
}
|
||||
let x = if at.x + 18.0 + size.x > bounds.right() { at.x - 18.0 - size.x } else { at.x + 18.0 };
|
||||
let y = if at.y + 14.0 + size.y > bounds.bottom() { at.y - 14.0 - size.y } else { at.y + 14.0 };
|
||||
let x = x.clamp(bounds.left(), (bounds.right() - size.x).max(bounds.left()));
|
||||
let y = y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
|
||||
Rect::from_min_size(pos2(x, y), size)
|
||||
}
|
||||
|
||||
/// On top of everything (the tooltip layer), so neither the photo nor a panel covers it.
|
||||
fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter {
|
||||
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique(name)))
|
||||
}
|
||||
|
||||
/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo,
|
||||
/// turned `turn` radians clockwise ([`rotate_cursor_turn`]).
|
||||
fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) {
|
||||
let p = top_painter(ui, "crop-rotate-cursor");
|
||||
let r = Rect::from_center_size(at, vec2(22.0, 22.0));
|
||||
p.circle_filled(at, 12.0, Color32::from_black_alpha(150));
|
||||
crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn);
|
||||
register(ui, "cropRotateCursor", r);
|
||||
}
|
||||
|
||||
/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges
|
||||
/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge,
|
||||
/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest
|
||||
/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it
|
||||
/// the other way, still away from that edge.
|
||||
pub(super) fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 {
|
||||
let nearest = (0..pts.len())
|
||||
.map(|i| {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
|
||||
let ab = b - a;
|
||||
let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
a + ab * t
|
||||
})
|
||||
.min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q)));
|
||||
let Some(nearest) = nearest else { return 0.0 };
|
||||
let away = if inside { nearest - q } else { q - nearest };
|
||||
if away.length() < 0.5 {
|
||||
return 0.0;
|
||||
}
|
||||
away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2
|
||||
}
|
||||
|
||||
/// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the
|
||||
/// pointer's other side when it would run past an edge.
|
||||
fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = top_painter(ui, "crop-angle-readout");
|
||||
let galley = p.layout_no_wrap(crop_angle_label(angle), t.font(12.5), Color32::WHITE);
|
||||
let rect = readout_rect(at, galley.size() + vec2(12.0, 6.0), bounds);
|
||||
p.rect_filled(rect, 4.0, Color32::from_black_alpha(170));
|
||||
p.galley(rect.min + vec2(6.0, 3.0), galley, Color32::WHITE);
|
||||
register(ui, "cropAngleReadout", rect);
|
||||
}
|
||||
|
||||
/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are
|
||||
/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps.
|
||||
fn guided_overlay(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
resp: &egui::Response,
|
||||
map: &CanvasMap,
|
||||
frame: &Frame,
|
||||
d: &DevelopSettings,
|
||||
id: PhotoId,
|
||||
) {
|
||||
let p = ui.painter_at(map.rect.expand(8.0));
|
||||
let col = Color32::from_rgb(255, 196, 40);
|
||||
// the selected guide: brighter, so the Delete key has a visible target
|
||||
let selected = app.ui.selected_guide.filter(|(photo, _)| *photo == id.0).map(|(_, i)| i);
|
||||
let draw = |a: Pos2, b: Pos2, sel: bool| {
|
||||
let (edge, core, at) = if sel { (3.5, 2.5, 5.0) } else { (3.0, 1.5, 4.0) };
|
||||
p.line_segment([a, b], Stroke::new(edge, Color32::from_black_alpha(120)));
|
||||
p.line_segment([a, b], Stroke::new(core, if sel { Color32::WHITE } else { col }));
|
||||
for q in [a, b] {
|
||||
p.circle_filled(q, at, if sel { Color32::WHITE } else { col });
|
||||
p.circle_stroke(q, at, Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
};
|
||||
let on_screen = |guide: &(Point, Point)| {
|
||||
let (sa, sb) = (map.screen(frame.corrected_to_transformed(guide.0)), map.screen(frame.corrected_to_transformed(guide.1)));
|
||||
(sa, sb)
|
||||
};
|
||||
for (i, guide) in d.geometry.guides.iter().enumerate() {
|
||||
let (sa, sb) = on_screen(guide);
|
||||
register(ui, format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb));
|
||||
draw(sa, sb, selected == Some(i));
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
// A press starts what it means, right away (the pointer's press point, not where a drag
|
||||
// threshold would have been crossed): on an endpoint it drags that endpoint (the guide is
|
||||
// selected when it lands), on a line it selects the guide (a press on the selected one
|
||||
// deselects), anywhere else it starts a new guide from the press point. The primary button
|
||||
// only, never on the navigator — it pans the photo, it draws no guide — and only where the
|
||||
// loupe itself takes the press: `contains_pointer` is layer-aware over the current pointer
|
||||
// (the raw pointer is not), and `layer_id_at` asks the same question at the press point —
|
||||
// a menu can receive the press and the pointer can leave it within the same frame, so the
|
||||
// press itself must belong to the loupe's layer. Anything floating above the photo — a
|
||||
// context menu, a dropdown, a dialog — owns its own press; the guide under it stays put.
|
||||
// A slow frame can also swallow the whole press-move-release into one batch, and egui
|
||||
// clears `press_origin` on the release: then the press position is read back from the
|
||||
// frame's own events, so the gesture still starts where the pointer went down.
|
||||
let nav = app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r);
|
||||
if ui.input(|i| i.pointer.primary_pressed())
|
||||
&& let Some(q) = ui
|
||||
.input(|i| {
|
||||
i.pointer.press_origin().or_else(|| {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } => Some(*pos),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
.or(resp.interact_pointer_pos())
|
||||
&& resp.contains_pointer()
|
||||
&& ui.ctx().layer_id_at(q) == Some(resp.layer_id)
|
||||
&& resp.rect.contains(q)
|
||||
&& !nav.is_some_and(|n| n.expand(5.0).contains(q))
|
||||
{
|
||||
let endpoint = d.geometry.guides.iter().enumerate().find_map(|(i, guide)| {
|
||||
let (a, b) = on_screen(guide);
|
||||
if a.distance(q) <= 7.0 {
|
||||
Some((i, 0))
|
||||
} else if b.distance(q) <= 7.0 {
|
||||
Some((i, 1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let line = d.geometry.guides.iter().position(|guide| {
|
||||
let (a, b) = on_screen(guide);
|
||||
point_segment_dist(q, a, b) <= 6.0
|
||||
});
|
||||
match (endpoint, line) {
|
||||
(Some((i, end)), _) => app.gesture = Some(Gesture::GuideEndpoint { i, end, from: q }),
|
||||
(None, Some(i)) => app.ui.selected_guide = if selected == Some(i) { None } else { Some((id.0, i)) },
|
||||
// drawing a new guide leaves nothing selected
|
||||
(None, None) => {
|
||||
app.ui.selected_guide = None;
|
||||
app.gesture = Some(Gesture::Guide { a: map.norm(q) });
|
||||
}
|
||||
}
|
||||
}
|
||||
let q = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
|
||||
if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, q) {
|
||||
draw(map.screen(*a), q, false);
|
||||
}
|
||||
// the endpoint being dragged: the guide follows the pointer, bright
|
||||
if let (Some(Gesture::GuideEndpoint { i, end, .. }), Some(q)) = (&app.gesture, q)
|
||||
&& let Some(guide) = d.geometry.guides.get(*i)
|
||||
{
|
||||
let fixed = if *end == 0 { guide.1 } else { guide.0 };
|
||||
draw(map.screen(frame.corrected_to_transformed(fixed)), q, true);
|
||||
}
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
match app.gesture.take() {
|
||||
// the endpoint lands: the guide stays (one undo step) and is selected. A press
|
||||
// without a real drag only selects — no no-op undo entry for a click.
|
||||
Some(Gesture::GuideEndpoint { i, end, from }) => {
|
||||
app.ui.selected_guide = Some((id.0, i));
|
||||
if let (Some(q), Some(guide)) = (q, d.geometry.guides.get(i)) {
|
||||
let fixed_t = if end == 0 { guide.1 } else { guide.0 };
|
||||
let new_t = frame.transformed_to_corrected(map.norm(q));
|
||||
let (a, b) = if end == 0 { (new_t, fixed_t) } else { (fixed_t, new_t) };
|
||||
let (na, nb) = (frame.corrected_to_transformed(a), frame.corrected_to_transformed(b));
|
||||
// a press that only selected (it landed within the endpoint's grab radius
|
||||
// and never moved) writes no guide: compare against where it went down
|
||||
let moved = from.distance(q) > 2.0;
|
||||
if moved && na.dist(nb) > 0.02 {
|
||||
let all: Vec<[f64; 4]> = d
|
||||
.geometry
|
||||
.guides
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, g)| {
|
||||
let (x, y) = if j == i { (a, b) } else { *g };
|
||||
[x.x, x.y, y.x, y.y]
|
||||
})
|
||||
.collect();
|
||||
let _ = app.run("geometry.guides", json!({"guides": all}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Guide { a }) => {
|
||||
if let Some(q) = q
|
||||
&& map.norm(q).dist(a) > 0.02
|
||||
{
|
||||
let b = map.norm(q);
|
||||
let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b));
|
||||
let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true}));
|
||||
}
|
||||
}
|
||||
other => app.gesture = other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
|
||||
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
|
||||
let (w, h) = (frame.ow, frame.oh);
|
||||
let px = Point::new(n.x * w, n.y * h);
|
||||
let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px);
|
||||
Point::new(r.x / w, r.y / h)
|
||||
}
|
||||
|
||||
/// How far `q` is from the segment `a`–`b` (screen pixels): the click that selects a guide.
|
||||
fn point_segment_dist(q: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + ab * t).distance(q)
|
||||
}
|
||||
|
||||
/// A crop box's corners (top-left, top-right, bottom-right, bottom-left of the unflipped photo)
|
||||
/// in the order they appear on screen when the photo is shown flipped.
|
||||
pub(super) fn flipped_corners(pts: &[Pos2], flip_h: bool, flip_v: bool) -> [Pos2; 4] {
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or(Pos2::ZERO);
|
||||
let order = match (flip_h, flip_v) {
|
||||
(false, false) => [0, 1, 2, 3],
|
||||
(true, false) => [1, 0, 3, 2],
|
||||
(false, true) => [3, 2, 1, 0],
|
||||
(true, true) => [2, 3, 0, 1],
|
||||
};
|
||||
order.map(at)
|
||||
}
|
||||
|
||||
/// The dimming outside the crop box: the photo's rectangle minus the crop quad, as one mesh of
|
||||
/// non-overlapping convex pieces. The quad is a rotated rectangle, so its opposite sides are
|
||||
/// parallel and the plane outside it splits into four disjoint half-strips: outside edge `i`
|
||||
/// and inside edge `i + 1`. Each is clipped to the photo and fanned into triangles.
|
||||
///
|
||||
/// Issue #731: joining photo corner `i` to crop corner `i` with a quad per side only works while
|
||||
/// the two rectangles are aligned. Rotated, those quads cross themselves and each other, which
|
||||
/// showed as dimmed wedges inside the crop and doubly dark ones outside, growing with the angle.
|
||||
/// A degenerate quad (a point, a line, a non-finite corner) dims nothing.
|
||||
pub(super) fn outside_crop_mesh(img: Rect, quad: &[Pos2; 4], color: Color32) -> egui::epaint::Mesh {
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
if !quad.iter().all(|q| q.x.is_finite() && q.y.is_finite()) || !img.is_finite() {
|
||||
return mesh;
|
||||
}
|
||||
let centre = pos2((quad[0].x + quad[1].x + quad[2].x + quad[3].x) / 4.0, (quad[0].y + quad[1].y + quad[2].y + quad[3].y) / 4.0);
|
||||
// signed distance from edge i's line, positive towards the crop's centre
|
||||
let side = |i: usize, q: Pos2| -> Option<f32> {
|
||||
let (a, b) = (quad[i % 4], quad[(i + 1) % 4]);
|
||||
let n = vec2(a.y - b.y, b.x - a.x);
|
||||
let orient = n.dot(centre - a);
|
||||
// an edge the centre lies on: the box is flat
|
||||
(orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum())
|
||||
};
|
||||
// Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero
|
||||
let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option<f32>| -> Option<Vec<Pos2>> {
|
||||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||||
for (k, &a) in poly.iter().enumerate() {
|
||||
let b = *poly.get((k + 1) % poly.len())?;
|
||||
let (da, db) = (keep(a)?, keep(b)?);
|
||||
if da >= 0.0 {
|
||||
out.push(a);
|
||||
}
|
||||
if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON {
|
||||
let t = da / (da - db);
|
||||
out.push(a + (b - a) * t);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
};
|
||||
let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
|
||||
for i in 0..4 {
|
||||
let outside = |q: Pos2| side(i, q).map(|d| -d);
|
||||
let inside_next = |q: Pos2| side(i + 1, q);
|
||||
let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() };
|
||||
if strip.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let base = mesh.vertices.len() as u32;
|
||||
mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color }));
|
||||
for k in 1..(strip.len() as u32).saturating_sub(1) {
|
||||
mesh.indices.extend([base, base + k, base + k + 1]);
|
||||
}
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
|
||||
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
|
||||
f.crop_quad_norm()
|
||||
}
|
||||
|
||||
/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto.
|
||||
/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle.
|
||||
/// `held`: drawn with ⌘ held in the crop tool, which stays active afterwards.
|
||||
fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, held: bool) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if !held && resp.double_clicked() {
|
||||
app.auto_straighten(ui.ctx());
|
||||
app.ui.tool.clear();
|
||||
app.gesture = None;
|
||||
return;
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
app.gesture = Some(Gesture::StraightenLine { a: q });
|
||||
}
|
||||
let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return };
|
||||
let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return };
|
||||
let p = ui.painter();
|
||||
p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140)));
|
||||
p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE));
|
||||
if resp.drag_stopped() {
|
||||
app.gesture = None;
|
||||
let v = b - a;
|
||||
if v.length() > 8.0 {
|
||||
let mut deg = v.y.atan2(v.x).to_degrees() as f64;
|
||||
// fold to the nearest axis: a line closer to vertical straightens plumb lines
|
||||
while deg > 45.0 {
|
||||
deg -= 90.0;
|
||||
}
|
||||
while deg < -45.0 {
|
||||
deg += 90.0;
|
||||
}
|
||||
let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0}));
|
||||
}
|
||||
if !held {
|
||||
app.ui.tool.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use super::*;
|
||||
|
||||
/// Size of the Navigator mini map (points).
|
||||
const NAV_W: f32 = 180.0;
|
||||
|
||||
/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the
|
||||
/// visible region outlined; click or drag on it to pan. Returns the area it covers.
|
||||
pub(super) fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option<Rect> {
|
||||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||||
if !app.ui.navigator || !zoomed || app.ui.fullscreen {
|
||||
return None;
|
||||
}
|
||||
let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id));
|
||||
let aspect = img.width() / img.height().max(1.0);
|
||||
let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) };
|
||||
let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h));
|
||||
let p = ui.painter_at(canvas);
|
||||
p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190));
|
||||
match tex {
|
||||
Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE),
|
||||
None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton),
|
||||
};
|
||||
// the visible part of the photo, in normalized image coordinates
|
||||
let vis = canvas.intersect(img);
|
||||
let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height());
|
||||
let (a, b) = (n(vis.min), n(vis.max));
|
||||
let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height());
|
||||
let view = Rect::from_min_max(to_nav(a), to_nav(b));
|
||||
p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside);
|
||||
p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
|
||||
register(ui, "canvas:navigator", frame);
|
||||
let resp = ui.interact(frame.expand(5.0), egui::Id::unique("navigator"), Sense::click_and_drag());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Navigator"));
|
||||
if (resp.clicked() || resp.dragged())
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
// centre the view on the point under the pointer
|
||||
let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0);
|
||||
let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0);
|
||||
app.ui.pan = (u, v);
|
||||
}
|
||||
Some(frame.expand(5.0))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ filmstrip
|
||||
|
||||
/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an
|
||||
/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266).
|
||||
pub(super) fn film_label(name: &str) -> String {
|
||||
if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::<String>()) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when
|
||||
/// film names are on, badges when film badges are on, and stack counts, following the active
|
||||
/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends
|
||||
/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active
|
||||
/// side.
|
||||
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));
|
||||
let ids = app.session.visible_cloned();
|
||||
let active = app.session.selection.active;
|
||||
let cell_w = 120.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
if ids.is_empty() {
|
||||
let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim);
|
||||
return;
|
||||
}
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0))));
|
||||
// a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work)
|
||||
child.style_mut().always_scroll_the_only_direction = true;
|
||||
// only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active`
|
||||
let follow = crate::panels::grid::follow_active(child.ctx(), egui::Id::unique("film-follow-active"), active, app.session.source);
|
||||
// a wheel notch scrolls a third of its width; see `grid::wheel_step`
|
||||
register(ui, "filmstrip", child.max_rect());
|
||||
let wheel = crate::panels::grid::wheel_step(&child, egui::Id::unique("film-wheel"), child.max_rect(), child.max_rect().width(), true);
|
||||
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
|
||||
if wheel != egui::Vec2::ZERO {
|
||||
ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
|
||||
app.film_scroll = Some(vp.left());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height()));
|
||||
let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size());
|
||||
// centred when it is (partly) off screen; a click on a visible thumbnail leaves the strip alone
|
||||
if follow && Some(*id) == active && !(local.left() >= vp.left() && local.right() <= vp.right()) {
|
||||
ui.scroll_to_rect(cr, Some(egui::Align::Center));
|
||||
}
|
||||
if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(cr, egui::Id::unique(("film", id.0)), Sense::click());
|
||||
let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some();
|
||||
crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed));
|
||||
register(ui, format!("film:{}", id.0), cr);
|
||||
let state = app.session.selection.state_of(*id);
|
||||
let sel = state == lightcraft_engine::SelectionState::Active;
|
||||
let p = ui.painter();
|
||||
let label = app.session.catalog.photo(*id).and_then(|ph| ph.label);
|
||||
let selected = state != lightcraft_engine::SelectionState::NotSelected;
|
||||
let base = if selected {
|
||||
t.cell_selected
|
||||
} else if resp.hovered() {
|
||||
t.cell_selected.gamma_multiply(0.6)
|
||||
} else {
|
||||
t.canvas
|
||||
};
|
||||
p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, selected));
|
||||
let names = app.ui.settings.film_names;
|
||||
if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) {
|
||||
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
crate::panels::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8);
|
||||
let thumb = app.renderer.thumb(*id);
|
||||
if thumb.is_none() {
|
||||
p.rect_filled(img_area, 0.0, t.skeleton);
|
||||
if failed {
|
||||
p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text);
|
||||
}
|
||||
}
|
||||
if let Some(tex) = thumb {
|
||||
let [tw, th] = tex.size;
|
||||
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
|
||||
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
|
||||
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if sel {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
||||
} else if state == lightcraft_engine::SelectionState::Selected {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
}
|
||||
if app.ui.settings.film_badges
|
||||
&& let Some(ph) = app.session.catalog.photo(*id)
|
||||
{
|
||||
film_badges(p, &t, fr, ph);
|
||||
}
|
||||
if let Some(st) = app.session.catalog.stack_of(*id) {
|
||||
let collapsed = st.collapsed;
|
||||
let text =
|
||||
if collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) };
|
||||
let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE);
|
||||
let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0));
|
||||
// like the grid's badge: a click expands / collapses the stack
|
||||
let badge = ui.interact(br, egui::Id::unique(("film-stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed {
|
||||
"Stack — click to expand"
|
||||
} else {
|
||||
"Stack — click to collapse"
|
||||
});
|
||||
crate::widgets::announce(&badge, crate::i18n::tr(if collapsed { "Expand Stack" } else { "Collapse Stack" }));
|
||||
register(ui, format!("filmstack:{}", id.0), br);
|
||||
let p = ui.painter();
|
||||
let fill = if badge.hovered() { Color32::from_black_alpha(230) } else { Color32::from_black_alpha(170) };
|
||||
p.rect_filled(br, 7.5, fill);
|
||||
crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE);
|
||||
p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
if badge.clicked() {
|
||||
crate::panels::grid::toggle_stack(app, ui.ctx(), *id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let compare = app.ui.view == crate::state::ViewMode::Compare;
|
||||
let r = if compare && !m.command && !m.shift {
|
||||
// Compare: the clicked photo replaces the active side (or swaps with the other one)
|
||||
app.run("compare.set", json!({"id": id.0}))
|
||||
} else if compare {
|
||||
// ⌘/Ctrl- and Shift-clicks change the selection; the pair on screen stays (#187)
|
||||
crate::panels::compare::select_with_modifier(app, *id, !m.command)
|
||||
} else {
|
||||
let mode = if m.command {
|
||||
"toggle"
|
||||
} else if m.shift {
|
||||
"range"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
app.run("library.select", json!({"ids": [id.0], "mode": mode}))
|
||||
};
|
||||
if compare && let Err(e) = r {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
|
||||
crate::widgets::context_menu(&resp, |ui| crate::panels::grid::context_menu(app, ui, *id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture
|
||||
/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the
|
||||
/// catalog no longer has the photo (or its file name is blank); the reason then stands alone.
|
||||
pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String {
|
||||
match (file_name.is_empty(), failed) {
|
||||
(_, false) => file_name.to_string(),
|
||||
(true, true) => crate::i18n::tr("This photo can't be opened").to_string(),
|
||||
(false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface).
|
||||
fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) {
|
||||
use crate::icons::{Icon, paint};
|
||||
use lightcraft_catalog::Flag;
|
||||
let edited = ph.is_edited();
|
||||
if ph.rating == 0 && ph.flag == Flag::None && !edited {
|
||||
return;
|
||||
}
|
||||
let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom());
|
||||
p.rect_filled(bar, 0.0, Color32::from_black_alpha(130));
|
||||
let y = bar.center().y;
|
||||
let mut x = bar.left() + 3.0;
|
||||
x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0;
|
||||
match ph.flag {
|
||||
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick),
|
||||
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject),
|
||||
Flag::None => {}
|
||||
}
|
||||
if edited {
|
||||
paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
#[cfg(test)]
|
||||
use super::info::label_badge_rect;
|
||||
use super::*;
|
||||
|
||||
/// Screen ↔ normalized-image mapping for the displayed frame.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CanvasMap {
|
||||
pub(super) rect: Rect,
|
||||
to_norm: Affine,
|
||||
from_norm: Affine,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
impl CanvasMap {
|
||||
pub(super) fn new(frame: &Frame, rect: Rect) -> CanvasMap {
|
||||
let k = 8.0f32;
|
||||
let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize);
|
||||
let to_norm = frame.out_to_norm(w, h);
|
||||
CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k }
|
||||
}
|
||||
pub fn norm(&self, p: Pos2) -> Point {
|
||||
self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64))
|
||||
}
|
||||
pub fn screen(&self, n: Point) -> Pos2 {
|
||||
let q = self.from_norm.apply(n);
|
||||
pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
|
||||
let zoom = match zoom {
|
||||
Zoom::Percent(p) if !p.is_finite() || p <= 0.0 => Zoom::Fit,
|
||||
_ => zoom,
|
||||
};
|
||||
let (aw, ah) = (area.width(), area.height());
|
||||
let (w, h) = match zoom {
|
||||
Zoom::Fit => {
|
||||
if aw / ah > aspect {
|
||||
(ah * aspect, ah)
|
||||
} else {
|
||||
(aw, aw / aspect)
|
||||
}
|
||||
}
|
||||
Zoom::Fill => {
|
||||
if aw / ah > aspect {
|
||||
(aw, aw / aspect)
|
||||
} else {
|
||||
(ah * aspect, ah)
|
||||
}
|
||||
}
|
||||
Zoom::Percent(p) => {
|
||||
// the photo's own pixels at p% (its width as shown: cropped and rotated)
|
||||
let w = img_px[0] as f32 * p / 100.0 / ppp;
|
||||
(w, w / aspect)
|
||||
}
|
||||
};
|
||||
let pan = bounded_pan(area, vec2(w, h), pan);
|
||||
let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h);
|
||||
let rect = Rect::from_center_size(c, vec2(w, h));
|
||||
// At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole
|
||||
// physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width
|
||||
// canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump.
|
||||
let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5;
|
||||
if matches!(zoom, Zoom::Percent(_)) && one_to_one {
|
||||
let snap = |v: f32| (v * ppp).round() / ppp;
|
||||
return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size());
|
||||
}
|
||||
rect
|
||||
}
|
||||
|
||||
/// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can
|
||||
/// only show their embedded camera JPEG, whose crop and aspect ratio can differ from that metadata.
|
||||
pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect {
|
||||
let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32);
|
||||
let scale = (area.width() / w).min(area.height() / h);
|
||||
Rect::from_center_size(area.center(), vec2(w * scale, h * scale))
|
||||
}
|
||||
|
||||
/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is
|
||||
/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred
|
||||
/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its
|
||||
/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to
|
||||
/// the sampler.
|
||||
pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect {
|
||||
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
|
||||
let (w, h) = (size[0] as f32, size[1] as f32);
|
||||
if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 {
|
||||
return rect;
|
||||
}
|
||||
let snap = |v: f32| (v * ppp).round() / ppp;
|
||||
Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp))
|
||||
}
|
||||
|
||||
/// Keep the viewport inside the photo; axes smaller than the viewport stay centred.
|
||||
fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) {
|
||||
let bound = |visible: f32, image: f32, centre: f32| {
|
||||
let centre = if centre.is_finite() { centre } else { 0.5 };
|
||||
if image > visible.max(0.0) {
|
||||
let half = visible.max(0.0) / image / 2.0;
|
||||
centre.clamp(half, 1.0 - half)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
};
|
||||
(bound(area.width(), size.x, pan.0), bound(area.height(), size.y, pan.1))
|
||||
}
|
||||
|
||||
pub(crate) fn pan_image(app: &mut LightcraftApp, area: Rect, img: Rect, delta: egui::Vec2) {
|
||||
let centre = area.center() - img.min - delta;
|
||||
let pan = bounded_pan(area, img.size(), (centre.x / img.width().max(1.0), centre.y / img.height().max(1.0)));
|
||||
let _ = app.run("view.navigate", json!({"pan": pan}));
|
||||
}
|
||||
|
||||
/// A drag with the middle button (the mouse wheel pressed) pans the zoomed photo whatever tool is
|
||||
/// active, so the healing brush, mask brushes and crop never paint or move while you look around;
|
||||
/// the hand cursor shows while it does.
|
||||
pub(super) fn middle_pan(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, area: Rect, img: Rect) -> bool {
|
||||
if !resp.dragged_by(egui::PointerButton::Middle) {
|
||||
return false;
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if img.width() > area.width() + 1.0 || img.height() > area.height() + 1.0 {
|
||||
pan_image(app, area, img, resp.drag_delta());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Net vertical notches (up is positive) of unmodified line/page-unit wheel events: a real mouse wheel, not a trackpad.
|
||||
fn wheel_notches(events: &[egui::Event]) -> f32 {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page, delta, modifiers, .. }
|
||||
if *modifiers == egui::Modifiers::NONE && delta.y.is_finite() =>
|
||||
{
|
||||
Some(delta.y)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Native pinch (also modifier-wheel zoom) and two-finger scroll, scoped to this image view.
|
||||
pub(crate) fn navigate_gesture(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, area: Rect, img: Rect, native: [usize; 2]) -> bool {
|
||||
if !resp.hovered() {
|
||||
return false;
|
||||
}
|
||||
let (mut factor, mut delta, anchor) =
|
||||
ui.input(|i| (i.zoom_delta(), i.translation_delta(), i.multi_touch().map(|t| t.center_pos).or(i.pointer.hover_pos())));
|
||||
// A notched mouse wheel (line/page units, no modifier) zooms around the pointer, through egui's smoothed wheel
|
||||
// delta like Ctrl+wheel (so it glides instead of jumping); trackpads report points and still pan.
|
||||
let wheel_id = egui::Id::unique("loupe-wheel-zoom");
|
||||
if ui.input(|i| wheel_notches(&i.events)) != 0.0 {
|
||||
ui.data_mut(|d| d.insert_temp(wheel_id, true));
|
||||
}
|
||||
if ui.data(|d| d.get_temp::<bool>(wheel_id)).unwrap_or(false) {
|
||||
let smooth = ui.input(|i| i.smooth_scroll_delta.y);
|
||||
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed);
|
||||
if smooth == 0.0 {
|
||||
ui.data_mut(|d| d.remove_temp::<bool>(wheel_id));
|
||||
} else {
|
||||
factor *= (speed * smooth).exp();
|
||||
delta = egui::Vec2::ZERO;
|
||||
}
|
||||
}
|
||||
if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) {
|
||||
return false;
|
||||
}
|
||||
let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false };
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let aspect = img.width() / img.height().max(1.0);
|
||||
let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5));
|
||||
let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32;
|
||||
let mut zoom = app.ui.zoom;
|
||||
let size = if factor != 1.0 {
|
||||
// Start at the displayed scale (including Fit/Fill and an interrupted click animation).
|
||||
let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0);
|
||||
zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) };
|
||||
fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size()
|
||||
} else {
|
||||
img.size()
|
||||
};
|
||||
// The image point under the pointer remains there as its size changes, then follows the pan.
|
||||
let point = (anchor - img.min) / img.size();
|
||||
let offset = area.center() - anchor - delta;
|
||||
let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0)));
|
||||
let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan}));
|
||||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||||
ui.ctx().request_repaint();
|
||||
true
|
||||
}
|
||||
|
||||
/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the
|
||||
/// smallest limit WebGL devices have).
|
||||
///
|
||||
/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit
|
||||
/// in the first frame's input only, so the raw value is `None` from the second frame on
|
||||
/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it).
|
||||
pub(crate) fn texture_side(ctx: &egui::Context) -> usize {
|
||||
ctx.input(|i| i.max_texture_side)
|
||||
}
|
||||
|
||||
/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what
|
||||
/// 100 % zoom and the render size limit are measured against), at least 1 × 1.
|
||||
pub(crate) fn output_px(frame: &Frame) -> [usize; 2] {
|
||||
let (w, h) = frame.native_size();
|
||||
let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 };
|
||||
[px(w), px(h)]
|
||||
}
|
||||
|
||||
/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the
|
||||
/// normalized box over the drawn image: `None` when the photo has none or it is cropped away.
|
||||
pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option<lightcraft_geom::Rect> {
|
||||
let f = photo.meta.focus.as_ref()?;
|
||||
let d = &photo.develop;
|
||||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref());
|
||||
let [w, h] = output_px(&frame);
|
||||
let to_out = frame.out_to_norm(w, h).inverse()?;
|
||||
// stored on the upright photo: the user's Rotate / Flip on top, then the crop
|
||||
let r = d.orientation.map_norm_rect(f.rect);
|
||||
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| {
|
||||
let q = to_out.apply(c);
|
||||
(q.x / w as f64, q.y / h as f64)
|
||||
});
|
||||
let xs = corners.map(|c| c.0);
|
||||
let ys = corners.map(|c| c.1);
|
||||
let out = lightcraft_geom::Rect {
|
||||
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
};
|
||||
if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// its centre must be in the frame; the box may reach past the edge (it is clipped when drawn)
|
||||
let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0);
|
||||
((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out)
|
||||
}
|
||||
|
||||
/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame.
|
||||
pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> {
|
||||
let r = focus_box(app.session.catalog.photo(id)?)?;
|
||||
Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32))
|
||||
}
|
||||
|
||||
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
|
||||
pub(super) fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
let t = if *anim { 0.22 } else { 0.0 };
|
||||
let id = egui::Id::unique("loupe_anim");
|
||||
let v = |k: &str, x: f32| ctx.animate_value_with_time(id.with(k), x, t);
|
||||
let (c, s) = (target.center(), target.size());
|
||||
let r = Rect::from_center_size(pos2(v("cx", c.x), v("cy", c.y)), vec2(v("w", s.x), v("h", s.y)));
|
||||
if *anim && (r.center() - c).abs().max_elem() < 0.5 && (r.size() - s).abs().max_elem() < 0.5 {
|
||||
*anim = false;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod preview_geometry_tests {
|
||||
use super::{fit_rect, fit_texture_rect, label_badge_rect};
|
||||
use crate::state::Zoom;
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
/// The "Before" label sits on a dark pill that wraps the text with padding on every side.
|
||||
#[test]
|
||||
fn before_label_badge_wraps_the_text() {
|
||||
let text = vec2(40.0, 14.0);
|
||||
let pane = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0));
|
||||
let bg = label_badge_rect(photo, pane, text, 0.0);
|
||||
assert_eq!(bg.min, pos2(28.0, 38.0));
|
||||
assert_eq!(bg.size(), vec2(56.0, 22.0));
|
||||
assert!(photo.contains_rect(bg));
|
||||
}
|
||||
|
||||
/// Zoomed in, the photo's corner is off the canvas: the pill moves to the canvas' corner
|
||||
/// instead of leaving with it (issue #2267), on whichever axes the photo reaches past.
|
||||
#[test]
|
||||
fn before_label_badge_stays_in_the_pane_when_the_photo_reaches_past_it() {
|
||||
let text = vec2(40.0, 14.0);
|
||||
let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0));
|
||||
let both = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, 0.0);
|
||||
assert_eq!(both.min, pos2(108.0, 58.0));
|
||||
assert!(pane.contains_rect(both));
|
||||
let left_only = label_badge_rect(Rect::from_min_size(pos2(-900.0, 120.0), vec2(3000.0, 400.0)), pane, text, 0.0);
|
||||
assert_eq!(left_only.min, pos2(108.0, 128.0));
|
||||
assert!(pane.contains_rect(left_only));
|
||||
}
|
||||
|
||||
/// With a filter active, the pill in the canvas' corner has that corner: the label sits below
|
||||
/// it, whether the photo is past the pane or just under the pill. A photo lower down is left
|
||||
/// alone. A wide label near the pane's right edge stays inside the pane.
|
||||
#[test]
|
||||
fn before_label_badge_keeps_clear_of_the_filter_pill_and_the_right_edge() {
|
||||
let text = vec2(40.0, 14.0);
|
||||
let pane = Rect::from_min_size(pos2(100.0, 50.0), vec2(800.0, 600.0));
|
||||
let inset = super::FILTER_PILL_INSET + super::FILTER_PILL_HEIGHT;
|
||||
let past = label_badge_rect(Rect::from_min_size(pos2(-900.0, -700.0), vec2(3000.0, 2000.0)), pane, text, inset);
|
||||
assert_eq!(past.min, pos2(108.0, 50.0 + inset + 8.0));
|
||||
let under = label_badge_rect(Rect::from_min_size(pos2(200.0, 60.0), vec2(400.0, 300.0)), pane, text, inset);
|
||||
assert_eq!(under.min.y, 50.0 + inset + 8.0, "a photo whose corner is under the pill moves down too");
|
||||
let lower = label_badge_rect(Rect::from_min_size(pos2(200.0, 200.0), vec2(400.0, 300.0)), pane, text, inset);
|
||||
assert_eq!(lower.min, pos2(208.0, 208.0));
|
||||
let wide = label_badge_rect(Rect::from_min_size(pos2(860.0, 200.0), vec2(400.0, 300.0)), pane, text, 0.0);
|
||||
assert!(pane.contains_rect(wide), "{wide:?}");
|
||||
assert_eq!(wide.max.x, pane.right() - 8.0, "8 points from the pane's right edge, like the left inset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() {
|
||||
let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_texture_rect(frame, [100, 200]);
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.center(), frame.center());
|
||||
assert!(frame.contains_rect(image));
|
||||
}
|
||||
|
||||
/// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear
|
||||
/// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off).
|
||||
#[test]
|
||||
fn percent_zoom_lands_on_whole_physical_pixels() {
|
||||
let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0));
|
||||
for ppp in [1.0, 1.25, 2.0, 3.0] {
|
||||
for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] {
|
||||
let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan);
|
||||
for v in [image.min.x, image.min.y] {
|
||||
assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}");
|
||||
}
|
||||
assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portrait_preview_aspect_fits_detail_area_without_stretching() {
|
||||
let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0));
|
||||
let image = fit_rect(area, 0.5, Zoom::Fit, [4000, 6000], 1.0, (0.5, 0.5));
|
||||
|
||||
assert!((image.width() / image.height() - 0.5).abs() < 1e-6);
|
||||
assert_eq!(image.size(), vec2(100.0, 200.0));
|
||||
assert_eq!(image.center(), area.center());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wheel_zoom_tests {
|
||||
use super::*;
|
||||
|
||||
fn wheel(unit: egui::MouseWheelUnit, y: f32, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_notched_unmodified_wheels_count() {
|
||||
let m = egui::Modifiers::NONE;
|
||||
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, m), wheel(egui::MouseWheelUnit::Line, 2.0, m)]), 3.0);
|
||||
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Point, 40.0, m)]), 0.0);
|
||||
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, 1.0, egui::Modifiers::CTRL)]), 0.0);
|
||||
assert_eq!(wheel_notches(&[wheel(egui::MouseWheelUnit::Line, f32::NAN, m)]), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod snap_tests {
|
||||
use super::*;
|
||||
|
||||
/// A texture drawn at its own size lands on whole physical pixels at exactly that size (no
|
||||
/// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given.
|
||||
#[test]
|
||||
fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() {
|
||||
let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2));
|
||||
let s = snap_fit_rect(r, [1088, 720], 1.0);
|
||||
assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0));
|
||||
// at 1.5 points per pixel the snap is to physical pixels and the size stays in points
|
||||
let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5);
|
||||
assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}");
|
||||
let other = snap_fit_rect(r, [2560, 1694], 1.0);
|
||||
assert_eq!(other, r);
|
||||
let bad = snap_fit_rect(r, [1088, 720], f32::NAN);
|
||||
assert_eq!((bad.width(), bad.height()), (1088.0, 720.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
use super::geometry::focus_box;
|
||||
use super::*;
|
||||
|
||||
/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Focus Point): green corner
|
||||
/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt.
|
||||
/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:<id>`).
|
||||
pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) {
|
||||
let Some(r) = focus_box(photo) else { return };
|
||||
let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height());
|
||||
let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1));
|
||||
let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0)));
|
||||
let arm = (b.width().min(b.height()) * 0.3).max(5.0);
|
||||
let green = Color32::from_rgb(90, 230, 110);
|
||||
for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] {
|
||||
let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)];
|
||||
p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170))));
|
||||
p.add(egui::Shape::line(pts, Stroke::new(2.0, green)));
|
||||
}
|
||||
let c = b.center();
|
||||
p.circle_filled(c, 2.5, Color32::from_black_alpha(170));
|
||||
p.circle_filled(c, 1.5, green);
|
||||
register_geometry(p.ctx(), key, b);
|
||||
}
|
||||
|
||||
/// View Options' Show Briefly When Photo Changes: note when the loupe changes to photo `id`, and
|
||||
/// whether the brief info overlay shows this frame (until [`BRIEF_INFO_SECS`] have passed; a
|
||||
/// repaint is asked for then, so it hides without input).
|
||||
///
|
||||
/// [`BRIEF_INFO_SECS`]: crate::state::BRIEF_INFO_SECS
|
||||
pub(super) fn brief_info(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let brief = &mut app.ui.brief_info;
|
||||
if brief.photo != Some(id) {
|
||||
brief.photo = Some(id);
|
||||
brief.since = now;
|
||||
}
|
||||
let left = crate::state::BRIEF_INFO_SECS - (now - brief.since);
|
||||
brief.shown = app.ui.settings.loupe_info.briefly != crate::state::InfoOverlay::Off && left > 0.0;
|
||||
if brief.shown {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(left));
|
||||
}
|
||||
}
|
||||
|
||||
/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the
|
||||
/// photo's top left, inset 8 points, with the `text` size plus padding. Zoomed in, the photo
|
||||
/// reaches past its `pane` (the canvas): the pill then sits at the pane's top left, so the label
|
||||
/// stays in view (issue #2267: at 100 % it left the screen with the photo's corner), below the
|
||||
/// `top_inset` the filter pill takes in that corner, and never past the pane's right edge.
|
||||
pub(super) fn label_badge_rect(photo: Rect, pane: Rect, text: egui::Vec2, top_inset: f32) -> Rect {
|
||||
let size = text + vec2(16.0, 8.0);
|
||||
let left = photo.left().max(pane.left()).min(pane.right() - size.x - 16.0).max(pane.left());
|
||||
let corner = pos2(left, photo.top().max(pane.top() + top_inset));
|
||||
Rect::from_min_size(corner + vec2(8.0, 8.0), size)
|
||||
}
|
||||
|
||||
/// The info overlay at the canvas' top left (`view.infoOverlay`): the lines View Options chose for
|
||||
/// Info 1 or Info 2 (`view.viewOptions`).
|
||||
pub(super) fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
|
||||
let Some(lines) = info_overlay_lines(app, photo) else { return };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let mut y = canvas.top() + info_overlay_top(app, &t);
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
// laid out uncoloured: `galley` only fills placeholder text with its colour, so a white
|
||||
// layout made the shadow a second white copy (#1690)
|
||||
let g = p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER);
|
||||
let at = pos2(canvas.left() + 14.0, y);
|
||||
// a soft shadow keeps the text readable on bright photos
|
||||
p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200));
|
||||
p.galley(at, g.clone(), Color32::WHITE);
|
||||
y += g.size().y + INFO_OVERLAY_GAP;
|
||||
}
|
||||
y = overlay_badges(p, &t, pos2(canvas.left() + 14.0, y), photo);
|
||||
register_geometry(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
|
||||
}
|
||||
|
||||
/// Where the info overlay's text starts below the canvas' top (without window controls over it),
|
||||
/// and the gap after each line.
|
||||
const INFO_OVERLAY_TOP: f32 = 12.0;
|
||||
|
||||
const INFO_OVERLAY_GAP: f32 = 3.0;
|
||||
|
||||
/// The info overlay's font for line `i` (the file name is the first, larger line).
|
||||
fn info_overlay_font(t: &Tokens, i: usize) -> egui::FontId {
|
||||
if i == 0 { t.semibold(15.0) } else { t.font(12.5) }
|
||||
}
|
||||
|
||||
/// How far below the canvas' top the info overlay's text and badges reach (0 when it is off), so a
|
||||
/// label drawn in that corner can sit below it.
|
||||
pub(super) fn info_overlay_extent(app: &LightcraftApp, p: &egui::Painter, photo: &lightcraft_catalog::Photo) -> f32 {
|
||||
let Some(lines) = info_overlay_lines(app, photo) else { return 0.0 };
|
||||
let t = Tokens::get(p.ctx());
|
||||
let text = lines.iter().enumerate().fold(info_overlay_top(app, &t), |y, (i, l)| {
|
||||
y + p.layout_no_wrap(l.clone(), info_overlay_font(&t, i), Color32::PLACEHOLDER).size().y + INFO_OVERLAY_GAP
|
||||
});
|
||||
text + if has_overlay_badges(photo) { OVERLAY_BADGES_H } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Where the info overlay's text starts below the canvas' top: the full-screen preview has no top
|
||||
/// bar, so on macOS it starts below the window controls that sit over the photo there (#2460).
|
||||
fn info_overlay_top(app: &LightcraftApp, t: &Tokens) -> f32 {
|
||||
let controls = app.ui.fullscreen && app.integrated_titlebar && !app.window_is_fullscreen;
|
||||
if controls { t.top_bar_h } else { INFO_OVERLAY_TOP }
|
||||
}
|
||||
|
||||
/// The info overlay's lines for `photo`, or `None` when it is off.
|
||||
fn info_overlay_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Option<Vec<String>> {
|
||||
use crate::state::InfoOverlay;
|
||||
// with the overlay off, Show Briefly When Photo Changes shows its Info for a moment
|
||||
let brief = app.ui.info_overlay == InfoOverlay::Off && app.ui.brief_info.shown;
|
||||
let mode = if brief { app.ui.settings.loupe_info.briefly } else { app.ui.info_overlay };
|
||||
let fields = match mode {
|
||||
InfoOverlay::Off => return None,
|
||||
InfoOverlay::Basic => &app.ui.settings.loupe_info.info1,
|
||||
InfoOverlay::Exposure => &app.ui.settings.loupe_info.info2,
|
||||
};
|
||||
let lines: Vec<String> = fields.iter().filter_map(|f| info_line(*f, photo)).collect();
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
/// The height of the info overlay's rating and flag row.
|
||||
const OVERLAY_BADGES_H: f32 = 17.0;
|
||||
|
||||
fn has_overlay_badges(photo: &lightcraft_catalog::Photo) -> bool {
|
||||
photo.rating > 0 || photo.flag != lightcraft_catalog::Flag::None
|
||||
}
|
||||
|
||||
/// The star rating and flag under the info overlay's lines, when the photo has either; returns
|
||||
/// the y below them.
|
||||
fn overlay_badges(p: &egui::Painter, t: &Tokens, at: Pos2, photo: &lightcraft_catalog::Photo) -> f32 {
|
||||
use crate::icons::{Icon, paint};
|
||||
use lightcraft_catalog::Flag;
|
||||
let flag = match photo.flag {
|
||||
Flag::Pick => Some((Icon::FlagPickFilled, Color32::WHITE)),
|
||||
Flag::Reject => Some((Icon::FlagReject, t.reject)),
|
||||
Flag::None => None,
|
||||
};
|
||||
if !has_overlay_badges(photo) {
|
||||
return at.y;
|
||||
}
|
||||
let stars = photo.rating.min(5);
|
||||
let mut icons: Vec<(Icon, Color32)> = vec![(Icon::StarFilled, Color32::WHITE); stars as usize];
|
||||
icons.extend(flag);
|
||||
let side = 12.0;
|
||||
let mut x = at.x;
|
||||
for (i, (icon, color)) in icons.into_iter().enumerate() {
|
||||
// the flag stands a little apart from the stars
|
||||
if i > 0 && i == stars as usize {
|
||||
x += 5.0;
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(x, at.y + 1.0), vec2(side, side));
|
||||
paint(p, r.translate(vec2(1.0, 1.0)), icon, Color32::from_black_alpha(200));
|
||||
paint(p, r, icon, color);
|
||||
x += side + 2.0;
|
||||
}
|
||||
register_geometry(p.ctx(), "canvas:infoOverlayBadges", Rect::from_min_max(at, pos2(x, at.y + side + 2.0)));
|
||||
at.y + OVERLAY_BADGES_H
|
||||
}
|
||||
|
||||
/// The filter pill's inset from the canvas' top-left corner and its height (the same inset from
|
||||
/// the left as from the top, so the pill sits in the corner, #1645).
|
||||
pub(super) const FILTER_PILL_INSET: f32 = 3.0;
|
||||
|
||||
pub(super) const FILTER_PILL_HEIGHT: f32 = 20.0;
|
||||
|
||||
/// Whether [`filter_pill`] has something to show (a filter is active).
|
||||
pub(super) fn filter_pill_shown(app: &LightcraftApp) -> bool {
|
||||
!lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty()
|
||||
}
|
||||
|
||||
/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous
|
||||
/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the
|
||||
/// canvas margin, so the photo does not move.
|
||||
pub(super) fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
|
||||
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
|
||||
if chips.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter_at(canvas);
|
||||
let label =
|
||||
chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · ");
|
||||
let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225));
|
||||
let inset = FILTER_PILL_INSET;
|
||||
let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0));
|
||||
let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, FILTER_PILL_HEIGHT));
|
||||
let resp = ui.interact(r, egui::Id::unique("loupe-filter-pill"), Sense::click());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Filters"));
|
||||
register(ui, "loupe:filters", r);
|
||||
p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 }));
|
||||
let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y));
|
||||
p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
|
||||
let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0);
|
||||
let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) });
|
||||
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
|
||||
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
|
||||
if resp.on_hover_text(crate::i18n::tr("Click to clear these filters")).clicked() {
|
||||
let _ = app.run("library.clearFilter", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so;
|
||||
/// hovering it explains (registered as `notice:previewOnly:loupe`).
|
||||
pub(super) fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter_at(canvas);
|
||||
let text = crate::i18n::tr_format!("Preview only — editing the camera's embedded JPEG ({})", crate::widgets::preview_only_variant(reason));
|
||||
let g = p.layout_no_wrap(text, t.font(12.0), Color32::WHITE);
|
||||
let size = vec2(g.size().x + 40.0, 26.0);
|
||||
let r = Rect::from_min_size(pos2(canvas.center().x - size.x / 2.0, canvas.top() + 12.0), size);
|
||||
p.rect_filled(r, 13.0, Color32::from_black_alpha(185));
|
||||
crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(15.0, 15.0)), crate::icons::Icon::Info, t.caution);
|
||||
p.galley(pos2(r.left() + 29.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE);
|
||||
register(ui, "notice:previewOnly:loupe", r);
|
||||
ui.interact(r, egui::Id::unique("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason));
|
||||
}
|
||||
|
||||
/// One line of the info overlay for `photo`; `None` when the field is None or the photo has
|
||||
/// nothing to show for it.
|
||||
fn info_line(field: crate::state::LoupeInfoField, photo: &lightcraft_catalog::Photo) -> Option<String> {
|
||||
use crate::state::LoupeInfoField as F;
|
||||
let m = &photo.meta;
|
||||
let text = match field {
|
||||
F::None => return None,
|
||||
F::FileName => photo.file_name.clone(),
|
||||
F::CaptureDate => pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported)),
|
||||
F::Dimensions => format!("{} × {}", photo.width, photo.height),
|
||||
F::Exposure => {
|
||||
let mut exp = Vec::new();
|
||||
if !m.shutter.is_empty() {
|
||||
exp.push(format!("{} s", m.shutter));
|
||||
}
|
||||
if let Some(a) = m.aperture {
|
||||
exp.push(format!("f/{a:.1}"));
|
||||
}
|
||||
if let Some(i) = m.iso {
|
||||
exp.push(format!("ISO {i}"));
|
||||
}
|
||||
if let Some(f) = m.focal_mm {
|
||||
exp.push(format!("{f:.0} mm"));
|
||||
}
|
||||
if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") }
|
||||
}
|
||||
F::Camera => m.camera.clone(),
|
||||
F::Lens => m.lens.clone(),
|
||||
F::CameraAndLens => [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · "),
|
||||
};
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// `2026-09-30T12:00:00` → `2026-09-30 12:00`.
|
||||
fn pretty_date(iso: &str) -> String {
|
||||
let d = iso.replacen('T', " ", 1);
|
||||
d.get(..16).map(str::to_string).unwrap_or(d)
|
||||
}
|
||||
|
||||
/// How long a render may be on its way, with nothing new on screen, before the loupe says
|
||||
/// "Rendering…" (s): as long as the activity stack waits before it shows a task.
|
||||
pub(super) const RENDERING_AFTER_SECS: f64 = 0.5;
|
||||
|
||||
/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on
|
||||
/// screen, since when, and whether the pill is up.
|
||||
pub(crate) type RenderingWait = Option<([Option<u64>; 4], f64, bool)>;
|
||||
|
||||
/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with
|
||||
/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws
|
||||
/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming:
|
||||
/// drafts slower than the wait don't make it blink.
|
||||
pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option<u64>; 4], coming: bool, now: f64) -> bool {
|
||||
if !coming {
|
||||
*wait = None;
|
||||
return false;
|
||||
}
|
||||
let (seen, since, up) = wait.get_or_insert((keys, now, false));
|
||||
if !*up && *seen != keys {
|
||||
(*seen, *since) = (keys, now);
|
||||
}
|
||||
*up |= now - *since > RENDERING_AFTER_SECS;
|
||||
*up
|
||||
}
|
||||
|
||||
/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that
|
||||
/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The
|
||||
/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see
|
||||
/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all
|
||||
/// there will be.
|
||||
pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option<Rect>, id: &str, text: &str, caution: bool) -> Rect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter_at(canvas);
|
||||
let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225));
|
||||
let inset = if caution { 29.0 } else { 10.0 };
|
||||
let size = vec2(g.size().x + inset + 10.0, 20.0);
|
||||
let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size);
|
||||
if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) {
|
||||
r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size);
|
||||
}
|
||||
p.rect_filled(r, 10.0, Color32::from_black_alpha(185));
|
||||
if caution {
|
||||
crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution);
|
||||
}
|
||||
p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
|
||||
register(ui, id, r);
|
||||
r
|
||||
}
|
||||
|
||||
/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]):
|
||||
/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with
|
||||
/// the error on hover and for screen readers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn failed_pill(
|
||||
ui: &egui::Ui,
|
||||
app: &LightcraftApp,
|
||||
photo: &lightcraft_catalog::Photo,
|
||||
canvas: Rect,
|
||||
over: Rect,
|
||||
navigator: Option<Rect>,
|
||||
id: &str,
|
||||
error: &str,
|
||||
) {
|
||||
let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path));
|
||||
let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" });
|
||||
let r = loupe_pill(ui, canvas, over, navigator, id, text, true);
|
||||
let resp = ui.interact(r, egui::Id::unique(("render-failed", id)), Sense::hover()).on_hover_text(error);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}: {error}")));
|
||||
}
|
||||
|
||||
pub(super) fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
|
||||
use lightcraft_engine::media::QuickSource;
|
||||
match q {
|
||||
QuickSource::Cached => "cached",
|
||||
QuickSource::Embedded => "embedded",
|
||||
QuickSource::Small => "small",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod filter_pill_tests {
|
||||
use super::*;
|
||||
|
||||
/// #1645: the filter pill sits in the loupe's top-left corner as far from the left edge as
|
||||
/// from the top one.
|
||||
#[test]
|
||||
fn the_filter_pill_is_inset_equally_from_both_edges() {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
|
||||
app.session.filter.text = "717".into();
|
||||
let ctx = egui::Context::default();
|
||||
let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0));
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| filter_pill(&mut app, ui, canvas));
|
||||
out.textures_delta.clear();
|
||||
let widgets = crate::widgets::take_registry(&ctx);
|
||||
let pill = widgets.iter().find(|(w, _)| w == "loupe:filters").map(|(_, r)| *r).expect("the pill shows with a search");
|
||||
let (left, top) = (pill.left() - canvas.left(), pill.top() - canvas.top());
|
||||
assert!(top > 0.0, "inside the canvas: {pill:?}");
|
||||
assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod info_overlay_tests {
|
||||
use super::*;
|
||||
|
||||
/// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white
|
||||
/// copy one point off, each line read doubled.
|
||||
#[test]
|
||||
fn the_info_overlay_shadow_is_dark() {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
|
||||
app.ui.info_overlay = crate::state::InfoOverlay::Basic;
|
||||
let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos");
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0));
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo));
|
||||
out.textures_delta.clear();
|
||||
// the colour a text shape paints its glyphs in, as epaint resolves it
|
||||
let ink = |s: &egui::epaint::TextShape| {
|
||||
let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color);
|
||||
s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c })
|
||||
};
|
||||
fn texts(shape: &egui::epaint::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::epaint::Shape::Text(t) => out.push(t.clone()),
|
||||
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut all));
|
||||
let copies: Vec<Color32> = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect();
|
||||
assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}");
|
||||
assert_eq!(copies[1], Color32::WHITE, "the text on top");
|
||||
assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]);
|
||||
}
|
||||
|
||||
/// View Options' Show Briefly When Photo Changes: with the overlay off, the chosen Info shows
|
||||
/// for `BRIEF_INFO_SECS` after the loupe changes photo, then hides; an overlay that is on stays.
|
||||
#[test]
|
||||
fn show_briefly_shows_the_info_after_the_photo_changes() {
|
||||
use crate::state::{BRIEF_INFO_SECS, InfoOverlay};
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
|
||||
let photos: Vec<lightcraft_catalog::Photo> = app.session.catalog.photos().take(2).map(|p| (**p).clone()).collect();
|
||||
assert_eq!(photos.len(), 2, "the demo catalog has two photos");
|
||||
let ctx = egui::Context::default();
|
||||
// the loupe shows photo `i` at `time`: the lines the overlay paints then
|
||||
let at = |app: &mut LightcraftApp, i: usize, time: f64| {
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, time, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| brief_info(app, ui.ctx(), photos[i].id));
|
||||
out.textures_delta.clear();
|
||||
painted_lines(app, &photos[i])
|
||||
};
|
||||
let name = |i: usize| photos[i].file_name.clone();
|
||||
assert!(at(&mut app, 0, 1.0).is_empty(), "off by default");
|
||||
app.ui.settings.loupe_info.briefly = InfoOverlay::Exposure;
|
||||
let info2 = at(&mut app, 1, 2.0);
|
||||
assert_eq!(info2.first(), Some(&name(1)), "Info 2 shows when the photo changes: {info2:?}");
|
||||
assert!(!at(&mut app, 1, 2.0 + BRIEF_INFO_SECS - 0.5).is_empty(), "still up");
|
||||
assert!(at(&mut app, 1, 2.0 + BRIEF_INFO_SECS + 0.1).is_empty(), "then hidden");
|
||||
assert_eq!(at(&mut app, 0, 10.0).first(), Some(&name(0)), "and shown again for the next photo");
|
||||
app.ui.settings.loupe_info.briefly = InfoOverlay::Basic;
|
||||
let info1 = at(&mut app, 1, 20.0);
|
||||
assert_eq!(info1.len(), 3, "Info 1's three lines: {info1:?}");
|
||||
assert_ne!(info1, info2, "Info 1, not Info 2");
|
||||
app.ui.info_overlay = InfoOverlay::Exposure;
|
||||
assert_eq!(at(&mut app, 1, 30.0), info2, "an overlay that is on shows its own Info, and stays");
|
||||
}
|
||||
|
||||
/// The lines the overlay paints, top to bottom (each once: the shadow copies left out).
|
||||
fn painted_lines(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> Vec<String> {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0));
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo));
|
||||
out.textures_delta.clear();
|
||||
let mut lines = Vec::new();
|
||||
for c in &out.shapes {
|
||||
if let egui::epaint::Shape::Text(t) = &c.shape
|
||||
&& t.override_text_color.unwrap_or(t.fallback_color) == Color32::WHITE
|
||||
{
|
||||
lines.push(t.galley.job.text.clone());
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// View ▸ View Options: Info 1 and Info 2 each show the three lines chosen for them, in that
|
||||
/// order, without the lines set to None.
|
||||
#[test]
|
||||
fn the_info_overlay_shows_the_lines_chosen_in_view_options() {
|
||||
use crate::state::{InfoOverlay, LoupeInfoField as F};
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
|
||||
let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos");
|
||||
let size = format!("{} × {}", photo.width, photo.height);
|
||||
let date = pretty_date(photo.captured.as_deref().unwrap_or(&photo.imported));
|
||||
app.ui.settings.loupe_info.info1 = [F::Dimensions, F::None, F::FileName];
|
||||
app.ui.settings.loupe_info.info2 = [F::CaptureDate, F::FileName, F::None];
|
||||
app.ui.info_overlay = InfoOverlay::Basic;
|
||||
assert_eq!(painted_lines(&app, &photo), vec![size.clone(), photo.file_name.clone()], "Info 1");
|
||||
app.ui.info_overlay = InfoOverlay::Exposure;
|
||||
assert_eq!(painted_lines(&app, &photo), vec![date, photo.file_name.clone()], "Info 2");
|
||||
app.ui.settings.loupe_info.info1 = [F::None; 3];
|
||||
app.ui.info_overlay = InfoOverlay::Basic;
|
||||
assert_eq!(painted_lines(&app, &photo), Vec::<String>::new(), "nothing chosen, nothing shown");
|
||||
}
|
||||
|
||||
fn demo_app() -> (LightcraftApp, lightcraft_catalog::Photo) {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });
|
||||
app.ui.info_overlay = crate::state::InfoOverlay::Basic;
|
||||
let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos");
|
||||
(app, photo)
|
||||
}
|
||||
|
||||
/// The overlay's text shapes and the rects it registered, painted on a canvas at the window's corner.
|
||||
fn paint_overlay(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let canvas = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 700.0));
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);
|
||||
let mut out = ctx.run_ui(raw, |ui| info_overlay(app, ui.painter(), canvas, photo));
|
||||
out.textures_delta.clear();
|
||||
fn texts(shape: &egui::epaint::Shape, out: &mut Vec<egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::epaint::Shape::Text(t) => out.push(t.clone()),
|
||||
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut all));
|
||||
(all, crate::widgets::take_registry(&ctx))
|
||||
}
|
||||
|
||||
fn name_top(app: &LightcraftApp, photo: &lightcraft_catalog::Photo) -> f32 {
|
||||
let (texts, _) = paint_overlay(app, photo);
|
||||
texts.iter().filter(|t| t.galley.job.text == photo.file_name).map(|t| t.pos.y).fold(f32::INFINITY, f32::min)
|
||||
}
|
||||
|
||||
/// #2460: in the full-screen preview on macOS the window controls stay at the top-left, so the
|
||||
/// file name starts below them; in macOS full screen they are gone and it keeps the corner.
|
||||
#[test]
|
||||
fn the_file_name_clears_the_macos_window_controls_in_the_full_screen_preview() {
|
||||
let (mut app, photo) = demo_app();
|
||||
app.ui.fullscreen = true;
|
||||
assert_eq!(name_top(&app, &photo), 12.0, "no window controls over the photo");
|
||||
app.integrated_titlebar = true;
|
||||
let bar = Tokens::get(&egui::Context::default()).top_bar_h;
|
||||
assert!(name_top(&app, &photo) >= bar, "below the window controls");
|
||||
app.window_is_fullscreen = true;
|
||||
assert_eq!(name_top(&app, &photo), 12.0, "macOS full screen hides the window controls");
|
||||
app.window_is_fullscreen = false;
|
||||
app.ui.fullscreen = false;
|
||||
assert_eq!(name_top(&app, &photo), 12.0, "the loupe's canvas starts below the top bar");
|
||||
}
|
||||
|
||||
/// #2460: a rated or flagged photo shows its stars and flag under the date and size.
|
||||
#[test]
|
||||
fn the_info_overlay_shows_the_rating_and_flag_under_the_date() {
|
||||
let (_, mut photo) = demo_app();
|
||||
let (app, _) = demo_app();
|
||||
photo.rating = 0;
|
||||
photo.flag = lightcraft_catalog::Flag::None;
|
||||
let badges = |photo: &lightcraft_catalog::Photo| {
|
||||
paint_overlay(&app, photo).1.into_iter().find(|(id, _)| id == "canvas:infoOverlayBadges").map(|(_, r)| r)
|
||||
};
|
||||
assert_eq!(badges(&photo), None, "nothing to show");
|
||||
photo.rating = 3;
|
||||
let stars = badges(&photo).expect("three stars");
|
||||
photo.flag = lightcraft_catalog::Flag::Pick;
|
||||
let flagged = badges(&photo).expect("three stars and a flag");
|
||||
assert!(flagged.width() > stars.width(), "the flag follows the stars: {stars:?} {flagged:?}");
|
||||
let (texts, _) = paint_overlay(&app, &photo);
|
||||
let date_bottom = texts.iter().map(|t| t.pos.y + t.galley.size().y).fold(0.0, f32::max);
|
||||
assert!(flagged.top() >= date_bottom, "under the date and size: {flagged:?}, text ends at {date_bottom}");
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);
|
||||
let mut extent = 0.0;
|
||||
ctx.run_ui(raw, |ui| extent = info_overlay_extent(&app, ui.painter(), &photo)).textures_delta.clear();
|
||||
assert!(extent >= flagged.bottom(), "a label below the overlay clears the badges: {extent} < {flagged:?}");
|
||||
photo.rating = 0;
|
||||
photo.flag = lightcraft_catalog::Flag::Reject;
|
||||
assert!(badges(&photo).is_some(), "a rejected photo shows its flag alone");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
use super::geometry::CanvasMap;
|
||||
use super::*;
|
||||
|
||||
/// What the user did to a face box this frame.
|
||||
pub(super) enum RegionEdit {
|
||||
/// The × was clicked.
|
||||
Remove(usize),
|
||||
/// A handle drag ended: the region's new box (normalized, upright frame).
|
||||
Resize(usize, lightcraft_geom::Rect),
|
||||
/// A name was typed or picked for the face.
|
||||
Name(usize, String),
|
||||
/// The name box's offer to set up face recognition was pressed.
|
||||
SetUp,
|
||||
}
|
||||
|
||||
/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show.
|
||||
const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [
|
||||
(0.0, 0.0, egui::CursorIcon::ResizeNwSe),
|
||||
(0.5, 0.0, egui::CursorIcon::ResizeVertical),
|
||||
(1.0, 0.0, egui::CursorIcon::ResizeNeSw),
|
||||
(1.0, 0.5, egui::CursorIcon::ResizeHorizontal),
|
||||
(1.0, 1.0, egui::CursorIcon::ResizeNwSe),
|
||||
(0.5, 1.0, egui::CursorIcon::ResizeVertical),
|
||||
(0.0, 1.0, egui::CursorIcon::ResizeNeSw),
|
||||
(0.0, 0.5, egui::CursorIcon::ResizeHorizontal),
|
||||
];
|
||||
|
||||
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows
|
||||
/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once,
|
||||
/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits:
|
||||
/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo;
|
||||
/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes.
|
||||
pub(super) fn region_overlay(
|
||||
ui: &egui::Ui,
|
||||
p: &egui::Painter,
|
||||
map: &CanvasMap,
|
||||
photo: &lightcraft_catalog::Photo,
|
||||
orient: lightcraft_geom::Orientation,
|
||||
hints: Option<&crate::panels::faces::Hints>,
|
||||
people: &[String],
|
||||
editing: &mut Option<crate::state::NameEdit>,
|
||||
setup: crate::panels::faces::Setup,
|
||||
) -> Option<RegionEdit> {
|
||||
let t = Tokens::get(p.ctx());
|
||||
let clip = p.clip_rect();
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
// the box being dragged: (region index, its box so far)
|
||||
let drag_key = egui::Id::unique("region-drag");
|
||||
let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key));
|
||||
let mut edit = None;
|
||||
for (index, r) in photo.meta.regions.iter().enumerate() {
|
||||
let dragging = live.filter(|(i, _)| *i == index);
|
||||
let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n));
|
||||
let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1)));
|
||||
// white with a black keyline just outside it, so the box shows on any background
|
||||
p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside);
|
||||
p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside);
|
||||
if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) {
|
||||
for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() {
|
||||
let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height());
|
||||
let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::unique(("region-handle", index, h)), Sense::drag());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Resize region"));
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(*cursor);
|
||||
}
|
||||
let sq = Rect::from_center_size(c, vec2(7.0, 7.0));
|
||||
p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE });
|
||||
p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside);
|
||||
if resp.dragged()
|
||||
&& let Some(pp) = resp.interact_pointer_pos()
|
||||
{
|
||||
// move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one
|
||||
let mut n = rect;
|
||||
if *fx == 0.0 {
|
||||
n.min.x = pp.x.min(n.max.x - 12.0);
|
||||
} else if *fx == 1.0 {
|
||||
n.max.x = pp.x.max(n.min.x + 12.0);
|
||||
}
|
||||
if *fy == 0.0 {
|
||||
n.min.y = pp.y.min(n.max.y - 12.0);
|
||||
} else if *fy == 1.0 {
|
||||
n.max.y = pp.y.max(n.min.y + 12.0);
|
||||
}
|
||||
let (a, b) = (map.norm(n.min), map.norm(n.max));
|
||||
let shown = lightcraft_geom::Rect {
|
||||
x0: a.x.min(b.x).clamp(0.0, 1.0),
|
||||
y0: a.y.min(b.y).clamp(0.0, 1.0),
|
||||
x1: a.x.max(b.x).clamp(0.0, 1.0),
|
||||
y1: a.y.max(b.y).clamp(0.0, 1.0),
|
||||
};
|
||||
// back to the upright frame the region is stored in
|
||||
let new = orient.inverse().map_norm_rect(shown);
|
||||
ui.data_mut(|d| d.insert_temp(drag_key, (index, new)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) {
|
||||
edit = Some(RegionEdit::Resize(i, new));
|
||||
}
|
||||
ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) {
|
||||
let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(xr, egui::Id::unique(("region-x", index)), Sense::click());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Remove region"));
|
||||
register(ui, format!("regionRemove:{index}"), xr);
|
||||
let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 });
|
||||
p.rect_filled(xr, 2.0, fill);
|
||||
p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside);
|
||||
let (c, m) = (xr.center(), 3.5);
|
||||
let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) });
|
||||
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
|
||||
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
|
||||
if resp.on_hover_text(crate::i18n::tr("Remove this face box (undo with Edit ▸ Undo)")).clicked() {
|
||||
edit = Some(RegionEdit::Remove(index));
|
||||
}
|
||||
}
|
||||
// what the label says: the name, a guess to confirm ("Jane Doe?"), or on hover an invitation to name the face
|
||||
let is_face = r.kind == lightcraft_meta::RegionKind::Face;
|
||||
let hint = hints.and_then(|h| h.by_index.get(&index));
|
||||
let hovered = pointer.is_some_and(|h| rect.expand(3.0).contains(h));
|
||||
let editing_this = editing.as_ref().is_some_and(|e| e.photo == photo.id.0 && e.index == index);
|
||||
let (text, text_color, fill_alpha, invitation) = match (&r.name, hint.and_then(|h| h.suggestion.as_ref())) {
|
||||
(Some(n), _) => (n.clone(), Color32::from_gray(225), 235, false),
|
||||
(None, Some((n, _))) if is_face => (format!("{n}?"), Color32::from_gray(185), 200, false),
|
||||
(None, None) if is_face && live.is_none() => ("Add name".to_string(), Color32::from_gray(150), 190, true),
|
||||
_ => continue,
|
||||
};
|
||||
// the name in a dark label with a caret, centred above the box (below it when there is no room)
|
||||
let name = &text;
|
||||
let g = p.layout_no_wrap(name.clone(), t.font(13.0), text_color);
|
||||
let (pad, caret) = (vec2(14.0, 7.0), 5.0);
|
||||
let size = g.size() + pad * 2.0;
|
||||
let above = rect.top() - caret - size.y >= clip.top();
|
||||
let (top, tip, base) = if above {
|
||||
(rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0)
|
||||
} else {
|
||||
(rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0)
|
||||
};
|
||||
let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left()));
|
||||
let label = Rect::from_min_size(pos2(left, top), size);
|
||||
// the invitation shows while the pointer is on the box *or on the label* (and the gap between), so it can be reached
|
||||
if invitation && !(hovered || editing_this || pointer.is_some_and(|h| rect.union(label).expand(3.0).contains(h))) {
|
||||
continue;
|
||||
}
|
||||
let fill = Color32::from_rgba_unmultiplied(56, 56, 56, fill_alpha);
|
||||
p.rect_filled(label, 3.0, fill);
|
||||
p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside);
|
||||
let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0);
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE));
|
||||
p.galley(label.min + pad, g, text_color);
|
||||
if is_face && live.is_none() {
|
||||
// a click on the label opens the name box, filled with the name or the guess
|
||||
let resp = ui.interact(label, egui::Id::unique(("region-label", index)), Sense::click());
|
||||
crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face"));
|
||||
register(ui, format!("regionLabel:{index}"), label);
|
||||
if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() {
|
||||
let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default();
|
||||
*editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true });
|
||||
}
|
||||
}
|
||||
if editing_this && let Some(e) = editing.as_mut() {
|
||||
let candidates = hint.map(|h| h.candidates.as_slice()).unwrap_or(&[]);
|
||||
match crate::panels::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) {
|
||||
crate::panels::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)),
|
||||
crate::panels::faces::Editor::Setup => edit = Some(RegionEdit::SetUp),
|
||||
crate::panels::faces::Editor::Cancel => *editing = None,
|
||||
crate::panels::faces::Editor::Open => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
edit
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ masks
|
||||
|
||||
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo.
|
||||
pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
|
||||
match shape {
|
||||
MaskShape::Radial { center, .. } => Some(*center),
|
||||
MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)),
|
||||
MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
|
||||
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape {
|
||||
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
|
||||
match shape.clone() {
|
||||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle {
|
||||
1 | 3 => {
|
||||
let l = frame_long_norm(map);
|
||||
let dx = (at.x - center.x) / l.0;
|
||||
let dy = (at.y - center.y) / l.1;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let new_rx = (dx * co + dy * s).abs().max(0.01);
|
||||
let mut new_ry = ry;
|
||||
if alt {
|
||||
new_ry = ry * (new_rx / rx.max(0.01));
|
||||
}
|
||||
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
|
||||
new_ry = new_rx;
|
||||
}
|
||||
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
|
||||
}
|
||||
2 | 4 => {
|
||||
let l = frame_long_norm(map);
|
||||
let dx = (at.x - center.x) / l.0;
|
||||
let dy = (at.y - center.y) / l.1;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let new_ry = (dx * (-s) + dy * co).abs().max(0.01);
|
||||
let mut new_rx = rx;
|
||||
if alt {
|
||||
new_rx = rx * (new_ry / ry.max(0.01));
|
||||
}
|
||||
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
|
||||
new_rx = new_ry;
|
||||
}
|
||||
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
|
||||
}
|
||||
5 => {
|
||||
let l = frame_long_norm(map);
|
||||
let dx = (at.x - center.x) / l.0;
|
||||
let dy = (at.y - center.y) / l.1;
|
||||
let new_angle = dy.atan2(dx).to_degrees();
|
||||
MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert }
|
||||
}
|
||||
_ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
|
||||
},
|
||||
MaskShape::Linear { start, end } => match handle {
|
||||
1 => MaskShape::Linear { start: at, end },
|
||||
2 => MaskShape::Linear { start, end: at },
|
||||
_ => MaskShape::Linear { start: mv(start), end: mv(end) },
|
||||
},
|
||||
MaskShape::Brush { mut strokes } => {
|
||||
for st in &mut strokes {
|
||||
st.points.iter_mut().for_each(|p| *p = mv(*p));
|
||||
}
|
||||
MaskShape::Brush { strokes }
|
||||
}
|
||||
s => s,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units,
|
||||
/// so undo the same scaling and rotation used to draw its outline before testing containment.
|
||||
fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
|
||||
let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false };
|
||||
if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let l = frame_long_norm(map);
|
||||
let dx = (at.x - center.x) / l.0;
|
||||
let dy = (at.y - center.y) / l.1;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let x = (dx * co + dy * s) / rx;
|
||||
let y = (-dx * s + dy * co) / ry;
|
||||
x * x + y * y <= 1.0
|
||||
}
|
||||
|
||||
/// Colour Range eyedropper: a click samples the colour under it for the selected mask's
|
||||
/// colour range, at normalized frame coordinates (crop, rotation and zoom aware);
|
||||
/// ⇧-click adds a sample (up to 5).
|
||||
pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &egui::Response, map: &CanvasMap) {
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let add = ui.input(|i| i.modifiers.shift);
|
||||
if let Err(e) = app.run("mask.sampleColor", json!({"x": n.x, "y": n.y, "add": add})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask
|
||||
/// component (click selects its mask, drag moves the component), and brush painting. The mask
|
||||
/// itself shows as a rendered overlay ([`view_overlay`]).
|
||||
pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let active = app.session.active_mask;
|
||||
let clip = app.canvas_rect.unwrap_or(map.rect);
|
||||
let p = &ui.painter_at(clip);
|
||||
// (mask, component, handle, screen position) of everything that can be grabbed
|
||||
let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new();
|
||||
// pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing
|
||||
let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map));
|
||||
let mut pins = p.clone();
|
||||
if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) {
|
||||
pins.set_invisible();
|
||||
}
|
||||
for m in &d.masks {
|
||||
let sel = Some(m.id) == active;
|
||||
// Ctrl+Shift+O (`mask_overlay_all`) shows every visible mask's region; outline each one
|
||||
// too, not just the selected mask. Handles and the rotation grip stay on the selected
|
||||
// mask so nothing else becomes draggable. The component check keeps this in step with the
|
||||
// painted region (`view_overlay` at detail.rs:1354 only covers masks with components).
|
||||
let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty());
|
||||
let stroke_a: u8 = if sel { 230 } else { 170 };
|
||||
for (ci, c) in m.components.iter().enumerate() {
|
||||
match &c.shape {
|
||||
MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => {
|
||||
let pts: Vec<Pos2> = (0..64)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let (x, y) = (rx * a.cos(), ry * a.sin());
|
||||
let l = frame_long_norm(map);
|
||||
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a))));
|
||||
|
||||
if sel {
|
||||
let l = frame_long_norm(map);
|
||||
for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() {
|
||||
let a_local = a_deg.to_radians();
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let (x, y) = (rx * a_local.cos(), ry * a_local.sin());
|
||||
let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1));
|
||||
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, i as u8 + 1, q));
|
||||
}
|
||||
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let rot_r = rx + 0.08;
|
||||
let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1));
|
||||
let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1));
|
||||
p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200)));
|
||||
p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, 5, rot_q));
|
||||
}
|
||||
}
|
||||
MaskShape::Linear { start, end } if show_outline => {
|
||||
let (a, b) = (map.screen(*start), map.screen(*end));
|
||||
let dir = (b - a).normalized();
|
||||
let perp = vec2(-dir.y, dir.x) * 3000.0;
|
||||
let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) };
|
||||
for (q, alpha) in [(a, a1), (b, a2)] {
|
||||
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
|
||||
}
|
||||
if sel {
|
||||
for (i, q) in [a, b].iter().enumerate() {
|
||||
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, i as u8 + 1, *q));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if app.ui.mask_pins
|
||||
&& mode.shows(sel, live)
|
||||
&& let Some(at) = component_pin(&c.shape)
|
||||
{
|
||||
let q = map.screen(at);
|
||||
pin(&pins, q, sel);
|
||||
register(ui, format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0)));
|
||||
grips.push((m.id, ci, 0, q));
|
||||
}
|
||||
}
|
||||
}
|
||||
mask_keys(app, ui, map, d, &grips);
|
||||
// Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude
|
||||
// (clicks still on their way to the model included)
|
||||
let pending = app.session.segmenter.pending_clicks().cloned();
|
||||
for m in d.masks.iter().filter(|m| Some(m.id) == active) {
|
||||
for (k, c) in m.components.iter().enumerate() {
|
||||
if let MaskShape::Object { hint, exclude, .. } = &c.shape {
|
||||
let (hint, exclude) = match pending.as_ref().filter(|q| (q.mask, q.comp) == (m.id, k)) {
|
||||
Some(q) => (&q.hint, &q.exclude),
|
||||
None => (hint, exclude),
|
||||
};
|
||||
for (pts, col) in [(hint, Color32::from_rgb(40, 200, 90)), (exclude, Color32::from_rgb(230, 60, 60))] {
|
||||
for q in pts {
|
||||
let q = map.screen(*q);
|
||||
p.circle_filled(q, 5.0, col);
|
||||
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Colour Range "Pick": the Masking panel owns the loupe's clicks (general_interaction
|
||||
// never runs while it is open), so the eyedropper is handled here too.
|
||||
if app.ui.tool == "colorRange" {
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
color_range_pick(app, ui, resp, map);
|
||||
return;
|
||||
}
|
||||
// Object tool: a click includes what's under it, ⌥-click leaves it out
|
||||
if app.ui.tool == "object" {
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let exclude = ui.input(|i| i.modifiers.alt);
|
||||
match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) {
|
||||
Ok(_) => {
|
||||
if let Some(mid) = app.session.active_mask {
|
||||
app.ui.detail_due = Some((app.tick_now() + 1.0, mid));
|
||||
}
|
||||
}
|
||||
Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))),
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// brush tool
|
||||
if app.ui.tool == "brush" {
|
||||
let r = (app.ui.brush_size as f64 * long) as f32;
|
||||
if let Some(h) = resp.hover_pos() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
|
||||
}
|
||||
if let Some(q) = resp.interact_pointer_pos()
|
||||
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None };
|
||||
match &mut app.gesture {
|
||||
// a held, unmoving pointer records nothing: coverage builds by passes, not time (#518)
|
||||
Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {}
|
||||
Some(Gesture::Brush { points, pressure }) => {
|
||||
record_pressure(pressure, points.len(), force);
|
||||
points.push(n);
|
||||
}
|
||||
_ => {
|
||||
let mut pressure = Vec::new();
|
||||
record_pressure(&mut pressure, 0, force);
|
||||
app.gesture = Some(Gesture::Brush { points: vec![n], pressure });
|
||||
}
|
||||
}
|
||||
}
|
||||
// the stroke being painted (the render shows it once it's committed), with the committed
|
||||
// stroke's dab spacing, feather and flow (issue #517)
|
||||
if let Some(Gesture::Brush { points, pressure }) = &app.gesture {
|
||||
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
|
||||
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
|
||||
let params = crate::brush_live::LiveParams {
|
||||
clip,
|
||||
scale: ui.ctx().pixels_per_point(),
|
||||
r,
|
||||
hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)),
|
||||
flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0),
|
||||
density: 1.0,
|
||||
color: app.ui.mask_overlay_color,
|
||||
opacity: opacity.clamp(0.0, 1.0),
|
||||
};
|
||||
crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure);
|
||||
} else {
|
||||
app.brush_live = None;
|
||||
}
|
||||
if (resp.drag_stopped() || resp.clicked())
|
||||
&& let Some(Gesture::Brush { points, pressure }) = app.gesture.take()
|
||||
{
|
||||
app.brush_live = None;
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
|
||||
let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift));
|
||||
let erase = app.ui.brush_erase != alt;
|
||||
// a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom
|
||||
let connect = shift && resp.clicked() && pts.len() == 1;
|
||||
let _ = app.run(
|
||||
"mask.brushStroke",
|
||||
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow,
|
||||
"erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// the grip under the pointer: the selected mask's first, then the nearest
|
||||
let hit = |q: Pos2| {
|
||||
grips
|
||||
.iter()
|
||||
.filter(|g| g.3.distance(q) < 10.0)
|
||||
.min_by(|a, b| {
|
||||
(Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& let Some((mid, ..)) = hit(q)
|
||||
&& Some(mid) != active
|
||||
{
|
||||
let _ = app.run("mask.select", json!({"id": mid}));
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| {
|
||||
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
|
||||
// Handles take precedence; otherwise grab the selected radial component under
|
||||
// the press, including components after the first in compound masks.
|
||||
let at = map.norm(q);
|
||||
if let Some(ci) = m.components.iter().position(|c| radial_body_contains(&c.shape, at, map)) {
|
||||
return Some((m.id, ci, 0));
|
||||
}
|
||||
// anywhere on the photo drags the selected linear gradient
|
||||
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
|
||||
});
|
||||
if let Some((mask, comp, handle)) = grip
|
||||
&& let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone())
|
||||
{
|
||||
if Some(mask) != active {
|
||||
let _ = app.run("mask.select", json!({"id": mask}));
|
||||
}
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
|
||||
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape });
|
||||
}
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q);
|
||||
let n0 = map.norm(press_origin);
|
||||
let dn = Point::new(n.x - n0.x, n.y - n0.y);
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt);
|
||||
let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape}));
|
||||
}
|
||||
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial
|
||||
/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end.
|
||||
fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String {
|
||||
let part = match (shape, handle) {
|
||||
(_, 0) => "pin",
|
||||
(MaskShape::Radial { .. }, 1) => "right edge",
|
||||
(MaskShape::Radial { .. }, 2) => "bottom edge",
|
||||
(MaskShape::Radial { .. }, 3) => "left edge",
|
||||
(MaskShape::Radial { .. }, 4) => "top edge",
|
||||
(MaskShape::Radial { .. }, _) => "rotation",
|
||||
(MaskShape::Linear { .. }, 1) => "start",
|
||||
(MaskShape::Linear { .. }, _) => "end",
|
||||
_ => "handle",
|
||||
};
|
||||
format!("{mask} {}", crate::i18n::tr(part))
|
||||
}
|
||||
|
||||
/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a
|
||||
/// step (one undo step), as dragging it would; Return on a pin selects its mask.
|
||||
fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) {
|
||||
for &(mid, ci, handle, q) in grips {
|
||||
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue };
|
||||
let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue };
|
||||
let at = map.norm(q);
|
||||
let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() };
|
||||
let name = mask_handle_name(&mask_name, shape, handle);
|
||||
let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q);
|
||||
if keys.activated && app.session.active_mask != Some(mid) {
|
||||
let _ = app.run("mask.select", json!({"id": mid}));
|
||||
}
|
||||
let Some((dx, dy)) = keys.delta else { continue };
|
||||
if app.session.active_mask != Some(mid) {
|
||||
let _ = app.run("mask.select", json!({"id": mid}));
|
||||
}
|
||||
let to = Point::new(at.x + dx, at.y + dy);
|
||||
let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false);
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
|
||||
let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape}));
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-edge units → normalized units for x and y.
|
||||
fn frame_long_norm(map: &CanvasMap) -> (f64, f64) {
|
||||
// derive from the mapping: 1 normalized unit in x and y in screen points
|
||||
let o = map.screen(Point::new(0.0, 0.0));
|
||||
let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64;
|
||||
let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64;
|
||||
let l = x.max(y);
|
||||
(l / x.max(1e-9), l / y.max(1e-9))
|
||||
}
|
||||
|
||||
/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is
|
||||
/// being dragged.
|
||||
pub(super) fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool {
|
||||
resp.hover_pos().is_some_and(|q| map.rect.contains(q))
|
||||
|| matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. }))
|
||||
// a handle moved with the keyboard stays shown while it has the focus
|
||||
|| crate::canvas_keys::has_focus(&resp.ctx)
|
||||
}
|
||||
|
||||
pub(super) fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
|
||||
p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) });
|
||||
p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
|
||||
/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit
|
||||
/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse.
|
||||
pub(crate) fn pen_force(i: &egui::InputState) -> Option<f32> {
|
||||
i.events.iter().rev().find_map(|e| match e {
|
||||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last
|
||||
/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list.
|
||||
pub(crate) fn record_pressure(pressure: &mut Vec<f32>, index: usize, force: Option<f32>) {
|
||||
match (force, pressure.last().copied()) {
|
||||
(Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)),
|
||||
(Some(f), _) => pressure.push(f),
|
||||
(None, Some(last)) => pressure.push(last),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pen_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() {
|
||||
let mut p = Vec::new();
|
||||
record_pressure(&mut p, 0, None);
|
||||
record_pressure(&mut p, 1, None);
|
||||
assert!(p.is_empty(), "a mouse: no pressure list");
|
||||
// the first force covers the points before it, a frame without one repeats the last
|
||||
record_pressure(&mut p, 2, Some(0.4));
|
||||
record_pressure(&mut p, 3, None);
|
||||
record_pressure(&mut p, 4, Some(0.9));
|
||||
assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]);
|
||||
|
||||
let mut i = egui::InputState::default();
|
||||
assert_eq!(pen_force(&i), None);
|
||||
let touch = |force| egui::Event::Touch {
|
||||
device_id: egui::TouchDeviceId(1),
|
||||
id: egui::TouchId(1),
|
||||
phase: egui::TouchPhase::Move,
|
||||
pos: egui::pos2(1.0, 1.0),
|
||||
force,
|
||||
};
|
||||
i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)];
|
||||
assert_eq!(pen_force(&i), Some(0.3), "the latest usable force");
|
||||
i.events = vec![touch(Some(4.0))];
|
||||
assert_eq!(pen_force(&i), Some(1.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use super::geometry::CanvasMap;
|
||||
use super::masks::{pin, pins_live};
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------------ remove
|
||||
|
||||
/// The Remove tool on the photo: every spot's outline and pin (the selected one with its source),
|
||||
/// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one.
|
||||
/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a
|
||||
/// click there adds a new one.
|
||||
pub(super) fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let active = app.session.active_spot.filter(|i| *i < d.spots.len());
|
||||
let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map));
|
||||
// Auto also hides them while a new spot is painted
|
||||
let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. }));
|
||||
let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect));
|
||||
let mut pins = p.clone();
|
||||
if painting {
|
||||
pins.set_invisible();
|
||||
}
|
||||
// (spot, is source, screen centre, radius) of everything that can be grabbed
|
||||
let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new();
|
||||
for (i, s) in d.spots.iter().enumerate() {
|
||||
let r = (s.size * long) as f32;
|
||||
let sel = Some(i) == active;
|
||||
if !mode.shows(sel, live) {
|
||||
continue;
|
||||
}
|
||||
let p = &pins;
|
||||
let col = Color32::from_white_alpha(if sel { 230 } else { 150 });
|
||||
for q in &s.points {
|
||||
p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col));
|
||||
}
|
||||
let Some(&t) = s.points.first() else { continue };
|
||||
let tq = map.screen(t);
|
||||
if let Some(o) = s.source_offset.filter(|_| sel) {
|
||||
let sq = map.screen(Point::new(t.x + o.x, t.y + o.y));
|
||||
for q in &s.points {
|
||||
p.circle_stroke(map.screen(Point::new(q.x + o.x, q.y + o.y)), r, Stroke::new(1.0, Color32::from_white_alpha(170)));
|
||||
}
|
||||
let dir = (tq - sq).normalized();
|
||||
p.arrow(sq + dir * r, (tq - sq) - dir * (2.0 * r).min((tq - sq).length()), Stroke::new(1.0, Color32::from_white_alpha(200)));
|
||||
register(ui, format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0)));
|
||||
grips.push((i, true, sq, r));
|
||||
}
|
||||
pin(p, tq, sel);
|
||||
register(ui, format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0)));
|
||||
grips.push((i, false, tq, r));
|
||||
}
|
||||
if spot_keys(app, ui, map, d, &grips) {
|
||||
return;
|
||||
}
|
||||
let r = (app.ui.remove_size as f64 * long) as f32;
|
||||
// a target or source under `q`: the selected spot's first, then the nearest pin
|
||||
let hit = |q: Pos2| {
|
||||
grips
|
||||
.iter()
|
||||
.filter(|g| g.2.distance(q) < g.3.max(8.0))
|
||||
.min_by(|a, b| {
|
||||
(Some(a.0) != active, a.2.distance(q)).partial_cmp(&(Some(b.0) != active, b.2.distance(q))).unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
let hover = resp.hover_pos().and_then(hit);
|
||||
if let Some(h) = resp.hover_pos() {
|
||||
if hover.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
} else {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
p.circle_stroke(h, r * (1.0 - app.ui.remove_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(110)));
|
||||
}
|
||||
}
|
||||
let press = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos());
|
||||
if resp.drag_started()
|
||||
&& let Some((spot, source, ..)) = press.and_then(hit)
|
||||
{
|
||||
if Some(spot) != active {
|
||||
let _ = app.run("spot.select", json!({"index": spot}));
|
||||
}
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"}));
|
||||
app.gesture = Some(Gesture::SpotMove { spot, source });
|
||||
}
|
||||
if let Some(Gesture::SpotMove { spot, source }) = app.gesture.clone() {
|
||||
if resp.dragged()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let (n, n0) = (map.norm(q), map.norm(q - resp.drag_delta()));
|
||||
let dn = [n.x - n0.x, n.y - n0.y];
|
||||
if dn != [0.0, 0.0] {
|
||||
let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: dn}));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some((spot, ..)) = press.and_then(hit)
|
||||
{
|
||||
let _ = app.run("spot.select", json!({"index": spot}));
|
||||
return;
|
||||
}
|
||||
if let Some(q) = resp.interact_pointer_pos()
|
||||
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
match &mut app.gesture {
|
||||
Some(Gesture::Spot { points }) => points.push(n),
|
||||
_ => app.gesture = Some(Gesture::Spot { points: vec![n] }),
|
||||
}
|
||||
}
|
||||
if let Some(Gesture::Spot { points }) = &app.gesture {
|
||||
for q in points {
|
||||
p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60));
|
||||
}
|
||||
}
|
||||
if (resp.drag_stopped() || resp.clicked())
|
||||
&& let Some(Gesture::Spot { points }) = app.gesture.take()
|
||||
{
|
||||
let mode = match app.ui.tool.as_str() {
|
||||
"heal" => "heal",
|
||||
"clone" => "clone",
|
||||
"fill" => "fill",
|
||||
_ => "remove",
|
||||
};
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
let _ = app.run(
|
||||
"spot.add",
|
||||
json!({"mode": mode, "points": pts, "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves
|
||||
/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move
|
||||
/// and Return turns into a new spot of the brush's size there. True when a spot was added.
|
||||
fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool {
|
||||
for &(spot, source, q, _) in grips {
|
||||
let at = map.norm(q);
|
||||
let name =
|
||||
format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() });
|
||||
let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default();
|
||||
let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0);
|
||||
let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q);
|
||||
if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) {
|
||||
let _ = app.run("spot.select", json!({"index": spot}));
|
||||
}
|
||||
if let Some((dx, dy)) = keys.delta {
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"}));
|
||||
let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]}));
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
// where a new spot goes: the photo's centre at first, then wherever the arrows took it
|
||||
let key = egui::Id::unique("lc-spot-placement");
|
||||
let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5));
|
||||
let q = map.screen(Point::new(place.0, place.1));
|
||||
let keys = crate::canvas_keys::handle(
|
||||
ui,
|
||||
"spot:new",
|
||||
crate::i18n::tr("New spot (Return adds it here)"),
|
||||
crate::canvas_keys::position_text(place.0, place.1),
|
||||
q,
|
||||
);
|
||||
if let Some((dx, dy)) = keys.delta {
|
||||
let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0));
|
||||
ui.data_mut(|m| m.insert_temp(key, moved));
|
||||
}
|
||||
if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) {
|
||||
let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32;
|
||||
ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
if keys.activated {
|
||||
let mode = match app.ui.tool.as_str() {
|
||||
"heal" => "heal",
|
||||
"clone" => "clone",
|
||||
"fill" => "fill",
|
||||
_ => "remove",
|
||||
};
|
||||
let _ = app.run(
|
||||
"spot.add",
|
||||
json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ red eye
|
||||
|
||||
/// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it.
|
||||
pub(super) fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
// screen points per long-edge unit
|
||||
let o = map.screen(Point::new(0.0, 0.0));
|
||||
let (sx, sy) = (map.screen(Point::new(1.0, 0.0)).distance(o), map.screen(Point::new(0.0, 1.0)).distance(o));
|
||||
let per_long = sx.max(sy);
|
||||
let p = ui.painter();
|
||||
for (i, e) in d.red_eye.iter().enumerate() {
|
||||
let c = map.screen(e.center);
|
||||
let r = vec2(e.rx as f32 * per_long, e.ry as f32 * per_long);
|
||||
let sel = i == app.ui.eye;
|
||||
let col = if sel { Color32::WHITE } else { Color32::from_white_alpha(150) };
|
||||
p.add(egui::Shape::ellipse_stroke(c, r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)));
|
||||
p.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.0, col));
|
||||
p.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.0, col));
|
||||
}
|
||||
if resp.hover_pos().is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
// from where the button went down (a drag is recognized only after some movement)
|
||||
app.gesture = Some(Gesture::Eye { a: map.norm(q) });
|
||||
}
|
||||
if let Some(Gesture::Eye { a }) = app.gesture
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let (a, b) = (map.screen(a), q);
|
||||
p.add(egui::Shape::ellipse_stroke(a.lerp(b, 0.5), ((b - a) / 2.0).abs(), Stroke::new(1.0, Color32::WHITE)));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Some(Gesture::Eye { a }) = app.gesture.take()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let (sa, sb) = (map.screen(a), q);
|
||||
let half = ((sb - sa) / 2.0).abs() / per_long;
|
||||
if half.x > 0.002 && half.y > 0.002 {
|
||||
let c = map.norm(sa.lerp(sb, 0.5));
|
||||
let r = app.run("redeye.add", json!({"center": [c.x, c.y], "rx": half.x, "ry": half.y, "pet": app.ui.eye_pet}));
|
||||
if let Ok(v) = r {
|
||||
app.ui.eye = v["index"].as_u64().unwrap_or(0) as usize;
|
||||
}
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& let Some(i) = d.red_eye.iter().position(|e| {
|
||||
let c = map.screen(e.center);
|
||||
let (dx, dy) = ((q.x - c.x) / (e.rx as f32 * per_long).max(4.0), (q.y - c.y) / (e.ry as f32 * per_long).max(4.0));
|
||||
dx * dx + dy * dy <= 1.0
|
||||
})
|
||||
{
|
||||
app.ui.eye = i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::*;
|
||||
|
||||
/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all)
|
||||
/// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize
|
||||
/// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high
|
||||
/// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin
|
||||
/// ([`crate::region::margin_for`]: 4.5 %). Each is tested equal to the whole-frame render's pixels.
|
||||
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
use lightcraft_pipeline::Overlay as O;
|
||||
match overlay {
|
||||
O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a window render of the loupe needs to know about the view it belongs to.
|
||||
pub(super) struct WindowCtx {
|
||||
pub(super) id: PhotoId,
|
||||
pub(super) overlay: lightcraft_pipeline::Overlay,
|
||||
/// Long edge of the frame the windows are cut from (`None`: no windows).
|
||||
pub(super) frame_edge: Option<usize>,
|
||||
pub(super) drawn_long: f32,
|
||||
pub(super) aspect: f32,
|
||||
pub(super) ppp: f32,
|
||||
pub(super) texture_side: usize,
|
||||
pub(super) crop_tool: bool,
|
||||
pub(super) interacting: bool,
|
||||
/// Hash of the photo's develop settings.
|
||||
pub(super) look: u64,
|
||||
/// A pinch or two-finger scroll is running: keep the windows there are.
|
||||
pub(super) holding: bool,
|
||||
}
|
||||
|
||||
/// One side of the loupe that gets a window render.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct WindowView {
|
||||
pub(super) slot: Slot,
|
||||
/// The Before side (the photo without its edits).
|
||||
pub(super) before: bool,
|
||||
/// The rect the photo is drawn in.
|
||||
pub(super) img: Rect,
|
||||
/// The rect it is requested for (the drawn rect without a click-zoom animation).
|
||||
pub(super) target: Rect,
|
||||
/// The area whose pixels are on screen.
|
||||
pub(super) visible: Rect,
|
||||
}
|
||||
|
||||
/// Ask for the window of `v` that holds what is on screen; remember it so its texture can be
|
||||
/// drawn where it belongs. `None`: no window is wanted (or possible) for this view.
|
||||
pub(super) fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option<crate::region::RegionView> {
|
||||
let frame_edge = c.frame_edge?;
|
||||
// while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one
|
||||
// per frame (zooming out would ask for ever wider ones)
|
||||
if c.holding {
|
||||
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
|
||||
}
|
||||
let (fw, fh) =
|
||||
if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) };
|
||||
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
|
||||
// what is on screen, in pixels of the window's frame (the drawn frame, scaled)
|
||||
let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0);
|
||||
let visible = (
|
||||
to_px * (v.visible.left() - v.target.left()),
|
||||
to_px * (v.visible.top() - v.target.top()),
|
||||
to_px * (v.visible.right() - v.target.left()),
|
||||
to_px * (v.visible.bottom() - v.target.top()),
|
||||
);
|
||||
let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?;
|
||||
// a window waits for the original another window job is decoding (see `defer_window`)
|
||||
let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh)));
|
||||
let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore);
|
||||
if crate::region::defer_window(window_pending, original_held) {
|
||||
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
|
||||
}
|
||||
let job = if v.before {
|
||||
app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)
|
||||
} else {
|
||||
app.session.region_job(c.id, fw, fh, win, !c.crop_tool)
|
||||
};
|
||||
// refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one
|
||||
// render, so the whole frame is rendered at the drawn size instead (see `region::plan`)
|
||||
let Some(job) = job else {
|
||||
app.window_refused = Some((c.id, c.look, frame_edge));
|
||||
return None;
|
||||
};
|
||||
// a drag renders drafts of the window, as it does of the whole frame. The Before tile stays
|
||||
// plain; the After tile carries the same mask overlay as the whole-frame render.
|
||||
let job = if c.interacting { job.draft() } else { job };
|
||||
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
|
||||
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
|
||||
// the After window is proofed like the whole-frame render under it; the Before side is
|
||||
// never proofed (its whole-frame render isn't either)
|
||||
let proof = app.ui.soft_proof.then_some(app.ui.proof);
|
||||
let job = job.with_overlay(overlay).with_proof(if v.before { None } else { proof });
|
||||
// keyed as the renderer keys the texture (a display profile changes the key)
|
||||
let job = app.renderer.job_for(v.slot, job);
|
||||
// The overlay and the proof are part of the tile's identity too, so changing mask view/colour
|
||||
// or the proof never leaves a stale region tile on screen while its replacement renders.
|
||||
let proof_key = if v.before { 0 } else { proof.map_or(0, |p| p.key()) };
|
||||
let look = job.settings.hash64() ^ overlay.key().rotate_left(17) ^ proof_key.rotate_left(41);
|
||||
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
|
||||
// windows of other photos, looks and zooms of this side can't be drawn any more
|
||||
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
|
||||
app.region_tiles.insert((v.before, job.key), view);
|
||||
app.renderer.request(v.slot, job, 99);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
/// Draw `v`'s window texture over the whole-frame render, if it is the one asked for (or, in a
|
||||
/// drag, an earlier draft of it).
|
||||
pub(super) fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &WindowView, wanted: &crate::region::RegionView) {
|
||||
let Some(tex) = app.renderer.textures.get(&v.slot).filter(|t| t.photo == c.id) else { return };
|
||||
let Some(tile) = app.region_tiles.get(&(v.before, tex.key)).filter(|t| t.is_current(c.id, wanted.full, wanted.settings, c.interacting)) else {
|
||||
return;
|
||||
};
|
||||
let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32);
|
||||
let at = v.img.min + vec2(tile.window.x as f32 / fw * v.img.width(), tile.window.y as f32 / fh * v.img.height());
|
||||
let dst = Rect::from_min_size(at, vec2(tile.window.w as f32 / fw * v.img.width(), tile.window.h as f32 / fh * v.img.height()));
|
||||
p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
Reference in New Issue
Block a user