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.
508 lines
14 KiB
Rust
508 lines
14 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
use crate::types::{ Length, Rect };
|
|
use crate::render::Canvas;
|
|
use crate::widget::Element;
|
|
|
|
/// A vertical layout container.
|
|
///
|
|
/// Children are arranged top-to-bottom with optional spacing and padding.
|
|
/// Spacers absorb remaining vertical space, enabling push-to-bottom layouts.
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ltk::{ button, column, spacer, text, Element };
|
|
/// # #[ derive( Clone ) ] enum Msg { Ok }
|
|
/// # fn _ex() -> Element<Msg> {
|
|
/// column()
|
|
/// .padding( 24.0 )
|
|
/// .spacing( 12.0 )
|
|
/// .push( text( "Title" ) )
|
|
/// .push( spacer() )
|
|
/// .push( button( "OK" ).on_press( Msg::Ok ) )
|
|
/// .into()
|
|
/// # }
|
|
/// ```
|
|
///
|
|
/// `padding`, `spacing` and `max_width` all accept any
|
|
/// [`crate::Length`], so a responsive layout reads as:
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ltk::{ button, column, text, Length, Element };
|
|
/// # #[ derive( Clone ) ] enum Msg { Ok }
|
|
/// # fn _ex() -> Element<Msg> {
|
|
/// column()
|
|
/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px.
|
|
/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) )
|
|
/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) )
|
|
/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) )
|
|
/// .push( text( "Responsive" ) )
|
|
/// .push( button( "OK" ).on_press( Msg::Ok ) )
|
|
/// .into()
|
|
/// # }
|
|
/// ```
|
|
pub struct Column<Msg: Clone>
|
|
{
|
|
pub( crate ) children: Vec<Element<Msg>>,
|
|
/// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)`
|
|
/// or `Em(0.5)` gap scales with the viewport instead of freezing at a
|
|
/// px constant.
|
|
pub( crate ) spacing: Length,
|
|
/// Padding on all sides. Same [`Length`] semantics as `spacing`.
|
|
pub( crate ) padding: Length,
|
|
pub( crate ) align_center_x: bool,
|
|
pub( crate ) center_y: bool,
|
|
pub( crate ) max_width: Option<Length>,
|
|
pub( crate ) fit_content: bool,
|
|
}
|
|
|
|
impl<Msg: Clone> Column<Msg>
|
|
{
|
|
pub fn new() -> Self
|
|
{
|
|
Self
|
|
{
|
|
children: Vec::new(),
|
|
spacing: Length::px( 8.0 ),
|
|
padding: Length::px( 16.0 ),
|
|
align_center_x: true,
|
|
center_y: false,
|
|
max_width: None,
|
|
fit_content: false,
|
|
}
|
|
}
|
|
|
|
/// Append a child widget or layout.
|
|
pub fn push( mut self, e: impl Into<Element<Msg>> ) -> Self
|
|
{
|
|
self.children.push( e.into() );
|
|
self
|
|
}
|
|
|
|
/// Set the vertical gap between children. Default: `8.0` px. Accepts
|
|
/// any [`Length`] — pass an `f32` for the px case, or a relative
|
|
/// value like `Length::vmin( 2.0 )` to scale with the viewport.
|
|
pub fn spacing( mut self, s: impl Into<Length> ) -> Self
|
|
{
|
|
self.spacing = s.into();
|
|
self
|
|
}
|
|
|
|
/// Set the padding (all sides). Default: `16.0` px. Accepts any
|
|
/// [`Length`].
|
|
pub fn padding( mut self, p: impl Into<Length> ) -> Self
|
|
{
|
|
self.padding = p.into();
|
|
self
|
|
}
|
|
|
|
/// When `true` (default), children are centered horizontally.
|
|
pub fn align_center_x( mut self, c: bool ) -> Self
|
|
{
|
|
self.align_center_x = c;
|
|
self
|
|
}
|
|
|
|
/// When `true`, center the content block vertically (only when no spacers are present).
|
|
pub fn center_y( mut self, c: bool ) -> Self
|
|
{
|
|
self.center_y = c;
|
|
self
|
|
}
|
|
|
|
/// Limit the content width. Accepts any [`Length`]. The column still
|
|
/// reports the parent's `max_width` as its preferred width so the
|
|
/// parent allocates the full available rect.
|
|
pub fn max_width( mut self, w: impl Into<Length> ) -> Self
|
|
{
|
|
self.max_width = Some( w.into() );
|
|
self
|
|
}
|
|
|
|
#[ inline ]
|
|
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
|
|
{
|
|
canvas.resolve_geom( self.spacing )
|
|
}
|
|
|
|
#[ inline ]
|
|
fn resolved_padding( &self, canvas: &Canvas ) -> f32
|
|
{
|
|
canvas.resolve_geom( self.padding )
|
|
}
|
|
|
|
#[ inline ]
|
|
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
|
|
{
|
|
self.max_width.map( |l| canvas.resolve_geom( l ) )
|
|
}
|
|
|
|
/// Report the intrinsic content width as preferred width instead of filling
|
|
/// the available `max_width`. Use this when the column represents a card
|
|
/// or widget meant to sit side-by-side with other children inside a
|
|
/// [`Row`](crate::layout::row::Row) — without this flag, two columns in a
|
|
/// row each claim the full row width and overflow their siblings.
|
|
///
|
|
/// The preferred width is computed as the max of children's preferred
|
|
/// widths plus padding, capped by the external `max_width` the parent
|
|
/// offers and by any `max_width` setting on the column itself.
|
|
pub fn fit_content( mut self ) -> Self
|
|
{
|
|
self.fit_content = true;
|
|
self
|
|
}
|
|
|
|
fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32
|
|
{
|
|
let w = available - self.resolved_padding( canvas ) * 2.0;
|
|
self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w )
|
|
}
|
|
|
|
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
|
|
{
|
|
// Spacers and flex children contribute 0 to natural height (their
|
|
// real height is leftover distribution, mirroring how a row counts
|
|
// flex width); spacing still applies between all children.
|
|
self.children.iter()
|
|
.map( |c| match c
|
|
{
|
|
Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ),
|
|
Element::Flex( _ ) => 0.0,
|
|
other => other.preferred_size( inner_w, canvas ).1,
|
|
} )
|
|
.sum::<f32>()
|
|
+ self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32
|
|
}
|
|
|
|
/// Return the preferred `(width, height)` given available `max_width`.
|
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
|
{
|
|
let inner_w = self.inner_w( max_width, canvas );
|
|
let pad = self.resolved_padding( canvas );
|
|
let total_h = self.content_h( inner_w, canvas ) + pad * 2.0;
|
|
|
|
let w = if self.fit_content
|
|
{
|
|
// "Filler" widgets (Spacer, Separator, Scroll, ProgressBar, Slider,
|
|
// Toggle, TextEdit) all report `max_width` as their preferred width:
|
|
// they stretch across whatever rect the parent allocates. Including
|
|
// them when picking the intrinsic content width would claim
|
|
// `max_width` and defeat the flag, so skip them — only content-sized
|
|
// children (Text, Button, Image, nested fit-content Columns/Rows)
|
|
// drive the natural width.
|
|
let content_w = self.children.iter()
|
|
.map( |c| match c
|
|
{
|
|
Element::Spacer( _ ) => 0.0,
|
|
Element::Separator( _ ) => 0.0,
|
|
Element::Scroll( _ ) => 0.0,
|
|
Element::ProgressBar( _ ) => 0.0,
|
|
Element::Slider( _ ) => 0.0,
|
|
// TextEdit defaults to claiming `max_width`, but
|
|
// a field built with `.fixed_width( w )` reports
|
|
// a pinned natural size — let those through so a
|
|
// numeric digit field inside a `fit_content`
|
|
// stepper column can drive the column's width.
|
|
Element::TextEdit( t ) => if t.fixed_width.is_some()
|
|
{
|
|
t.preferred_size( inner_w, canvas ).0
|
|
} else { 0.0 },
|
|
other => other.preferred_size( inner_w, canvas ).0,
|
|
} )
|
|
.fold( 0.0_f32, f32::max );
|
|
( content_w + pad * 2.0 ).min( max_width )
|
|
} else {
|
|
max_width
|
|
};
|
|
|
|
( w, total_h )
|
|
}
|
|
|
|
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
|
|
|
|
/// Layout children within rect and return (rect, child_index) pairs.
|
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
|
{
|
|
let inner_w = self.inner_w( rect.width, canvas );
|
|
let pad = self.resolved_padding( canvas );
|
|
let spacing = self.resolved_spacing( canvas );
|
|
|
|
let total_weight: u32 = self.children.iter()
|
|
.map( |c| match c
|
|
{
|
|
Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight,
|
|
Element::Scroll( s ) if s.axis.allows_y() => 1,
|
|
Element::Flex( f ) => f.weight,
|
|
_ => 0,
|
|
} )
|
|
.sum();
|
|
|
|
let fixed_h: f32 = self.children.iter()
|
|
.map( |c|
|
|
{
|
|
if matches!( c, Element::Scroll( s ) if s.axis.allows_y() )
|
|
{
|
|
0.0
|
|
} else if matches!( c, Element::Flex( _ ) )
|
|
{
|
|
0.0
|
|
} else if let Element::Spacer( s ) = c {
|
|
s.resolved_height( canvas ).unwrap_or( 0.0 )
|
|
} else {
|
|
c.preferred_size( inner_w, canvas ).1
|
|
}
|
|
} )
|
|
.sum::<f32>()
|
|
+ spacing * ( self.children.len().saturating_sub( 1 ) ) as f32;
|
|
|
|
let avail_h = rect.height - pad * 2.0;
|
|
let avail_spare = ( avail_h - fixed_h ).max( 0.0 );
|
|
|
|
// `center_y` only applies when there are no flexible children
|
|
// (weight-only spacers, y-scrolls, flex wrappers).
|
|
let start_y = if total_weight == 0 && self.center_y
|
|
{
|
|
rect.y + pad + avail_spare / 2.0
|
|
} else {
|
|
rect.y + pad
|
|
};
|
|
|
|
let start_x = rect.x + (rect.width - inner_w) / 2.0;
|
|
|
|
let mut y = start_y;
|
|
let mut result = Vec::new();
|
|
for ( i, child ) in self.children.iter().enumerate()
|
|
{
|
|
let ( w, h ) = match child
|
|
{
|
|
Element::Spacer( s ) =>
|
|
{
|
|
let h = if let Some( fixed ) = s.resolved_height( canvas )
|
|
{
|
|
fixed
|
|
} else if total_weight > 0
|
|
{
|
|
avail_spare * s.weight as f32 / total_weight as f32
|
|
} else {
|
|
0.0
|
|
};
|
|
( inner_w, h )
|
|
},
|
|
Element::Scroll( s ) if s.axis.allows_y() =>
|
|
{
|
|
let h = if total_weight > 0
|
|
{
|
|
avail_spare / total_weight as f32
|
|
} else {
|
|
0.0
|
|
};
|
|
( inner_w, h )
|
|
},
|
|
Element::Flex( f ) =>
|
|
{
|
|
let h = if total_weight > 0
|
|
{
|
|
avail_spare * f.weight as f32 / total_weight as f32
|
|
} else {
|
|
0.0
|
|
};
|
|
( inner_w, h )
|
|
},
|
|
other => other.preferred_size( inner_w, canvas ),
|
|
};
|
|
let x = if self.align_center_x && !matches!( child, Element::Spacer( _ ) )
|
|
{
|
|
start_x + (inner_w - w) / 2.0
|
|
} else {
|
|
start_x
|
|
};
|
|
result.push( ( Rect { x, y, width: w, height: h }, i ) );
|
|
y += h + spacing;
|
|
}
|
|
result
|
|
}
|
|
|
|
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Column<U>
|
|
where
|
|
U: Clone + 'static,
|
|
Msg: 'static,
|
|
{
|
|
Column
|
|
{
|
|
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
|
|
spacing: self.spacing,
|
|
padding: self.padding,
|
|
align_center_x: self.align_center_x,
|
|
center_y: self.center_y,
|
|
max_width: self.max_width,
|
|
fit_content: self.fit_content,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create an empty column layout.
|
|
pub fn column<Msg: Clone>() -> Column<Msg>
|
|
{
|
|
Column::new()
|
|
}
|
|
|
|
impl<Msg: Clone> Default for Column<Msg>
|
|
{
|
|
fn default() -> Self
|
|
{
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests
|
|
{
|
|
use super::*;
|
|
use crate::render::Canvas;
|
|
|
|
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
|
|
|
|
#[ test ]
|
|
fn preferred_size_width_equals_max_width()
|
|
{
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( 10.0 );
|
|
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
|
|
assert_eq!( w, 200.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn empty_column_height_is_two_paddings()
|
|
{
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( 10.0 );
|
|
let ( _, h ) = col.preferred_size( 200.0, &canvas );
|
|
assert_eq!( h, 20.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn max_width_caps_inner_w_not_preferred_w()
|
|
{
|
|
let canvas = make_canvas();
|
|
// preferred_size always returns available max_width; max_width caps inner layout only
|
|
let col = column::<()>().padding( 0.0 ).max_width( 100.0 );
|
|
let ( w, _ ) = col.preferred_size( 200.0, &canvas );
|
|
assert_eq!( w, 200.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn inner_w_respects_padding_and_max_width()
|
|
{
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( 20.0 ).max_width( 100.0 );
|
|
// available = 200, minus padding*2 = 160, capped at max_width = 100
|
|
assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn inner_w_without_max_width_subtracts_padding()
|
|
{
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( 10.0 );
|
|
assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn spacing_between_children_accumulates()
|
|
{
|
|
let canvas = make_canvas();
|
|
// Three zero-height spacers, two 8 px gaps between them = 16.
|
|
let col = column::<()>()
|
|
.padding( 0.0 )
|
|
.spacing( 8.0 )
|
|
.push( crate::spacer() )
|
|
.push( crate::spacer() )
|
|
.push( crate::spacer() );
|
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
|
assert_eq!( h, 16.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn vmin_spacing_resolves_against_canvas_viewport()
|
|
{
|
|
// Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap.
|
|
// Three zero-height spacers → two gaps → 60 px total.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>()
|
|
.padding( 0.0 )
|
|
.spacing( Length::vmin( 5.0 ) )
|
|
.push( crate::spacer() )
|
|
.push( crate::spacer() )
|
|
.push( crate::spacer() );
|
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
|
assert_eq!( h, 60.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn vmin_padding_doubles_around_content()
|
|
{
|
|
// 4 % of 600 = 24 px padding on each side → 48 px on an empty column.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( Length::vmin( 4.0 ) );
|
|
let ( _, h ) = col.preferred_size( 100.0, &canvas );
|
|
assert_eq!( h, 48.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn vmin_max_width_caps_inner_w()
|
|
{
|
|
// 20 % of 600 = 120 px max-width.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) );
|
|
assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn flex_child_takes_leftover_height()
|
|
{
|
|
// A 30 px fixed spacer and one flex child in a 100 px rect:
|
|
// the flex gets the remaining 70 px at full inner width.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>()
|
|
.padding( 0.0 )
|
|
.spacing( 0.0 )
|
|
.push( crate::spacer().height( 30.0 ) )
|
|
.push( crate::flex( crate::spacer() ) );
|
|
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 200.0, height: 100.0 };
|
|
let rects = col.layout( rect, &canvas );
|
|
assert_eq!( rects.len(), 2 );
|
|
assert!( ( rects[1].0.height - 70.0 ).abs() < 0.01 );
|
|
assert!( ( rects[1].0.width - 200.0 ).abs() < 0.01 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn flex_children_split_leftover_by_weight()
|
|
{
|
|
// Two flex children weighted 1:3 share 100 px as 25 / 75.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>()
|
|
.padding( 0.0 )
|
|
.spacing( 0.0 )
|
|
.push( crate::flex( crate::spacer() ) )
|
|
.push( crate::flex( crate::spacer() ).weight( 3 ) );
|
|
let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 200.0, height: 100.0 };
|
|
let rects = col.layout( rect, &canvas );
|
|
assert!( ( rects[0].0.height - 25.0 ).abs() < 0.01 );
|
|
assert!( ( rects[1].0.height - 75.0 ).abs() < 0.01 );
|
|
}
|
|
|
|
#[ test ]
|
|
fn flex_contributes_zero_to_natural_height()
|
|
{
|
|
// Natural height counts only the fixed spacer, like row width math.
|
|
let canvas = make_canvas();
|
|
let col = column::<()>()
|
|
.padding( 0.0 )
|
|
.spacing( 0.0 )
|
|
.push( crate::spacer().height( 30.0 ) )
|
|
.push( crate::flex( crate::spacer() ) );
|
|
let ( _, h ) = col.preferred_size( 200.0, &canvas );
|
|
assert_eq!( h, 30.0 );
|
|
}
|
|
}
|