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

@@ -44,6 +44,7 @@ examples:
LTK_THEMES_DIR=themes cargo run --release --example widgets LTK_THEMES_DIR=themes cargo run --release --example widgets
LTK_THEMES_DIR=themes cargo run --release --example pickers LTK_THEMES_DIR=themes cargo run --release --example pickers
LTK_THEMES_DIR=themes cargo run --release --example dialog LTK_THEMES_DIR=themes cargo run --release --example dialog
LTK_THEMES_DIR=themes cargo run --release --example carousel
clean: clean:
dh_clean dh_clean

View File

@@ -22,7 +22,7 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md).
- [Decoration and chrome](#decoration-and-chrome) - [Decoration and chrome](#decoration-and-chrome)
- [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget) - [`container`](#container) · [`separator`](#separator) · [`img_widget`](#img_widget)
- [Clipping wrappers](#clipping-wrappers) - [Clipping wrappers](#clipping-wrappers)
- [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) - [`scroll`](#scroll) · [`viewport`](#viewport) · [`flex`](#flex) · [`carousel`](#carousel)
- [Overlays and feedback](#overlays-and-feedback) - [Overlays and feedback](#overlays-and-feedback)
- [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog) - [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`dialog`](#dialog)
- [Pickers](#pickers) - [Pickers](#pickers)
@@ -522,6 +522,47 @@ spacer siblings.
**See also**: [`spacer`](#spacer) for invisible fillers, **See also**: [`spacer`](#spacer) for invisible fillers,
[`column`](#column) and [`row`](#row). [`column`](#column) and [`row`](#row).
### `carousel`
Horizontal carousel: the focused child sits centred in the viewport at
`focused_width_frac` of the viewport width and its neighbours peek out
on the left / right at `gap` separation. The widget is a pure layout
primitive — the `offset` (positive shifts content right) is owned by
the caller, so drag / inertia / snap-ease live in the host (compositor,
gesture recogniser, etc.).
**When**: mobile-style app switchers, image / story strips, any
single-focus horizontal navigation where neighbours hint at the next /
previous tile.
```rust,no_run
# use ltk::{ button, carousel, container, Element, Color, Corners };
# #[ derive( Clone ) ] enum Msg { Open( usize ) }
# fn _ex( offset: f32 ) -> Element<Msg> {
carousel()
.focused_width_frac( 0.8 )
.gap( 16.0 )
.offset( offset )
.push( container( button::<Msg>( "Tile 1" ).on_press( Msg::Open( 0 ) ) )
.background( Color::rgb( 0.95, 0.4, 0.4 ) )
.radius( Corners::all( 12.0 ) ) )
.push( container( button::<Msg>( "Tile 2" ).on_press( Msg::Open( 1 ) ) )
.background( Color::rgb( 0.95, 0.85, 0.3 ) )
.radius( Corners::all( 12.0 ) ) )
.into()
# }
```
Helpers on the widget translate between offsets and indices:
`snap_offset( viewport_w, idx )` returns the offset that centres tile
`idx`; `focused_index( viewport_w )` rounds the current offset to the
nearest tile. The runnable demo at
[`examples/carousel.rs`](../examples/carousel.rs) shows Prev / Next
buttons and arrow-key navigation against an external `offset` state.
**See also**: [`scroll`](#scroll) for free vertical / horizontal panning
of arbitrary content; [`tabs`](#tabs) for a non-touch alternative.
--- ---
## Overlays and feedback ## Overlays and feedback
@@ -834,6 +875,11 @@ grid( 4 )
Wrap inside [`scroll`](#scroll) when the grid may overflow. Wrap inside [`scroll`](#scroll) when the grid may overflow.
`centre_last_row( true )` shifts a partial last row so its tiles sit
centred under the full rows above instead of left-aligned — useful for
app switchers and gallery layouts where a 7-of-9 leftover band reads
better balanced.
### `spacer` ### `spacer`
An invisible flexible filler. Inside a column / row, absorbs leftover An invisible flexible filler. Inside a column / row, absorbs leftover

182
examples/carousel.rs Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `cargo run --example carousel`
//!
//! Demonstrates the `carousel()` widget: the focused tile sits centred
//! in the viewport at `focused_width_frac` of its width, and its
//! neighbours peek out on the left / right at `gap` separation.
//!
//! The carousel widget itself is a stateless layout primitive — the
//! `offset` (positive shifts content right) is owned by the host. The
//! example mutates that offset directly when Prev / Next / arrow keys
//! change the focused index; in a real touch-driven app the host would
//! drive it from drag / inertia / snap-ease.
//!
//! Esc quits. Arrow keys = Prev / Next.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a
//! running Wayland compositor.
use ltk::{
App, ButtonVariant, Color, Corners, Element, Keysym,
button, carousel, column, container, row, spacer, text,
};
#[ derive( Clone ) ]
enum Message
{
Prev,
Next,
Tile( usize ),
}
struct CarouselApp
{
focused: usize,
offset: f32,
last_msg: String,
}
const TILE_COUNT: usize = 7;
/// Approximate viewport width used to translate "focused index → offset".
/// Real apps would derive this from the laid-out viewport rect; for an
/// example the constant keeps the focus / step math obvious.
const VIEWPORT_W: f32 = 800.0;
const FOCUSED_FRAC: f32 = 0.7;
const GAP: f32 = 16.0;
const COLORS: [( f32, f32, f32 ); TILE_COUNT] = [
( 0.95, 0.40, 0.40 ),
( 0.95, 0.65, 0.30 ),
( 0.95, 0.85, 0.30 ),
( 0.50, 0.85, 0.35 ),
( 0.35, 0.80, 0.85 ),
( 0.45, 0.55, 0.95 ),
( 0.75, 0.45, 0.90 ),
];
impl CarouselApp
{
fn new() -> Self
{
Self { focused: 0, offset: 0.0, last_msg: String::new() }
}
fn snap_offset_for( &self, focused: usize ) -> f32
{
let stride = VIEWPORT_W * FOCUSED_FRAC + GAP;
-( focused as f32 ) * stride
}
}
impl App for CarouselApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let mut car = carousel::<Message>()
.focused_width_frac( FOCUSED_FRAC )
.gap( GAP )
.offset( self.offset );
for i in 0..TILE_COUNT
{
let ( r, g, b ) = COLORS[i];
let tile = container::<Message>(
column::<Message>()
.padding( 24.0 )
.spacing( 12.0 )
.push( text( format!( "Tile {}", i + 1 ) ).size( 28.0 ).color( Color::WHITE ).align_center() )
.push( spacer() )
.push(
button::<Message>( "Activate" )
.variant( ButtonVariant::Primary )
.on_press( Message::Tile( i ) ),
),
)
.background( Color::rgb( r, g, b ) )
.radius( Corners::all( 12.0 ) );
car = car.push( tile );
}
let status_line = if self.last_msg.is_empty()
{
text( "← / → cycle · click Activate to fire Message::Tile · Esc quits" )
.size( 12.0 )
.color( secondary )
.align_center()
} else {
text( &self.last_msg )
.size( 12.0 )
.color( secondary )
.align_center()
};
column::<Message>()
.padding( 16.0 )
.spacing( 12.0 )
.push( text( "ltk — carousel showcase" ).size( 22.0 ).color( primary ).align_center() )
.push(
row::<Message>()
.spacing( 8.0 )
.push( button::<Message>( "◀ Prev" ).on_press( Message::Prev ) )
.push( spacer() )
.push( text( format!( "Focused: {} / {}", self.focused + 1, TILE_COUNT ) ).size( 14.0 ).color( secondary ) )
.push( spacer() )
.push( button::<Message>( "Next ▶" ).on_press( Message::Next ) ),
)
.push( car )
.push( status_line )
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::Prev =>
{
if self.focused > 0
{
self.focused -= 1;
self.offset = self.snap_offset_for( self.focused );
}
}
Message::Next =>
{
if self.focused + 1 < TILE_COUNT
{
self.focused += 1;
self.offset = self.snap_offset_for( self.focused );
}
}
Message::Tile( i ) =>
{
self.last_msg = format!( "Pressed tile {}", i + 1 );
}
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
match keysym
{
Keysym::Escape => { std::process::exit( 0 ); }
Keysym::Left => Some( Message::Prev ),
Keysym::Right => Some( Message::Next ),
_ => None,
}
}
}
fn main()
{
ltk::run( CarouselApp::new() );
}

View File

@@ -67,6 +67,16 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
} }
idx idx
} }
Element::Carousel( car ) =>
{
let child_rects = car.layout( rect, canvas );
let mut idx = flat_idx;
for ( child_rect, child_i ) in child_rects
{
idx = layout_and_draw::<Msg>( &car.children[child_i], canvas, child_rect, ctx, idx );
}
idx
}
Element::Flex( f ) => Element::Flex( f ) =>
{ {
// `Flex` is invisible chrome: it claimed leftover width up at // `Flex` is invisible chrome: it claimed leftover width up at

View File

@@ -32,15 +32,18 @@ use crate::widget::Element;
pub struct WrapGrid<Msg: Clone> pub struct WrapGrid<Msg: Clone>
{ {
/// Child widgets laid out in row-major order. /// Child widgets laid out in row-major order.
pub children: Vec<Element<Msg>>, pub children: Vec<Element<Msg>>,
/// Number of columns per row. /// Number of columns per row.
pub columns: usize, pub columns: usize,
/// Horizontal gap between cells (pixels). /// Horizontal gap between cells (pixels).
pub spacing_x: f32, pub spacing_x: f32,
/// Vertical gap between rows (pixels). /// Vertical gap between rows (pixels).
pub spacing_y: f32, pub spacing_y: f32,
/// Padding on all sides (pixels). /// 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> impl<Msg: Clone> WrapGrid<Msg>
@@ -81,6 +84,14 @@ impl<Msg: Clone> WrapGrid<Msg>
self 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. /// Compute the preferred size given an available width.
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) 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 ) .map( |c| c.preferred_size( cell_w, canvas ).1 )
.fold( 0.0_f32, f32::max ); .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 }; let crect = Rect { x, y, width: cell_w, height: row_h };
out.push( ( crect, start + col ) ); out.push( ( crect, start + col ) );
} }
@@ -151,11 +169,12 @@ impl<Msg: Clone> WrapGrid<Msg>
{ {
WrapGrid WrapGrid
{ {
children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(), children: self.children.into_iter().map( |c| c.map_arc( f ) ).collect(),
columns: self.columns, columns: self.columns,
spacing_x: self.spacing_x, spacing_x: self.spacing_x,
spacing_y: self.spacing_y, spacing_y: self.spacing_y,
padding: self.padding, 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.x - 50.0).abs() < 0.01 );
assert!( (rects[0].0.y - 30.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. /// Create a grid layout with the given number of columns.
@@ -338,10 +394,11 @@ pub fn grid<Msg: Clone>( columns: usize ) -> WrapGrid<Msg>
{ {
WrapGrid WrapGrid
{ {
children: Vec::new(), children: Vec::new(),
columns, columns,
spacing_x: 8.0, spacing_x: 8.0,
spacing_y: 8.0, spacing_y: 8.0,
padding: 0.0, padding: 0.0,
centre_last_row: false,
} }
} }

View File

@@ -244,6 +244,7 @@ pub use layout::stack::{ Stack, stack, HAlign, VAlign };
pub use layout::wrap_grid::{ WrapGrid, grid }; pub use layout::wrap_grid::{ WrapGrid, grid };
pub use widget::scroll::{ scroll, ScrollAxis }; pub use widget::scroll::{ scroll, ScrollAxis };
pub use widget::viewport::{ Viewport, viewport }; pub use widget::viewport::{ Viewport, viewport };
pub use widget::carousel::{ Carousel, carousel };
pub use app::run; pub use app::run;
pub use app::{ try_run, RunError }; pub use app::{ try_run, RunError };
pub use smithay_client_toolkit::seat::keyboard::Keysym; pub use smithay_client_toolkit::seat::keyboard::Keysym;

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::types::{ Point, Rect };
use crate::render::Canvas; use crate::render::Canvas;
use super::{ use super::{
anchored_overlay, button, checkbox, container, external, flex, image, anchored_overlay, button, carousel, checkbox, container, external, flex,
list_item, pressable, progress_bar, radio, scroll, separator, slider, image, list_item, pressable, progress_bar, radio, scroll, separator,
spinner, text, text_edit, toggle, viewport, vslider, window_button, slider, spinner, text, text_edit, toggle, viewport, vslider, window_button,
}; };
use super::handlers::WidgetHandlers; use super::handlers::WidgetHandlers;
use super::MapFn; use super::MapFn;
@@ -40,6 +40,7 @@ pub enum Element<Msg: Clone>
AnchoredOverlay( anchored_overlay::AnchoredOverlay<Msg> ), AnchoredOverlay( anchored_overlay::AnchoredOverlay<Msg> ),
Spinner( spinner::Spinner ), Spinner( spinner::Spinner ),
External( external::External ), External( external::External ),
Carousel( carousel::Carousel<Msg> ),
} }
impl<Msg: Clone> Element<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::AnchoredOverlay( a ) => a.child.preferred_size( max_width, canvas ),
Element::Spinner( s ) => s.preferred_size( max_width ), Element::Spinner( s ) => s.preferred_size( max_width ),
Element::External( e ) => e.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::AnchoredOverlay( _ ) => {}
Element::Spinner( s ) => s.draw( canvas, rect ), Element::Spinner( s ) => s.draw( canvas, rect ),
Element::External( e ) => e.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::AnchoredOverlay( a ) => Element::AnchoredOverlay( a.map_msg( f ) ),
Element::Spinner( s ) => Element::Spinner( s ), Element::Spinner( s ) => Element::Spinner( s ),
Element::External( e ) => Element::External( e ), 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`], //! * **Images / decoration**: [`image::Image`], [`separator::Separator`],
//! [`container::Container`]. //! [`container::Container`].
//! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven //! * **Clipping wrappers**: [`scroll::Scroll`] (with gesture-driven
//! scrolling), [`viewport::Viewport`] (passive clip / fade), and //! scrolling), [`viewport::Viewport`] (passive clip / fade),
//! [`flex::Flex`] (treats a non-spacer child as a row filler). //! [`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 //! * **Overlays**: [`dialog::Dialog`] (modal / non-modal centered
//! confirmation card with built-in scrim, ESC-to-cancel, and //! confirmation card with built-in scrim, ESC-to-cancel, and
//! tap-outside-to-dismiss for the non-modal variant). //! tap-outside-to-dismiss for the non-modal variant).
@@ -87,6 +89,7 @@ pub mod time_picker;
pub mod color_picker; pub mod color_picker;
pub mod dialog; pub mod dialog;
pub mod external; pub mod external;
pub mod carousel;
pub mod element; pub mod element;
pub mod handlers; pub mod handlers;