First commit. Version 0.1.0
This commit is contained in:
100
src/render/clip.rs
Normal file
100
src/render/clip.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Clip-mask management for [`SoftwareCanvas`]. The partial-redraw
|
||||
//! path calls `set_clip_rects` before every repaint so only pixels
|
||||
//! inside the dirty rects are touched.
|
||||
|
||||
use tiny_skia::{ FillRule, Mask, PathBuilder, Transform };
|
||||
|
||||
use crate::types::Rect;
|
||||
|
||||
use super::SoftwareCanvas;
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
/// Set the active clip region to the union of `rects` (physical pixels).
|
||||
pub fn set_clip_rects( &mut self, rects: &[Rect] )
|
||||
{
|
||||
let w = self.pixmap.width();
|
||||
let h = self.pixmap.height();
|
||||
let Some( mut mask ) = Mask::new( w, h ) else
|
||||
{
|
||||
self.clip_mask = None;
|
||||
self.clip_bounds = Vec::new();
|
||||
return;
|
||||
};
|
||||
let mut pb = PathBuilder::new();
|
||||
for r in rects
|
||||
{
|
||||
let x0 = r.x.max( 0.0 ).min( w as f32 );
|
||||
let y0 = r.y.max( 0.0 ).min( h as f32 );
|
||||
let x1 = ( r.x + r.width ).max( 0.0 ).min( w as f32 );
|
||||
let y1 = ( r.y + r.height ).max( 0.0 ).min( h as f32 );
|
||||
if x1 <= x0 || y1 <= y0 { continue; }
|
||||
pb.push_rect( tiny_skia::Rect::from_ltrb( x0, y0, x1, y1 )
|
||||
.expect( "valid rect" ) );
|
||||
}
|
||||
if let Some( path ) = pb.finish()
|
||||
{
|
||||
mask.fill_path( &path, FillRule::Winding, false, Transform::identity() );
|
||||
self.clip_mask = Some( mask );
|
||||
self.clip_bounds = rects.to_vec();
|
||||
} else {
|
||||
self.clip_mask = None;
|
||||
self.clip_bounds = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the active clip so subsequent paints cover the full canvas.
|
||||
pub fn clear_clip( &mut self )
|
||||
{
|
||||
self.clip_mask = None;
|
||||
self.clip_bounds = Vec::new();
|
||||
}
|
||||
|
||||
pub ( super ) fn has_clip( &self ) -> bool
|
||||
{
|
||||
self.clip_mask.is_some()
|
||||
}
|
||||
|
||||
/// Snapshot of the active clip bounds (empty when no clip is set).
|
||||
pub fn clip_bounds_snapshot( &self ) -> Vec<Rect>
|
||||
{
|
||||
if self.has_clip() { self.clip_bounds.clone() } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// True when a horizontal strip `y` in `[y0, y1]` touches any clip bound.
|
||||
pub ( super ) fn strip_intersects_clip( &self, y0: f32, y1: f32 ) -> bool
|
||||
{
|
||||
if self.clip_bounds.is_empty() { return !self.has_clip(); }
|
||||
self.clip_bounds.iter().any( |r|
|
||||
{
|
||||
y1 > r.y && y0 < r.y + r.height
|
||||
} )
|
||||
}
|
||||
|
||||
/// Zero the alpha+RGB bytes inside each rect, used by the
|
||||
/// partial-redraw path when the surface background is fully
|
||||
/// transparent.
|
||||
pub fn clear_rects_transparent( &mut self, rects: &[Rect] )
|
||||
{
|
||||
let pw = self.pixmap.width() as i32;
|
||||
let ph = self.pixmap.height() as i32;
|
||||
let bytes = self.pixmap.data_mut();
|
||||
for r in rects
|
||||
{
|
||||
let x0 = ( r.x as i32 ).max( 0 );
|
||||
let y0 = ( r.y as i32 ).max( 0 );
|
||||
let x1 = ( ( r.x + r.width ).ceil() as i32 ).min( pw );
|
||||
let y1 = ( ( r.y + r.height ).ceil() as i32 ).min( ph );
|
||||
if x1 <= x0 || y1 <= y0 { continue; }
|
||||
for py in y0..y1
|
||||
{
|
||||
let row_start = ( py * pw + x0 ) as usize * 4;
|
||||
let row_end = ( py * pw + x1 ) as usize * 4;
|
||||
bytes[ row_start..row_end ].fill( 0 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/render/helpers.rs
Normal file
112
src/render/helpers.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Backend-neutral helpers for the software renderer: rounded-rect
|
||||
//! path construction + system-font lookup.
|
||||
|
||||
use tiny_skia::{ Path, PathBuilder };
|
||||
|
||||
use crate::types::Corners;
|
||||
|
||||
/// Cubic bezier control-point factor for a quarter-circle approximation
|
||||
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
|
||||
const KAPPA: f32 = 0.5523_f32;
|
||||
|
||||
/// Build a rounded rectangle path with independent per-corner radii
|
||||
/// using cubic bezier curves. Each corner is clamped against the
|
||||
/// inscribed-circle limit `min(width, height) / 2` before drawing,
|
||||
/// so callers can pass theme pill sentinels (e.g. `RADIUS = 100`) and
|
||||
/// still get a well-formed pill on a small rect.
|
||||
pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -> Option<Path>
|
||||
{
|
||||
let c = corners.clamp_to_size( rect.width(), rect.height() );
|
||||
let tl = c.tl;
|
||||
let tr = c.tr;
|
||||
let br = c.br;
|
||||
let bl = c.bl;
|
||||
|
||||
let x0 = rect.left();
|
||||
let y0 = rect.top();
|
||||
let x1 = rect.right();
|
||||
let y1 = rect.bottom();
|
||||
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to( x0 + tl, y0 );
|
||||
pb.line_to( x1 - tr, y0 );
|
||||
if tr > 0.0
|
||||
{
|
||||
let kk = tr * KAPPA;
|
||||
pb.cubic_to( x1 - tr + kk, y0, x1, y0 + tr - kk, x1, y0 + tr );
|
||||
}
|
||||
pb.line_to( x1, y1 - br );
|
||||
if br > 0.0
|
||||
{
|
||||
let kk = br * KAPPA;
|
||||
pb.cubic_to( x1, y1 - br + kk, x1 - br + kk, y1, x1 - br, y1 );
|
||||
}
|
||||
pb.line_to( x0 + bl, y1 );
|
||||
if bl > 0.0
|
||||
{
|
||||
let kk = bl * KAPPA;
|
||||
pb.cubic_to( x0 + bl - kk, y1, x0, y1 - bl + kk, x0, y1 - bl );
|
||||
}
|
||||
pb.line_to( x0, y0 + tl );
|
||||
if tl > 0.0
|
||||
{
|
||||
let kk = tl * KAPPA;
|
||||
pb.cubic_to( x0, y0 + tl - kk, x0 + tl - kk, y0, x0 + tl, y0 );
|
||||
}
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// System-font search chain, ordered by preference. Shared by
|
||||
/// [`find_font`] (which panics when none match) and
|
||||
/// [`find_font_opt`] (which returns `None` — used by tests that
|
||||
/// want to skip gracefully on images without the usual fonts
|
||||
/// installed).
|
||||
const SYSTEM_FONT_CANDIDATES: &[&str] =
|
||||
&[
|
||||
// Debian `fonts-sora` — the canonical path `ltk-theme-default`
|
||||
// depends on. Listed first so Sora wins as the default font
|
||||
// whenever the package is installed.
|
||||
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
|
||||
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
|
||||
"/usr/share/fonts/sora/Sora-Regular.ttf",
|
||||
"/usr/share/fonts/TTF/Sora-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
|
||||
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
];
|
||||
|
||||
/// Resolve the first system font available from
|
||||
/// [`SYSTEM_FONT_CANDIDATES`], or `None` if none exist. Used by
|
||||
/// tests; runtime code uses [`find_font`].
|
||||
pub ( crate ) fn find_font_opt() -> Option<String>
|
||||
{
|
||||
SYSTEM_FONT_CANDIDATES.iter()
|
||||
.find( |p| std::path::Path::new( p ).exists() )
|
||||
.copied()
|
||||
.map( str::to_string )
|
||||
}
|
||||
|
||||
/// Load the bytes of a default system font. Tries the candidate chain
|
||||
/// via [`find_font_opt`]; falls back to the embedded
|
||||
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
|
||||
/// OFL 1.1) when nothing matches or the file cannot be read. Always
|
||||
/// returns usable bytes so canvas construction never panics on a
|
||||
/// system without the expected fonts.
|
||||
pub ( super ) fn load_default_font_bytes() -> Vec<u8>
|
||||
{
|
||||
if let Some( path ) = find_font_opt()
|
||||
{
|
||||
if let Ok( bytes ) = std::fs::read( &path )
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
crate::theme::fallback::FALLBACK_FONT.to_vec()
|
||||
}
|
||||
115
src/render/image.rs
Normal file
115
src/render/image.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Image draw + SHM serialisation for [`SoftwareCanvas`].
|
||||
//!
|
||||
//! `draw_image_data` premultiplies the incoming straight-alpha RGBA
|
||||
//! into a thread-local scratch buffer, wraps the result in a
|
||||
//! short-lived tiny-skia pixmap, and composites it into `self.pixmap`
|
||||
//! honouring the active clip mask + global alpha + `opacity`.
|
||||
//!
|
||||
//! `write_to_wayland_buf` is the serialisation path to the `wl_shm`
|
||||
//! pool used by the software draw path. Either memcpys straight
|
||||
//! (Abgr8888 matches tiny-skia's byte order) or swaps R/B in blocks
|
||||
//! of four pixels (Argb8888 fallback).
|
||||
|
||||
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
||||
|
||||
use crate::types::Rect;
|
||||
|
||||
use super::SoftwareCanvas;
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
||||
{
|
||||
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
|
||||
if img_w == 0 || img_h == 0 || rgba_data.len() != expected
|
||||
{
|
||||
eprintln!(
|
||||
"[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
|
||||
img_w, img_h, rgba_data.len(), expected,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
|
||||
|
||||
thread_local! {
|
||||
static PREMUL_BUF: std::cell::RefCell<Vec<u8>> = std::cell::RefCell::new( Vec::new() );
|
||||
}
|
||||
|
||||
PREMUL_BUF.with( |cell|
|
||||
{
|
||||
let mut premul = cell.borrow_mut();
|
||||
let needed = rgba_data.len();
|
||||
premul.resize( needed, 0 );
|
||||
for ( dst, src ) in premul.chunks_exact_mut( 4 ).zip( rgba_data.chunks_exact( 4 ) )
|
||||
{
|
||||
let a = (src[3] as f32 / 255.0) * opacity * self.global_alpha;
|
||||
dst[0] = (src[0] as f32 * a) as u8;
|
||||
dst[1] = (src[1] as f32 * a) as u8;
|
||||
dst[2] = (src[2] as f32 * a) as u8;
|
||||
dst[3] = (a * 255.0) as u8;
|
||||
}
|
||||
|
||||
if let Some( src_pixmap ) = Pixmap::from_vec( std::mem::take( &mut *premul ), int_size )
|
||||
{
|
||||
let sx = dest.width / img_w as f32;
|
||||
let sy = dest.height / img_h as f32;
|
||||
let t = Transform::from_scale( sx, sy ).post_translate( dest.x, dest.y );
|
||||
let paint = PixmapPaint
|
||||
{
|
||||
quality: tiny_skia::FilterQuality::Bilinear,
|
||||
..PixmapPaint::default()
|
||||
};
|
||||
self.pixmap.draw_pixmap( 0, 0, src_pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
|
||||
*premul = src_pixmap.take();
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
pub fn write_to_wayland_buf( &self, buf: &mut [u8], swap_rb: bool )
|
||||
{
|
||||
let src = self.pixmap.data();
|
||||
let len = src.len().min( buf.len() );
|
||||
|
||||
if !swap_rb
|
||||
{
|
||||
buf[..len].copy_from_slice( &src[..len] );
|
||||
return;
|
||||
}
|
||||
|
||||
let chunks = len / 16;
|
||||
let remainder = len % 16;
|
||||
let mut i = 0;
|
||||
for _ in 0..chunks
|
||||
{
|
||||
buf[i] = src[i + 2];
|
||||
buf[i + 1] = src[i + 1];
|
||||
buf[i + 2] = src[i];
|
||||
buf[i + 3] = src[i + 3];
|
||||
buf[i + 4] = src[i + 6];
|
||||
buf[i + 5] = src[i + 5];
|
||||
buf[i + 6] = src[i + 4];
|
||||
buf[i + 7] = src[i + 7];
|
||||
buf[i + 8] = src[i + 10];
|
||||
buf[i + 9] = src[i + 9];
|
||||
buf[i + 10] = src[i + 8];
|
||||
buf[i + 11] = src[i + 11];
|
||||
buf[i + 12] = src[i + 14];
|
||||
buf[i + 13] = src[i + 13];
|
||||
buf[i + 14] = src[i + 12];
|
||||
buf[i + 15] = src[i + 15];
|
||||
i += 16;
|
||||
}
|
||||
for _ in 0..(remainder / 4)
|
||||
{
|
||||
buf[i] = src[i + 2];
|
||||
buf[i + 1] = src[i + 1];
|
||||
buf[i + 2] = src[i];
|
||||
buf[i + 3] = src[i + 3];
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
634
src/render/mod.rs
Normal file
634
src/render/mod.rs
Normal file
@@ -0,0 +1,634 @@
|
||||
// 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, 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,
|
||||
//! rasterize_cached}`.
|
||||
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
|
||||
//! write_to_wayland_buf}`.
|
||||
//! * [`helpers`] — free functions: `build_rounded_rect`,
|
||||
//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`.
|
||||
|
||||
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, Rect };
|
||||
|
||||
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 `(char, size)` do not collide on the cache.
|
||||
#[ derive( Hash, PartialEq, Eq, Clone, Copy ) ]
|
||||
pub ( super ) struct GlyphKey
|
||||
{
|
||||
pub ( super ) ch: char,
|
||||
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::render::helpers::find_font`] at construction time.
|
||||
pub font: Arc<Font>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> )
|
||||
{
|
||||
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 ),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/render/primitives.rs
Normal file
103
src/render/primitives.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Primitive draw ops for [`SoftwareCanvas`]: clear, solid fill,
|
||||
//! rounded-rect fill, stroke, line. tiny-skia does the heavy
|
||||
//! lifting; this file just converts from ltk's `Rect` / `Color` to
|
||||
//! tiny-skia's and threads `global_alpha` + `clip_mask` through
|
||||
//! every call.
|
||||
|
||||
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
|
||||
|
||||
use crate::types::{ Color, Corners, Rect };
|
||||
|
||||
use super::helpers::build_rounded_rect;
|
||||
use super::SoftwareCanvas;
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
pub fn clear( &mut self )
|
||||
{
|
||||
self.pixmap.fill( tiny_skia::Color::TRANSPARENT );
|
||||
}
|
||||
|
||||
pub fn fill( &mut self, color: Color )
|
||||
{
|
||||
if self.clip_mask.is_none()
|
||||
{
|
||||
self.pixmap.fill( color.to_tiny_skia() );
|
||||
return;
|
||||
}
|
||||
let w = self.pixmap.width() as f32;
|
||||
let h = self.pixmap.height() as f32;
|
||||
let Some( ts_rect ) = tiny_skia::Rect::from_ltrb( 0.0, 0.0, w, h ) else { return };
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color( color.to_tiny_skia() );
|
||||
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
|
||||
}
|
||||
|
||||
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
|
||||
{
|
||||
let pw = self.pixmap.width() as f32;
|
||||
let ph = self.pixmap.height() as f32;
|
||||
if rect.x + rect.width < 0.0 || rect.x > pw
|
||||
|| rect.y + rect.height < 0.0 || rect.y > ph { return; }
|
||||
|
||||
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
|
||||
let mut paint = Paint::default();
|
||||
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
|
||||
paint.set_color( adjusted_color.to_tiny_skia() );
|
||||
paint.anti_alias = true;
|
||||
if !corners.is_zero()
|
||||
{
|
||||
if let Some( path ) = build_rounded_rect( ts_rect, corners )
|
||||
{
|
||||
self.pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
tiny_skia::FillRule::Winding,
|
||||
Transform::identity(),
|
||||
self.clip_mask.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.pixmap.fill_rect( ts_rect, &paint, Transform::identity(), self.clip_mask.as_ref() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners )
|
||||
{
|
||||
let Some( ts_rect ) = rect.to_tiny_skia() else { return };
|
||||
let mut paint = Paint::default();
|
||||
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
|
||||
paint.set_color( adjusted_color.to_tiny_skia() );
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = width;
|
||||
if !corners.is_zero()
|
||||
{
|
||||
if let Some( path ) = build_rounded_rect( ts_rect, corners )
|
||||
{
|
||||
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||
}
|
||||
} else {
|
||||
let path = PathBuilder::from_rect( ts_rect );
|
||||
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 )
|
||||
{
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to( x0, y0 );
|
||||
pb.line_to( x1, y1 );
|
||||
let Some( path ) = pb.finish() else { return };
|
||||
let mut paint = Paint::default();
|
||||
let adjusted_color = Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha );
|
||||
paint.set_color( adjusted_color.to_tiny_skia() );
|
||||
paint.anti_alias = true;
|
||||
let mut stroke = Stroke::default();
|
||||
stroke.width = width;
|
||||
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||
}
|
||||
}
|
||||
124
src/render/setup.rs
Normal file
124
src/render/setup.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Construction + accessors for [`SoftwareCanvas`]: new / sub_canvas
|
||||
//! / resize plus the font-registry installer and `blit`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{ Arc, OnceLock };
|
||||
|
||||
use fontdue::{ Font, FontSettings };
|
||||
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
||||
|
||||
use crate::theme::{ FontRegistry, FontStyle };
|
||||
|
||||
use super::helpers::load_default_font_bytes;
|
||||
use super::SoftwareCanvas;
|
||||
|
||||
/// Process-wide cache of the default font face. Avoids re-reading +
|
||||
/// re-parsing the file on every surface bring-up. Sora is small
|
||||
/// (~50 KB) so the cost was minor in absolute terms — but a layer
|
||||
/// shell that brings up a launcher overlay, a QS panel, a calendar
|
||||
/// popup and a handful of toast surfaces would still pay the parse
|
||||
/// cost a dozen times in a single session, all of which is wasted
|
||||
/// work.
|
||||
static DEFAULT_FONT: OnceLock<Arc<Font>> = OnceLock::new();
|
||||
|
||||
fn default_font() -> Arc<Font>
|
||||
{
|
||||
Arc::clone( DEFAULT_FONT.get_or_init( ||
|
||||
{
|
||||
let bytes = load_default_font_bytes();
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
||||
.expect( "bad font" );
|
||||
Arc::new( font )
|
||||
} ) )
|
||||
}
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
/// Create a canvas of the given pixel dimensions, loading a system font.
|
||||
///
|
||||
/// Fallback fonts (Noto Sans / CJK / Devanagari / …) are NOT loaded
|
||||
/// here — they are owned by the crate-private system-fonts chain
|
||||
/// and loaded lazily per codepoint. A canvas that only ever paints
|
||||
/// Latin text will never touch those files; the first non-Latin
|
||||
/// glyph triggers a single targeted load and the rest of the
|
||||
/// process reuses the cached `Arc<Font>`.
|
||||
pub fn new( width: u32, height: u32 ) -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
|
||||
font: default_font(),
|
||||
font_registry: None,
|
||||
dpi_scale: 1.0,
|
||||
global_alpha: 1.0,
|
||||
glyph_cache: HashMap::new(),
|
||||
clip_mask: None,
|
||||
clip_bounds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a blank sub-canvas sharing the same font and DPI scale.
|
||||
pub fn sub_canvas( &self, width: u32, height: u32 ) -> SoftwareCanvas
|
||||
{
|
||||
SoftwareCanvas
|
||||
{
|
||||
pixmap: Pixmap::new( width, height ).expect( "pixmap" ),
|
||||
font: Arc::clone( &self.font ),
|
||||
font_registry: self.font_registry.as_ref().map( Arc::clone ),
|
||||
dpi_scale: self.dpi_scale,
|
||||
global_alpha: self.global_alpha,
|
||||
glyph_cache: HashMap::new(),
|
||||
clip_mask: None,
|
||||
clip_bounds: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a theme font registry so [`Self::font_for`] can
|
||||
/// resolve family+weight+style triples declared by the theme's
|
||||
/// `fonts` block. The default [`Self::font`] stays in place as a
|
||||
/// fallback.
|
||||
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
|
||||
{
|
||||
self.font_registry = Some( registry );
|
||||
}
|
||||
|
||||
/// Resolve a specific font from the theme registry, falling back
|
||||
/// to the canvas' 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>
|
||||
{
|
||||
self.font_registry
|
||||
.as_ref()
|
||||
.and_then( |r| r.resolve( family, weight, style ) )
|
||||
.unwrap_or_else( || Arc::clone( &self.font ) )
|
||||
}
|
||||
|
||||
/// Pick the right font for `ch`. Tries the primary [`Self::font`]
|
||||
/// first; on a miss, delegates to the crate-private system-fonts
|
||||
/// fallback chain (lazy load of the relevant Noto pack). Falls
|
||||
/// back to the primary (which paints a `.notdef` box) when no
|
||||
/// installed fallback covers the codepoint.
|
||||
pub fn font_for_char( &self, ch: char ) -> Arc<Font>
|
||||
{
|
||||
if self.font.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
return Arc::clone( &self.font );
|
||||
}
|
||||
crate::system_fonts::lookup( ch ).unwrap_or_else( || Arc::clone( &self.font ) )
|
||||
}
|
||||
|
||||
pub fn blit( &mut self, src: &SoftwareCanvas, dest_x: i32, dest_y: i32 )
|
||||
{
|
||||
let paint = PixmapPaint::default();
|
||||
let t = Transform::from_translate( dest_x as f32, dest_y as f32 );
|
||||
self.pixmap.draw_pixmap( 0, 0, src.pixmap.as_ref(), &paint, t, self.clip_mask.as_ref() );
|
||||
}
|
||||
|
||||
pub fn resize( &mut self, width: u32, height: u32 )
|
||||
{
|
||||
self.pixmap = Pixmap::new( width, height ).expect( "pixmap" );
|
||||
}
|
||||
}
|
||||
203
src/render/text.rs
Normal file
203
src/render/text.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Text rendering for [`SoftwareCanvas`]. fontdue rasterises each
|
||||
//! glyph once into the persistent [`super::GlyphEntry`] cache; the
|
||||
//! per-frame hot path just lays out positions and blends cached
|
||||
//! bitmaps into the pixmap with the active clip mask + global alpha.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
|
||||
|
||||
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
|
||||
|
||||
/// Stable identifier for an `Arc<Font>`: the address of the font's
|
||||
/// allocation. Different reweights / families always live in
|
||||
/// distinct allocations, so the address is enough to disambiguate
|
||||
/// glyph cache entries.
|
||||
fn font_id( font: &Arc<Font> ) -> usize
|
||||
{
|
||||
Arc::as_ptr( font ) as usize
|
||||
}
|
||||
|
||||
impl SoftwareCanvas
|
||||
{
|
||||
/// Per-glyph default font lookup. Routes through
|
||||
/// [`Self::font_for_char`], which already consults the lazy
|
||||
/// system-font fallback chain. Returns an owned [`Arc<Font>`] so
|
||||
/// callers can hold the handle across `&mut self` borrows of the
|
||||
/// glyph cache.
|
||||
fn default_font_for_char( &self, ch: char ) -> ( usize, Arc<Font> )
|
||||
{
|
||||
let font = self.font_for_char( ch );
|
||||
let id = Arc::as_ptr( &font ) as usize;
|
||||
( id, font )
|
||||
}
|
||||
|
||||
pub ( super ) fn rasterize_cached( &mut self, ch: char, scaled: f32 ) -> &GlyphEntry
|
||||
{
|
||||
let ( id, font ) = self.default_font_for_char( ch );
|
||||
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
|
||||
self.evict_if_full( &key );
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
|
||||
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
|
||||
}
|
||||
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
|
||||
}
|
||||
|
||||
/// Pick the font to use for `ch` given a "preferred" override.
|
||||
/// Falls through to the canvas default + Noto chain when the
|
||||
/// preferred font does not own the glyph — so Sora Bold rendering
|
||||
/// of CJK / Devanagari / etc. still works.
|
||||
fn font_for_char_with_pref( &self, ch: char, pref: &Arc<Font> ) -> ( usize, Arc<Font> )
|
||||
{
|
||||
if pref.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
return ( font_id( pref ), Arc::clone( pref ) );
|
||||
}
|
||||
self.default_font_for_char( ch )
|
||||
}
|
||||
|
||||
fn rasterize_cached_with( &mut self, ch: char, scaled: f32, pref: &Arc<Font> ) -> &GlyphEntry
|
||||
{
|
||||
let ( id, font ) = self.font_for_char_with_pref( ch, pref );
|
||||
let key = GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id };
|
||||
self.evict_if_full( &key );
|
||||
if !self.glyph_cache.contains_key( &key )
|
||||
{
|
||||
let ( metrics, bitmap ) = font.rasterize( ch, scaled );
|
||||
self.glyph_cache.insert( key, GlyphEntry { metrics, bitmap } );
|
||||
}
|
||||
self.glyph_cache.get( &key ).expect( "inserted above on miss" )
|
||||
}
|
||||
|
||||
fn evict_if_full( &mut self, key: &GlyphKey )
|
||||
{
|
||||
if !self.glyph_cache.contains_key( key )
|
||||
&& self.glyph_cache.len() >= GLYPH_CACHE_SOFT_CAP
|
||||
{
|
||||
let drop_n = self.glyph_cache.len() / 2;
|
||||
let victims: Vec<_> = self.glyph_cache.keys().copied().take( drop_n ).collect();
|
||||
for k in victims
|
||||
{
|
||||
self.glyph_cache.remove( &k );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_text( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color )
|
||||
{
|
||||
self.draw_text_inner( text, x, y, size, color, None );
|
||||
}
|
||||
|
||||
/// Draw `text` using the explicitly supplied font instead of the
|
||||
/// canvas default + fallback chain.
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
|
||||
{
|
||||
self.draw_text_inner( text, x, y, size, color, Some( font ) );
|
||||
}
|
||||
|
||||
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<Font>> )
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let line_h = scaled * 1.5;
|
||||
let ph = self.pixmap.height() as f32;
|
||||
let pw = self.pixmap.width() as f32;
|
||||
if y + line_h < 0.0 || y - line_h > ph { return; }
|
||||
if x > pw { return; }
|
||||
if self.has_clip() && !self.strip_intersects_clip( y - line_h, y + 0.5 * line_h )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut layout: Vec<( GlyphKey, f32 )> = Vec::with_capacity( text.chars().count() );
|
||||
{
|
||||
let mut cursor_x = x;
|
||||
for ch in text.chars()
|
||||
{
|
||||
let ( id, advance ) = match font
|
||||
{
|
||||
Some( f ) =>
|
||||
{
|
||||
let id = self.font_for_char_with_pref( ch, f ).0;
|
||||
let advance = self.rasterize_cached_with( ch, scaled, f ).metrics.advance_width;
|
||||
( id, advance )
|
||||
}
|
||||
None =>
|
||||
{
|
||||
let id = self.default_font_for_char( ch ).0;
|
||||
let advance = self.rasterize_cached( ch, scaled ).metrics.advance_width;
|
||||
( id, advance )
|
||||
}
|
||||
};
|
||||
layout.push( ( GlyphKey { ch, size_bits: scaled.to_bits(), font_id: id }, cursor_x ) );
|
||||
cursor_x += advance;
|
||||
}
|
||||
}
|
||||
|
||||
let w = self.pixmap.width() as i32;
|
||||
let h = self.pixmap.height() as i32;
|
||||
let cr = (color.r * 255.0) as u8;
|
||||
let cg = (color.g * 255.0) as u8;
|
||||
let cb = (color.b * 255.0) as u8;
|
||||
let color_a = color.a * self.global_alpha;
|
||||
|
||||
let pixels = self.pixmap.data_mut();
|
||||
let cache = &self.glyph_cache;
|
||||
let mask_data = self.clip_mask.as_ref().map( |m| ( m.data(), m.width() as i32 ) );
|
||||
|
||||
for ( key, cursor_x ) in layout
|
||||
{
|
||||
let entry = cache.get( &key ).expect( "warmed above" );
|
||||
let metrics = &entry.metrics;
|
||||
let bitmap = &entry.bitmap;
|
||||
for ( i, &alpha ) in bitmap.iter().enumerate()
|
||||
{
|
||||
if alpha == 0 { continue; }
|
||||
let px = cursor_x as i32 + metrics.xmin + (i % metrics.width) as i32;
|
||||
let py = y as i32
|
||||
- metrics.ymin as i32
|
||||
- metrics.height as i32
|
||||
+ 1
|
||||
+ (i / metrics.width) as i32;
|
||||
if px < 0 || py < 0 || px >= w || py >= h { continue; }
|
||||
if let Some( ( md, mw ) ) = mask_data
|
||||
{
|
||||
if md[ ( py * mw + px ) as usize ] == 0 { continue; }
|
||||
}
|
||||
let idx = (py as usize * w as usize + px as usize) * 4;
|
||||
let a = (alpha as f32 / 255.0) * color_a;
|
||||
let inv = 1.0 - a;
|
||||
pixels[idx] = (cr as f32 * a + pixels[idx] as f32 * inv) as u8;
|
||||
pixels[idx + 1] = (cg as f32 * a + pixels[idx + 1] as f32 * inv) as u8;
|
||||
pixels[idx + 2] = (cb as f32 * a + pixels[idx + 2] as f32 * inv) as u8;
|
||||
let a_dst = pixels[idx + 3] as f32 / 255.0;
|
||||
pixels[idx + 3] = ( ( a + a_dst * inv ) * 255.0 ) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
{
|
||||
self.font_for_char( ch ).metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
}
|
||||
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
||||
{
|
||||
text.chars().map( |ch|
|
||||
{
|
||||
let ( _, picked ) = self.font_for_char_with_pref( ch, font );
|
||||
picked.metrics( ch, size * self.dpi_scale ).advance_width
|
||||
} ).sum()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user