1mod app_state;
2mod handle_key;
3mod palette;
4mod palette_controller;
5mod palette_widget;
6mod plugin_manager;
7mod registry;
8mod screens;
9mod starfield;
10mod status_bar;
11mod theme_manager;
12
13use crate::auth::AuthHandle;
14use crate::config::ConfigManager;
15use crate::plugin::{Plugin, PluginContext};
16use crossterm::event::{Event, KeyEventKind};
17use crossterm::execute;
18use crossterm::terminal::{
19 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
20};
21use ratatui::backend::CrosstermBackend;
22use ratatui::layout::{Constraint, Direction, Layout};
23use ratatui::style::Color;
24use ratatui::Frame;
25use ratatui::Terminal;
26use std::sync::Arc;
27use std::time::Duration;
28
29const VERSION: &str = env!("CARGO_PKG_VERSION");
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(super) enum BuiltinId {
34 SignInGoogle,
35 SignInGitHub,
36 SignOut,
37 SwitchTheme,
38 About,
39}
40
41pub(super) fn all_builtins() -> Vec<(BuiltinId, &'static str, &'static str)> {
44 vec![
45 (BuiltinId::SignInGoogle, "Auth", "Sign in with Google"),
46 (BuiltinId::SignInGitHub, "Auth", "Sign in with GitHub"),
47 (BuiltinId::SignOut, "Auth", "Sign out"),
48 (BuiltinId::SwitchTheme, "System", "Switch theme"),
49 (BuiltinId::About, "System", "About"),
50 ]
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub(super) enum ItemIndex {
56 Builtin(usize),
57 Dynamic(usize),
58 PluginCmd(usize),
59}
60
61const PAD_L: u16 = 2;
62const PAD_T: u16 = 1;
63const PAD_B: u16 = 1;
64const HEADER_H: u16 = 4;
65const PAL_MIN_W: u16 = 30;
66const PAL_IDEAL_W: u16 = 60;
67
68pub(super) fn max_list_h(content_h: u16) -> u16 {
69 (content_h / 2).saturating_sub(6).max(3)
70}
71
72pub(super) fn pal_w(content_w: u16) -> u16 {
73 let max = content_w.saturating_sub(2);
74 if max < PAL_MIN_W {
75 return max;
76 }
77 max.clamp(PAL_MIN_W, PAL_IDEAL_W)
78}
79
80pub(super) fn parse_hex(s: &str) -> Option<Color> {
84 let s = s.trim_start_matches('#');
85 if s.len() != 6 {
86 return None;
87 }
88 let val = u32::from_str_radix(s, 16).ok()?;
89 Some(Color::Rgb(
90 ((val >> 16) & 0xFF) as u8,
91 ((val >> 8) & 0xFF) as u8,
92 (val & 0xFF) as u8,
93 ))
94}
95
96fn dim_color(fg: Color, factor: f64) -> Color {
97 match fg {
98 Color::Rgb(r, g, b) => Color::Rgb(
99 (r as f64 * factor) as u8,
100 (g as f64 * factor) as u8,
101 (b as f64 * factor) as u8,
102 ),
103 _ => fg,
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn pal_w_given_small_width_returns_width() {
113 let w = pal_w(10);
114 assert_eq!(w, 8);
115 }
116
117 #[test]
118 fn pal_w_returns_content_minus_two_when_above_min() {
119 let w = pal_w(40);
120 assert_eq!(w, 38);
121 }
122
123 #[test]
124 fn pal_w_clamps_to_max() {
125 let w = pal_w(200);
126 assert_eq!(w, 60);
127 }
128
129 #[test]
130 fn pal_w_zero_saturates() {
131 let w = pal_w(0);
132 assert_eq!(w, 0);
133 }
134
135 #[test]
136 fn pal_w_one_saturates() {
137 let w = pal_w(1);
138 assert_eq!(w, 0);
139 }
140
141 #[test]
142 fn max_list_h_small_height() {
143 let h = max_list_h(10);
144 assert_eq!(h, 3);
145 }
146
147 #[test]
148 fn max_list_h_normal() {
149 let h = max_list_h(48);
150 assert_eq!(h, 18);
151 }
152
153 #[test]
154 fn max_list_h_large() {
155 let h = max_list_h(100);
156 assert_eq!(h, 44);
157 }
158
159 #[test]
160 fn max_list_h_minimum() {
161 let h = max_list_h(4);
162 assert_eq!(h, 3);
163 }
164
165 #[test]
166 fn filtered_themes_empty_query_returns_all() {
167 let app = Santui::new();
168 let themes = app.theme_manager.filtered();
169 assert_eq!(themes.len(), app.theme_manager.themes.len());
170 }
171
172 #[test]
173 fn filtered_themes_matches_partial() {
174 let mut app = Santui::new();
175 app.theme_manager.picker_query = "cat".into();
176 let themes = app.theme_manager.filtered();
177 assert!(themes.len() >= 3);
178 for &i in &themes {
179 let name = app.theme_manager.themes[i].0.to_lowercase();
180 assert!(
181 name.contains("cat"),
182 "expected '{}' to contain 'cat'",
183 app.theme_manager.themes[i].0
184 );
185 }
186 }
187
188 #[test]
189 fn filtered_themes_no_match() {
190 let mut app = Santui::new();
191 app.theme_manager.picker_query = "xyznonexistent".into();
192 let themes = app.theme_manager.filtered();
193 assert!(themes.is_empty());
194 }
195
196 #[test]
197 fn filtered_themes_case_insensitive() {
198 let mut app = Santui::new();
199 app.theme_manager.picker_query = "NORD".into();
200 let themes = app.theme_manager.filtered();
201 assert_eq!(themes.len(), 1);
202 assert_eq!(app.theme_manager.themes[themes[0]].0, "Nord");
203 }
204
205 #[test]
208 fn dim_color_black_stays_black() {
209 assert_eq!(dim_color(Color::Rgb(0, 0, 0), 0.45), Color::Rgb(0, 0, 0));
210 }
211
212 #[test]
213 fn dim_color_white_becomes_gray() {
214 assert_eq!(
215 dim_color(Color::Rgb(255, 255, 255), 0.45),
216 Color::Rgb(114, 114, 114)
217 );
218 }
219
220 #[test]
221 fn dim_color_gold_becomes_dim_gold() {
222 assert_eq!(
224 dim_color(Color::Rgb(255, 185, 0), 0.45),
225 Color::Rgb(114, 83, 0)
226 );
227 }
228
229 #[test]
230 fn dim_color_full_factor_returns_original() {
231 assert_eq!(
232 dim_color(Color::Rgb(123, 200, 50), 1.0),
233 Color::Rgb(123, 200, 50)
234 );
235 }
236
237 #[test]
238 fn dim_color_zero_factor_returns_black() {
239 assert_eq!(
240 dim_color(Color::Rgb(100, 150, 200), 0.0),
241 Color::Rgb(0, 0, 0)
242 );
243 }
244
245 #[test]
246 fn dim_color_preserves_hue() {
247 let dimmed = dim_color(Color::Rgb(200, 100, 50), 0.5);
249 assert_eq!(dimmed, Color::Rgb(100, 50, 25));
250 }
251
252 #[test]
253 fn dim_color_reset_passes_through() {
254 assert_eq!(dim_color(Color::Reset, 0.45), Color::Reset);
255 }
256
257 #[test]
258 fn dim_color_indexed_passes_through() {
259 assert_eq!(dim_color(Color::Indexed(7), 0.45), Color::Indexed(7));
260 }
261
262 #[test]
265 fn dim_color_factor_above_one_saturates_at_255() {
266 let result = dim_color(Color::Rgb(200, 100, 50), 2.0);
268 assert_eq!(result, Color::Rgb(255, 200, 100));
270 }
271
272 #[test]
273 fn dim_color_negative_factor_saturates_to_zero() {
274 assert_eq!(
276 dim_color(Color::Rgb(100, 150, 200), -0.5),
277 Color::Rgb(0, 0, 0)
278 );
279 }
280
281 #[test]
282 fn dim_color_tiny_factor_is_effectively_zero() {
283 assert_eq!(
285 dim_color(Color::Rgb(200, 150, 100), 1e-12),
286 Color::Rgb(0, 0, 0)
287 );
288 }
289
290 #[test]
291 fn dim_color_nan_factor_does_not_panic() {
292 let result = dim_color(Color::Rgb(100, 150, 200), f64::NAN);
294 assert_eq!(result, Color::Rgb(0, 0, 0));
295 }
296
297 #[test]
298 fn dim_color_inf_factor_does_not_panic() {
299 let result = dim_color(Color::Rgb(100, 150, 200), f64::INFINITY);
301 assert_eq!(result, Color::Rgb(255, 255, 255));
302 }
303
304 #[test]
305 fn dim_color_indexed_196_passes_through() {
306 assert_eq!(dim_color(Color::Indexed(196), 0.45), Color::Indexed(196));
308 }
309
310 #[test]
313 fn parse_hex_valid_with_hash() {
314 assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
315 }
316
317 #[test]
318 fn parse_hex_valid_without_hash() {
319 assert_eq!(parse_hex("ff8800"), Some(Color::Rgb(255, 136, 0)));
320 }
321
322 #[test]
323 fn parse_hex_all_zeros() {
324 assert_eq!(parse_hex("#000000"), Some(Color::Rgb(0, 0, 0)));
325 }
326
327 #[test]
328 fn parse_hex_all_fs() {
329 assert_eq!(parse_hex("#ffffff"), Some(Color::Rgb(255, 255, 255)));
330 }
331
332 #[test]
333 fn parse_hex_mixed_case() {
334 assert_eq!(parse_hex("#Ff8800"), Some(Color::Rgb(255, 136, 0)));
335 }
336
337 #[test]
338 fn parse_hex_uppercase() {
339 assert_eq!(parse_hex("#FF8800"), Some(Color::Rgb(255, 136, 0)));
340 }
341
342 #[test]
343 fn parse_hex_invalid_chars_returns_none() {
344 assert_eq!(parse_hex("#gggggg"), None);
345 }
346
347 #[test]
348 fn parse_hex_too_short_returns_none() {
349 assert_eq!(parse_hex("#fff"), None);
350 }
351
352 #[test]
353 fn parse_hex_too_long_returns_none() {
354 assert_eq!(parse_hex("#ff8800ff"), None);
355 }
356
357 #[test]
358 fn parse_hex_empty_string_returns_none() {
359 assert_eq!(parse_hex(""), None);
360 }
361
362 #[test]
363 fn parse_hex_just_hash_returns_none() {
364 assert_eq!(parse_hex("#"), None);
365 }
366
367 #[test]
368 fn parse_hex_double_hash_returns_some() {
369 assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
371 }
372
373 #[test]
374 fn parse_hex_hash_only_returns_none() {
375 assert_eq!(parse_hex("##"), None);
377 }
378
379 #[test]
380 fn parse_hex_hash_in_middle_returns_none() {
381 assert_eq!(parse_hex("ff88#00"), None);
383 }
384
385 use proptest::prelude::*;
388
389 proptest! {
390 #[test]
391 fn dim_color_identity(
392 r in any::<u8>(),
393 g in any::<u8>(),
394 b in any::<u8>(),
395 ) {
396 let color = Color::Rgb(r, g, b);
397 prop_assert_eq!(dim_color(color, 1.0), color);
398 }
399
400 #[test]
401 fn dim_color_zero_factor_yields_black(
402 r in any::<u8>(),
403 g in any::<u8>(),
404 b in any::<u8>(),
405 ) {
406 let result = dim_color(Color::Rgb(r, g, b), 0.0);
407 prop_assert_eq!(result, Color::Rgb(0, 0, 0));
408 }
409
410 #[test]
411 fn dim_color_non_rgb_passthrough(
412 f in prop::num::f64::ANY,
413 ) {
414 prop_assert_eq!(dim_color(Color::Reset, f), Color::Reset);
416 prop_assert_eq!(dim_color(Color::Indexed(123), f), Color::Indexed(123));
417 }
418
419 #[test]
420 fn dim_color_channel_never_exceeds_original(
421 r in any::<u8>(),
422 g in any::<u8>(),
423 b in any::<u8>(),
424 f in 0.0f64..=1.0f64,
425 ) {
426 let result = dim_color(Color::Rgb(r, g, b), f);
427 if let Color::Rgb(r2, g2, b2) = result {
428 prop_assert!(r2 <= r, "red {} > original {}", r2, r);
429 prop_assert!(g2 <= g, "green {} > original {}", g2, g);
430 prop_assert!(b2 <= b, "blue {} > original {}", b2, b);
431 } else {
432 panic!("expected Rgb, got {:?}", result);
433 }
434 }
435
436 #[test]
437 fn dim_color_hue_preserved_within_tolerance(
438 r in any::<u8>(),
439 g in any::<u8>(),
440 b in any::<u8>(),
441 f in 0.0f64..=1.0f64,
442 ) {
443 let result = dim_color(Color::Rgb(r, g, b), f);
444 if let Color::Rgb(r2, g2, b2) = result {
445 if r != 0 && g != 0 && r2 != 0 && g2 != 0 {
450 let diff = (r2 as i32 * g as i32 - g2 as i32 * r as i32)
451 .unsigned_abs();
452 prop_assert!(diff <= r.max(g) as u32,
453 "r:g ratio not preserved: {}:{} vs {}:{} (f={})",
454 r, g, r2, g2, f);
455 }
456 if r != 0 && b != 0 && r2 != 0 && b2 != 0 {
457 let diff = (r2 as i32 * b as i32 - b2 as i32 * r as i32)
458 .unsigned_abs();
459 prop_assert!(diff <= r.max(b) as u32,
460 "r:b ratio not preserved: {}:{} vs {}:{} (f={})",
461 r, b, r2, b2, f);
462 }
463 }
464 }
465 }
466}
467
468pub struct Santui {
469 pub(super) plugin_manager: plugin_manager::PluginManager,
471 pub(super) event_bus: crate::event::EventBus,
473 pub(super) auth: Option<Arc<dyn AuthHandle>>,
475 pub(super) app_state: app_state::AppState,
477 pub(super) theme_manager: theme_manager::ThemeManager,
479 palette_controller: palette_controller::PaletteController,
481 pub(super) config_manager: crate::config::ConfigManager,
483 pub(super) starfield: starfield::Starfield,
485}
486
487impl Default for Santui {
488 fn default() -> Self {
489 Self::new()
490 }
491}
492
493impl Santui {
494 pub fn new() -> Self {
495 let theme_manager = theme_manager::ThemeManager::new();
496 let theme = theme_manager.current().clone();
497 Santui {
498 plugin_manager: plugin_manager::PluginManager::new(),
499 event_bus: crate::event::EventBus::new(),
500 auth: None,
501 app_state: app_state::AppState::new(theme),
502 theme_manager,
503 palette_controller: palette_controller::PaletteController::new(),
504 config_manager: ConfigManager::new(std::path::PathBuf::new()),
505 starfield: starfield::Starfield::new(),
506 }
507 }
508
509 pub fn set_tick_rate(&mut self, duration: Duration) {
512 self.config_manager.set_tick_rate(duration);
513 }
514
515 pub fn set_auth(&mut self, auth: Arc<dyn AuthHandle>) {
516 self.auth = Some(auth);
517 }
518
519 pub fn set_config_dir(&mut self, dir: std::path::PathBuf) {
522 self.config_manager = ConfigManager::new(dir.clone());
523 self.theme_manager.load_user_themes(&dir);
524 self.apply_config();
525 }
526
527 pub(super) fn apply_config(&mut self) {
529 let cfg = self.config_manager.config().clone();
530
531 if let Some(ref theme_name) = cfg.theme {
533 let lower = theme_name.to_lowercase();
534 if let Some(idx) = self
535 .theme_manager
536 .themes
537 .iter()
538 .position(|(n, _)| n.to_lowercase() == lower)
539 {
540 self.select_theme(idx);
541 }
542 }
543
544 if let Some(ref custom) = cfg.custom_theme {
546 let mut t = self.app_state.theme.clone();
547 if let Some(ref v) = custom.accent {
548 if let Some(c) = parse_hex(v) {
549 t.accent = c;
550 }
551 }
552 if let Some(ref v) = custom.highlight {
553 if let Some(c) = parse_hex(v) {
554 t.highlight = c;
555 }
556 }
557 if let Some(ref v) = custom.logo {
558 if let Some(c) = parse_hex(v) {
559 t.logo = c;
560 }
561 }
562 if let Some(ref v) = custom.text {
563 if let Some(c) = parse_hex(v) {
564 t.text = c;
565 }
566 }
567 if let Some(ref v) = custom.text_muted {
568 if let Some(c) = parse_hex(v) {
569 t.text_muted = c;
570 }
571 }
572 if let Some(ref v) = custom.background {
573 if let Some(c) = parse_hex(v) {
574 t.background = c;
575 }
576 }
577 if let Some(ref v) = custom.background_panel {
578 if let Some(c) = parse_hex(v) {
579 t.background_panel = c;
580 }
581 }
582 if let Some(ref v) = custom.background_overlay {
583 if let Some(c) = parse_hex(v) {
584 t.background_overlay = c;
585 }
586 }
587 if let Some(ref v) = custom.border {
588 if let Some(c) = parse_hex(v) {
589 t.border = c;
590 }
591 }
592 if let Some(ref v) = custom.success {
593 if let Some(c) = parse_hex(v) {
594 t.success = c;
595 }
596 }
597 if let Some(ref v) = custom.error {
598 if let Some(c) = parse_hex(v) {
599 t.error = c;
600 }
601 }
602 if let Some(ref v) = custom.inverted_text {
603 if let Some(c) = parse_hex(v) {
604 t.inverted_text = c;
605 }
606 }
607 self.event_bus.emit(crate::event::Event::ThemeChanged(t));
608 }
609
610 self.config_manager.ack();
611 }
612
613 pub fn current_theme_name(&self) -> &str {
615 &self.theme_manager.themes[self.theme_manager.current_idx].0
616 }
617
618 pub fn register(&mut self, plugin: Box<dyn Plugin + Send>) {
619 self.plugin_manager.register(plugin);
620 }
621
622 pub fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
623 enable_raw_mode()?;
624 let mut stdout = std::io::stdout();
625 execute!(stdout, EnterAlternateScreen)?;
626 let backend = CrosstermBackend::new(stdout);
627 let mut terminal = Terminal::new(backend)?;
628 terminal.clear()?;
629
630 let (term_w, term_h) = crossterm::terminal::size()?;
632 self.starfield.resize(term_w, term_h);
633
634 let mut ctx = PluginContext {
635 theme: self.app_state.theme.clone(),
636 auth: self.auth.clone(),
637 data_dir: self.plugin_manager.data_dir().to_path_buf(),
638 };
639 self.plugin_manager.init_all(&mut ctx)?;
640
641 self.plugin_manager.read_registry_installed();
643
644 while self.app_state.running {
645 self.plugin_manager.tick_all();
646
647 self.config_manager.poll();
649 if self.config_manager.dirty {
650 self.apply_config();
651 }
652
653 let mut ctx = PluginContext {
655 theme: self.app_state.theme.clone(),
656 auth: self.auth.clone(),
657 data_dir: self.plugin_manager.data_dir().to_path_buf(),
658 };
659 self.plugin_manager.check_reloads(&mut ctx);
660
661 self.plugin_manager.poll_registry_installed();
663
664 let events = self.event_bus.drain();
666 self.app_state.process_events(&events);
667 self.plugin_manager.process_events(&events);
668
669 if let Some(ref auth) = self.auth {
671 if let Some(result) = auth.drain_pending_sign_in() {
672 match result {
673 Ok(user) => {
674 self.plugin_manager.on_user_update_all(Some(&user));
675 self.event_bus.emit(crate::event::Event::UserUpdated);
676 }
677 Err(e) => {
678 log::error!("[auth] background sign-in error: {e}");
679 }
680 }
681 }
682 }
683
684 self.starfield.tick = self.starfield.tick.wrapping_add(1);
685 self.starfield.update();
686
687 terminal.draw(|f| self.render(f))?;
688
689 if crossterm::event::poll(self.config_manager.tick_rate())? {
690 if let Event::Key(key) = crossterm::event::read()? {
691 if key.kind != KeyEventKind::Press {
692 continue;
693 }
694 self.handle_key(key);
695 }
696 }
697 }
698
699 disable_raw_mode()?;
700 execute!(std::io::stdout(), LeaveAlternateScreen)?;
701 terminal.show_cursor()?;
702 Ok(())
703 }
704
705 fn render(&self, f: &mut Frame) {
706 let area = f.area();
707
708 let chunks = Layout::default()
709 .direction(Direction::Vertical)
710 .constraints([Constraint::Min(0), Constraint::Length(1)])
711 .split(area);
712
713 match self.plugin_manager.active() {
714 None => {
715 if self.app_state.show_about {
716 self.render_about(f, chunks[0]);
717 } else {
718 self.render_splash(f, chunks[0]);
719 }
720 }
721 Some(idx) => {
722 self.plugin_manager.render(idx, f, chunks[0]);
723 }
724 }
725
726 let hints = self
727 .plugin_manager
728 .active()
729 .map(|idx| self.plugin_manager.status_hints(idx))
730 .unwrap_or_default();
731 let current_user = self.auth.as_ref().and_then(|a| a.current_user());
732 let auth_message = self.auth.as_ref().and_then(|a| a.auth_message());
733 status_bar::StatusBar {
734 theme: &self.app_state.theme,
735 palette_open: self.palette_controller.is_open(),
736 theme_picker_open: self.app_state.theme_picker_open,
737 about_open: self.app_state.show_about,
738 plugin_active: self.plugin_manager.active().is_some(),
739 active_plugin_hints: &hints,
740 user: current_user.as_ref(),
741 config_error: self.config_manager.error(),
742 auth_message: auth_message.as_deref(),
743 }
744 .render(f, chunks[1]);
745
746 if self.palette_controller.is_open() || self.app_state.theme_picker_open {
747 let dim_bg = self.app_state.theme.background_overlay;
748 let buf = f.buffer_mut();
749 const DIM: f64 = 0.45;
755 for y in area.top()..area.bottom() {
756 for x in area.left()..area.right() {
757 if let Some(cell) = buf.cell_mut((x, y)) {
758 let mut style = cell.style();
759 if let Some(fg) = style.fg {
760 style.fg = Some(dim_color(fg, DIM));
761 }
762 if let Some(bg) = style.bg {
763 style.bg = Some(dim_color(bg, DIM));
764 } else {
765 style.bg = Some(dim_bg);
766 }
767 cell.set_style(style);
768 }
769 }
770 }
771 }
772
773 if self.palette_controller.is_open() {
774 let cmds = self.plugin_manager.commands();
775 self.palette_controller.render(
776 f,
777 chunks[0],
778 &self.app_state.theme,
779 self.starfield.tick,
780 &self.app_state.builtin_items,
781 self.plugin_manager.dynamic_items(),
782 cmds,
783 );
784 }
785
786 if self.app_state.theme_picker_open {
787 self.theme_manager.render_picker(
788 f,
789 chunks[0],
790 &self.app_state.theme,
791 self.starfield.tick,
792 );
793 }
794 }
795}