First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

227
examples/combo.rs Normal file
View File

@@ -0,0 +1,227 @@
//! `cargo run --example combo`
//!
//! Showcases the `combo` widget — a select / dropdown with editable
//! query, multi-select chips and a scrollable popup.
//!
//! The trigger lives in the main `view()` tree; the popup is returned
//! from [`App::overlays`] as a real Wayland **xdg-popup** child of the
//! main window, so it can extend outside the parent surface (the
//! canonical select / dropdown behaviour). The compositor positions the
//! popup adjacent to the trigger pill and dismisses it via
//! `popup_done` when the user clicks outside, which fires the spec's
//! `on_dismiss` message.
//!
//! Tap on an item to add it to the selection; tap on the `×` next to a
//! chip to remove it; tap outside the popup to dismiss. Esc exits.
use ltk::
{
combo, column, row, separator, spacer, text,
App, Color, ComboState, Element, Keysym, OverlaySpec, WidgetId,
};
// Distinct anchor ids so each combo's popup can find its own trigger
// pill in the previous-frame layout snapshot.
const FRUIT_ANCHOR: WidgetId = WidgetId( "demo-fruit-trigger" );
const REGION_ANCHOR: WidgetId = WidgetId( "demo-region-trigger" );
#[ derive( Clone, Debug ) ]
enum Msg
{
// Multi-select fruit picker.
FruitToggle,
FruitQuery( String ),
FruitSelect( usize ),
FruitUnselect( usize ),
FruitDismiss,
// Single-select region picker (non-searchable).
RegionToggle,
RegionSelect( usize ),
RegionDismiss,
}
struct Demo
{
fruits: ComboState,
fruit_items: Vec<String>,
region: ComboState,
region_items: Vec<String>,
}
impl Demo
{
fn new() -> Self
{
Self
{
fruits: ComboState::new(),
fruit_items: [
"Apple", "Apricot", "Avocado", "Banana", "Blackberry",
"Blueberry", "Cherry", "Date", "Fig", "Grape", "Kiwi",
"Lemon", "Lime", "Mango", "Orange", "Papaya", "Peach",
"Pear", "Pineapple", "Plum", "Raspberry", "Strawberry",
].iter().map( |s| s.to_string() ).collect(),
region: ComboState::new(),
region_items: [
"Galicia", "Asturias", "Cantabria", "País Vasco",
"Navarra", "La Rioja", "Aragón", "Cataluña",
].iter().map( |s| s.to_string() ).collect(),
}
}
fn fruit_combo( &self ) -> ltk::Combo<Msg>
{
combo( self.fruits.clone(), self.fruit_items.clone() )
.label( "Fruits" )
.description( "Pick the ones you want in the smoothie." )
.placeholder( "Type to filter…" )
.helper( "Selected fruits show up as chips above the field." )
.multi_select( true )
.searchable( true )
.anchor_id( FRUIT_ANCHOR )
.popup_max_height( 280.0 )
.on_query_change( Msg::FruitQuery )
.on_toggle_open( Msg::FruitToggle )
.on_select_idx( Msg::FruitSelect )
.on_unselect_idx( Msg::FruitUnselect )
.on_dismiss( Msg::FruitDismiss )
}
fn region_combo( &self ) -> ltk::Combo<Msg>
{
combo( self.region.clone(), self.region_items.clone() )
.label( "Region" )
.description( "Single-select, non-searchable." )
.placeholder( "Choose a region…" )
.anchor_id( REGION_ANCHOR )
.popup_max_height( 240.0 )
.on_toggle_open( Msg::RegionToggle )
.on_select_idx( Msg::RegionSelect )
.on_dismiss( Msg::RegionDismiss )
}
fn body( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
column::<Msg>()
.padding( 32.0 )
.spacing( 24.0 )
.center_y( false )
.push(
text( "ltk — combo / select demo" )
.size( 24.0 )
.color( palette.text_primary )
.align_center(),
)
.push( separator() )
.push( self.fruit_combo().trigger() )
.push( separator() )
.push( self.region_combo().trigger() )
.push( spacer() )
.push(
row::<Msg>()
.padding( 0.0 )
.spacing( 8.0 )
.push(
text( format!( "Fruits selected: {}", self.fruits.selected.len() ) )
.size( 14.0 )
.color( palette.text_secondary ),
)
.push( spacer() )
.push(
text(
self.region.selected.first()
.and_then( |&i| self.region_items.get( i ) )
.map( |s| format!( "Region: {s}" ) )
.unwrap_or_else( || "Region: —".to_string() ),
)
.size( 14.0 )
.color( palette.text_secondary ),
),
)
.push(
text( "Esc = quit" )
.size( 12.0 )
.color( palette.text_secondary )
.align_center(),
)
.into()
}
}
impl App for Demo
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
self.body()
}
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
// Each open combo contributes one xdg-popup overlay anchored
// to its trigger pill; closed combos contribute nothing.
let mut out = Vec::new();
if let Some( o ) = self.fruit_combo().overlay() { out.push( o ); }
if let Some( o ) = self.region_combo().overlay() { out.push( o ); }
out
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::FruitToggle => self.fruits.toggle_open(),
Msg::FruitQuery( q ) => self.fruits.query = q,
Msg::FruitSelect( i ) =>
{
if self.fruits.selected.contains( &i )
{
self.fruits.unselect( i );
}
else
{
self.fruits.select( i );
}
}
Msg::FruitUnselect( i ) => self.fruits.unselect( i ),
Msg::FruitDismiss =>
{
self.fruits.is_open = false;
self.fruits.query.clear();
}
Msg::RegionToggle => self.region.toggle_open(),
Msg::RegionSelect( i ) =>
{
// Single-select: replace the whole `selected` vector
// with just this index, then close.
self.region.selected = vec![ i ];
self.region.is_open = false;
}
Msg::RegionDismiss => self.region.is_open = false,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( Demo::new() );
}

231
examples/dialog.rs Normal file
View File

