Files
ltk/examples/mini_shell.rs
Pedro M. de Echanove Pasquin ce893ac776
Some checks are pending
CI / build + test (push) Waiting to run
CI / cargo audit (push) Waiting to run
responsive fluid/physical scaling, widget-API stabilization, and perf guardrails
Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
2026-07-07 17:40:33 +02:00

627 lines
17 KiB
Rust

//! `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, Length, 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: ( Length::px( 0.0 ), Length::px( 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: ( Length::px( 0.0 ), Length::px( 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: ( Length::px( 0.0 ), Length::px( 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() );
}