use std::collections::HashMap;
use std::ops::Range;
use std::time::{Duration, Instant};
use teksilo_canvas::AnimParams;
use teksilo_tokens::{Color, Easing};
use crate::arena::WidgetArena;
use crate::color_prop::ColorProp;
use crate::styles::Theme;
use crate::widget_id::WidgetId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnimatedQuadHandle {
slot: u32,
}
impl AnimatedQuadHandle {
pub fn slot(self) -> u32 {
self.slot
}
}
#[derive(Debug, Clone)]
pub enum AnimatedQuadKind {
IndeterminateSweep {
period: Duration,
sweep_ratio: f32,
track_color: ColorProp,
fill_color: ColorProp,
},
SpriteCycle {
image_name: String,
frame_count: u32,
cols: u32,
rows: u32,
period: Duration,
tint: Option<ColorProp>,
},
SpinnerArc {
period: Duration,
arc_fraction: f32,
stroke_fraction: f32,
color: ColorProp,
},
}
struct AnimatedQuadEntry {
owner: WidgetId,
kind: AnimatedQuadKind,
started_at: Instant,
#[allow(dead_code)]
paused_at: Option<Instant>,
}
pub struct AnimatedQuadRegistry {
entries: HashMap<u32, AnimatedQuadEntry>,
owners: HashMap<WidgetId, Vec<u32>>,
epoch: Option<Instant>,
free_slots: Vec<u32>,
next_slot: u32,
scratch: Vec<AnimParams>,
window_active: bool,
paused_at: Option<Instant>,
last_tick_at: Option<Instant>,
frame_interval: Duration,
dirty: Vec<bool>,
}
const DEFAULT_SHADER_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
impl AnimatedQuadRegistry {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
owners: HashMap::new(),
epoch: None,
free_slots: Vec::new(),
next_slot: 0,
scratch: Vec::new(),
window_active: true,
paused_at: None,
last_tick_at: None,
frame_interval: DEFAULT_SHADER_FRAME_INTERVAL,
dirty: Vec::new(),
}
}
pub fn register(
&mut self,
owner: WidgetId,
kind: AnimatedQuadKind,
now: Instant,
) -> AnimatedQuadHandle {
let slot = self.free_slots.pop().unwrap_or_else(|| {
let s = self.next_slot;
self.next_slot = self.next_slot.saturating_add(1);
s
});
let started_at = *self.epoch.get_or_insert(now);
self.entries.insert(
slot,
AnimatedQuadEntry {
owner,
kind,
started_at,
paused_at: None,
},
);
self.owners.entry(owner).or_default().push(slot);
if (slot as usize) >= self.scratch.len() {
self.scratch
.resize((slot as usize) + 1, AnimParams::default());
}
if (slot as usize) >= self.dirty.len() {
self.dirty.resize((slot as usize) + 1, false);
}
self.dirty[slot as usize] = true;
AnimatedQuadHandle { slot }
}
pub fn cancel_by_widget(&mut self, widget_id: WidgetId) {
if let Some(slots) = self.owners.remove(&widget_id) {
for slot in slots {
self.entries.remove(&slot);
self.free_slots.push(slot);
}
}
}
pub fn set_window_active(&mut self, active: bool, now: Instant) {
if self.window_active == active {
return;
}
if active {
if let Some(paused_at) = self.paused_at.take() {
let offset = now.saturating_duration_since(paused_at);
for entry in self.entries.values_mut() {
entry.started_at += offset;
}
if let Some(epoch) = self.epoch.as_mut() {
*epoch += offset;
}
}
} else {
self.paused_at = Some(now);
}
self.window_active = active;
}
pub fn is_window_active(&self) -> bool {
self.window_active
}
pub fn active_count(&self) -> usize {
self.entries.len()
}
pub fn params_capacity(&self) -> usize {
self.scratch.len()
}
pub fn scratch_slice(&self) -> &[AnimParams] {
&self.scratch
}
pub fn tick(
&mut self,
now: Instant,
arena: &WidgetArena,
paint_epoch: u64,
theme: &Theme,
) -> &[AnimParams] {
if !self.window_active {
return &self.scratch;
}
if self.scratch.len() < self.next_slot as usize {
self.scratch
.resize(self.next_slot as usize, AnimParams::default());
}
if self.dirty.len() < self.scratch.len() {
self.dirty.resize(self.scratch.len(), false);
}
for (&slot, entry) in self.entries.iter() {
if !widget_visible(arena, entry.owner, paint_epoch) {
continue;
}
let params = compute_params(entry, now, theme);
let i = slot as usize;
if self.scratch[i] != params {
self.scratch[i] = params;
self.dirty[i] = true;
}
}
self.last_tick_at = Some(now);
&self.scratch
}
pub fn take_dirty_ranges(&mut self) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut start: Option<usize> = None;
for (i, &dirty) in self.dirty.iter().enumerate() {
match (dirty, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
ranges.push(s..i);
start = None;
}
_ => {}
}
}
if let Some(s) = start {
ranges.push(s..self.dirty.len());
}
for slot in self.dirty.iter_mut() {
*slot = false;
}
ranges
}
pub fn has_running(&self) -> bool {
self.window_active && !self.entries.is_empty()
}
pub fn next_deadline(&self, arena: &WidgetArena, paint_epoch: u64) -> Option<Instant> {
if !self.window_active {
return None;
}
let any_visible = self
.entries
.values()
.any(|entry| widget_visible(arena, entry.owner, paint_epoch));
if !any_visible {
return None;
}
Some(match self.last_tick_at {
Some(t) => t + self.frame_interval,
None => Instant::now(),
})
}
}
impl Default for AnimatedQuadRegistry {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for AnimatedQuadRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnimatedQuadRegistry")
.field("active_count", &self.entries.len())
.field("capacity", &self.scratch.len())
.field("window_active", &self.window_active)
.finish()
}
}
use crate::motion_visibility::painted_this_frame as widget_visible;
fn compute_params(entry: &AnimatedQuadEntry, now: Instant, theme: &Theme) -> AnimParams {
match &entry.kind {
AnimatedQuadKind::IndeterminateSweep {
period,
sweep_ratio,
track_color,
fill_color,
} => {
let phase = looping_phase(entry.started_at, now, *period, Easing::Linear);
AnimParams {
kind: 0,
phase,
sweep_ratio: *sweep_ratio,
color0: color_to_rgba(&track_color.resolve(theme, true)),
color1: color_to_rgba(&fill_color.resolve(theme, true)),
..AnimParams::default()
}
}
AnimatedQuadKind::SpriteCycle {
period,
frame_count,
cols,
rows,
tint,
..
} => {
let t = looping_phase(entry.started_at, now, *period, Easing::Linear);
let frame_index = (t * *frame_count as f32)
.floor()
.min((*frame_count - 1) as f32);
let tint_rgba = tint
.as_ref()
.map(|c| color_to_rgba(&c.resolve(theme, true)))
.unwrap_or([0.0; 4]);
AnimParams {
kind: 1,
phase: frame_index,
color1: tint_rgba,
atlas_cols: *cols as f32,
atlas_rows: *rows as f32,
..AnimParams::default()
}
}
AnimatedQuadKind::SpinnerArc {
period,
arc_fraction,
stroke_fraction,
color,
} => {
let phase = looping_phase(entry.started_at, now, *period, Easing::Linear);
AnimParams {
kind: 2,
phase,
sweep_ratio: arc_fraction.clamp(0.0, 1.0),
_pad0: stroke_fraction.clamp(0.0, 0.5),
color1: color_to_rgba(&color.resolve(theme, true)),
..AnimParams::default()
}
}
}
}
fn looping_phase(started_at: Instant, now: Instant, period: Duration, easing: Easing) -> f32 {
if period.is_zero() {
return 0.0;
}
let elapsed = now.saturating_duration_since(started_at).as_secs_f32();
let period_s = period.as_secs_f32();
let t = (elapsed % period_s) / period_s;
easing.apply(t)
}
fn color_to_rgba(c: &Color) -> [f32; 4] {
[c.r(), c.g(), c.b(), c.a()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arena::WidgetArena;
use crate::test_widgets::FillWidget;
use teksilo_tokens::SurfaceRole;
fn arena_with(n: usize) -> (WidgetArena, Vec<WidgetId>) {
let mut arena = WidgetArena::new();
let ids = (0..n)
.map(|_| arena.insert(Box::new(FillWidget::new())))
.collect();
(arena, ids)
}
fn sweep_kind() -> AnimatedQuadKind {
AnimatedQuadKind::IndeterminateSweep {
period: Duration::from_millis(100),
sweep_ratio: 0.42,
track_color: SurfaceRole::Sunken.into(),
fill_color: SurfaceRole::Accent.into(),
}
}
#[test]
fn register_allocates_unique_slots() {
let mut reg = AnimatedQuadRegistry::new();
let (_arena, ids) = arena_with(3);
let now = Instant::now();
let h0 = reg.register(ids[0], sweep_kind(), now);
let h1 = reg.register(ids[1], sweep_kind(), now);
let h2 = reg.register(ids[2], sweep_kind(), now);
assert_ne!(h0.slot(), h1.slot());
assert_ne!(h1.slot(), h2.slot());
assert_eq!(reg.active_count(), 3);
}
#[test]
fn cancel_by_widget_frees_slots() {
let mut reg = AnimatedQuadRegistry::new();
let (_arena, ids) = arena_with(2);
let now = Instant::now();
let h0 = reg.register(ids[0], sweep_kind(), now);
let h1 = reg.register(ids[1], sweep_kind(), now);
assert_eq!(reg.active_count(), 2);
reg.cancel_by_widget(ids[0]);
assert_eq!(reg.active_count(), 1);
let h2 = reg.register(ids[1], sweep_kind(), now);
assert_eq!(h2.slot(), h0.slot(), "freed slot should be reused");
assert_ne!(h2.slot(), h1.slot());
}
#[test]
fn tick_writes_phase_for_visible_widget() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
let theme = crate::presets::intui::light();
let h = reg.register(ids[0], sweep_kind(), start);
let params = reg.tick(start + Duration::from_millis(50), &arena, 0, &theme);
let p = params[h.slot() as usize];
assert_eq!(p.kind, 0);
assert!(
(p.phase - 0.5).abs() < 0.01,
"phase at 50% should be ~0.5, got {}",
p.phase
);
assert!((p.sweep_ratio - 0.42).abs() < 1e-6);
assert!(p.color1[3] > 0.0);
}
#[test]
fn tick_skips_offscreen_widgets() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
let theme = crate::presets::intui::light();
let h = reg.register(ids[0], sweep_kind(), start);
let params = reg.tick(start + Duration::from_millis(50), &arena, 5, &theme);
let p = params[h.slot() as usize];
assert_eq!(
p.phase, 0.0,
"offscreen widget must not have its phase updated"
);
}
#[test]
fn window_inactive_is_noop() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
let theme = crate::presets::intui::light();
let h = reg.register(ids[0], sweep_kind(), start);
reg.set_window_active(false, start);
let params = reg.tick(start + Duration::from_millis(50), &arena, 0, &theme);
let p = params[h.slot() as usize];
assert_eq!(p.phase, 0.0, "paused tick must not advance phase");
}
#[test]
fn resume_rebases_phase_continuously() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
let theme = crate::presets::intui::light();
let h = reg.register(ids[0], sweep_kind(), start);
let params = reg.tick(start + Duration::from_millis(25), &arena, 0, &theme);
assert!((params[h.slot() as usize].phase - 0.25).abs() < 0.02);
reg.set_window_active(false, start + Duration::from_millis(25));
let resume_at = start + Duration::from_millis(25) + Duration::from_secs(10);
reg.set_window_active(true, resume_at);
let params = reg.tick(resume_at + Duration::from_millis(25), &arena, 0, &theme);
let p = params[h.slot() as usize].phase;
assert!(
(p - 0.5).abs() < 0.02,
"phase-continuous resume expected ~0.5, got {p}"
);
}
#[test]
fn next_deadline_advances_on_each_tick() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let theme = crate::presets::intui::light();
let start = Instant::now();
reg.register(ids[0], sweep_kind(), start);
let d0 = reg.next_deadline(&arena, 0).expect("must be scheduled");
assert!(
d0 <= Instant::now() + Duration::from_millis(1),
"first deadline should be ~now, got {:?} from now",
d0.saturating_duration_since(Instant::now())
);
let interval = Duration::from_micros(16_667);
reg.tick(start, &arena, 0, &theme);
let d1 = reg.next_deadline(&arena, 0).expect("must stay scheduled");
assert_eq!(
d1,
start + interval,
"deadline must advance by frame_interval after each tick"
);
reg.tick(start + interval, &arena, 0, &theme);
let d2 = reg.next_deadline(&arena, 0).expect("still scheduled");
assert_eq!(d2, start + 2 * interval);
}
#[test]
fn next_deadline_none_when_window_inactive() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
reg.register(ids[0], sweep_kind(), start);
reg.set_window_active(false, start);
assert!(
reg.next_deadline(&arena, 0).is_none(),
"paused registry must not contribute to next_timer_deadline"
);
}
#[test]
fn next_deadline_none_when_all_entries_offscreen() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let start = Instant::now();
reg.register(ids[0], sweep_kind(), start);
assert!(
reg.next_deadline(&arena, 5).is_none(),
"offscreen-only registry must not keep the event loop awake"
);
}
#[test]
fn register_marks_slot_dirty() {
let mut reg = AnimatedQuadRegistry::new();
let (_arena, ids) = arena_with(1);
let now = Instant::now();
reg.register(ids[0], sweep_kind(), now);
let ranges = reg.take_dirty_ranges();
assert_eq!(ranges.len(), 1, "newly registered slot must be dirty");
assert_eq!(ranges[0], 0..1);
assert!(reg.take_dirty_ranges().is_empty());
}
#[test]
fn tick_unchanged_params_does_not_dirty() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let theme = crate::presets::intui::light();
let now = Instant::now();
reg.register(ids[0], sweep_kind(), now);
reg.tick(now, &arena, 0, &theme);
let _ = reg.take_dirty_ranges();
reg.tick(now, &arena, 0, &theme);
assert!(
reg.take_dirty_ranges().is_empty(),
"tick with unchanged params must not flip dirty"
);
}
#[test]
fn tick_changed_params_marks_dirty() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(1);
let theme = crate::presets::intui::light();
let start = Instant::now();
reg.register(ids[0], sweep_kind(), start);
let _ = reg.take_dirty_ranges();
reg.tick(start + Duration::from_millis(25), &arena, 0, &theme);
let ranges = reg.take_dirty_ranges();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 0..1);
}
#[test]
fn take_dirty_ranges_coalesces_contiguous_slots() {
let mut reg = AnimatedQuadRegistry::new();
let (_arena, ids) = arena_with(3);
let now = Instant::now();
reg.register(ids[0], sweep_kind(), now);
reg.register(ids[1], sweep_kind(), now);
reg.register(ids[2], sweep_kind(), now);
let ranges = reg.take_dirty_ranges();
assert_eq!(
ranges,
vec![0..3],
"three contiguous dirty slots must coalesce into one range"
);
}
#[test]
fn take_dirty_ranges_splits_non_contiguous() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(3);
let theme = crate::presets::intui::light();
let start = Instant::now();
reg.register(ids[0], sweep_kind(), start);
reg.register(ids[1], sweep_kind(), start);
reg.register(ids[2], sweep_kind(), start);
let _ = reg.take_dirty_ranges();
reg.cancel_by_widget(ids[1]);
reg.tick(start + Duration::from_millis(25), &arena, 0, &theme);
let ranges = reg.take_dirty_ranges();
assert_eq!(ranges, vec![0..1, 2..3]);
}
#[test]
fn rebuild_pattern_frees_and_reallocates() {
let mut reg = AnimatedQuadRegistry::new();
let (_arena, ids) = arena_with(1);
let now = Instant::now();
let h0 = reg.register(ids[0], sweep_kind(), now);
let cap_before = reg.params_capacity();
reg.cancel_by_widget(ids[0]);
let h1 = reg.register(ids[0], sweep_kind(), now);
assert_eq!(h0.slot(), h1.slot(), "slot should be reused from free list");
assert_eq!(reg.params_capacity(), cap_before);
assert_eq!(reg.active_count(), 1);
}
#[test]
fn recreated_quad_continues_phase_from_shared_epoch() {
let mut reg = AnimatedQuadRegistry::new();
let (arena, ids) = arena_with(2);
let theme = crate::presets::intui::light();
let start = Instant::now();
let h0 = reg.register(ids[0], sweep_kind(), start); let later = start + Duration::from_millis(25); let h1 = reg.register(ids[1], sweep_kind(), later);
let params = reg.tick(later, &arena, 0, &theme);
let p0 = params[h0.slot() as usize].phase;
let p1 = params[h1.slot() as usize].phase;
assert!(
(p1 - 0.25).abs() < 0.02,
"recreated quad must read the current phase ~0.25, got {p1}"
);
assert!(
(p0 - p1).abs() < 0.001,
"both quads share the epoch phase clock ({p0} vs {p1})"
);
}
}