mod app_state;
mod handle_key;
mod palette;
mod palette_widget;
mod plugin_manager;
mod registry;
mod registry_screen;
mod screens;
mod starfield;
mod status_bar;
mod theme_manager;
use crate::auth::AuthHandle;
use crate::config::ConfigManager;
use crate::plugin::{Plugin, PluginContext};
use crossterm::event::{Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::Color;
use ratatui::Frame;
use ratatui::Terminal;
use santui_registry::Registry as PluginRegistry;
use std::sync::Arc;
use std::time::Duration;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BuiltinId {
SignInGoogle,
SignInGitHub,
SignOut,
PluginRegistry,
SwitchTheme,
About,
}
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"),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ItemIndex {
Builtin(usize),
Dynamic(usize),
PluginCmd(usize),
}
const PAD_L: u16 = 2;
const PAD_T: u16 = 1;
const PAD_B: u16 = 1;
const HEADER_H: u16 = 4;
const PAL_MIN_W: u16 = 30;
const PAL_IDEAL_W: u16 = 60;
pub(super) fn max_list_h(content_h: u16) -> u16 {
(content_h / 2).saturating_sub(6).max(3)
}
pub(super) fn pal_w(content_w: u16) -> u16 {
let max = content_w.saturating_sub(2);
if max < PAL_MIN_W {
return max;
}
max.clamp(PAL_MIN_W, PAL_IDEAL_W)
}
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,
))
}
fn dim_color(fg: Color, factor: f64) -> Color {
match fg {
Color::Rgb(r, g, b) => Color::Rgb(
(r as f64 * factor) as u8,
(g as f64 * factor) as u8,
(b as f64 * factor) as u8,
),
_ => fg,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pal_w_given_small_width_returns_width() {
let w = pal_w(10);
assert_eq!(w, 8);
}
#[test]
fn pal_w_returns_content_minus_two_when_above_min() {
let w = pal_w(40);
assert_eq!(w, 38);
}
#[test]
fn pal_w_clamps_to_max() {
let w = pal_w(200);
assert_eq!(w, 60);
}
#[test]
fn pal_w_zero_saturates() {
let w = pal_w(0);
assert_eq!(w, 0);
}
#[test]
fn pal_w_one_saturates() {
let w = pal_w(1);
assert_eq!(w, 0);
}
#[test]
fn max_list_h_small_height() {
let h = max_list_h(10);
assert_eq!(h, 3);
}
#[test]
fn max_list_h_normal() {
let h = max_list_h(48);
assert_eq!(h, 18);
}
#[test]
fn max_list_h_large() {
let h = max_list_h(100);
assert_eq!(h, 44);
}
#[test]
fn max_list_h_minimum() {
let h = max_list_h(4);
assert_eq!(h, 3);
}
#[test]
fn filtered_themes_empty_query_returns_all() {
let app = Santui::new();
let themes = app.theme_manager.filtered();
assert_eq!(themes.len(), app.theme_manager.themes.len());
}
#[test]
fn filtered_themes_matches_partial() {
let mut app = Santui::new();
app.theme_manager.picker_query = "cat".into();
let themes = app.theme_manager.filtered();
assert!(themes.len() >= 3);
for &i in &themes {
let name = app.theme_manager.themes[i].0.to_lowercase();
assert!(
name.contains("cat"),
"expected '{}' to contain 'cat'",
app.theme_manager.themes[i].0
);
}
}
#[test]
fn filtered_themes_no_match() {
let mut app = Santui::new();
app.theme_manager.picker_query = "xyznonexistent".into();
let themes = app.theme_manager.filtered();
assert!(themes.is_empty());
}
#[test]
fn filtered_themes_case_insensitive() {
let mut app = Santui::new();
app.theme_manager.picker_query = "NORD".into();
let themes = app.theme_manager.filtered();
assert_eq!(themes.len(), 1);
assert_eq!(app.theme_manager.themes[themes[0]].0, "Nord");
}
#[test]
fn dim_color_black_stays_black() {
assert_eq!(dim_color(Color::Rgb(0, 0, 0), 0.45), Color::Rgb(0, 0, 0));
}
#[test]
fn dim_color_white_becomes_gray() {
assert_eq!(
dim_color(Color::Rgb(255, 255, 255), 0.45),
Color::Rgb(114, 114, 114)
);
}
#[test]
fn dim_color_gold_becomes_dim_gold() {
assert_eq!(
dim_color(Color::Rgb(255, 185, 0), 0.45),
Color::Rgb(114, 83, 0)
);
}
#[test]
fn dim_color_full_factor_returns_original() {
assert_eq!(
dim_color(Color::Rgb(123, 200, 50), 1.0),
Color::Rgb(123, 200, 50)
);
}
#[test]
fn dim_color_zero_factor_returns_black() {
assert_eq!(
dim_color(Color::Rgb(100, 150, 200), 0.0),
Color::Rgb(0, 0, 0)
);
}
#[test]
fn dim_color_preserves_hue() {
let dimmed = dim_color(Color::Rgb(200, 100, 50), 0.5);
assert_eq!(dimmed, Color::Rgb(100, 50, 25));
}
#[test]
fn dim_color_reset_passes_through() {
assert_eq!(dim_color(Color::Reset, 0.45), Color::Reset);
}
#[test]
fn dim_color_indexed_passes_through() {
assert_eq!(dim_color(Color::Indexed(7), 0.45), Color::Indexed(7));
}
#[test]
fn dim_color_factor_above_one_saturates_at_255() {
let result = dim_color(Color::Rgb(200, 100, 50), 2.0);
assert_eq!(result, Color::Rgb(255, 200, 100));
}
#[test]
fn dim_color_negative_factor_saturates_to_zero() {
assert_eq!(
dim_color(Color::Rgb(100, 150, 200), -0.5),
Color::Rgb(0, 0, 0)
);
}
#[test]
fn dim_color_tiny_factor_is_effectively_zero() {
assert_eq!(
dim_color(Color::Rgb(200, 150, 100), 1e-12),
Color::Rgb(0, 0, 0)
);
}
#[test]
fn dim_color_nan_factor_does_not_panic() {
let result = dim_color(Color::Rgb(100, 150, 200), f64::NAN);
assert_eq!(result, Color::Rgb(0, 0, 0));
}
#[test]
fn dim_color_inf_factor_does_not_panic() {
let result = dim_color(Color::Rgb(100, 150, 200), f64::INFINITY);
assert_eq!(result, Color::Rgb(255, 255, 255));
}
#[test]
fn dim_color_indexed_196_passes_through() {
assert_eq!(dim_color(Color::Indexed(196), 0.45), Color::Indexed(196));
}
#[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);
}
use proptest::prelude::*;
proptest! {
#[test]
fn dim_color_identity(
r in any::<u8>(),
g in any::<u8>(),
b in any::<u8>(),
) {
let color = Color::Rgb(r, g, b);
prop_assert_eq!(dim_color(color, 1.0), color);
}
#[test]
fn dim_color_zero_factor_yields_black(
r in any::<u8>(),
g in any::<u8>(),
b in any::<u8>(),
) {
let result = dim_color(Color::Rgb(r, g, b), 0.0);
prop_assert_eq!(result, Color::Rgb(0, 0, 0));
}
#[test]
fn dim_color_non_rgb_passthrough(
f in prop::num::f64::ANY,
) {
prop_assert_eq!(dim_color(Color::Reset, f), Color::Reset);
prop_assert_eq!(dim_color(Color::Indexed(123), f), Color::Indexed(123));
}
#[test]
fn dim_color_channel_never_exceeds_original(
r in any::<u8>(),
g in any::<u8>(),
b in any::<u8>(),
f in 0.0f64..=1.0f64,
) {
let result = dim_color(Color::Rgb(r, g, b), f);
if let Color::Rgb(r2, g2, b2) = result {
prop_assert!(r2 <= r, "red {} > original {}", r2, r);
prop_assert!(g2 <= g, "green {} > original {}", g2, g);
prop_assert!(b2 <= b, "blue {} > original {}", b2, b);
} else {
panic!("expected Rgb, got {:?}", result);
}
}
#[test]
fn dim_color_hue_preserved_within_tolerance(
r in any::<u8>(),
g in any::<u8>(),
b in any::<u8>(),
f in 0.0f64..=1.0f64,
) {
let result = dim_color(Color::Rgb(r, g, b), f);
if let Color::Rgb(r2, g2, b2) = result {
if r != 0 && g != 0 && r2 != 0 && g2 != 0 {
let diff = (r2 as i32 * g as i32 - g2 as i32 * r as i32)
.unsigned_abs();
prop_assert!(diff <= r.max(g) as u32,
"r:g ratio not preserved: {}:{} vs {}:{} (f={})",
r, g, r2, g2, f);
}
if r != 0 && b != 0 && r2 != 0 && b2 != 0 {
let diff = (r2 as i32 * b as i32 - b2 as i32 * r as i32)
.unsigned_abs();
prop_assert!(diff <= r.max(b) as u32,
"r:b ratio not preserved: {}:{} vs {}:{} (f={})",
r, b, r2, b2, f);
}
}
}
}
}
pub struct Santui {
pub(super) plugin_manager: plugin_manager::PluginManager,
pub(super) event_bus: crate::event::EventBus,
pub(super) auth: Option<Arc<dyn AuthHandle>>,
registry: Option<PluginRegistry>,
pub(super) app_state: app_state::AppState,
pub(super) theme_manager: theme_manager::ThemeManager,
palette: Option<palette_widget::PaletteWidget>,
pub(super) registry_screen: registry_screen::RegistryScreen,
pub(super) config_manager: crate::config::ConfigManager,
pub(super) dynamic_items: Vec<(String, String, String)>,
pub(super) plugin_factory: Option<crate::plugin::PluginFactory>,
pub(super) starfield: starfield::Starfield,
}
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,
registry: None,
app_state: app_state::AppState::new(theme),
theme_manager,
palette: None,
registry_screen: registry_screen::RegistryScreen::new(),
config_manager: ConfigManager::new(std::path::PathBuf::new()),
dynamic_items: Vec::new(),
plugin_factory: None,
starfield: starfield::Starfield::new(),
}
}
pub(super) fn refresh_dynamic_items(&mut self) {
self.dynamic_items.clear();
if let Some(ref reg) = self.registry {
for plugin in ®.available {
let enabled = reg.installed.iter().any(|p| {
p.path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim_end_matches(".exe"))
== Some(&plugin.id)
&& p.enabled
});
if enabled {
self.dynamic_items.push((
"Modules".into(),
plugin.id.clone(),
plugin.name.clone(),
));
}
}
}
}
pub fn set_auth(&mut self, auth: Arc<dyn AuthHandle>) {
self.auth = Some(auth);
}
pub fn set_config_dir(&mut self, dir: std::path::PathBuf) {
self.config_manager = ConfigManager::new(dir);
self.apply_config();
}
pub(super) fn apply_config(&mut self) {
let cfg = self.config_manager.config().clone();
if let Some(ref theme_name) = cfg.theme {
let lower = theme_name.to_lowercase();
if let Some(idx) = self
.theme_manager
.themes
.iter()
.position(|(n, _)| n.to_lowercase() == lower)
{
self.select_theme(idx);
}
}
if let Some(ref custom) = cfg.custom_theme {
let mut t = self.app_state.theme.clone();
if let Some(ref v) = custom.accent {
if let Some(c) = parse_hex(v) {
t.accent = c;
}
}
if let Some(ref v) = custom.highlight {
if let Some(c) = parse_hex(v) {
t.highlight = c;
}
}
if let Some(ref v) = custom.logo {
if let Some(c) = parse_hex(v) {
t.logo = c;
}
}
if let Some(ref v) = custom.text {
if let Some(c) = parse_hex(v) {
t.text = c;
}
}
if let Some(ref v) = custom.text_muted {
if let Some(c) = parse_hex(v) {
t.text_muted = c;
}
}
if let Some(ref v) = custom.background {
if let Some(c) = parse_hex(v) {
t.background = c;
}
}
if let Some(ref v) = custom.background_panel {
if let Some(c) = parse_hex(v) {
t.background_panel = c;
}
}
if let Some(ref v) = custom.background_overlay {
if let Some(c) = parse_hex(v) {
t.background_overlay = c;
}
}
if let Some(ref v) = custom.border {
if let Some(c) = parse_hex(v) {
t.border = c;
}
}
if let Some(ref v) = custom.success {
if let Some(c) = parse_hex(v) {
t.success = c;
}
}
if let Some(ref v) = custom.error {
if let Some(c) = parse_hex(v) {
t.error = c;
}
}
if let Some(ref v) = custom.inverted_text {
if let Some(c) = parse_hex(v) {
t.inverted_text = c;
}
}
self.app_state.theme = t;
self.plugin_manager
.on_theme_change_all(&self.app_state.theme);
self.event_bus.emit(crate::event::Event::ThemeChanged);
}
self.config_manager.ack();
}
pub fn current_theme_name(&self) -> &'static str {
self.theme_manager.themes[self.theme_manager.current_idx].0
}
pub fn register(&mut self, plugin: Box<dyn Plugin>) {
self.plugin_manager.register(plugin);
}
pub fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let mut ctx = PluginContext {
theme: self.app_state.theme.clone(),
auth: self.auth.clone(),
};
self.plugin_manager.init_all(&mut ctx)?;
let tick_rate = Duration::from_millis(100);
while self.app_state.running {
self.plugin_manager.tick_all();
self.config_manager.poll();
if self.config_manager.dirty {
self.apply_config();
}
let mut ctx = PluginContext {
theme: self.app_state.theme.clone(),
auth: self.auth.clone(),
};
self.plugin_manager.check_reloads(&mut ctx);
let events = self.event_bus.drain();
self.plugin_manager.process_events(&events);
self.starfield.tick = self.starfield.tick.wrapping_add(1);
self.starfield.update();
terminal.draw(|f| self.render(f))?;
if crossterm::event::poll(tick_rate)? {
if let Event::Key(key) = crossterm::event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
self.handle_key(key);
}
}
}
disable_raw_mode()?;
execute!(std::io::stdout(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn render(&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());
status_bar::StatusBar {
theme: &self.app_state.theme,
palette_open: self.palette.is_some(),
theme_picker_open: self.app_state.theme_picker_open,
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(),
}
.render(f, chunks[1]);
if self.palette.is_some() || self.app_state.theme_picker_open {
let dim_bg = self.app_state.theme.background_overlay;
let buf = f.buffer_mut();
const DIM: f64 = 0.45;
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
let mut style = cell.style();
if let Some(fg) = style.fg {
style.fg = Some(dim_color(fg, DIM));
}
if let Some(bg) = style.bg {
style.bg = Some(dim_color(bg, DIM));
} else {
style.bg = Some(dim_bg);
}
cell.set_style(style);
}
}
}
}
if let Some(ref pal) = self.palette {
let cmds = self.plugin_manager.commands();
pal.render(
f,
chunks[0],
&self.app_state.theme,
self.starfield.tick,
&self.app_state.builtin_items,
&self.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,
);
}
if self.app_state.registry_open {
self.registry_screen
.render(f, chunks[0], &self.app_state.theme, &self.registry);
}
}
}