use super::event_queue::{EventQueue, EventSender};
use super::timer::IdleTask;
use super::timer::TimerManager;
#[cfg(all(feature = "touch", not(alloc_frugal)))]
use super::translator::is_touch_cancel;
use super::types::{Event, EventPriority};
#[cfg(all(feature = "touch", not(alloc_frugal)))]
use crate::compat::HashMap;
use crate::compat::{lock, Box, MiniToString, Mutex, String, Vec};
use crate::core::ObjectId;
#[cfg(all(feature = "touch", not(alloc_frugal)))]
use crate::gesture::GestureEngine;
use alloc::sync::Arc;
use core::sync::atomic::AtomicU64;
use core::sync::atomic::AtomicUsize;
use core::sync::atomic::Ordering;
use core::time::Duration;
#[cfg(not(alloc_frugal))]
use std::thread;
#[cfg(all(feature = "touch", not(alloc_frugal)))]
use std::time::{SystemTime, UNIX_EPOCH};
pub type EventDispatchFn = Arc<dyn Fn(ObjectId, &Event) + Send + Sync>;
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn now_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
}
#[cfg(feature = "touch")]
fn feeds_the_gesture_engine(event: &Event) -> bool {
event.is_touch() || matches!(event, Event::Timer { .. })
}
#[cfg(all(feature = "touch", not(alloc_frugal)))]
struct GestureRegistry {
engines: HashMap<ObjectId, GestureEngineEntry>,
}
#[cfg(all(feature = "touch", not(alloc_frugal)))]
struct GestureEngineEntry {
engine: GestureEngine,
last_active_ms: u64,
}
#[cfg(all(feature = "touch", not(alloc_frugal)))]
impl GestureRegistry {
fn new() -> Self {
Self { engines: HashMap::new() }
}
fn idle_grace() -> u64 {
crate::gesture::DOUBLE_TAP_TIMEOUT_MS + 600
}
fn process(&mut self, target: ObjectId, event: &Event, now_ms: u64) -> Option<Event> {
if is_touch_cancel(event) {
self.engines.remove(&target);
return None;
}
let entry = self.engines.entry(target).or_insert_with(|| GestureEngineEntry {
engine: GestureEngine::new(),
last_active_ms: now_ms,
});
entry.last_active_ms = now_ms;
entry.engine.process(event, now_ms)
}
fn sweep(&mut self, now_ms: u64) -> usize {
let grace = Self::idle_grace();
let before = self.engines.len();
self.engines.retain(|_, entry| now_ms.saturating_sub(entry.last_active_ms) <= grace);
before - self.engines.len()
}
fn release(&mut self, target: ObjectId) -> bool {
self.engines.remove(&target).is_some()
}
#[cfg(test)]
fn len(&self) -> usize {
self.engines.len()
}
}
pub const ANIMATION_FRAME_EVENT_NAME: &str = "animation_frame";
#[cfg(not(alloc_frugal))]
const PER_TURN_EVENT_DRAIN_CAP: usize = 512;
#[cfg(not(alloc_frugal))]
const HIGH_PHASE_BUDGET: Duration = Duration::from_millis(4);
#[cfg(not(alloc_frugal))]
const NORMAL_PHASE_BUDGET: Duration = Duration::from_millis(4);
fn animation_frame_id(event: &Event) -> Option<u64> {
let Event::Custom { name, payload } = event else {
return None;
};
if name != ANIMATION_FRAME_EVENT_NAME {
return None;
}
let bytes: [u8; 8] = payload.as_slice().try_into().ok()?;
Some(u64::from_le_bytes(bytes))
}
#[derive(Debug, Default)]
struct AnimFrameState {
pending: Vec<u64>,
cancelled: Vec<u64>,
}
impl AnimFrameState {
fn request(&mut self, id: u64) {
self.pending.push(id);
}
fn forget(&mut self, id: u64) {
if let Some(position) = self.pending.iter().position(|pending| *pending == id) {
self.pending.swap_remove(position);
}
}
fn cancel(&mut self, id: u64) -> bool {
if let Some(position) = self.pending.iter().position(|pending| *pending == id) {
self.pending.swap_remove(position);
self.cancelled.push(id);
return true;
}
false
}
fn dispatched_and_cancelled(&mut self, id: u64) -> bool {
if let Some(position) = self.pending.iter().position(|pending| *pending == id) {
self.pending.swap_remove(position);
}
take_cancelled_ids(&mut self.cancelled, id)
}
}
fn take_cancelled_ids(cancelled: &mut Vec<u64>, id: u64) -> bool {
if let Some(position) = cancelled.iter().position(|pending| *pending == id) {
cancelled.swap_remove(position);
return true;
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AnimationFrameRequest {
pub id: u64,
}
pub struct EventLoop {
#[cfg_attr(alloc_frugal, allow(dead_code))]
#[cfg_attr(alloc_frugal, allow(clippy::arc_with_non_send_sync))]
queue: Arc<Mutex<EventQueue>>,
sender: EventSender,
running: Arc<Mutex<bool>>,
#[cfg(not(alloc_frugal))]
thread_handle: Option<thread::JoinHandle<()>>,
#[cfg(alloc_frugal)]
#[cfg_attr(alloc_frugal, allow(dead_code))]
thread_handle: Option<()>,
dispatch_fn: Option<EventDispatchFn>,
timer_manager: TimerManager,
next_anim_frame_id: AtomicU64,
native_pump: Option<Arc<dyn Fn() + Send + Sync>>,
idle_tasks: Vec<IdleTask>,
#[cfg_attr(alloc_frugal, allow(clippy::arc_with_non_send_sync))]
cancelled_anim_frames: Arc<Mutex<AnimFrameState>>,
#[cfg_attr(alloc_frugal, allow(dead_code))]
idle_task_cursor: Arc<AtomicUsize>,
#[cfg(all(feature = "touch", not(alloc_frugal)))]
gesture_releases: Arc<Mutex<Vec<ObjectId>>>,
}
impl EventLoop {
#[cfg_attr(alloc_frugal, allow(clippy::arc_with_non_send_sync))]
pub fn new() -> Self {
let queue = EventQueue::new();
let sender = queue.sender();
let timer_manager = TimerManager::new(sender.clone());
Self {
queue: Arc::new(Mutex::new(queue)),
sender,
running: Arc::new(Mutex::new(false)),
thread_handle: None,
dispatch_fn: None,
timer_manager,
next_anim_frame_id: AtomicU64::new(1),
native_pump: None,
idle_tasks: Vec::new(),
idle_task_cursor: Arc::new(AtomicUsize::new(0)),
#[cfg(all(feature = "touch", not(alloc_frugal)))]
gesture_releases: Arc::new(Mutex::new(Vec::new())),
cancelled_anim_frames: Arc::new(Mutex::new(AnimFrameState::default())),
}
}
#[cfg(not(alloc_frugal))]
pub fn add_idle_task(&mut self, task: IdleTask) -> bool {
if *lock(&self.running) {
return false;
}
self.idle_tasks.retain(|existing| existing.id != task.id);
self.idle_tasks.push(task);
true
}
pub fn remove_idle_task(&mut self, id: u64) -> bool {
let before = self.idle_tasks.len();
self.idle_tasks.retain(|task| task.id != id);
self.idle_tasks.len() != before
}
pub fn idle_task_count(&self) -> usize {
self.idle_tasks.len()
}
#[cfg(not(alloc_frugal))]
pub fn start(&mut self) {
if *lock(&self.running) {
return;
}
*lock(&self.running) = true;
let running = Arc::clone(&self.running);
let queue = Arc::clone(&self.queue);
let dispatch_fn = self.dispatch_fn.clone();
#[cfg(feature = "touch")]
let mut gesture_engines = GestureRegistry::new();
let native_pump = self.native_pump.clone();
let mut idle_tasks = core::mem::take(&mut self.idle_tasks);
let cancelled_anim_frames = Arc::clone(&self.cancelled_anim_frames);
let idle_task_cursor = Arc::clone(&self.idle_task_cursor);
#[cfg(all(feature = "touch", not(alloc_frugal)))]
let gesture_releases = Arc::clone(&self.gesture_releases);
let handle = thread::spawn(move || {
while *lock(&running) {
if let Some(ref pump) = native_pump {
pump();
}
crate::event::types::drain_tasks();
#[cfg(feature = "touch")]
{
let releases: Vec<ObjectId> = {
let mut pending = lock(&gesture_releases);
core::mem::take(&mut *pending)
};
for target in releases {
gesture_engines.release(target);
}
}
let mut had_work = false;
let mut high_events: Vec<(ObjectId, Event)> = Vec::new();
let mut normal_events: Vec<(ObjectId, Event)> = Vec::new();
let mut idle_events: Vec<(ObjectId, Event)> = Vec::new();
let mut drained = 0usize;
while drained < PER_TURN_EVENT_DRAIN_CAP {
let Some((target, event, priority)) = lock(&queue).dequeue() else { break };
had_work = true;
drained += 1;
match priority {
EventPriority::High => high_events.push((target, event)),
EventPriority::Normal => normal_events.push((target, event)),
EventPriority::Idle => idle_events.push((target, event)),
}
}
let dispatch_fn_ref = &dispatch_fn;
let cancelled_ref = &cancelled_anim_frames;
#[cfg(feature = "touch")]
let gesture_ref = &mut gesture_engines;
#[cfg_attr(not(feature = "touch"), allow(unused_mut))]
let mut dispatch_one = |target: ObjectId, event: &Event| {
let anim_id = animation_frame_id(event);
let skip = match anim_id {
Some(id) => lock(cancelled_ref).dispatched_and_cancelled(id),
None => false,
};
if skip {
return;
}
#[cfg(feature = "touch")]
let maybe_gesture_event =
if feeds_the_gesture_engine(event) || is_touch_cancel(event) {
gesture_ref.process(target, event, now_ms())
} else {
None
};
if let Some(dispatch) = dispatch_fn_ref {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dispatch(target, event);
#[cfg(feature = "touch")]
if let Some(ref gesture) = maybe_gesture_event {
dispatch(target, gesture);
}
}));
if let Err(e) = result {
log::error!("[event-loop] Dispatch panicked: {e:?}");
}
} else {
log::warn!(
"[event-loop] No dispatch_fn set — dropping event {event:?} for target {target:?}"
);
}
};
let mut processed = 0usize;
let budget_start = std::time::Instant::now();
while processed < high_events.len() {
if budget_start.elapsed() >= HIGH_PHASE_BUDGET {
break;
}
let (target, event) = &high_events[processed];
dispatch_one(*target, event);
processed += 1;
}
if processed < high_events.len() {
let sender = lock(&queue).sender();
for (target, event) in high_events.drain(processed..) {
let _ = sender.post_with_priority(target, event, EventPriority::High);
}
}
let mut processed = 0usize;
let budget_start = std::time::Instant::now();
while processed < normal_events.len() {
if budget_start.elapsed() >= NORMAL_PHASE_BUDGET {
break;
}
let (target, event) = &normal_events[processed];
dispatch_one(*target, event);
processed += 1;
}
if processed < normal_events.len() {
let sender = lock(&queue).sender();
for (target, event) in normal_events.drain(processed..) {
let _ = sender.post_with_priority(target, event, EventPriority::Normal);
}
}
if !idle_events.is_empty() {
#[cfg(not(alloc_frugal))]
let idle_budget_start = std::time::Instant::now();
let mut processed = 0;
while processed < idle_events.len() {
#[cfg(not(alloc_frugal))]
if idle_budget_start.elapsed().as_millis() >= 5 {
break;
}
let (target, event) = &idle_events[processed];
dispatch_one(*target, event);
processed += 1;
}
if processed < idle_events.len() {
let sender = lock(&queue).sender();
for (target, event) in idle_events.drain(processed..) {
let _ = sender.post_idle(target, event);
}
}
}
#[cfg(feature = "touch")]
{
let removed = gesture_engines.sweep(now_ms());
if removed > 0 {
log::trace!("[event-loop] reclaimed {removed} idle gesture engine(s)");
}
}
if !idle_tasks.is_empty() {
#[cfg(not(alloc_frugal))]
let task_budget_start = std::time::Instant::now();
let task_count = idle_tasks.len();
let start_at = idle_task_cursor.load(Ordering::SeqCst) % task_count;
let mut offset = 0;
while offset < task_count {
#[cfg(not(alloc_frugal))]
if task_budget_start.elapsed().as_millis() >= 5 {
break;
}
let index = (start_at + offset) % task_count;
offset += 1;
let task = &mut idle_tasks[index];
let outcome =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| task.tick()));
match outcome {
Ok(true) => had_work = true,
Ok(false) => {}
Err(e) => {
log::error!("[event-loop] Idle task {} panicked: {e:?}", task.id)
}
}
}
idle_task_cursor.store((start_at + offset) % task_count, Ordering::SeqCst);
}
if !had_work {
std::thread::sleep(Duration::from_millis(1));
}
}
});
self.thread_handle = Some(handle);
}
#[cfg(alloc_frugal)]
pub fn start(&mut self) {
*lock(&self.running) = true;
}
#[cfg(alloc_frugal)]
pub fn pump_once(&mut self) -> bool {
if !self.is_running() {
return false;
}
self.timer_manager.pump();
let next = lock(&self.queue).dequeue_priority_first();
let Some((target, event, _priority)) = next else {
return false;
};
let id = animation_frame_id(&event);
let skip = match id {
Some(id) => lock(&self.cancelled_anim_frames).dispatched_and_cancelled(id),
None => false,
};
if skip {
return true;
}
if let Some(dispatch) = &self.dispatch_fn {
dispatch(target, &event);
}
true
}
#[cfg(not(alloc_frugal))]
pub fn stop(&mut self) {
*lock(&self.running) = false;
self.timer_manager.clear();
let _ =
self.sender.post(0, Event::Custom { name: "__stop_wake".to_string(), payload: vec![] });
if let Some(handle) = self.thread_handle.take() {
if let Err(e) = handle.join() {
log::error!("[event-loop] Thread join failed: {e:?}");
}
}
}
#[cfg(alloc_frugal)]
pub fn stop(&mut self) {
*lock(&self.running) = false;
self.timer_manager.clear();
}
pub fn post_event(
&self,
target: ObjectId,
event: Event,
priority: EventPriority,
) -> Result<(), String> {
self.sender.post_with_priority(target, event, priority)
}
pub fn request_animation_frame(
&self,
target: ObjectId,
) -> Result<AnimationFrameRequest, String> {
let id = self.next_anim_frame_id.fetch_add(1, Ordering::SeqCst);
lock(&self.cancelled_anim_frames).request(id);
let event = Event::Custom {
name: ANIMATION_FRAME_EVENT_NAME.to_string(),
payload: id.to_le_bytes().to_vec(),
};
if let Err(error) = self.post_event(target, event, EventPriority::Normal) {
lock(&self.cancelled_anim_frames).forget(id);
return Err(error);
}
Ok(AnimationFrameRequest { id })
}
pub fn cancel_animation_frame(&mut self, request: AnimationFrameRequest) -> bool {
lock(&self.cancelled_anim_frames).cancel(request.id)
}
pub fn take_cancelled_animation_frame(&self, id: u64) -> bool {
lock(&self.cancelled_anim_frames).dispatched_and_cancelled(id)
}
pub fn set_dispatch_fn(&mut self, f: EventDispatchFn) {
self.dispatch_fn = Some(f);
}
pub fn set_native_pump(&mut self, pump: Box<dyn Fn() + Send + Sync>) {
self.native_pump = Some(Arc::from(pump));
}
pub fn is_running(&self) -> bool {
*lock(&self.running)
}
pub fn start_timer(
&self,
target: ObjectId,
timer_id: u32,
interval: Duration,
repeating: bool,
) -> Result<(), String> {
self.timer_manager.start_timer(target, timer_id, interval, repeating)
}
pub fn stop_timer(&self, target: ObjectId, timer_id: u32) -> bool {
self.timer_manager.stop_timer(target, timer_id)
}
pub fn stop_timers_for_target(&self, target: ObjectId) -> usize {
self.timer_manager.stop_timers_for_target(target)
}
#[cfg(all(feature = "touch", not(alloc_frugal)))]
pub fn release_gesture_target(&self, target: ObjectId) {
lock(&self.gesture_releases).push(target);
}
}
crate::impl_default_via_new!(EventLoop);
#[cfg(test)]
mod tests {
use super::*;
use crate::event::types::Event;
use crate::event::EventPriority;
use crate::event::EventQueue;
#[cfg(not(alloc_frugal))]
use alloc::sync::Arc;
#[cfg(not(alloc_frugal))]
use core::sync::atomic::{AtomicUsize, Ordering};
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
use core::sync::atomic::AtomicBool;
#[test]
#[cfg(feature = "touch")]
fn a_timer_reaches_the_gesture_engine() {
use crate::core::Point;
assert!(
feeds_the_gesture_engine(&Event::Timer { id: 0 }),
"a timer must reach the engine, or a long press can never time out"
);
assert!(
feeds_the_gesture_engine(&Event::touch_begin(1, 1, 7)),
"touch input is what the recognisers follow"
);
assert!(!feeds_the_gesture_engine(&Event::KeyPress { key: 65, modifiers: 0 }));
assert!(!feeds_the_gesture_engine(&Event::Resize { size: crate::core::Size::new(10, 10) }));
assert!(!feeds_the_gesture_engine(&Event::Custom {
name: "x".to_string(),
payload: alloc::vec::Vec::new(),
}));
assert!(feeds_the_gesture_engine(&Event::LongPress { pos: Point::new(0, 0) }));
}
#[test]
#[cfg(not(alloc_frugal))]
fn registered_idle_task_runs_on_the_loop() {
let runs = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&runs);
let mut el = EventLoop::new();
assert!(el.add_idle_task(IdleTask::new(1, 2, move || {
counter.fetch_add(1, Ordering::SeqCst);
})));
assert_eq!(el.idle_task_count(), 1);
el.start();
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(600);
while runs.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
}
el.stop();
assert!(
runs.load(Ordering::SeqCst) > 0,
"a registered idle task must run; nothing was ticking it before"
);
}
#[test]
#[cfg(not(alloc_frugal))]
fn idle_task_registration_is_refused_once_running() {
let mut el = EventLoop::new();
el.start();
assert!(
!el.add_idle_task(IdleTask::new(7, 1, || {})),
"a task registered after start could never run, so it must be refused"
);
assert_eq!(el.idle_task_count(), 0);
el.stop();
}
#[test]
#[cfg(not(alloc_frugal))]
fn idle_task_ids_are_unique() {
let mut el = EventLoop::new();
assert!(el.add_idle_task(IdleTask::new(3, 1, || {})));
assert!(el.add_idle_task(IdleTask::new(3, 1, || {})));
assert_eq!(el.idle_task_count(), 1, "a duplicate id must replace, not accumulate");
assert!(el.remove_idle_task(3));
assert!(!el.remove_idle_task(3), "removing twice must report no-op");
assert_eq!(el.idle_task_count(), 0);
}
#[test]
fn test_event_queue_high_throughput() {
let queue = EventQueue::new();
let sender = queue.sender();
let target: ObjectId = 1;
for i in 0..1000 {
let bytes: [u8; 8] = (i as u64).to_le_bytes();
let event = Event::Custom { name: "test".to_string(), payload: bytes.to_vec() };
sender.post_with_priority(target, event, EventPriority::Normal).unwrap();
}
let mut count = 0;
while let Some((_, _, _)) = queue.dequeue() {
count += 1;
}
assert_eq!(count, 1000);
}
#[test]
fn test_event_queue_empty_drain() {
let queue = EventQueue::new();
assert!(queue.dequeue().is_none());
}
#[test]
fn test_event_priority_order() {
let queue = EventQueue::new();
let sender = queue.sender();
let target: ObjectId = 1;
let normal_event = Event::Custom { name: "normal".to_string(), payload: vec![] };
let high_event = Event::Custom { name: "high".to_string(), payload: vec![] };
let idle_event = Event::Custom { name: "idle".to_string(), payload: vec![] };
sender.post_with_priority(target, normal_event, EventPriority::Normal).unwrap();
sender.post_with_priority(target, high_event, EventPriority::High).unwrap();
sender.post_with_priority(target, idle_event, EventPriority::Idle).unwrap();
let mut events: Vec<EventPriority> = Vec::new();
while let Some((_, _, prio)) = queue.dequeue() {
events.push(prio);
}
assert_eq!(events.len(), 3);
assert_eq!(events[0], EventPriority::Normal);
assert_eq!(events[1], EventPriority::High);
assert_eq!(events[2], EventPriority::Idle);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn test_native_pump_called_on_empty_queue() {
let mut el = EventLoop::new();
let pump_called = Arc::new(AtomicBool::new(false));
let pump_called_clone = pump_called.clone();
el.set_native_pump(Box::new(move || {
pump_called_clone.store(true, Ordering::SeqCst);
}));
el.start();
#[cfg(not(alloc_frugal))]
std::thread::sleep(std::time::Duration::from_millis(50));
el.stop();
assert!(
pump_called.load(Ordering::SeqCst),
"native pump should have been called during loop iteration"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn test_event_loop_timer_integration() {
let mut el = EventLoop::new();
let timer_fired = Arc::new(AtomicBool::new(false));
let timer_fired_clone = timer_fired.clone();
el.set_dispatch_fn(Arc::new(move |_target, event| {
if matches!(event, Event::Timer { id: 1 }) {
timer_fired_clone.store(true, Ordering::SeqCst);
}
}));
el.start_timer(1u64, 1, Duration::from_millis(20), false).unwrap();
el.start();
#[cfg(not(alloc_frugal))]
std::thread::sleep(Duration::from_millis(150));
el.stop();
assert!(
timer_fired.load(Ordering::SeqCst),
"timer should have fired and been dispatched through the event loop"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn test_event_loop_animation_frame_dispatch() {
let mut el = EventLoop::new();
let anim_fired = Arc::new(AtomicBool::new(false));
let anim_fired_clone = anim_fired.clone();
el.set_dispatch_fn(Arc::new(move |_target, event| {
if let Event::Custom { name, .. } = event {
if name == ANIMATION_FRAME_EVENT_NAME {
anim_fired_clone.store(true, Ordering::SeqCst);
}
}
}));
el.request_animation_frame(1u64).unwrap();
el.start();
#[cfg(not(alloc_frugal))]
std::thread::sleep(Duration::from_millis(100));
el.stop();
assert!(
anim_fired.load(Ordering::SeqCst),
"animation frame event should have been dispatched through the event loop"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn test_cancelled_animation_frame_never_dispatches() {
let mut el = EventLoop::new();
let cancelled_fired = Arc::new(AtomicBool::new(false));
let kept_fired = Arc::new(AtomicBool::new(false));
let cancelled_flag = Arc::clone(&cancelled_fired);
let kept_flag = Arc::clone(&kept_fired);
let cancelled_id = Arc::new(AtomicU64::new(0));
let seen_id = Arc::clone(&cancelled_id);
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Some(id) = animation_frame_id(event) else { return };
if id == seen_id.load(Ordering::SeqCst) {
cancelled_flag.store(true, Ordering::SeqCst);
} else {
kept_flag.store(true, Ordering::SeqCst);
}
}));
let doomed = el.request_animation_frame(1u64).unwrap();
let kept = el.request_animation_frame(1u64).unwrap();
assert_ne!(doomed.id, kept.id, "each request carries its own id");
cancelled_id.store(doomed.id, Ordering::SeqCst);
assert!(el.cancel_animation_frame(doomed), "the first cancel reports the change");
assert!(
!el.cancel_animation_frame(doomed),
"a second cancel of the same request changes nothing, so it must not report `true`"
);
el.start();
std::thread::sleep(Duration::from_millis(100));
el.stop();
assert!(
!cancelled_fired.load(Ordering::SeqCst),
"a cancelled frame must never reach the dispatcher"
);
assert!(
kept_fired.load(Ordering::SeqCst),
"the un-cancelled frame in the same loop must still fire, or this test would pass \
for the wrong reason"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_event_loop_start_stop_idempotent() {
let mut el = EventLoop::new();
el.start();
assert!(el.is_running());
el.start();
assert!(el.is_running());
el.stop();
assert!(!el.is_running());
el.stop();
assert!(!el.is_running());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_event_loop_post_event_without_dispatch() {
let mut el = EventLoop::new();
el.start();
let result = el.post_event(
1u64,
Event::Custom { name: "orphan".to_string(), payload: vec![] },
EventPriority::Normal,
);
assert!(result.is_ok());
#[cfg(not(alloc_frugal))]
std::thread::sleep(Duration::from_millis(30));
el.stop();
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn cancel_is_refused_for_non_pending_handles() {
let mut el = EventLoop::new();
assert!(
!el.cancel_animation_frame(AnimationFrameRequest { id: 999 }),
"a forged id is not cancellable"
);
let other = EventLoop::new();
let foreign = other.request_animation_frame(1u64).unwrap();
assert!(!el.cancel_animation_frame(foreign), "a foreign loop's handle is not cancellable");
assert!(el.cancel_animation_frame(el.request_animation_frame(1).unwrap()));
assert!(el.cancel_animation_frame(el.request_animation_frame(1).unwrap()));
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn cancel_after_dispatch_reports_false() {
let mut el = EventLoop::new();
let dispatched = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&dispatched);
el.set_dispatch_fn(Arc::new(move |_target, event| {
if animation_frame_id(event).is_some() {
flag.store(true, Ordering::SeqCst);
}
}));
let handle = el.request_animation_frame(1u64).unwrap();
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(500);
while !dispatched.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
el.stop();
assert!(dispatched.load(Ordering::SeqCst), "the frame must have been dispatched first");
assert!(
!el.cancel_animation_frame(handle),
"a dispatched frame has nothing left to cancel, so the report must be false"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn a_consumed_cancellation_makes_later_cancels_false() {
let mut el = EventLoop::new();
let handle = el.request_animation_frame(1u64).unwrap();
assert!(el.cancel_animation_frame(handle), "the first cancel reports the change");
assert!(el.take_cancelled_animation_frame(handle.id));
assert!(
!el.cancel_animation_frame(handle),
"the cancellation was consumed, so a later cancel changes nothing"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn a_pending_cancel_prevents_the_callback() {
let mut el = EventLoop::new();
let fired = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&fired);
el.set_dispatch_fn(Arc::new(move |_target, event| {
if animation_frame_id(event).is_some() {
flag.store(true, Ordering::SeqCst);
}
}));
let handle = el.request_animation_frame(1u64).unwrap();
assert!(el.cancel_animation_frame(handle));
el.start();
std::thread::sleep(Duration::from_millis(100));
el.stop();
assert!(!fired.load(Ordering::SeqCst), "a cancelled pending frame must never dispatch");
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn a_slow_front_idle_task_does_not_starve_the_tail() {
let slow_runs = Arc::new(AtomicUsize::new(0));
let tail_runs = Arc::new(AtomicUsize::new(0));
let mut el = EventLoop::new();
let slow = Arc::clone(&slow_runs);
assert!(el.add_idle_task(IdleTask::new(1, 1, move || {
slow.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(8));
})));
let tail = Arc::clone(&tail_runs);
assert!(el.add_idle_task(IdleTask::new(2, 1, move || {
tail.fetch_add(1, Ordering::SeqCst);
})));
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(1_500);
while tail_runs.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
el.stop();
assert!(
tail_runs.load(Ordering::SeqCst) > 0,
"the task behind a budget-burning head task must eventually run"
);
assert!(slow_runs.load(Ordering::SeqCst) > 0, "the slow task still runs too");
}
#[cfg(not(alloc_frugal))]
#[test]
fn the_anim_frame_state_tracks_pending_and_cancelled() {
let mut state = AnimFrameState::default();
state.request(1);
assert!(state.cancel(1), "a pending id can be cancelled");
assert!(!state.cancel(1), "a second cancel of the same id changes nothing");
assert!(state.dispatched_and_cancelled(1), "a cancelled pending frame is skipped");
assert!(!state.dispatched_and_cancelled(1), "the cancellation was consumed");
state.request(2);
assert!(!state.dispatched_and_cancelled(2), "an uncancelled dispatch is not skipped");
assert!(!state.cancel(2), "a dispatched id is no longer pending");
}
#[cfg(not(alloc_frugal))]
#[test]
fn a_frame_dispatched_before_cancel_leaves_no_orphan_entry() {
let mut el = EventLoop::new();
let handle = el.request_animation_frame(1u64).unwrap();
{
let state = lock(&el.cancelled_anim_frames);
assert!(
state.pending.contains(&handle.id),
"the id must be pending immediately after the request returns"
);
}
assert!(
!el.take_cancelled_animation_frame(handle.id),
"an un-cancelled frame is not skipped at dispatch"
);
assert!(
!el.cancel_animation_frame(handle),
"a frame dispatched before the cancel must report `false`"
);
let state = lock(&el.cancelled_anim_frames);
assert!(state.pending.is_empty(), "no pending entry may survive a dispatched frame");
assert!(
state.cancelled.is_empty(),
"no orphan cancellation entry may be recorded for a dispatched frame"
);
}
#[cfg(not(alloc_frugal))]
#[test]
fn a_request_dispatch_cancel_sequence_leaves_the_sets_empty() {
let mut el = EventLoop::new();
for _ in 0..512 {
let handle = el.request_animation_frame(1u64).unwrap();
assert!(!el.take_cancelled_animation_frame(handle.id));
assert!(!el.cancel_animation_frame(handle));
}
let state = lock(&el.cancelled_anim_frames);
assert!(state.pending.is_empty(), "no pending entries may accumulate");
assert!(state.cancelled.is_empty(), "no cancelled entries may accumulate");
}
#[cfg(not(alloc_frugal))]
#[test]
fn a_cancel_before_dispatch_is_honoured_and_consumed() {
let mut el = EventLoop::new();
let handle = el.request_animation_frame(1u64).unwrap();
assert!(el.cancel_animation_frame(handle), "a still-pending frame can be cancelled");
assert!(
el.take_cancelled_animation_frame(handle.id),
"the dispatch must skip the cancelled frame"
);
let state = lock(&el.cancelled_anim_frames);
assert!(state.pending.is_empty(), "the cancelled id is no longer pending");
assert!(state.cancelled.is_empty(), "the cancellation was consumed by dispatch");
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn concurrent_request_dispatch_cancel_converges_to_empty_state() {
let dispatched = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&dispatched);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
if animation_frame_id(event).is_some() {
counter.fetch_add(1, Ordering::SeqCst);
}
}));
el.start();
for i in 0..2_000u64 {
let request = el.request_animation_frame(1u64).unwrap();
if i % 2 == 0 {
let _ = el.cancel_animation_frame(request);
}
}
let deadline = std::time::Instant::now() + Duration::from_millis(1_000);
while std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
el.stop();
let state = lock(&el.cancelled_anim_frames);
assert!(
state.pending.is_empty(),
"every request must be resolved to dispatched or cancelled; {} still pending",
state.pending.len()
);
assert!(
state.cancelled.is_empty(),
"every recorded cancellation must be consumed by a dispatch; {} orphaned",
state.cancelled.len()
);
assert!(
dispatched.load(Ordering::SeqCst) > 0,
"the loop must actually have dispatched frames, or the race was never exercised"
);
}
#[cfg(alloc_frugal)]
#[test]
fn pump_once_honours_cancellation() {
use core::sync::atomic::AtomicBool;
let mut el = EventLoop::new();
let fired = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&fired);
el.set_dispatch_fn(Arc::new(move |_target, event| {
if animation_frame_id(event).is_some() {
flag.store(true, Ordering::SeqCst);
}
}));
el.start();
let handle = el.request_animation_frame(1).unwrap();
assert!(el.cancel_animation_frame(handle));
assert!(el.pump_once(), "a cancelled frame is consumed work");
assert!(!fired.load(Ordering::SeqCst), "a cancelled frame must not dispatch in mini");
el.request_animation_frame(1).unwrap();
assert!(el.pump_once());
assert!(fired.load(Ordering::SeqCst), "an uncancelled frame still dispatches");
}
#[cfg(alloc_frugal)]
#[test]
fn pump_once_dispatches_by_priority() {
use std::sync::Mutex;
let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&order);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Event::Custom { name, .. } = event else { return };
let label = match name.as_str() {
"idle" => "idle",
"normal" => "normal",
"high" => "high",
_ => return,
};
log.lock().unwrap().push(label);
}));
el.start();
el.post_event(
1,
Event::Custom { name: "normal".to_string(), payload: vec![] },
EventPriority::Normal,
)
.unwrap();
el.post_event(
1,
Event::Custom { name: "idle".to_string(), payload: vec![] },
EventPriority::Idle,
)
.unwrap();
el.post_event(
1,
Event::Custom { name: "high".to_string(), payload: vec![] },
EventPriority::High,
)
.unwrap();
assert!(el.pump_once());
assert!(el.pump_once());
assert!(el.pump_once());
assert!(!el.pump_once(), "the queue is empty after three events");
assert_eq!(
*order.lock().unwrap(),
vec!["high", "normal", "idle"],
"mini must dispatch High before Normal before Idle"
);
order.lock().unwrap().clear();
el.post_event(
1,
Event::Custom { name: "high".to_string(), payload: vec![1] },
EventPriority::High,
)
.unwrap();
el.post_event(
1,
Event::Custom { name: "high".to_string(), payload: vec![2] },
EventPriority::High,
)
.unwrap();
assert!(el.pump_once());
assert!(el.pump_once());
assert_eq!(order.lock().unwrap().len(), 2, "both High events are dispatched");
}
#[test]
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn gesture_state_is_scoped_to_its_interaction_target() {
let mut registry = GestureRegistry::new();
let _ = registry.process(1, &Event::touch_begin(10, 10, 1), 0);
let first = registry.process(1, &Event::touch_end(10, 10, 1), 100);
assert!(matches!(first, Some(Event::Tap { .. })), "a single tap on target 1 is a Tap");
let _ = registry.process(2, &Event::touch_begin(10, 10, 2), 200);
let second = registry.process(2, &Event::touch_end(10, 10, 2), 250);
assert!(
!matches!(second, Some(Event::DoubleTap { .. })),
"a first tap on target 2 must not inherit target 1's tap"
);
assert!(matches!(second, Some(Event::Tap { .. })), "target 2's own tap is still a Tap");
let _ = registry.process(2, &Event::touch_begin(10, 10, 3), 300);
let third = registry.process(2, &Event::touch_end(10, 10, 3), 350);
assert!(
matches!(third, Some(Event::DoubleTap { .. })),
"two taps on the same target still complete a double tap"
);
}
#[test]
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn many_distinct_targets_are_reclaimed_after_they_go_quiet() {
let mut registry = GestureRegistry::new();
let touched_at = 1_000u64;
for target in 0..1000u64 {
let _ = registry.process(target, &Event::touch_begin(1, 1, target), touched_at);
let _ = registry.process(target, &Event::touch_end(1, 1, target), touched_at);
}
assert_eq!(registry.len(), 1000, "every target touched so far holds an engine");
let within_grace = touched_at + GestureRegistry::idle_grace();
assert_eq!(registry.sweep(within_grace), 0, "a target within the grace must be kept");
assert_eq!(registry.len(), 1000);
let past_grace = touched_at + GestureRegistry::idle_grace() + 1;
assert_eq!(registry.sweep(past_grace), 1000, "quiet targets must be reclaimed");
assert_eq!(registry.len(), 0, "no orphan gesture engine may remain");
}
#[test]
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn release_drops_only_the_named_target() {
let mut registry = GestureRegistry::new();
let _ = registry.process(1, &Event::touch_begin(0, 0, 1), 0);
let _ = registry.process(2, &Event::touch_begin(0, 0, 2), 0);
assert_eq!(registry.len(), 2);
assert!(registry.release(1), "releasing a present target reports true");
assert_eq!(registry.len(), 1, "only the named target is dropped");
assert!(!registry.release(1), "releasing an absent target reports false");
assert_eq!(registry.len(), 1);
}
#[test]
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn a_touch_cancel_drops_the_engine_and_produces_no_gesture() {
let mut registry = GestureRegistry::new();
let _ = registry.process(7, &Event::touch_begin(10, 10, 1), 0);
assert_eq!(registry.len(), 1);
let cancel = crate::event::translator::touch_cancel(crate::core::Point::new(10, 10), 1);
let produced = registry.process(7, &cancel, 50);
assert!(produced.is_none(), "a cancel must not produce a completed gesture");
assert_eq!(registry.len(), 0, "the cancelled target's engine must be dropped");
let after = registry.process(7, &Event::touch_end(10, 10, 1), 60);
assert!(after.is_none(), "an end after a cancel must not synthesise a tap");
}
#[test]
#[cfg(all(feature = "touch", not(alloc_frugal)))]
fn double_tap_still_works_across_a_sweep_within_the_grace() {
let mut registry = GestureRegistry::new();
let _ = registry.process(5, &Event::touch_begin(10, 10, 1), 0);
let first = registry.process(5, &Event::touch_end(10, 10, 1), 50);
assert!(matches!(first, Some(Event::Tap { .. })));
assert_eq!(registry.sweep(200), 0);
let _ = registry.process(5, &Event::touch_begin(10, 10, 2), 250);
let second = registry.process(5, &Event::touch_end(10, 10, 2), 300);
assert!(
matches!(second, Some(Event::DoubleTap { .. })),
"double-tap must survive a sweep that happens within the idle grace"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn a_burst_larger_than_the_drain_cap_is_delivered_in_order_without_loss() {
const BURST: usize = PER_TURN_EVENT_DRAIN_CAP * 3 + 7;
let delivered = Arc::new(std::sync::Mutex::new(Vec::<usize>::new()));
let order = Arc::clone(&delivered);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Event::Custom { name, payload } = event else { return };
if name != "burst" {
return;
}
let index = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
order.lock().unwrap().push(index);
}));
for index in 0..BURST {
el.post_event(
1,
Event::Custom {
name: "burst".to_string(),
payload: (index as u32).to_le_bytes().to_vec(),
},
EventPriority::Normal,
)
.unwrap();
}
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(5_000);
while delivered.lock().unwrap().len() < BURST && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
el.stop();
let seen = delivered.lock().unwrap().clone();
assert_eq!(seen.len(), BURST, "every event in the burst must be delivered");
assert_eq!(
seen,
(0..BURST).collect::<Vec<_>>(),
"deferring past the drain cap must preserve FIFO order"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn a_high_event_behind_a_normal_backlog_is_delivered_within_bounded_turns() {
let saw_high = Arc::new(AtomicBool::new(false));
let saw_high_flag = Arc::clone(&saw_high);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Event::Custom { name, .. } = event else { return };
if name == "high" {
saw_high_flag.store(true, Ordering::SeqCst);
}
}));
let backlog = PER_TURN_EVENT_DRAIN_CAP * 4;
for _ in 0..backlog {
el.post_event(
1,
Event::Custom { name: "normal".to_string(), payload: vec![] },
EventPriority::Normal,
)
.unwrap();
}
el.post_event(
1,
Event::Custom { name: "high".to_string(), payload: vec![] },
EventPriority::High,
)
.unwrap();
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(3_000);
while !saw_high.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
el.stop();
assert!(
saw_high.load(Ordering::SeqCst),
"a High event must not be starved: it was not delivered within the bounded deadline"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn high_events_dispatch_before_normal_events_in_the_same_turn() {
let order = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
let log = Arc::clone(&order);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Event::Custom { name, .. } = event else { return };
let mut guard = log.lock().unwrap();
if name == "h" && guard.iter().filter(|entry| **entry == "h").count() < 4 {
guard.push("h");
} else if name == "n" && guard.iter().filter(|entry| **entry == "n").count() < 4 {
guard.push("n");
}
}));
for i in 0..4 {
el.post_event(
1,
Event::Custom { name: "n".to_string(), payload: vec![i] },
EventPriority::Normal,
)
.unwrap();
el.post_event(
1,
Event::Custom { name: "h".to_string(), payload: vec![i] },
EventPriority::High,
)
.unwrap();
}
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(2_000);
while order.lock().unwrap().len() < 8 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
el.stop();
let seen = order.lock().unwrap().clone();
let first_four = &seen[..seen.len().min(4)];
assert!(
first_four.iter().all(|entry| *entry == "h"),
"the High phase must run before the Normal phase; observed {seen:?}"
);
}
#[cfg(all(not(alloc_frugal), not(target_arch = "wasm32")))]
#[test]
fn idle_events_beyond_the_budget_are_delivered_later() {
let delivered = Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let delivered_clone = Arc::clone(&delivered);
let mut el = EventLoop::new();
el.set_dispatch_fn(Arc::new(move |_target, event| {
let Event::Custom { name, payload } = event else { return };
if name != "idle" {
return;
}
let index = payload.first().copied().unwrap_or(0);
let first = {
let mut order = delivered_clone.lock().unwrap();
order.push(index);
order.len() == 1
};
if first {
std::thread::sleep(Duration::from_millis(8));
}
}));
for index in 0u8..3 {
el.post_event(
1,
Event::Custom { name: "idle".to_string(), payload: vec![index] },
EventPriority::Idle,
)
.unwrap();
}
el.start();
let deadline = std::time::Instant::now() + Duration::from_millis(1_500);
while delivered.lock().unwrap().len() < 3 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
el.stop();
assert_eq!(
*delivered.lock().unwrap(),
vec![0, 1, 2],
"idle events beyond the budget are delivered later, in order, not dropped"
);
}
}