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

336
tests/animation.rs Normal file
View File

@@ -0,0 +1,336 @@
// Animation lifecycle coverage. The runtime polls `App::is_animating()` once
// per frame and, while it returns `true`, redraws at ~60 Hz reading
// `Instant::now()` against a stored start time. These tests exercise that
// lifecycle, the swipe-progress callbacks and the `take_focus_request` /
// `on_text_input_focused` hooks against an `App` impl — without the Wayland
// loop, but with the same trait the loop drives.
use std::time::{ Duration, Instant };
use ltk::test_support::WidgetHandlers;
use ltk::core::{ RenderOptions, UiSurface };
use ltk::{
column, text, App, Color, Element, WidgetId,
};
const TWEEN: Duration = Duration::from_millis( 200 );
#[ derive( Clone, Debug, PartialEq ) ]
enum Msg
{
StartFade,
FadeDone,
}
struct Animator
{
tween_start: Option<Instant>,
swipe_progress: f32,
swipe_h: f32,
tap_count: u32,
drag_xy: Option<( f32, f32 )>,
drop_xy: Option<( f32, f32 )>,
focus_request: Option<WidgetId>,
ime_active: bool,
}
impl Animator
{
fn new() -> Self
{
Self
{
tween_start: None,
swipe_progress: 0.0,
swipe_h: 0.0,
tap_count: 0,
drag_xy: None,
drop_xy: None,
focus_request: None,
ime_active: false,
}
}
fn tween_progress( &self ) -> f32
{
match self.tween_start
{
Some( t ) => ( t.elapsed().as_secs_f32() / TWEEN.as_secs_f32() ).clamp( 0.0, 1.0 ),
None => 1.0,
}
}
}
impl App for Animator
{
type Message = Msg;
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
.padding( 16.0 )
.push( text( format!( "progress = {:.2}", self.tween_progress() ) ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::StartFade => self.tween_start = Some( Instant::now() ),
Msg::FadeDone => self.tween_start = None,
}
}
fn is_animating( &self ) -> bool
{
self.tween_start.is_some()
}
fn poll_external( &mut self ) -> Vec<Msg>
{
// End-of-animation cleanup belongs here per the architecture doc:
// when the tween completes, clear the start instant so is_animating
// goes back to false and the runtime sleeps again.
if let Some( t ) = self.tween_start
{
if t.elapsed() >= TWEEN
{
self.tween_start = None;
}
}
vec![]
}
fn on_swipe_progress( &mut self, progress: f32 )
{
self.swipe_progress = progress;
}
fn on_swipe_horizontal_progress( &mut self, progress: f32 )
{
self.swipe_h = progress;
}
fn on_tap( &mut self ) -> Option<Msg>
{
self.tap_count += 1;
None
}
fn on_drag_move( &mut self, x: f32, y: f32 )
{
self.drag_xy = Some( ( x, y ) );
}
fn on_drop( &mut self, x: f32, y: f32 ) -> Option<Msg>
{
self.drop_xy = Some( ( x, y ) );
None
}
fn take_focus_request( &mut self ) -> Option<WidgetId>
{
self.focus_request.take()
}
fn on_text_input_focused( &mut self, active: bool )
{
self.ime_active = active;
}
}
// ── is_animating lifecycle ────────────────────────────────────────────────────
#[ test ]
fn fresh_animator_is_not_animating()
{
let app = Animator::new();
assert!( !app.is_animating() );
}
#[ test ]
fn starting_a_tween_flips_is_animating_to_true()
{
let mut app = Animator::new();
app.update( Msg::StartFade );
assert!( app.is_animating() );
}
#[ test ]
fn explicit_fade_done_message_settles_animation()
{
let mut app = Animator::new();
app.update( Msg::StartFade );
app.update( Msg::FadeDone );
assert!( !app.is_animating() );
}
#[ test ]
fn poll_external_clears_tween_after_duration_elapsed()
{
let mut app = Animator::new();
// Set tween_start to a moment far in the past so the elapsed > TWEEN
// branch in poll_external runs immediately. Avoids `thread::sleep`.
app.tween_start = Some( Instant::now() - Duration::from_secs( 10 ) );
assert!( app.is_animating() );
let drained = app.poll_external();
assert!( drained.is_empty() );
assert!( !app.is_animating(), "elapsed > TWEEN must clear the tween via poll_external" );
}
#[ test ]
fn poll_external_keeps_tween_alive_within_duration()
{
let mut app = Animator::new();
app.tween_start = Some( Instant::now() );
let _ = app.poll_external();
assert!( app.is_animating(), "fresh tween must not be cleared by poll_external" );
}
// ── tween progress reading ────────────────────────────────────────────────────
#[ test ]
fn tween_progress_reads_one_when_no_animation_in_flight()
{
let app = Animator::new();
assert_eq!( app.tween_progress(), 1.0 );
}
#[ test ]
fn tween_progress_clamps_to_unit_interval()
{
let mut app = Animator::new();
app.tween_start = Some( Instant::now() - Duration::from_secs( 60 ) );
let p = app.tween_progress();
assert!( ( 0.0..=1.0 ).contains( &p ), "progress {p} should clamp to [0,1]" );
assert!( ( p - 1.0 ).abs() < f32::EPSILON );
}
#[ test ]
fn tween_progress_starts_close_to_zero_just_after_start()
{
let mut app = Animator::new();
app.tween_start = Some( Instant::now() );
let p = app.tween_progress();
assert!( p < 0.1, "progress {p} should be near 0.0 at the start of the tween" );
}
// ── swipe / tap / drag / drop callbacks ───────────────────────────────────────
#[ test ]
fn on_swipe_progress_records_value_per_axis()
{
let mut app = Animator::new();
app.on_swipe_progress( 0.7 );
app.on_swipe_horizontal_progress( -0.4 );
assert_eq!( app.swipe_progress, 0.7 );
assert_eq!( app.swipe_h, -0.4 );
}
#[ test ]
fn on_tap_increments_counter()
{
let mut app = Animator::new();
app.on_tap();
app.on_tap();
app.on_tap();
assert_eq!( app.tap_count, 3 );
}
#[ test ]
fn on_drag_move_and_on_drop_record_coordinates()
{
let mut app = Animator::new();
app.on_drag_move( 12.0, 34.0 );
app.on_drop( 56.0, 78.0 );
assert_eq!( app.drag_xy, Some( ( 12.0, 34.0 ) ) );
assert_eq!( app.drop_xy, Some( ( 56.0, 78.0 ) ) );
}
// ── focus / IME hooks ─────────────────────────────────────────────────────────
#[ test ]
fn take_focus_request_returns_some_once_then_none()
{
let mut app = Animator::new();
app.focus_request = Some( WidgetId( "username" ) );
let first = app.take_focus_request();
let second = app.take_focus_request();
assert_eq!( first, Some( WidgetId( "username" ) ) );
assert_eq!( second, None, "take_focus_request must consume the slot" );
}
#[ test ]
fn on_text_input_focused_records_active_state()
{
let mut app = Animator::new();
assert!( !app.ime_active );
app.on_text_input_focused( true );
assert!( app.ime_active );
app.on_text_input_focused( false );
assert!( !app.ime_active );
}
// ── swipe_threshold / long_press_duration defaults ────────────────────────────
#[ test ]
fn swipe_and_long_press_defaults_match_documented_constants()
{
let app = Animator::new();
// `0.6` (60 % of surface height for a vertical swipe) and 500 ms are
// the documented defaults — apps override only when they need a hair
// trigger or a longer press.
assert!( ( app.swipe_threshold() - 0.6 ).abs() < 1e-6 );
assert_eq!( app.long_press_duration(), Duration::from_millis( 500 ) );
}
// ── animation + UiSurface interplay ───────────────────────────────────────────
#[ test ]
fn animator_renders_inside_uisurface_without_wayland_loop()
{
// Prove that an App with an active animation can drive UiSurface.render
// directly — important for compositor embedders that don't run
// `ltk::run` but still want the same animation contract.
let mut surface = UiSurface::<Msg>::new( 320, 80 );
let mut app = Animator::new();
app.update( Msg::StartFade );
assert!( app.is_animating() );
let opts = RenderOptions::full_canvas( 320, 80 ).background( Color::rgb( 0.0, 0.0, 0.0 ) );
let _ = surface.render( &app.view(), opts );
// Snapshot the text widget — it isn't focusable / interactive, so the
// surface's widget_rects should be empty.
assert!( surface.widget_rects().is_empty() );
// Pretend a frame went by; force re-layout so the new tween_progress
// percolates into the view.
surface.mark_content_dirty();
let _ = surface.render( &app.view(), opts );
// Animation eventually settles via poll_external.
app.tween_start = Some( Instant::now() - Duration::from_secs( 5 ) );
let _ = app.poll_external();
assert!( !app.is_animating() );
}
#[ test ]
fn handlers_match_default_for_text_only_animator_view()
{
// Defensive: the animator's view is text-only, so widget_rects is empty
// and any handlers lookup returns None. Regression guard against the
// runtime accidentally treating animation-driven views as interactive.
let mut surface = UiSurface::<Msg>::new( 320, 80 );
let app = Animator::new();
let _ = surface.render(
&app.view(),
RenderOptions::full_canvas( 320, 80 ),
);
assert!( surface.widget_rects().is_empty() );
assert!( surface.handlers( 0 ).is_none() );
let _: Option<&WidgetHandlers<Msg>> = surface.handlers( 999 );
}

63
tests/common/mod.rs Normal file
View File

