// 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 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>, b_rgba: Arc>, w: u32, h: u32 ) -> Element { /// 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() /// # } /// ``` /// /// `spacing` and `padding` accept any [`crate::Length`]: /// /// ```rust,no_run /// # use std::sync::Arc; /// # use ltk::{ icon_button, row, Length, Element }; /// # #[ derive( Clone ) ] enum Msg { A, B } /// # fn _ex( a_rgba: Arc>, b_rgba: Arc>, w: u32, h: u32 ) -> Element { /// row() /// // 2 % of the viewport's smaller side, never below 8 px. /// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) ) /// .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 { pub( crate ) children: Vec>, /// Horizontal gap between children. [`Length`]; default `8.0` px. pub( crate ) spacing: Length, /// Padding on all sides. [`Length`]; default `0.0` px. pub( crate ) padding: Length, pub( crate ) align_right: bool, pub( crate ) align_top: bool, pub( crate ) fill_height: bool, /// When false the row keeps every child at its preferred width even if /// the cluster overflows. Default true. pub( crate ) shrink: bool, } impl Row { pub fn new() -> Self { Self { children: Vec::new(), spacing: Length::px( 8.0 ), padding: Length::px( 0.0 ), align_right: false, align_top: false, fill_height: false, shrink: true, } } /// Append a child widget or layout. pub fn push( mut self, e: impl Into> ) -> Self { self.children.push( e.into() ); self } /// Set the horizontal gap between children. Default: `8.0` px. Accepts /// any [`Length`] so the gap can scale with the viewport. pub fn spacing( mut self, s: impl Into ) -> Self { self.spacing = s.into(); self } /// Set the padding (all sides). Default: `0.0` px. Accepts any /// [`Length`]. pub fn padding( mut self, p: impl Into ) -> Self { self.padding = p.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 ) } /// Keep every child at its preferred width even when the cluster does not /// fit, letting it overflow the row's rect. /// /// The default distributes the shortfall instead, which is what a row of /// labelled buttons wants. Opt out for strips that are deliberately wider /// than their viewport — a carousel rail, a tray meant to be scrolled — /// where shrinking to fit would defeat the point. pub fn no_shrink( mut self ) -> Self { self.shrink = false; 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 } /// Pin children to the top edge instead of the default vertical /// centering, so siblings of slightly different heights (e.g. two /// cards whose text metrics differ) share the same top line. pub fn align_top( mut self ) -> Self { self.align_top = true; self } /// Stretch every child to the row's inner height, so siblings share /// both the top and the bottom line regardless of their natural /// heights. The row's own height still comes from the tallest /// child's preferred size; children whose content should track the /// stretch need internal flexible spacers. pub fn fill_height( mut self ) -> Self { self.fill_height = true; self } /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { let pad = self.resolved_padding( canvas ); let spacing = self.resolved_spacing( canvas ); // 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 - pad * 2.0 ).max( 0.0 ); let gaps = 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.resolved_width( canvas ).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.resolved_width( canvas ).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::() + gaps + pad * 2.0; total_w.min( max_width ) }; ( w, max_h + pad * 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 pad = self.resolved_padding( canvas ); let spacing = self.resolved_spacing( canvas ); let inner_h = rect.height - pad * 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 = 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.resolved_width( canvas ).is_some(), _ => true, } ) .map( |( _, ( w, _ ) )| *w ) .sum(); let total_weight: u32 = self.children.iter() .filter_map( |c| match c { Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ), Element::Flex( f ) => Some( f.weight ), _ => None, } ) .sum(); let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 ); // Deficit distribution: cap the widest children first, so a long // label gives up its width before a neighbouring icon does. A uniform // scale-down would thin a header's back arrow along with its title. // Spacers keep the width they were pinned to — an explicit gap is a // decision, not slack. let mut flexible: Vec = self.children.iter().zip( sizes.iter() ) .filter( |( c, _ )| !matches!( c, Element::Flex( _ ) | Element::Spacer( _ ) ) ) .map( |( _, ( w, _ ) )| *w ) .collect(); let flexible_w: f32 = flexible.iter().sum(); let rigid_w = ( fixed_w - flexible_w ).max( 0.0 ); let cap = if self.shrink { width_cap( &mut flexible, ( inner_w - gaps - rigid_w ).max( 0.0 ) ) } else { f32::INFINITY }; let laid_w = rigid_w + flexible.iter().map( |w| w.min( cap ) ).sum::(); let leftover = ( inner_w - laid_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 + pad, leftover / total_weight as f32 ) } else if self.align_right { ( rect.x + rect.width - ( laid_w + gaps ) - pad, 0.0 ) } else { ( rect.x + ( rect.width - laid_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.resolved_width( canvas ) { Some( fw ) => fw, None => flex_unit * s.weight as f32, }, Element::Flex( f ) => flex_unit * f.weight as f32, _ => w.min( cap ), }; let ( y, height ) = if self.fill_height && !matches!( child, Element::Spacer( _ ) ) { ( rect.y + pad, inner_h ) } else if self.align_top { ( rect.y + pad, h ) } else { ( rect.y + pad + ( inner_h - h ) / 2.0, h ) }; result.push( ( Rect { x, y, width, height }, i ) ); x += width + spacing; } result } pub( crate ) fn map_msg( self, f: &crate::widget::MapFn ) -> Row 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, align_top: self.align_top, fill_height: self.fill_height, shrink: self.shrink, } } } /// Largest per-child width `c` for which `sum( min( w, c ) ) <= available`. /// /// Water filling: the widest children give up width first and the narrow ones /// are left alone until the wide ones have been levelled down to them. In a /// header that means the title absorbs the whole shortfall while the back /// arrow beside it keeps its size. Sorts `widths` in place; returns infinity /// when everything already fits. fn width_cap( widths: &mut [f32], available: f32 ) -> f32 { if widths.iter().sum::() <= available { return f32::INFINITY; } widths.sort_by( |a, b| a.partial_cmp( b ).unwrap_or( std::cmp::Ordering::Equal ) ); let mut prefix = 0.0_f32; for ( i, w ) in widths.iter().enumerate() { let remaining = ( widths.len() - i ) as f32; let cap = ( available - prefix ) / remaining; if cap <= *w { return cap.max( 0.0 ); } prefix += *w; } 0.0 } /// Create an empty row layout. pub fn row() -> Row { Row::new() } impl Default for Row { 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 ) } /// Children whose preferred widths overflow the rect are scaled down to /// fit, rather than spilling symmetrically out of it. #[ test ] fn overflowing_children_shrink_to_fit() { let canvas = make_canvas(); let build = || row::<()>() .spacing( 0.0 ) .padding( 0.0 ) .push( crate::toggle( false ) ) .push( crate::toggle( false ) ); let natural = build().preferred_size( 10_000.0, &canvas ).0; assert!( natural > 0.0, "toggles must report a width for this to test anything" ); let rect = Rect { x: 0.0, y: 0.0, width: natural / 2.0, height: 48.0 }; let laid: f32 = build().layout( rect, &canvas ) .iter().map( |( r, _ )| r.width ).sum(); assert!( laid <= rect.width + 0.5, "laid out {laid} into {}", rect.width ); let kept: f32 = build().no_shrink().layout( rect, &canvas ) .iter().map( |( r, _ )| r.width ).sum(); assert!( kept > rect.width, "no_shrink kept {kept}, expected wider than {}", rect.width ); } /// The shortfall comes off the widest child first: a narrow neighbour — /// a header's back arrow next to its title — keeps its size. #[ test ] fn narrow_child_survives_a_wide_neighbour() { let mut w = vec![ 20.0_f32, 300.0 ]; let cap = width_cap( &mut w, 200.0 ); assert_eq!( 20.0_f32.min( cap ), 20.0 ); assert_eq!( 300.0_f32.min( cap ), 180.0 ); } /// Equal children share the shortfall equally, which is what a segmented /// control wants. #[ test ] fn equal_children_shrink_equally() { let mut w = vec![ 100.0_f32, 100.0, 100.0 ]; let cap = width_cap( &mut w, 150.0 ); assert_eq!( cap, 50.0 ); } #[ test ] fn nothing_shrinks_when_it_all_fits() { let mut w = vec![ 10.0_f32, 20.0 ]; assert_eq!( width_cap( &mut w, 100.0 ), f32::INFINITY ); } /// Pinned spacers are an explicit gap, not slack, so the shrink pass /// leaves them alone. #[ test ] fn pinned_spacers_keep_their_width_when_shrinking() { let canvas = make_canvas(); let r = row::<()>() .spacing( 0.0 ) .padding( 0.0 ) .push( crate::spacer().width( 60.0 ).height( 10.0 ) ) .push( crate::toggle( false ) ); let rect = Rect { x: 0.0, y: 0.0, width: 70.0, height: 48.0 }; let cells = r.layout( rect, &canvas ); assert_eq!( cells[ 0 ].0.width, 60.0 ); } #[ 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() ); } #[ test ] fn vmin_padding_doubles_around_content() { // 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height. let canvas = make_canvas(); let r = row::<()>().padding( Length::vmin( 4.0 ) ); let ( _, h ) = r.preferred_size( 500.0, &canvas ); assert_eq!( h, 48.0 ); } #[ test ] fn vmin_spacing_pins_visible_layout_gap() { // Two fixed-width spacers separated by a vmin spacing of 5 %. // Canvas vmin = 600 → 30 px between the inner edges. With both // spacers 10 px wide, the second one's `x` minus the first one's // `x + width` must equal the gap regardless of where the row chose // to anchor the cluster (centered, since there are no flex spacers). let canvas = make_canvas(); let r = row::<()>() .padding( 0.0 ) .spacing( Length::vmin( 5.0 ) ) .push( crate::spacer().width( 10.0 ) ) .push( crate::spacer().width( 10.0 ) ); let rect = Rect { x: 0., y: 0., width: 200., height: 48. }; let placed = r.layout( rect, &canvas ); assert_eq!( placed.len(), 2 ); let ( first_rect, _ ) = placed[ 0 ]; let ( second_rect, _ ) = placed[ 1 ]; let gap = second_rect.x - ( first_rect.x + first_rect.width ); assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" ); } }