Files
ltk/src/render/mod.rs
Pedro M. de Echanove Pasquin ce893ac776
Some checks are pending
CI / build + test (push) Waiting to run
CI / cargo audit (push) Waiting to run
responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
2026-07-07 17:40:33 +02:00

1013 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<bool> = 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<Font>` 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<u8>,
}
// ─── 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<Font>,
/// 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<Vec<u8>>,
/// 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<Arc<FontRegistry>>,
/// 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<GlyphKey, GlyphEntry>,
/// 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<Mask>,
/// 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<Rect>,
}
// ─── 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<glow::Context>, 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<BorrowedGlesTexture>
{
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<FontRegistry> )
{
// 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<Font>
{
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<LineMetrics>
{
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<Rect>
{
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<Corners> )
{
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<Corners> )
{
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<Corners> )
{
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<Corners> )
{
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<Corners> )
{
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<Corners>,
)
{
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<Font> )
{
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<Font> ) -> 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<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" );
}
}
#[ 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<u8>
{
[ 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<u8>
{
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" );
}
}