arreliny-term 0.1.2

A simple terminal for keeping multiple shells in one window
use std::env;
use std::fs;
use std::path::PathBuf;

#[derive(Clone)]
pub struct Settings {
    pub language: String,
    pub spawn_accel: String,
    pub close_accel: String,
    pub focus_left_accel: String,
    pub focus_right_accel: String,
    pub focus_up_accel: String,
    pub focus_down_accel: String,
    pub equalize_panes_accel: String,
    pub palette: String,
    pub custom_foreground: String,
    pub custom_background: String,
    pub custom_colors: [String; 16],
    pub font: String,
    pub font_weight: i32,
    pub cursor_style: String,
    pub cursor_color: String,
    pub cursor_blink: bool,
    pub cursor_blink_on: i32,
    pub cursor_blink_off: i32,
    pub selection_foreground: String,
    pub selection_background: String,
    pub suggestion_color: String,
    pub pane_gap: i32,
    pub pane_layout: String,
    pub max_panes: i32,
    pub persist_dividers: bool,
    pub divider_columns: String,
    pub divider_rows: String,
    pub base_width: i32,
    pub base_height: i32,
    pub focus_color: String,
    pub focus_width: i32,
    pub focus_style: String,
    pub focus_animation: bool,
    pub background_opacity: i32,
    pub colors: bool,
    pub center_on_expand: bool,
    pub copy_on_select: bool,
    pub middle_click_paste: bool,
    pub bracketed_paste: bool,
    pub trim_selection: bool,
    pub scroll_lines: i32,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            language: "en".to_string(),
            spawn_accel: platform_accelerator("<Alt>Return", "<Super>Return"),
            close_accel: platform_accelerator("<Alt>w", "<Super>w"),
            focus_left_accel: "<Alt>Left".to_string(),
            focus_right_accel: "<Alt>Right".to_string(),
            focus_up_accel: "<Alt>Up".to_string(),
            focus_down_accel: "<Alt>Down".to_string(),
            equalize_panes_accel: platform_accelerator("<Alt>e", "<Super>e"),
            palette: "catppuccin-mocha".to_string(),
            custom_foreground: "#e8e8e8".to_string(),
            custom_background: "#000000".to_string(),
            custom_colors: [
                "#1a1a1a", "#ff5555", "#50fa7b", "#f1fa8c", "#8be9fd", "#bd93f9", "#8be9fd",
                "#e8e8e8", "#555555", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df",
                "#a4ffff", "#ffffff",
            ]
            .map(str::to_string),
            font: "JetBrains Mono 11".to_string(),
            font_weight: 500,
            cursor_style: "application".to_string(),
            cursor_color: "#f0b35a".to_string(),
            cursor_blink: true,
            cursor_blink_on: 600,
            cursor_blink_off: 400,
            selection_foreground: "#ffffff".to_string(),
            selection_background: "#5a78b4".to_string(),
            suggestion_color: "#7f849c".to_string(),
            pane_gap: 3,
            pane_layout: "auto".to_string(),
            max_panes: 4,
            persist_dividers: true,
            divider_columns: String::new(),
            divider_rows: String::new(),
            base_width: 820,
            base_height: 500,
            focus_color: "#f0b35a".to_string(),
            focus_width: 1,
            focus_style: "solid".to_string(),
            focus_animation: true,
            background_opacity: 100,
            colors: true,
            center_on_expand: true,
            copy_on_select: false,
            middle_click_paste: true,
            bracketed_paste: true,
            trim_selection: true,
            scroll_lines: 1,
        }
    }
}

fn platform_accelerator(other: &str, macos: &str) -> String {
    if cfg!(target_os = "macos") {
        macos.to_string()
    } else {
        other.to_string()
    }
}

impl Settings {
    pub fn load() -> Self {
        let mut settings = Self::default();
        let Ok(contents) = fs::read_to_string(config_path()) else {
            return settings;
        };
        for line in contents.lines() {
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            match key {
                "language" => settings.language = value.to_string(),
                "spawn_accel" => settings.spawn_accel = value.to_string(),
                "close_accel" => settings.close_accel = value.to_string(),
                "focus_left_accel" => settings.focus_left_accel = value.to_string(),
                "focus_right_accel" => settings.focus_right_accel = value.to_string(),
                "focus_up_accel" => settings.focus_up_accel = value.to_string(),
                "focus_down_accel" => settings.focus_down_accel = value.to_string(),
                "equalize_panes_accel" => settings.equalize_panes_accel = value.to_string(),
                "palette" => settings.palette = value.to_string(),
                "custom_foreground" => settings.custom_foreground = value.to_string(),
                "custom_background" => settings.custom_background = value.to_string(),
                key if key.starts_with("custom_color_") => {
                    if let Ok(index) = key[13..].parse::<usize>()
                        && index < 16
                    {
                        settings.custom_colors[index] = value.to_string();
                    }
                }
                "font" => settings.font = value.to_string(),
                "font_weight" => settings.font_weight = parse_range(value, 100, 900, 500),
                "cursor_style" => settings.cursor_style = value.to_string(),
                "cursor_color" => settings.cursor_color = value.to_string(),
                "cursor_blink" => settings.cursor_blink = value == "true",
                "cursor_blink_on" => {
                    settings.cursor_blink_on = parse_range(value, 100, 2_000, 600);
                }
                "cursor_blink_off" => {
                    settings.cursor_blink_off = parse_range(value, 100, 2_000, 400);
                }
                "selection_foreground" => settings.selection_foreground = value.to_string(),
                "selection_background" => settings.selection_background = value.to_string(),
                "suggestion_color" => settings.suggestion_color = value.to_string(),
                "pane_gap" => settings.pane_gap = parse_range(value, 0, 32, 3),
                "pane_layout" => settings.pane_layout = value.to_string(),
                "max_panes" => settings.max_panes = parse_range(value, 1, 9, 4),
                "persist_dividers" => settings.persist_dividers = value == "true",
                "divider_columns" => settings.divider_columns = value.to_string(),
                "divider_rows" => settings.divider_rows = value.to_string(),
                "base_width" => settings.base_width = parse_range(value, 320, 10_000, 820),
                "base_height" => settings.base_height = parse_range(value, 200, 10_000, 500),
                "focus_color" => settings.focus_color = value.to_string(),
                "focus_width" => settings.focus_width = parse_range(value, 1, 12, 1),
                "focus_style" => settings.focus_style = value.to_string(),
                "focus_animation" => settings.focus_animation = value == "true",
                "background_opacity" => {
                    settings.background_opacity = parse_range(value, 10, 100, 100);
                }
                "colors" => settings.colors = value == "true",
                "center_on_expand" => settings.center_on_expand = value == "true",
                "copy_on_select" => settings.copy_on_select = value == "true",
                "middle_click_paste" => settings.middle_click_paste = value == "true",
                "bracketed_paste" => settings.bracketed_paste = value == "true",
                "trim_selection" => settings.trim_selection = value == "true",
                "scroll_lines" => settings.scroll_lines = parse_range(value, 1, 20, 1),
                _ => {}
            }
        }
        settings
    }

