Add Canvas::set_clip_path — anti-aliased arbitrary-path clipping on both backends
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Add `Canvas::set_clip_path(&[PathCmd])`, clipping subsequent draws to an arbitrary vector path with an anti-aliased edge, on both the software and GLES backends. It complements the existing rect clip (`set_clip_rects`) and is what an embedder needs to render a shaped clip — a circular avatar, a rounded card, a `VectorDrawable` mask — rather than a bounding box. Kept general rather than tied to any one consumer.
Software backend: rasterise the path into an anti-aliased tiny-skia coverage `Mask` (Winding fill) and install it as the active clip mask. Every software primitive already threads `clip_mask` through tiny-skia (fills, strokes, lines, paths, images, text, blit), so the path clip applies uniformly with smooth edges. `clip_bounds` reports the path's bounding box while it is active.
GLES backend: a 1-bit stencil would clip exactly but leave a hard, aliased edge, so instead the clipped draws are captured into an offscreen layer and composited back through an anti-aliased coverage mask. `set_clip_path` rasterises the path coverage (tiny-skia, anti-aliased), uploads it as a mask texture, allocates a full-canvas layer FBO on first use, and redirects subsequent draws to it via `activate_target`. Ending the clip (`clear_clip` / `set_clip_rects` / a new `set_clip_path`) composites the layer back onto the canvas FBO with a new two-sampler program (`CLIP_COMPOSITE_FRAG_SRC`) that multiplies the layer colour by the mask coverage and blends it premultiplied-over. The layer attaches to the canvas's own shadow FBO, so it needs no stencil bits in the EGL config; it is freed and reallocated on resize and freed on drop, and shared programs/uniforms are copied to sub-canvases like the rest.
Usage: a path clip is bracketed — `set_clip_path` then, after the clipped draws, `clear_clip` or `set_clip_rects` to flush it (on GLES this is when the layer is composited). Snapshot the prior clip with `clip_bounds` beforehand and restore it with `set_clip_rects` to compose with an outer clip without leaking state.
Add an `examples/clip_path.rs` demo (rounded rect, circle, triangle — same smooth result on both backends) and software-backend unit tests covering the bounding box, the empty-path clear, and a pixel-level check that a triangular clip masks a fill to the path silhouette rather than its bounding box. The GLES layer-composite path needs a live GL context and is exercised by the example.
Also fix three rustdoc intra-doc-link warnings surfaced along the way: a private-item link in `app.rs` (`scroll`) and the new GLES doc (`SoftwareCanvas::set_clip_path`) demoted to code spans, and a redundant explicit link target in `chassis.rs`.
This commit is contained in:
2026-06-18 23:59:38 +02:00
parent b00cf460bb
commit f8c45f0e30
10 changed files with 482 additions and 19 deletions

131
examples/clip_path.rs Normal file
View File

