First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

359
src/layout/column.rs Normal file
View File

@@ -0,0 +1,359 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::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()
/// # }
/// ```
pub struct Column<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_center_x: bool,
pub center_y: bool,
pub max_width: Option<f32>,
pub fit_content: bool,
}
impl<Msg: Clone> Column<Msg>
{
pub fn new() -> Self
{
Self
{
children: Vec::new(),
spacing: 8.0,
padding: 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 in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `16.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
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 in pixels. The column still reports `max_width` as
/// its preferred width so the parent allocates the full available rect.
pub fn max_width( mut self, w: f32 ) -> Self
{
self.max_width = Some( w );
self
}
/// 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 ) -> f32
{
let w = available - self.padding * 2.0;
self.max_width.map( |m| w.min( m ) ).unwrap_or( w )
}
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
{
// Spacers contribute 0 to natural height; spacing still applies between all children.
self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ),
other => other.preferred_size( inner_w, canvas ).1,
} )
.sum::<f32>()
+ self.spacing * (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 );
let total_h = self.content_h( inner_w, canvas ) + self.padding * 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 + self.padding * 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 );
// Flexible spacers and Scroll widgets claim remaining vertical space.
// Fixed-height spacers behave like normal fixed-size children.
let total_weight: u32 = self.children.iter()
.map( |c| match c
{
Element::Spacer( s ) if s.fixed_height.is_none() => s.weight,
Element::Scroll( _ ) => 1,
_ => 0,
} )
.sum();
let fixed_h: f32 = self.children.iter()
.map( |c|
{
if matches!( c, Element::Scroll( _ ) )
{
0.0
} else if let Element::Spacer( s ) = c {
s.fixed_height.unwrap_or( 0.0 )
} else {
c.preferred_size( inner_w, canvas ).1
}
} )
.sum::<f32>()
+ self.spacing * (self.children.len().saturating_sub( 1 )) as f32;
let avail_h = rect.height - self.padding * 2.0;
let avail_spare = (avail_h - fixed_h).max( 0.0 );
// `center_y` only applies when there are no spacers.
let start_y = if total_weight == 0 && self.center_y
{
rect.y + self.padding + avail_spare / 2.0
} else {
rect.y + self.padding
};
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.fixed_height
{
fixed
} else if total_weight > 0
{
avail_spare * s.weight as f32 / total_weight as f32
} else {
0.0
};
( inner_w, h )
},
Element::Scroll( _ ) =>
{
let h = if total_weight > 0
{
avail_spare / 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 + self.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 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 ), 100.0 );
}
#[ test ]
fn inner_w_without_max_width_subtracts_padding()
{
let col = column::<()>().padding( 10.0 );
assert_eq!( col.inner_w( 200.0 ), 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 );
}
}

47
src/layout/mod.rs Normal file
View File

@@ -0,0 +1,47 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Layouts — composable arrangers for [`Element`](crate::Element) trees.
//!
//! Layouts decide *where* their children sit; they don't paint anything of
//! their own. Each layout exposes a free constructor (`column()`, `row()`,
//! `stack()`, `grid(N)`, `spacer()`) and a builder-style API for spacing,
//! padding, alignment and sizing. Layouts and [widgets](crate::widget)
//! share the same [`Element<Msg>`](crate::Element) tree — anything that
//! converts into an `Element` can be pushed into any layout.
//!
//! ## What's available
//!
//! * **Flow** — [`column::Column`] (top-to-bottom),
//! [`row::Row`] (left-to-right). Both honour `padding`, `spacing`,
//! `max_width`, `align_center_*` and inline `Spacer` distribution.
//! * **Overlay** — [`stack::Stack`] for FrameLayout-style layering with
//! per-child [`HAlign`](stack::HAlign) / [`VAlign`](stack::VAlign) and
//! margin / pixel-translation overrides. Useful for foreground HUD on
//! top of a background image, or a floating action button anchored to
//! the bottom-right.
//! * **Grid** — [`wrap_grid::WrapGrid`] for fixed-column-count grids that
//! wrap their children into rows (icon launchers, photo galleries).
//! * **Filler** — [`spacer::Spacer`], an invisible child that absorbs
//! leftover space along the parent's main axis. Pair with
//! [`flex::Flex`](crate::Flex) when the filler is non-trivial (a card
//! that should stretch a row).
//!
//! Most layouts default to "fill the parent's available rect" and only
//! shrink to their content with `fit_content()`.
//!
//! ## Sizing model
//!
//! Layouts implement `preferred_size( max_width, &Canvas ) -> ( w, h )`
//! the same way widgets do. The convention is: the layout claims the
//! parent-supplied `max_width` (or the explicit `max_width(...)` setting)
//! and reports the height needed for its children. Spacers and
//! `Scroll`/`Flex` declare zero intrinsic main-axis size and absorb the
//! leftover space the parent has after fixed-size siblings have been laid
//! out.
pub mod column;
pub mod row;
pub mod spacer;
pub mod stack;
pub mod wrap_grid;

299
src/layout/row.rs Normal file
View File

@@ -0,0 +1,299 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// A horizontal layout container.
///
/// Children are arranged left-to-right. Use [`Row::align_right`] to
/// push the content block to the right edge of the available width.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ icon_button, row, Element };
/// # #[ derive( Clone ) ] enum Msg { A, B }
/// # fn _ex( a_rgba: Arc<Vec<u8>>, b_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// row()
/// .spacing( 16.0 )
/// .align_right()
/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) )
/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) )
/// .into()
/// # }
/// ```
pub struct Row<Msg: Clone>
{
pub children: Vec<Element<Msg>>,
pub spacing: f32,
pub padding: f32,
pub align_right: bool,
}
impl<Msg: Clone> Row<Msg>
{
pub fn new() -> Self
{
Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: 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 horizontal gap between children in pixels. Default: `8.0`.
pub fn spacing( mut self, s: f32 ) -> Self
{
self.spacing = s;
self
}
/// Set the padding (all sides) in pixels. Default: `0.0`.
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// Push the content block to the right edge of the available width.
pub fn align_right( mut self ) -> Self
{
self.align_right = true;
self
}
/// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
// Width contribution of every fixed (non-flex, non-flex-spacer) child.
// Used to compute the residual width that wrap-style children will
// actually render in, so their reported height matches the layout.
let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 );
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter()
.filter( |c| match c
{
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum();
let residual = ( inner_w - fixed_w - gaps ).max( 0.0 );
let max_h: f32 = self.children.iter()
.map( |c| match c
{
Element::Flex( _ ) => c.preferred_size( residual, canvas ).1,
Element::Spacer( _ ) => c.preferred_size( max_width, canvas ).1,
_ => c.preferred_size( max_width, canvas ).1,
} )
.fold( 0.0_f32, f32::max );
// `align_right` and any flex / weight-only spacer child make the row
// claim the full `max_width`: in those cases the row's rendered width
// comes from leftover distribution (or the right-edge anchor), not
// from the sum of children's preferred widths. Reporting only the
// natural sum would leave the parent allocating a too-narrow rect and
// the flex children would collapse to 0.
let has_flex = self.children.iter().any( |c| match c
{
Element::Flex( _ ) => true,
Element::Spacer( s ) => s.fixed_width.is_none(),
_ => false,
} );
let w = if self.align_right || has_flex
{
max_width
} else {
let total_w: f32 = self.children.iter()
.map( |c| c.preferred_size( max_width, canvas ).0 )
.sum::<f32>()
+ gaps
+ self.padding * 2.0;
total_w.min( max_width )
};
( w, max_h + self.padding * 2.0 )
}
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
/// Layout children within rect and return `(rect, child_index)` pairs.
///
/// Flexible [`Spacer`](crate::layout::spacer::Spacer) children claim the
/// leftover horizontal space: non-spacer widgets keep their preferred
/// width, the remaining width (after subtracting spacing + padding) is
/// distributed between spacers in proportion to their `weight`. When no
/// spacers are present the cluster is centered (or right-aligned via
/// [`Row::align_right`]).
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
let inner_h = rect.height - self.padding * 2.0;
// Spacers and `Flex` wrappers report 0 width here; their real width
// comes from the flex distribution below.
let sizes: Vec<(f32, f32)> = self.children.iter()
.map( |c| c.preferred_size( rect.width, canvas ) )
.collect();
let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32;
let fixed_w: f32 = self.children.iter().zip( sizes.iter() )
.filter( |( c, _ )| match c
{
// Pure flex children (`Flex` and weight-only `Spacer`) take
// width from the leftover pool; everything else, including
// `Spacer::width(...)`-pinned spacers, contributes to the
// fixed-width tally.
Element::Flex( _ ) => false,
Element::Spacer( s ) => s.fixed_width.is_some(),
_ => true,
} )
.map( |( _, ( w, _ ) )| *w )
.sum();
let total_weight: u32 = self.children.iter()
.filter_map( |c| match c {
Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ),
Element::Flex( f ) => Some( f.weight ),
_ => None,
} )
.sum();
let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 );
let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 );
let has_spacers = total_weight > 0;
let ( start_x, flex_unit ) = if has_spacers
{
// Spacers and `Flex` wrappers claim the leftover; the cluster
// sits flush to the left edge of the inner rect.
( rect.x + self.padding, leftover / total_weight as f32 )
}
else if self.align_right
{
( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 )
}
else
{
( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 )
};
let mut x = start_x;
let mut result = Vec::with_capacity( self.children.len() );
for ( i, ( (w, h), child) ) in sizes.into_iter().zip( self.children.iter() ).enumerate()
{
let width = match child
{
Element::Spacer( s ) => match s.fixed_width
{
Some( fw ) => fw,
None => flex_unit * s.weight as f32,
},
Element::Flex( f ) => flex_unit * f.weight as f32,
_ => w,
};
let y = rect.y + self.padding + (inner_h - h) / 2.0;
result.push( ( Rect { x, y, width, height: h }, i ) );
x += width + self.spacing;
}
result
}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Row<U>
where
U: Clone + 'static,
Msg: 'static,
{
Row
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
spacing: self.spacing,
padding: self.padding,
align_right: self.align_right,
}
}
}
/// Create an empty row layout.
pub fn row<Msg: Clone>() -> Row<Msg>
{
Row::new()
}
impl<Msg: Clone> Default for Row<Msg>
{
fn default() -> Self
{
Self::new()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use crate::render::Canvas;
use crate::types::Rect;
fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) }
#[ test ]
fn align_right_returns_full_max_width()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 500.0 );
}
#[ test ]
fn align_right_true_regardless_of_children()
{
let canvas = make_canvas();
let r = row::<()>().align_right().spacing( 999.0 );
let ( w, _ ) = r.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_width()
{
let canvas = make_canvas();
let r = row::<()>();
let ( w, _ ) = r.preferred_size( 500.0, &canvas );
assert_eq!( w, 0.0 );
}
#[ test ]
fn centered_empty_row_returns_zero_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 0.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 0.0 );
}
#[ test ]
fn padding_adds_to_height()
{
let canvas = make_canvas();
let r = row::<()>().padding( 8.0 );
let ( _, h ) = r.preferred_size( 500.0, &canvas );
assert_eq!( h, 16.0 );
}
#[ test ]
fn layout_of_empty_row_is_empty()
{
let canvas = make_canvas();
let r = row::<()>().align_right();
let rect = Rect { x: 0., y: 0., width: 400., height: 48. };
assert!( r.layout( rect, &canvas ).is_empty() );
}
}

