Skip to main content

santui_core/app/
mod.rs

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
32/// Set by the `ctrlc` signal handler when SIGINT/SIGTERM/SIGHUP is received.
33static SIGINT: AtomicBool = AtomicBool::new(false);
34
35/// Identifier for a built-in palette command.
36#[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
47/// Return the canonical list of built-in command definitions.
48/// Each entry is `(id, category, label)`.
49pub(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/// Index into either built-in, dynamic (registry), or plugin-registered items.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub(super) enum ItemIndex {
64    Builtin(usize),
65    Dynamic(usize),
66    PluginCmd(usize),
67}
68
69/// Parse a hex colour string like `"#ff8800"` or `"ff8800"` into a `Color::Rgb`.
70pub(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    // ---- parse_hex tests ----
88
89    #[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        // trim_start_matches('#') strips ALL leading #s, so "##ff8800" → "ff8800" (len 6) → valid!
147        assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
148    }
149
150    #[test]
151    fn parse_hex_hash_only_returns_none() {
152        // "##" → strips both #s → "" → len 0 ≠ 6
153        assert_eq!(parse_hex("##"), None);
154    }
155
156    #[test]
157    fn parse_hex_hash_in_middle_returns_none() {
158        // "ff88#00" — no leading # to strip, len 7 ≠ 6
159        assert_eq!(parse_hex("ff88#00"), None);
160    }
161}
162
163pub struct Santui {
164    /// All plugin lifecycle management.
165    pub(super) plugin_manager: plugin_manager::PluginManager,
166    /// In-app event bus for decoupled communication.
167    pub(super) event_bus: crate::event::EventBus,
168    /// Authentication handle (set by main.rs before run()).
169    pub(super) auth: Option<Arc<dyn AuthHandle>>,
170    /// Central database access for plugin user data.
171    pub(super) db: Option<Box<dyn DbAccess>>,
172    /// Centralized application state.
173    pub(super) app_state: app_state::AppState,
174    /// Manages theme selection, preview, and theme-picker UI state.
175    pub(super) theme_manager: theme_manager::ThemeManager,
176    /// Command palette overlay state and key handling.
177    palette_controller: palette_controller::PaletteController,
178    /// Hot-reloadable configuration manager.
179    pub(super) config_manager: crate::config::ConfigManager,
180    /// Starfield background animation.
181    pub(super) starfield: starfield::Starfield,
182    /// Pre-built splash logo lines, invalidated on theme change.
183    cached_logo: Option<Vec<ratatui::text::Line<'static>>>,
184    /// Cached terminal height, updated on resize.
185    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    /// Set the main loop tick rate (default 100ms).
214    /// Lower values = smoother animation but more CPU.
215    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    /// Set the central database access for plugins.
224    /// Call before `run()`.
225    pub fn set_db(&mut self, db: Box<dyn DbAccess>) {
226        self.db = Some(db);
227    }
228
229    /// Set the config directory and load (or create) `config.toml`.
230    /// Call before `run()`.
231    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    /// Apply the loaded config (theme, custom colors) to the current app state.
238    pub(super) fn apply_config(&mut self) {
239        // Apply default theme if specified (borrow config_manager, then drop before mutate).
240        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        // Apply custom color overrides.
259        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    /// Get the currently selected theme name.
317    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        // Reset and install OS-level signal handler (SIGINT/SIGTERM/SIGHUP on Unix,
327        // CTRL_C_EVENT/CTRL_BREAK_EVENT on Windows). In raw mode, keyboard Ctrl+C
328        // passes through as a key event (handled in handle_key), so this catches
329        // external signals like `kill` or system shutdown.
330        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        // Restore terminal on panic so user doesn't get stuck in raw mode.
341        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        // Resize starfield to match actual terminal dimensions.
355        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        // Process any pending requests from init responses (e.g. DbGet).
367        if let Some(ref mut db) = self.db {
368            self.plugin_manager
369                .process_all_requests(db.as_mut(), &self.auth);
370        }
371
372        // Populate palette "Plugins" category from registry.toml.
373        self.plugin_manager.read_registry_installed();
374
375        while self.app_state.running {
376            // Check for external signals (SIGINT/SIGTERM via ctrlc handler).
377            if SIGINT.load(Ordering::SeqCst) {
378                self.app_state.running = false;
379                break;
380            }
381            self.plugin_manager.tick_all();
382
383            // Process pending plugin requests (DB read/write, auth, etc.).
384            if let Some(ref mut db) = self.db {
385                self.plugin_manager
386                    .process_all_requests(db.as_mut(), &self.auth);
387            }
388
389            // Event-driven Esc: if the active plugin had a pending Esc response
390            // resolved on this tick, close the plugin if it was not consumed.
391            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            // Poll for config changes (hot-reload).
401            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            // Check for plugin binary updates (hot-reload).
408            self.plugin_manager.check_reloads(&mut ctx);
409
410            // Poll registry.toml for changes (registry plugin writes it).
411            self.plugin_manager.poll_registry_installed();
412
413            // Drain the event bus and forward events to subsystems.
414            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            // Check for pending non-blocking sign-in results.
425            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                    // Process pending plugin requests from key responses
451                    // (e.g. DbSet on Space press).
452                    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}