Files
slerm/src/config.rs
T

356 lines
11 KiB
Rust

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<String>,
pub working_directory: Option<PathBuf>,
pub font_size: f32,
pub font_path: Option<PathBuf>,
pub font_family: Option<String>,
pub font_bold_path: Option<PathBuf>,
pub font_italic_path: Option<PathBuf>,
pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>,
pub scrollback: usize,
pub scrollback_editor: Option<String>,
pub window_width: f32,
pub window_height: f32,
pub theme: String,
/// Shorthand for the `[colors]` entries of the same name.
pub background: Option<String>,
pub foreground: Option<String>,
pub accent: Option<String>,
/// Colors overriding the theme, keyed by the names in `theme::FIELDS`.
pub colors: BTreeMap<String, String>,
pub kitty: Kitty,
pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>,
}
#[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,
OpenScrollback,
}
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,
scrollback_editor: None,
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),
("Alt+S", Action::OpenScrollback),
("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<PathBuf> {
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<Self> {
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<Self> {
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"
);
if let Some(editor) = &config.scrollback_editor {
let command =
shell_words::split(editor).context("Invalid scrollback_editor command")?;
ensure!(
!command.is_empty() && !command[0].is_empty(),
"scrollback_editor 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<Theme> {
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<theme::Rgb> {
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<libghostty_vt::style::RgbColor> {
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<egui::KeyboardShortcut> {
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 = []",
"scrollback_editor = ''",
"scrollback_editor = ' '",
"scrollback_editor = \"''\"",
"scrollback_editor = \"'\"",
"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),
("Alt+S", Action::OpenScrollback),
] {
assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt);
}
}
}