Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
342 lines
9.5 KiB
Rust
342 lines
9.5 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 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<String> ) -> 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<String>, 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<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
|
|
}
|
|
|
|
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 ),
|
|
}
|
|
}
|
|
|
|
fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc<Font>> ) -> 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<Font>> )
|
|
{
|
|
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<Font>> ) -> 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.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<Msg: Clone + 'static> From<Text> for Element<Msg>
|
|
{
|
|
fn from( t: Text ) -> Self
|
|
{
|
|
Element::Text( t )
|
|
}
|
|
}
|