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