From f858578f8c014104cdf3a9f5b468fdccd20e020e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 03:20:48 +0000 Subject: [PATCH] refactor(ui-egui): split the detail view, mask overlay and denoise panel --- crates/ui-egui/src/panels/denoise.rs | 64 +- crates/ui-egui/src/panels/detail.rs | 921 ++++++++++++++-------- crates/ui-egui/src/panels/detail/masks.rs | 332 ++++---- 3 files changed, 802 insertions(+), 515 deletions(-) diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index fdb208b51..b72df10b2 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -319,6 +319,13 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V if status["enabled"] != true { return; } + speed_section(app, ui, t, list, &status); + photos_section(app, ui, t, list, &status); + cache_section(app, ui, t, list, &status); +} + +/// Where the work runs. +fn speed_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Speed"); let current = list["runOn"].as_str().unwrap_or("auto").to_string(); let mut run_on = current.as_str(); @@ -342,6 +349,10 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V app.caches.denoise.epoch += 1; } } +} + +/// Whether photos are made as they are looked at, and the progress of what is being made. +fn photos_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Photos"); let mut auto = list["auto"].as_bool().unwrap_or(true); if check(ui, "settings.denoiseAuto", &mut auto, "Make the denoised picture of the photos I am looking at") { @@ -350,27 +361,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } hint(ui, t, "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing."); let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued); - match running { - Some((done, total)) => { - let name = status["running"]["photo"] - .as_u64() - .and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())) - .unwrap_or_default(); - let text = tr_format!( - "Denoising {name}: {done} of {total} tiles{}", - if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() }, - name = name, - done = done, - total = total - ); - settings_bar(ui, done, total, text); - } - None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), - None => { - let ready = status["ready"].as_u64().unwrap_or(0); - hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) - } - } + progress_line(app, ui, t, status, running, queued); ui.horizontal(|ui| { let r = ui.button(tr("Denoise all photos with an Amount")); register(ui, "denoise:queueAll", r.rect); @@ -385,6 +376,37 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V } } }); +} + +/// "Denoising {photo}: {done} of {total} tiles", and how many photos wait. +fn running_text(app: &LightcraftApp, status: &Value, done: u64, total: u64, queued: u64) -> String { + let name = + status["running"]["photo"].as_u64().and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone())).unwrap_or_default(); + tr_format!("Denoising {name}: {done} of {total} tiles{}", waiting_suffix(queued), name = name, done = done, total = total) +} + +/// " · N waiting" after the running photo, when any wait. +fn waiting_suffix(queued: u64) -> String { + if queued > 0 { tr_format!(" · {queued} waiting", queued = queued) } else { String::new() } +} + +/// What is being made now, or how many photos wait or are done. +fn progress_line(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, status: &Value, running: Option<(u64, u64)>, queued: u64) { + match running { + Some((done, total)) => { + let text = running_text(app, status, done, total, queued); + settings_bar(ui, done, total, text); + } + None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")), + None => { + let ready = status["ready"].as_u64().unwrap_or(0); + hint(ui, t, &count(ready, "photo has its denoised picture.", "photos have their denoised picture.")) + } + } +} + +/// The cache's contents and limit. +fn cache_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &Value, status: &Value) { heading(ui, t, "Cache"); let (files, bytes) = (status["cache"]["files"].as_u64().unwrap_or(0), status["cache"]["bytes"].as_u64().unwrap_or(0)); hint( diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3eb94779c..2b58595d2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -138,11 +138,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - if super::empty_library::shown(&app.session) { - super::empty_library::show(app, ui, canvas); - } else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); - } + show_no_photo(app, ui, canvas); return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -154,47 +150,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 area = canvas.shrink(loupe_margin(fullscreen, crop_tool)); 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 = 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 areas = view_areas(app.ui.before_after, area); let main_area = *areas.last().unwrap_or(&area); - // A previous crop/rotation can stay visible while its replacement renders. Its pixels - // must not override the current frame geometry, even when it is the same photo. - let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(&d), !crop_tool)); - let visible_texture_aspect = app - .renderer - .textures - .get(&Slot::Main) - .filter(current_texture) - .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) - .or_else(|| app.renderer.thumb(id).filter(current_texture)) - .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32); + let visible_texture_aspect = visible_texture_aspect(app, id, &d, crop_tool); // 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. @@ -204,15 +168,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); - let mut navigating = false; - for &view_area in &areas { + let navigating = areas.iter().any(|&view_area| { let view_img = in_pane(view_area, img_rect); - if navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) { - navigating = true; - target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); - img_rect = target_rect; - break; - } + navigate_gesture(app, ui, &resp, view_area, view_img, native) || middle_pan(app, ui, &resp, view_area, view_img) + }); + if navigating { + target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); + img_rect = target_rect; } let before_img = in_pane(areas[0], img_rect); let before_target = in_pane(areas[0], target_rect); @@ -222,14 +184,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let overlay = view_overlay(app, &d); // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; // `ready` first: `available` would wait for a device still being created) - let gpu = interacting - && lightcraft_engine::gpu::handles( - lightcraft_pipeline::OutputDepth::U8, - app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), - overlay, - ) - && lightcraft_engine::gpu::ready() - && lightcraft_engine::gpu::available(); + let gpu = interacting && gpu_draws_view(app, overlay); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); let now = ui.input(|i| i.time); @@ -250,10 +205,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { draft, windows: 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 }); - } + let plan = loupe_plan(app, id, look, sizes); // time the full-size drafts where they are what shows: under a window the whole-frame draft // is small anyway, and the frame's time is the window's upload if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { @@ -271,14 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near // the output's limit) and shrunk once, by the Lanczos display fit below. // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) - let want = if interacting || plan.window_edge.is_some() { - plan.main_edge - } else { - // (a user's explicit Preview size below the preview level still caps it) - let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; - let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); - plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) - }; + let want = whole_frame_edge(app, interacting, &plan, native_long, texture_side); // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along // one axis only. A box that matches the source to rounding is rendered at exactly its size. @@ -287,72 +232,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // 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, short(se, aspect)) } else { (short(se, 1.0 / aspect), 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 }; - // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on - // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap - // sampling (the render is up to a 64 px step larger, or the preview level) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - 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); - } - } + let ask = LoupeAsk { id, with_crop: !crop_tool, interacting, fit_px, overlay }; + request_main(app, ui, &ask, [rw, rh], plan.window_edge.is_none()); // 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| { - // (the same display fit as the After view, drawn texel for texel like it) - let job = job.with_display_fit(fit_px[0], fit_px[1]); - 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); - } - } - } + let hover_key = request_hover(app, &ask, [sw, sh]); + if !interacting { + prefetch_neighbours(app, id, main_area, ppp, texture_side, !crop_tool); } // 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); - } + request_stand_in(app, id, want, !crop_tool); 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) - { - // (the same display fit as the After view, so both panes are the same texture size) - let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; - app.renderer.request(Slot::Before, job, 90); + if split || show_before || split_view { + request_before(app, &ask, [sw, sh]); } // 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 and @@ -371,63 +263,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 = Vec::new(); - if window_ctx.frame_edge.is_some() { - let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; - if split { - window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: 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 = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); - 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 + let window_views = if window_ctx.frame_edge.is_some() { + let mode = ViewMode { split, show_before, split_view }; + window_views(mode, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }) + } else { + Vec::new() }; + let windows = request_windows(app, &window_ctx, window_views); + let p = ui.painter_at(canvas); + let draw = |slot: Slot, r: Rect, clip: Rect| draw_slot(app, &p, id, ppp, slot, r, clip); // 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)); + proof_surround(app, &p, &t, canvas); } let shown; // the Before label's pill, painted once the window renders are down (they would cover it) @@ -441,10 +288,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let br = before_img; 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); + split_labels(&p, &t, [(br, areas[0]), (img_rect, main_area)]); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); // on the photo itself, so it gets the hover label's dark pill to stay readable over light @@ -454,37 +298,136 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } 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 = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); + draw_hover(&p, &t, tex, label, [img_rect, canvas], badge_top_inset); 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 if app.ui.settings.loupe_messages => { - p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); - } - None => {} - } + none_message(app, &p, &t, canvas, id); } } 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); + draw_wipe_before(app, &p, id, img_rect, line); + // …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); + } + if let Some((bg, g)) = before_badge { + paint_badge(&p, bg, g); + register(ui, "canvas:beforeLabel", bg); + } + // the line of a wipe over both + draw_wipe_line(app.ui.before_after, &p, img_rect, line); + let map = CanvasMap::new(&frame, img_rect); + let over = OverPhoto { photo: &photo, d: &d, map: &map, canvas, img_rect, fullscreen, crop_tool, clipping: !show_before }; + photo_overlays(app, ui, &p, &over); + // 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 { + let tv = ToolView { right, map: &map, frame: &frame, d: &d, id, split, native, aspect }; + tool_interaction(app, ui, &resp, &tv, &Views { img_rect, target_rect, before_img, before_target, areas: &areas, main_area, canvas }); + } + // 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 coming = render_coming(app, shown, &windows, ViewMode { split, show_before, split_view }); + 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::unique("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 = failed_render(app, id, !rendering && (!show_before || split), shown); + if rendering || failed.is_some() { + 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 }; + status_pill(ui, app, &photo, [canvas, over], nav, failed); + } + if !fullscreen { + photo.preview_only.as_deref().inspect(|why| preview_only_pill(ui, canvas, why)); + } + loupe_context_menu(app, &resp, id); +} + +/// The loupe's right-click menu: zoom commands and the grid's photo menu. +fn loupe_context_menu(app: &mut LightcraftApp, resp: &egui::Response, id: PhotoId) { + crate::widgets::context_menu(resp, |ui| { + crate::widgets::menu_button(ui, 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); + }); +} + +/// A fine grid while a transform (geometry) slider is dragged, to judge verticals. +fn transform_grid(app: &LightcraftApp, p: &egui::Painter, img_rect: Rect) { + 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); + } + } +} + +/// The photo's face boxes and the name box on them. +fn face_boxes( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + d: &DevelopSettings, +) { + let id = photo.id; + // 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 }; + let regions = RegionContext { photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; + match region_overlay(ui, p, map, ®ions, &mut app.ui.name_edit) { + 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 wipe shows the Before whole-frame render on its side of the line. +fn draw_wipe_before(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, img_rect: Rect, line: Pos2) { if app.ui.before_after == BeforeAfter::Split && let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) { @@ -501,157 +444,437 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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); +} + +/// Once a photo is on screen, prepare its neighbours in filmstrip order (source decoded and kept, +/// view render cached) so stepping to them is instant. +fn prefetch_neighbours(app: &mut LightcraftApp, id: PhotoId, main_area: Rect, ppp: f32, texture_side: usize, with_crop: bool) { + // only once this photo is on screen + if app.renderer.is_pending(Slot::Main) || !app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { + return; } - if let Some((bg, g)) = before_badge { - p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); - p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); - register(ui, "canvas:beforeLabel", bg); - } - // 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); - } - // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) - super::layout_overlay::draw(app, &p, img_rect); - register(ui, "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 }; - let regions = RegionContext { photo: &photo, orient: d.orientation, hints: hints.as_deref(), people: &people, setup }; - match region_overlay(ui, &p, &map, ®ions, &mut app.ui.name_edit) { - 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 => {} - } - } - if app.ui.settings.focus_point && !crop_tool { - draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); - } - // 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. - // A click-zoom there centres both views on the point clicked (#1742). - let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let (map, img, pane) = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), - _ => (map, img_rect, main_area), - }; - general_interaction(app, ui, &resp, &map, &PhotoPlacement { img, canvas: pane, native, aspect }) + 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, with_crop, 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), with_crop) { + app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); } } } - // 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 { +} + +/// The rects of the two views of a before/after (side by side or stacked), else the one area. +fn view_areas(mode: BeforeAfter, area: Rect) -> Vec { + match mode { + 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], + } +} + +/// Which before/after layout is up. +#[derive(Clone, Copy)] +struct ViewMode { + split: bool, + show_before: bool, + split_view: bool, +} + +/// The rects the loupe's views are drawn in. +struct Views<'a> { + img_rect: Rect, + target_rect: Rect, + before_img: Rect, + before_target: Rect, + areas: &'a [Rect], + main_area: Rect, + canvas: Rect, +} + +/// The window renders wanted for the layout: (slot, before side?, rect to draw in, rect it is +/// requested for, area whose pixels show). +fn window_views(mode: ViewMode, v: &Views) -> Vec { + let mut out: Vec = Vec::new(); + let after = WindowView { slot: Slot::Region, before: false, img: v.img_rect, target: v.target_rect, visible: v.main_area }; + if mode.split { + out.push(WindowView { + slot: Slot::RegionBefore, + before: true, + img: v.before_img, + target: v.before_target, + visible: v.areas.first().copied().unwrap_or(v.canvas), + }); + out.push(after); + } else if mode.show_before { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + } else if mode.split_view { + out.push(WindowView { slot: Slot::RegionBefore, before: true, img: v.img_rect, target: v.target_rect, visible: v.canvas }); + out.push(WindowView { visible: v.canvas, ..after }); + } else { + out.push(WindowView { visible: v.canvas, ..after }); + } + out +} + +/// What the canvas says when nothing could be drawn: why the photo failed, or that it is rendering. +fn none_message(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect, id: PhotoId) { + 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 if app.ui.settings.loupe_messages => { + p.text(canvas.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(13.0), t.text_dim); + } + None => {} + } +} + +/// 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). +fn failed_render<'a>(app: &'a LightcraftApp, id: PhotoId, settled: bool, shown: &str) -> Option<&'a str> { + (settled && !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() +} + +/// The pill over the photo (the After in two views; in the crop tool's wide margin, under it): the +/// failure, or View Options' "Show message when loading or rendering photos". +fn status_pill( + ui: &egui::Ui, + app: &LightcraftApp, + photo: &lightcraft_catalog::Photo, + [canvas, over]: [Rect; 2], + nav: Option, + failed: Option<&str>, +) { + match failed { + Some(e) => failed_pill(ui, app, photo, canvas, over, nav, "loupe:renderFailed", e), + None if app.ui.settings.loupe_messages => { + loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); + } + None => {} + } +} + +/// Soft proofing: a paper-white surround and the proof's name, as Lightroom shows it. +fn proof_surround(app: &LightcraftApp, p: &egui::Painter, t: &Tokens, canvas: Rect) { + 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)); +} + +/// Request the window renders, remember which sides have one, and release the slot of a side without. +fn request_windows(app: &mut LightcraftApp, ctx: &WindowCtx, views: Vec) -> Vec<(WindowView, crate::region::RegionView)> { + let mut windows: Vec<(WindowView, crate::region::RegionView)> = Vec::new(); + for v in views { + if let Some(view) = request_window(app, 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); + } + } + windows +} + +/// The aspect of the texture on screen for this photo and geometry, if one is. +fn visible_texture_aspect(app: &LightcraftApp, id: PhotoId, d: &DevelopSettings, crop_tool: bool) -> Option { + // A previous crop/rotation can stay visible while its replacement renders. Its pixels + // must not override the current frame geometry, even when it is the same photo. + let current_texture = |t: &&crate::render::Tex| t.photo == id && t.look == Some((crate::render::geometry_look(d), !crop_tool)); + app.renderer + .textures + .get(&Slot::Main) + .filter(current_texture) + .or_else(|| app.renderer.textures.get(&Slot::Preview).filter(current_texture)) + .or_else(|| app.renderer.thumb(id).filter(current_texture)) + .map(|tex| tex.size[0].max(1) as f32 / tex.size[1].max(1) as f32) +} + +/// Whether the GPU draws the view (by the GPU's own rule, `handles`; `ready` first: `available` +/// would wait for a device still being created). +fn gpu_draws_view(app: &LightcraftApp, overlay: lightcraft_pipeline::Overlay) -> bool { + lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available() +} + +/// The line of a wipe. +fn draw_wipe_line(mode: BeforeAfter, p: &egui::Painter, img_rect: Rect, line: Pos2) { + let stroke = Stroke::new(1.5, Color32::WHITE); + match mode { + BeforeAfter::Split => { + p.line_segment([pos2(line.x, img_rect.top()), pos2(line.x, img_rect.bottom())], stroke); + } + BeforeAfter::SplitTopBottom => { + p.line_segment([pos2(img_rect.left(), line.y), pos2(img_rect.right(), line.y)], stroke); + } + _ => {} + } +} + +/// Whether the render on its way is not what is on screen (a stand-in, the last render after an +/// edit, a magnified frame under its window). +fn render_coming(app: &LightcraftApp, shown: &str, windows: &[(WindowView, crate::region::RegionView)], mode: ViewMode) -> bool { + let whole: &[Slot] = if mode.show_before && !mode.split { &[Slot::Before] - } else if split || split_view { + } else if mode.split || mode.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::unique("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), - // View Options' Show message when loading or rendering photos - None if app.ui.settings.loupe_messages => { - loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false); - } - None => {} + !matches!(shown, "none" | "hover") + && whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s)) +} + +/// What the active tool needs to know about the loupe. +struct ToolView<'a> { + right: RightPanel, + map: &'a CanvasMap, + frame: &'a Frame, + d: &'a DevelopSettings, + id: PhotoId, + split: bool, + native: [usize; 2], + aspect: f32, +} + +/// Route the pointer to the right panel's tool on the photo, or to the general interactions. +fn tool_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, tv: &ToolView, v: &Views) { + let (map, d) = (tv.map, tv.d); + match tv.right { + RightPanel::Crop => crop_overlay(app, ui, resp, map, tv.frame, d, tv.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. + // A click-zoom there centres both views on the point clicked (#1742). + let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); + let (map, img, pane) = match (tv.split, v.areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(tv.frame, v.before_img), v.before_img, *before), + _ => (CanvasMap::new(tv.frame, v.img_rect), v.img_rect, v.main_area), + }; + general_interaction(app, ui, resp, &map, &PhotoPlacement { img, canvas: pane, native: tv.native, aspect: tv.aspect }) } } - if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) { - preview_only_pill(ui, canvas, why); +} + +/// The loupe's render plan, without a window for a view the GPU already refused one for. +fn loupe_plan(app: &LightcraftApp, id: PhotoId, look: u64, sizes: crate::region::ViewSizes) -> crate::region::LoupePlan { + let plan = crate::region::plan(&app.ui.settings, sizes); + if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { + return crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); + } + plan +} + +fn whole_frame_edge(app: &LightcraftApp, interacting: bool, plan: &crate::region::LoupePlan, native_long: usize, texture_side: usize) -> usize { + if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + } +} + +/// What a loupe render request is for. +struct LoupeAsk { + id: PhotoId, + with_crop: bool, + interacting: bool, + fit_px: [usize; 2], + overlay: lightcraft_pipeline::Overlay, +} + +/// Request the loupe's own render at `[rw, rh]` (drafts during drags). +fn request_main(app: &mut LightcraftApp, ui: &egui::Ui, a: &LoupeAsk, [rw, rh]: [usize; 2], whole_draft: bool) { + let (id, interacting, fit_px, overlay) = (a.id, a.interacting, a.fit_px, a.overlay); + if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), a.with_crop) { + let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { job } else { job.with_display_fit(fit_px[0], fit_px[1]) }; + 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 && whole_draft { + 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). The key of its render and the label to show. +fn request_hover(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) -> Option<(u64, String)> { + match app.hover_preview.clone() { + Some(h) if !a.interacting => app.session.preview_job(a.id, sw.max(8), sh.max(8), a.with_crop, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit(a.fit_px[0], a.fit_px[1]); + let key = job.key; + app.renderer.request(Slot::Hover, job, 105); + (key, h.label) + }), + _ => None, + } +} + +/// The Before render, at the stand-in size. +fn request_before(app: &mut LightcraftApp, a: &LoupeAsk, [sw, sh]: [usize; 2]) { + if let Some(job) = app.session.render_job(a.id, sw.max(8), sh.max(8), true, a.with_crop) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if a.interacting { job } else { job.with_display_fit(a.fit_px[0], a.fit_px[1]) }; + app.renderer.request(Slot::Before, job, 90); + } +} + +/// 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). Returns what it drew. +fn draw_slot(app: &LightcraftApp, p: &egui::Painter, id: PhotoId, ppp: f32, 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 = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); + 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 +} + +/// The hovered look's render over the photo, with its label. +fn draw_hover(p: &egui::Painter, t: &Tokens, tex: &crate::render::Tex, label: &str, [img_rect, canvas]: [Rect; 2], badge_top_inset: f32) { + 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.to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(hover_rect, canvas, g.size(), badge_top_inset); + paint_badge(p, bg, g); +} + +/// A label on a dark pill. +fn paint_badge(p: &egui::Painter, bg: Rect, g: std::sync::Arc) { + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); +} + +/// No photo is active: the empty-library message if the library is empty, else "No photo selected". +fn show_no_photo(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } +} + +/// The margin round the photo: none full screen, wide in the crop tool. +fn loupe_margin(fullscreen: bool, crop_tool: bool) -> f32 { + if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + } +} + +/// The labels under each photo, or under its pane when the photo reaches past it. +fn split_labels(p: &egui::Painter, t: &Tokens, [before, after]: [(Rect, Rect); 2]) { + let label_at = |r: Rect, pane: Rect| pos2(r.left().max(pane.left()), r.bottom().min(pane.bottom()) + 14.0); + p.text(label_at(before.0, before.1), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text_dim); + p.text(label_at(after.0, after.1), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); +} + +/// What is drawn over the photo. +struct OverPhoto<'a> { + photo: &'a lightcraft_catalog::Photo, + d: &'a DevelopSettings, + map: &'a CanvasMap, + canvas: Rect, + img_rect: Rect, + fullscreen: bool, + crop_tool: bool, + /// the clipping overlay may show (not in the Before view) + clipping: bool, +} + +/// The overlays over the photo: clipping, layout image, info, filter pill, faces, focus point and +/// the transform grid. +fn photo_overlays(app: &mut LightcraftApp, ui: &mut egui::Ui, p: &egui::Painter, o: &OverPhoto) { + let (canvas, img_rect) = (o.canvas, o.img_rect); + if app.ui.show_clipping && o.clipping && !o.fullscreen { + clipping_overlay(app, p, img_rect); + } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, p, img_rect); + register(ui, "canvas:image", img_rect); + info_overlay(app, p, canvas, o.photo); + if !o.fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + face_boxes(app, ui, p, o.map, o.photo, o.d); + } + if app.ui.settings.focus_point && !o.crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, o.photo, "canvas:focusPoint"); + } + transform_grid(app, p, img_rect); +} + +fn request_stand_in(app: &mut LightcraftApp, id: PhotoId, edge: usize, with_crop: bool) { + if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id) + && let Some(q) = app.session.quick_view_job(id, edge.max(8), with_crop) + { + app.renderer.request_quick(Slot::Preview, q, 110); } - crate::widgets::context_menu(&resp, |ui| { - crate::widgets::menu_button(ui, 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); - }); } /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index c150eac53..365ae2311 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -291,7 +291,6 @@ pub(super) fn color_range_pick(app: &mut LightcraftApp, ui: &egui::Ui, resp: &eg /// component (click selects its mask, drag moves the component), and brush painting. The mask /// itself shows as a rendered overlay ([`view_overlay`]). pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); @@ -310,60 +309,9 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg // mask so nothing else becomes draggable. The component check keeps this in step with the // painted region (`view_overlay` at detail.rs:1354 only covers masks with components). let show_outline = sel || (app.ui.mask_overlay_all && m.visible && !m.components.is_empty()); - let stroke_a: u8 = if sel { 230 } else { 170 }; for (ci, c) in m.components.iter().enumerate() { - match &c.shape { - MaskShape::Radial { center, rx, ry, angle, .. } if show_outline => { - let pts: Vec = (0..64) - .map(|i| { - let a = i as f64 / 64.0 * std::f64::consts::TAU; - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a.cos(), ry * a.sin()); - let l = frame_long_norm(map); - map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) - }) - .collect(); - p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); - - if sel { - let l = frame_long_norm(map); - for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { - let a_local = a_deg.to_radians(); - let (s, co) = angle.to_radians().sin_cos(); - let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); - let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); - p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, q)); - } - - let (s, co) = angle.to_radians().sin_cos(); - let rot_r = rx + 0.08; - let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); - let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); - p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); - p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); - grips.push((m.id, ci, 5, rot_q)); - } - } - MaskShape::Linear { start, end } if show_outline => { - let (a, b) = (map.screen(*start), map.screen(*end)); - let dir = (b - a).normalized(); - let perp = vec2(-dir.y, dir.x) * 3000.0; - let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; - for (q, alpha) in [(a, a1), (b, a2)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); - } - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui, format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - grips.push((m.id, ci, i as u8 + 1, *q)); - } - } - } - _ => {} + if show_outline { + component_outline(ui, p, map, (m.id, ci), &c.shape, sel, &mut grips); } if app.ui.mask_pins && mode.shows(sel, live) @@ -377,8 +325,98 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } mask_keys(app, ui, map, d, &grips); - // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude - // (clicks still on their way to the model included) + object_marks(app, p, map, d, active); + // 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; + } + if app.ui.tool == "object" { + object_tool(app, ui, resp, map); + return; + } + if app.ui.tool == "brush" { + brush_tool(app, ui, resp, map, p, clip); + return; + } + grip_gestures(app, ui, resp, map, d, &grips); +} + +/// The outline of one mask component, and (on the selected mask) its grips, registered and +/// collected into `grips`. +fn component_outline( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + at: (u32, usize), + shape: &MaskShape, + sel: bool, + grips: &mut Vec<(u32, usize, u8, Pos2)>, +) { + let (mask, ci) = at; + let stroke_a: u8 = if sel { 230 } else { 170 }; + match shape { + MaskShape::Radial { center, rx, ry, angle, .. } => { + let pts: Vec = (0..64) + .map(|i| { + let a = i as f64 / 64.0 * std::f64::consts::TAU; + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a.cos(), ry * a.sin()); + let l = frame_long_norm(map); + map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) + }) + .collect(); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(stroke_a)))); + + if sel { + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{}", mask, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:5", mask), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((mask, ci, 5, rot_q)); + } + } + MaskShape::Linear { start, end } => { + let (a, b) = (map.screen(*start), map.screen(*end)); + let dir = (b - a).normalized(); + let perp = vec2(-dir.y, dir.x) * 3000.0; + let (a1, a2): (u8, u8) = if sel { (230, 110) } else { (170, 80) }; + for (q, alpha) in [(a, a1), (b, a2)] { + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); + } + if sel { + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui, format!("maskHandle:{}:{ci}:{i}", mask), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((mask, ci, i as u8 + 1, *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). +fn object_marks(app: &LightcraftApp, p: &egui::Painter, map: &CanvasMap, d: &DevelopSettings, active: Option) { 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() { @@ -397,100 +435,104 @@ pub(super) fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &eg } } } - // 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. +fn object_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } - // Object tool: a click includes what's under it, ⌥-click leaves it out - if app.ui.tool == "object" { - if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && let Some(q) = resp.interact_pointer_pos() - { - let n = map.norm(q); - let exclude = ui.input(|i| i.modifiers.alt); - match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { - Ok(_) => { - if let Some(mid) = app.session.active_mask { - app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); - } - } - Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), - } - } - return; - } - // brush tool - if app.ui.tool == "brush" { - let r = (app.ui.brush_size as f64 * long) as f32; - if let Some(h) = resp.hover_pos() { - ui.ctx().set_cursor_icon(egui::CursorIcon::None); - p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); - p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); - } - if let Some(q) = resp.interact_pointer_pos() - && (resp.drag_started() || resp.dragged() || resp.clicked()) - { - let n = map.norm(q); - let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; - match &mut app.gesture { - // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points, pressure }) => { - record_pressure(pressure, points.len(), force); - points.push(n); - } - _ => { - let mut pressure = Vec::new(); - record_pressure(&mut pressure, 0, force); - app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + if resp.clicked() + && let Some(q) = resp.interact_pointer_pos() + { + let n = map.norm(q); + let exclude = ui.input(|i| i.modifiers.alt); + match app.run_typed("mask.objectPoint", json!({"x": n.x, "y": n.y, "exclude": exclude})) { + Ok(_) => { + if let Some(mid) = app.session.active_mask { + app.ui.detail_due = Some((app.tick_now() + 1.0, mid)); } } + Err(e) => app.ai_error(ui.ctx(), e, Some(("object", "new"))), } - // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { - let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; - let params = crate::brush_live::LiveParams { - clip, - scale: ui.ctx().pixels_per_point(), - r, - hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), - flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), - density: 1.0, - color: app.ui.mask_overlay_color, - opacity: opacity.clamp(0.0, 1.0), - }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); - } else { - app.brush_live = None; - } - if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() - { - app.brush_live = None; - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); - let erase = app.ui.brush_erase != alt; - // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom - let connect = shift && resp.clicked() && pts.len() == 1; - let _ = app.run( - "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), - ); - } - return; } +} + +/// Brush tool: the cursor, the stroke being painted and its commit. +fn brush_tool(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, p: &egui::Painter, clip: Rect) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + // the stroke being painted (the render shows it once it's committed), with the committed + // stroke's dab spacing, feather and flow (issue #517) + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: 1.0, + color: app.ui.mask_overlay_color, + opacity: opacity.clamp(0.0, 1.0), + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; + let _ = app.run( + "mask.brushStroke", + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), + ); + } +} + +/// Grabbing mask grips and component bodies: select, drag, release. +fn grip_gestures( + app: &mut LightcraftApp, + ui: &egui::Ui, + resp: &egui::Response, + map: &CanvasMap, + d: &DevelopSettings, + grips: &[(u32, usize, u8, Pos2)], +) { + let active = app.session.active_mask; // the grip under the pointer: the selected mask's first, then the nearest let hit = |q: Pos2| { grips