@@ -0,0 +1,63 @@
// Shared fixtures for the integration tests in this directory. Each test
// binary compiles its own copy of this module and only sees the helpers it
// actually uses as "live", so the rest trip the `dead_code` lint —
// `#![allow(dead_code)]` is the standard fix for shared `tests/common/`.
#![ allow( dead_code ) ]
use ltk::test_support::{ LaidOutWidget, WidgetHandlers };
use ltk::{ CursorShape, Rect };
/// Build a synthetic [`LaidOutWidget`] with no handlers. The `flat_idx` is
/// what most tree lookups key on; `rect` is what hit testing keys on.
/// Defaults to `keyboard_focusable = true` so existing Tab-navigation tests
/// keep their meaning — the entry behaves like a typical Button.
pub fn lw_none( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
{
LaidOutWidget
{
rect,
flat_idx,
id: None,
paint_rect: rect,
handlers: WidgetHandlers::None,
keyboard_focusable: true,
cursor: CursorShape::Default,
}
}
/// Variant: synthetic Button entry that emits `()` on press.
pub fn lw_button( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
{
LaidOutWidget
{
rect,
flat_idx,
id: None,
paint_rect: rect,
handlers: WidgetHandlers::Button { on_press: Some( () ), on_long_press: None, on_drag_start: None, on_escape: None, repeating: false },
keyboard_focusable: true,
cursor: CursorShape::Pointer,
}
}
/// Variant: synthetic interactive-but-not-focusable entry, modelling chrome
/// such as `WindowButton`. Used by the Tab-navigation tests that verify the
/// projection step skips these.
pub fn lw_chrome( flat_idx: usize, rect: Rect ) -> LaidOutWidget<()>
{
LaidOutWidget
{
rect,
flat_idx,
id: None,
paint_rect: rect,
handlers: WidgetHandlers::WindowButton { on_press: Some( () ) },
keyboard_focusable: false,
cursor: CursorShape::Pointer,
}
}
pub fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}

227
tests/core_surface.rs Normal file
View File

@@ -0,0 +1,227 @@
use ltk::core::{ Canvas, RenderOptions, UiSurface };
use ltk::{ button, column, window_controls, Color, Element, Point, WindowButtonKind };
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pressed,
Close,
Maximize,
Minimize,
}
fn view() -> Element<Msg>
{
column::<Msg>()
.padding( 16.0 )
.push( button( "Press" ).on_press( Msg::Pressed ) )
.into()
}
fn decoration_view() -> Element<Msg>
{
window_controls(
Some( Msg::Minimize ),
WindowButtonKind::Maximize,
Some( Msg::Maximize ),
Some( Msg::Close ),
)
.into()
}
#[ test ]
fn core_surface_new_is_software_by_default()
{
let surface = UiSurface::<Msg>::new( 240, 120 );
assert!( matches!( surface.canvas(), Canvas::Software( _ ) ) );
assert!( surface.canvas().borrowed_gles_texture().is_none() );
}
#[ test ]
fn core_surface_renders_and_exposes_handlers_without_run()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let out = surface.render(
&view(),
RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
assert!( out.full_redraw );
assert_eq!( surface.widget_rects().len(), 1 );
let first = &surface.widget_rects()[ 0 ];
let hit = surface.hit_test( Point { x: first.rect.x + 1.0, y: first.rect.y + 1.0 } );
assert_eq!( hit, Some( first.flat_idx ) );
let msg = surface.handlers( first.flat_idx ).and_then( |h| h.press_msg() );
assert_eq!( msg, Some( Msg::Pressed ) );
}
#[ test ]
fn core_surface_can_drive_window_controls()
{
let mut surface = UiSurface::<Msg>::new( 180, 48 );
let _ = surface.render(
&decoration_view(),
RenderOptions::full_canvas( 180, 48 ).background( Color::rgb( 0.08, 0.08, 0.09 ) ),
);
assert_eq!( surface.widget_rects().len(), 3 );
let close = surface.widget_rects()[ 2 ].flat_idx;
let msg = surface.handlers( close ).and_then( |h| h.press_msg() );
assert_eq!( msg, Some( Msg::Close ) );
}
#[ test ]
fn core_surface_reports_partial_damage_for_focus_change()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &view(), opts );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_focused( Some( idx ) );
let out = surface.render( &view(), opts );
assert!( !out.full_redraw, "focus-only transition should be partial damage" );
assert!( !out.damage_rects.is_empty() );
}
// ── interaction-only damage paths ─────────────────────────────────────────────
#[ test ]
fn hover_only_change_produces_partial_damage()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &view(), opts );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_hovered( Some( idx ) );
let out = surface.render( &view(), opts );
assert!( !out.full_redraw, "hover-only transition should be partial damage" );
assert!( !out.damage_rects.is_empty() );
}
#[ test ]
fn pressed_only_change_produces_partial_damage()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &view(), opts );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_pressed( Some( idx ) );
let out = surface.render( &view(), opts );
assert!( !out.full_redraw );
}
#[ test ]
fn focused_hovered_pressed_setters_round_trip()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 );
let _ = surface.render( &view(), opts );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_focused( Some( idx ) );
surface.set_hovered( Some( idx ) );
surface.set_pressed( Some( idx ) );
assert_eq!( surface.focused(), Some( idx ) );
assert_eq!( surface.hovered(), Some( idx ) );
assert_eq!( surface.pressed(), Some( idx ) );
surface.set_focused( None );
surface.set_hovered( None );
surface.set_pressed( None );
assert_eq!( surface.focused(), None );
assert_eq!( surface.hovered(), None );
assert_eq!( surface.pressed(), None );
}
// ── full-redraw paths ─────────────────────────────────────────────────────────
#[ test ]
fn first_render_is_a_full_redraw()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let out = surface.render(
&view(),
RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
assert!( out.full_redraw, "the first render of a fresh surface is always a full redraw" );
}
#[ test ]
fn mark_content_dirty_forces_next_render_to_full_redraw()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &view(), opts );
// No interaction change — the next render would normally be partial.
surface.mark_content_dirty();
let out = surface.render( &view(), opts );
assert!( out.full_redraw );
}
#[ test ]
fn resize_forces_next_render_to_full_redraw()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
surface.resize( 320, 200 );
assert_eq!( surface.size(), ( 320, 200 ) );
let out = surface.render( &view(), RenderOptions::full_canvas( 320, 200 ) );
assert!( out.full_redraw );
}
#[ test ]
fn tree_size_change_forces_full_redraw()
{
let mut surface = UiSurface::<Msg>::new( 320, 200 );
let opts = RenderOptions::full_canvas( 320, 200 );
let _ = surface.render( &view(), opts );
let initial_count = surface.widget_rects().len();
// Render a different tree (decoration_view has 3 buttons; view has 1).
let out = surface.render( &decoration_view(), opts );
assert!( out.full_redraw );
assert_ne!( surface.widget_rects().len(), initial_count );
}
// ── lookups ───────────────────────────────────────────────────────────────────
#[ test ]
fn hit_test_outside_any_widget_returns_none()
{
let mut surface = UiSurface::<Msg>::new( 320, 200 );
let _ = surface.render( &view(), RenderOptions::full_canvas( 320, 200 ) );
// (0, 0) is the top-left corner — far from the centred button.
assert_eq!( surface.hit_test( Point { x: 0.0, y: 0.0 } ), None );
}
#[ test ]
fn handlers_lookup_for_unknown_idx_returns_none()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
assert!( surface.handlers( 999_999 ).is_none() );
}
#[ test ]
fn widget_lookup_for_known_idx_returns_some()
{
let mut surface = UiSurface::<Msg>::new( 240, 120 );
let _ = surface.render( &view(), RenderOptions::full_canvas( 240, 120 ) );
let idx = surface.widget_rects()[ 0 ].flat_idx;
assert!( surface.widget( idx ).is_some() );
}

248
tests/element_map.rs Normal file
View File

