Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping.
Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly.
Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`.
FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site.
rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width.
Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free.
Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
This commit is contained in:
2026-06-25 12:43:40 +02:00
parent fb3552e9f7
commit d4d7ee742e
56 changed files with 936 additions and 549 deletions

View File

@@ -1,8 +1,9 @@
// 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.
//! Backend-neutral helpers for the software renderer: vector-path and
//! rounded-rect construction. System-font resolution lives in
//! `crate::system_fonts`.
use tiny_skia::{ Path, PathBuilder };
@@ -31,6 +32,25 @@ pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option<Path>
pb.finish()
}
/// Validate the dimensions of an RGBA8 image buffer for `draw_image_data`,
/// shared by both backends. Returns `true` when the buffer is drawable;
/// `false` (after logging a one-line warning tagged with `backend`) when the
/// declared `img_w × img_h × 4` does not match `data.len()` or either extent is
/// zero, so the caller returns without uploading or seeding a cache key.
pub ( crate ) fn validate_rgba_dims( backend: &str, data: &[u8], img_w: u32, img_h: u32 ) -> bool
{
let expected = ( img_w as usize ).saturating_mul( img_h as usize ).saturating_mul( 4 );
if img_w == 0 || img_h == 0 || data.len() != expected
{
eprintln!(
"[ltk] {}::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
backend, img_w, img_h, data.len(), expected,
);
return false;
}
true
}
/// 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,
@@ -78,54 +98,3 @@ pub ( super ) fn build_rounded_rect( rect: tiny_skia::Rect, corners: Corners ) -
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 ( crate ) 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()
}

View File

@@ -23,16 +23,22 @@ 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
if !super::helpers::validate_rgba_dims( "SoftwareCanvas", rgba_data, img_w, img_h )
{
eprintln!(
"[ltk] SoftwareCanvas::draw_image_data: refusing draw — {}×{} declared, {} bytes provided, expected {}",
img_w, img_h, rgba_data.len(), expected,
);
return;
}
// Snap the destination to integer pixels (matching the GLES backend) so a
// fractional dest does not sub-texel-offset the bilinear sample and read
// ~1 px softer than the source; at 1:1 the scale collapses to identity.
let dest = Rect
{
x: dest.x.round(),
y: dest.y.round(),
width: dest.width.round(),
height: dest.height.round(),
};
let Some( int_size ) = tiny_skia::IntSize::from_wh( img_w, img_h ) else { return };
thread_local! {

View File

@@ -27,8 +27,9 @@
//! * [`text`] — `SoftwareCanvas::{draw_text, measure_text}`.
//! * [`image`] — `SoftwareCanvas::{draw_image_data,
//! write_to_wayland_buf}`.
//! * [`helpers`] — free functions: `build_rounded_rect`,
//! `find_font`, `find_font_opt`, `SYSTEM_FONT_CANDIDATES`.
//! * [`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;
@@ -120,7 +121,7 @@ pub struct SoftwareCanvas
/// 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.
/// `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
@@ -869,3 +870,65 @@ mod clip_path_tests
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" );
}
}

View File

@@ -5,43 +5,16 @@
//! / resize plus the font-registry installer and `blit`.
use std::collections::HashMap;
use std::sync::{ Arc, OnceLock };
use std::sync::Arc;
use fontdue::{ Font, FontSettings };
use fontdue::Font;
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
use crate::system_fonts::FontHandle;
use crate::system_fonts::default_handle;
use crate::theme::{ FontRegistry, FontStyle };
use super::helpers::load_default_font_bytes;
use super::SoftwareCanvas;
/// Process-wide cache of the default font face. The handle keeps the
/// raw bytes (`Arc<Vec<u8>>`) alongside the fontdue `Arc<Font>` so
/// the HarfBuzz shaper can be invoked without re-reading the file.
/// 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<FontHandle> = OnceLock::new();
fn default_handle() -> FontHandle
{
DEFAULT_FONT.get_or_init( ||
{
let bytes = load_default_font_bytes();
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
.expect( "bad font" );
FontHandle
{
font: Arc::new( font ),
bytes: Arc::new( bytes ),
face: 0,
}
} ).clone()
}
impl SoftwareCanvas
{
/// Create a canvas of the given pixel dimensions, loading a system font.

View File

@@ -189,15 +189,19 @@ impl SoftwareCanvas
let metrics = &entry.metrics;
let bitmap = &entry.bitmap;
if metrics.width == 0 || metrics.height == 0 { continue; }
// Round the pen position to the nearest pixel (matching the GLES
// backend) so a glyph at a fractional x/y lands on the same integer
// origin on both backends; the integer glyph metrics are added after.
let gx = cursor_x.round() as i32 + metrics.xmin;
let gy = ( y - glyph_y_offset ).round() as i32
- metrics.ymin as i32
- metrics.height as i32
+ 1;
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 - glyph_y_offset ) as i32
- metrics.ymin as i32
- metrics.height as i32
+ 1
+ (i / metrics.width) as i32;
let px = gx + (i % metrics.width) as i32;
let py = gy + (i / metrics.width) as i32;
if px < 0 || py < 0 || px >= w || py >= h { continue; }
if let Some( ( md, mw ) ) = mask_data
{