143
src/layout/spacer.rs Normal file
View File

@@ -0,0 +1,143 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::widget::Element;
/// A flexible, invisible spacer that expands to fill available space.
///
/// The optional `weight` controls how much of the remaining space this spacer
/// claims relative to other spacers in the same layout. A spacer with `weight = 2`
/// takes twice as much space as one with `weight = 1`.
///
/// Place a `Spacer` between two widgets inside a [`Column`](crate::layout::column::Column)
/// or [`Row`](crate::layout::row::Row) to push them apart:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Top" ) )
/// .push( spacer() ) // pushes "Bottom" to the bottom
/// .push( text( "Bottom" ) )
/// .into()
/// # }
/// ```
///
/// Use [`.weight(n)`](Spacer::weight) to replace several consecutive spacers:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Use [`.height(px)`](Spacer::height) to create a fixed-size vertical spacer:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "Header" ) )
/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap
/// .push( text( "Content" ) )
/// .into()
/// # }
/// ```
pub struct Spacer
{
/// Relative weight of this spacer (default 1).
pub weight: u32,
/// Fixed height in pixels (overrides flexible behavior in a column).
pub fixed_height: Option<f32>,
/// Fixed width in pixels (overrides flexible behavior in a row).
pub fixed_width: Option<f32>,
}
impl Spacer
{
/// Set the relative weight of this spacer (default 1).
pub fn weight( mut self, w: u32 ) -> Self
{
self.weight = w;
self
}
/// Set a fixed height for this spacer in pixels.
/// When set, the spacer will occupy exactly this much vertical space
/// instead of expanding flexibly.
pub fn height( mut self, h: f32 ) -> Self
{
self.fixed_height = Some( h );
self
}
/// Set a fixed width for this spacer in pixels.
/// When set, the spacer occupies exactly this much horizontal space
/// inside a [`Row`](crate::layout::row::Row) instead of expanding
/// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful
/// to reserve a precise visual margin while a sibling
/// [`Flex`](crate::Flex) claims the remaining width.
pub fn width( mut self, w: f32 ) -> Self
{
self.fixed_width = Some( w );
self
}
/// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the
/// axes that were not pinned. The parent layout distributes leftover
/// along its main axis among the still-flexible spacers and `Flex`
/// wrappers, weighted by `weight`.
pub fn preferred_size( &self ) -> (f32, f32)
{
( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) )
}
/// No-op — spacers are invisible.
pub fn draw( &self ) {}
}
impl<Msg: Clone + 'static> From<Spacer> for Element<Msg>
{
fn from( s: Spacer ) -> Self
{
Element::Spacer( s )
}
}
/// Create a flexible spacer with weight 1.
///
/// Call [`.weight(n)`](Spacer::weight) to set a relative weight greater than 1:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, Column };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() {
/// // These two are equivalent:
/// let _: Column<Msg> = column().push( spacer().weight( 3 ) );
/// let _: Column<Msg> = column().push( spacer() ).push( spacer() ).push( spacer() );
/// # }
/// ```
///
/// Call [`.height(px)`](Spacer::height) to create a fixed-size vertical gap:
///
/// ```rust,no_run
/// # use ltk::{ column, spacer, text, Element };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex() -> Element<Msg> {
/// column()
/// .push( text( "First" ) )
/// .push( spacer().height( 24.0 ) ) // Fixed 24px gap
/// .push( text( "Second" ) )
/// .into()
/// # }
/// ```
pub fn spacer() -> Spacer
{
Spacer { weight: 1, fixed_height: None, fixed_width: None }
}

