Responsive scaling. ltk now offers two first-class ways to size a UI so it adapts across screens, chosen per process via `WidgetScaling { Fluid, Physical }` (`set_widget_scaling` / `widget_scaling`, default `Fluid`). Fluid sizing (`Length::fluid( px )`) makes a design pixel a proportion of the surface's smaller side, calibrated against a reference width (`set_fluid_reference` / `fluid_reference`, 412 px default) and bounded by `FLUID_MIN` / `FLUID_MAX`; physical sizing (`Length::dp( px )`) is a constant-physical-size pixel scaled by display density (`set_density` / `density`). `Length` gains `orient( portrait, landscape )` — resolve one value in portrait, another in landscape — plus `widget( px )`, which picks fluid or dp per the active mode. Canvas exposes `geom_px` (geometry, resolved in physical layout space) and `font_px` (font size, bridging logical / physical per mode) so widgets and apps share one resolution path. Note the rename: `set_design_reference` / `design_reference` became `set_fluid_reference` / `fluid_reference`, and `Length::dp` changed meaning — the old surface-proportional behaviour now lives on `Length::fluid`.
Widgets. Every stock widget resolves its default geometry and font through the widget-scaling mode instead of frozen pixels, so a whole UI scales coherently without per-call units. New size builders where they were missing: `button` gains `font_size` / `height`, `text_edit` gains `height` / `font_size_fluid`, `separator` gains `pad_v`, and assorted widgets accept a `Length` where they previously took only `f32`.
Overlays. `OverlaySpec::size` is now `( Length, Length )` instead of `( u32, u32 )`, resolved against the main surface when the overlay is materialized, so overlays can scale with the display; `Length::px( … )` reproduces the old fixed sizing.
API stabilization (toward 1.0). Widget struct fields are now `pub( crate )` — they are configured through builders, not field access — except the value / state types apps genuinely read or construct (`Time`, `Date`, `ComboState`), which stay public. The internal `test_support` helpers move behind a `test-support` Cargo feature (off by default, so third-party builds never see them; ltk's own `make test` enables it). `Separator` drops its `0.0`-means-mode sentinel for `Option<Length>`, so an explicit `pad_v( 0.0 )` is a real flush divider distinct from the mode-following default.
Performance guardrails. Opt-in diagnostics via `LTK_PERF_WARN=1` warn about stuck animations, sustained software-render animation, and low `poll_interval`; software-rendered animation is capped near 30 Hz to spare CPU on machines that fall back off EGL. Apps can override the cap with `App::cap_software_animation`.
Docs and build. The two scaling modes are documented in README, onboarding and architecture, with the earlier gradient / backdrop doc drift cleaned up. The Makefile now ships the `locales/` directory into the packaged crate (fixing i18n keys rendering raw for downstreams), builds the new `responsive` example, and runs tests with `--features test-support`.
286 lines
8.3 KiB
Rust
286 lines
8.3 KiB
Rust
#![ cfg( feature = "test-support" ) ]
|
||
|
||
// End-to-end coverage for the runtime contract: `Msg → App::update → next
|
||
// view → render`. The Wayland event loop in `ltk::run` is the integration
|
||
// point that ties widget-level handler snapshots, focus traversal and keysym
|
||
// dispatch together; these tests exercise the same wiring against `UiSurface`
|
||
// (the runtime-free embedding of that loop).
|
||
|
||
use ltk::core::{ RenderOptions, UiSurface };
|
||
use ltk::test_support::next_focusable_index;
|
||
use ltk::{
|
||
button, column, text, App, Color, Element, Keysym,
|
||
};
|
||
|
||
// ── A small counter app ───────────────────────────────────────────────────────
|
||
|
||
#[ derive( Clone, Debug, PartialEq, Eq ) ]
|
||
enum Msg
|
||
{
|
||
Inc,
|
||
Dec,
|
||
Reset,
|
||
Quit,
|
||
}
|
||
|
||
struct Counter
|
||
{
|
||
value: i32,
|
||
pending: Vec<Msg>,
|
||
quit: bool,
|
||
}
|
||
|
||
impl Counter
|
||
{
|
||
fn new() -> Self
|
||
{
|
||
Self { value: 0, pending: vec![], quit: false }
|
||
}
|
||
}
|
||
|
||
impl App for Counter
|
||
{
|
||
type Message = Msg;
|
||
|
||
fn 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 );
|
||
}
|