First commit. Version 0.1.0
This commit is contained in:
227
examples/combo.rs
Normal file
227
examples/combo.rs
Normal 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
231
examples/dialog.rs
Normal 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
128
examples/inputs.rs
Normal 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
626
examples/mini_shell.rs
Normal 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
166
examples/pickers.rs
Normal 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
154
examples/scroll.rs
Normal 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
252
examples/showcase.rs
Normal 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
155
examples/sliders.rs
Normal 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.45–3.6 px offsets,
|
||||
// 0.9–13.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
217
examples/widgets.rs
Normal 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() );
|
||||
}
|
||||
Reference in New Issue
Block a user