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