use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context;
use bevy::prelude::Resource;
use etcetera::{BaseStrategy, choose_base_strategy};
use serde::{Deserialize, Deserializer};
use crate::paths::expand_path;
pub const APP_NAME: &str = "ratty";
pub const CONFIG_PATH: &str = "config/ratty.toml";
pub const TERMINAL_TEXTURE_LABEL: &str = "ratty.parley_ratatui";
pub const CURSOR_DEPTH: f32 = 10.0;
#[derive(Resource, Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct AppConfig {
pub window: WindowConfig,
pub terminal: TerminalConfig,
pub shell: ShellConfig,
pub env: BTreeMap<String, String>,
pub bindings: BindingsConfig,
pub font: FontConfig,
pub theme: ThemeConfig,
pub cursor: CursorConfig,
}
impl AppConfig {
pub fn load() -> anyhow::Result<Self> {
Self::load_from_path(None)
}
pub fn load_from_path(path: Option<&Path>) -> anyhow::Result<Self> {
let selected_path = if let Some(path) = path {
Some(expand_path(path))
} else {
Self::default_config_path()?
};
let Some(path) = selected_path else {
return Ok(Self::default());
};
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut config: Self = toml::from_str(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
config.resolve_relative_paths(&path);
Ok(config)
}
fn default_config_path() -> anyhow::Result<Option<PathBuf>> {
let strategy =
choose_base_strategy().context("failed to determine system config directory")?;
let system_path = strategy.config_dir().join(APP_NAME).join("ratty.toml");
let local_path = PathBuf::from(CONFIG_PATH);
Ok(if system_path.exists() {
Some(system_path)
} else if local_path.exists() {
Some(local_path)
} else {
None
})
}
fn resolve_relative_paths(&mut self, path: &Path) {
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
self.cursor.model.path = resolve_config_path(config_dir, &self.cursor.model.path);
if let Some(program) = self.shell.program.as_mut() {
*program = resolve_config_path(config_dir, program);
}
}
}
fn resolve_config_path(config_dir: &Path, path: &Path) -> PathBuf {
let expanded = expand_path(path);
if !expanded.is_relative() {
return expanded;
}
let config_relative = config_dir.join(&expanded);
if expanded
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty())
|| config_relative.exists()
{
config_relative
} else {
expanded
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct WindowConfig {
pub width: u32,
pub height: u32,
pub scale_factor: f32,
pub opacity: f32,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
width: 960,
height: 620,
scale_factor: 1.0,
opacity: 1.0,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TerminalConfig {
pub default_cols: u16,
pub default_rows: u16,
pub scrollback: usize,
}
impl Default for TerminalConfig {
fn default() -> Self {
Self {
default_cols: 104,
default_rows: 32,
scrollback: 2_000,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct ShellConfig {
pub program: Option<PathBuf>,
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct BindingsConfig {
pub keys: Vec<KeyBindingConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct KeyBindingConfig {
pub key: String,
#[serde(default)]
pub with: String,
pub action: BindingAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum BindingAction {
#[serde(rename = "none")]
None,
#[serde(rename = "Toggle3DMode")]
Toggle3DMode,
#[serde(rename = "ToggleMobiusMode")]
ToggleMobiusMode,
#[serde(rename = "ScrollPageUp")]
ScrollPageUp,
#[serde(rename = "ScrollPageDown")]
ScrollPageDown,
#[serde(rename = "ScrollUp")]
ScrollUp,
#[serde(rename = "ScrollDown")]
ScrollDown,
#[serde(rename = "IncreaseWarp")]
IncreaseWarp,
#[serde(rename = "DecreaseWarp")]
DecreaseWarp,
#[serde(rename = "Copy")]
Copy,
#[serde(rename = "Paste")]
Paste,
#[serde(rename = "IncreaseFontSize")]
IncreaseFontSize,
#[serde(rename = "DecreaseFontSize")]
DecreaseFontSize,
#[serde(rename = "ResetFontSize")]
ResetFontSize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct FontConfig {
pub family: String,
pub style: FontStyleConfig,
pub size: i32,
}
impl Default for FontConfig {
fn default() -> Self {
Self {
family: "DejaVu Sans Mono".to_string(),
style: FontStyleConfig::Regular,
size: 18,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default)]
pub enum FontStyleConfig {
#[serde(rename = "Regular")]
#[default]
Regular,
#[serde(rename = "Bold")]
Bold,
#[serde(rename = "Italic")]
Italic,
#[serde(rename = "BoldItalic")]
BoldItalic,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
#[serde(deserialize_with = "deserialize_hex_color")]
pub foreground: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub background: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub cursor: [u8; 3],
#[serde(default = "ThemePaletteConfig::default_normal")]
pub normal: ThemePaletteConfig,
#[serde(default = "ThemePaletteConfig::default_bright")]
pub bright: ThemePaletteConfig,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
foreground: [220, 215, 186],
background: [31, 31, 40],
cursor: [126, 156, 216],
normal: ThemePaletteConfig::default_normal(),
bright: ThemePaletteConfig::default_bright(),
}
}
}
impl ThemeConfig {
pub fn palette(&self) -> [[u8; 3]; 16] {
[
self.normal.black,
self.normal.red,
self.normal.green,
self.normal.yellow,
self.normal.blue,
self.normal.magenta,
self.normal.cyan,
self.normal.white,
self.bright.black,
self.bright.red,
self.bright.green,
self.bright.yellow,
self.bright.blue,
self.bright.magenta,
self.bright.cyan,
self.bright.white,
]
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ThemePaletteConfig {
#[serde(deserialize_with = "deserialize_hex_color")]
pub black: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub red: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub green: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub yellow: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub blue: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub magenta: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub cyan: [u8; 3],
#[serde(deserialize_with = "deserialize_hex_color")]
pub white: [u8; 3],
}
impl ThemePaletteConfig {
pub fn default_normal() -> Self {
Self {
black: [0, 0, 0],
red: [205, 49, 49],
green: [13, 188, 121],
yellow: [229, 229, 16],
blue: [36, 114, 200],
magenta: [188, 63, 188],
cyan: [17, 168, 205],
white: [229, 229, 229],
}
}
pub fn default_bright() -> Self {
Self {
black: [102, 102, 102],
red: [241, 76, 76],
green: [35, 209, 139],
yellow: [245, 245, 67],
blue: [59, 142, 234],
magenta: [214, 112, 214],
cyan: [41, 184, 219],
white: [255, 255, 255],
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct CursorConfig {
pub model: CursorModelConfig,
pub animation: CursorAnimationConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CursorModelConfig {
pub visible: bool,
pub scale_factor: f32,
pub x_offset: f32,
pub plane_offset: f32,
pub brightness: f32,
#[serde(deserialize_with = "deserialize_hex_color")]
pub color: [u8; 3],
pub path: PathBuf,
}
impl Default for CursorModelConfig {
fn default() -> Self {
Self {
visible: true,
scale_factor: 6.0,
x_offset: 0.1,
plane_offset: 18.0,
brightness: 1.0,
color: [255, 255, 255],
path: PathBuf::from("CairoSpinyMouse.obj"),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CursorAnimationConfig {
pub spin_speed: f32,
pub bob_speed: f32,
pub bob_amplitude: f32,
}
impl Default for CursorAnimationConfig {
fn default() -> Self {
Self {
spin_speed: 1.4,
bob_speed: 2.2,
bob_amplitude: 0.08,
}
}
}
fn deserialize_hex_color<'de, D>(deserializer: D) -> Result<[u8; 3], D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
parse_hex_color(&value).map_err(serde::de::Error::custom)
}
fn parse_hex_color(value: &str) -> anyhow::Result<[u8; 3]> {
let hex = value.strip_prefix('#').unwrap_or(value);
if hex.len() != 6 {
anyhow::bail!("expected hex color in #RRGGBB format, got {value}");
}
let r = u8::from_str_radix(&hex[0..2], 16)
.with_context(|| format!("invalid red component in {value}"))?;
let g = u8::from_str_radix(&hex[2..4], 16)
.with_context(|| format!("invalid green component in {value}"))?;
let b = u8::from_str_radix(&hex[4..6], 16)
.with_context(|| format!("invalid blue component in {value}"))?;
Ok([r, g, b])
}