512 lines
15 KiB
Rust
512 lines
15 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! 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<Msg: Clone>(
|
||
widget_rects: &[ LaidOutWidget<Msg> ],
|
||
prev_focused: Option<usize>, prev_hovered: Option<usize>, prev_pressed: Option<usize>,
|
||
new_focused: Option<usize>, new_hovered: Option<usize>, new_pressed: Option<usize>,
|
||
pw: u32,
|
||
ph: u32,
|
||
) -> Vec<Rect>
|
||
{
|
||
let mut rects: Vec<Rect> = Vec::new();
|
||
let visit = |idx_opt: Option<usize>, sink: &mut Vec<Rect>|
|
||
{
|
||
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<Msg: Clone>(
|
||
old_rects: &[ LaidOutWidget<Msg> ],
|
||
new_rects: &[ LaidOutWidget<Msg> ],
|
||
old_focused: Option<usize>,
|
||
old_hovered: Option<usize>,
|
||
old_pressed: Option<usize>,
|
||
new_focused: Option<usize>,
|
||
new_hovered: Option<usize>,
|
||
new_pressed: Option<usize>,
|
||
screen_w: u32,
|
||
screen_h: u32,
|
||
) -> Vec<Rect>
|
||
{
|
||
// 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<usize> = [
|
||
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 );
|
||
}
|
||
}
|
||
}
|