// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. 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 { /// 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 { /// 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 { pub( crate ) children: Vec>, /// 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, pub( crate ) fit_content: bool, } impl Column { 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> ) -> 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 ) -> 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 ) -> 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 ) -> 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 { 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::() + 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::() + 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( self, f: &crate::widget::MapFn ) -> Column 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() -> Column { Column::new() } impl Default for Column { 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 ); } }