    pub fn save(&self) -> Result<(), String> {
        let path = config_path();
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|error| error.to_string())?;
        }
        let custom = self
            .custom_colors
            .iter()
            .enumerate()
            .map(|(index, value)| format!("custom_color_{index}={value}\n"))
            .collect::<String>();
        let contents = format!(
            "language={}\nspawn_accel={}\nclose_accel={}\nfocus_left_accel={}\nfocus_right_accel={}\nfocus_up_accel={}\nfocus_down_accel={}\nequalize_panes_accel={}\npalette={}\ncustom_foreground={}\ncustom_background={}\n{}font={}\nfont_weight={}\ncursor_style={}\ncursor_color={}\ncursor_blink={}\ncursor_blink_on={}\ncursor_blink_off={}\nselection_foreground={}\nselection_background={}\nsuggestion_color={}\npane_gap={}\npane_layout={}\nmax_panes={}\npersist_dividers={}\ndivider_columns={}\ndivider_rows={}\nbase_width={}\nbase_height={}\nfocus_color={}\nfocus_width={}\nfocus_style={}\nfocus_animation={}\nbackground_opacity={}\ncolors={}\ncenter_on_expand={}\ncopy_on_select={}\nmiddle_click_paste={}\nbracketed_paste={}\ntrim_selection={}\nscroll_lines={}\n",
            self.language,
            self.spawn_accel,
            self.close_accel,
            self.focus_left_accel,
            self.focus_right_accel,
            self.focus_up_accel,
            self.focus_down_accel,
            self.equalize_panes_accel,
            self.palette,
            self.custom_foreground,
            self.custom_background,
            custom,
            self.font,
            self.font_weight,
            self.cursor_style,
            self.cursor_color,
            self.cursor_blink,
            self.cursor_blink_on,
            self.cursor_blink_off,
            self.selection_foreground,
            self.selection_background,
            self.suggestion_color,
            self.pane_gap,
            self.pane_layout,
            self.max_panes,
            self.persist_dividers,
            self.divider_columns,
            self.divider_rows,
            self.base_width,
            self.base_height,
            self.focus_color,
            self.focus_width,
            self.focus_style,
            self.focus_animation,
            self.background_opacity,
            self.colors,
            self.center_on_expand,
            self.copy_on_select,
            self.middle_click_paste,
            self.bracketed_paste,
            self.trim_selection,
            self.scroll_lines
        );
        let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
        fs::write(&temporary, contents).map_err(|error| error.to_string())?;
        replace_file(&temporary, &path).map_err(|error| {
            let _ = fs::remove_file(&temporary);
            error.to_string()
        })
    }
}

#[cfg(not(target_os = "windows"))]
fn replace_file(source: &std::path::Path, destination: &std::path::Path) -> std::io::Result<()> {
    fs::rename(source, destination)
}

#[cfg(target_os = "windows")]
fn replace_file(source: &std::path::Path, destination: &std::path::Path) -> std::io::Result<()> {
    if destination.exists() {
        fs::remove_file(destination)?;
    }
    fs::rename(source, destination)
}

#[cfg(target_os = "windows")]
fn config_path() -> PathBuf {
    env::var_os("APPDATA")
        .map(PathBuf::from)
        .map(|path| path.join("Arreliny/config"))
        .unwrap_or_else(|| PathBuf::from("arreliny.conf"))
}

#[cfg(target_os = "macos")]
fn config_path() -> PathBuf {
    env::var_os("HOME")
        .map(PathBuf::from)
        .map(|path| path.join("Library/Application Support/Arreliny/config"))
        .unwrap_or_else(|| PathBuf::from("arreliny.conf"))
}

#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
fn config_path() -> PathBuf {
    env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .map(|path| path.join("arreliny/config"))
        .or_else(|| {
            env::var_os("HOME")
                .map(PathBuf::from)
                .map(|path| path.join(".config/arreliny/config"))
        })
        .unwrap_or_else(|| PathBuf::from("arreliny.conf"))
}

fn parse_range(value: &str, minimum: i32, maximum: i32, fallback: i32) -> i32 {
    value
        .parse::<i32>()
        .ok()
        .filter(|value| (minimum..=maximum).contains(value))
        .unwrap_or(fallback)
}