@@ -0,0 +1,248 @@
//! Integration tests for [`ltk::Element::map`] — the Elm-style adapter
//! that re-tags every per-leaf message in a sub-tree.
//!
//! Each test renders the mapped tree through a software [`UiSurface`],
//! looks up the per-widget [`WidgetHandlers`] snapshot the runtime
//! produced, and asserts that pressing a leaf fires the *outer*
//! `AppMsg::Sub( ... )` instead of the inner `SubMsg::...`.
use ltk::core::{ RenderOptions, UiSurface };
use ltk::test_support::WidgetHandlers;
use ltk::{
button, checkbox, column, list_item, radio, slider, text_edit, toggle,
vslider,
Color, Element,
};
#[ derive( Clone, Debug, PartialEq ) ]
enum SubMsg
{
Pressed,
Toggled,
Selected,
Open,
Slid( i32 ),
Vol( i32 ),
Typed( String ),
Submit,
}
#[ derive( Clone, Debug, PartialEq ) ]
enum AppMsg
{
Sub( SubMsg ),
}
fn sub_view() -> Element<SubMsg>
{
column::<SubMsg>()
.padding( 0.0 )
.spacing( 4.0 )
.push( button( "press" ).on_press( SubMsg::Pressed ) )
.push( toggle( false ).on_toggle( SubMsg::Toggled ) )
.push( checkbox( true ).on_toggle( SubMsg::Toggled ) )
.push( radio( false ).on_select( SubMsg::Selected ) )
.push( list_item( "row" ).on_press( SubMsg::Open ) )
.push( slider( 0.5 ).on_change( |v| SubMsg::Slid( ( v * 100.0 ) as i32 ) ) )
.push( vslider( 0.25 ).on_change( |v| SubMsg::Vol( ( v * 100.0 ) as i32 ) ) )
.push( text_edit::<SubMsg>( "ph", "" )
.on_change( SubMsg::Typed )
.on_submit( SubMsg::Submit ) )
.into()
}
fn render_mapped() -> UiSurface<AppMsg>
{
let mapped: Element<AppMsg> = sub_view().map( AppMsg::Sub );
let mut surface = UiSurface::<AppMsg>::new( 320, 320 );
let _ = surface.render(
&mapped,
RenderOptions::full_canvas( 320, 320 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
surface
}
/// Walk every laid-out widget rect in `surface` and call `probe` with
/// each handler snapshot. The probe builds the test assertion for the
/// matching widget kind.
fn for_each_handler( surface: &UiSurface<AppMsg>, mut probe: impl FnMut( &WidgetHandlers<AppMsg> ) )
{
for widget in surface.widget_rects()
{
probe( &widget.handlers );
}
}
#[ test ]
fn button_press_msg_is_remapped()
{
let surface = render_mapped();
let mut found = false;
for_each_handler( &surface, |h|
{
if let WidgetHandlers::Button { on_press, .. } = h
{
if let Some( msg ) = on_press
{
assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ), "button.on_press" );
found = true;
}
}
} );
assert!( found, "no Button handler with on_press in the rendered tree" );
}
#[ test ]
fn toggle_and_checkbox_messages_are_remapped()
{
let surface = render_mapped();
let mut toggle_seen = false;
let mut checkbox_seen = false;
for_each_handler( &surface, |h|
{
match h
{
WidgetHandlers::Toggle { on_toggle: Some( m ) } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
toggle_seen = true;
}
WidgetHandlers::Checkbox { on_toggle: Some( m ) } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Toggled ) );
checkbox_seen = true;
}
_ => {}
}
} );
assert!( toggle_seen );
assert!( checkbox_seen );
}
#[ test ]
fn radio_and_list_item_messages_are_remapped()
{
let surface = render_mapped();
let mut radio_seen = false;
let mut item_seen = false;
for_each_handler( &surface, |h|
{
match h
{
WidgetHandlers::Radio { on_select: Some( m ) } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Selected ) );
radio_seen = true;
}
WidgetHandlers::ListItem { on_press: Some( m ) } =>
{
assert_eq!( *m, AppMsg::Sub( SubMsg::Open ) );
item_seen = true;
}
_ => {}
}
} );
assert!( radio_seen );
assert!( item_seen );
}
#[ test ]
fn slider_callbacks_run_through_the_mapper()
{
let surface = render_mapped();
let mut h_axis = false;
let mut v_axis = false;
for_each_handler( &surface, |h|
{
if let WidgetHandlers::Slider { on_change: Some( cb ), axis } = h
{
// `cb` binds by-ref through default match ergonomics, so
// reach the trait object via `**cb` to invoke it.
let msg = ( **cb )( 0.5 );
match axis
{
ltk::SliderAxis::Horizontal =>
{
assert_eq!( msg, AppMsg::Sub( SubMsg::Slid( 50 ) ) );
h_axis = true;
}
ltk::SliderAxis::Vertical =>
{
assert_eq!( msg, AppMsg::Sub( SubMsg::Vol( 50 ) ) );
v_axis = true;
}
}
}
} );
assert!( h_axis, "horizontal slider handler not exercised" );
assert!( v_axis, "vertical slider handler not exercised" );
}
#[ test ]
fn text_edit_on_change_and_submit_are_remapped()
{
let surface = render_mapped();
let mut found = false;
for_each_handler( &surface, |h|
{
if let WidgetHandlers::TextEdit { on_change: Some( cb ), on_submit: Some( s ), .. } = h
{
let typed = ( **cb )( "hello".to_string() );
assert_eq!( typed, AppMsg::Sub( SubMsg::Typed( "hello".to_string() ) ) );
assert_eq!( *s, AppMsg::Sub( SubMsg::Submit ) );
found = true;
}
} );
assert!( found, "TextEdit handler not seen" );
}
#[ test ]
fn map_walks_into_nested_layouts()
{
// One level of nesting: Column -> Container -> Button.
use ltk::container;
let inner: Element<SubMsg> = container( button( "inner" ).on_press( SubMsg::Pressed ) ).into();
let outer: Element<SubMsg> = column::<SubMsg>().padding( 0.0 ).push( inner ).into();
let mapped: Element<AppMsg> = outer.map( AppMsg::Sub );
let mut surface = UiSurface::<AppMsg>::new( 200, 200 );
let _ = surface.render(
&mapped,
RenderOptions::full_canvas( 200, 200 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
let widget = surface.widget_rects().iter().find( |w|
matches!( w.handlers, WidgetHandlers::Button { on_press: Some( _ ), .. } ) );
let widget = widget.expect( "button under nested layout not found" );
if let WidgetHandlers::Button { on_press: Some( msg ), .. } = &widget.handlers
{
assert_eq!( *msg, AppMsg::Sub( SubMsg::Pressed ) );
} else { unreachable!() }
}
#[ test ]
fn map_can_be_chained()
{
// `view -> map(L1) -> map(L2)` rewrites twice; the inner Sub gets
// double-wrapped. Verifies that a child Element<U> mapped again is
// still walkable and the closure chain composes left-to-right.
#[ derive( Clone, Debug, PartialEq ) ]
enum L1 { Wrap( SubMsg ) }
#[ derive( Clone, Debug, PartialEq ) ]
enum L2 { Wrap( L1 ) }
let leaf: Element<SubMsg> = button( "x" ).on_press( SubMsg::Pressed ).into();
let l1: Element<L1> = leaf.map( L1::Wrap );
let l2: Element<L2> = l1.map( L2::Wrap );
let mut surface = UiSurface::<L2>::new( 100, 80 );
let _ = surface.render(
&l2,
RenderOptions::full_canvas( 100, 80 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
let widget = &surface.widget_rects()[ 0 ];
if let WidgetHandlers::Button { on_press: Some( msg ), .. } = &widget.handlers
{
assert_eq!( *msg, L2::Wrap( L1::Wrap( SubMsg::Pressed ) ) );
} else { panic!( "expected Button handler" ) }
}

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 );
}

View File

@@ -0,0 +1,277 @@
// Integration coverage for `Stack` alignment / margin / translation and for
// the way `Spacer` distributes leftover space inside a `Column`.
use ltk::core::Canvas;
use ltk::{ stack, spacer, HAlign, VAlign, Rect };
fn make_canvas() -> Canvas
{
Canvas::new( 800, 600 )
}
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
// ── Stack: alignment ──────────────────────────────────────────────────────────
#[ test ]
fn stack_fill_child_takes_full_rect()
{
let canvas = make_canvas();
let s = stack::<()>().push( spacer().width( 100.0 ).height( 100.0 ) );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
assert_eq!( layout.len(), 1 );
let ( child_rect, _ ) = layout[ 0 ];
// Fill ignores the spacer's preferred size and stretches across the
// entire stack rect.
assert_eq!( child_rect, rect( 0.0, 0.0, 400.0, 300.0 ) );
}
#[ test ]
fn stack_center_centers_child_inside_rect()
{
let canvas = make_canvas();
let child = spacer().width( 100.0 ).height( 50.0 );
let s = stack::<()>().push_aligned( child, HAlign::Center, VAlign::Center );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 150.0 );
assert_eq!( child_rect.y, 125.0 );
assert_eq!( child_rect.width, 100.0 );
assert_eq!( child_rect.height, 50.0 );
}
#[ test ]
fn stack_end_bottom_anchors_to_bottom_right()
{
let canvas = make_canvas();
let child = spacer().width( 80.0 ).height( 40.0 );
let s = stack::<()>().push_aligned( child, HAlign::End, VAlign::Bottom );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 320.0 );
assert_eq!( child_rect.y, 260.0 );
}
#[ test ]
fn stack_start_top_anchors_to_top_left()
{
let canvas = make_canvas();
let child = spacer().width( 40.0 ).height( 30.0 );
let s = stack::<()>().push_aligned( child, HAlign::Start, VAlign::Top );
let layout = s.layout( rect( 100.0, 50.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 100.0 );
assert_eq!( child_rect.y, 50.0 );
}
// ── Stack: margin ─────────────────────────────────────────────────────────────
#[ test ]
fn stack_margin_insets_fill_child_on_all_sides()
{
let canvas = make_canvas();
let child = spacer().width( 50.0 ).height( 50.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 12.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
// Fill + margin = original rect inset on every side.
assert_eq!( child_rect, rect( 12.0, 12.0, 376.0, 276.0 ) );
}
#[ test ]
fn stack_margin_pushes_end_anchored_child_inward()
{
let canvas = make_canvas();
let child = spacer().width( 50.0 ).height( 50.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::End, VAlign::Top, 8.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
// 400 - 50 - 8 = 342 from left edge; y starts at 8.
assert_eq!( child_rect.x, 342.0 );
assert_eq!( child_rect.y, 8.0 );
}
#[ test ]
fn stack_margin_larger_than_half_rect_clamps_inner_to_zero()
{
let canvas = make_canvas();
let child = spacer().width( 10.0 ).height( 10.0 );
let s = stack::<()>()
.push_aligned_margin( child, HAlign::Fill, VAlign::Fill, 200.0 );
// Stack rect is 100×100; margin 200 → inner is clamped to (0, 0).
let layout = s.layout( rect( 0.0, 0.0, 100.0, 100.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.width, 0.0 );
assert_eq!( child_rect.height, 0.0 );
}
// ── Stack: translation ────────────────────────────────────────────────────────
#[ test ]
fn stack_translation_offsets_child_after_alignment()
{
let canvas = make_canvas();
let child = spacer().width( 40.0 ).height( 30.0 );
let s = stack::<()>()
.push_translated( child, HAlign::Start, VAlign::Top, 5.0, 7.0 );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
let ( child_rect, _ ) = layout[ 0 ];
assert_eq!( child_rect.x, 5.0 );
assert_eq!( child_rect.y, 7.0 );
}
// ── Stack: order ─────────────────────────────────────────────────────────────
#[ test ]
fn stack_layout_preserves_child_order()
{
let canvas = make_canvas();
let s = stack::<()>()
.push( spacer().width( 10.0 ).height( 10.0 ) )
.push( spacer().width( 20.0 ).height( 20.0 ) )
.push( spacer().width( 30.0 ).height( 30.0 ) );
let layout = s.layout( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas );
assert_eq!( layout.len(), 3 );
assert_eq!( layout[ 0 ].1, 0 );
assert_eq!( layout[ 1 ].1, 1 );
assert_eq!( layout[ 2 ].1, 2 );
}
// ── Stack: preferred_size ─────────────────────────────────────────────────────
#[ test ]
fn stack_preferred_size_returns_max_child_height()
{
let canvas = make_canvas();
let s = stack::<()>()
.push( spacer().width( 40.0 ).height( 30.0 ) )
.push( spacer().width( 60.0 ).height( 80.0 ) );
let ( w, h ) = s.preferred_size( 400.0, &canvas );
// Stack always reports `max_width` for width, and the tallest child for
// height — Stack is meant for overlaying, not flow.
assert_eq!( w, 400.0 );
assert_eq!( h, 80.0 );
}
#[ test ]
fn stack_empty_preferred_size_height_is_zero()
{
let canvas = make_canvas();
let s: ltk::Stack<()> = stack();
let ( _, h ) = s.preferred_size( 400.0, &canvas );
assert_eq!( h, 0.0 );
}
// ── Spacer: builder semantics ─────────────────────────────────────────────────
#[ test ]
fn spacer_default_has_weight_one_and_no_fixed_size()
{
let s = spacer();
assert_eq!( s.weight, 1 );
assert!( s.fixed_height.is_none() );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size(), ( 0.0, 0.0 ) );
}
#[ test ]
fn spacer_height_pins_vertical_axis_only()
{
let s = spacer().height( 24.0 );
assert_eq!( s.fixed_height, Some( 24.0 ) );
assert!( s.fixed_width.is_none() );
assert_eq!( s.preferred_size(), ( 0.0, 24.0 ) );
}
#[ test ]
fn spacer_width_pins_horizontal_axis_only()
{
let s = spacer().width( 12.0 );
assert_eq!( s.fixed_width, Some( 12.0 ) );
assert!( s.fixed_height.is_none() );
assert_eq!( s.preferred_size(), ( 12.0, 0.0 ) );
}
#[ test ]
fn spacer_weight_replaces_default()
{
let s = spacer().weight( 5 );
assert_eq!( s.weight, 5 );
}
// ── Spacer in Column: leftover distribution ───────────────────────────────────
// These use `column().layout(...)` directly (Column re-exports its `layout()`
// method as `pub`). Fixed-height children consume known space; flexible spacers
// must absorb the rest.
#[ test ]
fn flexible_spacer_in_column_consumes_leftover_height()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().width( 50.0 ).height( 100.0 ) )
.push( spacer() ) // flexible
.push( spacer().width( 50.0 ).height( 100.0 ) );
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
assert_eq!( layout.len(), 3 );
// Two fixed children of 100 each + one spacer absorbing the leftover 300.
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
assert!( ( layout[ 2 ].0.height - 100.0 ).abs() < 1e-3 );
}
#[ test ]
fn weighted_spacers_split_leftover_proportionally()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().weight( 1 ) )
.push( spacer().weight( 3 ) );
let layout = col.layout( rect( 0.0, 0.0, 200.0, 400.0 ), &canvas );
// Total weight 4; leftover 400; weight 1 → 100, weight 3 → 300.
assert!( ( layout[ 0 ].0.height - 100.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 300.0 ).abs() < 1e-3 );
}
#[ test ]
fn fixed_height_spacer_does_not_consume_leftover()
{
use ltk::column;
let canvas = make_canvas();
let col = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( spacer().height( 50.0 ) ) // fixed — keeps its 50 px
.push( spacer() ); // flexible — absorbs the rest
let layout = col.layout( rect( 0.0, 0.0, 200.0, 500.0 ), &canvas );
assert!( ( layout[ 0 ].0.height - 50.0 ).abs() < 1e-3 );
assert!( ( layout[ 1 ].0.height - 450.0 ).abs() < 1e-3 );
}

View File

@@ -0,0 +1,85 @@
use ltk::test_support::diff_overlay_ids;
use ltk::OverlayId;
fn ids( xs: &[ u32 ] ) -> Vec<OverlayId>
{
xs.iter().copied().map( OverlayId ).collect()
}
#[ test ]
fn empty_to_empty_is_no_change()
{
let ( added, removed ) = diff_overlay_ids( ids( &[] ), &ids( &[] ) );
assert!( added.is_empty() );
assert!( removed.is_empty() );
}
#[ test ]
fn first_frame_marks_everything_added()
{
let ( added, removed ) = diff_overlay_ids( ids( &[] ), &ids( &[ 1, 2, 3 ] ) );
assert_eq!( added, ids( &[ 1, 2, 3 ] ) );
assert!( removed.is_empty() );
}
#[ test ]
fn empty_next_marks_everything_removed()
{
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[] ) );
assert!( added.is_empty() );
let mut removed_sorted = removed;
removed_sorted.sort();
assert_eq!( removed_sorted, ids( &[ 1, 2, 3 ] ) );
}
#[ test ]
fn unchanged_set_produces_no_diff()
{
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[ 1, 2, 3 ] ) );
assert!( added.is_empty(), "no overlays should be added" );
assert!( removed.is_empty(), "no overlays should be removed" );
}
#[ test ]
fn order_in_next_does_not_matter_for_diff()
{
// Same set, different order — the overlay surfaces should be reused, not
// recreated.
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2, 3 ] ), &ids( &[ 3, 2, 1 ] ) );
assert!( added.is_empty() );
assert!( removed.is_empty() );
}
#[ test ]
fn open_one_panel_marks_only_it_added()
{
let ( added, removed ) = diff_overlay_ids( ids( &[ 1 ] ), &ids( &[ 1, 2 ] ) );
assert_eq!( added, ids( &[ 2 ] ) );
assert!( removed.is_empty() );
}
#[ test ]
fn close_one_panel_marks_only_it_removed()
{
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2 ] ), &ids( &[ 1 ] ) );
assert!( added.is_empty() );
assert_eq!( removed, ids( &[ 2 ] ) );
}
#[ test ]
fn swap_one_panel_marks_one_added_one_removed()
{
let ( added, removed ) = diff_overlay_ids( ids( &[ 1, 2 ] ), &ids( &[ 1, 3 ] ) );
assert_eq!( added, ids( &[ 3 ] ) );
assert_eq!( removed, ids( &[ 2 ] ) );
}
#[ test ]
fn added_preserves_next_order()
{
// Creation order is deterministic — the runtime instantiates new
// surfaces in the order the app returned them, and diffing must
// preserve it.
let ( added, _removed ) = diff_overlay_ids( ids( &[] ), &ids( &[ 7, 3, 5 ] ) );
assert_eq!( added, ids( &[ 7, 3, 5 ] ) );
}

