use std::path::PathBuf;
use crate::renderer::{BackgroundImageMode, BackgroundImageSettings, RenderTheme};
#[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, }
#[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
}
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());
}
}