Skip to main content

xei_core/
config.rs

1//! User config at `~/.xei.toml` (simple line-oriented, no extra deps).
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Clone, Debug)]
8pub struct Config {
9    pub theme: String,
10    /// Spaces per tab / indent
11    pub tab_width: usize,
12    /// Mirror yanks to system clipboard (unnamedplus-style)
13    pub clipboard_sync: bool,
14    /// Show relative line numbers in gutter
15    pub relative_number: bool,
16    /// Soft-wrap long lines (false = horizontal scroll).
17    pub wrap_lines: bool,
18    /// Startup check for a newer release (welcome-screen notice).
19    pub update_check: bool,
20    /// Keep undo history on disk when a file closes (resume on reopen).
21    pub undo_caching: bool,
22    /// Kitty-graphics layer (inline preview images, pet, media previews).
23    pub gpu_graphics: bool,
24    /// OSC 8 hyperlinks.
25    pub gpu_hyperlinks: bool,
26    /// GPU-terminal progressive enhancements (Ghostty/Kitty).
27    pub gpu_acc: bool,
28    /// Show which-key style chord hints after prefix keys.
29    pub key_hints: bool,
30    /// Master switch for automatic LSP start.
31    pub lsp_enabled: bool,
32    /// Per-language LSP command overrides.
33    /// Key = language id (`rust`, `python`, …).
34    /// Value = command line; empty string = disabled for that language.
35    /// Missing key = use built-in default.
36    pub lsp_servers: HashMap<String, String>,
37    /// Desktop pet GIF overlay (requires gpu_acc + Kitty graphics).
38    pub pet_enabled: bool,
39    pub pet_path: String,
40    pub pet_x: u16,
41    pub pet_y: u16,
42    pub pet_width_cells: u16,
43    /// Playback speed percent (25..=400). 100 = native GIF timing.
44    pub pet_speed: u16,
45}
46
47impl Default for Config {
48    fn default() -> Self {
49        Self {
50            theme: "ocean".into(),
51            tab_width: 4,
52            clipboard_sync: true,
53            relative_number: false,
54            wrap_lines: true,
55            update_check: true,
56            undo_caching: false,
57            gpu_graphics: true,
58            gpu_hyperlinks: true,
59            gpu_acc: true,
60            key_hints: true,
61            lsp_enabled: true,
62            lsp_servers: HashMap::new(),
63            pet_enabled: false,
64            pet_path: String::new(),
65            pet_x: 2,
66            pet_y: 2,
67            pet_width_cells: 12,
68            pet_speed: 100,
69        }
70    }
71}
72
73/// Languages shown in Settings → LSP (order preserved).
74pub fn lsp_lang_catalog() -> &'static [(&'static str, &'static str, &'static str)] {
75    // (settings key, display label, default command)
76    &[
77        ("rust", "Rust", "rust-analyzer"),
78        ("python", "Python", "pyright-langserver --stdio"),
79        ("typescript", "TypeScript", "typescript-language-server --stdio"),
80        ("javascript", "JavaScript", "typescript-language-server --stdio"),
81        ("c", "C / C++", "clangd"),
82        ("go", "Go", "gopls"),
83        ("java", "Java", "jdtls"),
84        ("lua", "Lua", "lua-language-server"),
85        ("json", "JSON", "vscode-json-language-server --stdio"),
86        ("yaml", "YAML", "yaml-language-server --stdio"),
87        ("toml", "TOML", "taplo lsp stdio"),
88        ("markdown", "Markdown", "marksman server"),
89        ("bash", "Bash", "bash-language-server start"),
90        ("zig", "Zig", "zls"),
91    ]
92}
93
94fn config_path() -> PathBuf {
95    let home = std::env::var("HOME")
96        .or_else(|_| std::env::var("USERPROFILE"))
97        .unwrap_or_else(|_| ".".to_string());
98    PathBuf::from(home).join(".xei.toml")
99}
100
101pub fn load() -> Config {
102    let mut cfg = Config::default();
103    let Ok(content) = fs::read_to_string(config_path()) else {
104        return cfg;
105    };
106    for line in content.lines() {
107        let line = line.trim();
108        if line.is_empty() || line.starts_with('#') {
109            continue;
110        }
111        let Some((k, v)) = line.split_once('=') else {
112            continue;
113        };
114        let k = k.trim();
115        let v = v.trim().trim_matches('"').trim_matches('\'');
116        match k {
117            "theme" => {
118                if !v.is_empty() {
119                    cfg.theme = v.to_string();
120                }
121            }
122            "tab_width" | "tabstop" => {
123                if let Ok(n) = v.parse::<usize>() {
124                    if n > 0 && n <= 16 {
125                        cfg.tab_width = n;
126                    }
127                }
128            }
129            "clipboard_sync" => {
130                cfg.clipboard_sync = matches!(v, "true" | "1" | "yes" | "on");
131            }
132            "relative_number" | "relativenumber" => {
133                cfg.relative_number = matches!(v, "true" | "1" | "yes" | "on");
134            }
135            "wrap_lines" | "wrap" => {
136                cfg.wrap_lines = matches!(v, "true" | "1" | "yes" | "on");
137            }
138            "update_check" => {
139                cfg.update_check = matches!(v, "true" | "1" | "yes" | "on");
140            }
141            "undo_caching" => {
142                cfg.undo_caching = matches!(v, "true" | "1" | "yes" | "on");
143            }
144            "gpu_graphics" => {
145                cfg.gpu_graphics = matches!(v, "true" | "1" | "yes" | "on");
146            }
147            "gpu_hyperlinks" => {
148                cfg.gpu_hyperlinks = matches!(v, "true" | "1" | "yes" | "on");
149            }
150            "gpu_acc" | "gpu_acceleration" | "graphics" => {
151                cfg.gpu_acc = matches!(
152                    v,
153                    "true" | "1" | "yes" | "on" | "auto" | "kitty" | "ghostty"
154                );
155            }
156            "key_hints" | "which_key" | "chord_hints" => {
157                cfg.key_hints = matches!(v, "true" | "1" | "yes" | "on");
158            }
159            "lsp_enabled" | "lsp" => {
160                cfg.lsp_enabled = matches!(v, "true" | "1" | "yes" | "on");
161            }
162            "pet_enabled" | "pet" => {
163                cfg.pet_enabled = matches!(v, "true" | "1" | "yes" | "on");
164            }
165            "pet_path" => {
166                cfg.pet_path = v.to_string();
167            }
168            "pet_x" => {
169                if let Ok(n) = v.parse::<u16>() {
170                    // Soft cap only; runtime clamps to terminal size.
171                    cfg.pet_x = n.min(10_000);
172                }
173            }
174            "pet_y" => {
175                if let Ok(n) = v.parse::<u16>() {
176                    cfg.pet_y = n.min(10_000);
177                }
178            }
179            "pet_width_cells" | "pet_width" => {
180                if let Ok(n) = v.parse::<u16>() {
181                    cfg.pet_width_cells = n.clamp(4, 80);
182                }
183            }
184            "pet_speed" => {
185                if let Ok(n) = v.parse::<u16>() {
186                    cfg.pet_speed = n.clamp(25, 400);
187                }
188            }
189            k if k.starts_with("lsp.") => {
190                let lang = k.trim_start_matches("lsp.").trim().to_lowercase();
191                if !lang.is_empty() {
192                    // empty value or "off" / "none" / "false" disables
193                    if matches!(v, "" | "off" | "none" | "false" | "0") {
194                        cfg.lsp_servers.insert(lang, String::new());
195                    } else {
196                        cfg.lsp_servers.insert(lang, v.to_string());
197                    }
198                }
199            }
200            _ => {}
201        }
202    }
203    cfg
204}
205
206pub fn save(cfg: &Config) {
207    let mut content = format!(
208        "# xei config\ntheme = \"{}\"\ntab_width = {}\nclipboard_sync = {}\nrelative_number = {}\nwrap_lines = {}\nupdate_check = {}\nundo_caching = {}\ngpu_graphics = {}\ngpu_hyperlinks = {}\ngpu_acc = {}\nkey_hints = {}\nlsp_enabled = {}\npet_enabled = {}\npet_path = \"{}\"\npet_x = {}\npet_y = {}\npet_width_cells = {}\npet_speed = {}\n",
209        cfg.theme,
210        cfg.tab_width,
211        cfg.clipboard_sync,
212        cfg.relative_number,
213        if cfg.wrap_lines { "true" } else { "false" },
214        if cfg.update_check { "true" } else { "false" },
215        if cfg.undo_caching { "true" } else { "false" },
216        if cfg.gpu_graphics { "true" } else { "false" },
217        if cfg.gpu_hyperlinks { "true" } else { "false" },
218        if cfg.gpu_acc { "true" } else { "false" },
219        if cfg.key_hints { "true" } else { "false" },
220        if cfg.lsp_enabled { "true" } else { "false" },
221        if cfg.pet_enabled { "true" } else { "false" },
222        cfg.pet_path.replace('"', ""),
223        cfg.pet_x,
224        cfg.pet_y,
225        cfg.pet_width_cells,
226        cfg.pet_speed.clamp(25, 400),
227    );
228    content.push_str("\n# LSP servers (empty / off = disabled; omit = built-in default)\n");
229    // Save known catalog keys first (stable order), then any extras
230    let mut seen = std::collections::HashSet::new();
231    for (key, _label, _default) in lsp_lang_catalog() {
232        if let Some(cmd) = cfg.lsp_servers.get(*key) {
233            seen.insert(key.to_string());
234            if cmd.is_empty() {
235                content.push_str(&format!("lsp.{key} = \"off\"\n"));
236            } else {
237                content.push_str(&format!("lsp.{key} = \"{}\"\n", cmd.replace('"', "")));
238            }
239        }
240    }
241    let mut extras: Vec<_> = cfg
242        .lsp_servers
243        .iter()
244        .filter(|(k, _)| !seen.contains(k.as_str()))
245        .collect();
246    extras.sort_by(|a, b| a.0.cmp(b.0));
247    for (k, cmd) in extras {
248        if cmd.is_empty() {
249            content.push_str(&format!("lsp.{k} = \"off\"\n"));
250        } else {
251            content.push_str(&format!("lsp.{k} = \"{}\"\n", cmd.replace('"', "")));
252        }
253    }
254    let _ = fs::write(config_path(), content);
255}
256
257pub fn save_theme(name: &str) {
258    let mut cfg = load();
259    cfg.theme = name.to_string();
260    save(&cfg);
261}
262
263pub fn load_theme() -> Option<String> {
264    let cfg = load();
265    if cfg.theme.is_empty() {
266        None
267    } else {
268        Some(cfg.theme)
269    }
270}