First commit. Version 0.1.0
This commit is contained in:
359
src/layout/column.rs
Normal file
359
src/layout/column.rs
Normal 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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user