1use ratatui::{
23 buffer::Buffer,
24 layout::Rect,
25 style::{Color, Style},
26 text::{Line, Span},
27 widgets::{Paragraph, Widget},
28};
29
30#[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(Color),
72}
73
74impl Theme {
75 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 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
204fn 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 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
224fn 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
234struct ScannerState {
236 active_pos: usize,
237 is_forward: bool,
238 is_holding: bool,
239 hold_progress: f64,
241 hold_frame: usize,
243}
244
245#[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 min_fade: f64,
258 inverted: bool,
260 frame_index: usize,
261 total_frames: usize,
262 hold_start: usize,
263 hold_end: usize,
264}
265
266impl KittLoader {
267 pub fn new() -> Self {
269 Self::with_theme(Theme::Opencode)
270 }
271
272 pub fn with_theme(theme: Theme) -> Self {
274 Self::with_color(theme.accent())
275 }
276
277 pub fn with_color(accent: Color) -> Self {
279 Self::build(accent, 8, 6, 30, 9, 0.25, 0.55)
280 }
281
282 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 pub fn set_theme(&mut self, theme: Theme) {
321 self.set_color(theme.accent());
322 }
323
324 pub fn inverted(mut self, inv: bool) -> Self {
328 self.inverted = inv;
329 self
330 }
331
332 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 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 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 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 let faded_inactive = self.apply_fade(self.inactive_color, fade);
410
411 let spans: Vec<Span<'static>> = (0..w)
412 .map(|i| {
413 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 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 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 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 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 for _ in 0..loader.hold_start - 1 {
534 loader.tick();
535 }
536 let line_end = loader.into_line(8);
537 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}