Text measuring for hosts, colour emoji, caller-supplied faces, aligned rich text and a wheel hook in detents
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

A host that lays text out with its own measurement (rustdroid sizes Android views from it before ltk draws them) needs ltk to measure exactly as it renders. Several gaps made the two disagree; this closes them and adds what such a host needs from the pointer and the paragraph widgets.
Shaping. `shape_line` shaped the first bidi paragraph only, so any text after a line break measured zero width; it now shapes every paragraph. Shaped glyphs keep the cluster rustybuzz reports (`ShapedGlyph::cluster`, and `PositionedGlyph::cluster` as a byte offset into the line), which `caret_offsets` uses to give one x per char boundary, splitting a cluster's advance evenly across its chars. `caret_offsets`, `caret_offsets_with_font` (the same with a given face leading the resolver) and `line_metrics` are exported. The per-codepoint resolver shared by `measure_text` and the new functions is `default_resolver`: primary font, then the system fallback chain, then the primary again for `.notdef`, the same choice the renderers make.
Caller-supplied faces. `draw_text_with_font` and `measure_text_with_font` built a `FontHandle` with empty bytes for a face that was not the canvas default, so HarfBuzz could not shape it and the text silently fell back to the system chain. `FontFace` now exposes the file it was parsed from (`data`, `face_index`) and `FontHandle::of` builds a complete handle from a face; both renderers use it. The GLES renderer also registers the preferred face in its glyph font list so its glyphs are rasterised.
Colour emoji. `FontFace::color_glyph` decodes, once per glyph, the PNG strike a CBDT or sbix font draws a glyph with, as a `ColorGlyph` (straight RGBA plus its box in ems). Both renderers draw such glyphs as images at the pen position, scaled by the text size and faded by the text colour's alpha, instead of rasterising an empty outline. NotoColorEmoji joins the fallback chain after DejaVu Sans. `ColorGlyph` is exported.
Rich text alignment. `RichText` gains `align( TextAlign )`, applied per wrapped line; link hit rects follow the aligned line, so a centred paragraph with links stays tappable where it is drawn.
Wrap pad. A wrapping `Text` wraps its lines to the rect less 4 px and insets a left-aligned line by it. The constant is now public as `ltk::TEXT_WRAP_PAD`, documented as the extra width a host must add when it sizes a label's rect from its own measurement; without it a label sized to its text wrapped its last word.
Wheel. `App::on_pointer_wheel( x, y, notches_x, notches_y ) -> bool` is called before `on_pointer_axis` for a scroll outside any ltk viewport that carries discrete detents (`value120`, or `discrete` on older compositors), with the number of detents per axis, fractional on high-resolution wheels. Returning `true` consumes the event; the default `false` keeps the previous behaviour, where `on_pointer_axis` receives the raw axis value scaled by 10 for wheels. A host that maps detents to its own unit (Android's AXIS_VSCROLL is one unit per detent) no longer has to guess the compositor's axis scale.
debian/changelog gains the 0.3.0-4 entry describing the shaping, caret and caller-supplied face changes.
This commit is contained in:
2026-10-08 22:03:10 +02:00
parent 4a6ec797a2
commit 0d492f7c1d
11 changed files with 322 additions and 103 deletions
+9
View File
@@ -1,3 +1,12 @@
ltk (0.3.0-4) unstable; urgency=low
* Text shaping keeps each glyph's cluster; caret_offsets and line_metrics are exported so hosts can measure per character and per line with the renderer's own metrics.
* shape_line shapes every bidi paragraph instead of the first one only: text after a line break no longer measures zero width.
* Canvas::draw_text_with_font and measure_text_with_font use the face they are given: a caller-supplied FontFace is shaped with its own bytes (FontFace::data, face_index, FontHandle::of) and rasterised, instead of silently falling back to the system chain.
* caret_offsets_with_font: per-character advances with a given face leading the resolver.
-- Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net> Thu, 08 Oct 2026 00:00:00 +0200
ltk (0.3.0-3) unstable; urgency=low
* Focus requests reach widgets clipped by a scroll viewport: the runtime moves the viewport to reveal the target and waits a bounded number of frames instead of redrawing indefinitely.
+7
View File
@@ -905,6 +905,13 @@ pub trait App: 'static
/// the hook returns, so state mutated here shows immediately.
fn on_pointer_axis( &mut self, _x: f32, _y: f32, _dx: f32, _dy: f32 ) {}
/// Called before [`Self::on_pointer_axis`] for a scroll event from a
/// wheel or any device that reports discrete detents, with the number
/// of detents on each axis (fractional on high-resolution wheels;
/// positive is right / down). Return `true` to consume it; `false`, the
/// default, lets the same event reach `on_pointer_axis` in raw units.
fn on_pointer_wheel( &mut self, _x: f32, _y: f32, _notches_x: f32, _notches_y: f32 ) -> bool { false }
/// Raw multi-touch callbacks. Default: no-op.
///
/// The first finger to land becomes the *primary slot* and is fed
+58 -1
View File
@@ -25,9 +25,23 @@
use std::collections::HashMap;
use std::sync::{ Arc, Mutex };
use rustybuzz::ttf_parser::{ Face, GlyphId, OutlineBuilder };
use rustybuzz::ttf_parser::{ Face, GlyphId, OutlineBuilder, RasterImageFormat };
use tiny_skia::{ FillRule, Mask, Path, PathBuilder, Transform };
/// A glyph drawn as a colour image rather than an outline. The box is in ems, from the pen on the baseline, y up: scale it by the text size in pixels.
#[ derive( Debug ) ]
pub struct ColorGlyph
{
/// Straight RGBA, row-major from the top-left corner.
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
pub left: f32,
pub bottom: f32,
pub em_width: f32,
pub em_height: f32,
}
/// Bounding box of a glyph's outline, in subpixels, `ymin` upwards from
/// the baseline.
#[ derive( Copy, Clone, PartialEq, Debug, Default ) ]
@@ -115,6 +129,7 @@ pub struct FontFace
line: LineMetrics,
outlines: Mutex<HashMap<u16, Arc<Outline>>>,
indices: Mutex<HashMap<char, u16>>,
colors: Mutex<HashMap<u16, Option<Arc<ColorGlyph>>>>,
}
/// The glyph caches are left out: formatting must not block on them.
@@ -163,6 +178,7 @@ impl FontFace
line,
outlines: Mutex::new( HashMap::new() ),
indices: Mutex::new( HashMap::new() ),
colors: Mutex::new( HashMap::new() ),
} )
}
@@ -171,6 +187,18 @@ impl FontFace
self.units_per_em
}
/// The font file the face was parsed from; HarfBuzz shapes against it.
pub fn data( &self ) -> &Arc<Vec<u8>>
{
&self.data
}
/// The face's index in its file (0 unless the file is a collection).
pub fn face_index( &self ) -> u32
{
self.index
}
pub fn scale_factor( &self, px: f32 ) -> f32
{
px / self.units_per_em
@@ -240,6 +268,35 @@ impl FontFace
( metrics, mask.take() )
}
/// The colour image glyph `id` is drawn with instead of an outline (an emoji in a CBDT or sbix font), decoded once from the face's largest strike.
pub fn color_glyph( &self, id: u16 ) -> Option<Arc<ColorGlyph>>
{
if let Some( hit ) = self.colors.lock().ok().and_then( |c| c.get( &id ).cloned() ) { return hit; }
let glyph = self.decode_color_glyph( id ).map( Arc::new );
if let Ok( mut c ) = self.colors.lock() { c.insert( id, glyph.clone() ); }
glyph
}
fn decode_color_glyph( &self, id: u16 ) -> Option<ColorGlyph>
{
let face = self.face()?;
let raster = face.glyph_raster_image( GlyphId( id ), u16::MAX )?;
if !matches!( raster.format, RasterImageFormat::PNG ) || raster.pixels_per_em == 0 { return None; }
let decoded = image::load_from_memory_with_format( raster.data, image::ImageFormat::Png ).ok()?.to_rgba8();
let ( width, height ) = decoded.dimensions();
let ppem = raster.pixels_per_em as f32;
Some( ColorGlyph
{
rgba: decoded.into_raw(),
width,
height,
left: raster.x as f32 / ppem,
bottom: raster.y as f32 / ppem,
em_width: width as f32 / ppem,
em_height: height as f32 / ppem,
} )
}
fn face( &self ) -> Option<Face<'_>>
{
Face::parse( self.data.as_slice(), self.index ).ok()
+27 -17
View File
@@ -21,7 +21,7 @@ use std::sync::Arc;
use crate::font_face::FontFace;
use glow::HasContext;
use crate::types::Color;
use crate::types::{ Color, Rect };
use super::{ ATLAS_SIZE, GlesCanvas, GlyphEntry };
@@ -78,16 +78,7 @@ impl GlesCanvas
}
else
{
// Caller-supplied face without bytes — leave bytes
// empty so the resolve function below knows it
// cannot be shaped through HarfBuzz and must fall
// back to the system chain.
crate::system_fonts::FontHandle
{
font: f,
bytes: Arc::new( Vec::new() ),
face: 0,
}
crate::system_fonts::FontHandle::of( f )
}
} ).unwrap_or_else( || canvas_handle.clone() );
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
@@ -115,6 +106,10 @@ impl GlesCanvas
{
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
}
if !Arc::ptr_eq( &prefer_handle.font, &canvas_handle.font )
{
fonts.push( ( font_id( &prefer_handle.font ), Arc::clone( &prefer_handle.font ) ) );
}
for g in shaped.iter()
{
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
@@ -231,11 +226,26 @@ impl GlesCanvas
let surface_w = self.width as f32;
let surface_h = self.height as f32;
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
let mut color_glyphs: Vec<( Arc<crate::font_face::ColorGlyph>, Rect )> = Vec::new();
let mut cx = x;
for g in shaped.iter()
{
let glyph_id = g.glyph_id as u16;
let key = ( glyph_id, size_key, g.font_id );
let color_glyph = fonts.iter().find( |( id, _ )| *id == g.font_id ).and_then( |( _, f )| f.color_glyph( glyph_id ) );
if let Some( image ) = color_glyph
{
let rect = Rect
{
x: cx + g.x_offset + image.left * scaled,
y: y - g.y_offset - ( image.bottom + image.em_height ) * scaled,
width: image.em_width * scaled,
height: image.em_height * scaled,
};
color_glyphs.push( ( image, rect ) );
cx += g.x_advance;
continue;
}
let Some( entry ) = self.glyph_cache.get( &key ) else
{
cx += g.x_advance;
@@ -293,6 +303,11 @@ impl GlesCanvas
}
unsafe { self.gl.bind_texture( glow::TEXTURE_2D, None ); }
for ( image, rect ) in color_glyphs
{
self.draw_image_data( &image.rgba, image.width, image.height, rect, color.a );
}
}
/// Advance width of one shaped line of `text` in surface px, using the canvas
@@ -322,12 +337,7 @@ impl GlesCanvas
}
else
{
crate::system_fonts::FontHandle
{
font: f,
bytes: Arc::new( Vec::new() ),
face: 0,
}
crate::system_fonts::FontHandle::of( f )
}
} ).unwrap_or_else( || canvas_handle.clone() );
let resolve = |ch: char| -> Option<crate::system_fonts::FontHandle>
+13 -3
View File
@@ -79,9 +79,18 @@ impl<A: App> AppData<A>
ss.request_redraw();
} else {
// No LTK scroll viewport under the cursor —
// surface the raw axis to the app so embedded
// content (e.g. a WPE view) can handle scrolling
// itself.
// surface the event to the app so embedded content
// (e.g. a WPE view) can handle scrolling itself:
// discrete detents first, then the raw axis.
let notches_x = if horizontal.value120 != 0
{
horizontal.value120 as f32 / 120.0
} else {
horizontal.discrete as f32
};
let consumed = ( notches != 0.0 || notches_x != 0.0 ) && self.app.on_pointer_wheel( pos.x, pos.y, notches_x, notches );
if !consumed
{
let multiplier = match source
{
Some( wl_pointer::AxisSource::Wheel ) => 10.0,
@@ -90,6 +99,7 @@ impl<A: App> AppData<A>
let dx = horizontal.absolute as f32 * multiplier;
let dy = vertical.absolute as f32 * multiplier;
self.app.on_pointer_axis( pos.x, pos.y, dx, dy );
}
// The hook may mutate view-driving state (a wheel-stepped
// carousel, an embedder-scrolled canvas), so rebuild and
// repaint like the in-viewport branch above does.
+3 -3
View File
@@ -346,7 +346,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 font_face::{ ColorGlyph, FontFace, LineMetrics, Metrics, OutlineBounds };
pub use osk::Purpose as InputPurpose;
pub use wallpaper::{ WallpaperBundle, ImageData };
pub use nav_stack::NavStack;
@@ -360,13 +360,13 @@ pub use types::{ widget_scaling, set_widget_scaling };
pub use types::{ Orientation, orientation, viewport_size, set_viewport_size };
pub use types::{ output_logical_size, set_output_logical_size };
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
pub use text_shaping::measure_text;
pub use text_shaping::{ caret_offsets, caret_offsets_with_font, line_metrics, measure_text };
pub use widget::rich_text::{ rich_text, RichText, LinkSpan };
pub use widget::button::ButtonVariant;
pub use widget::slider::{ Slider, slider, SliderAxis };
pub use widget::vslider::{ VSlider, vslider };
pub use widget::pattern_grid::{ PatternGrid, pattern_grid };
pub use widget::text::TextAlign;
pub use widget::text::{ TextAlign, WRAP_PAD as TEXT_WRAP_PAD };
pub use widget::toggle::{ Toggle, toggle };
pub use widget::separator::{ Separator, separator };
pub use widget::progress_bar::{ ProgressBar, progress_bar };
+22 -13
View File
@@ -14,7 +14,7 @@ use std::sync::Arc;
use crate::font_face::FontFace;
use crate::types::Color;
use crate::types::{ Color, Rect };
use super::{ GlyphEntry, GlyphKey, SoftwareCanvas };
@@ -90,12 +90,7 @@ impl SoftwareCanvas
match font
{
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
Some( f ) => crate::system_fonts::FontHandle
{
font: Arc::clone( f ),
bytes: Arc::new( Vec::new() ),
face: 0,
},
Some( f ) => crate::system_fonts::FontHandle::of( Arc::clone( f ) ),
None => h,
}
};
@@ -155,6 +150,7 @@ impl SoftwareCanvas
}
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
let mut color_glyphs: Vec<( Arc<crate::font_face::ColorGlyph>, Rect )> = Vec::new();
{
let mut cursor_x = x;
for g in shaped.iter()
@@ -165,6 +161,19 @@ impl SoftwareCanvas
continue;
};
let glyph_id = g.glyph_id as u16;
if let Some( image ) = font_arc.color_glyph( glyph_id )
{
let rect = Rect
{
x: cursor_x + g.x_offset + image.left * scaled,
y: y - g.y_offset - ( image.bottom + image.em_height ) * scaled,
width: image.em_width * scaled,
height: image.em_height * scaled,
};
color_glyphs.push( ( image, rect ) );
cursor_x += g.x_advance;
continue;
}
let _ = self.rasterize_indexed_cached( font_arc, glyph_id, scaled, g.font_id );
let key = GlyphKey { glyph_id, size_bits: scaled.to_bits(), font_id: g.font_id };
layout.push( ( key, cursor_x + g.x_offset, g.y_offset ) );
@@ -217,6 +226,11 @@ impl SoftwareCanvas
pixels[idx + 3] = ( ( a + a_dst * inv ) * 255.0 ) as u8;
}
}
for ( image, rect ) in color_glyphs
{
self.draw_image_data( &image.rgba, image.width, image.height, rect, color.a );
}
}
pub fn measure_text( &self, text: &str, size: f32 ) -> f32
@@ -238,12 +252,7 @@ impl SoftwareCanvas
match font
{
Some( f ) if Arc::ptr_eq( f, &h.font ) => h,
Some( f ) => crate::system_fonts::FontHandle
{
font: Arc::clone( f ),
bytes: Arc::new( Vec::new() ),
face: 0,
},
Some( f ) => crate::system_fonts::FontHandle::of( Arc::clone( f ) ),
None => h,
}
};
+15
View File
@@ -44,6 +44,17 @@ pub struct FontHandle
pub face: u32,
}
impl FontHandle
{
/// The handle of a face, with the bytes it was parsed from.
pub fn of( font: Arc<FontFace> ) -> Self
{
let bytes = Arc::clone( font.data() );
let face = font.face_index();
Self { font, bytes, face }
}
}
/// Per-script fallback font path with the TrueType-collection face
/// index to load (most files are single-face → 0; CJK `.ttc` archives
/// carry many faces, see the SC face on the canonical Adobe-built
@@ -80,6 +91,10 @@ const FALLBACK_FONT_CANDIDATES: &[ FallbackFontSpec ] =
// symbol, arrow and box-drawing blocks. Ships almost everywhere.
FallbackFontSpec { path: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", face: 0 },
// Colour emoji, flags included; its glyphs are bitmaps (FontFace::color_glyph).
FallbackFontSpec { path: "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", face: 0 },
FallbackFontSpec { path: "/usr/share/fonts/noto/NotoColorEmoji.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
+97 -21
View File
@@ -22,15 +22,14 @@
//! splitting.
/// Single shaped glyph returned by [`shape_run`]. Horizontal-only —
/// vertical-script metrics (`y_advance`) and the source-string
/// `cluster` index that rustybuzz also returns are dropped because
/// no caller in the crate consumes them; add them back when a
/// caller actually needs vertical layout or text-edit
/// cluster-aware caret navigation.
/// vertical-script metrics (`y_advance`) are dropped because no
/// caller in the crate consumes them.
pub struct ShapedGlyph
{
/// Glyph index in the font (not a Unicode codepoint).
pub glyph_id: u32,
/// Byte offset, within the shaped run, of the cluster this glyph belongs to.
pub cluster: u32,
/// Horizontal advance in 26.6 fixed-point font units, already scaled
/// to the requested pixel size.
pub x_advance: f32,
@@ -48,6 +47,8 @@ pub struct PositionedGlyph
{
pub glyph_id: u32,
pub font_id: usize,
/// Byte offset, within the shaped line, of the cluster this glyph belongs to.
pub cluster: usize,
pub x_advance: f32,
pub x_offset: f32,
pub y_offset: f32,
@@ -71,14 +72,14 @@ where
{
if text.is_empty() { return vec![]; }
let bidi = unicode_bidi::BidiInfo::new( text, None );
if bidi.paragraphs.is_empty() { return vec![]; }
let para = &bidi.paragraphs[0];
let ( levels, runs ) = bidi.visual_runs( para, para.range.clone() );
let mut out: Vec<PositionedGlyph> = Vec::with_capacity( text.chars().count() );
for para in &bidi.paragraphs
{
let ( levels, runs ) = bidi.visual_runs( para, para.range.clone() );
for run in runs
{
let is_rtl = levels.get( run.start ).map( |l| l.is_rtl() ).unwrap_or( false );
let run_start = run.start;
let run_text = &text[ run ];
// Split the BiDi run into sub-runs that share a single font.
// `resolve_font` is asked once per codepoint; consecutive
@@ -86,7 +87,8 @@ where
// shaped together.
let mut current_handle: Option<crate::system_fonts::FontHandle> = None;
let mut sub_run = String::new();
let flush = | handle_opt: &Option<crate::system_fonts::FontHandle>, sub_run: &mut String, out: &mut Vec<PositionedGlyph> |
let mut sub_start = run_start;
let flush = | handle_opt: &Option<crate::system_fonts::FontHandle>, sub_run: &mut String, sub_start: usize, out: &mut Vec<PositionedGlyph> |
{
if sub_run.is_empty() { return; }
let Some( h ) = handle_opt.as_ref() else { sub_run.clear(); return };
@@ -98,6 +100,7 @@ where
{
glyph_id: g.glyph_id,
font_id,
cluster: sub_start + g.cluster as usize,
x_advance: g.x_advance,
x_offset: g.x_offset,
y_offset: g.y_offset,
@@ -105,7 +108,7 @@ where
}
sub_run.clear();
};
for ch in run_text.chars()
for ( idx, ch ) in run_text.char_indices()
{
let handle = resolve_font( ch );
let same = match ( &current_handle, &handle )
@@ -116,12 +119,17 @@ where
};
if !same
{
flush( &current_handle, &mut sub_run, &mut out );
flush( &current_handle, &mut sub_run, sub_start, &mut out );
current_handle = handle.clone();
}
if sub_run.is_empty()
{
sub_start = run_start + idx;
}
sub_run.push( ch );
}
flush( &current_handle, &mut sub_run, &mut out );
flush( &current_handle, &mut sub_run, sub_start, &mut out );
}
}
out
}
@@ -157,20 +165,87 @@ pub fn measure_text( text: &str, size: f32 ) -> ( f32, f32 )
{
return ( 0.0, line_h );
}
let resolver = primary.clone();
let glyphs = shape_line( text, size, move | ch |
let glyphs = shape_line( text, size, default_resolver( primary ) );
let width: f32 = glyphs.iter().map( | g | g.x_advance ).sum();
( width, line_h )
}
/// Caret positions for a single line of `text` at `size` px with the default
/// UI font: one x per char boundary in logical order (`chars + 1` values,
/// starting at 0). A cluster's advance is split evenly across its chars.
pub fn caret_offsets( text: &str, size: f32 ) -> Vec<f32>
{
if resolver.font.lookup_glyph_index( ch ) != 0
caret_offsets_for( text, size, crate::system_fonts::primary_handle() )
}
/// [`caret_offsets`] with `font` leading the resolver, as [`crate::Canvas::draw_text_with_font`] draws it.
pub fn caret_offsets_with_font( text: &str, size: f32, font: &std::sync::Arc<crate::font_face::FontFace> ) -> Vec<f32>
{
Some( resolver.clone() )
caret_offsets_for( text, size, crate::system_fonts::FontHandle::of( std::sync::Arc::clone( font ) ) )
}
fn caret_offsets_for( text: &str, size: f32, lead: crate::system_fonts::FontHandle ) -> Vec<f32>
{
let boundaries: Vec<usize> = text.char_indices().map( | ( i, _ ) | i ).collect();
let mut out = vec![ 0.0; boundaries.len() + 1 ];
if text.is_empty()
{
return out;
}
let mut cluster_advance: std::collections::BTreeMap<usize, f32> = std::collections::BTreeMap::new();
for g in shape_line( text, size, default_resolver( lead ) )
{
*cluster_advance.entry( g.cluster ).or_insert( 0.0 ) += g.x_advance;
}
let mut char_advance = vec![ 0.0f32; boundaries.len() ];
let starts: Vec<usize> = cluster_advance.keys().copied().collect();
for ( n, start ) in starts.iter().enumerate()
{
let end = starts.get( n + 1 ).copied().unwrap_or( text.len() );
let first = boundaries.partition_point( | &b | b < *start );
let last = boundaries.partition_point( | &b | b < end );
let count = ( last - first ).max( 1 );
let share = cluster_advance[ start ] / count as f32;
for a in char_advance.iter_mut().take( last ).skip( first )
{
*a = share;
}
}
for i in 0..char_advance.len()
{
out[ i + 1 ] = out[ i ] + char_advance[ i ];
}
out
}
/// Vertical metrics of the default UI font at `size` px.
pub fn line_metrics( size: f32 ) -> crate::font_face::LineMetrics
{
let primary = crate::system_fonts::primary_handle();
primary.font.horizontal_line_metrics( size ).unwrap_or( crate::font_face::LineMetrics
{
ascent: size * 0.8,
descent: -size * 0.2,
line_gap: size * 0.3,
new_line_size: size * 1.3,
} )
}
/// Primary font first, then the system fallback chain, then the primary again
/// (which paints `.notdef`): the same per-codepoint choice the renderers make.
fn default_resolver( primary: crate::system_fonts::FontHandle ) -> impl FnMut( char ) -> Option<crate::system_fonts::FontHandle>
{
move | ch |
{
if primary.font.lookup_glyph_index( ch ) != 0
{
Some( primary.clone() )
}
else
{
crate::system_fonts::lookup_handle( ch ).or_else( || Some( resolver.clone() ) )
crate::system_fonts::lookup_handle( ch ).or_else( || Some( primary.clone() ) )
}
}
} );
let width: f32 = glyphs.iter().map( | g | g.x_advance ).sum();
( width, line_h )
}
/// Cached wrapper over [`shape_line`]. `font_ctx` identifies the resolver's
@@ -241,6 +316,7 @@ pub fn shape_run( text: &str, font_bytes: &[u8], face_index: u32, px: f32, rtl:
ShapedGlyph
{
glyph_id: info.glyph_id,
cluster: info.cluster,
x_advance: p.x_advance as f32 * scale,
x_offset: p.x_offset as f32 * scale,
y_offset: p.y_offset as f32 * scale,
+27 -4
View File
@@ -13,6 +13,7 @@ use crate::font_face::FontFace;
use crate::theme::FontStyle;
use crate::types::{ Color, Length, Rect };
use crate::render::Canvas;
use super::text::TextAlign;
use super::{ Element, MapFn };
#[ cfg( test ) ]
@@ -38,6 +39,7 @@ pub struct RichText<Msg: Clone>
pub( crate ) color: Color,
pub( crate ) link_color: Color,
pub( crate ) font: Option<( String, u16, FontStyle )>,
pub( crate ) align: TextAlign,
pub( crate ) links: Vec<LinkSpan<Msg>>,
}
@@ -54,10 +56,28 @@ impl<Msg: Clone> RichText<Msg>
color: Color::WHITE,
link_color: Color::rgb( 0.20, 0.50, 0.95 ),
font: None,
align: TextAlign::Left,
links: Vec::new(),
}
}
/// Set the horizontal alignment of each line within the rect.
pub fn align( mut self, a: TextAlign ) -> Self
{
self.align = a;
self
}
fn line_x( &self, rect: Rect, line_w: f32 ) -> f32
{
match self.align
{
TextAlign::Left => rect.x,
TextAlign::Center => rect.x + ( ( rect.width - line_w ) / 2.0 ).max( 0.0 ),
TextAlign::Right => rect.x + ( rect.width - line_w ).max( 0.0 ),
}
}
/// Set the font size.
pub fn size( mut self, s: impl Into<Length> ) -> Self
{
@@ -135,17 +155,18 @@ impl<Msg: Clone> RichText<Msg>
for ( i, line ) in lines.iter().enumerate()
{
let ty = rect.y + ascent + line_h * i as f32;
let lx = self.line_x( rect, self.measure( &line.text, canvas, font.as_ref() ) );
match font.as_ref()
{
Some( f ) => canvas.draw_text_with_font( &line.text, rect.x, ty, size, self.color, f ),
None => canvas.draw_text( &line.text, rect.x, ty, size, self.color ),
Some( f ) => canvas.draw_text_with_font( &line.text, lx, ty, size, self.color, f ),
None => canvas.draw_text( &line.text, lx, ty, size, self.color ),
}
for link in &self.links
{
let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
let tx = rect.x + dx;
let tx = lx + dx;
match font.as_ref()
{
Some( f ) => canvas.draw_text_with_font( &sub, tx, ty, size, self.link_color, f ),
@@ -170,13 +191,14 @@ impl<Msg: Clone> RichText<Msg>
let mut out = Vec::new();
for ( i, line ) in lines.iter().enumerate()
{
let lx = self.line_x( rect, self.measure( &line.text, canvas, font.as_ref() ) );
for link in &self.links
{
let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
let sw = self.measure( &sub, canvas, font.as_ref() );
let y = rect.y + line_h * i as f32;
out.push( ( Rect { x: rect.x + dx, y, width: sw, height: ascent.max( line_h ) }, link.msg.clone() ) );
out.push( ( Rect { x: lx + dx, y, width: sw, height: ascent.max( line_h ) }, link.msg.clone() ) );
}
}
out
@@ -191,6 +213,7 @@ impl<Msg: Clone> RichText<Msg>
color: self.color,
link_color: self.link_color,
font: self.font,
align: self.align,
links: self.links.into_iter().map( |l| LinkSpan { start: l.start, end: l.end, msg: f( l.msg ) } ).collect(),
}
}
+4 -1
View File
@@ -297,7 +297,10 @@ impl Text
}
}
const WRAP_PAD: f32 = 4.0;
/// Extra width a wrapping [`Text`] needs beyond its widest line: the lines
/// wrap to the rect less this much, and a left-aligned line is inset by it.
/// A host that sizes rects from its own measurement must add it.
pub const WRAP_PAD: f32 = 4.0;
/// Greedy word-wrap: split `text` on whitespace and pack words into
/// lines whose total width stays under `max_width`. A word longer