//! `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, brightness: f32, // Animated OSD toast toast_text: Option, toast_started: Option, } 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 { match self.route { Route::Home => self.home.view(), Route::Settings => self.settings.view(), } } fn overlays( &self ) -> Vec> { 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 { 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 { Some( AppMsg::OpenQuickSettings ) } fn swipe_down_threshold( &self ) -> f32 { 0.10 } fn on_key( &mut self, k: Keysym ) -> Option { 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 { match msg { HomeMsg::LaunchApp( name ) => { self.last_launched = Some( name ); Some( AppMsg::ShowToast( format!( "Launched {}", name ) ) ) } } } pub fn view( &self ) -> Element { 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::( 2 ).spacing( 12.0 ).padding( 0.0 ); for name in &["Browser", "Mail", "Camera", "Music"] { grid = grid.push( button::( *name ) .variant( ButtonVariant::Secondary ) .on_press( AppMsg::Home( HomeMsg::LaunchApp( name ) ) ), ); } column::() .padding( 32.0 ) .spacing( 16.0 ) .push( title ) .push( status ) .push( spacer() ) .push( grid ) .push( spacer() ) .push( button::( "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 { 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::() .padding( 32.0 ) .spacing( 16.0 ) .push( text( "Settings" ).size( 24.0 ).color( palette.text_primary ) ) .push( toggle::( 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::( label ) .variant( ButtonVariant::Secondary ) .on_press( AppMsg::SetThemeMode( next ) ), ) .push( spacer() ) .push( row::() .spacing( 12.0 ) .push( button::( "Show toast" ) .variant( ButtonVariant::Secondary ) .on_press( AppMsg::ShowToast( "Hello from Settings".into() ) ), ) .push( button::( "Reset settings" ) .variant( ButtonVariant::Secondary ) .on_press( AppMsg::ShowConfirm( Action::Reset ) ), ), ) .push( button::( "Power off" ) .on_press( AppMsg::ShowConfirm( Action::PowerOff ) ), ) .push( spacer() ) .push( button::( "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 { 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::( column::() .padding( 0.0 ) .spacing( 12.0 ) .push( row::() .spacing( 8.0 ) .push( text( "Quick Settings" ).size( 18.0 ).color( palette.text_primary ) ) .push( spacer() ) .push( button::( "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::( app.brightness ).on_change( AppMsg::SetBrightness ) ) .push( button::( 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::() .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 { let palette = ltk::theme_palette(); let panel = container::( column::() .padding( 0.0 ) .spacing( 16.0 ) .push( text( action.label() ) .size( 16.0 ) .color( palette.text_primary ) .align_center() ) .push( row::() .spacing( 12.0 ) .push( button::( "Cancel" ) .variant( ButtonVariant::Tertiary ) .on_press( AppMsg::DismissConfirm ), ) .push( spacer() ) .push( button::( "Confirm" ) .on_press( AppMsg::ConfirmYes ), ), ), ) .background( palette.surface_alt ) .radius( 16.0 ) .padding( 24.0 ); column::() .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 { 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::( column::() .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::() .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=/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() ); }