Glyphs on demand: FontFace replaces fontdue, DejaVu ahead of the CJK collection in the fallback chain; App::take_clipboard_request and App::on_pointer_secondary
Every face fontdue opened was parsed whole: `Font::from_bytes` walks the `cmap` and builds the outline of every glyph before returning. For the theme's Sora that is a few hundred glyphs and nobody notices. For a fallback it is the whole file: the first string with one arrow or one bullet resolved through the chain to `NotoSansCJK-Regular.ttc`, and a compositor or a notification daemon that had drawn a dozen labels was suddenly holding 330 MB of outlines for 44 810 glyphs, after 1.7 s of CPU, to paint the hundred or so glyphs a screen ever shows. Measured on forge: 240 MB before the first app-switcher label, 542 MB after it, and never given back, because the table is live, not garbage. `FontFace`. The new `src/font_face.rs` is the face type the whole crate now rasterises with, built on the `ttf-parser` that rustybuzz already brings in and on tiny-skia, which already fills paths. `from_bytes( data, index )` keeps the bytes and reads only what a face needs up front: units per em and the vertical line metrics. A `ttf_parser::Face` is rebuilt on every use rather than kept, since parsing one is table-offset arithmetic, in the noise next to the outline work it guards. Each glyph's outline is extracted the first time it is asked for, through `Outliner`, into a `tiny_skia::Path`, and cached for the process lifetime in a `HashMap<u16, Arc<Outline>>`; codepoint lookups are cached the same way. Rasterising fills that path into a `Mask` and hands back the 8-bit coverage, top row first, which is what both canvases stored before. The metrics keep fontdue's conventions, so no widget changed: sizes scale by `px / units_per_em`, `Metrics::xmin` / `ymin` are whole-pixel placements with the fractional remainder folded into the raster offset, `bounds` is the outline box in subpixels, `LineMetrics` carries ascent, descent, line gap and their sum. One thing is deliberately not what `glyf` declares: the box comes from the points the outline actually reaches, quadratic and cubic extrema included, because declared boxes may be loose and a box wider than the ink shifts the glyph on the pixel grid. Tests cover the turning-point arithmetic and the line-metric scaling. `FontFace`, `Metrics`, `LineMetrics` and `OutlineBounds` are exported, since `Canvas::font` and `font_for` hand them out. What changed around it. `fontdue` leaves `Cargo.toml`. The two canvases, their text and setup modules, the registry, the rich text and text widgets swap `Arc<Font>` for `Arc<FontFace>` and nothing else; the glyph caches still key on `(glyph_id, size_bits, font_id)`. `FontRegistry` now reads a source into an `Arc<Vec<u8>>` and gives the face the bytes it will pull outlines from, so the registry no longer owns a second copy; its parse error names the file rather than a library message. `FontHandle` keeps `font` and `bytes` side by side, now naming the same buffer, so shaping needs no second read and the comment that explained the duplication is gone. `system_fonts` builds fallback faces from the bytes each slot already holds, and the slot stays two-staged: the bytes are read once to probe coverage with `ttf-parser`, the face is built only when a glyph in that slot is needed, and a face that is built no longer costs anything until it draws. The chain. DejaVu Sans moves from last resort to just before the CJK collection. It owns the geometric shapes, arrows and box-drawing blocks, which are what a user interface most often needs outside its own family; behind the collection, every one of those pulled the 19 MB file in, since probing a slot for coverage reads it whole. The CJK entries stay last for the same reason. The two hooks are for a host that embeds another UI, trust in this case, and complete the ones `App` already had. `take_clipboard_request` is the counterpart of nothing: an app could paste from the system clipboard through its text fields but had no way to put text into it. Return `Some( text )` once and the run loop stores it and publishes it as the seat's selection through the data device, the same path `handle_copy` takes for Ctrl+C in a text field, so other applications see it. `on_pointer_secondary` reports a right-button press that no widget of ltk's own answered, in physical pixels like `on_pointer_button`: `on_pointer_right_click` already dispatched a widget's `on_long_press` and the built-in text menu, and in the remaining case only dismissed an open menu; it now also tells the app, which can forward the press to an embedded view that has a context menu of its own. Not changed. Variable fonts are read at their default instance, as before. Colour glyphs, `COLR` or bitmap, are not rendered by either rasteriser; the outline path draws what it can. The glyph caches are still unbounded per face, but they now hold what was drawn, not what the file contains.
This commit is contained in:
@@ -37,7 +37,6 @@ smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-way
|
||||
calloop = "0.14"
|
||||
calloop-wayland-source = "0.4"
|
||||
tiny-skia = "0.12"
|
||||
fontdue = "=0.9.3"
|
||||
unicode-bidi = "0.3"
|
||||
rustybuzz = "0.14"
|
||||
image = { version = "=0.25.9", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
@@ -868,6 +868,9 @@ pub trait App: 'static
|
||||
/// Return `true` once to drop the keyboard focus, which folds the on-screen keyboard; a [`take_focus_request`](Self::take_focus_request) in the same cycle wins.
|
||||
fn take_blur_request( &mut self ) -> bool { false }
|
||||
|
||||
/// Return `Some( text )` once to put it in the clipboard, for other applications as well.
|
||||
fn take_clipboard_request( &mut self ) -> Option<String> { None }
|
||||
|
||||
/// Called on every pointer motion (mouse move, button press, button
|
||||
/// release). `x`, `y` are in physical pixels of the main surface,
|
||||
/// the same coordinate space as widget rects. Default: no-op.
|
||||
@@ -958,6 +961,9 @@ pub trait App: 'static
|
||||
/// classifier collapses press+release into a single event.
|
||||
fn on_pointer_button( &mut self, _x: f32, _y: f32, _pressed: bool ) {}
|
||||
|
||||
/// A secondary-button press that no widget of ltk's own answered, in physical pixels. Default: no-op.
|
||||
fn on_pointer_secondary( &mut self, _x: f32, _y: f32 ) {}
|
||||
|
||||
/// Called on every keyboard event (press *and* release) before the
|
||||
/// regular focus-aware dispatch ([`Self::on_key`] /
|
||||
/// [`Self::on_key_with_modifiers`]) runs. Default: no-op.
|
||||
|
||||
@@ -921,6 +921,12 @@ pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
|
||||
data.focus_retry = None;
|
||||
}
|
||||
|
||||
if let Some( text ) = data.app.take_clipboard_request()
|
||||
{
|
||||
data.clipboard = text;
|
||||
data.publish_clipboard_selection();
|
||||
}
|
||||
|
||||
// Focus the widget with the requested WidgetId if the app requests it.
|
||||
// Walks main + every overlay because the targeted widget can live
|
||||
// on any of them (a search field on a launcher overlay, a text
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Glyph source: outlines extracted on first use, never up front.
|
||||
//!
|
||||
//! A face keeps its file bytes and rebuilds the `ttf_parser::Face` on
|
||||
//! demand — parsing one is table-offset arithmetic, so the cost lands in
|
||||
//! the noise next to the outline work it guards. Each glyph's outline is
|
||||
//! turned into a [`tiny_skia::Path`] the first time it is asked for and
|
||||
//! cached for the process lifetime; rasterising then fills that path
|
||||
//! into an 8-bit coverage [`Mask`].
|
||||
//!
|
||||
//! The eager alternative — building every glyph in the `cmap` when the
|
||||
//! face is constructed — costs 330 MB and 1.7 s of CPU for a 44 810
|
||||
//! glyph CJK collection, to draw the hundred or so glyphs a screen
|
||||
//! actually shows. A single arrow or bullet resolving through the
|
||||
//! fallback chain was enough to pay it in full.
|
||||
//!
|
||||
//! [`Metrics`], [`OutlineBounds`] and [`LineMetrics`] keep the layout
|
||||
//! conventions the renderers and widgets already lay text out with:
|
||||
//! sizes scale by `px / units_per_em`, `bounds` is the outline box in
|
||||
//! subpixels, `ymin` grows upwards from the baseline, and the bitmap
|
||||
//! runs row-major from its top-left corner.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{ Arc, Mutex };
|
||||
|
||||
use rustybuzz::ttf_parser::{ Face, GlyphId, OutlineBuilder };
|
||||
use tiny_skia::{ FillRule, Mask, Path, PathBuilder, Transform };
|
||||
|
||||
/// Bounding box of a glyph's outline, in subpixels, `ymin` upwards from
|
||||
/// the baseline.
|
||||
#[ derive( Copy, Clone, PartialEq, Debug, Default ) ]
|
||||
pub struct OutlineBounds
|
||||
{
|
||||
pub xmin: f32,
|
||||
pub ymin: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl OutlineBounds
|
||||
{
|
||||
fn scale( &self, scale: f32 ) -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
xmin: self.xmin * scale,
|
||||
ymin: self.ymin * scale,
|
||||
width: self.width * scale,
|
||||
height: self.height * scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Placement and size of one rasterised glyph.
|
||||
#[ derive( Copy, Clone, PartialEq, Debug, Default ) ]
|
||||
pub struct Metrics
|
||||
{
|
||||
/// Whole-pixel offset of the bitmap's left edge, negative when the
|
||||
/// glyph reaches left of the origin.
|
||||
pub xmin: i32,
|
||||
/// Whole-pixel offset of the bitmap's bottom edge, negative below
|
||||
/// the baseline.
|
||||
pub ymin: i32,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub advance_width: f32,
|
||||
pub advance_height: f32,
|
||||
pub bounds: OutlineBounds,
|
||||
}
|
||||
|
||||
/// Vertical metrics of a face at a given size.
|
||||
#[ derive( Copy, Clone, PartialEq, Debug, Default ) ]
|
||||
pub struct LineMetrics
|
||||
{
|
||||
pub ascent: f32,
|
||||
pub descent: f32,
|
||||
pub line_gap: f32,
|
||||
/// `ascent - descent + line_gap`.
|
||||
pub new_line_size: f32,
|
||||
}
|
||||
|
||||
impl LineMetrics
|
||||
{
|
||||
fn scale( &self, scale: f32 ) -> Self
|
||||
{
|
||||
Self
|
||||
{
|
||||
ascent: self.ascent * scale,
|
||||
descent: self.descent * scale,
|
||||
line_gap: self.line_gap * scale,
|
||||
new_line_size: self.new_line_size * scale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One glyph's outline in font units, plus the advances layout needs.
|
||||
struct Outline
|
||||
{
|
||||
/// `None` for glyphs with no contours — a space, or a codepoint the
|
||||
/// face maps to an empty shape.
|
||||
path: Option<Path>,
|
||||
bounds: OutlineBounds,
|
||||
advance_width: f32,
|
||||
advance_height: f32,
|
||||
}
|
||||
|
||||
/// A font face that materialises glyphs on demand.
|
||||
pub struct FontFace
|
||||
{
|
||||
data: Arc<Vec<u8>>,
|
||||
index: u32,
|
||||
units_per_em: f32,
|
||||
line: LineMetrics,
|
||||
outlines: Mutex<HashMap<u16, Arc<Outline>>>,
|
||||
indices: Mutex<HashMap<char, u16>>,
|
||||
}
|
||||
|
||||
/// The glyph caches are left out: formatting must not block on them.
|
||||
impl std::fmt::Debug for FontFace
|
||||
{
|
||||
fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
|
||||
{
|
||||
f.debug_struct( "FontFace" )
|
||||
.field( "index", &self.index )
|
||||
.field( "units_per_em", &self.units_per_em )
|
||||
.field( "line", &self.line )
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontFace
|
||||
{
|
||||
/// Parse the face at `index` of `data`, keeping the bytes alive for
|
||||
/// the outline loads that follow. `None` when the face does not
|
||||
/// parse or declares a zero em square.
|
||||
pub fn from_bytes( data: Arc<Vec<u8>>, index: u32 ) -> Option<Self>
|
||||
{
|
||||
let ( units_per_em, line ) =
|
||||
{
|
||||
let face = Face::parse( data.as_slice(), index ).ok()?;
|
||||
let upem = face.units_per_em() as f32;
|
||||
if upem <= 0.0 { return None; }
|
||||
let ascent = face.ascender() as f32;
|
||||
let descent = face.descender() as f32;
|
||||
let line_gap = face.line_gap() as f32;
|
||||
let line = LineMetrics
|
||||
{
|
||||
ascent,
|
||||
descent,
|
||||
line_gap,
|
||||
new_line_size: ascent - descent + line_gap,
|
||||
};
|
||||
( upem, line )
|
||||
};
|
||||
|
||||
Some( Self
|
||||
{
|
||||
data,
|
||||
index,
|
||||
units_per_em,
|
||||
line,
|
||||
outlines: Mutex::new( HashMap::new() ),
|
||||
indices: Mutex::new( HashMap::new() ),
|
||||
} )
|
||||
}
|
||||
|
||||
pub fn units_per_em( &self ) -> f32
|
||||
{
|
||||
self.units_per_em
|
||||
}
|
||||
|
||||
pub fn scale_factor( &self, px: f32 ) -> f32
|
||||
{
|
||||
px / self.units_per_em
|
||||
}
|
||||
|
||||
/// Glyph index for `ch`, or `0` when the face has no glyph for it.
|
||||
pub fn lookup_glyph_index( &self, ch: char ) -> u16
|
||||
{
|
||||
if let Some( id ) = self.indices.lock().ok().and_then( |m| m.get( &ch ).copied() )
|
||||
{
|
||||
return id;
|
||||
}
|
||||
let id = self.face()
|
||||
.and_then( |f| f.glyph_index( ch ) )
|
||||
.map( |g| g.0 )
|
||||
.unwrap_or( 0 );
|
||||
if let Ok( mut m ) = self.indices.lock() { m.insert( ch, id ); }
|
||||
id
|
||||
}
|
||||
|
||||
pub fn horizontal_line_metrics( &self, px: f32 ) -> Option<LineMetrics>
|
||||
{
|
||||
Some( self.line.scale( self.scale_factor( px ) ) )
|
||||
}
|
||||
|
||||
pub fn metrics( &self, ch: char, px: f32 ) -> Metrics
|
||||
{
|
||||
self.metrics_indexed( self.lookup_glyph_index( ch ), px )
|
||||
}
|
||||
|
||||
pub fn metrics_indexed( &self, id: u16, px: f32 ) -> Metrics
|
||||
{
|
||||
if px <= 0.0 { return Metrics::default(); }
|
||||
self.raw_metrics( &self.outline( id ), self.scale_factor( px ) ).0
|
||||
}
|
||||
|
||||
/// Coverage bitmap for `id` at `px`, row-major from the top-left
|
||||
/// corner, alongside its placement.
|
||||
pub fn rasterize_indexed( &self, id: u16, px: f32 ) -> ( Metrics, Vec<u8> )
|
||||
{
|
||||
if px <= 0.0 { return ( Metrics::default(), Vec::new() ); }
|
||||
|
||||
let outline = self.outline( id );
|
||||
let scale = self.scale_factor( px );
|
||||
let ( metrics, ox, oy ) = self.raw_metrics( &outline, scale );
|
||||
if metrics.width == 0 || metrics.height == 0
|
||||
{
|
||||
return ( metrics, Vec::new() );
|
||||
}
|
||||
|
||||
let blank = || vec![ 0u8; metrics.width * metrics.height ];
|
||||
let Some( path ) = outline.path.as_ref() else { return ( metrics, blank() ) };
|
||||
let Some( mut mask ) = Mask::new( metrics.width as u32, metrics.height as u32 ) else { return ( metrics, blank() ) };
|
||||
|
||||
// Font units are y-up from the baseline and the mask is y-down
|
||||
// from its own corner, so the outline box's top-left lands on
|
||||
// the fractional offsets the metrics reserved for it.
|
||||
let ts = Transform::from_row(
|
||||
scale,
|
||||
0.0,
|
||||
0.0,
|
||||
-scale,
|
||||
ox - metrics.bounds.xmin,
|
||||
oy + metrics.bounds.ymin + metrics.bounds.height,
|
||||
);
|
||||
mask.fill_path( path, FillRule::Winding, true, ts );
|
||||
( metrics, mask.take() )
|
||||
}
|
||||
|
||||
fn face( &self ) -> Option<Face<'_>>
|
||||
{
|
||||
Face::parse( self.data.as_slice(), self.index ).ok()
|
||||
}
|
||||
|
||||
/// Scale `outline` and place it on the pixel grid, returning the
|
||||
/// fractional offsets the rasteriser has to shift it by.
|
||||
fn raw_metrics( &self, outline: &Outline, scale: f32 ) -> ( Metrics, f32, f32 )
|
||||
{
|
||||
let bounds = outline.bounds.scale( scale );
|
||||
let mut ox = bounds.xmin.fract();
|
||||
let mut oy = ( 1.0 - bounds.height.fract() - bounds.ymin.fract() ).fract();
|
||||
if ox < 0.0 { ox += 1.0; }
|
||||
if oy < 0.0 { oy += 1.0; }
|
||||
|
||||
let metrics = Metrics
|
||||
{
|
||||
xmin: bounds.xmin.floor() as i32,
|
||||
ymin: bounds.ymin.floor() as i32,
|
||||
width: ( bounds.width + ox ).ceil().max( 0.0 ) as usize,
|
||||
height: ( bounds.height + oy ).ceil().max( 0.0 ) as usize,
|
||||
advance_width: scale * outline.advance_width,
|
||||
advance_height: scale * outline.advance_height,
|
||||
bounds,
|
||||
};
|
||||
( metrics, ox, oy )
|
||||
}
|
||||
|
||||
fn outline( &self, id: u16 ) -> Arc<Outline>
|
||||
{
|
||||
if let Some( o ) = self.outlines.lock().ok().and_then( |m| m.get( &id ).cloned() )
|
||||
{
|
||||
return o;
|
||||
}
|
||||
let built = Arc::new( self.build_outline( id ) );
|
||||
if let Ok( mut m ) = self.outlines.lock() { m.insert( id, Arc::clone( &built ) ); }
|
||||
built
|
||||
}
|
||||
|
||||
fn build_outline( &self, id: u16 ) -> Outline
|
||||
{
|
||||
let empty = || Outline
|
||||
{
|
||||
path: None,
|
||||
bounds: OutlineBounds::default(),
|
||||
advance_width: 0.0,
|
||||
advance_height: 0.0,
|
||||
};
|
||||
|
||||
let Some( face ) = self.face() else { return empty() };
|
||||
|
||||
let gid = GlyphId( id );
|
||||
let advance_width = face.glyph_hor_advance( gid ).unwrap_or( 0 ) as f32;
|
||||
let advance_height = face.glyph_ver_advance( gid ).unwrap_or( 0 ) as f32;
|
||||
|
||||
let mut sink = Outliner::new();
|
||||
let _ = face.outline_glyph( gid, &mut sink );
|
||||
|
||||
// The box comes from the points the outline actually reaches,
|
||||
// curve extrema included, rather than the one `glyf` declares:
|
||||
// declared boxes are allowed to be loose, and a box wider than
|
||||
// the ink shifts the glyph on the pixel grid.
|
||||
let bounds = sink.aabb.finish().unwrap_or_default();
|
||||
let path = sink.builder.finish();
|
||||
|
||||
Outline { path, bounds, advance_width, advance_height }
|
||||
}
|
||||
}
|
||||
|
||||
/// Running bounding box over every point the outline reaches, curve
|
||||
/// extrema included.
|
||||
struct Aabb
|
||||
{
|
||||
xmin: f32,
|
||||
ymin: f32,
|
||||
xmax: f32,
|
||||
ymax: f32,
|
||||
seen: bool,
|
||||
}
|
||||
|
||||
impl Aabb
|
||||
{
|
||||
fn new() -> Self
|
||||
{
|
||||
Self { xmin: f32::MAX, ymin: f32::MAX, xmax: f32::MIN, ymax: f32::MIN, seen: false }
|
||||
}
|
||||
|
||||
fn add( &mut self, x: f32, y: f32 )
|
||||
{
|
||||
self.add_x( x );
|
||||
self.add_y( y );
|
||||
self.seen = true;
|
||||
}
|
||||
|
||||
fn add_x( &mut self, x: f32 )
|
||||
{
|
||||
self.xmin = self.xmin.min( x );
|
||||
self.xmax = self.xmax.max( x );
|
||||
}
|
||||
|
||||
fn add_y( &mut self, y: f32 )
|
||||
{
|
||||
self.ymin = self.ymin.min( y );
|
||||
self.ymax = self.ymax.max( y );
|
||||
}
|
||||
|
||||
fn finish( &self ) -> Option<OutlineBounds>
|
||||
{
|
||||
self.seen.then( || OutlineBounds
|
||||
{
|
||||
xmin: self.xmin,
|
||||
ymin: self.ymin,
|
||||
width: self.xmax - self.xmin,
|
||||
height: self.ymax - self.ymin,
|
||||
} )
|
||||
}
|
||||
}
|
||||
|
||||
struct Outliner
|
||||
{
|
||||
builder: PathBuilder,
|
||||
aabb: Aabb,
|
||||
from: ( f32, f32 ),
|
||||
}
|
||||
|
||||
impl Outliner
|
||||
{
|
||||
fn new() -> Self
|
||||
{
|
||||
Self { builder: PathBuilder::new(), aabb: Aabb::new(), from: ( 0.0, 0.0 ) }
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlineBuilder for Outliner
|
||||
{
|
||||
fn move_to( &mut self, x: f32, y: f32 )
|
||||
{
|
||||
self.builder.move_to( x, y );
|
||||
self.aabb.add( x, y );
|
||||
self.from = ( x, y );
|
||||
}
|
||||
|
||||
fn line_to( &mut self, x: f32, y: f32 )
|
||||
{
|
||||
self.builder.line_to( x, y );
|
||||
self.aabb.add( x, y );
|
||||
self.from = ( x, y );
|
||||
}
|
||||
|
||||
fn quad_to( &mut self, x1: f32, y1: f32, x: f32, y: f32 )
|
||||
{
|
||||
self.builder.quad_to( x1, y1, x, y );
|
||||
self.aabb.add( x, y );
|
||||
let ( x0, y0 ) = self.from;
|
||||
if let Some( v ) = quad_extremum( x0, x1, x ) { self.aabb.add_x( v ); }
|
||||
if let Some( v ) = quad_extremum( y0, y1, y ) { self.aabb.add_y( v ); }
|
||||
self.from = ( x, y );
|
||||
}
|
||||
|
||||
fn curve_to( &mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32 )
|
||||
{
|
||||
self.builder.cubic_to( x1, y1, x2, y2, x, y );
|
||||
self.aabb.add( x, y );
|
||||
let ( x0, y0 ) = self.from;
|
||||
for v in cubic_extrema( x0, x1, x2, x ) { self.aabb.add_x( v ); }
|
||||
for v in cubic_extrema( y0, y1, y2, y ) { self.aabb.add_y( v ); }
|
||||
self.from = ( x, y );
|
||||
}
|
||||
|
||||
fn close( &mut self )
|
||||
{
|
||||
self.builder.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Value of a quadratic Bézier at its turning point, when that point
|
||||
/// falls strictly inside the segment.
|
||||
fn quad_extremum( p0: f32, p1: f32, p2: f32 ) -> Option<f32>
|
||||
{
|
||||
let denom = p0 - 2.0 * p1 + p2;
|
||||
if denom.abs() <= f32::EPSILON { return None; }
|
||||
let t = ( p0 - p1 ) / denom;
|
||||
if !( t > 0.0 && t < 1.0 ) { return None; }
|
||||
let mt = 1.0 - t;
|
||||
Some( mt * mt * p0 + 2.0 * mt * t * p1 + t * t * p2 )
|
||||
}
|
||||
|
||||
/// Values of a cubic Bézier at the turning points inside the segment.
|
||||
/// `B'(t)/3` is `a t² + b t + c` for the coefficients below.
|
||||
fn cubic_extrema( p0: f32, p1: f32, p2: f32, p3: f32 ) -> Vec<f32>
|
||||
{
|
||||
let a = -p0 + 3.0 * p1 - 3.0 * p2 + p3;
|
||||
let b = 2.0 * ( p0 - 2.0 * p1 + p2 );
|
||||
let c = p1 - p0;
|
||||
|
||||
let mut roots: Vec<f32> = Vec::new();
|
||||
if a.abs() <= f32::EPSILON
|
||||
{
|
||||
if b.abs() > f32::EPSILON { roots.push( -c / b ); }
|
||||
}
|
||||
else
|
||||
{
|
||||
let disc = b * b - 4.0 * a * c;
|
||||
if disc >= 0.0
|
||||
{
|
||||
let root = disc.sqrt();
|
||||
roots.push( ( -b + root ) / ( 2.0 * a ) );
|
||||
roots.push( ( -b - root ) / ( 2.0 * a ) );
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
.into_iter()
|
||||
.filter( |t| *t > 0.0 && *t < 1.0 )
|
||||
.map( |t|
|
||||
{
|
||||
let mt = 1.0 - t;
|
||||
mt * mt * mt * p0
|
||||
+ 3.0 * mt * mt * t * p1
|
||||
+ 3.0 * mt * t * t * p2
|
||||
+ t * t * t * p3
|
||||
} )
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[ cfg( test ) ]
|
||||
mod tests
|
||||
{
|
||||
use super::*;
|
||||
|
||||
#[ test ]
|
||||
fn quadratic_turning_point_is_the_apex()
|
||||
{
|
||||
// 0 -> 10 -> 0 peaks at t = 0.5, i.e. 5.
|
||||
assert_eq!( quad_extremum( 0.0, 10.0, 0.0 ), Some( 5.0 ) );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn monotone_quadratic_has_no_interior_turning_point()
|
||||
{
|
||||
assert_eq!( quad_extremum( 0.0, 5.0, 10.0 ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn cubic_turning_points_bracket_the_control_hull()
|
||||
{
|
||||
let v = cubic_extrema( 0.0, 10.0, -10.0, 0.0 );
|
||||
assert_eq!( v.len(), 2 );
|
||||
for x in v { assert!( x.abs() < 10.0 ); }
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn monotone_cubic_has_no_interior_turning_point()
|
||||
{
|
||||
assert!( cubic_extrema( 0.0, 1.0, 2.0, 3.0 ).is_empty() );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn line_metrics_scale_together()
|
||||
{
|
||||
let m = LineMetrics { ascent: 800.0, descent: -200.0, line_gap: 0.0, new_line_size: 1000.0 };
|
||||
let s = m.scale( 0.02 );
|
||||
assert_eq!( s.ascent, 16.0 );
|
||||
assert_eq!( s.descent, -4.0 );
|
||||
assert_eq!( s.new_line_size, 20.0 );
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
use glow::HasContext;
|
||||
|
||||
use crate::theme::FontRegistry;
|
||||
@@ -107,7 +107,7 @@ pub struct BorrowedGlesTexture
|
||||
/// Cached glyph: rasterized bitmap placed in the shared atlas.
|
||||
pub ( super ) struct GlyphEntry
|
||||
{
|
||||
pub ( super ) metrics: fontdue::Metrics,
|
||||
pub ( super ) metrics: crate::font_face::Metrics,
|
||||
pub ( super ) tex_w: i32,
|
||||
pub ( super ) tex_h: i32,
|
||||
pub ( super ) atlas_x: u32,
|
||||
@@ -118,7 +118,7 @@ pub ( super ) struct GlyphEntry
|
||||
/// software canvas's `GlyphKey`. `glyph_id` is the per-font index
|
||||
/// returned by HarfBuzz shaping; `size_bits` is `f32::to_bits` of
|
||||
/// `size * dpi_scale`; `font_id` is the address of the
|
||||
/// `Arc<fontdue::Font>` used for rasterisation.
|
||||
/// `Arc<FontFace>` used for rasterisation.
|
||||
pub( super ) type GlyphAtlasKey = ( u16, u32, usize );
|
||||
|
||||
pub( super ) const ATLAS_SIZE: u32 = 2048;
|
||||
@@ -139,7 +139,7 @@ pub struct GlesCanvas
|
||||
/// Default font loaded from the system via `system_fonts::default_handle`.
|
||||
/// Kept as a fallback for callers that do not route through the
|
||||
/// theme registry.
|
||||
pub font: Arc<Font>,
|
||||
pub font: Arc<FontFace>,
|
||||
/// Raw bytes of the default font. Required by rustybuzz for
|
||||
/// HarfBuzz shaping (see the `text_shaping` private module). Kept on the
|
||||
/// canvas so the shape pipeline has direct access without a
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::{ Font, LineMetrics, Metrics };
|
||||
use crate::font_face::{ FontFace, LineMetrics, Metrics };
|
||||
use glow::HasContext;
|
||||
|
||||
use crate::theme::{ FontRegistry, FontStyle };
|
||||
@@ -728,7 +728,7 @@ impl GlesCanvas
|
||||
pub fn set_global_alpha( &mut self, a: f32 ) { self.global_alpha = a; }
|
||||
|
||||
/// The canvas default font, used when no specific face is resolved.
|
||||
pub fn font( &self ) -> &Font { &self.font }
|
||||
pub fn font( &self ) -> &FontFace { &self.font }
|
||||
|
||||
/// Install a theme font registry so [`Self::font_for`] can resolve
|
||||
/// family+weight+style triples declared by the theme's `fonts` block.
|
||||
@@ -740,7 +740,7 @@ impl GlesCanvas
|
||||
/// 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>
|
||||
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<FontFace>
|
||||
{
|
||||
self.font_registry
|
||||
.as_ref()
|
||||
@@ -753,7 +753,7 @@ impl GlesCanvas
|
||||
/// 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>
|
||||
pub fn font_for_char( &self, ch: char ) -> Arc<FontFace>
|
||||
{
|
||||
if self.font.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
|
||||
@@ -179,7 +179,7 @@ void main()
|
||||
// Glyphs live in a shared GL_LUMINANCE atlas. `u_uv_scale` and `u_uv_offset`
|
||||
// map the unit quad's (0,0)–(1,1) UV range into the glyph's sub-region:
|
||||
// tex_uv = vec2(v_uv.x, 1 - v_uv.y) * u_uv_scale + u_uv_offset
|
||||
// The Y-flip (`1 - v_uv.y`) corrects for fontdue bitmaps being stored
|
||||
// The Y-flip (`1 - v_uv.y`) corrects for glyph bitmaps being stored
|
||||
// top-row-first while GL tex coords have V=0 at the bottom.
|
||||
//
|
||||
// The atlas is GL_R8 on ES3 and GL_LUMINANCE on ES2 (see
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! per-glyph draw call. The line is shaped through
|
||||
//! [`crate::text_shaping::shape_line`] (Unicode BiDi + per-sub-run
|
||||
//! rustybuzz / HarfBuzz) and each shaped glyph is rasterised by
|
||||
//! glyph index via `fontdue::Font::rasterize_indexed`, so the cache
|
||||
//! glyph index via [`FontFace::rasterize_indexed`], so the cache
|
||||
//! is keyed on `(glyph_id, size_bits, font_id)` and Arabic
|
||||
//! connected forms / Devanagari clusters / CJK shaped glyphs are
|
||||
//! cached correctly without colliding with the source codepoints.
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
use glow::HasContext;
|
||||
|
||||
use crate::types::Color;
|
||||
@@ -27,7 +27,7 @@ use super::{ ATLAS_SIZE, GlesCanvas, GlyphEntry };
|
||||
|
||||
const GLYPH_CACHE_SOFT_CAP: usize = 8192;
|
||||
|
||||
fn font_id( font: &Arc<Font> ) -> usize
|
||||
fn font_id( font: &Arc<FontFace> ) -> usize
|
||||
{
|
||||
Arc::as_ptr( font ) as usize
|
||||
}
|
||||
@@ -47,12 +47,12 @@ impl GlesCanvas
|
||||
/// Like [`Self::draw_text`] but leads the resolver with `font` instead of the
|
||||
/// canvas default, falling back to the system chain for codepoints it does not
|
||||
/// cover.
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<Font> )
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<FontFace> )
|
||||
{
|
||||
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>> )
|
||||
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<FontFace>> )
|
||||
{
|
||||
self.activate_target();
|
||||
let scaled = size * self.dpi_scale;
|
||||
@@ -110,7 +110,7 @@ impl GlesCanvas
|
||||
// `Arc<Font>::as_ptr` of the resolved handle, so we walk
|
||||
// every codepoint and ask the same resolver — exactly once
|
||||
// per distinct id thanks to the dedup loop.
|
||||
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
|
||||
let mut fonts: Vec<( usize, Arc<FontFace> )> = Vec::new();
|
||||
if !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
||||
@@ -305,12 +305,12 @@ impl GlesCanvas
|
||||
|
||||
/// Like [`Self::measure_text`] but measures with `font` leading the resolver,
|
||||
/// 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
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<FontFace> ) -> f32
|
||||
{
|
||||
self.measure_inner( text, size, Some( font ) )
|
||||
}
|
||||
|
||||
fn measure_inner( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
|
||||
fn measure_inner( &self, text: &str, size: f32, font: Option<&Arc<FontFace>> ) -> f32
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let canvas_handle = self.font_handle();
|
||||
|
||||
@@ -65,6 +65,7 @@ impl<A: App> AppData<A>
|
||||
self.show_context_menu( focus, idx, pos );
|
||||
} else {
|
||||
self.hide_context_menu( focus );
|
||||
self.app.on_pointer_secondary( pos.x, pos.y );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -13,7 +13,7 @@
|
||||
//! A lightweight Wayland UI toolkit built on top of
|
||||
//! [smithay-client-toolkit](https://crates.io/crates/smithay-client-toolkit),
|
||||
//! [tiny-skia](https://crates.io/crates/tiny-skia) and
|
||||
//! [fontdue](https://crates.io/crates/fontdue).
|
||||
//! [rustybuzz](https://crates.io/crates/rustybuzz).
|
||||
//!
|
||||
//! ltk is the UI toolkit for the Liberux desktop. The Liberux compositor (Forge)
|
||||
//! handles window management, decorations, and positioning — ltk focuses on
|
||||
@@ -284,6 +284,7 @@ rust_i18n::i18n!( "locales", fallback = "en" );
|
||||
pub( crate ) static TEST_GLOBALS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new( () );
|
||||
|
||||
pub mod types;
|
||||
pub mod font_face;
|
||||
pub( crate ) mod render;
|
||||
pub( crate ) mod system_fonts;
|
||||
pub( crate ) mod text_shaping;
|
||||
@@ -344,6 +345,7 @@ pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as th
|
||||
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
||||
pub use render::is_software_render;
|
||||
pub use render::Canvas;
|
||||
pub use font_face::{ FontFace, LineMetrics, Metrics, OutlineBounds };
|
||||
pub use wallpaper::{ WallpaperBundle, ImageData };
|
||||
pub use nav_stack::NavStack;
|
||||
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
|
||||
|
||||
+12
-12
@@ -4,7 +4,7 @@
|
||||
//! 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
|
||||
//! backend. The CPU backend is [`SoftwareCanvas`] (tiny-skia, glyphs
|
||||
//! rasterised into a `Pixmap`). The GPU backend is
|
||||
//! [`crate::gles_render::GlesCanvas`] (EGL + GLES2/3).
|
||||
//!
|
||||
@@ -34,7 +34,7 @@
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::{ Font, LineMetrics, Metrics };
|
||||
use crate::font_face::{ FontFace, LineMetrics, Metrics };
|
||||
use tiny_skia::{ Mask, Pixmap };
|
||||
|
||||
use crate::gles_render::{ BorrowedGlesTexture, GlesCanvas, GlesVersion };
|
||||
@@ -81,7 +81,7 @@ pub fn is_software_render() -> bool
|
||||
|
||||
/// 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 /
|
||||
/// `Arc<FontFace>` 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
|
||||
@@ -107,7 +107,7 @@ pub ( super ) struct GlyphEntry
|
||||
// ─── SoftwareCanvas ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Software rendering backend backed by a tiny-skia [`Pixmap`] and a
|
||||
/// fontdue [`Font`].
|
||||
/// [`FontFace`].
|
||||
///
|
||||
/// Wrapped by [`Canvas`] so the GPU backend can be slotted in by the
|
||||
/// runtime without changing widget code. Widgets themselves never see
|
||||
@@ -122,10 +122,10 @@ pub struct SoftwareCanvas
|
||||
/// specific family through [`SoftwareCanvas::font_for`] keep
|
||||
/// working. Populated from
|
||||
/// `crate::system_fonts::default_handle` at construction time.
|
||||
pub font: Arc<Font>,
|
||||
pub font: Arc<FontFace>,
|
||||
/// 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.
|
||||
/// the file; the face names the same 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).
|
||||
@@ -176,7 +176,7 @@ pub struct SoftwareCanvas
|
||||
/// inlinable in the hot path.
|
||||
pub enum Canvas
|
||||
{
|
||||
/// CPU rasterisation via tiny-skia + fontdue, written to a
|
||||
/// CPU rasterisation via tiny-skia, written to a
|
||||
/// `wl_shm` buffer.
|
||||
Software( SoftwareCanvas ),
|
||||
/// GPU rasterisation via EGL + GLES 2/3. Presents via
|
||||
@@ -495,10 +495,10 @@ impl Canvas
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared font handle. Exposed so widgets that need raw `fontdue`
|
||||
/// Shared font handle. Exposed so widgets that need raw face
|
||||
/// access (e.g. `Text` for ascent/descent) do not have to go
|
||||
/// through wrappers for every metric they read.
|
||||
pub fn font( &self ) -> &Font
|
||||
pub fn font( &self ) -> &FontFace
|
||||
{
|
||||
match self
|
||||
{
|
||||
@@ -523,7 +523,7 @@ impl Canvas
|
||||
/// 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>
|
||||
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<FontFace>
|
||||
{
|
||||
match self
|
||||
{
|
||||
@@ -911,7 +911,7 @@ impl Canvas
|
||||
/// 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> )
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<FontFace> )
|
||||
{
|
||||
match self
|
||||
{
|
||||
@@ -932,7 +932,7 @@ impl Canvas
|
||||
/// 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
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<FontFace> ) -> f32
|
||||
{
|
||||
match self
|
||||
{
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
use tiny_skia::{ Pixmap, PixmapPaint, Transform };
|
||||
|
||||
use crate::system_fonts::default_handle;
|
||||
@@ -78,7 +78,7 @@ impl SoftwareCanvas
|
||||
/// 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>
|
||||
pub fn font_for( &self, family: &str, weight: u16, style: FontStyle ) -> Arc<FontFace>
|
||||
{
|
||||
self.font_registry
|
||||
.as_ref()
|
||||
@@ -91,7 +91,7 @@ impl SoftwareCanvas
|
||||
/// 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>
|
||||
pub fn font_for_char( &self, ch: char ) -> Arc<FontFace>
|
||||
{
|
||||
if self.font.lookup_glyph_index( ch ) != 0
|
||||
{
|
||||
|
||||
+8
-8
@@ -4,7 +4,7 @@
|
||||
//! Text rendering for [`SoftwareCanvas`]. The line is shaped through
|
||||
//! [`crate::text_shaping::shape_line`] (BiDi reordering + per-sub-run
|
||||
//! rustybuzz / HarfBuzz shaping) and each shaped glyph is rasterised
|
||||
//! by glyph index via `fontdue::Font::rasterize_indexed`. The cache
|
||||
//! by glyph index via [`FontFace::rasterize_indexed`]. The cache
|
||||
//! is keyed on `(glyph_id, size, font_id)` so Arabic connected
|
||||
//! forms, Devanagari clusters, and CJK shaped glyphs cluster
|
||||
//! correctly without colliding with the per-codepoint cache the
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
|
||||
use crate::types::Color;
|
||||
|
||||
@@ -40,7 +40,7 @@ impl SoftwareCanvas
|
||||
/// the result under `(glyph_id, size_bits, font_id)`. Used by
|
||||
/// both the public `draw_text` and `draw_text_with_font` paths
|
||||
/// after shaping has resolved every codepoint to a glyph index.
|
||||
fn rasterize_indexed_cached( &mut self, font: &Arc<Font>, glyph_id: u16, scaled: f32, font_id: usize ) -> &GlyphEntry
|
||||
fn rasterize_indexed_cached( &mut self, font: &Arc<FontFace>, glyph_id: u16, scaled: f32, font_id: usize ) -> &GlyphEntry
|
||||
{
|
||||
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id };
|
||||
self.evict_if_full( &key );
|
||||
@@ -59,12 +59,12 @@ impl SoftwareCanvas
|
||||
|
||||
/// 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> )
|
||||
pub fn draw_text_with_font( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: &Arc<FontFace> )
|
||||
{
|
||||
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>> )
|
||||
fn draw_text_inner( &mut self, text: &str, x: f32, y: f32, size: f32, color: Color, font: Option<&Arc<FontFace>> )
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let line_h = scaled * 1.5;
|
||||
@@ -124,7 +124,7 @@ impl SoftwareCanvas
|
||||
// rasterization — we keep a per-font-id index into a small
|
||||
// vec of `Arc<Font>` so the rasterizer step does not have to
|
||||
// re-walk the resolve fn (mut self borrow conflicts).
|
||||
let mut fonts: Vec<( usize, Arc<Font> )> = Vec::new();
|
||||
let mut fonts: Vec<( usize, Arc<FontFace> )> = Vec::new();
|
||||
let primary_id = Arc::as_ptr( &canvas_handle.font ) as usize;
|
||||
if !canvas_handle.bytes.is_empty()
|
||||
{
|
||||
@@ -224,12 +224,12 @@ impl SoftwareCanvas
|
||||
self.measure_with_font( text, size, None )
|
||||
}
|
||||
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<Font> ) -> f32
|
||||
pub fn measure_text_with_font( &self, text: &str, size: f32, font: &Arc<FontFace> ) -> f32
|
||||
{
|
||||
self.measure_with_font( text, size, Some( font ) )
|
||||
}
|
||||
|
||||
fn measure_with_font( &self, text: &str, size: f32, font: Option<&Arc<Font>> ) -> f32
|
||||
fn measure_with_font( &self, text: &str, size: f32, font: Option<&Arc<FontFace>> ) -> f32
|
||||
{
|
||||
let scaled = size * self.dpi_scale;
|
||||
let canvas_handle =
|
||||
|
||||
+35
-35
@@ -10,11 +10,11 @@
|
||||
//! that band — Cyrillic, Devanagari, Arabic, Hebrew, Thai, the CJK
|
||||
//! ideographic block — relies on a chain of system Noto fonts loaded
|
||||
//! on demand: a fallback file is read off disk the first time some
|
||||
//! glyph asks for it and the resulting `Arc<Font>` is cached
|
||||
//! glyph asks for it and the resulting `Arc<FontFace>` is cached
|
||||
//! process-wide.
|
||||
//!
|
||||
//! Each `(path, face)` entry in [`FALLBACK_FONT_CANDIDATES`] gets its
|
||||
//! own `OnceLock<Option<Arc<Font>>>` slot. `Some(font)` means the
|
||||
//! own `OnceLock<Option<Arc<FontFace>>>` slot. `Some(font)` means the
|
||||
//! file was read and parsed successfully; `None` means it's missing
|
||||
//! or unparseable, locked in for the rest of the process so we don't
|
||||
//! re-`stat` the same path on every glyph miss. The `OnceLock`
|
||||
@@ -26,28 +26,28 @@
|
||||
|
||||
use std::sync::{ Arc, OnceLock };
|
||||
|
||||
use fontdue::{ Font, FontSettings };
|
||||
use crate::font_face::FontFace;
|
||||
|
||||
/// Bytes-aware font handle. The `font` is what fontdue rasterises
|
||||
/// Bytes-aware font handle. The `font` is what glyphs rasterise
|
||||
/// against; `bytes` is the raw OpenType / TrueType buffer that
|
||||
/// rustybuzz (HarfBuzz) requires for shaping. They are owned
|
||||
/// separately because fontdue does not expose its internal byte
|
||||
/// buffer — we keep both alive in lockstep instead.
|
||||
/// rustybuzz (HarfBuzz) requires for shaping. Both name the same
|
||||
/// buffer — the face holds its own `Arc` so it can pull outlines in
|
||||
/// later — and are kept side by side so shaping needs no second read.
|
||||
///
|
||||
/// `face` is the TTC sub-face index (0 for single-face files, the
|
||||
/// face index for collections like Noto Sans CJK).
|
||||
#[ derive( Clone ) ]
|
||||
pub struct FontHandle
|
||||
{
|
||||
pub font: Arc<Font>,
|
||||
pub font: Arc<FontFace>,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub face: u32,
|
||||
}
|
||||
|
||||
/// Per-script fallback font path with the TrueType-collection face
|
||||
/// index fontdue should load (most files are single-face → 0; CJK
|
||||
/// `.ttc` archives carry many faces, see the SC face on the canonical
|
||||
/// Adobe-built `NotoSansCJK-Regular.ttc`).
|
||||
/// index to load (most files are single-face → 0; CJK `.ttc` archives
|
||||
/// carry many faces, see the SC face on the canonical Adobe-built
|
||||
/// `NotoSansCJK-Regular.ttc`).
|
||||
struct FallbackFontSpec
|
||||
{
|
||||
path: &'static str,
|
||||
@@ -58,8 +58,9 @@ struct FallbackFontSpec
|
||||
/// the first slot whose font owns a non-zero glyph index for the
|
||||
/// codepoint wins, so the broadest families come first (Noto Sans
|
||||
/// covers Cyrillic, Greek, extended Latin) and the script-specific
|
||||
/// + CJK packs trail. DejaVu is the last resort — most distros carry
|
||||
/// it under one path or another.
|
||||
/// + CJK packs trail. DejaVu goes ahead of the CJK collection: it owns
|
||||
/// the geometric shapes, arrows and box drawing that would otherwise
|
||||
/// pull a 19 MB collection in for a single bullet.
|
||||
const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
||||
&[
|
||||
// Noto Sans — Cyrillic, Greek, extended Latin.
|
||||
@@ -75,25 +76,25 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
|
||||
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", face: 0 },
|
||||
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", face: 0 },
|
||||
|
||||
// DejaVu — broad-but-shallow coverage of most scripts, plus the
|
||||
// symbol, arrow and box-drawing blocks. Ships almost everywhere.
|
||||
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
|
||||
|
||||
// CJK packs ship as `.ttc`. The collection layout on the canonical
|
||||
// Adobe-built `NotoSansCJK-Regular.ttc` is 0=JP, 1=KR, 2=SC, 3=TC,
|
||||
// 4=HK; we want CJK shared ideographs available in any locale, so
|
||||
// any of the faces is fine — pick SC (face 2) as the broadest
|
||||
// baseline. ~30 MB on disk; under the lazy loader this only fires
|
||||
// when a user-visible string actually contains a CJK codepoint.
|
||||
// baseline. Last, because probing a slot reads its whole file, and
|
||||
// this one is 19 MB.
|
||||
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", face: 2 },
|
||||
FallbackFontSpec { path: "/usr/share/fonts/opentype/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
|
||||
FallbackFontSpec { path: "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", face: 2 },
|
||||
|
||||
// Last resort — DejaVu has broad-but-shallow coverage of most
|
||||
// scripts and ships almost everywhere.
|
||||
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
|
||||
];
|
||||
|
||||
/// Per-slot lazy state, two stages so probing a slot for coverage never
|
||||
/// pays the fontdue parse: `bytes` is the file read once, `font` is the
|
||||
/// fontdue face built only when a glyph in this slot is actually needed.
|
||||
/// `None` in a resolved stage is a sticky negative result.
|
||||
/// builds a face: `bytes` is the file read once, `font` is the face
|
||||
/// built only when a glyph in this slot is actually needed. `None` in a
|
||||
/// resolved stage is a sticky negative result.
|
||||
struct Slot
|
||||
{
|
||||
bytes: OnceLock<Option<Arc<Vec<u8>>>>,
|
||||
@@ -133,17 +134,16 @@ fn slot_covers( idx: usize, ch: char ) -> bool
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Build (once) the fontdue face for slot `idx`. The raw bytes are
|
||||
/// preserved inside the handle so the same `Arc<Vec<u8>>` can be handed
|
||||
/// to rustybuzz for shaping without re-reading the file.
|
||||
/// Build (once) the face for slot `idx`. The raw bytes are preserved
|
||||
/// inside the handle so the same `Arc<Vec<u8>>` can be handed to
|
||||
/// rustybuzz for shaping without re-reading the file.
|
||||
fn slot_handle( idx: usize ) -> Option<FontHandle>
|
||||
{
|
||||
slots()[ idx ].font.get_or_init( ||
|
||||
{
|
||||
let bytes = slot_bytes( idx )?;
|
||||
let face = FALLBACK_FONT_CANDIDATES[ idx ].face;
|
||||
let opts = FontSettings { collection_index: face, ..FontSettings::default() };
|
||||
let font = Font::from_bytes( bytes.as_slice(), opts ).ok()?;
|
||||
let font = FontFace::from_bytes( Arc::clone( &bytes ), face )?;
|
||||
Some( FontHandle { font: Arc::new( font ), bytes, face } )
|
||||
} )
|
||||
.clone()
|
||||
@@ -151,10 +151,10 @@ fn slot_handle( idx: usize ) -> Option<FontHandle>
|
||||
|
||||
/// Find the first fallback font that has a non-zero glyph index for
|
||||
/// `ch`, loading it from disk on the first hit and caching the
|
||||
/// `Arc<Font>` for the rest of the process. Returns `None` if no
|
||||
/// `Arc<FontFace>` for the rest of the process. Returns `None` if no
|
||||
/// installed fallback covers the codepoint — the caller then paints
|
||||
/// the primary font's `.notdef` rather than dropping the glyph.
|
||||
pub fn lookup( ch: char ) -> Option<Arc<Font>>
|
||||
pub fn lookup( ch: char ) -> Option<Arc<FontFace>>
|
||||
{
|
||||
lookup_handle( ch ).map( |h| h.font )
|
||||
}
|
||||
@@ -215,9 +215,9 @@ pub ( crate ) fn default_handle() -> FontHandle
|
||||
static PRIMARY: OnceLock<FontHandle> = OnceLock::new();
|
||||
PRIMARY.get_or_init( ||
|
||||
{
|
||||
let bytes = load_default_font_bytes();
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).expect( "primary font parses" );
|
||||
FontHandle { font: Arc::new( font ), bytes: Arc::new( bytes ), face: 0 }
|
||||
let bytes = Arc::new( load_default_font_bytes() );
|
||||
let font = FontFace::from_bytes( Arc::clone( &bytes ), 0 ).expect( "primary font parses" );
|
||||
FontHandle { font: Arc::new( font ), bytes, face: 0 }
|
||||
} ).clone()
|
||||
}
|
||||
|
||||
@@ -230,9 +230,9 @@ pub fn primary_handle() -> FontHandle
|
||||
}
|
||||
|
||||
/// Bytes-aware variant of [`lookup`]. Returns the full
|
||||
/// [`FontHandle`] (fontdue handle + raw bytes + face index) so
|
||||
/// callers that need to invoke a HarfBuzz-style shaper can do so
|
||||
/// without re-reading the font file.
|
||||
/// [`FontHandle`] (face + raw bytes + face index) so callers that
|
||||
/// need to invoke a HarfBuzz-style shaper can do so without
|
||||
/// re-reading the font file.
|
||||
pub fn lookup_handle( ch: char ) -> Option<FontHandle>
|
||||
{
|
||||
( 0..FALLBACK_FONT_CANDIDATES.len() )
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
//! visual order. Renderers consume that sequence directly: each
|
||||
//! `PositionedGlyph` carries the per-font `glyph_id`, the visual
|
||||
//! advance, and ink offsets, which is exactly what
|
||||
//! `fontdue::Font::rasterize_indexed` needs to render Arabic
|
||||
//! [`crate::font_face::FontFace::rasterize_indexed`] needs to render Arabic
|
||||
//! connected forms, Devanagari clusters and CJK shaped glyphs
|
||||
//! correctly.
|
||||
//!
|
||||
|
||||
+22
-22
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
|
||||
//! Runtime font registry: families → (weight, style) → [`FontFace`].
|
||||
//!
|
||||
//! The registry is the live, loaded-in-memory counterpart of the theme's
|
||||
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
|
||||
@@ -27,7 +27,7 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::{ Font, FontSettings };
|
||||
use crate::font_face::FontFace;
|
||||
|
||||
use super::fonts::FontFamilyDef;
|
||||
use super::text_style::FontStyle;
|
||||
@@ -54,7 +54,7 @@ pub enum FontLoadError
|
||||
{
|
||||
/// The source file could not be read.
|
||||
Io( PathBuf, std::io::Error ),
|
||||
/// The source file was read but `fontdue` rejected its contents.
|
||||
/// The source file was read but its contents did not parse as a face.
|
||||
Parse( PathBuf, String ),
|
||||
}
|
||||
|
||||
@@ -75,11 +75,11 @@ impl std::error::Error for FontLoadError {}
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A loaded font registry: the theme's declared families materialised into
|
||||
/// live `fontdue::Font` handles, indexed by family id / weight / style.
|
||||
/// live [`FontFace`] handles, indexed by family id / weight / style.
|
||||
#[ derive( Debug, Default ) ]
|
||||
pub struct FontRegistry
|
||||
{
|
||||
by_key: HashMap<FontKey, Arc<Font>>,
|
||||
by_key: HashMap<FontKey, Arc<FontFace>>,
|
||||
fallbacks: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ impl FontRegistry
|
||||
family: impl Into<String>,
|
||||
weight: u16,
|
||||
style: FontStyle,
|
||||
font: Arc<Font>,
|
||||
font: Arc<FontFace>,
|
||||
)
|
||||
{
|
||||
self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
|
||||
@@ -118,9 +118,9 @@ impl FontRegistry
|
||||
/// Whether the registry has no sources loaded.
|
||||
pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }
|
||||
|
||||
/// Resolve a triple to a loaded [`Font`]. See the module docs for the
|
||||
/// Resolve a triple to a loaded [`FontFace`]. See the module docs for the
|
||||
/// precedence order.
|
||||
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
|
||||
pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<FontFace>>
|
||||
{
|
||||
// 1. Exact match.
|
||||
let exact = FontKey { family: family.to_string(), weight, style };
|
||||
@@ -183,9 +183,10 @@ impl FontRegistry
|
||||
for src in &family.sources
|
||||
{
|
||||
let bytes = std::fs::read( &src.path )
|
||||
.map( Arc::new )
|
||||
.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
||||
.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
|
||||
let font = FontFace::from_bytes( bytes, 0 )
|
||||
.ok_or_else( || FontLoadError::Parse( src.path.clone(), "unparseable face".to_string() ) )?;
|
||||
reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
|
||||
}
|
||||
}
|
||||
@@ -218,7 +219,7 @@ impl FontRegistry
|
||||
{
|
||||
let bytes = match std::fs::read( &src.path )
|
||||
{
|
||||
Ok( b ) => b,
|
||||
Ok( b ) => Arc::new( b ),
|
||||
Err( e ) =>
|
||||
{
|
||||
eprintln!
|
||||
@@ -229,15 +230,15 @@ impl FontRegistry
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
|
||||
let font = match FontFace::from_bytes( bytes, 0 )
|
||||
{
|
||||
Ok( f ) => f,
|
||||
Err( e ) =>
|
||||
Some( f ) => f,
|
||||
None =>
|
||||
{
|
||||
eprintln!
|
||||
(
|
||||
"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
|
||||
src.path.display(), src.weight, src.style, e
|
||||
"[ltk] skipping font {} (weight {}, {:?}): unparseable face",
|
||||
src.path.display(), src.weight, src.style
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -258,14 +259,13 @@ mod tests
|
||||
|
||||
/// Load a real TTF from the system via the same search chain the
|
||||
/// software canvas uses. Only used by tests that need an actual
|
||||
/// `Font` — skipped when no font is found (keeps CI green on images
|
||||
/// without the usual system fonts).
|
||||
fn system_font() -> Option<Arc<Font>>
|
||||
/// `FontFace` — skipped when no font is found (keeps CI green on
|
||||
/// images without the usual system fonts).
|
||||
fn system_font() -> Option<Arc<FontFace>>
|
||||
{
|
||||
let path = crate::system_fonts::find_font_opt()?;
|
||||
let bytes = std::fs::read( path ).ok()?;
|
||||
let font = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
|
||||
Some( Arc::new( font ) )
|
||||
let bytes = Arc::new( std::fs::read( path ).ok()? );
|
||||
Some( Arc::new( FontFace::from_bytes( bytes, 0 )? ) )
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! This module only models the **declaration**: a family name, a set of
|
||||
//! source files indexed by weight/style, and a fallback chain. Loading the
|
||||
//! `.ttf` bytes into `fontdue` and resolving text styles against a live
|
||||
//! `.ttf` bytes into a face and resolving text styles against a live
|
||||
//! registry is the runtime registry's job (see [`super::FontRegistry`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -29,7 +29,7 @@ pub struct FontFamilyDef
|
||||
/// satisfy is used.
|
||||
pub fallbacks: Vec<String>,
|
||||
/// One source per weight/style the family ships. The runtime font
|
||||
/// registry registers each source in `fontdue` at load time.
|
||||
/// registry parses each source into a face at load time.
|
||||
pub sources: Vec<FontSource>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
|
||||
use crate::theme::FontStyle;
|
||||
use crate::types::{ Color, Length, Rect };
|
||||
@@ -100,12 +100,12 @@ impl<Msg: Clone> RichText<Msg>
|
||||
canvas.resolve_font( self.size )
|
||||
}
|
||||
|
||||
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
|
||||
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<FontFace>>
|
||||
{
|
||||
self.font.as_ref().map( |( family, weight, style )| canvas.font_for( family, *weight, *style ) )
|
||||
}
|
||||
|
||||
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
|
||||
{
|
||||
let size = self.resolved_size( canvas );
|
||||
match font
|
||||
@@ -231,7 +231,7 @@ impl VisualLine
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_tracked( content: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<VisualLine>
|
||||
fn wrap_tracked( content: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> Vec<VisualLine>
|
||||
{
|
||||
let measure = |s: &str| -> f32
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
use crate::font_face::FontFace;
|
||||
|
||||
use crate::theme::FontStyle;
|
||||
use crate::types::{ Color, Length, Rect };
|
||||
@@ -77,7 +77,7 @@ impl Text
|
||||
}
|
||||
|
||||
/// Resolve the font size against the canvas viewport. Internal
|
||||
/// helper: every method that needs an `f32` size for a `fontdue`
|
||||
/// helper: every method that needs an `f32` size for a face
|
||||
/// call routes through this so the field can stay a `Length`.
|
||||
#[ inline ]
|
||||
fn resolved_size( &self, canvas: &Canvas ) -> f32
|
||||
@@ -160,7 +160,7 @@ impl Text
|
||||
self
|
||||
}
|
||||
|
||||
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
|
||||
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<FontFace>>
|
||||
{
|
||||
self.font.as_ref().map( |( family, weight, style )|
|
||||
{
|
||||
@@ -168,7 +168,7 @@ impl Text
|
||||
} )
|
||||
}
|
||||
|
||||
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
|
||||
{
|
||||
let size = self.resolved_size( canvas );
|
||||
match font
|
||||
@@ -178,7 +178,7 @@ impl Text
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
|
||||
{
|
||||
let size = self.resolved_size( canvas );
|
||||
match font
|
||||
@@ -188,7 +188,7 @@ impl Text
|
||||
}
|
||||
}
|
||||
|
||||
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<Font>> )
|
||||
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<FontFace>> )
|
||||
{
|
||||
let size = self.resolved_size( canvas );
|
||||
match font
|
||||
@@ -302,7 +302,7 @@ impl Text
|
||||
/// than `max_width` overflows on its own line rather than being
|
||||
/// hyphenated. Routes through the font override when one is set so
|
||||
/// measurement and rendering agree on advance widths.
|
||||
fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<String>
|
||||
fn wrap_lines( text: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> Vec<String>
|
||||
{
|
||||
if max_width <= 0.0 || text.is_empty()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pixel-level "golden-like" coverage. We deliberately avoid storing reference
|
||||
// PNG bytes: their value depends on fontdue / tiny-skia / theme versions, so a
|
||||
// PNG bytes: their value depends on the rasteriser / theme versions, so a
|
||||
// byte-exact reference would break on every dependency bump. Instead these
|
||||
// tests assert *structural* properties of the rendered buffer (background
|
||||
// colour, transparency, determinism, focus-state divergence) plus a few
|
||||
|
||||
Reference in New Issue
Block a user