Skip to main content

tui_spinner/
linear_spinner.rs

1//! Linear spinner — horizontal scrolling window or vertical bounce.
2//!
3//! A single [`LinearSpinner`] covers both animation patterns along a straight axis:
4//!
5//! - **[`Direction::Horizontal`]** — a window of lit symbols scrolls left-to-right
6//!   across a row of configurable length, wrapping around. Classic ellipsis effect.
7//!
8//! - **[`Direction::Vertical`]** — a single lit symbol bounces up and down a column
9//!   of configurable height: `0 → 1 → … → n-1 → … → 1 → 0 → …`
10//!   (the "Zed / Copilot" activity indicator pattern).
11//!
12//! Both directions support the same set of [`LinearStyle`] symbol pairs, so you
13//! can mix and match appearance independently of layout direction.
14
15use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Paragraph, Widget};
20
21// ── Direction ─────────────────────────────────────────────────────────────────
22
23/// The animation direction (and layout axis) of a [`LinearSpinner`].
24///
25/// # Examples
26///
27/// ```
28/// use tui_spinner::{LinearSpinner, Direction};
29///
30/// let horizontal = LinearSpinner::new(0).direction(Direction::Horizontal);
31/// let vertical   = LinearSpinner::new(0).direction(Direction::Vertical);
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum Direction {
35    /// A window of lit symbols scrolls left-to-right across a single row.
36    ///
37    /// The widget occupies **1 row** of height and `total_slots` columns of width.
38    #[default]
39    Horizontal,
40
41    /// A single lit symbol bounces up and down a single column.
42    ///
43    /// The widget occupies **1 column** of width and `total_slots` rows of height.
44    /// When the area is taller than `total_slots` the symbols are pinned to the
45    /// bottom so they align with the last log line in a side-column layout.
46    Vertical,
47}
48
49// ── Flow ──────────────────────────────────────────────────────────────────────
50
51/// The animation flow direction of a [`LinearSpinner`].
52///
53/// Controls whether the animation plays forwards (the default) or backwards.
54///
55/// - [`Flow::Forwards`] — horizontal scrolls left-to-right; vertical bounces
56///   starting upward (index 0 → n-1 → 0 …).
57/// - [`Flow::Backwards`] — horizontal scrolls right-to-left; vertical bounces
58///   starting downward (index n-1 → 0 → n-1 …).
59///
60/// # Examples
61///
62/// ```
63/// use tui_spinner::{LinearSpinner, Flow};
64///
65/// let backwards = LinearSpinner::new(0).flow(Flow::Backwards);
66/// ```
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum Flow {
69    /// Normal playback direction (default).
70    ///
71    /// Horizontal scrolls left-to-right; vertical bounces starting from the top.
72    #[default]
73    Forwards,
74
75    /// Reversed playback direction.
76    ///
77    /// Horizontal scrolls right-to-left; vertical bounces starting from the bottom.
78    Backwards,
79}
80
81// ── LinearStyle ──────────────────────────────────────────────────────────────────
82
83/// The symbol pair used to draw active and inactive slot positions.
84///
85/// Each variant defines an `(active, inactive)` character pair rendered with
86/// bold + `active_color` / `inactive_color` respectively.
87///
88/// # Examples
89///
90/// ```
91/// use tui_spinner::{LinearSpinner, LinearStyle};
92///
93/// let spinner = LinearSpinner::new(0).linear_style(LinearStyle::Diamond);
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum LinearStyle {
97    /// `●` / `·` — filled circle / middle dot. The original classic look.
98    #[default]
99    Classic,
100
101    /// `■` / `□` — filled square / open square.
102    Square,
103
104    /// `◆` / `◇` — filled diamond / open diamond.
105    Diamond,
106
107    /// `▰` / `▱` — parallelogram filled / parallelogram empty.
108    Bar,
109
110    /// `⣿` / `⠀` — full braille cell / blank braille cell.
111    Braille,
112
113    /// `▶` / `▷` — filled right-arrow / open right-arrow.
114    /// Rotates to `▼` / `▽` when [`Direction::Vertical`] is used.
115    Arrow,
116}
117
118impl LinearStyle {
119    /// Returns the `(active, inactive)` string pair for this style,
120    /// optionally adjusted for the given direction.
121    #[must_use]
122    pub const fn symbols(self, direction: Direction) -> (&'static str, &'static str) {
123        match self {
124            Self::Classic => ("●", "·"),
125            Self::Square => ("■", "□"),
126            Self::Diamond => ("◆", "◇"),
127            Self::Bar => ("▰", "▱"),
128            Self::Braille => ("⣿", "⠀"),
129            Self::Arrow => match direction {
130                Direction::Horizontal => ("▶", "▷"),
131                Direction::Vertical => ("▼", "▽"),
132            },
133        }
134    }
135
136    /// Returns the number of terminal columns each slot occupies.
137    ///
138    /// Most symbols are 1 column wide in a typical Western terminal.
139    /// Symbols whose Unicode East Asian Width property is "Wide" occupy 2
140    /// columns; callers that lay out the spinner area manually (e.g. in
141    /// a Ratatui [`Layout`]) should multiply `total_slots` by this value
142    /// to get the correct `Constraint::Length` value.
143    ///
144    /// [`Layout`]: ratatui::layout::Layout
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use tui_spinner::{Direction, LinearStyle};
150    ///
151    /// // Allocate exactly the right width for a 5-slot horizontal spinner:
152    /// let style = LinearStyle::Classic;
153    /// let width = 5 * style.columns_per_slot();
154    /// assert_eq!(width, 5);
155    /// ```
156    #[must_use]
157    pub const fn columns_per_slot(self) -> u16 {
158        // All current styles occupy exactly 1 terminal column.
159        // EAW=N (Narrow) and EAW=A (Ambiguous) symbols are both treated as
160        // 1 column in Western terminals; update individual arms here if a
161        // future style adds a genuinely Wide (EAW=W) glyph.
162        match self {
163            Self::Classic
164            | Self::Square
165            | Self::Diamond
166            | Self::Bar
167            | Self::Braille
168            | Self::Arrow => 1,
169        }
170    }
171}
172
173// ── LinearSpinner ───────────────────────────────────────────────────────────────
174
175/// A linear spinner that animates either horizontally or vertically.
176///
177/// Pass a monotonically increasing `tick` counter (typically incremented once
178/// per render frame) and call `.direction()`, `.linear_style()`, and colour
179/// methods to customise the appearance.
180///
181/// # Horizontal (default)
182///
183/// ```text
184/// tick  0–2 : ●●·
185/// tick  3–5 : ·●●
186/// tick  6–8 : ··●   (window wraps)
187/// tick  9–11: ●··
188/// ```
189///
190/// # Vertical (bounce)
191///
192/// ```text
193/// tick  0–2 : ●      ← slot 0 lit
194///             ·
195///             ·
196/// tick  3–5 : ·
197///             ●      ← slot 1 lit
198///             ·
199/// tick  6–8 : ·
200///             ·
201///             ●      ← slot 2 lit
202/// tick  9–11: ·
203///             ●      ← slot 1 lit (bouncing back)
204///             ·
205/// ```
206///
207/// # Examples
208///
209/// ```no_run
210/// use ratatui::Frame;
211/// use ratatui::layout::Rect;
212/// use tui_spinner::{Direction, LinearStyle, LinearSpinner};
213///
214/// fn draw(frame: &mut Frame, area: Rect, tick: u64) {
215///     // Horizontal ellipsis
216///     frame.render_widget(LinearSpinner::new(tick), area);
217///
218///     // Vertical bounce with diamond symbols
219///     frame.render_widget(
220///         LinearSpinner::new(tick)
221///             .direction(Direction::Vertical)
222///             .linear_style(LinearStyle::Diamond),
223///         area,
224///     );
225/// }
226/// ```
227#[derive(Debug, Clone, PartialEq)]
228pub struct LinearSpinner<'a> {
229    /// Monotonically increasing frame counter.
230    tick: u64,
231    /// Total number of slots.
232    total_slots: usize,
233    /// How many consecutive slots are lit at once (horizontal only).
234    lit_slots: usize,
235    /// Ticks each animation step is held (default: 3).
236    ticks_per_step: u64,
237    /// Animation direction and layout axis.
238    direction: Direction,
239    /// Animation flow (forwards or backwards).
240    flow: Flow,
241    /// Symbol set.
242    linear_style: LinearStyle,
243    /// Colour of lit / active symbols.
244    active_color: Color,
245    /// Colour of unlit / inactive symbols.
246    inactive_color: Color,
247    /// Optional block wrapper.
248    block: Option<Block<'a>>,
249    /// Base style.
250    style: Style,
251}
252
253impl<'a> LinearSpinner<'a> {
254    /// Creates a new [`LinearSpinner`] at the given animation tick with all
255    /// defaults: 3 slots, 2 lit, horizontal, classic style, 3 ticks/step.
256    ///
257    /// # Examples
258    ///
259    /// ```
260    /// use tui_spinner::LinearSpinner;
261    ///
262    /// let spinner = LinearSpinner::new(0);
263    /// ```
264    #[must_use]
265    pub fn new(tick: u64) -> Self {
266        Self {
267            tick,
268            total_slots: 3,
269            lit_slots: 2,
270            ticks_per_step: 3,
271            direction: Direction::Horizontal,
272            flow: Flow::Forwards,
273            linear_style: LinearStyle::Classic,
274            active_color: Color::White,
275            inactive_color: Color::DarkGray,
276            block: None,
277            style: Style::default(),
278        }
279    }
280
281    /// Sets the animation direction (default: [`Direction::Horizontal`]).
282    ///
283    /// - [`Direction::Horizontal`] — scrolling window across a row.
284    /// - [`Direction::Vertical`]   — bouncing symbol down a column.
285    ///
286    /// # Examples
287    ///
288    /// ```
289    /// use tui_spinner::{Direction, LinearSpinner};
290    ///
291    /// let vertical = LinearSpinner::new(0).direction(Direction::Vertical);
292    /// ```
293    #[must_use]
294    pub const fn direction(mut self, direction: Direction) -> Self {
295        self.direction = direction;
296        self
297    }
298
299    /// Sets the animation flow direction (default: [`Flow::Forwards`]).
300    ///
301    /// - [`Flow::Forwards`]  — normal playback (left-to-right / upward-first bounce).
302    /// - [`Flow::Backwards`] — reversed playback (right-to-left / downward-first bounce).
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// use tui_spinner::{Flow, LinearSpinner};
308    ///
309    /// let backwards = LinearSpinner::new(0).flow(Flow::Backwards);
310    /// ```
311    #[must_use]
312    pub const fn flow(mut self, flow: Flow) -> Self {
313        self.flow = flow;
314        self
315    }
316
317    /// Sets the symbol pair used to draw active and inactive slots
318    /// (default: [`LinearStyle::Classic`]).
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use tui_spinner::{LinearStyle, LinearSpinner};
324    ///
325    /// let spinner = LinearSpinner::new(0).linear_style(LinearStyle::Square);
326    /// ```
327    #[must_use]
328    pub const fn linear_style(mut self, style: LinearStyle) -> Self {
329        self.linear_style = style;
330        self
331    }
332
333    /// Sets the total number of slots (default: 3, minimum: 1).
334    ///
335    /// For [`Direction::Vertical`] this is the column height.
336    /// For [`Direction::Horizontal`] this is the row width.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use tui_spinner::LinearSpinner;
342    ///
343    /// let spinner = LinearSpinner::new(0).total_slots(5);
344    /// ```
345    #[must_use]
346    pub fn total_slots(mut self, n: usize) -> Self {
347        self.total_slots = n.max(1);
348        self
349    }
350
351    /// Sets the number of consecutive slots lit at once (default: 2).
352    ///
353    /// Only meaningful for [`Direction::Horizontal`]; ignored in vertical mode
354    /// where exactly one slot is always lit. Values are clamped at render time
355    /// to `[1, total_slots]`.
356    ///
357    /// # Examples
358    ///
359    /// ```
360    /// use tui_spinner::LinearSpinner;
361    ///
362    /// let spinner = LinearSpinner::new(0).lit_slots(1);
363    /// ```
364    #[must_use]
365    pub fn lit_slots(mut self, n: usize) -> Self {
366        self.lit_slots = n.max(1);
367        self
368    }
369
370    /// Sets how many ticks each animation step is held (default: 3).
371    ///
372    /// Higher values slow the animation; lower values speed it up. Zero is
373    /// silently clamped to 1.
374    ///
375    /// # Examples
376    ///
377    /// ```
378    /// use tui_spinner::LinearSpinner;
379    ///
380    /// let fast = LinearSpinner::new(0).ticks_per_step(1);
381    /// ```
382    #[must_use]
383    pub fn ticks_per_step(mut self, n: u64) -> Self {
384        self.ticks_per_step = n.max(1);
385        self
386    }
387
388    /// Sets the colour of active (lit) symbols (default: [`Color::White`]).
389    ///
390    /// # Examples
391    ///
392    /// ```
393    /// use ratatui::style::Color;
394    /// use tui_spinner::LinearSpinner;
395    ///
396    /// let spinner = LinearSpinner::new(0).active_color(Color::Cyan);
397    /// ```
398    #[must_use]
399    pub const fn active_color(mut self, color: Color) -> Self {
400        self.active_color = color;
401        self
402    }
403
404    /// Sets the colour of inactive (dim) symbols (default: [`Color::DarkGray`]).
405    ///
406    /// # Examples
407    ///
408    /// ```
409    /// use ratatui::style::Color;
410    /// use tui_spinner::LinearSpinner;
411    ///
412    /// let spinner = LinearSpinner::new(0).inactive_color(Color::DarkGray);
413    /// ```
414    #[must_use]
415    pub const fn inactive_color(mut self, color: Color) -> Self {
416        self.inactive_color = color;
417        self
418    }
419
420    /// Wraps the spinner in a [`Block`].
421    ///
422    /// # Examples
423    ///
424    /// ```
425    /// use ratatui::widgets::Block;
426    /// use tui_spinner::LinearSpinner;
427    ///
428    /// let spinner = LinearSpinner::new(0).block(Block::bordered().title("Loading"));
429    /// ```
430    #[must_use]
431    pub fn block(mut self, block: Block<'a>) -> Self {
432        self.block = Some(block);
433        self
434    }
435
436    /// Sets the base style applied to the widget area.
437    #[must_use]
438    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
439        self.style = style.into();
440        self
441    }
442
443    // ── Internal helpers ──────────────────────────────────────────────────────
444
445    /// Current animation step index (ticks divided and wrapped to `total_slots`).
446    #[allow(clippy::cast_possible_truncation)]
447    fn step(&self) -> usize {
448        (self.tick / self.ticks_per_step) as usize
449    }
450
451    /// Span for one slot at `idx` — shared by both directions.
452    fn slot_span(&self, _idx: usize, is_lit: bool) -> Span<'static> {
453        let (on, off) = self.linear_style.symbols(self.direction);
454        if is_lit {
455            Span::styled(
456                on,
457                Style::default()
458                    .fg(self.active_color)
459                    .add_modifier(Modifier::BOLD),
460            )
461        } else {
462            Span::styled(off, Style::default().fg(self.inactive_color))
463        }
464    }
465
466    // ── Horizontal rendering ──────────────────────────────────────────────────
467
468    /// Builds the single [`Line`] used in horizontal mode.
469    fn build_horizontal_line(&self) -> Line<'static> {
470        let total = self.total_slots.max(1);
471        let lit = self.lit_slots.min(total);
472        let raw_step = self.step() % total;
473
474        // In backwards mode the step index runs in reverse so the window
475        // scrolls right-to-left instead of left-to-right.
476        let step = match self.flow {
477            Flow::Forwards => raw_step,
478            Flow::Backwards => (total - 1) - raw_step,
479        };
480
481        let spans: Vec<Span<'static>> = (0..total)
482            .map(|i| {
483                let is_lit = if step + lit <= total {
484                    i >= step && i < step + lit
485                } else {
486                    // Window wraps around the end.
487                    i >= step || i < (step + lit) % total
488                };
489                self.slot_span(i, is_lit)
490            })
491            .collect();
492
493        Line::from(spans)
494    }
495
496    // ── Vertical rendering ────────────────────────────────────────────────────
497
498    /// The bounce sequence maps a step index to a slot index.
499    ///
500    /// For `total_slots = n` the forwards sequence is `0, 1, …, n-1, n-2, …, 1`
501    /// (ping-pong starting from the top).  [`Flow::Backwards`] mirrors this to
502    /// `n-1, n-2, …, 0, 1, …, n-2` (starting from the bottom).
503    fn bounce_index(&self) -> usize {
504        let n = self.total_slots.max(1);
505        if n == 1 {
506            return 0;
507        }
508        // Full cycle length = 2*(n-1)
509        let cycle = 2 * (n - 1);
510        let pos = self.step() % cycle;
511        let idx = if pos < n { pos } else { cycle - pos };
512
513        match self.flow {
514            Flow::Forwards => idx,
515            // Mirror: 0 ↔ n-1, 1 ↔ n-2, …
516            Flow::Backwards => (n - 1) - idx,
517        }
518    }
519
520    /// Builds the column of [`Line`]s used in vertical mode, bottom-aligned
521    /// within the available `height`.
522    fn build_vertical_lines(&self, height: usize) -> Vec<Line<'static>> {
523        let n = self.total_slots.max(1);
524        let active = self.bounce_index();
525
526        let mut lines: Vec<Line<'static>> = vec![Line::from(""); height];
527
528        // Pin the symbols to the last `n` rows so they sit next to the latest
529        // log line when used as a side-column activity indicator.
530        if height >= n {
531            let start = height - n;
532            for (i, line) in lines.iter_mut().skip(start).enumerate() {
533                *line = Line::from(self.slot_span(i, i == active));
534            }
535        } else {
536            // Area shorter than slot count — fill all rows in order.
537            for (i, line) in lines.iter_mut().enumerate() {
538                *line = Line::from(self.slot_span(i, i == active));
539            }
540        }
541
542        lines
543    }
544}
545
546// ── Styled ────────────────────────────────────────────────────────────────────
547
548impl_styled_for!(LinearSpinner<'_>);
549
550// ── Widget ────────────────────────────────────────────────────────────────────
551
552impl_widget_via_ref!(LinearSpinner<'_>);
553
554impl Widget for &LinearSpinner<'_> {
555    fn render(self, area: Rect, buf: &mut Buffer) {
556        buf.set_style(area, self.style);
557
558        let inner = if let Some(ref block) = self.block {
559            let inner_area = block.inner(area);
560            block.clone().render(area, buf);
561            inner_area
562        } else {
563            area
564        };
565
566        if inner.height == 0 || inner.width == 0 {
567            return;
568        }
569
570        match self.direction {
571            Direction::Horizontal => {
572                Paragraph::new(self.build_horizontal_line()).render(inner, buf);
573            }
574            Direction::Vertical => {
575                let lines = self.build_vertical_lines(inner.height as usize);
576                Paragraph::new(lines).render(inner, buf);
577            }
578        }
579    }
580}
581
582// ── Tests ─────────────────────────────────────────────────────────────────────
583
584#[cfg(test)]
585mod tests {
586    #![allow(clippy::needless_range_loop)]
587    use super::*;
588
589    // ── Horizontal ────────────────────────────────────────────────────────────
590
591    #[test]
592    fn horizontal_first_step_lights_first_two() {
593        let s = LinearSpinner::new(0); // step=0, lit=2 → slots 0,1
594        let line = s.build_horizontal_line();
595        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
596        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
597        assert_eq!(content, &[on, on, off]);
598    }
599
600    #[test]
601    fn horizontal_second_step_advances() {
602        let s = LinearSpinner::new(3); // step=1 → slots 1,2
603        let line = s.build_horizontal_line();
604        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
605        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
606        assert_eq!(content, &[off, on, on]);
607    }
608
609    #[test]
610    fn horizontal_window_wraps() {
611        let s = LinearSpinner::new(6); // step=2, lit=2 → slots 2 and 0 (wrap)
612        let line = s.build_horizontal_line();
613        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
614        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
615        assert_eq!(content, &[on, off, on]);
616    }
617
618    #[test]
619    fn horizontal_lit_clamped_to_total_at_render() {
620        let s = LinearSpinner::new(0).total_slots(2).lit_slots(99);
621        let line = s.build_horizontal_line();
622        let (on, _) = LinearStyle::Classic.symbols(Direction::Horizontal);
623        let lit = line
624            .spans
625            .iter()
626            .filter(|sp| sp.content.as_ref() == on)
627            .count();
628        assert!(lit <= 2, "lit count must not exceed total_slots");
629    }
630
631    #[test]
632    fn horizontal_single_dot_no_panic() {
633        let s = LinearSpinner::new(0).total_slots(1).lit_slots(1);
634        let _ = s.build_horizontal_line();
635    }
636
637    // ── Vertical / bounce ─────────────────────────────────────────────────────
638
639    #[test]
640    fn vertical_bounce_sequence_3_dots() {
641        // For n=3 the cycle is [0,1,2,1] with ticks_per_step=3.
642        let expected = [0usize, 0, 0, 1, 1, 1, 2, 2, 2, 1, 1, 1, 0, 0, 0];
643        for (tick, &exp) in expected.iter().enumerate() {
644            let s = LinearSpinner::new(tick as u64).direction(Direction::Vertical);
645            assert_eq!(s.bounce_index(), exp, "tick={tick}");
646        }
647    }
648
649    #[test]
650    fn vertical_bounce_sequence_1_dot() {
651        // Single slot — always index 0, never panics.
652        for tick in 0..10u64 {
653            let s = LinearSpinner::new(tick)
654                .direction(Direction::Vertical)
655                .total_slots(1);
656            assert_eq!(s.bounce_index(), 0);
657        }
658    }
659
660    // ── Flow::Backwards — horizontal ──────────────────────────────────────────
661
662    #[test]
663    fn horizontal_backwards_first_step_lights_last_two() {
664        // Flow::Backwards with step=0 → reversed step = total-1 = 2, lit=2
665        // so the window starts at index 2 and wraps: slots 2 and 0 are lit.
666        let s = LinearSpinner::new(0).flow(Flow::Backwards);
667        let line = s.build_horizontal_line();
668        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
669        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
670        assert_eq!(content, &[on, off, on]);
671    }
672
673    #[test]
674    fn horizontal_backwards_second_step_reverses() {
675        // Flow::Backwards, tick=3 → raw step=1 → reversed step = 2-1 = 1, lit=2
676        // window at 1: slots 1,2 lit.
677        let s = LinearSpinner::new(3).flow(Flow::Backwards);
678        let line = s.build_horizontal_line();
679        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
680        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
681        assert_eq!(content, &[off, on, on]);
682    }
683
684    #[test]
685    fn horizontal_backwards_third_step() {
686        // Flow::Backwards, tick=6 → raw step=2 → reversed step = 2-2 = 0, lit=2
687        // window at 0: slots 0,1 lit.
688        let s = LinearSpinner::new(6).flow(Flow::Backwards);
689        let line = s.build_horizontal_line();
690        let content: Vec<&str> = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
691        let (on, off) = LinearStyle::Classic.symbols(Direction::Horizontal);
692        assert_eq!(content, &[on, on, off]);
693    }
694
695    // ── Flow::Backwards — vertical / bounce ───────────────────────────────────
696
697    #[test]
698    fn vertical_backwards_bounce_sequence_3_dots() {
699        // For n=3 forwards bounce is [0,1,2,1]. Backwards mirrors: [2,1,0,1].
700        // With ticks_per_step=3, each step is held for 3 ticks.
701        let expected = [2usize, 2, 2, 1, 1, 1, 0, 0, 0, 1, 1, 1, 2, 2, 2];
702        for (tick, &exp) in expected.iter().enumerate() {
703            let s = LinearSpinner::new(tick as u64)
704                .direction(Direction::Vertical)
705                .flow(Flow::Backwards);
706            assert_eq!(s.bounce_index(), exp, "tick={tick}");
707        }
708    }
709
710    #[test]
711    fn vertical_backwards_bounce_sequence_1_dot() {
712        // Single slot — always index 0 regardless of flow.
713        for tick in 0..10u64 {
714            let s = LinearSpinner::new(tick)
715                .direction(Direction::Vertical)
716                .flow(Flow::Backwards)
717                .total_slots(1);
718            assert_eq!(s.bounce_index(), 0);
719        }
720    }
721
722    #[test]
723    fn flow_forwards_is_default() {
724        let s = LinearSpinner::new(0);
725        assert_eq!(s.flow, Flow::Forwards);
726    }
727
728    #[test]
729    fn flow_default_trait() {
730        assert_eq!(Flow::default(), Flow::Forwards);
731    }
732
733    #[test]
734    fn vertical_ticks_per_step_one_faster() {
735        let s = LinearSpinner::new(1)
736            .direction(Direction::Vertical)
737            .ticks_per_step(1);
738        assert_eq!(s.bounce_index(), 1);
739    }
740
741    #[test]
742    fn vertical_lines_bottom_aligned() {
743        let s = LinearSpinner::new(0)
744            .direction(Direction::Vertical)
745            .total_slots(3);
746        let lines = s.build_vertical_lines(6);
747        // First 3 rows should be empty, last 3 should contain symbols.
748        assert_eq!(lines.len(), 6);
749        assert!(lines[0].spans.is_empty() || lines[0].to_string().is_empty());
750        assert!(!lines[3].spans.is_empty());
751    }
752
753    #[test]
754    fn vertical_lines_short_area_no_panic() {
755        let s = LinearSpinner::new(0)
756            .direction(Direction::Vertical)
757            .total_slots(5);
758        let lines = s.build_vertical_lines(2);
759        assert_eq!(lines.len(), 2);
760    }
761
762    // ── LinearStyle symbols ──────────────────────────────────────────────────────
763
764    #[test]
765    fn linear_style_arrow_changes_with_direction() {
766        let (h_on, _) = LinearStyle::Arrow.symbols(Direction::Horizontal);
767        let (v_on, _) = LinearStyle::Arrow.symbols(Direction::Vertical);
768        assert_ne!(h_on, v_on, "Arrow should differ between H and V");
769    }
770
771    #[test]
772    fn all_styles_return_non_empty_symbols() {
773        let styles = [
774            LinearStyle::Classic,
775            LinearStyle::Square,
776            LinearStyle::Diamond,
777            LinearStyle::Bar,
778            LinearStyle::Braille,
779            LinearStyle::Arrow,
780        ];
781        for style in styles {
782            for dir in [Direction::Horizontal, Direction::Vertical] {
783                let (on, off) = style.symbols(dir);
784                assert!(!on.is_empty(), "{style:?}/{dir:?} active symbol empty");
785                assert!(!off.is_empty(), "{style:?}/{dir:?} inactive symbol empty");
786            }
787        }
788    }
789
790    // ── Widget render smoke tests ─────────────────────────────────────────────
791
792    #[test]
793    fn render_horizontal_does_not_panic_on_zero_area() {
794        let s = LinearSpinner::new(0);
795        let area = Rect::new(0, 0, 0, 0);
796        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
797        Widget::render(&s, area, &mut buf);
798    }
799
800    #[test]
801    fn render_vertical_does_not_panic_on_zero_area() {
802        let s = LinearSpinner::new(0).direction(Direction::Vertical);
803        let area = Rect::new(0, 0, 0, 0);
804        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
805        Widget::render(&s, area, &mut buf);
806    }
807
808    #[test]
809    fn render_vertical_does_not_panic_on_small_area() {
810        let s = LinearSpinner::new(5).direction(Direction::Vertical);
811        let area = Rect::new(0, 0, 1, 1);
812        let mut buf = Buffer::empty(area);
813        Widget::render(&s, area, &mut buf);
814    }
815}