diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90cff1825..56aeb97c5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -207,6 +207,8 @@ pub struct LightcraftApp { pub region_before_view: Option, /// The loupe's render sizes while a pinch or two-finger scroll runs. pub(crate) size_hold: region::SizeHold, + /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. + pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, /// What the loupe drew last frame, in order (tests check layering). #[cfg(test)] pub(crate) draw_order: Vec<&'static str>, @@ -281,6 +283,7 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + window_refused: None, #[cfg(test)] draw_order: Vec::new(), region_tiles: Default::default(), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index bf33336ce..c51609f92 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -225,6 +225,10 @@ struct WindowCtx { 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. @@ -245,6 +249,11 @@ struct WindowView { /// drawn where it belongs. `None`: no window is wanted (or possible) for this view. fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option { let frame_edge = c.frame_edge?; + // while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one + // per frame (zooming out would ask for ever wider ones) + if c.holding { + return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id); + } let (fw, fh) = if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) }; let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize); @@ -258,9 +267,15 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt ); let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?; let job = if v.before { - app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)? + 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)? + 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 let job = if c.interacting { job.draft() } else { job }; @@ -372,16 +387,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 plan = crate::region::plan( - &app.ui.settings, - crate::region::ViewSizes { - drawn_long, - canvas_long: canvas.width().max(canvas.height()) * ppp, - native_long, - texture_side, - draft_scale: scale, - }, - ); + let look = d.hash64(); + // (modes that draw another picture over the loupe, or reads too far for one window, have the + // whole frame rendered at the size it is drawn, as they did before windows) + 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 && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None, + }; + 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); @@ -451,8 +471,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None && crate::region::same_aspect(display_aspect, aspect); - let window_ctx = - WindowCtx { id, frame_edge: plan.window_edge.filter(|_| plain_view), drawn_long, aspect, ppp, texture_side, crop_tool, interacting }; + let window_ctx = WindowCtx { + id, + frame_edge: plan.window_edge.filter(|_| plain_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 = Vec::new(); if window_ctx.frame_edge.is_some() { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index a3fb86d57..031db8ebe 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -72,6 +72,10 @@ pub struct ViewSizes { pub texture_side: usize, /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, + /// Whether a window can be drawn over the whole-frame render (not with an overlay, soft + /// proofing, or a read too far for one window): if not, the whole frame is rendered at the + /// size it is drawn. + pub windows: bool, } /// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is @@ -92,13 +96,20 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 }; let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; - let main_edge = settings - .loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side) - .min(lightcraft_engine::SourceLevel::Preview.max_edge()); + if !v.windows { + let main_edge = settings.loupe_edge(drawn_long * scale, v.native_long, v.texture_side); + return LoupePlan { main_edge, window_edge: None }; + } + // a user who chose a size above the preview source level (3840, 5120) means it; otherwise the + // original is not decoded for the whole-frame render + let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize }; + let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap); + let main_edge = edge_at(scale); let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); // a user who capped the preview size has chosen speed over sharpness at fit let fit = drawn_long <= canvas_long * 1.05; - let window_edge = (frame_edge as f32 > main_edge as f32 / scale * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); + // (against the undrafted whole-frame render: a drag must not switch the window off and on) + let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); LoupePlan { main_edge, window_edge } } @@ -166,12 +177,6 @@ pub fn same_aspect(shown: f32, frame: f32) -> bool { shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02 } -/// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of -/// the whole-frame render, which has only `rendered_long` of them. -pub fn needed(drawn_long: f32, rendered_long: usize) -> bool { - drawn_long.is_finite() && drawn_long > rendered_long as f32 * 1.02 + 1.0 -} - /// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the /// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin, /// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame, @@ -209,7 +214,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0 } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true } } fn auto() -> AppSettings { @@ -291,6 +296,53 @@ mod tests { assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0)); } + // Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays: + // it is only the whole-frame draft that gets smaller + #[test] + fn a_drag_at_fit_on_a_large_display_keeps_the_window() { + for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) }); + assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); + assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); + } + } + + // Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too + // far for one render), the whole-frame render is as big as the view, as it was before windows + #[test] + fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { + let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!((p.main_edge, p.window_edge), (6000, None)); + let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) }); + assert_eq!((fit.main_edge, fit.window_edge), (2816, None)); + let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) }); + assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels"); + let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) }); + assert_eq!(small.main_edge, 2048); + } + + // Given a user who chose a preview size above the preview source level (3840 or 5120 on a big + // display), the whole-frame render is that big + #[test] + fn a_large_preview_limit_is_honoured() { + for limit in [3840u32, 5120] { + let s = AppSettings { preview_limit: limit, ..Default::default() }; + let p = plan(&s, sizes(5120.0, 5120.0, 6000)); + assert_eq!(p.main_edge, limit as usize, "{limit}"); + assert_eq!(p.window_edge, None, "a fit view at the limit needs no window"); + } + let s = AppSettings { preview_limit: 2560, ..Default::default() }; + assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560); + } + + // Given a medium format photo wider than 8192 px, 1:1 is its own pixels + #[test] + fn a_window_frame_can_be_as_big_as_the_photo() { + let p = plan(&auto(), sizes(40000.0, 2800.0, 11648)); + assert_eq!(p.window_edge, Some(11648)); + } + // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { @@ -362,16 +414,6 @@ mod tests { assert!(p.main_edge >= 8); } - // Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is - #[test] - fn a_window_is_needed_only_when_the_whole_frame_render_is_stretched() { - assert!(!needed(6000.0, 6000)); - assert!(!needed(6100.0, 6000), "a couple of percent is not worth a second render"); - assert!(needed(6000.0, 2560)); - assert!(needed(48000.0, 8192)); - assert!(!needed(f32::NAN, 100) && !needed(f32::INFINITY, 100)); - } - // Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid #[test] fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c7ff1e86b..17f5c58b7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -182,6 +182,10 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; /// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting. pub const LOUPE_EDGE_CEILING: u32 = 8192; +/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more +/// than the photos there are (a window holds only what is on screen, so the frame costs nothing). +pub const WINDOW_FRAME_CEILING: usize = 65536; + /// The smallest GPU texture side the loupe plans for, whatever the host reports. pub const MIN_TEXTURE_SIDE: usize = 512; @@ -223,8 +227,8 @@ impl AppSettings { /// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about /// the whole-frame render (see [`crate::region::plan`]). pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize { - let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, LOUPE_EDGE_CEILING as f32) } as usize; - wanted.min(native_long_edge.max(8)).min(LOUPE_EDGE_CEILING as usize) + let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize; + wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING) } /// The edge Build Standard-Sized Previews uses. diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 6efbba351..55157eb8a 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -555,4 +555,51 @@ mod in_the_loupe { h.settle(SETTLE); assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::>()); } + + // Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame + // render is as big as the view, not a canvas-sized upscale + #[test] + fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() { + let (mut h, native) = detail(); + h.app.ui.soft_proof = true; + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert_eq!(h.app.region_view, None); + assert_eq!(rendered_long_edge(&h), native); + } + + // Given a window the photo's spots make too big for one render (refused), the loupe falls back + // to the whole frame at the drawn size, and tries windows again when the look changes + #[test] + fn a_refused_window_falls_back_to_a_full_size_render() { + let (mut h, native) = detail(); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + assert!(h.app.region_view.is_some()); + let id = h.app.session.active().unwrap(); + let look = h.app.session.develop_of(id).unwrap().hash64(); + h.app.window_refused = Some((id, look, native)); + h.settle(SETTLE); + assert_eq!(h.app.region_view, None); + assert_eq!(rendered_long_edge(&h), native); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + assert!(h.app.region_view.is_some(), "a new look tries a window again"); + } + + // Given a pinch out from 400 %, the window there is is kept (magnified, then shrunk) until the + // pinch stops: a new, ever wider window every frame is not asked for + #[test] + fn pinching_out_asks_for_no_new_windows() { + let (mut h, _) = detail(); + h.app.ui.zoom = crate::state::Zoom::Percent(400.0); + h.settle(SETTLE); + let key = h.app.region_view.expect("a window").key; + let at = h.app.canvas_rect.unwrap().center(); + h.request("ui.move", json!({"x": at.x, "y": at.y}), T); + for _ in 0..12 { + h.request("ui.zoom", json!({"factor": 0.9}), T); + assert_eq!(h.app.region_view.map(|v| v.key), Some(key), "the window of before the pinch"); + } + } }