312
tests/render_pixels.rs Normal file
View File

@@ -0,0 +1,312 @@
// Pixel-level "golden-like" coverage. We deliberately avoid storing reference
// PNG bytes: their value depends on fontdue / tiny-skia / theme versions, so a
// byte-exact reference would break on every dependency bump. Instead these
// tests assert *structural* properties of the rendered buffer (background
// colour, transparency, determinism, focus-state divergence) plus a few
// canonical scenes that exercise the partial-redraw and full-redraw paths.
use ltk::core::{ Canvas, RenderOptions, UiSurface };
use ltk::{ button, column, container, spacer, text, Color, Element };
// ── helpers ───────────────────────────────────────────────────────────────────
fn extract_pixels( surface: &UiSurface<()>, w: u32, h: u32 ) -> Vec<u8>
{
let mut buf = vec![ 0u8; ( w * h * 4 ) as usize ];
// Software canvas only — `Canvas::Gles::write_to_wayland_buf` is a no-op
// (presentation goes through `eglSwapBuffers` elsewhere). UiSurface::new
// returns a software surface.
assert!( matches!( surface.canvas(), Canvas::Software( _ ) ) );
surface.canvas().write_to_wayland_buf( &mut buf, false );
buf
}
fn fnv1a_64( bytes: &[u8] ) -> u64
{
let mut h: u64 = 0xcbf29ce484222325;
for &b in bytes
{
h ^= b as u64;
h = h.wrapping_mul( 0x100000001b3 );
}
h
}
// Approximate equality on a single u8 channel — accounts for AA / rounding
// drift on the boundary of background-vs-content fills.
fn near( a: u8, b: u8, tol: u8 ) -> bool
{
a.abs_diff( b ) <= tol
}
// ── background colour ─────────────────────────────────────────────────────────
#[ test ]
fn fully_transparent_background_yields_all_zero_alpha()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::TRANSPARENT ),
);
let buf = extract_pixels( &surface, 64, 64 );
for chunk in buf.chunks_exact( 4 )
{
// Every pixel ends up with alpha = 0; RGB after premultiplication is
// also 0 because the background fill is the only paint.
assert_eq!( chunk[ 3 ], 0, "transparent bg must produce zero alpha" );
}
}
#[ test ]
fn opaque_background_paints_canonical_rgb_in_corner_pixel()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::rgb( 1.0, 0.25, 0.0 ) ),
);
let buf = extract_pixels( &surface, 64, 64 );
// Top-left pixel: nothing draws there beyond the background fill, so the
// channels should match (255, ~64, 0, 255) with mild rounding tolerance.
assert!( near( buf[ 0 ], 255, 1 ) );
assert!( near( buf[ 1 ], 64, 2 ) );
assert!( near( buf[ 2 ], 0, 1 ) );
assert_eq!( buf[ 3 ], 255 );
}
#[ test ]
fn background_fills_every_pixel_when_view_is_empty()
{
let mut surface = UiSurface::<()>::new( 32, 32 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 32, 32 ).background( Color::rgb( 0.0, 0.5, 0.0 ) ),
);
let buf = extract_pixels( &surface, 32, 32 );
for chunk in buf.chunks_exact( 4 )
{
assert!( near( chunk[ 0 ], 0, 1 ) );
assert!( near( chunk[ 1 ], 128, 2 ) );
assert!( near( chunk[ 2 ], 0, 1 ) );
assert_eq!( chunk[ 3 ], 255 );
}
}
// ── determinism ───────────────────────────────────────────────────────────────
fn counter_view( count: u32 ) -> Element<()>
{
column::<()>()
.padding( 16.0 )
.spacing( 8.0 )
.push( text( format!( "Count: {count}" ) ) )
.push( button( "Increment" ) )
.into()
}
#[ test ]
fn rendering_the_same_view_twice_produces_identical_pixel_buffers()
{
let mut surface = UiSurface::<()>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
let _ = surface.render( &counter_view( 0 ), opts );
let buf_a = extract_pixels( &surface, 240, 120 );
// Force a clean re-paint via mark_content_dirty so both passes hit the
// same code path (full redraw rather than partial-damage clip).
surface.mark_content_dirty();
let _ = surface.render( &counter_view( 0 ), opts );
let buf_b = extract_pixels( &surface, 240, 120 );
assert_eq!( buf_a, buf_b, "two identical renders must produce identical pixels" );
}
#[ test ]
fn render_hash_is_stable_across_two_back_to_back_passes()
{
let mut surface = UiSurface::<()>::new( 320, 200 );
let opts = RenderOptions::full_canvas( 320, 200 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &counter_view( 7 ), opts );
let h1 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
surface.mark_content_dirty();
let _ = surface.render( &counter_view( 7 ), opts );
let h2 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
assert_eq!( h1, h2, "a deterministic render pass must produce a stable hash" );
}
// ── content presence ──────────────────────────────────────────────────────────
#[ test ]
fn rendering_a_button_produces_pixels_that_differ_from_the_background()
{
let mut surface = UiSurface::<()>::new( 240, 120 );
let bg = Color::rgb( 0.0, 0.0, 0.0 );
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 240, 120 ).background( bg ),
);
let buf = extract_pixels( &surface, 240, 120 );
let differs = buf.chunks_exact( 4 ).any( |chunk|
!( chunk[ 0 ] == 0 && chunk[ 1 ] == 0 && chunk[ 2 ] == 0 && chunk[ 3 ] == 255 )
);
assert!( differs, "a button must paint at least one non-background pixel" );
}
#[ test ]
fn rendering_text_produces_pixels_that_differ_from_the_background()
{
let mut surface = UiSurface::<()>::new( 240, 120 );
let bg = Color::rgb( 0.0, 0.0, 0.0 );
let view: Element<()> = column::<()>().push( text( "ltk" ).size( 32.0 ) ).into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 240, 120 ).background( bg ),
);
let buf = extract_pixels( &surface, 240, 120 );
let glyph_pixels = buf.chunks_exact( 4 )
.filter( |chunk| !( chunk[ 0 ] == 0 && chunk[ 1 ] == 0 && chunk[ 2 ] == 0 ) )
.count();
assert!( glyph_pixels > 0, "text rasterisation must produce non-bg pixels" );
}
#[ test ]
fn container_with_explicit_color_fill_paints_inside_padding()
{
// Fill a sized container with a known colour, render on a black
// background, and confirm a measurable region of red pixels appears.
let mut surface = UiSurface::<()>::new( 100, 100 );
let view: Element<()> = column::<()>()
.padding( 0.0 )
.push(
container::<()>( spacer().width( 40.0 ).height( 40.0 ) )
.background( Color::rgb( 1.0, 0.0, 0.0 ) ),
)
.into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 100, 100 ).background( Color::BLACK ),
);
let bytes = extract_pixels( &surface, 100, 100 );
let red_pixels = bytes.chunks_exact( 4 )
.filter( |c| c[ 0 ] > 200 && c[ 1 ] < 30 && c[ 2 ] < 30 && c[ 3 ] == 255 )
.count();
// A 40×40 fill produces 1600 fully-saturated red pixels; allow a wide
// floor to absorb anti-aliased edge pixels that mix with the background.
assert!(
red_pixels > 500,
"container with red fill must produce a substantial red region (got {red_pixels})",
);
}
// ── focus-state divergence ────────────────────────────────────────────────────
#[ test ]
fn focusing_a_button_changes_pixels_inside_its_paint_rect()
{
// The focus ring is one of the cheapest ways to verify the partial-
// redraw path actually paints something different. Render with no focus,
// snapshot the pixels, set focus, render again, and confirm the buffer
// has changed somewhere.
let mut surface = UiSurface::<()>::new( 200, 80 );
let opts = RenderOptions::full_canvas( 200, 80 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
let _ = surface.render( &view, opts );
let unfocused = extract_pixels( &surface, 200, 80 );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_focused( Some( idx ) );
let _ = surface.render( &view, opts );
let focused = extract_pixels( &surface, 200, 80 );
assert_ne!(
unfocused, focused,
"focus state change must produce visibly different pixels",
);
}
// ── invariants ────────────────────────────────────────────────────────────────
#[ test ]
fn pixel_buffer_length_matches_canvas_size()
{
let surface = UiSurface::<()>::new( 100, 50 );
let mut buf = vec![ 0u8; 100 * 50 * 4 ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
// All pixels are zero before render — confirms the buffer is correctly
// sized and write_to_wayland_buf doesn't overwrite past the end.
assert!( buf.iter().all( |&b| b == 0 ) );
}
#[ test ]
fn resize_changes_the_required_buffer_length()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let opts = RenderOptions::full_canvas( 64, 64 );
let _ = surface.render( &view, opts );
surface.resize( 128, 96 );
assert_eq!( surface.size(), ( 128, 96 ) );
// Old buffer would be too short; the new size dictates a 49152-byte
// buffer. Confirm `write_to_wayland_buf` accepts and fills it without
// panicking.
let mut new_buf = vec![ 0u8; 128 * 96 * 4 ];
let _ = surface.render(
&view,
RenderOptions::full_canvas( 128, 96 ).background( Color::rgb( 0.0, 0.0, 0.5 ) ),
);
surface.canvas().write_to_wayland_buf( &mut new_buf, false );
// Last pixel should carry the navy bg.
let last = &new_buf[ new_buf.len() - 4 .. ];
assert!( near( last[ 2 ], 128, 2 ), "last pixel blue channel ≈ 128" );
assert_eq!( last[ 3 ], 255 );
}
// ── canonical scene byte equality ─────────────────────────────────────────────
#[ test ]
fn canonical_solid_red_scene_has_exact_byte_layout()
{
// A scene with NO text (so no font-version drift) and a colour with NO
// rounding ambiguity (1.0 → 255, 0.0 → 0). The buffer is portable
// across machines because the path is plain rectangle clear → tiny-skia
// premul → byte copy, and the values land exactly. If this assertion
// drifts, either the renderer or the buffer format changed; investigate
// before regenerating.
let mut surface = UiSurface::<()>::new( 16, 16 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 16, 16 ).background( Color::rgb( 1.0, 0.0, 0.0 ) ),
);
let actual = extract_pixels( &surface, 16, 16 );
let expected: Vec<u8> = ( 0..16 * 16 ).flat_map( |_| [ 255u8, 0, 0, 255 ] ).collect();
assert_eq!( actual, expected, "16×16 solid red must serialise to exact RGBA bytes" );
}
#[ test ]
fn fnv1a_hash_changes_when_a_single_byte_flips()
{
// Sanity check on the in-test hash function itself — guards the rest of
// the determinism tests against a regression that breaks the hash but
// leaves the assertions trivially passing.
let a = [ 0u8, 0, 0, 0 ];
let mut b = a;
b[ 2 ] = 1;
assert_ne!( fnv1a_64( &a ), fnv1a_64( &b ) );
}

108
tests/scroll.rs Normal file
View File

