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`.
339 lines
9.1 KiB
Rust
339 lines
9.1 KiB
Rust
#![ cfg( feature = "test-support" ) ]
|
|
|
|
// 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 );
|
|
}
|