mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
2660 lines
133 KiB
Rust
2660 lines
133 KiB
Rust
//! The Detail (loupe) view: the developed photo, before/after, zoom & pan, the filmstrip, and the
|
||
//! on-canvas tools (crop, brush/gradient masks, remove spots, white-balance picker).
|
||
|
||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use lightcraft_catalog::PhotoId;
|
||
use lightcraft_develop::{DevelopSettings, MaskShape};
|
||
use lightcraft_geom::{Affine, Point};
|
||
use lightcraft_pipeline::geometry::Frame;
|
||
use serde_json::json;
|
||
|
||
use crate::LightcraftApp;
|
||
use crate::render::Slot;
|
||
use crate::state::{BeforeAfter, RightPanel, Zoom};
|
||
use crate::theme::Tokens;
|
||
use crate::widgets::register;
|
||
|
||
/// An in-progress on-canvas gesture.
|
||
#[derive(Clone, Debug)]
|
||
pub enum Gesture {
|
||
Brush {
|
||
points: Vec<Point>,
|
||
},
|
||
Spot {
|
||
points: Vec<Point>,
|
||
},
|
||
/// Remove tool: dragging spot `spot`'s target (or its source).
|
||
SpotMove {
|
||
spot: usize,
|
||
source: bool,
|
||
},
|
||
CropHandle {
|
||
handle: u8,
|
||
start: lightcraft_geom::Rect,
|
||
angle: f64,
|
||
},
|
||
/// Straighten tool: a line drawn along something that should be level (or plumb).
|
||
StraightenLine {
|
||
a: Pos2,
|
||
},
|
||
CropRotate {
|
||
start_angle: f64,
|
||
a0: f32,
|
||
},
|
||
Pan {
|
||
start: (f32, f32),
|
||
at: Pos2,
|
||
},
|
||
/// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1)
|
||
/// or end (2).
|
||
MaskHandle {
|
||
mask: u32,
|
||
comp: usize,
|
||
handle: u8,
|
||
original_shape: MaskShape,
|
||
},
|
||
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
|
||
Guide {
|
||
a: Point,
|
||
},
|
||
/// Red Eye: an ellipse being dragged from corner `a` (normalized).
|
||
Eye {
|
||
a: Point,
|
||
},
|
||
/// Targeted adjustment drag from `at` (normalized); `acc` = vertical travel not yet applied.
|
||
Targeted {
|
||
at: Point,
|
||
acc: f32,
|
||
},
|
||
}
|
||
|
||
/// 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);
|
||
Rect::from_center_size(c, vec2(w, h))
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
|
||
/// 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}));
|
||
}
|
||
|
||
/// 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::new("loupe-wheel-zoom");
|
||
if ui.input(|i| wheel_notches(&i.events)) != 0.0 {
|
||
ui.data_mut(|d| d.insert_temp(wheel_id, true));
|
||
}
|
||
if ui.data(|d| d.get_temp::<bool>(wheel_id)).unwrap_or(false) {
|
||
let smooth = ui.input(|i| i.smooth_scroll_delta.y);
|
||
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed);
|
||
if smooth == 0.0 {
|
||
ui.data_mut(|d| d.remove_temp::<bool>(wheel_id));
|
||
} else {
|
||
factor *= (speed * smooth).exp();
|
||
delta = egui::Vec2::ZERO;
|
||
}
|
||
}
|
||
if !factor.is_finite() || factor <= 0.0 || !delta.is_finite() || (factor == 1.0 && delta == egui::Vec2::ZERO) {
|
||
return false;
|
||
}
|
||
let Some(anchor) = anchor.filter(|p| area.contains(*p)) else { return false };
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let aspect = img.width() / img.height().max(1.0);
|
||
let fit = fit_rect(area, aspect, Zoom::Fit, native, ppp, (0.5, 0.5));
|
||
let fit_pct = fit.width() * ppp * 100.0 / native[0].max(1) as f32;
|
||
let mut zoom = app.ui.zoom;
|
||
let size = if factor != 1.0 {
|
||
// Start at the displayed scale (including Fit/Fill and an interrupted click animation).
|
||
let pct = (img.width() * ppp * 100.0 / native[0].max(1) as f32 * factor).clamp(fit_pct.min(800.0), 800.0);
|
||
zoom = if pct <= fit_pct { Zoom::Fit } else { Zoom::Percent(pct) };
|
||
fit_rect(area, aspect, zoom, native, ppp, (0.5, 0.5)).size()
|
||
} else {
|
||
img.size()
|
||
};
|
||
// The image point under the pointer remains there as its size changes, then follows the pan.
|
||
let point = (anchor - img.min) / img.size();
|
||
let offset = area.center() - anchor - delta;
|
||
let pan = bounded_pan(area, size, (point.x + offset.x / size.x.max(1.0), point.y + offset.y / size.y.max(1.0)));
|
||
let _ = app.run("view.navigate", json!({"zoom": zoom, "pan": pan}));
|
||
ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO);
|
||
ui.ctx().request_repaint();
|
||
true
|
||
}
|
||
|
||
/// The largest texture the GPU behind `ctx` takes (egui reports it; 2048 until it does, the
|
||
/// smallest limit WebGL devices have).
|
||
///
|
||
/// The value egui keeps, not the frame's raw input: a native host (egui-winit) reports the limit
|
||
/// in the first frame's input only, so the raw value is `None` from the second frame on
|
||
/// (issue #652: every window render was cut to 2048 px, with blurry strips beside it).
|
||
pub(crate) fn texture_side(ctx: &egui::Context) -> usize {
|
||
ctx.input(|i| i.max_texture_side)
|
||
}
|
||
|
||
/// The photo's own pixels as it is shown: its size after the crop and the user's rotation (what
|
||
/// 100 % zoom and the render size limit are measured against), at least 1 × 1.
|
||
pub(crate) fn output_px(frame: &Frame) -> [usize; 2] {
|
||
let (w, h) = frame.native_size();
|
||
let px = |v: f64| if v.is_finite() { v.round().clamp(1.0, 1e9) as usize } else { 1 };
|
||
[px(w), px(h)]
|
||
}
|
||
|
||
/// 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::new("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
|
||
}
|
||
|
||
/// Whether an overlay can be rendered on a region tile without changing what the tile means.
|
||
/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic
|
||
/// overlays either need whole-frame context or deliberately replace the loupe.
|
||
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
|
||
matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. })
|
||
}
|
||
|
||
/// 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<usize>,
|
||
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<crate::region::RegionView> {
|
||
let frame_edge = c.frame_edge?;
|
||
// while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one
|
||
// per frame (zooming out would ask for ever wider ones)
|
||
if c.holding {
|
||
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
|
||
}
|
||
let (fw, fh) =
|
||
if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) };
|
||
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
|
||
// what is on screen, in pixels of the window's frame (the drawn frame, scaled)
|
||
let to_px = c.ppp * frame_edge as f32 / c.drawn_long.max(1.0);
|
||
let visible = (
|
||
to_px * (v.visible.left() - v.target.left()),
|
||
to_px * (v.visible.top() - v.target.top()),
|
||
to_px * (v.visible.right() - v.target.left()),
|
||
to_px * (v.visible.bottom() - v.target.top()),
|
||
);
|
||
let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?;
|
||
// a window waits for the original another window job is decoding (see `defer_window`)
|
||
let original_held = app.session.media.has_source(c.id, lightcraft_engine::SourceLevel::for_size(fw.max(fh)));
|
||
let window_pending = app.renderer.is_pending(Slot::Region) || app.renderer.is_pending(Slot::RegionBefore);
|
||
if crate::region::defer_window(window_pending, original_held) {
|
||
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
|
||
}
|
||
let job = if v.before {
|
||
app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)
|
||
} else {
|
||
app.session.region_job(c.id, fw, fh, win, !c.crop_tool)
|
||
};
|
||
// refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one
|
||
// render, so the whole frame is rendered at the drawn size instead (see `region::plan`)
|
||
let Some(job) = job else {
|
||
app.window_refused = Some((c.id, c.look, frame_edge));
|
||
return None;
|
||
};
|
||
// a drag renders drafts of the window, as it does of the whole frame. The Before tile stays
|
||
// plain; the After tile carries the same mask overlay as the whole-frame render.
|
||
let job = if c.interacting { job.draft() } else { job };
|
||
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
|
||
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
|
||
let job = job.with_overlay(overlay);
|
||
// keyed as the renderer keys the texture (a display profile changes the key)
|
||
let job = app.renderer.job_for(v.slot, job);
|
||
// The overlay is part of the tile's identity too, so changing mask view/colour never leaves a
|
||
// stale region tile on screen while its replacement renders.
|
||
let look = job.settings.hash64() ^ overlay.key().rotate_left(17);
|
||
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);
|
||
}
|
||
|
||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let full = ui.max_rect();
|
||
let fullscreen = app.ui.fullscreen;
|
||
let show_film = app.ui.filmstrip && !fullscreen;
|
||
let film_h = if show_film { t.film_h } else { 0.0 };
|
||
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
|
||
app.canvas_rect = Some(canvas);
|
||
if show_film {
|
||
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
|
||
}
|
||
let Some(id) = app.session.active() else {
|
||
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
|
||
return;
|
||
};
|
||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||
let d = (*photo.develop).clone();
|
||
// the full-screen preview shows the photo only: no tool overlays
|
||
let right = if fullscreen { RightPanel::None } else { app.ui.right };
|
||
let crop_tool = right == RightPanel::Crop;
|
||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
|
||
let aspect = frame.aspect() as f32;
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let area = canvas.shrink(if fullscreen {
|
||
0.0
|
||
} else if crop_tool {
|
||
48.0
|
||
} else {
|
||
24.0
|
||
});
|
||
let native = output_px(&frame);
|
||
let texture_side = texture_side(ui.ctx());
|
||
// two views (before, after): side by side or stacked
|
||
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
|
||
let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom);
|
||
let areas: Vec<Rect> = match app.ui.before_after {
|
||
BeforeAfter::SideBySide => {
|
||
let half = area.width() / 2.0 - 6.0;
|
||
vec![
|
||
Rect::from_min_size(area.min, vec2(half, area.height())),
|
||
Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())),
|
||
]
|
||
}
|
||
BeforeAfter::TopBottom => {
|
||
let half = area.height() / 2.0 - 14.0;
|
||
vec![
|
||
Rect::from_min_size(area.min, vec2(area.width(), half)),
|
||
Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)),
|
||
]
|
||
}
|
||
_ => vec![area],
|
||
};
|
||
let main_area = *areas.last().unwrap_or(&area);
|
||
let visible_texture_aspect = app
|
||
.renderer
|
||
.textures
|
||
.get(&Slot::Main)
|
||
.filter(|t| t.photo == id)
|
||
.or_else(|| app.renderer.textures.get(&Slot::Preview).filter(|t| t.photo == id))
|
||
.or_else(|| app.renderer.thumb(id))
|
||
.map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32);
|
||
// Follow the texture only where it really differs (an unsupported raw's embedded JPEG with
|
||
// another crop): a texture's whole-pixel size is a little off the exact aspect, and the crop
|
||
// tool's overlays need the frame's own geometry.
|
||
let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect);
|
||
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
||
let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||
let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect);
|
||
let mut navigating = false;
|
||
for &view_area in &areas {
|
||
let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan) };
|
||
if navigate_gesture(app, ui, &resp, view_area, view_img, native) {
|
||
navigating = true;
|
||
target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||
img_rect = target_rect;
|
||
break;
|
||
}
|
||
}
|
||
app.image_rect = Some(img_rect);
|
||
// request renders: the loupe at display resolution (drafts during drags)
|
||
let interacting = app.session.interaction.is_some();
|
||
let scale = if interacting { 0.6 } else { 1.0 };
|
||
// render at the final size: a click-zoom animation only changes how the result is drawn
|
||
let native_long = native[0].max(native[1]);
|
||
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
|
||
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
|
||
let look = d.hash64();
|
||
let overlay = view_overlay(app, &d);
|
||
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
|
||
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
|
||
let sizes = crate::region::ViewSizes {
|
||
drawn_long,
|
||
canvas_long: canvas.width().max(canvas.height()) * ppp,
|
||
native_long,
|
||
texture_side,
|
||
draft_scale: scale,
|
||
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
|
||
};
|
||
let mut plan = crate::region::plan(&app.ui.settings, sizes);
|
||
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
|
||
plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes });
|
||
}
|
||
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
|
||
let now = ui.input(|i| i.time);
|
||
let plan = app.size_hold.apply(id, now, navigating, plan);
|
||
if app.size_hold.holding(now) {
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS));
|
||
}
|
||
let want = plan.main_edge;
|
||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||
// hover and before renders are stand-ins: the preview size is plenty and keeps them cheap
|
||
let se = app.ui.settings.stand_in_edge(want, texture_side);
|
||
let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) };
|
||
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
||
let job = if interacting { job.draft() } else { job };
|
||
let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
|
||
let job = job.with_hidden_grading(app.ui.grading_hidden_for(id));
|
||
app.renderer.request(Slot::Main, job, 100);
|
||
// a drag's draft that renders in a few ms (GPU) is drawn in this frame, not the next (zoomed
|
||
// in, what changes on screen is the window's draft)
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if interacting && plan.window_edge.is_none() {
|
||
let frame = ui.input(|i| i.predicted_dt).clamp(1.0 / 240.0, 1.0 / 30.0);
|
||
// (a frame time that is not a number waits for nothing)
|
||
let budget = std::time::Duration::try_from_secs_f32(frame * 0.4).unwrap_or_default();
|
||
app.renderer.wait_for_draft(ui.ctx(), &mut app.session, budget);
|
||
}
|
||
}
|
||
// hovering a preset or profile: the photo with that look, shown instead of the loupe render
|
||
// once it is ready (nothing is committed)
|
||
let hover_key = match app.hover_preview.clone() {
|
||
Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| {
|
||
let key = job.key;
|
||
app.renderer.request(Slot::Hover, job, 105);
|
||
(key, h.label)
|
||
}),
|
||
_ => None,
|
||
};
|
||
// once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and
|
||
// kept, view render cached) so stepping to them is instant
|
||
if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) {
|
||
let ids = app.session.visible_cloned();
|
||
if let Some(i) = ids.iter().position(|p| *p == id) {
|
||
let next = ids.get(i + 1).copied();
|
||
let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied();
|
||
for (n, nid) in [next, prev].into_iter().enumerate() {
|
||
let Some(nid) = nid else { continue };
|
||
let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };
|
||
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
|
||
let na = nf.aspect() as f32;
|
||
let nr = fit_rect(main_area, na, app.ui.zoom, output_px(&nf), ppp, app.ui.pan);
|
||
let nw =
|
||
app.ui.settings.prefetch_edge(nr.width().max(nr.height()) * ppp, output_px(&nf).into_iter().max().unwrap_or(1), texture_side);
|
||
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
|
||
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
|
||
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// until the loupe has this photo: show its cached render / embedded preview / a thumbnail
|
||
if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id)
|
||
&& let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool)
|
||
{
|
||
app.renderer.request_quick(Slot::Preview, q, 110);
|
||
}
|
||
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
|
||
if (split || show_before || split_view)
|
||
&& let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool)
|
||
{
|
||
app.renderer.request(Slot::Before, job, 90);
|
||
}
|
||
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
|
||
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use
|
||
// the same window; diagnostic overlays that need whole-frame context do not.
|
||
let windowable_view =
|
||
hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
|
||
let window_ctx = WindowCtx {
|
||
id,
|
||
overlay,
|
||
frame_edge: plan.window_edge.filter(|_| windowable_view),
|
||
drawn_long,
|
||
aspect,
|
||
ppp,
|
||
texture_side,
|
||
crop_tool,
|
||
interacting,
|
||
look,
|
||
holding: navigating || app.size_hold.holding(now),
|
||
};
|
||
// (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show)
|
||
let mut window_views: Vec<WindowView> = Vec::new();
|
||
if window_ctx.frame_edge.is_some() {
|
||
let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area };
|
||
let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area };
|
||
if split {
|
||
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||
window_views.push(before_rect(br, areas[0]));
|
||
window_views.push(after);
|
||
} else if show_before {
|
||
window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas });
|
||
} else if split_view {
|
||
window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas });
|
||
window_views.push(WindowView { visible: canvas, ..after });
|
||
} else {
|
||
window_views.push(WindowView { visible: canvas, ..after });
|
||
}
|
||
}
|
||
let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new();
|
||
for v in window_views {
|
||
if let Some(view) = request_window(app, &window_ctx, &v) {
|
||
windows.push((v, view));
|
||
}
|
||
}
|
||
for (slot, before) in [(Slot::Region, false), (Slot::RegionBefore, true)] {
|
||
let shown = windows.iter().find(|(v, _)| v.before == before).map(|(_, view)| *view);
|
||
if before {
|
||
app.region_before_view = shown;
|
||
} else {
|
||
app.region_view = shown;
|
||
}
|
||
if shown.is_none() {
|
||
app.region_tiles.retain(|(b, _), _| *b != before);
|
||
app.renderer.release(slot);
|
||
}
|
||
}
|
||
let p = ui.painter_at(canvas);
|
||
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
|
||
// between photos, and the full render replaces them in place)
|
||
let draw = |slot: Slot, r: Rect, clip: Rect| -> &'static str {
|
||
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
|
||
let (tex, what) = if let Some(t) = mine(slot) {
|
||
(t, "render")
|
||
} else if let Some(t) = mine(Slot::Preview) {
|
||
(t, t.quick.map(quick_name).unwrap_or("preview"))
|
||
} else if let Some(t) = app.renderer.thumb(id) {
|
||
(t, "thumb")
|
||
} else {
|
||
return "none";
|
||
};
|
||
let image_rect = fit_texture_rect(r, tex.size);
|
||
p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||
what
|
||
};
|
||
// soft proofing: a paper-white surround and the proof's name, as Lightroom shows it
|
||
if app.ui.soft_proof && !fullscreen {
|
||
p.rect_filled(canvas, 0.0, Color32::from_gray(238));
|
||
let label = crate::i18n::tr_format!("Proof Preview · {}", app.ui.proof.space.label());
|
||
p.text(pos2(canvas.right() - 16.0, canvas.top() + 14.0), Align2::RIGHT_CENTER, label, t.font(12.5), Color32::from_gray(60));
|
||
}
|
||
let shown;
|
||
if split {
|
||
// each view stays in its pane: zoomed in, a photo reaches past it (and would cover the
|
||
// other view's margins with its whole-frame render, magnified and blurred)
|
||
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||
draw(Slot::Before, br, areas[0]);
|
||
shown = draw(Slot::Main, img_rect, main_area);
|
||
// the labels under each photo, or under its pane when the photo reaches past it
|
||
let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0);
|
||
p.text(label_at(br, areas[0]), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim);
|
||
p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim);
|
||
} else if show_before {
|
||
shown = draw(Slot::Before, img_rect, canvas);
|
||
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text);
|
||
} else if let Some((_key, label)) = &hover_key
|
||
&& let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id)
|
||
{
|
||
let hover_rect = fit_texture_rect(img_rect, tex.size);
|
||
p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||
let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE);
|
||
let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0));
|
||
p.rect_filled(bg, 4.0, Color32::from_black_alpha(160));
|
||
p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE);
|
||
shown = "hover";
|
||
} else {
|
||
shown = draw(Slot::Main, img_rect, canvas);
|
||
if shown == "none" {
|
||
match app.renderer.failure_of(Slot::Main, id) {
|
||
Some(e) => {
|
||
p.text(
|
||
canvas.center() - vec2(0.0, 10.0),
|
||
Align2::CENTER_CENTER,
|
||
crate::i18n::tr("This photo can't be opened"),
|
||
t.semibold(14.0),
|
||
t.text,
|
||
);
|
||
p.text(canvas.center() + vec2(0.0, 12.0), Align2::CENTER_CENTER, e, t.font(12.0), t.text_dim);
|
||
}
|
||
None => {
|
||
p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
app.loupe_shown = Some((id, shown));
|
||
// a wipe shows the Before whole-frame render on its side of the line…
|
||
let line = crate::region::wipe_line(img_rect, canvas);
|
||
if app.ui.before_after == BeforeAfter::Split
|
||
&& let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id)
|
||
{
|
||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||
let u = ((line.x - before_rect.left()) / before_rect.width().max(1.0)).clamp(0.0, 1.0);
|
||
let left = Rect::from_min_max(before_rect.min, pos2(before_rect.left() + u * before_rect.width(), before_rect.bottom()));
|
||
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(u, 1.0)), Color32::WHITE);
|
||
}
|
||
if app.ui.before_after == BeforeAfter::SplitTopBottom
|
||
&& let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id)
|
||
{
|
||
let before_rect = fit_texture_rect(img_rect, tex.size);
|
||
let v = ((line.y - before_rect.top()) / before_rect.height().max(1.0)).clamp(0.0, 1.0);
|
||
let top = Rect::from_min_max(before_rect.min, pos2(before_rect.right(), before_rect.top() + v * before_rect.height()));
|
||
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, v)), Color32::WHITE);
|
||
}
|
||
// …and the window renders go over the whole-frame ones, all of them, where they belong in the
|
||
// frame (clipped to their pane, or their side of the line)
|
||
for (v, view) in &windows {
|
||
// (holding `\` in a wipe shows the Before everywhere: it is the Before view alone then)
|
||
let mode = if show_before { BeforeAfter::Original } else { app.ui.before_after };
|
||
let clip = crate::region::window_clip(mode, v.before, v.visible, canvas, img_rect);
|
||
draw_window(&p.with_clip_rect(clip), app, &window_ctx, v, view);
|
||
}
|
||
// the line of a wipe over both
|
||
if app.ui.before_after == BeforeAfter::Split {
|
||
p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
|
||
}
|
||
if app.ui.before_after == BeforeAfter::SplitTopBottom {
|
||
p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], Stroke::new(1.5, Color32::WHITE));
|
||
}
|
||
if app.ui.show_clipping && !show_before && !fullscreen {
|
||
clipping_overlay(app, &p, img_rect);
|
||
}
|
||
register(ui.ctx(), "canvas:image", img_rect);
|
||
let map = CanvasMap::new(&frame, img_rect);
|
||
info_overlay(app, &p, canvas, &photo);
|
||
if !fullscreen {
|
||
filter_pill(app, ui, canvas);
|
||
}
|
||
if app.ui.face_boxes {
|
||
// a name box left open on another photo is dropped
|
||
if app.ui.name_edit.as_ref().is_some_and(|e| e.photo != id.0) {
|
||
app.ui.name_edit = None;
|
||
}
|
||
let hints = super::faces::hints_for(app, id.0);
|
||
let people = if app.ui.name_edit.is_some() { app.caches.person_names(&app.session.catalog) } else { Default::default() };
|
||
// while the name box is open and recognition is not set up, the box offers the next step
|
||
let setup = if app.ui.name_edit.is_some() { super::faces::setup(app, ui.ctx()) } else { super::faces::Setup::Running };
|
||
match region_overlay(ui, &p, &map, &photo, d.orientation, hints.as_deref(), &people, &mut app.ui.name_edit, setup) {
|
||
Some(RegionEdit::SetUp) => {
|
||
app.ui.name_edit = None;
|
||
super::faces::take_step(app, ui.ctx(), setup);
|
||
}
|
||
Some(RegionEdit::Remove(index)) => {
|
||
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
|
||
}
|
||
Some(RegionEdit::Name(index, name)) => {
|
||
app.ui.name_edit = None;
|
||
let _ = app.run("faces.setName", json!({"id": id.0, "index": index, "name": name}));
|
||
}
|
||
Some(RegionEdit::Resize(index, r)) => {
|
||
let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}}));
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
// a fine grid while a transform (geometry) slider is dragged, to judge verticals
|
||
if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) {
|
||
let n = 12;
|
||
let stroke = Stroke::new(1.0, Color32::from_white_alpha(70));
|
||
for i in 1..n {
|
||
let fx = img_rect.left() + img_rect.width() * i as f32 / n as f32;
|
||
p.line_segment([pos2(fx, img_rect.top()), pos2(fx, img_rect.bottom())], stroke);
|
||
}
|
||
let rows = ((n as f32) * img_rect.height() / img_rect.width()).round().max(2.0) as usize;
|
||
for i in 1..rows {
|
||
let fy = img_rect.top() + img_rect.height() * i as f32 / rows as f32;
|
||
p.line_segment([pos2(img_rect.left(), fy), pos2(img_rect.right(), fy)], stroke);
|
||
}
|
||
}
|
||
// Cursor selection must also run on gesture frames, when tool input is suppressed.
|
||
if !matches!(right, RightPanel::Crop | RightPanel::Masking | RightPanel::Remove | RightPanel::RedEye) {
|
||
general_cursor(app, ui, &resp, img_rect, main_area);
|
||
}
|
||
if !navigating {
|
||
match right {
|
||
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
||
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
||
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
||
RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d),
|
||
_ => {
|
||
// side by side / stacked: Before shows the same frame, so a click in its view (white
|
||
// balance, colour samples, targeted adjustment) means the same point of the photo.
|
||
// Zoomed in, a view's image reaches past its pane: what counts is the pane clicked.
|
||
let pointer = resp.interact_pointer_pos().or(resp.hover_pos());
|
||
let map = match (split, areas.first(), pointer) {
|
||
(true, Some(before), Some(q)) if before.contains(q) => {
|
||
CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan))
|
||
}
|
||
_ => map,
|
||
};
|
||
general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect)
|
||
}
|
||
}
|
||
}
|
||
// drawn and hit-tested above the loupe and its tools: clicks on it pan
|
||
let nav = navigator(app, ui, canvas, img_rect, id);
|
||
// what is on screen isn't the render on its way (a stand-in, the last render after an edit,
|
||
// a magnified frame under its window): say so once that has lasted a moment
|
||
let whole: &[Slot] = if show_before && !split {
|
||
&[Slot::Before]
|
||
} else if split || split_view {
|
||
&[Slot::Main, Slot::Before]
|
||
} else {
|
||
&[Slot::Main]
|
||
};
|
||
let coming = !matches!(shown, "none" | "hover")
|
||
&& whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s));
|
||
let keys =
|
||
[Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key));
|
||
let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now));
|
||
// the render failed while a stand-in is up: what is on screen is all there will be (also while
|
||
// a library change retries it)
|
||
let failed = (!rendering
|
||
&& !(show_before && !split)
|
||
&& !matches!(shown, "render" | "none" | "hover")
|
||
&& (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main)))
|
||
.then(|| app.renderer.failure_of(Slot::Main, id))
|
||
.flatten();
|
||
if rendering || failed.is_some() {
|
||
// over the photo (the After in two views); in the crop tool's wide margin, under it
|
||
let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) };
|
||
let over = if over.is_positive() { over } else { canvas };
|
||
match failed {
|
||
Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e),
|
||
None => {
|
||
loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false);
|
||
}
|
||
}
|
||
}
|
||
if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) {
|
||
preview_only_pill(ui, canvas, why);
|
||
}
|
||
resp.context_menu(|ui| {
|
||
ui.menu_button(crate::i18n::tr("Zoom"), |ui| {
|
||
for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] {
|
||
if ui.button(crate::i18n::tr(label)).clicked() {
|
||
let _ = app.run(cmd, json!({}));
|
||
}
|
||
}
|
||
});
|
||
ui.separator();
|
||
super::grid::context_menu(app, ui, id);
|
||
});
|
||
}
|
||
|
||
/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date
|
||
/// and size, or with the camera and exposure.
|
||
fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) {
|
||
use crate::state::InfoOverlay;
|
||
let lines: Vec<String> = match app.ui.info_overlay {
|
||
InfoOverlay::Off => return,
|
||
InfoOverlay::Basic => {
|
||
let date = photo.captured.as_deref().unwrap_or(&photo.imported);
|
||
vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)]
|
||
}
|
||
InfoOverlay::Exposure => {
|
||
let m = &photo.meta;
|
||
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"));
|
||
}
|
||
let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" · ");
|
||
let mut v = vec![photo.file_name.clone()];
|
||
v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") });
|
||
if !camera.is_empty() {
|
||
v.push(camera);
|
||
}
|
||
v
|
||
}
|
||
};
|
||
let t = Tokens::get(p.ctx());
|
||
let mut y = canvas.top() + 12.0;
|
||
for (i, l) in lines.iter().enumerate() {
|
||
let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) };
|
||
let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE);
|
||
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 + 3.0;
|
||
}
|
||
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
|
||
}
|
||
|
||
/// 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<crate::state::NameEdit>,
|
||
setup: super::faces::Setup,
|
||
) -> Option<RegionEdit> {
|
||
let t = Tokens::get(p.ctx());
|
||
let clip = p.clip_rect();
|
||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||
// the box being dragged: (region index, its box so far)
|
||
let drag_key = egui::Id::new("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::new(("region-handle", index, h)), Sense::drag());
|
||
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::new(("region-x", index)), Sense::click());
|
||
register(ui.ctx(), 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::new(("region-label", index)), Sense::click());
|
||
register(ui.ctx(), format!("regionLabel:{index}"), label);
|
||
if resp.on_hover_text("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
|
||
}
|
||
|
||
/// 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::<Vec<_>>().join(" · ");
|
||
let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225));
|
||
let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0));
|
||
let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0));
|
||
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
|
||
register(ui.ctx(), "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.ctx(), "notice:previewOnly:loupe", r);
|
||
ui.interact(r, egui::Id::new("preview-only-pill"), Sense::hover()).on_hover_text(crate::widgets::preview_only_explanation(reason));
|
||
}
|
||
|
||
/// `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<Rect> {
|
||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||
if !app.ui.navigator || !zoomed || app.ui.fullscreen {
|
||
return None;
|
||
}
|
||
let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id));
|
||
let aspect = img.width() / img.height().max(1.0);
|
||
let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) };
|
||
let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h));
|
||
let p = ui.painter_at(canvas);
|
||
p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190));
|
||
match tex {
|
||
Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE),
|
||
None => p.rect_filled(frame, 0.0, Color32::from_gray(40)),
|
||
};
|
||
// 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.ctx(), "canvas:navigator", frame);
|
||
let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag());
|
||
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<u64>; 4], f64, bool)>;
|
||
|
||
/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with
|
||
/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws
|
||
/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming:
|
||
/// drafts slower than the wait don't make it blink.
|
||
pub(crate) fn rendering_overdue(wait: &mut RenderingWait, keys: [Option<u64>; 4], coming: bool, now: f64) -> bool {
|
||
if !coming {
|
||
*wait = None;
|
||
return false;
|
||
}
|
||
let (seen, since, up) = wait.get_or_insert((keys, now, false));
|
||
if !*up && *seen != keys {
|
||
(*seen, *since) = (keys, now);
|
||
}
|
||
*up |= now - *since > RENDERING_AFTER_SECS;
|
||
*up
|
||
}
|
||
|
||
/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that
|
||
/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The
|
||
/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see
|
||
/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all
|
||
/// there will be.
|
||
pub(crate) fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option<Rect>, id: &str, text: &str, caution: bool) -> Rect {
|
||
let t = Tokens::get(ui.ctx());
|
||
let p = ui.painter_at(canvas);
|
||
let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225));
|
||
let inset = if caution { 29.0 } else { 10.0 };
|
||
let size = vec2(g.size().x + inset + 10.0, 20.0);
|
||
let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size);
|
||
if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) {
|
||
r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size);
|
||
}
|
||
p.rect_filled(r, 10.0, Color32::from_black_alpha(185));
|
||
if caution {
|
||
crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution);
|
||
}
|
||
p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
|
||
register(ui.ctx(), id, r);
|
||
r
|
||
}
|
||
|
||
/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]):
|
||
/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with
|
||
/// the error on hover and for screen readers.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(crate) fn failed_pill(
|
||
ui: &egui::Ui,
|
||
app: &LightcraftApp,
|
||
photo: &lightcraft_catalog::Photo,
|
||
canvas: Rect,
|
||
over: Rect,
|
||
navigator: Option<Rect>,
|
||
id: &str,
|
||
error: &str,
|
||
) {
|
||
let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path));
|
||
let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" });
|
||
let r = loupe_pill(ui, canvas, over, navigator, id, text, true);
|
||
let resp = ui.interact(r, egui::Id::new(("render-failed", id)), Sense::hover()).on_hover_text(error);
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::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).
|
||
fn targeted_drag(app: &mut LightcraftApp, resp: &egui::Response, map: &CanvasMap, target: &str) {
|
||
if resp.drag_started()
|
||
&& let Some(q) = resp.interact_pointer_pos()
|
||
{
|
||
let _ = app.run("develop.beginInteraction", json!({"label": "Targeted Adjustment"}));
|
||
app.gesture = Some(Gesture::Targeted { at: map.norm(q), acc: 0.0 });
|
||
}
|
||
if resp.dragged()
|
||
&& let Some(Gesture::Targeted { at, acc }) = &mut app.gesture
|
||
{
|
||
*acc -= resp.drag_delta().y * 0.5;
|
||
let step = acc.trunc();
|
||
if step != 0.0 {
|
||
*acc -= step;
|
||
let at = *at;
|
||
let mut p = json!({"target": target, "x": at.x, "y": at.y, "delta": step});
|
||
if target == "curve" && app.ui.curve_channel != "parametric" {
|
||
p["channel"] = json!(app.ui.curve_channel);
|
||
}
|
||
let _ = app.run("develop.targeted", p);
|
||
}
|
||
}
|
||
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::Targeted { .. })) {
|
||
app.gesture = None;
|
||
let _ = app.run("develop.endInteraction", json!({}));
|
||
}
|
||
}
|
||
|
||
/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range,
|
||
/// Visualize Spots).
|
||
pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay {
|
||
use lightcraft_pipeline::{MaskView, Overlay};
|
||
if app.ui.fullscreen {
|
||
return Overlay::None;
|
||
}
|
||
// Show every visible mask's region at once (Ctrl+Shift+O / Cmd+Shift+O), ahead of the selected-mask overlay
|
||
// so the shortcut is not shadowed by the always-on single-mask overlay.
|
||
if app.ui.right == RightPanel::Masking && app.ui.mask_overlay_all && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) {
|
||
return Overlay::AllMasks {
|
||
view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(),
|
||
color: app.ui.mask_overlay_color,
|
||
opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8,
|
||
};
|
||
}
|
||
if app.ui.right == RightPanel::Masking
|
||
&& app.ui.mask_overlay
|
||
&& let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id))
|
||
&& !m.components.is_empty()
|
||
{
|
||
return Overlay::Mask {
|
||
id: m.id.min(u16::MAX as u32) as u16,
|
||
view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(),
|
||
color: app.ui.mask_overlay_color,
|
||
opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8,
|
||
};
|
||
}
|
||
if app.ui.right == RightPanel::Remove && app.ui.visualize_spots {
|
||
return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8);
|
||
}
|
||
let edit = app.ui.right == RightPanel::Edit;
|
||
if edit && app.ui.point_color_visualize && app.ui.flyout_open("pointColor") && app.ui.point_color < d.point_colors.len() {
|
||
return Overlay::PointColorRange(app.ui.point_color as u8);
|
||
}
|
||
if edit && app.ui.hdr_visualize && d.hdr.enabled {
|
||
return Overlay::HdrRange;
|
||
}
|
||
Overlay::None
|
||
}
|
||
|
||
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
|
||
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
|
||
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
|
||
let (lo, hi) = h.clipping();
|
||
if hi > 0.003 {
|
||
p.rect_stroke(r, 0.0, Stroke::new(2.0, Color32::from_rgb(255, 60, 60)), StrokeKind::Outside);
|
||
}
|
||
if lo > 0.003 {
|
||
p.rect_stroke(r.expand(3.0), 0.0, Stroke::new(2.0, Color32::from_rgb(60, 120, 255)), StrokeKind::Outside);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn general_cursor(app: &LightcraftApp, ui: &egui::Ui, resp: &egui::Response, img: Rect, canvas: Rect) {
|
||
// A panel or overlay under the pointer keeps its own cursor.
|
||
if !resp.dragged() && !resp.hovered() {
|
||
return;
|
||
}
|
||
let cursor = match app.ui.tool.as_str() {
|
||
"wbPicker" | "colorRange" | "pointColor" => egui::CursorIcon::Crosshair,
|
||
tool if tool.starts_with("tat:") => egui::CursorIcon::ResizeVertical,
|
||
_ if img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0 => {
|
||
if resp.dragged() {
|
||
egui::CursorIcon::Grabbing
|
||
} else {
|
||
egui::CursorIcon::Grab
|
||
}
|
||
}
|
||
_ => return,
|
||
};
|
||
ui.ctx().set_cursor_icon(cursor);
|
||
}
|
||
|
||
fn general_interaction(
|
||
app: &mut LightcraftApp,
|
||
ui: &mut egui::Ui,
|
||
resp: &egui::Response,
|
||
map: &CanvasMap,
|
||
img: Rect,
|
||
canvas: Rect,
|
||
native: [usize; 2],
|
||
aspect: f32,
|
||
) {
|
||
if app.ui.tool == "wbPicker" {
|
||
if resp.clicked()
|
||
&& let Some(q) = resp.interact_pointer_pos()
|
||
{
|
||
let n = map.norm(q);
|
||
let _ = app.run("develop.wbPick", json!({"x": n.x, "y": n.y}));
|
||
app.ui.tool.clear();
|
||
}
|
||
return;
|
||
}
|
||
if let Some(target) = app.ui.tool.strip_prefix("tat:").map(str::to_string) {
|
||
targeted_drag(app, resp, map, &target);
|
||
return;
|
||
}
|
||
if app.ui.tool == "colorRange" {
|
||
color_range_pick(app, ui, resp, map);
|
||
return;
|
||
}
|
||
if app.ui.tool == "pointColor" {
|
||
if resp.clicked()
|
||
&& let Some(q) = resp.interact_pointer_pos()
|
||
{
|
||
let n = map.norm(q);
|
||
if let Ok(r) = app.run("pointColor.pick", json!({"x": n.x, "y": n.y})) {
|
||
app.ui.point_color = r["index"].as_u64().unwrap_or(0) as usize;
|
||
}
|
||
app.ui.tool.clear();
|
||
}
|
||
return;
|
||
}
|
||
// click toggles Fit ↔ the chosen click-zoom ratio at the clicked point; drag pans when zoomed
|
||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||
if resp.double_clicked() || (resp.clicked() && !zoomed) {
|
||
if let Some(q) = resp.interact_pointer_pos() {
|
||
let u = ((q.x - img.left()) / img.width()).clamp(0.0, 1.0);
|
||
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
|
||
app.ui.pan = (u, v);
|
||
}
|
||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit };
|
||
app.ui.zoom_anim = true;
|
||
} else if resp.clicked() && zoomed {
|
||
app.ui.zoom = Zoom::Fit;
|
||
app.ui.zoom_anim = true;
|
||
}
|
||
if zoomed && resp.dragged() {
|
||
pan_image(app, canvas, img, resp.drag_delta());
|
||
}
|
||
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) {
|
||
if app.ui.tool == "guidedUpright" {
|
||
guided_overlay(app, ui, resp, map, frame, d);
|
||
return;
|
||
}
|
||
if app.ui.tool == "straighten" {
|
||
straighten_overlay(app, ui, resp, false);
|
||
return;
|
||
}
|
||
// hold ⌘ and drag: draw a straighten line without leaving the crop tool
|
||
let cmd_drag = resp.drag_started() && ui.input(|i| i.modifiers.command);
|
||
if cmd_drag || matches!(app.gesture, Some(Gesture::StraightenLine { .. })) {
|
||
straighten_overlay(app, ui, resp, true);
|
||
return;
|
||
}
|
||
if resp.hovered() && ui.input(|i| i.modifiers.command) {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
}
|
||
let quad = frame_crop_quad(d, frame);
|
||
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
|
||
let p = ui.painter();
|
||
// darken outside the crop (the box's corners in screen order: a flipped photo shows its
|
||
// top-left corner elsewhere)
|
||
let quad = flipped_corners(&pts, frame.flip_h, frame.flip_v);
|
||
if pts.len() == 4 {
|
||
p.add(outside_crop_mesh(map.rect, &quad, Color32::from_black_alpha(150)));
|
||
}
|
||
p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||
// overlay guides
|
||
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
|
||
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
|
||
// (u, v) across / down the (rotated) crop frame
|
||
let at = |(u, v): (f32, f32)| pts[0] + (pts[1] - pts[0]) * u + (pts[3] - pts[0]) * v;
|
||
let (cw, ch) = (pts[0].distance(pts[1]), pts[0].distance(pts[3]));
|
||
for line in 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<Pos2> = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect();
|
||
for (i, h) in handles.iter().enumerate() {
|
||
register(ui.ctx(), 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);
|
||
}
|
||
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;
|
||
}
|
||
if resp.drag_started()
|
||
&& let Some(q) = 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);
|
||
}
|
||
let _ = id;
|
||
}
|
||
|
||
/// 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::new(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.ctx(), "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.ctx(), "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) {
|
||
let p = ui.painter_at(map.rect.expand(8.0));
|
||
let col = Color32::from_rgb(255, 196, 40);
|
||
let draw = |a: Pos2, b: Pos2| {
|
||
p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120)));
|
||
p.line_segment([a, b], Stroke::new(1.5, col));
|
||
for q in [a, b] {
|
||
p.circle_filled(q, 4.0, col);
|
||
p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK));
|
||
}
|
||
};
|
||
for (i, (a, b)) in d.geometry.guides.iter().enumerate() {
|
||
let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b)));
|
||
register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb));
|
||
draw(sa, sb);
|
||
}
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||
if resp.drag_started()
|
||
&& let Some(q) = resp.interact_pointer_pos()
|
||
{
|
||
app.gesture = Some(Gesture::Guide { a: map.norm(q) });
|
||
}
|
||
if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) {
|
||
draw(map.screen(*a), q);
|
||
}
|
||
if resp.drag_stopped()
|
||
&& let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||
{
|
||
app.gesture = None;
|
||
let b = map.norm(q);
|
||
if a.dist(b) > 0.02 {
|
||
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}));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// 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<f32> {
|
||
let (a, b) = (quad[i % 4], quad[(i + 1) % 4]);
|
||
let n = vec2(a.y - b.y, b.x - a.x);
|
||
let orient = n.dot(centre - a);
|
||
// an edge the centre lies on: the box is flat
|
||
(orient.abs() > 1e-6 * n.length().max(1.0)).then(|| n.dot(q - a) * orient.signum())
|
||
};
|
||
// Sutherland–Hodgman: the part of a convex polygon where `keep` is at or above zero
|
||
let clip = |poly: &[Pos2], keep: &dyn Fn(Pos2) -> Option<f32>| -> Option<Vec<Pos2>> {
|
||
let mut out = Vec::with_capacity(poly.len() + 2);
|
||
for (k, &a) in poly.iter().enumerate() {
|
||
let b = *poly.get((k + 1) % poly.len())?;
|
||
let (da, db) = (keep(a)?, keep(b)?);
|
||
if da >= 0.0 {
|
||
out.push(a);
|
||
}
|
||
if (da < 0.0) != (db < 0.0) && (da - db).abs() > f32::EPSILON {
|
||
let t = da / (da - db);
|
||
out.push(a + (b - a) * t);
|
||
}
|
||
}
|
||
Some(out)
|
||
};
|
||
let photo = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
|
||
for i in 0..4 {
|
||
let outside = |q: Pos2| side(i, q).map(|d| -d);
|
||
let inside_next = |q: Pos2| side(i + 1, q);
|
||
let Some(strip) = clip(&photo, &outside).and_then(|poly| clip(&poly, &inside_next)) else { return egui::epaint::Mesh::default() };
|
||
if strip.len() < 3 {
|
||
continue;
|
||
}
|
||
let base = mesh.vertices.len() as u32;
|
||
mesh.vertices.extend(strip.iter().map(|&pos| egui::epaint::Vertex { pos, uv: Pos2::ZERO, color }));
|
||
for k in 1..(strip.len() as u32).saturating_sub(1) {
|
||
mesh.indices.extend([base, base + k, base + k + 1]);
|
||
}
|
||
}
|
||
mesh
|
||
}
|
||
|
||
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
|
||
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
|
||
f.crop_quad_norm()
|
||
}
|
||
|
||
// ------------------------------------------------------------------------ masks
|
||
|
||
/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo.
|
||
pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
|
||
match shape {
|
||
MaskShape::Radial { center, .. } => Some(*center),
|
||
MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)),
|
||
MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
|
||
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape {
|
||
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
|
||
match shape.clone() {
|
||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle {
|
||
1 | 3 => {
|
||
let l = frame_long_norm(map);
|
||
let dx = (at.x - center.x) / l.0;
|
||
let dy = (at.y - center.y) / l.1;
|
||
let (s, co) = angle.to_radians().sin_cos();
|
||
let new_rx = (dx * co + dy * s).abs().max(0.01);
|
||
let mut new_ry = ry;
|
||
if alt {
|
||
new_ry = ry * (new_rx / rx.max(0.01));
|
||
}
|
||
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
|
||
new_ry = new_rx;
|
||
}
|
||
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
|
||
}
|
||
2 | 4 => {
|
||
let l = frame_long_norm(map);
|
||
let dx = (at.x - center.x) / l.0;
|
||
let dy = (at.y - center.y) / l.1;
|
||
let (s, co) = angle.to_radians().sin_cos();
|
||
let new_ry = (dx * (-s) + dy * co).abs().max(0.01);
|
||
let mut new_rx = rx;
|
||
if alt {
|
||
new_rx = rx * (new_ry / ry.max(0.01));
|
||
}
|
||
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
|
||
new_rx = new_ry;
|
||
}
|
||
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
|
||
}
|
||
5 => {
|
||
let l = frame_long_norm(map);
|
||
let dx = (at.x - center.x) / l.0;
|
||
let dy = (at.y - center.y) / l.1;
|
||
let new_angle = dy.atan2(dx).to_degrees();
|
||
MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert }
|
||
}
|
||
_ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
|
||
},
|
||
MaskShape::Linear { start, end } => match handle {
|
||
1 => MaskShape::Linear { start: at, end },
|
||
2 => MaskShape::Linear { start, end: at },
|
||
_ => MaskShape::Linear { start: mv(start), end: mv(end) },
|
||
},
|
||
MaskShape::Brush { mut strokes } => {
|
||
for st in &mut strokes {
|
||
st.points.iter_mut().for_each(|p| *p = mv(*p));
|
||
}
|
||
MaskShape::Brush { strokes }
|
||
}
|
||
s => s,
|
||
}
|
||
}
|
||
|
||
/// Hit the geometric ellipse, independently of feather/inversion. Radii use long-edge units,
|
||
/// so undo the same scaling and rotation used to draw its outline before testing containment.
|
||
fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
|
||
let MaskShape::Radial { center, rx, ry, angle, .. } = shape else { return false };
|
||
if !rx.is_finite() || !ry.is_finite() || *rx <= 0.0 || *ry <= 0.0 {
|
||
return false;
|
||
}
|
||
let l = frame_long_norm(map);
|
||
let dx = (at.x - center.x) / l.0;
|
||
let dy = (at.y - center.y) / l.1;
|
||
let (s, co) = angle.to_radians().sin_cos();
|
||
let x = (dx * co + dy * s) / rx;
|
||
let y = (-dx * s + dy * co) / ry;
|
||
x * x + y * y <= 1.0
|
||
}
|
||
|
||
/// Colour Range eyedropper: a click samples the colour under it for the selected mask's
|
||
/// colour range, at normalized frame coordinates (crop, rotation and zoom aware);
|
||
/// ⇧-click adds a sample (up to 5).
|
||
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();
|
||
for m in &d.masks {
|
||
let sel = Some(m.id) == active;
|
||
for (ci, c) in m.components.iter().enumerate() {
|
||
match &c.shape {
|
||
MaskShape::Radial { center, rx, ry, angle, .. } if sel => {
|
||
let pts: Vec<Pos2> = (0..64)
|
||
.map(|i| {
|
||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||
let (s, co) = angle.to_radians().sin_cos();
|
||
let (x, y) = (rx * a.cos(), ry * a.sin());
|
||
let l = frame_long_norm(map);
|
||
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
|
||
})
|
||
.collect();
|
||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230))));
|
||
|
||
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.ctx(), 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.ctx(), 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 sel => {
|
||
let (a, b) = (map.screen(*start), map.screen(*end));
|
||
let dir = (b - a).normalized();
|
||
let perp = vec2(-dir.y, dir.x) * 3000.0;
|
||
for (q, alpha) in [(a, 230), (b, 110)] {
|
||
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha)));
|
||
}
|
||
for (i, q) in [a, b].iter().enumerate() {
|
||
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||
register(ui.ctx(), 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
|
||
&& let Some(at) = component_pin(&c.shape)
|
||
{
|
||
let q = map.screen(at);
|
||
pin(p, q, sel);
|
||
register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0)));
|
||
grips.push((m.id, ci, 0, q));
|
||
}
|
||
}
|
||
}
|
||
// 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("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((ui.input(|i| i.time) + 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);
|
||
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 }) => points.push(n),
|
||
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
|
||
}
|
||
}
|
||
// 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 }) = &app.gesture {
|
||
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
|
||
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
|
||
let params = crate::brush_live::LiveParams {
|
||
clip,
|
||
scale: ui.ctx().pixels_per_point(),
|
||
r,
|
||
hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)),
|
||
flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0),
|
||
density: 1.0,
|
||
color: app.ui.mask_overlay_color,
|
||
opacity: opacity.clamp(0.0, 1.0),
|
||
};
|
||
crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen);
|
||
} else {
|
||
app.brush_live = None;
|
||
}
|
||
if (resp.drag_stopped() || resp.clicked())
|
||
&& let Some(Gesture::Brush { points }) = 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 erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt);
|
||
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}),
|
||
);
|
||
}
|
||
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!({}));
|
||
}
|
||
}
|
||
|
||
/// 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))
|
||
}
|
||
|
||
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.
|
||
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 p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect));
|
||
let active = app.session.active_spot.filter(|i| *i < d.spots.len());
|
||
// (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;
|
||
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.ctx(), 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.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0)));
|
||
grips.push((i, false, tq, r));
|
||
}
|
||
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}),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------------ 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::<String>()) } else { name.to_string() }
|
||
}
|
||
|
||
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::new("film-follow-active"), active, app.session.source);
|
||
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
|
||
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::new(("film", id.0)), Sense::click());
|
||
register(ui.ctx(), 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);
|
||
if let Some(tex) = app.renderer.thumb(*id) {
|
||
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 text =
|
||
if st.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));
|
||
p.rect_filled(br, 7.5, Color32::from_black_alpha(170));
|
||
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 resp.clicked() && app.ui.view == crate::state::ViewMode::Compare {
|
||
// Compare: the clicked photo replaces the active side (or swaps with the other one)
|
||
if let Err(e) = app.run("compare.set", json!({"id": id.0})) {
|
||
app.toast(ui.ctx(), e);
|
||
}
|
||
} else if resp.clicked() {
|
||
let m = ui.input(|i| i.modifiers);
|
||
let mode = if m.command {
|
||
"toggle"
|
||
} else if m.shift {
|
||
"range"
|
||
} else {
|
||
"replace"
|
||
};
|
||
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
|
||
}
|
||
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
|
||
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 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;
|
||
for i in 0..ph.rating {
|
||
paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star);
|
||
}
|
||
x += ph.rating as f32 * 9.0 + 2.0;
|
||
match ph.flag {
|
||
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, 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};
|
||
use crate::state::Zoom;
|
||
use egui::{Rect, pos2, vec2};
|
||
|
||
#[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));
|
||
}
|
||
|
||
#[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() {
|
||
let _ = app.run("crop.autoStraighten", json!({}));
|
||
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
|
||
/// a box that is itself rotated.
|
||
#[test]
|
||
fn rotate_cursor_faces_away_from_the_crop_box() {
|
||
use egui::{Pos2, pos2};
|
||
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||
let near = |a: f32, b: f32| ((a - b + PI).rem_euclid(2.0 * PI) - PI).abs() < 1e-3;
|
||
let pts = [pos2(100.0, 100.0), pos2(300.0, 100.0), pos2(300.0, 200.0), pos2(100.0, 200.0)];
|
||
let turn = |x: f32, y: f32| super::rotate_cursor_turn(&pts, pos2(x, y), false);
|
||
assert!(near(turn(200.0, 80.0), 0.0), "above: as drawn");
|
||
assert!(near(turn(320.0, 150.0), FRAC_PI_2), "right");
|
||
assert!(near(turn(200.0, 230.0), PI), "below");
|
||
assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left");
|
||
assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up");
|
||
// off each corner: diagonally out from it
|
||
assert!(near(turn(80.0, 80.0), -FRAC_PI_4), "top-left corner");
|
||
assert!(near(turn(320.0, 80.0), FRAC_PI_4), "top-right corner");
|
||
assert!(near(turn(320.0, 220.0), 3.0 * FRAC_PI_4), "bottom-right corner");
|
||
assert!(near(turn(80.0, 220.0), -3.0 * FRAC_PI_4), "bottom-left corner");
|
||
assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up");
|
||
// a box turned 90°: its top edge is now on the right
|
||
let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x));
|
||
assert!(near(super::rotate_cursor_turn(&turned, pos2(420.0, 200.0), false), FRAC_PI_2));
|
||
// a box turned 30°: straight out from its top edge is 30° clockwise of up
|
||
let c = pos2(200.0, 150.0);
|
||
let rot = |p: Pos2| {
|
||
let (s, co) = (30f32.to_radians().sin(), 30f32.to_radians().cos());
|
||
let d = p - c;
|
||
pos2(c.x + d.x * co - d.y * s, c.y + d.x * s + d.y * co)
|
||
};
|
||
let tilted = pts.map(rot);
|
||
assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians()));
|
||
}
|
||
|
||
use super::{film_label, flipped_corners, outside_crop_mesh, texture_side};
|
||
use egui::{Color32, Pos2, Rect, pos2};
|
||
|
||
/// A rotated `w` × `h` box centred on `centre`.
|
||
fn box_at(centre: Pos2, w: f32, h: f32, angle: f32) -> [Pos2; 4] {
|
||
let (s, c) = angle.to_radians().sin_cos();
|
||
[(-w, -h), (w, -h), (w, h), (-w, h)].map(|(x, y)| pos2(centre.x + (x * c - y * s) / 2.0, centre.y + (x * s + y * c) / 2.0))
|
||
}
|
||
|
||
/// Signed distances from a convex polygon's edges, positive inwards (its winding either way).
|
||
fn sides(poly: &[Pos2], p: Pos2) -> Vec<f32> {
|
||
let n = poly.len() as f32;
|
||
let centre = pos2(poly.iter().map(|q| q.x).sum::<f32>() / n, poly.iter().map(|q| q.y).sum::<f32>() / n);
|
||
(0..poly.len())
|
||
.map(|i| {
|
||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||
let nrm = egui::vec2(a.y - b.y, b.x - a.x).normalized();
|
||
nrm.dot(p - a) * nrm.dot(centre - a).signum()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Issue #731: the dimming is the photo minus the crop box at any angle. Every point of the
|
||
/// photo outside the box lies in exactly one of the mesh's triangles; no point inside the box
|
||
/// or outside the photo lies in any. (Four quads joining photo and box corners crossed and
|
||
/// overlapped once the box was rotated.)
|
||
#[test]
|
||
fn the_dimming_outside_a_rotated_crop_is_exactly_the_photo_minus_the_box() {
|
||
let img = Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 650.0));
|
||
// boxes inside the photo, poking out of it, and bigger than it
|
||
for (w, h) in [(240.0, 360.0), (420.0, 300.0), (700.0, 900.0)] {
|
||
for angle in [0.0f32, 5.0, 15.0, 30.0, -45.0, 90.0, 137.0, 200.0] {
|
||
let quad = box_at(img.center() + egui::vec2(30.0, -20.0), w, h, angle);
|
||
let mesh = outside_crop_mesh(img, &quad, Color32::BLACK);
|
||
let tris: Vec<[Pos2; 3]> = mesh
|
||
.indices
|
||
.chunks(3)
|
||
.map(|t| [mesh.vertices[t[0] as usize].pos, mesh.vertices[t[1] as usize].pos, mesh.vertices[t[2] as usize].pos])
|
||
.collect();
|
||
let (mut inside, mut outside) = (0, 0);
|
||
let mut y = img.top() - 20.0;
|
||
while y < img.bottom() + 20.0 {
|
||
let mut x = img.left() - 20.0;
|
||
while x < img.right() + 20.0 {
|
||
let p = pos2(x, y);
|
||
let in_box = sides(&quad, p);
|
||
let in_img = sides(&[img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()], p);
|
||
// a pixel on an edge may fall either way: skip the boundaries themselves
|
||
if in_box.iter().chain(&in_img).any(|d| d.abs() < 1.0) {
|
||
x += 7.0;
|
||
continue;
|
||
}
|
||
let expected = if in_img.iter().all(|d| *d > 0.0) && !in_box.iter().all(|d| *d > 0.0) { 1 } else { 0 };
|
||
// a sample on the seam between two of a strip's fan triangles is in both
|
||
// or in neither, depending on rounding: count it both ways
|
||
let covered = |eps: f32| tris.iter().filter(|t| sides(*t, p).iter().all(|d| *d > eps)).count();
|
||
let (at_least, at_most) = (covered(0.01), covered(-0.01));
|
||
assert!(
|
||
at_least <= expected && expected <= at_most,
|
||
"{w}×{h} box at {angle}°: ({x}, {y}) lies in {at_least}..={at_most} triangle(s), not {expected}"
|
||
);
|
||
if expected == 1 {
|
||
outside += 1
|
||
} else if in_img.iter().all(|d| *d > 0.0) {
|
||
inside += 1
|
||
}
|
||
x += 7.0;
|
||
}
|
||
y += 7.0;
|
||
}
|
||
// (a box bigger than the photo swallows it whole at some angles)
|
||
assert!(inside > 50 && (outside > 50 || w > img.width()), "{w}×{h} at {angle}°: {outside} dimmed, {inside} clear samples");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A flat or broken box dims nothing rather than something arbitrary (or panicking).
|
||
#[test]
|
||
fn a_degenerate_crop_box_dims_nothing() {
|
||
let img = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
|
||
let point = [pos2(10.0, 10.0); 4];
|
||
let line = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 10.0), pos2(10.0, 10.0)];
|
||
let nan = [pos2(f32::NAN, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)];
|
||
for quad in [point, line, nan] {
|
||
assert!(outside_crop_mesh(img, &quad, Color32::BLACK).indices.is_empty(), "{quad:?}");
|
||
}
|
||
assert!(outside_crop_mesh(Rect::NAN, &box_at(pos2(50.0, 50.0), 20.0, 20.0, 10.0), Color32::BLACK).indices.is_empty());
|
||
}
|
||
|
||
/// A raw's embedded JPEG shown while the raw decodes, or the last render while an edit renders,
|
||
/// looked like the finished photo: the loupe says "Rendering…" once a render has been coming for
|
||
/// a moment with nothing new on screen, and doesn't blink while slow drafts arrive.
|
||
#[test]
|
||
fn the_loupe_says_rendering_when_the_screen_falls_behind() {
|
||
use super::{RENDERING_AFTER_SECS as WAIT, rendering_overdue};
|
||
let key = |k| [Some(k), None, None, None];
|
||
let mut wait = None;
|
||
assert!(!rendering_overdue(&mut wait, key(1), false, 0.0), "nothing coming");
|
||
// an edit after an idle spell: the wait starts now, not at the last frame drawn
|
||
assert!(!rendering_overdue(&mut wait, key(1), true, 30.0), "a render just asked for");
|
||
assert!(!rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 0.8), "a quick render never shows it");
|
||
assert!(rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 1.2), "nothing new for the wait");
|
||
assert!(rendering_overdue(&mut wait, key(2), true, 31.0 + WAIT), "once up, a slow draft doesn't blink it");
|
||
assert!(!rendering_overdue(&mut wait, key(3), false, 32.0), "done");
|
||
// a drag whose drafts keep up: always something coming, a new draft every 0.1 s
|
||
for i in 1..30u32 {
|
||
assert!(!rendering_overdue(&mut wait, key(u64::from(i)), true, 40.0 + f64::from(i) * 0.1), "draft {i}");
|
||
}
|
||
}
|
||
|
||
/// Issue #652: a native host reports the GPU's texture limit in the first frame's input only.
|
||
/// The loupe must still know it in every later frame (it read the raw input, found nothing and
|
||
/// cut every window render to 2048 px).
|
||
#[test]
|
||
fn the_texture_limit_outlives_the_frame_it_was_reported_in() {
|
||
let ctx = egui::Context::default();
|
||
assert_eq!(texture_side(&ctx), 2048, "until the host says: the smallest limit there is");
|
||
let mut seen = Vec::new();
|
||
for reported in [Some(8192), None, None] {
|
||
let raw = egui::RawInput { max_texture_side: reported, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| seen.push(texture_side(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
}
|
||
assert_eq!(seen, [8192, 8192, 8192]);
|
||
}
|
||
|
||
#[test]
|
||
fn film_labels_cut_on_characters_not_bytes() {
|
||
// the name from #266: byte 13 falls inside '限'
|
||
assert_eq!(film_label("202407層三限定訂閱圖(4)"), "202407層三限定訂閱圖…");
|
||
assert_eq!(film_label("写真"), "写真");
|
||
// exactly 14 characters (42 bytes) is shown whole
|
||
assert_eq!(film_label("一二三四五六七八九十一二三四"), "一二三四五六七八九十一二三四");
|
||
assert_eq!(film_label("IMG_20240712_153012"), "IMG_20240712_…");
|
||
assert_eq!(film_label("DSC_0001"), "DSC_0001");
|
||
assert_eq!(film_label(""), "");
|
||
}
|
||
|
||
/// With the photo flipped, the crop box's corners reach the screen mirrored; the dimming
|
||
/// outside the box pairs each photo corner with the box corner on the same side of the screen
|
||
/// (it used to pair them in data order, so a flipped photo was dimmed inside the box).
|
||
#[test]
|
||
fn flipped_crop_box_corners_come_in_screen_order() {
|
||
let screen = [pos2(10.0, 10.0), pos2(90.0, 10.0), pos2(90.0, 50.0), pos2(10.0, 50.0)];
|
||
// data order (top-left, top-right, bottom-right, bottom-left) as each flip shows it
|
||
let h = [screen[1], screen[0], screen[3], screen[2]];
|
||
let v = [screen[3], screen[2], screen[1], screen[0]];
|
||
let hv = [screen[2], screen[3], screen[0], screen[1]];
|
||
assert_eq!(flipped_corners(&screen, false, false), screen);
|
||
assert_eq!(flipped_corners(&h, true, false), screen);
|
||
assert_eq!(flipped_corners(&v, false, true), screen);
|
||
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, 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);
|
||
}
|
||
}
|