@@ -0,0 +1,231 @@
//! Example for the `dialog` widget.
//!
//! Demonstrates the three shapes the widget covers:
//!
//! * **Modal confirm** — title + subtitle + "Cancel" / "Delete"
//! actions. Backed by `cancel( Msg::Cancel )` so pressing `Esc`
//! matches tapping the Cancel button. Pointer events outside the
//! card are silently absorbed.
//! * **Non-modal pick** — `modal( false ) + dismiss_on_scrim( … )`. A
//! tap on the scrim outside the card emits the dismiss message; ESC
//! still fires `cancel`.
//! * **Custom body** — a body element (here, a slider) injected
//! between the subtitle and the action row.
use ltk::{
App, ButtonVariant, Element,
button, column, dialog, row, slider, spacer, stack, text,
};
#[ derive( Clone, Copy, PartialEq, Eq ) ]
enum Open
{
None,
Modal,
NonModal,
CustomBody,
}
#[ derive( Clone ) ]
enum Msg
{
Open( Open ),
Close,
Confirm,
PickAlpha,
PickBeta,
PickGamma,
BrightnessChanged( f32 ),
}
struct DialogApp
{
open: Open,
last_event: String,
brightness: f32,
}
impl DialogApp
{
fn new() -> Self
{
Self
{
open: Open::None,
last_event: "(no dialog interactions yet)".to_string(),
brightness: 0.5,
}
}
}
impl App for DialogApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let header = column::<Msg>()
.spacing( 8.0 )
.push( text( "ltk · dialog widget" ).size( 24.0 ).color( primary ) )
.push( text( "Open one of the dialogs below. ESC always fires the dialog's `cancel` message." )
.size( 14.0 )
.color( secondary )
.wrap( true ) );
let openers = row::<Msg>()
.spacing( 12.0 )
.push(
button::<Msg>( "Modal confirm" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Open( Open::Modal ) ),
)
.push(
button::<Msg>( "Non-modal pick" )
.variant( ButtonVariant::Secondary )
.on_press( Msg::Open( Open::NonModal ) ),
)
.push(
button::<Msg>( "Custom body" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Open( Open::CustomBody ) ),
);
let event_label = text( format!( "Last event: {}", self.last_event ) )
.size( 13.0 )
.color( secondary )
.wrap( true );
let main = column::<Msg>()
.spacing( 24.0 )
.padding( 32.0 )
.push( header )
.push( openers )
.push( spacer() )
.push( event_label );
// Build the active dialog (if any) and stack it on top of the
// main column. Stack draws children in order, so the dialog
// always sits over the main UI.
let overlay: Option<Element<Msg>> = match self.open
{
Open::None => None,
Open::Modal => Some( modal_confirm() ),
Open::NonModal => Some( non_modal_pick() ),
Open::CustomBody => Some( custom_body_dialog( self.brightness ) ),
};
match overlay
{
Some( o ) =>
{
stack::<Msg>()
.push( main )
.push( o )
.into()
}
None => main.into(),
}
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Open( o ) => { self.open = o; self.last_event = "opened".into(); }
Msg::Close => { self.open = Open::None; self.last_event = "cancelled (button or ESC or scrim)".into(); }
Msg::Confirm => { self.open = Open::None; self.last_event = "confirmed".into(); }
Msg::PickAlpha => { self.open = Open::None; self.last_event = "picked Alpha".into(); }
Msg::PickBeta => { self.open = Open::None; self.last_event = "picked Beta".into(); }
Msg::PickGamma => { self.open = Open::None; self.last_event = "picked Gamma".into(); }
Msg::BrightnessChanged( v ) => { self.brightness = v; }
}
}
}
fn modal_confirm() -> Element<Msg>
{
dialog::<Msg>()
.title( "Delete partition?" )
.subtitle( "This will permanently erase every file in /dev/sda2 and cannot be undone. Make sure you have a backup before continuing." )
.cancel( Msg::Close )
.action(
button::<Msg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Close ),
)
.action(
button::<Msg>( "Delete" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Confirm ),
)
.into()
}
fn non_modal_pick() -> Element<Msg>
{
dialog::<Msg>()
.modal( false )
.title( "Pick a flavour" )
.subtitle( "Tap the dim background or press ESC to cancel." )
.cancel( Msg::Close )
.dismiss_on_scrim( Msg::Close )
.action(
button::<Msg>( "Alpha" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::PickAlpha ),
)
.action(
button::<Msg>( "Beta" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::PickBeta ),
)
.action(
button::<Msg>( "Gamma" )
.variant( ButtonVariant::Primary )
.on_press( Msg::PickGamma ),
)
.into()
}
fn custom_body_dialog( brightness: f32 ) -> Element<Msg>
{
let palette = ltk::theme_palette();
let secondary = palette.text_secondary;
let body = column::<Msg>()
.spacing( 8.0 )
.push( text( format!( "Brightness: {:>3.0} %", brightness * 100.0 ) )
.size( 14.0 )
.color( secondary ) )
.push(
slider::<Msg>( brightness )
.on_change( |v| Msg::BrightnessChanged( v ) )
.accent_thumb( true ),
);
dialog::<Msg>()
.title( "Display brightness" )
.subtitle( "Drag the slider, then accept or cancel." )
.cancel( Msg::Close )
.body( body )
.action(
button::<Msg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( Msg::Close ),
)
.action(
button::<Msg>( "Accept" )
.variant( ButtonVariant::Primary )
.on_press( Msg::Confirm ),
)
.into()
}
fn main()
{
ltk::run( DialogApp::new() );
}

128
examples/inputs.rs Normal file
View File

