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::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
31/// Set by the `ctrlc` signal handler when SIGINT/SIGTERM/SIGHUP is received.
32static SIGINT: AtomicBool = AtomicBool::new(false);
33
34/// Identifier for a built-in palette command.
35#[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
46/// Return the canonical list of built-in command definitions.
47/// Each entry is `(id, category, label)`.
48pub(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/// Index into either built-in, dynamic (registry), or plugin-registered items.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub(super) enum ItemIndex {
63    Builtin(usize),
64    Dynamic(usize),
65    PluginCmd(usize),
66}
67
68/// Parse a hex colour string like `"#ff8800"` or `"ff8800"` into a `Color::Rgb`.
69pub(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    // ---- parse_hex tests ----
87
88    #[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        // trim_start_matches('#') strips ALL leading #s, so "##ff8800" → "ff8800" (len 6) → valid!
146        assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
147    }
148
149    #[test]
150    fn parse_hex_hash_only_returns_none() {
151        // "##" → strips both #s → "" → len 0 ≠ 6
152        assert_eq!(parse_hex("##"), None);
153    }
154
155    #[test]
156    fn parse_hex_hash_in_middle_returns_none() {
157        // "ff88#00" — no leading # to strip, len 7 ≠ 6
158        assert_eq!(parse_hex("ff88#00"), None);
159    }
160}
161
162pub struct Santui {
163    /// All plugin lifecycle management.
164    pub(super) plugin_manager: plugin_manager::PluginManager,
165    /// In-app event bus for decoupled communication.
166    pub(super) event_bus: crate::event::EventBus,
167    /// Authentication handle (set by main.rs before run()).
168    pub(super) auth: Option<Arc<dyn AuthHandle>>,
169    /// Centralized application state.
170    pub(super) app_state: app_state::AppState,
171    /// Manages theme selection, preview, and theme-picker UI state.
172    pub(super) theme_manager: theme_manager::ThemeManager,
173    /// Command palette overlay state and key handling.
174    palette_controller: palette_controller::PaletteController,
175    /// Hot-reloadable configuration manager.
176    pub(super) config_manager: crate::config::ConfigManager,
177    /// Starfield background animation.
178    pub(super) starfield: starfield::Starfield,
179    /// Pre-built splash logo lines, invalidated on theme change.
180    cached_logo: Option<Vec<ratatui::text::Line<'static>>>,
181    /// Cached terminal height, updated on resize.
182    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    /// Set the main loop tick rate (default 100ms).
210    /// Lower values = smoother animation but more CPU.
211    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    /// Set the config directory and load (or create) `config.toml`.
220    /// Call before `run()`.
221    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    /// Apply the loaded config (theme, custom colors) to the current app state.
228    pub(super) fn apply_config(&mut self) {
229        // Apply default theme if specified (borrow config_manager, then drop before mutate).
230        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        // Apply custom color overrides.
249        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    /// Get the currently selected theme name.
307    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        // Reset and install OS-level signal handler (SIGINT/SIGTERM/SIGHUP on Unix,
317        // CTRL_C_EVENT/CTRL_BREAK_EVENT on Windows). In raw mode, keyboard Ctrl+C
318        // passes through as a key event (handled in handle_key), so this catches
319        // external signals like `kill` or system shutdown.
320        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        // Restore terminal on panic so user doesn't get stuck in raw mode.
331        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        // Resize starfield to match actual terminal dimensions.
345        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        // Populate palette "Plugins" category from registry.toml.
357        self.plugin_manager.read_registry_installed();
358
359        while self.app_state.running {
360            // Check for external signals (SIGINT/SIGTERM via ctrlc handler).
361            if SIGINT.load(Ordering::SeqCst) {
362                self.app_state.running = false;
363                break;
364            }
365            self.plugin_manager.tick_all();
366
367            // Event-driven Esc: if the active plugin had a pending Esc response
368            // resolved on this tick, close the plugin if it was not consumed.
369            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            // Poll for config changes (hot-reload).
379            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            // Check for plugin binary updates (hot-reload).
386            self.plugin_manager.check_reloads(&mut ctx);
387
388            // Poll registry.toml for changes (registry plugin writes it).
389            self.plugin_manager.poll_registry_installed();
390
391            // Drain the event bus and forward events to subsystems.
392            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            // Check for pending non-blocking sign-in results.
403            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}