@@ -0,0 +1,108 @@
// Integration coverage for `scroll`. The pure clamping math already lives in
// `widget::scroll::tests`; these tests exercise the wiring: `preferred_size`
// claims remaining space, and interactive children inside the viewport remain
// reachable through `UiSurface`'s widget rects.
use ltk::core::{ Canvas, RenderOptions, UiSurface };
use ltk::{ button, column, scroll, text, Color, Element, Point };
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Pressed( u32 ),
}
fn list_view() -> Element<Msg>
{
scroll(
column::<Msg>()
.padding( 0.0 )
.spacing( 4.0 )
.push( button( "A" ).on_press( Msg::Pressed( 0 ) ) )
.push( button( "B" ).on_press( Msg::Pressed( 1 ) ) )
.push( button( "C" ).on_press( Msg::Pressed( 2 ) ) ),
).into()
}
#[ test ]
fn scroll_preferred_size_returns_zero_height_to_act_as_filler()
{
let canvas = Canvas::new( 800, 600 );
let s = scroll::<Msg>( column::<Msg>() );
let ( w, h ) = s.preferred_size( 320.0, &canvas );
assert_eq!( w, 320.0 );
assert_eq!( h, 0.0 );
}
#[ test ]
fn scroll_with_buttons_exposes_them_through_widget_rects()
{
let mut surface = UiSurface::<Msg>::new( 320, 480 );
let _ = surface.render(
&list_view(),
RenderOptions::full_canvas( 320, 480 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
// Three interactive buttons are visible inside the scroll viewport.
assert_eq!( surface.widget_rects().len(), 3 );
}
#[ test ]
fn buttons_inside_scroll_dispatch_their_press_messages()
{
let mut surface = UiSurface::<Msg>::new( 320, 480 );
let _ = surface.render(
&list_view(),
RenderOptions::full_canvas( 320, 480 ),
);
let first = &surface.widget_rects()[ 0 ];
let msg = surface.handlers( first.flat_idx ).and_then( |h| h.press_msg() );
assert_eq!( msg, Some( Msg::Pressed( 0 ) ) );
}
#[ test ]
fn hit_test_inside_scroll_returns_button_under_pointer()
{
let mut surface = UiSurface::<Msg>::new( 320, 480 );
let _ = surface.render(
&list_view(),
RenderOptions::full_canvas( 320, 480 ),
);
let first = &surface.widget_rects()[ 0 ];
let centre = Point
{
x: first.rect.x + first.rect.width * 0.5,
y: first.rect.y + first.rect.height * 0.5,
};
assert_eq!( surface.hit_test( centre ), Some( first.flat_idx ) );
}
#[ test ]
fn empty_scroll_renders_without_panic()
{
let mut surface = UiSurface::<Msg>::new( 320, 480 );
let view: Element<Msg> = scroll( column::<Msg>() ).into();
let out = surface.render(
&view,
RenderOptions::full_canvas( 320, 480 ),
);
assert!( out.full_redraw );
assert!( surface.widget_rects().is_empty(), "no buttons inside → no interactive widgets" );
}
#[ test ]
fn scroll_around_text_only_child_produces_no_interactive_widgets()
{
let mut surface = UiSurface::<Msg>::new( 320, 480 );
let view: Element<Msg> = scroll(
column::<Msg>()
.push( text( "header" ) )
.push( text( "body" ) ),
).into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 320, 480 ),
);
// Text is non-interactive — widget_rects only tracks focusable / hittable
// widgets.
assert!( surface.widget_rects().is_empty() );
}

112
tests/security_upload.rs Normal file
View File

@@ -0,0 +1,112 @@
// Defensive coverage for the image-upload boundary. The GLES path requires a
// live GL context (out of scope for these CPU-only tests) so the assertions
// below exercise the software backend, which shares the same length-vs-
// dimensions check before delegating to tiny-skia. Both paths:
//
// * refuse the upload when `data.len() != width * height * 4`,
// * refuse zero-area uploads (`width == 0` or `height == 0`),
// * never panic — they log to stderr and return without drawing.
use std::sync::Arc;
use ltk::core::{ RenderOptions, UiSurface };
use ltk::{ column, img_widget, Color, Element };
fn render_image( rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> UiSurface<()>
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column()
.padding( 0.0 )
.push( img_widget( rgba, w, h ) )
.into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::BLACK ),
);
surface
}
#[ test ]
fn declared_size_matching_buffer_renders_without_panic()
{
// Sanity baseline: a 4×4 image with the expected 64-byte buffer must
// render through the path that the validation tests below exercise.
let bytes = Arc::new( vec![ 0xFFu8; 4 * 4 * 4 ] );
let _ = render_image( bytes, 4, 4 );
}
#[ test ]
fn shorter_buffer_is_rejected_without_panic()
{
// 10×10 declared but only 16 bytes provided. Without the boundary
// check tiny-skia would walk past the slice end inside its premul
// loop. With the check the renderer logs once and skips the draw.
let bytes = Arc::new( vec![ 0u8; 16 ] );
let _ = render_image( bytes, 10, 10 );
}
#[ test ]
fn longer_buffer_is_rejected_without_panic()
{
// 2×2 declared, 256 bytes provided. The buffer is larger than the
// driver would read, but the size mismatch still fails the strict
// equality and the draw is refused.
let bytes = Arc::new( vec![ 0u8; 256 ] );
let _ = render_image( bytes, 2, 2 );
}
#[ test ]
fn zero_width_image_does_not_blit()
{
// IntSize::from_wh rejects zero dimensions on tiny-skia, but the
// boundary check fires earlier for a uniform error path on both
// backends.
let bytes = Arc::new( vec![] );
let _ = render_image( bytes, 0, 10 );
}
#[ test ]
fn zero_height_image_does_not_blit()
{
let bytes = Arc::new( vec![] );
let _ = render_image( bytes, 10, 0 );
}
#[ test ]
fn non_multiple_of_four_buffer_is_rejected()
{
// 1×1 should be 4 bytes; 3 bytes is shorter and not a multiple of 4 —
// every byte counts.
let bytes = Arc::new( vec![ 0xFF, 0xFF, 0xFF ] );
let _ = render_image( bytes, 1, 1 );
}
#[ test ]
fn dimensions_overflow_usize_are_rejected_without_panic()
{
// `u32::MAX × u32::MAX × 4` overflows. The boundary check uses
// `saturating_mul` (or i64-promotion in the GLES path) so the
// expected length never wraps to a small number that happens to
// match a real buffer length. The provided buffer is small, so the
// equality fails and the draw is refused.
let bytes = Arc::new( vec![ 0u8; 16 ] );
let _ = render_image( bytes, u32::MAX, u32::MAX );
}
#[ test ]
fn renderer_recovers_after_a_rejected_upload()
{
// A frame with a malformed image must not poison the surface. The
// next render with a valid view succeeds.
let bad = Arc::new( vec![ 0u8; 5 ] );
let mut s = render_image( bad, 4, 4 );
let view: Element<()> = column().padding( 0.0 ).into();
let out = s.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::rgb( 0.0, 1.0, 0.0 ) ),
);
// Tree shape change → full redraw, but the important check is that
// render() returned at all.
let _ = out;
}

65
tests/slider_math.rs Normal file
View File

@@ -0,0 +1,65 @@
use ltk::test_support::value_from_x_in_rect;
use ltk::Rect;
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
#[ test ]
fn left_edge_clamps_to_zero()
{
let r = rect( 0.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 0.0 ), 0.0 );
assert_eq!( value_from_x_in_rect( r, -50.0 ), 0.0 );
}
#[ test ]
fn right_edge_clamps_to_one()
{
let r = rect( 0.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 200.0 ), 1.0 );
assert_eq!( value_from_x_in_rect( r, 999.0 ), 1.0 );
}
#[ test ]
fn center_returns_half()
{
// The thumb adds padding on both sides, so the geometric center of the
// rect produces ~0.5 (within the THUMB_SIZE/2 tolerance).
let r = rect( 0.0, 0.0, 200.0, 36.0 );
let v = value_from_x_in_rect( r, 100.0 );
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
}
#[ test ]
fn rect_offset_translates_correctly()
{
let r = rect( 500.0, 0.0, 200.0, 36.0 );
assert_eq!( value_from_x_in_rect( r, 500.0 ), 0.0 );
assert_eq!( value_from_x_in_rect( r, 700.0 ), 1.0 );
let v = value_from_x_in_rect( r, 600.0 );
assert!( ( v - 0.5 ).abs() < 0.1, "expected ~0.5 got {}", v );
}
#[ test ]
fn output_is_always_in_unit_range()
{
let r = rect( 10.0, 10.0, 300.0, 36.0 );
for x in -100i32 ..= 500
{
let v = value_from_x_in_rect( r, x as f32 );
assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at x={}", v, x );
}
}
#[ test ]
fn very_narrow_rect_does_not_divide_by_zero()
{
// Width < THUMB_SIZE — track_w is clamped to 1.0 internally so the
// formula stays defined.
let r = rect( 0.0, 0.0, 4.0, 36.0 );
let v = value_from_x_in_rect( r, 2.0 );
assert!( v.is_finite() );
assert!( ( 0.0..= 1.0 ).contains( &v ) );
}

107
tests/tab_navigation.rs Normal file
View File

