Skip to main content

tui_spinner/
flux_spinner.rs

1//! Braille rotation spinner.
2//!
3//! Each character cell cycles through 8 braille frames where one dot is
4//! missing and the gap travels around the cell.  Direction is controlled by
5//! [`Spin`]:
6//!
7//! ```text
8//! Clockwise        ⣾ ⣷ ⣯ ⣟ ⡿ ⢿ ⣽ ⣻  →  ⣾ …
9//! CounterClockwise ⣾ ⣻ ⣽ ⢿ ⡿ ⣟ ⣯ ⣷  →  ⣾ …
10//! ```
11//!
12//! At `width = 1` / `height = 1` this is a single animated glyph — perfect
13//! as a compact status-bar indicator.  For wider or taller sizes each cell is
14//! offset by [`phase_step`](FluxSpinner::phase_step) frames from its neighbour,
15//! producing a travelling diagonal wave:
16//!
17//! ```text
18//! width = 6, phase_step = 1, Clockwise
19//! ⣾⣷⣯⣟⡿⢿   (tick 0)
20//! ⣷⣯⣟⡿⢿⣽   (tick 1)
21//! ⣯⣟⡿⢿⣽⣻   (tick 2)
22//! …
23//! ```
24//!
25//! With [`Spin::CounterClockwise`] the wave travels in the opposite direction.
26//!
27//! ## Usage
28//!
29//! ```no_run
30//! use ratatui::style::Color;
31//! use ratatui::Frame;
32//! use ratatui::layout::Rect;
33//! use tui_spinner::{FluxSpinner, Spin};
34//!
35//! fn draw(frame: &mut Frame, area: Rect, tick: u64) {
36//!     // Single-character status-bar spinner (clockwise, default)
37//!     frame.render_widget(FluxSpinner::new(tick), area);
38//!
39//!     // Counter-clockwise wave spanning a full column
40//!     frame.render_widget(
41//!         FluxSpinner::new(tick)
42//!             .width(12)
43//!             .spin(Spin::CounterClockwise)
44//!             .color(Color::Cyan),
45//!         area,
46//!     );
47//! }
48//! ```
49
50use ratatui::buffer::Buffer;
51use ratatui::layout::{Alignment, Rect};
52use ratatui::style::{Color, Style};
53use ratatui::text::{Line, Span};
54use ratatui::widgets::{Block, Widget};
55
56use crate::Spin;
57
58// ── Frame presets ─────────────────────────────────────────────────────────────
59
60/// Built-in frame sequences for [`FluxSpinner`].
61///
62/// Pass any preset (or a custom `&'static [char]` slice) to
63/// [`FluxSpinner::frames`] to change the animation glyphs.
64///
65/// | Preset     | Glyphs                        | Frames | Description                         |
66/// |------------|-------------------------------|--------|-------------------------------------|
67/// | `BRAILLE`  | `⣾ ⣷ ⣯ ⣟ ⡿ ⢿ ⣽ ⣻`     | 8      | Full cell, one dot missing (default)|
68/// | `ORBIT`    | `⠁ ⠈ ⠐ ⠠ ⢀ ⡀ ⠄ ⠂`     | 8      | Single dot orbiting (inverse)       |
69/// | `CLASSIC`  | `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏` | 10     | Classic braille spinner             |
70/// | `LINE`     | `│ ╱ ─ ╲`                 | 4      | Rotating line                       |
71/// | `BLOCK`    | `▖ ▘ ▝ ▗`                 | 4      | Quarter-block rotation              |
72/// | `ARC`      | `◜ ◝ ◞ ◟`                 | 4      | Quarter-arc rotation                |
73/// | `CLOCK`     | `◷ ◶ ◵ ◴`                     | 4      | Quarter-circle pie slice      |
74/// | `MOON`      | `◓ ◑ ◒ ◐`                     | 4      | Half-circle moon phase        |
75/// | `TRIANGLES` | `▲ ▶ ▼ ◀`                     | 4      | Filled triangle four dirs     |
76/// | `PULSE`     | `⣀ ⣤ ⣶ ⣾ ⣿ ⣾ ⣶ ⣤`         | 8      | Braille fill pulse            |
77/// | `BOUNCE`    | `⠉ ⠒ ⣀ ⠒`                    | 4      | Braille row bouncing top→mid→bottom |
78/// | `HALF`      | `▀ ▐ ▄ ▌`                     | 4      | Half-block rotating clockwise |
79/// | `SQUARE`    | `◰ ◳ ◲ ◱`                     | 4      | White square, one filled quadrant   |
80/// | `DICE`      | `⚀ ⚁ ⚂ ⚃ ⚄ ⚅`               | 6      | Dice faces one to six         |
81/// | `BAR`       | `▁ ▂ ▃ ▄ ▅ ▆ ▇ █`             | 8      | Sub-block growing bar         |
82/// | `CORNERS`     | `┌ ┐ ┘ └`                     | 4      | Box-drawing corners rotate    |
83/// | `CIRCLE_FILL` | `○ ◔ ◑ ◕ ●`                   | 5      | Circle filling clockwise      |
84/// | `PISTON`      | `▁ ▃ ▅ ▇ █ ▇ ▅ ▃`             | 8      | Bouncing bar (repeats)        |
85/// | `STAR`        | `✶ ✷ ✸ ✹`                     | 4      | Braille-asterisk star ramp    |
86/// | `PAIR`        | `⠉ ⠘ ⠰ ⢠ ⣀ ⡄ ⠆ ⠃`         | 8      | Two dots rotating together    |
87/// | `DIAMOND`     | `◇ ◈ ◆ ◈`                     | 4      | Diamond pulse (repeats)       |
88///
89/// # Examples
90///
91/// ```
92/// use tui_spinner::{FluxSpinner, FluxFrames};
93///
94/// let braille = FluxSpinner::new(0);  // BRAILLE is the default
95/// let orbit   = FluxSpinner::new(0).frames(FluxFrames::ORBIT);
96/// let line    = FluxSpinner::new(0).frames(FluxFrames::LINE);
97/// let custom  = FluxSpinner::new(0).frames(&['a', 'b', 'c', 'd']);
98/// ```
99pub struct FluxFrames;
100
101impl FluxFrames {
102    /// Full braille cell with one dot missing — the gap rotates clockwise.
103    ///
104    /// `⣾ ⣷ ⣯ ⣟ ⡿ ⢿ ⣽ ⣻` — **default**.
105    pub const BRAILLE: &'static [char] = &['⣾', '⣷', '⣯', '⣟', '⡿', '⢿', '⣽', '⣻'];
106
107    /// Single braille dot orbiting clockwise — visual complement of `BRAILLE`.
108    ///
109    /// `⠁ ⠈ ⠐ ⠠ ⢀ ⡀ ⠄ ⠂`
110    pub const ORBIT: &'static [char] = &['⠁', '⠈', '⠐', '⠠', '⢀', '⡀', '⠄', '⠂'];
111
112    /// Classic 10-frame braille spinner.
113    ///
114    /// `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`
115    pub const CLASSIC: &'static [char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
116
117    /// Rotating line — 4 frames.
118    ///
119    /// `│ ╱ ─ ╲`
120    pub const LINE: &'static [char] = &['│', '╱', '─', '╲'];
121
122    /// Quarter-block rotation — 4 frames.
123    ///
124    /// `▖ ▘ ▝ ▗`
125    pub const BLOCK: &'static [char] = &['▖', '▘', '▝', '▗'];
126
127    /// Quarter-arc rotation — 4 frames.
128    ///
129    /// `◜ ◝ ◞ ◟`
130    pub const ARC: &'static [char] = &['◜', '◝', '◞', '◟'];
131
132    /// Quarter-circle pie slice rotating through four positions.
133    ///
134    /// `◷ ◶ ◵ ◴`
135    pub const CLOCK: &'static [char] = &['◷', '◶', '◵', '◴'];
136
137    /// Half-circle (moon phase) rotating through four positions.
138    ///
139    /// `◓ ◑ ◒ ◐`
140    pub const MOON: &'static [char] = &['◓', '◑', '◒', '◐'];
141
142    /// Filled triangle pointing in four directions.
143    ///
144    /// `▲ ▶ ▼ ◀`
145    pub const TRIANGLES: &'static [char] = &['▲', '▶', '▼', '◀'];
146
147    /// Braille fill pulsing from a thin baseline up to full density and back.
148    ///
149    /// `⣀ ⣤ ⣶ ⣾ ⣿ ⣾ ⣶ ⣤`
150    pub const PULSE: &'static [char] = &['⣀', '⣤', '⣶', '⣾', '⣿', '⣾', '⣶', '⣤'];
151
152    /// Single braille row bouncing top → middle → bottom → middle.
153    ///
154    /// `⠉ ⠒ ⣀ ⠒`
155    ///
156    /// `⠒` is intentionally repeated — that is the bounce return step.
157    pub const BOUNCE: &'static [char] = &['⠉', '⠒', '⣀', '⠒'];
158
159    /// Half-block rotating clockwise through four positions.
160    ///
161    /// `▀ ▐ ▄ ▌`
162    pub const HALF: &'static [char] = &['▀', '▐', '▄', '▌'];
163
164    /// White square with one filled quadrant rotating clockwise.
165    ///
166    /// `◰ ◳ ◲ ◱`
167    pub const SQUARE: &'static [char] = &['◰', '◳', '◲', '◱'];
168
169    /// Dice faces cycling from one to six.
170    ///
171    /// `⚀ ⚁ ⚂ ⚃ ⚄ ⚅`
172    pub const DICE: &'static [char] = &['⚀', '⚁', '⚂', '⚃', '⚄', '⚅'];
173
174    /// Eight sub-block glyphs growing from one-eighth to full height.
175    ///
176    /// `▁ ▂ ▃ ▄ ▅ ▆ ▇ █`
177    ///
178    /// Clockwise runs the bar upward; counter-clockwise shrinks it back down.
179    pub const BAR: &'static [char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
180
181    /// Box-drawing corners rotating clockwise.
182    ///
183    /// `┌ ┐ ┘ └`
184    pub const CORNERS: &'static [char] = &['┌', '┐', '┘', '└'];
185
186    /// Circle gradually filling clockwise through five stages.
187    ///
188    /// `○ ◔ ◑ ◕ ●`
189    pub const CIRCLE_FILL: &'static [char] = &['○', '◔', '◑', '◕', '●'];
190
191    /// Bar that bounces from one-eighth height to full and back.
192    ///
193    /// `▁ ▃ ▅ ▇ █ ▇ ▅ ▃`
194    ///
195    /// `▃`, `▅`, `▇` are intentionally repeated — that is the bounce return.
196    pub const PISTON: &'static [char] = &['▁', '▃', '▅', '▇', '█', '▇', '▅', '▃'];
197
198    /// Four braille-asterisk star glyphs increasing in density.
199    ///
200    /// `✶ ✷ ✸ ✹`
201    pub const STAR: &'static [char] = &['✶', '✷', '✸', '✹'];
202
203    /// Two adjacent braille dots rotating clockwise around the cell.
204    ///
205    /// `⠉ ⠘ ⠰ ⢠ ⣀ ⡄ ⠆ ⠃`
206    pub const PAIR: &'static [char] = &['⠉', '⠘', '⠰', '⢠', '⣀', '⡄', '⠆', '⠃'];
207
208    /// Diamond pulsing between hollow, dotted, and solid.
209    ///
210    /// `◇ ◈ ◆ ◈`
211    ///
212    /// `◈` is intentionally repeated — that is the pulse return step.
213    pub const DIAMOND: &'static [char] = &['◇', '◈', '◆', '◈'];
214}
215
216// ── Public widget ─────────────────────────────────────────────────────────────
217
218/// A compact braille rotation spinner.
219///
220/// Each character cell shows a full 8-dot braille glyph (`⣿`) with one dot
221/// missing; the gap rotates through all eight positions every 8 steps,
222/// creating an animated "spinning hole" effect.
223///
224/// Direction is controlled by [`Spin`]:
225/// - [`Spin::Clockwise`] (default) — gap moves clockwise: `⣾ ⣷ ⣯ ⣟ ⡿ ⢿ ⣽ ⣻`
226/// - [`Spin::CounterClockwise`]    — gap moves counter-clockwise: `⣾ ⣻ ⣽ ⢿ ⡿ ⣟ ⣯ ⣷`
227///
228/// Scaling up via [`width`](FluxSpinner::width) / [`height`](FluxSpinner::height)
229/// adds a configurable per-cell phase offset
230/// ([`phase_step`](FluxSpinner::phase_step)) so adjacent characters are
231/// staggered in time, producing a smooth diagonal wave across the spinner
232/// block.  The wave direction follows the spin direction.
233///
234/// # Default values
235///
236/// | Field            | Default                     |
237/// |------------------|-----------------------------|
238/// | `width`          | `1`                         |
239/// | `height`         | `1`                         |
240/// | `spin`           | [`Spin::Clockwise`]         |
241/// | `color`          | [`Color::Cyan`]             |
242/// | `ticks_per_step` | `1`                         |
243/// | `phase_step`     | `1`                         |
244/// | `frames`         | [`FluxFrames::BRAILLE`]     |
245///
246/// # Examples
247///
248/// ```
249/// use tui_spinner::{FluxFrames, FluxSpinner, Spin};
250///
251/// // Minimal 1×1 clockwise spinner
252/// let s = FluxSpinner::new(42);
253///
254/// // 8-wide counter-clockwise wave
255/// let wave = FluxSpinner::new(42)
256///     .width(8)
257///     .spin(Spin::CounterClockwise)
258///     .phase_step(1);
259///
260/// // Custom frame sequence
261/// let line = FluxSpinner::new(42).frames(FluxFrames::LINE);
262/// ```
263#[derive(Debug, Clone)]
264pub struct FluxSpinner<'a> {
265    tick: u64,
266    /// Width in character columns (default 1).
267    width: usize,
268    /// Height in character rows (default 1).
269    height: usize,
270    /// Rotation direction (default [`Spin::Clockwise`]).
271    spin: Spin,
272    /// Colour of each spinner glyph (default [`Color::Cyan`]).
273    color: Color,
274    /// Ticks held per animation frame (default 1; higher = slower).
275    ticks_per_step: u64,
276    /// Frame offset added to each successive cell (default 1).
277    ///
278    /// `0` → all cells are synchronised (uniform pulse).
279    /// `1` → each cell is 1 frame ahead of its left/upper neighbour (smooth
280    ///        wave in the spin direction).
281    /// `4` → cells 4 frames apart have opposite phase (`⣾` vs `⡿`).
282    phase_step: u8,
283    /// The frame sequence to animate through (default [`FluxFrames::BRAILLE`]).
284    frames: &'static [char],
285    block: Option<Block<'a>>,
286    style: Style,
287    alignment: Alignment,
288}
289
290impl<'a> FluxSpinner<'a> {
291    /// Creates a new [`FluxSpinner`] with default settings: `1 × 1`,
292    /// clockwise, cyan, 1 tick per frame, phase step 1.
293    ///
294    /// # Examples
295    ///
296    /// ```
297    /// use tui_spinner::FluxSpinner;
298    ///
299    /// let s = FluxSpinner::new(0);
300    /// ```
301    #[must_use]
302    pub fn new(tick: u64) -> Self {
303        Self {
304            tick,
305            width: 1,
306            height: 1,
307            spin: Spin::Clockwise,
308            color: Color::Cyan,
309            ticks_per_step: 1,
310            phase_step: 1,
311            frames: FluxFrames::BRAILLE,
312            block: None,
313            style: Style::default(),
314            alignment: Alignment::Left,
315        }
316    }
317
318    /// Sets the width in character columns (default 1).
319    ///
320    /// # Examples
321    ///
322    /// ```
323    /// use tui_spinner::FluxSpinner;
324    ///
325    /// let wide = FluxSpinner::new(0).width(6);
326    /// ```
327    #[must_use]
328    pub fn width(mut self, w: usize) -> Self {
329        self.width = w.max(1);
330        self
331    }
332
333    /// Sets the height in character rows (default 1).
334    ///
335    /// # Examples
336    ///
337    /// ```
338    /// use tui_spinner::FluxSpinner;
339    ///
340    /// let tall = FluxSpinner::new(0).height(3);
341    /// ```
342    #[must_use]
343    pub fn height(mut self, h: usize) -> Self {
344        self.height = h.max(1);
345        self
346    }
347
348    /// Sets the rotation direction (default [`Spin::Clockwise`]).
349    ///
350    /// Also reverses the phase-wave direction for multi-cell spinners.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// use tui_spinner::{FluxSpinner, Spin};
356    ///
357    /// let cw  = FluxSpinner::new(0).spin(Spin::Clockwise);
358    /// let ccw = FluxSpinner::new(0).spin(Spin::CounterClockwise);
359    /// ```
360    #[must_use]
361    pub const fn spin(mut self, spin: Spin) -> Self {
362        self.spin = spin;
363        self
364    }
365
366    /// Sets the spinner colour (default [`Color::Cyan`]).
367    ///
368    /// # Examples
369    ///
370    /// ```
371    /// use ratatui::style::Color;
372    /// use tui_spinner::FluxSpinner;
373    ///
374    /// let s = FluxSpinner::new(0).color(Color::White);
375    /// ```
376    #[must_use]
377    pub const fn color(mut self, color: Color) -> Self {
378        self.color = color;
379        self
380    }
381
382    /// Sets how many ticks each frame is held (default 1; higher = slower).
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use tui_spinner::FluxSpinner;
388    ///
389    /// let slow = FluxSpinner::new(0).ticks_per_step(4);
390    /// ```
391    #[must_use]
392    pub fn ticks_per_step(mut self, n: u64) -> Self {
393        self.ticks_per_step = n.max(1);
394        self
395    }
396
397    /// Sets the frame offset between adjacent cells (default 1).
398    ///
399    /// | value | effect                                               |
400    /// |-------|------------------------------------------------------|
401    /// | `0`   | All cells synchronised — a uniform pulsing block     |
402    /// | `1`   | Smooth diagonal wave (default)                       |
403    /// | `2`   | Faster / wider wave                                  |
404    /// | `4`   | Anti-phase: neighbouring cells spin opposite (`⣾`/`⡿`)|
405    ///
406    /// The wave travels in the [`spin`](FluxSpinner::spin) direction.
407    ///
408    /// # Examples
409    ///
410    /// ```
411    /// use tui_spinner::FluxSpinner;
412    ///
413    /// let sync = FluxSpinner::new(0).width(4).phase_step(0);
414    /// let wave = FluxSpinner::new(0).width(4).phase_step(1);
415    /// let anti = FluxSpinner::new(0).width(4).phase_step(4);
416    /// ```
417    #[must_use]
418    pub const fn phase_step(mut self, step: u8) -> Self {
419        self.phase_step = step;
420        self
421    }
422
423    /// Sets the frame sequence (default [`FluxFrames::BRAILLE`]).
424    ///
425    /// Use one of the [`FluxFrames`] presets or supply any
426    /// `&'static [char]` slice for a fully custom animation.
427    ///
428    /// # Examples
429    ///
430    /// ```
431    /// use tui_spinner::{FluxSpinner, FluxFrames};
432    ///
433    /// let orbit  = FluxSpinner::new(0).frames(FluxFrames::ORBIT);
434    /// let line   = FluxSpinner::new(0).frames(FluxFrames::LINE);
435    /// let custom = FluxSpinner::new(0).frames(&['◐', '◓', '◑', '◒']);
436    /// ```
437    #[must_use]
438    pub fn frames(mut self, frames: &'static [char]) -> Self {
439        self.frames = frames;
440        self
441    }
442
443    /// Wraps the spinner in a [`Block`].
444    ///
445    /// # Examples
446    ///
447    /// ```
448    /// use ratatui::widgets::Block;
449    /// use tui_spinner::FluxSpinner;
450    ///
451    /// let s = FluxSpinner::new(0).block(Block::bordered().title("Indexing…"));
452    /// ```
453    #[must_use]
454    pub fn block(mut self, block: Block<'a>) -> Self {
455        self.block = Some(block);
456        self
457    }
458
459    /// Sets the base style applied to the widget area.
460    #[must_use]
461    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
462        self.style = style.into();
463        self
464    }
465
466    /// Sets the horizontal alignment of the rendered output (default: left).
467    #[must_use]
468    pub const fn alignment(mut self, alignment: Alignment) -> Self {
469        self.alignment = alignment;
470        self
471    }
472
473    /// Returns the rendered size in character cells `(cols, rows)`.
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// use tui_spinner::FluxSpinner;
479    ///
480    /// assert_eq!(FluxSpinner::new(0).width(5).height(2).char_size(), (5, 2));
481    /// ```
482    #[must_use]
483    pub fn char_size(&self) -> (usize, usize) {
484        (self.width.max(1), self.height.max(1))
485    }
486
487    fn build_lines(&self) -> Vec<Line<'static>> {
488        let n = self.frames.len();
489        if n == 0 {
490            return vec![];
491        }
492
493        // Use usize throughout — avoids cast truncation on large tick values.
494        #[allow(clippy::cast_possible_truncation)]
495        let base = (self.tick / self.ticks_per_step) as usize;
496        let ccw = matches!(self.spin, Spin::CounterClockwise);
497
498        (0..self.height)
499            .map(|r| {
500                let spans: Vec<Span<'static>> = (0..self.width)
501                    .map(|c| {
502                        let cell_idx = r * self.width + c;
503                        let phase = cell_idx * usize::from(self.phase_step);
504                        let raw = base.wrapping_add(phase);
505
506                        // CW:  advance through frames.
507                        // CCW: retreat through frames (reverses wave too).
508                        let frame_idx = if ccw { (n - raw % n) % n } else { raw % n };
509
510                        let ch = self.frames[frame_idx];
511                        Span::styled(ch.to_string(), Style::default().fg(self.color))
512                    })
513                    .collect();
514                Line::from(spans)
515            })
516            .collect()
517    }
518}
519
520// ── Trait impls ───────────────────────────────────────────────────────────────
521
522impl_styled_for!(FluxSpinner<'_>);
523
524impl_widget_via_ref!(FluxSpinner<'_>);
525
526impl Widget for &FluxSpinner<'_> {
527    fn render(self, area: Rect, buf: &mut Buffer) {
528        render_spinner_body!(self, area, buf, self.build_lines());
529    }
530}
531
532// ── Tests ─────────────────────────────────────────────────────────────────────
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use ratatui::{backend::TestBackend, Terminal};
538
539    // ── Frame table ───────────────────────────────────────────────────────────
540
541    #[test]
542    fn braille_preset_has_eight_frames() {
543        assert_eq!(FluxFrames::BRAILLE.len(), 8);
544    }
545
546    #[test]
547    fn all_presets_non_empty() {
548        assert!(!FluxFrames::BRAILLE.is_empty());
549        assert!(!FluxFrames::ORBIT.is_empty());
550        assert!(!FluxFrames::CLASSIC.is_empty());
551        assert!(!FluxFrames::LINE.is_empty());
552        assert!(!FluxFrames::BLOCK.is_empty());
553        assert!(!FluxFrames::ARC.is_empty());
554        assert!(!FluxFrames::BOUNCE.is_empty());
555        assert!(!FluxFrames::HALF.is_empty());
556        assert!(!FluxFrames::SQUARE.is_empty());
557        assert!(!FluxFrames::DICE.is_empty());
558        assert!(!FluxFrames::CLOCK.is_empty());
559        assert!(!FluxFrames::MOON.is_empty());
560        assert!(!FluxFrames::TRIANGLES.is_empty());
561        assert!(!FluxFrames::PULSE.is_empty());
562        assert!(!FluxFrames::BAR.is_empty());
563        assert!(!FluxFrames::CORNERS.is_empty());
564        assert!(!FluxFrames::CIRCLE_FILL.is_empty());
565        assert!(!FluxFrames::PISTON.is_empty());
566        assert!(!FluxFrames::STAR.is_empty());
567        assert!(!FluxFrames::PAIR.is_empty());
568        assert!(!FluxFrames::DIAMOND.is_empty());
569    }
570
571    #[test]
572    fn all_presets_have_distinct_chars_within_set() {
573        // PULSE deliberately repeats chars (it pulses, not rotates).
574        // BOUNCE repeats ⠒ by design (return step).
575        // PISTON and DIAMOND repeat chars by design (bounce/pulse return steps).
576        for (name, preset) in [
577            ("BRAILLE", FluxFrames::BRAILLE),
578            ("ORBIT", FluxFrames::ORBIT),
579            ("CLASSIC", FluxFrames::CLASSIC),
580            ("LINE", FluxFrames::LINE),
581            ("BLOCK", FluxFrames::BLOCK),
582            ("ARC", FluxFrames::ARC),
583            ("CLOCK", FluxFrames::CLOCK),
584            ("MOON", FluxFrames::MOON),
585            ("TRIANGLES", FluxFrames::TRIANGLES),
586            ("HALF", FluxFrames::HALF),
587            ("SQUARE", FluxFrames::SQUARE),
588            ("DICE", FluxFrames::DICE),
589            ("BAR", FluxFrames::BAR),
590            ("CORNERS", FluxFrames::CORNERS),
591            ("CIRCLE_FILL", FluxFrames::CIRCLE_FILL),
592            ("STAR", FluxFrames::STAR),
593            ("PAIR", FluxFrames::PAIR),
594        ] {
595            let unique: std::collections::HashSet<char> = preset.iter().copied().collect();
596            assert_eq!(unique.len(), preset.len(), "{name} has duplicate chars");
597        }
598    }
599
600    // ── Clockwise animation ───────────────────────────────────────────────────
601
602    #[test]
603    fn cw_advances_each_tick() {
604        let f0 = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
605        let f1 = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
606        assert_ne!(f0, f1, "consecutive ticks should produce different frames");
607    }
608
609    #[test]
610    fn cw_wraps_after_eight_steps() {
611        let f0 = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
612        let f8 = FluxSpinner::new(8).spin(Spin::Clockwise).build_lines();
613        assert_eq!(f0, f8, "should wrap back to frame 0 after 8 ticks");
614    }
615
616    #[test]
617    fn ticks_per_step_slows_animation() {
618        let a = FluxSpinner::new(0).ticks_per_step(4).build_lines();
619        let b = FluxSpinner::new(3).ticks_per_step(4).build_lines();
620        assert_eq!(
621            a, b,
622            "ticks 0–3 should all be frame 0 when ticks_per_step=4"
623        );
624
625        let c = FluxSpinner::new(4).ticks_per_step(4).build_lines();
626        assert_ne!(a, c, "tick 4 should advance to frame 1");
627    }
628
629    // ── Direction ─────────────────────────────────────────────────────────────
630
631    #[test]
632    fn cw_and_ccw_differ_at_same_tick() {
633        let cw = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
634        let ccw = FluxSpinner::new(1)
635            .spin(Spin::CounterClockwise)
636            .build_lines();
637        assert_ne!(
638            cw, ccw,
639            "CW and CCW should produce different frames at tick 1"
640        );
641    }
642
643    #[test]
644    fn cw_and_ccw_agree_at_tick_zero() {
645        // Frame index 0 for CW: (8 - 0) % 8 == 0 for CCW — both start at FRAMES[0].
646        let cw = FluxSpinner::new(0).spin(Spin::Clockwise).build_lines();
647        let ccw = FluxSpinner::new(0)
648            .spin(Spin::CounterClockwise)
649            .build_lines();
650        assert_eq!(cw, ccw, "both directions share frame 0 at tick 0");
651    }
652
653    #[test]
654    fn ccw_is_reverse_of_cw() {
655        // CW tick 1 == CCW tick 7, because (8 - 1) % 8 == 7 and 1 % 8 == 1
656        // are symmetric: CW[1] == CCW[7].
657        let cw_1 = FluxSpinner::new(1).spin(Spin::Clockwise).build_lines();
658        let ccw_7 = FluxSpinner::new(7)
659            .spin(Spin::CounterClockwise)
660            .build_lines();
661        assert_eq!(cw_1, ccw_7, "CW tick 1 should equal CCW tick 7");
662    }
663
664    #[test]
665    fn ccw_wraps_after_eight_steps() {
666        let f0 = FluxSpinner::new(0)
667            .spin(Spin::CounterClockwise)
668            .build_lines();
669        let f8 = FluxSpinner::new(8)
670            .spin(Spin::CounterClockwise)
671            .build_lines();
672        assert_eq!(f0, f8, "CCW should wrap back to frame 0 after 8 ticks");
673    }
674
675    #[test]
676    fn ccw_wave_differs_from_cw_wave() {
677        let cw = FluxSpinner::new(1)
678            .width(4)
679            .phase_step(1)
680            .spin(Spin::Clockwise)
681            .build_lines();
682        let ccw = FluxSpinner::new(1)
683            .width(4)
684            .phase_step(1)
685            .spin(Spin::CounterClockwise)
686            .build_lines();
687        assert_ne!(
688            cw, ccw,
689            "CW and CCW waves should differ for width>1 at tick 1"
690        );
691    }
692
693    // ── Phase wave ────────────────────────────────────────────────────────────
694
695    #[test]
696    fn phase_step_zero_all_cells_same() {
697        let lines = FluxSpinner::new(0).width(4).phase_step(0).build_lines();
698        let spans = &lines[0].spans;
699        let first = &spans[0].content;
700        for s in spans.iter().skip(1) {
701            assert_eq!(&s.content, first, "phase_step=0 → all cells identical");
702        }
703    }
704
705    #[test]
706    fn phase_step_one_cells_differ() {
707        let lines = FluxSpinner::new(0).width(4).phase_step(1).build_lines();
708        let spans = &lines[0].spans;
709        for pair in spans.windows(2) {
710            assert_ne!(
711                pair[0].content, pair[1].content,
712                "adjacent cells should differ with phase_step=1"
713            );
714        }
715    }
716
717    #[test]
718    fn phase_step_eight_wraps_to_same() {
719        let base = FluxSpinner::new(0).width(3).phase_step(0).build_lines();
720        let wrap = FluxSpinner::new(0).width(3).phase_step(8).build_lines();
721        assert_eq!(base, wrap, "phase_step=8 should behave like phase_step=0");
722    }
723
724    // ── Size ──────────────────────────────────────────────────────────────────
725
726    #[test]
727    fn output_dimensions_match_width_height() {
728        for w in 1..=5usize {
729            for h in 1..=3usize {
730                let lines = FluxSpinner::new(0).width(w).height(h).build_lines();
731                assert_eq!(lines.len(), h, "height={h}");
732                for (i, line) in lines.iter().enumerate() {
733                    assert_eq!(line.spans.len(), w, "row {i}: width={w}");
734                }
735            }
736        }
737    }
738
739    #[test]
740    fn char_size_returns_width_height() {
741        let s = FluxSpinner::new(0).width(4).height(2);
742        assert_eq!(s.char_size(), (4, 2));
743    }
744
745    #[test]
746    fn char_size_clamps_to_one() {
747        let s = FluxSpinner::new(0).width(0).height(0);
748        let (w, h) = s.char_size();
749        assert!(w >= 1);
750        assert!(h >= 1);
751    }
752
753    // ── Widget rendering ──────────────────────────────────────────────────────
754
755    #[test]
756    fn widget_renders_without_panic() {
757        let backend = TestBackend::new(10, 5);
758        let mut terminal = Terminal::new(backend).unwrap();
759        terminal
760            .draw(|frame| {
761                frame.render_widget(FluxSpinner::new(42).width(3).height(2), frame.area());
762            })
763            .unwrap();
764    }
765
766    #[test]
767    fn widget_single_char_renders() {
768        let backend = TestBackend::new(1, 1);
769        let mut terminal = Terminal::new(backend).unwrap();
770        terminal
771            .draw(|frame| {
772                frame.render_widget(FluxSpinner::new(0), frame.area());
773            })
774            .unwrap();
775    }
776
777    #[test]
778    fn widget_zero_area_no_panic() {
779        let backend = TestBackend::new(0, 0);
780        let mut terminal = Terminal::new(backend).unwrap();
781        terminal
782            .draw(|frame| {
783                frame.render_widget(FluxSpinner::new(0), frame.area());
784            })
785            .unwrap();
786    }
787
788    // ── Builder chain ─────────────────────────────────────────────────────────
789
790    #[test]
791    fn builder_chain() {
792        use ratatui::widgets::Block;
793        let s = FluxSpinner::new(99)
794            .width(6)
795            .height(2)
796            .spin(Spin::CounterClockwise)
797            .color(Color::Green)
798            .ticks_per_step(3)
799            .phase_step(2)
800            .frames(FluxFrames::LINE)
801            .block(Block::bordered())
802            .alignment(Alignment::Center);
803        assert_eq!(s.width, 6);
804        assert_eq!(s.height, 2);
805        assert!(matches!(s.spin, Spin::CounterClockwise));
806        assert_eq!(s.color, Color::Green);
807        assert_eq!(s.ticks_per_step, 3);
808        assert_eq!(s.phase_step, 2);
809        assert_eq!(s.frames, FluxFrames::LINE);
810    }
811
812    #[test]
813    fn output_chars_come_from_frame_set() {
814        for tick in 0..8u64 {
815            for spin in [Spin::Clockwise, Spin::CounterClockwise] {
816                let lines = FluxSpinner::new(tick)
817                    .width(4)
818                    .height(2)
819                    .spin(spin)
820                    .build_lines();
821                let frame_set: std::collections::HashSet<char> =
822                    FluxFrames::BRAILLE.iter().copied().collect();
823                for line in &lines {
824                    for span in &line.spans {
825                        let ch = span.content.chars().next().unwrap();
826                        assert!(
827                            frame_set.contains(&ch),
828                            "U+{:04X} not in BRAILLE preset",
829                            ch as u32
830                        );
831                    }
832                }
833            }
834        }
835    }
836
837    #[test]
838    fn custom_frames_respected() {
839        let frames: &'static [char] = &['a', 'b', 'c', 'd'];
840        let lines = FluxSpinner::new(0).frames(frames).build_lines();
841        let ch = lines[0].spans[0].content.chars().next().unwrap();
842        assert_eq!(ch, 'a', "tick=0 should show first custom frame");
843
844        let lines4 = FluxSpinner::new(4).frames(frames).build_lines();
845        let ch4 = lines4[0].spans[0].content.chars().next().unwrap();
846        assert_eq!(ch4, 'a', "tick=4 (n=4) wraps back to first frame");
847    }
848
849    #[test]
850    fn frames_builder_changes_output() {
851        let braille = FluxSpinner::new(1)
852            .frames(FluxFrames::BRAILLE)
853            .build_lines();
854        let line = FluxSpinner::new(1).frames(FluxFrames::LINE).build_lines();
855        assert_ne!(
856            braille, line,
857            "different frame sets produce different output"
858        );
859    }
860}