diff --git a/README.md b/README.md index 49e14d4..827f526 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,30 @@ Bytes cross as `Uint8Array`, never as a string. TCP splits UTF-8 characters across chunks routinely, and decoding either half alone corrupts them; the bug appears only with CJK or emoji output, which makes it easy to miss. +### Terminal font + +The terminal uses **the desktop's own monospace font**, read from +`org.gnome.desktop.interface monospace-font-name` at startup. GNOME Terminal +defaults to `use-system-font`, so this is literally the same font rather than a +lookalike — and it stays the same font after someone changes their system +setting, which a hardcoded family would not. + +Two details that are easy to get wrong: + +- **Pango reports points, CSS wants pixels.** A `13` from GSettings is 13pt, and + rendering it as 13px makes the terminal a third too small — which reads as + "the font is wrong" rather than "the size is wrong". The conversion is 96/72. +- **The size is the *trailing* number.** Families legitimately contain digits + (`Fira Code 2` is a family), so splitting on the first number would be wrong. + +The family is always quoted in the CSS value. An unquoted `Noto Sans Mono` is a +*stack* of three families as far as CSS is concerned, and the terminal would +silently fall back to something else. + +A desktop that will not answer — not GNOME-based, or no `gsettings` — falls back +to `ui-monospace`, which asks the engine for the system monospace face. Same +request, resolved one layer down. + ### Reconnecting When a transport drops, the `Session` survives and only the backend beneath it diff --git a/README.zh-CN.md b/README.zh-CN.md index 04bb927..e3049cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -241,6 +241,26 @@ preload contextBridge,窄白名单 —— 这个文件**就是** 字节以 `Uint8Array` 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。 +### 终端字体 + +终端使用**桌面自己的等宽字体**——启动时从 `org.gnome.desktop.interface +monospace-font-name` 读取。GNOME Terminal 默认是 `use-system-font`,所以这就是 +**同一个字体**而不是仿制的;而且在用户改了系统设置之后它仍然跟着变,硬编码字 +体名做不到这一点。 + +两个容易搞错的地方: + +- **Pango 用点,CSS 用像素。** GSettings 给的 `13` 是 13pt,当成 13px 渲染会让 + 终端小三分之一——而这读起来像"字体不对",而不是"字号不对"。换算是 96/72。 +- **尺寸是*最后*一个数字。** 字体名里合法地可以带数字(`Fira Code 2` 就是一个 + 字体名),按第一个数字切会切错。 + +字体名在 CSS 值里始终加引号。未加引号的 `Noto Sans Mono` 在 CSS 看来是**三个** +字体名组成的栈,终端会静默回退到别的字体。 + +不回答的桌面——非 GNOME、或没有 `gsettings`——回退到 `ui-monospace`,那是向引擎 +要系统等宽字面。同一个请求,只是下沉一层解析。 + ### 重连 传输断开时,`Session` 存活,只替换它底下的 backend——这正是那两者被拆成独立对象的理由。渲染进程保住了滚动历史和标签页,因为终端从未被拆掉。 diff --git a/src/main/desktop.ts b/src/main/desktop.ts new file mode 100644 index 0000000..068b2fd --- /dev/null +++ b/src/main/desktop.ts @@ -0,0 +1,97 @@ +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 + } +} diff --git a/src/main/ipc/registry.ts b/src/main/ipc/registry.ts index 711bf0b..af454af 100644 --- a/src/main/ipc/registry.ts +++ b/src/main/ipc/registry.ts @@ -18,6 +18,7 @@ import { } from '@shared/ipc-schemas' import type { AppContext } from '../context' import { inspectSafeStorage } from '../security/safeStorageStatus' +import { readTerminalFont } from '../desktop' import * as connections from '../db/repositories/connections' import { deleteCredential, @@ -57,6 +58,7 @@ export function registerIpcHandlers(context: AppContext): void { platform: process.platform, safeStorage: inspectSafeStorage(), vault: context.vault.status, + terminalFont: readTerminalFont(), }), ) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5a8f69d..89f2627 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import TerminalHost from './components/Terminal/TerminalHost' import HostKeyDialog from './components/Session/HostKeyDialog' import PasswordDialog from './components/Session/PasswordDialog' import { useSessionStore } from './stores/sessionStore' +import { useSettingsStore } from './stores/settingsStore' import { SUPPORTED_LOCALES, type Locale } from './i18n' export default function App() { @@ -18,6 +19,7 @@ export default function App() { const sessionError = useSessionStore((state) => state.error) const clearSessionError = useSessionStore((state) => state.clearError) + const setTerminalFont = useSettingsStore((state) => state.setTerminalFont) // No session is opened at startup. The fixed local entry in the sidebar is // always there to double-click; opening a shell nobody asked for would just @@ -28,7 +30,10 @@ export default function App() { window.nexusterm.app .info() .then((value) => { - if (!cancelled) setInfo(value) + if (cancelled) return + setInfo(value) + // The terminal font comes from the desktop, not from this app. + setTerminalFont(value.terminalFont) }) .catch((cause: unknown) => { if (!cancelled) { diff --git a/src/renderer/src/components/Terminal/TerminalView.tsx b/src/renderer/src/components/Terminal/TerminalView.tsx index 009e994..99a32c1 100644 --- a/src/renderer/src/components/Terminal/TerminalView.tsx +++ b/src/renderer/src/components/Terminal/TerminalView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import { TerminalHandle } from '../../lib/terminals' import { forgetSessionPort, onSessionPort } from '../../lib/sessionBridge' import { useSessionStore } from '../../stores/sessionStore' +import { useSettingsStore } from '../../stores/settingsStore' interface TerminalViewProps { sessionId: string @@ -23,11 +24,18 @@ export default function TerminalView({ sessionId, active }: TerminalViewProps) { const markClosed = useSessionStore((state) => state.markClosed) const markStatus = useSessionStore((state) => state.markStatus) + const font = useSettingsStore((state) => state.terminalFont) + // Read at construction, but deliberately not a dependency of the effect that + // constructs: rebuilding the handle to change a font would throw away the + // scrollback. The effect below applies changes in place instead. + const fontRef = useRef(font) + fontRef.current = font + useEffect(() => { const container = containerRef.current if (!container) return - const handle = new TerminalHandle(container) + const handle = new TerminalHandle(container, fontRef.current) handleRef.current = handle handle.onExit(() => markClosed(sessionId)) handle.onStatus((status) => markStatus(sessionId, status)) @@ -60,5 +68,11 @@ export default function TerminalView({ sessionId, active }: TerminalViewProps) { if (active) handle.focus() }, [active]) + // Covers a terminal built before the main process reported the desktop's + // font, which is a matter of milliseconds but not one worth leaving to luck. + useEffect(() => { + if (font) handleRef.current?.setFont(font) + }, [font]) + return
} diff --git a/src/renderer/src/lib/terminals.ts b/src/renderer/src/lib/terminals.ts index 0792354..c40c750 100644 --- a/src/renderer/src/lib/terminals.ts +++ b/src/renderer/src/lib/terminals.ts @@ -1,3 +1,4 @@ +import type { TerminalFont } from '@shared/ipc-schemas' import i18n from '../i18n' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' @@ -10,6 +11,14 @@ const SCROLLBACK_LINES = 10_000 /** Acks are batched: one message per chunk would flood the port. */ const ACK_INTERVAL_MS = 4 +/** + * Used only until the main process reports the desktop's font — normally a few + * milliseconds after startup, and in practice always before a session exists. + */ +const FALLBACK_FONT_FAMILY = + 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace' +const FALLBACK_FONT_SIZE = 13 + /** * One terminal, its renderer addons, and its connection to the main process. * @@ -30,13 +39,15 @@ export class TerminalHandle { private onExitCallback: ((code: number | null) => void) | null = null private onStatusCallback: ((status: 'connected' | 'reconnecting') => void) | null = null - constructor(container: HTMLElement) { + constructor(container: HTMLElement, font: TerminalFont | null = null) { this.container = container this.terminal = new Terminal({ - fontFamily: - 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, "Noto Sans Mono CJK SC", monospace', - fontSize: 13, + // The desktop's own terminal font, read at startup. Falls back to asking + // the engine for the system monospace face, which is the same request + // made one layer down. + fontFamily: font?.family ?? FALLBACK_FONT_FAMILY, + fontSize: font?.sizePx ?? FALLBACK_FONT_SIZE, lineHeight: 1.2, cursorBlink: true, // Without this addon CJK and emoji are measured as single cells and the @@ -120,6 +131,28 @@ export class TerminalHandle { this.terminal.focus() } + /** + * Adopt a different font. + * + * A terminal created before the desktop's font was known would otherwise keep + * the fallback for its whole life, so this closes that race rather than + * leaving it to timing. xterm re-measures on an option change, but the + * resulting size has to be sent to the remote as well. + */ + setFont(font: TerminalFont): void { + if (this.disposed) return + if ( + this.terminal.options.fontFamily === font.family && + this.terminal.options.fontSize === font.sizePx + ) { + return + } + + this.terminal.options.fontFamily = font.family + this.terminal.options.fontSize = font.sizePx + this.fitAndReport() + } + /** * Recompute cols/rows from the element's size and tell the remote. * diff --git a/src/renderer/src/stores/settingsStore.ts b/src/renderer/src/stores/settingsStore.ts new file mode 100644 index 0000000..b2f8aa8 --- /dev/null +++ b/src/renderer/src/stores/settingsStore.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand' +import type { TerminalFont } from '@shared/ipc-schemas' + +/** + * Appearance settings that come from outside the app. + * + * The terminal font is not a preference this app owns — it is the desktop's, + * read at startup. Keeping it in a store rather than threading it through props + * means a terminal created before the value arrives can still be corrected + * afterwards. + */ +interface SettingsState { + /** Null until the main process has answered. */ + terminalFont: TerminalFont | null + setTerminalFont: (font: TerminalFont) => void +} + +export const useSettingsStore = create