import { execFileSync } from 'node:child_process' /** * The font the desktop's own terminal would use. * * GNOME Terminal is configured with `use-system-font` by default, so what it * renders is whatever `org.gnome.desktop.interface monospace-font-name` says. * Reading that — rather than hardcoding a family — is what makes "the same font * as GNOME Terminal" keep being true after someone changes their system font. */ export interface TerminalFont { /** A CSS font-family value. */ family: string /** In CSS pixels, converted from the point size the desktop reports. */ sizePx: number } /** * What to use when the desktop will not say, or is not GNOME. * * `ui-monospace` is deliberately first: it asks the engine for the system * monospace face, which is the same intent as the GSettings lookup, just * resolved one layer down. */ export const FALLBACK_TERMINAL_FONT: TerminalFont = { family: 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace', sizePx: 13, } const DEFAULT_SIZE_PX = 13 /** Pango reports points; CSS wants pixels. 1pt = 1/72in, 1px = 1/96in. */ const PT_TO_PX = 96 / 72 /** * Parse a Pango font description such as `'Ubuntu Mono 13'`. * * The size is the *trailing* number, not the first: families legitimately * contain digits (`Noto Sans Mono 12` is a family called "Noto Sans Mono", * while `Fira Code 12` vs `Fira Code 2 12` are different things). Splitting on * the last whitespace-delimited number is the only rule that survives both. */ export function parseMonospaceFont(raw: string): TerminalFont | null { // Either quote style, since the description's quoting is not part of what it // means and the caller should not have to care which one it was handed. const unquoted = raw.trim().replace(/^['"](.*)['"]$/, '$1').trim() if (!unquoted) return null const match = /^(.*\S)\s+(\d+(?:\.\d+)?)$/.exec(unquoted) if (!match) { // A bare family name with no size. Usable, sized by our default. return { family: quoteFamily(unquoted), sizePx: DEFAULT_SIZE_PX } } const family = match[1].trim() const points = Number(match[2]) if (!family || !Number.isFinite(points) || points <= 0) return null return { family: quoteFamily(family), sizePx: Math.round(points * PT_TO_PX * 100) / 100, } } /** * Always quote, on both paths. * * A CSS `font-family` value is a comma-separated stack, so an unquoted * `Noto Sans Mono` is read as three families — "Noto", then "Sans", then * "Mono" — and the terminal silently falls back to something else entirely. * Quoting a single-word family costs nothing. */ function quoteFamily(family: string): string { return `"${family.replace(/"/g, '')}"` } /** * Ask the desktop for its monospace font. * * Runs `gsettings` once at startup and falls back silently. A desktop that is * not GNOME-based simply will not answer, and that is not an error worth * surfacing — the fallback stack is the same request made a different way. */ export function readTerminalFont(): TerminalFont { try { const raw = execFileSync( 'gsettings', ['get', 'org.gnome.desktop.interface', 'monospace-font-name'], { encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] }, ) return parseMonospaceFont(raw) ?? FALLBACK_TERMINAL_FONT } catch { return FALLBACK_TERMINAL_FONT } }