From 9db6abbcd4f039e4c821fe98081b4b3885406a62 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:33:20 +0200 Subject: [PATCH] refactor(render): own image and blur caches explicitly --- docs/codebase-overview.md | 18 +- src/backend/wayland/state/AGENTS.md | 2 +- src/backend/wayland/state/canvas_layer.rs | 113 ++++-- .../wayland/state/render/canvas/mod.rs | 129 ++++--- .../state/render/canvas/resource_tests.rs | 329 ++++++++++++++++++ src/backend/wayland/state/render/runtime.rs | 18 +- src/backend/wayland/state/render/ui.rs | 3 +- src/canvas_export/mod.rs | 10 +- src/canvas_export/page.rs | 26 +- src/canvas_export/pdf.rs | 16 +- src/canvas_export/png.rs | 5 +- src/canvas_export/region.rs | 5 +- src/canvas_export/tests/cache_tests.rs | 327 +++++++++++++++++ src/draw/AGENTS.md | 2 + src/draw/mod.rs | 20 +- src/draw/render/blur.rs | 34 +- src/draw/render/blur/tests.rs | 66 ++++ src/draw/render/context.rs | 66 ++++ src/draw/render/context/tests.rs | 175 ++++++++++ src/draw/render/image.rs | 63 ++-- src/draw/render/mod.rs | 2 + src/draw/render/shapes.rs | 19 +- src/input/state/render.rs | 115 ++++-- src/ui/board_picker.rs | 16 +- src/ui/board_picker/page_panel.rs | 7 +- .../page_panel/thumbnail/cards.rs | 18 +- .../page_panel/thumbnail/content.rs | 112 +++++- .../page_panel/thumbnail/types.rs | 12 +- 28 files changed, 1516 insertions(+), 212 deletions(-) create mode 100644 src/backend/wayland/state/render/canvas/resource_tests.rs create mode 100644 src/canvas_export/tests/cache_tests.rs create mode 100644 src/draw/render/context.rs create mode 100644 src/draw/render/context/tests.rs diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 32b75869d..9cf33b27f 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -77,7 +77,7 @@ Daemon mode therefore provides a persistent background service that reacts to us - Communicate with `capture::CaptureManager` for screenshot actions. - Exit when `InputState.should_exit` is set (Escape, tray close, etc.). -`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. +`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, reusable image and blur caches, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. Within `PointerRuntime`, pending chrome targets and device-owned release suppression have separate lifecycles. Clearing a toast, HUD, or zoom-chip press preserves mouse and touch suppression; cancelling a touch clears only its own release latch. @@ -146,8 +146,14 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen builds an owned `FramePlan`, paints with Cairo, and submits the buffer. A busy buffer pool defers the frame before preparation. `state/render/{prepare,plan,paint,submit}.rs` keep these boundaries explicit; the planner derives canvas policy and screen/world/buffer damage. - - `CanvasRenderCtx` borrows frame parameters and the local Cairo target. The canvas layer - cache, reusable profile baseline, and effect damage history remain in `RenderRuntime`. + - `CanvasRenderCtx` borrows frame parameters and the local Cairo target. `RenderRuntime` + owns the canvas layer cache, drawing resources, reusable profile baseline, and effect + damage history. Drawing resources stay outside the owned `FramePlan`. + - `draw::RenderCtx` borrows a Cairo target and explicit `RenderCaches` for image and blur + rendering. The overlay shares its cache owner across direct drawing, layer baking, + provisional previews, and board thumbnails. Each export job creates its own owner; + PDF pages and magnified-page raster passes share that job's image cache. Export blur + remains uncached because page backdrops can share a numeric source identifier. - Draw order: board background → finalized shapes → spotlight effects → provisional shape → text cursor preview → UI. Color-profile passes surround UI painting according to their selected targets. Main-surface submission precedes toolbar rendering and marking capture @@ -157,6 +163,12 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen The result is a predictable pipeline: Wayland → handlers → `InputState` → `BoardManager`/active `BoardPages`/`DrawingState` → `WaylandState::render`. +The public `draw::render_shape*` and `draw::render_blur_rect` convenience functions create +local drawing resources, as do `InputState::render_provisional_shape` and +`ui::render_board_picker`. Repeated internal rendering uses an explicitly borrowed cache +owner. Public PNG/PDF export functions keep their snapshot-based interfaces and create +Cairo resources inside the rendering job, after any worker-thread handoff. + --- ## 5. Capture Pipeline diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index eea9685be..5d3744498 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -8,7 +8,7 @@ - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. - Runtime owners extracted from `WaylandState` live beside it: `focus.rs` (activation, focus, and startup acquisition), `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer position, board-pan and chrome gestures, cursor, pointer-lock, and touch lifecycles), `region_capture/runtime.rs` (region generations, active/review/window-snap state, and query/preview workers), `acquisition.rs` (screen acquisition, zoom waiters, and eyedropper source correlation), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `preference_stores.rs` (persistence stores and workers), `ui_animation.rs` (animation scheduling), `font_catalog.rs` (font-catalog prewarm), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). - `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns output/fullscreen/layer placement and frozen-fullscreen transitions. -- `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns cache, profile baseline, and per-effect damage history. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. +- `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns the canvas layer cache, explicit drawing caches, profile baseline, and per-effect damage history. Direct canvas rendering, layer baking, previews, and board thumbnails borrow that drawing owner in short scopes. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. ## Invariants - Preserve snapshot boundaries for export and session actions. diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index ce3aef0f8..c59a3f58c 100644 --- a/src/backend/wayland/state/canvas_layer.rs +++ b/src/backend/wayland/state/canvas_layer.rs @@ -92,14 +92,14 @@ impl CanvasLayerCache { /// Renders one committed shape with the standard eraser/blur replay handling. /// Shared between the direct canvas render path and the layer-cache bake. pub(in crate::backend::wayland) fn render_committed_shape( - ctx: &cairo::Context, + render: &mut crate::draw::RenderCtx<'_, '_>, drawn_shape: &crate::draw::DrawnShape, replay_ctx: &crate::draw::EraserReplayContext<'_>, text_halo_enabled: bool, ) { match &drawn_shape.shape { crate::draw::Shape::EraserStroke { points, brush } => { - crate::draw::render_eraser_stroke(ctx, points, brush, replay_ctx); + crate::draw::render_eraser_stroke(render.cairo, points, brush, replay_ctx); } crate::draw::Shape::BlurRect { x, @@ -109,8 +109,7 @@ pub(in crate::backend::wayland) fn render_committed_shape( strength, style, } => { - crate::draw::render_blur_rect( - ctx, + render.render_blur_rect( crate::draw::BlurRectParams { x: *x, y: *y, @@ -124,7 +123,7 @@ pub(in crate::backend::wayland) fn render_committed_shape( ); } other => { - crate::draw::render_shape_with_halo(ctx, other, text_halo_enabled); + render.render_shape_with_halo(other, text_halo_enabled); } } } @@ -154,16 +153,10 @@ impl WaylandState { height: u32, scale: i32, ) -> bool { - let scale = scale.max(1); - let (origin_x, origin_y) = self.canvas_view_origin(); - let view_x = origin_x.floor() as i32; - let view_y = origin_y.floor() as i32; - let logical_w = width.min(i32::MAX as u32) as i32; - let logical_h = height.min(i32::MAX as u32) as i32; - if logical_w <= 0 || logical_h <= 0 { + let origin = self.canvas_view_origin(); + if width == 0 || height == 0 { return false; } - let background = match self.input_state.boards.active_background() { crate::input::BoardBackground::Solid(color) => Some(*color), crate::input::BoardBackground::Transparent => None, @@ -175,10 +168,65 @@ impl WaylandState { ); let generation = self.input_state.canvas_content_generation(); let frame = self.input_state.boards.active_frame(); - let shapes_len = frame.shapes.len(); - let last_shape_id = frame.shapes.last().map(|shape| shape.id); + let (cache, draw_caches) = self.render.canvas_draw_parts_mut(); + cache.ensure( + draw_caches, + &frame.shapes, + CanvasLayerInputs { + width, + height, + scale, + origin, + background, + text_halo_enabled, + board_key, + generation, + }, + ) + } +} - let cache = self.render.canvas_layer_cache(); +#[derive(Clone, Copy)] +pub(super) struct CanvasLayerInputs { + pub(super) width: u32, + pub(super) height: u32, + pub(super) scale: i32, + pub(super) origin: (f64, f64), + pub(super) background: Option, + pub(super) text_halo_enabled: bool, + pub(super) board_key: (usize, usize), + pub(super) generation: u64, +} + +impl CanvasLayerCache { + pub(super) fn ensure( + &mut self, + draw_caches: &mut crate::draw::RenderCaches, + shapes: &[crate::draw::DrawnShape], + inputs: CanvasLayerInputs, + ) -> bool { + let CanvasLayerInputs { + width, + height, + scale, + origin, + background, + text_halo_enabled, + board_key, + generation, + } = inputs; + let scale = scale.max(1); + let (origin_x, origin_y) = origin; + let view_x = origin_x.floor() as i32; + let view_y = origin_y.floor() as i32; + let logical_w = width.min(i32::MAX as u32) as i32; + let logical_h = height.min(i32::MAX as u32) as i32; + if logical_w <= 0 || logical_h <= 0 { + return false; + } + let shapes_len = shapes.len(); + let last_shape_id = shapes.last().map(|shape| shape.id); + let cache = self; let params_match = cache.valid && cache.surface.is_some() && cache.scale == scale @@ -204,37 +252,35 @@ impl WaylandState { let phys_w = bake_w.saturating_mul(scale); let phys_h = bake_h.saturating_mul(scale); if phys_w <= 0 || phys_h <= 0 || phys_w > CAIRO_MAX_DIM || phys_h > CAIRO_MAX_DIM { - self.render.canvas_layer_cache_mut().clear(); + cache.clear(); return false; } if phys_w as usize * phys_h as usize * 4 > MAX_CACHE_BYTES { - self.render.canvas_layer_cache_mut().clear(); + cache.clear(); return false; } - let reuse_surface = self - .render - .canvas_layer_cache_mut() + let reuse_surface = cache .surface .as_ref() .is_some_and(|surface| surface.width() == phys_w && surface.height() == phys_h); if !reuse_surface { match cairo::ImageSurface::create(cairo::Format::ARgb32, phys_w, phys_h) { - Ok(surface) => self.render.canvas_layer_cache_mut().surface = Some(surface), + Ok(surface) => cache.surface = Some(surface), Err(err) => { debug!("canvas layer cache: surface allocation failed: {err}"); - self.render.canvas_layer_cache_mut().clear(); + cache.clear(); return false; } } } { - let Some(surface) = self.render.canvas_layer_cache_mut().surface.as_ref() else { + let Some(surface) = cache.surface.as_ref() else { return false; }; let Ok(bake_ctx) = cairo::Context::new(surface) else { - self.render.canvas_layer_cache_mut().clear(); + cache.clear(); return false; }; @@ -267,20 +313,27 @@ impl WaylandState { width: bake_w, height: bake_h, }; - let frame = self.input_state.boards.active_frame(); - for drawn_shape in &frame.shapes { + let mut render = crate::draw::RenderCtx { + cairo: &bake_ctx, + caches: draw_caches, + }; + for drawn_shape in shapes { if let Some(bbox) = drawn_shape.bounding_box() && rects_intersect(bbox, bake_bounds) { - render_committed_shape(&bake_ctx, drawn_shape, &replay_ctx, text_halo_enabled); + render_committed_shape( + &mut render, + drawn_shape, + &replay_ctx, + text_halo_enabled, + ); } } } - if let Some(surface) = self.render.canvas_layer_cache_mut().surface.as_ref() { + if let Some(surface) = cache.surface.as_ref() { surface.flush(); } - let cache = self.render.canvas_layer_cache_mut(); cache.world_x = world_x; cache.world_y = world_y; cache.width = bake_w; diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 342555f0b..23058abac 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -153,7 +153,11 @@ impl WaylandState { let replay_ctx = eraser_ctx.replay_context(); let completed_shapes_start = perf.as_ref().map(|_| Instant::now()); - self.render_committed_canvas_shapes( + let (layer_cache, draw_caches) = self.render.canvas_draw_parts_mut(); + render_committed_canvas_shapes( + &self.input_state.boards.active_frame().shapes, + layer_cache, + draw_caches, canvas, layer_cache_ready, &replay_ctx, @@ -283,13 +287,14 @@ impl WaylandState { let provisional = self.input_state.provisional_tool_stroke(mx, my); let provisional_points = provisional_point_count(&provisional); let provisional_start = perf.as_ref().map(|_| Instant::now()); + let mut render = crate::draw::RenderCtx::new(ctx, self.render.draw_caches_mut()); let rendered_provisional = match provisional { crate::input::tool::ProvisionalToolStroke::BlurReplayPreview(params) => { - crate::draw::render_blur_rect(ctx, params, &replay_ctx); + render.render_blur_rect(params, &replay_ctx); true } _ => self.input_state.render_provisional_shape_for_damage( - ctx, + &mut render, mx, my, damage_world, @@ -323,71 +328,76 @@ impl WaylandState { Ok(()) } +} - fn render_committed_canvas_shapes( - &self, - canvas: &CanvasRenderCtx<'_>, - layer_cache_ready: bool, - replay_ctx: &crate::draw::EraserReplayContext<'_>, - mut perf: Option<&mut PerfRenderBreakdown>, - ) { - let ctx = canvas.cairo; - let width = canvas.geometry.width; - let height = canvas.geometry.height; - let damage_world = canvas.damage_world; - let canvas_transform_active = canvas.canvas.transform_active; - let text_halo_enabled = canvas.canvas.text_halo_enabled; - let shapes = &self.input_state.boards.active_frame().shapes; - if layer_cache_ready && self.render.canvas_layer_cache().blit(ctx) { - debug!("Rendered committed shapes from layer cache"); - if let Some(perf) = perf.as_mut() { - perf.shapes_total = shapes.len(); - perf.canvas_layer_cache_used = true; - } - return; - } - debug!("Rendering {} completed shapes", shapes.len()); +fn render_committed_canvas_shapes( + shapes: &[crate::draw::DrawnShape], + layer_cache: &super::super::canvas_layer::CanvasLayerCache, + draw_caches: &mut crate::draw::RenderCaches, + canvas: &CanvasRenderCtx<'_>, + layer_cache_ready: bool, + replay_ctx: &crate::draw::EraserReplayContext<'_>, + mut perf: Option<&mut PerfRenderBreakdown>, +) { + let ctx = canvas.cairo; + let width = canvas.geometry.width; + let height = canvas.geometry.height; + let damage_world = canvas.damage_world; + let canvas_transform_active = canvas.canvas.transform_active; + let text_halo_enabled = canvas.canvas.text_halo_enabled; + if layer_cache_ready && layer_cache.blit(ctx) { + debug!("Rendered committed shapes from layer cache"); if let Some(perf) = perf.as_mut() { perf.shapes_total = shapes.len(); + perf.canvas_layer_cache_used = true; } - let render_shape = |shape: &crate::draw::DrawnShape| { - super::super::canvas_layer::render_committed_shape( - ctx, - shape, - replay_ctx, - text_halo_enabled, - ) - }; - let Some(bounds) = union_damage_bounds(damage_world) else { - for shape in shapes { - render_shape(shape); - } - if let Some(perf) = perf.as_mut() { - perf.shapes_tested = shapes.len(); - perf.shapes_rendered = shapes.len(); - } - return; - }; - let Some(safe_bounds) = - safe_shape_damage_bounds(bounds, width, height, canvas_transform_active) - else { - return; - }; - let mut shapes_rendered = 0usize; + return; + } + debug!("Rendering {} completed shapes", shapes.len()); + if let Some(perf) = perf.as_mut() { + perf.shapes_total = shapes.len(); + } + let mut render = crate::draw::RenderCtx { + cairo: ctx, + caches: draw_caches, + }; + let mut render_shape = |shape: &crate::draw::DrawnShape| { + super::super::canvas_layer::render_committed_shape( + &mut render, + shape, + replay_ctx, + text_halo_enabled, + ) + }; + let Some(bounds) = union_damage_bounds(damage_world) else { for shape in shapes { - if shape - .bounding_box() - .is_some_and(|bounds| rects_intersect(bounds, safe_bounds)) - { - render_shape(shape); - shapes_rendered += 1; - } + render_shape(shape); } if let Some(perf) = perf.as_mut() { perf.shapes_tested = shapes.len(); - perf.shapes_rendered = shapes_rendered; + perf.shapes_rendered = shapes.len(); + } + return; + }; + let Some(safe_bounds) = + safe_shape_damage_bounds(bounds, width, height, canvas_transform_active) + else { + return; + }; + let mut shapes_rendered = 0usize; + for shape in shapes { + if shape + .bounding_box() + .is_some_and(|bounds| rects_intersect(bounds, safe_bounds)) + { + render_shape(shape); + shapes_rendered += 1; } } + if let Some(perf) = perf.as_mut() { + perf.shapes_tested = shapes.len(); + perf.shapes_rendered = shapes_rendered; + } } fn union_damage_bounds(regions: &[crate::util::Rect]) -> Option { @@ -491,3 +501,6 @@ mod tests { assert!(rects_intersect(Rect::new(9, 10, 2, 5).unwrap(), damage)); } } + +#[cfg(test)] +mod resource_tests; diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs new file mode 100644 index 000000000..90bf24e55 --- /dev/null +++ b/src/backend/wayland/state/render/canvas/resource_tests.rs @@ -0,0 +1,329 @@ +use super::*; +use crate::backend::wayland::state::canvas_layer::{CanvasLayerCache, CanvasLayerInputs}; +use crate::backend::wayland::state::render::plan::{CanvasFrame, FrameGeometry}; +use crate::draw::{Color, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, Shape}; + +fn inputs() -> CanvasLayerInputs { + CanvasLayerInputs { + width: 80, + height: 64, + scale: 1, + origin: (0.0, 0.0), + background: Some(Color { + r: 0.1, + g: 0.2, + b: 0.3, + a: 1.0, + }), + text_halo_enabled: true, + board_key: (0, 0), + generation: 1, + } +} + +fn shapes() -> Vec { + let mut bytes = std::io::Cursor::new(Vec::new()); + let source = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let context = cairo::Context::new(&source).unwrap(); + context.set_source_rgb(220.0 / 255.0, 40.0 / 255.0, 20.0 / 255.0); + context.paint().unwrap(); + source.write_to_png(&mut bytes).unwrap(); + [ + Shape::Image { + x: 5, + y: 5, + w: 28, + h: 28, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: bytes.into_inner().into(), + }, + }, + Shape::Text { + x: 8, + y: 52, + text: "Cache".into(), + color: Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + size: 14.0, + font_descriptor: crate::draw::FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }, + Shape::EraserStroke { + points: vec![(6, 16), (31, 16)], + brush: EraserBrush { + size: 6.0, + kind: EraserKind::Circle, + }, + }, + ] + .into_iter() + .enumerate() + .map(|(id, shape)| DrawnShape::with_metadata(id as u64, shape, 0, false)) + .collect() +} + +fn paint( + shapes: &[DrawnShape], + layer: &CanvasLayerCache, + caches: &mut crate::draw::RenderCaches, + inputs: CanvasLayerInputs, + cached: bool, +) -> Vec { + let geometry = FrameGeometry::new(inputs.width, inputs.height, inputs.scale); + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + geometry.physical_width as i32, + geometry.physical_height as i32, + ) + .unwrap(); + { + let cairo = cairo::Context::new(&surface).unwrap(); + if let Some(color) = inputs.background { + cairo.set_source_rgba(color.r, color.g, color.b, color.a); + cairo.paint().unwrap(); + } + cairo.scale(inputs.scale as f64, inputs.scale as f64); + cairo.translate(-inputs.origin.0, -inputs.origin.1); + let frame = CanvasFrame { + draw_committed: true, + render_transients: false, + transform_active: true, + origin: inputs.origin, + zoom_scale: None, + text_halo_enabled: inputs.text_halo_enabled, + layer_cache_eligible: true, + }; + let canvas = CanvasRenderCtx { + cairo: &cairo, + geometry: &geometry, + canvas: &frame, + damage_world: &[], + now: Instant::now(), + }; + let replay = crate::draw::EraserReplayContext { + pattern: None, + surface: None, + backdrop_cache_key: None, + bg_color: inputs.background, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + }; + render_committed_canvas_shapes(shapes, layer, caches, &canvas, cached, &replay, None); + } + surface.flush(); + surface.data().unwrap().to_vec() +} + +fn assert_pixels_match(actual: &[u8], expected: &[u8], label: &str) { + assert_eq!(actual.len(), expected.len(), "{label}: buffer length"); + if let Some((index, (actual, expected))) = actual + .iter() + .zip(expected) + .enumerate() + .find(|(_, (a, b))| a != b) + { + panic!("{label}: first difference at byte {index}: actual={actual}, expected={expected}"); + } +} + +fn fresh_baked(shapes: &[DrawnShape], request: CanvasLayerInputs) -> Vec { + let mut layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + assert!(layer.ensure(&mut caches, shapes, request)); + paint(shapes, &layer, &mut caches, request, true) +} + +#[test] +fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { + let mut layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let mut shapes = shapes(); + let initial = inputs(); + for (iteration, request) in [ + initial, + initial, + CanvasLayerInputs { + origin: (12.0, 8.0), + ..initial + }, + CanvasLayerInputs { + scale: 2, + ..initial + }, + CanvasLayerInputs { + board_key: (1, 1), + ..initial + }, + CanvasLayerInputs { + generation: 2, + ..initial + }, + CanvasLayerInputs { + origin: (600.0, 0.0), + ..initial + }, + ] + .into_iter() + .enumerate() + { + if request.generation == 2 { + shapes[0].set_shape(Shape::Rect { + x: 3, + y: 3, + w: 30, + h: 20, + color: Color { + r: 0.0, + g: 0.8, + b: 0.2, + a: 1.0, + }, + thick: 2.0, + fill: true, + }); + } + assert!(layer.ensure(&mut caches, &shapes, request)); + let baked = paint(&shapes, &layer, &mut caches, request, true); + let direct = paint(&shapes, &layer, &mut caches, request, false); + // Direct eraser edges retain partial alpha; a baked surface is later + // composited over the background. Compare each established rendering + // route to itself with fresh resources, not to the other route. + assert_pixels_match( + &baked, + &fresh_baked(&shapes, request), + &format!("baked pass {iteration}"), + ); + let mut fresh = crate::draw::RenderCaches::default(); + let expected_direct = paint( + &shapes, + &CanvasLayerCache::new(), + &mut fresh, + request, + false, + ); + assert_pixels_match( + &direct, + &expected_direct, + &format!("direct pass {iteration}"), + ); + } +} + +#[test] +fn rejected_bake_clears_previous_layer_and_direct_fallback_still_paints() { + let mut layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let shapes = shapes(); + let request = inputs(); + assert!(layer.ensure(&mut caches, &shapes, request)); + assert!(!layer.ensure( + &mut caches, + &shapes, + CanvasLayerInputs { + width: 40_000, + ..request + } + )); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); + assert!(!layer.blit(&cairo::Context::new(&surface).unwrap())); + assert_pixels_match( + &paint(&shapes, &layer, &mut caches, request, true), + &paint(&shapes, &layer, &mut caches, request, false), + "invalid layer falls back to direct rendering", + ); +} + +#[test] +fn each_scene_key_rebakes_without_shape_identity_changes() { + let initial = inputs(); + for (name, changed, replace_image) in [ + ( + "generation", + CanvasLayerInputs { + generation: 2, + ..initial + }, + true, + ), + ( + "board", + CanvasLayerInputs { + board_key: (1, 0), + ..initial + }, + true, + ), + ( + "page", + CanvasLayerInputs { + board_key: (0, 1), + ..initial + }, + true, + ), + ( + "background", + CanvasLayerInputs { + background: Some(Color { + r: 0.8, + g: 0.2, + b: 0.1, + a: 1.0, + }), + ..initial + }, + false, + ), + ( + "halo", + CanvasLayerInputs { + text_halo_enabled: false, + ..initial + }, + false, + ), + ] { + let mut layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let mut scene = shapes(); + assert!(layer.ensure(&mut caches, &scene, initial)); + let before = paint(&scene, &layer, &mut caches, initial, true); + if replace_image { + // Shape count and IDs remain unchanged; only the scene key can + // invalidate the already baked pixels for this different scene. + scene[0].set_shape(Shape::Rect { + x: 3, + y: 3, + w: 30, + h: 20, + color: Color { + r: 0.0, + g: 0.8, + b: 0.2, + a: 1.0, + }, + thick: 2.0, + fill: true, + }); + } + assert!(layer.ensure(&mut caches, &scene, changed)); + let actual = paint(&scene, &layer, &mut caches, changed, true); + let expected = fresh_baked(&scene, changed); + assert!(before != expected, "fixture must change pixels for {name}"); + assert_pixels_match( + &actual, + &expected, + &format!("stale layer after {name} changed"), + ); + } +} diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index f036a7a2a..a1512bdc0 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -119,6 +119,7 @@ impl UiDamageHistory { pub(in crate::backend::wayland) struct RenderRuntime { canvas_layer_cache: CanvasLayerCache, + draw_caches: crate::draw::RenderCaches, ui_damage: UiDamageHistory, profile_ui_baseline: Vec, } @@ -127,21 +128,30 @@ impl RenderRuntime { pub(in crate::backend::wayland) fn new() -> Self { Self { canvas_layer_cache: CanvasLayerCache::new(), + draw_caches: crate::draw::RenderCaches::default(), ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), } } - pub(in crate::backend::wayland::state) fn canvas_layer_cache(&self) -> &CanvasLayerCache { - &self.canvas_layer_cache - } - pub(in crate::backend::wayland::state) fn canvas_layer_cache_mut( &mut self, ) -> &mut CanvasLayerCache { &mut self.canvas_layer_cache } + pub(in crate::backend::wayland::state) fn draw_caches_mut( + &mut self, + ) -> &mut crate::draw::RenderCaches { + &mut self.draw_caches + } + + pub(in crate::backend::wayland::state) fn canvas_draw_parts_mut( + &mut self, + ) -> (&mut CanvasLayerCache, &mut crate::draw::RenderCaches) { + (&mut self.canvas_layer_cache, &mut self.draw_caches) + } + pub(in crate::backend::wayland::state) fn ui_damage_mut(&mut self) -> &mut UiDamageHistory { &mut self.ui_damage } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 8f284b813..8cecb0386 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -215,8 +215,9 @@ impl WaylandState { if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); + let mut render = crate::draw::RenderCtx::new(ctx, self.render.draw_caches_mut()); crate::ui::render_board_picker_with_halo( - ctx, + &mut render, &self.input_state, width, height, diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index 0d9df2236..1d78c0a4f 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +#[path = "tests/cache_tests.rs"] +mod cache_tests; mod page; mod pdf; mod pdf_labels; @@ -190,7 +193,12 @@ mod tests { cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).expect("surface"); { let ctx = cairo::Context::new(&surface).expect("context"); - draw_canvas_page(&ctx, &page_snapshot(frame), 2.0).expect("draw"); + draw_canvas_page( + &mut crate::draw::RenderCtx::new(&ctx, &mut crate::draw::RenderCaches::default()), + &page_snapshot(frame), + 2.0, + ) + .expect("draw"); } assert_ne!(pixel(&mut surface, 9, 9), 0); diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index 8140ab7fb..cda9153d9 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -2,10 +2,9 @@ use std::sync::Arc; use crate::capture::CaptureError; use crate::draw::{ - BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightMagnifierOutcome, - SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, render_blur_rect, - render_eraser_stroke, render_shape_over_with_halo, render_spotlight_magnification_pass, - render_spotlight_pass, spotlight_regions_for_frame, + BlurRectParams, Color, EraserReplayContext, Frame, RenderCtx, Shape, SpotlightMagnifierOutcome, + SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, render_eraser_stroke, + render_spotlight_magnification_pass, render_spotlight_pass, spotlight_regions_for_frame, }; use crate::screen_pixels::ScreenImage; @@ -98,10 +97,11 @@ impl CanvasExportRect { } pub fn draw_canvas_page( - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, output_scale: f64, ) -> Result<(), CaptureError> { + let ctx = render.cairo; let backdrop = ExportBackdrop::new(&page.backdrop)?; let source = CanvasExportRect { x: page.origin_x as f64, @@ -125,7 +125,7 @@ pub fn draw_canvas_page( (f64::from(page.viewport_height) * output_scale).ceil() as u32, ); let rendered = draw_canvas_page_region( - ctx, + render, page, &backdrop, source, @@ -138,7 +138,7 @@ pub fn draw_canvas_page( } pub(crate) fn draw_canvas_page_region( - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, source: CanvasExportRect, @@ -146,6 +146,7 @@ pub(crate) fn draw_canvas_page_region( paint_backdrop: bool, fallback_target_size: Option<(u32, u32)>, ) -> Result<(), CaptureError> { + let ctx = render.cairo; let _ = ctx.save(); ctx.rectangle( destination.x, @@ -161,7 +162,7 @@ pub(crate) fn draw_canvas_page_region( ); ctx.translate(-source.x, -source.y); let rendered = - draw_canvas_page_contents(ctx, page, backdrop, paint_backdrop, fallback_target_size); + draw_canvas_page_contents(render, page, backdrop, paint_backdrop, fallback_target_size); let _ = ctx.restore(); rendered } @@ -380,12 +381,13 @@ impl ExportBackdrop { } fn draw_canvas_page_contents( - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, paint_backdrop: bool, fallback_target_size: Option<(u32, u32)>, ) -> Result<(), CaptureError> { + let ctx = render.cairo; if paint_backdrop { backdrop.paint(ctx); } @@ -408,8 +410,7 @@ fn draw_canvas_page_contents( h, strength, style, - } => render_blur_rect( - ctx, + } => render.render_blur_rect( BlurRectParams { x: *x, y: *y, @@ -421,8 +422,7 @@ fn draw_canvas_page_contents( }, &replay_ctx, ), - other => render_shape_over_with_halo( - ctx, + other => render.render_shape_over_with_halo( other, known_background_luminance, page.text_halo_enabled, diff --git a/src/canvas_export/pdf.rs b/src/canvas_export/pdf.rs index 6bbc1f647..2c14df4d5 100644 --- a/src/canvas_export/pdf.rs +++ b/src/canvas_export/pdf.rs @@ -1,5 +1,6 @@ use crate::capture::CaptureError; use crate::config::{PdfExportConfig, PdfFitMode, PdfOrientation, PdfPageSize}; +use crate::draw::{RenderCaches, RenderCtx}; use super::page::{ CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, ExportBackdrop, @@ -142,6 +143,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca let ctx = cairo::Context::new(&surface) .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF context: {err}")))?; + let mut caches = RenderCaches::default(); for page in &snapshot.pages { let layout = page.layout; validate_page_size(layout.page_width, layout.page_height)?; @@ -153,14 +155,19 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca paint_pdf_page_background(&ctx, &page.page, layout.page_width, layout.page_height); let backdrop = ExportBackdrop::new(&page.page.backdrop)?; if frame_has_magnified_spotlight(&page.page.frame) { - render_magnified_page_raster(&ctx, &page.page, &backdrop, layout)?; + render_magnified_page_raster( + &mut RenderCtx::new(&ctx, &mut caches), + &page.page, + &backdrop, + layout, + )?; } else { let paint_content_backdrop = matches!( page.page.backdrop, CanvasExportBackdropSnapshot::PersistedImage { .. } ); draw_canvas_page_region( - &ctx, + &mut RenderCtx::new(&ctx, &mut caches), &page.page, &backdrop, layout.source_rect, @@ -194,11 +201,12 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca } fn render_magnified_page_raster( - pdf_ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, layout: PdfPageLayout, ) -> Result<(), CaptureError> { + let pdf_ctx = render.cairo; let (width, height) = checked_raster_dimensions(layout.source_rect)?; let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).map_err(|err| { @@ -210,7 +218,7 @@ fn render_magnified_page_raster( )) })?; draw_canvas_page_region( - &raster_ctx, + &mut RenderCtx::new(&raster_ctx, render.caches), page, backdrop, layout.source_rect, diff --git a/src/canvas_export/png.rs b/src/canvas_export/png.rs index 274ab994b..19c02e68a 100644 --- a/src/canvas_export/png.rs +++ b/src/canvas_export/png.rs @@ -1,5 +1,5 @@ use crate::capture::{CaptureError, ImageFormatMetadata, RenderedImage}; -use crate::draw::Frame; +use crate::draw::{Frame, RenderCaches, RenderCtx}; use crate::render_profiles::RenderColorProfile; use crate::util::Rect; @@ -77,6 +77,7 @@ pub(crate) fn encode_surface_png( pub(crate) fn render_canvas_surface( snapshot: &CanvasExportSnapshot, ) -> Result { + let mut caches = RenderCaches::default(); let viewport = snapshot.viewport; let scale = viewport.scale.max(1); let physical_width = viewport.logical_width.saturating_mul(scale as u32); @@ -99,7 +100,7 @@ pub(crate) fn render_canvas_surface( })?; let page = canvas_page_from_snapshot(snapshot); - draw_canvas_page(&ctx, &page, scale as f64)?; + draw_canvas_page(&mut RenderCtx::new(&ctx, &mut caches), &page, scale as f64)?; } if let Some(profile) = snapshot.render_profile.as_ref() { diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index 4343525d9..2f67f6751 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use crate::draw::{RenderCaches, RenderCtx}; + use crate::canvas_export::page::{ CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, ExportBackdrop, SpotlightPassSnapshot, draw_canvas_page_region, @@ -160,6 +162,7 @@ fn packed_argb32_from_surface( pub(crate) fn render_canvas_region_pixels( snapshot: CanvasRegionExportSnapshot, ) -> Result { + let mut caches = RenderCaches::default(); let working_selection = snapshot .source .magnifier_working_selection(snapshot.selection, &snapshot.frame) @@ -209,7 +212,7 @@ pub(crate) fn render_canvas_region_pixels( ) .expect("validated non-empty destination"); draw_canvas_page_region( - &ctx, + &mut RenderCtx::new(&ctx, &mut caches), &page, &backdrop, working_source_rect, diff --git a/src/canvas_export/tests/cache_tests.rs b/src/canvas_export/tests/cache_tests.rs new file mode 100644 index 000000000..5607a544c --- /dev/null +++ b/src/canvas_export/tests/cache_tests.rs @@ -0,0 +1,327 @@ +use std::sync::Arc; + +use super::page::{CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, draw_canvas_page}; +use crate::draw::{BlurStyle, EmbeddedImage, Frame, RenderCaches, RenderCtx, Shape}; + +fn page(frame: Frame, backdrop: CanvasExportBackdropSnapshot) -> CanvasPageExportSnapshot { + CanvasPageExportSnapshot { + frame, + backdrop, + viewport_width: 20, + viewport_height: 20, + origin_x: 0, + origin_y: 0, + text_halo_enabled: true, + spotlight: Default::default(), + } +} + +fn pixels(page: &CanvasPageExportSnapshot, caches: &mut RenderCaches) -> Vec { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).unwrap(); + { + let cairo = cairo::Context::new(&surface).unwrap(); + draw_canvas_page(&mut RenderCtx::new(&cairo, caches), page, 1.0).unwrap(); + } + surface.flush(); + surface.data().unwrap().to_vec() +} + +#[test] +fn page_owner_reuses_embedded_images_and_releases_them_after_the_job() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let cairo = cairo::Context::new(&surface).unwrap(); + cairo.set_source_rgb(1.0, 0.0, 0.0); + cairo.paint().unwrap(); + let mut png = Vec::new(); + surface.write_to_png(&mut png).unwrap(); + let bytes: Arc<[u8]> = png.into(); + let mut frame = Frame::new(); + frame.add_shape(Shape::Image { + x: 2, + y: 2, + w: 8, + h: 8, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: Arc::clone(&bytes), + }, + }); + let page = page(frame, CanvasExportBackdropSnapshot::Transparent); + let baseline = Arc::strong_count(&bytes); + let mut caches = RenderCaches::default(); + let first = pixels(&page, &mut caches); + let retained = Arc::strong_count(&bytes); + assert!( + retained > baseline, + "page rendering must retain decoded image resources in its owner" + ); + assert!( + first.iter().any(|value| *value != 0), + "image must be painted" + ); + assert_eq!(pixels(&page, &mut caches), first); + assert_eq!( + Arc::strong_count(&bytes), + retained, + "another page must reuse the same image entry" + ); + drop(caches); + assert_eq!(Arc::strong_count(&bytes), baseline); +} + +#[test] +fn shared_export_owner_never_aliases_page_backdrops_with_identical_blur_geometry() { + let mut frame = Frame::new(); + frame.add_shape(Shape::BlurRect { + x: 2, + y: 2, + w: 16, + h: 16, + strength: 8.0, + style: BlurStyle::Gaussian, + }); + let make_page = |color: u32| { + page( + frame.clone_without_history(), + CanvasExportBackdropSnapshot::PersistedImage { + data: Arc::from(color.to_ne_bytes().repeat(20 * 20)), + width: 20, + height: 20, + stride: 80, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }, + ) + }; + let red = make_page(0xffff0000); + let blue = make_page(0xff0000ff); + let mut caches = RenderCaches::default(); + let red_pixels = pixels(&red, &mut caches); + let blue_pixels = pixels(&blue, &mut caches); + assert_ne!(red_pixels, blue_pixels); + assert_eq!(blue_pixels, pixels(&blue, &mut RenderCaches::default())); + assert_eq!(red_pixels, pixels(&red, &mut caches)); +} + +#[test] +fn export_snapshots_remain_sendable_to_workers() { + fn assert_send() {} + assert_send::(); + assert_send::(); + assert_send::(); +} + +#[test] +fn pdf_pages_keep_distinct_blurred_backdrops_when_rasterized() { + use super::{ + BoardPdfExportSnapshot, PdfPageExportSnapshot, PdfPageMetadata, render_board_pdf, + resolve_pdf_page_layout, + }; + use std::process::Command; + if Command::new("pdftoppm").arg("-v").output().is_err() { + return; + } + let mut frame = Frame::new(); + frame.add_shape(Shape::BlurRect { + x: 2, + y: 2, + w: 16, + h: 16, + strength: 8.0, + style: BlurStyle::Gaussian, + }); + // Each page holds the same encoded allocation, exercising PDF image reuse. + let image = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let image_ctx = cairo::Context::new(&image).unwrap(); + image_ctx.set_source_rgb(0.0, 1.0, 0.0); + image_ctx.paint().unwrap(); + let mut encoded = Vec::new(); + image.write_to_png(&mut encoded).unwrap(); + frame.add_shape(Shape::Image { + x: 0, + y: 0, + w: 2, + h: 2, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: encoded.into(), + }, + }); + let layout = resolve_pdf_page_layout(20, 20, 0, 0, None, &Default::default()).unwrap(); + let pages = [0xffff0000u32, 0xff0000ffu32] + .into_iter() + .enumerate() + .map(|(index, color)| PdfPageExportSnapshot { + page: page( + frame.clone_without_history(), + CanvasExportBackdropSnapshot::PersistedImage { + data: Arc::from(color.to_ne_bytes().repeat(20 * 20)), + width: 20, + height: 20, + stride: 80, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }, + ), + layout, + metadata: PdfPageMetadata::new( + 0, + 1, + index, + 2, + index, + 2, + index, + 2, + "Board".into(), + None, + ), + }) + .collect(); + let pdf = render_board_pdf(&BoardPdfExportSnapshot { + pages, + labels: Default::default(), + }) + .unwrap(); + let temp = crate::test_temp::tempdir().unwrap(); + let path = temp.path().join("pages.pdf"); + std::fs::write(&path, pdf).unwrap(); + let prefix = temp.path().join("page"); + let result = Command::new("pdftoppm") + .args(["-png", "-r", "72"]) + .arg(&path) + .arg(&prefix) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + for (index, expected) in [(1, 0xffff0000u32), (2, 0xff0000ffu32)] { + let mut file = std::fs::File::open(temp.path().join(format!("page-{index}.png"))).unwrap(); + let mut image = cairo::ImageSurface::create_from_png(&mut file).unwrap(); + let offset = 10 * image.stride() as usize + 10 * 4; + let data = image.data().unwrap(); + let actual = u32::from_ne_bytes(data[offset..offset + 4].try_into().unwrap()); + assert_eq!( + u32::from_ne_bytes(data[0..4].try_into().unwrap()), + 0xff00ff00, + "both pages must retain the repeated image" + ); + assert_eq!( + actual, expected, + "page {index} blur must use its own backdrop" + ); + } +} + +#[test] +fn png_and_region_entries_preserve_embedded_image_and_text_pixels() { + use super::{ + BoardExportSnapshot, CanvasExportRect, CanvasExportSnapshot, CanvasExportViewport, + CanvasRegionExportSnapshot, CanvasRegionSource, render_canvas_png, + render_canvas_region_pixels, + }; + use crate::screen_pixels::{ImagePixelRect, ScreenImage}; + let image = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let ctx = cairo::Context::new(&image).unwrap(); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.paint().unwrap(); + let mut encoded = Vec::new(); + image.write_to_png(&mut encoded).unwrap(); + let mut frame = Frame::new(); + frame.add_shape(Shape::Image { + x: 14, + y: 24, + w: 20, + h: 20, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: encoded.into(), + }, + }); + frame.add_shape(Shape::Text { + x: 42, + y: 54, + text: "Cache".into(), + color: crate::draw::BLACK, + size: 24.0, + font_descriptor: Default::default(), + background_enabled: false, + wrap_width: None, + }); + let export = CanvasExportSnapshot { + viewport: CanvasExportViewport { + logical_width: 160, + logical_height: 64, + scale: 1, + origin_x: 10, + origin_y: 20, + }, + backdrop: CanvasExportBackdropSnapshot::Solid(crate::draw::WHITE), + board: BoardExportSnapshot { + frame: frame.clone_without_history(), + }, + render_profile: None, + text_halo_enabled: false, + spotlight: Default::default(), + }; + let png = render_canvas_png(&export).unwrap(); + assert_eq!((png.width, png.height), (160, 64)); + let mut decoded = cairo::ImageSurface::create_from_png(&mut png.bytes.as_slice()).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (160, 64)); + let stride = decoded.stride() as usize; + let png_pixels = decoded.data().unwrap().to_vec(); + let pixel = |x: usize, y: usize| { + u32::from_ne_bytes( + png_pixels[y * stride + x * 4..y * stride + x * 4 + 4] + .try_into() + .unwrap(), + ) + }; + assert_eq!( + pixel(12, 12), + 0xffff0000, + "PNG image location follows viewport origin" + ); + assert_eq!(pixel(150, 50), 0xffffffff, "background remains white"); + let dark_text_pixels = (8..40) + .flat_map(|y| (30..130).map(move |x| (x, y))) + .filter(|&(x, y)| pixel(x, y) & 0x00ffffff < 0x00404040) + .count(); + assert!( + dark_text_pixels > 50, + "PNG must contain visible text beside the image" + ); + let region = render_canvas_region_pixels(CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: Arc::new(ScreenImage { + data: 0xffffffffu32.to_ne_bytes().repeat(160 * 64), + width: 160, + height: 64, + stride: 160 * 4, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, 160.0, 64.0).unwrap(), + }, + selection: ImagePixelRect::new(0, 0, 160, 64, (160, 64)).unwrap(), + frame, + text_halo_enabled: false, + spotlight: Default::default(), + }) + .unwrap(); + assert_eq!((region.width(), region.height()), (160, 64)); + for y in 0..64usize { + assert_eq!( + &png_pixels[y * stride..y * stride + 640], + ®ion.data()[y * 640..(y + 1) * 640], + "PNG and region row {y}" + ); + } +} diff --git a/src/draw/AGENTS.md b/src/draw/AGENTS.md index 8d7532b37..92cd80070 100644 --- a/src/draw/AGENTS.md +++ b/src/draw/AGENTS.md @@ -8,11 +8,13 @@ - `frame/` owns frame storage, serialization, and undo/redo history. - `shape/` owns shape types, bounds, text cache, polygons, step markers, and labels. - `render/` owns Cairo/Pango rendering helpers. +- `render/context.rs` exposes `RenderCaches` for image/blur resources and the short-lived `RenderCtx` drawing borrow. Repeated rendering uses an explicit cache owner; public standalone drawing wrappers create local resources. Text measurement keeps its separate ownership and migration boundary. - `spotlight.rs` owns shared Spotlight magnification defaults, normalization, formatting, and Serde boundaries. ## Invariants - Keep this area mostly pure; rendering helpers should not mutate application state except intentional caches or Cairo surface/path operations. - Preserve serialization compatibility, undo/history invariants, canvas/page identity, and Cairo path isolation. +- Keep drawing resources out of shapes, frames, snapshots, and input state. Preserve image payload identity, cache budgets, blur source/style keys, and uncached export blur behavior. ## Coupled Changes - Drawing changes may affect input tools, selection behavior, canvas export, session snapshots, toolbar controls, config defaults, and tests. diff --git a/src/draw/mod.rs b/src/draw/mod.rs index f9fbb38dc..5f4756435 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -35,16 +35,16 @@ pub(crate) use render::render_sticky_note_preview; pub(crate) use render::with_saved_state; #[allow(unused_imports)] pub use render::{ - BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, - SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, - SpotlightRegion, SpotlightSnapshotStrategy, caret_line_width, caret_outline_width, - painted_background_luminance, perceived_luminance, render_blur_rect, render_board_background, - render_click_highlight, render_freehand_borrowed, render_marker_stroke_borrowed, - render_selection_halo, render_selection_handles, render_shape, render_shape_over, - render_shape_over_with_halo, render_shape_with_halo, render_spotlight_magnification_pass, - render_spotlight_pass, render_sticky_note, render_text, render_text_over_with_halo, - render_text_with_halo, selection_handle_rects, spotlight_regions_for_frame, - sticky_note_foreground, text_outline_color, + BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, RenderCaches, RenderCtx, + SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, + SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy, + caret_line_width, caret_outline_width, painted_background_luminance, perceived_luminance, + render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed, + render_marker_stroke_borrowed, render_selection_halo, render_selection_handles, render_shape, + render_shape_over, render_shape_over_with_halo, render_shape_with_halo, + render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, + render_text_over_with_halo, render_text_with_halo, selection_handle_rects, + spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; #[allow(unused_imports)] pub use shape::{ diff --git a/src/draw/render/blur.rs b/src/draw/render/blur.rs index 02e0b900a..3d8e29ad7 100644 --- a/src/draw/render/blur.rs +++ b/src/draw/render/blur.rs @@ -1,10 +1,7 @@ use super::types::EraserReplayContext; use crate::draw::shape::BlurStyle; use crate::util::normalize_i32_rect; -use std::{ - cell::RefCell, - collections::{HashMap, VecDeque}, -}; +use std::collections::{HashMap, VecDeque}; const PLACEHOLDER_FILL: (f64, f64, f64, f64) = (0.12, 0.15, 0.2, 0.82); const PLACEHOLDER_STROKE: (f64, f64, f64, f64) = (0.92, 0.94, 0.98, 0.35); @@ -85,7 +82,7 @@ struct CachedBlurRegion { approx_bytes: usize, } -struct BlurRenderCache { +pub(super) struct BlurRenderCache { entries: HashMap, access_order: VecDeque, max_entries: usize, @@ -144,10 +141,10 @@ impl BlurRenderCache { } } -thread_local! { - static BLUR_RENDER_CACHE: RefCell = RefCell::new( - BlurRenderCache::new(BLUR_CACHE_MAX_ENTRIES, BLUR_CACHE_MAX_BYTES) - ); +impl Default for BlurRenderCache { + fn default() -> Self { + Self::new(BLUR_CACHE_MAX_ENTRIES, BLUR_CACHE_MAX_BYTES) + } } fn blur_rect_geometry(x: i32, y: i32, w: i32, h: i32) -> (f64, f64, f64, f64) { @@ -433,18 +430,19 @@ fn build_blur_cache_key( } fn cacheable_blur_entry( + cache: &mut BlurRenderCache, cache_key: Option, compute: impl FnOnce() -> Option, ) -> Option { if let Some(key) = cache_key - && let Some(entry) = BLUR_RENDER_CACHE.with(|cache| cache.borrow_mut().get(&key)) + && let Some(entry) = cache.get(&key) { return Some(entry); } let entry = compute()?; if let Some(key) = cache_key { - return Some(BLUR_RENDER_CACHE.with(|cache| cache.borrow_mut().insert(key, entry))); + return Some(cache.insert(key, entry)); } Some(entry) } @@ -499,10 +497,22 @@ fn render_blur_region( }) } +/// Paints a blur rectangle using temporary drawing caches. Repeated painting +/// should use [`super::RenderCtx::render_blur_rect`] with a persistent owner. pub fn render_blur_rect( ctx: &cairo::Context, params: BlurRectParams, replay_ctx: &EraserReplayContext<'_>, +) { + super::RenderCtx::new(ctx, &mut super::RenderCaches::default()) + .render_blur_rect(params, replay_ctx); +} + +pub(super) fn render_blur_rect_with_cache( + cache: &mut BlurRenderCache, + ctx: &cairo::Context, + params: BlurRectParams, + replay_ctx: &EraserReplayContext<'_>, ) { let BlurRectParams { x, @@ -552,7 +562,7 @@ pub fn render_blur_rect( let cache_key = cacheable .then(|| build_blur_cache_key(replay_ctx, recipe, style, src_x, src_y, src_w, src_h)); let cache_key = cache_key.flatten(); - let Some(blurred) = cacheable_blur_entry(cache_key, || { + let Some(blurred) = cacheable_blur_entry(cache, cache_key, || { render_blur_region(surface, src_x, src_y, src_w, src_h, recipe, style) }) else { render_blur_placeholder(ctx, x, y, w, h, false); diff --git a/src/draw/render/blur/tests.rs b/src/draw/render/blur/tests.rs index afe580a7f..2c8a15860 100644 --- a/src/draw/render/blur/tests.rs +++ b/src/draw/render/blur/tests.rs @@ -184,3 +184,69 @@ fn blur_render_cache_does_not_retain_an_entry_larger_than_its_byte_budget() { assert_eq!(returned.surface.width(), 4); assert_eq!(returned.surface.height(), 4); } + +#[test] +fn blur_hit_promotes_entry_and_replacement_updates_byte_accounting() { + let mut cache = BlurRenderCache::new(2, 128); + let key = |backdrop_cache_key| BlurCacheKey { + backdrop_cache_key, + src_x: 0, + src_y: 0, + src_w: 4, + src_h: 4, + primary_factor: 18, + secondary_factor: 24, + style: BackdropStyle::Gaussian, + }; + let entry = |size| CachedBlurRegion { + surface: cairo::ImageSurface::create(cairo::Format::ARgb32, size, size).unwrap(), + stats: super::FALLBACK_BLUR_STATS, + approx_bytes: (size * size * 4) as usize, + }; + cache.insert(key(1), entry(4)); + cache.insert(key(2), entry(4)); + assert!(cache.get(&key(1)).is_some()); + cache.insert(key(3), entry(4)); + assert!(cache.get(&key(2)).is_none(), "hit promoted the first entry"); + assert!(cache.get(&key(1)).is_some()); + cache.insert(key(1), entry(2)); + assert_eq!(cache.cached_bytes, 80); + assert_eq!(cache.access_order.len(), 2); + assert_eq!(cache.get(&key(1)).unwrap().surface.width(), 2); + assert!(cache.get(&key(3)).is_some()); +} + +#[test] +fn cacheable_blur_entry_skips_compute_on_hit_and_computes_without_a_key() { + let mut cache = BlurRenderCache::default(); + let key = BlurCacheKey { + backdrop_cache_key: 1, + src_x: 0, + src_y: 0, + src_w: 4, + src_h: 4, + primary_factor: 18, + secondary_factor: 24, + style: BackdropStyle::Gaussian, + }; + let mut computations = 0; + let mut compute = || { + computations += 1; + Some(CachedBlurRegion { + surface: cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(), + stats: super::FALLBACK_BLUR_STATS, + approx_bytes: 64, + }) + }; + assert!(super::cacheable_blur_entry(&mut cache, Some(key), &mut compute).is_some()); + assert!( + super::cacheable_blur_entry(&mut cache, Some(key), || panic!("cache hit recomputed")) + .is_some() + ); + assert!(super::cacheable_blur_entry(&mut cache, None, &mut compute).is_some()); + assert!(super::cacheable_blur_entry(&mut cache, None, &mut compute).is_some()); + assert_eq!( + computations, 3, + "each unkeyed call must compute independently" + ); +} diff --git a/src/draw/render/context.rs b/src/draw/render/context.rs new file mode 100644 index 000000000..3eaeaa312 --- /dev/null +++ b/src/draw/render/context.rs @@ -0,0 +1,66 @@ +use super::blur::{BlurRenderCache, render_blur_rect_with_cache}; +use super::image::ImageSurfaceCache; +use super::{BlurRectParams, EraserReplayContext}; +use crate::draw::Shape; + +/// Decoded image and sampled backdrop resources for one rendering owner. +/// +/// Reuse across passes to retain cached surfaces. Independent backdrop generation +/// namespaces must use separate owners. Construction allocates no Cairo resources; +/// entries are populated on demand. Images retain at most 32 entries and 64 MiB +/// of decoded pixels; blur retains at most 8 entries and 64 MiB. +#[derive(Default)] +pub struct RenderCaches { + images: ImageSurfaceCache, + blur: BlurRenderCache, +} + +/// A short drawing pass borrowing its target and persistent resources. +pub struct RenderCtx<'c, 'r> { + pub cairo: &'c cairo::Context, + pub caches: &'r mut RenderCaches, +} + +impl<'c, 'r> RenderCtx<'c, 'r> { + pub fn new(cairo: &'c cairo::Context, caches: &'r mut RenderCaches) -> Self { + Self { cairo, caches } + } + + pub fn render_shape(&mut self, shape: &Shape) { + self.render_shape_with_halo(shape, true); + } + + pub fn render_shape_with_halo(&mut self, shape: &Shape, text_halo_enabled: bool) { + self.render_shape_over_with_halo(shape, None, text_halo_enabled); + } + + pub fn render_shape_over(&mut self, shape: &Shape, known_background_luminance: Option) { + self.render_shape_over_with_halo(shape, known_background_luminance, true); + } + + pub fn render_shape_over_with_halo( + &mut self, + shape: &Shape, + known_background_luminance: Option, + text_halo_enabled: bool, + ) { + super::shapes::render_shape_with_cache( + &mut self.caches.images, + self.cairo, + shape, + known_background_luminance, + text_halo_enabled, + ); + } + + pub fn render_blur_rect( + &mut self, + params: BlurRectParams, + replay_ctx: &EraserReplayContext<'_>, + ) { + render_blur_rect_with_cache(&mut self.caches.blur, self.cairo, params, replay_ctx); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/draw/render/context/tests.rs b/src/draw/render/context/tests.rs new file mode 100644 index 000000000..ad424dd80 --- /dev/null +++ b/src/draw/render/context/tests.rs @@ -0,0 +1,175 @@ +use super::{RenderCaches, RenderCtx}; +use crate::draw::{BlurRectParams, BlurStyle, EmbeddedImage, EraserReplayContext, Shape}; +use cairo::{Context, Format, ImageSurface}; +use std::sync::Arc; + +fn backdrop(red: bool) -> ImageSurface { + let surface = ImageSurface::create(Format::ARgb32, 64, 64).unwrap(); + let ctx = Context::new(&surface).unwrap(); + ctx.set_source_rgb( + if red { 0.9 } else { 0.1 }, + 0.2, + if red { 0.1 } else { 0.9 }, + ); + ctx.paint().unwrap(); + ctx.set_source_rgba(0.9, 0.8, 0.2, 0.7); + ctx.rectangle(8.0, 12.0, 20.0, 32.0); + ctx.fill().unwrap(); + surface +} + +fn render_blur( + caches: &mut RenderCaches, + backdrop: &ImageSurface, + generation: Option, + params: BlurRectParams, +) -> Vec { + let mut output = ImageSurface::create(Format::ARgb32, 64, 64).unwrap(); + let ctx = Context::new(&output).unwrap(); + let replay = EraserReplayContext { + pattern: None, + surface: Some(backdrop), + backdrop_cache_key: generation, + bg_color: None, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + }; + RenderCtx::new(&ctx, caches).render_blur_rect(params, &replay); + ctx.status().unwrap(); + drop(ctx); + output.data().unwrap().to_vec() +} + +fn params(style: BlurStyle) -> BlurRectParams { + BlurRectParams { + x: 4, + y: 4, + w: 52, + h: 52, + strength: 12.0, + style, + cacheable: true, + } +} + +#[test] +fn blur_hits_match_uncached_pixels_and_keep_source_style_and_geometry_identity() { + let backdrop = backdrop(true); + let mut caches = RenderCaches::default(); + for generation in [1, 2] { + for style in [BlurStyle::Gaussian, BlurStyle::Pixelate, BlurStyle::Secure] { + for x in [4, 9] { + let params = BlurRectParams { x, ..params(style) }; + let miss = render_blur(&mut caches, &backdrop, Some(generation), params); + let hit = render_blur(&mut caches, &backdrop, Some(generation), params); + let uncached = render_blur( + &mut caches, + &backdrop, + Some(generation), + BlurRectParams { + cacheable: false, + ..params + }, + ); + assert_eq!(hit, miss); + assert_eq!( + hit, uncached, + "generation {generation}, style {style:?}, x {x}" + ); + } + } + } +} + +#[test] +fn independent_owners_do_not_confuse_identical_backdrop_generations() { + let red = backdrop(true); + let blue = backdrop(false); + let params = params(BlurStyle::Pixelate); + let mut first = RenderCaches::default(); + let mut second = RenderCaches::default(); + let red_pixels = render_blur(&mut first, &red, Some(1), params); + let blue_pixels = render_blur(&mut second, &blue, Some(1), params); + assert_ne!(red_pixels, blue_pixels); + assert_eq!(red_pixels, render_blur(&mut first, &red, Some(1), params)); + assert_eq!( + blue_pixels, + render_blur(&mut second, &blue, Some(1), params) + ); + assert_eq!(blue_pixels, render_blur(&mut first, &blue, Some(2), params)); +} + +#[test] +fn disabled_or_unkeyed_blur_bypasses_existing_entries_and_does_not_replace_them() { + let red = backdrop(true); + let blue = backdrop(false); + let params = params(BlurStyle::Gaussian); + let mut caches = RenderCaches::default(); + let red_pixels = render_blur(&mut caches, &red, Some(1), params); + let expected_blue = render_blur(&mut RenderCaches::default(), &blue, Some(1), params); + for generation in [Some(1), None] { + let bypass = render_blur( + &mut caches, + &blue, + generation, + BlurRectParams { + cacheable: generation.is_none(), + ..params + }, + ); + assert_eq!(bypass, expected_blue); + assert_ne!(bypass, red_pixels); + assert_eq!(render_blur(&mut caches, &red, Some(1), params), red_pixels); + } +} + +fn render_image(caches: &mut RenderCaches, shape: &Shape) -> Vec { + let mut output = ImageSurface::create(Format::ARgb32, 80, 80).unwrap(); + let ctx = Context::new(&output).unwrap(); + ctx.translate(2.0, 3.0); + RenderCtx::new(&ctx, caches).render_shape(shape); + assert_eq!(ctx.matrix().x0(), 2.0); + assert_eq!(ctx.matrix().y0(), 3.0); + assert!(!ctx.has_current_point().unwrap()); + drop(ctx); + output.data().unwrap().to_vec() +} + +#[test] +fn decoded_image_hits_match_fresh_owner_and_owner_drop_releases_payload() { + let mut png = Vec::new(); + backdrop(true).write_to_png(&mut png).unwrap(); + let bytes: Arc<[u8]> = png.into(); + let payload = Arc::downgrade(&bytes); + let shape = Shape::Image { + x: 70, + y: 65, + w: -60, + h: -55, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 64, + height: 64, + bytes, + }, + }; + let mut caches = RenderCaches::default(); + let miss = render_image(&mut caches, &shape); + let hit = render_image(&mut caches, &shape); + let fresh = render_image(&mut RenderCaches::default(), &shape); + assert_eq!(miss, hit); + assert_eq!(hit, fresh); + assert!(hit.iter().any(|byte| *byte != 0)); + drop(shape); + assert!( + payload.upgrade().is_some(), + "owner retains decoded payload identity" + ); + drop(caches); + assert!( + payload.upgrade().is_none(), + "owner drop releases payload identity" + ); +} diff --git a/src/draw/render/image.rs b/src/draw/render/image.rs index 31659e3f9..32a12d399 100644 --- a/src/draw/render/image.rs +++ b/src/draw/render/image.rs @@ -1,14 +1,13 @@ use crate::draw::shape::EmbeddedImage; use crate::image_decode::{decode_rgba, format_from_mime_or_bytes}; use cairo::{Format, ImageSurface}; -use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; use std::rc::Rc; use std::sync::Arc; const IMAGE_CACHE_ENTRIES: usize = 32; -/// Per-render-thread budget for decoded ARGB32 image pixels. +/// Per-owner budget for decoded ARGB32 image pixels. const IMAGE_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024; #[derive(Clone, Debug)] @@ -37,19 +36,12 @@ struct ImageCacheKey { height: u32, } -thread_local! { - static IMAGE_CACHE: RefCell = RefCell::new(ImageSurfaceCache::new( - IMAGE_CACHE_ENTRIES, - IMAGE_CACHE_MAX_BYTES, - )); -} - struct CachedImageSurface { surface: Rc, decoded_bytes: usize, } -struct ImageSurfaceCache { +pub(super) struct ImageSurfaceCache { entries: HashMap, access_order: VecDeque, max_entries: usize, @@ -57,6 +49,12 @@ struct ImageSurfaceCache { cached_bytes: usize, } +impl Default for ImageSurfaceCache { + fn default() -> Self { + Self::new(IMAGE_CACHE_ENTRIES, IMAGE_CACHE_MAX_BYTES) + } +} + impl ImageSurfaceCache { fn new(max_entries: usize, max_bytes: usize) -> Self { Self { @@ -136,7 +134,8 @@ impl ImageSurfaceCache { } } -pub fn render_image_shape( +pub(super) fn render_image_shape( + cache: &mut ImageSurfaceCache, ctx: &cairo::Context, x: i32, y: i32, @@ -147,7 +146,7 @@ pub fn render_image_shape( if w == 0 || h == 0 { return; } - let Some(surface) = cached_surface(data) else { + let Some(surface) = cached_surface(cache, data) else { render_missing_image_placeholder(ctx, x, y, w, h); return; }; @@ -170,7 +169,7 @@ pub fn render_image_shape( let _ = ctx.restore(); } -fn cached_surface(data: &EmbeddedImage) -> Option> { +fn cached_surface(cache: &mut ImageSurfaceCache, data: &EmbeddedImage) -> Option> { let key = ImageCacheKey { mime_type: data.mime_type.clone(), bytes: ImageBytesIdentity(Arc::clone(&data.bytes)), @@ -178,15 +177,12 @@ fn cached_surface(data: &EmbeddedImage) -> Option> { height: data.height, }; - IMAGE_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - if let Some(surface) = cache.get(&key) { - return Some(surface); - } + if let Some(surface) = cache.get(&key) { + return Some(surface); + } - let (surface, decoded_bytes) = decode_surface(data)?; - Some(cache.insert(key, Rc::new(surface), decoded_bytes)) - }) + let (surface, decoded_bytes) = decode_surface(data)?; + Some(cache.insert(key, Rc::new(surface), decoded_bytes)) } fn decode_surface(data: &EmbeddedImage) -> Option<(ImageSurface, usize)> { @@ -350,4 +346,29 @@ mod tests { assert!(first_payload.upgrade().is_none()); assert!(weak_surface.upgrade().is_none()); } + + #[test] + fn decoded_cache_reuses_only_shared_payload_and_matching_metadata() { + let mut bytes = Vec::new(); + surface().write_to_png(&mut bytes).unwrap(); + let data = crate::draw::EmbeddedImage { + mime_type: "image/png".into(), + width: 1, + height: 1, + bytes: bytes.clone().into(), + }; + let mut cache = ImageSurfaceCache::default(); + let first = super::cached_surface(&mut cache, &data).unwrap(); + let shared = super::cached_surface(&mut cache, &data.clone()).unwrap(); + assert!(Rc::ptr_eq(&first, &shared)); + let separate_payload = crate::draw::EmbeddedImage { + bytes: bytes.into(), + ..data.clone() + }; + let distinct = super::cached_surface(&mut cache, &separate_payload).unwrap(); + assert!(!Rc::ptr_eq(&first, &distinct)); + let different_metadata = crate::draw::EmbeddedImage { width: 2, ..data }; + let distinct = super::cached_surface(&mut cache, &different_metadata).unwrap(); + assert!(!Rc::ptr_eq(&first, &distinct)); + } } diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index 0b9d1c176..358e04824 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -6,6 +6,7 @@ pub use backdrop_probe::painted_luminance as painted_background_luminance; pub use backdrop_probe::perceived_luminance; mod background; mod blur; +mod context; mod highlight; mod image; mod pressure_strokes; @@ -19,6 +20,7 @@ mod types; pub use background::{fill_transparent, render_board_background}; pub use blur::{BlurRectParams, render_blur_rect}; +pub use context::{RenderCaches, RenderCtx}; pub use highlight::render_click_highlight; #[allow(unused_imports)] pub use pressure_strokes::render_freehand_pressure_borrowed; diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index f3d064bcc..24adbcb28 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -42,6 +42,9 @@ struct StepMarkerRenderSpec<'a> { /// Dispatches to the appropriate internal rendering function based on shape type. /// Handles all shape variants: Freehand, Line, Rect, Ellipse, Arrow, and Text. /// +/// This convenience entry point uses temporary drawing caches. Repeated painting +/// should use [`super::RenderCtx`] with a persistent [`super::RenderCaches`] owner. +/// /// # Arguments /// * `ctx` - Cairo drawing context to render to /// * `shape` - The shape to render @@ -72,6 +75,20 @@ pub fn render_shape_over_with_halo( shape: &Shape, known_background_luminance: Option, text_halo_enabled: bool, +) { + super::RenderCtx::new(ctx, &mut super::RenderCaches::default()).render_shape_over_with_halo( + shape, + known_background_luminance, + text_halo_enabled, + ); +} + +pub(super) fn render_shape_with_cache( + images: &mut super::image::ImageSurfaceCache, + ctx: &cairo::Context, + shape: &Shape, + known_background_luminance: Option, + text_halo_enabled: bool, ) { let text_options = ShapeTextOptions { known_background_luminance, @@ -246,7 +263,7 @@ pub fn render_shape_over_with_halo( // Eraser strokes require an eraser replay context; ignore in generic rendering. } Shape::Image { x, y, w, h, data } => { - render_image_shape(ctx, *x, *y, *w, *h, data); + render_image_shape(images, ctx, *x, *y, *w, *h, data); } } } diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 0d0550e60..667bb1b4c 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -1,8 +1,7 @@ use crate::draw::render::{render_freehand_pressure_preview_borrowed, render_polygon_preview}; use crate::draw::shape::bounding_box_for_points; use crate::draw::{ - Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape, - render_shape_with_halo, + Color, RenderCaches, RenderCtx, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, }; use crate::input::Tool; use crate::input::tool::{ @@ -100,10 +99,11 @@ impl InputState { pub(crate) fn render_provisional_tool_stroke( &self, - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, stroke: ProvisionalToolStroke<'_>, text_halo_enabled: bool, ) -> bool { + let ctx = render.cairo; match stroke { ProvisionalToolStroke::BorrowedFreehand { points, @@ -140,21 +140,18 @@ impl InputState { true } ProvisionalToolStroke::Shape(shape) => { - render_shape_with_halo(ctx, &shape, text_halo_enabled); + render.render_shape_with_halo(&shape, text_halo_enabled); true } ProvisionalToolStroke::BlurReplayPreview(params) => { - render_shape( - ctx, - &Shape::BlurRect { - x: params.x, - y: params.y, - w: params.w, - h: params.h, - strength: params.strength, - style: params.style, - }, - ); + render.render_shape(&Shape::BlurRect { + x: params.x, + y: params.y, + w: params.w, + h: params.h, + strength: params.strength, + style: params.style, + }); true } ProvisionalToolStroke::None => false, @@ -163,11 +160,12 @@ impl InputState { pub(crate) fn render_provisional_tool_stroke_for_damage( &self, - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, stroke: ProvisionalToolStroke<'_>, damage_regions: &[Rect], text_halo_enabled: bool, ) -> bool { + let ctx = render.cairo; match stroke { ProvisionalToolStroke::BorrowedFreehand { points, @@ -252,7 +250,7 @@ impl InputState { } true } - other => self.render_provisional_tool_stroke(ctx, other, text_halo_enabled), + other => self.render_provisional_tool_stroke(render, other, text_halo_enabled), } } @@ -274,20 +272,27 @@ impl InputState { current_x: i32, current_y: i32, ) -> bool { - self.render_provisional_shape_with_halo(ctx, current_x, current_y, true) + let mut caches = RenderCaches::default(); + self.render_provisional_shape_with_halo( + &mut RenderCtx::new(ctx, &mut caches), + current_x, + current_y, + true, + ) } pub(crate) fn render_provisional_shape_with_halo( &self, - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, current_x: i32, current_y: i32, text_halo_enabled: bool, ) -> bool { + let ctx = render.cairo; match &self.state { DrawingState::Drawing { .. } => { let stroke = self.provisional_tool_stroke(current_x, current_y); - self.render_provisional_tool_stroke(ctx, stroke, text_halo_enabled) + self.render_provisional_tool_stroke(render, stroke, text_halo_enabled) } DrawingState::Selecting { start_x, @@ -339,7 +344,7 @@ impl InputState { pub(crate) fn render_provisional_shape_for_damage( &self, - ctx: &cairo::Context, + render: &mut RenderCtx<'_, '_>, current_x: i32, current_y: i32, damage_regions: &[Rect], @@ -348,14 +353,14 @@ impl InputState { if matches!(self.state, DrawingState::Drawing { .. }) { let stroke = self.provisional_tool_stroke(current_x, current_y); return self.render_provisional_tool_stroke_for_damage( - ctx, + render, stroke, damage_regions, text_halo_enabled, ); } - self.render_provisional_shape_with_halo(ctx, current_x, current_y, text_halo_enabled) + self.render_provisional_shape_with_halo(render, current_x, current_y, text_halo_enabled) } } @@ -440,6 +445,70 @@ fn pressure_preview_needs_full_mask_render(color: Color, ranges: &[Range] mod tests { use super::*; + #[test] + fn persistent_preview_dispatch_matches_shape_rendering() { + let input = crate::input::state::test_support::TestInputStateBuilder::default().build(); + let mut png = std::io::Cursor::new(Vec::new()); + let source = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let context = cairo::Context::new(&source).unwrap(); + context.set_source_rgb(240.0 / 255.0, 50.0 / 255.0, 20.0 / 255.0); + context.paint().unwrap(); + source.write_to_png(&mut png).unwrap(); + let shapes = [ + Shape::Image { + x: 2, + y: 2, + w: 20, + h: 20, + data: crate::draw::EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: png.into_inner().into(), + }, + }, + Shape::Text { + x: 2, + y: 42, + text: "Preview".into(), + color: crate::draw::RED, + size: 14.0, + font_descriptor: crate::draw::FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }, + ]; + let mut caches = RenderCaches::default(); + for halo in [true, false, true] { + let mut actual = cairo::ImageSurface::create(cairo::Format::ARgb32, 96, 64).unwrap(); + let mut expected = cairo::ImageSurface::create(cairo::Format::ARgb32, 96, 64).unwrap(); + { + let cairo = cairo::Context::new(&actual).unwrap(); + let mut render = RenderCtx::new(&cairo, &mut caches); + for shape in &shapes { + assert!(input.render_provisional_tool_stroke_for_damage( + &mut render, + ProvisionalToolStroke::Shape(shape.clone()), + &[], + halo, + )); + } + let cairo = cairo::Context::new(&expected).unwrap(); + let mut fresh = RenderCaches::default(); + let mut render = RenderCtx::new(&cairo, &mut fresh); + for shape in &shapes { + render.render_shape_with_halo(shape, halo); + } + } + actual.flush(); + expected.flush(); + let actual = actual.data().unwrap().to_vec(); + let expected = expected.data().unwrap().to_vec(); + assert!(actual.iter().any(|&byte| byte != 0)); + assert_eq!(actual, expected, "preview halo={halo}"); + } + } + #[test] fn path_damage_ranges_limits_long_path_to_intersecting_tail() { let points: Vec<_> = (0..100).map(|index| (index * 10, 0)).collect(); diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index e423c6a28..5e39bbeb3 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -16,22 +16,32 @@ use page_panel::render_page_panel; use palette::render_board_palette; use rows::render_board_rows; +/// Renders a standalone board picker with drawing resources local to this call. +/// The overlay runtime uses the explicit-context entry point to reuse its owner. pub fn render_board_picker( ctx: &cairo::Context, input_state: &InputState, screen_width: u32, screen_height: u32, ) { - render_board_picker_with_halo(ctx, input_state, screen_width, screen_height, true); + let mut caches = crate::draw::RenderCaches::default(); + render_board_picker_with_halo( + &mut crate::draw::RenderCtx::new(ctx, &mut caches), + input_state, + screen_width, + screen_height, + true, + ); } pub(crate) fn render_board_picker_with_halo( - ctx: &cairo::Context, + render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, screen_width: u32, screen_height: u32, text_halo_enabled: bool, ) { + let ctx = render.cairo; if !input_state.is_board_picker_open() { return; } @@ -147,7 +157,7 @@ pub(crate) fn render_board_picker_with_halo( render_board_palette(ctx, input_state, layout); render_page_panel( - ctx, + render, input_state, layout, screen_width, diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 140b7821a..f760234dd 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -27,13 +27,14 @@ use thumbnail::{ }; pub(super) fn render_page_panel( - ctx: &cairo::Context, + render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, layout: &BoardPickerLayout, screen_width: u32, screen_height: u32, text_halo_enabled: bool, ) { + let ctx = render.cairo; if !layout.page_panel_enabled { return; } @@ -137,7 +138,7 @@ pub(super) fn render_page_panel( && d.current_index == index }); render_page_thumbnail(PageThumbnailArgs { - ctx, + render, frame: page, background: &board.spec.background, x: thumb_x, @@ -172,7 +173,7 @@ pub(super) fn render_page_panel( let thumb_y = start_y + row as f64 * row_stride; let page = &pages[hover_index]; render_page_preview(PagePreviewArgs { - ctx, + render, frame: page, background: &board.spec.background, thumb_x, diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 30ba2212d..903a3bf9b 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -22,9 +22,11 @@ use super::icons::{ }; use super::types::{PREVIEW_SCALE, PageContentArgs, PagePreviewArgs, PageThumbnailArgs}; -pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageThumbnailArgs<'_>) { +pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( + args: PageThumbnailArgs<'_, '_, '_>, +) { let PageThumbnailArgs { - ctx, + render, frame, background, x, @@ -45,6 +47,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT duplicate_hovered, rename_hovered, } = args; + let ctx = render.cairo; let radius = RADIUS_STD; draw_rounded_rect(ctx, x, y, width, height, radius); if is_drop_target { @@ -58,7 +61,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT let _ = ctx.stroke(); render_page_content(PageContentArgs { - ctx, + render, frame, background, x, @@ -284,9 +287,11 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( ); } -pub(in crate::ui::board_picker::page_panel) fn render_page_preview(args: PagePreviewArgs<'_>) { +pub(in crate::ui::board_picker::page_panel) fn render_page_preview( + args: PagePreviewArgs<'_, '_, '_>, +) { let PagePreviewArgs { - ctx, + render, frame, background, thumb_x, @@ -298,6 +303,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview(args: PagePre text_halo_enabled, page_number, } = args; + let ctx = render.cairo; let base_w = thumb_w * PREVIEW_SCALE; let base_h = thumb_h * PREVIEW_SCALE; let margin = 8.0; @@ -332,7 +338,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview(args: PagePre let _ = ctx.stroke(); render_page_content(PageContentArgs { - ctx, + render, frame, background, x: preview_x, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index b4c9b42f9..1ad84566f 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -1,7 +1,7 @@ use crate::draw::{ EraserReplayContext, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, - render_eraser_stroke, render_shape_with_halo, render_spotlight_magnification_pass, - render_spotlight_pass, spotlight_regions_for_frame, + render_eraser_stroke, render_spotlight_magnification_pass, render_spotlight_pass, + spotlight_regions_for_frame, }; use crate::input::BoardBackground; use crate::input::state::{PAGE_NAME_HEIGHT, PAGE_NAME_PADDING}; @@ -24,9 +24,9 @@ const THUMBNAIL_SPOTLIGHT_FEATHER: f64 = 0.35; const TRANSPARENT_TINT: Rgba = (1.0, 1.0, 1.0, 0.06); const TRANSPARENT_CROSS: Rgba = (1.0, 1.0, 1.0, 0.08); -pub(super) fn render_page_content(args: PageContentArgs<'_>) { +pub(super) fn render_page_content(args: PageContentArgs<'_, '_, '_>) { let PageContentArgs { - ctx, + render, frame, background, x, @@ -37,6 +37,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { screen_height, text_halo_enabled, } = args; + let ctx = render.cairo; let radius = RADIUS_STD; let _ = ctx.save(); draw_rounded_rect(ctx, x, y, width, height, radius); @@ -73,7 +74,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { ctx.translate(x + inset + offset_x, y + inset + offset_y); ctx.scale(scale, scale); render_frame_shapes( - ctx, + render, frame, background, screen_width, @@ -85,13 +86,14 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { } fn render_frame_shapes( - ctx: &cairo::Context, + render: &mut crate::draw::RenderCtx<'_, '_>, frame: &crate::draw::Frame, background: &BoardBackground, target_width: u32, target_height: u32, text_halo_enabled: bool, ) { + let ctx = render.cairo; let eraser_ctx = EraserReplayContext { pattern: None, surface: None, @@ -112,7 +114,7 @@ fn render_frame_shapes( render_eraser_stroke(ctx, points, brush, &eraser_ctx); } _ => { - render_shape_with_halo(ctx, &drawn.shape, text_halo_enabled); + render.render_shape_with_halo(&drawn.shape, text_halo_enabled); } } } @@ -282,7 +284,10 @@ mod tests { magnification, }); render_page_content(PageContentArgs { - ctx: &ctx, + render: &mut crate::draw::RenderCtx::new( + &ctx, + &mut crate::draw::RenderCaches::default(), + ), frame: &frame, background, x: 0.0, @@ -316,7 +321,10 @@ mod tests { wrap_width: None, }); render_page_content(PageContentArgs { - ctx: &ctx, + render: &mut crate::draw::RenderCtx::new( + &ctx, + &mut crate::draw::RenderCaches::default(), + ), frame: &frame, background: &BoardBackground::Solid(Color::new(1.0, 1.0, 1.0, 1.0)), x: 0.0, @@ -366,4 +374,90 @@ mod tests { "an unavailable loupe must still say what it was asked for" ); } + + #[test] + fn thumbnail_owner_reuses_images_across_frames_with_pixel_parity() { + use crate::draw::{EmbeddedImage, RenderCaches, RenderCtx}; + use std::sync::Arc; + let image = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let ctx = cairo::Context::new(&image).unwrap(); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.paint().unwrap(); + let mut png = Vec::new(); + image.write_to_png(&mut png).unwrap(); + let bytes: Arc<[u8]> = png.into(); + let mut frame = Frame::new(); + frame.add_shape(Shape::Image { + x: 8, + y: 8, + w: 24, + h: 24, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: Arc::clone(&bytes), + }, + }); + frame.add_shape(Shape::Text { + x: 40, + y: 36, + text: "Image".into(), + color: crate::draw::BLACK, + size: 20.0, + font_descriptor: Default::default(), + background_enabled: false, + wrap_width: None, + }); + let paint = |caches: &mut RenderCaches| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 124, 64).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + render_page_content(PageContentArgs { + render: &mut RenderCtx::new(&ctx, caches), + frame: &frame, + background: &BoardBackground::Solid(crate::draw::WHITE), + x: 0.0, + y: 0.0, + width: 124.0, + height: 64.0, + screen_width: 120, + screen_height: 60, + text_halo_enabled: false, + }); + } + surface.flush(); + surface.data().unwrap().to_vec() + }; + let baseline = Arc::strong_count(&bytes); + let mut caches = RenderCaches::default(); + let first = paint(&mut caches); + let retained = Arc::strong_count(&bytes); + assert!( + retained > baseline, + "thumbnail must retain its decoded image in the supplied owner" + ); + assert_eq!(paint(&mut caches), first); + assert_eq!(Arc::strong_count(&bytes), retained); + assert_eq!(paint(&mut RenderCaches::default()), first); + let offset = (20 * 124 + 20) * 4; + assert_eq!( + u32::from_ne_bytes(first[offset..offset + 4].try_into().unwrap()), + 0xffff0000 + ); + let dark = (8..42) + .flat_map(|y| (40..115).map(move |x| (y * 124 + x) * 4)) + .filter(|&offset| { + u32::from_ne_bytes(first[offset..offset + 4].try_into().unwrap()) & 0x00ffffff + < 0x00404040 + }) + .count(); + assert!(dark > 30, "thumbnail must contain text beside the image"); + drop(caches); + assert_eq!( + Arc::strong_count(&bytes), + baseline, + "owner drop releases the retained payload" + ); + } } diff --git a/src/ui/board_picker/page_panel/thumbnail/types.rs b/src/ui/board_picker/page_panel/thumbnail/types.rs index 79ec076f3..01622574e 100644 --- a/src/ui/board_picker/page_panel/thumbnail/types.rs +++ b/src/ui/board_picker/page_panel/thumbnail/types.rs @@ -2,8 +2,8 @@ use crate::input::BoardBackground; pub(in crate::ui::board_picker::page_panel) const PREVIEW_SCALE: f64 = 1.6; -pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a> { - pub(in crate::ui::board_picker::page_panel) ctx: &'a cairo::Context, +pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a, 'c, 'r> { + pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, pub(in crate::ui::board_picker::page_panel) x: f64, @@ -25,8 +25,8 @@ pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a> { pub(in crate::ui::board_picker::page_panel) rename_hovered: bool, } -pub(in crate::ui::board_picker::page_panel) struct PagePreviewArgs<'a> { - pub(in crate::ui::board_picker::page_panel) ctx: &'a cairo::Context, +pub(in crate::ui::board_picker::page_panel) struct PagePreviewArgs<'a, 'c, 'r> { + pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, pub(in crate::ui::board_picker::page_panel) thumb_x: f64, @@ -39,8 +39,8 @@ pub(in crate::ui::board_picker::page_panel) struct PagePreviewArgs<'a> { pub(in crate::ui::board_picker::page_panel) page_number: usize, } -pub(in crate::ui::board_picker::page_panel) struct PageContentArgs<'a> { - pub(in crate::ui::board_picker::page_panel) ctx: &'a cairo::Context, +pub(in crate::ui::board_picker::page_panel) struct PageContentArgs<'a, 'c, 'r> { + pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, pub(in crate::ui::board_picker::page_panel) x: f64,