A field built with `select_on_focus( true )` kept its cursor at the end of the value after every insertion and deletion. That was meant for the time picker, whose `on_change` normalises what was typed, "2" becoming "02", so that a cursor placed at the end of the typed text would land in the middle of the normalised one. But the flag was also the only way to have a field select its whole value on focus, so every field that wanted that got the pinning too, and in an address bar it is a defect: tapping inside the address to correct a word, the cursor was placed where the finger was, and the first character typed sent it to the end, so the rest of the edit went to the wrong place. The two behaviours are now separate. `select_on_focus` only selects the value when the field takes the focus. The new `TextEdit::pin_cursor_to_end( bool )`, `false` by default, keeps the cursor at the end of the displayed value through the `usize::MAX` sentinel, as before, for fields whose `on_change` rewrites the value. It reaches the runtime in a new `pin_cursor_to_end` field of `WidgetHandlers::TextEdit`, set where the snapshot is built in `Element` and copied in its `Clone`, and the four places in `insert_delete.rs` that anchored the cursor, for an insertion, a delete forward, the deletion of a selection and the move after a replacement, read it instead of `select_on_focus`. The time picker's value fields ask for it, so they behave as they did. A field that uses `select_on_focus` only to select on focus now edits with an ordinary cursor. The one other user known is a dialog in `crate`, which gets the ordinary behaviour; if its `on_change` normalises the text it has to ask for `pin_cursor_to_end( true )` as the time picker does. Documentation of the previous commit and of what building crate showed. `docs/widgets.md` says that a `pressable` loses the tap and the right click to an interactive widget inside it even when that widget has no handler, as a `list_item` without `on_press`, so its content should be built from pieces that take no input; and that the hint of `Button::tooltip` is an xdg-popup beside the button in a window, placed by the compositor and taking no grab, and an overlay spanning the output on a layer surface. `docs/theming.md` gains "Application icons": a desktop entry can point at an installed theme icon, the Figma glass effects resvg does not reproduce, a `backdrop-filter` in a `<foreignObject>` ignored and `plus-lighter` rewritten to `screen` by `decode_svg_bytes`, and the advice to flatten them; the table row of `decode_svg_bytes` mentions the rewrite and that its pixels come back premultiplied. `docs/cookbook.md` says that requesting the focus for the text field that already has it puts the cursor at the end of its value without a second `on_focus`, which is how an application moves the cursor after replacing the value, and that Tab and the vertical arrows reach `App::on_key_with_modifiers` before the field, while Escape reaches it after ltk has closed any transient UI and taken the focus off the field. Losing the keyboard. An application that repeats keys on its own timer, as prompt does for its terminal, never heard that the window lost the keyboard, so a key held while the focus went elsewhere kept repeating until the next key, since its release went to the other window. The new `App::on_keyboard_leave()`, a no-op by default, is called from the keyboard's `leave`, right after ltk stops its own repeat, for the application to stop its repeat and forget the modifiers it kept. Documentation of the previous commit's hooks and widgets. `docs/architecture.md` lists `on_mouse_button` with `MouseButtonEvent` and `MouseButton`, `on_modifiers` and `on_keyboard_leave` among the input hooks. `docs/widgets.md` describes `ListItem::ink( label, secondary )`, which draws a row's label, subtitle, trailing text and hover and press tints in colours of its own for a surface the theme does not know about, and says that `icon_button` keeps a bitmap's proportions, fitting one that is not square into its size and centring it rather than stretching it. Not done. There is no test for the new flag, as `select_on_focus` has one in `text_edit/tests.rs` for its builder and its propagation to the handler, and the documentation of the widget does not mention it.
1130 lines
36 KiB
Markdown
1130 lines
36 KiB
Markdown
# ltk cookbook
|
|
|
|
Concrete recipes for patterns that come up often when building real
|
|
applications and shells with `ltk`. Each recipe pairs a sketch of the
|
|
problem with a copy-pasteable shape of the solution and pointers to the
|
|
relevant APIs.
|
|
|
|
If you are looking for the mental model, read
|
|
[`docs/onboarding.md`](./onboarding.md) and
|
|
[`docs/architecture.md`](./architecture.md) first. For per-widget
|
|
reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
|
|
[`docs/theming.md`](./theming.md).
|
|
|
|
## Table of contents
|
|
|
|
- [Responsive sizing across mobile / tablet / desktop](#responsive-sizing-across-mobile--tablet--desktop)
|
|
- [Slide-in panel](#slide-in-panel)
|
|
- [Password field with PAM submit](#password-field-with-pam-submit)
|
|
- [Swipe-to-dismiss overlay](#swipe-to-dismiss-overlay)
|
|
- [Runtime light / dark theme toggle](#runtime-light--dark-theme-toggle)
|
|
- [Icon launcher with WrapGrid](#icon-launcher-with-wrapgrid)
|
|
- [Channel-driven external state](#channel-driven-external-state)
|
|
- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
|
|
- [Tab navigation between widgets](#tab-navigation-between-widgets)
|
|
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
|
|
- [Surviving relaunch: session state](#surviving-relaunch-session-state)
|
|
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
|
|
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping)
|
|
- [Projecting an externally-laid-out view tree](#projecting-an-externally-laid-out-view-tree)
|
|
|
|
---
|
|
|
|
## Responsive sizing across mobile / tablet / desktop
|
|
|
|
Size everything as a fraction of the surface so one view reads
|
|
coherently from a portrait phone to a landscape desktop window,
|
|
without per-target forks. The rule is *fluid but clamped*: a `vmin`
|
|
percentage tracks the surface's smaller side, and a px `clamp` keeps
|
|
it from collapsing on a tiny screen or ballooning on a 4K monitor.
|
|
Use `Length::orient( portrait, landscape )` when the *proportion*
|
|
itself should differ between orientations, and `Image::short_side` to
|
|
size a logo along the screen's short side while preserving its aspect
|
|
ratio.
|
|
|
|
```rust,no_run
|
|
# use std::sync::Arc;
|
|
# use ltk::{ column, img_widget, text, Length, Element };
|
|
# #[ 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()
|
|
# }
|
|
```
|
|
|
|
Fluid units scale with the screen's pixels, not real-world
|
|
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`)
|
|
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.
|
|
|
|
---
|
|
|
|
## Slide-in panel
|
|
|
|
A quick-settings or notification panel that slides down from the top of
|
|
the screen and dissolves into transparency at its leading edge so the
|
|
edge does not knife-cut against the layer below.
|
|
|
|
```rust,no_run
|
|
# use std::time::Instant;
|
|
# use ltk::{ container, text, viewport, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
|
|
# const OVERLAY_QS: OverlayId = OverlayId( 1 );
|
|
# const SLIDE_DURATION: f32 = 0.25;
|
|
# #[ derive( Clone ) ] enum Msg { CloseQs }
|
|
# struct App { qs_started: Option<Instant>, surface_width: u32, surface_height: u32 }
|
|
# impl App {
|
|
# 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
|
|
// 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;
|
|
|
|
// 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();
|
|
|
|
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 )
|
|
}
|
|
# }
|
|
```
|
|
|
|
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
|
|
when the software path is active.
|
|
|
|
If the panel is a fixed-size pill on a much larger surface (a phone-shaped quick-settings card pinned to a corner of a desktop monitor), add `.local_viewport()` to the `viewport` so `vw` / `vmin` and fluid sizes inside resolve against the pill rect instead of the whole surface — without it the panel's content scales with the monitor and overflows the pill.
|
|
|
|
**See also**: [`Viewport`](./widgets.md#viewport),
|
|
[`OverlaySpec`](../src/app.rs).
|
|
|
|
---
|
|
|
|
## Password field with PAM submit
|
|
|
|
A login screen where the user types a password, presses Enter, and the
|
|
app forwards the submission to a background thread that runs PAM.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ button, column, text, text_edit, App, ChannelSender, Element };
|
|
# #[ derive( Clone ) ] enum Msg {
|
|
# UsernameChanged( String ), PasswordChanged( String ),
|
|
# Submit, AuthResult( bool ),
|
|
# }
|
|
# fn pam_authenticate( _user: &str, _pass: &str ) -> bool { true }
|
|
struct LoginApp
|
|
{
|
|
username: String,
|
|
password: String,
|
|
sender: Option<ChannelSender<Msg>>,
|
|
}
|
|
|
|
impl App for LoginApp
|
|
{
|
|
type Message = Msg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.Login" }
|
|
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
|
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
|
|
|
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 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 ) );
|
|
} );
|
|
|
|
// 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 */ }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`secure( true )` does two things:
|
|
1. Renders bullets instead of the actual characters.
|
|
2. Wipes the underlying byte buffer with zeroes when the
|
|
[`TextEdit`](./widgets.md#text_edit) (and the per-frame handler
|
|
snapshot) is dropped — the heap allocation that used to hold the
|
|
credential is overwritten before it returns to the allocator.
|
|
|
|
`text_edit::on_submit` fires when the user presses Enter inside the
|
|
field; it is wired to the same `Msg::Submit` the explicit button uses
|
|
so both keyboard and pointer paths converge on the same `update` arm.
|
|
|
|
The cleanup of `self.password` is the application's responsibility once
|
|
authentication completes. `ltk` cannot guarantee a clean memory image
|
|
on its own — for the strongest guarantees clear long-lived state in
|
|
your `Drop` impl too.
|
|
|
|
**See also**: [`SECURITY.md`](../SECURITY.md), the
|
|
[`text_edit`](./widgets.md#text_edit) widget, and
|
|
[`crate::ChannelSender`](../src/app.rs).
|
|
|
|
---
|
|
|
|
## Swipe-to-dismiss overlay
|
|
|
|
A modal panel that closes when the user swipes down past a threshold or
|
|
taps outside the panel.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ column, container, spacer, text, Anchor, Element, Layer, Length, OverlayId, OverlaySpec };
|
|
# const OVERLAY_MODAL: OverlayId = OverlayId( 2 );
|
|
# #[ derive( Clone ) ] enum Msg { CloseModal }
|
|
# struct App { modal_open: bool, modal_drag_progress: f32 }
|
|
# impl App {
|
|
# fn modal_body( &self ) -> Element<Msg> { text( "modal" ).into() }
|
|
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
|
|
{
|
|
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();
|
|
|
|
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 )
|
|
}
|
|
|
|
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;
|
|
}
|
|
# }
|
|
```
|
|
|
|
`on_dismiss` is the runtime's "tap outside the panel" hook — it fires
|
|
for any release on the overlay surface that did not land on an
|
|
interactive widget. The runtime also fires it on a press on the main
|
|
surface that does not hit the trigger (relevant for xdg-popup overlays
|
|
under compositors that do not break the popup grab on parent-surface
|
|
clicks) and on Escape with an xdg-popup open. Pair with `on_swipe_down`
|
|
for a follow-the-finger gesture, and `on_swipe_down_progress` if you
|
|
want the modal to track the finger before commit.
|
|
|
|
**See also**: [`OverlaySpec`](../src/app.rs),
|
|
[`docs/architecture.md`](./architecture.md#surface-composition).
|
|
|
|
---
|
|
|
|
## Runtime light / dark theme toggle
|
|
|
|
A "switch theme" affordance that flips the active mode without a
|
|
restart.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ button, Element, ThemeMode };
|
|
# #[ derive( Clone ) ] enum Msg { ToggleTheme }
|
|
# struct App;
|
|
# 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()
|
|
}
|
|
|
|
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.
|
|
}
|
|
}
|
|
}
|
|
# }
|
|
```
|
|
|
|
`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
|
|
renders against the new colours. There is no manual invalidation step.
|
|
|
|
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" );
|
|
ltk::set_active_document( doc );
|
|
# }
|
|
```
|
|
|
|
**See also**: [`docs/theming.md`](./theming.md#using-the-theme-from-app-code).
|
|
|
|
---
|
|
|
|
## Icon launcher with WrapGrid
|
|
|
|
An app drawer that displays an N-column grid of icon buttons, scrolls
|
|
when content overflows, and caches decoded icons across frames.
|
|
|
|
```rust,no_run
|
|
# use std::cell::RefCell;
|
|
# use std::collections::HashMap;
|
|
# use std::sync::Arc;
|
|
# use ltk::{ grid, icon_button, scroll, App, Element };
|
|
# #[ derive( Clone ) ] enum Msg { Launch( String ) }
|
|
# struct DesktopEntry { id: String, icon_path: String }
|
|
# fn decode_icon( _path: &str ) -> ( Arc<Vec<u8>>, u32, u32 ) {
|
|
# ( Arc::new( vec![ 0; 4 ] ), 1, 1 )
|
|
# }
|
|
struct LauncherApp
|
|
{
|
|
apps: Vec<DesktopEntry>,
|
|
icon_cache: RefCell<HashMap<String, ( Arc<Vec<u8>>, u32, u32 )>>,
|
|
}
|
|
|
|
impl App for LauncherApp
|
|
{
|
|
type Message = Msg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.Launcher" }
|
|
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
|
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
|
|
|
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();
|
|
|
|
grid = grid.push(
|
|
icon_button( bytes, w, h )
|
|
.on_press( Msg::Launch( app.id.clone() ) ),
|
|
);
|
|
}
|
|
|
|
scroll( grid ).into()
|
|
}
|
|
|
|
fn update( &mut self, _msg: Msg ) { /* ... */ }
|
|
}
|
|
```
|
|
|
|
The `RefCell<HashMap<…>>` cache is single-threaded — the runtime is
|
|
single-threaded, so `Mutex` would only add lock overhead. Decoded
|
|
icons are kept as `Arc<Vec<u8>>` so building the widget is a pointer
|
|
clone instead of a full byte clone.
|
|
|
|
**See also**: [`grid`](./widgets.md#grid),
|
|
[`scroll`](./widgets.md#scroll),
|
|
[`docs/architecture.md`](./architecture.md#larger-state-patterns).
|
|
|
|
---
|
|
|
|
## Channel-driven external state
|
|
|
|
Surface external events (D-Bus signals, file watches, timers) into the
|
|
event loop without busy-polling.
|
|
|
|
```rust,no_run
|
|
# use std::time::{ Duration, Instant };
|
|
# use ltk::ChannelSender;
|
|
# #[ derive( Clone ) ] struct BatteryEvent;
|
|
# fn wait_for_battery_event() -> BatteryEvent { BatteryEvent }
|
|
# struct OsdToast { expires_at: Instant }
|
|
# #[ derive( Clone ) ] enum Msg { BatteryChanged( BatteryEvent ), HideToast }
|
|
# struct App { sender: Option<ChannelSender<Msg>>, toast: Option<OsdToast> }
|
|
# impl App {
|
|
fn set_channel_sender( &mut self, sender: ChannelSender<Msg> )
|
|
{
|
|
// 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 ) );
|
|
}
|
|
} );
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 ) )
|
|
}
|
|
# }
|
|
```
|
|
|
|
The `set_channel_sender` hook gives you a clone-able sender that wakes
|
|
the event loop from another thread without busy-waiting. `poll_external`
|
|
runs in the loop's own thread after each tick, so anything that costs
|
|
CPU but does not block (TTL checks, RefCell snapshot diffs) is fine
|
|
there.
|
|
|
|
**See also**: [`docs/architecture.md`](./architecture.md#larger-state-patterns).
|
|
|
|
---
|
|
|
|
## Toast / OSD with auto-expiry
|
|
|
|
A short-lived overlay that fades out after a fixed duration without
|
|
blocking other UI.
|
|
|
|
```rust,no_run
|
|
# use std::time::Instant;
|
|
# use ltk::{ container, text, App, Anchor, Color, Element, Layer, Length, OverlayId, OverlaySpec };
|
|
# const OVERLAY_TOAST: OverlayId = OverlayId( 3 );
|
|
# #[ derive( Clone ) ] enum Msg {}
|
|
struct AppState
|
|
{
|
|
toast: Option<Toast>,
|
|
// ...
|
|
}
|
|
|
|
struct Toast
|
|
{
|
|
text: String,
|
|
started: Instant,
|
|
}
|
|
|
|
const TOAST_DURATION: f32 = 2.0;
|
|
const TOAST_FADE: f32 = 0.25;
|
|
|
|
impl AppState
|
|
{
|
|
# fn main_view( &self ) -> Element<Msg> { text( "main" ).into() }
|
|
// ...
|
|
}
|
|
|
|
impl App for AppState
|
|
{
|
|
type Message = Msg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.Toast" }
|
|
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
|
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
|
|
|
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![],
|
|
};
|
|
|
|
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,
|
|
},
|
|
]
|
|
}
|
|
|
|
fn update( &mut self, _msg: Msg ) {}
|
|
|
|
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![]
|
|
}
|
|
}
|
|
```
|
|
|
|
Two ideas worth noting:
|
|
|
|
- `input_region: Some( vec![] )` makes the overlay pass-through: pointer
|
|
events fall through to whatever surface is below. The toast does not
|
|
steal taps from the main UI.
|
|
- The fade-out cleanup belongs in `poll_external`, not `view()`. `view`
|
|
must stay pure — read state, build a tree, no mutation.
|
|
|
|
**See also**: [`docs/architecture.md`](./architecture.md#animations).
|
|
|
|
---
|
|
|
|
## Tab navigation between widgets
|
|
|
|
The runtime ships Tab / Shift+Tab traversal automatically; you only need
|
|
to set up programmatic focus when an external event should land focus on
|
|
a specific widget.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ column, text_edit, App, Element, WidgetId };
|
|
# #[ derive( Clone ) ] enum Msg {
|
|
# UsernameChanged( String ), PasswordChanged( String ), AuthFailed,
|
|
# }
|
|
const FIELD_USERNAME: WidgetId = WidgetId( "username" );
|
|
const FIELD_PASSWORD: WidgetId = WidgetId( "password" );
|
|
|
|
struct LoginApp
|
|
{
|
|
username: String,
|
|
password: String,
|
|
pending_focus: Option<WidgetId>,
|
|
// ...
|
|
}
|
|
|
|
impl App for LoginApp
|
|
{
|
|
type Message = Msg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.Login" }
|
|
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
|
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
|
|
|
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 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 );
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`take_focus_request` is consumed once: the runtime dispatches focus to
|
|
the returned id and the next call returns `None` because the slot was
|
|
drained. The application owns "when should focus move". The target may
|
|
be added to the view by the same `update` that requests it, and may sit
|
|
inside a `scroll` below the fold: the runtime waits a few frames for it
|
|
and moves the viewport so the widget is visible when it takes focus.
|
|
|
|
Requesting focus for the text field that already has it puts its cursor at the end of the value, without firing its `on_focus` again. That is how an application moves the cursor after replacing the value itself, for instance after completing a path on Tab: set the new value in `update` and return the field's id from `take_focus_request`. A field built with `select_on_focus( true )` selects its whole value instead.
|
|
|
|
Tab and Shift+Tab traverse focusable widgets in declaration order — no
|
|
opt-in needed beyond the widget being interactive. While a text field has the focus, Tab and the vertical arrows still reach `App::on_key_with_modifiers` first, so an application can claim them, for completion say: return a message for the key and ltk neither moves the focus nor the cursor. Escape reaches it too, after ltk has closed any transient UI and taken the focus off the field.
|
|
|
|
**See also**: [`WidgetId`](../src/types.rs),
|
|
[`tests/tab_navigation.rs`](../tests/tab_navigation.rs).
|
|
|
|
---
|
|
|
|
## Multi-screen app via sub-state pattern
|
|
|
|
When the app has more than ~30 message variants it is time to split by
|
|
screen. Each screen owns its sub-state and sub-message, and the
|
|
top-level enum wraps them. Which screen is showing is the app's
|
|
*navigation state*: a [`NavStack`](./navigation.md#navstack) holds it, so
|
|
the system back button and a relaunch both work from the same value.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ column, text, App, BackResponse, Element, NavStack };
|
|
# #[ derive( Clone, Copy, PartialEq ) ] enum Screen { Home, Settings, About }
|
|
# #[ derive( Clone ) ] enum HomeMsg {}
|
|
# #[ derive( Clone ) ] enum SettingsMsg {}
|
|
# struct HomeState;
|
|
# struct SettingsState;
|
|
# fn home_view( _: &HomeState ) -> Element<HomeMsg> { text( "home" ).into() }
|
|
# fn home_update( _: &mut HomeState, _: HomeMsg ) {}
|
|
# fn settings_view( _: &SettingsState ) -> Element<SettingsMsg> { text( "settings" ).into() }
|
|
# fn settings_update( _: &mut SettingsState, _: SettingsMsg ) {}
|
|
# fn about_view() -> Element<AppMsg> { text( "about" ).into() }
|
|
# fn nav_bar( _: Screen ) -> Element<AppMsg> { text( "nav" ).into() }
|
|
#[derive(Clone)]
|
|
enum AppMsg
|
|
{
|
|
Open( Screen ),
|
|
Back,
|
|
Home( HomeMsg ),
|
|
Settings( SettingsMsg ),
|
|
}
|
|
|
|
struct AppState
|
|
{
|
|
nav: NavStack<Screen>,
|
|
home: HomeState,
|
|
settings: SettingsState,
|
|
}
|
|
|
|
impl Screen
|
|
{
|
|
fn name( self ) -> &'static str
|
|
{
|
|
match self { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" }
|
|
}
|
|
|
|
fn parse( name: &str ) -> Option<Self>
|
|
{
|
|
[ Screen::Home, Screen::Settings, Screen::About ].into_iter().find( |s| s.name() == name )
|
|
}
|
|
}
|
|
|
|
impl App for AppState
|
|
{
|
|
type Message = AppMsg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
|
|
|
|
// Persist the whole route stack, root first, one screen per line:
|
|
// after a restore, back retraces the same screens. With serde in the
|
|
// app, `serde_json::to_vec( &self.nav )` does the same in one call.
|
|
fn save_state( &self ) -> Option<Vec<u8>>
|
|
{
|
|
let lines: Vec<&str> = self.nav.iter().map( |s| s.name() ).collect();
|
|
Some( lines.join( "\n" ).into_bytes() )
|
|
}
|
|
|
|
fn restore_state( &mut self, state: Vec<u8> )
|
|
{
|
|
let Ok( text ) = String::from_utf8( state ) else { return };
|
|
let Some( screens ) = text.lines().map( Screen::parse ).collect::<Option<Vec<_>>>() else { return };
|
|
let mut screens = screens.into_iter();
|
|
let Some( root ) = screens.next() else { return };
|
|
self.nav.reset( root );
|
|
for screen in screens { self.nav.push( screen ); }
|
|
}
|
|
|
|
// The same value answers the system back button.
|
|
fn on_back( &mut self ) -> BackResponse<AppMsg> { self.nav.back( AppMsg::Back ) }
|
|
|
|
fn view( &self ) -> Element<AppMsg>
|
|
{
|
|
let current = *self.nav.current();
|
|
let body = match 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( current ) )
|
|
.push( body )
|
|
.into()
|
|
}
|
|
|
|
fn update( &mut self, msg: AppMsg )
|
|
{
|
|
match msg
|
|
{
|
|
AppMsg::Open( s ) => self.nav.push( s ),
|
|
AppMsg::Back => { self.nav.pop(); }
|
|
AppMsg::Home( m ) => home_update( &mut self.home, m ),
|
|
AppMsg::Settings( m ) => settings_update( &mut self.settings, m ),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Each screen owns its `view( state ) -> Element<SubMsg>` and
|
|
`update( state, msg )` functions; the parent wraps the sub-message in
|
|
the outer enum on the way out and unwraps it on the way in. There is no
|
|
runtime-level "screen" abstraction in `ltk` because the widget tree is
|
|
already cheap to rebuild every frame — the dispatch above is just three
|
|
function calls.
|
|
|
|
In a real project each screen lives in its own file (`src/home.rs`,
|
|
`src/settings.rs`, `src/about.rs`) declared as `pub mod home;` etc. in
|
|
`lib.rs`, and the call sites become `home::view( &self.home )` and
|
|
`home::update( &mut self.home, m )` instead of the flat
|
|
`home_view` / `home_update` shape used above. The snippet is flat so
|
|
that `doctest-md` can typecheck the dispatch without needing one file
|
|
per screen.
|
|
|
|
An in-app back arrow sends `AppMsg::Back`, the same message `on_back`
|
|
returns, so the arrow and the system button cannot drift apart. A
|
|
sideways move that back should not retrace — switching between sibling
|
|
tabs — uses `self.nav.replace( s )` instead of `push`.
|
|
|
|
**See also**: [`docs/navigation.md`](./navigation.md) for the model
|
|
behind `on_back`, and [`docs/architecture.md`](./architecture.md#larger-state-patterns).
|
|
|
|
---
|
|
|
|
## Surviving relaunch: session state
|
|
|
|
The runtime persists whatever `App::save_state` returns and hands it
|
|
back through `App::restore_state` before the first frame — but only
|
|
when the shell relaunches the app as part of a session restore
|
|
(`LTK_SESSION_RESTORE=1` in the environment) or when the previous run
|
|
did not exit cleanly. A plain launch starts fresh, Android-style;
|
|
window size and position still come back on every launch through
|
|
`xdg-session-management-v1`.
|
|
|
|
The bytes are yours: version them, and treat a parse failure as "keep
|
|
the defaults". No serde needed for small state.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ column, text, text_edit, App, Element };
|
|
#[derive(Clone)]
|
|
enum Msg { Tab( usize ), Draft( String ) }
|
|
|
|
struct Editor { tab: usize, draft: String }
|
|
|
|
impl App for Editor
|
|
{
|
|
type Message = Msg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.Editor" }
|
|
|
|
fn save_state( &self ) -> Option<Vec<u8>>
|
|
{
|
|
// Line 1 is the format version; the draft goes last so it may
|
|
// contain newlines.
|
|
Some( format!( "1\n{}\n{}", self.tab, self.draft ).into_bytes() )
|
|
}
|
|
|
|
fn restore_state( &mut self, state: Vec<u8> )
|
|
{
|
|
let Ok( text ) = String::from_utf8( state ) else { return };
|
|
let mut parts = text.splitn( 3, '\n' );
|
|
if parts.next() != Some( "1" ) { return; }
|
|
if let Some( tab ) = parts.next().and_then( |t| t.parse().ok() ) { self.tab = tab; }
|
|
if let Some( draft ) = parts.next() { self.draft = draft.to_string(); }
|
|
}
|
|
|
|
fn view( &self ) -> Element<Msg>
|
|
{
|
|
column()
|
|
.push( text( format!( "tab {}", self.tab ) ) )
|
|
.push( text_edit( "Draft", &self.draft ).on_change( Msg::Draft ) )
|
|
.into()
|
|
}
|
|
|
|
fn update( &mut self, msg: Msg )
|
|
{
|
|
match msg
|
|
{
|
|
Msg::Tab( t ) => self.tab = t,
|
|
Msg::Draft( d ) => self.draft = d,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The runtime saves in three situations: every ~30 s while the bytes
|
|
differ from the last save, when the window closes, and on `SIGTERM` /
|
|
`SIGINT` (which ltk turns into a clean exit of the event loop). Files
|
|
land in `$XDG_STATE_HOME/<app_id>/` — `state.bin` for your bytes,
|
|
`session.json` for the compositor session id and the clean-exit marker.
|
|
|
|
To try it: run the app, change something, `kill -TERM $(pidof my-app)`,
|
|
then start it again with `LTK_SESSION_RESTORE=1 my-app` — the state is
|
|
back. Start it without the variable and it opens fresh (the window
|
|
still lands where you left it). `kill -KILL` instead of `-TERM` and the
|
|
next plain launch restores too, because the marker says the last run
|
|
never exited cleanly.
|
|
|
|
Shell components (layer-shell panels, lock screens) have no toplevel and
|
|
are never persisted: return `None` and leave `restore_state` empty.
|
|
|
|
What to persist is the app's *navigation state* — the same state
|
|
`App::on_back` reads — so that back retraces the same places after a
|
|
restore. [`docs/navigation.md`](./navigation.md#back-and-session-state-are-the-same-state)
|
|
explains the rule.
|
|
|
|
**See also**: `App::save_state` / `App::restore_state` rustdoc for the
|
|
full contract, and [`docs/architecture.md`](./architecture.md#known-gaps-and-non-goals) for the protocol status.
|
|
|
|
---
|
|
|
|
## Embedding ltk without `ltk::run`
|
|
|
|
A compositor or embedder that already owns the Wayland connection and
|
|
just wants ltk's layout, rendering and hit-testing.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ button, Color, Element, Rect };
|
|
# use ltk::core::{ Canvas, RenderOptions, UiSurface };
|
|
# #[ derive( Clone ) ] enum Msg { Tick }
|
|
# struct App;
|
|
# impl App { fn view( &self ) -> Element<Msg> { button( "x" ).into() }
|
|
# fn update( &mut self, _: Msg ) {} }
|
|
# struct Event;
|
|
# impl Event { fn into_msg( self ) -> Msg { Msg::Tick } }
|
|
# struct Queue( Vec<Event> );
|
|
# impl Queue { fn drain( &mut self ) -> std::vec::Drain<'_, Event> { self.0.drain( .. ) } }
|
|
# fn present_argb8888( _: &[u8] ) {}
|
|
# fn wl_damage( _: &Rect ) {}
|
|
# fn _ex( width: u32, height: u32, mut app: App, mut input_queue: Queue, pos_x: f32, pos_y: f32 ) {
|
|
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() ); }
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
// 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 );
|
|
}
|
|
}
|
|
# break;
|
|
}
|
|
# }
|
|
```
|
|
|
|
`UiSurface` keeps the focus / hover / pressed state, the cursor map,
|
|
the scroll-offset table, and the per-frame widget rects. Set
|
|
`set_focused( Some( idx ) )` etc. from your own input handler and the
|
|
next render automatically uses the partial-damage path when only
|
|
interaction state changed.
|
|
|
|
**See also**: [`tests/core_surface.rs`](../tests/core_surface.rs) for
|
|
the full set of supported operations,
|
|
[`docs/onboarding.md`](./onboarding.md#when-to-use-coreuisurface).
|
|
|
|
---
|
|
|
|
## Custom CPU drawing and path clipping
|
|
|
|
Painting something the widget set does not cover — a gauge, a
|
|
`VectorDrawable`, a Lottie frame, a shaped avatar — straight onto the
|
|
canvas, identically on the GLES and software backends. `External::cpu`
|
|
gives you a closure invoked once per frame with the `Canvas` and the
|
|
widget's laid-out rect; `set_clip_path` then clips arbitrary draws to a
|
|
vector path with an anti-aliased edge.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ External, Element, Color, PathCmd };
|
|
# #[ derive( Clone ) ] enum Msg {}
|
|
# fn _ex() -> Element<Msg> {
|
|
External::cpu( 96.0, 96.0, |canvas, rect|
|
|
{
|
|
// Background, unclipped.
|
|
canvas.fill_rect( rect, Color::rgb( 0.10, 0.10, 0.12 ), 0.0 );
|
|
|
|
// Snapshot the outer clip so the path clip does not leak into the
|
|
// rest of the frame, then clip the foreground to a circle.
|
|
let saved = canvas.clip_bounds();
|
|
let r = rect.width.min( rect.height ) / 2.0;
|
|
let ( cx, cy ) = ( rect.x + rect.width / 2.0, rect.y + rect.height / 2.0 );
|
|
let k = r * 0.5523;
|
|
canvas.set_clip_path( &[
|
|
PathCmd::MoveTo( cx, cy - r ),
|
|
PathCmd::CubicTo( cx + k, cy - r, cx + r, cy - k, cx + r, cy ),
|
|
PathCmd::CubicTo( cx + r, cy + k, cx + k, cy + r, cx, cy + r ),
|
|
PathCmd::CubicTo( cx - k, cy + r, cx - r, cy + k, cx - r, cy ),
|
|
PathCmd::CubicTo( cx - r, cy - k, cx - k, cy - r, cx, cy - r ),
|
|
PathCmd::Close,
|
|
] );
|
|
canvas.fill_rect( rect, Color::rgb( 0.20, 0.50, 0.95 ), 0.0 );
|
|
canvas.set_clip_rects( &saved );
|
|
} ).into()
|
|
# }
|
|
```
|
|
|
|
The path clip is bracketed: `set_clip_path` installs it and
|
|
`set_clip_rects( &saved )` flushes it (on GLES this is when the offscreen
|
|
layer is composited). `clip_bounds` snapshots the prior clip first so an
|
|
outer clip is restored rather than dropped. `fill_path` / `stroke_path`
|
|
take the same `PathCmd` list to paint a path instead of clipping to it.
|
|
|
|
**See also**: [`examples/clip_path.rs`](../examples/clip_path.rs) (rounded
|
|
rect, circle, triangle — same smooth result on both backends).
|
|
|
|
---
|
|
|
|
## Projecting an externally-laid-out view tree
|
|
|
|
Hosting a widget tree whose geometry is computed elsewhere — an Android
|
|
measure/layout pass, say, which yields one absolute rect per view —
|
|
projected onto an ltk `stack` in paint order. `measure_text` gives the
|
|
external layout the same metrics the renderer will use, without a live
|
|
`Canvas`; `push_placed` drops each child at its exact rect; and
|
|
`push_placed_clipped` clips a child's overflow like Android's
|
|
`clipChildren`.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ measure_text, stack, text, Element, Rect };
|
|
# #[ derive( Clone ) ] enum Msg {}
|
|
# struct View { label: String, rect: Rect, clip: Option<Rect> }
|
|
# fn external_layout_pass() -> Vec<View> { Vec::new() }
|
|
# fn _ex() -> Element<Msg> {
|
|
// The external layout pass measures text with the renderer's own metrics.
|
|
let ( w, line_h ) = measure_text( "Inbox", 16.0 );
|
|
let _ = ( w, line_h );
|
|
|
|
let views = external_layout_pass();
|
|
let mut s = stack();
|
|
for v in views
|
|
{
|
|
let child = text( v.label );
|
|
s = match v.clip
|
|
{
|
|
Some( clip ) => s.push_placed_clipped( child, v.rect, clip ),
|
|
None => s.push_placed( child, v.rect ),
|
|
};
|
|
}
|
|
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 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.
|