// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Damage tracking: what rects need to be repainted this frame, and //! what rects need to be declared to Wayland. //! //! Two entry points with different jobs: //! //! * [`compute_interaction_dirty_rects`] — called on the partial-redraw //! path BEFORE painting. Takes the previous and current interaction //! snapshots (focus / hover / pressed) and emits the union of the //! `paint_rect`s of the widgets whose state transitioned. The caller //! uses these to install a clip mask before the partial repaint and //! to stamp `wl_surface.damage_buffer` afterwards. //! * [`compute_damage`] — called on the full-redraw path AFTER painting. //! Compares the previous-frame widget rects with the just-produced //! ones; if layout moved or changed, returns an empty vec (meaning //! "damage the whole surface"), otherwise returns a tight list of //! rects for the widgets whose interaction state changed. //! //! The 50%-of-screen heuristic is shared by both: if the accumulated //! damage covers more than half the surface, the single full-surface //! damage rect is cheaper than the per-region list. use crate::types::Rect; use crate::widget::LaidOutWidget; /// Intersect `r` with `bounds`, returning a rect that is entirely /// inside `bounds`. Returns a zero-size rect if they do not overlap. pub( super ) fn clamp_rect_to( r: Rect, bounds: Rect ) -> Rect { let x0 = r.x.max( bounds.x ); let y0 = r.y.max( bounds.y ); let x1 = ( r.x + r.width ).min( bounds.x + bounds.width ); let y1 = ( r.y + r.height ).min( bounds.y + bounds.height ); if x1 <= x0 || y1 <= y0 { Rect { x: x0, y: y0, width: 0.0, height: 0.0 } } else { Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } } } /// Build the dirty-rect list for an interaction-only frame: union of /// the `paint_rect`s of the widgets whose focus / hover / pressed /// transitioned. Each widget's `paint_rect` already encloses its hover /// halo, focus ring, and any other overdraw, so no extra padding is /// needed here. pub( crate ) fn compute_interaction_dirty_rects( widget_rects: &[ LaidOutWidget ], prev_focused: Option, prev_hovered: Option, prev_pressed: Option, new_focused: Option, new_hovered: Option, new_pressed: Option, pw: u32, ph: u32, ) -> Vec { let mut rects: Vec = Vec::new(); let visit = |idx_opt: Option, sink: &mut Vec| { if let Some( idx ) = idx_opt { if let Some( w ) = widget_rects.iter().find( |w| w.flat_idx == idx ) { if !w.handlers.is_slider() { sink.push( w.paint_rect ); } } } }; if prev_focused != new_focused { visit( prev_focused, &mut rects ); visit( new_focused, &mut rects ); } if prev_hovered != new_hovered { visit( prev_hovered, &mut rects ); visit( new_hovered, &mut rects ); } if prev_pressed != new_pressed { visit( prev_pressed, &mut rects ); visit( new_pressed, &mut rects ); } // Snap to integer pixel boundaries before clamping. The clip mask is // rasterized with anti_alias=false (binary, sampled at pixel centers), and // `Canvas::clear_rects_transparent` uses `as i32` floor on the min and // `.ceil() as i32` on the max. If `paint_rect` carries fractional coords // (e.g. from `expand( 14.5 )` on icon-button hover halos), those two paths // can disagree by 1 px at the edge — leaving a pixel cleared to transparent // black but not repainted on the next frame. Floor min / ceil max here so // both paths see the same whole-pixel rect. let sw = pw as f32; let sh = ph as f32; for r in &mut rects { let x0 = r.x.floor().max( 0.0 ); let y0 = r.y.floor().max( 0.0 ); let x1 = ( r.x + r.width ).ceil().min( sw ); let y1 = ( r.y + r.height ).ceil().min( sh ); r.x = x0; r.y = y0; r.width = ( x1 - x0 ).max( 0.0 ); r.height = ( y1 - y0 ).max( 0.0 ); } rects.retain( |r| r.width > 0.0 && r.height > 0.0 ); rects } /// Compare previous and current widget rects + interaction state to /// find damaged regions. Returns a list of damage rects, or empty vec /// if everything changed (full redraw). pub( crate ) fn compute_damage( old_rects: &[ LaidOutWidget ], new_rects: &[ LaidOutWidget ], old_focused: Option, old_hovered: Option, old_pressed: Option, new_focused: Option, new_hovered: Option, new_pressed: Option, screen_w: u32, screen_h: u32, ) -> Vec { // Widget tree structure changed: full redraw. if old_rects.len() != new_rects.len() { return Vec::new(); } let mut damage = Vec::new(); let changed_indices: Vec = [ old_focused, new_focused, old_hovered, new_hovered, old_pressed, new_pressed, ].iter().filter_map( |&idx| idx ).collect(); for &idx in &changed_indices { // Each widget's declared `paint_rect` already encloses its overdraw. if let Some( w ) = old_rects.iter().find( |w| w.flat_idx == idx ) { damage.push( w.paint_rect ); } if let Some( w ) = new_rects.iter().find( |w| w.flat_idx == idx ) { damage.push( w.paint_rect ); } } // Layout shift: any rect moved or resized => full redraw. for ( i, old_w ) in old_rects.iter().enumerate() { if let Some( new_w ) = new_rects.get( i ) { let old_r = old_w.rect; let new_r = new_w.rect; if ( old_r.x - new_r.x ).abs() > 0.5 || ( old_r.y - new_r.y ).abs() > 0.5 || ( old_r.width - new_r.width ).abs() > 0.5 || ( old_r.height - new_r.height ).abs() > 0.5 { return Vec::new(); } } } // No interaction changes but a redraw was requested => content changed // (e.g. clock tick) and a full redraw is the right answer. if damage.is_empty() { return Vec::new(); } // Dilate every damage rect by 1 px on each side. The GPU rect/gradient // shaders draw their quads expanded by 1 px so the outer half of the // SDF antialiasing band has fragments to cover; under a partial // redraw the scissor would otherwise clip that band at the widget's // `paint_rect` boundary, re-introducing the aliased step on the // straight edges of pills / rounded rects. The cost is negligible // (one extra pixel of clear + repaint per damage rect). for r in &mut damage { r.x -= 1.0; r.y -= 1.0; r.width += 2.0; r.height += 2.0; } let sw = screen_w as f32; let sh = screen_h as f32; for r in &mut damage { r.x = r.x.max( 0.0 ); r.y = r.y.max( 0.0 ); r.width = r.width.min( sw - r.x ); r.height = r.height.min( sh - r.y ); } // Total damage > 50% of screen: full redraw is no slower and emits a // single damage rect instead of many. let total_damage: f32 = damage.iter().map( |r| r.width * r.height ).sum(); if total_damage > sw * sh * 0.5 { return Vec::new(); } damage } #[ cfg( test ) ] mod tests { use super::*; use crate::widget::WidgetHandlers; fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect { Rect { x, y, width: w, height: h } } fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()> { LaidOutWidget { rect, flat_idx: idx, id: None, paint_rect: paint, handlers: WidgetHandlers::None, keyboard_focusable: true, cursor: crate::types::CursorShape::Default, } } // ── clamp_rect_to ───────────────────────────────────────────────────────── #[ test ] fn clamp_rect_to_returns_intersection() { let bounds = r( 0.0, 0.0, 100.0, 100.0 ); let inside = r( 10.0, 10.0, 20.0, 20.0 ); assert_eq!( clamp_rect_to( inside, bounds ), inside ); } #[ test ] fn clamp_rect_to_clips_to_bounds() { let bounds = r( 0.0, 0.0, 100.0, 100.0 ); let bleed = r( 80.0, 80.0, 50.0, 50.0 ); assert_eq!( clamp_rect_to( bleed, bounds ), r( 80.0, 80.0, 20.0, 20.0 ) ); } #[ test ] fn clamp_rect_to_disjoint_returns_zero_size() { let bounds = r( 0.0, 0.0, 100.0, 100.0 ); let off = r( 200.0, 200.0, 50.0, 50.0 ); let out = clamp_rect_to( off, bounds ); assert_eq!( ( out.width, out.height ), ( 0.0, 0.0 ) ); } // ── compute_interaction_dirty_rects ─────────────────────────────────────── #[ test ] fn no_state_change_yields_empty_dirty_rects() { let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; let rects = compute_interaction_dirty_rects( &widgets, Some( 1 ), None, None, Some( 1 ), None, None, 800, 600, ); assert!( rects.is_empty() ); } #[ test ] fn focus_change_emits_both_old_and_new_paint_rects() { let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), ]; let rects = compute_interaction_dirty_rects( &widgets, Some( 1 ), None, None, Some( 2 ), None, None, 800, 600, ); assert_eq!( rects.len(), 2 ); } #[ test ] fn hover_change_emits_paint_rects_independent_of_focus() { let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), ]; let rects = compute_interaction_dirty_rects( &widgets, Some( 1 ), Some( 1 ), None, Some( 1 ), Some( 2 ), None, 800, 600, ); assert_eq!( rects.len(), 2 ); } #[ test ] fn dirty_rects_are_snapped_and_clamped_to_screen() { let widgets = vec![ lw( 1, r( -10.0, -10.0, 30.5, 40.5 ), r( -10.0, -10.0, 30.5, 40.5 ) ), ]; let rects = compute_interaction_dirty_rects( &widgets, None, None, None, Some( 1 ), None, None, 100, 100, ); assert_eq!( rects.len(), 1 ); // Floor of -10 is -10 → max(0) = 0; ceil of -10+30.5=20.5 → 21. assert_eq!( rects[ 0 ].x, 0.0 ); assert_eq!( rects[ 0 ].y, 0.0 ); assert_eq!( rects[ 0 ].width, 21.0 ); assert_eq!( rects[ 0 ].height, 31.0 ); } #[ test ] fn dirty_rects_outside_screen_are_dropped() { let widgets = vec![ lw( 1, r( 1000.0, 1000.0, 50.0, 50.0 ), r( 1000.0, 1000.0, 50.0, 50.0 ) ), ]; let rects = compute_interaction_dirty_rects( &widgets, None, None, None, Some( 1 ), None, None, 100, 100, ); assert!( rects.is_empty() ); } #[ test ] fn missing_widget_idx_silently_skipped() { let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; // Old focus references an idx that no longer exists in widget_rects — // this happens during a layout where a widget disappeared between // frames. The function must not panic; it just emits whatever new // state's rect it can find. let rects = compute_interaction_dirty_rects( &widgets, Some( 99 ), None, None, Some( 1 ), None, None, 800, 600, ); assert_eq!( rects.len(), 1 ); } // ── compute_damage ──────────────────────────────────────────────────────── #[ test ] fn tree_size_change_returns_full_redraw() { let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; let new = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), lw( 2, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ), ]; let damage = compute_damage( &old, &new, None, None, None, None, None, None, 800, 600, ); assert!( damage.is_empty(), "tree size change must signal full redraw via empty vec" ); } #[ test ] fn layout_shift_returns_full_redraw() { let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; let new = vec![ lw( 1, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ) ]; let damage = compute_damage( &old, &new, Some( 1 ), None, None, Some( 1 ), None, None, 800, 600, ); assert!( damage.is_empty() ); } #[ test ] fn focus_change_emits_partial_damage() { let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ), lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ), ]; let damage = compute_damage( &widgets, &widgets, Some( 1 ), None, None, Some( 2 ), None, None, 800, 600, ); assert!( !damage.is_empty(), "focus change must produce non-empty damage list" ); assert!( damage.len() >= 2, "both old and new focused widgets contribute their paint_rects" ); } #[ test ] fn no_change_with_redraw_request_returns_full_redraw() { // `damage.is_empty()` after the changed_indices loop means nothing // interaction-level changed but the caller still asked to redraw — // content tick (e.g. clock). The function returns the empty vec to // signal "redraw everything" rather than nothing. let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ]; let damage = compute_damage( &widgets, &widgets, None, None, None, None, None, None, 800, 600, ); assert!( damage.is_empty() ); } #[ test ] fn damage_rects_are_dilated_by_one_pixel_each_side() { // Every damage rect grows by 1 px on each side to cover the SDF // antialiasing band; the rect is then clamped to the surface. let widgets = vec![ lw( 1, r( 100.0, 100.0, 50.0, 50.0 ), r( 100.0, 100.0, 50.0, 50.0 ) ) ]; let damage = compute_damage( &widgets, &widgets, None, None, None, Some( 1 ), None, None, 800, 600, ); assert_eq!( damage.len(), 2 ); for d in &damage { assert_eq!( d.x, 99.0 ); assert_eq!( d.y, 99.0 ); assert_eq!( d.width, 52.0 ); assert_eq!( d.height, 52.0 ); } } #[ test ] fn damage_above_fifty_percent_collapses_to_full_redraw() { // Widget covers 60 % of a 100×100 surface. The dilated rect easily // exceeds the 50 % threshold and the function falls back to full // redraw. let widgets = vec![ lw( 1, r( 0.0, 0.0, 80.0, 80.0 ), r( 0.0, 0.0, 80.0, 80.0 ) ), ]; let damage = compute_damage( &widgets, &widgets, None, None, None, Some( 1 ), None, None, 100, 100, ); assert!( damage.is_empty(), "damage > 50 % of surface must signal full redraw" ); } #[ test ] fn damage_below_fifty_percent_returns_partial() { // 10×10 widget on a 100×100 surface → ~1 % per rect, well under 50 %. let widgets = vec![ lw( 1, r( 5.0, 5.0, 10.0, 10.0 ), r( 5.0, 5.0, 10.0, 10.0 ) ), ]; let damage = compute_damage( &widgets, &widgets, None, None, None, Some( 1 ), None, None, 100, 100, ); assert!( !damage.is_empty() ); } #[ test ] fn damage_clamped_to_surface_extents() { // Widget paint_rect at the right edge — dilation can push it past the // surface; the clamp must keep width/height within the surface. let widgets = vec![ lw( 1, r( 90.0, 90.0, 5.0, 5.0 ), r( 90.0, 90.0, 5.0, 5.0 ) ), ]; let damage = compute_damage( &widgets, &widgets, None, None, None, Some( 1 ), None, None, 100, 100, ); for d in &damage { assert!( d.x + d.width <= 100.0 + f32::EPSILON ); assert!( d.y + d.height <= 100.0 + f32::EPSILON ); } } }