268 lines
7.8 KiB
Rust
268 lines
7.8 KiB
Rust
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 scrollback: usize,
|
|
pub window_width: f32,
|
|
pub window_height: f32,
|
|
pub background: String,
|
|
pub foreground: String,
|
|
pub accent: 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,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
scrollback: 10000,
|
|
window_width: 1100.0,
|
|
window_height: 720.0,
|
|
background: "#17191c".into(),
|
|
foreground: "#e2e4e8".into(),
|
|
accent: "#58c4ad".into(),
|
|
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<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.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 color in [&config.background, &config.foreground, &config.accent] {
|
|
rgb(color)?;
|
|
}
|
|
for key in config.keys.keys() {
|
|
shortcut(key)?;
|
|
}
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
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",
|
|
"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);
|
|
}
|
|
}
|
|
}
|