@@ -0,0 +1,128 @@
//! `cargo run --example inputs`
//!
//! Shows a plain username field and a secure password field with a
//! built-in show / hide-password eye toggle. Tap the eye to flip
//! the bullets ↔ plaintext rendering — the buffer keeps its
//! wipe-on-drop guarantee either way. Tab / Shift+Tab moves focus
//! between fields. Enter or the Login button submits. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::{ App, Element, Keysym, ButtonVariant, button, column, text, text_edit, spacer };
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
UsernameChanged( String ),
PasswordChanged( String ),
TogglePasswordVisibility,
Submit,
}
// ── App state ─────────────────────────────────────────────────────────────────
struct InputsApp
{
username: String,
password: String,
password_visible: bool,
submitted: bool,
}
impl InputsApp
{
fn new() -> Self
{
Self
{
username: String::new(),
password: String::new(),
password_visible: false,
submitted: false,
}
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for InputsApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = if self.submitted
{
format!( "Submitted as: {}", self.username )
} else {
String::from( "Fill in the fields and press Login" )
};
column::<Message>()
.padding( 32.0 )
.spacing( 16.0 )
.max_width( 380.0 )
.center_y( true )
.push( text( "ltk — text input showcase" ).size( 24.0 ).color( primary ).align_center() )
.push( text( status ).size( 14.0 ).color( secondary ).align_center() )
.push( spacer() )
.push(
text_edit::<Message>( "Username", &self.username )
.on_change( Message::UsernameChanged )
.on_submit( Message::Submit ),
)
.push(
text_edit::<Message>( "Password", &self.password )
.on_change( Message::PasswordChanged )
.on_submit( Message::Submit )
.password_toggle( self.password_visible, Message::TogglePasswordVisibility ),
)
.push(
button::<Message>( "Login" )
.variant( ButtonVariant::Primary )
.on_press( Message::Submit ),
)
.push( spacer() )
.push(
text( "Tab = next field · Enter = submit · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::UsernameChanged( v ) => self.username = v,
Message::PasswordChanged( v ) => self.password = v,
Message::TogglePasswordVisibility => self.password_visible = !self.password_visible,
Message::Submit => self.submitted = true,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( InputsApp::new() );
}

626
examples/mini_shell.rs Normal file
View File

@@ -0,0 +1,626 @@
//! `cargo run --example mini_shell`
//!
//! A self-contained mini-shell that exercises the patterns described in
//! `docs/architecture.md`:
//!
//! - Multi-screen routing on the main surface (Home / Settings)
//! - Two coordinated overlays (Quick Settings + Confirm dialog)
//! - A pass-through OSD overlay with a fade-in/fade-out animation driven by
//! `is_animating()`
//! - Live dark/light theme switching from a UI toggle
//! - Nested message enums (`AppMsg::Home(HomeMsg)`, `AppMsg::Settings(...)`)
//! - One module per screen / overlay so the file scales like a real app
//!
//! Controls:
//! Esc — quit
//! Swipe down — open Quick Settings
//! Tap outside — dismiss any overlay
//!
//! NOTE: ltk is a Wayland toolkit. Run inside a Wayland session
//! (sway, labwc, the Liberux desktop, …).
use std::time::{ Duration, Instant };
use ltk::
{
App, ButtonVariant, Color, Element, Keysym, OverlayId, OverlaySpec,
ThemeMode,
button, column, container, progress_bar, row, slider, spacer, text, toggle,
};
// ── Stable overlay ids ────────────────────────────────────────────────────────
//
// Declare ids as constants so the runtime can diff the overlay list across
// frames and keep the underlying surfaces alive.
const OVERLAY_QUICK_SETTINGS: OverlayId = OverlayId( 1 );
const OVERLAY_CONFIRM: OverlayId = OverlayId( 2 );
const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
const TOAST_LIFETIME: Duration = Duration::from_millis( 1800 );
const TOAST_FADE_IN: Duration = Duration::from_millis( 200 );
const TOAST_FADE_OUT: Duration = Duration::from_millis( 600 );
// ── Routing ───────────────────────────────────────────────────────────────────
#[ derive( Clone, Copy, PartialEq, Eq ) ]
enum Route { Home, Settings }
// ── Confirm dialog payload ────────────────────────────────────────────────────
//
// Models a user-initiated action that needs confirmation before it takes
// effect. The dialog itself is just a view of `AppState::pending_action`.
#[ derive( Clone, Copy ) ]
enum Action
{
PowerOff,
Reset,
}
impl Action
{
fn label( self ) -> &'static str
{
match self
{
Action::PowerOff => "Power off this device?",
Action::Reset => "Reset all settings to defaults?",
}
}
}
// ── Message tree ──────────────────────────────────────────────────────────────
//
// The top-level enum stays small and forwards to per-screen sub-enums — the
// recommended pattern once a shell has more than a handful of messages.
#[ derive( Clone ) ]
enum AppMsg
{
Nav( Route ),
Home( HomeMsg ),
Settings( SettingsMsg ),
// Overlay coordination
OpenQuickSettings,
CloseQuickSettings,
SetBrightness( f32 ),
ShowConfirm( Action ),
DismissConfirm,
ConfirmYes,
// Cross-cutting
SetThemeMode( ThemeMode ),
ShowToast( String ),
Quit,
}
#[ derive( Clone ) ]
enum HomeMsg { LaunchApp( &'static str ) }
#[ derive( Clone ) ]
enum SettingsMsg { ToggleNotifications }
// ── App state ─────────────────────────────────────────────────────────────────
struct AppState
{
route: Route,
// Per-screen state (in a real app each lives in its own module struct).
home: home::Home,
settings: settings::Settings,
// Overlay state
quick_settings_visible: bool,
pending_action: Option<Action>,
brightness: f32,
// Animated OSD toast
toast_text: Option<String>,
toast_started: Option<Instant>,
}
impl AppState
{
fn new() -> Self
{
Self
{
route: Route::Home,
home: home::Home::new(),
settings: settings::Settings::new(),
quick_settings_visible: false,
pending_action: None,
brightness: 0.7,
toast_text: None,
toast_started: None,
}
}
fn show_toast( &mut self, msg: String )
{
self.toast_text = Some( msg );
self.toast_started = Some( Instant::now() );
}
/// 0.0 = invisible, 1.0 = fully opaque. Used by the OSD overlay's view.
fn toast_alpha( &self ) -> f32
{
let Some( start ) = self.toast_started else { return 0.0 };
let elapsed = start.elapsed();
if elapsed < TOAST_FADE_IN
{
elapsed.as_secs_f32() / TOAST_FADE_IN.as_secs_f32()
} else if elapsed < TOAST_LIFETIME - TOAST_FADE_OUT {
1.0
} else if elapsed < TOAST_LIFETIME {
let t = ( TOAST_LIFETIME - elapsed ).as_secs_f32() / TOAST_FADE_OUT.as_secs_f32();
t.max( 0.0 )
} else {
0.0
}
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for AppState
{
type Message = AppMsg;
fn view( &self ) -> Element<AppMsg>
{
match self.route
{
Route::Home => self.home.view(),
Route::Settings => self.settings.view(),
}
}
fn overlays( &self ) -> Vec<OverlaySpec<AppMsg>>
{
let mut out = Vec::new();
if self.quick_settings_visible
{
out.push( OverlaySpec
{
id: OVERLAY_QUICK_SETTINGS,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: overlays::quick_settings( self ),
on_dismiss: Some( AppMsg::CloseQuickSettings ),
anchor_widget_id: None,
} );
}
if let Some( action ) = self.pending_action
{
out.push( OverlaySpec
{
id: OVERLAY_CONFIRM,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: overlays::confirm( action ),
on_dismiss: Some( AppMsg::DismissConfirm ),
anchor_widget_id: None,
} );
}
if self.toast_text.is_some()
{
out.push( OverlaySpec
{
id: OVERLAY_TOAST,
layer: ltk::Layer::Overlay,
anchor: ltk::Anchor::ALL,
size: ( 0, 0 ),
exclusive_zone: 0,
keyboard_exclusive: false,
// Pass-through: the OSD must not steal taps from anything below.
input_region: Some( Vec::new() ),
view: overlays::toast( self ),
on_dismiss: None,
anchor_widget_id: None,
} );
}
out
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
AppMsg::Nav( r ) => self.route = r,
AppMsg::Home( m ) =>
{
if let Some( follow ) = self.home.update( m )
{
self.update( follow );
}
}
AppMsg::Settings( m ) => self.settings.update( m ),
AppMsg::OpenQuickSettings => self.quick_settings_visible = true,
AppMsg::CloseQuickSettings => self.quick_settings_visible = false,
AppMsg::SetBrightness( v ) => self.brightness = v,
AppMsg::ShowConfirm( a ) => self.pending_action = Some( a ),
AppMsg::DismissConfirm => self.pending_action = None,
AppMsg::ConfirmYes =>
{
if let Some( action ) = self.pending_action.take()
{
match action
{
Action::PowerOff => self.show_toast( "Pretending to power off…".into() ),
Action::Reset =>
{
self.settings = settings::Settings::new();
self.brightness = 0.7;
self.show_toast( "Settings reset".into() );
}
}
}
}
AppMsg::SetThemeMode( m ) => ltk::set_active_mode( m ),
AppMsg::ShowToast( s ) => self.show_toast( s ),
AppMsg::Quit => std::process::exit( 0 ),
}
}
// Animation: keep redrawing while the toast is on screen so it can fade.
fn is_animating( &self ) -> bool
{
self.toast_started.is_some()
}
// Auto-expire the toast once its lifetime is up. Putting this in
// `poll_external` (rather than `update`) means the loop wakes itself when
// `is_animating()` is driving redraws and cleans up without user input.
fn poll_external( &mut self ) -> Vec<AppMsg>
{
if let Some( start ) = self.toast_started
{
if start.elapsed() >= TOAST_LIFETIME
{
self.toast_text = None;
self.toast_started = None;
}
}
Vec::new()
}
fn on_swipe_down( &mut self ) -> Option<AppMsg>
{
Some( AppMsg::OpenQuickSettings )
}
fn swipe_down_threshold( &self ) -> f32 { 0.10 }
fn on_key( &mut self, k: Keysym ) -> Option<AppMsg>
{
match k
{
Keysym::Escape => Some( AppMsg::Quit ),
_ => None,
}
}
fn background_color( &self ) -> Color
{
// Use the active theme's background so dark/light flips immediately.
ltk::theme_palette().bg
}
}
// ── Per-screen modules ────────────────────────────────────────────────────────
//
// In a real app each of these lives in its own file (`src/home.rs`,
// `src/settings.rs`, …). Inlined here so the example stays a single file.
mod home
{
use super::*;
pub struct Home
{
pub last_launched: Option<&'static str>,
}
impl Home
{
pub fn new() -> Self { Self { last_launched: None } }
/// Cmd-style: returns an optional follow-up message instead of mutating
/// shared state. The parent `update` runs the follow-up, which keeps
/// the borrow checker happy.
pub fn update( &mut self, msg: HomeMsg ) -> Option<AppMsg>
{
match msg
{
HomeMsg::LaunchApp( name ) =>
{
self.last_launched = Some( name );
Some( AppMsg::ShowToast( format!( "Launched {}", name ) ) )
}
}
}
pub fn view( &self ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let title = text( "Mini Shell" )
.size( 28.0 )
.color( palette.text_primary )
.align_center();
let status = text( match self.last_launched
{
Some( n ) => format!( "Last launched: {}", n ),
None => "No app launched yet".into(),
} )
.size( 13.0 )
.color( palette.text_secondary )
.align_center();
let mut grid = ltk::grid::<AppMsg>( 2 ).spacing( 12.0 ).padding( 0.0 );
for name in &["Browser", "Mail", "Camera", "Music"]
{
grid = grid.push(
button::<AppMsg>( *name )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::Home( HomeMsg::LaunchApp( name ) ) ),
);
}
column::<AppMsg>()
.padding( 32.0 )
.spacing( 16.0 )
.push( title )
.push( status )
.push( spacer() )
.push( grid )
.push( spacer() )
.push(
button::<AppMsg>( "Settings" )
.on_press( AppMsg::Nav( Route::Settings ) ),
)
.into()
}
}
}
mod settings
{
use super::*;
pub struct Settings
{
pub notifications_enabled: bool,
}
impl Settings
{
pub fn new() -> Self { Self { notifications_enabled: true } }
pub fn update( &mut self, msg: SettingsMsg )
{
match msg
{
SettingsMsg::ToggleNotifications => self.notifications_enabled = !self.notifications_enabled,
}
}
pub fn view( &self ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let is_dark = ltk::active_mode() == ThemeMode::Dark;
let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark };
let label = if is_dark { "Switch to Light" } else { "Switch to Dark" };
column::<AppMsg>()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Settings" ).size( 24.0 ).color( palette.text_primary ) )
.push(
toggle::<AppMsg>( self.notifications_enabled )
.label( "Notifications" )
.on_toggle( AppMsg::Settings( SettingsMsg::ToggleNotifications ) ),
)
.push( spacer() )
.push( text( "Theme" ).size( 14.0 ).color( palette.text_secondary ) )
.push(
button::<AppMsg>( label )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::SetThemeMode( next ) ),
)
.push( spacer() )
.push(
row::<AppMsg>()
.spacing( 12.0 )
.push(
button::<AppMsg>( "Show toast" )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::ShowToast( "Hello from Settings".into() ) ),
)
.push(
button::<AppMsg>( "Reset settings" )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::ShowConfirm( Action::Reset ) ),
),
)
.push(
button::<AppMsg>( "Power off" )
.on_press( AppMsg::ShowConfirm( Action::PowerOff ) ),
)
.push( spacer() )
.push(
button::<AppMsg>( "Back" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::Nav( Route::Home ) ),
)
.into()
}
}
}
mod overlays
{
use super::*;
/// Quick Settings: brightness slider + theme toggle + close button.
pub fn quick_settings( app: &AppState ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let is_dark = ltk::active_mode() == ThemeMode::Dark;
let next = if is_dark { ThemeMode::Light } else { ThemeMode::Dark };
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 12.0 )
.push(
row::<AppMsg>()
.spacing( 8.0 )
.push( text( "Quick Settings" ).size( 18.0 ).color( palette.text_primary ) )
.push( spacer() )
.push(
button::<AppMsg>( "Close" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::CloseQuickSettings ),
),
)
.push( text( format!( "Brightness {:.0}%", app.brightness * 100.0 ) )
.size( 13.0 )
.color( palette.text_primary ) )
.push( slider::<AppMsg>( app.brightness ).on_change( AppMsg::SetBrightness ) )
.push(
button::<AppMsg>( if is_dark { "Light theme" } else { "Dark theme" } )
.variant( ButtonVariant::Secondary )
.on_press( AppMsg::SetThemeMode( next ) ),
),
)
.background( palette.surface_alt )
.radius( 16.0 )
.padding( 20.0 );
// Center the panel on the overlay surface using spacers.
column::<AppMsg>()
.padding( 24.0 )
.spacing( 0.0 )
.max_width( 360.0 )
.push( panel )
.push( spacer() )
.into()
}
/// Confirm dialog. Same overlay pattern, different content.
pub fn confirm( action: Action ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( action.label() )
.size( 16.0 )
.color( palette.text_primary )
.align_center() )
.push(
row::<AppMsg>()
.spacing( 12.0 )
.push(
button::<AppMsg>( "Cancel" )
.variant( ButtonVariant::Tertiary )
.on_press( AppMsg::DismissConfirm ),
)
.push( spacer() )
.push(
button::<AppMsg>( "Confirm" )
.on_press( AppMsg::ConfirmYes ),
),
),
)
.background( palette.surface_alt )
.radius( 16.0 )
.padding( 24.0 );
column::<AppMsg>()
.padding( 0.0 )
.spacing( 0.0 )
.max_width( 320.0 )
.push( spacer() )
.push( panel )
.push( spacer() )
.into()
}
/// Pass-through OSD. The fade is computed from elapsed time, not stored.
pub fn toast( app: &AppState ) -> Element<AppMsg>
{
let palette = ltk::theme_palette();
let alpha = app.toast_alpha();
let txt = app.toast_text.as_deref().unwrap_or( "" );
// Apply the fade by attenuating the surface alpha. Text/progress stay
// readable; the container background fades.
let mut bg = palette.surface_alt;
bg.a *= alpha;
let mut fg = palette.text_primary;
fg.a *= alpha;
let panel = container::<AppMsg>(
column::<AppMsg>()
.padding( 0.0 )
.spacing( 8.0 )
.push( text( txt ).size( 15.0 ).color( fg ).align_center() )
.push( progress_bar( alpha ) ),
)
.background( bg )
.radius( 12.0 )
.padding( 16.0 );
column::<AppMsg>()
.padding( 0.0 )
.spacing( 0.0 )
.max_width( 280.0 )
.push( spacer() )
.push( spacer() )
.push( spacer() )
.push( panel )
.into()
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
// Load the on-disk default theme. ltk has no in-code fallback: the
// `ltk-theme-default` Debian package (or a dev-time
// `LTK_THEMES_DIR=<repo>/themes`) must provide
// `default/theme.json`. `ltk::run` would otherwise panic on first
// theme access with the same message as the one below.
let doc = ltk::ThemeDocument::find( "default" )
.expect( "ltk: the `default` theme is required (install `ltk-theme-default` or set `LTK_THEMES_DIR`)" );
ltk::set_active_document( doc );
ltk::set_active_mode( ThemeMode::Dark );
ltk::run( AppState::new() );
}

