Skip to main content

ratatui_opentui_loader/
lib.rs

1//! # ratatui-opentui-loader
2//!
3//! A KITT-style (Knight Rider) scanner/loader widget for
4//! [ratatui](https://ratatui.rs), inspired by the opencode/opentui spinner.
5//!
6//! A bright dot bounces left and right with a fading color trail behind it,
7//! using block characters (`■` / `⬝`). During the brief pause at each edge,
8//! all inactive dots fade out and then fade back in as the dot resumes — just
9//! like the original opencode loader.
10//!
11//! ## Quick start
12//!
13//! ```rust,ignore
14//! use ratatui_opentui_loader::KittLoader;
15//!
16//! let mut loader = KittLoader::new();
17//! // each tick (~40ms), call loader.tick() then render:
18//! loader.tick();
19//! frame.render_widget(&loader, area);
20//! ```
21
22use ratatui::{
23    buffer::Buffer,
24    layout::Rect,
25    style::{Color, Style},
26    text::{Line, Span},
27    widgets::{Paragraph, Widget},
28};
29
30/// Pre-built color themes matching opencode's theme collection.
31///
32/// Each variant uses the dark-mode primary color from the corresponding
33/// opencode theme. Use [`Theme::Custom`] for any color not listed here.
34#[derive(Debug, Clone, Copy)]
35pub enum Theme {
36    Opencode,
37    Dracula,
38    Gruvbox,
39    Catppuccin,
40    CatppuccinFrappe,
41    CatppuccinMacchiato,
42    Nord,
43    Tokyonight,
44    Solarized,
45    Rosepine,
46    Ayu,
47    Monokai,
48    OneDark,
49    Kanagawa,
50    Material,
51    Everforest,
52    Github,
53    Amoled,
54    Aura,
55    Carbonfox,
56    Cobalt2,
57    Cursor,
58    Flexoki,
59    Matrix,
60    Mercury,
61    Nightowl,
62    Palenight,
63    ShadesOfPurple,
64    Synthwave84,
65    Vesper,
66    Zenburn,
67    Vercel,
68    Orng,
69    OsakaJade,
70    /// Custom single accent color
71    Custom(Color),
72}
73
74impl Theme {
75    /// Returns all built-in theme variants (excludes `Custom`).
76    pub fn all() -> &'static [Theme] {
77        &[
78            Theme::Opencode,
79            Theme::Dracula,
80            Theme::Gruvbox,
81            Theme::Catppuccin,
82            Theme::CatppuccinFrappe,
83            Theme::CatppuccinMacchiato,
84            Theme::Nord,
85            Theme::Tokyonight,
86            Theme::Solarized,
87            Theme::Rosepine,
88            Theme::Ayu,
89            Theme::Monokai,
90            Theme::OneDark,
91            Theme::Kanagawa,
92            Theme::Material,
93            Theme::Everforest,
94            Theme::Github,
95            Theme::Amoled,
96            Theme::Aura,
97            Theme::Carbonfox,
98            Theme::Cobalt2,
99            Theme::Cursor,
100            Theme::Flexoki,
101            Theme::Matrix,
102            Theme::Mercury,
103            Theme::Nightowl,
104            Theme::Palenight,
105            Theme::ShadesOfPurple,
106            Theme::Synthwave84,
107            Theme::Vesper,
108            Theme::Zenburn,
109            Theme::Vercel,
110            Theme::Orng,
111            Theme::OsakaJade,
112        ]
113    }
114
115    /// Human-readable name matching the opencode theme id.
116    pub fn name(&self) -> &'static str {
117        match self {
118            Theme::Opencode => "opencode",
119            Theme::Dracula => "dracula",
120            Theme::Gruvbox => "gruvbox",
121            Theme::Catppuccin => "catppuccin",
122            Theme::CatppuccinFrappe => "catppuccin-frappe",
123            Theme::CatppuccinMacchiato => "catppuccin-macchiato",
124            Theme::Nord => "nord",
125            Theme::Tokyonight => "tokyonight",
126            Theme::Solarized => "solarized",
127            Theme::Rosepine => "rosepine",
128            Theme::Ayu => "ayu",
129            Theme::Monokai => "monokai",
130            Theme::OneDark => "one-dark",
131            Theme::Kanagawa => "kanagawa",
132            Theme::Material => "material",
133            Theme::Everforest => "everforest",
134            Theme::Github => "github",
135            Theme::Amoled => "amoled",
136            Theme::Aura => "aura",
137            Theme::Carbonfox => "carbonfox",
138            Theme::Cobalt2 => "cobalt2",
139            Theme::Cursor => "cursor",
140            Theme::Flexoki => "flexoki",
141            Theme::Matrix => "matrix",
142            Theme::Mercury => "mercury",
143            Theme::Nightowl => "nightowl",
144            Theme::Palenight => "palenight",
145            Theme::ShadesOfPurple => "shadesofpurple",
146            Theme::Synthwave84 => "synthwave84",
147            Theme::Vesper => "vesper",
148            Theme::Zenburn => "zenburn",
149            Theme::Vercel => "vercel",
150            Theme::Orng => "orng",
151            Theme::OsakaJade => "osaka-jade",
152            Theme::Custom(_) => "custom",
153        }
154    }
155
156    fn accent(&self) -> Color {
157        match self {
158            Theme::Opencode => Color::Rgb(0xfa, 0xb2, 0x83),
159            Theme::Dracula => Color::Rgb(0xbd, 0x93, 0xf9),
160            Theme::Gruvbox => Color::Rgb(0x83, 0xa5, 0x98),
161            Theme::Catppuccin => Color::Rgb(0xb4, 0xbe, 0xfe),
162            Theme::CatppuccinFrappe => Color::Rgb(0x8d, 0xa4, 0xe2),
163            Theme::CatppuccinMacchiato => Color::Rgb(0x8a, 0xad, 0xf4),
164            Theme::Nord => Color::Rgb(0x88, 0xc0, 0xd0),
165            Theme::Tokyonight => Color::Rgb(0x7a, 0xa2, 0xf7),
166            Theme::Solarized => Color::Rgb(0x6c, 0x71, 0xc4),
167            Theme::Rosepine => Color::Rgb(0x9c, 0xcf, 0xd8),
168            Theme::Ayu => Color::Rgb(0x3f, 0xb7, 0xe3),
169            Theme::Monokai => Color::Rgb(0xae, 0x81, 0xff),
170            Theme::OneDark => Color::Rgb(0x61, 0xaf, 0xef),
171            Theme::Kanagawa => Color::Rgb(0x7e, 0x9c, 0xd8),
172            Theme::Material => Color::Rgb(0x82, 0xaa, 0xff),
173            Theme::Everforest => Color::Rgb(0xa7, 0xc0, 0x80),
174            Theme::Github => Color::Rgb(0x58, 0xa6, 0xff),
175            Theme::Amoled => Color::Rgb(0xb3, 0x88, 0xff),
176            Theme::Aura => Color::Rgb(0xa2, 0x77, 0xff),
177            Theme::Carbonfox => Color::Rgb(0x33, 0xb1, 0xff),
178            Theme::Cobalt2 => Color::Rgb(0x00, 0x88, 0xff),
179            Theme::Cursor => Color::Rgb(0x88, 0xc0, 0xd0),
180            Theme::Flexoki => Color::Rgb(0xda, 0x70, 0x2c),
181            Theme::Matrix => Color::Rgb(0x2e, 0xff, 0x6a),
182            Theme::Mercury => Color::Rgb(0x8d, 0xa4, 0xf5),
183            Theme::Nightowl => Color::Rgb(0x82, 0xaa, 0xff),
184            Theme::Palenight => Color::Rgb(0x82, 0xaa, 0xff),
185            Theme::ShadesOfPurple => Color::Rgb(0xc7, 0x92, 0xff),
186            Theme::Synthwave84 => Color::Rgb(0x36, 0xf9, 0xf6),
187            Theme::Vesper => Color::Rgb(0xff, 0xc7, 0x99),
188            Theme::Zenburn => Color::Rgb(0x8c, 0xd0, 0xd3),
189            Theme::Vercel => Color::Rgb(0x00, 0x70, 0xf3),
190            Theme::Orng => Color::Rgb(0xec, 0x5b, 0x2b),
191            Theme::OsakaJade => Color::Rgb(0x2d, 0xd5, 0xb7),
192            Theme::Custom(c) => *c,
193        }
194    }
195}
196
197fn rgb_components(c: Color) -> (u8, u8, u8) {
198    match c {
199        Color::Rgb(r, g, b) => (r, g, b),
200        _ => (255, 0, 0),
201    }
202}
203
204/// Derive a trail of colors from a single accent color.
205fn derive_trail(accent: Color, steps: usize) -> Vec<Color> {
206    let (r, g, b) = rgb_components(accent);
207    (0..steps)
208        .map(|i| {
209            if i == 0 {
210                // Head: full brightness
211                Color::Rgb(r, g, b)
212            } else {
213                let factor = 0.65_f64.powi(i as i32);
214                Color::Rgb(
215                    (r as f64 * factor) as u8,
216                    (g as f64 * factor) as u8,
217                    (b as f64 * factor) as u8,
218                )
219            }
220        })
221        .collect()
222}
223
224/// Derive the dim "inactive dot" color, scaled by `factor` (0.0–1.0).
225fn derive_inactive(accent: Color, factor: f64) -> Color {
226    let (r, g, b) = rgb_components(accent);
227    Color::Rgb(
228        (r as f64 * factor) as u8,
229        (g as f64 * factor) as u8,
230        (b as f64 * factor) as u8,
231    )
232}
233
234/// Snapshot of where the scanner head is on a given frame.
235struct ScannerState {
236    active_pos: usize,
237    is_forward: bool,
238    is_holding: bool,
239    /// 0.0–1.0 progress through the current hold phase
240    hold_progress: f64,
241    /// Absolute frame count into the hold (not normalized)
242    hold_frame: usize,
243}
244
245/// A KITT-style scanner loader widget.
246///
247/// Call [`tick()`](KittLoader::tick) each frame (~40ms) to advance the
248/// animation, then render with `frame.render_widget(&loader, area)`.
249#[derive(Debug, Clone)]
250pub struct KittLoader {
251    width: usize,
252    trail_colors: Vec<Color>,
253    inactive_color: Color,
254    accent: Color,
255    inactive_factor: f64,
256    /// Minimum brightness multiplier at full fade (0.0 = fully dark, 1.0 = no fade)
257    min_fade: f64,
258    /// If true, head is darkest and trail gets brighter (for light backgrounds)
259    inverted: bool,
260    frame_index: usize,
261    total_frames: usize,
262    hold_start: usize,
263    hold_end: usize,
264}
265
266impl KittLoader {
267    /// Create a loader with default settings (width 8, opencode theme).
268    pub fn new() -> Self {
269        Self::with_theme(Theme::Opencode)
270    }
271
272    /// Create a loader with a specific theme.
273    pub fn with_theme(theme: Theme) -> Self {
274        Self::with_color(theme.accent())
275    }
276
277    /// Create a loader with a custom accent color.
278    pub fn with_color(accent: Color) -> Self {
279        Self::build(accent, 8, 6, 30, 9, 0.25, 0.55)
280    }
281
282    /// Full builder.
283    ///
284    /// - `accent` – the bright head color
285    /// - `width` – number of character cells
286    /// - `trail_steps` – length of the fading trail
287    /// - `hold_start` – frames to pause at the left edge
288    /// - `hold_end` – frames to pause at the right edge
289    /// - `inactive_factor` – brightness multiplier for inactive dots (0.0–1.0)
290    /// - `min_fade` – minimum brightness at full fade-out (0.0–1.0)
291    pub fn build(
292        accent: Color,
293        width: usize,
294        trail_steps: usize,
295        hold_start: usize,
296        hold_end: usize,
297        inactive_factor: f64,
298        min_fade: f64,
299    ) -> Self {
300        let trail_colors = derive_trail(accent, trail_steps);
301        let inactive_color = derive_inactive(accent, inactive_factor);
302        let total_frames = width + hold_end + (width - 1) + hold_start;
303
304        Self {
305            width,
306            trail_colors,
307            inactive_color,
308            accent,
309            inactive_factor,
310            min_fade,
311            inverted: false,
312            frame_index: 0,
313            total_frames,
314            hold_start,
315            hold_end,
316        }
317    }
318
319    /// Change the theme at runtime.
320    pub fn set_theme(&mut self, theme: Theme) {
321        self.set_color(theme.accent());
322    }
323
324    /// Invert the trail gradient (darkest at head, brightest in tail).
325    /// Use this on light terminal backgrounds where the accent color
326    /// would otherwise blend into the background.
327    pub fn inverted(mut self, inv: bool) -> Self {
328        self.inverted = inv;
329        self
330    }
331
332    /// Change the accent color at runtime.
333    pub fn set_color(&mut self, accent: Color) {
334        self.accent = accent;
335        self.trail_colors = derive_trail(accent, self.trail_colors.len());
336        self.inactive_color = derive_inactive(accent, self.inactive_factor);
337    }
338
339    /// Advance the animation by one frame.
340    pub fn tick(&mut self) {
341        self.frame_index = (self.frame_index + 1) % self.total_frames;
342    }
343
344    fn scanner_state(&self) -> ScannerState {
345        let fi = self.frame_index;
346        let w = self.width;
347        let he = self.hold_end;
348        let hs = self.hold_start;
349        let backward_frames = w - 1;
350
351        if fi < w {
352            ScannerState {
353                active_pos: fi,
354                is_forward: true,
355                is_holding: false,
356                hold_progress: 0.0,
357                hold_frame: 0,
358            }
359        } else if fi < w + he {
360            let p = fi - w;
361            ScannerState {
362                active_pos: w - 1,
363                is_forward: true,
364                is_holding: true,
365                hold_progress: if he > 0 { p as f64 / he as f64 } else { 1.0 },
366                hold_frame: p,
367            }
368        } else if fi < w + he + backward_frames {
369            let back_i = fi - w - he;
370            ScannerState {
371                active_pos: w - 2 - back_i,
372                is_forward: false,
373                is_holding: false,
374                hold_progress: 0.0,
375                hold_frame: 0,
376            }
377        } else {
378            let p = fi - w - he - backward_frames;
379            ScannerState {
380                active_pos: 0,
381                is_forward: false,
382                is_holding: true,
383                hold_progress: if hs > 0 { p as f64 / hs as f64 } else { 1.0 },
384                hold_frame: p,
385            }
386        }
387    }
388
389    /// Render to a [`Line`] with explicit width.
390    pub fn into_line(&self, render_width: usize) -> Line<'static> {
391        let w = self.width.min(render_width);
392        if w == 0 {
393            return Line::default();
394        }
395
396        let state = self.scanner_state();
397
398        // Compute the global fade factor for inactive dots.
399        // During hold: fade from 1.0 down to min_fade (breathing out).
400        // During movement: always full brightness — no slowdown.
401        let fade = if state.is_holding {
402            let p = state.hold_progress.min(1.0);
403            1.0 - p * (1.0 - self.min_fade)
404        } else {
405            1.0
406        };
407
408        // Pre-compute the faded inactive color
409        let faded_inactive = self.apply_fade(self.inactive_color, fade);
410
411        let spans: Vec<Span<'static>> = (0..w)
412            .map(|i| {
413                // Directional distance: positive = trailing behind the head
414                let dist = if state.is_forward {
415                    state.active_pos as i32 - i as i32
416                } else {
417                    i as i32 - state.active_pos as i32
418                };
419
420                // During hold, shift trail by absolute frame count (1 per frame)
421                // so the trail dissolves at the same speed on both edges
422                let effective_dist = if state.is_holding {
423                    dist + state.hold_frame as i32
424                } else {
425                    dist
426                };
427
428                if effective_dist >= 0 && (effective_dist as usize) < self.trail_colors.len() {
429                    let idx = if self.inverted {
430                        self.trail_colors.len() - 1 - effective_dist as usize
431                    } else {
432                        effective_dist as usize
433                    };
434                    let color = self.trail_colors[idx];
435                    Span::styled("■".to_string(), Style::default().fg(color))
436                } else {
437                    Span::styled("⬝".to_string(), Style::default().fg(faded_inactive))
438                }
439            })
440            .collect();
441
442        Line::from(spans)
443    }
444
445    /// Scale a color's brightness by `fade` (0.0–1.0).
446    fn apply_fade(&self, color: Color, fade: f64) -> Color {
447        let (r, g, b) = rgb_components(color);
448        Color::Rgb(
449            (r as f64 * fade) as u8,
450            (g as f64 * fade) as u8,
451            (b as f64 * fade) as u8,
452        )
453    }
454}
455
456impl Default for KittLoader {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462impl Widget for &KittLoader {
463    fn render(self, area: Rect, buf: &mut Buffer) {
464        Paragraph::new(self.into_line(area.width as usize)).render(area, buf);
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn default_creates_8_wide() {
474        let loader = KittLoader::new();
475        assert_eq!(loader.width, 8);
476        assert_eq!(loader.trail_colors.len(), 6);
477    }
478
479    #[test]
480    fn tick_wraps_around() {
481        let mut loader = KittLoader::new();
482        for _ in 0..loader.total_frames {
483            loader.tick();
484        }
485        assert_eq!(loader.frame_index, 0);
486    }
487
488    #[test]
489    fn into_line_correct_width() {
490        let loader = KittLoader::new();
491        let line = loader.into_line(8);
492        assert_eq!(line.spans.len(), 8);
493    }
494
495    #[test]
496    fn zero_width_line() {
497        let loader = KittLoader::new();
498        let line = loader.into_line(0);
499        assert!(line.spans.is_empty());
500    }
501
502    #[test]
503    fn theme_changes_color() {
504        let mut loader = KittLoader::with_theme(Theme::Dracula);
505        assert_eq!(loader.trail_colors[0], Color::Rgb(0xbd, 0x93, 0xf9));
506        loader.set_theme(Theme::Matrix);
507        assert_eq!(loader.trail_colors[0], Color::Rgb(0x2e, 0xff, 0x6a));
508    }
509
510    #[test]
511    fn fading_during_hold() {
512        let mut loader = KittLoader::new();
513        // Advance through forward + hold_end + backward to reach hold-at-start
514        let ticks_to_hold_start = loader.width + loader.hold_end + (loader.width - 1);
515        for _ in 0..ticks_to_hold_start {
516            loader.tick();
517        }
518        let state = loader.scanner_state();
519        assert!(state.is_holding);
520        assert_eq!(state.active_pos, 0);
521    }
522
523    #[test]
524    fn fade_at_hold_produces_dimmer_color() {
525        let mut loader = KittLoader::new();
526        // Advance to the hold-at-start phase
527        let ticks_to_hold_start = loader.width + loader.hold_end + (loader.width - 1);
528        for _ in 0..ticks_to_hold_start {
529            loader.tick();
530        }
531        let line_start = loader.into_line(8);
532        // Advance near end of hold
533        for _ in 0..loader.hold_start - 1 {
534            loader.tick();
535        }
536        let line_end = loader.into_line(8);
537        // The inactive dot at position 7 should be dimmer at end of hold
538        let start_fg = line_start.spans[7].style.fg.unwrap();
539        let end_fg = line_end.spans[7].style.fg.unwrap();
540        let (sr, _, _) = rgb_components(start_fg);
541        let (er, _, _) = rgb_components(end_fg);
542        assert!(er <= sr, "inactive dot should get dimmer during hold");
543    }
544}