First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

283
tests/event_loop_flow.rs Normal file
View File

@@ -0,0 +1,283 @@
// 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 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 ) ),
)
}
// ── 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 );
}