First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

511
src/draw/damage.rs Normal file
View File

@@ -0,0 +1,511 @@
// 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 );
}
}
}