`Text` and `RichText` could only draw in a theme family, named by `(family, weight, style)` and resolved through the active theme's font registry, or in the canvas default. A host that finds its fonts elsewhere could not use them: rustdroid draws Android's sans-serif, which is Roboto looked up through fontconfig, so that an app's text keeps the widths it was designed with, and those faces are not in any ltk theme. Both widgets gain `face( Arc<FontFace> )`. A face given this way takes precedence over `font` in `resolve_font`, so it is used for drawing and for measuring alike, and `RichText::map` carries it over. Without it nothing changes. .gitignore lists what a Debian package build leaves under debian/: the debhelper directories and logs, the staged package trees of libltk-dev, libltk-doc and ltk-theme-default, the build stamp, debian/files and the substvars.
387 lines
11 KiB
Rust
387 lines
11 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
//! Single- or multi-line text: a font-sized, coloured, aligned string that
|
|
//! either stays on one line (truncating with an ellipsis on overflow) or
|
|
//! word-wraps to the layout width.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::font_face::FontFace;
|
|
|
|
use crate::theme::FontStyle;
|
|
use crate::types::{ Color, Length, Rect };
|
|
use crate::render::Canvas;
|
|
use super::Element;
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests;
|
|
|
|
/// Horizontal alignment of text within its layout rect.
|
|
#[ derive( Debug, Clone, Copy, PartialEq ) ]
|
|
pub enum TextAlign
|
|
{
|
|
/// Align to the left edge.
|
|
Left,
|
|
/// Centre within the rect.
|
|
Center,
|
|
/// Align to the right edge.
|
|
Right,
|
|
}
|
|
|
|
/// A run of text rendered with a size, colour, alignment and optional font,
|
|
/// either word-wrapped or kept on one line with ellipsis truncation.
|
|
pub struct Text
|
|
{
|
|
pub( crate ) content: String,
|
|
/// Font size as a [`Length`]. Resolved against the surface's logical
|
|
/// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales
|
|
/// with the screen instead of being frozen at a px constant.
|
|
pub( crate ) size: Length,
|
|
pub( crate ) color: Color,
|
|
pub( crate ) align: TextAlign,
|
|
pub( crate ) wrap: bool,
|
|
/// When `true` (default), overflowing single-line text is truncated
|
|
/// with an ellipsis. When `false`, the full string is painted even
|
|
/// if it extends past the layout rect — useful for very short
|
|
/// labels (calendar day numbers, day-of-week stubs) where a couple
|
|
/// of pixels of overflow is invisible but "..." is noisy.
|
|
pub( crate ) truncate: bool,
|
|
/// Optional `(family, weight, style)` override resolved through
|
|
/// the active theme's font registry on every draw. `None` keeps
|
|
/// the canvas default font (Sora Regular in `ltk-theme-default`).
|
|
pub( crate ) font: Option<( String, u16, FontStyle )>,
|
|
/// A caller-supplied face that takes precedence over [`Self::font`].
|
|
pub( crate ) face: Option<Arc<FontFace>>,
|
|
/// Multiplier applied to the font's natural line height when wrapping
|
|
/// onto multiple lines. `1.0` (default) uses the font-declared leading;
|
|
/// values above `1.0` open up the gap between wrapped lines.
|
|
pub( crate ) line_height: f32,
|
|
}
|
|
|
|
impl Text
|
|
{
|
|
/// A left-aligned, non-wrapping, ellipsis-truncated white label at the
|
|
/// default 16 px size with the canvas default font.
|
|
pub fn new( content: impl Into<String> ) -> Self
|
|
{
|
|
Self
|
|
{
|
|
content: content.into(),
|
|
size: Length::px( 16.0 ),
|
|
color: Color::WHITE,
|
|
align: TextAlign::Left,
|
|
wrap: false,
|
|
truncate: true,
|
|
font: None,
|
|
face: None,
|
|
line_height: 1.0,
|
|
}
|
|
}
|
|
|
|
/// Resolve the font size against the canvas viewport. Internal
|
|
/// 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
|
|
{
|
|
canvas.resolve_font( self.size )
|
|
}
|
|
|
|
/// Paint the full string even when it overflows, instead of truncating
|
|
/// with an ellipsis.
|
|
pub fn no_truncate( mut self ) -> Self
|
|
{
|
|
self.truncate = false;
|
|
self
|
|
}
|
|
|
|
/// Set a `(family, weight, style)` override for this text node.
|
|
/// The triple is resolved through [`Canvas::font_for`] at draw
|
|
/// time, so missing weights fall through the registry's nearest-
|
|
/// match precedence.
|
|
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
|
|
{
|
|
self.font = Some( ( family.into(), weight, style ) );
|
|
self
|
|
}
|
|
|
|
/// Draw and measure with `face`, a font the caller loaded itself (a host
|
|
/// that resolves its own system families), instead of a theme family.
|
|
pub fn face( mut self, face: Arc<FontFace> ) -> Self
|
|
{
|
|
self.face = Some( face );
|
|
self
|
|
}
|
|
|
|
/// Shorthand for [`Self::font`] when the desired override is just
|
|
/// a weight on the default Sora family (the family `ltk-theme-
|
|
/// default` declares).
|
|
pub fn weight( mut self, weight: u16 ) -> Self
|
|
{
|
|
self.font = Some( ( "sora".to_string(), weight, FontStyle::Normal ) );
|
|
self
|
|
}
|
|
|
|
/// Set the font size.
|
|
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
|
{
|
|
self.size = s.into();
|
|
self
|
|
}
|
|
|
|
/// Set the text colour.
|
|
pub fn color( mut self, c: Color ) -> Self
|
|
{
|
|
self.color = c;
|
|
self
|
|
}
|
|
|
|
/// Set the horizontal alignment.
|
|
pub fn align( mut self, a: TextAlign ) -> Self
|
|
{
|
|
self.align = a;
|
|
self
|
|
}
|
|
|
|
/// Shorthand for [`Self::align`] with [`TextAlign::Center`].
|
|
pub fn align_center( mut self ) -> Self
|
|
{
|
|
self.align = TextAlign::Center;
|
|
self
|
|
}
|
|
|
|
/// Enable word-wrapping. With `wrap = true` the text breaks on
|
|
/// whitespace at `max_width` and the widget reports the natural
|
|
/// height of all the resulting lines. With `wrap = false` (the
|
|
/// default) the text stays on one line and is truncated with an
|
|
/// ellipsis when it overflows.
|
|
pub fn wrap( mut self, w: bool ) -> Self
|
|
{
|
|
self.wrap = w;
|
|
self
|
|
}
|
|
|
|
/// Multiply the natural line height for wrapped text. `1.0` keeps the
|
|
/// font's declared leading; e.g. `1.4` opens the gap between wrapped
|
|
/// lines. Clamped to a `0.5` floor so lines never overlap.
|
|
pub fn line_height( mut self, mult: f32 ) -> Self
|
|
{
|
|
self.line_height = mult.max( 0.5 );
|
|
self
|
|
}
|
|
|
|
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<FontFace>>
|
|
{
|
|
if let Some( face ) = &self.face
|
|
{
|
|
return Some( face.clone() );
|
|
}
|
|
self.font.as_ref().map( |( family, weight, style )|
|
|
{
|
|
canvas.font_for( family, *weight, *style )
|
|
} )
|
|
}
|
|
|
|
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
|
|
{
|
|
let size = self.resolved_size( canvas );
|
|
match font
|
|
{
|
|
Some( f ) => canvas.measure_text_with_font( text, size, f ),
|
|
None => canvas.measure_text( text, size ),
|
|
}
|
|
}
|
|
|
|
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<FontFace>> ) -> f32
|
|
{
|
|
let size = self.resolved_size( canvas );
|
|
match font
|
|
{
|
|
Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width,
|
|
None => canvas.font_metrics( ch, size ).advance_width,
|
|
}
|
|
}
|
|
|
|
fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc<FontFace>> )
|
|
{
|
|
let size = self.resolved_size( canvas );
|
|
match font
|
|
{
|
|
Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ),
|
|
None => canvas.draw_text( text, x, y, size, self.color ),
|
|
}
|
|
}
|
|
|
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
|
{
|
|
let size = self.resolved_size( canvas );
|
|
// `new_line_size = ascent - descent + line_gap` is the standard
|
|
// typographic line height; the previous `ascent - descent` discarded
|
|
// the font-declared leading and at large sizes left adjacent rows
|
|
// visibly overlapping when stacked tight in a column.
|
|
let line_h = canvas.font_line_metrics( size )
|
|
.map( |m| m.new_line_size )
|
|
.unwrap_or( size ) * self.line_height;
|
|
let font = self.resolve_font( canvas );
|
|
|
|
if self.wrap
|
|
{
|
|
let lines = wrap_lines( &self.content, size, max_width - WRAP_PAD, canvas, font.as_ref() );
|
|
let h = line_h * lines.len().max( 1 ) as f32;
|
|
( max_width, h )
|
|
}
|
|
else
|
|
{
|
|
let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width );
|
|
( w, line_h )
|
|
}
|
|
}
|
|
|
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
|
|
{
|
|
let size = self.resolved_size( canvas );
|
|
let ascent = canvas.font_line_metrics( size )
|
|
.map( |m| m.ascent )
|
|
.unwrap_or( size * 0.8 );
|
|
let line_h = canvas.font_line_metrics( size )
|
|
.map( |m| m.new_line_size )
|
|
.unwrap_or( size ) * self.line_height;
|
|
let font = self.resolve_font( canvas );
|
|
|
|
if self.wrap
|
|
{
|
|
let lines = wrap_lines( &self.content, size, rect.width - WRAP_PAD, canvas, font.as_ref() );
|
|
for ( i, line ) in lines.iter().enumerate()
|
|
{
|
|
let line_w = self.measure( line, canvas, font.as_ref() );
|
|
let slack = ( rect.width - line_w ).max( 0.0 );
|
|
let pad = WRAP_PAD.min( slack );
|
|
let tx = match self.align
|
|
{
|
|
TextAlign::Left => rect.x + pad,
|
|
TextAlign::Center => rect.x + slack / 2.0,
|
|
TextAlign::Right => rect.x + rect.width - line_w - pad,
|
|
};
|
|
let ty = rect.y + ascent + line_h * i as f32;
|
|
self.paint( canvas, line, tx, ty, font.as_ref() );
|
|
}
|
|
return;
|
|
}
|
|
|
|
let text_w = self.measure( &self.content, canvas, font.as_ref() );
|
|
|
|
let display = if self.truncate && text_w > rect.width && rect.width > 0.0
|
|
{
|
|
let ellipsis = "...";
|
|
let ell_w = self.measure( ellipsis, canvas, font.as_ref() );
|
|
let budget = rect.width - ell_w;
|
|
if budget <= 0.0
|
|
{
|
|
ellipsis.to_string()
|
|
}
|
|
else
|
|
{
|
|
let mut accum = 0.0_f32;
|
|
let truncated: String = self.content.chars().take_while( |ch|
|
|
{
|
|
let cw = self.measure_char( *ch, canvas, font.as_ref() );
|
|
accum += cw;
|
|
accum <= budget
|
|
} ).collect();
|
|
format!( "{truncated}{ellipsis}" )
|
|
}
|
|
}
|
|
else
|
|
{
|
|
self.content.clone()
|
|
};
|
|
|
|
let disp_w = self.measure( &display, canvas, font.as_ref() );
|
|
let slack = ( rect.width - disp_w ).max( 0.0 );
|
|
let pad = 4.0_f32.min( slack );
|
|
let tx = match self.align
|
|
{
|
|
TextAlign::Left => rect.x + pad,
|
|
TextAlign::Center => rect.x + slack / 2.0,
|
|
TextAlign::Right => rect.x + rect.width - disp_w - pad,
|
|
};
|
|
|
|
let ty = rect.y + ascent;
|
|
self.paint( canvas, &display, tx, ty, font.as_ref() );
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
/// than `max_width` is broken between characters and continues on
|
|
/// the next line. 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<FontFace>> ) -> Vec<String>
|
|
{
|
|
if max_width <= 0.0 || text.is_empty()
|
|
{
|
|
return vec![ text.to_string() ];
|
|
}
|
|
let measure = |s: &str| -> f32
|
|
{
|
|
match font
|
|
{
|
|
Some( f ) => canvas.measure_text_with_font( s, size, f ),
|
|
None => canvas.measure_text( s, size ),
|
|
}
|
|
};
|
|
let space_w = measure( " " );
|
|
let mut lines = Vec::new();
|
|
let mut current = String::new();
|
|
let mut current_w = 0.0_f32;
|
|
for word in text.split_whitespace()
|
|
{
|
|
let word_w = measure( word );
|
|
if !current.is_empty() && current_w + space_w + word_w <= max_width
|
|
{
|
|
current.push( ' ' );
|
|
current.push_str( word );
|
|
current_w += space_w + word_w;
|
|
continue;
|
|
}
|
|
if !current.is_empty()
|
|
{
|
|
lines.push( std::mem::take( &mut current ) );
|
|
}
|
|
if word_w <= max_width
|
|
{
|
|
current.push_str( word );
|
|
current_w = word_w;
|
|
continue;
|
|
}
|
|
let mut start = 0;
|
|
for ( i, ch ) in word.char_indices().skip( 1 )
|
|
{
|
|
if measure( &word[ start..i + ch.len_utf8() ] ) > max_width
|
|
{
|
|
lines.push( word[ start..i ].to_string() );
|
|
start = i;
|
|
}
|
|
}
|
|
current.push_str( &word[ start.. ] );
|
|
current_w = measure( ¤t );
|
|
}
|
|
if !current.is_empty() { lines.push( current ); }
|
|
if lines.is_empty() { lines.push( String::new() ); }
|
|
lines
|
|
}
|
|
|
|
impl<Msg: Clone + 'static> From<Text> for Element<Msg>
|
|
{
|
|
fn from( t: Text ) -> Self
|
|
{
|
|
Element::Text( t )
|
|
}
|
|
}
|