Skip to main content

tui_spinner/
bar_spinner.rs

1//! Rectangle braille-arc bouncing spinner.
2//!
3//! A braille or symbol-based loading bar: every character cell is filled with a
4//! glyph; a bright arc window slides left-to-right (and bounces) over
5//! a dimmer background track.
6//!
7//! Set [`BarSpinner::width`] to `0` (the default) to fill the
8//! available terminal width automatically.
9//!
10//! ## Visual
11//!
12//! ```text
13//! ⣀⣀⣀⣀⠉⠛⠿⣿⣿⣿⣿⣿⣿⠿⠛⠉⣀⣀⣀⣀⣀⣀⣀⣀
14//! ```
15//!
16//! The outer-edge columns of the arc (`⠉ ⠛ ⠿`) taper from a short
17//! top-row glyph up to full density, giving a smooth gradient.
18//! The dim background track uses `⣀` (bottom-two-dot rail).
19//!
20//! ## How it works
21//!
22//! 1. A `width × height` character grid is rendered; arc columns use the
23//!    full-dot glyph `⣿` in `arc_color`, dim columns use `⣀` in `dim_color`.
24//! 2. The three outermost columns on each arc edge use the fade ramp
25//!    `⠉ ⠛ ⠿` so the arc blends into the track.
26//! 3. The arc window advances one column per step and reverses at each end.
27
28use ratatui::buffer::Buffer;
29use ratatui::layout::{Alignment, Rect};
30use ratatui::style::{Color, Style};
31use ratatui::text::{Line, Span};
32use ratatui::widgets::{Block, Paragraph, Widget};
33
34use crate::Spin;
35
36// ── Braille glyph constants ───────────────────────────────────────────────────
37
38/// Fade ramp for arc edges — outermost (index 0) to innermost (index 3).
39///
40/// ```text
41/// ⠉  0x09   dots 1,4       — top row only
42/// ⠛  0x1B   dots 1,2,4,5   — top two rows
43/// ⠿  0x3F   dots 1–6       — top three rows
44/// ⣿  0xFF   dots 1–8       — full
45/// ```
46const FADE: [u8; 4] = [0x09, 0x1B, 0x3F, 0xFF];
47
48/// Braille byte for the dim background track.
49///
50/// `⣀` (0xC0) — bottom two dots only — gives a subtle rail behind the arc.
51const DIM_BYTE: u8 = 0xC0;
52
53// ── Track style ───────────────────────────────────────────────────────────────
54
55/// Controls the appearance of the dim background track behind the bouncing arc.
56///
57/// | Variant | Byte | Glyph | Effect |
58/// |---------|------|-------|--------|
59/// | `Rail`  | `0xC0` | `⣀` | Bottom-two-dot baseline — subtle, default |
60/// | `Full`  | `0xFF` | `⣿` | Full-density track in `dim_color` |
61/// | `Empty` | `0x00` | `⠀` | Invisible — arc floats on empty space |
62/// | `Custom(u8)` | any | any braille | User-defined braille byte |
63///
64/// # Examples
65///
66/// ```
67/// use tui_spinner::{BarSpinner, BarTrack};
68///
69/// let rail  = BarSpinner::new(0).track(BarTrack::Rail);    // ⣀ default
70/// let solid = BarSpinner::new(0).track(BarTrack::Full);    // ⣿ solid track
71/// let float = BarSpinner::new(0).track(BarTrack::Empty);   // ⠀ no track
72/// let dot   = BarSpinner::new(0).track(BarTrack::Custom(0x09)); // ⠉ top-row
73/// ```
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum BarTrack {
76    /// `⣀` (0xC0) — bottom-two-dot rail, subtle baseline (default).
77    #[default]
78    Rail,
79    /// `⣿` (0xFF) — full-density track in `dim_color`.
80    Full,
81    /// `⠀` (0x00) — invisible background; the arc floats on empty space.
82    Empty,
83    /// Any custom braille byte.
84    Custom(u8),
85}
86
87impl BarTrack {
88    fn byte(self) -> u8 {
89        match self {
90            Self::Rail => DIM_BYTE,
91            Self::Full => 0xFF,
92            Self::Empty => 0x00,
93            Self::Custom(b) => b,
94        }
95    }
96}
97
98// ── Symbol style ─────────────────────────────────────────────────────────────
99
100/// Selects the glyph set used for the arc and background track.
101///
102/// [`Braille`](BarStyle::Braille) (the default) uses braille bytes and
103/// supports the full `fade_width` density ramp.  All other variants use a
104/// single Unicode character for the arc and one for the track, with no
105/// intermediate fade.
106///
107/// | Variant   | Arc | Track | Notes |
108/// |-----------|-----|-------|-------|
109/// | `Braille` | `⣿` | `⣀`  | Braille density fade (default) |
110/// | `Block`   | `█` | `░`   | Solid / light block |
111/// | `Shade`   | `▓` | `░`   | Dark shade / light block |
112/// | `Dot`     | `●` | `·`   | Filled / middle dot |
113/// | `Diamond` | `◆` | `◇`   | Filled / open diamond |
114/// | `Square`  | `■` | `□`   | Filled / open square |
115/// | `Star`    | `★` | `☆` | Filled / outline star             |
116/// | `Heart`   | `♥` | `♡` | Filled / outline heart            |
117/// | `Arrow`   | `▶` | `▷` | Solid / outline right triangle    |
118/// | `Circle`  | `◉` | `○` | Fisheye / open circle             |
119/// | `Spark`   | `✦` | `✧` | Black / white four-pointed star   |
120/// | `Cross`    | `✚` | `✛` | Heavy / open-centre cross         |
121/// | `Progress` | `▰` | `▱` | Bold progress-bar segments |
122/// | `Thick`    | `━` | `─` | Heavy / thin horizontal line |
123/// | `Wave`     | `≈` | `˜` | Wave / tilde |
124/// | `Pip`      | `▪` | `·` | Small square / middle dot |
125///
126/// # Examples
127///
128/// ```
129/// use tui_spinner::{BarSpinner, BarStyle};
130///
131/// let braille = BarSpinner::new(0);                                    // default
132/// let block   = BarSpinner::new(0).bar_style(BarStyle::Block);
133/// let dot     = BarSpinner::new(0).bar_style(BarStyle::Dot);
134/// ```
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
136pub enum BarStyle {
137    /// Dense braille glyphs with a smooth density-gradient fade (default).
138    ///
139    /// Respects `arc_char`, `track`, and `fade_width` settings.
140    #[default]
141    Braille,
142    /// Solid block — `█` arc, `░` track.
143    Block,
144    /// Shade blocks — `▓` arc, `░` track.
145    Shade,
146    /// Filled / middle dot — `●` arc, `·` track.
147    Dot,
148    /// Filled / open diamond — `◆` arc, `◇` track.
149    Diamond,
150    /// Filled / open square — `■` arc, `□` track.
151    Square,
152    /// Filled / outline star — `★` arc, `☆` track.
153    Star,
154    /// Filled / outline heart — `♥` arc, `♡` track.
155    Heart,
156    /// Solid / outline right-pointing triangle — `▶` arc, `▷` track.
157    Arrow,
158    /// Fisheye / open circle — `◉` arc, `○` track.
159    Circle,
160    /// Black / white four-pointed star — `✦` arc, `✧` track.
161    Spark,
162    /// Heavy Greek cross / open-centre cross — `✚` arc, `✛` track.
163    Cross,
164    /// Bold progress-bar segments — `▰` arc, `▱` track.
165    Progress,
166    /// Heavy / thin horizontal line — `━` arc, `─` track.
167    Thick,
168    /// Wave / tilde — `≈` arc, `˜` track.
169    Wave,
170    /// Small square / middle dot — `▪` arc, `·` track.
171    Pip,
172}
173
174impl BarStyle {
175    /// Returns `Some((arc_char, track_char))` for symbol styles, or `None`
176    /// for [`BarStyle::Braille`] (which uses the existing braille rendering).
177    pub(crate) fn chars(self) -> Option<(char, char)> {
178        match self {
179            Self::Braille => None,
180            Self::Block => Some(('█', '░')),
181            Self::Shade => Some(('▓', '░')),
182            Self::Dot => Some(('●', '·')),
183            Self::Diamond => Some(('◆', '◇')),
184            Self::Square => Some(('■', '□')),
185            Self::Star => Some(('★', '☆')),
186            Self::Heart => Some(('♥', '♡')),
187            Self::Arrow => Some(('▶', '▷')),
188            Self::Circle => Some(('◉', '○')),
189            Self::Spark => Some(('✦', '✧')),
190            Self::Cross => Some(('✚', '✛')),
191            Self::Progress => Some(('▰', '▱')),
192            Self::Thick => Some(('━', '─')),
193            Self::Wave => Some(('≈', '˜')),
194            Self::Pip => Some(('▪', '·')),
195        }
196    }
197}
198
199// ── Motion mode ───────────────────────────────────────────────────────────────
200
201/// Controls how the arc behaves when it reaches the edge of the bar.
202///
203/// | Variant  | Behaviour |
204/// |----------|-----------|
205/// | `Bounce` | Reverses at each edge — classic ping-pong (default) |
206/// | `Loop`   | Wraps around: when the arc exits one edge it re-enters from the other |
207///
208/// Combined with [`Spin`], `Loop` produces a continuous sweep:
209/// - `Spin::Clockwise` + `Loop` → sweeps left → right endlessly
210/// - `Spin::CounterClockwise` + `Loop` → sweeps right → left endlessly
211///
212/// # Examples
213///
214/// ```
215/// use tui_spinner::{BarSpinner, BarMotion, Spin};
216///
217/// // Default ping-pong
218/// let bounce = BarSpinner::new(0).motion(BarMotion::Bounce);
219///
220/// // Continuous left-to-right sweep
221/// let sweep = BarSpinner::new(0).spin(Spin::Clockwise).motion(BarMotion::Loop);
222/// ```
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum BarMotion {
225    /// Reverse at each edge — ping-pong (default).
226    #[default]
227    Bounce,
228    /// Wrap around: exit one edge and re-enter from the other.
229    Loop,
230}
231
232// ── Engine ────────────────────────────────────────────────────────────────────
233
234/// Map an edge distance to the appropriate [`FADE`] byte.
235///
236/// - `fade_width = 0` → no ramp; all arc cells use `arc_byte`.
237/// - `fade_width = 1` → only the outermost column fades.
238/// - `fade_width = 3` → default three-column ramp (`⠉ ⠛ ⠿ → arc_byte`).
239#[inline]
240fn fade_byte(from_edge: usize, fade_width: usize, arc_byte: u8) -> u8 {
241    if fade_width == 0 || from_edge >= fade_width {
242        arc_byte
243    } else {
244        FADE[(from_edge * 3).div_ceil(fade_width).min(2)]
245    }
246}
247
248/// Internal bounce engine operating in **character-column** space.
249///
250/// All positions are whole character columns, so there are never any
251/// sub-character boundary artefacts.
252struct RectEngine {
253    char_w: usize,
254    char_h: usize,
255    /// Width of the bright window in character columns.
256    arc_cols: usize,
257    /// Leftmost character column of the bright window.
258    anchor: usize,
259    going_forward: bool,
260    motion: BarMotion,
261}
262
263impl RectEngine {
264    fn build(
265        char_w: usize,
266        char_h: usize,
267        arc_width: usize,
268        spin: Spin,
269        motion: BarMotion,
270    ) -> Self {
271        let char_w = char_w.max(3);
272        let char_h = char_h.max(1);
273
274        // Arc width: explicit value, or auto (~⅓ of bar, min 4 so fade shows).
275        let arc_cols = if arc_width > 0 {
276            arc_width.min(char_w.saturating_sub(1))
277        } else {
278            char_w.div_ceil(3).max(4)
279        };
280
281        let going_forward = matches!(spin, Spin::Clockwise);
282        let anchor = if going_forward {
283            0
284        } else {
285            char_w.saturating_sub(arc_cols)
286        };
287
288        Self {
289            char_w,
290            char_h,
291            arc_cols,
292            anchor,
293            going_forward,
294            motion,
295        }
296    }
297
298    /// Advance one step, reversing or wrapping at each edge.
299    fn walk(&mut self) {
300        match self.motion {
301            BarMotion::Bounce => {
302                let max_anchor = self.char_w.saturating_sub(self.arc_cols);
303                if self.going_forward {
304                    if self.anchor < max_anchor {
305                        self.anchor += 1;
306                    } else {
307                        self.going_forward = false;
308                    }
309                } else if self.anchor > 0 {
310                    self.anchor -= 1;
311                } else {
312                    self.going_forward = true;
313                }
314            }
315            BarMotion::Loop => {
316                // Anchor travels the full 0..char_w range modularly.
317                // render_lines uses (ci - anchor) % char_w to detect the arc,
318                // so the glyph phases in at the leading edge while phasing out
319                // at the trailing edge in the same frame.
320                if self.going_forward {
321                    self.anchor = (self.anchor + 1) % self.char_w;
322                } else {
323                    self.anchor = (self.anchor + self.char_w - 1) % self.char_w;
324                }
325            }
326        }
327    }
328
329    /// Render the current frame as styled [`Line`]s.
330    fn render_lines(
331        &self,
332        arc_color: Color,
333        dim_color: Color,
334        fade_width: usize,
335        track_byte: u8,
336        arc_byte: u8,
337        style_chars: Option<(char, char)>,
338    ) -> Vec<Line<'static>> {
339        let char_w = self.char_w;
340        let arc_cols = self.arc_cols;
341
342        (0..self.char_h)
343            .map(|_| {
344                let spans: Vec<Span<'static>> = (0..char_w)
345                    .map(|ci| {
346                        // Determine whether this column is inside the arc and,
347                        // if so, its distance from the nearer arc edge.
348                        let (in_arc, from_edge) = match self.motion {
349                            BarMotion::Bounce => {
350                                let arc_end = self.anchor + arc_cols;
351                                if ci >= self.anchor && ci < arc_end {
352                                    let fe = (ci - self.anchor).min(arc_end - 1 - ci);
353                                    (true, fe)
354                                } else {
355                                    (false, 0)
356                                }
357                            }
358                            BarMotion::Loop => {
359                                // Modular offset: how far past `anchor` is `ci`?
360                                // This correctly handles the wrap-around case where
361                                // part of the arc is near the right edge and part
362                                // has already reappeared at the left edge.
363                                let offset = (ci + char_w - self.anchor) % char_w;
364                                if offset < arc_cols {
365                                    let fe = offset.min(arc_cols - 1 - offset);
366                                    (true, fe)
367                                } else {
368                                    (false, 0)
369                                }
370                            }
371                        };
372
373                        let (ch, color) = if let Some((arc_ch, track_ch)) = style_chars {
374                            if in_arc {
375                                (arc_ch, arc_color)
376                            } else {
377                                (track_ch, dim_color)
378                            }
379                        } else if in_arc {
380                            let byte = fade_byte(from_edge, fade_width, arc_byte);
381                            let ch = char::from_u32(0x2800 + u32::from(byte)).unwrap_or('\u{2800}');
382                            (ch, arc_color)
383                        } else {
384                            let ch = char::from_u32(0x2800 + u32::from(track_byte))
385                                .unwrap_or('\u{2800}');
386                            (ch, dim_color)
387                        };
388
389                        Span::styled(ch.to_string(), Style::default().fg(color))
390                    })
391                    .collect();
392                Line::from(spans)
393            })
394            .collect()
395    }
396}
397
398// ── Public widget ─────────────────────────────────────────────────────────────
399
400/// A Zed / Claude-style braille loading bar that **bounces** left and right.
401///
402/// Every character cell in the bar is a braille glyph.  A bright arc window
403/// slides across and reverses at each end; the arc edges fade through a
404/// density ramp (`⠉ ⠛ ⠿ ⣿`) for a soft comet-glow look.  The dim
405/// background uses `⣀` (a two-dot rail) so the full bar extent is visible
406/// without competing with the arc.
407///
408/// # Width
409///
410/// Leave [`width`](BarSpinner::width) at its default **`0`** to fill
411/// the available area automatically (most common usage).  Set it to a fixed
412/// positive value if you need a predetermined size.
413///
414/// # Examples
415///
416/// ```no_run
417/// use ratatui::style::Color;
418/// use ratatui::Frame;
419/// use ratatui::layout::Rect;
420/// use tui_spinner::{BarSpinner, BarTrack, Spin};
421///
422/// fn draw(frame: &mut Frame, area: Rect, tick: u64) {
423///     // Fills the full width of `area` — typical Zed/Claude style.
424///     frame.render_widget(
425///         BarSpinner::new(tick)
426///             .arc_color(Color::Cyan)
427///             .dim_color(Color::DarkGray),
428///         area,
429///     );
430/// }
431/// ```
432///
433/// # Field Defaults
434///
435/// | Field           | Default                     |
436/// |-----------------|-----------------------------||
437/// | `track`         | [`BarTrack::Rail`]          |
438/// | `fade_width`    | `3`                         |
439/// | `arc_byte`      | `0xFF` (`⣿`)               |
440/// | `bar_style`     | [`BarStyle::Braille`]       |
441/// | `motion`        | [`BarMotion::Bounce`]       |
442#[derive(Debug, Clone)]
443pub struct BarSpinner<'a> {
444    tick: u64,
445    /// `0` = fill available area; positive = fixed column count.
446    width: usize,
447    /// Height in character rows (minimum 1).
448    height: usize,
449    /// Explicit arc width in character columns (`0` = auto ~⅓ of bar).
450    arc_width: usize,
451    /// Starting direction before the first bounce.
452    spin: Spin,
453    /// Ticks held per animation step (higher = slower).
454    ticks_per_step: u64,
455    /// Colour of the bright arc glyph.
456    arc_color: Color,
457    /// Colour of the dim background track glyph.
458    dim_color: Color,
459    /// Background track style (default [`BarTrack::Rail`]).
460    track: BarTrack,
461    /// Fade ramp width in character columns (default 3; 0 = sharp cutoff).
462    fade_width: usize,
463    /// Braille byte used for the fully-lit arc centre cells (default `0xFF` = `⣿`).
464    arc_byte: u8,
465    /// Symbol style for arc and track glyphs (default [`BarStyle::Braille`]).
466    bar_style: BarStyle,
467    /// Arc motion mode (default [`BarMotion::Bounce`]).
468    motion: BarMotion,
469    block: Option<Block<'a>>,
470    style: Style,
471    alignment: Alignment,
472}
473
474impl<'a> BarSpinner<'a> {
475    // ── Presets ───────────────────────────────────────────────────────────────
476
477    /// **Zed-style** preset — 1 row, cyan arc, subtle Rail track, clockwise.
478    ///
479    /// ```
480    /// use tui_spinner::BarSpinner;
481    /// let s = BarSpinner::zed(42);
482    /// ```
483    #[must_use]
484    pub fn zed(tick: u64) -> Self {
485        Self::new(tick)
486            .height(1)
487            .arc_color(Color::Cyan)
488            .dim_color(Color::DarkGray)
489    }
490
491    /// **Claude-style** preset — 2 rows, warm-orange arc, Rail track, clockwise.
492    ///
493    /// ```
494    /// use tui_spinner::BarSpinner;
495    /// let s = BarSpinner::claude(42);
496    /// ```
497    #[must_use]
498    pub fn claude(tick: u64) -> Self {
499        Self::new(tick)
500            .height(2)
501            .arc_color(Color::Rgb(255, 165, 0))
502            .dim_color(Color::DarkGray)
503    }
504
505    /// **Minimal** preset — 1 row, white arc, Empty track (arc floats on space).
506    ///
507    /// ```
508    /// use tui_spinner::BarSpinner;
509    /// let s = BarSpinner::minimal(42);
510    /// ```
511    #[must_use]
512    pub fn minimal(tick: u64) -> Self {
513        Self::new(tick)
514            .height(1)
515            .arc_color(Color::White)
516            .dim_color(Color::Black)
517            .track(BarTrack::Empty)
518    }
519
520    /// **Solid** preset — 1 row, cyan arc, Full track, sharp zero-fade edges.
521    ///
522    /// ```
523    /// use tui_spinner::BarSpinner;
524    /// let s = BarSpinner::solid(42);
525    /// ```
526    #[must_use]
527    pub fn solid(tick: u64) -> Self {
528        Self::new(tick)
529            .height(1)
530            .arc_color(Color::Cyan)
531            .dim_color(Color::DarkGray)
532            .track(BarTrack::Full)
533            .fade_width(0)
534    }
535
536    /// Creates a new [`BarSpinner`] with defaults:
537    /// auto-width, 1-row height, clockwise start, cyan arc, dark-gray track,
538    /// 1 tick per step, auto arc width.
539    ///
540    /// # Examples
541    ///
542    /// ```
543    /// use tui_spinner::BarSpinner;
544    ///
545    /// let spinner = BarSpinner::new(42);
546    /// ```
547    #[must_use]
548    pub fn new(tick: u64) -> Self {
549        Self {
550            tick,
551            width: 0,
552            height: 1,
553            arc_width: 0,
554            spin: Spin::Clockwise,
555            ticks_per_step: 1,
556            arc_color: Color::Cyan,
557            dim_color: Color::DarkGray,
558            track: BarTrack::Rail,
559            fade_width: 3,
560            arc_byte: 0xFF,
561            bar_style: BarStyle::Braille,
562            motion: BarMotion::Bounce,
563            block: None,
564            style: Style::default(),
565            alignment: Alignment::Left,
566        }
567    }
568
569    /// Sets the fixed width in character columns.
570    ///
571    /// Pass **`0`** (the default) to fill the available area width
572    /// automatically.
573    ///
574    /// # Examples
575    ///
576    /// ```
577    /// use tui_spinner::BarSpinner;
578    ///
579    /// let fixed = BarSpinner::new(0).width(24);
580    /// let auto  = BarSpinner::new(0).width(0); // fills area
581    /// ```
582    #[must_use]
583    pub fn width(mut self, w: usize) -> Self {
584        self.width = w;
585        self
586    }
587
588    /// Sets the height in character rows (minimum 1, default 1).
589    ///
590    /// Use `1` for a thin Zed-style bar or `2`–`3` for a thicker
591    /// Claude-style block.
592    ///
593    /// # Examples
594    ///
595    /// ```
596    /// use tui_spinner::BarSpinner;
597    ///
598    /// let thick = BarSpinner::new(0).height(2);
599    /// ```
600    #[must_use]
601    pub fn height(mut self, h: usize) -> Self {
602        self.height = h.max(1);
603        self
604    }
605
606    /// Sets the arc width in character columns (`0` = auto ~⅓ of bar).
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use tui_spinner::BarSpinner;
612    ///
613    /// let narrow = BarSpinner::new(0).arc_width(6);
614    /// let wide   = BarSpinner::new(0).arc_width(20);
615    /// ```
616    #[must_use]
617    pub fn arc_width(mut self, w: usize) -> Self {
618        self.arc_width = w;
619        self
620    }
621
622    /// Sets the starting direction before the first bounce
623    /// (default: [`Spin::Clockwise`] = starts moving right).
624    ///
625    /// # Examples
626    ///
627    /// ```
628    /// use tui_spinner::{BarSpinner, Spin};
629    ///
630    /// let rtl = BarSpinner::new(0).spin(Spin::CounterClockwise);
631    /// ```
632    #[must_use]
633    pub const fn spin(mut self, spin: Spin) -> Self {
634        self.spin = spin;
635        self
636    }
637
638    /// Sets how many ticks each arc position is held (default 1; higher = slower).
639    ///
640    /// # Examples
641    ///
642    /// ```
643    /// use tui_spinner::BarSpinner;
644    ///
645    /// let slow = BarSpinner::new(0).ticks_per_step(3);
646    /// ```
647    #[must_use]
648    pub fn ticks_per_step(mut self, n: u64) -> Self {
649        self.ticks_per_step = n.max(1);
650        self
651    }
652
653    /// Sets the colour of the bright arc glyph (default: [`Color::Cyan`]).
654    ///
655    /// # Examples
656    ///
657    /// ```
658    /// use ratatui::style::Color;
659    /// use tui_spinner::BarSpinner;
660    ///
661    /// let spinner = BarSpinner::new(0).arc_color(Color::LightBlue);
662    /// ```
663    #[must_use]
664    pub const fn arc_color(mut self, color: Color) -> Self {
665        self.arc_color = color;
666        self
667    }
668
669    /// Sets the colour of the dim background track (default: [`Color::DarkGray`]).
670    ///
671    /// Set to the terminal background colour (e.g. [`Color::Black`]) to hide
672    /// the track so only the glowing arc is visible.
673    ///
674    /// # Examples
675    ///
676    /// ```
677    /// use ratatui::style::Color;
678    /// use tui_spinner::BarSpinner;
679    ///
680    /// // Visible track
681    /// let with_track    = BarSpinner::new(0).dim_color(Color::DarkGray);
682    /// // Arc floats on empty space
683    /// let no_track      = BarSpinner::new(0).dim_color(Color::Black);
684    /// ```
685    #[must_use]
686    pub const fn dim_color(mut self, color: Color) -> Self {
687        self.dim_color = color;
688        self
689    }
690
691    /// Sets both `arc_color` and `dim_color` in one call.
692    ///
693    /// Equivalent to `.arc_color(arc).dim_color(dim)`.
694    ///
695    /// # Examples
696    ///
697    /// ```
698    /// use ratatui::style::Color;
699    /// use tui_spinner::BarSpinner;
700    ///
701    /// let s = BarSpinner::new(0).with_colors(Color::Cyan, Color::DarkGray);
702    /// ```
703    #[must_use]
704    pub fn with_colors(mut self, arc_color: Color, dim_color: Color) -> Self {
705        self.arc_color = arc_color;
706        self.dim_color = dim_color;
707        self
708    }
709
710    /// Sets the background track style (default [`BarTrack::Rail`]).
711    ///
712    /// # Examples
713    ///
714    /// ```
715    /// use tui_spinner::{BarSpinner, BarTrack};
716    ///
717    /// let solid = BarSpinner::new(0).track(BarTrack::Full);
718    /// let float = BarSpinner::new(0).track(BarTrack::Empty);
719    /// ```
720    #[must_use]
721    pub fn track(mut self, track: BarTrack) -> Self {
722        self.track = track;
723        self
724    }
725
726    /// Sets the arc fade-ramp width in character columns (default `3`).
727    ///
728    /// `0` = sharp cutoff — the arc edge is a hard boundary.
729    /// `1`–`3` = progressively softer gradient (default `3` gives `⠉ ⠛ ⠿ ⣿`).
730    ///
731    /// # Examples
732    ///
733    /// ```
734    /// use tui_spinner::BarSpinner;
735    ///
736    /// let sharp = BarSpinner::new(0).fade_width(0);
737    /// let soft  = BarSpinner::new(0).fade_width(3); // default
738    /// ```
739    #[must_use]
740    pub fn fade_width(mut self, w: usize) -> Self {
741        self.fade_width = w;
742        self
743    }
744
745    /// Sets the braille byte for the fully-lit arc centre cells (default `0xFF` = `⣿`).
746    ///
747    /// Use any braille byte to change the arc density.  The fade ramp always
748    /// starts from `⠉` and tapers *up to* this value.
749    ///
750    /// | Example byte | Glyph | Dots |
751    /// |---|---|---|
752    /// | `0xFF` | `⣿` | 8 — full (default) |
753    /// | `0x7F` | `⡿` | 7 |
754    /// | `0x3F` | `⠿` | 6 |
755    /// | `0x1B` | `⠛` | 4 |
756    ///
757    /// # Examples
758    ///
759    /// ```
760    /// use tui_spinner::BarSpinner;
761    ///
762    /// let light = BarSpinner::new(0).arc_char(0x3F); // ⠿ lighter arc
763    /// ```
764    #[must_use]
765    pub fn arc_char(mut self, byte: u8) -> Self {
766        self.arc_byte = byte;
767        self
768    }
769
770    /// Sets the glyph style for the arc and background track
771    /// (default [`BarStyle::Braille`]).
772    ///
773    /// Symbol styles (`Block`, `Shade`, `Dot`, `Diamond`, `Square`) use a
774    /// single Unicode character for the arc and one for the track.  They
775    /// ignore `arc_char`, `track`, and `fade_width`.
776    ///
777    /// # Examples
778    ///
779    /// ```
780    /// use tui_spinner::{BarSpinner, BarStyle};
781    ///
782    /// let block = BarSpinner::new(0).bar_style(BarStyle::Block);
783    /// let dot   = BarSpinner::new(0).bar_style(BarStyle::Dot);
784    /// ```
785    #[must_use]
786    pub fn bar_style(mut self, style: BarStyle) -> Self {
787        self.bar_style = style;
788        self
789    }
790
791    /// Sets the arc motion mode (default [`BarMotion::Bounce`]).
792    ///
793    /// - [`BarMotion::Bounce`] — reverses at each edge (ping-pong).
794    /// - [`BarMotion::Loop`] — wraps around; use with [`Spin`] to set the sweep direction.
795    ///
796    /// # Examples
797    ///
798    /// ```
799    /// use tui_spinner::{BarSpinner, BarMotion, Spin};
800    ///
801    /// // Continuous left-to-right sweep
802    /// let sweep = BarSpinner::new(0)
803    ///     .spin(Spin::Clockwise)
804    ///     .motion(BarMotion::Loop);
805    /// ```
806    #[must_use]
807    pub fn motion(mut self, motion: BarMotion) -> Self {
808        self.motion = motion;
809        self
810    }
811
812    /// Wraps the spinner in a [`Block`].
813    ///
814    /// # Examples
815    ///
816    /// ```
817    /// use ratatui::widgets::Block;
818    /// use tui_spinner::BarSpinner;
819    ///
820    /// let spinner = BarSpinner::new(0)
821    ///     .block(Block::bordered().title("Loading…"));
822    /// ```
823    #[must_use]
824    pub fn block(mut self, block: Block<'a>) -> Self {
825        self.block = Some(block);
826        self
827    }
828
829    /// Sets the base style applied to the widget area.
830    #[must_use]
831    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
832        self.style = style.into();
833        self
834    }
835
836    /// Sets the horizontal alignment of the rendered output (default: left).
837    #[must_use]
838    pub const fn alignment(mut self, alignment: Alignment) -> Self {
839        self.alignment = alignment;
840        self
841    }
842
843    /// Returns the explicit rendered size `(cols, rows)`, or `None` when the
844    /// width is set to auto (`0`).
845    ///
846    /// # Examples
847    ///
848    /// ```
849    /// use tui_spinner::BarSpinner;
850    ///
851    /// assert_eq!(
852    ///     BarSpinner::new(0).width(20).height(2).char_size(),
853    ///     Some((20, 2))
854    /// );
855    /// assert_eq!(BarSpinner::new(0).char_size(), None);
856    /// ```
857    #[must_use]
858    pub fn char_size(&self) -> Option<(usize, usize)> {
859        if self.width == 0 {
860            None
861        } else {
862            Some((self.width.max(3), self.height.max(1)))
863        }
864    }
865
866    fn build_lines(&self, actual_width: usize) -> Vec<Line<'static>> {
867        let w = actual_width.max(3);
868        let mut engine = RectEngine::build(w, self.height, self.arc_width, self.spin, self.motion);
869
870        #[allow(clippy::cast_possible_truncation)]
871        let steps = (self.tick / self.ticks_per_step) as usize;
872        for _ in 0..steps {
873            engine.walk();
874        }
875
876        engine.render_lines(
877            self.arc_color,
878            self.dim_color,
879            self.fade_width,
880            self.track.byte(),
881            self.arc_byte,
882            self.bar_style.chars(),
883        )
884    }
885}
886
887// ── Trait impls ───────────────────────────────────────────────────────────────
888
889impl_styled_for!(BarSpinner<'_>);
890
891impl_widget_via_ref!(BarSpinner<'_>);
892
893impl Widget for &BarSpinner<'_> {
894    fn render(self, area: Rect, buf: &mut Buffer) {
895        if area.area() == 0 {
896            return;
897        }
898
899        buf.set_style(area, self.style);
900
901        let inner_area = self.block.as_ref().map_or(area, |b| {
902            let inner = b.inner(area);
903            Widget::render(b.clone(), area, buf);
904            inner
905        });
906
907        if inner_area.area() == 0 {
908            return;
909        }
910
911        // Resolve width: explicit fixed value, or fill available area.
912        let actual_width = if self.width == 0 {
913            inner_area.width as usize
914        } else {
915            self.width
916        };
917
918        let lines = self.build_lines(actual_width);
919        Paragraph::new(lines)
920            .alignment(self.alignment)
921            .render(inner_area, buf);
922    }
923}
924
925// ── Tests ─────────────────────────────────────────────────────────────────────
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use ratatui::{backend::TestBackend, Terminal};
931
932    // ── Engine: construction ──────────────────────────────────────────────────
933
934    #[test]
935    fn engine_builds_without_panic() {
936        for w in 3..=30usize {
937            for h in 1..=5usize {
938                for spin in [Spin::Clockwise, Spin::CounterClockwise] {
939                    let _ = RectEngine::build(w, h, 0, spin, BarMotion::Bounce);
940                }
941            }
942        }
943    }
944
945    #[test]
946    fn engine_walk_does_not_panic() {
947        let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
948        for _ in 0..1000 {
949            e.walk();
950        }
951    }
952
953    #[test]
954    fn engine_anchor_stays_in_bounds() {
955        let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
956        let max_anchor = e.char_w.saturating_sub(e.arc_cols);
957        for _ in 0..500 {
958            e.walk();
959            assert!(
960                e.anchor <= max_anchor,
961                "anchor={} exceeds max={max_anchor}",
962                e.anchor
963            );
964        }
965    }
966
967    #[test]
968    fn engine_bounces_direction() {
969        let mut e = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
970        assert!(e.going_forward, "should start going forward (CW)");
971
972        // Walk until direction reverses to backward.
973        let mut reversed = false;
974        for _ in 0..100 {
975            e.walk();
976            if !e.going_forward {
977                reversed = true;
978                break;
979            }
980        }
981        assert!(reversed, "engine never reversed to backward");
982
983        // Walk until it reverses back to forward.
984        let mut re_reversed = false;
985        for _ in 0..100 {
986            e.walk();
987            if e.going_forward {
988                re_reversed = true;
989                break;
990            }
991        }
992        assert!(re_reversed, "engine never reversed back to forward");
993    }
994
995    #[test]
996    fn cw_starts_at_left_ccw_at_right() {
997        let cw = RectEngine::build(20, 1, 0, Spin::Clockwise, BarMotion::Bounce);
998        let ccw = RectEngine::build(20, 1, 0, Spin::CounterClockwise, BarMotion::Bounce);
999        assert_eq!(cw.anchor, 0, "CW should start at column 0");
1000        assert!(
1001            ccw.anchor > 0,
1002            "CCW should start at the right (anchor={})",
1003            ccw.anchor
1004        );
1005        assert!(cw.going_forward);
1006        assert!(!ccw.going_forward);
1007    }
1008
1009    // ── Fade ramp ─────────────────────────────────────────────────────────────
1010
1011    #[test]
1012    fn arc_edges_use_fade_bytes() {
1013        // Build a wide engine so there is a full-density centre.
1014        let e = RectEngine::build(20, 1, 12, Spin::Clockwise, BarMotion::Bounce);
1015        let lines = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
1016        assert_eq!(lines.len(), 1);
1017        let spans = &lines[0].spans;
1018
1019        // The outermost arc column (index anchor+0) should be FADE[0] = 0x09.
1020        let outer = char::from_u32(0x2800 + u32::from(FADE[0])).unwrap();
1021        assert_eq!(
1022            spans[e.anchor].content.chars().next(),
1023            Some(outer),
1024            "outermost arc edge should be FADE[0]"
1025        );
1026
1027        // The centre arc column (index anchor + arc_cols/2) should be FADE[3] = 0xFF.
1028        let centre_idx = e.anchor + e.arc_cols / 2;
1029        let full = char::from_u32(0x2800 + u32::from(FADE[3])).unwrap();
1030        assert_eq!(
1031            spans[centre_idx].content.chars().next(),
1032            Some(full),
1033            "arc centre should be full density FADE[3]"
1034        );
1035    }
1036
1037    #[test]
1038    fn dim_columns_use_dim_byte() {
1039        let e = RectEngine::build(20, 1, 6, Spin::Clockwise, BarMotion::Bounce);
1040        let lines = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
1041        let spans = &lines[0].spans;
1042        let dim_char = char::from_u32(0x2800 + u32::from(DIM_BYTE)).unwrap();
1043        // Columns before the arc anchor should all be DIM_BYTE.
1044        for i in 0..e.anchor {
1045            assert_eq!(
1046                spans[i].content.chars().next(),
1047                Some(dim_char),
1048                "column {i} should be dim"
1049            );
1050        }
1051    }
1052
1053    // ── Widget output ─────────────────────────────────────────────────────────
1054
1055    #[test]
1056    fn build_lines_height_matches() {
1057        for h in 1..=5usize {
1058            let lines = BarSpinner::new(0).width(20).height(h).build_lines(20);
1059            assert_eq!(lines.len(), h, "height={h}");
1060        }
1061    }
1062
1063    #[test]
1064    fn build_lines_width_matches() {
1065        let w = 24usize;
1066        let lines = BarSpinner::new(0).width(w).height(1).build_lines(w);
1067        assert_eq!(lines[0].spans.len(), w, "each line should have {w} spans");
1068    }
1069
1070    #[test]
1071    fn different_ticks_produce_different_output() {
1072        let a = BarSpinner::new(0).width(20).height(1).build_lines(20);
1073        let b = BarSpinner::new(8).width(20).height(1).build_lines(20);
1074        assert_ne!(a, b, "tick=0 and tick=8 should differ");
1075    }
1076
1077    #[test]
1078    fn cw_and_ccw_differ_at_same_tick() {
1079        let cw = BarSpinner::new(5)
1080            .width(20)
1081            .height(1)
1082            .spin(Spin::Clockwise)
1083            .build_lines(20);
1084        let ccw = BarSpinner::new(5)
1085            .width(20)
1086            .height(1)
1087            .spin(Spin::CounterClockwise)
1088            .build_lines(20);
1089        assert_ne!(cw, ccw, "CW and CCW should differ at the same tick");
1090    }
1091
1092    #[test]
1093    fn ticks_per_step_slows_animation() {
1094        let fast = BarSpinner::new(10)
1095            .width(20)
1096            .height(1)
1097            .ticks_per_step(1)
1098            .build_lines(20);
1099        let slow = BarSpinner::new(10)
1100            .width(20)
1101            .height(1)
1102            .ticks_per_step(5)
1103            .build_lines(20);
1104        assert_ne!(
1105            fast, slow,
1106            "different speeds should produce different output"
1107        );
1108    }
1109
1110    #[test]
1111    fn arc_width_override_respected() {
1112        let e = RectEngine::build(20, 1, 7, Spin::Clockwise, BarMotion::Bounce);
1113        assert_eq!(e.arc_cols, 7);
1114    }
1115
1116    // ── Widget rendering ──────────────────────────────────────────────────────
1117
1118    #[test]
1119    fn widget_renders_without_panic() {
1120        let backend = TestBackend::new(40, 3);
1121        let mut terminal = Terminal::new(backend).unwrap();
1122        terminal
1123            .draw(|frame| {
1124                frame.render_widget(BarSpinner::new(42), frame.area());
1125            })
1126            .unwrap();
1127    }
1128
1129    #[test]
1130    fn widget_fixed_width_renders_without_panic() {
1131        let backend = TestBackend::new(40, 3);
1132        let mut terminal = Terminal::new(backend).unwrap();
1133        terminal
1134            .draw(|frame| {
1135                frame.render_widget(BarSpinner::new(42).width(24).height(2), frame.area());
1136            })
1137            .unwrap();
1138    }
1139
1140    #[test]
1141    fn widget_zero_area_no_panic() {
1142        let backend = TestBackend::new(0, 0);
1143        let mut terminal = Terminal::new(backend).unwrap();
1144        terminal
1145            .draw(|frame| {
1146                frame.render_widget(BarSpinner::new(0), frame.area());
1147            })
1148            .unwrap();
1149    }
1150
1151    #[test]
1152    fn char_size_fixed_width() {
1153        let s = BarSpinner::new(0).width(20).height(2);
1154        assert_eq!(s.char_size(), Some((20, 2)));
1155    }
1156
1157    #[test]
1158    fn char_size_auto_width_is_none() {
1159        let s = BarSpinner::new(0); // width = 0 = auto
1160        assert_eq!(s.char_size(), None);
1161    }
1162
1163    #[test]
1164    fn char_size_clamps_minimum() {
1165        let s = BarSpinner::new(0).width(1).height(0);
1166        if let Some((w, h)) = s.char_size() {
1167            assert!(w >= 3, "width clamped to at least 3");
1168            assert!(h >= 1, "height clamped to at least 1");
1169        }
1170    }
1171
1172    // ── Builder chain ─────────────────────────────────────────────────────────
1173
1174    #[test]
1175    fn builder_chain() {
1176        use ratatui::widgets::Block;
1177        let s = BarSpinner::new(0)
1178            .width(24)
1179            .height(2)
1180            .arc_width(8)
1181            .spin(Spin::CounterClockwise)
1182            .ticks_per_step(3)
1183            .arc_color(Color::Blue)
1184            .dim_color(Color::Black)
1185            .block(Block::bordered())
1186            .alignment(Alignment::Center);
1187        assert_eq!(s.width, 24);
1188        assert_eq!(s.height, 2);
1189        assert_eq!(s.arc_width, 8);
1190        assert!(matches!(s.spin, Spin::CounterClockwise));
1191        assert_eq!(s.ticks_per_step, 3);
1192        assert_eq!(s.arc_color, Color::Blue);
1193        assert_eq!(s.dim_color, Color::Black);
1194    }
1195
1196    #[test]
1197    fn arc_char_changes_centre_byte() {
1198        let e = RectEngine::build(20, 1, 10, Spin::Clockwise, BarMotion::Bounce);
1199        // Default arc_byte = 0xFF → centre cell is ⣿
1200        let lines_default = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0xFF, None);
1201        // Custom arc_byte = 0x3F → centre cell is ⠿
1202        let lines_custom = e.render_lines(Color::Cyan, Color::DarkGray, 3, DIM_BYTE, 0x3F, None);
1203        assert_ne!(
1204            lines_default, lines_custom,
1205            "different arc_byte produces different output"
1206        );
1207        // The centre span in lines_custom should be ⠿ (U+283F)
1208        let centre_idx = e.anchor + e.arc_cols / 2;
1209        let centre_char = lines_custom[0].spans[centre_idx]
1210            .content
1211            .chars()
1212            .next()
1213            .unwrap();
1214        assert_eq!(
1215            centre_char, '\u{283F}',
1216            "centre cell should be ⠿ when arc_byte=0x3F"
1217        );
1218    }
1219
1220    #[test]
1221    fn preset_zed_defaults() {
1222        let s = BarSpinner::zed(0);
1223        assert_eq!(s.height, 1);
1224        assert_eq!(s.arc_color, Color::Cyan);
1225    }
1226
1227    #[test]
1228    fn preset_solid_has_full_track_and_zero_fade() {
1229        let s = BarSpinner::solid(0);
1230        assert_eq!(s.track, BarTrack::Full);
1231        assert_eq!(s.fade_width, 0);
1232    }
1233
1234    #[test]
1235    fn track_and_fade_width_builder() {
1236        let s = BarSpinner::new(0).track(BarTrack::Full).fade_width(0);
1237        assert_eq!(s.track, BarTrack::Full);
1238        assert_eq!(s.fade_width, 0);
1239
1240        // Sharp fade: every arc cell should show full density (FADE[3] = 0xFF).
1241        let lines = s.width(12).build_lines(12);
1242        // All spans should be ⣿ (U+28FF) in arc_color OR ⣿ in dim_color (Full track).
1243        for line in &lines {
1244            for span in &line.spans {
1245                let ch = span.content.chars().next().unwrap();
1246                assert_eq!(ch, '\u{28FF}', "sharp fade + Full track → every cell is ⣿");
1247            }
1248        }
1249    }
1250
1251    // ── with_colors convenience ────────────────────────────────────────────────
1252
1253    #[test]
1254    fn with_colors_sets_both() {
1255        use ratatui::style::Color;
1256        let s = BarSpinner::new(0).with_colors(Color::Red, Color::Blue);
1257        assert_eq!(s.arc_color, Color::Red);
1258        assert_eq!(s.dim_color, Color::Blue);
1259    }
1260
1261    // ── Arc width clamping ────────────────────────────────────────────────────
1262
1263    #[test]
1264    fn arc_width_larger_than_bar_is_clamped() {
1265        // arc_width > char_w should be clamped to char_w - 1 inside the engine.
1266        let e = RectEngine::build(10, 1, 99, Spin::Clockwise, BarMotion::Bounce);
1267        assert!(e.arc_cols < e.char_w, "arc_cols must be < char_w");
1268    }
1269
1270    #[test]
1271    fn arc_width_zero_uses_auto() {
1272        let e = RectEngine::build(30, 1, 0, Spin::Clockwise, BarMotion::Bounce);
1273        assert!(e.arc_cols >= 4, "auto arc should be at least 4 cols");
1274        assert!(e.arc_cols < e.char_w);
1275    }
1276
1277    // ── Loop wraps correctly across the full range ────────────────────────────
1278
1279    #[test]
1280    fn loop_all_columns_lit_over_full_cycle() {
1281        let width = 12usize;
1282        let arc_cols = 4usize;
1283        let mut e = RectEngine::build(width, 1, arc_cols, Spin::Clockwise, BarMotion::Loop);
1284        let mut ever_lit = vec![false; width];
1285
1286        // Walk one full cycle (char_w steps) and record which columns are lit.
1287        for _ in 0..width {
1288            let lines = e.render_lines(
1289                ratatui::style::Color::Cyan,
1290                ratatui::style::Color::DarkGray,
1291                3,
1292                DIM_BYTE,
1293                0xFF,
1294                None,
1295            );
1296            for (ci, span) in lines[0].spans.iter().enumerate() {
1297                if span.style.fg == Some(ratatui::style::Color::Cyan) {
1298                    ever_lit[ci] = true;
1299                }
1300            }
1301            e.walk();
1302        }
1303
1304        // Every column should have been lit at least once.
1305        for (ci, &lit) in ever_lit.iter().enumerate() {
1306            assert!(lit, "column {ci} was never lit during a full Loop cycle");
1307        }
1308    }
1309
1310    // ── BarStyle produces expected characters ─────────────────────────────────
1311
1312    #[test]
1313    fn non_braille_style_chars_match_declaration() {
1314        for (style, expected_arc, expected_track) in [
1315            (BarStyle::Block, '\u{2588}', '\u{2591}'),
1316            (BarStyle::Dot, '\u{25CF}', '\u{00B7}'),
1317            (BarStyle::Star, '\u{2605}', '\u{2606}'),
1318            (BarStyle::Progress, '\u{25B0}', '\u{25B1}'),
1319        ] {
1320            let Some((arc, track)) = style.chars() else {
1321                panic!("{style:?} should have char pair");
1322            };
1323            assert_eq!(arc, expected_arc, "{style:?} arc char mismatch");
1324            assert_eq!(track, expected_track, "{style:?} track char mismatch");
1325        }
1326    }
1327
1328    #[test]
1329    fn bar_style_block_produces_non_braille_chars() {
1330        let lines = BarSpinner::new(0)
1331            .width(20)
1332            .bar_style(BarStyle::Block)
1333            .build_lines(20);
1334        // Every character should be either █ or ░
1335        for line in &lines {
1336            for span in &line.spans {
1337                let ch = span.content.chars().next().unwrap();
1338                assert!(
1339                    ch == '█' || ch == '░',
1340                    "Block style: unexpected char U+{:04X}",
1341                    ch as u32
1342                );
1343            }
1344        }
1345    }
1346
1347    #[test]
1348    fn all_non_braille_styles_have_char_pairs() {
1349        let styles = [
1350            BarStyle::Block,
1351            BarStyle::Shade,
1352            BarStyle::Dot,
1353            BarStyle::Diamond,
1354            BarStyle::Square,
1355            BarStyle::Star,
1356            BarStyle::Heart,
1357            BarStyle::Arrow,
1358            BarStyle::Circle,
1359            BarStyle::Spark,
1360            BarStyle::Cross,
1361            BarStyle::Progress,
1362            BarStyle::Thick,
1363            BarStyle::Wave,
1364            BarStyle::Pip,
1365        ];
1366        for style in styles {
1367            assert!(style.chars().is_some(), "{style:?} should have a char pair");
1368        }
1369        assert!(BarStyle::Braille.chars().is_none());
1370    }
1371
1372    #[test]
1373    fn bar_style_builder() {
1374        let s = BarSpinner::new(0).bar_style(BarStyle::Dot);
1375        assert_eq!(s.bar_style, BarStyle::Dot);
1376    }
1377
1378    // ── BarMotion ──────────────────────────────────────────────────────────────────
1379
1380    #[test]
1381    fn loop_motion_wraps_at_right_edge() {
1382        let mut e = RectEngine::build(20, 1, 4, Spin::Clockwise, BarMotion::Loop);
1383        // Loop anchor travels 0..char_w modularly (not 0..char_w-arc_cols).
1384        let last = e.char_w - 1;
1385        for _ in 0..200 {
1386            if e.anchor == last {
1387                break;
1388            }
1389            e.walk();
1390        }
1391        assert_eq!(e.anchor, last, "anchor should reach char_w-1");
1392        e.walk();
1393        assert_eq!(e.anchor, 0, "Loop CW should wrap to 0");
1394        assert!(e.going_forward, "Loop must not flip direction");
1395    }
1396
1397    #[test]
1398    fn loop_motion_wraps_at_left_edge_ccw() {
1399        let mut e = RectEngine::build(20, 1, 4, Spin::CounterClockwise, BarMotion::Loop);
1400        // CCW starts at char_w - arc_cols; walk it to 0.
1401        for _ in 0..200 {
1402            if e.anchor == 0 {
1403                break;
1404            }
1405            e.walk();
1406        }
1407        assert_eq!(e.anchor, 0);
1408        e.walk();
1409        assert_eq!(e.anchor, e.char_w - 1, "CCW Loop should wrap to char_w-1");
1410        assert!(!e.going_forward);
1411    }
1412
1413    #[test]
1414    fn bounce_still_reverses() {
1415        let mut e = RectEngine::build(20, 1, 4, Spin::Clockwise, BarMotion::Bounce);
1416        let max = e.char_w - e.arc_cols;
1417        for _ in 0..100 {
1418            if e.anchor == max {
1419                break;
1420            }
1421            e.walk();
1422        }
1423        e.walk();
1424        assert!(!e.going_forward, "Bounce must reverse at max_anchor");
1425    }
1426
1427    #[test]
1428    fn motion_builder() {
1429        let s = BarSpinner::new(0).motion(BarMotion::Loop);
1430        assert_eq!(s.motion, BarMotion::Loop);
1431    }
1432}