use crate::theme::{self, Theme}; use anyhow::{Context, Result, bail, ensure}; use serde::Deserialize; use std::{ collections::BTreeMap, path::{Path, PathBuf}, }; pub const EXAMPLE: &str = include_str!("../config.example.toml"); #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Config { pub shell: Vec, pub working_directory: Option, pub font_size: f32, pub font_path: Option, pub font_family: Option, pub font_bold_path: Option, pub font_italic_path: Option, pub font_bold_italic_path: Option, pub font_fallbacks: Vec, pub scrollback: usize, pub window_width: f32, pub window_height: f32, pub theme: String, /// Shorthand for the `[colors]` entries of the same name. pub background: Option, pub foreground: Option, pub accent: Option, /// Colors overriding the theme, keyed by the names in `theme::FIELDS`. pub colors: BTreeMap, pub kitty: Kitty, pub multiplexer: Multiplexer, pub keys: BTreeMap, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Kitty { pub image_storage_mb: u64, pub allow_file: bool, pub allow_temp_file: bool, pub allow_shared_memory: bool, } #[derive(Clone, Debug, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Multiplexer { pub max_panes: usize, pub floating_width: f32, pub floating_height: f32, pub floating_max_width: f32, pub floating_max_height: f32, } #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Action { SplitRight, SplitDown, NewFloating, NewPane, ToggleFloating, ToggleFloats, NextPane, PreviousPane, FocusLeft, FocusDown, FocusUp, FocusRight, ClosePane, ZoomPane, NewTab, NextTab, PreviousTab, ReloadConfig, Copy, Paste, } impl Default for Kitty { fn default() -> Self { Self { image_storage_mb: 64, allow_file: true, allow_temp_file: true, allow_shared_memory: true, } } } impl Default for Multiplexer { fn default() -> Self { Self { max_panes: 32, floating_width: 0.65, floating_height: 0.65, // The fractions alone make a float fill half of a large display, which is // far more than a scratch terminal needs, so cap them in points too. floating_max_width: 960.0, floating_max_height: 640.0, } } } impl Default for Config { fn default() -> Self { Self { shell: vec![std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())], working_directory: None, font_size: 16.0, font_path: None, font_family: None, font_bold_path: None, font_italic_path: None, font_bold_italic_path: None, font_fallbacks: Vec::new(), scrollback: 10000, window_width: 1100.0, window_height: 720.0, theme: theme::DEFAULT_NAME.into(), background: None, foreground: None, accent: None, colors: BTreeMap::new(), kitty: Kitty::default(), multiplexer: Multiplexer::default(), keys: [ ("Alt+F", Action::ToggleFloats), ("Alt+M", Action::NewPane), ("Alt+H", Action::FocusLeft), ("Alt+J", Action::FocusDown), ("Alt+K", Action::FocusUp), ("Alt+L", Action::FocusRight), ("Alt+A", Action::NewTab), ("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+L", Action::NextTab), ("Alt+X", Action::ClosePane), ("Ctrl+Shift+Z", Action::ZoomPane), ("Ctrl+Shift+R", Action::ReloadConfig), ("Ctrl+Shift+C", Action::Copy), ("Ctrl+Shift+V", Action::Paste), ] .into_iter() .map(|(k, v)| (k.into(), v)) .collect(), } } } impl Config { pub fn path() -> Result { if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|p| !p.is_empty()) { return Ok(PathBuf::from(dir).join("slerm/config.toml")); } Ok( PathBuf::from(std::env::var_os("HOME").context("HOME is not set")?) .join(".config/slerm/config.toml"), ) } pub fn load(path: &Path) -> Result { match std::fs::read_to_string(path) { Ok(text) => { Self::parse(&text).with_context(|| format!("Invalid config: {}", path.display())) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(e) => Err(e).with_context(|| format!("Cannot read {}", path.display())), } } pub fn parse(text: &str) -> Result { let config: Self = toml::from_str(text)?; ensure!( config.font_path.is_none() || config.font_family.is_none(), "set either font_family or font_path, not both" ); ensure!( config .font_family .iter() .chain(&config.font_fallbacks) .all(|name| !name.trim().is_empty()), "font family names must not be empty" ); ensure!( !config.shell.is_empty() && !config.shell[0].is_empty(), "shell must contain an executable" ); ensure!( (8.0..=48.0).contains(&config.font_size), "font_size must be 8..48" ); ensure!( (320.0..=16384.0).contains(&config.window_width) && (200.0..=16384.0).contains(&config.window_height), "invalid window dimensions" ); ensure!( config.scrollback <= 1_000_000, "scrollback must be <= 1000000" ); ensure!( (1..=1024).contains(&config.kitty.image_storage_mb), "image_storage_mb must be 1..1024" ); ensure!( (1..=128).contains(&config.multiplexer.max_panes), "max_panes must be 1..128" ); for fraction in [ config.multiplexer.floating_width, config.multiplexer.floating_height, ] { ensure!( (0.2..=0.95).contains(&fraction), "floating dimensions must be fractions in 0.2..0.95" ); } for points in [ config.multiplexer.floating_max_width, config.multiplexer.floating_max_height, ] { ensure!( (160.0..=16384.0).contains(&points), "floating maximums must be 160..16384 points" ); } config.theme()?; for key in config.keys.keys() { shortcut(key)?; } Ok(config) } /// The configured theme with the `[colors]` overrides applied. Validated by /// `parse`, so a config that loaded is a config whose theme resolves. pub fn theme(&self) -> Result { let mut theme = theme::find(&self.theme) .with_context(|| format!("unknown theme: {} (try --list-themes)", self.theme))?; let shorthand = [ ("background", &self.background), ("foreground", &self.foreground), ("accent", &self.accent), ]; let named = shorthand .iter() .filter_map(|(name, value)| Some((*name, value.as_ref()?))) .chain(self.colors.iter().map(|(k, v)| (k.as_str(), v))); for (name, value) in named { theme.set(name, packed(value)?)?; } Ok(theme) } } /// A `#RRGGBB` string as the packed integer a theme stores. pub fn packed(text: &str) -> Result { let color = rgb(text)?; Ok((color.r as u32) << 16 | (color.g as u32) << 8 | color.b as u32) } pub fn rgb(text: &str) -> Result { ensure!( text.len() == 7 && text.starts_with('#') && text[1..].bytes().all(|c| c.is_ascii_hexdigit()), "color must be #RRGGBB: {text}" ); let value = u32::from_str_radix(&text[1..], 16)?; Ok(libghostty_vt::style::RgbColor { r: (value >> 16) as u8, g: (value >> 8) as u8, b: value as u8, }) } pub fn shortcut(text: &str) -> Result { let mut mods = egui::Modifiers::NONE; let parts: Vec<_> = text.split('+').collect(); for part in &parts[..parts.len() - 1] { match *part { "Ctrl" => mods.ctrl = true, "Shift" => mods.shift = true, "Alt" => mods.alt = true, "Super" => { mods.mac_cmd = true; mods.command = true; } _ => bail!("unknown modifier in {text}"), } } let key = egui::Key::from_name(parts[parts.len() - 1]) .with_context(|| format!("unknown key in {text}"))?; Ok(egui::KeyboardShortcut::new(mods, key)) } #[cfg(test)] mod tests { use super::*; #[test] fn validates_example_and_rejects_bad_values() { let example = Config::parse(EXAMPLE).unwrap(); assert_eq!(example.keys, Config::default().keys); for text in [ "font_size = nan", "font_family = ''", "font_fallbacks = [' ']", "font_family = 'Test'\nfont_path = '/font.ttf'", "shell = []", "background = '#xx0000'", "font_sze = 16", "[keys]\n'Hyper+Q' = 'close_pane'", "[multiplexer]\nfloating_width = 2", ] { assert!(Config::parse(text).is_err(), "accepted {text}"); } } #[test] fn default_shortcuts_match_pane_and_tab_actions() { let config = Config::default(); for (binding, action) in [ ("Alt+F", Action::ToggleFloats), ("Alt+M", Action::NewPane), ("Alt+H", Action::FocusLeft), ("Alt+J", Action::FocusDown), ("Alt+K", Action::FocusUp), ("Alt+L", Action::FocusRight), ("Alt+A", Action::NewTab), ("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+L", Action::NextTab), ("Alt+X", Action::ClosePane), ] { assert_eq!(config.keys.get(binding), Some(&action)); assert!(shortcut(binding).unwrap().modifiers.alt); } } }