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}