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

166
src/widget/carousel/mod.rs Normal file
View File

@@ -0,0 +1,166 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Horizontal carousel: each child occupies `focused_width_frac` of the
//! viewport width, neighbours peek out to the sides. The carousel itself
//! is a pure layout primitive — the caller owns the scroll offset and
//! drives drag / inertia / snap externally. This keeps the widget side
//! stateless and lets the host compositor reuse its existing touch
//! pipeline.
//!
//! ```rust,no_run
//! # use ltk::{ carousel, container, spacer, Element };
//! # #[ derive( Clone ) ] enum Msg { Select( usize ) }
//! # fn _ex( offset: f32 ) -> Element<Msg> {
//! carousel()
//! .focused_width_frac( 0.8 )
//! .gap( 16.0 )
//! .offset( offset )
//! .push( container( spacer() ) )
//! .push( container( spacer() ) )
//! .into()
//! # }
//! ```
use crate::render::Canvas;
use crate::types::{ Rect, WidgetId };
use crate::widget::Element;
#[cfg(test)]
mod tests;
pub struct Carousel<Msg: Clone>
{
/// Child widgets in display order, left-to-right.
pub children: Vec<Element<Msg>>,
/// Optional stable identifier — used by the host as the key for its
/// drag / inertia / snap state.
pub id: Option<WidgetId>,
/// Each child's width as a fraction of the viewport width. 0.8 leaves
/// 10% on each side for the neighbours to peek out.
pub focused_width_frac: f32,
/// Horizontal gap between adjacent children, in logical pixels.
pub gap: f32,
/// Logical-pixel offset applied to every child. Positive values shift
/// children to the right (revealing later tiles). The host clamps,
/// snaps and animates this value.
pub offset: f32,
}
impl<Msg: Clone> Carousel<Msg>
{
pub fn push( mut self, child: impl Into<Element<Msg>> ) -> Self
{
self.children.push( child.into() );
self
}
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
pub fn focused_width_frac( mut self, f: f32 ) -> Self
{
self.focused_width_frac = f.clamp( 0.05, 1.0 );
self
}
pub fn gap( mut self, g: f32 ) -> Self
{
self.gap = g.max( 0.0 );
self
}
pub fn offset( mut self, o: f32 ) -> Self
{
self.offset = o;
self
}
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
{
if self.children.is_empty() { return ( max_width, 0.0 ); }
let child_w = ( max_width * self.focused_width_frac ).max( 1.0 );
let max_h = self.children.iter()
.map( |c| c.preferred_size( child_w, canvas ).1 )
.fold( 0.0_f32, f32::max );
( max_width, max_h )
}
/// Snap-target offset that centres `idx` in the viewport. Positive
/// values shift the carousel content right — i.e. the negation of
/// the natural "scroll x" so callers can pass it straight back into
/// [`offset()`](Self::offset).
pub fn snap_offset( &self, viewport_w: f32, idx: usize ) -> f32
{
if self.children.is_empty() { return 0.0; }
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
let stride = child_w + self.gap;
-( idx as f32 ) * stride
}
/// Index of the child whose centre is closest to the viewport centre,
/// given the current `offset`. Used by the host to decide which tile a
/// release should snap to and to compute the keyboard-navigation target.
pub fn focused_index( &self, viewport_w: f32 ) -> usize
{
if self.children.is_empty() { return 0; }
let child_w = ( viewport_w * self.focused_width_frac ).max( 1.0 );
let stride = child_w + self.gap;
let raw = -self.offset / stride;
raw.round().clamp( 0.0, ( self.children.len() - 1 ) as f32 ) as usize
}
pub fn layout( &self, rect: Rect, _canvas: &Canvas ) -> Vec<( Rect, usize )>
{
if self.children.is_empty() { return Vec::new(); }
let child_w = ( rect.width * self.focused_width_frac ).max( 1.0 );
let base_x = rect.x + ( rect.width - child_w ) / 2.0 + self.offset;
let stride = child_w + self.gap;
self.children.iter().enumerate().map( |( i, _ )|
{
let x = base_x + ( i as f32 ) * stride;
( Rect { x, y: rect.y, width: child_w, height: rect.height }, i )
}).collect()
}
pub fn draw( &self ) {}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Carousel<U>
where
U: Clone + 'static,
Msg: 'static,
{
Carousel
{
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
id: self.id,
focused_width_frac: self.focused_width_frac,
gap: self.gap,
offset: self.offset,
}
}
}
impl<Msg: Clone + 'static> From<Carousel<Msg>> for Element<Msg>
{
fn from( c: Carousel<Msg> ) -> Self
{
Element::Carousel( c )
}
}
pub fn carousel<Msg: Clone>() -> Carousel<Msg>
{
Carousel
{
children: Vec::new(),
id: None,
focused_width_frac: 0.8,
gap: 16.0,
offset: 0.0,
}
}

View File