@@ -0,0 +1,131 @@
//! `cargo run --example clip_path`
//!
//! Demonstrates [`Canvas::set_clip_path`] — real per-path clipping. Each cell
//! fills its whole rect with a background colour, then installs a path clip and
//! fills again with a foreground colour: only the pixels inside the path are
//! repainted, so the shape's silhouette appears (not its bounding box).
//!
//! Both backends clip with smooth, anti-aliased edges: the software backend
//! with a tiny-skia coverage mask, the GLES backend by compositing an offscreen
//! layer through an equivalent coverage mask. The circle and rounded corners
//! look the same on both. Esc exits.
use ltk::{ column, row, text, App, Color, Element, External, Keysym, PathCmd, Rect };
#[ derive( Clone, Debug ) ]
enum Msg {}
struct Demo;
const CELL: f32 = 220.0;
const MARGIN: f32 = 24.0;
fn bg() -> Color { Color::rgba( 0.16, 0.18, 0.22, 1.0 ) }
fn fg() -> Color { Color::rgba( 0.30, 0.68, 0.90, 1.0 ) }
/// Rounded rectangle inset by `MARGIN`, corners of `radius`, built from lines
/// and quadratic corners.
fn rounded_rect_path( r: Rect, radius: f32 ) -> Vec<PathCmd>
{
let l = r.x + MARGIN;
let t = r.y + MARGIN;
let right = r.x + r.width - MARGIN;
let b = r.y + r.height - MARGIN;
vec![
PathCmd::MoveTo( l + radius, t ),
PathCmd::LineTo( right - radius, t ),
PathCmd::QuadTo( right, t, right, t + radius ),
PathCmd::LineTo( right, b - radius ),
PathCmd::QuadTo( right, b, right - radius, b ),
PathCmd::LineTo( l + radius, b ),
PathCmd::QuadTo( l, b, l, b - radius ),
PathCmd::LineTo( l, t + radius ),
PathCmd::QuadTo( l, t, l + radius, t ),
PathCmd::Close,
]
}
/// Circle centred in the cell, drawn as four cubic-Bézier quadrants.
fn circle_path( r: Rect ) -> Vec<PathCmd>
{
let cx = r.x + r.width / 2.0;
let cy = r.y + r.height / 2.0;
let rad = ( r.width.min( r.height ) ) / 2.0 - MARGIN;
let k = rad * 0.552_284_75; // 4/3 * tan(pi/8): cubic circle approximation
vec![
PathCmd::MoveTo( cx + rad, cy ),
PathCmd::CubicTo( cx + rad, cy + k, cx + k, cy + rad, cx, cy + rad ),
PathCmd::CubicTo( cx - k, cy + rad, cx - rad, cy + k, cx - rad, cy ),
PathCmd::CubicTo( cx - rad, cy - k, cx - k, cy - rad, cx, cy - rad ),
PathCmd::CubicTo( cx + k, cy - rad, cx + rad, cy - k, cx + rad, cy ),
PathCmd::Close,
]
}
/// Downward triangle inset by `MARGIN` — a polygon clip.
fn triangle_path( r: Rect ) -> Vec<PathCmd>
{
vec![
PathCmd::MoveTo( r.x + r.width / 2.0, r.y + MARGIN ),
PathCmd::LineTo( r.x + r.width - MARGIN, r.y + r.height - MARGIN ),
PathCmd::LineTo( r.x + MARGIN, r.y + r.height - MARGIN ),
PathCmd::Close,
]
}
/// A cell that paints `bg()` everywhere, then `fg()` clipped to `shape`. The
/// previous clip is snapshotted and restored so the clip does not leak to the
/// rest of the frame.
fn clipped_cell( shape: fn( Rect ) -> Vec<PathCmd> ) -> Element<Msg>
{
External::cpu( CELL, CELL, move |canvas, rect|
{
canvas.fill_rect( rect, bg(), 0.0 );
let saved = canvas.clip_bounds();
canvas.set_clip_path( &shape( rect ) );
canvas.fill_rect( rect, fg(), 0.0 );
canvas.set_clip_rects( &saved );
} ).into()
}
impl App for Demo
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.spacing( 16.0 )
.padding( 24.0 )
.push( text( "Canvas::set_clip_path — rounded rect, circle, triangle" ) )
.push(
row()
.spacing( 16.0 )
.push( clipped_cell( |r| rounded_rect_path( r, 32.0 ) ) )
.push( clipped_cell( circle_path ) )
.push( clipped_cell( triangle_path ) )
)
.into()
}
fn update( &mut self, _msg: Msg ) {}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( Demo );
}

View File

@@ -473,7 +473,7 @@ pub trait App: 'static
/// edge, matching the usual system-panel pull-down UX. /// edge, matching the usual system-panel pull-down UX.
fn swipe_down_edge( &self ) -> f32 { 1.0 } fn swipe_down_edge( &self ) -> f32 { 1.0 }
/// Called while a vertical [`scroll`](crate::widget::scroll) is pinned at /// Called while a vertical `scroll` is pinned at
/// its top and the finger keeps pulling down. `progress` is the /// its top and the finger keeps pulling down. `progress` is the
/// accumulated overscroll as a fraction of the surface height (≥ 0.0, /// accumulated overscroll as a fraction of the surface height (≥ 0.0,
/// unbounded). Use it to drive a pull-from-top-to-dismiss panel that /// unbounded). Use it to drive a pull-from-top-to-dismiss panel that

View File

