layout: adaptive grid and vertical flex; enforce the mechanical style rules
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

grid_min_cell( width ) adds the adaptive mode WrapGrid was missing: instead of a fixed column count, the count is derived at layout time from the available width so every cell is at least `width` wide (any Length, so a fluid threshold works; never fewer than one column), re-derived on every resize. Cells then share the width equally, and WrapGrid::max_columns( n ) caps the derived count so cells grow instead of multiplying on very wide surfaces — the cap only applies to the adaptive mode, grid( n ) keeps the fixed behaviour untouched. Four new layout tests cover width derivation, spacing accounting, the one-column floor and the cap; the row-wrap assertions check X positions rather than Y because zero-height spacer children produce zero-height rows.
flex( child ) now distributes leftover height inside a Column, mirroring its leftover-width behaviour in a Row: flex children join the weight pool alongside weight-only spacers and y-scrolls, draw their child inside the allocated share at full inner width, and contribute zero to the column's natural height exactly like a row counts flex width. This closes the documented "vertical flex is not yet implemented" gap; the Flex rustdoc and the widgets.md entry now describe both axes.
Add scripts/style-check.sh and a make stylecheck target, wired into CI: mechanical verification of the grep-checkable subset of code_style_guide.md — tab indentation and spaces inside attribute brackets — with comment lines skipped so prose may cite raw attribute syntax. To turn the check on green, the 82 unspaced attribute sites across 24 files (#[test], #[derive(...)], #[cfg(...)], #[allow(...)]) were normalized to the spaced form. CONTRIBUTING and the style guide reference the new target; brace placement and paren spacing remain review concerns.
This commit is contained in:
2026-07-30 22:20:21 +02:00
parent 1fd697aa6d
commit a7f953ca42
33 changed files with 380 additions and 115 deletions

View File

@@ -160,11 +160,14 @@ impl<Msg: Clone> Column<Msg>
fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32
{
// Spacers contribute 0 to natural height; spacing still applies between all children.
// 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::<f32>()
@@ -229,6 +232,7 @@ impl<Msg: Clone> Column<Msg>
{
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();
@@ -237,6 +241,9 @@ impl<Msg: Clone> Column<Msg>
.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 {
@@ -251,7 +258,8 @@ impl<Msg: Clone> Column<Msg>
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 spacers.
// `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
@@ -290,6 +298,16 @@ impl<Msg: Clone> Column<Msg>
};
( 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( _ ) )
@@ -438,4 +456,52 @@ mod tests
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 );
}
}

View File

