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`.
328 lines
9.6 KiB
Rust
328 lines
9.6 KiB
Rust
#![ cfg( feature = "test-support" ) ]
|
||
|
||
// End-to-end coverage for the runtime contract: `Msg → App::update → next
|
||
// view → render`. The Wayland event loop in `ltk::run` is the integration
|
||
// point that ties widget-level handler snapshots, focus traversal and keysym
|
||
// dispatch together; these tests exercise the same wiring against `UiSurface`
|
||
// (the runtime-free embedding of that loop).
|
||
|
||
use ltk::core::{ RenderOptions, UiSurface };
|
||
use ltk::test_support::next_focusable_index;
|
||
use ltk::{
|
||
button, column, text, App, Color, Element, Keysym,
|
||
};
|
||
|
||
// ── A small counter app ───────────────────────────────────────────────────────
|
||
|
||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||
enum Msg
|
||
{
|
||
Inc,
|
||
Dec,
|
||
Reset,
|
||
Quit,
|
||
}
|
||
|
||
struct Counter
|
||
{
|
||
value: i32,
|
||
pending: Vec<Msg>,
|
||
quit: bool,
|
||
}
|
||
|
||
impl Counter
|
||
{
|
||
fn new() -> Self
|
||
{
|
||
Self { value: 0, pending: vec![], quit: false }
|
||
}
|
||
}
|
||
|
||
impl App for Counter
|
||
{
|
||
type Message = Msg;
|
||
|
||
fn app_id( &self ) -> &str { "net.liberux.ltk.test.counter" }
|
||
|
||
fn save_state( &self ) -> Option<Vec<u8>>
|
||
{
|
||
Some( self.value.to_string().into_bytes() )
|
||
}
|
||
|
||
fn restore_state( &mut self, state: Vec<u8> )
|
||
{
|
||
if let Some( v ) = std::str::from_utf8( &state ).ok().and_then( |s| s.parse().ok() )
|
||
{
|
||
self.value = v;
|
||
}
|
||
}
|
||
|
||
fn view( &self ) -> Element<Msg>
|
||
{
|
||
column::<Msg>()
|
||
.padding( 16.0 )
|
||
.spacing( 8.0 )
|
||
.push( text( format!( "{}", self.value ) ) )
|
||
.push( button( "+" ).on_press( Msg::Inc ) )
|
||
.push( button( "−" ).on_press( Msg::Dec ) )
|
||
.push( button( "reset" ).on_press( Msg::Reset ) )
|
||
.into()
|
||
}
|
||
|
||
fn update( &mut self, msg: Msg )
|
||
{
|
||
match msg
|
||
{
|
||
Msg::Inc => self.value += 1,
|
||
Msg::Dec => self.value -= 1,
|
||
Msg::Reset => self.value = 0,
|
||
Msg::Quit => self.quit = true,
|
||
}
|
||
}
|
||
|
||
fn poll_external( &mut self ) -> Vec<Msg>
|
||
{
|
||
std::mem::take( &mut self.pending )
|
||
}
|
||
|
||
fn on_key( &mut self, keysym: Keysym ) -> Option<Msg>
|
||
{
|
||
match keysym
|
||
{
|
||
Keysym::Escape => Some( Msg::Quit ),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render( surface: &mut UiSurface<Msg>, app: &Counter ) -> ltk::core::RenderOutput
|
||
{
|
||
surface.render(
|
||
&app.view(),
|
||
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
|
||
)
|
||
}
|
||
|
||
// ── save_state → restore_state ────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn save_restore_round_trip()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
for _ in 0..3 { app.update( Msg::Inc ); }
|
||
let bytes = app.save_state().expect( "counter persists its value" );
|
||
|
||
let mut restored = Counter::new();
|
||
assert_eq!( restored.value, 0 );
|
||
restored.restore_state( bytes );
|
||
assert_eq!( restored.value, app.value );
|
||
|
||
// The restored app renders the same shape as the original.
|
||
let _ = render( &mut surface, &app );
|
||
let n = surface.widget_rects().len();
|
||
let _ = render( &mut surface, &restored );
|
||
assert_eq!( surface.widget_rects().len(), n );
|
||
|
||
// Garbage never panics and leaves the defaults alone.
|
||
let mut fresh = Counter::new();
|
||
fresh.restore_state( vec![ 0xff, 0xfe ] );
|
||
assert_eq!( fresh.value, 0 );
|
||
}
|
||
|
||
// ── Msg → update → re-render ──────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn pressing_increment_button_advances_counter_state()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
let _ = render( &mut surface, &app );
|
||
|
||
// Locate the "+" button. Layout pushes the text widget first (non-
|
||
// interactive), then the three buttons in declaration order.
|
||
let plus_idx = surface.widget_rects()[ 0 ].flat_idx;
|
||
let msg = surface.handlers( plus_idx )
|
||
.and_then( |h| h.press_msg() )
|
||
.expect( "button must carry on_press" );
|
||
assert_eq!( msg, Msg::Inc );
|
||
|
||
app.update( msg );
|
||
assert_eq!( app.value, 1 );
|
||
|
||
let _ = render( &mut surface, &app );
|
||
// Three buttons remain laid out — view shape did not change.
|
||
assert_eq!( surface.widget_rects().len(), 3 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn multiple_dispatch_cycles_accumulate_state()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
|
||
for _ in 0..5
|
||
{
|
||
let _ = render( &mut surface, &app );
|
||
let inc_idx = surface.widget_rects()[ 0 ].flat_idx;
|
||
let msg = surface.handlers( inc_idx ).and_then( |h| h.press_msg() ).unwrap();
|
||
app.update( msg );
|
||
}
|
||
assert_eq!( app.value, 5 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn dec_then_reset_returns_state_to_zero()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
let _ = render( &mut surface, &app );
|
||
|
||
// Buttons appear in declaration order: 0 = "+", 1 = "−", 2 = "reset".
|
||
let dec_idx = surface.widget_rects()[ 1 ].flat_idx;
|
||
let reset_idx = surface.widget_rects()[ 2 ].flat_idx;
|
||
|
||
let dec_msg = surface.handlers( dec_idx ).and_then( |h| h.press_msg() ).unwrap();
|
||
app.update( dec_msg );
|
||
assert_eq!( app.value, -1 );
|
||
|
||
let _ = render( &mut surface, &app );
|
||
let reset_msg = surface.handlers( reset_idx ).and_then( |h| h.press_msg() ).unwrap();
|
||
app.update( reset_msg );
|
||
assert_eq!( app.value, 0 );
|
||
}
|
||
|
||
#[ test ]
|
||
fn re_render_after_state_change_preserves_widget_count()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
|
||
let first = render( &mut surface, &app );
|
||
app.update( Msg::Inc );
|
||
// A real runtime would call `mark_content_dirty` here so the next render
|
||
// repaints the value text; UiSurface leaves that to the embedder. We
|
||
// just assert that the layout shape is stable across the message
|
||
// dispatch.
|
||
let _second = render( &mut surface, &app );
|
||
|
||
assert!( first.full_redraw, "first render is always a full redraw" );
|
||
assert_eq!( surface.widget_rects().len(), 3 );
|
||
}
|
||
|
||
// ── on_key / poll_external ────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn on_key_escape_emits_quit_message()
|
||
{
|
||
let mut app = Counter::new();
|
||
let msg = app.on_key( Keysym::Escape );
|
||
assert_eq!( msg, Some( Msg::Quit ) );
|
||
}
|
||
|
||
#[ test ]
|
||
fn on_key_unknown_keysym_returns_none()
|
||
{
|
||
let mut app = Counter::new();
|
||
assert!( app.on_key( Keysym::Tab ).is_none() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn poll_external_drains_pending_messages_in_order()
|
||
{
|
||
let mut app = Counter::new();
|
||
app.pending.extend( [ Msg::Inc, Msg::Inc, Msg::Reset ] );
|
||
|
||
let drained = app.poll_external();
|
||
assert_eq!( drained, vec![ Msg::Inc, Msg::Inc, Msg::Reset ] );
|
||
|
||
// Consuming the queue empties it.
|
||
let again = app.poll_external();
|
||
assert!( again.is_empty() );
|
||
}
|
||
|
||
#[ test ]
|
||
fn updating_with_drained_messages_reflects_on_render()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
app.pending.extend( [ Msg::Inc, Msg::Inc, Msg::Inc ] );
|
||
|
||
for msg in app.poll_external()
|
||
{
|
||
app.update( msg );
|
||
}
|
||
assert_eq!( app.value, 3 );
|
||
let _ = render( &mut surface, &app );
|
||
}
|
||
|
||
#[ test ]
|
||
fn defaults_for_unset_app_hooks_are_inert()
|
||
{
|
||
let app = Counter::new();
|
||
// is_animating defaults to false — confirms the runtime sleeps on idle.
|
||
assert!( !app.is_animating() );
|
||
// poll_interval defaults to None — pure event-driven scheduling.
|
||
assert!( app.poll_interval().is_none() );
|
||
}
|
||
|
||
// ── Tab navigation through UiSurface widget rects ─────────────────────────────
|
||
|
||
#[ test ]
|
||
fn tab_navigation_advances_through_focusable_widgets()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let app = Counter::new();
|
||
let _ = render( &mut surface, &app );
|
||
|
||
let widgets = surface.widget_rects();
|
||
assert_eq!( widgets.len(), 3 );
|
||
|
||
// Forward from None lands on the first focusable (button "+").
|
||
let first = next_focusable_index( widgets, None, false ).unwrap();
|
||
let second = next_focusable_index( widgets, Some( first ), false ).unwrap();
|
||
let third = next_focusable_index( widgets, Some( second ), false ).unwrap();
|
||
let wrap = next_focusable_index( widgets, Some( third ), false ).unwrap();
|
||
|
||
assert_ne!( first, second );
|
||
assert_ne!( second, third );
|
||
assert_eq!( wrap, first, "focus wraps to the head after the tail" );
|
||
}
|
||
|
||
#[ test ]
|
||
fn tab_navigation_reverse_walks_backward()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let app = Counter::new();
|
||
let _ = render( &mut surface, &app );
|
||
|
||
let widgets = surface.widget_rects();
|
||
let last = next_focusable_index( widgets, None, true ).unwrap();
|
||
let prev = next_focusable_index( widgets, Some( last ), true ).unwrap();
|
||
let wrap = next_focusable_index( widgets, Some( prev ), true ).unwrap();
|
||
let wrap2 = next_focusable_index( widgets, Some( wrap ), true ).unwrap();
|
||
|
||
assert_ne!( last, prev );
|
||
assert_ne!( prev, wrap );
|
||
assert_eq!( wrap2, last, "reverse traversal also wraps" );
|
||
}
|
||
|
||
// ── External invalidation ─────────────────────────────────────────────────────
|
||
|
||
#[ test ]
|
||
fn mark_content_dirty_triggers_full_redraw_after_external_state_mutation()
|
||
{
|
||
let mut surface = UiSurface::<Msg>::new( 320, 240 );
|
||
let mut app = Counter::new();
|
||
let _ = render( &mut surface, &app );
|
||
|
||
// Application state mutates via a path not derived from a Msg dispatch
|
||
// (e.g. a clock tick stored in a RefCell during view()). The runtime
|
||
// signals "content changed without interaction transition" via
|
||
// `mark_content_dirty`; the next render must come back as full redraw.
|
||
app.value = 42;
|
||
surface.mark_content_dirty();
|
||
let out = render( &mut surface, &app );
|
||
assert!( out.full_redraw );
|
||
}
|