@@ -0,0 +1,112 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use super::*;
use crate::layout::spacer::spacer;
use crate::render::Canvas;
fn canvas() -> Canvas { Canvas::new( 1, 1 ) }
fn three_spacer_carousel() -> Carousel<()>
{
carousel()
.focused_width_frac( 0.8 )
.gap( 0.0 )
.push( spacer() )
.push( spacer() )
.push( spacer() )
}
#[test]
fn empty_layout_is_empty()
{
let c: Carousel<()> = carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
assert!( c.layout( rect, &canvas() ).is_empty() );
}
#[test]
fn first_child_centred_at_offset_zero()
{
let c = three_spacer_carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
// child_w = 80, base_x = (100 - 80)/2 = 10
assert!( ( rects[0].0.x - 10.0 ).abs() < 0.01 );
assert!( ( rects[0].0.width - 80.0 ).abs() < 0.01 );
}
#[test]
fn children_strided_by_child_width_plus_gap()
{
let c = carousel::<()>()
.focused_width_frac( 0.5 )
.gap( 8.0 )
.push( spacer() )
.push( spacer() );
let rect = Rect { x: 0.0, y: 0.0, width: 200.0, height: 100.0 };
let rects = c.layout( rect, &canvas() );
// child_w = 100; stride = 108; base_x = (200 - 100)/2 = 50
assert!( ( rects[1].0.x - ( rects[0].0.x + 108.0 ) ).abs() < 0.01 );
}
#[test]
fn snap_offset_centres_target_index()
{
let c = three_spacer_carousel();
// child_w = 80, stride = 80; index 2 → offset -160 to land it centred.
assert!( ( c.snap_offset( 100.0, 2 ) - ( -160.0 ) ).abs() < 0.01 );
}
#[test]
fn focused_index_rounds_to_nearest_tile()
{
let mut c = three_spacer_carousel();
c.offset = -120.0; // halfway between index 1 (-80) and index 2 (-160) → rounds away from 0
assert_eq!( c.focused_index( 100.0 ), 2 );
c.offset = -100.0; // closer to index 1
assert_eq!( c.focused_index( 100.0 ), 1 );
}
#[test]
fn focused_index_clamps_to_valid_range()
{
let mut c = three_spacer_carousel();
c.offset = 500.0;
assert_eq!( c.focused_index( 100.0 ), 0 );
c.offset = -10_000.0;
assert_eq!( c.focused_index( 100.0 ), 2 );
}
#[test]
fn offset_shifts_all_children_horizontally()
{
let c = three_spacer_carousel().offset( -25.0 );
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
// child_w=80, base_x_at_offset_0 = 10. With offset = -25, base_x = -15.
assert!( ( rects[0].0.x - ( -15.0 ) ).abs() < 0.01 );
// Stride = 80, so each subsequent child is +80 of the previous.
assert!( ( rects[1].0.x - rects[0].0.x - 80.0 ).abs() < 0.01 );
}
#[test]
fn focused_width_frac_is_clamped_to_unit_range()
{
let c: Carousel<()> = carousel().focused_width_frac( 5.0 ).push( spacer() );
assert!( c.focused_width_frac <= 1.0 );
let c2: Carousel<()> = carousel().focused_width_frac( -3.0 ).push( spacer() );
assert!( c2.focused_width_frac > 0.0 );
}
#[test]
fn children_use_full_rect_height()
{
let c = three_spacer_carousel();
let rect = Rect { x: 0.0, y: 0.0, width: 100.0, height: 200.0 };
let rects = c.layout( rect, &canvas() );
for ( r, _ ) in &rects {
assert!( ( r.height - 200.0 ).abs() < 0.01 );
assert!( r.y.abs() < 0.01 );
}
}

View File

@@ -5,9 +5,9 @@ use std::sync::Arc;
use crate::types::{ Point, Rect };
use crate::render::Canvas;
use super::{
anchored_overlay, button, checkbox, container, external, flex, image,
list_item, pressable, progress_bar, radio, scroll, separator, slider,
spinner, text, text_edit, toggle, viewport, vslider, window_button,
anchored_overlay, button, carousel, checkbox, container, external, flex,
image, list_item, pressable, progress_bar, radio, scroll, separator,
slider, spinner, text, text_edit, toggle, viewport, vslider, window_button,
};
use super::handlers::WidgetHandlers;
use super::MapFn;
@@ -40,6 +40,7 @@ pub enum Element<Msg: Clone>
AnchoredOverlay( anchored_overlay::AnchoredOverlay<Msg> ),
Spinner( spinner::Spinner ),
External( external::External ),
Carousel( carousel::Carousel<Msg> ),
}
impl<Msg: Clone> Element<Msg>
@@ -74,6 +75,7 @@ impl<Msg: Clone> Element<Msg>
Element::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ),
Element::Spinner( s ) => s.preferred_size( max_width ),
Element::External( e ) => e.preferred_size( max_width ),
Element::Carousel( c ) => c.preferred_size( max_width, canvas ),
}
}
@@ -116,6 +118,7 @@ impl<Msg: Clone> Element<Msg>
Element::AnchoredOverlay( _ ) => {}
Element::Spinner( s ) => s.draw( canvas, rect ),
Element::External( e ) => e.draw( canvas, rect ),
Element::Carousel( _ ) => {}
}
}
@@ -408,6 +411,7 @@ impl<Msg: Clone + 'static> Element<Msg>
Element::AnchoredOverlay( a ) => Element::AnchoredOverlay( a.map_msg( f ) ),
Element::Spinner( s ) => Element::Spinner( s ),
Element::External( e ) => Element::External( e ),
Element::Carousel( c ) => Element::Carousel( c.map_msg( f ) ),
}
}
}

View File

@@ -36,8 +36,10 @@
//! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
//! [`container::Container`].
//! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven
//! scrolling), [`viewport::Viewport`] (passive clip / fade), and
//! [`flex::Flex`] (treats a non-spacer child as a row filler).
//! scrolling), [`viewport::Viewport`] (passive clip / fade),
//! [`flex::Flex`] (treats a non-spacer child as a row filler), and
//! [`carousel::Carousel`] (horizontal focused-tile carousel with
//! host-controlled offset).
//! * **Overlays**: [`dialog::Dialog`] (modal / non-modal centered
//! confirmation card with built-in scrim, ESC-to-cancel, and
//! tap-outside-to-dismiss for the non-modal variant).
@@ -87,6 +89,7 @@ pub mod time_picker;
pub mod color_picker;
pub mod dialog;
pub mod external;
pub mod carousel;
pub mod element;
pub mod handlers;