add Carousel widget and WrapGrid::centre_last_row
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:
1
Makefile
1
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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
182
examples/carousel.rs
Normal 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() );
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
166
src/widget/carousel/mod.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/widget/carousel/tests.rs
Normal file
112
src/widget/carousel/tests.rs
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ) ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user