Skip to main content

recursive/tui/ui/
spinner.rs

1//! Braille spinner used while a turn is in flight.
2//!
3//! Goal-144 introduces a single-line spinner rendered transiently
4//! at the bottom of the transcript while [`crate::tui::app::TurnState::running`]
5//! is true. The animation frame is driven by a counter in
6//! [`crate::tui::app::App::spinner_frame`] that the main loop ticks every
7//! draw cycle.
8
9/// 10-frame braille spinner sequence (≈100ms per frame at 50ms ticks).
10pub const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
11
12/// Look up the spinner glyph for a given monotonically-increasing
13/// frame counter.
14pub fn frame_char(index: usize) -> &'static str {
15    FRAMES[index % FRAMES.len()]
16}
17
18/// Format the spinner one-liner: `<spinner> <verb> <elapsed>s`.
19pub fn format_line(index: usize, verb: &str, elapsed_secs: f64) -> String {
20    format!("{} {} {:.1}s", frame_char(index), verb, elapsed_secs)
21}
22
23#[cfg(test)]
24mod tests {
25    use super::*;
26
27    #[test]
28    fn spinner_frame_advances_with_index() {
29        assert_eq!(frame_char(0), "⠋");
30        assert_eq!(frame_char(1), "⠙");
31        assert_eq!(frame_char(9), "⠏");
32        // wraps
33        assert_eq!(frame_char(10), "⠋");
34        assert_eq!(frame_char(20), "⠋");
35    }
36
37    #[test]
38    fn format_line_includes_verb_and_elapsed() {
39        let line = format_line(0, "Thinking", 2.34);
40        assert!(line.contains("Thinking"));
41        assert!(line.contains("2.3s"));
42        assert!(line.starts_with('⠋'));
43    }
44}