diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index c9f2af46..32b75869 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -142,8 +142,16 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen - `render.rs` exposes provisional shape previews for live feedback. 4. **Rendering to the overlay** - - `WaylandState::render` uses Cairo + SHM buffers. - - Draw order: board background → finalized shapes → provisional shape → text cursor preview → status bar (if enabled) → help overlay (if toggled). + - `WaylandState::render` acquires an SHM buffer, prepares mutable animation/damage state, + 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`. + - 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 + preflight rendered. - `ui` module encapsulates status/help overlays, while `draw` handles actual vector geometry routines. The result is a predictable pipeline: Wayland → handlers → `InputState` → diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index cde37b4b..eea9685b 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/` owns overlay render phases plus `RenderRuntime` cache, 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 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. ## Invariants - Preserve snapshot boundaries for export and session actions. diff --git a/src/backend/wayland/state/render/canvas/context.rs b/src/backend/wayland/state/render/canvas/context.rs new file mode 100644 index 00000000..73c2e23c --- /dev/null +++ b/src/backend/wayland/state/render/canvas/context.rs @@ -0,0 +1,14 @@ +use std::time::Instant; + +use super::super::plan::{CanvasFrame, FrameGeometry}; +use crate::util::Rect; + +/// Parameters borrowed from the owned frame plan and its local Cairo target. +/// Backdrop replay resources and mutable runtime caches stay in the painter. +pub(in crate::backend::wayland::state::render) struct CanvasRenderCtx<'a> { + pub(in crate::backend::wayland::state::render) cairo: &'a cairo::Context, + pub(in crate::backend::wayland::state::render) geometry: &'a FrameGeometry, + pub(in crate::backend::wayland::state::render) canvas: &'a CanvasFrame, + pub(in crate::backend::wayland::state::render) damage_world: &'a [Rect], + pub(in crate::backend::wayland::state::render) now: Instant, +} diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 7a77ca73..342555f0 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -1,8 +1,10 @@ mod background; +mod context; mod overlays; mod text; use super::super::*; +pub(super) use context::CanvasRenderCtx; const SPOTLIGHT_MAGNIFIER_TOAST_SOURCE: &str = "spotlight-magnifier"; @@ -71,36 +73,30 @@ impl WaylandState { ); } - #[allow(clippy::too_many_arguments)] pub(super) fn render_canvas_layer( &mut self, - ctx: &cairo::Context, - width: u32, - height: u32, - scale: i32, - phys_width: u32, - phys_height: u32, - now: Instant, - damage_world: &[crate::util::Rect], - render_transients: bool, + canvas: &CanvasRenderCtx<'_>, mut perf: Option<&mut PerfRenderBreakdown>, ) -> Result<()> { - let capture_picker_active = self.capture_picker_chrome_suppressed(); - let capture_picker_draws_committed = capture_picker_draws_committed( - capture_picker_active, - self.region_picker_include_drawings(), - ); - let render_transients = render_transients && !capture_picker_active; - let canvas_transform_active = self.canvas_transform_active(); - let (canvas_origin_x, canvas_origin_y) = self.canvas_view_origin(); + let ctx = canvas.cairo; + let width = canvas.geometry.width; + let height = canvas.geometry.height; + let scale = canvas.geometry.scale; + let phys_width = canvas.geometry.physical_width; + let phys_height = canvas.geometry.physical_height; + let now = canvas.now; + let damage_world = canvas.damage_world; + let render_transients = canvas.canvas.render_transients; + let canvas_transform_active = canvas.canvas.transform_active; + let (canvas_origin_x, canvas_origin_y) = canvas.canvas.origin; let shapes_total = self.input_state.boards.active_frame().shapes.len(); - let text_halo_enabled = self.config.drawing.text_halo_enabled; + let text_halo_enabled = canvas.canvas.text_halo_enabled; // For pure pan transforms, serve the board background and committed // shapes from the baked layer cache: pan frames force full damage, so // this turns an O(shapes) Cairo replay into a single aligned blit. let layer_cache_start = perf.as_ref().map(|_| Instant::now()); - let layer_cache_ready = if !capture_picker_active && self.canvas_layer_cache_usable() { + let layer_cache_ready = if canvas.canvas.layer_cache_eligible { self.ensure_canvas_layer_cache(width, height, scale) } else { self.render.canvas_layer_cache_mut().clear(); @@ -129,7 +125,7 @@ impl WaylandState { // frozen capture. Turning the Review toggle off keeps only the raw // backdrop. Transient handles, provisional strokes, text previews, // hover effects, and click highlights remain suppressed in both cases. - if !capture_picker_draws_committed { + if !canvas.canvas.draw_committed { self.spotlight.note_frame(false); if let Some(perf) = perf.as_mut() { perf.shapes_total = shapes_total; @@ -148,8 +144,8 @@ impl WaylandState { if canvas_transform_active { let _ = ctx.save(); - if self.zoom.active { - ctx.scale(self.zoom.scale, self.zoom.scale); + if let Some(zoom_scale) = canvas.canvas.zoom_scale { + ctx.scale(zoom_scale, zoom_scale); } ctx.translate(-canvas_origin_x, -canvas_origin_y); } @@ -158,14 +154,9 @@ impl WaylandState { let completed_shapes_start = perf.as_ref().map(|_| Instant::now()); self.render_committed_canvas_shapes( - ctx, - width, - height, - damage_world, - canvas_transform_active, + canvas, layer_cache_ready, &replay_ctx, - text_halo_enabled, perf.as_deref_mut(), ); if let (Some(perf), Some(completed_shapes_start)) = (perf.as_mut(), completed_shapes_start) @@ -317,7 +308,7 @@ impl WaylandState { } // Render text cursor/buffer if in text mode - self.render_text_input_preview(ctx); + self.render_text_input_preview(canvas); self.input_state.render_highlight_tool_ring(ctx, mx, my); @@ -333,19 +324,19 @@ impl WaylandState { Ok(()) } - #[allow(clippy::too_many_arguments)] fn render_committed_canvas_shapes( - &mut self, - ctx: &cairo::Context, - width: u32, - height: u32, - damage_world: &[crate::util::Rect], - canvas_transform_active: bool, + &self, + canvas: &CanvasRenderCtx<'_>, layer_cache_ready: bool, replay_ctx: &crate::draw::EraserReplayContext<'_>, - text_halo_enabled: bool, 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"); @@ -464,19 +455,9 @@ fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_ } } -const fn capture_picker_draws_committed( - capture_picker_active: bool, - include_drawings: bool, -) -> bool { - !capture_picker_active || include_drawings -} - #[cfg(test)] mod tests { - use super::{ - capture_picker_draws_committed, rects_intersect, safe_shape_damage_bounds, - union_damage_bounds, - }; + use super::{rects_intersect, safe_shape_damage_bounds, union_damage_bounds}; use crate::util::Rect; #[test] @@ -509,12 +490,4 @@ mod tests { assert!(!rects_intersect(Rect::new(0, 10, 10, 5).unwrap(), damage)); assert!(rects_intersect(Rect::new(9, 10, 2, 5).unwrap(), damage)); } - - #[test] - fn picker_preview_follows_the_annotated_export_choice() { - assert!(capture_picker_draws_committed(false, false)); - assert!(capture_picker_draws_committed(false, true)); - assert!(capture_picker_draws_committed(true, true)); - assert!(!capture_picker_draws_committed(true, false)); - } } diff --git a/src/backend/wayland/state/render/canvas/text.rs b/src/backend/wayland/state/render/canvas/text.rs index 8412600e..de481f10 100644 --- a/src/backend/wayland/state/render/canvas/text.rs +++ b/src/backend/wayland/state/render/canvas/text.rs @@ -1,12 +1,14 @@ mod ime; use super::super::super::*; +use super::CanvasRenderCtx; use crate::draw::Shape; use ime::{paint_preedit_selection, paint_preedit_underline}; use std::ops::Range; impl WaylandState { - pub(super) fn render_text_input_preview(&self, ctx: &cairo::Context) { + pub(super) fn render_text_input_preview(&self, canvas: &CanvasRenderCtx<'_>) { + let ctx = canvas.cairo; if let DrawingState::TextInput { x, y, .. } = &self.input_state.state { let is_editing_existing = self.input_state.text_editing.edit_target().is_some(); @@ -18,7 +20,7 @@ impl WaylandState { if self.input_state.text_edit_ghost_visible() && let Some((_, snapshot)) = self.input_state.text_editing.edit_target() { - self.render_text_edit_ghost(ctx, &snapshot.shape); + self.render_text_edit_ghost(canvas, &snapshot.shape); } // Render entry animation if active @@ -53,7 +55,7 @@ impl WaylandState { &self.input_state.style.font_descriptor, self.input_state.style.text_background_enabled, self.input_state.style.text_wrap_width, - self.config.drawing.text_halo_enabled, + canvas.canvas.text_halo_enabled, ); } crate::input::TextInputMode::StickyNote => { @@ -78,7 +80,14 @@ impl WaylandState { preview.underline.as_ref(), decoration_color, ); - self.render_caret_line(ctx, *x, *y, &preview.text, preview.caret, decoration_color); + self.render_caret_line( + canvas, + *x, + *y, + &preview.text, + preview.caret, + decoration_color, + ); } } @@ -88,7 +97,7 @@ impl WaylandState { /// cursor position keeps it correct on wrapped and multiline text. fn render_caret_line( &self, - ctx: &cairo::Context, + canvas: &CanvasRenderCtx<'_>, x: i32, y: i32, preview_text: &str, @@ -111,7 +120,15 @@ impl WaylandState { let caret_x = x as f64 + geom.x; let top = y as f64 + geom.y_from_baseline; let bottom = top + geom.height; - render_caret_stroke(ctx, caret_x, top, bottom, color, size, &self.config.drawing); + render_caret_stroke( + canvas.cairo, + caret_x, + top, + bottom, + color, + size, + canvas.canvas.text_halo_enabled, + ); } /// Underline the IME preedit span (a byte range into `preview_text`) so @@ -185,7 +202,8 @@ impl WaylandState { } /// Renders the original text as a semi-transparent ghost during editing. - fn render_text_edit_ghost(&self, ctx: &cairo::Context, original_shape: &Shape) { + fn render_text_edit_ghost(&self, canvas: &CanvasRenderCtx<'_>, original_shape: &Shape) { + let ctx = canvas.cairo; let _ = ctx.save(); // Apply transparency to show it as a ghost ctx.push_group(); @@ -211,7 +229,7 @@ impl WaylandState { font_descriptor, *background_enabled, *wrap_width, - self.config.drawing.text_halo_enabled, + canvas.canvas.text_halo_enabled, ); } Shape::StickyNote { @@ -312,13 +330,13 @@ fn render_caret_stroke( bottom: f64, color: crate::draw::Color, size: f64, - drawing: &crate::config::DrawingConfig, + text_halo_enabled: bool, ) { ctx.save().ok(); // Widths come from the draw layer so the damage tracker sizes the caret's // repaint rectangle from the exact same numbers. let line_width = crate::draw::caret_line_width(size); - if drawing.text_halo_enabled { + if text_halo_enabled { // The caret stands where the glyphs will, so it asks the same question // they do: what is behind this point on the canvas? let background_luminance = crate::draw::painted_background_luminance( @@ -360,7 +378,6 @@ fn text_preview_decoration_color( #[cfg(test)] mod tests { use super::{render_caret_stroke, text_preview_decoration_color}; - use crate::config::DrawingConfig; use crate::draw::Color; use crate::input::TextInputMode; @@ -390,10 +407,6 @@ mod tests { let ctx = cairo::Context::new(&surface).expect("caret context"); ctx.set_source_rgb(1.0, 1.0, 1.0); ctx.paint().expect("white backdrop"); - let drawing = DrawingConfig { - text_halo_enabled, - ..DrawingConfig::default() - }; render_caret_stroke( &ctx, 40.0, @@ -401,18 +414,58 @@ mod tests { 80.0, Color::new(0.96, 0.2, 0.25, 1.0), 36.0, - &drawing, + text_halo_enabled, ); } surface.flush(); surface.data().expect("caret pixels").to_vec() } + #[test] + fn caret_restores_cairo_state_with_and_without_halo() { + for text_halo_enabled in [false, true] { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 80, 100).expect("caret surface"); + let ctx = cairo::Context::new(&surface).expect("caret context"); + ctx.translate(3.0, 5.0); + ctx.scale(1.25, 1.5); + ctx.set_line_width(7.0); + ctx.set_dash(&[2.0, 3.0], 1.0); + ctx.set_source_rgba(0.1, 0.2, 0.3, 0.4); + let matrix = ctx.matrix(); + let source = cairo::SolidPattern::try_from(ctx.source()) + .expect("solid source") + .rgba() + .expect("source color"); + + render_caret_stroke( + &ctx, + 40.0, + 20.0, + 50.0, + Color::new(0.96, 0.2, 0.25, 1.0), + 36.0, + text_halo_enabled, + ); + + assert_eq!(ctx.matrix(), matrix); + assert_eq!(ctx.line_width(), 7.0); + assert_eq!(ctx.dash(), (vec![2.0, 3.0], 1.0)); + assert_eq!( + cairo::SolidPattern::try_from(ctx.source()) + .expect("restored solid source") + .rgba() + .expect("restored source color"), + source, + ); + } + } + #[test] fn text_entry_caret_honours_the_configured_halo_setting() { assert!( caret_pixels(true) != caret_pixels(false), - "caret rendering must read DrawingConfig::text_halo_enabled", + "caret rendering must honor the frame text halo policy", ); } } diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index 7830706d..28bddd5c 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -1,14 +1,34 @@ use super::*; +macro_rules! record_stage { + ($breakdown:expr, $field:ident, $body:expr) => {{ + record_render_stage( + $breakdown.is_some(), + $breakdown.as_mut(), + |breakdown, duration| { + breakdown.stages.$field = breakdown.stages.$field.saturating_add(duration); + }, + || $body, + ) + }}; +} + mod canvas; mod measure_badge; +mod paint; +mod plan; +mod prepare; +mod profile; mod runtime; -pub(in crate::backend::wayland) use runtime::RenderRuntime; -use runtime::{UiEffect, UiEffectFlags}; +mod submit; mod tool_preview; mod ui; mod ui_effect_damage; +use plan::{FrameGeometry, FrameVisibility, plan_frame}; +pub(in crate::backend::wayland) use runtime::RenderRuntime; +use runtime::{UiEffect, UiEffectFlags}; + /// What a render pass actually did. /// /// `BuffersInFlight` is not a frame: nothing was painted or committed, so the @@ -22,33 +42,6 @@ pub(in crate::backend::wayland) enum RenderOutcome { BuffersInFlight, } -#[derive(Clone, Copy)] -struct RenderAnimationState { - highlight: bool, - preset_feedback: bool, - ui_toast: bool, - blocked_feedback: bool, - text_edit_entry: bool, - input_hud: bool, - ocr_scan: bool, -} - -impl RenderAnimationState { - fn any_active(self) -> bool { - [ - self.highlight, - self.preset_feedback, - self.ui_toast, - self.blocked_feedback, - self.text_edit_entry, - self.input_hud, - self.ocr_scan, - ] - .into_iter() - .any(|active| active) - } -} - fn record_render_stage( enabled: bool, breakdown: Option<&mut PerfRenderBreakdown>, @@ -69,501 +62,58 @@ impl WaylandState { qh: &QueueHandle, ) -> Result { debug!("=== RENDER START ==="); - let board_is_transparent = self.input_state.board_is_transparent(); - let suppression = self - .suppression - .reason() - .effective_for_board(board_is_transparent); - let render_canvas = suppression.renders_canvas(); - let render_canvas_transients = suppression.renders_canvas_transients(); - let render_ui = suppression.renders_ui(); - - // Create pool if needed + // Suppression and surface geometry precede acquisition; animation time, + // layout, damage history and profile selection follow it. + let visibility = FrameVisibility::new( + self.suppression.reason(), + self.input_state.board_is_transparent(), + ); + let geometry = FrameGeometry::new( + self.surface.width(), + self.surface.height(), + self.surface.scale(), + ); let buffer_count = self.config.performance.buffer_count as usize; - let scale = self.surface.scale().max(1); - let width = self.surface.width(); - let height = self.surface.height(); - let phys_width = width.saturating_mul(scale as u32); - let phys_height = height.saturating_mul(scale as u32); - let perf_enabled = self.perf_enabled(); - let mut render_breakdown = perf_enabled.then(|| PerfRenderBreakdown { - surface_px: u64::from(phys_width).saturating_mul(u64::from(phys_height)), + let mut breakdown = self.perf_enabled().then(|| PerfRenderBreakdown { + surface_px: u64::from(geometry.physical_width) + .saturating_mul(u64::from(geometry.physical_height)), ..PerfRenderBreakdown::default() }); - macro_rules! record_stage { - ($field:ident, $body:expr) => {{ - record_render_stage( - perf_enabled, - render_breakdown.as_mut(), - |breakdown, duration| { - breakdown.stages.$field = breakdown.stages.$field.saturating_add(duration); - }, - || $body, - ) - }}; - } - - // Acquire the buffer before anything mutates render state. Animation - // ticks and `collect_ui_effect_damage` both advance clocks and record - // previous bounds exactly once per rendered frame, so running them for - // a frame that is then deferred would let the next real commit miss an - // effect's on-screen footprint. - let acquired = record_stage!(buffer_acquire, { + let acquired = record_stage!(breakdown, buffer_acquire, { self.surface.acquire_buffer( self.protocol.shm(), buffer_count, - phys_width as i32, - phys_height as i32, - (phys_width * 4) as i32, + geometry.physical_width as i32, + geometry.physical_height as i32, + geometry.stride, ) })?; - let Some(acquired) = acquired else { - // Every slot is still owned by the compositor. Keep the redraw - // pending and retry on the next pass rather than painting over a - // buffer that is still on screen. + let outcome = render_acquired_frame(acquired, |acquired| { + let prepared = self.prepare_frame(geometry, visibility, &acquired, &mut breakdown); + let plan = plan_frame(prepared); + self.paint_frame(&plan, &acquired, &mut breakdown)?; + self.submit_frame(qh, acquired, &plan, &mut breakdown)?; + Ok(plan.keep_rendering) + })?; + if outcome == RenderOutcome::BuffersInFlight { debug!("All {buffer_count} buffers in flight - deferring this frame"); self.record_perf_render_skip(PerfRenderSkipReason::BuffersInFlight); - return Ok(RenderOutcome::BuffersInFlight); - }; - // The canvas pointer doubles as the slot identifier for damage - // tracking: a slot keeps its memory for the pool's lifetime, so the - // same pointer means the same slot. - let super::super::surface::AcquiredBuffer { - buffer, - canvas_ptr, - pool_generation: pool_gen, - pool_size, - } = acquired; - debug!("Buffer acquired from pool (slot ptr: 0x{:x})", canvas_ptr); - self.surface.update_pool_size(pool_size); - - let now = Instant::now(); - let animation_state = - record_stage!(advance_animations, { self.advance_render_animations(now) }); - let ui_animation_active = animation_state.any_active(); - self.ui_animation.schedule(now, ui_animation_active); - let keep_rendering = ui_animation_active && self.ui_animation.is_uncapped(); - - // Add new dirty regions from input state to the per-buffer damage - // tracker. This runs after the buffer is acquired but before its damage - // is drained below, so the current frame's changes are included in the - // damage reported for this slot. - let logical_width = width.min(i32::MAX as u32) as i32; - let logical_height = height.min(i32::MAX as u32) as i32; - let mut damage_diagnostics = record_stage!(dirty_collect, { - self.collect_frame_damage( - render_ui, - animation_state, - width, - height, - logical_width, - logical_height, - ) - }); - - // Take damage for this buffer slot (identified by canvas memory address). - // Pool identity (generation + size) is passed to detect pool recreation/growth. - // SlotPool reuses the same memory regions for released buffers, so the - // canvas pointer serves as a stable slot identifier across buffer reuse. - let damage_report = self.buffer_damage.take_buffer_damage_report( - canvas_ptr, - logical_width, - logical_height, - pool_gen, - pool_size, - ); - damage_diagnostics.buffer_regions_before_merge = damage_report.regions_before_merge; - damage_diagnostics.buffer_regions_after_merge = damage_report.regions_after_merge; - let mut logical_damage = damage_report.regions; - let mut full_damage_reason = damage_report.full_reason; - if logical_damage.is_empty() - && let Some(full) = crate::util::Rect::new(0, 0, logical_width, logical_height) - { - logical_damage = vec![full]; - full_damage_reason = Some(FullDamageReason::EmptyDamageFallback); - self.buffer_damage - .mark_all_full(FullDamageReason::EmptyDamageFallback); - } - let damage_screen = logical_damage; - damage_diagnostics.buffer_covers_surface = - damage_covers_logical_surface(&damage_screen, logical_width, logical_height); - let damage_world = if self.canvas_transform_active() { - let scale = if self.zoom.active { - self.zoom.scale.max(f64::MIN_POSITIVE) - } else { - 1.0 - }; - let view_width = ((width as f64) / scale).ceil() as i32; - let view_height = ((height as f64) / scale).ceil() as i32; - let (view_x, view_y) = self.canvas_view_origin(); - crate::util::Rect::new( - view_x.floor() as i32, - view_y.floor() as i32, - view_width, - view_height, - ) - .map(|rect| vec![rect]) - .unwrap_or_default() - } else { - damage_screen.clone() - }; - let scaled_damage = scale_damage_regions(damage_screen.clone(), scale); - let active_render_profile = self.input_state.active_render_profile().cloned(); - let remap_canvas = self.input_state.active_canvas_render_profile().is_some(); - let remap_ui = self.input_state.active_ui_render_profile().is_some(); - if let Some(breakdown) = render_breakdown.as_mut() { - breakdown.render_profile = PerfRenderProfileKind::from_flags(remap_canvas, remap_ui); - } - let stride = (phys_width * 4) as i32; - let canvas_len = phys_height as usize * stride as usize; - - // SAFETY: This unsafe block creates a Cairo surface from raw memory buffer. - // Safety invariants that must be maintained: - // 1. `canvas_ptr` comes from a valid SlotPool slice with exactly (width * height * 4) bytes - // 2. The buffer format ARgb32 matches the allocation (4 bytes per pixel: alpha, red, green, blue) - // 3. The stride (width * 4) correctly represents the number of bytes per row - // 4. `cairo_surface` and `ctx` are explicitly dropped before the buffer is committed to Wayland, - // ensuring Cairo doesn't access memory after ownership transfers - // 5. No other references to this memory exist during Cairo's usage - // 6. The buffer remains valid throughout Cairo's usage (enforced by Rust's borrow checker) - // Render using Cairo - let draw_start = std::time::Instant::now(); - let (cairo_surface, ctx) = record_stage!(cairo_surface, { - let cairo_surface = unsafe { - cairo::ImageSurface::create_for_data_unsafe( - canvas_ptr as *mut u8, - cairo::Format::ARgb32, - phys_width as i32, - phys_height as i32, - (phys_width * 4) as i32, - ) - .context("Failed to create Cairo surface") - }; - cairo_surface.and_then(|cairo_surface| { - let ctx = cairo::Context::new(&cairo_surface) - .context("Failed to create Cairo context")?; - Ok((cairo_surface, ctx)) - }) - })?; - - record_stage!(clear_clip, { - // Optimization: Clip drawing to the damage regions. - // This dramatically reduces CPU fill rate pressure on high-res screens by - // avoiding redraws of static content (which is preserved in the back-buffer). - // Note: Cairo works in logical coordinates if we scale it, but here we are - // pre-scale (identity transform). We must scale the logical damage rects to pixels. - if !damage_screen.is_empty() { - for rect in &damage_screen { - // Scale logical rect to physical pixels - let x = rect.x as f64 * scale as f64; - let y = rect.y as f64 * scale as f64; - let w = rect.width as f64 * scale as f64; - let h = rect.height as f64 * scale as f64; - ctx.rectangle(x, y, w, h); - } - ctx.clip(); - } - - // Clear with fully transparent background (only clears within clip) - debug!("Clearing background"); - ctx.set_operator(cairo::Operator::Clear); - ctx.paint().context("Failed to clear background")?; - ctx.set_operator(cairo::Operator::Over); - Ok::<(), anyhow::Error>(()) - })?; - - if render_canvas { - self.render_canvas_layer( - &ctx, - width, - height, - scale, - phys_width, - phys_height, - now, - &damage_world, - render_canvas_transients, - render_breakdown.as_mut(), - )?; - } - - let mut has_ui_baseline = false; - record_stage!(render_profile, { - if let Some(profile) = active_render_profile.as_ref() { - if remap_canvas && !remap_ui { - cairo_surface.flush(); - // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. - // Cairo has flushed all pending writes, so the rendered canvas pixels can be - // rewritten before UI is drawn on top. - let canvas = unsafe { - std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) - }; - profile.remap_argb8888_regions( - canvas, - phys_width as i32, - phys_height as i32, - stride, - &scaled_damage, - ); - cairo_surface.mark_dirty(); - } else if !remap_canvas && remap_ui && render_ui { - cairo_surface.flush(); - // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. - // Cairo has flushed all pending writes, so we can snapshot the canvas-only pixels in - // a reusable scratch buffer before drawing UI and later remap only bytes changed by - // the UI pass. - let canvas = unsafe { - std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) - }; - self.render.profile_ui_baseline_mut().resize(canvas_len, 0); - self.render - .profile_ui_baseline_mut() - .copy_from_slice(canvas); - has_ui_baseline = true; - } - } - }); - - record_stage!(ui, { - self.render_ui_layer(&ctx, width, height, scale, render_ui); - }); - - // Flush Cairo - debug!("Flushing Cairo surface"); - cairo_surface.flush(); - drop(ctx); - drop(cairo_surface); - - record_stage!(render_profile, { - if let Some(profile) = active_render_profile.as_ref() { - // SAFETY: `canvas_ptr` points to the SlotPool memory for the buffer created above. - // Cairo has been flushed and dropped, and the buffer has not been attached yet, so this - // is the only active mutable access to the rendered pixel bytes. - let canvas = - unsafe { std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) }; - if remap_canvas && remap_ui { - profile.remap_argb8888_regions( - canvas, - phys_width as i32, - phys_height as i32, - stride, - &scaled_damage, - ); - } else if !remap_canvas && remap_ui && has_ui_baseline { - profile.remap_argb8888_regions_changed_from( - canvas, - self.render.profile_ui_baseline(), - phys_width as i32, - phys_height as i32, - stride, - &scaled_damage, - ); - } - } - }); - - let draw_duration = draw_start.elapsed(); - if self.input_state.region_is_active() - && self - .input_state - .region_state() - .purpose() - .is_some_and(|purpose| purpose.is_capture()) - { - debug!( - "Region picker frame: logical={}x{}, physical={}x{}, scale={}, cairo_draw={:?}", - width, height, phys_width, phys_height, scale, draw_duration - ); - } - if draw_duration > std::time::Duration::from_millis(2) { - debug!("Cairo draw took {:?}", draw_duration); - } - - record_stage!(damage_commit, { - // Attach buffer and commit - debug!("Attaching buffer and committing surface"); - let wl_surface = self - .surface - .wl_surface() - .cloned() - .context("Surface not created")?; - wl_surface.set_buffer_scale(scale); - // `attach_to` marks the slot active until the compositor releases the - // buffer. Attaching the raw `wl_buffer()` instead leaves the slot free, - // so the pool hands the same memory back on the next frame and the next - // paint lands in the buffer the compositor is still reading - the whole - // swapchain collapses to one slot and partial damage resurfaces stale or - // half-drawn pixels. - buffer - .attach_to(&wl_surface) - .map_err(|err| anyhow::anyhow!("failed to attach the overlay buffer: {err}"))?; - - // Damage logic moved to top of function (add_regions and take_buffer_damage). - // We now use the computed screen-space damage for clipping and compositor hints. - - if debug_damage_logging_enabled() { - debug!( - "Damage (scaled): count={}, {}", - scaled_damage.len(), - damage_summary(&scaled_damage) - ); - } - - // Apply per-buffer damage regions for correct incremental rendering. - // Each buffer tracks damage since it was last displayed, avoiding stale pixels. - for region in &scaled_damage { - wl_surface.damage_buffer(region.x, region.y, region.width, region.height); - } - - let capture_generation = self.suppression.barrier.begin_main_surface_submission(); - if self.config.performance.enable_vsync { - debug!("Requesting frame callback (vsync enabled)"); - let callback = self - .surface - .begin_frame_callback(wl_surface.clone(), capture_generation); - wl_surface.frame(qh, callback); - } else if capture_generation.is_some() { - debug!("Requesting frame callback (preflight)"); - let callback = self - .surface - .begin_frame_callback(wl_surface.clone(), capture_generation); - wl_surface.frame(qh, callback); - } else { - debug!("Skipping frame callback (vsync disabled - allows back-to-back renders)"); - } - - self.commit_perf_frame( - PerfFrameDamageContext { - damage_screen: &damage_screen, - logical_width: width, - logical_height: height, - damage_rects: scaled_damage.len(), - force_full_reason: full_damage_reason, - diagnostics: damage_diagnostics, - }, - Instant::now(), - ); - wl_surface.commit(); - Ok::<(), anyhow::Error>(()) - })?; - debug!("=== RENDER COMPLETE ==="); - - // Render toolbar overlays if visible, only when state/hover changed. - record_stage!(toolbar, { - self.render_layer_toolbars_if_needed(); - }); - if let Some(breakdown) = render_breakdown { - self.record_perf_render_breakdown(breakdown); - } - - if self.suppression.capture_suppressed() { - self.capture.mark_preflight_rendered(); - } - Ok(RenderOutcome::Committed { keep_rendering }) - } - - fn advance_render_animations(&mut self, now: Instant) -> RenderAnimationState { - RenderAnimationState { - highlight: self.input_state.advance_click_highlights(now), - preset_feedback: self.input_state.advance_preset_feedback(now), - ui_toast: self.input_state.advance_ui_toast(now), - blocked_feedback: self.input_state.advance_blocked_feedback(now), - text_edit_entry: self.input_state.advance_text_edit_entry_feedback(now), - input_hud: self.input_state.advance_input_hud(now), - ocr_scan: self.input_state.advance_ocr_scan(now), - } - } - - #[allow(clippy::too_many_arguments)] - fn collect_frame_damage( - &mut self, - render_ui: bool, - animation: RenderAnimationState, - width: u32, - height: u32, - logical_width: i32, - logical_height: i32, - ) -> PerfDamageDiagnostics { - let input_damage_report = self.input_state.take_dirty_region_report(); - let input_damage = input_damage_report.regions; - let input_full_reason = input_full_damage_reason(input_damage_report.full_reason); - let diagnostics = PerfDamageDiagnostics { - input_regions: input_damage.len(), - input_full_reason, - input_covers_surface: damage_covers_logical_surface( - &input_damage, - logical_width, - logical_height, - ), - ..PerfDamageDiagnostics::default() - }; - let ui_effects = UiEffectFlags::default() - .with(UiEffect::UiToast, animation.ui_toast) - .with(UiEffect::PresetToast, animation.preset_feedback) - .with(UiEffect::TextEditEntry, animation.text_edit_entry) - .with( - UiEffect::StatusHud, - render_ui && self.input_state.ui_visibility.show_status_bar, - ) - .with(UiEffect::ZoomChip, render_ui && self.zoom_chip_visible()) - .with( - UiEffect::InputHud, - render_ui && self.input_state.input_hud_visible(), - ) - .with( - UiEffect::CommandPalette, - render_ui && self.input_state.command_palette_is_engaged(), - ) - .with( - UiEffect::ColorPicker, - render_ui && self.input_state.is_color_picker_popup_open(), - ) - .with( - UiEffect::ToolPreview, - render_ui && self.mouse_tool_preview_eligible(), - ) - .with( - UiEffect::ShapeMeasureBadge, - render_ui && !self.capture_picker_chrome_suppressed(), - ) - .with_blocked_feedback(animation.blocked_feedback); - let ui_effect_damage = self.collect_ui_effect_damage(ui_effects, width, height); - if let Some(reason) = self.render_force_full_damage_reason().or(input_full_reason) { - self.buffer_damage.mark_all_full(reason); - } else { - self.buffer_damage.add_regions(input_damage); - self.buffer_damage.add_regions(ui_effect_damage); - } - diagnostics - } - - fn render_force_full_damage_reason(&self) -> Option { - if self.zoom.active { - Some(FullDamageReason::Zoom) - } else if self.canvas_transform_active() { - Some(FullDamageReason::BoardPan) - } else if self - .spotlight - .needs_dim_washout(self.input_state.has_spotlight()) - { - // A spotlight darkens every pixel outside itself, so no partial - // damage rect can describe adding, moving, or removing one. The - // previous frame counts as well: after the last spotlight is deleted - // or undone the flag is already false, yet the buffer on screen still - // holds its dim layer and only the former opening would be redrawn. - Some(FullDamageReason::Spotlight) - } else { - None } + Ok(outcome) } } -fn input_full_damage_reason( - reason: Option, -) -> Option { - reason.map(|reason| match reason { - crate::draw::DirtyFullReason::CanvasClear => FullDamageReason::CanvasClear, - crate::draw::DirtyFullReason::FirstRunOnboarding => FullDamageReason::FirstRunOnboarding, - crate::draw::DirtyFullReason::InlineToolbar => FullDamageReason::InlineToolbar, - }) +/// The only entrance to preparation and painting. A busy slot leaves animation, +/// input damage and effect history untouched so the next real frame can drain them. +fn render_acquired_frame( + acquired: Option, + render: impl FnOnce(B) -> Result, +) -> Result { + let Some(buffer) = acquired else { + return Ok(RenderOutcome::BuffersInFlight); + }; + render(buffer).map(|keep_rendering| RenderOutcome::Committed { keep_rendering }) } + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/render/paint.rs b/src/backend/wayland/state/render/paint.rs new file mode 100644 index 00000000..cb27a79a --- /dev/null +++ b/src/backend/wayland/state/render/paint.rs @@ -0,0 +1,166 @@ +use super::plan::FramePlan; +use super::profile::{PixelBuffer, ProfileMode}; +use super::*; +use crate::backend::wayland::surface::AcquiredBuffer; + +impl WaylandState { + pub(super) fn paint_frame( + &mut self, + plan: &FramePlan, + acquired: &AcquiredBuffer, + breakdown: &mut Option, + ) -> Result<()> { + let canvas_ptr = acquired.canvas_ptr; + let FrameGeometry { + width, + height, + scale, + physical_width: phys_width, + physical_height: phys_height, + .. + } = plan.geometry; + let damage_screen = &plan.damage.screen; + let render_canvas = plan.render_canvas; + let render_ui = plan.render_ui; + // The acquired SHM slot supplies width * height * 4 bytes in ARgb32. + // It stays owned by this render attempt until submission. All Cairo + // handles are dropped before attach_to marks the slot in flight. + let draw_start = std::time::Instant::now(); + let (cairo_surface, ctx) = record_stage!(breakdown, cairo_surface, { + // SAFETY: the unsubmitted slot owns the backing memory throughout + // painting; geometry and stride match its acquisition parameters. + let cairo_surface = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + canvas_ptr as *mut u8, + cairo::Format::ARgb32, + phys_width as i32, + phys_height as i32, + plan.geometry.stride, + ) + .context("Failed to create Cairo surface") + }; + cairo_surface.and_then(|cairo_surface| { + let ctx = cairo::Context::new(&cairo_surface) + .context("Failed to create Cairo context")?; + Ok((cairo_surface, ctx)) + }) + })?; + + record_stage!(breakdown, clear_clip, { + // Optimization: Clip drawing to the damage regions. + // This dramatically reduces CPU fill rate pressure on high-res screens by + // avoiding redraws of static content (which is preserved in the back-buffer). + // Note: Cairo works in logical coordinates if we scale it, but here we are + // pre-scale (identity transform). We must scale the logical damage rects to pixels. + if !damage_screen.is_empty() { + for rect in damage_screen { + // Scale logical rect to physical pixels + let x = rect.x as f64 * scale as f64; + let y = rect.y as f64 * scale as f64; + let w = rect.width as f64 * scale as f64; + let h = rect.height as f64 * scale as f64; + ctx.rectangle(x, y, w, h); + } + ctx.clip(); + } + + // Clear with fully transparent background (only clears within clip) + debug!("Clearing background"); + ctx.set_operator(cairo::Operator::Clear); + ctx.paint().context("Failed to clear background")?; + ctx.set_operator(cairo::Operator::Over); + Ok::<(), anyhow::Error>(()) + })?; + + if render_canvas { + self.render_canvas_layer( + &canvas::CanvasRenderCtx { + cairo: &ctx, + geometry: &plan.geometry, + canvas: &plan.canvas, + damage_world: &plan.damage.world, + now: plan.now, + }, + breakdown.as_mut(), + )?; + } + + record_stage!(breakdown, render_profile, { + if plan.profile.needs_before_ui(render_ui) { + cairo_surface.flush(); + // SAFETY: the acquired slot remains exclusive to this paint pass. + // Cairo is flushed and the temporary slice ends before UI painting. + let data = unsafe { + std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, plan.geometry.byte_len) + }; + let rewritten = plan.profile.before_ui( + PixelBuffer { + data, + width: phys_width as i32, + height: phys_height as i32, + stride: plan.geometry.stride, + damage: &plan.damage.buffer, + }, + self.render.profile_ui_baseline_mut(), + render_ui, + ); + if rewritten { + cairo_surface.mark_dirty(); + } + } + }); + + record_stage!(breakdown, ui, { + self.render_ui_layer(&ctx, width, height, scale, render_ui); + }); + + // Flush Cairo + debug!("Flushing Cairo surface"); + cairo_surface.flush(); + drop(ctx); + drop(cairo_surface); + + record_stage!(breakdown, render_profile, { + if matches!( + plan.profile.mode(), + ProfileMode::Ui | ProfileMode::CanvasAndUi + ) { + // SAFETY: Cairo is flushed and dropped, and the slot has not been + // attached. This is the only access to its pixel bytes. + let data = unsafe { + std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, plan.geometry.byte_len) + }; + plan.profile.after_ui( + PixelBuffer { + data, + width: phys_width as i32, + height: phys_height as i32, + stride: plan.geometry.stride, + damage: &plan.damage.buffer, + }, + self.render.profile_ui_baseline(), + render_ui, + ); + } + }); + + let draw_duration = draw_start.elapsed(); + if self.input_state.region_is_active() + && self + .input_state + .region_state() + .purpose() + .is_some_and(|purpose| purpose.is_capture()) + { + debug!( + "Region picker frame: logical={}x{}, physical={}x{}, scale={}, cairo_draw={:?}", + width, height, phys_width, phys_height, scale, draw_duration + ); + } + if draw_duration > std::time::Duration::from_millis(2) { + debug!("Cairo draw took {:?}", draw_duration); + } + + Ok(()) + } +} diff --git a/src/backend/wayland/state/render/plan.rs b/src/backend/wayland/state/render/plan.rs new file mode 100644 index 00000000..96372558 --- /dev/null +++ b/src/backend/wayland/state/render/plan.rs @@ -0,0 +1,159 @@ +use std::time::Instant; + +use crate::util::Rect; + +use super::super::{ + FullDamageReason, OverlaySuppression, PerfDamageDiagnostics, scale_damage_regions, +}; +use super::profile::FrameProfile; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct FrameGeometry { + pub(super) width: u32, + pub(super) height: u32, + pub(super) scale: i32, + pub(super) physical_width: u32, + pub(super) physical_height: u32, + pub(super) stride: i32, + pub(super) byte_len: usize, +} + +impl FrameGeometry { + pub(super) fn new(width: u32, height: u32, scale: i32) -> Self { + let scale = scale.max(1); + let physical_width = width.saturating_mul(scale as u32); + let physical_height = height.saturating_mul(scale as u32); + let stride = (physical_width * 4) as i32; + let byte_len = physical_height as usize * stride as usize; + Self { + width, + height, + scale, + physical_width, + physical_height, + stride, + byte_len, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct FrameVisibility { + pub(super) render_canvas: bool, + pub(super) render_transients: bool, + pub(super) render_ui: bool, +} + +impl FrameVisibility { + pub(super) fn new(suppression: OverlaySuppression, board_is_transparent: bool) -> Self { + let suppression = suppression.effective_for_board(board_is_transparent); + Self { + render_canvas: suppression.renders_canvas(), + render_transients: suppression.renders_canvas_transients(), + render_ui: suppression.renders_ui(), + } + } +} + +pub(super) struct CanvasPolicyInputs { + pub(super) capture_picker_active: bool, + pub(super) include_drawings: bool, + pub(super) transform_active: bool, + pub(super) origin: (f64, f64), + pub(super) zoom_scale: Option, + pub(super) text_halo_enabled: bool, + pub(super) layer_cache_usable: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct CanvasFrame { + pub(super) draw_committed: bool, + pub(super) render_transients: bool, + pub(super) transform_active: bool, + pub(super) origin: (f64, f64), + pub(super) zoom_scale: Option, + pub(super) text_halo_enabled: bool, + pub(super) layer_cache_eligible: bool, +} + +/// Values sampled by preparation after successful buffer acquisition. +/// Slot history and empty-damage fallback remain with the mutable preparer. +pub(super) struct PreparedFrame { + pub(super) geometry: FrameGeometry, + pub(super) visibility: FrameVisibility, + pub(super) canvas: CanvasPolicyInputs, + pub(super) damage_screen: Vec, + pub(super) full_damage_reason: Option, + pub(super) damage_diagnostics: PerfDamageDiagnostics, + pub(super) profile: FrameProfile, + pub(super) now: Instant, + pub(super) keep_rendering: bool, +} + +pub(super) struct FrameDamage { + pub(super) screen: Vec, + pub(super) world: Vec, + pub(super) buffer: Vec, + pub(super) full_reason: Option, + pub(super) diagnostics: PerfDamageDiagnostics, +} + +pub(super) struct FramePlan { + pub(super) geometry: FrameGeometry, + pub(super) canvas: CanvasFrame, + pub(super) render_canvas: bool, + pub(super) render_ui: bool, + pub(super) damage: FrameDamage, + pub(super) profile: FrameProfile, + pub(super) now: Instant, + pub(super) keep_rendering: bool, +} + +/// Derives paint and submission values without advancing runtime state. +pub(super) fn plan_frame(prepared: PreparedFrame) -> FramePlan { + let inputs = prepared.canvas; + let canvas = CanvasFrame { + draw_committed: !inputs.capture_picker_active || inputs.include_drawings, + render_transients: prepared.visibility.render_transients && !inputs.capture_picker_active, + transform_active: inputs.transform_active, + origin: inputs.origin, + zoom_scale: inputs.zoom_scale, + text_halo_enabled: inputs.text_halo_enabled, + layer_cache_eligible: inputs.layer_cache_usable && !inputs.capture_picker_active, + }; + let world = if canvas.transform_active { + let zoom = canvas.zoom_scale.unwrap_or(1.0).max(f64::MIN_POSITIVE); + let view_width = (f64::from(prepared.geometry.width) / zoom).ceil() as i32; + let view_height = (f64::from(prepared.geometry.height) / zoom).ceil() as i32; + Rect::new( + canvas.origin.0.floor() as i32, + canvas.origin.1.floor() as i32, + view_width, + view_height, + ) + .map(|rect| vec![rect]) + .unwrap_or_default() + } else { + prepared.damage_screen.clone() + }; + let buffer = scale_damage_regions(prepared.damage_screen.clone(), prepared.geometry.scale); + FramePlan { + geometry: prepared.geometry, + canvas, + render_canvas: prepared.visibility.render_canvas, + render_ui: prepared.visibility.render_ui, + damage: FrameDamage { + screen: prepared.damage_screen, + world, + buffer, + full_reason: prepared.full_damage_reason, + diagnostics: prepared.damage_diagnostics, + }, + profile: prepared.profile, + now: prepared.now, + keep_rendering: prepared.keep_rendering, + } +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/render/plan/tests.rs b/src/backend/wayland/state/render/plan/tests.rs new file mode 100644 index 00000000..e8a8639e --- /dev/null +++ b/src/backend/wayland/state/render/plan/tests.rs @@ -0,0 +1,216 @@ +use super::*; + +fn rect(x: i32, y: i32, width: i32, height: i32) -> Rect { + Rect::new(x, y, width, height).expect("positive test rectangle") +} + +fn prepared() -> PreparedFrame { + PreparedFrame { + geometry: FrameGeometry::new(101, 79, 2), + visibility: FrameVisibility::new(OverlaySuppression::None, true), + canvas: CanvasPolicyInputs { + capture_picker_active: false, + include_drawings: true, + transform_active: false, + origin: (0.0, 0.0), + zoom_scale: None, + text_halo_enabled: true, + layer_cache_usable: true, + }, + damage_screen: vec![rect(3, 7, 11, 13), rect(70, 50, 9, 8)], + full_damage_reason: None, + damage_diagnostics: PerfDamageDiagnostics::default(), + profile: FrameProfile::new(None, false, false), + now: Instant::now(), + keep_rendering: false, + } +} + +#[test] +fn untransformed_damage_preserves_screen_regions_and_scales_only_buffer_regions() { + let plan = plan_frame(prepared()); + assert_eq!( + plan.damage.screen, + vec![rect(3, 7, 11, 13), rect(70, 50, 9, 8)] + ); + assert_eq!(plan.damage.world, plan.damage.screen); + assert_eq!( + plan.damage.buffer, + vec![rect(6, 14, 22, 26), rect(140, 100, 18, 16)] + ); +} + +#[test] +fn pan_uses_world_viewport_while_compositor_damage_stays_in_screen_space() { + let mut input = prepared(); + input.canvas.transform_active = true; + input.canvas.origin = (-12.25, 8.75); + let plan = plan_frame(input); + assert_eq!(plan.damage.world, vec![rect(-13, 8, 101, 79)]); + assert_eq!(plan.damage.screen[0], rect(3, 7, 11, 13)); + assert_eq!(plan.damage.buffer[0], rect(6, 14, 22, 26)); + assert_eq!(plan.canvas.origin, (-12.25, 8.75)); +} + +#[test] +fn fractional_zoom_rounds_world_extent_up_and_negative_origin_down() { + for (zoom, width, height) in [(1.5, 68, 53), (0.75, 135, 106)] { + let mut input = prepared(); + input.canvas.transform_active = true; + input.canvas.origin = (-0.1, -40.01); + input.canvas.zoom_scale = Some(zoom); + let plan = plan_frame(input); + assert_eq!(plan.damage.world, vec![rect(-1, -41, width, height)]); + assert_eq!(plan.canvas.zoom_scale, Some(zoom)); + assert_eq!(plan.damage.buffer[0], rect(6, 14, 22, 26)); + } +} + +#[test] +fn nonpositive_zoom_preserves_saturated_world_extent_behavior() { + for zoom in [0.0, -1.0] { + let mut input = prepared(); + input.canvas.transform_active = true; + input.canvas.zoom_scale = Some(zoom); + let plan = plan_frame(input); + assert_eq!(plan.damage.world, vec![rect(0, 0, i32::MAX, i32::MAX)]); + } +} + +#[test] +fn empty_damage_is_not_replaced_or_given_a_new_full_damage_reason() { + let mut input = prepared(); + input.damage_screen.clear(); + let plan = plan_frame(input); + assert!(plan.damage.screen.is_empty()); + assert!(plan.damage.world.is_empty()); + assert!(plan.damage.buffer.is_empty()); + assert_eq!(plan.damage.full_reason, None); +} + +#[test] +fn transformed_world_viewport_does_not_depend_on_screen_damage() { + let mut input = prepared(); + input.damage_screen.clear(); + input.canvas.transform_active = true; + let plan = plan_frame(input); + assert_eq!(plan.damage.world, vec![rect(0, 0, 101, 79)]); + assert!(plan.damage.screen.is_empty()); + assert!(plan.damage.buffer.is_empty()); +} + +#[test] +fn zero_dimensions_do_not_create_a_world_viewport() { + for (width, height) in [(0, 79), (101, 0), (0, 0)] { + let mut input = prepared(); + input.geometry = FrameGeometry::new(width, height, 2); + input.canvas.transform_active = true; + input.damage_screen.clear(); + let plan = plan_frame(input); + assert!(plan.damage.world.is_empty()); + assert_eq!(plan.geometry.byte_len, 0); + } +} + +#[test] +fn geometry_normalizes_buffer_scale_and_preserves_argb_layout() { + for scale in [-2, 0, 1] { + assert_eq!( + FrameGeometry::new(101, 79, scale), + FrameGeometry { + width: 101, + height: 79, + scale: 1, + physical_width: 101, + physical_height: 79, + stride: 404, + byte_len: 31_916, + } + ); + } + let geometry = FrameGeometry::new(101, 79, 2); + assert_eq!( + (geometry.physical_width, geometry.physical_height), + (202, 158) + ); + assert_eq!(geometry.stride, 808); + assert_eq!(geometry.byte_len, 127_664); +} + +#[test] +fn suppression_policy_covers_transparent_and_opaque_boards() { + use OverlaySuppression::*; + for transparent in [false, true] { + for (suppression, expected) in [ + (None, (true, true, true)), + (Capture, (true, false, false)), + (DesktopBackdrop, (false, false, false)), + (ExternalDialog, (false, false, false)), + (Frozen, (false, false, false)), + (Zoom, (!transparent, !transparent, !transparent)), + ] { + let mut input = prepared(); + input.visibility = FrameVisibility::new(suppression, transparent); + let plan = plan_frame(input); + assert_eq!( + ( + plan.render_canvas, + plan.canvas.render_transients, + plan.render_ui + ), + expected, + "suppression={suppression:?}, transparent={transparent}" + ); + } + } +} + +#[test] +fn picker_drawings_option_controls_committed_shapes_but_disables_transients_and_cache() { + for (picker, include_drawings, committed, transients, cache) in [ + (false, false, true, true, true), + (false, true, true, true, true), + (true, false, false, false, false), + (true, true, true, false, false), + ] { + let mut input = prepared(); + input.canvas.capture_picker_active = picker; + input.canvas.include_drawings = include_drawings; + let plan = plan_frame(input); + assert_eq!(plan.canvas.draw_committed, committed); + assert_eq!(plan.canvas.render_transients, transients); + assert_eq!(plan.canvas.layer_cache_eligible, cache); + } +} + +#[test] +fn unavailable_cache_and_suppressed_transients_remain_disabled_outside_picker() { + let mut input = prepared(); + input.canvas.layer_cache_usable = false; + input.visibility = FrameVisibility::new(OverlaySuppression::Capture, true); + let plan = plan_frame(input); + assert!(plan.canvas.draw_committed); + assert!(!plan.canvas.layer_cache_eligible); + assert!(!plan.canvas.render_transients); +} + +#[test] +fn planning_preserves_prepared_damage_diagnostics_and_animation_values() { + let mut input = prepared(); + input.full_damage_reason = Some(FullDamageReason::EmptyDamageFallback); + input.damage_diagnostics.input_regions = 7; + input.damage_diagnostics.buffer_regions_before_merge = 9; + input.keep_rendering = true; + input.canvas.text_halo_enabled = false; + let diagnostics = input.damage_diagnostics; + let now = input.now; + let plan = plan_frame(input); + assert_eq!( + plan.damage.full_reason, + Some(FullDamageReason::EmptyDamageFallback) + ); + assert_eq!(plan.damage.diagnostics, diagnostics); + assert_eq!(plan.now, now); + assert!(plan.keep_rendering); + assert!(!plan.canvas.text_halo_enabled); +} diff --git a/src/backend/wayland/state/render/prepare.rs b/src/backend/wayland/state/render/prepare.rs new file mode 100644 index 00000000..cfe1d612 --- /dev/null +++ b/src/backend/wayland/state/render/prepare.rs @@ -0,0 +1,241 @@ +use super::plan::{CanvasPolicyInputs, PreparedFrame}; +use super::profile::FrameProfile; +use super::*; +use crate::backend::wayland::state::buffer_damage::{BufferDamageReport, BufferDamageTracker}; +use crate::backend::wayland::surface::AcquiredBuffer; + +#[derive(Clone, Copy)] +struct RenderAnimationState { + highlight: bool, + preset_feedback: bool, + ui_toast: bool, + blocked_feedback: bool, + text_edit_entry: bool, + input_hud: bool, + ocr_scan: bool, +} + +impl RenderAnimationState { + fn any_active(self) -> bool { + [ + self.highlight, + self.preset_feedback, + self.ui_toast, + self.blocked_feedback, + self.text_edit_entry, + self.input_hud, + self.ocr_scan, + ] + .into_iter() + .any(|active| active) + } +} + +impl WaylandState { + pub(super) fn prepare_frame( + &mut self, + geometry: FrameGeometry, + visibility: FrameVisibility, + acquired: &AcquiredBuffer, + breakdown: &mut Option, + ) -> PreparedFrame { + let width = geometry.width; + let height = geometry.height; + self.surface.update_pool_size(acquired.pool_size); + + let now = Instant::now(); + let animation_state = record_stage!(breakdown, advance_animations, { + self.advance_render_animations(now) + }); + let ui_animation_active = animation_state.any_active(); + self.ui_animation.schedule(now, ui_animation_active); + let keep_rendering = ui_animation_active && self.ui_animation.is_uncapped(); + + // Add new dirty regions from input state to the per-buffer damage + // tracker. This runs after the buffer is acquired but before its damage + // is drained below, so the current frame's changes are included in the + // damage reported for this slot. + let logical_width = width.min(i32::MAX as u32) as i32; + let logical_height = height.min(i32::MAX as u32) as i32; + let mut damage_diagnostics = record_stage!(breakdown, dirty_collect, { + self.collect_frame_damage(visibility.render_ui, animation_state, &geometry) + }); + + // Take damage for this buffer slot (identified by canvas memory address). + // Pool identity (generation + size) is passed to detect pool recreation/growth. + // SlotPool reuses the same memory regions for released buffers, so the + // canvas pointer serves as a stable slot identifier across buffer reuse. + let damage_report = take_frame_damage( + &mut self.buffer_damage, + &geometry, + acquired.canvas_ptr, + acquired.pool_generation, + acquired.pool_size, + ); + damage_diagnostics.buffer_regions_before_merge = damage_report.regions_before_merge; + damage_diagnostics.buffer_regions_after_merge = damage_report.regions_after_merge; + let full_damage_reason = damage_report.full_reason; + let damage_screen = damage_report.regions; + damage_diagnostics.buffer_covers_surface = + damage_covers_logical_surface(&damage_screen, logical_width, logical_height); + let profile = self.input_state.active_render_profile().cloned(); + let remap_canvas = self.input_state.active_canvas_render_profile().is_some(); + let remap_ui = self.input_state.active_ui_render_profile().is_some(); + if let Some(breakdown) = breakdown.as_mut() { + breakdown.render_profile = PerfRenderProfileKind::from_flags(remap_canvas, remap_ui); + } + PreparedFrame { + geometry, + visibility, + canvas: CanvasPolicyInputs { + capture_picker_active: self.capture_picker_chrome_suppressed(), + include_drawings: self.region_picker_include_drawings(), + transform_active: self.canvas_transform_active(), + origin: self.canvas_view_origin(), + zoom_scale: self.zoom.active.then_some(self.zoom.scale), + text_halo_enabled: self.config.drawing.text_halo_enabled, + layer_cache_usable: self.canvas_layer_cache_usable(), + }, + damage_screen, + full_damage_reason, + damage_diagnostics, + profile: FrameProfile::new(profile, remap_canvas, remap_ui), + now, + keep_rendering, + } + } + + fn advance_render_animations(&mut self, now: Instant) -> RenderAnimationState { + RenderAnimationState { + highlight: self.input_state.advance_click_highlights(now), + preset_feedback: self.input_state.advance_preset_feedback(now), + ui_toast: self.input_state.advance_ui_toast(now), + blocked_feedback: self.input_state.advance_blocked_feedback(now), + text_edit_entry: self.input_state.advance_text_edit_entry_feedback(now), + input_hud: self.input_state.advance_input_hud(now), + ocr_scan: self.input_state.advance_ocr_scan(now), + } + } + + fn collect_frame_damage( + &mut self, + render_ui: bool, + animation: RenderAnimationState, + geometry: &FrameGeometry, + ) -> PerfDamageDiagnostics { + let logical_width = geometry.width.min(i32::MAX as u32) as i32; + let logical_height = geometry.height.min(i32::MAX as u32) as i32; + let input_damage_report = self.input_state.take_dirty_region_report(); + let input_damage = input_damage_report.regions; + let input_full_reason = input_full_damage_reason(input_damage_report.full_reason); + let diagnostics = PerfDamageDiagnostics { + input_regions: input_damage.len(), + input_full_reason, + input_covers_surface: damage_covers_logical_surface( + &input_damage, + logical_width, + logical_height, + ), + ..PerfDamageDiagnostics::default() + }; + let ui_effects = UiEffectFlags::default() + .with(UiEffect::UiToast, animation.ui_toast) + .with(UiEffect::PresetToast, animation.preset_feedback) + .with(UiEffect::TextEditEntry, animation.text_edit_entry) + .with( + UiEffect::StatusHud, + render_ui && self.input_state.ui_visibility.show_status_bar, + ) + .with(UiEffect::ZoomChip, render_ui && self.zoom_chip_visible()) + .with( + UiEffect::InputHud, + render_ui && self.input_state.input_hud_visible(), + ) + .with( + UiEffect::CommandPalette, + render_ui && self.input_state.command_palette_is_engaged(), + ) + .with( + UiEffect::ColorPicker, + render_ui && self.input_state.is_color_picker_popup_open(), + ) + .with( + UiEffect::ToolPreview, + render_ui && self.mouse_tool_preview_eligible(), + ) + .with( + UiEffect::ShapeMeasureBadge, + render_ui && !self.capture_picker_chrome_suppressed(), + ) + .with_blocked_feedback(animation.blocked_feedback); + let ui_effect_damage = + self.collect_ui_effect_damage(ui_effects, geometry.width, geometry.height); + if let Some(reason) = self.render_force_full_damage_reason().or(input_full_reason) { + self.buffer_damage.mark_all_full(reason); + } else { + self.buffer_damage.add_regions(input_damage); + self.buffer_damage.add_regions(ui_effect_damage); + } + diagnostics + } + + fn render_force_full_damage_reason(&self) -> Option { + if self.zoom.active { + Some(FullDamageReason::Zoom) + } else if self.canvas_transform_active() { + Some(FullDamageReason::BoardPan) + } else if self + .spotlight + .needs_dim_washout(self.input_state.has_spotlight()) + { + // A spotlight darkens every pixel outside itself, so no partial + // damage rect can describe adding, moving, or removing one. The + // previous frame counts as well: after the last spotlight is deleted + // or undone the flag is already false, yet the buffer on screen still + // holds its dim layer and only the former opening would be redrawn. + Some(FullDamageReason::Spotlight) + } else { + None + } + } +} + +fn take_frame_damage( + tracker: &mut BufferDamageTracker, + geometry: &FrameGeometry, + slot_id: usize, + pool_generation: u64, + pool_size: usize, +) -> BufferDamageReport { + let logical_width = geometry.width.min(i32::MAX as u32) as i32; + let logical_height = geometry.height.min(i32::MAX as u32) as i32; + let mut report = tracker.take_buffer_damage_report( + slot_id, + logical_width, + logical_height, + pool_generation, + pool_size, + ); + if report.regions.is_empty() + && let Some(full) = crate::util::Rect::new(0, 0, logical_width, logical_height) + { + report.regions = vec![full]; + report.full_reason = Some(FullDamageReason::EmptyDamageFallback); + tracker.mark_all_full(FullDamageReason::EmptyDamageFallback); + } + // Merge diagnostics describe the tracker result before the fallback above. + report +} + +fn input_full_damage_reason( + reason: Option, +) -> Option { + reason.map(|reason| match reason { + crate::draw::DirtyFullReason::CanvasClear => FullDamageReason::CanvasClear, + crate::draw::DirtyFullReason::FirstRunOnboarding => FullDamageReason::FirstRunOnboarding, + crate::draw::DirtyFullReason::InlineToolbar => FullDamageReason::InlineToolbar, + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/render/prepare/tests.rs b/src/backend/wayland/state/render/prepare/tests.rs new file mode 100644 index 00000000..e5f83318 --- /dev/null +++ b/src/backend/wayland/state/render/prepare/tests.rs @@ -0,0 +1,70 @@ +use super::*; +use crate::util::Rect; + +const GENERATION: u64 = 1; +const POOL_SIZE: usize = 4096; + +#[test] +fn empty_damage_fallback_reaches_every_reused_slot_and_retains_merge_counts() { + let geometry = FrameGeometry::new(800, 600, 1); + let full = Rect::new(0, 0, 800, 600).unwrap(); + let mut tracker = BufferDamageTracker::new(3); + for slot in [1, 2, 3] { + let report = take_frame_damage(&mut tracker, &geometry, slot, GENERATION, POOL_SIZE); + assert_eq!(report.regions, [full]); + } + + let report = take_frame_damage(&mut tracker, &geometry, 1, GENERATION, POOL_SIZE); + assert_eq!(report.regions, [full]); + assert_eq!( + report.full_reason, + Some(FullDamageReason::EmptyDamageFallback) + ); + assert_eq!( + (report.regions_before_merge, report.regions_after_merge), + (0, 0) + ); + + // Both lagging slots and the triggering slot retain the global fallback. + for slot in [2, 3, 1] { + let report = take_frame_damage(&mut tracker, &geometry, slot, GENERATION, POOL_SIZE); + assert_eq!(report.regions, [full], "slot {slot}"); + assert_eq!( + report.full_reason, + Some(FullDamageReason::EmptyDamageFallback) + ); + assert_eq!( + (report.regions_before_merge, report.regions_after_merge), + (0, 0) + ); + } + + // Once full damage has drained, real merging and its diagnostics resume. + let dirty = Rect::new(10, 10, 20, 20).unwrap(); + tracker.add_regions(vec![dirty, dirty]); + for slot in [1, 2, 3] { + let report = take_frame_damage(&mut tracker, &geometry, slot, GENERATION, POOL_SIZE); + assert_eq!(report.regions, [dirty]); + assert_eq!(report.full_reason, None); + assert_eq!( + (report.regions_before_merge, report.regions_after_merge), + (2, 1) + ); + } +} + +#[test] +fn empty_surface_does_not_manufacture_full_damage_fallback() { + let geometry = FrameGeometry::new(0, 600, 1); + let mut tracker = BufferDamageTracker::new(1); + let first = take_frame_damage(&mut tracker, &geometry, 1, GENERATION, POOL_SIZE); + assert!(first.regions.is_empty()); + assert_eq!(first.full_reason, Some(FullDamageReason::InitialFrame)); + let reused = take_frame_damage(&mut tracker, &geometry, 1, GENERATION, POOL_SIZE); + assert!(reused.regions.is_empty()); + assert_eq!(reused.full_reason, None); + assert_eq!( + (reused.regions_before_merge, reused.regions_after_merge), + (0, 0) + ); +} diff --git a/src/backend/wayland/state/render/profile.rs b/src/backend/wayland/state/render/profile.rs new file mode 100644 index 00000000..7f469510 --- /dev/null +++ b/src/backend/wayland/state/render/profile.rs @@ -0,0 +1,112 @@ +use crate::render_profiles::RenderColorProfile; +use crate::util::Rect; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ProfileMode { + Off, + Canvas, + Ui, + CanvasAndUi, +} + +/// The selected profile is a frame value; reusable baseline storage stays in RenderRuntime. +#[derive(Debug)] +pub(super) struct FrameProfile { + profile: Option, + mode: ProfileMode, +} + +/// A flushed ARGB8888 target and its physical damage regions. +pub(super) struct PixelBuffer<'a> { + pub data: &'a mut [u8], + pub width: i32, + pub height: i32, + pub stride: i32, + pub damage: &'a [Rect], +} + +impl FrameProfile { + pub(super) fn new( + profile: Option, + remap_canvas: bool, + remap_ui: bool, + ) -> Self { + let mode = if profile.is_none() { + ProfileMode::Off + } else { + match (remap_canvas, remap_ui) { + (false, false) => ProfileMode::Off, + (true, false) => ProfileMode::Canvas, + (false, true) => ProfileMode::Ui, + (true, true) => ProfileMode::CanvasAndUi, + } + }; + Self { profile, mode } + } + + pub(super) fn mode(&self) -> ProfileMode { + self.mode + } + + pub(super) fn needs_before_ui(&self, render_ui: bool) -> bool { + self.mode == ProfileMode::Canvas || (self.mode == ProfileMode::Ui && render_ui) + } + + /// Returns whether Cairo must be notified of rewritten canvas pixels. + pub(super) fn before_ui( + &self, + pixels: PixelBuffer<'_>, + baseline: &mut Vec, + render_ui: bool, + ) -> bool { + let Some(profile) = self.profile.as_ref() else { + return false; + }; + match self.mode { + ProfileMode::Canvas => { + profile.remap_argb8888_regions( + pixels.data, + pixels.width, + pixels.height, + pixels.stride, + pixels.damage, + ); + true + } + ProfileMode::Ui if render_ui => { + baseline.resize(pixels.data.len(), 0); + baseline.copy_from_slice(pixels.data); + false + } + _ => false, + } + } + + /// Called after UI painting with the same visibility decision used by `before_ui`. + pub(super) fn after_ui(&self, pixels: PixelBuffer<'_>, baseline: &[u8], render_ui: bool) { + let Some(profile) = self.profile.as_ref() else { + return; + }; + match self.mode { + ProfileMode::CanvasAndUi => profile.remap_argb8888_regions( + pixels.data, + pixels.width, + pixels.height, + pixels.stride, + pixels.damage, + ), + ProfileMode::Ui if render_ui => profile.remap_argb8888_regions_changed_from( + pixels.data, + baseline, + pixels.width, + pixels.height, + pixels.stride, + pixels.damage, + ), + _ => {} + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/render/profile/tests.rs b/src/backend/wayland/state/render/profile/tests.rs new file mode 100644 index 00000000..ea2ae72e --- /dev/null +++ b/src/backend/wayland/state/render/profile/tests.rs @@ -0,0 +1,145 @@ +use super::*; +use crate::config::{RenderColorMappingConfig, RenderProfileConfig}; + +const RED: u32 = 0xffff0000; +const GREEN: u32 = 0xff00ff00; +const BLUE: u32 = 0xff0000ff; + +fn selected_profile() -> RenderColorProfile { + RenderColorProfile::from_config(&RenderProfileConfig { + id: "test".into(), + name: "Test".into(), + mappings: vec![ + RenderColorMappingConfig { + from: "#ff0000".into(), + to: "#00ff00".into(), + }, + RenderColorMappingConfig { + from: "#00ff00".into(), + to: "#0000ff".into(), + }, + ], + }) + .expect("valid test profile") +} + +fn canvas() -> cairo::ImageSurface { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 1).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.paint().unwrap(); + ctx.set_source_rgb(0.0, 0.0, 1.0); + ctx.rectangle(1.0, 0.0, 1.0, 1.0); + ctx.fill().unwrap(); + surface +} + +fn pixels<'a>(data: &'a mut [u8], damage: &'a [Rect]) -> PixelBuffer<'a> { + PixelBuffer { + data, + width: 4, + height: 1, + stride: 16, + damage, + } +} + +fn read(surface: &mut cairo::ImageSurface) -> Vec { + surface + .data() + .unwrap() + .as_chunks::<4>() + .0 + .iter() + .map(|bytes| u32::from_ne_bytes(*bytes)) + .collect() +} + +#[test] +fn profile_passes_preserve_canvas_and_ui_separation_with_partial_damage() { + let damage = [Rect::new(0, 0, 3, 1).unwrap()]; + for (canvas_enabled, ui_enabled, before, after) in [ + (false, false, [RED, BLUE, RED, RED], [RED, RED, RED, RED]), + ( + true, + false, + [GREEN, BLUE, GREEN, RED], + [GREEN, RED, GREEN, RED], + ), + (false, true, [RED, BLUE, RED, RED], [RED, GREEN, RED, RED]), + ( + true, + true, + [RED, BLUE, RED, RED], + [GREEN, GREEN, GREEN, RED], + ), + ] { + let profile = FrameProfile::new(Some(selected_profile()), canvas_enabled, ui_enabled); + let mut surface = canvas(); + let mut baseline = Vec::new(); + let rewritten = profile.needs_before_ui(true) + && profile.before_ui( + pixels(&mut surface.data().unwrap(), &damage), + &mut baseline, + true, + ); + assert_eq!(rewritten, canvas_enabled && !ui_enabled); + assert_eq!(read(&mut surface), before); + // UI paints an opaque red pixel over the blue canvas pixel. + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.rectangle(1.0, 0.0, 1.0, 1.0); + ctx.fill().unwrap(); + } + profile.after_ui( + pixels(&mut surface.data().unwrap(), &damage), + &baseline, + true, + ); + assert_eq!(read(&mut surface), after); + } +} + +#[test] +fn suppressed_ui_does_not_copy_or_remap_a_stale_baseline() { + let profile = FrameProfile::new(Some(selected_profile()), false, true); + let damage = [Rect::new(0, 0, 4, 1).unwrap()]; + let mut surface = canvas(); + let mut baseline = vec![0; 16]; + assert!(!profile.needs_before_ui(false)); + assert!(!profile.before_ui( + pixels(&mut surface.data().unwrap(), &damage), + &mut baseline, + false, + )); + assert_eq!(baseline, vec![0; 16]); + profile.after_ui( + pixels(&mut surface.data().unwrap(), &damage), + &baseline, + false, + ); + assert_eq!(read(&mut surface), [RED, BLUE, RED, RED]); +} + +#[test] +fn absent_profile_does_not_remap_even_when_both_targets_are_enabled() { + let profile = FrameProfile::new(None, true, true); + let damage = [Rect::new(0, 0, 4, 1).unwrap()]; + let mut surface = canvas(); + let mut baseline = vec![7]; + assert_eq!(profile.mode(), ProfileMode::Off); + assert!(!profile.needs_before_ui(true)); + assert!(!profile.before_ui( + pixels(&mut surface.data().unwrap(), &damage), + &mut baseline, + true, + )); + profile.after_ui( + pixels(&mut surface.data().unwrap(), &damage), + &baseline, + true, + ); + assert_eq!(baseline, [7]); + assert_eq!(read(&mut surface), [RED, BLUE, RED, RED]); +} diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 56369028..f036a7a2 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -159,6 +159,7 @@ impl RenderRuntime { mod tests { use super::super::tool_preview::mouse_tool_preview_damage_rect; use super::*; + use crate::backend::wayland::state::buffer_damage::{BufferDamageTracker, FullDamageReason}; fn rect(x: i32) -> Rect { Rect::new(x, 0, 10, 10).expect("test rectangle") @@ -223,6 +224,109 @@ mod tests { ); } + #[test] + fn status_hud_visibility_transitions_do_not_leave_stale_history() { + let mut history = UiDamageHistory::default(); + let surface = Rect::new(0, 0, 800, 600).expect("surface"); + let first = rect(20); + let moved = rect(100); + + for (current, expected) in [ + (Some(first), vec![surface]), + (Some(first), vec![first]), + (Some(moved), vec![first, moved]), + (None, vec![surface]), + (None, vec![]), + (Some(moved), vec![surface]), + (Some(moved), vec![moved]), + ] { + let mut regions = Vec::new(); + history.roll_status_hud(current, Some(surface), &mut regions); + assert_eq!(regions, expected, "HUD transition to {current:?}"); + assert_eq!(history.previous(UiEffect::StatusHud), current); + } + } + + #[test] + fn tool_preview_cleanup_reaches_each_reused_buffer() { + let mut history = UiDamageHistory::default(); + let mut damage = BufferDamageTracker::new(2); + let first = mouse_tool_preview_damage_rect(8.0, (100.0, 100.0), 800, 600) + .expect("first preview footprint"); + let moved = mouse_tool_preview_damage_rect(8.0, (400.0, 400.0), 800, 600) + .expect("moved preview footprint"); + + // Warm both slots, then let one lag behind while the other displays + // the preview's appearance and movement. + for slot in [1, 2] { + damage.take_buffer_damage_report(slot, 800, 600, 1, 4096); + } + for (current, expected) in [ + (Some(first), vec![first]), + (Some(moved), vec![first, moved]), + ] { + let mut regions = Vec::new(); + history.roll(UiEffect::ToolPreview, current, &mut regions); + damage.add_regions(regions); + let report = damage.take_buffer_damage_report(1, 800, 600, 1, 4096); + assert_eq!(report.full_reason, None); + assert_eq!(report.regions, expected); + } + + let mut regions = Vec::new(); + history.roll(UiEffect::ToolPreview, None, &mut regions); + damage.add_regions(regions); + let lagging = damage.take_buffer_damage_report(2, 800, 600, 1, 4096); + assert_eq!(lagging.full_reason, None); + assert_eq!(lagging.regions, vec![first, moved]); + + let recent = damage.take_buffer_damage_report(1, 800, 600, 1, 4096); + assert_eq!(recent.full_reason, None); + assert_eq!(recent.regions, vec![moved]); + for slot in [1, 2] { + assert!( + damage + .take_buffer_damage_report(slot, 800, 600, 1, 4096) + .regions + .is_empty(), + "cleanup must be drained independently for slot {slot}" + ); + } + } + + #[test] + fn full_damage_does_not_replace_effect_history_for_later_cleanup() { + let mut history = UiDamageHistory::default(); + let mut damage = BufferDamageTracker::new(2); + let surface = Rect::new(0, 0, 800, 600).expect("surface"); + let first = rect(20); + let moved = rect(100); + for slot in [1, 2] { + damage.take_buffer_damage_report(slot, 800, 600, 1, 4096); + } + history.roll(UiEffect::ToolPreview, Some(first), &mut Vec::new()); + + // History still advances when a separate cause forces full damage. + damage.mark_all_full(FullDamageReason::Zoom); + let mut regions = Vec::new(); + history.roll(UiEffect::ToolPreview, Some(moved), &mut regions); + damage.add_regions(regions); + for slot in [1, 2] { + let report = damage.take_buffer_damage_report(slot, 800, 600, 1, 4096); + assert_eq!(report.regions, vec![surface]); + assert_eq!(report.full_reason, Some(FullDamageReason::Zoom)); + } + + let mut regions = Vec::new(); + history.roll(UiEffect::ToolPreview, None, &mut regions); + damage.add_regions(regions); + for slot in [1, 2] { + let report = damage.take_buffer_damage_report(slot, 800, 600, 1, 4096); + assert_eq!(report.full_reason, None); + assert_eq!(report.regions, vec![moved]); + } + } + #[test] fn blocked_feedback_cleanup_is_requested_once() { let mut history = UiDamageHistory::default(); diff --git a/src/backend/wayland/state/render/submit.rs b/src/backend/wayland/state/render/submit.rs new file mode 100644 index 00000000..ee1cb07f --- /dev/null +++ b/src/backend/wayland/state/render/submit.rs @@ -0,0 +1,97 @@ +use super::plan::FramePlan; +use super::*; +use crate::backend::wayland::surface::AcquiredBuffer; + +impl WaylandState { + pub(super) fn submit_frame( + &mut self, + qh: &QueueHandle, + acquired: AcquiredBuffer, + plan: &FramePlan, + breakdown: &mut Option, + ) -> Result<()> { + let width = plan.geometry.width; + let height = plan.geometry.height; + let scale = plan.geometry.scale; + let scaled_damage = &plan.damage.buffer; + let buffer = acquired.buffer; + record_stage!(breakdown, damage_commit, { + // Attach buffer and commit + debug!("Attaching buffer and committing surface"); + let wl_surface = self + .surface + .wl_surface() + .cloned() + .context("Surface not created")?; + wl_surface.set_buffer_scale(scale); + // `attach_to` marks the slot active until the compositor releases the + // buffer. Attaching the raw `wl_buffer()` instead leaves the slot free, + // so the pool hands the same memory back on the next frame and the next + // paint lands in the buffer the compositor is still reading - the whole + // swapchain collapses to one slot and partial damage resurfaces stale or + // half-drawn pixels. + buffer + .attach_to(&wl_surface) + .map_err(|err| anyhow::anyhow!("failed to attach the overlay buffer: {err}"))?; + + if debug_damage_logging_enabled() { + debug!( + "Damage (scaled): count={}, {}", + scaled_damage.len(), + damage_summary(scaled_damage) + ); + } + + // Apply per-buffer damage regions for correct incremental rendering. + // Each buffer tracks damage since it was last displayed, avoiding stale pixels. + for region in scaled_damage { + wl_surface.damage_buffer(region.x, region.y, region.width, region.height); + } + + let capture_generation = self.suppression.barrier.begin_main_surface_submission(); + if self.config.performance.enable_vsync { + debug!("Requesting frame callback (vsync enabled)"); + let callback = self + .surface + .begin_frame_callback(wl_surface.clone(), capture_generation); + wl_surface.frame(qh, callback); + } else if capture_generation.is_some() { + debug!("Requesting frame callback (preflight)"); + let callback = self + .surface + .begin_frame_callback(wl_surface.clone(), capture_generation); + wl_surface.frame(qh, callback); + } else { + debug!("Skipping frame callback (vsync disabled - allows back-to-back renders)"); + } + + self.commit_perf_frame( + PerfFrameDamageContext { + damage_screen: &plan.damage.screen, + logical_width: width, + logical_height: height, + damage_rects: scaled_damage.len(), + force_full_reason: plan.damage.full_reason, + diagnostics: plan.damage.diagnostics, + }, + Instant::now(), + ); + wl_surface.commit(); + Ok::<(), anyhow::Error>(()) + })?; + debug!("=== RENDER COMPLETE ==="); + + // Render toolbar overlays if visible, only when state/hover changed. + record_stage!(breakdown, toolbar, { + self.render_layer_toolbars_if_needed(); + }); + if let Some(breakdown) = breakdown.take() { + self.record_perf_render_breakdown(breakdown); + } + + if self.suppression.capture_suppressed() { + self.capture.mark_preflight_rendered(); + } + Ok(()) + } +} diff --git a/src/backend/wayland/state/render/tests.rs b/src/backend/wayland/state/render/tests.rs new file mode 100644 index 00000000..8a73154b --- /dev/null +++ b/src/backend/wayland/state/render/tests.rs @@ -0,0 +1,138 @@ +use std::time::{Duration, Instant}; + +use super::runtime::{UiDamageHistory, UiEffect}; +use super::{RenderOutcome, render_acquired_frame}; +use crate::backend::wayland::state::ui_animation::UiAnimationClock; +use crate::input::InputState; +use crate::input::state::test_support::make_test_input_state; +use crate::util::Rect; + +struct FrameOwners { + input: InputState, + history: UiDamageHistory, + clock: UiAnimationClock, + now: Instant, + previous: Rect, + current: Rect, + damage: Vec, + stages: Vec<&'static str>, +} + +impl FrameOwners { + fn new() -> Self { + let mut input = make_test_input_state(); + input.update_screen_dimensions(800, 600); + input.take_dirty_region_report(); + input.mark_selection_dirty_region(Rect::new(100, 100, 20, 20)); + let previous = Rect::new(20, 20, 10, 10).expect("old effect bounds"); + let current = Rect::new(60, 20, 10, 10).expect("new effect bounds"); + let mut history = UiDamageHistory::default(); + history.roll(UiEffect::ToolPreview, Some(previous), &mut Vec::new()); + let now = Instant::now(); + let mut clock = UiAnimationClock::from_fps(20); + clock.schedule(now, true); + Self { + input, + history, + clock, + now, + previous, + current, + damage: Vec::new(), + stages: Vec::new(), + } + } + + fn attempt( + &mut self, + acquired: Option, + paint_result: anyhow::Result<()>, + keep_rendering: bool, + ) -> anyhow::Result { + // Exercise the production acquisition gate with real mutable owners. + // The continuation stands in for preparation, painting and submission; + // a busy acquisition must not enter any part of it. + render_acquired_frame(acquired, |slot| { + assert_eq!(slot, 7); + self.stages.push("prepare"); + self.clock + .schedule(self.now + Duration::from_millis(10), true); + self.damage = self.input.take_dirty_region_report().regions; + self.history + .roll(UiEffect::ToolPreview, Some(self.current), &mut self.damage); + self.stages.push("paint"); + paint_result?; + self.stages.push("submit"); + Ok(keep_rendering) + }) + } + + fn assert_prepared(&mut self) { + assert!(self.input.take_dirty_region_report().regions.is_empty()); + assert_eq!( + self.history.previous(UiEffect::ToolPreview), + Some(self.current) + ); + assert_eq!( + self.clock.timeout(self.now), + Some(Duration::from_millis(60)) + ); + assert!(self.damage.contains(&self.previous)); + assert!(self.damage.contains(&self.current)); + assert!( + self.damage.len() > 2, + "input damage was drained into the frame" + ); + } +} + +#[test] +fn busy_acquisition_preserves_pending_input_effect_history_and_animation_deadline() { + let mut owners = FrameOwners::new(); + + let outcome = owners.attempt(None, Ok(()), true).expect("deferred frame"); + + assert_eq!(outcome, RenderOutcome::BuffersInFlight); + assert!( + owners.stages.is_empty(), + "preparation, paint and submit are deferred" + ); + assert!(owners.damage.is_empty()); + assert!(!owners.input.take_dirty_region_report().regions.is_empty()); + assert_eq!( + owners.history.previous(UiEffect::ToolPreview), + Some(owners.previous) + ); + assert_eq!( + owners.clock.timeout(owners.now), + Some(Duration::from_millis(50)) + ); +} + +#[test] +fn acquired_frame_runs_the_continuation_and_preserves_redraw_outcome() { + for keep_rendering in [false, true] { + let mut owners = FrameOwners::new(); + + let outcome = owners + .attempt(Some(7), Ok(()), keep_rendering) + .expect("committed frame"); + + assert_eq!(outcome, RenderOutcome::Committed { keep_rendering }); + assert_eq!(owners.stages, ["prepare", "paint", "submit"]); + owners.assert_prepared(); + } +} + +#[test] +fn paint_failure_retains_preparation_mutations_without_reporting_a_commit() { + let mut owners = FrameOwners::new(); + + let error = owners + .attempt(Some(7), Err(anyhow::anyhow!("paint failed")), true) + .expect_err("paint failure must propagate"); + + assert_eq!(error.to_string(), "paint failed"); + assert_eq!(owners.stages, ["prepare", "paint"]); + owners.assert_prepared(); +}