@@ -33,8 +33,13 @@ pub struct WrapGrid<Msg: Clone>
{
/// Child widgets laid out in row-major order.
pub( crate ) children: Vec<Element<Msg>>,
/// Number of columns per row.
/// Number of columns per row. Ignored when `min_cell_width` is set.
pub( crate ) columns: usize,
/// Adaptive mode: derive the column count from the available width
/// so every cell is at least this wide. See [`grid_min_cell`].
pub( crate ) min_cell_width: Option<Length>,
/// Upper bound on the derived column count in adaptive mode.
pub( crate ) max_columns: Option<usize>,
/// Horizontal gap between cells.
pub( crate ) spacing_x: Length,
/// Vertical gap between rows.
@@ -93,6 +98,36 @@ impl<Msg: Clone> WrapGrid<Msg>
self
}
/// Cap the column count derived by [`grid_min_cell`] so cells stop
/// multiplying on very wide surfaces and grow instead. No effect on
/// a fixed-column [`grid`].
pub fn max_columns( mut self, n: usize ) -> Self
{
self.max_columns = Some( n );
self
}
/// Column count for the given inner width: fixed, or derived from
/// `min_cell_width` (as many columns as fit at least that wide,
/// never fewer than one, capped by `max_columns`).
fn effective_columns( &self, inner_w: f32, sx: f32, canvas: &Canvas ) -> usize
{
match self.min_cell_width
{
Some( m ) =>
{
let m = m.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).max( 1.0 );
let cols = ( ( ( inner_w + sx ) / ( m + sx ) ).floor() as usize ).max( 1 );
match self.max_columns
{
Some( cap ) => cols.min( cap.max( 1 ) ),
None => cols,
}
}
None => self.columns,
}
}
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
{
let vp = canvas.viewport_layout();
@@ -107,13 +142,13 @@ impl<Msg: Clone> WrapGrid<Msg>
/// 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
let ( sx, sy, pad ) = self.resolved( canvas );
let inner_w = (max_width - pad * 2.0).max( 0.0 );
let cols = self.effective_columns( inner_w, sx, canvas );
if self.children.is_empty() || cols == 0
{
return ( max_width, 0.0 );
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (max_width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let row_count = (self.children.len() + cols - 1) / cols;
@@ -135,13 +170,13 @@ impl<Msg: Clone> WrapGrid<Msg>
/// 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
let ( sx, sy, pad ) = self.resolved( canvas );
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
let cols = self.effective_columns( inner_w, sx, canvas );
if self.children.is_empty() || cols == 0
{
return Vec::new();
}
let ( sx, sy, pad ) = self.resolved( canvas );
let cols = self.columns;
let inner_w = (rect.width - pad * 2.0).max( 0.0 );
let cell_w = (inner_w - sx * (cols as f32 - 1.0)).max( 0.0 ) / cols as f32;
let x0 = rect.x + pad;
let mut y = rect.y + pad;
@@ -185,6 +220,8 @@ impl<Msg: Clone> WrapGrid<Msg>
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns,
min_cell_width: self.min_cell_width,
max_columns: self.max_columns,
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
@@ -201,7 +238,7 @@ impl<Msg: Clone + 'static> From<WrapGrid<Msg>> for Element<Msg>
}
}
#[cfg(test)]
#[ cfg( test ) ]
mod tests
{
use super::*;
@@ -221,7 +258,7 @@ mod tests
// --- preferred_size ---
#[test]
#[ test ]
fn empty_grid_height_is_zero()
{
let g: WrapGrid<()> = grid( 4 );
@@ -229,7 +266,7 @@ mod tests
assert_eq!( h, 0.0 );
}
#[test]
#[ test ]
fn preferred_width_equals_max_width()
{
let g = spacer_grid( 4, 8, 0.0, 0.0 );
@@ -239,7 +276,7 @@ mod tests
// --- layout: cell widths ---
#[test]
#[ test ]
fn cell_width_no_spacing_no_padding()
{
// 400px / 4 cols = 100px each
@@ -251,7 +288,7 @@ mod tests
for ( r, _ ) in &rects { assert!( (r.width - 100.0).abs() < 0.01 ); }
}
#[test]
#[ test ]
fn cell_width_with_spacing()
{
// (400 - 3 * 10) / 4 = 370 / 4 = 92.5
@@ -262,7 +299,7 @@ mod tests
for ( r, _ ) in &rects { assert!( (r.width - 92.5).abs() < 0.01 ); }
}
#[test]
#[ test ]
fn cell_width_with_padding()
{
// inner = 400 - 2*20 = 360; 360 / 4 = 90
@@ -275,7 +312,7 @@ mod tests
// --- layout: child count and indices ---
#[test]
#[ test ]
fn layout_yields_one_rect_per_child()
{
let g = spacer_grid( 4, 7, 0.0, 0.0 );
@@ -285,7 +322,7 @@ mod tests
assert_eq!( rects.len(), 7 );
}
#[test]
#[ test ]
fn layout_indices_are_sequential()
{
let g = spacer_grid( 3, 5, 0.0, 0.0 );
@@ -298,7 +335,7 @@ mod tests
// --- layout: column x-positions ---
#[test]
#[ test ]
fn column_x_positions_no_spacing()
{
// 300px / 3 cols = 100px each, starting at x=0
@@ -312,7 +349,7 @@ mod tests
assert!( (xs[2] - 200.0).abs() < 0.01 );
}
#[test]
#[ 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
@@ -329,7 +366,7 @@ mod tests
// --- layout: partial last row ---
#[test]
#[ test ]
fn partial_last_row_has_correct_count()
{
// 7 children, 4 cols => row 0: 4, row 1: 3.
@@ -343,7 +380,7 @@ mod tests
// --- layout: rect origin offset ---
#[test]
#[ test ]
fn layout_respects_rect_origin()
{
let g = spacer_grid( 2, 2, 0.0, 0.0 );
@@ -356,7 +393,7 @@ mod tests
// --- layout: centre_last_row ---
#[test]
#[ test ]
fn last_row_centred_when_partial()
{
// 3 children, 2 cols => row 0: 2 items, row 1: 1 item centred.
@@ -368,7 +405,7 @@ mod tests
assert!( (rects[2].0.x - 50.0).abs() < 0.01 );
}
#[test]
#[ test ]
fn centre_last_row_noop_on_full_row()
{
// 4 children, 2 cols => both rows full; nothing to centre.
@@ -380,7 +417,7 @@ mod tests
assert!( (rects[3].0.x - 100.0).abs() < 0.01 );
}
#[test]
#[ test ]
fn centre_last_row_off_by_default()
{
// Same case as above but without the flag — last item stays at x=0.
@@ -390,6 +427,71 @@ mod tests
let rects = g.layout( rect, &c );
assert!( rects[2].0.x.abs() < 0.01 );
}
// --- adaptive column count (grid_min_cell) ---
fn min_cell_grid( min: f32, n: usize, spacing: f32 ) -> WrapGrid<()>
{
let mut g = grid_min_cell( min ).spacing( spacing );
for _ in 0..n { g = g.push( spacer() ); }
g
}
#[ test ]
fn min_cell_derives_columns_from_width()
{
// 400px / min 90 => floor(400/90) = 4 columns, cells 100px.
let g = min_cell_grid( 90.0, 8, 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 );
for ( r, _ ) in &rects { assert!( ( r.width - 100.0 ).abs() < 0.01 ); }
// 8 children in 4 columns => index 3 sits in the last column and
// index 4 wraps back to x = 0 on the next row.
assert!( ( rects[3].0.x - 300.0 ).abs() < 0.01 );
assert!( rects[4].0.x.abs() < 0.01 );
}
#[ test ]
fn min_cell_accounts_for_spacing()
{
// With spacing 10: floor((300+10)/(90+10)) = 3 columns; the resulting
// cells ((300 - 2*10)/3 ≈ 93.3) still clear the 90px minimum.
let g = min_cell_grid( 90.0, 3, 10.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 );
assert!( rects.iter().all( |( r, _ )| r.width >= 90.0 ) );
assert!( ( rects[0].0.y - rects[2].0.y ).abs() < 0.01 );
}
#[ test ]
fn min_cell_never_below_one_column()
{
// Narrower than the minimum still lays out a single column:
// both children sit flush left at the full 80px width.
let g = min_cell_grid( 120.0, 2, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 80.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert_eq!( rects.len(), 2 );
assert!( rects[1].0.x.abs() < 0.01 );
for ( r, _ ) in &rects { assert!( ( r.width - 80.0 ).abs() < 0.01 ); }
}
#[ test ]
fn max_columns_caps_adaptive_count()
{
// 400px / min 90 would give 4; the cap keeps 2 and cells grow to 200.
let g = min_cell_grid( 90.0, 4, 0.0 ).max_columns( 2 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 400.0, height: 400.0 };
let rects = g.layout( rect, &c );
for ( r, _ ) in &rects { assert!( ( r.width - 200.0 ).abs() < 0.01 ); }
// Two columns: index 1 fills the second column, index 2 wraps.
assert!( ( rects[1].0.x - 200.0 ).abs() < 0.01 );
assert!( rects[2].0.x.abs() < 0.01 );
}
}
/// Create a grid layout with the given number of columns.
@@ -410,6 +512,50 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
children: Vec::new(),
columns,
min_cell_width: None,
max_columns: None,
spacing_x: Length::px( 8.0 ),
spacing_y: Length::px( 8.0 ),
padding: Length::px( 0.0 ),
centre_last_row: false,
}
}
/// Create an adaptive grid: the column count is derived at layout time
/// from the available width, fitting as many columns as possible while
/// keeping every cell at least `min_cell_width` wide (never fewer than
/// one). Cells then share the width equally, so they range between
/// `min_cell_width` and just under twice it — cap the count with
/// [`max_columns`](WrapGrid::max_columns) to let cells grow instead on
/// very wide surfaces.
///
/// Accepts logical `f32` pixels or any [`Length`] (e.g.
/// `Length::fluid( 96.0 )` for a threshold that scales with the
/// surface).
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use ltk::{ grid_min_cell, icon_button, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { Open( usize ) }
/// # fn _ex( data: Arc<Vec<u8>>, w: u32, h: u32 ) -> Element<Msg> {
/// scroll(
/// grid_min_cell( 96.0 )
/// .max_columns( 8 )
/// .spacing( 12.0 )
/// .push( icon_button( data, w, h ).on_press( Msg::Open( 0 ) ) )
/// // ...
/// )
/// .into()
/// # }
/// ```
pub fn grid_min_cell<Msg: Clone>( min_cell_width: impl Into<Length> ) -> WrapGrid<Msg>
{
WrapGrid
{
children: Vec::new(),
columns: 0,
min_cell_width: Some( min_cell_width.into() ),
max_columns: None,
spacing_x: Length::px( 8.0 ),
spacing_y: Length::px( 8.0 ),
padding: Length::px( 0.0 ),