master ohmu/fastfetch-openbsd / src / detection / terminalfont / terminalfont.c
ohmu  ·  2026-10-15
  1#include "terminalfont.h"
  2#include "common/io.h"
  3#include "common/properties.h"
  4#include "common/processing.h"
  5#include "common/debug.h"
  6#include "detection/terminalshell/terminalshell.h"
  7
  8static void detectAlacritty(FFTerminalFontResult* terminalFont) {
  9    // Maybe using a toml parser to read the config file is better?
 10    // https://github.com/cktan/tomlc17
 11
 12    // Doc: https://alacritty.org/config-alacritty.html#s26
 13    FF_STRBUF_AUTO_DESTROY fontNormal = ffStrbufCreate();
 14    FF_STRBUF_AUTO_DESTROY fontFamily = ffStrbufCreate();
 15    FF_STRBUF_AUTO_DESTROY fontStyle = ffStrbufCreate();
 16    FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
 17
 18    do {
 19        FFpropquery fontQueryToml[] = {
 20            { "normal =", &fontNormal },
 21            { "family =", &fontFamily },
 22            { "size =", &fontSize },
 23        };
 24
 25        // alacritty parses config files in this order
 26        if (ffParsePropFileConfigValues("alacritty/alacritty.toml", 3, fontQueryToml)) {
 27            break;
 28        }
 29        if (ffParsePropFileConfigValues("alacritty.toml", 3, fontQueryToml)) {
 30            break;
 31        }
 32        if (ffParsePropFileConfigValues(".alacritty.toml", 3, fontQueryToml)) {
 33            break;
 34        }
 35    } while (false);
 36
 37    if (fontNormal.length > 0) {
 38        ffStrbufClear(&fontFamily); // If both `normal` and `family` are specified, `normal` takes precedence
 39
 40        // { family = "Fira Code", style = "Medium" }
 41        ffStrbufTrimSpace(&fontNormal);
 42        ffStrbufTrimRight(&fontNormal, '}');
 43        ffStrbufTrimLeft(&fontNormal, '{');
 44        ffStrbufTrimSpace(&fontNormal);
 45
 46        // family = "Fira Code", style = "Medium"
 47        ffStrbufReplaceAllC(&fontNormal, ',', '\n'); // Assume no commas in font names
 48        ffParsePropLines(fontNormal.chars, "family =", &fontFamily);
 49        ffParsePropLines(fontNormal.chars, "style =", &fontStyle);
 50    }
 51
 52    if (fontFamily.length == 0) {
 53#if __APPLE__
 54        ffStrbufSetStatic(&fontFamily, "Menlo");
 55#elif _WIN32
 56        ffStrbufSetStatic(&fontFamily, "Consolas");
 57#else
 58        ffStrbufSetStatic(&fontFamily, "monospace");
 59#endif
 60    }
 61    if (fontStyle.length == 0) {
 62        ffStrbufSetStatic(&fontStyle, "Regular");
 63    }
 64
 65    if (fontSize.length == 0) {
 66        ffStrbufSetStatic(&fontSize, "11.25");
 67    }
 68
 69    ffFontInitMoveValues(&terminalFont->font, &fontFamily, &fontSize, &fontStyle);
 70}
 71
 72static void parseGhosttyConfig(FFstrbuf* path, FFstrbuf* fontName, FFstrbuf* fontNameFallback, FFstrbuf* fontSize, FFlist* configFiles /* list of FFstrbuf */) {
 73    // Maximum number of `config-file` directives to follow, guarding against runaway includes
 74    enum { FF_GHOSTTY_MAX_CONFIG_FILES = 16 };
 75
 76    FF_DEBUG("parsing config: %s", path->chars);
 77
 78    FF_STRBUF_AUTO_DESTROY buffer = ffStrbufCreate();
 79    FF_STRBUF_AUTO_DESTROY temp = ffStrbufCreate();
 80    if (!ffAppendFileBuffer(path->chars, &buffer)) {
 81        FF_DEBUG("cannot read config: %s", path->chars);
 82        if (ffStrbufEndsWithS(path, ".ghostty")) {
 83            FF_DEBUG("Trying to load config without .ghostty extension");
 84            path->chars[path->length - strlen(".ghostty")] = '\0';
 85            bool ok = ffAppendFileBuffer(path->chars, &buffer);
 86            path->chars[path->length - strlen(".ghostty")] = '.';
 87            if (!ok) {
 88                FF_DEBUG("cannot read config: %s", path->chars);
 89                return;
 90            }
 91        } else {
 92            return;
 93        }
 94    }
 95
 96    char* line = nullptr;
 97    size_t len = 0;
 98    while (ffStrbufGetline(&line, &len, &buffer)) {
 99        if (ffParsePropLine(line, "font-family =", &temp)) {
100            FF_DEBUG("found font-family='%s' in %s", temp.chars, path->chars);
101            // Ghostty appends to a fallback list; the first entry is the primary font.
102            // An empty value resets the list.
103            if (temp.length == 0) {
104                ffStrbufClear(fontName);
105                ffStrbufClear(fontNameFallback);
106            } else if (fontName->length == 0) {
107                ffStrbufSet(fontName, &temp);
108            } else if (fontNameFallback->length == 0) {
109                ffStrbufSet(fontNameFallback, &temp);
110            }
111            ffStrbufClear(&temp);
112        } else if (ffParsePropLine(line, "font-size =", &temp)) {
113            FF_DEBUG("found font-size='%s' in %s", temp.chars, path->chars);
114            // Latter overrides former
115            ffStrbufDestroy(fontSize);
116            ffStrbufInitMove(fontSize, &temp);
117        } else if (ffParsePropLine(line, "config-file =", &temp)) {
118            // Doc: https://ghostty.org/docs/config/reference#config-file
119            // A leading `?` suppresses errors if the file doesn't exist; missing files are skipped here either way
120            ffStrbufTrimLeft(&temp, '?');
121            ffStrbufTrim(&temp, '"');
122            if (temp.length == 0) {
123                continue;
124            }
125
126            if (!ffStrbufStartsWithC(&temp, '/')) {
127                // Relative paths are relative to the file containing the `config-file` directive
128                FF_STRBUF_AUTO_DESTROY absolutePath = ffStrbufCreateCopy(path);
129                ffStrbufSubstrBeforeLastC(&absolutePath, '/');
130                ffStrbufAppendC(&absolutePath, '/');
131                ffStrbufAppend(&absolutePath, &temp);
132                ffStrbufDestroy(&temp);
133                ffStrbufInitMove(&temp, &absolutePath);
134            }
135
136            // Each unique file is only loaded once, which also prevents include cycles
137            bool loaded = ffStrbufEqual(&temp, path);
138            if (!loaded) {
139                FF_LIST_FOR_EACH(FFstrbuf, it, *configFiles) {
140                    if (ffStrbufEqual(it, &temp)) {
141                        loaded = true;
142                        break;
143                    }
144                }
145            }
146
147            if (loaded) {
148                FF_DEBUG("config-file '%s' was already loaded, skipping to avoid cycles", temp.chars);
149            } else if (configFiles->length >= FF_GHOSTTY_MAX_CONFIG_FILES) {
150                FF_DEBUG("too many config-file directives, ignoring '%s'", temp.chars);
151            } else {
152                FF_DEBUG("found config-file='%s' in %s", temp.chars, path->chars);
153                ffStrbufInitMove(FF_LIST_ADD(FFstrbuf, *configFiles), &temp);
154            }
155            ffStrbufClear(&temp);
156        }
157    }
158}
159
160static void detectGhostty(FFTerminalFontResult* terminalFont, [[maybe_unused]] const char* configPathMac, const char* configPathUnix) {
161    FF_DEBUG("detectGhostty: start");
162    FF_STRBUF_AUTO_DESTROY configPath = ffStrbufCreate();
163    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
164    FF_STRBUF_AUTO_DESTROY fontNameFallback = ffStrbufCreate();
165    FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
166    FF_LIST_AUTO_DESTROY configFiles = ffListCreate(); // list of FFstrbuf
167
168    // Ghostty loads the XDG config first, then (on macOS) the Application Support config,
169    // so values in the latter override the former
170    if (instance.state.platform.configDirs.length > 0) {
171        ffStrbufSet(&configPath, FF_LIST_FIRST(FFstrbuf, instance.state.platform.configDirs));
172        ffStrbufAppendS(&configPath, configPathUnix); // ghostty/config
173        parseGhosttyConfig(&configPath, &fontName, &fontNameFallback, &fontSize, &configFiles);
174    }
175
176#if __APPLE__
177    ffStrbufSet(&configPath, &instance.state.platform.homeDir);
178    ffStrbufAppendS(&configPath, "Library/Application Support/");
179    ffStrbufAppendS(&configPath, configPathMac); // com.mitchellh.ghostty/config
180    parseGhosttyConfig(&configPath, &fontName, &fontNameFallback, &fontSize, &configFiles);
181#endif
182
183    // Files referenced by `config-file` don't take effect until the whole configuration is loaded,
184    // so they are parsed after all root config files, in the order they were found
185    for (uint32_t i = 0; i < configFiles.length; ++i) {
186        // Copy the path as parseGhosttyConfig may grow the list, invalidating pointers into it
187        ffStrbufSet(&configPath, FF_LIST_GET(FFstrbuf, configFiles, i));
188        parseGhosttyConfig(&configPath, &fontName, &fontNameFallback, &fontSize, &configFiles);
189    }
190
191    FF_LIST_FOR_EACH(FFstrbuf, it, configFiles) {
192        ffStrbufDestroy(it);
193    }
194
195    if (fontName.length == 0) {
196        ffStrbufAppendS(&fontName, "JetBrainsMono Nerd Font");
197        FF_DEBUG("using default family='%s'", fontName.chars);
198    }
199
200    if (fontSize.length == 0) {
201        ffStrbufAppendS(&fontSize,
202#if __APPLE__
203            "13"
204#else
205            "12"
206#endif
207        );
208        FF_DEBUG("using default size='%s'", fontSize.chars);
209    }
210
211    ffFontInitValues(&terminalFont->font, fontName.chars, fontSize.chars);
212    if (fontNameFallback.length > 0) {
213        FF_DEBUG("applying fallback family='%s'", fontNameFallback.chars);
214        ffFontInitValues(&terminalFont->fallback, fontNameFallback.chars, nullptr);
215    }
216    FF_DEBUG("result family='%s' size='%s'%s", fontName.chars, fontSize.chars, fontNameFallback.length ? " (with fallback)" : "");
217    FF_DEBUG("detectGhostty: end");
218}
219
220[[maybe_unused]] static void detectTTY(FFTerminalFontResult* terminalFont) {
221    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
222
223    ffParsePropFile(FASTFETCH_TARGET_DIR_ETC "/vconsole.conf", "Font =", &fontName);
224
225    if (fontName.length == 0) {
226        ffStrbufAppendS(&fontName, "VGA default kernel font ");
227        ffProcessAppendStdOut(&fontName, (char* const[]){ "showconsolefont", "--info", nullptr });
228
229        ffStrbufTrimRight(&fontName, ' ');
230    }
231
232    if (fontName.length > 0) {
233        ffFontInitCopy(&terminalFont->font, fontName.chars);
234    } else {
235        ffStrbufAppendS(&terminalFont->error, "Couldn't find Font in " FASTFETCH_TARGET_DIR_ETC "/vconsole.conf");
236    }
237}
238
239[[maybe_unused]] static bool detectKitty(const FFstrbuf* exe, FFTerminalFontResult* result) {
240    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
241    FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
242
243    char fontHex[512] = "", sizeHex[512] = "";
244    // https://github.com/fastfetch-cli/fastfetch/discussions/1030#discussioncomment-9845233
245    if (ffGetTerminalResponse(
246            "\eP+q6b697474792d71756572792d666f6e745f66616d696c79;6b697474792d71756572792d666f6e745f73697a65\e\\", // kitty-query-font_family;kitty-query-font_size
247            2,
248            "\eP1+r%*[^=]=%511[^\e]\e\\\eP1+r%*[^=]=%511[^\e]\e\\",
249            fontHex,
250            sizeHex) == nullptr &&
251        *fontHex && *sizeHex) {
252        // decode hex string
253        for (const char* p = fontHex; p[0] && p[1]; p += 2) {
254            unsigned value;
255            if (sscanf(p, "%2x", &value) == 1) {
256                ffStrbufAppendC(&fontName, (char) value);
257            }
258        }
259        for (const char* p = sizeHex; p[0] && p[1]; p += 2) {
260            unsigned value;
261            if (sscanf(p, "%2x", &value) == 1) {
262                ffStrbufAppendC(&fontSize, (char) value);
263            }
264        }
265    } else {
266        FF_STRBUF_AUTO_DESTROY buf = ffStrbufCreate();
267        if (!ffProcessAppendStdOut(&buf, (char* const[]){
268                                             exe->chars,
269                                             "+kitten",
270                                             "query-terminal",
271                                             nullptr,
272                                         })) {
273            ffParsePropLines(buf.chars, "font_family: ", &fontName);
274            ffParsePropLines(buf.chars, "font_size: ", &fontSize);
275        } else {
276            FFpropquery fontQuery[] = {
277                { "font_family ", &fontName },
278                { "font_size ", &fontSize },
279            };
280
281            ffParsePropFileConfigValues("kitty/kitty.conf", 2, fontQuery);
282
283            if (fontName.length == 0) {
284                ffStrbufSetS(&fontName, "monospace");
285            }
286            if (fontSize.length == 0) {
287                ffStrbufSetS(&fontSize, "11.0");
288            }
289        }
290    }
291
292    ffFontInitValues(&result->font, fontName.chars, fontSize.chars);
293
294    return true;
295}
296
297static bool detectWezterm(const FFstrbuf* exe, FFTerminalFontResult* result) {
298    FF_STRBUF_AUTO_DESTROY cli = ffStrbufCreateCopy(exe);
299    ffStrbufSubstrBeforeLastC(&cli, '-');
300
301#ifdef _WIN32
302    ffStrbufAppendS(&cli, ".exe");
303#endif
304
305    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
306
307    ffStrbufSetS(&result->error, ffProcessAppendStdOut(&fontName, (char* const[]){ cli.chars, "ls-fonts", "--text", "a", nullptr }));
308    if (result->error.length) {
309        return false;
310    }
311
312    // LeftToRight
313    //  0 a    \u{61}       x_adv=7  cells=1  glyph=a,180  wezterm.font("JetBrains Mono", {weight="Regular", stretch="Normal", style="Normal"})
314    //                                       <built-in>, BuiltIn
315    ffStrbufSubstrAfterFirstC(&fontName, '"');
316    ffStrbufSubstrBeforeFirstC(&fontName, '"');
317
318    if (!fontName.length) {
319        return false;
320    }
321
322    ffFontInitCopy(&result->font, fontName.chars);
323    return true;
324}
325
326static bool detectTabby(FFTerminalFontResult* result) {
327    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
328    FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
329
330    FFpropquery fontQuery[] = {
331        { "font: ", &fontName },
332        { "fontSize: ", &fontSize },
333    };
334
335    if (!ffParsePropFileConfigValues("tabby/config.yaml", 2, fontQuery)) {
336        return false;
337    }
338
339    if (fontName.length == 0) {
340        ffStrbufSetS(&fontName, "monospace");
341    }
342    if (fontSize.length == 0) {
343        ffStrbufSetS(&fontSize, "14");
344    }
345
346    ffFontInitValues(&result->font, fontName.chars, fontSize.chars);
347
348    return true;
349}
350
351static bool detectContour(const FFstrbuf* exe, FFTerminalFontResult* result) {
352    FF_STRBUF_AUTO_DESTROY buf = ffStrbufCreate();
353    if (ffProcessAppendStdOut(&buf, (char* const[]){ exe->chars, "font-locator", nullptr })) {
354        ffStrbufAppendS(&result->error, "`contour font-locator` failed");
355        return false;
356    }
357
358    //[error] Missing key .logging.enabled. Using default: false.
359    //[error] ...
360    // Matching fonts using  : Fontconfig
361    // Font description      : (family=Sarasa Term SC Nerd weight=Regular slant=Roman spacing=Monospace, strict_spacing=yes)
362    // Number of fonts found : 49
363    //  path /usr/share/fonts/google-noto/NotoSansMono-Regular.ttf Regular Roman
364    //  path ...
365
366    uint32_t index = ffStrbufFirstIndexS(&buf, "Font description      : (family=");
367    if (index >= buf.length) {
368        return false;
369    }
370    index += (uint32_t) strlen("Font description      : (family=");
371    ffStrbufSubstrBefore(&buf, ffStrbufNextIndexS(&buf, index, " weight="));
372    ffFontInitCopy(&result->font, buf.chars + index);
373    return true;
374}
375
376static bool detectRio(FFTerminalFontResult* terminalFont) {
377    FF_STRBUF_AUTO_DESTROY fontName = ffStrbufCreate();
378    FF_STRBUF_AUTO_DESTROY fontSize = ffStrbufCreate();
379
380    FFpropquery fontQueryToml[] = {
381        { "family =", &fontName },
382        { "size =", &fontSize },
383    };
384
385    ffParsePropFileConfigValues("rio/config.toml", 2, fontQueryToml);
386
387    if (fontName.length == 0) {
388        ffStrbufAppendS(&fontName, "Cascadia Code");
389    }
390
391    if (fontSize.length == 0) {
392        ffStrbufAppendS(&fontSize, "18");
393    }
394
395    ffFontInitValues(&terminalFont->font, fontName.chars, fontSize.chars);
396
397    return true;
398}
399
400bool ffDetectTerminalFontPlatform(const FFTerminalResult* terminal, FFTerminalFontResult* terminalFont);
401
402static bool detectTerminalFontCommon(const FFTerminalResult* terminal, FFTerminalFontResult* terminalFont) {
403    if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "alacritty")) {
404        detectAlacritty(terminalFont);
405    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "wezterm-gui")) {
406        detectWezterm(&terminal->exe, terminalFont);
407    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "tabby")) {
408        detectTabby(terminalFont);
409    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "contour")) {
410        detectContour(&terminal->exe, terminalFont);
411    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "ghostty")) {
412        detectGhostty(terminalFont, "com.mitchellh.ghostty/config.ghostty", "ghostty/config.ghostty");
413    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "Muxy")) {
414        detectGhostty(terminalFont, "Muxy/ghostty.conf", "muxy/ghostty.conf");
415    } else if (ffStrbufStartsWithIgnCaseS(&terminal->processName, "rio")) {
416        detectRio(terminalFont);
417    }
418#ifndef _WIN32
419    else if (ffStrbufStartsWithIgnCaseS(&terminal->exe, "/dev/pts/")) {
420        ffStrbufAppendS(&terminalFont->error, "Terminal font detection is not supported on PTS");
421    } else if (ffStrbufIgnCaseEqualS(&terminal->processName, "kitty")) {
422        detectKitty(&terminal->exe, terminalFont);
423    } else if (ffStrbufStartsWithIgnCaseS(&terminal->exe, "/dev/tty")) {
424        detectTTY(terminalFont);
425    }
426#endif
427
428    else {
429        return false;
430    }
431
432    return true;
433}
434
435bool ffDetectTerminalFont(FFTerminalFontResult* result) {
436    const FFTerminalResult* terminal = ffDetectTerminal();
437
438    if (terminal->processName.length == 0) {
439        ffStrbufAppendS(&result->error, "Terminal font needs successful terminal detection");
440    }
441
442    else if (!detectTerminalFontCommon(terminal, result)) {
443        ffDetectTerminalFontPlatform(terminal, result);
444    }
445
446    if (result->error.length == 0 && result->font.pretty.length == 0) {
447        ffStrbufAppendF(&result->error, "Unknown terminal: %s", terminal->processName.chars);
448    }
449
450    return result->error.length == 0;
451}