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.
338 lines
9.9 KiB
Rust
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 )
|
|
}
|