diff --git a/debian/changelog b/debian/changelog index 59fa06e..a781eb0 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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. diff --git a/src/app.rs b/src/app.rs index 6d80b52..10ebb80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 diff --git a/src/font_face.rs b/src/font_face.rs index 78fb979..fcd02ef 100644 --- a/src/font_face.rs +++ b/src/font_face.rs @@ -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, + 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>>, indices: Mutex>, + colors: Mutex>>>, } /// 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> + { + &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> + { + 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 + { + 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::parse( self.data.as_slice(), self.index ).ok() diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs index 733309e..06648d4 100644 --- a/src/gles_render/text.rs +++ b/src/gles_render/text.rs @@ -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 @@ -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 = Vec::with_capacity( shaped.len() * 24 ); + let mut color_glyphs: Vec<( Arc, 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 diff --git a/src/input/pointer/scroll.rs b/src/input/pointer/scroll.rs index 58c2545..c861b1e 100644 --- a/src/input/pointer/scroll.rs +++ b/src/input/pointer/scroll.rs @@ -79,17 +79,27 @@ impl AppData 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. - let multiplier = match source + // 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 { - Some( wl_pointer::AxisSource::Wheel ) => 10.0, - _ => 1.0, + horizontal.value120 as f32 / 120.0 + } else { + horizontal.discrete as f32 }; - 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 ); + 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, + _ => 1.0, + }; + 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. diff --git a/src/lib.rs b/src/lib.rs index dc4be94..02b754d 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 }; diff --git a/src/render/text.rs b/src/render/text.rs index 705e1af..546a235 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -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, 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, } }; diff --git a/src/system_fonts.rs b/src/system_fonts.rs index cf6faf3..b25b78a 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -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 ) -> 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 diff --git a/src/text_shaping.rs b/src/text_shaping.rs index 2bd4154..c0f3229 100644 --- a/src/text_shaping.rs +++ b/src/text_shaping.rs @@ -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,57 +72,64 @@ 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 = Vec::with_capacity( text.chars().count() ); - for run in runs + for para in &bidi.paragraphs { - let is_rtl = levels.get( run.start ).map( |l| l.is_rtl() ).unwrap_or( false ); - 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 - // codepoints whose handle has the same `font` pointer are - // shaped together. - let mut current_handle: Option = None; - let mut sub_run = String::new(); - let flush = | handle_opt: &Option, sub_run: &mut String, out: &mut Vec | + let ( levels, runs ) = bidi.visual_runs( para, para.range.clone() ); + for run in runs { - if sub_run.is_empty() { return; } - let Some( h ) = handle_opt.as_ref() else { sub_run.clear(); return }; - let glyphs = shape_run( sub_run, &h.bytes, h.face, px, is_rtl ); - let font_id = std::sync::Arc::as_ptr( &h.font ) as usize; - for g in glyphs + 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 + // codepoints whose handle has the same `font` pointer are + // shaped together. + let mut current_handle: Option = None; + let mut sub_run = String::new(); + let mut sub_start = run_start; + let flush = | handle_opt: &Option, sub_run: &mut String, sub_start: usize, out: &mut Vec | { - out.push( PositionedGlyph + if sub_run.is_empty() { return; } + let Some( h ) = handle_opt.as_ref() else { sub_run.clear(); return }; + let glyphs = shape_run( sub_run, &h.bytes, h.face, px, is_rtl ); + let font_id = std::sync::Arc::as_ptr( &h.font ) as usize; + for g in glyphs { - glyph_id: g.glyph_id, - font_id, - x_advance: g.x_advance, - x_offset: g.x_offset, - y_offset: g.y_offset, - } ); - } - sub_run.clear(); - }; - for ch in run_text.chars() - { - let handle = resolve_font( ch ); - let same = match ( ¤t_handle, &handle ) - { - ( Some( a ), Some( b ) ) => std::sync::Arc::ptr_eq( &a.font, &b.font ), - ( None, None ) => true, - _ => false, + out.push( PositionedGlyph + { + 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, + } ); + } + sub_run.clear(); }; - if !same + for ( idx, ch ) in run_text.char_indices() { - flush( ¤t_handle, &mut sub_run, &mut out ); - current_handle = handle.clone(); + let handle = resolve_font( ch ); + let same = match ( ¤t_handle, &handle ) + { + ( Some( a ), Some( b ) ) => std::sync::Arc::ptr_eq( &a.font, &b.font ), + ( None, None ) => true, + _ => false, + }; + if !same + { + flush( ¤t_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 ); } - sub_run.push( ch ); + flush( ¤t_handle, &mut sub_run, sub_start, &mut out ); } - flush( ¤t_handle, &mut sub_run, &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 +{ + 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 ) -> Vec +{ + 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 +{ + let boundaries: Vec = text.char_indices().map( | ( i, _ ) | i ).collect(); + let mut out = vec![ 0.0; boundaries.len() + 1 ]; + if text.is_empty() { - if resolver.font.lookup_glyph_index( ch ) != 0 + return out; + } + let mut cluster_advance: std::collections::BTreeMap = 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 = 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 ) { - Some( resolver.clone() ) + *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 +{ + 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, diff --git a/src/widget/rich_text/mod.rs b/src/widget/rich_text/mod.rs index aa1345c..f438140 100644 --- a/src/widget/rich_text/mod.rs +++ b/src/widget/rich_text/mod.rs @@ -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 pub( crate ) color: Color, pub( crate ) link_color: Color, pub( crate ) font: Option<( String, u16, FontStyle )>, + pub( crate ) align: TextAlign, pub( crate ) links: Vec>, } @@ -54,10 +56,28 @@ impl RichText 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 ) -> Self { @@ -135,17 +155,18 @@ impl RichText 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 RichText 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 RichText 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(), } } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 793c252..8ecde1a 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -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