@@ -1,7 +1,7 @@
//! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen, //! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen,
//! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed //! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed
//! view stack. Thin convenience over [`theme`](crate::theme) and //! view stack. Thin convenience over [`theme`](crate::theme) and
//! [`WallpaperBundle`](crate::WallpaperBundle) — every app that paints a //! [`WallpaperBundle`] — every app that paints a
//! wallpaper behind centred content repeats this otherwise. //! wallpaper behind centred content repeats this otherwise.
use std::sync::Arc; use std::sync::Arc;

View File

@@ -1,12 +1,15 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `glScissor`-based clipping + whole-canvas fill / clear for //! Clipping + whole-canvas fill / clear for [`GlesCanvas`]. Rect clips
//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives //! ([`GlesCanvas::set_clip_rects`]) use `glScissor`: when given multiple rects
//! multiple rects the bounding-box union is used as the scissor — //! the bounding-box union is the scissor — coarse, but the partial-redraw path
//! coarse, but the partial-redraw path normally clusters 13 rects //! normally clusters 13 rects so the union is barely larger than the sum
//! so the union is barely larger than the sum. Disjoint regions //! (disjoint regions are not clipped tightly). Arbitrary path clips
//! would want a stencil-buffer path; not implemented today. //! ([`GlesCanvas::set_clip_path`]) are anti-aliased: the clipped draws are
//! captured into an offscreen layer FBO, then composited back onto the canvas
//! multiplied by a CPU-rasterised (tiny-skia) anti-aliased coverage mask, so the
//! clipped edge is smooth rather than the hard edge a 1-bit stencil would give.
use glow::HasContext; use glow::HasContext;
@@ -18,6 +21,8 @@ impl GlesCanvas
{ {
pub fn set_clip_rects( &mut self, rects: &[Rect] ) pub fn set_clip_rects( &mut self, rects: &[Rect] )
{ {
// A rect clip replaces any active path clip: flush its layer first.
self.flush_clip_layer();
// Scissor is global GL state; rebind our FBO first so the scissor // Scissor is global GL state; rebind our FBO first so the scissor
// applies to this canvas and not whatever target was active before. // applies to this canvas and not whatever target was active before.
self.activate_target(); self.activate_target();
@@ -58,6 +63,114 @@ impl GlesCanvas
// to match below. // to match below.
unsafe { self.gl.disable( glow::SCISSOR_TEST ); } unsafe { self.gl.disable( glow::SCISSOR_TEST ); }
self.clip_scissor = None; self.clip_scissor = None;
self.flush_clip_layer();
}
/// Allocate the offscreen clip layer (a full-canvas colour FBO) on first
/// use. Freed and re-allocated at the new size by `resize`.
fn ensure_clip_layer( &mut self )
{
if self.clip_layer.is_some() { return; }
// SAFETY: GL context current (canvas invariant). Allocates an FBO + colour
// texture sized to the canvas and attaches it; the handles are stored so
// `Drop`/`resize` manage their lifetime. Restores the canvas FBO binding.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "clip-layer FBO" );
let tex = super::helpers::alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
self.gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0 );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.clip_layer = Some( ( fbo, tex ) );
}
}
/// Clip subsequent draws to an arbitrary vector path, with an anti-aliased
/// edge. Begins capturing draws into the offscreen clip layer; the path's
/// anti-aliased coverage is rasterised (tiny-skia) into `clip_mask_tex` and
/// applied when the layer is composited back by `flush_clip_layer` (on the
/// next `clear_clip` / `set_clip_rects`). GLES counterpart of the software
/// backend's `set_clip_path`.
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
// A new path clip supersedes any layer still open.
self.flush_clip_layer();
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { self.clear_clip(); return; };
let b = path.bounds();
let x0 = b.left().floor().max( 0.0 );
let y0 = b.top().floor().max( 0.0 );
let x1 = b.right().ceil().min( self.width as f32 );
let y1 = b.bottom().ceil().min( self.height as f32 );
let pw = ( x1 - x0 ).max( 0.0 ) as u32;
let ph = ( y1 - y0 ).max( 0.0 ) as u32;
if pw == 0 || ph == 0 || pw > 8192 || ph > 8192 { self.clear_clip(); return; }
// Rasterise the path coverage (anti-aliased, opaque inside) into a
// bbox-sized pixmap; the composite shader multiplies the layer by it.
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return; };
let mut paint = tiny_skia::Paint::default();
paint.set_color( tiny_skia::Color::WHITE );
paint.anti_alias = true;
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None );
let rgba = pixmap.data().to_vec();
let mask = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
self.clip_mask_tex = Some( mask );
self.clip_bbox = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
self.ensure_clip_layer();
// Redirect to the layer and clear it transparent so untouched pixels stay
// empty (they contribute nothing to the composite).
self.clip_layer_active = true;
self.clip_scissor = None;
self.activate_target();
// SAFETY: GL context current; `activate_target` bound the layer FBO.
unsafe
{
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT );
}
}
/// Composite the clip layer back onto the canvas FBO, multiplied by the
/// anti-aliased coverage mask, then end the path clip. No-op when no path
/// clip is open.
fn flush_clip_layer( &mut self )
{
if !self.clip_layer_active { return; }
self.clip_layer_active = false;
let Some( ( _, layer_tex ) ) = self.clip_layer else { return; };
let Some( mask_tex ) = self.clip_mask_tex.take() else { return; };
let bbox = self.clip_bbox;
let mvp = super::helpers::ortho_rect( self.width, self.height, bbox );
// SAFETY: GL context current. Bind the canvas FBO, disable scissor, and
// draw a quad over the bbox sampling the layer (unit 0) and coverage mask
// (unit 1); the premultiplied result is blended SRC_OVER. All handles are
// canvas-owned; the transient mask texture is deleted after the draw.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.use_program( Some( self.clip_composite_program ) );
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_clip_mvp ), false, &mvp );
self.gl.uniform_2_f32( Some( &self.u_clip_canvas ), self.width as f32, self.height as f32 );
self.gl.uniform_4_f32( Some( &self.u_clip_bbox ), bbox.x, bbox.y, bbox.width, bbox.height );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( layer_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_layer ), 0 );
self.gl.active_texture( glow::TEXTURE1 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( mask_tex ) );
self.gl.uniform_1_i32( Some( &self.u_clip_mask ), 1 );
self.gl.bind_vertex_array( Some( self.quad_vao ) );
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
self.gl.bind_vertex_array( None );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, None );
self.gl.delete_texture( mask_tex );
}
} }
/// Snapshot of the active scissor as a `Vec<Rect>` (empty when no /// Snapshot of the active scissor as a `Vec<Rect>` (empty when no

