// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Wrapped paragraph text with clickable link ranges — the ltk side of an //! Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Unlike [`Text`] it //! carries a `Msg` per link and the layout pass emits one hit rect per link //! line so taps land on the link, not the whole paragraph. use std::sync::Arc; use fontdue::Font; use crate::theme::FontStyle; use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; use super::{ Element, MapFn }; #[ cfg( test ) ] mod tests; /// A clickable range `[start, end)` (byte offsets into the content) and the /// message to emit when it is tapped. pub struct LinkSpan { pub( crate ) start: usize, pub( crate ) end: usize, pub( crate ) msg: Msg, } /// A wrapped paragraph with clickable link ranges — the ltk counterpart of an /// Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Each [`LinkSpan`] /// pairs a byte range with a `Msg` emitted on tap; the layout pass yields one /// hit rect per link line so taps land on the link rather than the paragraph. pub struct RichText { pub( crate ) content: String, pub( crate ) size: Length, pub( crate ) color: Color, pub( crate ) link_color: Color, pub( crate ) font: Option<( String, u16, FontStyle )>, pub( crate ) links: Vec>, } impl RichText { /// A paragraph of `content` with no links: white text, the default blue /// link colour, default 16 px size and the canvas default font. pub fn new( content: impl Into ) -> Self { Self { content: content.into(), size: Length::px( 16.0 ), color: Color::WHITE, link_color: Color::rgb( 0.20, 0.50, 0.95 ), font: None, links: Vec::new(), } } /// Set the font size. pub fn size( mut self, s: impl Into ) -> Self { self.size = s.into(); self } /// Set the colour of non-link text. pub fn color( mut self, c: Color ) -> Self { self.color = c; self } /// Set the colour of link ranges (drawn underlined). pub fn link_color( mut self, c: Color ) -> Self { self.link_color = c; self } /// Override the font with a `(family, weight, style)` triple resolved /// through the active theme's font registry on draw. pub fn font( mut self, family: impl Into, weight: u16, style: FontStyle ) -> Self { self.font = Some( ( family.into(), weight, style ) ); self } /// Add a clickable range `[start, end)` (byte offsets) emitting `msg`. pub fn link( mut self, start: usize, end: usize, msg: Msg ) -> Self { self.links.push( LinkSpan { start, end, msg } ); self } #[ inline ] fn resolved_size( &self, canvas: &Canvas ) -> f32 { canvas.resolve_font( self.size ) } 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 ), } } pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { let size = self.resolved_size( canvas ); let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size ); let font = self.resolve_font( canvas ); let lines = wrap_tracked( &self.content, size, max_width, canvas, font.as_ref() ); ( max_width, line_h * lines.len().max( 1 ) as f32 ) } pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) { 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 ); let lines = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() ); for ( i, line ) in lines.iter().enumerate() { let ty = rect.y + ascent + line_h * i as f32; 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 ), } 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; match font.as_ref() { Some( f ) => canvas.draw_text_with_font( &sub, tx, ty, size, self.link_color, f ), None => canvas.draw_text( &sub, tx, ty, size, self.link_color ), } let sw = self.measure( &sub, canvas, font.as_ref() ); canvas.draw_line( tx, ty + 2.0, tx + sw, ty + 2.0, self.link_color, 1.0 ); } } } /// Per-link hit rects for the layout pass: one rect per visual line a link /// covers, paired with the link's message. pub fn link_rects( &self, rect: Rect, canvas: &Canvas ) -> Vec<( Rect, Msg )> { 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 ); let lines = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() ); let mut out = Vec::new(); for ( i, line ) in lines.iter().enumerate() { 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 } pub( crate ) fn map_msg( self, f: &MapFn ) -> RichText { RichText { content: self.content, size: self.size, color: self.color, link_color: self.link_color, font: self.font, links: self.links.into_iter().map( |l| LinkSpan { start: l.start, end: l.end, msg: f( l.msg ) } ).collect(), } } } /// One visual row of the wrapped paragraph. `text` is the rendered line (words /// joined by single spaces); `offsets` maps each rendered char to its source /// byte offset in the original content, with a trailing sentinel = line end. struct VisualLine { text: String, offsets: Vec, } impl VisualLine { /// The rendered substring covered by source range `[start, end)`, as /// `( char_prefix_len, substring_byte_len, substring )` — or None when the /// link does not touch this line. `char_prefix_len` is a byte index into /// `self.text` (the rendered chars before the link on this line). fn segment( &self, start: usize, end: usize ) -> Option<( usize, usize, String )> { let mut byte = 0; let mut first: Option = None; let mut last_byte = 0; for ( ci, ch ) in self.text.chars().enumerate() { let src = self.offsets.get( ci ).copied().unwrap_or( usize::MAX ); if src >= start && src < end { if first.is_none() { first = Some( byte ); } last_byte = byte + ch.len_utf8(); } byte += ch.len_utf8(); } let f = first?; Some( ( f, last_byte - f, self.text[f..last_byte].to_string() ) ) } } fn wrap_tracked( content: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc> ) -> Vec { let measure = |s: &str| -> f32 { match font { Some( f ) => canvas.measure_text_with_font( s, size, f ), None => canvas.measure_text( s, size ), } }; // Tokenise into words (byte ranges) and hard breaks (from '\n'). let mut words: Vec<( usize, usize )> = Vec::new(); let mut breaks: Vec = Vec::new(); // word-index after which a hard break sits let mut start: Option = None; for ( b, ch ) in content.char_indices() { if ch == '\n' { if let Some( s ) = start.take() { words.push( ( s, b ) ); } breaks.push( words.len() ); } else if ch.is_whitespace() { if let Some( s ) = start.take() { words.push( ( s, b ) ); } } else if start.is_none() { start = Some( b ); } } if let Some( s ) = start { words.push( ( s, content.len() ) ); } let space_w = measure( " " ); let mut lines: Vec = Vec::new(); let mut cur: Vec<( usize, usize )> = Vec::new(); let mut cur_w = 0.0_f32; for ( wi, &( ws, we ) ) in words.iter().enumerate() { let word_w = measure( &content[ws..we] ); if cur.is_empty() { cur.push( ( ws, we ) ); cur_w = word_w; } else if max_width > 0.0 && cur_w + space_w + word_w > max_width { flush_line( content, &mut cur, &mut lines ); cur.push( ( ws, we ) ); cur_w = word_w; } else { cur.push( ( ws, we ) ); cur_w += space_w + word_w; } if breaks.contains( &( wi + 1 ) ) { flush_line( content, &mut cur, &mut lines ); cur_w = 0.0; } } if !cur.is_empty() { flush_line( content, &mut cur, &mut lines ); } if lines.is_empty() { lines.push( VisualLine { text: String::new(), offsets: vec![ 0 ] } ); } lines } fn flush_line( content: &str, cur: &mut Vec<( usize, usize )>, lines: &mut Vec ) { let mut text = String::new(); let mut offsets = Vec::new(); for ( wi, &( ws, we ) ) in cur.iter().enumerate() { if wi > 0 { offsets.push( ws ); text.push( ' ' ); } for ( b, ch ) in content[ws..we].char_indices() { offsets.push( ws + b ); text.push( ch ); } } offsets.push( cur.last().map( |&( _, we )| we ).unwrap_or( 0 ) ); lines.push( VisualLine { text, offsets } ); cur.clear(); } impl From> for Element { fn from( t: RichText ) -> Self { Element::RichText( t ) } } /// Free-function shorthand for [`RichText::new`]. pub fn rich_text( content: impl Into ) -> RichText { RichText::new( content ) }