166
examples/pickers.rs Normal file
View File

@@ -0,0 +1,166 @@
//! `cargo run --example pickers`
//!
//! Showcases the four composition widgets added in this pass:
//! `notebook` (paginated tabs with a content area), `date_picker`,
//! `time_picker`, and `color_picker`. Each picker lives on its own
//! notebook page so the example doubles as a notebook demo.
//!
//! Esc exits.
use ltk::
{
App, Element, Color, Keysym,
column, text, scroll,
notebook, date_picker, time_picker, color_picker,
Date, Time,
};
#[ derive( Clone, Debug ) ]
enum Msg
{
SelectTab( usize ),
DateChanged( Date ),
DateView( i32, u8 ),
TimeChanged( Time ),
ColorChanged( Color ),
}
struct PickerApp
{
tab: usize,
date: Date,
view_year: i32,
view_month: u8,
time: Time,
color: Color,
}
impl PickerApp
{
fn new() -> Self
{
let today = Date::new( 2026, 5, 2 );
Self
{
tab: 0,
date: today,
view_year: today.year,
view_month: today.month,
time: Time::new( 9, 30 ),
color: Color::hex( 0x14, 0xB8, 0xA6 ),
}
}
fn date_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"Selected: {}-{:02}-{:02}",
self.date.year, self.date.month, self.date.day,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
date_picker( self.date )
.view( self.view_year, self.view_month )
.today( Date::new( 2026, 5, 2 ) )
.on_change( Msg::DateChanged )
.on_navigate( Msg::DateView )
)
.into()
}
fn time_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"Selected: {:02}:{:02}",
self.time.hour, self.time.minute,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
time_picker( self.time )
.minute_step( 1 )
.twelve_hour( true )
.on_change( Msg::TimeChanged )
)
.into()
}
fn color_page( &self ) -> Element<Msg>
{
let primary = ltk::theme_palette().text_primary;
let summary = format!(
"R={:.2} G={:.2} B={:.2} A={:.2}",
self.color.r, self.color.g, self.color.b, self.color.a,
);
column::<Msg>()
.padding( 0.0 )
.spacing( 16.0 )
.push( text( summary ).size( 14.0 ).color( primary ) )
.push(
color_picker( self.color )
.show_alpha( true )
.on_change( Msg::ColorChanged )
)
.into()
}
}
impl App for PickerApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let header = text( "ltk pickers" )
.size( 24.0 )
.color( ltk::theme_palette().text_primary )
.align_center();
let body = column::<Msg>()
.padding( 24.0 )
.spacing( 16.0 )
.push( header )
.push(
notebook::<Msg>()
.page( "Date", self.date_page() )
.page( "Time", self.time_page() )
.page( "Color", self.color_page() )
.selected( self.tab )
.on_select( Msg::SelectTab )
);
// Wrap in a scroll so the calendar's 6×7 grid fits even on
// modest window heights.
scroll( body ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::SelectTab( i ) => self.tab = i,
Msg::DateChanged( d ) => { self.date = d; self.view_year = d.year; self.view_month = d.month; }
Msg::DateView( y, m ) => { self.view_year = y; self.view_month = m; }
Msg::TimeChanged( t ) => self.time = t,
Msg::ColorChanged( c ) => self.color = c,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
}
fn main()
{
ltk::run( PickerApp::new() );
}

