mod app_state;
mod handle_key;
mod palette;
mod palette_controller;
mod plugin_manager;
mod registry;
mod screens;
mod starfield;
mod status_bar;
mod theme_manager;
use crate::auth::AuthHandle;
use crate::config::ConfigManager;
use crate::db_access::DbAccess;
use crate::plugin::{Plugin, PluginContext};
use crate::widgets::DimOverlay;
use crossterm::event::{Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::enable_raw_mode;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
use ratatui::Frame;
use ratatui::Terminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
const VERSION: &str = env!("CARGO_PKG_VERSION");
static SIGINT: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BuiltinId {
SignInGoogle,
SignInGitHub,
SignOut,
PluginRegistry,
SwitchTheme,
About,
Exit,
}
pub(super) fn all_builtins() -> Vec<(BuiltinId, &'static str, &'static str)> {
vec![
(BuiltinId::SignInGoogle, "Auth", "Sign in with Google"),
(BuiltinId::SignInGitHub, "Auth", "Sign in with GitHub"),
(BuiltinId::SignOut, "Auth", "Sign out"),
(BuiltinId::PluginRegistry, "System", "Plugin registry"),
(BuiltinId::SwitchTheme, "System", "Switch theme"),
(BuiltinId::About, "System", "About"),
(BuiltinId::Exit, "System", "Exit"),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ItemIndex {
Builtin(usize),
Dynamic(usize),
PluginCmd(usize),
}
pub(super) fn parse_hex(s: &str) -> Option<Color> {
let s = s.trim_start_matches('#');
if s.len() != 6 {
return None;
}
let val = u32::from_str_radix(s, 16).ok()?;
Some(Color::Rgb(
((val >> 16) & 0xFF) as u8,
((val >> 8) & 0xFF) as u8,
(val & 0xFF) as u8,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hex_valid_with_hash() {
assert_eq!(parse_hex("#ff8800"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_hex_valid_without_hash() {
assert_eq!(parse_hex("ff8800"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_hex_all_zeros() {
assert_eq!(parse_hex("#000000"), Some(Color::Rgb(0, 0, 0)));
}
#[test]
fn parse_hex_all_fs() {
assert_eq!(parse_hex("#ffffff"), Some(Color::Rgb(255, 255, 255)));
}
#[test]
fn parse_hex_mixed_case() {
assert_eq!(parse_hex("#Ff8800"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_hex_uppercase() {
assert_eq!(parse_hex("#FF8800"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_hex_invalid_chars_returns_none() {
assert_eq!(parse_hex("#gggggg"), None);
}
#[test]
fn parse_hex_too_short_returns_none() {
assert_eq!(parse_hex("#fff"), None);
}
#[test]
fn parse_hex_too_long_returns_none() {
assert_eq!(parse_hex("#ff8800ff"), None);
}
#[test]
fn parse_hex_empty_string_returns_none() {
assert_eq!(parse_hex(""), None);
}
#[test]
fn parse_hex_just_hash_returns_none() {
assert_eq!(parse_hex("#"), None);
}
#[test]
fn parse_hex_double_hash_returns_some() {
assert_eq!(parse_hex("##ff8800"), Some(Color::Rgb(255, 136, 0)));
}
#[test]
fn parse_hex_hash_only_returns_none() {
assert_eq!(parse_hex("##"), None);
}
#[test]
fn parse_hex_hash_in_middle_returns_none() {
assert_eq!(parse_hex("ff88#00"), None);
}
}
pub struct Santui {
pub(super) plugin_manager: plugin_manager::PluginManager,
pub(super) event_bus: crate::event::EventBus,
pub(super) auth: Option<Arc<dyn AuthHandle>>,
pub(super) db: Option<Box<dyn DbAccess>>,
pub(super) app_state: app_state::AppState,
pub(super) theme_manager: theme_manager::ThemeManager,
palette_controller: palette_controller::PaletteController,
pub(super) config_manager: crate::config::ConfigManager,
pub(super) starfield: starfield::Starfield,
cached_logo: Option<Vec<ratatui::text::Line<'static>>>,
term_h: u16,
}
impl Default for Santui {
fn default() -> Self {
Self::new()
}
}
impl Santui {
pub fn new() -> Self {
let theme_manager = theme_manager::ThemeManager::new();
let theme = theme_manager.current().clone();
Santui {
plugin_manager: plugin_manager::PluginManager::new(),
event_bus: crate::event::EventBus::new(),
auth: None,
db: None,
app_state: app_state::AppState::new(theme),
theme_manager,
palette_controller: palette_controller::PaletteController::new(),
config_manager: ConfigManager::new(std::path::PathBuf::new()),
starfield: starfield::Starfield::new(),
cached_logo: None,
term_h: 24,
}
}
pub fn set_tick_rate(&mut self, duration: Duration) {
self.config_manager.set_tick_rate(duration);
}
pub fn set_auth(&mut self, auth: Arc<dyn AuthHandle>) {
self.auth = Some(auth);
}
pub fn set_db(&mut self, db: Box<dyn DbAccess>) {
self.db = Some(db);
}
pub fn set_config_dir(&mut self, dir: std::path::PathBuf) {
self.config_manager = ConfigManager::new(dir.clone());
self.theme_manager.load_user_themes(&dir);
self.apply_config();
}
pub(super) fn apply_config(&mut self) {
let theme_idx = self
.config_manager
.config()
.theme
.as_ref()
.and_then(|theme_name| {
let lower = theme_name.to_lowercase();
self.theme_manager
.themes
.iter()
.position(|(n, _)| n.to_lowercase() == lower)
});
if let Some(idx) = theme_idx {
self.select_theme(idx);
} else if let Some(theme_name) = &self.config_manager.config().theme {
log::warn!("[config] unknown theme '{theme_name}'");
}
if let Some(custom) = &self.config_manager.config().custom_theme {
let mut t = self.app_state.theme.clone();
macro_rules! apply_color {
($field:ident, $v:expr, $name:literal) => {
if let Some(c) = parse_hex($v) {
t.$field = c;
} else {
log::warn!(
"[config] invalid hex colour '{}' for custom_theme.{}",
$v,
$name
);
}
};
}
if let Some(ref v) = custom.accent {
apply_color!(accent, v, "accent");
}
if let Some(ref v) = custom.highlight {
apply_color!(highlight, v, "highlight");
}
if let Some(ref v) = custom.logo {
apply_color!(logo, v, "logo");
}
if let Some(ref v) = custom.text {
apply_color!(text, v, "text");
}
if let Some(ref v) = custom.text_muted {
apply_color!(text_muted, v, "text_muted");
}
if let Some(ref v) = custom.background {
apply_color!(background, v, "background");
}
if let Some(ref v) = custom.background_panel {
apply_color!(background_panel, v, "background_panel");
}
if let Some(ref v) = custom.background_overlay {
apply_color!(background_overlay, v, "background_overlay");
}
if let Some(ref v) = custom.border {
apply_color!(border, v, "border");
}
if let Some(ref v) = custom.success {
apply_color!(success, v, "success");
}
if let Some(ref v) = custom.error {
apply_color!(error, v, "error");
}
if let Some(ref v) = custom.inverted_text {
apply_color!(inverted_text, v, "inverted_text");
}
self.event_bus.emit(crate::event::Event::ThemeChanged(t));
}
self.config_manager.ack();
}
pub fn current_theme_name(&self) -> &str {
&self.theme_manager.themes[self.theme_manager.current_idx].0
}
pub fn register(&mut self, plugin: Box<dyn Plugin + Send>) {
self.plugin_manager.register(plugin);
}
pub fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
SIGINT.store(false, Ordering::SeqCst);
ctrlc::set_handler(|| SIGINT.store(true, Ordering::SeqCst))?;
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, crossterm::terminal::EnterAlternateScreen,)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::cursor::Show,
);
}
}
let _guard = TerminalGuard;
let (term_w, term_h) = crossterm::terminal::size()?;
self.term_h = term_h;
self.starfield.resize(term_w, term_h);
let mut ctx = PluginContext {
theme: self.app_state.theme.clone(),
auth: self.auth.clone(),
data_dir: self.plugin_manager.data_dir().to_path_buf(),
};
self.plugin_manager.init_all(&mut ctx)?;
if let Some(ref mut db) = self.db {
self.plugin_manager
.process_all_requests(db.as_mut(), &self.auth);
}
self.plugin_manager.read_registry_installed();
while self.app_state.running {
if SIGINT.load(Ordering::SeqCst) {
self.app_state.running = false;
break;
}
self.plugin_manager.tick_all();
if let Some(ref mut db) = self.db {
self.plugin_manager
.process_all_requests(db.as_mut(), &self.auth);
}
if let Some(idx) = self.plugin_manager.active() {
if let Some(consumed) = self.plugin_manager.drain_esc_result(idx) {
if !consumed {
self.plugin_manager.shutdown_and_remove(idx);
self.app_state.home_selected = None;
}
}
}
self.config_manager.poll();
if self.config_manager.dirty {
self.apply_config();
ctx.theme = self.app_state.theme.clone();
}
self.plugin_manager.check_reloads(&mut ctx);
self.plugin_manager.poll_registry_installed();
let events = self.event_bus.drain();
if events
.iter()
.any(|e| matches!(e, crate::event::Event::ThemeChanged(_)))
{
self.cached_logo = None;
}
self.app_state.process_events(&events);
self.plugin_manager.process_events(&events);
if let Some(ref auth) = self.auth {
if let Some(result) = auth.drain_pending_sign_in() {
match result {
Ok(user) => {
self.plugin_manager.on_user_update_all(Some(&user));
}
Err(e) => {
log::error!("[auth] background sign-in error: {e}");
}
}
}
}
self.starfield.tick = self.starfield.tick.wrapping_add(1);
self.starfield.update();
terminal.draw(|f| self.render(f))?;
if crossterm::event::poll(self.config_manager.tick_rate())? {
if let Event::Key(key) = crossterm::event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
self.handle_key(key);
if let Some(ref mut db) = self.db {
self.plugin_manager
.process_all_requests(db.as_mut(), &self.auth);
}
}
}
}
Ok(())
}
fn render(&mut self, f: &mut Frame) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
match self.plugin_manager.active() {
None => {
if self.app_state.show_about {
self.render_about(f, chunks[0]);
} else {
self.render_splash(f, chunks[0]);
}
}
Some(idx) => {
self.plugin_manager.render(idx, f, chunks[0]);
}
}
let hints = self
.plugin_manager
.active()
.map(|idx| self.plugin_manager.status_hints(idx))
.unwrap_or_default();
let current_user = self.auth.as_ref().and_then(|a| a.current_user());
let auth_message = self.auth.as_ref().and_then(|a| a.auth_message());
status_bar::StatusBar {
theme: &self.app_state.theme,
about_open: self.app_state.show_about,
plugin_active: self.plugin_manager.active().is_some(),
active_plugin_hints: &hints,
user: current_user.as_ref(),
config_error: self.config_manager.error(),
auth_message: auth_message.as_deref(),
plugin_errors: self.plugin_manager.crashed_plugins(),
}
.render(f, chunks[1]);
if self.palette_controller.is_open() || self.app_state.theme_picker_open {
DimOverlay {
style: Style::default().bg(self.app_state.theme.background_overlay),
}
.render(area, f.buffer_mut());
}
if self.palette_controller.is_open() {
let cmds = self.plugin_manager.commands();
self.palette_controller.render(
f,
chunks[0],
&self.app_state.theme,
self.starfield.tick,
&self.app_state.builtin_items,
self.plugin_manager.dynamic_items(),
cmds,
);
}
if self.app_state.theme_picker_open {
self.theme_manager.render_picker(
f,
chunks[0],
&self.app_state.theme,
self.starfield.tick,
);
}
}
}