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}