Skip to main content

omp_tui/
anim.rs

1//! Time-driven animation primitives for retained and immediate paints.
2//!
3//! Everything here is a pure function of a caller-supplied clock — a
4//! [`Duration`](std::time::Duration) since an arbitrary epoch — so animated
5//! paints stay deterministic and testable. Retained components read the clock
6//! from [`crate::PaintCtx::now`] and request a future repaint with
7//! [`crate::PaintCtx::wake`]; immediate-mode painters call the same types
8//! with their own elapsed time.
9//!
10//! Three shapes cover terminal animation:
11//! - [`Frames`](crate::anim::Frames): periodic glyph cycles (spinners, pulses)
12//!   that run while a state holds and never finish.
13//! - [`Tween`](crate::anim::Tween): finite eased interpolations (color fades,
14//!   progress smoothing) that settle, and can be retargeted mid-flight without
15//!   a visual jump.
16//! - [`Reveal`](crate::anim::Reveal): a paced cursor chasing a growing unit
17//!   total (streamed text), catching up smoothly and settling once even.
18//!
19//! Components get all of this declaratively: the `anim`, `ease`, and `spin`
20//! properties tween `fg`/`bg`/`bc` colors, gradient endpoints, `w`/`h`
21//! sizes, and gradient rotation on any component without custom paint code
22//! — see [`crate::Props::anim`], [`crate::Props::ease`], and
23//! [`crate::Props::spin`]. The `shimmer` property sweeps a
24//! [`Shimmer`](crate::anim::Shimmer) brightness crest across `<text>`, the
25//! `reveal` property paces streamed `<text>` content through a [`Reveal`]
26//! cursor, and the `hover` and `lift` properties ride the same clock:
27//! pointer-driven border chrome and elevation ease through their declared
28//! `anim`/`ease`, and snap without one.
29
30use std::time::Duration;
31
32use crate::frame::{Color, Style};
33
34/// Repaint cadence for continuously changing values (mid-flight tweens,
35/// gradient spins): fast enough to read as motion in a terminal, slow
36/// enough to stay cheap. Pass `now + FRAME` to [`crate::PaintCtx::wake`].
37pub const FRAME: Duration = Duration::from_millis(33);
38
39/// Easing curve applied to a tween's normalized progress.
40#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
41pub enum Easing {
42	/// Constant velocity.
43	#[default]
44	Linear,
45	/// Cubic acceleration from rest.
46	EaseIn,
47	/// Cubic deceleration to rest.
48	EaseOut,
49	/// Cubic acceleration, then deceleration.
50	EaseInOut,
51}
52
53impl Easing {
54	/// Maps linear progress onto the eased curve; both ends clamp to `[0, 1]`.
55	#[must_use]
56	pub fn apply(self, t: f32) -> f32 {
57		let t = t.clamp(0.0, 1.0);
58		match self {
59			Self::Linear => t,
60			Self::EaseIn => t * t * t,
61			Self::EaseOut => {
62				let inv = 1.0 - t;
63				inv.mul_add(-inv * inv, 1.0)
64			},
65			Self::EaseInOut if t < 0.5 => 4.0 * t * t * t,
66			Self::EaseInOut => {
67				let inv = (-2.0f32).mul_add(t, 2.0);
68				inv.mul_add(-inv * inv / 2.0, 1.0)
69			},
70		}
71	}
72}
73
74/// Values a [`Tween`] can interpolate.
75pub trait Lerp: Copy {
76	/// Blends from `self` toward `to` at eased progress `t` in `[0, 1]`.
77	#[must_use]
78	fn lerp(self, to: Self, t: f32) -> Self;
79}
80
81impl Lerp for f32 {
82	fn lerp(self, to: Self, t: f32) -> Self {
83		(to - self).mul_add(t, self)
84	}
85}
86
87impl Lerp for u8 {
88	fn lerp(self, to: Self, t: f32) -> Self {
89		f32::from(self).lerp(f32::from(to), t).round() as Self
90	}
91}
92
93impl Lerp for u16 {
94	fn lerp(self, to: Self, t: f32) -> Self {
95		f32::from(self).lerp(f32::from(to), t).round() as Self
96	}
97}
98
99/// RGB endpoints blend per channel; palette and default endpoints have no
100/// interpolable space, so they snap at the halfway point.
101impl Lerp for Color {
102	fn lerp(self, to: Self, t: f32) -> Self {
103		match (self, to) {
104			(Self::Rgb(r0, g0, b0), Self::Rgb(r1, g1, b1)) => {
105				Self::Rgb(r0.lerp(r1, t), g0.lerp(g1, t), b0.lerp(b1, t))
106			},
107			_ if t < 0.5 => self,
108			_ => to,
109		}
110	}
111}
112
113/// Pairs blend componentwise on one shared eased clock — the natural shape
114/// for two-stop color ramps.
115impl<A: Lerp, B: Lerp> Lerp for (A, B) {
116	fn lerp(self, to: Self, t: f32) -> Self {
117		(self.0.lerp(to.0, t), self.1.lerp(to.1, t))
118	}
119}
120
121/// A finite eased interpolation between two values on a caller-supplied
122/// clock.
123///
124/// A tween never owns time: sampling and retargeting take `now`, so one
125/// value drives retained repaints and immediate-mode paints alike.
126/// [`Tween::retarget`] restarts from the current sample, so interrupting a
127/// running transition never jumps.
128///
129/// # Example
130/// ```
131/// use std::time::Duration;
132///
133/// use omp_tui::anim::{Easing, Tween};
134///
135/// let mut fade = Tween::settled(0.0f32);
136/// fade.retarget(Duration::ZERO, 1.0, Duration::from_millis(100), Easing::Linear);
137/// assert_eq!(fade.sample(Duration::from_millis(50)), 0.5);
138/// assert!(fade.is_settled(Duration::from_millis(100)));
139/// ```
140#[derive(Clone, Copy, Debug)]
141pub struct Tween<T: Lerp> {
142	from:     T,
143	to:       T,
144	start:    Duration,
145	duration: Duration,
146	easing:   Easing,
147}
148
149impl<T: Lerp> Tween<T> {
150	/// A tween already settled at `value`.
151	pub const fn settled(value: T) -> Self {
152		Self {
153			from:     value,
154			to:       value,
155			start:    Duration::ZERO,
156			duration: Duration::ZERO,
157			easing:   Easing::Linear,
158		}
159	}
160
161	/// The value at time `now`.
162	pub fn sample(&self, now: Duration) -> T {
163		if self.duration.is_zero() {
164			return self.to;
165		}
166		let t = now
167			.saturating_sub(self.start)
168			.div_duration_f32(self.duration);
169		self.from.lerp(self.to, self.easing.apply(t))
170	}
171
172	/// The value the tween is heading toward.
173	pub const fn target(&self) -> T {
174		self.to
175	}
176
177	/// Whether the tween has reached its target at time `now`.
178	pub fn is_settled(&self, now: Duration) -> bool {
179		now >= self.settles_at()
180	}
181
182	/// When the tween reaches its target — the deadline for the final frame.
183	pub const fn settles_at(&self) -> Duration {
184		self.start.saturating_add(self.duration)
185	}
186
187	/// Redirects the tween toward `to` over `duration`, starting from the
188	/// value currently on screen. A matching target is a no-op, so callers
189	/// may retarget unconditionally on every state change.
190	pub fn retarget(&mut self, now: Duration, to: T, duration: Duration, easing: Easing)
191	where
192		T: PartialEq,
193	{
194		if self.to == to {
195			return;
196		}
197		self.from = self.sample(now);
198		self.to = to;
199		self.start = now;
200		self.duration = duration;
201		self.easing = easing;
202	}
203}
204
205/// A periodic glyph cycle on a caller-supplied clock.
206///
207/// Pure phase arithmetic: the frame at `now` and the instant of the next
208/// change derive from `now mod interval`, so cycles stay aligned no matter
209/// when or how often they are sampled.
210#[derive(Clone, Copy, Debug)]
211pub struct Frames {
212	frames:   &'static [&'static str],
213	interval: Duration,
214}
215
216impl Frames {
217	/// Braille spinner for Unicode-capable terminals.
218	pub const SPINNER: Self =
219		Self::new(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], Duration::from_millis(80));
220	/// Four-spoke spinner safe for 7-bit ASCII terminals.
221	pub const SPINNER_ASCII: Self = Self::new(&["|", "/", "-", "\\"], Duration::from_millis(120));
222
223	/// Creates a cycle stepping through `frames` every `interval`.
224	///
225	/// # Panics
226	/// When `frames` is empty or `interval` is zero.
227	pub const fn new(frames: &'static [&'static str], interval: Duration) -> Self {
228		assert!(!frames.is_empty(), "a frame cycle needs at least one frame");
229		assert!(!interval.is_zero(), "a frame cycle needs a nonzero interval");
230		Self { frames, interval }
231	}
232
233	/// The glyph on screen at time `now`.
234	pub const fn at(&self, now: Duration) -> &'static str {
235		let step = now.as_nanos() / self.interval.as_nanos();
236		self.frames[(step % self.frames.len() as u128) as usize]
237	}
238
239	/// When the glyph after `now` appears — the deadline to pass to
240	/// [`crate::PaintCtx::wake`].
241	pub const fn next_change(&self, now: Duration) -> Duration {
242		let interval = self.interval.as_nanos();
243		let remaining = interval - now.as_nanos() % interval;
244		now.saturating_add(Duration::from_nanos(remaining as u64))
245	}
246}
247
248/// A brightness crest sweeping across one line of cells.
249///
250/// Pure phase arithmetic on the shared clock, like a gradient `spin`: the
251/// crest travels a track padded on both sides so it fully enters and exits
252/// the text instead of popping at an edge, then wraps and sweeps again.
253/// Cells away from the crest keep the authored style, its shoulders
254/// brighten, and the peak lifts further and paints bold. `<text>` consumes
255/// this declaratively through the `shimmer` property — see
256/// [`crate::Props::shimmer`].
257#[derive(Clone, Copy, Debug)]
258pub struct Shimmer {
259	/// Crest center in cells from the start of the padded track.
260	position: f32,
261}
262
263impl Shimmer {
264	/// Half-width of the cosine crest, in cells.
265	const HALF_WIDTH: f32 = 6.0;
266	/// Intensity at or above which a cell paints bold.
267	const HIGH: f32 = 0.65;
268	/// Intensity at or above which a cell keeps the base style.
269	const MID: f32 = 0.22;
270	/// Off-text runway on each side of the track, in cells.
271	const PADDING: f32 = 10.0;
272
273	/// Places the crest at `now`: one sweep across `length` cells (plus
274	/// runway) per `period`.
275	pub fn new(now: Duration, period: Duration, length: u16) -> Self {
276		let track = Self::PADDING.mul_add(2.0, f32::from(length));
277		let period = period.as_secs_f32().max(f32::EPSILON);
278		let phase = (now.as_secs_f32() / period).fract();
279		Self { position: phase * track }
280	}
281
282	/// Picks one of three values by crest intensity at `cell` (zero-based
283	/// from the text start): `low` off the crest, `mid` on its shoulders,
284	/// `high` at the peak. The seam for custom palettes — [`Shimmer::style_at`]
285	/// is this with the dim/base/bold derivation.
286	pub fn pick<T>(&self, cell: u16, low: T, mid: T, high: T) -> T {
287		let distance = (f32::from(cell) + Self::PADDING - self.position).abs();
288		if distance >= Self::HALF_WIDTH {
289			return low;
290		}
291		let angle = std::f32::consts::PI * distance / Self::HALF_WIDTH;
292		let intensity = f32::midpoint(1.0, angle.cos());
293		if intensity >= Self::HIGH {
294			high
295		} else if intensity >= Self::MID {
296			mid
297		} else {
298			low
299		}
300	}
301
302	/// The style for `cell` (zero-based from the text start). Shimmer is
303	/// additive: `base` rests unchanged off the crest, so a shimmering line
304	/// matches its non-shimmering appearance except under the sweep. An RGB
305	/// foreground lifts one-fifth toward white on the shoulders and
306	/// two-fifths plus bold at the peak; a default or indexed foreground
307	/// carries no channel data, so only the peak's bold shows.
308	pub fn style_at(&self, cell: u16, base: Style) -> Style {
309		let Color::Rgb(red, green, blue) = base.foreground_color() else {
310			return self.pick(cell, base, base, base.bold());
311		};
312		let lift = |channel: u8, fifths: u16| {
313			(u16::from(channel) + (255 - u16::from(channel)) * fifths / 5) as u8
314		};
315		let toward_white =
316			|fifths: u16| Color::Rgb(lift(red, fifths), lift(green, fifths), lift(blue, fifths));
317		self.pick(cell, base, base.fg(toward_white(1)), base.fg(toward_white(2)).bold())
318	}
319}
320
321/// A paced cursor over streamed units — grapheme clusters, rows, blocks —
322/// that chases a growing total and settles once even with it.
323///
324/// Unlike the phase-arithmetic shapes, this is an integrator: each
325/// [`advance`](Reveal::advance) moves an internal cursor forward, in two
326/// regimes. While the backlog exceeds one horizon's worth of the floor
327/// rate it decays exponentially with e-folding time `horizon`, so a
328/// bursty producer accelerates the reveal instead of queueing behind it;
329/// the remainder drains linearly at [`Reveal::MIN_RATE`] units per
330/// second, so the tail types out steadily rather than slowing
331/// asymptotically.
332///
333/// Progress per sample is capped at one [`FRAME`] of elapsed time, so the
334/// cursor follows frames actually observed — like a fixed-cadence
335/// interval timer. Stale paint clocks, stalled hosts, idle gaps, and a
336/// settled cursor awaiting more content all resume at the frame cadence
337/// instead of jumping. `<text>` consumes this declaratively through the
338/// `reveal` property — see [`crate::Props::reveal`].
339#[derive(Clone, Copy, Debug, Default)]
340pub struct Reveal {
341	/// Units currently revealed, fractional between frames.
342	shown: f32,
343	/// Previous sample instant; `None` while idle or settled.
344	last:  Option<Duration>,
345}
346
347impl Reveal {
348	/// Floor reveal rate in units per second (3 per 33 ms frame).
349	pub const MIN_RATE: f32 = 90.0;
350
351	/// A cursor with nothing revealed and the clock disarmed.
352	pub const fn new() -> Self {
353		Self { shown: 0.0, last: None }
354	}
355
356	/// Advances the cursor toward `total` at `now` and returns the whole
357	/// units revealed. A zero `horizon` snaps to `total`; a `total` below
358	/// the cursor clamps it down (content shrank in place).
359	pub fn advance(&mut self, now: Duration, total: usize, horizon: Duration) -> usize {
360		let target = total as f32;
361		if self.shown >= target {
362			self.shown = target;
363			self.last = None;
364			return total;
365		}
366		// One frame is the most a single sample may earn: the first sample
367		// of a run arms the clock and earns nothing.
368		let elapsed = self
369			.last
370			.map_or(Duration::ZERO, |prev| now.saturating_sub(prev).min(FRAME));
371		self.last = Some(now);
372		let horizon = horizon.as_secs_f32();
373		if horizon <= 0.0 {
374			self.shown = target;
375			self.last = None;
376			return total;
377		}
378		let mut backlog = target - self.shown;
379		let mut dt = elapsed.as_secs_f32();
380		// Catch-up regime: exact exponential decay while the implied rate
381		// exceeds the floor, then hand the leftover time to the floor.
382		let floor = Self::MIN_RATE * horizon;
383		if backlog > floor {
384			let cross = horizon * (backlog / floor).ln();
385			if dt < cross {
386				backlog *= (-dt / horizon).exp();
387				dt = 0.0;
388			} else {
389				backlog = floor;
390				dt -= cross;
391			}
392		}
393		backlog = Self::MIN_RATE.mul_add(-dt, backlog).max(0.0);
394		if backlog <= 0.0 {
395			self.shown = target;
396			self.last = None;
397			return total;
398		}
399		self.shown = target - backlog;
400		(self.shown as usize).min(total)
401	}
402
403	/// Restarts from nothing — the content was replaced, not extended.
404	pub const fn reset(&mut self) {
405		self.shown = 0.0;
406		self.last = None;
407	}
408
409	/// Whether the cursor has caught up with `total`.
410	pub fn is_settled(&self, total: usize) -> bool {
411		self.shown >= total as f32
412	}
413}
414
415#[cfg(test)]
416mod tests {
417	use super::*;
418
419	#[test]
420	fn easing_curves_hit_both_endpoints_and_stay_ordered() {
421		for easing in [Easing::Linear, Easing::EaseIn, Easing::EaseOut, Easing::EaseInOut] {
422			assert_eq!(easing.apply(0.0), 0.0, "{easing:?} must start at rest");
423			assert!((easing.apply(1.0) - 1.0).abs() < 1e-6, "{easing:?} must land on the target");
424			assert!(easing.apply(-1.0) == 0.0 && (easing.apply(2.0) - 1.0).abs() < 1e-6);
425		}
426		assert!(Easing::EaseIn.apply(0.25) < 0.25 && Easing::EaseOut.apply(0.25) > 0.25);
427	}
428
429	#[test]
430	fn color_lerp_blends_rgb_and_snaps_unblendable_endpoints() {
431		let midpoint = Color::Rgb(0, 100, 200).lerp(Color::Rgb(100, 200, 0), 0.5);
432		assert_eq!(midpoint, Color::Rgb(50, 150, 100));
433		assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.4), Color::Indexed(1));
434		assert_eq!(Color::Indexed(1).lerp(Color::Rgb(9, 9, 9), 0.6), Color::Rgb(9, 9, 9));
435	}
436
437	#[test]
438	fn retarget_resumes_from_the_current_sample_without_jumping() {
439		let mut fade = Tween::settled(Color::Rgb(0, 0, 0));
440		fade.retarget(
441			Duration::ZERO,
442			Color::Rgb(200, 200, 200),
443			Duration::from_millis(400),
444			Easing::Linear,
445		);
446		let now = Duration::from_millis(200);
447		let midway = fade.sample(now);
448		assert_eq!(midway, Color::Rgb(100, 100, 100));
449
450		// Interrupt halfway and head back: the sample at the turn is unchanged.
451		fade.retarget(now, Color::Rgb(0, 0, 0), Duration::from_millis(400), Easing::Linear);
452		assert_eq!(fade.sample(now), midway);
453		assert!(!fade.is_settled(Duration::from_millis(599)));
454		assert_eq!(fade.sample(Duration::from_millis(600)), Color::Rgb(0, 0, 0));
455		assert!(fade.is_settled(Duration::from_millis(600)));
456	}
457
458	#[test]
459	fn retargeting_the_same_target_keeps_the_running_tween() {
460		let mut fade = Tween::settled(0.0f32);
461		fade.retarget(Duration::ZERO, 1.0, Duration::from_millis(100), Easing::Linear);
462		fade.retarget(Duration::from_millis(50), 1.0, Duration::from_millis(100), Easing::Linear);
463		assert_eq!(fade.sample(Duration::from_millis(50)), 0.5);
464	}
465
466	#[test]
467	fn frame_cycles_wrap_and_predict_the_next_change() {
468		let cycle = Frames::new(&["a", "b", "c"], Duration::from_millis(10));
469		assert_eq!(cycle.at(Duration::ZERO), "a");
470		assert_eq!(cycle.at(Duration::from_millis(19)), "b");
471		assert_eq!(cycle.at(Duration::from_millis(35)), "a");
472		assert_eq!(cycle.next_change(Duration::from_millis(19)), Duration::from_millis(20));
473		assert_eq!(cycle.next_change(Duration::from_millis(20)), Duration::from_millis(30));
474	}
475
476	/// Steps the cursor at the frame cadence starting after `from`.
477	fn drain(reveal: &mut Reveal, from: Duration, total: usize, horizon: Duration) -> u32 {
478		let mut frames = 0;
479		while !reveal.is_settled(total) {
480			frames += 1;
481			assert!(frames < 1000, "reveal never settled");
482			reveal.advance(from + FRAME * frames, total, horizon);
483		}
484		frames
485	}
486
487	#[test]
488	fn reveal_arms_on_first_sample_then_drains_at_the_floor_rate() {
489		let mut reveal = Reveal::new();
490		let horizon = Duration::from_millis(250);
491		assert_eq!(reveal.advance(Duration::ZERO, 18, horizon), 0, "first sample only arms");
492		// Each 33ms frame at the 90 units/s floor earns 2.97 units.
493		assert_eq!(reveal.advance(FRAME, 18, horizon), 2);
494		assert_eq!(reveal.advance(FRAME * 2, 18, horizon), 5);
495		assert_eq!(reveal.advance(FRAME * 3, 18, horizon), 8);
496		assert_eq!(drain(&mut reveal, FRAME * 3, 18, horizon), 4);
497		assert!(reveal.is_settled(18));
498	}
499
500	#[test]
501	fn reveal_catches_up_exponentially_then_settles_on_the_floor() {
502		let mut reveal = Reveal::new();
503		let horizon = Duration::from_millis(250);
504		reveal.advance(Duration::ZERO, 1000, horizon);
505		// One frame decays the backlog by e^(-FRAME/horizon) ≈ 12%.
506		let shown = reveal.advance(FRAME, 1000, horizon);
507		assert!((110..=135).contains(&shown), "one frame reveals ~123 units, got {shown}");
508		// Exponential decay alone never lands; the floor finishes the tail
509		// (~29 catch-up frames plus ~8 floor frames).
510		let frames = drain(&mut reveal, FRAME, 1000, horizon);
511		assert!((30..=45).contains(&frames), "settled after {frames} more frames");
512	}
513
514	#[test]
515	fn reveal_never_earns_more_than_one_frame_per_sample() {
516		let mut reveal = Reveal::new();
517		let horizon = Duration::from_millis(250);
518		// Armed on a stale clock, first sampled 400ms later: the gap counts
519		// as one frame, not as banked catch-up time.
520		reveal.advance(Duration::ZERO, 20, horizon);
521		assert_eq!(reveal.advance(Duration::from_millis(400), 20, horizon), 2);
522
523		// A settled cursor idles a minute before the stream appends; the
524		// resume also earns at most one frame.
525		let mut idle = Reveal::new();
526		idle.advance(Duration::ZERO, 3, horizon);
527		idle.advance(FRAME, 3, horizon);
528		assert_eq!(idle.advance(FRAME * 2, 3, horizon), 3);
529		assert!(idle.is_settled(3));
530		assert_eq!(idle.advance(Duration::from_secs(60), 40, horizon), 3, "resume only arms");
531		let resumed = idle.advance(Duration::from_secs(60) + FRAME, 40, horizon);
532		assert!((4..=12).contains(&resumed), "one catch-up frame, not a jump: {resumed}");
533	}
534
535	#[test]
536	fn reveal_zero_horizon_snaps_and_a_smaller_total_clamps() {
537		let mut reveal = Reveal::new();
538		assert_eq!(reveal.advance(Duration::ZERO, 12, Duration::ZERO), 12);
539		assert_eq!(reveal.advance(Duration::from_secs(1), 5, Duration::from_millis(250)), 5);
540		assert!(reveal.is_settled(5));
541		reveal.reset();
542		assert_eq!(reveal.advance(Duration::from_secs(5), 12, Duration::from_millis(250)), 0);
543	}
544
545	#[test]
546	fn shimmer_bands_derive_from_an_rgb_foreground() {
547		use crate::frame::Style;
548		// 200ms into a 1s sweep over a 50-cell track puts the crest on cell 0.
549		let shimmer = Shimmer::new(Duration::from_millis(200), Duration::from_secs(1), 30);
550		let base = Style::new().fg(Color::Rgb(120, 120, 120));
551
552		// Peak: lifted two-fifths toward white and bold.
553		let peak = shimmer.style_at(0, base);
554		assert_eq!(peak.foreground_color(), Color::Rgb(174, 174, 174));
555		assert!(peak.bold && !peak.dim);
556		// Shoulder: lifted one-fifth toward white, attributes untouched.
557		let shoulder = shimmer.style_at(3, base);
558		assert_eq!(shoulder.foreground_color(), Color::Rgb(147, 147, 147));
559		assert!(!shoulder.bold && !shoulder.dim);
560		// Rest: shimmer is additive — the authored style passes through.
561		assert_eq!(shimmer.style_at(29, base), base);
562
563		// No channel data: only the peak's bold shows, and nothing dims.
564		let fallback = Style::new();
565		assert!(shimmer.style_at(0, fallback).bold);
566		assert_eq!(shimmer.style_at(29, fallback), fallback);
567	}
568}