View File

@@ -83,22 +83,32 @@ impl GlesCanvas
/// disable scissor when the canvas has no clip). /// disable scissor when the canvas has no clip).
pub( super ) fn activate_target( &self ) pub( super ) fn activate_target( &self )
{ {
// SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values // While a path clip is active, draws are redirected to the offscreen clip
// (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`) // layer (unclipped); the anti-aliased mask is applied when the layer is
// composited back on clip end. Otherwise draws go to `fbo` with the
// active scissor.
// SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent. // live as long as `&self`, and the bind is idempotent.
let target = match ( self.clip_layer_active, self.clip_layer )
{
( true, Some( ( fbo, _ ) ) ) => fbo,
_ => self.fbo,
};
let to_layer = self.clip_layer_active && self.clip_layer.is_some();
unsafe unsafe
{ {
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) ); self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 ); self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor match self.clip_scissor
{ {
Some( r ) => Some( r ) if !to_layer =>
{ {
let ( x, y, w, h ) = self.scissor_pixels( r ); let ( x, y, w, h ) = self.scissor_pixels( r );
self.gl.enable( glow::SCISSOR_TEST ); self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h ); self.gl.scissor( x, y, w, h );
} }
None => _ =>
{ {
self.gl.disable( glow::SCISSOR_TEST ); self.gl.disable( glow::SCISSOR_TEST );
} }

View File

@@ -9,12 +9,14 @@
//! [`crate::egl_context`] — this module is just the renderer that runs //! [`crate::egl_context`] — this module is just the renderer that runs
//! once a context is current. //! once a context is current.
//! //!
//! Clipping is implemented with `glScissor`. When //! Rect clipping is implemented with `glScissor`. When
//! [`GlesCanvas::set_clip_rects`] receives multiple rects the //! [`GlesCanvas::set_clip_rects`] receives multiple rects the
//! bounding-box union is used as the scissor — coarse, but the //! bounding-box union is used as the scissor — coarse, but the
//! partial-redraw path normally clusters the dirty rects of 13 //! partial-redraw path normally clusters the dirty rects of 13
//! widgets so the union is barely larger than the sum. Disjoint //! widgets so the union is barely larger than the sum. Arbitrary
//! regions would want a stencil-buffer path; not implemented today. //! path clipping ([`GlesCanvas::set_clip_path`]) captures the clipped
//! draws into an offscreen layer and composites it back through an
//! anti-aliased coverage mask, for a smooth clipped edge.
//! //!
//! # Submodule layout //! # Submodule layout
//! //!
@@ -354,6 +356,23 @@ pub struct GlesCanvas
/// (GL_SCISSOR_TEST is enabled), `None` when cleared. /// (GL_SCISSOR_TEST is enabled), `None` when cleared.
clip_scissor: Option<Rect>, clip_scissor: Option<Rect>,
/// Anti-aliased path clip (`set_clip_path`). While `clip_layer_active`,
/// `activate_target` redirects draws to `clip_layer` (a full-canvas
/// offscreen FBO); ending the clip composites that layer back onto `fbo`
/// multiplied by `clip_mask_tex` (an anti-aliased coverage texture covering
/// `clip_bbox`). The layer FBO is allocated lazily on the first path clip.
clip_layer: Option<( glow::Framebuffer, glow::Texture )>,
clip_layer_active: bool,
clip_mask_tex: Option<glow::Texture>,
clip_bbox: Rect,
/// Composite program (layer × coverage mask). Shared with sub-canvases.
clip_composite_program: glow::Program,
u_clip_mvp: glow::UniformLocation,
u_clip_layer: glow::UniformLocation,
u_clip_mask: glow::UniformLocation,
u_clip_canvas: glow::UniformLocation,
u_clip_bbox: glow::UniformLocation,
/// Persistent shadow framebuffer. All draw methods bind this; /// Persistent shadow framebuffer. All draw methods bind this;
/// [`Self::present`](framebuffer) is the only call that switches /// [`Self::present`](framebuffer) is the only call that switches
/// to the default framebuffer. /// to the default framebuffer.
@@ -398,6 +417,15 @@ impl Drop for GlesCanvas
{ {
self.gl.delete_framebuffer( self.fbo ); self.gl.delete_framebuffer( self.fbo );
self.gl.delete_texture( self.fbo_tex ); self.gl.delete_texture( self.fbo_tex );
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( tex ) = self.clip_mask_tex.take()
{
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_a.take() if let Some( ( fbo, tex ) ) = self.aux_a.take()
{ {
self.gl.delete_framebuffer( fbo ); self.gl.delete_framebuffer( fbo );

View File

@@ -28,6 +28,7 @@ use super::shaders::
GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC, GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC,
LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC, LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC,
RECT_FRAG_SRC, RECT_FRAG_SRC,
CLIP_COMPOSITE_FRAG_SRC,
SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC, SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC,
SUB_BLIT_FRAG_SRC, SUB_BLIT_FRAG_SRC,
TEX_FRAG_SRC, VERT_SRC, TEX_FRAG_SRC, VERT_SRC,
@@ -211,6 +212,16 @@ impl GlesCanvas
gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(), gl.get_uniform_location( glyph_batch_program, "u_sampler" ).unwrap(),
)}; )};
let clip_composite_program = compile_program( &gl, VERT_SRC, CLIP_COMPOSITE_FRAG_SRC );
let ( u_clip_mvp, u_clip_layer, u_clip_mask, u_clip_canvas, u_clip_bbox ) = unsafe
{(
gl.get_uniform_location( clip_composite_program, "u_mvp" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_layer" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_mask" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_canvas" ).unwrap(),
gl.get_uniform_location( clip_composite_program, "u_bbox" ).unwrap(),
)};
let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe let ( glyph_batch_vao, glyph_batch_vbo ) = unsafe
{ {
let vao = gl.create_vertex_array().unwrap(); let vao = gl.create_vertex_array().unwrap();
@@ -464,6 +475,16 @@ impl GlesCanvas
image_cache: HashMap::new(), image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(), gradient_lut_cache: HashMap::new(),
clip_scissor: None, clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program,
u_clip_mvp,
u_clip_layer,
u_clip_mask,
u_clip_canvas,
u_clip_bbox,
fbo, fbo,
fbo_tex, fbo_tex,
aux_a: None, aux_a: None,
@@ -665,6 +686,16 @@ impl GlesCanvas
image_cache: HashMap::new(), image_cache: HashMap::new(),
gradient_lut_cache: HashMap::new(), gradient_lut_cache: HashMap::new(),
clip_scissor: None, clip_scissor: None,
clip_layer: None,
clip_layer_active: false,
clip_mask_tex: None,
clip_bbox: crate::types::Rect::default(),
clip_composite_program: self.clip_composite_program,
u_clip_mvp: self.u_clip_mvp,
u_clip_layer: self.u_clip_layer,
u_clip_mask: self.u_clip_mask,
u_clip_canvas: self.u_clip_canvas,
u_clip_bbox: self.u_clip_bbox,
fbo, fbo,
fbo_tex, fbo_tex,
aux_a: None, aux_a: None,
@@ -768,10 +799,22 @@ impl GlesCanvas
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( self.fbo_tex ), 0, glow::TEXTURE_2D, Some( self.fbo_tex ), 0,
); );
// The clip layer was sized for the old dimensions — free it so the next
// path clip re-allocates at the new size; any in-flight clip is dropped.
if let Some( ( fbo, tex ) ) = self.clip_layer.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( tex ) = self.clip_mask_tex.take()
{
self.gl.delete_texture( tex );
}
self.gl.viewport( 0, 0, width as i32, height as i32 ); self.gl.viewport( 0, 0, width as i32, height as i32 );
self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 ); self.gl.clear_color( 0.0, 0.0, 0.0, 0.0 );
self.gl.clear( glow::COLOR_BUFFER_BIT ); self.gl.clear( glow::COLOR_BUFFER_BIT );
} }
self.clip_layer_active = false;
// Auxiliary textures were sized for the old dimensions — drop them so // Auxiliary textures were sized for the old dimensions — drop them so
// the next effect that needs them re-allocates at the new size. // the next effect that needs them re-allocates at the new size.
self.invalidate_aux(); self.invalidate_aux();

View File

@@ -147,6 +147,30 @@ void main()
} }
"##; "##;
// Clip-layer composite shader for `set_clip_path`. Path-clipped content is
// drawn to an offscreen layer; this composites that layer onto the canvas
// multiplied by an anti-aliased coverage mask, giving a smooth clipped edge.
// `u_layer` is the full-canvas layer texture (premultiplied), `u_mask` the
// path coverage rasterised over `u_bbox` (x, y, w, h in canvas pixels). The
// quad is drawn over the bbox; `v_uv` runs 0..1 across it. Both FBO-backed
// textures are sampled y-flipped (GL's lower-left origin).
pub( super ) const CLIP_COMPOSITE_FRAG_SRC: &str = r##"
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_layer;
uniform sampler2D u_mask;
uniform vec2 u_canvas;
uniform vec4 u_bbox;
void main()
{
vec2 sp = u_bbox.xy + v_uv * u_bbox.zw;
vec2 luv = vec2( sp.x / u_canvas.x, 1.0 - sp.y / u_canvas.y );
vec4 col = texture2D( u_layer, luv );
float cov = texture2D( u_mask, vec2( v_uv.x, 1.0 - v_uv.y ) ).a;
gl_FragColor = col * cov;
}
"##;
// Fragment shader for single-channel glyph textures with color tint. // Fragment shader for single-channel glyph textures with color tint.
// //
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset` // Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`

