From 1feac77398c22237239d10d16e98cc4c0703cc39 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 21:04:38 +0100 Subject: [PATCH 1/6] fix(ui): show a filter dialog's preview on the CPU canvas With Preview on, the canvas showed the document unfiltered whenever it drew through the CPU canvas: a mirrored or pixel-aspect-corrected view, a machine without a usable GPU, or a GPU fallback. Only the GPU canvas branch and the channel views asked for the filter preview. The CPU canvas now draws its result too (filter_channel_preview); a zoomed-out proxy covers the document at its own size, so its texture is redrawn whole and has no detail texture. Fixes #8972. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 43 +++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d2543a54c2..70d48a227e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1626,6 +1626,15 @@ pub(crate) fn ensure_texture_in_view( (st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id) }; let (mut doc, mut preview_key) = display_doc(app, idx); + // A filter dialog's live preview: the GPU canvas draws it from its own texture, but this CPU + // canvas (no usable GPU, a flipped or pixel-aspect-corrected view, a GPU fallback) showed the + // document unfiltered. Its result is drawn here instead, as the channel views draw it. + if let Some((preview, key)) = filter_channel_preview(app, idx, ctx) { + (doc, preview_key) = (preview, key); + } + // A preview reduced for a zoomed-out view (a proxy) covers the whole document at its own size. + let reduced = app.session.documents().get(idx).is_some_and(|st| st.doc.size != doc.size); + let view = view.filter(|_| !reduced); // A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of // a large document (taken by the GPU path) would cost a full-size CPU composite per change. if crate::adjust_preview::shown_key(app) == Some(preview_key) @@ -1636,7 +1645,7 @@ pub(crate) fn ensure_texture_in_view( } let (display, display_key) = canvas_display(app, &doc, output); let seen = app.canvases.get(&cache_key(id, output)).map(|c| (c.tex_revision, c.tex_preview_key)); - let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage)); + let damage = seen.and_then(|seen| damage_since(app, idx, seen, (revision, preview_key), display_key, last_damage)).filter(|_| !reduced); // The detail texture can lag the whole-document one (made or moved later): its own damage. let detail_seen = view.and_then(|v| { let c = app.canvases.get(&cache_key(id, output))?; @@ -6804,6 +6813,38 @@ mod tests { /// #7490: the inline (web) preview path composites and uploads each changed preview once, as /// the background worker does. It used to flatten and upload it twice and record it twice, /// so the frame's refresh time counted the preview twice. + /// The CPU canvas (no usable GPU; a flipped or pixel-aspect-corrected view, or a GPU fallback, + /// on a GPU machine) shows a filter dialog's live preview. It showed the document unfiltered. + #[test] + fn the_cpu_canvas_shows_a_filter_dialogs_preview() { + let pixels = |ctx: &egui::Context, id: egui::TextureId| { + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&id).and_then(|d| d.last()).expect("the canvas texture was written").image; + let (size, px) = (image.size, image.pixels.clone()); + delta.clear(); + (size, px) + }; + for (size, zoom) in [([64u32, 48u32], 1.0f32), ([2400, 2200], 0.25)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": size[0], "height": size[1], "background": "white"})).unwrap(); + app.run("edit.fill", json!({"color": "#3399cc"})).unwrap(); + app.ui.views.get_mut(0).expect("the view").zoom = zoom; + let ctx = egui::Context::default(); + let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let (_, plain) = pixels(&ctx, tex); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let (tex_size, shown) = pixels(&ctx, tex); + let preview = app.filter_preview.as_ref().and_then(|p| p.result.clone()).expect("the dialog's preview"); + let (display, _) = canvas_display(&app, &preview, None); + let expected = display_image(display.as_deref(), &photocraft_compose::flatten(&preview)); + assert_eq!(tex_size, expected.size, "{size:?}: the preview's own size (a proxy when zoomed out)"); + assert_eq!(shown, expected.pixels, "{size:?}: the canvas shows the preview"); + assert_ne!(shown.first(), plain.first(), "{size:?}: not the unfiltered document"); + } + } + #[test] fn inline_filter_preview_is_composited_and_recorded_once() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From 8a658316d5bd99a5f1ee494188e0cb349a788649 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 21:16:53 +0100 Subject: [PATCH 2/6] fix(ui): draw only document-shaped filter previews on the CPU canvas Review of #8978: - A preview whose dimensions change (Image > Rotation) would have been stretched over the document's bounds by the CPU canvas, so it draws only previews the document's size, reduced by their proxy factor (preview_fits); others keep today's behaviour there. - "Reduced" (no detail texture, whole redraws) is the proxy factor k > 1, not a size difference, so a full-resolution preview keeps the detail texture of a large document. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 70d48a227e..dcbcade454 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1611,6 +1611,13 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, ensure_texture_in_view(app, ctx, idx, display, None).map(|t| (t.texture, t.scale)) } +/// Whether a filter preview of `preview` size shows a `doc` sized document reduced by `k` (its +/// proxy factor), so the CPU canvas can draw it over the document's bounds unstretched. +fn preview_fits(doc: photocraft_geom::Size, preview: photocraft_geom::Size, k: u32) -> bool { + let k = k.max(1); + doc.width.div_ceil(k) == preview.width && doc.height.div_ceil(k) == preview.height +} + /// [`ensure_texture`], and for a canvas showing `view` of a document too large for one /// full-resolution texture, the detail texture of the part in view ([`detail_plan`], #7568). pub(crate) fn ensure_texture_in_view( @@ -1629,11 +1636,16 @@ pub(crate) fn ensure_texture_in_view( // A filter dialog's live preview: the GPU canvas draws it from its own texture, but this CPU // canvas (no usable GPU, a flipped or pixel-aspect-corrected view, a GPU fallback) showed the // document unfiltered. Its result is drawn here instead, as the channel views draw it. + // A preview reduced for a zoomed-out view (a proxy, factor `k`) covers the whole document at + // its own size. One whose dimensions change otherwise (Image › Rotation) would be stretched + // over the document's bounds here, so the CPU canvas keeps the document for it, as before. + let mut reduced = false; if let Some((preview, key)) = filter_channel_preview(app, idx, ctx) { - (doc, preview_key) = (preview, key); + let k = app.filter_preview.as_ref().map_or(1, |p| p.key.k.max(1)); + if app.session.documents().get(idx).is_some_and(|st| preview_fits(st.doc.size, preview.size, k)) { + (doc, preview_key, reduced) = (preview, key, k > 1); + } } - // A preview reduced for a zoomed-out view (a proxy) covers the whole document at its own size. - let reduced = app.session.documents().get(idx).is_some_and(|st| st.doc.size != doc.size); let view = view.filter(|_| !reduced); // A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of // a large document (taken by the GPU path) would cost a full-size CPU composite per change. @@ -6843,6 +6855,13 @@ mod tests { assert_eq!(shown, expected.pixels, "{size:?}: the canvas shows the preview"); assert_ne!(shown.first(), plain.first(), "{size:?}: not the unfiltered document"); } + // A preview that changes the canvas's dimensions (a 90° rotation) isn't stretched over the + // document's bounds: only one the document's size (reduced by its factor) is drawn. + let size = |w, h| photocraft_geom::Size::new(w, h); + assert!(preview_fits(size(64, 32), size(64, 32), 1)); + assert!(preview_fits(size(2401, 2200), size(1201, 1100), 2), "a proxy"); + assert!(!preview_fits(size(64, 32), size(32, 64), 1), "rotated"); + assert!(!preview_fits(size(64, 32), size(80, 40), 1), "canvas grown"); } #[test] From f2ebff3de52f44957c6f01f2f5b8fd80acb8a493 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 22:00:37 +0100 Subject: [PATCH 3/6] fix(ui): centre size-changing filter previews on the CPU canvas A filter preview that changes the canvas's dimensions (Image > Image Rotation) kept the unpreviewed document on the CPU canvas (a mirrored or pixel-aspect view, no usable GPU, a GPU fallback), since drawing its texture over the document's bounds stretched it. The CPU canvas now draws it over its own area centred on the document's, as the GPU canvas centres it (preview_bounds, CpuTextures::bounds), with the drop shadow, checkerboard and rotated-view mapping on that area too. Such previews redraw whole and have no detail texture (both work in the document's pixels). Turning Preview off or cancelling restores the document. Fixes #8989. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 126 +++++++++++++++++++++++++---------- 1 file changed, 92 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dcbcade454..d9db265062 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -226,6 +226,10 @@ pub struct DetailTexture { pub(crate) struct CpuTextures { pub texture: egui::TextureId, pub scale: f32, + /// The document area the texture covers: the document's bounds, or for a filter preview that + /// changes the canvas's dimensions (Image › Rotation) its result centred on the document, as + /// the GPU canvas draws it. + pub bounds: DRect, /// The detail texture and the document area it covers (x0, y0, x1, y1 in document pixels). pub detail: Option<(egui::TextureId, [f32; 4])>, } @@ -1611,11 +1615,25 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, ensure_texture_in_view(app, ctx, idx, display, None).map(|t| (t.texture, t.scale)) } -/// Whether a filter preview of `preview` size shows a `doc` sized document reduced by `k` (its -/// proxy factor), so the CPU canvas can draw it over the document's bounds unstretched. -fn preview_fits(doc: photocraft_geom::Size, preview: photocraft_geom::Size, k: u32) -> bool { +/// The document area a filter preview of `preview` size covers, for a `doc` sized document +/// previewed at proxy factor `k`: the document's bounds when it is the document's size (reduced by +/// `k`), else its own size (times `k`) centred on the document's centre, as the GPU canvas centres +/// a size-changing preview (Image › Rotation). +fn preview_bounds(doc: photocraft_geom::Size, preview: photocraft_geom::Size, k: u32) -> DRect { let k = k.max(1); - doc.width.div_ceil(k) == preview.width && doc.height.div_ceil(k) == preview.height + let full = DRect::from_size(doc); + if doc.width.div_ceil(k) == preview.width && doc.height.div_ceil(k) == preview.height { + return full; + } + let side = |p: u32, d: u32| { + let covered = i64::from(p) * i64::from(k); + let x0 = (i64::from(d) - covered).div_euclid(2); + let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32; + (clamp(x0), clamp(x0 + covered)) + }; + let (x0, x1) = side(preview.width, doc.width); + let (y0, y1) = side(preview.height, doc.height); + DRect::new(x0, y0, x1, y1) } /// [`ensure_texture`], and for a canvas showing `view` of a document too large for one @@ -1637,15 +1655,18 @@ pub(crate) fn ensure_texture_in_view( // canvas (no usable GPU, a flipped or pixel-aspect-corrected view, a GPU fallback) showed the // document unfiltered. Its result is drawn here instead, as the channel views draw it. // A preview reduced for a zoomed-out view (a proxy, factor `k`) covers the whole document at - // its own size. One whose dimensions change otherwise (Image › Rotation) would be stretched - // over the document's bounds here, so the CPU canvas keeps the document for it, as before. + // its own size. One whose dimensions change otherwise (Image › Rotation) covers its own area, + // centred on the document's, as the GPU canvas draws it (`preview_bounds`). + let committed = app.session.documents().get(idx).map(|st| st.doc.bounds()).unwrap_or(doc.bounds()); + let mut bounds = committed; let mut reduced = false; if let Some((preview, key)) = filter_channel_preview(app, idx, ctx) { let k = app.filter_preview.as_ref().map_or(1, |p| p.key.k.max(1)); - if app.session.documents().get(idx).is_some_and(|st| preview_fits(st.doc.size, preview.size, k)) { - (doc, preview_key, reduced) = (preview, key, k > 1); - } + bounds = preview_bounds(committed.size(), preview.size, k); + (doc, preview_key, reduced) = (preview, key, k > 1); } + // Detail textures and partial redraws work in the document's own pixels. + let reduced = reduced || bounds != committed; let view = view.filter(|_| !reduced); // A flipped view draws a GPU machine's canvas through here: an adjustment dialog's preview of // a large document (taken by the GPU path) would cost a full-size CPU composite per change. @@ -1714,10 +1735,10 @@ pub(crate) fn ensure_texture_in_view( let texture = cache.texture.as_ref()?.id(); let scale = cache.scale; // Without a view (the Navigator's call) the views' detail textures stay as they are. - let Some(view) = view else { return Some(CpuTextures { texture, scale, detail: None }) }; + let Some(view) = view else { return Some(CpuTextures { texture, scale, bounds, detail: None }) }; let old = cache.detail.iter().position(|(k, _)| *k == view.key).map(|i| cache.detail.remove(i).1); let Some(plan) = detail_plan([doc.size.width, doc.size.height], texture_side(ctx), view.zoom, view.visible) else { - return Some(CpuTextures { texture, scale, detail: None }); + return Some(CpuTextures { texture, scale, bounds, detail: None }); }; let t0 = crate::gpu_canvas::now_ms(); let name = format!("canvas-detail-{}-{}", id.0, cache_key(id, output).1); @@ -1725,7 +1746,7 @@ pub(crate) fn ensure_texture_in_view( if detail.as_ref().is_some_and(|d| d.rendered) { app.perf.span("canvas detail", crate::gpu_canvas::now_ms() - t0); } - let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, detail: None }) }; + let Some(mut detail) = detail else { return Some(CpuTextures { texture, scale, bounds, detail: None }) }; detail.rendered = false; // Where its texels lie in the document: reduced texel t covers [t·W/w, (t+1)·W/w). let (w, h) = ((doc.size.width / plan.factor).max(1), (doc.size.height / plan.factor).max(1)); @@ -1737,7 +1758,7 @@ pub(crate) fn ensure_texture_in_view( if cache.detail.len() > MAX_DETAIL_VIEWS { cache.detail.remove(0); } - Some(CpuTextures { texture, scale, detail: Some(shown) }) + Some(CpuTextures { texture, scale, bounds, detail: Some(shown) }) } /// Most CPU canvas views of one document on one display that keep a detail texture. @@ -3596,23 +3617,26 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: paint_gpu_view(&painter, rect, params, &pattern); } else { app.navigator_gpu.remove(&doc.id); - if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow { - painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0)); - } - let rotated = view.rotation.abs() > 0.05; - let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square)); // A document too large for one full-resolution texture also gets the part in view at // the screen's resolution, drawn over the reduced whole (#7568). let in_view = InView { visible: visible_doc_rect(&xf), zoom: view.zoom, key: egui::Id::new(("pc-canvas-detail", ctx.viewport_id(), idx, primary)).value() }; let textures = ensure_texture_in_view(app, &ctx, idx, output, Some(in_view)); let tex = textures.as_ref().map(|t| t.texture); - paint_cpu_document(&painter, &xf, doc.bounds(), rotated, checkerboard, tex); + // The area the image covers: a size-changing preview's own (Image › Rotation). + let area = textures.as_ref().map_or(doc.bounds(), |t| t.bounds); + if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow { + let shadow_rect = if area == doc.bounds() { img_rect } else { xf.doc_rect(area) }; + painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(shadow_rect, 0)); + } + let rotated = view.rotation.abs() > 0.05; + let checkerboard = app.session.prefs().transparency_and_gamut.square().map(|square| (checker(app, &ctx), square)); + paint_cpu_document(&painter, &xf, area, rotated, checkerboard, tex); if let Some((detail, area)) = textures.and_then(|t| t.detail) { paint_cpu_detail(&painter, &xf, doc.bounds(), rotated, checkerboard, detail, area); } for &c in &pattern { - paint_cpu_document(&painter, &pattern_xf(&xf, c, size), doc.bounds(), rotated, checkerboard, tex); + paint_cpu_document(&painter, &pattern_xf(&xf, c, size), area, rotated, checkerboard, tex); } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). @@ -4851,8 +4875,13 @@ fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on /// Textured document quad whose corners follow [`ViewXform`] (rotation + flip). fn paint_mapped_image(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, size: [f32; 2], tint: Color32) { - let [w, h] = size; - let pts = [xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)]; + paint_mapped_image_at(painter, xf, tex, uv, [0.0, 0.0], size, tint); +} + +/// [`paint_mapped_image`] over the `size` document area starting at `origin`. +fn paint_mapped_image_at(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, origin: [f32; 2], size: [f32; 2], tint: Color32) { + let ([x, y], [w, h]) = (origin, size); + let pts = [xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)]; let uvs = [uv.min, pos2(uv.max.x, uv.min.y), uv.max, pos2(uv.min.x, uv.max.y)]; let mut mesh = Mesh::with_texture(tex); for i in 0..4 { @@ -4897,12 +4926,13 @@ fn paint_cpu_document( tex: Option, ) { let size = [bounds.width() as f32, bounds.height() as f32]; + let origin = [bounds.x0 as f32, bounds.y0 as f32]; let img_rect = xf.doc_rect(bounds); match checker { Some((checker_id, square)) => { if rotated { let tiles = vec2(size[0], size[1]) / (2.0 * square); - paint_mapped_image(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE); + paint_mapped_image_at(painter, xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), origin, size, Color32::WHITE); } else { let tiles = img_rect.size() / (2.0 * square); painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE); @@ -4910,7 +4940,7 @@ fn paint_cpu_document( } None => { if rotated { - paint_mapped_quad(painter, xf, size, Color32::WHITE); + paint_mapped_quad(painter, xf, origin, size, Color32::WHITE); } else { painter.rect_filled(img_rect, 0.0, Color32::WHITE); } @@ -4918,7 +4948,7 @@ fn paint_cpu_document( } if let Some(tex) = tex { if rotated { - paint_mapped_image(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE); + paint_mapped_image_at(painter, xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), origin, size, Color32::WHITE); } else { let uv = if xf.flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; painter.image(tex, img_rect, uv, Color32::WHITE); @@ -4968,9 +4998,9 @@ fn paint_cpu_detail( quad(tex, [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]); } -fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, size: [f32; 2], color: Color32) { - let [w, h] = size; - let pts = vec![xf.to_screen(0.0, 0.0), xf.to_screen(w, 0.0), xf.to_screen(w, h), xf.to_screen(0.0, h)]; +fn paint_mapped_quad(painter: &egui::Painter, xf: &ViewXform, origin: [f32; 2], size: [f32; 2], color: Color32) { + let ([x, y], [w, h]) = (origin, size); + let pts = vec![xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)]; painter.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE)); } @@ -6855,13 +6885,41 @@ mod tests { assert_eq!(shown, expected.pixels, "{size:?}: the canvas shows the preview"); assert_ne!(shown.first(), plain.first(), "{size:?}: not the unfiltered document"); } - // A preview that changes the canvas's dimensions (a 90° rotation) isn't stretched over the - // document's bounds: only one the document's size (reduced by its factor) is drawn. + // A preview that changes the canvas's dimensions covers its own area, centred on the + // document's (as the GPU canvas centres it), rather than being stretched over it. let size = |w, h| photocraft_geom::Size::new(w, h); - assert!(preview_fits(size(64, 32), size(64, 32), 1)); - assert!(preview_fits(size(2401, 2200), size(1201, 1100), 2), "a proxy"); - assert!(!preview_fits(size(64, 32), size(32, 64), 1), "rotated"); - assert!(!preview_fits(size(64, 32), size(80, 40), 1), "canvas grown"); + assert_eq!(preview_bounds(size(64, 32), size(64, 32), 1), DRect::new(0, 0, 64, 32)); + assert_eq!(preview_bounds(size(2401, 2200), size(1201, 1100), 2), DRect::new(0, 0, 2401, 2200), "a proxy"); + assert_eq!(preview_bounds(size(64, 32), size(32, 64), 1), DRect::new(16, -16, 48, 48), "rotated"); + assert_eq!(preview_bounds(size(64, 32), size(80, 40), 1), DRect::new(-8, -4, 72, 36), "canvas grown"); + assert_eq!(preview_bounds(size(4000, 2000), size(1000, 2000), 2), DRect::new(1000, -1000, 3000, 3000), "a rotated proxy"); + + // Image › Image Rotation › Arbitrary 90° on a 64×32 document: its 32×64 preview is drawn over + // the centred 32×64 area. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + let id = crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap(); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap(); + d.fields.insert("angle".into(), json!(90.0)); + d.fields.insert("__preview".into(), json!(true)); + let ctx = egui::Context::default(); + let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap(); + let (size, _) = pixels(&ctx, t.texture); + assert_eq!(size, [32, 64], "the rotated preview's texture"); + assert_eq!(t.bounds, DRect::new(16, -16, 48, 48), "centred on the document"); + let centre = |r: DRect| (r.x0 + r.x1, r.y0 + r.y1); + assert_eq!(centre(t.bounds), centre(app.session.active().unwrap().doc.bounds()), "the same centre, so the view's pan holds"); + // Preview off, then Cancel: the document again, unchanged. + let revision = app.session.active().unwrap().revision; + app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap().fields.insert("__preview".into(), json!(false)); + let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap(); + assert_eq!(t.bounds, DRect::new(0, 0, 64, 32), "Preview off"); + crate::dialogs::cancel(&mut app, id).unwrap(); + let t = ensure_texture_in_view(&mut app, &ctx, 0, None, None).unwrap(); + assert_eq!(t.bounds, DRect::new(0, 0, 64, 32), "after Cancel"); + assert_eq!(app.session.active().unwrap().revision, revision, "the document untouched"); + assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(64, 32)); } #[test] From 4df36ba4c4623a858fac8b5a0140c153e7c10f57 Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 22:28:12 +0100 Subject: [PATCH 4/6] fix(ui): outline a size-changing preview's area with the Line border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #8994: with Canvas Border set to Line, the border stayed on the document's rectangle while a size-changing preview (a 90° rotation) drew over its own area. The canvas closure now returns the edge it drew (the preview's area on the CPU canvas), and the border outlines it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d9db265062..e3c382b14e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3539,6 +3539,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow; // Drop shadow, checkerboard, document image. let img_rect = xf.doc_rect(doc.bounds()); + // The canvas edge the Line border outlines: a size-changing preview's own (CPU canvas). + let mut edge_rect = img_rect; // Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas // (whose edge blends into them) and without its shadow. let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output); @@ -3625,8 +3627,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let tex = textures.as_ref().map(|t| t.texture); // The area the image covers: a size-changing preview's own (Image › Rotation). let area = textures.as_ref().map_or(doc.bounds(), |t| t.bounds); + if area != doc.bounds() { + edge_rect = xf.doc_rect(area); + } if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow { - let shadow_rect = if area == doc.bounds() { img_rect } else { xf.doc_rect(area) }; + let shadow_rect = edge_rect; painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(shadow_rect, 0)); } let rotated = view.rotation.abs() > 0.05; @@ -3801,7 +3806,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } - (img_rect, beyond, layer_edges) + (beyond, layer_edges, edge_rect) }; let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &display_view, xf)) }; let img_rect = xf.doc_rect(doc.bounds()); @@ -4463,7 +4468,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } else { xf }; - let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf)); + let (beyond, layer_edges, edge_rect) = painted.unwrap_or_else(|| paint_canvas(app, ui, &display_view, xf)); // A Hand drag (the tool, Space or the middle button) keeps every overlay on screen, as in // Photoshop: the Pen path being drawn, paths, guides, grid, transform box (#6592). Only the // tool cursors below give way to the fist. @@ -4510,7 +4515,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // The canvas edge isn't outlined while the pixels past it show (Photoshop's crop preview), // nor while the Crop tool hides everything outside its box (Show Cropped Area off). if border == photocraft_engine::prefs::CanvasBorder::Line && !beyond && !crate::crop_shield::hides_outside(app) { - painter.rect_stroke(img_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside); + painter.rect_stroke(edge_rect, 0.0, Stroke::new(1.0, Color32::from_gray(20)), egui::StrokeKind::Outside); } crate::type_tool::draw_overlay(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); From f74229c64a077a2fd6abe4f1fa329ec09b9cbaeb Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Fri, 23 Oct 2026 22:40:55 +0100 Subject: [PATCH 5/6] fix(ui): tile Pattern Preview copies by a size-changing preview's area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #8994: with View > Pattern Preview on, the CPU canvas placed the copies of a size-changing preview (a 90° rotation) at the document's size, leaving gaps and overlaps. They now tile the area the preview covers, from its origin. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e3c382b14e..811a75e703 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3640,8 +3640,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some((detail, area)) = textures.and_then(|t| t.detail) { paint_cpu_detail(&painter, &xf, doc.bounds(), rotated, checkerboard, detail, area); } - for &c in &pattern { - paint_cpu_document(&painter, &pattern_xf(&xf, c, size), area, rotated, checkerboard, tex); + // Pattern Preview tiles what is shown: a size-changing preview's area, from its origin. + let (tile, copies) = if area == doc.bounds() || !app.ui.view.pattern_preview { + (size, pattern.clone()) + } else { + let tile = [area.width(), area.height()]; + let v = visible_doc_rect(&xf).translate(-area.x0, -area.y0); + (tile, crate::pattern_preview::copies([v.x0, v.y0, v.x1, v.y1].map(f64::from), tile)) + }; + for &c in &copies { + paint_cpu_document(&painter, &pattern_xf(&xf, c, tile), area, rotated, checkerboard, tex); } } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). From 34f031e313cec269cc36d6a769f8e8b6895a0d1d Mon Sep 17 00:00:00 2001 From: Finn Newick Date: Sat, 24 Oct 2026 14:52:03 +0100 Subject: [PATCH 6/6] fix(ui): draw a channel overlay over a size-changing preview's area The overlay is rendered from the live preview, but the CPU canvas drew it over the document's bounds, stretching a rotated preview's overlay. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 65 +++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bb7ca02656..8400f64461 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3609,6 +3609,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let img_rect = xf.doc_rect(doc.bounds()); // The canvas edge the Line border outlines: a size-changing preview's own (CPU canvas). let mut edge_rect = img_rect; + // The document area the image is drawn over: a size-changing preview's own on the CPU + // canvas (Image › Rotation). Overlays drawn from the same preview follow it. + let mut shown_area = doc.bounds(); // Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas // (whose edge blends into them) and without its shadow. let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output); @@ -3695,6 +3698,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let tex = textures.as_ref().map(|t| t.texture); // The area the image covers: a size-changing preview's own (Image › Rotation). let area = textures.as_ref().map_or(doc.bounds(), |t| t.bounds); + shown_area = area; if area != doc.bounds() { edge_rect = xf.doc_rect(area); } @@ -3725,18 +3729,21 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } // Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs). if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) { + // Rendered from the live preview, so over the area the preview is shown in. + let a = shown_area; if view.rotation.abs() > 0.05 { - paint_mapped_image( + paint_mapped_image_at( &painter, &xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), - [doc.size.width as f32, doc.size.height as f32], + [a.x0 as f32, a.y0 as f32], + [a.width() as f32, a.height() as f32], Color32::WHITE, ); } else { let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) }; - painter.image(tex, img_rect, uv, Color32::WHITE); + painter.image(tex, xf.doc_rect(a), uv, Color32::WHITE); } } crate::color_range_ui::paint_selection_preview(app, &ctx, &painter, doc.id, img_rect, flip); @@ -4962,12 +4969,7 @@ fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y]; } -/// Textured document quad whose corners follow [`ViewXform`] (rotation + flip). -fn paint_mapped_image(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, size: [f32; 2], tint: Color32) { - paint_mapped_image_at(painter, xf, tex, uv, [0.0, 0.0], size, tint); -} - -/// [`paint_mapped_image`] over the `size` document area starting at `origin`. +/// A texture mapped over the `size` document area starting at `origin` (follows a rotated view). fn paint_mapped_image_at(painter: &egui::Painter, xf: &ViewXform, tex: egui::TextureId, uv: Rect, origin: [f32; 2], size: [f32; 2], tint: Color32) { let ([x, y], [w, h]) = (origin, size); let pts = [xf.to_screen(x, y), xf.to_screen(x + w, y), xf.to_screen(x + w, y + h), xf.to_screen(x, y + h)]; @@ -7019,6 +7021,51 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, photocraft_geom::Size::new(64, 32)); } + /// A channel overlay drawn from a size-changing preview covers the preview's own area, as the + /// image does, not the document's: a 90° rotation of a 64×32 document shows a 32×64 overlay. + #[test] + fn a_channel_overlay_follows_a_size_changing_preview() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 64, "height": 32, "background": "white"})).unwrap(); + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + let id = crate::filter_dialog::open(&mut app, "image.rotation.arbitrary").unwrap(); + let d = app.ui.dialogs.iter_mut().find(|d| d.id == id).unwrap(); + d.fields.insert("angle".into(), json!(90.0)); + d.fields.insert("__preview".into(), json!(true)); + let ctx = egui::Context::default(); + for rotation in [0.0f32, 30.0] { + let view = View { zoom: 4.0, center: [32.0, 16.0], rotation, ..app.ui.views[0].clone() }; + let mut output = ctx.run_ui(Default::default(), |ui| { + canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), view.clone(), true); + }); + output.textures_delta.clear(); + let overlay = crate::channel_view::ensure(&mut app, &ctx, 0).expect("the channel overlay"); + let mesh = output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id == overlay => Some(m.clone()), + _ => None, + }) + .expect("the overlay is painted"); + // The quad's sides, in screen points: the two shortest distances from a corner (the + // third is the diagonal; a rotated view turns the quad, not its side lengths). + let v: Vec = mesh.vertices.iter().map(|v| v.pos).collect(); + let mut d: Vec = v[1..4].iter().map(|p| v[0].distance(*p)).collect(); + d.sort_by(f32::total_cmp); + let (short, long) = (d[0], d[1]); + // Which side is the width: the one along the view's x axis turned by the rotation. + let along = |p: Pos2| { + let r = rotation.to_radians(); + ((p - v[0]).x * r.cos() + (p - v[0]).y * r.sin()).abs() + }; + let width = v[1..4].iter().map(|p| along(*p)).fold(0.0f32, f32::max); + assert!((long / short - 2.0).abs() < 0.01, "rotation {rotation}: sides {short}×{long}, the preview's 1:2"); + assert!((width - short).abs() < 0.5, "rotation {rotation}: the short side ({short}) runs across the view (width {width})"); + } + } + #[test] fn inline_filter_preview_is_composited_and_recorded_once() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());