docs overhaul, orientation API, fluid-sizing fixes, examples made honest
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Documentation pass: every claim in docs/ and the meta files was audited against the source and the drift fixed — around ninety corrections. CONTRIBUTING and the CI workflow now run cargo test with --features test-support (the gated test_support module made both the documented commands and the CI build fail to compile), make example becomes make examples, make doctest-md and the debhelper requirement of make clean are documented, and patch shape asks for a CHANGELOG entry. theming.md loses the nonexistent surface.backdrop, gains the real gradient defaults (linear-rgb, oklab), the six slot variants including typography, the ten-field palette, a truthful effects-consumer table, the ThemePreference/from_hour API and a responsive-sizing note; the stale docstrings in src/theme that fed the drift are fixed too. architecture.md's "Known gaps" section is rewritten against reality (multi-touch slots, xdg-activation, a11y live regions and SetValue/Increment/Decrement are implemented), gains a module map, subsurfaces and window-lifecycle coverage, and correct crustace/loginmanager paths. widgets.md fixes the ten factual errors (stateless spinner, toast/combo via overlays(), tooltip hover contract, row has no max_width, scroll axes, multiline text_edit, dialog panic wording) and now states the column() 16 px default padding — the recurring ambush — plus row's differing 0 default and dialog's max_width. onboarding, README and cookbook get the remaining sweep: build/test instructions, complete example lists, img_widget, clipping-parity honesty, ~30 Hz software cap, read_rgba_pixels signature, tab indentation in snippets, and rustdoc-style links that rendered literally are gone everywhere. CHANGELOG is restructured per Keep a Changelog with the missing entries (window_resizable, claims_raw_touch, Row::align_top/fill_height, caret fixes, dependency pins) and the pad_v Added/Changed contradiction resolved.
New adaptive-layout API: ltk::orientation() with the Orientation enum, backed by viewport_size()/set_viewport_size — the runtime records the main surface's physical dimensions on every configure, before App::on_resize, so view() can branch a layout on portrait vs landscape without hand-tracking resizes. The portrait rule matches Length::orient (square counts as portrait); embedders driving core::UiSurface call set_viewport_size themselves. Documented in the crate root's responsive-design section and architecture.md.
Fluid-vs-fixed sizing fixes in widgets, all the same disease — fluid content inside a fixed-pixel box. TextEdit::fixed_width takes impl Into<Length> (f32 call sites keep compiling as px) and the time picker's digit fields move to Length::fluid( 72.0 ), matching their fluid font so digits can no longer outgrow the box. Dialog::max_width takes impl Into<Length> with a Length::fluid( 480.0 ) default so the card scales with the stock buttons inside it, and the card's interior no longer stacks the column() default 16 px padding on top of CARD_PADDING — that double inset squeezed the action row until its buttons clipped on narrow windows. App::on_pointer_axis now triggers a view rebuild and repaint; previously state mutated in the hook did not paint until the next unrelated event.
Examples reworked to be honest demos: responsive's mode/density controls become stock buttons in a grid/column so they follow the modes they demonstrate instead of overflowing; dialog's openers stack vertically, and the example gains the app-level ESC handler so the ESC chain closes an open dialog first and quits second; widgets' tab strip now switches real per-tab pages; carousel gains pointer/touch drag through the horizontal-swipe hooks (crustace's pager pattern), one-tile-per-detent mouse wheel, and snap math driven by the real surface width from on_resize instead of a hardcoded 800; clip_path arranges its cells by ltk::orientation() and sizes them from the counter-axis of the flow.
This commit is contained in:
2026-07-30 19:28:26 +02:00
parent 14572ebfb6
commit 1fd697aa6d
33 changed files with 1131 additions and 661 deletions

View File

