#![ 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, swipe_progress: f32, swipe_h: f32, tap_count: u32, drag_xy: Option<( f32, f32 )>, drop_xy: Option<( f32, f32 )>, focus_request: Option, 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 { column::() .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 { // 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 { 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 { self.drop_xy = Some( ( x, y ) ); None } fn take_focus_request( &mut self ) -> Option { 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::::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::::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> = surface.handlers( 999 ); }