返回 CodeWhale
motion.rs
1 //! Motion: the few things that move, and the promise that nothing else does.
2 //!
3 //! Motion is allowed for a change of state or for spatial continuity, and
4 //! nothing else: no sweeps, dissolves, glitches or bounces. This module is
5 //! that layer in a few hundred lines, instead of a dependency.
6 //!
7 //! - A [`MotionStep`] eases a value from where it is to a target in `0..=1`,
8 //! sampled at an instant the caller passes in. Nothing here reads a clock,
9 //! spawns a thread or waits.
10 //! - [`MotionPolicy`] says what may move. `Full` animates; `Reduced` and
11 //! `Still` jump straight to the end state, and every helper takes the
12 //! policy so a component cannot forget it. Color steps run at truecolor
13 //! only: at 256 and 16 colors the in-between values snap into visible
14 //! flicker, so the step is skipped (§4.3).
15 //! - [`MotionSet::next_frame_at`] is the whole scheduler: the next instant a
16 //! redraw is due, never sooner than the frame cap, and `None` once
17 //! everything has settled, so an idle screen asks for zero redraws.
18 //!
19 //! The host owns the loop. After each draw it calls `next_frame_at(now,
20 //! policy)` and sleeps until that instant, or until an event, whichever comes
21 //! first. `None` means sleep until an event.
22
23 use std::borrow::Cow;
24 use std::time::{Duration, Instant};
25
26 use ratatui::{buffer::Buffer, layout::Rect, style::Style};
27
28 use crate::{
29 MotionMode, Paint, Role, Theme,
30 color::{ColorDepth, blend},
31 glyphs, text, tokens,
32 };
33
34 /// A redraw is never due sooner than this after it was asked for: 60 frames
35 /// a second. A step lasts 120 to 340 ms, so that is 7 to 20 frames.
36 pub const MOTION_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
37
38 /// The fastest cap a [`MotionSet`] accepts (120 frames a second, the
39 /// engine's draw cap). A faster request is raised to this.
40 pub const MOTION_MIN_FRAME_INTERVAL: Duration = Duration::from_micros(8_333);
41
42 /// How a step eases. Nothing overshoots: every curve stays inside `0..=1`
43 /// and never reverses.
44 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
45 pub enum MotionEasing {
46 Linear,
47 /// Fast, then settling: for things arriving.
48 #[default]
49 EaseOut,
50 /// Slow, then leaving: for things exiting.
51 EaseIn,
52 }
53
54 impl MotionEasing {
55 /// The eased progress for `t` in `0..=1`. Outside the range clamps; a
56 /// NaN counts as finished.
57 #[must_use]
58 pub fn apply(self, t: f32) -> f32 {
59 let t = if t.is_nan() { 1.0 } else { t.clamp(0.0, 1.0) };
60 match self {
61 Self::Linear => t,
62 Self::EaseOut => 1.0 - (1.0 - t).powi(3),
63 Self::EaseIn => t * t * t,
64 }
65 }
66 }
67
68 /// What a step moves. A color step needs a concrete truecolor ground; a
69 /// spatial step moves cells and works at any depth.
70 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
71 pub enum MotionChannel {
72 /// A column offset or a reveal width.
73 Space,
74 /// A role blend.
75 Tint,
76 }
77
78 /// How long a step takes and how it eases.
79 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
80 pub struct MotionTiming {
81 pub duration: Duration,
82 pub easing: MotionEasing,
83 pub channel: MotionChannel,
84 }
85
86 impl MotionTiming {
87 /// A row's state changed (`duration_state`, 120 ms, ease out).
88 pub const STATE: Self = Self::tint(
89 Duration::from_millis(tokens::MOTION_DURATION_STATE_MS as u64),
90 MotionEasing::EaseOut,
91 );
92 /// Something arrived (`duration_arrive`, 180 ms, ease out).
93 pub const ARRIVE: Self = Self::tint(
94 Duration::from_millis(tokens::MOTION_DURATION_ARRIVE_MS as u64),
95 MotionEasing::EaseOut,
96 );
97 /// Something left (`duration_state`, 120 ms, ease in).
98 pub const EXIT: Self = Self::tint(
99 Duration::from_millis(tokens::MOTION_DURATION_STATE_MS as u64),
100 MotionEasing::EaseIn,
101 );
102 /// A panel's width or position settles (`duration_panel`, 340 ms, ease
103 /// out).
104 pub const PANEL: Self = Self::space(
105 Duration::from_millis(tokens::MOTION_DURATION_PANEL_MS as u64),
106 MotionEasing::EaseOut,
107 );
108
109 /// A role blend.
110 #[must_use]
111 pub const fn tint(duration: Duration, easing: MotionEasing) -> Self {
112 Self {
113 duration,
114 easing,
115 channel: MotionChannel::Tint,
116 }
117 }
118
119 /// A column offset or reveal width.
120 #[must_use]
121 pub const fn space(duration: Duration, easing: MotionEasing) -> Self {
122 Self {
123 duration,
124 easing,
125 channel: MotionChannel::Space,
126 }
127 }
128 }
129
130 /// What may move, from the person's setting and what the terminal can show.
131 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
132 pub struct MotionPolicy {
133 mode: MotionMode,
134 tints: bool,
135 }
136
137 impl MotionPolicy {
138 /// `mode` on the terminal `theme` describes.
139 #[must_use]
140 pub fn new(mode: MotionMode, theme: &Theme) -> Self {
141 Self {
142 mode,
143 tints: can_blend(theme),
144 }
145 }
146
147 #[must_use]
148 pub const fn mode(self) -> MotionMode {
149 self.mode
150 }
151
152 /// Whether anything moves at all: `Full` only.
153 #[must_use]
154 pub const fn animates(self) -> bool {
155 self.mode.animates()
156 }
157
158 /// Whether a step on `channel` runs, or jumps to its end state.
159 #[must_use]
160 pub const fn allows(self, channel: MotionChannel) -> bool {
161 self.animates()
162 && match channel {
163 MotionChannel::Space => true,
164 MotionChannel::Tint => self.tints,
165 }
166 }
167 }
168
169 /// Colors blend only at truecolor on a known ground: the in-between values
170 /// of a 256-color blend are other roles, and an unknown ground has nothing
171 /// to blend with.
172 fn can_blend(theme: &Theme) -> bool {
173 theme.paints_grounds() && theme.depth() == ColorDepth::TrueColor
174 }
175
176 /// One value easing from where it is to a target, both in `0..=1`.
177 ///
178 /// `0.0` is the start state (the `from` role, the home column, nothing
179 /// revealed) and `1.0` the end state. A new step starts at rest.
180 #[derive(Clone, Copy, Debug, PartialEq)]
181 pub struct MotionStep {
182 from: f32,
183 to: f32,
184 start: Option<Instant>,
185 duration: Duration,
186 easing: MotionEasing,
187 }
188
189 impl Default for MotionStep {
190 fn default() -> Self {
191 Self::at_rest(0.0)
192 }
193 }
194
195 impl MotionStep {
196 /// A step that is not moving, at `value` (clamped to `0..=1`).
197 #[must_use]
198 pub fn at_rest(value: f32) -> Self {
199 let value = unit(value);
200 Self {
201 from: value,
202 to: value,
203 start: None,
204 duration: Duration::ZERO,
205 easing: MotionEasing::Linear,
206 }
207 }
208
209 /// Where the step is going.
210 #[must_use]
211 pub const fn target(&self) -> f32 {
212 self.to
213 }
214
215 /// The value at `now`, in `0..=1`. Before the start it is the start
216 /// value; at and after the end it is the target.
217 #[must_use]
218 pub fn at(&self, now: Instant) -> f32 {
219 let Some(start) = self.start else {
220 return self.to;
221 };
222 let elapsed = now.saturating_duration_since(start);
223 if self.duration.is_zero() || elapsed >= self.duration {
224 return self.to;
225 }
226 let t = elapsed.as_secs_f32() / self.duration.as_secs_f32();
227 unit(self.from + (self.to - self.from) * self.easing.apply(t))
228 }
229
230 /// Whether the step has reached its target at `now`.
231 #[must_use]
232 pub fn is_settled(&self, now: Instant) -> bool {
233 self.start.is_none_or(|start| {
234 self.duration.is_zero() || now.saturating_duration_since(start) >= self.duration
235 })
236 }
237
238 /// Whether this step still needs frames: it is moving, and `policy`
239 /// lets it move.
240 #[must_use]
241 pub fn is_live(&self, now: Instant, policy: MotionPolicy) -> bool {
242 policy.animates() && !self.is_settled(now)
243 }
244
245 /// Head for `target` (clamped to `0..=1`). Mid-flight it starts from
246 /// where the step is now, so nothing jumps. Asking for the target the
247 /// step already has changes nothing and does not restart it, so a
248 /// component may call this on every event. Returns whether anything
249 /// changed.
250 ///
251 /// Where the policy does not let `timing.channel` move, the step jumps
252 /// to `target` and is settled.
253 pub fn go(
254 &mut self,
255 target: f32,
256 now: Instant,
257 timing: MotionTiming,
258 policy: MotionPolicy,
259 ) -> bool {
260 if !target.is_finite() {
261 return false;
262 }
263 let target = unit(target);
264 if target == self.to {
265 return false;
266 }
267 *self = if policy.allows(timing.channel) && !timing.duration.is_zero() {
268 Self {
269 from: self.at(now),
270 to: target,
271 start: Some(now),
272 duration: timing.duration,
273 easing: timing.easing,
274 }
275 } else {
276 Self::at_rest(target)
277 };
278 true
279 }
280
281 /// Stop where the step is going, at once. Input wins: a key press
282 /// settles every running step.
283 pub fn settle(&mut self) {
284 *self = Self::at_rest(self.to);
285 }
286
287 /// The redraw this step wants after `now`, or `None` once it is settled
288 /// (or the policy does not animate).
289 #[must_use]
290 pub fn next_frame_at(&self, now: Instant, policy: MotionPolicy) -> Option<Instant> {
291 self.is_live(now, policy)
292 .then(|| now.checked_add(MOTION_FRAME_INTERVAL))
293 .flatten()
294 }
295
296 /// The value to show: eased where `policy` lets `channel` move, the
297 /// target where it does not. The one place the policy is applied at
298 /// paint time, so a mode changed mid-flight cannot leave a ghost.
299 fn shown(&self, now: Instant, policy: MotionPolicy, channel: MotionChannel) -> f32 {
300 if policy.allows(channel) {
301 self.at(now)
302 } else {
303 self.to
304 }
305 }
306
307 /// A column between `from` and `to`: slide a marker, nudge a panel.
308 #[must_use]
309 pub fn offset(&self, now: Instant, policy: MotionPolicy, from: u16, to: u16) -> u16 {
310 let v = self.shown(now, policy, MotionChannel::Space);
311 let (a, b) = (f32::from(from), f32::from(to));
312 (a + (b - a) * v).round().clamp(0.0, f32::from(u16::MAX)) as u16
313 }
314
315 /// How many of `width` columns are shown: `0` before, `width` after.
316 #[must_use]
317 pub fn reveal(&self, now: Instant, policy: MotionPolicy, width: u16) -> u16 {
318 self.offset(now, policy, 0, width)
319 }
320
321 /// Ink: [`Theme::fg`] blended from `from` toward `to`. At the ends it is
322 /// exactly `theme.fg(from)` and `theme.fg(to)`; between, a blend at
323 /// truecolor, and a jump to the nearer role anywhere else.
324 #[must_use]
325 pub fn fg(
326 &self,
327 now: Instant,
328 policy: MotionPolicy,
329 theme: &Theme,
330 from: Role,
331 to: Role,
332 ) -> Style {
333 let v = self.shown(now, policy, MotionChannel::Tint);
334 match ends(v, from, to) {
335 Some(role) => theme.fg(role),
336 None if !can_blend(theme) => theme.fg(nearer(v, from, to)),
337 None => match (theme.fg(from).fg, theme.fg(to).fg) {
338 (Some(a), Some(b)) => Style::default().fg(blend(b, a, v)),
339 _ => theme.fg(nearer(v, from, to)),
340 },
341 }
342 }
343
344 /// A ground: [`Theme::bg`] blended from `from` toward `to`. A ground the
345 /// terminal keeps (unpainted, or `NO_COLOR`) is not blended through: the
346 /// step jumps to the nearer role.
347 #[must_use]
348 pub fn bg(
349 &self,
350 now: Instant,
351 policy: MotionPolicy,
352 theme: &Theme,
353 from: Role,
354 to: Role,
355 ) -> Style {
356 let v = self.shown(now, policy, MotionChannel::Tint);
357 match ends(v, from, to) {
358 Some(role) => theme.bg(role),
359 None if !can_blend(theme) => theme.bg(nearer(v, from, to)),
360 None => match (theme.bg(from).bg, theme.bg(to).bg) {
361 (Some(a), Some(b)) => Style::default().bg(blend(b, a, v)),
362 _ => theme.bg(nearer(v, from, to)),
363 },
364 }
365 }
366 }
367
368 fn unit(v: f32) -> f32 {
369 if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
370 }
371
372 fn ends(v: f32, from: Role, to: Role) -> Option<Role> {
373 if v <= 0.0 {
374 Some(from)
375 } else if v >= 1.0 {
376 Some(to)
377 } else {
378 None
379 }
380 }
381
382 fn nearer(v: f32, from: Role, to: Role) -> Role {
383 if v >= 0.5 { to } else { from }
384 }
385
386 /// A component's few named motions, and the schedule for all of them.
387 #[derive(Clone, Debug)]
388 pub struct MotionSet {
389 steps: Vec<(&'static str, MotionStep)>,
390 interval: Duration,
391 }
392
393 impl Default for MotionSet {
394 fn default() -> Self {
395 Self::new()
396 }
397 }
398
399 impl MotionSet {
400 #[must_use]
401 pub fn new() -> Self {
402 Self {
403 steps: Vec::new(),
404 interval: MOTION_FRAME_INTERVAL,
405 }
406 }
407
408 /// Cap redraws at one per `interval` (at least
409 /// [`MOTION_MIN_FRAME_INTERVAL`]): 33 ms holds a slow terminal to 30
410 /// frames a second.
411 #[must_use]
412 pub fn with_frame_interval(mut self, interval: Duration) -> Self {
413 self.interval = interval.max(MOTION_MIN_FRAME_INTERVAL);
414 self
415 }
416
417 /// Head the motion called `name` for `target`; see [`MotionStep::go`].
418 /// A name seen for the first time starts at rest at `0.0`.
419 pub fn go(
420 &mut self,
421 name: &'static str,
422 target: f32,
423 now: Instant,
424 timing: MotionTiming,
425 policy: MotionPolicy,
426 ) -> bool {
427 let at = match self.steps.iter().position(|(n, _)| *n == name) {
428 Some(at) => at,
429 None => {
430 self.steps.push((name, MotionStep::default()));
431 self.steps.len() - 1
432 }
433 };
434 self.steps[at].1.go(target, now, timing, policy)
435 }
436
437 /// The motion called `name`, or one at rest at `0.0`.
438 #[must_use]
439 pub fn step(&self, name: &str) -> MotionStep {
440 self.steps
441 .iter()
442 .find(|(n, _)| *n == name)
443 .map_or_else(MotionStep::default, |(_, step)| *step)
444 }
445
446 /// Settle every motion at its target now. Input wins.
447 pub fn settle_all(&mut self) {
448 for (_, step) in &mut self.steps {
449 step.settle();
450 }
451 }
452
453 /// Whether every motion has reached its target at `now`.
454 #[must_use]
455 pub fn is_settled(&self, now: Instant) -> bool {
456 self.steps.iter().all(|(_, step)| step.is_settled(now))
457 }
458
459 /// When the next redraw is due: one frame interval after `now` while any
460 /// motion is live, and `None` once everything is settled or the policy
461 /// does not animate. Ask right after a draw, with the instant it was
462 /// drawn at, so the interval is the cap between draws.
463 #[must_use]
464 pub fn next_frame_at(&self, now: Instant, policy: MotionPolicy) -> Option<Instant> {
465 self.steps
466 .iter()
467 .any(|(_, step)| step.is_live(now, policy))
468 .then(|| now.checked_add(self.interval))
469 .flatten()
470 }
471 }
472
473 /// The earliest of several components' [`MotionSet::next_frame_at`]
474 /// answers; `None` when none of them wants a frame.
475 #[must_use]
476 pub fn soonest_frame(due: impl IntoIterator<Item = Option<Instant>>) -> Option<Instant> {
477 due.into_iter().flatten().min()
478 }
479
480 /// What one frame may spend, and the earliest redraw anyone asked for.
481 ///
482 /// Kept from the first draft of this package. The host makes one per frame,
483 /// each component claims what it draws, and the host reads
484 /// [`FrameBudget::next_frame_in`] after the draw. Only the first
485 /// [`claim_spinner`](Self::claim_spinner) and
486 /// [`claim_horizon`](Self::claim_horizon) return `true`: a second spinner is
487 /// drawn as the still `●` plus its word (hiding it would hide real work), and
488 /// a second horizon is not drawn.
489 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
490 pub struct FrameBudget {
491 next: Option<Duration>,
492 spinners: usize,
493 horizons: usize,
494 }
495
496 impl FrameBudget {
497 #[must_use]
498 pub fn new() -> Self {
499 Self::default()
500 }
501
502 /// A redraw wanted `after` from now (`None` asks for nothing), as
503 /// [`spin::next_frame_in`](crate::spin::next_frame_in) answers.
504 pub fn request(&mut self, after: Option<Duration>) {
505 if let Some(after) = after {
506 self.next = Some(self.next.map_or(after, |current| current.min(after)));
507 }
508 }
509
510 /// A redraw due at `at`, as [`MotionSet::next_frame_at`] answers. An
511 /// instant already past asks for a frame now.
512 pub fn request_at(&mut self, now: Instant, at: Option<Instant>) {
513 self.request(at.map(|at| at.saturating_duration_since(now)));
514 }
515
516 /// The soonest redraw anyone asked for; `None` when nothing moves, so an
517 /// idle screen sleeps until an event.
518 #[must_use]
519 pub fn next_frame_in(&self) -> Option<Duration> {
520 self.next
521 }
522
523 /// Whether this is the frame's first spinner.
524 pub fn claim_spinner(&mut self) -> bool {
525 self.spinners += 1;
526 self.spinners == 1
527 }
528
529 /// Whether this is the frame's first horizon rule.
530 pub fn claim_horizon(&mut self) -> bool {
531 self.horizons += 1;
532 self.horizons == 1
533 }
534 }
535
536 /// The words [`MotionDemo`] shows.
537 #[derive(Clone, Debug, PartialEq, Eq)]
538 pub struct MotionDemoWords {
539 pub working: Cow<'static, str>,
540 pub done: Cow<'static, str>,
541 pub slide: Cow<'static, str>,
542 pub detail: Cow<'static, str>,
543 }
544
545 impl Default for MotionDemoWords {
546 fn default() -> Self {
547 Self {
548 working: Cow::Borrowed("Working"),
549 done: Cow::Borrowed("Done"),
550 slide: Cow::Borrowed("Selected"),
551 detail: Cow::Borrowed("patch applied to 3 files"),
552 }
553 }
554 }
555
556 /// The reference component for this module: one state change, painted at
557 /// one instant.
558 ///
559 /// Three rows. The first is a state mark and word whose ink eases from
560 /// `Muted` to `Live` (a tint). The mark and the word always say the real
561 /// state, so a frame caught mid-step is never ambiguous. The second slides a
562 /// selection marker across, and the third reveals a line of detail. Where
563 /// the policy does not animate, every row is its end state.
564 #[derive(Clone, Debug)]
565 pub struct MotionDemo<'a> {
566 motions: &'a MotionSet,
567 now: Instant,
568 policy: MotionPolicy,
569 done: bool,
570 words: MotionDemoWords,
571 }
572
573 impl<'a> MotionDemo<'a> {
574 #[must_use]
575 pub fn new(motions: &'a MotionSet, now: Instant, policy: MotionPolicy, done: bool) -> Self {
576 Self {
577 motions,
578 now,
579 policy,
580 done,
581 words: MotionDemoWords::default(),
582 }
583 }
584
585 #[must_use]
586 pub fn with_words(mut self, words: MotionDemoWords) -> Self {
587 self.words = words;
588 self
589 }
590
591 /// Start the three motions toward `done` (or back to working).
592 pub fn start(motions: &mut MotionSet, done: bool, now: Instant, policy: MotionPolicy) {
593 let target = if done { 1.0 } else { 0.0 };
594 motions.go("ink", target, now, MotionTiming::ARRIVE, policy);
595 motions.go(
596 "slide",
597 target,
598 now,
599 MotionTiming::space(MotionTiming::ARRIVE.duration, MotionEasing::EaseOut),
600 policy,
601 );
602 motions.go("reveal", target, now, MotionTiming::PANEL, policy);
603 }
604 }
605
606 impl Paint for MotionDemo<'_> {
607 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
608 let area = area.intersection(buf.area);
609 if area.is_empty() {
610 return;
611 }
612 let (now, policy) = (self.now, self.policy);
613 let row = |i: u16| (area.height > i).then_some(area.y + i);
614 let put = |buf: &mut Buffer, x: u16, y: u16, s: &str, style: Style, max: u16| {
615 if x < area.right() {
616 buf.set_stringn(x, y, s, usize::from(max.min(area.right() - x)), style);
617 }
618 };
619
620 if let Some(y) = row(0) {
621 let ink = self
622 .motions
623 .step("ink")
624 .fg(now, policy, theme, Role::Muted, Role::Live);
625 let (mark, word) = if self.done {
626 (glyphs::DONE, &self.words.done)
627 } else {
628 (glyphs::CURRENT, &self.words.working)
629 };
630 let mark = glyphs::pick(mark, theme.ascii());
631 put(buf, area.x, y, mark, ink, area.width);
632 let x = area.x.saturating_add(text::width(mark) as u16 + 1);
633 put(
634 buf,
635 x,
636 y,
637 &text::display_safe(word),
638 theme.fg(Role::Foreground),
639 area.width,
640 );
641 }
642
643 if let Some(y) = row(1) {
644 let marker = glyphs::pick(glyphs::SELECTION, theme.ascii());
645 let label = text::display_safe(&self.words.slide);
646 let used = text::width(marker) + 1 + text::width(&label);
647 let span = area.width.saturating_sub(used as u16).min(24);
648 let x = area
649 .x
650 .saturating_add(self.motions.step("slide").offset(now, policy, 0, span));
651 put(buf, x, y, marker, theme.fg(Role::Primary), area.width);
652 let lx = x.saturating_add(text::width(marker) as u16 + 1);
653 put(buf, lx, y, &label, theme.fg(Role::Foreground), area.width);
654 }
655
656 if let Some(y) = row(2) {
657 let detail = text::display_safe(&self.words.detail);
658 let full = text::width(&detail).min(usize::from(area.width)) as u16;
659 let shown = self.motions.step("reveal").reveal(now, policy, full);
660 put(buf, area.x, y, &detail, theme.fg(Role::Muted), shown);
661 }
662 }
663
664 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
665 3
666 }
667 }
668
668 lines RUST