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.
183 lines
4.3 KiB
Rust
183 lines
4.3 KiB
Rust
// 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() );
|
|
}
|