add Carousel widget and WrapGrid::centre_last_row
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Forge's app switcher needs two layouts the existing widget set didn't cover. The desktop grid wants a partial last row centred under the rows above (3 tiles → row 1: two, row 2: one centred) so a 7-of-9 leftover band reads balanced rather than left-aligned. The mobile variant wants a horizontal carousel where the focused tile sits centred in the viewport at a configurable fraction of its width and its neighbours peek out on the sides at a fixed gap.
Extend `WrapGrid` with `centre_last_row( bool )`. When set, layout offsets a row that has fewer than `columns` children by `(missing * (cell_w + spacing)) / 2` so it stays centred inside the content rect. Defaults to false; every existing call site continues to land tiles flush-left. Covered by three layout tests (centred partial row, full row no-op, off-by-default).
Add the `Carousel` widget at `src/widget/carousel/`. It is a pure layout primitive: `focused_width_frac` (0.05–1.0, clamped), `gap` and `offset` are owned by the caller, leaving drag / inertia / snap policy to the host so the compositor can plug in its existing touch pipeline. Each child gets a rect at `base_x + idx * (child_w + gap)` and the full viewport height; `snap_offset( viewport_w, idx )` translates index to centring offset and `focused_index( viewport_w )` rounds the current offset back to the nearest tile. Plumbed into `Element::Carousel` with the matching arms in `widget/element.rs` and walker in `draw/layout.rs`; re-exported as `ltk::{ Carousel, carousel }`. Covered by nine unit tests (layout, offset shift, snap / focus round-trip, frac clamp, child height) plus a `cargo run --example carousel` demo with Prev / Next / arrow-key navigation against an external offset state. The example is wired into the `examples` Makefile target.
Updates the widget catalogue and the `widget/mod.rs` landing comment to list the carousel under "Clipping wrappers" and to mention `centre_last_row` in the grid section.
This commit is contained in:
2026-05-22 19:38:48 +02:00
parent 0e52274053
commit 88385e14b2
10 changed files with 604 additions and 22 deletions

View File

@@ -32,15 +32,18 @@ use crate::widget::Element;
pub struct WrapGrid<Msg: Clone>
{
/// Child widgets laid out in row-major order.
pub children: Vec<Element<Msg>>,
pub children: Vec<Element<Msg>>,
/// Number of columns per row.
pub columns: usize,
pub columns: usize,
/// Horizontal gap between cells (pixels).
pub spacing_x: f32,
pub spacing_x: f32,
/// Vertical gap between rows (pixels).
pub spacing_y: f32,
pub spacing_y: f32,
/// Padding on all sides (pixels).
pub padding: f32,
pub padding: f32,
/// When `true`, a partial last row is centred horizontally within
/// the grid's content rect instead of being left-aligned.
pub centre_last_row: bool,
}
impl<Msg: Clone> WrapGrid<Msg>
@@ -81,6 +84,14 @@ impl<Msg: Clone> WrapGrid<Msg>
self
}
/// Centre a partial last row horizontally inside the content rect.
/// Default is `false` (left-aligned, like other grids).
pub fn centre_last_row( mut self, yes: bool ) -> Self
{
self.centre_last_row = yes;
self
}
/// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{
@@ -133,9 +144,16 @@ impl<Msg: Clone> WrapGrid<Msg>
.map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
for col in 0..(end - start)
let items_in_row = end - start;
let row_offset = if self.centre_last_row && items_in_row < cols
{
let x = x0 + col as f32 * (cell_w + self.spacing_x);
let missing = (cols - items_in_row) as f32;
missing * (cell_w + self.spacing_x) / 2.0
} else { 0.0 };
for col in 0..items_in_row
{
let x = x0 + row_offset + col as f32 * (cell_w + self.spacing_x);
let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) );
}
@@ -151,11 +169,12 @@ impl<Msg: Clone> WrapGrid<Msg>
{
WrapGrid
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns,
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns,
spacing_x: self.spacing_x,
spacing_y: self.spacing_y,
padding: self.padding,
centre_last_row: self.centre_last_row,
}
}
}
@@ -320,6 +339,43 @@ mod tests
assert!( (rects[0].0.x - 50.0).abs() < 0.01 );
assert!( (rects[0].0.y - 30.0).abs() < 0.01 );
}
// --- layout: centre_last_row ---
#[test]
fn last_row_centred_when_partial()
{
// 3 children, 2 cols => row 0: 2 items, row 1: 1 item centred.
// cell_w = 200/2 = 100; centred-offset = (2-1)*100/2 = 50.
let g = spacer_grid( 2, 3, 0.0, 0.0 ).centre_last_row( true );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( (rects[2].0.x - 50.0).abs() < 0.01 );
}
#[test]
fn centre_last_row_noop_on_full_row()
{
// 4 children, 2 cols => both rows full; nothing to centre.
let g = spacer_grid( 2, 4, 0.0, 0.0 ).centre_last_row( true );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( rects[2].0.x.abs() < 0.01 );
assert!( (rects[3].0.x - 100.0).abs() < 0.01 );
}
#[test]
fn centre_last_row_off_by_default()
{
// Same case as above but without the flag — last item stays at x=0.
let g = spacer_grid( 2, 3, 0.0, 0.0 );
let c = canvas();
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 400.0 };
let rects = g.layout( rect, &c );
assert!( rects[2].0.x.abs() < 0.01 );
}
}
/// Create a grid layout with the given number of columns.
@@ -338,10 +394,11 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{
WrapGrid
{
children: Vec::new(),
children: Vec::new(),
columns,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
spacing_x: 8.0,
spacing_y: 8.0,
padding: 0.0,
centre_last_row: false,
}
}