182
src/layout/stack.rs Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::Rect;
use crate::render::Canvas;
use crate::widget::Element;
/// Horizontal alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum HAlign
{
/// Align to the left edge.
Start,
/// Center horizontally.
Center,
/// Align to the right edge.
End,
/// Stretch to fill the full width.
Fill,
}
/// Vertical alignment of a child within a [`Stack`] rect.
#[ derive( Debug, Clone, Copy, PartialEq ) ]
pub enum VAlign
{
/// Align to the top edge.
Top,
/// Center vertically.
Center,
/// Align to the bottom edge.
Bottom,
/// Stretch to fill the full height.
Fill,
}
/// A layout that draws all its children stacked on top of each other.
/// Each child can be positioned within the Stack rect via [`HAlign`]/[`VAlign`].
///
/// Useful for overlaying a foreground widget on top of a background image:
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ column, img_widget, stack, text, Element, HAlign, VAlign };
/// # #[ derive( Clone ) ] enum Msg {}
/// # fn _ex( bg_rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// stack()
/// .push( img_widget( bg_rgba, w, h ) )
/// .push_aligned( column().push( text( "Bottom right" ) ), HAlign::End, VAlign::Bottom )
/// .into()
/// # }
/// ```
pub struct Stack<Msg: Clone>
{
/// Children with their alignment, margin, and extra `(x, y)` translation
/// applied after alignment. Drawn in order — last child is on top.
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32 )>,
}
impl<Msg: Clone> Stack<Msg>
{
/// Create an empty stack.
pub fn new() -> Self
{
Self { children: Vec::new() }
}
/// Append a child that fills the entire Stack rect (Android FrameLayout default).
pub fn push( self, e: impl Into<Element<Msg>> ) -> Self
{
self.push_aligned_margin( e, HAlign::Fill, VAlign::Fill, 0.0 )
}
/// Append a child with explicit horizontal and vertical alignment.
pub fn push_aligned(
self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
) -> Self
{
self.push_aligned_margin( e, h_align, v_align, 0.0 )
}
/// Append a child with alignment and a uniform margin (inset from the Stack edges).
pub fn push_aligned_margin(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
margin: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
self
}
/// Append a child with alignment plus an extra `(x, y)` translation in
/// logical pixels. Useful when a child needs to shift outside the normal
/// alignment grid without giving up the margin or alignment shorthand.
/// Positive `x` / `y` move the child right / down.
pub fn push_translated(
mut self,
e: impl Into<Element<Msg>>,
h_align: HAlign,
v_align: VAlign,
offset_x: f32,
offset_y: f32,
) -> Self
{
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
self
}
/// Return the preferred `(width, height)` — the maximum height among children.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
let max_h = self.children.iter()
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
.fold( 0.0_f32, f32::max );
( max_width, max_h )
}
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
{
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )|
{
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
let ( x, width ) = match h_align
{
HAlign::Start => ( rect.x + margin, pref_w ),
HAlign::Center => ( rect.x + ( rect.width - pref_w ) / 2.0, pref_w ),
HAlign::End => ( rect.x + rect.width - pref_w - margin, pref_w ),
HAlign::Fill => ( rect.x + margin, inner_w ),
};
let ( y, height ) = match v_align
{
VAlign::Top => ( rect.y + margin, pref_h ),
VAlign::Center => ( rect.y + ( rect.height - pref_h ) / 2.0, pref_h ),
VAlign::Bottom => ( rect.y + rect.height - pref_h - margin, pref_h ),
VAlign::Fill => ( rect.y + margin, inner_h ),
};
( Rect { x: x + ox, y: y + oy, width, height }, i )
} ).collect()
}
/// No-op — children are drawn directly by the event loop during layout.
pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {}
pub( crate ) fn map_msg<U>( self, f: &crate::widget::MapFn<Msg, U> ) -> Stack<U>
where
U: Clone + 'static,
Msg: 'static,
{
Stack
{
children: self.children.into_iter()
.map( |( child, ha, va, margin, ox, oy )|
( child.map_arc( f ), ha, va, margin, ox, oy ) )
.collect(),
}
}
}
impl<Msg: Clone> Default for Stack<Msg>
{
fn default() -> Self
{
Self::new()
}
}
/// Create an empty [`Stack`].
pub fn stack<Msg: Clone>() -> Stack<Msg>
{
Stack::new()
}

