panasyn 0.1.0

A lightweight GPU-accelerated terminal emulator for macOS and Linux.
use std::path::PathBuf;

use crate::renderer::{BackgroundImageMode, BackgroundImageSettings, RenderTheme};

/// Panasyn configuration, loaded from ~/.config/panasyn/config.toml.
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct Config {
    pub font: FontConfig,
    pub terminal: TerminalConfig,
    pub keyboard: KeyboardConfig,
    pub performance: PerformanceConfig,
    pub scrollback: ScrollbackConfig,
    pub cursor: CursorConfig,
    pub appearance: AppearanceConfig,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct FontConfig {
    pub family: String,
    pub size: f32,
    pub line_height: f32,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct TerminalConfig {
    pub shell: String,
    pub term: String,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct KeyboardConfig {
    pub option_as_meta: bool,
    pub backspace: String, // "del" or "ctrl_h"
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct PerformanceConfig {
    pub mode: String,
    pub max_fps: u32,
    pub cache_rows: bool,
    pub coalesce_redraws: bool,
    pub max_parse_bytes_per_frame: usize,
    pub max_pty_chunks_per_frame: usize,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct CursorConfig {
    pub style: String,
    pub blink: bool,
    pub blink_interval_ms: u64,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct ScrollbackConfig {
    pub lines: usize,
    pub wheel_multiplier: f32,
    pub trackpad_pixels_per_line: f32,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct AppearanceConfig {
    pub background: String,
    pub foreground: String,
    pub selection_background: String,
    pub selection_foreground: String,
    pub cursor_background: String,
    pub cursor_foreground: String,
    pub cursor_thin: String,
    pub inverse_background: String,
    pub hyperlink_background: String,
    pub background_image: BackgroundImageConfig,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(default)]
pub struct BackgroundImageConfig {
    pub path: String,
    pub opacity: f32,
    pub mode: String,
    pub max_dimension: u32,
}

impl Default for CursorConfig {
    fn default() -> Self {
        Self {
            style: "block".into(),
            blink: true,
            blink_interval_ms: 500,
        }
    }
}

impl Default for FontConfig {
    fn default() -> Self {
        Self {
            family: "OCR A Extended".into(),
            size: 14.0,
            line_height: 1.4,
        }
    }
}

impl Default for TerminalConfig {
    fn default() -> Self {
        Self {
            shell: String::new(),
            term: "xterm-256color".into(),
        }
    }
}

impl Default for KeyboardConfig {
    fn default() -> Self {
        Self {
            option_as_meta: true,
            backspace: "del".into(),
        }
    }
}

impl Default for PerformanceConfig {
    fn default() -> Self {
        Self {
            mode: "balanced".into(),
            max_fps: 60,
            cache_rows: true,
            coalesce_redraws: true,
            max_parse_bytes_per_frame: 262144,
            max_pty_chunks_per_frame: 512,
        }
    }
}

impl Default for ScrollbackConfig {
    fn default() -> Self {
        Self {
            lines: 10000,
            wheel_multiplier: 5.0,
            trackpad_pixels_per_line: 8.0,
        }
    }
}

impl Default for AppearanceConfig {
    fn default() -> Self {
        Self {
            background: "#02050A".into(),
            foreground: "#E6E6E6".into(),
            selection_background: "#0066CC".into(),
            selection_foreground: "#BFBFBF".into(),
            cursor_background: "#00A2FF".into(),
            cursor_foreground: "#02050A".into(),
            cursor_thin: "#00A2FF".into(),
            inverse_background: "#0066CC".into(),
            hyperlink_background: "#0066CC".into(),
            background_image: BackgroundImageConfig::default(),
        }
    }
}

impl Default for BackgroundImageConfig {
    fn default() -> Self {
        Self {
            path: String::new(),
            opacity: 0.18,
            mode: "cover".into(),
            max_dimension: 1920,
        }
    }
}

impl AppearanceConfig {
    pub fn render_theme(&self) -> RenderTheme {
        let default = RenderTheme::default();
        RenderTheme {
            background: parse_hex_rgb(&self.background).unwrap_or(default.background),
            foreground: parse_hex_rgb(&self.foreground).unwrap_or(default.foreground),
            selection_background: parse_hex_rgb(&self.selection_background)
                .unwrap_or(default.selection_background),
            selection_foreground: parse_hex_rgb(&self.selection_foreground)
                .unwrap_or(default.selection_foreground),
            cursor_background: parse_hex_rgb(&self.cursor_background)
                .unwrap_or(default.cursor_background),
            cursor_foreground: parse_hex_rgb(&self.cursor_foreground)
                .unwrap_or(default.cursor_foreground),
            cursor_thin: parse_hex_rgb(&self.cursor_thin).unwrap_or(default.cursor_thin),
            inverse_background: parse_hex_rgb(&self.inverse_background)
                .unwrap_or(default.inverse_background),
            hyperlink_background: parse_hex_rgb(&self.hyperlink_background)
                .unwrap_or(default.hyperlink_background),
        }
    }

    pub fn background_image_settings(&self) -> Option<BackgroundImageSettings> {
        let path = self.background_image.path.trim();
        if path.is_empty() {
            return None;
        }
        Some(BackgroundImageSettings {
            path: path.to_string(),
            opacity: self.background_image.opacity,
            mode: BackgroundImageMode::from_config(&self.background_image.mode),
            max_dimension: self.background_image.max_dimension,
        })
    }
}

impl Config {
    pub fn load() -> Self {
        for path in [Self::path(), Self::legacy_path()] {
            if let Ok(content) = std::fs::read_to_string(&path) {
                match toml::de::from_str(&content) {
                    Ok(cfg) => {
                        log::info!("config loaded from {}", path.display());
                        return cfg;
                    }
                    Err(e) => {
                        log::warn!(
                            "config parse error at {}: {}; using defaults",
                            path.display(),
                            e
                        );
                    }
                }
            }
        }
        Self::default()
    }

    #[cfg(feature = "agent-harness")]
    pub fn agent_test_profile() -> Self {
        let mut cfg = Self::default();
        cfg.font.family = std::env::var("PANASYN_AGENT_FONT").unwrap_or_else(|_| "Menlo".into());
        cfg.font.size = 14.0;
        cfg.font.line_height = 1.4;
        cfg.terminal.shell = agent_shell_path();
        cfg.terminal.term = "xterm-256color".into();
        cfg.performance.mode = "agent-test".into();
        cfg.performance.max_fps = 60;
        cfg.performance.cache_rows = true;
        cfg.performance.coalesce_redraws = true;
        cfg.performance.max_parse_bytes_per_frame = 262_144;
        cfg.performance.max_pty_chunks_per_frame = 512;
        cfg.scrollback.lines = 20_000;
        cfg.scrollback.wheel_multiplier = 4.0;
        cfg.scrollback.trackpad_pixels_per_line = 8.0;
        cfg.cursor.style = "block".into();
        cfg.cursor.blink = false;
        cfg.cursor.blink_interval_ms = 500;
        cfg.appearance.background = "#02050A".into();
        cfg.appearance.foreground = "#E6E6E6".into();
        cfg.appearance.selection_background = "#0066CC".into();
        cfg.appearance.selection_foreground = "#BFBFBF".into();
        cfg.appearance.cursor_background = "#00A2FF".into();
        cfg.appearance.cursor_foreground = "#02050A".into();
        cfg.appearance.cursor_thin = "#00A2FF".into();
        cfg.appearance.inverse_background = "#0066CC".into();
        cfg.appearance.hyperlink_background = "#0066CC".into();
        cfg.appearance.background_image.path.clear();
        cfg
    }

    fn path() -> PathBuf {
        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
        let mut p = PathBuf::from(home);
        p.push(".config/panasyn/config.toml");
        p
    }

    fn legacy_path() -> PathBuf {
        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
        let mut p = PathBuf::from(home);
        p.push(".config/lumenterm/config.toml");
        p
    }

    /// Return the shell to use: config value, $SHELL env, or /bin/zsh.
    pub fn resolved_shell(&self) -> String {
        if !self.terminal.shell.is_empty() {
            self.terminal.shell.clone()
        } else {
            std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
        }
    }
}

#[cfg(feature = "agent-harness")]
fn agent_shell_path() -> String {
    if let Ok(path) = std::env::var("PANASYN_AGENT_SHELL")
        && !path.trim().is_empty()
    {
        return path;
    }
    let candidate = agent_repo_root()
        .or_else(|| std::env::current_dir().ok())
        .map(|root| root.join("scripts/agent-shell.sh"))
        .filter(|path| path.is_file());
    candidate
        .map(|path| path.to_string_lossy().into_owned())
        .unwrap_or_else(|| "/bin/sh".into())
}

#[cfg(feature = "agent-harness")]
fn agent_repo_root() -> Option<PathBuf> {
    let exe = std::env::current_exe().ok()?;
    let mut ancestors = exe.ancestors();
    let macos = ancestors.nth(1)?;
    if macos.file_name()? != "MacOS" {
        return None;
    }
    let contents = macos.parent()?;
    if contents.file_name()? != "Contents" {
        return None;
    }
    let app = contents.parent()?;
    if app.extension()? != "app" {
        return None;
    }
    app.parent()?.parent().map(PathBuf::from)
}

fn parse_hex_rgb(input: &str) -> Option<u32> {
    let value = input.trim().strip_prefix('#').unwrap_or(input.trim());
    if value.len() != 6 {
        return None;
    }
    u32::from_str_radix(value, 16).ok()
}

#[cfg(all(test, feature = "agent-harness"))]
mod tests {
    use super::*;

    #[cfg(feature = "agent-harness")]
    #[test]
    fn agent_test_profile_is_deterministic_and_non_blinking() {
        let cfg = Config::agent_test_profile();
        assert_eq!(cfg.performance.mode, "agent-test");
        assert_eq!(cfg.performance.max_parse_bytes_per_frame, 262_144);
        assert!(!cfg.cursor.blink);
        assert_eq!(cfg.scrollback.lines, 20_000);
        assert_eq!(cfg.font.family, "Menlo");
        assert_eq!(cfg.appearance.background, "#02050A");
        assert_eq!(cfg.terminal.term, "xterm-256color");
        assert!(!cfg.terminal.shell.is_empty());
    }
}