@@ -47,22 +47,24 @@ ratio.
# #[ derive( Clone ) ] enum Msg {}
# fn _ex( logo: Arc<Vec<u8>>, lw: u32, lh: u32 ) -> Element<Msg> {
column::<Msg>()
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
// Heading: fluid, but never below 20 px nor above 44 px.
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
.into()
.padding( Length::vmin( 4.0 ).clamp( 16.0, 48.0 ) )
.spacing( Length::vmin( 2.0 ).clamp( 8.0, 24.0 ) )
// Logo: 40 % of the width in portrait, 5 % of the height in landscape.
.push( img_widget( logo, lw, lh ).short_side( Length::orient( 40.0, 5.0 ) ) )
// Heading: fluid, but never below 20 px nor above 44 px.
.push( text( "Welcome" ).size( Length::vmin( 6.0 ).clamp( 20.0, 44.0 ) ) )
.into()
# }
```
Fluid units scale with the screen's pixels, not real-world
millimetres. For body text that must stay physically legible and
honour the user's font-size preference across an open-ended device
set, prefer `Length::dp` or `Length::em` over raw `vmin`; the
millimetres. For sizes that must stay physically constant across an
open-ended device set — touch targets, text that honours the user's
font-size preference — use `Length::dp` or `Length::em`. For text
that should stay fluid, reach for the
[`typography`](../src/theme/typography.rs) scale (`h0`…`body_xs`)
gives clamped-`vmin` sizes tuned for running text. Reserve
instead of hand-rolling raw `vmin`: its ramp is clamped-`vmin`,
tuned for running text. Reserve
`view()`-level branching on `surface_width` / `surface_height` for
genuine layout restructuring (sidebar → bottom tabs), not for sizing.
@@ -85,57 +87,62 @@ edge does not knife-cut against the layer below.
# fn quick_settings_view( &self ) -> Element<Msg> { text( "qs" ).into() }
fn build_quick_settings_overlay( &self ) -> OverlaySpec<Msg>
{
// Compute the slide progress based on a stored start instant. While
// animating, `is_animating()` returns `true` so the runtime redraws
// at ~60 Hz and reads the new progress every frame.
let progress = match self.qs_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ),
None => 1.0,
};
// Compute the slide progress based on a stored start instant. While
// animating, `is_animating()` returns `true` so the runtime redraws
// every compositor frame (~60 Hz on GLES; capped at ~30 Hz on the
// software backend, see `cap_software_animation`) and reads the new
// progress each time.
let progress = match self.qs_started
{
Some( t ) => ( t.elapsed().as_secs_f32() / SLIDE_DURATION ).min( 1.0 ),
None => 1.0,
};
let panel_height = self.surface_height as f32 * 0.85;
let visible_h = panel_height * progress;
let panel_height = self.surface_height as f32 * 0.85;
let visible_h = panel_height * progress;
// Feather the bottom edge during the slide; drop the fade once the
// panel is fully open so the bottom of a settled panel is hard.
let fade_px = if progress < 1.0 { 16.0 } else { 0.0 };
// Feather the bottom edge during the slide; drop the fade once the
// panel is fully open so the bottom of a settled panel is hard.
let fade_px = if progress < 1.0 { 16.0 } else { 0.0 };
let panel: Element<Msg> = container( self.quick_settings_view() )
.surface( "surface-card" )
.padding( 24.0 )
.into();
let panel: Element<Msg> = container( self.quick_settings_view() )
.surface( "surface-card" )
.padding( 24.0 )
.into();
OverlaySpec
{
id: OVERLAY_QS,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: viewport( panel )
.height( panel_height )
.fade_bottom( fade_px )
.into(),
on_dismiss: Some( Msg::CloseQs ),
anchor_widget_id: None,
}
OverlaySpec
{
id: OVERLAY_QS,
layer: Layer::Overlay,
anchor: Anchor::TOP,
size: ( Length::px( self.surface_width as f32 ), Length::px( visible_h ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None,
view: viewport( panel )
.height( panel_height )
.fade_bottom( fade_px )
.into(),
on_dismiss: Some( Msg::CloseQs ),
anchor_widget_id: None,
}
}
fn is_animating( &self ) -> bool
{
self.qs_started
.map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION )
.unwrap_or( false )
self.qs_started
.map( |t| t.elapsed().as_secs_f32() < SLIDE_DURATION )
.unwrap_or( false )
}
# }
```
Set `LTK_PERF_WARN=1` during development to be warned when `is_animating()`
sticks at `true` after an animation should have settled.
The `fade_bottom( px )` builder is a GLES-only effect; the software
backend renders a hard edge. If your shell must look identical on both
backends, branch on [`ltk::is_software_render()`] and skip the fade
backends, branch on `ltk::is_software_render()` and skip the fade
when the software path is active.
**See also**: [`Viewport`](./widgets.md#viewport),
@@ -157,69 +164,69 @@ app forwards the submission to a background thread that runs PAM.
# fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true }
struct LoginApp
{
username: String,
password: String,
sender: Option<ChannelSender<Msg>>,
username: String,
password: String,
sender: Option<ChannelSender<Msg>>,
}
impl App for LoginApp
{
type Message = Msg;
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Sign in" ).size( 28.0 ) )
.push(
text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.secure( true ) // mask glyphs + zeroize on drop
.on_change( |s| Msg::PasswordChanged( s ) )
.on_submit( Msg::Submit ), // Enter fires this
)
.push( button( "Log in" ).on_press( Msg::Submit ) )
.into()
}
fn view( &self ) -> Element<Msg>
{
column()
.padding( 32.0 )
.spacing( 16.0 )
.push( text( "Sign in" ).size( 28.0 ) )
.push(
text_edit( "Username", &self.username )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.secure( true ) // mask glyphs + zeroize on drop
.on_change( |s| Msg::PasswordChanged( s ) )
.on_submit( Msg::Submit ), // Enter fires this
)
.push( button( "Log in" ).on_press( Msg::Submit ) )
.into()
}
fn set_channel_sender( &mut self, s: ChannelSender<Msg> )
{
// Saved once at startup; cloned into worker threads so they can
// wake the loop without polling.
self.sender = Some( s );
}
fn set_channel_sender( &mut self, s: ChannelSender<Msg> )
{
// Saved once at startup; cloned into worker threads so they can
// wake the loop without polling.
self.sender = Some( s );
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::UsernameChanged( s ) => self.username = s,
Msg::PasswordChanged( s ) => self.password = s,
Msg::Submit =>
{
let username = self.username.clone();
let password = self.password.clone();
let sender = self.sender.clone().unwrap();
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::UsernameChanged( s ) => self.username = s,
Msg::PasswordChanged( s ) => self.password = s,
Msg::Submit =>
{
let username = self.username.clone();
let password = self.password.clone();
let sender = self.sender.clone().unwrap();
std::thread::spawn( move ||
{
let result = pam_authenticate( &username, &password );
let _ = sender.send( Msg::AuthResult( result ) );
} );
std::thread::spawn( move ||
{
let result = pam_authenticate( &username, &password );
let _ = sender.send( Msg::AuthResult( result ) );
} );
// Clear the visible field so the user has feedback;
// `secure( true )` zeroizes the buffer when the next
// view() rebuild drops the old TextEdit.
self.password.clear();
}
Msg::AuthResult( true ) => std::process::exit( 0 ),
Msg::AuthResult( false ) => { /* show error */ }
}
}
// Clear the visible field so the user has feedback;
// `secure( true )` zeroizes the buffer when the next
// view() rebuild drops the old TextEdit.
self.password.clear();
}
Msg::AuthResult( true ) => std::process::exit( 0 ),
Msg::AuthResult( false ) => { /* show error */ }
}
}
}
```
@@ -259,54 +266,54 @@ taps outside the panel.
# fn modal_body( &self ) -> Element<Msg> { text( "modal" ).into() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
if !self.modal_open { return vec![]; }
if !self.modal_open { return vec![]; }
// The modal body sits inside a column capped at 400 px so it stays
// legible on wide displays; the outer column with two spacers
// centres it vertically.
let modal: Element<Msg> = column()
.max_width( 400.0 )
.push(
container( self.modal_body() )
.surface( "surface-card" )
.padding( 24.0 ),
)
.into();
// The modal body sits inside a column capped at 400 px so it stays
// legible on wide displays; the outer column with two spacers
// centres it vertically.
let modal: Element<Msg> = column()
.max_width( 400.0 )
.push(
container( self.modal_body() )
.surface( "surface-card" )
.padding( 24.0 ),
)
.into();
vec![
OverlaySpec
{
id: OVERLAY_MODAL,
layer: Layer::Overlay,
anchor: Anchor::ALL,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None, // accept input
view: column()
.center_y( true )
.push( spacer() )
.push( modal )
.push( spacer() )
.into(),
on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses
anchor_widget_id: None,
},
]
vec![
OverlaySpec
{
id: OVERLAY_MODAL,
layer: Layer::Overlay,
anchor: Anchor::ALL,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: None, // accept input
view: column()
.center_y( true )
.push( spacer() )
.push( modal )
.push( spacer() )
.into(),
on_dismiss: Some( Msg::CloseModal ), // tap outside dismisses
anchor_widget_id: None,
},
]
}
// Swipe-down gesture (only fires inside the overlay because the main
// surface does not declare a down-swipe target).
fn on_swipe_down( &mut self ) -> Option<Msg>
{
Some( Msg::CloseModal )
Some( Msg::CloseModal )
}
fn on_swipe_down_progress( &mut self, progress: f32 )
{
// Optional follow-the-finger feedback: store the in-progress value
// and use it in view() to translate or fade the modal contents.
self.modal_drag_progress = progress;
// Optional follow-the-finger feedback: store the in-progress value
// and use it in view() to translate or fade the modal contents.
self.modal_drag_progress = progress;
}
# }
```
@@ -337,37 +344,37 @@ restart.
# impl App {
fn view( &self ) -> Element<Msg>
{
let label = match ltk::active_mode()
{
ThemeMode::Light => "Switch to dark",
ThemeMode::Dark => "Switch to light",
};
button( label ).on_press( Msg::ToggleTheme ).into()
let label = match ltk::active_mode()
{
ThemeMode::Light => "Switch to dark",
ThemeMode::Dark => "Switch to light",
};
button( label ).on_press( Msg::ToggleTheme ).into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::ToggleTheme =>
{
let new = match ltk::active_mode()
{
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Light,
};
ltk::set_active_mode( new );
// No further action — the next render reads the new mode
// through the slot helpers and recomposes the surface.
}
}
match msg
{
Msg::ToggleTheme =>
{
let new = match ltk::active_mode()
{
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Light,
};
ltk::set_active_mode( new );
// No further action — the next render reads the new mode
// through the slot helpers and recomposes the surface.
}
}
}
# }
```
`set_active_mode` mutates a process-global cell; the next `view()`
rebuild reads the new mode through the per-slot helpers
([`theme_palette`], [`theme_surface`], [`theme_paint`], etc.) and
(`theme_palette`, `theme_surface`, `theme_paint`, etc.) and
renders against the new colours. There is no manual invalidation step.
For a full theme swap, load a different `ThemeDocument` and apply it:
@@ -375,7 +382,7 @@ For a full theme swap, load a different `ThemeDocument` and apply it:
```rust,no_run
# fn _ex() {
let doc = ltk::ThemeDocument::find( "midnight" )
.expect( "midnight theme not installed" );
.expect( "midnight theme not installed" );
ltk::set_active_document( doc );
# }
```
@@ -401,41 +408,41 @@ when content overflows, and caches decoded icons across frames.
# }
struct LauncherApp
{
apps: Vec<DesktopEntry>,
icon_cache: RefCell<HashMap<String, ( Arc<Vec<u8>>, u32, u32 )>>,
apps: Vec<DesktopEntry>,
icon_cache: RefCell<HashMap<String, ( Arc<Vec<u8>>, u32, u32 )>>,
}
impl App for LauncherApp
{
type Message = Msg;
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
let mut grid = grid::<Msg>( 4 )
.padding( 16.0 )
.spacing( 12.0 );
fn view( &self ) -> Element<Msg>
{
let mut grid = grid::<Msg>( 4 )
.padding( 16.0 )
.spacing( 12.0 );
for app in &self.apps
{
// Decode icons once on first reference; reuse the Arc on
// every subsequent frame for a pointer copy.
let ( bytes, w, h ) = self
.icon_cache
.borrow_mut()
.entry( app.id.clone() )
.or_insert_with( || decode_icon( &app.icon_path ) )
.clone();
for app in &self.apps
{
// Decode icons once on first reference; reuse the Arc on
// every subsequent frame for a pointer copy.
let ( bytes, w, h ) = self
.icon_cache
.borrow_mut()
.entry( app.id.clone() )
.or_insert_with( || decode_icon( &app.icon_path ) )
.clone();
grid = grid.push(
icon_button( bytes, w, h )
.on_press( Msg::Launch( app.id.clone() ) ),
);
}
grid = grid.push(
icon_button( bytes, w, h )
.on_press( Msg::Launch( app.id.clone() ) ),
);
}
scroll( grid ).into()
}
scroll( grid ).into()
}
fn update( &mut self, _msg: Msg ) { /* ... */ }
fn update( &mut self, _msg: Msg ) { /* ... */ }
}
```
@@ -466,47 +473,47 @@ event loop without busy-polling.
# impl App {
fn set_channel_sender( &mut self, sender: ChannelSender<Msg> )
{
// Saved once and never changed.
self.sender = Some( sender.clone() );
// Saved once and never changed.
self.sender = Some( sender.clone() );
// Spawn the worker that watches for external events and forwards
// them as messages. Substitute `wait_for_battery_event` with whatever
// blocks for your real source — a D-Bus signal, a file watch, a
// socket read, a timer, etc.
std::thread::spawn( move ||
{
loop
{
// Block until the external source produces an event. When it
// arrives, post a message into the loop.
let event = wait_for_battery_event();
let _ = sender.send( Msg::BatteryChanged( event ) );
}
} );
// Spawn the worker that watches for external events and forwards
// them as messages. Substitute `wait_for_battery_event` with whatever
// blocks for your real source — a D-Bus signal, a file watch, a
// socket read, a timer, etc.
std::thread::spawn( move ||
{
loop
{
// Block until the external source produces an event. When it
// arrives, post a message into the loop.
let event = wait_for_battery_event();
let _ = sender.send( Msg::BatteryChanged( event ) );
}
} );
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// For state that doesn't need a dedicated thread (file mtime checks,
// expiry sweeps), drain it here. Called after every Wayland event
// and every poll_interval tick.
let mut msgs = vec![];
if let Some( osd ) = self.toast.as_ref()
{
if osd.expires_at <= Instant::now()
{
msgs.push( Msg::HideToast );
}
}
msgs
// For state that doesn't need a dedicated thread (file mtime checks,
// expiry sweeps), drain it here. Called after every Wayland event
// and every poll_interval tick.
let mut msgs = vec![];
if let Some( osd ) = self.toast.as_ref()
{
if osd.expires_at <= Instant::now()
{
msgs.push( Msg::HideToast );
}
}
msgs
}
fn poll_interval( &self ) -> Option<Duration>
{
// Wake every minute to re-check the clock display. Keep this `None`
// unless you actually need a wall-clock tick — it costs battery
// life on mobile targets.
Some( Duration::from_secs( 60 ) )
// Wake every minute to re-check the clock display. Keep this `None`
// unless you actually need a wall-clock tick — it costs battery
// life on mobile targets.
Some( Duration::from_secs( 60 ) )
}
# }
```
@@ -533,14 +540,14 @@ blocking other UI.
# #[ derive( Clone ) ] enum Msg {}
struct AppState
{
toast: Option<Toast>,
// ...
toast: Option<Toast>,
// ...
}
struct Toast
{
text: String,
started: Instant,
text: String,
started: Instant,
}
const TOAST_DURATION: f32 = 2.0;
@@ -549,71 +556,72 @@ const TOAST_FADE: f32 = 0.25;
impl AppState
{
# fn main_view( &self ) -> Element<Msg> { text( "main" ).into() }
// ...
// ...
}
impl App for AppState
{
type Message = Msg;
type Message = Msg;
fn view( &self ) -> Element<Msg> { self.main_view() }
fn view( &self ) -> Element<Msg> { self.main_view() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
let toast = match &self.toast
{
Some( t ) => t,
None => return vec![],
};
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
{
let toast = match &self.toast
{
Some( t ) => t,
None => return vec![],
};
let elapsed = toast.started.elapsed().as_secs_f32();
let alpha = if elapsed >= TOAST_DURATION
{
// Fade-out window: 0.25 s after expiry the alpha hits 0.
( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 )
} else { 1.0 };
let elapsed = toast.started.elapsed().as_secs_f32();
let alpha = if elapsed >= TOAST_DURATION
{
// Fade-out window: 0.25 s after expiry the alpha hits 0.
( 1.0 - ( elapsed - TOAST_DURATION ) / TOAST_FADE ).clamp( 0.0, 1.0 )
} else { 1.0 };
vec![
OverlaySpec
{
id: OVERLAY_TOAST,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through
view: container( text( &toast.text ).color( Color::WHITE ) )
.surface( "surface-panel" )
.padding( 12.0 )
.opacity( alpha )
.into(),
on_dismiss: None,
anchor_widget_id: None,
},
]
}
vec![
OverlaySpec
{
id: OVERLAY_TOAST,
layer: Layer::Overlay,
anchor: Anchor::BOTTOM,
size: ( Length::px( 0.0 ), Length::px( 0.0 ) ),
exclusive_zone: 0,
keyboard_exclusive: false,
input_region: Some( vec![] ), // pass-through
view: container( text( &toast.text ).color( Color::WHITE ) )
.surface( "surface-panel" )
.padding( 12.0 )
.opacity( alpha )
.into(),
on_dismiss: None,
anchor_widget_id: None,
},
]
}
fn update( &mut self, _msg: Msg ) {}
fn update( &mut self, _msg: Msg ) {}
fn is_animating( &self ) -> bool
{
// Redraw at 60 Hz while the toast is visible or fading.
self.toast.is_some()
}
fn is_animating( &self ) -> bool
{
// Redraw every compositor frame (capped at ~30 Hz on the
// software backend) while the toast is visible or fading.
self.toast.is_some()
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Drop the toast once the fade window completes.
if let Some( t ) = &self.toast
{
if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE
{
self.toast = None;
}
}
vec![]
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// Drop the toast once the fade window completes.
if let Some( t ) = &self.toast
{
if t.started.elapsed().as_secs_f32() >= TOAST_DURATION + TOAST_FADE
{
self.toast = None;
}
}
vec![]
}
}
```
@@ -645,50 +653,50 @@ const FIELD_PASSWORD: WidgetId = WidgetId( "password" );
struct LoginApp
{
username: String,
password: String,
pending_focus: Option<WidgetId>,
// ...
username: String,
password: String,
pending_focus: Option<WidgetId>,
// ...
}
impl App for LoginApp
{
type Message = Msg;
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column()
.push(
text_edit( "Username", &self.username )
.id( FIELD_USERNAME )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.id( FIELD_PASSWORD )
.secure( true )
.on_change( |s| Msg::PasswordChanged( s ) ),
)
.into()
}
fn view( &self ) -> Element<Msg>
{
column()
.push(
text_edit( "Username", &self.username )
.id( FIELD_USERNAME )
.on_change( |s| Msg::UsernameChanged( s ) ),
)
.push(
text_edit( "Password", &self.password )
.id( FIELD_PASSWORD )
.secure( true )
.on_change( |s| Msg::PasswordChanged( s ) ),
)
.into()
}
fn take_focus_request( &mut self ) -> Option<WidgetId>
{
// Returned once; the runtime focuses that widget on the next
// frame. Subsequent calls return None.
self.pending_focus.take()
}
fn take_focus_request( &mut self ) -> Option<WidgetId>
{
// Returned once; the runtime focuses that widget on the next
// frame. Subsequent calls return None.
self.pending_focus.take()
}
fn update( &mut self, msg: Msg )
{
if matches!( msg, Msg::AuthFailed )
{
// Clear the password and put focus back on the field so the
// user can retype without a click.
self.password.clear();
self.pending_focus = Some( FIELD_PASSWORD );
}
}
fn update( &mut self, msg: Msg )
{
if matches!( msg, Msg::AuthFailed )
{
// Clear the password and put focus back on the field so the
// user can retype without a click.
self.password.clear();
self.pending_focus = Some( FIELD_PASSWORD );
}
}
}
```
@@ -726,46 +734,46 @@ top-level enum wraps them.
#[derive(Clone)]
enum AppMsg
{
Nav( Screen ),
Home( HomeMsg ),
Settings( SettingsMsg ),
Nav( Screen ),
Home( HomeMsg ),
Settings( SettingsMsg ),
}
struct AppState
{
current: Screen,
home: HomeState,
settings: SettingsState,
current: Screen,
home: HomeState,
settings: SettingsState,
}
impl App for AppState
{
type Message = AppMsg;
type Message = AppMsg;
fn view( &self ) -> Element<AppMsg>
{
let body = match self.current
{
Screen::Home => home_view( &self.home ).map( AppMsg::Home ),
Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ),
Screen::About => about_view(),
};
fn view( &self ) -> Element<AppMsg>
{
let body = match self.current
{
Screen::Home => home_view( &self.home ).map( AppMsg::Home ),
Screen::Settings => settings_view( &self.settings ).map( AppMsg::Settings ),
Screen::About => about_view(),
};
column()
.push( nav_bar( self.current ) )
.push( body )
.into()
}
column()
.push( nav_bar( self.current ) )
.push( body )
.into()
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
AppMsg::Nav( s ) => self.current = s,
AppMsg::Home( m ) => home_update( &mut self.home, m ),
AppMsg::Settings( m ) => settings_update( &mut self.settings, m ),
}
}
fn update( &mut self, msg: AppMsg )
{
match msg
{
AppMsg::Nav( s ) => self.current = s,
AppMsg::Home( m ) => home_update( &mut self.home, m ),
AppMsg::Settings( m ) => settings_update( &mut self.settings, m ),
}
}
}
```
@@ -811,46 +819,46 @@ let mut surface = UiSurface::<Msg>::new( width, height );
loop
{
// 1. Drain pending app events from your own input source.
for ev in input_queue.drain() { app.update( ev.into_msg() ); }
// 1. Drain pending app events from your own input source.
for ev in input_queue.drain() { app.update( ev.into_msg() ); }
// 2. Build the tree and render.
let view = app.view();
let out = surface.render(
&view,
RenderOptions::full_canvas( width, height )
.background( Color::TRANSPARENT ),
);
// 2. Build the tree and render.
let view = app.view();
let out = surface.render(
&view,
RenderOptions::full_canvas( width, height )
.background( Color::TRANSPARENT ),
);
// 3. Pull pixels (software backend) or present the FBO (GLES).
match surface.canvas()
{
Canvas::Software( _ ) =>
{
let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
present_argb8888( &buf );
}
Canvas::Gles( _ ) =>
{
// Already drawn into the FBO the embedder owns; commit
// through your own EGL context.
}
}
// 3. Pull pixels (software backend) or present the FBO (GLES).
match surface.canvas()
{
Canvas::Software( _ ) =>
{
let mut buf = vec![ 0u8; ( width * height * 4 ) as usize ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
present_argb8888( &buf );
}
Canvas::Gles( _ ) =>
{
// Already drawn into the FBO the embedder owns; commit
// through your own EGL context.
}
}
// 4. Use damage rects to feed wl_surface.damage_buffer if you are
// on the software path.
for rect in &out.damage_rects { wl_damage( rect ); }
// 4. Use damage rects to feed wl_surface.damage_buffer if you are
// on the software path.
for rect in &out.damage_rects { wl_damage( rect ); }
// Pointer dispatch: turn a screen-space point into the widget under it.
let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } );
if let Some( idx ) = hit
{
if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() )
{
app.update( msg );
}
}
// Pointer dispatch: turn a screen-space point into the widget under it.
let hit = surface.hit_test( ltk::Point { x: pos_x, y: pos_y } );
if let Some( idx ) = hit
{
if let Some( msg ) = surface.handlers( idx ).and_then( |h| h.press_msg() )
{
app.update( msg );
}
}
# break;
}
# }
@@ -954,8 +962,9 @@ s.into()
To rasterise the result into a caller-owned buffer instead of presenting
it, render through a [`core::UiSurface`](#embedding-ltk-without-ltkrun)
and call `Canvas::read_rgba_pixels( &mut buf )` — it returns tightly
packed straight-alpha RGBA8 (top-left row first) from either backend
and call `Canvas::read_rgba_pixels( &mut buf )` — it fills the buffer
with tightly packed straight-alpha RGBA8 (top-left row first) and
returns `Result<(), String>`, on either backend
(the software path un-premultiplies for you). Branch on
`Canvas::is_software()` when a draw must honour a real path clip on
software but only a bounding rect on GLES.