1mod app_state;
2mod handle_key;
3mod palette;
4mod palette_controller;
5mod plugin_manager;
6mod registry;
7mod screens;
8mod starfield;
9mod status_bar;
10mod theme_manager;
11
12use crate::auth::AuthHandle;
13use crate::config::ConfigManager;
14use crate::db_access::DbAccess;
15use crate::plugin::{Plugin, PluginContext};
16use crate::widgets::DimOverlay;
17use crossterm::event::{Event, KeyEventKind};
18use crossterm::execute;
19use crossterm::terminal::enable_raw_mode;
20use ratatui::backend::CrosstermBackend;
21use ratatui::layout::{Constraint, Direction, Layout};
22use ratatui::style::{Color, Style};
23use ratatui::widgets::Widget;
24use ratatui::Frame;
25use ratatui::Terminal;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::Arc;
28use std::time::Duration;
29
30const VERSION: &str = env!("CARGO_PKG_VERSION");
31
32static SIGINT: AtomicBool = AtomicBool::new(false);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub(super) enum BuiltinId {
38 SignInGoogle,
39 SignInGitHub,
40 SignOut,
41 PluginRegistry,
42 SwitchTheme,
43 About,
44 Exit,
45}
46
47pub(super) fn all_builtins() -> Vec<(BuiltinId, &'static str, &'static str)> {
50 vec![
51 (BuiltinId::SignInGoogle, "Auth", "Sign in with Google"),
52 (BuiltinId::SignInGitHub, "Auth", "Sign in with GitHub"),
53 (BuiltinId::SignOut, "Auth", "Sign out"),
54 (BuiltinId::PluginRegistry, "System", "Plugin registry"),
55 (BuiltinId::SwitchTheme, "System", "Switch theme"),
56 (BuiltinId::About, "System", "About"),
57 (BuiltinId::Exit, "System", "Exit"),
58 ]
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(super) enum ItemIndex {
64 Builtin(usize),
65 Dynamic(usize),
66 PluginCmd(usize),
67}
68
69pub(super) fn parse_hex(s: &str) -> Option<Color> {
71 let s = s.trim_start_matches('#');
72 if s.len() != 6 {
73 return None;
74 }
75 let val = u32::from_str_radix(s, 16).ok()?;
76 Some(Color::Rgb(
77 ((val >> 16) & 0xFF) as u8,
78 ((val >> 8) & 0xFF) as u8,
79 (val & 0xFF) as u8,
80 ))
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
90 fn parse_hex_valid_with_hash() {
91 assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
92 }
93
94 #[test]
95 fn parse_hex_valid_without_hash() {
96 assert_eq!(parse_hex("ff8800"), Some(Color::Rgb(255, 136, 0)));
97 }
98
99 #[test]
100 fn parse_hex_all_zeros() {
101 assert_eq!(parse_hex("#000000"), Some(Color::Rgb(0, 0, 0)));
102 }
103
104 #[test]
105 fn parse_hex_all_fs() {
106 assert_eq!(parse_hex("#ffffff"), Some(Color::Rgb(255, 255, 255)));
107 }
108
109 #[test]
110 fn parse_hex_mixed_case() {
111 assert_eq!(parse_hex("#Ff8800"), Some(Color::Rgb(255, 136, 0)));
112 }
113
114 #[test]
115 fn parse_hex_uppercase() {
116 assert_eq!(parse_hex("#FF8800"), Some(Color::Rgb(255, 136, 0)));
117 }
118
119 #[test]
120 fn parse_hex_invalid_chars_returns_none() {
121 assert_eq!(parse_hex("#gggggg"), None);
122 }
123
124 #[test]
125 fn parse_hex_too_short_returns_none() {
126 assert_eq!(parse_hex("#fff"), None);
127 }
128
129 #[test]
130 fn parse_hex_too_long_returns_none() {
131 assert_eq!(parse_hex("#ff8800ff"), None);
132 }
133
134 #[test]
135 fn parse_hex_empty_string_returns_none() {
136 assert_eq!(parse_hex(""), None);
137 }
138
139 #[test]
140 fn parse_hex_just_hash_returns_none() {
141 assert_eq!(parse_hex("#"), None);
142 }
143
144 #[test]
145 fn parse_hex_double_hash_returns_some() {
146 assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
148 }
149
150 #[test]
151 fn parse_hex_hash_only_returns_none() {
152 assert_eq!(parse_hex("##"), None);
154 }
155
156 #[test]
157 fn parse_hex_hash_in_middle_returns_none() {
158 assert_eq!(parse_hex("ff88#00"), None);
160 }
161}
162
163pub struct Santui {
164 pub(super) plugin_manager: plugin_manager::PluginManager,
166 pub(super) event_bus: crate::event::EventBus,
168 pub(super) auth: Option<Arc<dyn AuthHandle>>,
170 pub(super) db: Option<Box<dyn DbAccess>>,
172 pub(super) app_state: app_state::AppState,
174 pub(super) theme_manager: theme_manager::ThemeManager,
176 palette_controller: palette_controller::PaletteController,
178 pub(super) config_manager: crate::config::ConfigManager,
180 pub(super) starfield: starfield::Starfield,
182 cached_logo: Option<Vec<ratatui::text::Line<'static>>>,
184 term_h: u16,
186}
187
188impl Default for Santui {
189 fn default() -> Self {
190 Self::new()
191 }
192}
193
194impl Santui {
195 pub fn new() -> Self {
196 let theme_manager = theme_manager::ThemeManager::new();
197 let theme = theme_manager.current().clone();
198 Santui {
199 plugin_manager: plugin_manager::PluginManager::new(),
200 event_bus: crate::event::EventBus::new(),
201 auth: None,
202 db: None,
203 app_state: app_state::AppState::new(theme),
204 theme_manager,
205 palette_controller: palette_controller::PaletteController::new(),
206 config_manager: ConfigManager::new(std::path::PathBuf::new()),
207 starfield: starfield::Starfield::new(),
208 cached_logo: None,
209 term_h: 24,
210 }
211 }
212
213 pub fn set_tick_rate(&mut self, duration: Duration) {
216 self.config_manager.set_tick_rate(duration);
217 }
218
219 pub fn set_auth(&mut self, auth: Arc<dyn AuthHandle>) {
220 self.auth = Some(auth);
221 }
222
223 pub fn set_db(&mut self, db: Box<dyn DbAccess>) {
226 self.db = Some(db);
227 }
228
229 pub fn set_config_dir(&mut self, dir: std::path::PathBuf) {
232 self.config_manager = ConfigManager::new(dir.clone());
233 self.theme_manager.load_user_themes(&dir);
234 self.apply_config();
235 }
236
237 pub(super) fn apply_config(&mut self) {
239 let theme_idx = self
241 .config_manager
242 .config()
243 .theme
244 .as_ref()
245 .and_then(|theme_name| {
246 let lower = theme_name.to_lowercase();
247 self.theme_manager
248 .themes
249 .iter()
250 .position(|(n, _)| n.to_lowercase() == lower)
251 });
252 if let Some(idx) = theme_idx {
253 self.select_theme(idx);
254 } else if let Some(theme_name) = &self.config_manager.config().theme {
255 log::warn!("[config] unknown theme '{theme_name}'");
256 }
257
258 if let Some(custom) = &self.config_manager.config().custom_theme {
260 let mut t = self.app_state.theme.clone();
261 macro_rules! apply_color {
262 ($field:ident, $v:expr, $name:literal) => {
263 if let Some(c) = parse_hex($v) {
264 t.$field = c;
265 } else {
266 log::warn!(
267 "[config] invalid hex colour '{}' for custom_theme.{}",
268 $v,
269 $name
270 );
271 }
272 };
273 }
274 if let Some(ref v) = custom.accent {
275 apply_color!(accent, v, "accent");
276 }
277 if let Some(ref v) = custom.highlight {
278 apply_color!(highlight, v, "highlight");
279 }
280 if let Some(ref v) = custom.logo {
281 apply_color!(logo, v, "logo");
282 }
283 if let Some(ref v) = custom.text {
284 apply_color!(text, v, "text");
285 }
286 if let Some(ref v) = custom.text_muted {
287 apply_color!(text_muted, v, "text_muted");
288 }
289 if let Some(ref v) = custom.background {
290 apply_color!(background, v, "background");
291 }
292 if let Some(ref v) = custom.background_panel {
293 apply_color!(background_panel, v, "background_panel");
294 }
295 if let Some(ref v) = custom.background_overlay {
296 apply_color!(background_overlay, v, "background_overlay");
297 }
298 if let Some(ref v) = custom.border {
299 apply_color!(border, v, "border");
300 }
301 if let Some(ref v) = custom.success {
302 apply_color!(success, v, "success");
303 }
304 if let Some(ref v) = custom.error {
305 apply_color!(error, v, "error");
306 }
307 if let Some(ref v) = custom.inverted_text {
308 apply_color!(inverted_text, v, "inverted_text");
309 }
310 self.event_bus.emit(crate::event::Event::ThemeChanged(t));
311 }
312
313 self.config_manager.ack();
314 }
315
316 pub fn current_theme_name(&self) -> &str {
318 &self.theme_manager.themes[self.theme_manager.current_idx].0
319 }
320
321 pub fn register(&mut self, plugin: Box<dyn Plugin + Send>) {
322 self.plugin_manager.register(plugin);
323 }
324
325 pub fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
326 SIGINT.store(false, Ordering::SeqCst);
331 ctrlc::set_handler(|| SIGINT.store(true, Ordering::SeqCst))?;
332
333 enable_raw_mode()?;
334 let mut stdout = std::io::stdout();
335 execute!(stdout, crossterm::terminal::EnterAlternateScreen,)?;
336 let backend = CrosstermBackend::new(stdout);
337 let mut terminal = Terminal::new(backend)?;
338 terminal.clear()?;
339
340 struct TerminalGuard;
342 impl Drop for TerminalGuard {
343 fn drop(&mut self) {
344 let _ = crossterm::terminal::disable_raw_mode();
345 let _ = crossterm::execute!(
346 std::io::stdout(),
347 crossterm::terminal::LeaveAlternateScreen,
348 crossterm::cursor::Show,
349 );
350 }
351 }
352 let _guard = TerminalGuard;
353
354 let (term_w, term_h) = crossterm::terminal::size()?;
356 self.term_h = term_h;
357 self.starfield.resize(term_w, term_h);
358
359 let mut ctx = PluginContext {
360 theme: self.app_state.theme.clone(),
361 auth: self.auth.clone(),
362 data_dir: self.plugin_manager.data_dir().to_path_buf(),
363 };
364 self.plugin_manager.init_all(&mut ctx)?;
365
366 if let Some(ref mut db) = self.db {
368 self.plugin_manager
369 .process_all_requests(db.as_mut(), &self.auth);
370 }
371
372 self.plugin_manager.read_registry_installed();
374
375 while self.app_state.running {
376 if SIGINT.load(Ordering::SeqCst) {
378 self.app_state.running = false;
379 break;
380 }
381 self.plugin_manager.tick_all();
382
383 if let Some(ref mut db) = self.db {
385 self.plugin_manager
386 .process_all_requests(db.as_mut(), &self.auth);
387 }
388
389 if let Some(idx) = self.plugin_manager.active() {
392 if let Some(consumed) = self.plugin_manager.drain_esc_result(idx) {
393 if !consumed {
394 self.plugin_manager.shutdown_and_remove(idx);
395 self.app_state.home_selected = None;
396 }
397 }
398 }
399
400 self.config_manager.poll();
402 if self.config_manager.dirty {
403 self.apply_config();
404 ctx.theme = self.app_state.theme.clone();
405 }
406
407 self.plugin_manager.check_reloads(&mut ctx);
409
410 self.plugin_manager.poll_registry_installed();
412
413 let events = self.event_bus.drain();
415 if events
416 .iter()
417 .any(|e| matches!(e, crate::event::Event::ThemeChanged(_)))
418 {
419 self.cached_logo = None;
420 }
421 self.app_state.process_events(&events);
422 self.plugin_manager.process_events(&events);
423
424 if let Some(ref auth) = self.auth {
426 if let Some(result) = auth.drain_pending_sign_in() {
427 match result {
428 Ok(user) => {
429 self.plugin_manager.on_user_update_all(Some(&user));
430 }
431 Err(e) => {
432 log::error!("[auth] background sign-in error: {e}");
433 }
434 }
435 }
436 }
437
438 self.starfield.tick = self.starfield.tick.wrapping_add(1);
439 self.starfield.update();
440
441 terminal.draw(|f| self.render(f))?;
442
443 if crossterm::event::poll(self.config_manager.tick_rate())? {
444 if let Event::Key(key) = crossterm::event::read()? {
445 if key.kind != KeyEventKind::Press {
446 continue;
447 }
448 self.handle_key(key);
449
450 if let Some(ref mut db) = self.db {
453 self.plugin_manager
454 .process_all_requests(db.as_mut(), &self.auth);
455 }
456 }
457 }
458 }
459
460 Ok(())
461 }
462
463 fn render(&mut self, f: &mut Frame) {
464 let area = f.area();
465
466 let chunks = Layout::default()
467 .direction(Direction::Vertical)
468 .constraints([Constraint::Min(0), Constraint::Length(1)])
469 .split(area);
470
471 match self.plugin_manager.active() {
472 None => {
473 if self.app_state.show_about {
474 self.render_about(f, chunks[0]);
475 } else {
476 self.render_splash(f, chunks[0]);
477 }
478 }
479 Some(idx) => {
480 self.plugin_manager.render(idx, f, chunks[0]);
481 }
482 }
483
484 let hints = self
485 .plugin_manager
486 .active()
487 .map(|idx| self.plugin_manager.status_hints(idx))
488 .unwrap_or_default();
489 let current_user = self.auth.as_ref().and_then(|a| a.current_user());
490 let auth_message = self.auth.as_ref().and_then(|a| a.auth_message());
491 status_bar::StatusBar {
492 theme: &self.app_state.theme,
493 about_open: self.app_state.show_about,
494 plugin_active: self.plugin_manager.active().is_some(),
495 active_plugin_hints: &hints,
496 user: current_user.as_ref(),
497 config_error: self.config_manager.error(),
498 auth_message: auth_message.as_deref(),
499 plugin_errors: self.plugin_manager.crashed_plugins(),
500 }
501 .render(f, chunks[1]);
502
503 if self.palette_controller.is_open() || self.app_state.theme_picker_open {
504 DimOverlay {
505 style: Style::default().bg(self.app_state.theme.background_overlay),
506 }
507 .render(area, f.buffer_mut());
508 }
509
510 if self.palette_controller.is_open() {
511 let cmds = self.plugin_manager.commands();
512 self.palette_controller.render(
513 f,
514 chunks[0],
515 &self.app_state.theme,
516 self.starfield.tick,
517 &self.app_state.builtin_items,
518 self.plugin_manager.dynamic_items(),
519 cmds,
520 );
521 }
522
523 if self.app_state.theme_picker_open {
524 self.theme_manager.render_picker(
525 f,
526 chunks[0],
527 &self.app_state.theme,
528 self.starfield.tick,
529 );
530 }
531 }
532}