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}