Skip to main content

retch_sysinfo/
terminal.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Terminal emulator detection and font configuration reading.
5
6use sysinfo::System;
7
8pub(crate) fn detect_terminal(sys: &System) -> Option<String> {
9    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
10        if !prog.is_empty() {
11            return Some(prog);
12        }
13    }
14    if let Ok(prog) = std::env::var("TERMINAL_EMULATOR") {
15        if !prog.is_empty() {
16            return Some(prog);
17        }
18    }
19    if std::env::var("ALACRITTY_LOG").is_ok() || std::env::var("ALACRITTY_WINDOW_ID").is_ok() {
20        return Some("alacritty".to_string());
21    }
22
23    let current_pid = sysinfo::Pid::from_u32(std::process::id());
24    let mut current_proc = sys.process(current_pid);
25
26    let known_terms = [
27        "kitty",
28        "alacritty",
29        "wezterm",
30        "gnome-terminal",
31        "konsole",
32        "iterm2",
33        "Terminal",
34        "rio",
35        "foot",
36        "tilix",
37        "xfce4-terminal",
38        "terminator",
39        "st",
40        "urxvt",
41        "ptyxis",
42    ];
43
44    let mut depth = 0;
45    while let Some(proc) = current_proc {
46        if depth > 5 {
47            break;
48        }
49        let name = proc.name().to_string_lossy().to_lowercase();
50        for term in &known_terms {
51            if name == *term || name.ends_with(term) || (name.contains(term) && term.len() > 3) {
52                return Some(term.to_string());
53            }
54        }
55        if let Some(parent_pid) = proc.parent() {
56            current_proc = sys.process(parent_pid);
57        } else {
58            break;
59        }
60        depth += 1;
61    }
62
63    if let Ok(term) = std::env::var("TERM") {
64        if term != "xterm-256color" && term != "xterm" && term != "linux" && term != "cygwin" {
65            if let Some(stripped) = term.strip_prefix("xterm-") {
66                return Some(stripped.to_string());
67            }
68            return Some(term);
69        }
70    }
71
72    None
73}
74
75pub(crate) fn detect_terminal_font(terminal: Option<&str>) -> Option<String> {
76    let term = terminal?;
77    let term_lower = term.to_lowercase();
78    let home = dirs::home_dir()?;
79
80    if term_lower.contains("kitty") {
81        let conf_path = home.join(".config/kitty/kitty.conf");
82        if let Ok(content) = std::fs::read_to_string(&conf_path) {
83            let mut family = None;
84            let mut size = None;
85            for line in content.lines() {
86                let line = line.trim();
87                if line.starts_with("font_family") {
88                    let parts: Vec<&str> = line.split_whitespace().collect();
89                    if parts.len() >= 2 {
90                        family = Some(parts[1..].join(" "));
91                    }
92                } else if line.starts_with("font_size") {
93                    let parts: Vec<&str> = line.split_whitespace().collect();
94                    if parts.len() >= 2 {
95                        size = Some(parts[1].to_string());
96                    }
97                }
98            }
99            match (family, size) {
100                (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
101                (Some(f), None) => return Some(f),
102                (None, Some(s)) => {
103                    let fallback = crate::theme::get_default_monospace_font()
104                        .unwrap_or_else(|| "Default".to_string());
105                    return Some(format!("{} ({})", fallback, s));
106                }
107                (None, None) => {}
108            }
109        }
110    } else if term_lower.contains("alacritty") {
111        let paths = [
112            home.join(".config/alacritty/alacritty.toml"),
113            home.join(".config/alacritty/alacritty.yml"),
114            home.join(".alacritty.toml"),
115            home.join(".alacritty.yml"),
116        ];
117        for path in paths {
118            if let Ok(content) = std::fs::read_to_string(&path) {
119                let mut family = None;
120                let mut size = None;
121                for line in content.lines() {
122                    let line = line.trim();
123                    if line.starts_with("family") {
124                        if let Some(idx) = line.find('=') {
125                            let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
126                            family = Some(val.to_string());
127                        } else if let Some(idx) = line.find(':') {
128                            let val = line[idx + 1..].trim().trim_matches('"').trim_matches('\'');
129                            family = Some(val.to_string());
130                        }
131                    } else if line.starts_with("size") {
132                        if let Some(idx) = line.find('=') {
133                            size = Some(line[idx + 1..].trim().to_string());
134                        } else if let Some(idx) = line.find(':') {
135                            size = Some(line[idx + 1..].trim().to_string());
136                        }
137                    }
138                }
139                match (family, size) {
140                    (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
141                    (Some(f), None) => return Some(f),
142                    (None, Some(s)) => {
143                        let fallback = crate::theme::get_default_monospace_font()
144                            .unwrap_or_else(|| "Default".to_string());
145                        return Some(format!("{} ({})", fallback, s));
146                    }
147                    (None, None) => {}
148                }
149            }
150        }
151    } else if term_lower.contains("wezterm") {
152        let paths = [
153            home.join(".wezterm.lua"),
154            home.join(".config/wezterm/wezterm.lua"),
155        ];
156        for path in paths {
157            if let Ok(content) = std::fs::read_to_string(&path) {
158                let mut family = None;
159                let mut size = None;
160                for line in content.lines() {
161                    if line.contains("wezterm.font") {
162                        if let Some(start) = line.find("wezterm.font") {
163                            let rest = &line[start..];
164                            if let Some(quote1) = rest.find('\'').or(rest.find('"')) {
165                                let quote_char = rest.chars().nth(quote1).unwrap();
166                                if let Some(quote2) = rest[quote1 + 1..].find(quote_char) {
167                                    family =
168                                        Some(rest[quote1 + 1..quote1 + 1 + quote2].to_string());
169                                }
170                            }
171                        }
172                    }
173                    if line.contains("font_size") {
174                        if let Some(idx) = line.find('=') {
175                            let val = line[idx + 1..].trim().trim_end_matches(',');
176                            size = Some(val.to_string());
177                        }
178                    }
179                }
180                match (family, size) {
181                    (Some(f), Some(s)) => return Some(format!("{} ({})", f, s)),
182                    (Some(f), None) => return Some(f),
183                    (None, Some(s)) => {
184                        let fallback = crate::theme::get_default_monospace_font()
185                            .unwrap_or_else(|| "Default".to_string());
186                        return Some(format!("{} ({})", fallback, s));
187                    }
188                    (None, None) => {}
189                }
190            }
191        }
192    } else if term_lower.contains("foot") {
193        let conf_path = home.join(".config/foot/foot.ini");
194        if let Ok(content) = std::fs::read_to_string(&conf_path) {
195            for line in content.lines() {
196                let line = line.trim();
197                if line.starts_with("font=") {
198                    let val = line.trim_start_matches("font=");
199                    let parts: Vec<&str> = val.split(':').collect();
200                    let family = parts[0].trim();
201                    let mut size = None;
202                    for part in &parts[1..] {
203                        if part.starts_with("size=") {
204                            size = Some(part.trim_start_matches("size=").trim());
205                        }
206                    }
207                    if let Some(s) = size {
208                        return Some(format!("{} ({})", family, s));
209                    } else {
210                        return Some(family.to_string());
211                    }
212                }
213            }
214        }
215    } else if term_lower.contains("ptyxis") {
216        #[cfg(target_os = "linux")]
217        if let Ok(output) = std::process::Command::new("gsettings")
218            .args(["get", "org.gnome.Ptyxis", "use-system-font"])
219            .output()
220        {
221            if output.status.success() {
222                let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
223                if s == "false" {
224                    if let Ok(font_out) = std::process::Command::new("gsettings")
225                        .args(["get", "org.gnome.Ptyxis", "font-name"])
226                        .output()
227                    {
228                        if font_out.status.success() {
229                            let mut font_str =
230                                String::from_utf8_lossy(&font_out.stdout).trim().to_string();
231                            font_str = font_str.trim_matches('\'').to_string();
232                            if !font_str.is_empty() {
233                                if let Some(last_space) = font_str.rfind(' ') {
234                                    let family = &font_str[..last_space];
235                                    let size = &font_str[last_space + 1..];
236                                    if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
237                                        return Some(format!("{} ({})", family, size));
238                                    }
239                                }
240                                return Some(font_str);
241                            }
242                        }
243                    }
244                } else {
245                    if let Ok(font_out) = std::process::Command::new("gsettings")
246                        .args(["get", "org.gnome.desktop.interface", "monospace-font-name"])
247                        .output()
248                    {
249                        if font_out.status.success() {
250                            let mut font_str =
251                                String::from_utf8_lossy(&font_out.stdout).trim().to_string();
252                            font_str = font_str.trim_matches('\'').to_string();
253                            if !font_str.is_empty() {
254                                if let Some(last_space) = font_str.rfind(' ') {
255                                    let family = &font_str[..last_space];
256                                    let size = &font_str[last_space + 1..];
257                                    if size.chars().all(|c| c.is_ascii_digit() || c == '.') {
258                                        return Some(format!("{} ({})", family, size));
259                                    }
260                                }
261                                return Some(font_str);
262                            }
263                        }
264                    }
265                    return crate::theme::get_default_monospace_font();
266                }
267            }
268        }
269    } else if term_lower.contains("konsole") {
270        let rc_path = home.join(".config/konsolerc");
271        let mut profile_name = "Default.profile".to_string();
272        if let Ok(content) = std::fs::read_to_string(&rc_path) {
273            for line in content.lines() {
274                let line = line.trim();
275                if line.starts_with("DefaultProfile=") {
276                    profile_name = line.trim_start_matches("DefaultProfile=").to_string();
277                    break;
278                }
279            }
280        }
281        let profile_path = home.join(".local/share/konsole").join(profile_name);
282        if let Ok(content) = std::fs::read_to_string(&profile_path) {
283            for line in content.lines() {
284                let line = line.trim();
285                if line.starts_with("Font=") {
286                    let val = line.trim_start_matches("Font=");
287                    let parts: Vec<&str> = val.split(',').collect();
288                    if !parts.is_empty() {
289                        let family = parts[0];
290                        if parts.len() > 1 {
291                            let size = parts[1];
292                            return Some(format!("{} ({})", family, size));
293                        }
294                        return Some(family.to_string());
295                    }
296                }
297            }
298        }
299        return crate::theme::get_default_monospace_font();
300    }
301
302    #[cfg(target_os = "macos")]
303    if term_lower == "iterm.app" || term_lower.contains("iterm2") {
304        if let Ok(output) = std::process::Command::new("defaults")
305            .args(["read", "com.googlecode.iterm2", "Normal Font"])
306            .output()
307        {
308            if let Ok(s) = String::from_utf8(output.stdout) {
309                let font = s.trim();
310                if !font.is_empty() {
311                    return Some(font.to_string());
312                }
313            }
314        }
315    }
316
317    None
318}