Files
ltk/src/render/clip.rs
T
admin da34eee9e6
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
External textures drawn at their own size, a repaint without rebuilding the view, an icon size for list rows, and lighter tabs on the desktop strip
External textures. `ExternalSource::Texture` stretches its texture over the widget's rect, so a producer that is a few frames behind a new rect shows its old picture stretched or squeezed. A web page while its window is resized is the clearest case: the page keeps sending frames of the old size until it has laid itself out again, and the content wobbles as the window moves. The new `ExternalSource::Frame` closure returns the texture with its size in physical pixels, and the texture is drawn at that size from the rect's top left corner, cut to the rect. A late frame then leaves an empty edge or loses one for a moment, as other browsers do, instead of being distorted. The cut uses `interior_clip`, which moves from the text field's drawing code to `render/clip.rs` so both widgets can use it, and the clip that was active before is restored afterwards.
Repaint without a view. An application whose only change is a new frame in an external texture had to report `InvalidationScope::All`, which rebuilds the whole view just to draw the same widgets again. `App::needs_repaint` is asked once per iteration, after the messages are processed. When it returns `true`, the main surface and the secondary surfaces are redrawn from the cached view, without calling `view`. The default is `false`, so existing applications behave as before.
List rows. `ListItem::icon_size` overrides the size of the leading icon, which was always the theme's 24 design px, for dense lists such as a menu, where an icon of that size dominates the row.
Tab strip. On the desktop, a tab's title is drawn at the regular weight whether it is selected or not; the selected tab already stands out by its fill and edge, and the bold title made the strip heavy. The desktop tab's close icon is 10 widget pixels instead of the 16 that the cards of the tab overview keep, so `parts::close` now takes the icon size from its caller.
2026-10-27 12:46:20 +01:00

171 lines
5.0 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
//! path calls `set_clip_rects` before every repaint so only pixels
//! inside the dirty rects are touched. `set_clip_path` installs an
//! arbitrary anti-aliased vector path as the clip (an exact tiny-skia
//! coverage [`Mask`]) for shaped clipping such as a circular avatar.
//! [`interior_clip`] narrows whatever clip is active to a widget's rect, on either backend.
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
use crate::types::Rect;
use super::SoftwareCanvas;
/// The active clip `outer` narrowed to `inner`. An empty intersection must still clip everything: an empty rect list would lift the clip.
pub( crate ) fn interior_clip( outer: &[ Rect ], inner: Rect ) -> Vec<Rect>
{
if outer.is_empty()
{
return vec![ inner ];
}
let clip: Vec<Rect> = outer.iter()
.filter_map( |o| rect_intersect( *o, inner ) )
.collect();
if clip.is_empty()
{
vec![ Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } ]
} else {
clip
}
}
fn rect_intersect( a: Rect, b: Rect ) -> Option<Rect>
{
let x0 = a.x.max( b.x );
let y0 = a.y.max( b.y );
let x1 = ( a.x + a.width ).min( b.x + b.width );
let y1 = ( a.y + a.height ).min( b.y + b.height );
if x1 > x0 && y1 > y0
{
Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } )
} else {
None
}
}
impl SoftwareCanvas
{
/// Set the active clip region to the union of `rects` (physical pixels).
pub fn set_clip_rects( &mut self, rects: &[Rect] )
{
let w = self.pixmap.width();
let h = self.pixmap.height();
let Some( mut mask ) = Mask::new( w, h ) else
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
return;
};
let mut pb = PathBuilder::new();
for r in rects
{
let x0 = r.x.max( 0.0 ).min( w as f32 );
let y0 = r.y.max( 0.0 ).min( h as f32 );
let x1 = ( r.x + r.width ).max( 0.0 ).min( w as f32 );
let y1 = ( r.y + r.height ).max( 0.0 ).min( h as f32 );
if x1 <= x0 || y1 <= y0 { continue; }
pb.push_rect( tiny_skia::Rect::from_ltrb( x0, y0, x1, y1 )
.expect( "valid rect" ) );
}
if let Some( path ) = pb.finish()
{
mask.fill_path( &path, FillRule::Winding, false, Transform::identity() );
self.clip_mask = Some( mask );
self.clip_bounds = rects.to_vec();
} else if rects.is_empty() {
self.clip_mask = None;
self.clip_bounds = Vec::new();
} else {
// Only zero-area rects: clip everything, like the GLES zero scissor.
self.clip_mask = Some( mask );
self.clip_bounds = Vec::new();
}
}
/// Clip subsequent paints to an arbitrary vector path (surface
/// coordinates), via an anti-aliased tiny-skia coverage mask. The GLES
/// counterpart composites an offscreen layer through an equivalent
/// anti-aliased mask; both give a smooth clipped edge.
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
let w = self.pixmap.width();
let h = self.pixmap.height();
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else
{
self.clear_clip();
return;
};
let Some( mut mask ) = Mask::new( w, h ) else
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
return;
};
mask.fill_path( &path, FillRule::Winding, true, Transform::identity() );
let b = path.bounds();
self.clip_mask = Some( mask );
self.clip_bounds = vec![ Rect
{
x: b.left(),
y: b.top(),
width: b.right() - b.left(),
height: b.bottom() - b.top(),
} ];
}
/// Remove the active clip so subsequent paints cover the full canvas.
pub fn clear_clip( &mut self )
{
self.clip_mask = None;
self.clip_bounds = Vec::new();
}
pub ( super ) fn has_clip( &self ) -> bool
{
self.clip_mask.is_some()
}
/// Snapshot of the active clip bounds (empty when no clip is set).
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
{
if self.has_clip() { self.clip_bounds.clone() } else { Vec::new() }
}
/// True when a horizontal strip `y` in `[y0, y1]` touches any clip bound.
pub ( super ) fn strip_intersects_clip( &self, y0: f32, y1: f32 ) -> bool
{
if self.clip_bounds.is_empty() { return !self.has_clip(); }
self.clip_bounds.iter().any( |r|
{
y1 > r.y && y0 < r.y + r.height
} )
}
/// Zero the alpha+RGB bytes inside each rect, used by the
/// partial-redraw path when the surface background is fully
/// transparent.
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
{
let pw = self.pixmap.width() as i32;
let ph = self.pixmap.height() as i32;
let bytes = self.pixmap.data_mut();
for r in rects
{
let x0 = ( r.x as i32 ).max( 0 );
let y0 = ( r.y as i32 ).max( 0 );
let x1 = ( ( r.x + r.width ).ceil() as i32 ).min( pw );
let y1 = ( ( r.y + r.height ).ceil() as i32 ).min( ph );
if x1 <= x0 || y1 <= y0 { continue; }
for py in y0..y1
{
let row_start = ( py * pw + x0 ) as usize * 4;
let row_end = ( py * pw + x1 ) as usize * 4;
bytes[ row_start..row_end ].fill( 0 );
}
}
}
}