View File

@@ -3,7 +3,9 @@
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw //! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
//! path calls `set_clip_rects` before every repaint so only pixels //! path calls `set_clip_rects` before every repaint so only pixels
//! inside the dirty rects are touched. //! 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.
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform }; use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
@@ -46,6 +48,37 @@ impl SoftwareCanvas
} }
} }
/// 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. /// Remove the active clip so subsequent paints cover the full canvas.
pub fn clear_clip( &mut self ) pub fn clear_clip( &mut self )
{ {

View File

@@ -19,8 +19,9 @@
//! //!
//! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit, //! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit,
//! set_font_registry, font_for}` (construction + accessors). //! set_font_registry, font_for}` (construction + accessors).
//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip, //! * [`clip`] — `SoftwareCanvas::{set_clip_rects, set_clip_path,
//! has_clip, strip_intersects_clip, clear_rects_transparent}`. //! clear_clip, has_clip, strip_intersects_clip,
//! clear_rects_transparent}`.
//! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect, //! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect,
//! stroke_rect, draw_line}`. //! stroke_rect, draw_line}`.
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`. //! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
@@ -417,6 +418,22 @@ impl Canvas
} }
} }
/// Clip subsequent draws to an arbitrary vector path (in surface
/// coordinates). Anti-aliased per-path clipping: the software backend uses a
/// tiny-skia coverage mask; the GLES backend captures the clipped draws into
/// an offscreen layer and composites it back through an anti-aliased coverage
/// mask. Replaces any active clip; restore the previous clip via
/// [`Self::set_clip_rects`] with
/// a [`Self::clip_bounds`] snapshot taken beforehand, or [`Self::clear_clip`].
pub fn set_clip_path( &mut self, cmds: &[ crate::types::PathCmd ] )
{
match self
{
Canvas::Software( c ) => c.set_clip_path( cmds ),
Canvas::Gles( c ) => c.set_clip_path( cmds ),
}
}
/// Snapshot the currently installed clip bounds (empty when no clip /// Snapshot the currently installed clip bounds (empty when no clip
/// is active). Used by widgets that need to install a tighter clip /// is active). Used by widgets that need to install a tighter clip
/// for a single primitive and then restore whatever the outer /// for a single primitive and then restore whatever the outer
@@ -749,3 +766,67 @@ mod viewport_tests
assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) ); assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) );
} }
} }
#[ cfg( test ) ]
mod clip_path_tests
{
// Exercised on the software backend (`Canvas::new`); the GLES layer-composite
// path needs a live GL context and is covered by the `clip_path` example.
use super::Canvas;
use crate::types::{ Color, PathCmd, Rect };
fn square( x: f32, y: f32, side: f32 ) -> Vec<PathCmd>
{
vec![
PathCmd::MoveTo( x, y ),
PathCmd::LineTo( x + side, y ),
PathCmd::LineTo( x + side, y + side ),
PathCmd::LineTo( x, y + side ),
PathCmd::Close,
]
}
#[ test ]
fn set_clip_path_bounds_match_the_path_bounding_box()
{
let mut c = Canvas::new( 200, 200 );
c.set_clip_path( &square( 10.0, 20.0, 100.0 ) );
let b = c.clip_bounds();
assert_eq!( b.len(), 1 );
let r = b[ 0 ];
assert!( ( r.x - 10.0 ).abs() < 0.5 && ( r.y - 20.0 ).abs() < 0.5, "origin {r:?}" );
assert!( ( r.width - 100.0 ).abs() < 0.5 && ( r.height - 100.0 ).abs() < 0.5, "size {r:?}" );
}
#[ test ]
fn set_clip_path_with_no_segments_clears_the_clip()
{
let mut c = Canvas::new( 100, 100 );
c.set_clip_path( &[] );
assert!( c.clip_bounds().is_empty() );
}
#[ test ]
fn set_clip_path_masks_a_fill_to_the_path_shape_not_its_bbox()
{
let mut c = Canvas::new( 100, 100 );
// Downward triangle: apex top-centre, base along the bottom.
let tri = vec![
PathCmd::MoveTo( 50.0, 5.0 ),
PathCmd::LineTo( 95.0, 95.0 ),
PathCmd::LineTo( 5.0, 95.0 ),
PathCmd::Close,
];
c.set_clip_path( &tri );
c.fill_rect( Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }, Color::rgba( 1.0, 0.0, 0.0, 1.0 ), 0.0 );
let Canvas::Software( sc ) = &c else { panic!( "Canvas::new builds a software canvas" ) };
// A point well inside the triangle is painted; a top corner — inside the
// bounding box but outside the triangle — stays clear, proving the clip
// follows the path silhouette rather than its bounding rect.
let inside = sc.pixmap.pixel( 50, 70 ).expect( "in-bounds pixel" );
let corner = sc.pixmap.pixel( 8, 8 ).expect( "in-bounds pixel" );
assert!( inside.red() > 200 && inside.alpha() > 200, "inside triangle must be filled" );
assert_eq!( corner.alpha(), 0, "bbox corner outside the triangle must stay clear" );
}
}