Files
ltk/examples/carousel.rs
Pedro M. de Echanove Pasquin ccf07de593
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0)
Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded `app_id = "ltk"` on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state.
The `App` trait gains three mandatory methods, deliberately without default bodies so every application states its position: `app_id()` (reverse-DNS, used for `xdg_toplevel.set_app_id`, the AccessKit application name and the state directory — the `app_id` element of the deprecated `window_config` tuple is now ignored and a one-time warning reports a mismatch), `save_state() -> Option<Vec<u8>>` and `restore_state(Vec<u8>)`. The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example.
The runtime persists under `$XDG_STATE_HOME/<app_id>/` (falling back to `~/.local/state`): `session.json` holds the compositor session id, a clean-exit marker and the writer's pid; `state.bin` holds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which covers `on_close_requested`, `requested_exit` and lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death. `restore_state` runs synchronously in `try_run` before the window is created and before the first `view()`, and only when the process is relaunched as part of a session restore (`LTK_SESSION_RESTORE=1`, removed from the environment before the app can spawn children) or when the previous run left `clean_exit: false`; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first.
The compositor side of geometry restore goes through `xdg-session-management-v1`. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored under `protocols/` and `wayland-scanner` generates the client module in-tree (`src/protocol/`), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session(reason, stored_id)` and `restore_toplevel(toplevel, "main")`; the three window-creation paths in `run.rs` are folded into one `make_window` helper so the attach always sits immediately before `commit()`. `created` persists the id, `replaced` destroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery.
Every `App` implementor in the tree is updated: the twelve examples (`showcase`, `scroll` and `mini_shell` persist real state; the rest return `None`), both integration tests (`event_loop_flow` gains `save_restore_round_trip`), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog. `src/session_state.rs` carries unit tests over a temporary state directory. `Makefile install` now copies `protocols/` into the cargo registry — without it downstream builds would fail inside the proc-macro — and `debian/copyright` covers the vendored XML.
The trait change is breaking, hence 0.3.0. Also fixes the pre-existing `viewport_tests` module in `render/mod.rs`, which used `Length` without importing it and broke `cargo test`.
2026-08-15 10:16:30 +02:00

250 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 drives it three ways: Prev / Next buttons and arrow keys
//! snap to an index, and a pointer / touch drag pans it live through
//! the `App` horizontal-swipe hooks (`on_swipe_horizontal_progress`
//! for follow-the-finger, `on_swipe_left` / `on_swipe_right` for the
//! commit) — the same pattern crustace's homescreen pager uses.
//!
//! Esc quits. Arrow keys = Prev / Next. Drag horizontally to pan, or
//! step tile by tile with the mouse wheel.
//!
//! 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,
viewport_w: f32,
wheel_accum: f32,
}
const TILE_COUNT: usize = 7;
/// Horizontal padding around the carousel (the root column's 16 px per
/// side) — subtracted from the surface width to get the carousel's real
/// viewport, so the snap / drag math matches what the widget draws.
const H_PADDING: f32 = 32.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(), viewport_w: 800.0 - H_PADDING, wheel_accum: 0.0 }
}
fn snap_offset_for( &self, focused: usize ) -> f32
{
let stride = self.viewport_w * FOCUSED_FRAC + GAP;
-( focused as f32 ) * stride
}
}
impl App for CarouselApp
{
type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.carousel" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
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 · drag or wheel to pan · 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;
}
// Always re-snap: a drag committed at the first / last
// tile leaves the strip displaced otherwise.
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 on_resize( &mut self, width: u32, _height: u32 )
{
self.viewport_w = width as f32 - H_PADDING;
// Keep the focused tile centred through window resizes.
self.offset = self.snap_offset_for( self.focused );
}
// Wheel / touchpad scroll steps the strip one tile at a time. One
// wheel detent arrives as ~100-150 units (the compositor's ~10-15
// per detent times the runtime's wheel multiplier), touchpads as a
// continuous stream of small deltas — so accumulate up to a detent
// and step at most once per event, resetting the residue so a
// coarse wheel cannot burst through several tiles.
fn on_pointer_axis( &mut self, _x: f32, _y: f32, dx: f32, dy: f32 )
{
const DETENT: f32 = 100.0;
// Wheels report one axis at a time; take the dominant one so a
// tilt-wheel or horizontal touchpad flick also pans the strip.
self.wheel_accum += if dx.abs() > dy.abs() { dx } else { dy };
if self.wheel_accum.abs() < DETENT { return; }
let forward = self.wheel_accum > 0.0;
self.wheel_accum = 0.0;
if forward
{
if self.focused + 1 < TILE_COUNT { self.focused += 1; }
} else if self.focused > 0 {
self.focused -= 1;
}
self.offset = self.snap_offset_for( self.focused );
}
// Pointer / touch drag, the same pattern crustace's homescreen pager
// uses: live progress pans the strip, a committed swipe steps the
// focus, and the cancellation sample (0.0) snaps back.
fn swipe_horizontal_threshold( &self ) -> f32 { 0.35 }
fn on_swipe_horizontal_progress( &mut self, progress: f32 )
{
// `progress` is dx / (threshold × width): ±1.0 marks the commit
// threshold. A release without commit delivers one final 0.0.
let dx = progress * ( self.viewport_w + H_PADDING ) * self.swipe_horizontal_threshold();
let min = self.snap_offset_for( TILE_COUNT - 1 );
self.offset = ( self.snap_offset_for( self.focused ) + dx ).clamp( min, 0.0 );
}
fn on_swipe_left( &mut self ) -> Option<Message>
{
Some( Message::Next )
}
fn on_swipe_right( &mut self ) -> Option<Message>
{
Some( Message::Prev )
}
}
fn main()
{
ltk::run( CarouselApp::new() );
}