@@ -0,0 +1,107 @@
mod common;
use common::{ lw_none, rect };
use ltk::test_support::next_focusable_index;
// ── Empty / single-element edge cases ─────────────────────────────────────────
#[ test ]
fn empty_widget_list_returns_none()
{
let widgets: Vec<_> = vec![];
assert_eq!( next_focusable_index::<()>( &widgets, None, false ), None );
assert_eq!( next_focusable_index::<()>( &widgets, Some( 42 ), false ), None );
assert_eq!( next_focusable_index::<()>( &widgets, None, true ), None );
}
#[ test ]
fn single_widget_wraps_to_itself()
{
let widgets = vec![ lw_none( 7, rect( 0.0, 0.0, 10.0, 10.0 ) ) ];
assert_eq!( next_focusable_index( &widgets, Some( 7 ), false ), Some( 7 ) );
assert_eq!( next_focusable_index( &widgets, Some( 7 ), true ), Some( 7 ) );
}
// ── Forward (Tab) ─────────────────────────────────────────────────────────────
#[ test ]
fn forward_advances_to_next_in_slice_order()
{
let widgets = vec![
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, Some( 10 ), false ), Some( 20 ) );
assert_eq!( next_focusable_index( &widgets, Some( 20 ), false ), Some( 30 ) );
}
#[ test ]
fn forward_wraps_around_at_end()
{
let widgets = vec![
lw_none( 1, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 2, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, Some( 2 ), false ), Some( 1 ) );
}
#[ test ]
fn forward_with_no_focus_targets_first_widget()
{
let widgets = vec![
lw_none( 100, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 200, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, None, false ), Some( 100 ) );
}
// ── Reverse (Shift+Tab) ───────────────────────────────────────────────────────
#[ test ]
fn reverse_steps_to_previous()
{
let widgets = vec![
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, Some( 30 ), true ), Some( 20 ) );
assert_eq!( next_focusable_index( &widgets, Some( 20 ), true ), Some( 10 ) );
}
#[ test ]
fn reverse_wraps_around_at_start()
{
let widgets = vec![
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, Some( 10 ), true ), Some( 20 ) );
}
#[ test ]
fn reverse_with_no_focus_targets_last_widget()
{
let widgets = vec![
lw_none( 100, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 200, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, None, true ), Some( 200 ) );
}
// ── Stale focus index ─────────────────────────────────────────────────────────
#[ test ]
fn stale_focus_idx_falls_back_to_first()
{
// Common after a re-layout removes the previously-focused widget: the
// stored focus idx no longer maps to any entry, and the walker falls
// back to the same behaviour as `None`.
let widgets = vec![
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert_eq!( next_focusable_index( &widgets, Some( 999 ), false ), Some( 10 ) );
assert_eq!( next_focusable_index( &widgets, Some( 999 ), true ), Some( 20 ) );
}

207
tests/text_edit_dispatch.rs Normal file
View File

@@ -0,0 +1,207 @@
// End-to-end coverage for the `text_edit` ↔ event-loop contract: the layout
// pass snapshots the widget's `on_change` / `on_submit` / `value` into a
// `WidgetHandlers::TextEdit` entry, and the runtime drives keystrokes through
// `text_change_msg` / `submit_msg` / `current_value`. These tests skip the
// Wayland loop and exercise that contract directly through `UiSurface`.
use ltk::core::{ RenderOptions, UiSurface };
use ltk::test_support::WidgetHandlers;
use ltk::{ column, text_edit, Color, Element };
#[ derive( Clone, Debug, PartialEq, Eq ) ]
enum Msg
{
Username( String ),
Password( String ),
UsernameSubmit,
PasswordSubmit,
}
fn login_view() -> Element<Msg>
{
column::<Msg>()
.padding( 16.0 )
.spacing( 8.0 )
.push(
text_edit( "Username", "alice" )
.on_change( |s| Msg::Username( s ) )
.on_submit( Msg::UsernameSubmit ),
)
.push(
text_edit::<Msg>( "Password", "" )
.secure( true )
.on_change( |s| Msg::Password( s ) )
.on_submit( Msg::PasswordSubmit ),
)
.into()
}
fn render_login() -> UiSurface<Msg>
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let _ = surface.render(
&login_view(),
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
surface
}
#[ test ]
fn layout_records_two_text_edit_widgets()
{
let surface = render_login();
assert_eq!( surface.widget_rects().len(), 2 );
for w in surface.widget_rects()
{
assert!( w.handlers.is_text_input() );
}
}
#[ test ]
fn text_change_msg_carries_new_value_through_user_callback()
{
let surface = render_login();
let username_idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( username_idx ).expect( "text edit handler should exist" );
let msg = h.text_change_msg( "alic" ); // user pressed backspace once
assert_eq!( msg, Some( Msg::Username( "alic".into() ) ) );
}
#[ test ]
fn text_change_msg_passes_unicode_unchanged()
{
let surface = render_login();
let username_idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( username_idx ).unwrap();
let msg = h.text_change_msg( "café 🦀" );
assert_eq!( msg, Some( Msg::Username( "café 🦀".into() ) ) );
}
#[ test ]
fn submit_msg_returns_configured_message()
{
let surface = render_login();
let password_idx = surface.widget_rects()[ 1 ].flat_idx;
let h = surface.handlers( password_idx ).unwrap();
assert_eq!( h.submit_msg(), Some( Msg::PasswordSubmit ) );
}
#[ test ]
fn each_text_edit_dispatches_to_its_own_callback()
{
// The two fields share the same `Msg` enum but differ in which variant
// they wrap the new value in — confirms the handler snapshot captured
// the right closure for the right widget.
let surface = render_login();
let username_h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
let password_h = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
assert_eq!( username_h.text_change_msg( "x" ), Some( Msg::Username( "x".into() ) ) );
assert_eq!( password_h.text_change_msg( "x" ), Some( Msg::Password( "x".into() ) ) );
}
#[ test ]
fn current_value_reflects_initial_state()
{
let surface = render_login();
let username_h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
let password_h = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
assert_eq!( username_h.current_value(), Some( "alice" ) );
assert_eq!( password_h.current_value(), Some( "" ) );
}
#[ test ]
fn current_value_reflects_state_after_app_update()
{
// User types into the username field. The app updates state and the next
// render must snapshot the new value into the handler.
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut username = String::from( "alice" );
let view = |val: &str| -> Element<Msg>
{
column::<Msg>()
.padding( 16.0 )
.push(
text_edit( "Username", val.to_string() )
.on_change( |s| Msg::Username( s ) ),
)
.into()
};
let opts = RenderOptions::full_canvas( 320, 240 );
let _ = surface.render( &view( &username ), opts );
assert_eq!(
surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap().current_value(),
Some( "alice" ),
);
username.push_str( "_2" );
let _ = surface.render( &view( &username ), opts );
assert_eq!(
surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap().current_value(),
Some( "alice_2" ),
);
}
#[ test ]
fn is_text_input_distinguishes_text_edit_from_other_handlers()
{
use ltk::button;
let mut surface = UiSurface::<Msg>::new( 240, 200 );
let view: Element<Msg> = column()
.push( text_edit::<Msg>( "p", "" ) )
.push( button( "go" ).on_press( Msg::UsernameSubmit ) )
.into();
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 200 ) );
let h0 = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
let h1 = surface.handlers( surface.widget_rects()[ 1 ].flat_idx ).unwrap();
assert!( h0.is_text_input() );
assert!( !h1.is_text_input() );
}
#[ test ]
fn submit_msg_returns_none_for_non_text_edit_handlers()
{
use ltk::button;
let mut surface = UiSurface::<Msg>::new( 240, 100 );
let view: Element<Msg> = column()
.push( button( "go" ).on_press( Msg::UsernameSubmit ) )
.into();
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 100 ) );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( h.submit_msg().is_none() );
}
#[ test ]
fn text_change_msg_returns_none_when_no_callback_configured()
{
let mut surface = UiSurface::<Msg>::new( 240, 100 );
let view: Element<Msg> = column()
.push( text_edit::<Msg>( "no callback", "x" ) )
.into();
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 100 ) );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( h.text_change_msg( "y" ).is_none() );
}
#[ test ]
fn handler_variant_for_text_edit_carries_value_field()
{
// Confirm the layout pass populates the `value` field of the
// `WidgetHandlers::TextEdit` snapshot — input dispatch reads it for cursor
// placement / backspace rebuild.
let surface = render_login();
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
match h
{
WidgetHandlers::TextEdit { value, .. } => assert_eq!( value, "alice" ),
_ => panic!( "expected TextEdit handler variant" ),
}
}

399
tests/theme_parsing.rs Normal file
View File

