diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 5e610b098..25f3baa24 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -14,6 +14,43 @@ use crate::state::{BeforeAfter, RightPanel, ToolOverlay, Zoom}; use crate::theme::Tokens; use crate::widgets::{register, register_geometry}; +mod crop; +mod filmstrip; +mod geometry; +mod info; +mod masks; +mod retouch; +mod window; + +pub(crate) use crop::crop_angle_label; +use crop::crop_overlay; +#[cfg(test)] +pub(crate) use crop::readout_rect; +#[cfg(test)] +use crop::{flipped_corners, outside_crop_mesh, rotate_cursor_turn}; +#[cfg(test)] +use filmstrip::film_label; +#[cfg(test)] +pub(crate) use filmstrip::film_spoken; +pub(crate) use filmstrip::filmstrip; +use filmstrip::navigator; +pub use geometry::CanvasMap; +#[cfg(test)] +pub(crate) use geometry::focus_box; +use geometry::{animated_rect, middle_pan}; +pub(crate) use geometry::{fit_rect, fit_texture_rect, focus_pan, navigate_gesture, output_px, pan_image, snap_fit_rect, texture_side}; +#[cfg(test)] +use info::RENDERING_AFTER_SECS; +use info::{ + FILTER_PILL_HEIGHT, FILTER_PILL_INSET, brief_info, filter_pill, filter_pill_shown, info_overlay, info_overlay_extent, label_badge_rect, + preview_only_pill, quick_name, +}; +pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; +use masks::{RegionEdit, color_range_pick, mask_overlay, region_overlay}; +use retouch::{eye_overlay, remove_overlay}; +pub(crate) use window::overlay_supports_window; +use window::{WindowCtx, WindowView, draw_window, request_window}; + /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] pub enum Gesture { @@ -85,419 +122,6 @@ pub enum Gesture { }, } -/// Screen ↔ normalized-image mapping for the displayed frame. -#[derive(Clone, Copy)] -pub struct CanvasMap { - rect: Rect, - to_norm: Affine, - from_norm: Affine, - k: f32, -} - -impl CanvasMap { - 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. -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::(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::(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 { - 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)) -} - -/// 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:`). -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); -} - -/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. -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 -} - -/// 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 -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)); - } -} - -/// 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. -struct WindowCtx { - id: PhotoId, - overlay: lightcraft_pipeline::Overlay, - /// Long edge of the frame the windows are cut from (`None`: no windows). - frame_edge: Option, - drawn_long: f32, - aspect: f32, - ppp: f32, - texture_side: usize, - crop_tool: bool, - interacting: bool, - /// Hash of the photo's develop settings. - look: u64, - /// A pinch or two-finger scroll is running: keep the windows there are. - holding: bool, -} - -/// One side of the loupe that gets a window render. -#[derive(Clone, Copy)] -struct WindowView { - slot: Slot, - /// The Before side (the photo without its edits). - before: bool, - /// The rect the photo is drawn in. - img: Rect, - /// The rect it is requested for (the drawn rect without a click-zoom animation). - target: Rect, - /// The area whose pixels are on screen. - 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. -fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { - 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). -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); -} - /// Draw the Develop loupe: the optional filmstrip, the fitted or zoomed photo (before/after and /// full-screen aware), crop overlays and the render requests for the current view, or, when no /// photo is active, the empty-library message if the library is empty and "No photo selected" @@ -1026,516 +650,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// 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. -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`). -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. -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> { - 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 = 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 -} - -/// What the user did to a face box this frame. -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. -fn region_overlay( - ui: &egui::Ui, - p: &egui::Painter, - map: &CanvasMap, - photo: &lightcraft_catalog::Photo, - orient: lightcraft_geom::Orientation, - hints: Option<&super::faces::Hints>, - people: &[String], - editing: &mut Option, - setup: super::faces::Setup, -) -> Option { - 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 super::faces::name_editor(ui.ctx(), pos2(label.left(), rect.bottom() + 8.0), e, candidates, people, setup) { - super::faces::Editor::Submit(name) => edit = Some(RegionEdit::Name(index, name)), - super::faces::Editor::Setup => edit = Some(RegionEdit::SetUp), - super::faces::Editor::Cancel => *editing = None, - super::faces::Editor::Open => {} - } - } - } - edit -} - -/// 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). -const FILTER_PILL_INSET: f32 = 3.0; -const FILTER_PILL_HEIGHT: f32 = 20.0; - -/// Whether [`filter_pill`] has something to show (a filter is active). -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. -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::>().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`). -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 { - 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::>().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) -} - -/// 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. -fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option { - 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)) -} - /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; -/// 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. -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. -type RenderingWait = Option<([Option; 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; 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, 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, - 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}"))); -} - -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", - } -} - /// Targeted adjustment tool: dragging up/down on the photo raises/lowers the tone-curve region or /// the colour-mixer bands under the press point (`develop.targeted`, one call per whole step, all /// in one interaction = one undo step). @@ -1701,1526 +818,6 @@ fn general_interaction( let _ = (native, aspect); } -// ------------------------------------------------------------------------ crop - -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 = 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 super::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 = (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. -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. -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. -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 { - 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| -> Option> { - 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() -} - -// ------------------------------------------------------------------------ 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 { - 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). -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`]). -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 = (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 = 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. -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) -} - -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)); -} - -// ------------------------------------------------------------------------ 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. -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. -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; - } -} - -// ------------------------------------------------------------------------ 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). -fn film_label(name: &str) -> String { - if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } 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 = super::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 = super::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)); - super::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() { - super::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) - super::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| super::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); - } -} - -#[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()); - } -} - -/// 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(); - } - } -} - #[cfg(test)] mod tests { /// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows @@ -3411,305 +1008,3 @@ mod tests { assert_eq!(flipped_corners(&hv, true, true), screen); } } - -#[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); - } -} - -/// 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 { - 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, index: usize, force: Option) { - 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)); - } -} - -#[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)); - } -} - -#[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) { - 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 = 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 = 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 { - 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::::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, 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) { - 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"); - } -} diff --git a/crates/ui-egui/src/panels/detail/crop.rs b/crates/ui-egui/src/panels/detail/crop.rs new file mode 100644 index 000000000..295b8f819 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/crop.rs @@ -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 = 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 = (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 { + 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| -> Option> { + 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(); + } + } +} diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs new file mode 100644 index 000000000..c39fc4442 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/filmstrip.rs @@ -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 { + 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::()) } 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); + } +} diff --git a/crates/ui-egui/src/panels/detail/geometry.rs b/crates/ui-egui/src/panels/detail/geometry.rs new file mode 100644 index 000000000..6424dabd8 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/geometry.rs @@ -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::(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::(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 { + 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)); + } +} diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs new file mode 100644 index 000000000..520ad6213 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/info.rs @@ -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:`). +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> { + 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 = 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::>().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 { + 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::>().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; 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; 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, 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, + 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) { + 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 = 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 = 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 { + 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::::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, 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) { + 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"); + } +} diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs new file mode 100644 index 000000000..073de8aa2 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -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, + setup: crate::panels::faces::Setup, +) -> Option { + 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 { + 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 = (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 = 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 { + 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, index: usize, force: Option) { + 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)); + } +} diff --git a/crates/ui-egui/src/panels/detail/retouch.rs b/crates/ui-egui/src/panels/detail/retouch.rs new file mode 100644 index 000000000..64f4c2281 --- /dev/null +++ b/crates/ui-egui/src/panels/detail/retouch.rs @@ -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; + } +} diff --git a/crates/ui-egui/src/panels/detail/window.rs b/crates/ui-egui/src/panels/detail/window.rs new file mode 100644 index 000000000..0b441913b --- /dev/null +++ b/crates/ui-egui/src/panels/detail/window.rs @@ -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, + 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 { + 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); +}