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