@@ -0,0 +1,399 @@
use std::path::PathBuf;
use std::sync::Mutex;
use std::sync::atomic::{ AtomicU32, Ordering };
use ltk::{ ThemeDocument, ThemeMode, WallpaperFit };
/// Serialises tests that mutate the process-global active theme state
/// (`set_active_mode`, `theme_wallpaper`, …). `cargo test` runs tests
/// in parallel by default; without this guard a Light test can read
/// Dark state set by a concurrent test mid-flight.
static THEME_STATE_LOCK: Mutex<()> = Mutex::new( () );
// ── Per-test temp dir ─────────────────────────────────────────────────────────
/// Build a unique temp dir per test invocation. `process::id()` alone is not
/// enough — `cargo test` runs tests in the same process so a stale dir from a
/// previous test in the same run would collide. Add a process-local counter.
fn fresh_temp_dir( tag: &str ) -> PathBuf
{
static COUNTER: AtomicU32 = AtomicU32::new( 0 );
let n = COUNTER.fetch_add( 1, Ordering::SeqCst );
let dir = std::env::temp_dir()
.join( format!( "ltk-theme-test-{}-{}-{}", std::process::id(), tag, n ) );
let _ = std::fs::remove_dir_all( &dir );
std::fs::create_dir_all( &dir ).unwrap();
dir
}
const MINIMAL_THEME_JSON: &str = r##"
{
"theme": { "id": "demo", "name": "Demo" },
"fonts": {
"sora": { "name": "Sora", "fallbacks": [], "sources": [] }
},
"modes": {
"light": {
"wallpaper": { "path": "wp/light.jpg", "fit": "cover" },
"lockscreen": { "path": "wp/lock-light.jpg", "fit": "cover" },
"launcher": { "background": "#FFFFFFE6", "border_radius": 24.0 },
"window_controls": {
"icon": "#112233",
"hover_bg": "#11223322",
"pressed_bg": "#11223333",
"close_hover_bg": "#AA0000",
"close_icon": "#FFFFFF",
"focus_ring": "#00CEB1"
},
"slots": {
"bg-page": { "type": "color", "value": "#FFFFFF" },
"surface": { "type": "color", "value": "#FFFFFF99" },
"surface-alt": { "type": "color", "value": "#FFFFFFD9" },
"text-primary": { "type": "color", "value": "#102030" },
"text-secondary": { "type": "color", "value": "#10203080" },
"accent": { "type": "color", "value": "#00CEB1" },
"divider": { "type": "color", "value": "#00000014" },
"icon": { "type": "color", "value": "#000000" }
}
},
"dark": {
"wallpaper": { "path": "wp/dark.jpg", "fit": "contain" },
"lockscreen": { "path": "wp/lock-dark.jpg", "fit": "cover" },
"launcher": { "background": "#221A5AE6", "border_radius": 24.0 },
"window_controls": {
"icon": "#EEEEEE",
"hover_bg": "#FFFFFF18",
"pressed_bg": "#FFFFFF26",
"close_hover_bg": "#E5484D",
"close_icon": "#FFFFFF",
"focus_ring": "#00CEB1"
},
"slots": {
"bg-page": { "type": "color", "value": "#000000" },
"surface": { "type": "color", "value": "#18104599" },
"surface-alt": { "type": "color", "value": "#221A5AD9" },
"text-primary": { "type": "color", "value": "#FFFFFF" },
"text-secondary": { "type": "color", "value": "#FFFFFFB3" },
"accent": { "type": "color", "value": "#00CEB1" },
"divider": { "type": "color", "value": "#FFFFFF14" },
"icon": { "type": "color", "value": "#FFFFFF" }
}
}
}
}
"##;
// ── Round-trip ────────────────────────────────────────────────────────────────
#[ test ]
fn parses_id_name_and_both_modes()
{
let dir = fresh_temp_dir( "round-trip" );
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
let doc = ThemeDocument::load_from_dir( &dir ).expect( "minimal theme should parse" );
assert_eq!( doc.id, "demo" );
assert_eq!( doc.name, "Demo" );
// Wallpaper fit fields round-trip through the enum.
assert_eq!( doc.light.wallpaper.as_ref().unwrap().fit, WallpaperFit::Cover );
assert_eq!( doc.dark.wallpaper.as_ref().unwrap().fit, WallpaperFit::Contain );
let _ = std::fs::remove_dir_all( &dir );
}
#[ test ]
fn palette_hex_strings_round_trip_through_slots()
{
let dir = fresh_temp_dir( "palette" );
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
// "#102030" → R=0x10/255, G=0x20/255, B=0x30/255, A=1.0
let p = doc.light.slots.color( "text-primary" ).expect( "text-primary slot" );
let expected = ( 0x10 as f32 / 255.0, 0x20 as f32 / 255.0, 0x30 as f32 / 255.0 );
assert!( ( p.r - expected.0 ).abs() < 1e-3, "r={}, expected {}", p.r, expected.0 );
assert!( ( p.g - expected.1 ).abs() < 1e-3, "g={}, expected {}", p.g, expected.1 );
assert!( ( p.b - expected.2 ).abs() < 1e-3, "b={}, expected {}", p.b, expected.2 );
assert!( ( p.a - 1.0 ).abs() < 1e-3, "a={}, expected 1.0", p.a );
// 8-digit hex with alpha: "#10203080" → A ≈ 0x80/255
let s = doc.light.slots.color( "text-secondary" ).unwrap();
assert!( ( s.a - ( 0x80 as f32 / 255.0 ) ).abs() < 1e-3 );
let _ = std::fs::remove_dir_all( &dir );
}
#[ test ]
fn window_control_tokens_round_trip()
{
let dir = fresh_temp_dir( "window-controls" );
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
let wc = doc.light.window_controls.as_ref().expect( "window_controls" );
assert!( ( wc.icon.r - ( 0x11 as f32 / 255.0 ) ).abs() < 1e-3 );
assert!( ( wc.icon.g - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 );
assert!( ( wc.icon.b - ( 0x33 as f32 / 255.0 ) ).abs() < 1e-3 );
assert!( ( wc.icon.a - 1.0 ).abs() < 1e-3 );
assert!( ( wc.hover_bg.a - ( 0x22 as f32 / 255.0 ) ).abs() < 1e-3 );
let _ = std::fs::remove_dir_all( &dir );
}
#[ test ]
fn relative_asset_paths_resolve_against_theme_dir()
{
let dir = fresh_temp_dir( "paths" );
std::fs::write( dir.join( "theme.json" ), MINIMAL_THEME_JSON ).unwrap();
let doc = ThemeDocument::load_from_dir( &dir ).unwrap();
let wp = doc.light.wallpaper.as_ref().unwrap().path.as_ref().expect( "wallpaper path" );
assert_eq!( wp, &dir.join( "wp/light.jpg" ) );
let lock = doc.dark.lockscreen.as_ref().unwrap().path.as_ref().expect( "lockscreen path" );
assert_eq!( lock, &dir.join( "wp/lock-dark.jpg" ) );
let _ = std::fs::remove_dir_all( &dir );
}
// ── Errors ────────────────────────────────────────────────────────────────────
#[ test ]
fn missing_theme_json_returns_error()
{
// Empty dir — load_from_dir should fail rather than panic.
let dir = fresh_temp_dir( "missing" );
let result = ThemeDocument::load_from_dir( &dir );
assert!( result.is_err(), "expected Err for empty dir" );
let _ = std::fs::remove_dir_all( &dir );
}
// ── Mode resolution ───────────────────────────────────────────────────────────
/// Point `LTK_THEMES_DIR` at the in-tree `themes/` so any test that
/// triggers `ensure_active` finds the bundled `default` theme without
/// requiring a system install. Sets the env var unconditionally — the
/// global `ACTIVE` is process-wide so the first test that calls
/// `ensure_active` wins, and a stale env var from a prior run would
/// just point at the same directory.
fn point_at_in_tree_themes()
{
std::env::set_var
(
"LTK_THEMES_DIR",
env!( "CARGO_MANIFEST_DIR" ).to_string() + "/themes",
);
}
#[ test ]
fn theme_mode_round_trip()
{
// Switching the active mode at runtime is observable via
// `ltk::active_mode()`. This triggers `ensure_active()` which loads
// the in-tree default theme.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Light );
assert_eq!( ltk::active_mode(), ThemeMode::Light );
ltk::set_active_mode( ThemeMode::Dark );
assert_eq!( ltk::active_mode(), ThemeMode::Dark );
}
#[ test ]
fn theme_wallpaper_resolves_for_active_mode()
{
// `theme_wallpaper()` resolves via the convention
// `branding/{mode}/wallpaper.svg` (the default theme no longer
// declares an explicit `wallpaper.path` in theme.json), with the
// standard mode → opposite-mode → no-mode fallback chain.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Light );
let wp = ltk::theme_wallpaper().expect( "Light resolves via branding/light/" );
let path = wp.path.expect( "convention resolution returns a concrete path" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/wallpaper.svg" ),
"unexpected Light wallpaper path: {}", path.display(),
);
ltk::set_active_mode( ThemeMode::Dark );
let wp = ltk::theme_wallpaper().expect( "Dark resolves via branding/dark/" );
let path = wp.path.expect( "convention resolution returns a concrete path" );
assert!
(
path.to_string_lossy().ends_with( "branding/dark/wallpaper.svg" ),
"unexpected Dark wallpaper path: {}", path.display(),
);
}
#[ test ]
fn theme_lockscreen_resolves_for_active_mode()
{
// `theme_lockscreen()` mirrors `theme_wallpaper()` against the
// convention `branding/{mode}/lockscreen.svg`. The default theme
// ships both Light and Dark variants, so each mode resolves to its
// own asset without needing the fallback chain.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Light );
let ls = ltk::theme_lockscreen().expect( "Light resolves via branding/light/" );
let path = ls.path.expect( "convention resolution returns a concrete path" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/lockscreen.svg" ),
"unexpected Light lockscreen path: {}", path.display(),
);
ltk::set_active_mode( ThemeMode::Dark );
let ls = ltk::theme_lockscreen().expect( "Dark resolves via branding/dark/" );
let path = ls.path.expect( "convention resolution returns a concrete path" );
assert!
(
path.to_string_lossy().ends_with( "branding/dark/lockscreen.svg" ),
"unexpected Dark lockscreen path: {}", path.display(),
);
}
#[ test ]
fn theme_logo_variants_resolve_for_active_mode()
{
// `theme_logo`, `theme_logo_square`, `theme_logo_horizontal` are
// thin wrappers over `branding_asset( "logo/<variant>", "svg" )`.
// The default theme ships all three SVG variants for both modes
// under `branding/{mode}/logo/`, so each call resolves directly
// to its own-mode file without the cross-mode fallback.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
for mode in [ ThemeMode::Light, ThemeMode::Dark ]
{
ltk::set_active_mode( mode );
let mode_dir = match mode
{
ThemeMode::Light => "branding/light/logo/",
ThemeMode::Dark => "branding/dark/logo/",
};
let primary = ltk::theme_logo()
.unwrap_or_else( || panic!( "theme_logo missing for {mode:?}" ) );
assert!
(
primary.to_string_lossy().ends_with( &format!( "{mode_dir}logo.svg" ) ),
"unexpected {mode:?} logo path: {}", primary.display(),
);
let square = ltk::theme_logo_square()
.unwrap_or_else( || panic!( "theme_logo_square missing for {mode:?}" ) );
assert!
(
square.to_string_lossy().ends_with( &format!( "{mode_dir}square.svg" ) ),
"unexpected {mode:?} square logo path: {}", square.display(),
);
let horizontal = ltk::theme_logo_horizontal()
.unwrap_or_else( || panic!( "theme_logo_horizontal missing for {mode:?}" ) );
assert!
(
horizontal.to_string_lossy().ends_with( &format!( "{mode_dir}horizontal.svg" ) ),
"unexpected {mode:?} horizontal logo path: {}", horizontal.display(),
);
}
}
#[ test ]
fn branding_raster_picks_smallest_covering_at_each_target_size()
{
// The default theme ships `1280x720`, `1920x1080`, `3840x2160` WebPs
// for both wallpaper and lockscreen in both modes. The lookup must
// pick the smallest entry whose two dimensions cover the surface.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Light );
// Surface ≤ 720p → 1280x720 covers it.
let path = ltk::theme_branding_raster( "wallpaper", 800, 600 )
.expect( "Light raster covers 800x600" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ),
"unexpected raster for 800x600: {}", path.display(),
);
// Surface 1080p exact → 1920x1080 covers it.
let path = ltk::theme_branding_raster( "wallpaper", 1920, 1080 )
.expect( "Light raster covers 1920x1080" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/wallpaper/1920x1080.webp" ),
"unexpected raster for 1920x1080: {}", path.display(),
);
// 1920x1200 (16:10 laptop) → 1080 < 1200, so 1080p is too short. Next
// up that covers both axes is 4K.
let path = ltk::theme_branding_raster( "wallpaper", 1920, 1200 )
.expect( "Light raster covers 1920x1200" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/wallpaper/3840x2160.webp" ),
"unexpected raster for 1920x1200: {}", path.display(),
);
}
#[ test ]
fn branding_raster_falls_back_to_largest_when_none_covers()
{
// 5000-wide surface exceeds the largest shipped raster (3840). No
// covering candidate, so the lookup falls back to the largest
// available raster — a fast-decoding upscaled WebP beats paying the
// SVG rasterisation cost.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Dark );
let path = ltk::theme_branding_raster( "wallpaper", 5000, 3000 )
.expect( "no covering raster but the largest is returned" );
assert!
(
path.to_string_lossy().ends_with( "branding/dark/wallpaper/3840x2160.webp" ),
"unexpected fallback raster for 5000x3000: {}", path.display(),
);
// `branding_image` returns the same — it only falls to the SVG when
// no raster files exist at all.
let img = ltk::theme_branding_image( "wallpaper", 5000, 3000 )
.expect( "branding_image returns the same largest raster" );
assert_eq!( img, path );
}
#[ test ]
fn branding_raster_with_zero_size_returns_smallest_available()
{
// (0, 0) means "surface size unknown — give me the smallest variant".
// Every entry trivially covers a 0×0 surface so the smallest by area
// wins. Used at startup so the first frame paints a fast-decoded
// lightweight raster instead of paying the SVG rasterisation cost.
let _guard = THEME_STATE_LOCK.lock().unwrap_or_else( |p| p.into_inner() );
point_at_in_tree_themes();
ltk::set_active_mode( ThemeMode::Light );
let path = ltk::theme_branding_raster( "wallpaper", 0, 0 )
.expect( "(0, 0) returns the smallest raster" );
assert!
(
path.to_string_lossy().ends_with( "branding/light/wallpaper/1280x720.webp" ),
"unexpected raster for (0, 0): {}", path.display(),
);
let img = ltk::theme_branding_image( "wallpaper", 0, 0 )
.expect( "branding_image returns the same smallest raster" );
assert_eq!( img, path );
}

95
tests/tree_lookup.rs Normal file
View File

