// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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 fontdue::Font; 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 )>, } 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 ) -> Self { Self { content: content.into(), size: Length::px( 16.0 ), color: Color::WHITE, align: TextAlign::Left, wrap: false, truncate: true, font: None, } } /// Resolve the font size against the canvas viewport. Internal /// helper: every method that needs an `f32` size for a `fontdue` /// call routes through this so the field can stay a `Length`. #[ inline ] fn resolved_size( &self, canvas: &Canvas ) -> f32 { self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) } /// 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, weight: u16, style: FontStyle ) -> Self { self.font = Some( ( family.into(), weight, style ) ); 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 ) -> 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 } fn resolve_font( &self, canvas: &Canvas ) -> Option> { self.font.as_ref().map( |( family, weight, style )| { canvas.font_for( family, *weight, *style ) } ) } fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> 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> ) -> 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> ) { 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 ); let font = self.resolve_font( canvas ); if self.wrap { let lines = wrap_lines( &self.content, size, max_width, 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 ); let font = self.resolve_font( canvas ); if self.wrap { let lines = wrap_lines( &self.content, size, rect.width, 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 = 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 - 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() ); } } /// 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` 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> ) -> Vec { 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.push_str( word ); current_w = word_w; } else if current_w + space_w + word_w <= max_width { current.push( ' ' ); current.push_str( word ); current_w += space_w + word_w; } else { lines.push( std::mem::take( &mut current ) ); current.push_str( word ); current_w = word_w; } } if !current.is_empty() { lines.push( current ); } if lines.is_empty() { lines.push( String::new() ); } lines } impl From for Element { fn from( t: Text ) -> Self { Element::Text( t ) } }