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`.
419 lines
12 KiB
Rust
419 lines
12 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
use crate::render::Canvas;
|
|
use crate::types::{ Length, Rect };
|
|
use crate::widget::Element;
|
|
|
|
/// A grid layout that wraps children into rows of a fixed column count.
|
|
///
|
|
/// All cells in a row share the same height (the tallest item in that row).
|
|
/// Column widths are equal, dividing the available width minus padding and spacing.
|
|
///
|
|
/// Designed for app-drawer style layouts — combine with [`scroll()`](crate::widget::scroll::scroll)
|
|
/// for vertically scrollable grids:
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use std::sync::Arc;
|
|
/// # use ltk::{ grid, icon_button, scroll, Element };
|
|
/// # #[ derive( Clone ) ] enum Msg { Open( usize ) }
|
|
/// # fn _ex( data: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
|
|
/// scroll(
|
|
/// grid( 4 )
|
|
/// .padding( 16.0 )
|
|
/// .spacing( 12.0 )
|
|
/// .push( icon_button( data.clone(), w, h ).on_press( Msg::Open( 0 ) ) )
|
|
/// .push( icon_button( data, w, h ).on_press( Msg::Open( 1 ) ) )
|
|
/// // ...
|
|
/// )
|
|
/// .into()
|
|
/// # }
|
|
/// ```
|
|
pub struct WrapGrid<Msg: Clone>
|
|
{
|
|
/// Child widgets laid out in row-major order.
|
|
pub( crate ) children: Vec<Element<Msg>>,
|
|
/// Number of columns per row.
|
|
pub( crate ) columns: usize,
|
|
/// Horizontal gap between cells.
|
|
pub( crate ) spacing_x: Length,
|
|
/// Vertical gap between rows.
|
|
pub( crate ) spacing_y: Length,
|
|
/// Padding on all sides.
|
|
pub( crate ) padding: Length,
|
|
/// When `true`, a partial last row is centred horizontally within
|
|
/// the grid's content rect instead of being left-aligned.
|
|
pub( crate ) centre_last_row: bool,
|
|
}
|
|
|
|
impl<Msg: Clone> WrapGrid<Msg>
|
|
{
|
|
/// Append a child widget to the grid.
|
|
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
|
|
{
|
|
self.children.push( child.into() );
|
|
self
|
|
}
|
|
|
|
/// Set both horizontal and vertical gap between cells (default 8.0).
|
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
|
{
|
|
let s = s.into();
|
|
self.spacing_x = s;
|
|
self.spacing_y = s;
|
|
self
|
|
}
|
|
|
|
/// Set only the horizontal gap between cells; leaves vertical spacing untouched.
|
|
pub fn spacing_x( mut self, s: impl Into<Length> ) -> Self
|
|
{
|
|
self.spacing_x = s.into();
|
|
self
|
|
}
|
|
|
|
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
|
|
pub fn spacing_y( mut self, s: impl Into<Length> ) -> Self
|
|
{
|
|
self.spacing_y = s.into();
|
|
self
|
|
}
|
|
|
|
/// Set the padding on all sides (default 0.0).
|
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
|
{
|
|
self.padding = p.into();
|
|
self
|
|
}
|
|
|
|
/// Centre a partial last row horizontally inside the content rect.
|
|
/// Default is `false` (left-aligned, like other grids).
|
|
pub fn centre_last_row( mut self, yes: bool ) -> Self
|
|
{
|
|
self.centre_last_row = yes;
|
|
self
|
|
}
|
|
|
|
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
|
|
{
|
|
let vp = canvas.viewport_layout();
|
|
let em = Length::EM_BASE_DEFAULT;
|
|
(
|
|
self.spacing_x.resolve( vp, em ),
|
|
self.spacing_y.resolve( vp, em ),
|
|
self.padding.resolve( vp, em ),
|
|
)
|
|
}
|
|
|
|
/// Compute the preferred size given an available width.
|
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
|
{
|
|
if self.children.is_empty() || self.columns == 0
|
|
{
|
|
return ( max_width, 0.0 );
|
|
}
|
|
let ( sx, sy, pad ) = self.resolved( canvas );
|
|
let cols = self.columns;
|
|
let inner_w = (max_width - pad * 2.0).max( 0.0 );
|
|
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
|
let row_count = (self.children.len() + cols - 1) / cols;
|
|
|
|
let mut total_h = pad * 2.0;
|
|
for row in 0..row_count
|
|
{
|
|
let start = row * cols;
|
|
let end = (start + cols).min( self.children.len() );
|
|
let row_h = self.children[start..end]
|
|
.iter()
|
|
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
|
.fold( 0.0_f32, f32::max );
|
|
total_h += row_h;
|
|
if row + 1 < row_count { total_h += sy; }
|
|
}
|
|
( max_width, total_h )
|
|
}
|
|
|
|
/// Compute child rects. Returns `(child_rect, index_in_children)` pairs.
|
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
|
{
|
|
if self.children.is_empty() || self.columns == 0
|
|
{
|
|
return Vec::new();
|
|
}
|
|
let ( sx, sy, pad ) = self.resolved( canvas );
|
|
let cols = self.columns;
|
|
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
|
|
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
|
|
let x0 = rect.x + pad;
|
|
let mut y = rect.y + pad;
|
|
|
|
let row_count = (self.children.len() + cols - 1) / cols;
|
|
let mut out = Vec::with_capacity( self.children.len() );
|
|
|
|
for row in 0..row_count
|
|
{
|
|
let start = row * cols;
|
|
let end = (start + cols).min( self.children.len() );
|
|
let row_h = self.children[start..end]
|
|
.iter()
|
|
.map( |c| c.preferred_size( cell_w, canvas ).1 )
|
|
.fold( 0.0_f32, f32::max );
|
|
|
|
let items_in_row = end - start;
|
|
let row_offset = if self.centre_last_row && items_in_row < cols
|
|
{
|
|
let missing = (cols - items_in_row) as f32;
|
|
missing * (cell_w + sx) / 2.0
|
|
} else { 0.0 };
|
|
|
|
for col in 0..items_in_row
|
|
{
|
|
let x = x0 + row_offset + col as f32 * (cell_w + sx);
|
|
let crect = Rect { x, y, width: cell_w, height: row_h };
|
|
out.push( ( crect, start + col ) );
|
|
}
|
|
y += row_h + sy;
|
|
}
|
|
out
|
|
}
|
|
|
|
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> WrapGrid<U>
|
|
where
|
|
U: Clone + 'static,
|
|
Msg: 'static,
|
|
{
|
|
WrapGrid
|
|
{
|
|
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
|
|
columns: self.columns,
|
|
spacing_x: self.spacing_x,
|
|
spacing_y: self.spacing_y,
|
|
padding: self.padding,
|
|
centre_last_row: self.centre_last_row,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<Msg: Clone + 'static> From<WrapGrid<Msg>> for Element<Msg>
|
|
{
|
|
fn from( g: WrapGrid<Msg> ) -> Self
|
|
{
|
|
Element::WrapGrid( g )
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
use crate::render::Canvas;
|
|
use crate::layout::spacer::spacer;
|
|
|
|
|
|
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
|
|
|
|
// Helper: build a grid of N spacer children with the given settings.
|
|
fn spacer_grid( cols: usize, n: usize, spacing: f32, padding: f32 ) -> WrapGrid<()>
|
|
{
|
|
let mut g = grid( cols ).spacing( spacing ).padding( padding );
|
|
for _ in 0..n { g = g.push( spacer() ); }
|
|
g
|
|
}
|
|
|
|
// --- preferred_size ---
|
|
|
|
#[test]
|
|
fn empty_grid_height_is_zero()
|
|
{
|
|
let g: WrapGrid<()> = grid( 4 );
|
|
let ( _, h ) = g.preferred_size( 400.0, &canvas() );
|
|
assert_eq!( h, 0.0 );
|
|
}
|
|
|
|
#[test]
|
|
fn preferred_width_equals_max_width()
|
|
{
|
|
let g = spacer_grid( 4, 8, 0.0, 0.0 );
|
|
let ( w, _ ) = g.preferred_size( 320.0, &canvas() );
|
|
assert_eq!( w, 320.0 );
|
|
}
|
|
|
|
// --- layout: cell widths ---
|
|
|
|
#[test]
|
|
fn cell_width_no_spacing_no_padding()
|
|
{
|
|
// 400px / 4 cols = 100px each
|
|
let g = spacer_grid( 4, 4, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert_eq!( rects.len(), 4 );
|
|
for ( r, _ ) in &rects { assert!( (r.width - 100.0).abs() < 0.01 ); }
|
|
}
|
|
|
|
#[test]
|
|
fn cell_width_with_spacing()
|
|
{
|
|
// (400 - 3 * 10) / 4 = 370 / 4 = 92.5
|
|
let g = spacer_grid( 4, 4, 10.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
|
|
let rects = g.layout( rect, &c );
|
|
for ( r, _ ) in &rects { assert!( (r.width - 92.5).abs() < 0.01 ); }
|
|
}
|
|
|
|
#[test]
|
|
fn cell_width_with_padding()
|
|
{
|
|
// inner = 400 - 2*20 = 360; 360 / 4 = 90
|
|
let g = spacer_grid( 4, 4, 0.0, 20.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 };
|
|
let rects = g.layout( rect, &c );
|
|
for ( r, _ ) in &rects { assert!( (r.width - 90.0).abs() < 0.01 ); }
|
|
}
|
|
|
|
// --- layout: child count and indices ---
|
|
|
|
#[test]
|
|
fn layout_yields_one_rect_per_child()
|
|
{
|
|
let g = spacer_grid( 4, 7, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert_eq!( rects.len(), 7 );
|
|
}
|
|
|
|
#[test]
|
|
fn layout_indices_are_sequential()
|
|
{
|
|
let g = spacer_grid( 3, 5, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 300.0 };
|
|
let rects = g.layout( rect, &c );
|
|
let indices: Vec<usize> = rects.iter().map( |( _, i )| *i ).collect();
|
|
assert_eq!( indices, vec![ 0, 1, 2, 3, 4 ] );
|
|
}
|
|
|
|
// --- layout: column x-positions ---
|
|
|
|
#[test]
|
|
fn column_x_positions_no_spacing()
|
|
{
|
|
// 300px / 3 cols = 100px each, starting at x=0
|
|
let g = spacer_grid( 3, 3, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
|
|
let rects = g.layout( rect, &c );
|
|
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
|
|
assert!( (xs[0] - 0.0).abs() < 0.01 );
|
|
assert!( (xs[1] - 100.0).abs() < 0.01 );
|
|
assert!( (xs[2] - 200.0).abs() < 0.01 );
|
|
}
|
|
|
|
#[test]
|
|
fn column_x_positions_with_spacing()
|
|
{
|
|
// (300 - 2*10) / 3 = 280/3 ≈ 93.33; x[0]=0, x[1]=103.33, x[2]=206.67
|
|
let g = spacer_grid( 3, 3, 10.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 300.0, height: 100.0 };
|
|
let rects = g.layout( rect, &c );
|
|
let cell_w = 280.0_f32 / 3.0;
|
|
let xs: Vec<f32> = rects.iter().map( |( r, _ )| r.x ).collect();
|
|
assert!( (xs[0] - 0.0).abs() < 0.01 );
|
|
assert!( (xs[1] - (cell_w + 10.0)).abs() < 0.01 );
|
|
assert!( (xs[2] - (2.0 * (cell_w + 10.0))).abs() < 0.01 );
|
|
}
|
|
|
|
// --- layout: partial last row ---
|
|
|
|
#[test]
|
|
fn partial_last_row_has_correct_count()
|
|
{
|
|
// 7 children, 4 cols => row 0: 4, row 1: 3.
|
|
let g = spacer_grid( 4, 7, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert_eq!( rects.len(), 7 );
|
|
for ( r, _ ) in &rects[..4] { assert!( r.y.abs() < 0.01 ); }
|
|
}
|
|
|
|
// --- layout: rect origin offset ---
|
|
|
|
#[test]
|
|
fn layout_respects_rect_origin()
|
|
{
|
|
let g = spacer_grid( 2, 2, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 50.0, y: 30.0, width: 200.0, height: 100.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
|
|
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
|
|
}
|
|
|
|
// --- layout: centre_last_row ---
|
|
|
|
#[test]
|
|
fn last_row_centred_when_partial()
|
|
{
|
|
// 3 children, 2 cols => row 0: 2 items, row 1: 1 item centred.
|
|
// cell_w = 200/2 = 100; centred-offset = (2-1)*100/2 = 50.
|
|
let g = spacer_grid( 2, 3, 0.0, 0.0 ).centre_last_row( true );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert!( (rects[2].0.x - 50.0).abs() < 0.01 );
|
|
}
|
|
|
|
#[test]
|
|
fn centre_last_row_noop_on_full_row()
|
|
{
|
|
// 4 children, 2 cols => both rows full; nothing to centre.
|
|
let g = spacer_grid( 2, 4, 0.0, 0.0 ).centre_last_row( true );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert!( rects[2].0.x.abs() < 0.01 );
|
|
assert!( (rects[3].0.x - 100.0).abs() < 0.01 );
|
|
}
|
|
|
|
#[test]
|
|
fn centre_last_row_off_by_default()
|
|
{
|
|
// Same case as above but without the flag — last item stays at x=0.
|
|
let g = spacer_grid( 2, 3, 0.0, 0.0 );
|
|
let c = canvas();
|
|
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
|
|
let rects = g.layout( rect, &c );
|
|
assert!( rects[2].0.x.abs() < 0.01 );
|
|
}
|
|
}
|
|
|
|
/// Create a grid layout with the given number of columns.
|
|
///
|
|
/// Use [`.push()`](WrapGrid::push), [`.spacing()`](WrapGrid::spacing), and
|
|
/// [`.padding()`](WrapGrid::padding) to populate and style the grid.
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ltk::{ button, grid, WrapGrid };
|
|
/// # #[ derive( Clone ) ] enum Msg { A }
|
|
/// # fn _ex() -> WrapGrid<Msg> {
|
|
/// grid( 4 ).padding( 16.0 ).spacing( 8.0 ).push( button( "A" ).on_press( Msg::A ) )
|
|
/// # }
|
|
/// ```
|
|
pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
|
|
{
|
|
WrapGrid
|
|
{
|
|
children: Vec::new(),
|
|
columns,
|
|
spacing_x: Length::px( 8.0 ),
|
|
spacing_y: Length::px( 8.0 ),
|
|
padding: Length::px( 0.0 ),
|
|
centre_last_row: false,
|
|
}
|
|
}
|