347
src/layout/wrap_grid.rs Normal file
View File

@@ -0,0 +1,347 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::render::Canvas;
use crate::types::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 children: Vec<Element<Msg>>,
/// Number of columns per row.
pub columns: usize,
/// Horizontal gap between cells (pixels).
pub spacing_x: f32,
/// Vertical gap between rows (pixels).
pub spacing_y: f32,
/// Padding on all sides (pixels).
pub padding: f32,
}
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: f32 ) -> Self
{
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: f32 ) -> Self
{
self.spacing_x = s;
self
}
/// Set only the vertical gap between rows; leaves horizontal spacing untouched.
pub fn spacing_y( mut self, s: f32 ) -> Self
{
self.spacing_y = s;
self
}
/// Set the padding on all sides (default 0.0).
pub fn padding( mut self, p: f32 ) -> Self
{
self.padding = p;
self
}
/// 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 cols = self.columns;
let inner_w = (max_width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let row_count = (self.children.len() + cols - 1) / cols;
let mut total_h = self.padding * 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 += self.spacing_y; }
}
( 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 cols = self.columns;
let inner_w = (rect.width - self.padding * 2.0).max( 0.0 );
let cell_w = (inner_w - self.spacing_x * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + self.padding;
let mut y = rect.y + self.padding;
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 );
for col in 0..(end - start)
{
let x = x0 + col as f32 * (cell_w + self.spacing_x);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
y += row_h + self.spacing_y;
}
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,
}
}
}
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 );
}
}
/// 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: 8.0,
spacing_y: 8.0,
padding: 0.0,
}
}