154
examples/scroll.rs Normal file
View File

@@ -0,0 +1,154 @@
//! `cargo run --example scroll`
//!
//! Demonstrates the two main scroll use cases:
//!
//! - **List mode** — a long vertical list inside a `scroll(column(...))`.
//! - **Grid mode** — a grid of buttons inside a `scroll(grid(4))` (app-drawer style).
//!
//! Press the "List" / "Grid" toggle at the top to switch between modes.
//! Drag the content area to scroll. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use ltk::{
App, Element, Keysym, ButtonVariant,
button, column, row, text, scroll, grid, spacer,
};
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
ShowList,
ShowGrid,
ItemPressed( usize ),
}
// ── App state ─────────────────────────────────────────────────────────────────
#[ derive( PartialEq ) ]
enum Mode { List, Grid }
struct ScrollApp
{
mode: Mode,
last_pressed: Option<usize>,
}
impl ScrollApp
{
fn new() -> Self
{
Self { mode: Mode::List, last_pressed: None }
}
}
// ── App trait ─────────────────────────────────────────────────────────────────
const ITEM_COUNT: usize = 64;
impl App for ScrollApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = match self.last_pressed
{
Some( i ) => format!( "Pressed item {}", i + 1 ),
None => String::from( "Drag to scroll · click an item" ),
};
// Top bar: toggle buttons + status
let toggle_list = button::<Message>( "List" )
.variant( if self.mode == Mode::List { ButtonVariant::Primary } else { ButtonVariant::Secondary } )
.on_press( Message::ShowList );
let toggle_grid = button::<Message>( "Grid" )
.variant( if self.mode == Mode::Grid { ButtonVariant::Primary } else { ButtonVariant::Secondary } )
.on_press( Message::ShowGrid );
let top_bar = row::<Message>()
.spacing( 8.0 )
.push( toggle_list )
.push( toggle_grid )
.push( spacer() )
.push( text( &status ).size( 14.0 ).color( secondary ) );
// Scrollable content area
let content: Element<Message> = match self.mode
{
Mode::List =>
{
// A long list of labeled buttons in a scrollable column
let mut col = column::<Message>().padding( 8.0 ).spacing( 6.0 );
for i in 0..ITEM_COUNT
{
col = col.push(
button::<Message>( format!( "List item {} — click me", i + 1 ) )
.variant( ButtonVariant::Secondary )
.on_press( Message::ItemPressed( i ) ),
);
}
scroll( col ).into()
}
Mode::Grid =>
{
// A grid of labeled buttons in a scrollable 4-column grid
let mut g = grid::<Message>( 4 ).padding( 8.0 ).spacing( 6.0 );
for i in 0..ITEM_COUNT
{
g = g.push(
button::<Message>( format!( "Item {}", i + 1 ) )
.variant( ButtonVariant::Secondary )
.on_press( Message::ItemPressed( i ) ),
);
}
scroll( g ).into()
}
};
column::<Message>()
.padding( 16.0 )
.spacing( 12.0 )
.push( text( "ltk — scroll showcase" ).size( 22.0 ).color( primary ).align_center() )
.push( top_bar )
.push( content )
.push(
text( "Drag = scroll · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::ShowList => self.mode = Mode::List,
Message::ShowGrid => self.mode = Mode::Grid,
Message::ItemPressed( i ) => self.last_pressed = Some( i ),
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( ScrollApp::new() );
}

252
examples/showcase.rs Normal file
View File

@@ -0,0 +1,252 @@
//! `cargo run --example showcase`
//!
//! Shows button variants, container with background, a slider, plus the
//! newer additions: a tab strip, a spinner driven by the wall clock, a
//! multiline text edit, and on-demand toast / tooltip overlays.
//! Tab / Shift+Tab cycles keyboard focus. Enter or Space activates the
//! focused button. Esc exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a running
//! Wayland compositor (e.g. sway, labwc, or a full desktop session).
use std::time::{ Duration, Instant };
use ltk::{
App, Element, Keysym, ButtonVariant, WidgetId,
OverlaySpec,
button, column, row, stack, text, text_edit, spacer, container, slider, scroll,
spinner, tabs, toast, tooltip,
};
// ── Messages ──────────────────────────────────────────────────────────────────
#[ derive( Clone ) ]
enum Message
{
Pressed( &'static str ),
SliderChanged( f32 ),
SelectTab( usize ),
NoteChanged( String ),
ShowToast,
ToggleTooltip,
HideToast,
}
// ── App state ─────────────────────────────────────────────────────────────────
struct ShowcaseApp
{
last_pressed: String,
slider_value: f32,
tab: usize,
note: String,
started_at: Instant,
toast_until: Option<Instant>,
tooltip_visible: bool,
}
impl ShowcaseApp
{
fn new() -> Self
{
Self
{
last_pressed: String::from( "" ),
slider_value: 0.5,
tab: 0,
note: String::new(),
started_at: Instant::now(),
toast_until: None,
tooltip_visible: false,
}
}
const SAVE_BUTTON_ID: WidgetId = WidgetId( "showcase/save" );
}
// ── App trait ─────────────────────────────────────────────────────────────────
impl App for ShowcaseApp
{
type Message = Message;
fn view( &self ) -> Element<Message>
{
// Pull text colours from the active theme — the runtime
// background defaults to `palette.bg`, so hard-coded white
// text would be unreadable in light mode.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let status = format!( "Last pressed: {}", self.last_pressed );
// Tabs strip — the active tab just relabels a banner below.
let tabs_strip: Element<Message> = tabs( [ "Buttons", "Inputs", "Hints" ] )
.selected( self.tab )
.on_select( Message::SelectTab )
.into();
let tab_banner = format!(
"Active tab: {}",
[ "Buttons", "Inputs", "Hints" ][ self.tab.min( 2 ) ],
);
let buttons = row::<Message>()
.spacing( 16.0 )
.push(
button( "Primary" )
.variant( ButtonVariant::Primary )
.id( Self::SAVE_BUTTON_ID )
.on_press( Message::Pressed( "Primary" ) ),
)
.push(
button( "Secondary" )
.variant( ButtonVariant::Secondary )
.on_press( Message::Pressed( "Secondary" ) ),
)
.push(
button( "Tertiary" )
.variant( ButtonVariant::Tertiary )
.on_press( Message::Pressed( "Tertiary" ) ),
);
let card = container::<Message>(
column::<Message>()
.spacing( 8.0 )
.push( text( "container() demo" ).size( 14.0 ).color( primary ) )
.push(
text( "Background color + padding + corner radius" )
.size( 12.0 )
.color( secondary ),
),
)
.background( palette.surface_alt )
.radius( 12.0 )
.padding( 16.0 );
let vol_label = format!( "Volume: {:.0}%", self.slider_value * 100.0 );
// Spinner — phase comes straight from the wall clock so the arc
// completes one revolution per second.
let phase = self.started_at.elapsed().as_secs_f32();
let spin_row: Element<Message> = row::<Message>()
.spacing( 12.0 )
.push( spinner().phase( phase ) )
.push( text( "Working…" ).size( 14.0 ).color( primary ) )
.into();
// Multiline text area.
let textarea = text_edit( "Type a note (multi-line)", &self.note )
.multiline( true )
.rows( 3 )
.on_change( Message::NoteChanged );
// Buttons that exercise the overlay-returning widgets.
let overlay_row = row::<Message>()
.spacing( 12.0 )
.push( button( "Show toast" ).on_press( Message::ShowToast ) )
.push(
button( if self.tooltip_visible { "Hide tooltip" } else { "Show tooltip" } )
.on_press( Message::ToggleTooltip )
);
let body = column::<Message>()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "ltk showcase" ).size( 24.0 ).color( primary ).align_center() )
.push( text( status ).size( 14.0 ).color( secondary ).align_center() )
.push( tabs_strip )
.push( text( tab_banner ).size( 12.0 ).color( secondary ).align_center() )
.push( spacer() )
.push( buttons )
.push( card )
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.slider_value ).on_change( Message::SliderChanged ) )
.push( spin_row )
.push( textarea )
.push( overlay_row )
.push( spacer() )
.push(
text( "Tab = focus next · Enter / Space = press · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
);
// Toast lives inside the main view (works on every
// compositor — no `wlr-layer-shell` required). The Tooltip
// stays in `overlays()` because xdg-popup is universal.
let mut s = stack::<Message>().push( scroll( body ) );
if self.toast_until.is_some()
{
s = s.push( toast::<Message>( "Saved" ).view() );
}
s.into()
}
fn update( &mut self, msg: Message )
{
match msg
{
Message::Pressed( name ) => self.last_pressed = name.to_string(),
Message::SliderChanged( val ) => self.slider_value = val,
Message::SelectTab( i ) => self.tab = i,
Message::NoteChanged( s ) => self.note = s,
Message::ShowToast =>
{
self.toast_until = Some( Instant::now() + Duration::from_secs( 2 ) );
}
Message::HideToast => self.toast_until = None,
Message::ToggleTooltip => self.tooltip_visible = !self.tooltip_visible,
}
}
fn overlays( &self ) -> Vec<OverlaySpec<Message>>
{
// Tooltip is an xdg-popup → works on any xdg-shell compositor.
// Toast is rendered inline in `view()` (see the Stack at the
// bottom of the function) so it does not depend on
// `wlr-layer-shell` either.
if self.tooltip_visible
{
vec![ tooltip::<Message>( "Saves the document and closes the dialog", Self::SAVE_BUTTON_ID ).overlay() ]
} else {
vec![]
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Message>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
// Keep redrawing while the spinner is on screen and while a toast
// is pending so its expiry timestamp is checked every frame.
fn is_animating( &self ) -> bool { true }
// Auto-clear an expired toast.
fn poll_interval( &self ) -> Option<Duration>
{
Some( Duration::from_millis( 250 ) )
}
fn poll_external( &mut self ) -> Vec<Message>
{
match self.toast_until
{
Some( deadline ) if Instant::now() >= deadline => vec![ Message::HideToast ],
_ => vec![],
}
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
fn main()
{
ltk::run( ShowcaseApp::new() );
}

155
examples/sliders.rs Normal file
View File

@@ -0,0 +1,155 @@
//! `cargo run --example sliders`
//!
//! Showcases the **Glass** effect on both axes of the slider widget. The
//! horizontal `slider` at the top and the two vertical `vslider` pills
//! below share the same composite surface — outer glow + four inset
//! shadows (two `PlusLighter` light-catchers, one `Overlay` rim, one
//! soft dark contact shadow) — over a translucent white-to-grey track
//! and a flat cyan accent fill.
//!
//! Drag or tap anywhere inside the controls to change their values; Esc
//! exits.
//!
//! NOTE: ltk is a Wayland layer-shell toolkit. This example requires a
//! running Wayland compositor (e.g. sway, labwc, or a full desktop
//! session). The Glass effect is rendered by the GPU (GLES2) backend —
//! the software backend falls back to a flat fill.
use ltk::
{
App, Element, Color, Keysym,
column, row, text, spacer, slider, vslider,
};
#[ derive( Clone ) ]
enum Msg
{
SetHue( f32 ),
SetVolume( f32 ),
SetBrightness( f32 ),
}
struct SlidersApp
{
hue: f32,
volume: f32,
brightness: f32,
}
impl SlidersApp
{
fn new() -> Self
{
Self { hue: 0.30, volume: 0.48, brightness: 0.92 }
}
}
impl App for SlidersApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
// Pill dimensions chosen so the Glass insets read at their
// intended proportions. The insets (0.453.6 px offsets,
// 0.913.5 px blur radii) are absolute, so smaller pills
// produce relatively tighter highlights.
const PILL_W: f32 = 80.0;
const PILL_H: f32 = 176.0;
// Track text against the active theme so switching to dark mode
// (via `ltk::set_active_mode`) flips the labels automatically.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
// Horizontal slider — claims its row's full width.
let hue_slider = slider( self.hue ).on_change( Msg::SetHue );
// Two vertical pills.
let brgt = vslider( self.brightness ).size( PILL_W, PILL_H ).on_change( Msg::SetBrightness );
let vol = vslider( self.volume ).size( PILL_W, PILL_H ).on_change( Msg::SetVolume );
let pills = row::<Msg>()
.spacing( 40.0 )
.padding( 0.0 )
.push( brgt )
.push( vol );
let pill_labels = row::<Msg>()
.spacing( 40.0 )
.padding( 0.0 )
.push(
text( format!( "Brightness {:>3.0}%", self.brightness * 100.0 ) )
.size( 14.0 )
.color( primary ),
)
.push(
text( format!( "Volume {:>3.0}%", self.volume * 100.0 ) )
.size( 14.0 )
.color( primary ),
);
column::<Msg>()
.padding( 32.0 )
.spacing( 16.0 )
.center_y( true )
.push(
text( "ltk — Glass sliders, both axes" )
.size( 24.0 )
.color( primary )
.align_center(),
)
.push( spacer() )
.push(
text( format!( "Hue {:>3.0}%", self.hue * 100.0 ) )
.size( 14.0 )
.color( primary ),
)
.push( hue_slider )
.push( spacer() )
.push( pills )
.push( pill_labels )
.push( spacer() )
.push(
text( "Drag or tap inside a control · Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
)
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::SetHue( v ) => self.hue = v,
Msg::SetVolume( v ) => self.volume = v,
Msg::SetBrightness( v ) => self.brightness = v,
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape
{
std::process::exit( 0 );
}
None
}
// Track the active theme's background so the Glass highlights and
// the translucent track read correctly against it, and dark-mode
// runs (via `ltk::set_active_mode(ThemeMode::Dark)`) flip
// automatically.
fn background_color( &self ) -> Color
{
ltk::theme_palette().bg
}
}
fn main()
{
ltk::run( SlidersApp::new() );
}

217
examples/widgets.rs Normal file
View File

@@ -0,0 +1,217 @@
use std::time::Instant;
use ltk::{
App, Element, Keysym, WidgetId,
column, row, text, text_edit, spacer, separator, slider,
toggle, checkbox, radio, progress_bar, scroll,
spinner, tabs,
};
#[derive( Clone )]
enum Msg
{
ToggleWifi,
ToggleBluetooth,
ToggleDarkMode,
CheckNotifications,
CheckUpdates,
SelectThemeLight,
SelectThemeDark,
SelectThemeAuto,
SliderChanged( f32 ),
SelectTab( usize ),
NoteChanged( String ),
Tick,
}
struct WidgetsApp
{
wifi: bool,
bluetooth: bool,
dark_mode: bool,
notifications: bool,
updates: bool,
theme: usize,
progress: f32,
volume: f32,
tab: usize,
note: String,
started_at: Instant,
}
impl WidgetsApp
{
fn new() -> Self
{
Self
{
wifi: true,
bluetooth: false,
dark_mode: true,
notifications: true,
updates: false,
theme: 2,
progress: 0.65,
volume: 0.7,
tab: 0,
note: String::new(),
started_at: Instant::now(),
}
}
}
impl App for WidgetsApp
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
// Pull text colours from the active theme so the example
// reads correctly under both light and dark mode without
// touching the example code.
let palette = ltk::theme_palette();
let primary = palette.text_primary;
let secondary = palette.text_secondary;
let title = text( "ltk widgets" ).size( 24.0 ).color( primary ).align_center();
// Segmented tab strip — selection just rendered as a banner below.
let tabs_strip: Element<Msg> = tabs( [ "General", "Audio", "Network" ] )
.selected( self.tab )
.on_select( Msg::SelectTab )
.into();
let tab_label = format!(
"Active tab: {}",
[ "General", "Audio", "Network" ][ self.tab.min( 2 ) ],
);
let toggles = column::<Msg>()
.spacing( 0.0 )
.push( text( "Toggle" ).size( 13.0 ).color( secondary ) )
.push( toggle( self.wifi ).label( "Wi-Fi" ).on_toggle( Msg::ToggleWifi ) )
.push( toggle( self.bluetooth ).label( "Bluetooth" ).on_toggle( Msg::ToggleBluetooth ) )
.push( toggle( self.dark_mode ).label( "Dark mode" ).on_toggle( Msg::ToggleDarkMode ) );
let checkboxes = column::<Msg>()
.spacing( 0.0 )
.push( text( "Checkbox" ).size( 13.0 ).color( secondary ) )
.push( checkbox( self.notifications ).label( "Notifications" ).on_toggle( Msg::CheckNotifications ) )
.push( checkbox( self.updates ).label( "Auto updates" ).on_toggle( Msg::CheckUpdates ) );
let radios = column::<Msg>()
.spacing( 0.0 )
.push( text( "Radio" ).size( 13.0 ).color( secondary ) )
.push( radio( self.theme == 0 ).label( "Light" ).on_select( Msg::SelectThemeLight ) )
.push( radio( self.theme == 1 ).label( "Dark" ).on_select( Msg::SelectThemeDark ) )
.push( radio( self.theme == 2 ).label( "Auto" ).on_select( Msg::SelectThemeAuto ) );
let vol_label = format!( "Volume: {:.0}%", self.volume * 100.0 );
let prog_label = format!( "Progress: {:.0}%", self.progress * 100.0 );
// Spinner — animation phase derived from the wall clock so the
// arc rotates at one revolution per second.
let phase = self.started_at.elapsed().as_secs_f32();
let spin_row: Element<Msg> = row::<Msg>()
.spacing( 12.0 )
.push( spinner().phase( phase ) )
.push( text( "Loading…" ).size( 14.0 ).color( primary ) )
.into();
// Multiline text area — Enter inserts a newline, Tab leaves it.
let textarea = text_edit( "Type a note (multi-line)", &self.note )
.multiline( true )
.rows( 4 )
.id( WidgetId( "widgets/note" ) )
.on_change( Msg::NoteChanged );
let content = column::<Msg>()
.padding( 32.0 )
.spacing( 12.0 )
.push( title )
.push( tabs_strip )
.push( text( tab_label ).size( 13.0 ).color( secondary ) )
.push( separator() )
.push( toggles )
.push( separator() )
.push( checkboxes )
.push( separator() )
.push( radios )
.push( separator() )
.push( text( vol_label ).size( 13.0 ).color( secondary ) )
.push( slider( self.volume ).on_change( Msg::SliderChanged ) )
.push( text( prog_label ).size( 13.0 ).color( secondary ) )
.push( progress_bar( self.progress ) )
.push( separator() )
.push( text( "Spinner" ).size( 13.0 ).color( secondary ) )
.push( spin_row )
.push( separator() )
.push( text( "Multiline text edit" ).size( 13.0 ).color( secondary ) )
.push( textarea )
.push( spacer() )
.push(
text( "Esc = quit" )
.size( 12.0 )
.color( secondary )
.align_center(),
);
scroll( content ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleWifi => self.wifi = !self.wifi,
Msg::ToggleBluetooth => self.bluetooth = !self.bluetooth,
Msg::ToggleDarkMode => self.dark_mode = !self.dark_mode,
Msg::CheckNotifications => self.notifications = !self.notifications,
Msg::CheckUpdates => self.updates = !self.updates,
Msg::SelectThemeLight => self.theme = 0,
Msg::SelectThemeDark => self.theme = 1,
Msg::SelectThemeAuto => self.theme = 2,
Msg::SliderChanged( v ) => self.volume = v,
Msg::SelectTab( i ) => self.tab = i,
Msg::NoteChanged( s ) => self.note = s,
Msg::Tick => {}
}
}
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
{
if keysym == Keysym::Escape { std::process::exit( 0 ); }
None
}
// Drive the spinner — the runtime keeps redrawing while this returns
// `true`, and `view()` reads the wall clock to compute the phase, so
// no explicit Tick message is even needed for the visual to advance.
fn is_animating( &self ) -> bool { true }
fn poll_interval( &self ) -> Option<std::time::Duration>
{
// Suppress the dead-code warning on Msg::Tick; a real app would
// also use the timer to drive non-visual periodic work here.
Some( std::time::Duration::from_millis( 250 ) )
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Consumed: the timer above keeps the runtime warm, but the
// spinner reads the wall clock directly so no Tick is required.
// We still emit one occasionally so the message variant is
// exercised — keeps the example honest about the pattern apps
// usually use for clock work.
if self.started_at.elapsed().as_millis() % 1_000 < 250
{
vec![ Msg::Tick ]
} else {
vec![]
}
}
}
fn main()
{
ltk::run( WidgetsApp::new() );
}