diff --git a/examples/clip_path.rs b/examples/clip_path.rs new file mode 100644 index 0000000..2405617 --- /dev/null +++ b/examples/clip_path.rs @@ -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 +{ + 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 +{ + 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 +{ + 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 ) -> Element +{ + 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 + { + 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 + { + if keysym == Keysym::Escape + { + std::process::exit( 0 ); + } + None + } + + fn background_color( &self ) -> Color + { + ltk::theme_palette().bg + } +} + +fn main() +{ + ltk::run( Demo ); +} diff --git a/src/app.rs b/src/app.rs index 3aac06c..6f81dab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -473,7 +473,7 @@ pub trait App: 'static /// edge, matching the usual system-panel pull-down UX. 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 /// accumulated overscroll as a fraction of the surface height (≥ 0.0, /// unbounded). Use it to drive a pull-from-top-to-dismiss panel that diff --git a/src/chassis.rs b/src/chassis.rs index 12c8256..791fe27 100644 --- a/src/chassis.rs +++ b/src/chassis.rs @@ -1,7 +1,7 @@ //! Scaffolding shared by full-screen ambient surfaces (greeter, lock screen, //! kiosk): theme bring-up, branding/wallpaper loading and the wallpaper-backed //! 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. use std::sync::Arc; diff --git a/src/gles_render/clip.rs b/src/gles_render/clip.rs index 0858da5..a49beab 100644 --- a/src/gles_render/clip.rs +++ b/src/gles_render/clip.rs @@ -1,12 +1,15 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -//! `glScissor`-based clipping + whole-canvas fill / clear for -//! [`GlesCanvas`]. When [`GlesCanvas::set_clip_rects`] receives -//! multiple rects the bounding-box union is used as the scissor — -//! coarse, but the partial-redraw path normally clusters 1–3 rects -//! so the union is barely larger than the sum. Disjoint regions -//! would want a stencil-buffer path; not implemented today. +//! Clipping + whole-canvas fill / clear for [`GlesCanvas`]. Rect clips +//! ([`GlesCanvas::set_clip_rects`]) use `glScissor`: when given multiple rects +//! the bounding-box union is the scissor — coarse, but the partial-redraw path +//! normally clusters 1–3 rects so the union is barely larger than the sum +//! (disjoint regions are not clipped tightly). Arbitrary path clips +//! ([`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; @@ -18,6 +21,8 @@ impl GlesCanvas { 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 // applies to this canvas and not whatever target was active before. self.activate_target(); @@ -58,6 +63,114 @@ impl GlesCanvas // to match below. unsafe { self.gl.disable( glow::SCISSOR_TEST ); } 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` (empty when no diff --git a/src/gles_render/framebuffer.rs b/src/gles_render/framebuffer.rs index 20ae768..6394d45 100644 --- a/src/gles_render/framebuffer.rs +++ b/src/gles_render/framebuffer.rs @@ -83,22 +83,32 @@ impl GlesCanvas /// disable scissor when the canvas has no clip). pub( super ) fn activate_target( &self ) { - // SAFETY: rebinds canvas-owned FBO + viewport + scissor. All values - // (`self.fbo`, `self.width`, `self.height`, `self.clip_scissor`) + // While a path clip is active, draws are redirected to the offscreen clip + // 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. + 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 { - 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 ); match self.clip_scissor { - Some( r ) => + Some( r ) if !to_layer => { let ( x, y, w, h ) = self.scissor_pixels( r ); self.gl.enable( glow::SCISSOR_TEST ); self.gl.scissor( x, y, w, h ); } - None => + _ => { self.gl.disable( glow::SCISSOR_TEST ); } diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index ac08b59..ed526eb 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -9,12 +9,14 @@ //! [`crate::egl_context`] — this module is just the renderer that runs //! 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 //! bounding-box union is used as the scissor — coarse, but the //! partial-redraw path normally clusters the dirty rects of 1–3 -//! widgets so the union is barely larger than the sum. Disjoint -//! regions would want a stencil-buffer path; not implemented today. +//! widgets so the union is barely larger than the sum. Arbitrary +//! 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 //! @@ -354,6 +356,23 @@ pub struct GlesCanvas /// (GL_SCISSOR_TEST is enabled), `None` when cleared. clip_scissor: Option, + /// 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, + 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; /// [`Self::present`](framebuffer) is the only call that switches /// to the default framebuffer. @@ -398,6 +417,15 @@ impl Drop for GlesCanvas { self.gl.delete_framebuffer( self.fbo ); 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() { self.gl.delete_framebuffer( fbo ); diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index a219c09..b1363d8 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -28,6 +28,7 @@ use super::shaders:: GLYPH_FRAG_SRC, GLYPH_BATCH_VERT_SRC, GLYPH_BATCH_FRAG_SRC, LINEAR_GRADIENT_FRAG_SRC, RADIAL_GRADIENT_FRAG_SRC, RECT_FRAG_SRC, + CLIP_COMPOSITE_FRAG_SRC, SHADOW_INSET_FRAG_SRC, SHADOW_INSET_OVERLAY_FRAG_SRC, SHADOW_OUTER_FRAG_SRC, SUB_BLIT_FRAG_SRC, TEX_FRAG_SRC, VERT_SRC, @@ -211,6 +212,16 @@ impl GlesCanvas 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 vao = gl.create_vertex_array().unwrap(); @@ -464,6 +475,16 @@ impl GlesCanvas image_cache: HashMap::new(), gradient_lut_cache: HashMap::new(), 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_tex, aux_a: None, @@ -665,6 +686,16 @@ impl GlesCanvas image_cache: HashMap::new(), gradient_lut_cache: HashMap::new(), 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_tex, aux_a: None, @@ -768,10 +799,22 @@ impl GlesCanvas glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, 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.clear_color( 0.0, 0.0, 0.0, 0.0 ); self.gl.clear( glow::COLOR_BUFFER_BIT ); } + self.clip_layer_active = false; // Auxiliary textures were sized for the old dimensions — drop them so // the next effect that needs them re-allocates at the new size. self.invalidate_aux(); diff --git a/src/gles_render/shaders.rs b/src/gles_render/shaders.rs index c7d1efd..5d89299 100644 --- a/src/gles_render/shaders.rs +++ b/src/gles_render/shaders.rs @@ -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. // // Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset` diff --git a/src/render/clip.rs b/src/render/clip.rs index 26cf043..9809e31 100644 --- a/src/render/clip.rs +++ b/src/render/clip.rs @@ -3,7 +3,9 @@ //! 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. +//! 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 }; @@ -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. pub fn clear_clip( &mut self ) { diff --git a/src/render/mod.rs b/src/render/mod.rs index 6419551..5ebb43d 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -19,8 +19,9 @@ //! //! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit, //! set_font_registry, font_for}` (construction + accessors). -//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, clear_clip, -//! has_clip, strip_intersects_clip, clear_rects_transparent}`. +//! * [`clip`] — `SoftwareCanvas::{set_clip_rects, set_clip_path, +//! clear_clip, has_clip, strip_intersects_clip, +//! clear_rects_transparent}`. //! * [`primitives`] — `SoftwareCanvas::{clear, fill, fill_rect, //! stroke_rect, draw_line}`. //! * [`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 /// is active). Used by widgets that need to install a tighter clip /// 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 ) ); } } + +#[ 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 + { + 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" ); + } +}