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