Skip to main content

tui_lipan/widgets/spinner/
mod.rs

1//! Spinner widget.
2
3mod layout;
4mod node;
5mod reconcile;
6
7pub use layout::measure_spinner;
8pub use node::SpinnerNode;
9pub use reconcile::reconcile_spinner;
10
11use std::sync::Arc;
12
13use crate::core::element::{Element, ElementKind};
14use crate::style::{Length, Style};
15
16/// Animation style for a [`Spinner`].
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub enum SpinnerStyle {
19    /// Dot spinner: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
20    #[default]
21    Dots,
22    /// Line spinner: `|/-\`
23    Line,
24    /// Circle spinner: `◐◓◑◒`
25    Circle,
26    /// Arc spinner: `◜◠◝◞◡◟`
27    Arc,
28    /// Braille spinner: `⣾⣽⣻⢿⡿⣟⣯⣷`
29    Braille,
30    /// Moon spinner: `🌑🌒🌓🌔🌕🌖🌗🌘`
31    Moon,
32    /// Box spinner: `▖▘▝▗`
33    Box,
34    /// Vertical bar spinner: ` ▂▃▄▅▆▇█▇▆▅▄▃▂ `
35    Bar,
36    /// Arrow spinner: `←↖↑↗→↘↓↙`
37    Arrow,
38    /// Fading pulse spinner: `█▓▒░▒▓█`
39    Fade,
40    /// Moving trail spinner: `█▓▒░   `
41    Trail,
42    /// Earth spinner: `🌍🌎🌏`
43    Earth,
44    /// Claude-style mirrored star spinner: `·✢✳✶✻*✻✶✳✢`
45    Claude,
46    /// OpenCode spinner: `⬝⬝⬝■■■⬝⬝`
47    OpenCode,
48    /// Three dot moving: `∙∙∙` -> `●∙∙`
49    ThreeDot,
50    /// Three dot with trail: `∙∙∙` -> `●∙∙` -> `•●∙`
51    ThreeDotFade,
52    /// Square gradient: `▱▱▱` -> `▰▱▱`
53    SquareFade,
54    /// Lightsaber: `⁌==⁍════════════` with ignition and retraction
55    Lightsaber,
56}
57
58/// Animation speed for a [`Spinner`].
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
60pub enum SpinnerSpeed {
61    /// Slow speed (approx 200ms per frame).
62    Slow,
63    /// Normal speed (approx 100ms per frame).
64    #[default]
65    Normal,
66    /// Fast speed (approx 50ms per frame).
67    Fast,
68    /// Custom speed, in milliseconds per frame.
69    Custom {
70        /// Milliseconds to hold each spinner frame.
71        frame_ms: u16,
72    },
73}
74
75impl SpinnerStyle {
76    /// Get the animation frames for this style.
77    pub fn frames(self) -> &'static [&'static str] {
78        match self {
79            Self::Dots => &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
80            Self::Line => &["|", "/", "-", "\\"],
81            Self::Circle => &["◐", "◓", "◑", "◒"],
82            Self::Arc => &["◜", "◠", "◝", "◞", "◡", "◟"],
83            Self::Braille => &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
84            Self::Moon => &["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"],
85            Self::Box => &["▖", "▘", "▝", "▗"],
86            Self::Bar => &[
87                " ", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂",
88            ],
89            Self::Arrow => &["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
90            Self::Fade => &["█", "▓", "▒", "░", "▒", "▓"],
91            Self::Trail => &[
92                "█    ",
93                "▓█   ",
94                "▒▓█  ",
95                "░▒▓█ ",
96                " ░▒▓█",
97                "  ░▒▓",
98                "   ░▒",
99                "    ░",
100            ],
101            Self::Earth => &["🌍", "🌎", "🌏"],
102            Self::Claude => &["·", "✢", "✳", "✶", "✻", "*", "✻", "✶", "✳", "✢"],
103            // The frames for OpenCode are handled by a custom renderer with 30-frame cycle,
104            // this array is a fallback for frame count queries.
105            Self::OpenCode => &[
106                "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝",
107                "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝", "⬝",
108            ],
109            Self::ThreeDot => &["∙∙∙", "●∙∙", "∙●∙", "∙∙●"],
110            Self::ThreeDotFade => &["∙∙∙", "●∙∙", "•●∙", "∙•●", "∙∙•"],
111            Self::SquareFade => &["▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱"],
112            Self::Lightsaber => &[
113                "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═",
114                "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═",
115                "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═",
116                "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═",
117                "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═", "═",
118            ],
119        }
120    }
121
122    /// Get the width of each frame (in cells).
123    pub fn width(self) -> u16 {
124        match self {
125            Self::Moon | Self::Earth => 2,
126            Self::Trail => 5,
127            Self::OpenCode => 8,
128            Self::Lightsaber => 16,
129            Self::ThreeDot | Self::ThreeDotFade | Self::SquareFade => 3,
130            _ => 1,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::{Spinner, SpinnerStyle};
138
139    #[test]
140    fn claude_spinner_uses_requested_mirrored_sequence() {
141        assert_eq!(
142            SpinnerStyle::Claude.frames(),
143            &["·", "✢", "✳", "✶", "✻", "*", "✻", "✶", "✳", "✢"],
144        );
145    }
146
147    #[test]
148    fn claude_spinner_tick_wraps_after_one_cycle() {
149        let mut spinner = Spinner::new().spinner_style(SpinnerStyle::Claude);
150        for _ in 0..SpinnerStyle::Claude.frames().len() {
151            spinner.tick();
152        }
153        assert_eq!(spinner.current_frame(), "·");
154    }
155}
156
157/// An animated spinner widget.
158#[derive(Clone, Debug)]
159pub struct Spinner {
160    /// Animation style.
161    pub spinner_style: SpinnerStyle,
162    /// Animation speed.
163    pub speed: SpinnerSpeed,
164    /// Current animation frame (0-indexed).
165    pub frame: Option<usize>,
166    /// Optional label displayed next to the spinner.
167    pub label: Option<Arc<str>>,
168    /// Gap between spinner and label.
169    pub gap: u16,
170    /// Style for the spinner.
171    pub style: Style,
172    /// Style for the label.
173    pub label_style: Style,
174    /// Requested width.
175    /// Default: `Length::Auto`.
176    pub width: Length,
177    /// Requested height.
178    /// Default: `Length::Auto`.
179    pub height: Length,
180}
181
182impl Default for Spinner {
183    fn default() -> Self {
184        Self {
185            spinner_style: SpinnerStyle::Dots,
186            speed: SpinnerSpeed::Normal,
187            frame: None,
188            label: None,
189            gap: 1,
190            style: Style::default(),
191            label_style: Style::default(),
192            width: Length::Auto,
193            height: Length::Auto,
194        }
195    }
196}
197
198impl Spinner {
199    /// Create a new spinner.
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Set the animation style.
205    pub fn spinner_style(mut self, style: SpinnerStyle) -> Self {
206        self.spinner_style = style;
207        self
208    }
209
210    /// Set the animation speed.
211    pub fn speed(mut self, speed: SpinnerSpeed) -> Self {
212        self.speed = speed;
213        self
214    }
215
216    /// Set the current animation frame.
217    pub fn frame(mut self, frame: usize) -> Self {
218        self.frame = Some(frame);
219        self
220    }
221
222    /// Advance to the next frame (wraps around).
223    pub fn tick(&mut self) {
224        let frames = self.spinner_style.frames();
225        let current = self.frame.unwrap_or(0);
226        self.frame = Some((current + 1) % frames.len());
227    }
228
229    /// Get the current frame character.
230    pub fn current_frame(&self) -> &'static str {
231        let frames = self.spinner_style.frames();
232        frames[self.frame.unwrap_or(0) % frames.len()]
233    }
234
235    /// Set the label.
236    pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
237        self.label = Some(label.into());
238        self
239    }
240
241    /// Set the gap between spinner and label.
242    pub fn gap(mut self, gap: u16) -> Self {
243        self.gap = gap;
244        self
245    }
246
247    /// Set spinner style.
248    pub fn style(mut self, style: Style) -> Self {
249        self.style = style;
250        self
251    }
252
253    /// Set label style.
254    pub fn label_style(mut self, style: Style) -> Self {
255        self.label_style = style;
256        self
257    }
258
259    /// Set requested width.
260    pub fn width(mut self, width: Length) -> Self {
261        self.width = width;
262        self
263    }
264
265    /// Set requested height.
266    pub fn height(mut self, height: Length) -> Self {
267        self.height = height;
268        self
269    }
270}
271
272impl From<Spinner> for Element {
273    fn from(value: Spinner) -> Self {
274        Element::new(ElementKind::Spinner(value))
275    }
276}
277
278impl crate::layout::hash::LayoutHash for Spinner {
279    fn layout_hash(
280        &self,
281        hasher: &mut impl std::hash::Hasher,
282        _recurse: &dyn Fn(&Element) -> Option<u64>,
283    ) -> Option<()> {
284        use std::hash::Hash;
285        self.width.hash(hasher);
286        self.height.hash(hasher);
287        self.spinner_style.hash(hasher);
288        self.gap.hash(hasher);
289        self.label.hash(hasher);
290        Some(())
291    }
292}