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`.
1092 lines
34 KiB
Markdown
1092 lines
34 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".
|
|
|
|
Tab and Shift+Tab traverse focusable widgets in declaration order — no
|
|
opt-in needed beyond the widget being interactive.
|
|
|
|
**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.
|
|
|
|
```rust,no_run
|
|
# use ltk::{ column, text, App, Element };
|
|
# #[ derive( Clone, Copy ) ] 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
|
|
{
|
|
Nav( Screen ),
|
|
Home( HomeMsg ),
|
|
Settings( SettingsMsg ),
|
|
}
|
|
|
|
struct AppState
|
|
{
|
|
current: Screen,
|
|
home: HomeState,
|
|
settings: SettingsState,
|
|
}
|
|
|
|
impl App for AppState
|
|
{
|
|
type Message = AppMsg;
|
|
|
|
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
|
|
|
|
// Persist only the routing; each screen would add its own line.
|
|
fn save_state( &self ) -> Option<Vec<u8>>
|
|
{
|
|
let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" };
|
|
Some( s.as_bytes().to_vec() )
|
|
}
|
|
|
|
fn restore_state( &mut self, state: Vec<u8> )
|
|
{
|
|
match state.as_slice()
|
|
{
|
|
b"home" => self.current = Screen::Home,
|
|
b"settings" => self.current = Screen::Settings,
|
|
b"about" => self.current = Screen::About,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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 ),
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
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.
|
|
|
|
**See also**: [`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.
|
|
|
|
**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.
|