337 lines
9.1 KiB
Rust
337 lines
9.1 KiB
Rust
// 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 );
|
|
}
|