diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3912e8f4be..0ed6960642 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1189,6 +1189,54 @@ fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool { /// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture /// itself; with the GPU canvas a thumbnail cached per revision, so an edit to a huge document /// doesn't also pay a full-resolution CPU composite for the navigator. +/// The Navigator's preview from the GPU canvas (#4518): the main canvas's texture of the +/// document, drawn at `rect` with its own mip chain, so an edit costs no CPU composite. `None` +/// while the canvas isn't showing the plain document on the GPU (the CPU thumbnail then). +pub fn navigator_gpu_view(app: &PhotocraftApp, idx: usize, rect: Rect, ctx: &egui::Context) -> Option { + app.gpu.as_ref()?; + let st = app.session.documents().get(idx)?; + let params = *app.navigator_gpu.get(&st.doc.id)?; + let [w, h] = params.doc_size; + if w == 0 || h == 0 || !rect.is_positive() || [st.doc.size.width, st.doc.size.height] != params.doc_size { + return None; + } + Some(crate::gpu_canvas::ViewParams { + zoom: rect.width() / w as f32, + center: [w as f32 / 2.0, h as f32 / 2.0], + rotation: 0.0, + shadow: false, + pixel_grid: false, + view_key: egui::Id::new(("pc-navigator", ctx.viewport_id(), idx)).value(), + offset: [0.0, 0.0], + ..params + }) +} + +/// Frames the Navigator waits for the GPU canvas to draw a document it hasn't drawn yet, rather +/// than build a CPU thumbnail it would only use once (a full composite on a large document). +const NAVIGATOR_WAIT_FRAMES: u32 = 3; + +/// Whether the Navigator should wait a frame for the GPU canvas (#4518): the GPU canvas is on but +/// hasn't drawn document `idx` yet (the Navigator can draw before the canvas in a frame). After +/// [`NAVIGATOR_WAIT_FRAMES`] it stops waiting, so it never stays empty. +pub fn navigator_waits_for_gpu(app: &PhotocraftApp, idx: usize, ctx: &egui::Context) -> bool { + if app.gpu.is_none() { + return false; + } + let Some(id) = app.session.documents().get(idx).map(|st| st.doc.id) else { return false }; + if app.canvases.keys().any(|(d, _)| *d == id) { + return false; + } + let key = egui::Id::new(("navigator-waits", id.0)); + let waited = ctx.data(|d| d.get_temp::(key)).unwrap_or(0); + if waited >= NAVIGATOR_WAIT_FRAMES { + return false; + } + ctx.data_mut(|d| d.insert_temp(key, waited + 1)); + ctx.request_repaint(); + true +} + pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Option { if app.gpu.is_none() { return ensure_texture(app, ctx, idx, app.session.color.main_display).map(|(t, _)| t); @@ -1463,6 +1511,14 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool { damage = Some(crate::adjust_preview::switch_damage(app, raw_key, r, visible)); } let preview_key = raw_key ^ display_key; + // The Navigator may draw this texture only while it holds the plain document (no live + // preview, which can be composited only where the view is). + if raw_key == 0 { + app.navigator_plain.insert(id); + } else { + app.navigator_plain.remove(&id); + app.navigator_gpu.remove(&id); + } let size = [doc.size.width, doc.size.height]; let cache = app.canvases.entry((id, GPU_OUTPUT)).or_insert(CanvasCache { revision: 0, @@ -2794,6 +2850,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx)) { on_gpu = true; + app.navigator_gpu.remove(&doc.id); let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)]; let params = crate::gpu_canvas::ViewParams { doc: key, @@ -2838,8 +2895,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: hdr: hdr_preview(app, &doc), offset: [0.0, 0.0], }; + if app.navigator_plain.contains(&doc.id) { + app.navigator_gpu.insert(doc.id, params); + } else { + app.navigator_gpu.remove(&doc.id); + } 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)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 70d6a32cb3..d3f1a14401 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,6 +502,11 @@ pub struct PhotocraftApp { /// `canvas::display_doc`'s live-adjust and Duotone documents per document: (snapshot, /// preview key, the document shown). display_memo: HashMap, u64, std::sync::Arc)>, + /// The main canvas's last GPU view of each document while it shows the plain document on + /// the GPU canvas: the Navigator draws the same texture at its own size (#4518). + navigator_gpu: HashMap, + /// Documents whose GPU canvas texture holds the plain document (`canvas::ensure_gpu`). + navigator_plain: std::collections::HashSet, /// Display profile readings (#569). monitors: monitor_status::State, checker: Option, @@ -755,6 +760,8 @@ impl PhotocraftApp { canvases: HashMap::new(), navigator_textures: HashMap::new(), display_memo: HashMap::new(), + navigator_gpu: HashMap::new(), + navigator_plain: std::collections::HashSet::new(), monitors: Default::default(), checker: None, drag: None, @@ -2437,6 +2444,9 @@ impl PhotocraftApp { } } +#[cfg(test)] +mod navigator_gpu_tests; + #[cfg(test)] mod color_swatch_tests; diff --git a/crates/ui-egui/src/navigator_gpu_tests.rs b/crates/ui-egui/src/navigator_gpu_tests.rs new file mode 100644 index 0000000000..51326597f4 --- /dev/null +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -0,0 +1,75 @@ +//! The Navigator draws the GPU canvas's own texture while the canvas is on the GPU (#4518), so an +//! edit costs it no CPU composite; elsewhere (a flipped view draws on the CPU) it keeps the CPU +//! thumbnail. Renders the real app offscreen; skips without a GPU adapter. + +use crate::PhotocraftApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.ui.panels.navigator = true; + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.sync_views(); + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +/// The colour at fractions (`fx`, `fy`) of the Navigator preview. +fn preview_px(h: &mut Harness, fx: f32, fy: f32) -> [u8; 4] { + let r = crate::panels::last_navigator_preview(&h.ctx).expect("navigator preview drawn"); + let img = h.render().expect("render"); + let (x, y) = ((r.left() + r.width() * fx) as u32, (r.top() + r.height() * fy) as u32); + img.get_pixel(x, y).0 +} + +fn near(a: [u8; 4], b: [u8; 3]) -> bool { + (0..3).all(|i| a[i].abs_diff(b[i]) <= 8) +} + +#[test] +fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() { + let Some(mut h) = harness() else { return }; + h.run_steps(8); + if !h.state().perf.gpu { + eprintln!("skipping: the canvas fell back to the CPU here"); + return; + } + let id = h.state().session.active().expect("doc").doc.id; + assert!(h.state().navigator_gpu.contains_key(&id), "the Navigator uses the GPU canvas view"); + assert!(!h.state().navigator_textures.contains_key(&id), "no CPU thumbnail was built"); + assert!(near(preview_px(&mut h, 0.25, 0.5), [255, 0, 0]), "left half red"); + assert!(near(preview_px(&mut h, 0.75, 0.5), [255, 255, 255]), "right half white"); + + // An edit shows in the Navigator, still without a CPU thumbnail. + h.state_mut().run("select.rect", json!({"x": 200, "y": 0, "width": 200, "height": 300})).unwrap(); + h.state_mut().run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + h.run_steps(6); + assert!(near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "right half now blue"); + assert!(!h.state().navigator_textures.contains_key(&id)); + + // A flipped view draws on the CPU: the Navigator goes back to its CPU thumbnail. + h.state_mut().ui.view.flip_horizontal = true; + h.run_steps(6); + assert!(!h.state().navigator_gpu.contains_key(&id), "no GPU view while the canvas draws on the CPU"); + assert!(h.state().navigator_textures.contains_key(&id), "the CPU thumbnail again"); + assert!(near(preview_px(&mut h, 0.25, 0.5), [255, 0, 0]) && near(preview_px(&mut h, 0.75, 0.5), [0, 0, 255]), "same picture, unflipped"); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2aae1c55e6..a69b40c41d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1879,7 +1879,6 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; }; let ctx = ui.ctx().clone(); - let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return }; let size = app.session.documents()[idx].doc.size; let avail = ui.available_width(); // The preview fills the group's height above the zoom controls (their height as measured @@ -1891,8 +1890,16 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.painter().rect_filled(frame, t.radius_sm, t.canvas); let rect = navigator_fit(frame, size.width, size.height); ui.data_mut(|d| d.insert_temp(navigator_preview_id(), rect)); - widgets::checker(ui.painter(), rect, 6.0); - ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + // On the GPU canvas, its own texture at this size (#4518); otherwise the CPU thumbnail. + if let Some(params) = crate::canvas::navigator_gpu_view(app, idx, rect, &ctx) { + crate::gpu_canvas::GpuCanvas::paint(ui.painter(), rect, params); + } else if crate::canvas::navigator_waits_for_gpu(app, idx, &ctx) { + widgets::checker(ui.painter(), rect, 6.0); + } else { + let Some(tex) = crate::canvas::navigator_texture(app, &ctx, idx) else { return }; + widgets::checker(ui.painter(), rect, 6.0); + ui.painter().image(tex, rect, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE); + } let s = rect.width() / size.width.max(1) as f32; if (resp.clicked() || resp.dragged()) && let Some(pp) = resp.interact_pointer_pos()