Files
ltk/src/widget/rich_text/mod.rs
Pedro M. de Echanove Pasquin 806dee5167
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
render, types, ci: resolution-time dp with per-canvas density, bounded GLES image cache, clippy gate, backend capability matrix
Length::dp no longer collapses to absolute pixels at construction: the design value travels in a new LengthBase::Dp variant and the density multiplication happens when the length is resolved. Previously dp( n ) baked in whatever density() returned at view-build time, so correctness across output changes depended on the view being rebuilt after set_density and in that order; now a density change is picked up by the very next paint with no reconstruction. Length::resolve keeps its signature (process density), and the new Length::resolve_with_density takes an explicit factor. dp becomes const in the bargain.
Density also becomes overridable per canvas, the first step towards surface-local responsive state. SoftwareCanvas and GlesCanvas carry a density: Option<f32> analogous to the layout_viewport introduced for sub-canvas fluid resolution: None means "use the process global", Canvas::set_density pins a local factor, and sub-canvases inherit it. All canvas-routed resolution honours it — geom_px / font_px for stock-widget design pixels, and the new Canvas::resolve_geom / resolve_font for explicit Length values, which every widget now uses in place of the raw l.resolve( canvas.viewport_layout(), EM ) pattern (row, column, wrap_grid, spacer, container, separator, button, text, rich_text, text_edit, list_item, vslider, image, and the container draw path). Overlay sizing keeps resolving against the main surface with the global density, which is what it describes. New tests cover explicit-density resolution, resolution-time application, the local-over-global override and sub-canvas inheritance.
The GLES image texture cache is now bounded. It was content-keyed but unbounded and never evicted, so a stream of distinct buffers — a photo carousel, video thumbnails — grew GPU memory for the lifetime of the canvas. The cache now tracks an estimated byte total (RGBA8, w × h × 4) against a 32 MiB budget and evicts least-recently-drawn textures on insert; the most recent entry is never evicted, so a single texture larger than the whole budget still draws and simply owns the cache until replaced. Drop-time cleanup is unchanged: drain deletes whatever the map holds.
CI gains a Clippy step (workspace, all targets, test-support, -D warnings) sharing the build cache of the test job, with make clippy mirroring the invocation locally and CONTRIBUTING listing it. Run make clippy locally before pushing the first time — the gate has not seen the tree yet and pre-existing lints will fail CI until addressed.
docs/backends.md formalises the software/GLES capability matrix that was previously scattered across per-method rustdoc: parity set (fills, strokes, text, images, paths, path clips), graceful degradations on software (flat-fill gradients, no shadows, no backdrop blur, hard bottom edge), GPU-only features (external textures), the shared Oklab-fallback limitation, and the cross-backend blit panic. Linked from README, onboarding and architecture's known-gaps list, which now states the parity gaps explicitly. The dp/density prose in architecture.md, lib.rs and the Length rustdoc is updated for resolution-time semantics and the per-canvas override.
2026-08-01 10:17:00 +02:00

338 lines
9.9 KiB
Rust

// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! 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<Msg>
{
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<Msg: Clone>
{
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<LinkSpan<Msg>>,
}
impl<Msg: Clone> RichText<Msg>
{
/// 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<String> ) -> 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<Length> ) -> 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<String>, 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<Arc<Font>>
{
self.font.as_ref().map( |( family, weight, style )| canvas.font_for( family, *weight, *style ) )
}
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> 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<U: Clone>( self, f: &MapFn<Msg, U> ) -> RichText<U>
{
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<usize>,
}
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<usize> = 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<Font>> ) -> Vec<VisualLine>
{
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<usize> = Vec::new(); // word-index after which a hard break sits
let mut start: Option<usize> = 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<VisualLine> = 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<VisualLine> )
{
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<Msg: Clone + 'static> From<RichText<Msg>> for Element<Msg>
{
fn from( t: RichText<Msg> ) -> Self
{
Element::RichText( t )
}
}
/// Free-function shorthand for [`RichText::new`].
pub fn rich_text<Msg: Clone>( content: impl Into<String> ) -> RichText<Msg>
{
RichText::new( content )
}