// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Rendering surface used by every widget. //! //! [`Canvas`] is a thin enum wrapper over the per-frame rendering //! backend. The CPU backend is [`SoftwareCanvas`] (tiny-skia + fontdue //! rasterised into a `Pixmap`). The GPU backend is //! [`crate::gles_render::GlesCanvas`] (EGL + GLES2/3). //! //! Widgets only ever see `&mut Canvas` — they call `fill_rect`, //! `draw_text`, etc. The enum dispatches by `match self` (no `dyn`, //! so the call sites stay monomorphic and inlinable). Field-style //! access to backend internals (`pixmap`, `font`, `dpi_scale`…) is //! replaced by accessor methods that the GPU variant can also //! implement. //! //! # Submodule layout //! //! * [`setup`] — `SoftwareCanvas::{new, sub_canvas, resize, blit, //! set_font_registry, font_for}` (construction + accessors). //! * [`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}`. //! * [`image`] — `SoftwareCanvas::{draw_image_data, //! write_to_wayland_buf}`. //! * [`helpers`] — free functions: `build_ts_path`, //! `build_rounded_rect`. System-font resolution lives in //! `crate::system_fonts`. use std::cell::Cell; use std::sync::Arc; use fontdue::{ Font, LineMetrics, Metrics }; use tiny_skia::{ Mask, Pixmap }; use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion }; use crate::theme::{ FontRegistry, FontStyle, InsetShadow, Paint as ThemePaint, Shadow }; use crate::types::{ Color, Corners, Length, Rect, WidgetScaling }; pub( crate ) mod setup; pub( crate ) mod clip; pub( crate ) mod primitives; pub( crate ) mod text; pub( crate ) mod image; pub( crate ) mod helpers; // ─── Backend flag ──────────────────────────────────────────────────────────── thread_local! { /// `true` when this thread's surfaces are rendered through the /// software (tiny-skia / SHM) path, `false` when they go through /// the GLES path. Set once at startup based on EGL availability /// and read by view code that needs to branch on backend (e.g. a /// layout that costs something specific to one path and isn't /// worth replicating on the other). Stays a thread-local so view /// code does not need to plumb a flag through every layout call. static SOFTWARE_RENDER: Cell = const { Cell::new( false ) }; } /// Toggle the software-render flag for this thread. Consumers read /// with [`is_software_render`]. pub fn set_software_render( on: bool ) { SOFTWARE_RENDER.with( | c | c.set( on ) ); } /// `true` when the active surfaces on this thread render through the /// software path. Used by view code that wants to avoid pipeline /// effects the software backend doesn't implement. pub fn is_software_render() -> bool { SOFTWARE_RENDER.with( | c | c.get() ) } // ─── Glyph cache ───────────────────────────────────────────────────────────── /// Cache key for a rasterized glyph. `size_bits` is the f32 bit /// pattern of `size * dpi_scale`; `font_id` is the address of the /// `Arc` used for the rasterisation, so distinct weights / /// families of the same `(glyph_id, size)` do not collide on the /// cache. `glyph_id` is the per-font glyph index returned by /// HarfBuzz shaping, so cached entries persist across script /// transitions and Arabic / Devanagari / CJK forms cluster /// independently of the `char` codepoint that produced them. #[ derive( Hash, PartialEq, Eq, Clone, Copy ) ] pub ( super ) struct GlyphKey { pub ( super ) glyph_id: u16, pub ( super ) size_bits: u32, pub ( super ) font_id: usize, } /// Cached glyph bitmap and metrics. Fontdue's rasterize call is the /// dominant per-frame CPU cost for text-heavy UIs; reusing across /// frames avoids that work. pub ( super ) struct GlyphEntry { pub ( super ) metrics: Metrics, pub ( super ) bitmap: Vec, } // ─── SoftwareCanvas ────────────────────────────────────────────────────────── /// Software rendering backend backed by a tiny-skia [`Pixmap`] and a /// fontdue [`Font`]. /// /// Wrapped by [`Canvas`] so the GPU backend can be slotted in by the /// runtime without changing widget code. Widgets themselves never see /// `SoftwareCanvas` directly. pub struct SoftwareCanvas { /// The pixel buffer drawn into each frame. pub pixmap: Pixmap, /// The loaded system font used for all text rendering. /// /// Kept as the default fallback so widgets that do not yet ask for a /// specific family through [`SoftwareCanvas::font_for`] keep /// working. Populated from /// `crate::system_fonts::default_handle` at construction time. pub font: Arc, /// Raw bytes of the default font. Kept alongside `font` so the /// HarfBuzz shaper (rustybuzz) can be invoked without re-reading /// the file — fontdue does not expose its internal byte buffer. pub font_bytes: Arc>, /// TTC sub-face index for the default font (0 for single-face /// files; collection index for `.ttc` archives). pub font_face: u32, /// Optional theme font registry. When present, /// [`SoftwareCanvas::font_for`] consults it before falling back /// to `font`. Populated by the caller once the theme's `fonts` /// block has been loaded. pub font_registry: Option>, /// DPI scale factor applied to font sizes. pub dpi_scale: f32, /// Global alpha multiplier for all drawing operations (0.0 = /// transparent, 1.0 = opaque). pub global_alpha: f32, /// Persistent cache of rasterized glyphs, indexed by (char, scaled size). /// Grows on demand; not LRU-bounded since typical UIs use few sizes. glyph_cache: std::collections::HashMap, /// Optional clip mask applied to all paint operations. Set via /// [`Canvas::set_clip_rects`] during a partial redraw so only /// pixels inside the dirty rects are touched. `None` means "draw /// everywhere". clip_mask: Option, /// Bounding boxes of the clip rects in physical pixels. Used by /// [`SoftwareCanvas::draw_text`] to do an early reject without /// poking the mask byte by byte (the Mask buffer is still /// authoritative inside the pixel loop). clip_bounds: Vec, } // ─── Canvas enum + dispatch ───────────────────────────────────────────────── /// Per-frame rendering surface. Wraps a backend (software or GPU) /// behind an enum so widgets can stay backend-agnostic. /// /// All drawing methods are dispatched by `match self` — no `dyn` /// indirection, so the backend branch stays predictable and /// inlinable in the hot path. pub enum Canvas { /// CPU rasterisation via tiny-skia + fontdue, written to a /// `wl_shm` buffer. Software( SoftwareCanvas ), /// GPU rasterisation via EGL + GLES 2/3. Presents via /// `eglSwapBuffers`; [`Canvas::write_to_wayland_buf`] is a no-op /// for this variant. Gles( GlesCanvas ), } impl Canvas { /// Build a software canvas. The GPU backend requires an EGL /// context — see [`Canvas::new_gles`]. pub fn new( width: u32, height: u32 ) -> Self { Canvas::Software( SoftwareCanvas::new( width, height ) ) } /// Build a GPU canvas on an already-current EGL context. pub fn new_gles( gl: Arc, version: GlesVersion, width: u32, height: u32, ) -> Self { Canvas::Gles( GlesCanvas::new( gl, version, width, height ) ) } /// `(width, height)` of the underlying surface in physical pixels. pub fn size( &self ) -> ( u32, u32 ) { match self { Canvas::Software( c ) => ( c.pixmap.width(), c.pixmap.height() ), Canvas::Gles( c ) => c.size(), } } /// `(width, height)` of the surface in **logical** pixels (physical /// size divided by `dpi_scale`). This is the right viewport for /// resolving [`crate::Length`] values, which are themselves in /// logical units. Falls back to physical size if `dpi_scale` is /// 0 or negative so a misconfigured canvas still returns a usable /// non-zero viewport instead of `NaN`/`inf`. /// /// ```rust,no_run /// # use ltk::core::Canvas; /// let mut c = Canvas::new( 720, 1440 ); /// c.set_dpi_scale( 2.0 ); /// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); /// ``` pub fn viewport_logical( &self ) -> ( f32, f32 ) { let ( pw, ph ) = self.size(); let scale = self.dpi_scale(); if scale > 0.0 { ( pw as f32 / scale, ph as f32 / scale ) } else { ( pw as f32, ph as f32 ) } } /// `(width, height)` of the surface in **physical** pixels — the same /// space the layout tree is computed in (the root rect is `pw × ph`). /// This is the viewport that layout-affecting [`crate::Length`] values /// (widths, paddings, gaps, widget sizes) must resolve against so a /// `Vw(100)` fills the surface. Text font sizes are the exception: /// they resolve against [`Self::viewport_logical`] and are then scaled /// by `dpi_scale` at raster time, so they must NOT use this. pub fn viewport_layout( &self ) -> ( f32, f32 ) { let ( pw, ph ) = self.size(); ( pw as f32, ph as f32 ) } /// Resolve a stock-widget **geometry** design pixel (height, padding, /// box size, gap…) through the process-wide [`crate::WidgetScaling`] /// mode, into a concrete physical-pixel value for the layout tree. /// Widgets call this instead of using a raw `theme::` constant so their /// intrinsic geometry follows the app's chosen adaptation strategy /// ([`crate::WidgetScaling::Fluid`] → surface-proportional, /// [`crate::WidgetScaling::Physical`] → constant physical size). Geometry /// lives in physical space, so this resolves against /// [`Self::viewport_layout`]. pub fn geom_px( &self, design_px: f32 ) -> f32 { Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT ) } /// Resolve a stock-widget **font** design pixel through the process-wide /// [`crate::WidgetScaling`] mode. Font sizes are handed to the raster /// path pre-`dpi_scale`, so this bridges the logical/physical split for /// each mode: in [`crate::WidgetScaling::Fluid`] it resolves the fluid /// size against [`Self::viewport_logical`] (so `× dpi_scale` at raster /// yields a surface-proportional physical size); in /// [`crate::WidgetScaling::Physical`] it divides the density-scaled size /// by `dpi_scale` (so `× dpi_scale` yields a constant physical size). pub fn font_px( &self, design_px: f32 ) -> f32 { match crate::types::widget_scaling() { WidgetScaling::Fluid => { Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT ) } WidgetScaling::Physical => { let scale = self.dpi_scale(); let scale = if scale > 0.0 { scale } else { 1.0 }; design_px * crate::types::density() / scale } } } /// Borrow the GLES texture backing this canvas, when the canvas /// is GPU-backed. pub fn borrowed_gles_texture( &self ) -> Option { match self { Canvas::Software( _ ) => None, Canvas::Gles( c ) => Some( c.borrowed_texture() ), } } /// Read a GLES canvas into tightly packed RGBA8, top-left row /// first. Intentionally unavailable for software canvases because /// the software backend's canonical export path is /// [`Self::write_to_wayland_buf`]. pub fn read_gles_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String> { match self { Canvas::Software( _ ) => Err( "read_gles_rgba_pixels requires Canvas::Gles".to_string() ), Canvas::Gles( c ) => c.read_rgba_pixels( out ), } } /// Whether this is the CPU (software) backend. Callers that can honour a real /// path clip on the software backend but only a bounding rect on GLES branch /// on this. pub fn is_software( &self ) -> bool { matches!( self, Canvas::Software( _ ) ) } /// Read this canvas into tightly packed straight-alpha RGBA8, top-left row /// first (`out.len()` must be at least width*height*4). Unlike /// [`Self::read_gles_rgba_pixels`] this also serves the software backend, by /// un-premultiplying its pixmap — used to read back an offscreen software /// canvas (e.g. an Android `Canvas(Bitmap)`) into a straight-alpha buffer. pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String> { match self { Canvas::Software( c ) => { let pixels = c.pixmap.pixels(); if out.len() < pixels.len() * 4 { return Err( "read_rgba_pixels: output buffer too small".to_string() ); } for ( i, p ) in pixels.iter().enumerate() { let d = p.demultiply(); let o = i * 4; out[ o ] = d.red(); out[ o + 1 ] = d.green(); out[ o + 2 ] = d.blue(); out[ o + 3 ] = d.alpha(); } Ok( () ) } Canvas::Gles( c ) => c.read_rgba_pixels( out ), } } /// Composite an externally-owned GL texture into `dest`. No-op on /// the software backend (no GL state to sample from). Used by /// widgets that host content rendered by an external producer — /// the producer keeps ownership of the texture name; this call /// only samples it through the standard texture program. pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 ) { match self { Canvas::Software( _ ) => {} Canvas::Gles( c ) => c.draw_external_texture( texture, dest, opacity ), } } pub fn dpi_scale( &self ) -> f32 { match self { Canvas::Software( c ) => c.dpi_scale, Canvas::Gles( c ) => c.dpi_scale(), } } pub fn set_dpi_scale( &mut self, s: f32 ) { match self { Canvas::Software( c ) => c.dpi_scale = s, Canvas::Gles( c ) => c.set_dpi_scale( s ), } } pub fn global_alpha( &self ) -> f32 { match self { Canvas::Software( c ) => c.global_alpha, Canvas::Gles( c ) => c.global_alpha(), } } pub fn set_global_alpha( &mut self, a: f32 ) { match self { Canvas::Software( c ) => c.global_alpha = a, Canvas::Gles( c ) => c.set_global_alpha( a ), } } /// Shared font handle. Exposed so widgets that need raw `fontdue` /// access (e.g. `Text` for ascent/descent) do not have to go /// through wrappers for every metric they read. pub fn font( &self ) -> &Font { match self { Canvas::Software( c ) => &c.font, Canvas::Gles( c ) => c.font(), } } /// Install a theme font registry on the active backend. pub fn set_font_registry( &mut self, registry: Arc ) { // Drop the shaped-line cache: a new registry can swap the faces the // resolver leads with, so cached glyph runs may no longer match. crate::text_shaping::clear_shape_cache(); match self { Canvas::Software( c ) => c.set_font_registry( registry ), Canvas::Gles( c ) => c.set_font_registry( registry ), } } /// Resolve a specific font via the theme registry, falling back /// to the system-default [`Self::font`] when no registry is /// installed or the triple cannot be satisfied. pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc { match self { Canvas::Software( c ) => c.font_for( family, weight, style ), Canvas::Gles( c ) => c.font_for( family, weight, style ), } } /// Convenience wrapper around `font().metrics(...)` already /// pre-scaled by `dpi_scale`. Most callers want this rather than /// the raw font handle. pub fn font_metrics( &self, ch: char, size: f32 ) -> Metrics { self.font().metrics( ch, size * self.dpi_scale() ) } /// Convenience wrapper around `font().horizontal_line_metrics(...)`. pub fn font_line_metrics( &self, size: f32 ) -> Option { self.font().horizontal_line_metrics( size ) } pub fn resize( &mut self, width: u32, height: u32 ) { match self { Canvas::Software( c ) => c.resize( width, height ), Canvas::Gles( c ) => c.resize( width, height ), } } pub fn sub_canvas( &self, width: u32, height: u32 ) -> Canvas { match self { Canvas::Software( c ) => Canvas::Software( c.sub_canvas( width, height ) ), Canvas::Gles( c ) => Canvas::Gles( c.sub_canvas( width, height ) ), } } pub fn blit( &mut self, src: &Canvas, dest_x: i32, dest_y: i32 ) { self.blit_fade_bottom( src, dest_x, dest_y, 0.0 ) } /// Like [`Self::blit`] but feathers the last `fade_bottom_px` source /// rows so the bottom edge fades to transparent. The software backend /// currently ignores `fade_bottom_px`, so the dissolve is GLES-only. pub fn blit_fade_bottom( &mut self, src: &Canvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 ) { match ( self, src ) { ( Canvas::Software( dst ), Canvas::Software( s ) ) => { let _ = fade_bottom_px; dst.blit( s, dest_x, dest_y ); } ( Canvas::Gles( dst ), Canvas::Gles( s ) ) => { dst.blit_fade_bottom( s, dest_x, dest_y, fade_bottom_px ); } // Cross-backend blits would need an SHM↔texture upload. // The toolkit only ever creates sub-canvases of the same // kind as their parent, so this is unreachable in practice. _ => unimplemented!( "cross-backend blit not supported" ), } } pub fn set_clip_rects( &mut self, rects: &[Rect] ) { match self { Canvas::Software( c ) => c.set_clip_rects( rects ), Canvas::Gles( c ) => c.set_clip_rects( rects ), } } /// 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 /// partial-redraw or sub-canvas clip was — there is no stack /// internally, so round-tripping through /// [`Self::set_clip_rects`] with the snapshot is how to compose. pub fn clip_bounds( &self ) -> Vec { match self { Canvas::Software( c ) => c.clip_bounds_snapshot(), Canvas::Gles( c ) => c.clip_bounds_snapshot(), } } pub fn clear_clip( &mut self ) { match self { Canvas::Software( c ) => c.clear_clip(), Canvas::Gles( c ) => c.clear_clip(), } } pub fn clear( &mut self ) { match self { Canvas::Software( c ) => c.clear(), Canvas::Gles( c ) => c.clear(), } } pub fn fill( &mut self, color: Color ) { match self { Canvas::Software( c ) => c.fill( color ), Canvas::Gles( c ) => c.fill( color ), } } pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: impl Into ) { let corners = corners.into(); match self { Canvas::Software( c ) => c.fill_rect( rect, color, corners ), Canvas::Gles( c ) => c.fill_rect( rect, color, corners ), } } /// Paint-driven rectangle fill. /// /// Dispatches on the [`crate::theme::Paint`] variant. Solid /// fills go straight through [`Self::fill_rect`]. Gradients /// (linear and radial) are routed to dedicated shaders on the /// GPU backend; on the Software backend they still collapse to a /// flat fill from the first stop — tiny-skia can render /// gradients natively, but wiring that up is left for a /// follow-up. pub fn fill_paint_rect( &mut self, rect: Rect, paint: &ThemePaint, corners: impl Into ) { let corners = corners.into(); match paint { ThemePaint::Solid( c ) => self.fill_rect( rect, *c, corners ), ThemePaint::Linear( g ) => { match self { Canvas::Software( sc ) => { let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT ); sc.fill_rect( rect, c, corners ); } Canvas::Gles( gc ) => gc.fill_linear_gradient_rect( rect, g, corners ), } } ThemePaint::Radial( g ) => { match self { Canvas::Software( sc ) => { let c = g.stops.first().map( |s| s.color ).unwrap_or( Color::TRANSPARENT ); sc.fill_rect( rect, c, corners ); } Canvas::Gles( gc ) => gc.fill_radial_gradient_rect( rect, g, corners ), } } } } pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: impl Into ) { let corners = corners.into(); match self { Canvas::Software( c ) => c.stroke_rect( rect, color, width, corners ), Canvas::Gles( c ) => c.stroke_rect( rect, color, width, corners ), } } /// Fill an arbitrary vector path (commands in surface coordinates) with a /// solid colour. The software backend rasterises with tiny-skia directly; /// the GLES backend rasterises into a tiny-skia pixmap and blits it (the /// CPU fallback — no path shader on the GPU path). pub fn fill_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color ) { match self { Canvas::Software( c ) => c.fill_path( cmds, color ), Canvas::Gles( c ) => c.fill_path( cmds, color ), } } /// Stroke an arbitrary vector path (commands in surface coordinates). pub fn stroke_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color, width: f32 ) { match self { Canvas::Software( c ) => c.stroke_path( cmds, color, width ), Canvas::Gles( c ) => c.stroke_path( cmds, color, width ), } } /// Paint an outer drop shadow behind the rounded rect `target`. /// /// On the GPU backend this runs an analytic soft-shadow shader /// in one draw call — no FBO, no cache, no readback. On the /// Software backend it is a no-op today. pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: impl Into ) { let corners = corners.into(); match self { Canvas::Software( _ ) => { /* TODO: tiny-skia BlurDropShadow */ } Canvas::Gles( c ) => c.fill_shadow_outer( target, shadow, corners ), } } /// Paint an inner (inset) shadow inside the rounded rect /// `target`. /// /// On the GPU backend, uses a dedicated shader whose inner SDF /// encodes `shadow.offset` and `shadow.spread`. The blend state /// is switched per-call to honour `shadow.blend`: `Normal`, /// `PlusLighter`, `Multiply` and `Screen` map to fixed-function /// blend modes; `Overlay` routes through a dedicated shader that /// snapshots the FBO and computes the CSS Overlay formula /// in-shader. /// /// On the Software backend this is a no-op today. pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: impl Into ) { let corners = corners.into(); match self { Canvas::Software( _ ) => { /* TODO: tiny-skia inner shadow */ } Canvas::Gles( c ) => c.fill_shadow_inset( target, shadow, corners ), } } /// Unified surface painter. Composes a themed surface in the canonical /// paint order: outer shadows → fill → insets. pub fn fill_surface ( &mut self, rect: Rect, fill: &ThemePaint, outer_shadows: &[Shadow], inset_shadows: &[InsetShadow], corners: impl Into, ) { let corners = corners.into(); for shadow in outer_shadows { self.fill_shadow_outer( rect, shadow, corners ); } self.fill_paint_rect( rect, fill, corners ); for inset in inset_shadows { self.fill_shadow_inset( rect, inset, corners ); } } pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) { match self { Canvas::Software( c ) => c.draw_line( x0, y0, x1, y1, color, width ), Canvas::Gles( c ) => c.draw_line( x0, y0, x1, y1, color, width ), } } pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color ) { match self { Canvas::Software( c ) => c.draw_text( text, x, y, size, color ), Canvas::Gles( c ) => c.draw_text( text, x, y, size, color ), } } /// Draw `text` with an explicitly supplied font instead of the /// canvas default. Use [`Self::font_for`] to resolve a `(family, /// weight, style)` triple from the active theme registry first. pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc ) { match self { Canvas::Software( c ) => c.draw_text_with_font( text, x, y, size, color, font ), Canvas::Gles( c ) => c.draw_text_with_font( text, x, y, size, color, font ), } } pub fn measure_text( &self, text: &str, size: f32 ) -> f32 { match self { Canvas::Software( c ) => c.measure_text( text, size ), Canvas::Gles( c ) => c.measure_text( text, size ), } } /// Width of `text` rendered with `font`. Mirrors /// [`Self::measure_text`] but bypasses the canvas default font so /// text laid out at one weight and drawn at another stays aligned. pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc ) -> f32 { match self { Canvas::Software( c ) => c.measure_text_with_font( text, size, font ), Canvas::Gles( c ) => c.measure_text_with_font( text, size, font ), } } pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 ) { match self { Canvas::Software( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ), Canvas::Gles( c ) => c.draw_image_data( rgba_data, img_w, img_h, dest, opacity ), } } /// Zero pixels 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] ) { match self { Canvas::Software( c ) => c.clear_rects_transparent( rects ), Canvas::Gles( c ) => c.clear_rects_transparent( rects ), } } /// Copy / present the rendered frame. For software this fills a /// `wl_shm` buffer (with optional R/B swap for Argb8888). For /// GPU the commit happens via `eglSwapBuffers` elsewhere — this /// call is a no-op. pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool ) { match self { Canvas::Software( c ) => c.write_to_wayland_buf( buf, swap_rb ), Canvas::Gles( _ ) => {} } } /// Publish the in-progress GPU frame: blit the FBO onto the EGL /// window's default framebuffer. The follow-up `eglSwapBuffers` /// (done outside the canvas) is what actually commits to the /// compositor. No-op on software, where presentation is the SHM /// `attach_to`/`commit` pair. pub fn present( &mut self ) { match self { Canvas::Software( _ ) => {} Canvas::Gles( c ) => c.present(), } } } #[ cfg( test ) ] mod viewport_tests { use super::Canvas; #[ test ] fn viewport_logical_at_scale_one_matches_physical() { let c = Canvas::new( 800, 600 ); assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); } #[ test ] fn viewport_logical_divides_by_dpi_scale() { let mut c = Canvas::new( 720, 1440 ); c.set_dpi_scale( 2.0 ); assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); } #[ test ] fn viewport_logical_falls_back_to_physical_when_scale_is_zero() { // Guard the misconfigured-scale path: a divide-by-zero would // poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream. let mut c = Canvas::new( 800, 600 ); c.set_dpi_scale( 0.0 ); assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); } #[ test ] fn viewport_layout_is_physical_and_ignores_dpi_scale() { // Layout-affecting `Length` values resolve against this, so it must // stay in physical space (where the layout tree is computed) even on // HiDPI — unlike `viewport_logical`, it does not divide by the scale. let mut c = Canvas::new( 720, 1440 ); assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) ); c.set_dpi_scale( 2.0 ); assert_eq!( c.viewport_layout(), ( 720.0, 1440.0 ) ); } #[ test ] fn geom_px_resolves_widget_design_pixel_per_mode() { use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length }; let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); // Fluid (default): geom_px == fluid( n ) against the physical viewport. set_widget_scaling( WidgetScaling::Fluid ); let c = Canvas::new( 412, 900 ); assert_eq!( c.geom_px( 48.0 ), Length::fluid( 48.0 ).resolve( ( 412.0, 900.0 ), Length::EM_BASE_DEFAULT ) ); // Physical: geom_px == n * density, independent of surface size. set_widget_scaling( WidgetScaling::Physical ); set_density( 2.0 ); assert_eq!( c.geom_px( 48.0 ), 96.0 ); set_density( 1.0 ); set_widget_scaling( WidgetScaling::Fluid ); } #[ test ] fn font_px_is_constant_physical_in_physical_mode() { use crate::types::{ set_widget_scaling, set_density, WidgetScaling }; let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); // Physical mode divides by dpi_scale so `× dpi_scale` at raster yields // a constant physical size (n * density). set_widget_scaling( WidgetScaling::Physical ); set_density( 2.0 ); let mut c = Canvas::new( 720, 1440 ); c.set_dpi_scale( 2.0 ); // 16 * 2 (density) / 2 (dpi_scale) = 16 logical → 32 physical after raster. assert_eq!( c.font_px( 16.0 ), 16.0 ); set_density( 1.0 ); set_widget_scaling( WidgetScaling::Fluid ); } } #[ 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" ); } } #[ cfg( test ) ] mod pixel_snap_tests { // The GLES backend rounds glyph pen positions and image destinations to // integer pixels for crisp 1:1 sampling; these check the software backend // now matches that snapping so both backends place content identically. use super::Canvas; use crate::types::{ Color, Rect }; fn red_4x4() -> Vec { [ 255u8, 0, 0, 255 ].repeat( 16 ) } #[ test ] fn image_dest_below_half_snaps_to_the_same_pixels_as_integer_dest() { let red = red_4x4(); let mut a = Canvas::new( 32, 32 ); a.draw_image_data( &red, 4, 4, Rect { x: 5.0, y: 5.0, width: 4.0, height: 4.0 }, 1.0 ); let mut b = Canvas::new( 32, 32 ); b.draw_image_data( &red, 4, 4, Rect { x: 5.4, y: 5.4, width: 4.0, height: 4.0 }, 1.0 ); let Canvas::Software( sa ) = &a else { panic!( "software canvas" ) }; let Canvas::Software( sb ) = &b else { panic!( "software canvas" ) }; assert_eq!( sa.pixmap.data(), sb.pixmap.data(), "a sub-half fractional dest snaps to the integer origin" ); assert!( sa.pixmap.pixel( 5, 5 ).unwrap().red() > 200, "the block lands on pixel (5,5)" ); assert_eq!( sa.pixmap.pixel( 4, 4 ).unwrap().alpha(), 0, "pixel (4,4) is outside the snapped block" ); } #[ test ] fn image_dest_at_or_above_half_rounds_to_the_next_pixel() { let red = red_4x4(); let mut c = Canvas::new( 32, 32 ); c.draw_image_data( &red, 4, 4, Rect { x: 5.6, y: 5.6, width: 4.0, height: 4.0 }, 1.0 ); let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) }; assert!( sc.pixmap.pixel( 6, 6 ).unwrap().red() > 200, "round(5.6)=6 moves the block one pixel" ); assert_eq!( sc.pixmap.pixel( 5, 5 ).unwrap().alpha(), 0, "pixel (5,5) is now clear" ); } #[ test ] fn text_pen_position_is_rounded_not_truncated() { let draw = |x: f32| -> Vec { let mut c = Canvas::new( 64, 64 ); c.draw_text( "l", x, 40.0, 32.0, Color::rgba( 1.0, 1.0, 1.0, 1.0 ) ); let Canvas::Software( sc ) = &c else { panic!( "software canvas" ) }; sc.pixmap.data().to_vec() }; let at_int = draw( 20.0 ); let at_low = draw( 20.4 ); let at_high = draw( 20.6 ); assert!( at_int.iter().any( |&b| b != 0 ), "the glyph must paint some pixels" ); assert_eq!( at_int, at_low, "x and x+0.4 round to the same pixel column" ); assert_ne!( at_int, at_high, "x+0.6 rounds up to the next column" ); } }