返回 CodeWhale
mode.rs
根目录 / crates / tui / src / tui / motion / mode.rs
1 //! Explicit motion modes with semantic (non-animated) fallbacks.
2
3 use crate::tui::spinner::{BRAILLE_SPINNER_STILL_FRAME, LIVE_STATIC_MARKER};
4
5 /// How the shell presents motion.
6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7 pub enum MotionMode {
8 /// Full decorative + status motion; streaming at the display-clock cadence.
9 Full,
10 /// Semantically calm: static markers, no ambient life, no catch-up bursts.
11 /// Streaming still follows the steady display clock (not a typewriter).
12 Reduced,
13 /// No decorative or status animation frames; redraw on state change only.
14 Still,
15 }
16
17 /// Resolved presentation for a live-work marker / spinner cell.
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 pub enum SpinnerPresentation {
20 /// Use the shared braille animation table.
21 Animate,
22 /// Hold a readable mid-fill glyph (reduced motion).
23 StaticCalm,
24 /// Hold the pre-delay static chevron (still / not-yet-earned).
25 StaticChevron,
26 }
27
28 /// Policy derived from settings + runtime overlays (tmux low-motion, etc.).
29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 pub struct MotionPolicy {
31 pub mode: MotionMode,
32 constrained_frame_rate: bool,
33 }
34
35 impl MotionPolicy {
36 /// Derive presentation semantics independently from the runtime frame cap.
37 /// Terminal compatibility limits may reduce redraw frequency, but must not
38 /// masquerade as an accessibility preference or disable authored motion.
39 #[must_use]
40 pub fn from_settings(
41 low_motion: bool,
42 fancy_animations: bool,
43 constrained_frame_rate: bool,
44 ) -> Self {
45 let mode = if low_motion {
46 MotionMode::Reduced
47 } else if !fancy_animations {
48 MotionMode::Still
49 } else {
50 MotionMode::Full
51 };
52 Self {
53 mode,
54 constrained_frame_rate,
55 }
56 }
57
58 #[must_use]
59 pub fn mode(self) -> MotionMode {
60 self.mode
61 }
62
63 #[must_use]
64 pub fn allows_decorative(self) -> bool {
65 matches!(self.mode, MotionMode::Full)
66 }
67
68 #[must_use]
69 pub fn allows_catch_up_bursts(self) -> bool {
70 matches!(self.mode, MotionMode::Full)
71 }
72
73 /// Whether the render loop should use its 30 FPS compatibility cap.
74 #[must_use]
75 pub fn uses_constrained_frame_rate(self) -> bool {
76 self.constrained_frame_rate || !matches!(self.mode, MotionMode::Full)
77 }
78
79 #[must_use]
80 pub fn spinner_presentation(self, earned_live_marker: bool) -> SpinnerPresentation {
81 match self.mode {
82 MotionMode::Full if earned_live_marker => SpinnerPresentation::Animate,
83 MotionMode::Full => SpinnerPresentation::StaticChevron,
84 MotionMode::Reduced => SpinnerPresentation::StaticCalm,
85 MotionMode::Still => SpinnerPresentation::StaticChevron,
86 }
87 }
88
89 /// Resolve the glyph a widget should paint for a running marker.
90 #[must_use]
91 pub fn spinner_glyph(
92 self,
93 animated_frame: &'static str,
94 earned_live_marker: bool,
95 ) -> &'static str {
96 match self.spinner_presentation(earned_live_marker) {
97 SpinnerPresentation::Animate => animated_frame,
98 SpinnerPresentation::StaticCalm => BRAILLE_SPINNER_STILL_FRAME,
99 SpinnerPresentation::StaticChevron => LIVE_STATIC_MARKER,
100 }
101 }
102
103 /// Whether widgets should request future animation frames.
104 #[must_use]
105 pub fn should_request_animation_frames(self) -> bool {
106 matches!(self.mode, MotionMode::Full)
107 }
108
109 /// Bridge to the legacy `low_motion` bool used across history/streaming.
110 #[must_use]
111 pub fn as_low_motion(self) -> bool {
112 !matches!(self.mode, MotionMode::Full)
113 }
114 }
115
116 #[cfg(test)]
117 mod tests {
118 use super::*;
119
120 #[test]
121 fn reduced_uses_calm_markers_without_decorative_motion() {
122 let reduced = MotionPolicy::from_settings(true, true, false);
123 let full = MotionPolicy::from_settings(false, true, false);
124 assert!(full.allows_decorative());
125 assert!(!reduced.allows_catch_up_bursts());
126 assert!(!reduced.allows_decorative());
127 assert_eq!(
128 reduced.spinner_presentation(true),
129 SpinnerPresentation::StaticCalm
130 );
131 }
132
133 #[test]
134 fn still_disables_animation_frames_and_uses_static_marker() {
135 let still = MotionPolicy::from_settings(false, false, false);
136 assert_eq!(still.mode, MotionMode::Still);
137 assert!(!still.should_request_animation_frames());
138 assert_eq!(
139 still.spinner_presentation(true),
140 SpinnerPresentation::StaticChevron
141 );
142 }
143
144 #[test]
145 fn frame_cap_preserves_authored_motion_semantics() {
146 let policy = MotionPolicy::from_settings(false, true, true);
147 assert_eq!(policy.mode, MotionMode::Full);
148 assert!(!policy.as_low_motion());
149 assert!(policy.allows_decorative());
150 assert!(policy.allows_catch_up_bursts());
151 assert!(policy.should_request_animation_frames());
152 assert!(policy.uses_constrained_frame_rate());
153 }
154
155 #[test]
156 fn full_mode_animates_after_live_marker_delay() {
157 let full = MotionPolicy::from_settings(false, true, false);
158 assert_eq!(
159 full.spinner_presentation(true),
160 SpinnerPresentation::Animate
161 );
162 assert_eq!(full.spinner_glyph("⣿", true), "⣿");
163 assert_eq!(full.spinner_glyph("⣿", false), LIVE_STATIC_MARKER);
164 }
165 }
166
166 lines RUST