@@ -0,0 +1,95 @@
mod common;
use common::{ lw_none, lw_button, rect };
use ltk::test_support::{ find_widget_at, find_widget, find_handlers, WidgetHandlers };
use ltk::Point;
fn pt( x: f32, y: f32 ) -> Point
{
Point { x, y }
}
// ── find_widget_at ────────────────────────────────────────────────────────────
#[ test ]
fn hit_test_misses_when_empty()
{
let widgets: Vec<_> = vec![];
assert_eq!( find_widget_at::<()>( &widgets, pt( 10.0, 10.0 ) ), None );
}
#[ test ]
fn hit_test_misses_outside_all_rects()
{
let widgets = vec![
lw_none( 1, rect( 0.0, 0.0, 50.0, 50.0 ) ),
lw_none( 2, rect( 100.0, 100.0, 50.0, 50.0 ) ),
];
assert_eq!( find_widget_at( &widgets, pt( 75.0, 75.0 ) ), None );
}
#[ test ]
fn hit_test_returns_widget_under_point()
{
let widgets = vec![
lw_none( 7, rect( 0.0, 0.0, 100.0, 100.0 ) ),
lw_none( 8, rect( 200.0, 0.0, 100.0, 100.0 ) ),
];
assert_eq!( find_widget_at( &widgets, pt( 50.0, 50.0 ) ), Some( 7 ) );
assert_eq!( find_widget_at( &widgets, pt( 250.0, 50.0 ) ), Some( 8 ) );
}
#[ test ]
fn hit_test_returns_topmost_when_overlapping()
{
// Topmost wins. Layout pushes parents before children, so iteration in
// reverse must return the child (drawn last, last in the slice).
let widgets = vec![
lw_none( 1, rect( 0.0, 0.0, 200.0, 200.0 ) ),
lw_none( 2, rect( 50.0, 50.0, 100.0, 100.0 ) ),
];
assert_eq!( find_widget_at( &widgets, pt( 100.0, 100.0 ) ), Some( 2 ) );
assert_eq!( find_widget_at( &widgets, pt( 10.0, 10.0 ) ), Some( 1 ) );
}
// ── find_widget ───────────────────────────────────────────────────────────────
#[ test ]
fn find_widget_returns_match_by_flat_idx()
{
let widgets = vec![
lw_none( 10, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 20, rect( 0.0, 0.0, 10.0, 10.0 ) ),
lw_none( 30, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
let w = find_widget( &widgets, 20 ).expect( "widget 20 should be found" );
assert_eq!( w.flat_idx, 20 );
}
#[ test ]
fn find_widget_returns_none_for_missing_idx()
{
let widgets = vec![ lw_none( 1, rect( 0.0, 0.0, 10.0, 10.0 ) ) ];
assert!( find_widget( &widgets, 999 ).is_none() );
}
// ── find_handlers ─────────────────────────────────────────────────────────────
#[ test ]
fn find_handlers_returns_button_handler()
{
let widgets = vec![
lw_button( 5, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
let h = find_handlers( &widgets, 5 ).expect( "handler should be present" );
assert!( matches!( h, WidgetHandlers::Button { on_press: Some( () ), .. } ) );
}
#[ test ]
fn find_handlers_returns_none_for_missing_idx()
{
let widgets = vec![
lw_button( 5, rect( 0.0, 0.0, 10.0, 10.0 ) ),
];
assert!( find_handlers( &widgets, 6 ).is_none() );
}

119
tests/vslider_math.rs Normal file
View File

@@ -0,0 +1,119 @@
use ltk::test_support::
{
value_from_y_in_rect,
value_from_pos_in_rect,
SliderAxis,
};
use ltk::{ Point, Rect };
fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect
{
Rect { x, y, width: w, height: h }
}
#[ test ]
fn top_is_one()
{
let r = rect( 0.0, 0.0, 56.0, 160.0 );
assert_eq!( value_from_y_in_rect( r, 0.0 ), 1.0 );
assert_eq!( value_from_y_in_rect( r, -50.0 ), 1.0 );
}
#[ test ]
fn bottom_is_zero()
{
let r = rect( 0.0, 0.0, 56.0, 160.0 );
assert_eq!( value_from_y_in_rect( r, 160.0 ), 0.0 );
assert_eq!( value_from_y_in_rect( r, 999.0 ), 0.0 );
}
#[ test ]
fn center_returns_half()
{
let r = rect( 0.0, 0.0, 56.0, 160.0 );
let v = value_from_y_in_rect( r, 80.0 );
assert!( ( v - 0.5 ).abs() < 1e-6, "expected 0.5 got {}", v );
}
#[ test ]
fn rect_offset_translates_correctly()
{
let r = rect( 0.0, 500.0, 56.0, 160.0 );
assert_eq!( value_from_y_in_rect( r, 500.0 ), 1.0 );
assert_eq!( value_from_y_in_rect( r, 660.0 ), 0.0 );
let v = value_from_y_in_rect( r, 580.0 );
assert!( ( v - 0.5 ).abs() < 1e-6, "expected 0.5 got {}", v );
}
#[ test ]
fn output_is_always_in_unit_range()
{
let r = rect( 10.0, 10.0, 56.0, 200.0 );
for y in -100i32 ..= 400
{
let v = value_from_y_in_rect( r, y as f32 );
assert!( ( 0.0..= 1.0 ).contains( &v ), "v={} out of range at y={}", v, y );
}
}
#[ test ]
fn very_short_rect_does_not_divide_by_zero()
{
// Height < 1.0 — track_h is clamped to 1.0 internally so the formula
// stays defined.
let r = rect( 0.0, 0.0, 56.0, 0.1 );
let v = value_from_y_in_rect( r, 0.05 );
assert!( v.is_finite() );
assert!( ( 0.0..= 1.0 ).contains( &v ) );
}
#[ test ]
fn value_is_monotonically_decreasing_along_y()
{
// Moving downward inside the pill must never *increase* the value —
// the invariant behind the "drag up = larger" UX contract.
let r = rect( 0.0, 0.0, 56.0, 160.0 );
let mut prev = value_from_y_in_rect( r, 0.0 );
for y in 0 ..= 160
{
let v = value_from_y_in_rect( r, y as f32 );
assert!( v <= prev + 1e-6, "not monotonic: y={} v={} prev={}", y, v, prev );
prev = v;
}
}
#[ test ]
fn value_from_pos_dispatches_horizontal()
{
let r = rect( 0.0, 0.0, 200.0, 36.0 );
let v = value_from_pos_in_rect(
r,
Point { x: 200.0, y: 0.0 },
SliderAxis::Horizontal,
);
assert_eq!( v, 1.0 );
}
#[ test ]
fn value_from_pos_dispatches_vertical()
{
let r = rect( 0.0, 0.0, 56.0, 160.0 );
let v = value_from_pos_in_rect(
r,
Point { x: 9_999.0, y: 0.0 },
SliderAxis::Vertical,
);
assert_eq!( v, 1.0 );
}
#[ test ]
fn value_from_pos_axes_are_independent()
{
let r = rect( 0.0, 0.0, 200.0, 200.0 );
let h = value_from_pos_in_rect(
r, Point { x: 100.0, y: 0.0 }, SliderAxis::Horizontal );
let v = value_from_pos_in_rect(
r, Point { x: 0.0, y: 100.0 }, SliderAxis::Vertical );
assert!( ( h - 0.5 ).abs() < 0.1 );
assert!( ( v - 0.5 ).abs() < 1e-6 );
}

281
tests/widget_dispatch.rs Normal file
View File

@@ -0,0 +1,281 @@
// Handler-level dispatch coverage for the interactive widgets that aren't
// buttons or text edits: toggle, checkbox, radio and slider. Each test renders
// the widget through `UiSurface`, locates its `WidgetHandlers` snapshot and
// confirms the snapshot routes activations to the configured Msg variant.
use ltk::core::{ RenderOptions, UiSurface };
use ltk::test_support::WidgetHandlers;
use ltk::{
checkbox, column, radio, slider, toggle, Color, Element, Point, Rect, SliderAxis,
};
#[ derive( Clone, Debug, PartialEq ) ]
enum Msg
{
WifiToggled,
TermsAccepted,
PriorityHigh,
Brightness( f32 ),
}
fn render_view( view: Element<Msg> ) -> UiSurface<Msg>
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let _ = surface.render(
&view,
RenderOptions::full_canvas( 320, 240 ).background( Color::rgb( 0.1, 0.1, 0.1 ) ),
);
surface
}
// ── Toggle ────────────────────────────────────────────────────────────────────
#[ test ]
fn toggle_handler_carries_on_toggle_message()
{
let view: Element<Msg> = column()
.push( toggle( false ).on_toggle( Msg::WifiToggled ) )
.into();
let surface = render_view( view );
let idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( idx ).expect( "toggle should expose a handler" );
assert!( !h.is_text_input() );
assert!( !h.is_slider() );
assert_eq!( h.press_msg(), Some( Msg::WifiToggled ) );
}
#[ test ]
fn toggle_handler_variant_is_toggle()
{
let view: Element<Msg> = column()
.push( toggle( true ).on_toggle( Msg::WifiToggled ) )
.into();
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Toggle { on_toggle: Some( _ ) } ) );
}
#[ test ]
fn toggle_without_on_toggle_yields_none_press_msg()
{
let view: Element<Msg> = column()
.push( toggle::<Msg>( false ) )
.into();
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( h.press_msg().is_none() );
}
// ── Checkbox ──────────────────────────────────────────────────────────────────
#[ test ]
fn checkbox_handler_carries_on_toggle_message()
{
let view: Element<Msg> = column()
.push( checkbox( false ).on_toggle( Msg::TermsAccepted ).label( "Accept" ) )
.into();
let surface = render_view( view );
let idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Checkbox { on_toggle: Some( _ ) } ) );
assert_eq!( h.press_msg(), Some( Msg::TermsAccepted ) );
}
#[ test ]
fn checkbox_state_is_independent_of_handler()
{
// The widget renders the current `checked` value; toggling that value is
// the parent app's job — handler dispatch must work regardless of the
// initial state.
let unchecked: Element<Msg> = column().push( checkbox( false ).on_toggle( Msg::TermsAccepted ) ).into();
let checked: Element<Msg> = column().push( checkbox( true ).on_toggle( Msg::TermsAccepted ) ).into();
for view in [ unchecked, checked ]
{
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert_eq!( h.press_msg(), Some( Msg::TermsAccepted ) );
}
}
// ── Radio ─────────────────────────────────────────────────────────────────────
#[ test ]
fn radio_handler_carries_on_select_message()
{
let view: Element<Msg> = column()
.push( radio( false ).on_select( Msg::PriorityHigh ).label( "High" ) )
.into();
let surface = render_view( view );
let idx = surface.widget_rects()[ 0 ].flat_idx;
let h = surface.handlers( idx ).unwrap();
assert!( matches!( h, WidgetHandlers::Radio { on_select: Some( _ ) } ) );
assert_eq!( h.press_msg(), Some( Msg::PriorityHigh ) );
}
#[ test ]
fn three_radios_each_route_to_their_own_message()
{
#[ derive( Clone, Debug, PartialEq ) ]
enum Choice { Low, Medium, High }
let view: Element<Choice> = column()
.push( radio( false ).on_select( Choice::Low ) )
.push( radio( false ).on_select( Choice::Medium ) )
.push( radio( true ).on_select( Choice::High ) )
.into();
let mut surface = UiSurface::<Choice>::new( 240, 240 );
let _ = surface.render( &view, RenderOptions::full_canvas( 240, 240 ) );
let messages: Vec<_> = surface.widget_rects().iter()
.map( |w| surface.handlers( w.flat_idx ).and_then( |h| h.press_msg() ) )
.collect();
assert_eq!(
messages,
vec![ Some( Choice::Low ), Some( Choice::Medium ), Some( Choice::High ) ],
);
}
// ── Slider ────────────────────────────────────────────────────────────────────
#[ test ]
fn slider_handler_variant_is_slider()
{
let view: Element<Msg> = column()
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert!( h.is_slider() );
assert!( !h.is_text_input() );
}
#[ test ]
fn slider_change_msg_wraps_value_through_callback()
{
let view: Element<Msg> = column()
.push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let h = surface.handlers( surface.widget_rects()[ 0 ].flat_idx ).unwrap();
assert_eq!( h.slider_change_msg( 0.0 ), Some( Msg::Brightness( 0.0 ) ) );
assert_eq!( h.slider_change_msg( 0.5 ), Some( Msg::Brightness( 0.5 ) ) );
assert_eq!( h.slider_change_msg( 1.0 ), Some( Msg::Brightness( 1.0 ) ) );
}
#[ test ]
fn slider_value_from_pos_at_left_edge_is_zero()
{
let view: Element<Msg> = column()
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let w = &surface.widget_rects()[ 0 ];
let left = Point { x: w.rect.x, y: w.rect.y + w.rect.height * 0.5 };
let v = w.handlers.slider_value_from_pos( w.rect, left );
assert!( ( v - 0.0 ).abs() < 1e-3 );
}
#[ test ]
fn slider_value_from_pos_at_right_edge_is_one()
{
let view: Element<Msg> = column()
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let w = &surface.widget_rects()[ 0 ];
let right = Point { x: w.rect.x + w.rect.width, y: w.rect.y + w.rect.height * 0.5 };
let v = w.handlers.slider_value_from_pos( w.rect, right );
assert!( ( v - 1.0 ).abs() < 1e-3 );
}
#[ test ]
fn slider_value_from_pos_at_midpoint_is_one_half()
{
let view: Element<Msg> = column()
.push( slider( 0.0 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let w = &surface.widget_rects()[ 0 ];
let mid = Point
{
x: w.rect.x + w.rect.width * 0.5,
y: w.rect.y + w.rect.height * 0.5,
};
let v = w.handlers.slider_value_from_pos( w.rect, mid );
assert!( ( v - 0.5 ).abs() < 1e-3 );
}
#[ test ]
fn slider_value_from_pos_clamps_outside_rect()
{
// Pointer way outside the rect on either side must clamp to 0 or 1.
let view: Element<Msg> = column()
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let w = &surface.widget_rects()[ 0 ];
let far_left = Point { x: w.rect.x - 200.0, y: w.rect.y + 10.0 };
let far_right = Point { x: w.rect.x + w.rect.width + 200.0, y: w.rect.y + 10.0 };
assert_eq!( w.handlers.slider_value_from_pos( w.rect, far_left ), 0.0 );
assert_eq!( w.handlers.slider_value_from_pos( w.rect, far_right ), 1.0 );
}
// Bonus: confirm slider_value_from_pos returns 0.0 for non-slider variants —
// gives the gesture state machine a safe fallback when a slider handler gets
// swapped out mid-gesture.
#[ test ]
fn slider_value_from_pos_returns_zero_for_non_slider_handler()
{
use ltk::button;
let view: Element<Msg> = column()
.push( button( "go" ).on_press( Msg::WifiToggled ) )
.into();
let surface = render_view( view );
let w = &surface.widget_rects()[ 0 ];
let mid = Point
{
x: w.rect.x + w.rect.width * 0.5,
y: w.rect.y + w.rect.height * 0.5,
};
assert_eq!( w.handlers.slider_value_from_pos( w.rect, mid ), 0.0 );
}
// ── SliderAxis sanity ─────────────────────────────────────────────────────────
#[ test ]
fn slider_axis_enum_is_publicly_exposed()
{
// Compile-time guard: SliderAxis must remain a publicly-named enum so
// downstream apps can match on it (e.g. when wrapping value_from_pos
// for a custom hit area).
let _ = SliderAxis::Horizontal;
let _ = SliderAxis::Vertical;
}
// Bonus: ensure the rect from layout has non-zero size so the value tests
// above are meaningful (if rect.width were 0 every position lookup would
// degenerate to 0 / 0).
#[ test ]
fn slider_layout_rect_has_non_zero_width()
{
let view: Element<Msg> = column()
.push( slider( 0.5 ).on_change( |v| Msg::Brightness( v ) ) )
.into();
let surface = render_view( view );
let w: Rect = surface.widget_rects()[ 0 ].rect;
assert!( w.width > 0.0 );
assert!( w.height > 0.0 );
}