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 );
}
}