Files
ltk/src/draw/damage.rs

512 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 );
}
}
}