From 0b42b14a913e481d166bf4ebec46c01e434320fb Mon Sep 17 00:00:00 2001 From: Harun Oral Date: Tue, 9 Jun 2026 22:36:10 +0200 Subject: [PATCH] fix: respect terminal font override settings --- .../lib/terminal/terminalLifecycle.ts | 6 +- .../lib/terminal/terminalRegistry.test.ts | 55 ++++++++++++++++--- src/renderer/lib/terminal/terminalSettings.ts | 44 +++++++++++++++ src/renderer/panels/TerminalPanel.tsx | 18 +++--- 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/renderer/lib/terminal/terminalLifecycle.ts b/src/renderer/lib/terminal/terminalLifecycle.ts index 92c352ee..f776b631 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.ts @@ -24,6 +24,8 @@ import { type RegistryEntry, } from './registryState' import { + getTerminalFontFamily, + getTerminalBaseFontSize, getScrollback, getScrollSensitivity, getContrastRatio, @@ -103,8 +105,8 @@ export function createAndConfigureXtermTerminal(opts: CreateOpts): ConfiguredTer const terminal = new Terminal({ theme: getActiveTheme().terminal, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: 13, + fontFamily: getTerminalFontFamily(), + fontSize: getTerminalBaseFontSize(), cursorBlink: effectiveCursorBlink(), allowProposedApi: true, scrollback: getScrollback(), diff --git a/src/renderer/lib/terminal/terminalRegistry.test.ts b/src/renderer/lib/terminal/terminalRegistry.test.ts index 4657d4f5..98aabf27 100644 --- a/src/renderer/lib/terminal/terminalRegistry.test.ts +++ b/src/renderer/lib/terminal/terminalRegistry.test.ts @@ -18,6 +18,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const events: string[] = [] beforeEach(() => { events.length = 0 }) +const settingsState = { + terminalFontFamily: '', + terminalFontSize: 0, + terminalScrollback: 2000, + terminalCursorBlink: false, + terminalScrollSpeed: 1.0, + terminalContrast: 4.5, + terminalOptionIsMeta: true, +} + vi.mock('@xterm/xterm', () => { // Faithful-enough fake: models the buffer viewportY/baseY scroll indices and // a real `.xterm-viewport` DOM child (so the registry's scroll listener and @@ -25,11 +35,14 @@ vi.mock('@xterm/xterm', () => { // scrollToBottom mutate viewportY the way xterm does. class FakeTerminal { public writes: string[] = [] - public options: { theme?: unknown } = {} + public options: Record public buffer = { active: { baseY: 0, cursorY: 0, viewportY: 0, getLine: () => undefined } } public element: HTMLElement | undefined public cols = 80 public rows = 24 + constructor(options: Record = {}) { + this.options = options + } loadAddon(): void { /* no-op */ } open(container: HTMLElement): void { this.element = document.createElement('div') @@ -79,12 +92,7 @@ vi.mock('../../stores/statusStore', () => ({ })) vi.mock('../../stores/settingsStore', () => ({ useSettingsStore: { - getState: () => ({ - terminalScrollback: 2000, - terminalCursorBlink: false, - terminalScrollSpeed: 1.0, - terminalContrast: 4.5, - }), + getState: () => settingsState, subscribe: () => () => {}, }, })) @@ -113,6 +121,13 @@ const panelTransferAck = vi.fn(async (_id: string) => undefined as undefined) const shellRegisterTerminal = vi.fn(async () => undefined) beforeEach(() => { + settingsState.terminalFontFamily = '' + settingsState.terminalFontSize = 0 + settingsState.terminalScrollback = 2000 + settingsState.terminalCursorBlink = false + settingsState.terminalScrollSpeed = 1.0 + settingsState.terminalContrast = 4.5 + settingsState.terminalOptionIsMeta = true terminalCreate.mockClear() panelTransferAck.mockClear() shellRegisterTerminal.mockClear() @@ -137,6 +152,32 @@ beforeEach(() => { }) }) +describe('terminal font settings', () => { + it('passes default terminal font settings to xterm', async () => { + const { terminalRegistry } = await import('./terminalRegistry') + + const entry = await terminalRegistry.getOrCreate('panel-font-defaults', { workspaceId: 'ws-1' }) + + expect(entry.terminal.options.fontFamily).toBe('Menlo, Monaco, "Courier New", monospace') + expect(entry.terminal.options.fontSize).toBe(13) + + terminalRegistry.dispose('panel-font-defaults') + }) + + it('passes configured terminal font settings to xterm', async () => { + settingsState.terminalFontFamily = "'Hack Nerd Font Mono','Segoe UI'" + settingsState.terminalFontSize = 16 + const { terminalRegistry } = await import('./terminalRegistry') + + const entry = await terminalRegistry.getOrCreate('panel-font-custom', { workspaceId: 'ws-1' }) + + expect(entry.terminal.options.fontFamily).toBe("'Hack Nerd Font Mono','Segoe UI'") + expect(entry.terminal.options.fontSize).toBe(16) + + terminalRegistry.dispose('panel-font-custom') + }) +}) + describe('isTerminalPasteChord', () => { const kd = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init) diff --git a/src/renderer/lib/terminal/terminalSettings.ts b/src/renderer/lib/terminal/terminalSettings.ts index 305b8b43..66d9eeba 100644 --- a/src/renderer/lib/terminal/terminalSettings.ts +++ b/src/renderer/lib/terminal/terminalSettings.ts @@ -10,6 +10,29 @@ import { getActiveTheme, subscribeTheme } from '../themeManager' import type { Theme } from '../../../shared/types' import { registry } from './registryState' +export const DEFAULT_TERMINAL_FONT_FAMILY = 'Menlo, Monaco, "Courier New", monospace' +export const DEFAULT_TERMINAL_FONT_SIZE = 13 + +/** Read the configured terminal font family, falling back to xterm defaults. */ +export function getTerminalFontFamily(): string { + return resolveTerminalFontFamily(useSettingsStore.getState().terminalFontFamily) +} + +export function resolveTerminalFontFamily(raw: string): string { + const value = typeof raw === 'string' ? raw.trim() : '' + return value || DEFAULT_TERMINAL_FONT_FAMILY +} + +/** Read the base xterm font size before canvas zoom render scaling is applied. */ +export function getTerminalBaseFontSize(): number { + return resolveTerminalFontSize(useSettingsStore.getState().terminalFontSize) +} + +export function resolveTerminalFontSize(raw: number): number { + if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_TERMINAL_FONT_SIZE + return Math.max(1, Math.min(raw, 32)) +} + /** Read the configured scrollback limit, clamped to a sane range. */ export function getScrollback(): number { const raw = useSettingsStore.getState().terminalScrollback @@ -83,6 +106,18 @@ export function applyCursorBlinkToAll(blink: boolean): void { } } +/** Apply terminal font settings to every live terminal. */ +export function applyFontSettingsToAll(fontFamily: string, fontSize: number): void { + for (const entry of registry.values()) { + try { + entry.terminal.options.fontFamily = fontFamily + entry.terminal.options.fontSize = fontSize + } catch { + /* terminal mid-dispose — ignore */ + } + } +} + /** Apply a scroll-speed multiplier (xterm `scrollSensitivity`) to every live terminal. */ export function applyScrollSensitivityToAll(value: number): void { for (const entry of registry.values()) { @@ -128,11 +163,20 @@ subscribeTheme((theme) => { // Live-apply terminal settings (cursor-blink toggle, scroll speed, Option-as-Meta) // so changes are visible without a reload. +let lastFontFamily = getTerminalFontFamily() +let lastFontSize = getTerminalBaseFontSize() let lastCursorBlink = getCursorBlink() let lastScrollSensitivity = getScrollSensitivity() let lastContrastRatio = getContrastRatio() let lastOptionIsMeta = getOptionIsMeta() useSettingsStore.subscribe((state) => { + const fontFamily = resolveTerminalFontFamily(state.terminalFontFamily) + const fontSize = resolveTerminalFontSize(state.terminalFontSize) + if (fontFamily !== lastFontFamily || fontSize !== lastFontSize) { + lastFontFamily = fontFamily + lastFontSize = fontSize + applyFontSettingsToAll(fontFamily, fontSize) + } const cursorBlink = state.terminalCursorBlink === true if (cursorBlink !== lastCursorBlink) { lastCursorBlink = cursorBlink diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index cb3d0319..9fda8166 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -18,17 +18,14 @@ import type { TerminalPanelProps } from './types' import { terminalRegistry } from '../lib/terminal/terminalRegistry' import { formatTerminalPaste, type DroppedRef } from './terminalDrop' import { useAppStore } from '../stores/appStore' +import { useSettingsStore } from '../stores/settingsStore' import { useCanvasStoreContext, useCanvasStoreApi } from '../stores/CanvasStoreContext' +import { resolveTerminalFontSize } from '../lib/terminal/terminalSettings' // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- -// Base xterm font size — must match the value used in terminalRegistry.ts when -// creating the Terminal. We re-rasterize at BASE_FONT_SIZE * renderScale when -// the canvas zooms in, so glyph atlases stay crisp instead of being CSS-upscaled. -const BASE_FONT_SIZE = 13 - // Discrete render-scale steps. We snap canvas zoom to one of these so a // continuous pinch only triggers a small number of expensive atlas rebuilds. // Capped at 2.5× — beyond that, atlas memory grows without perceptible gain. @@ -65,6 +62,9 @@ export default function TerminalPanel({ const fitTimerRef = useRef | null>(null) const lastFitSizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 }) const [renderScale, setRenderScale] = useState(1.0) + const terminalBaseFontSize = useSettingsStore((state) => + resolveTerminalFontSize(state.terminalFontSize), + ) const [showSearch, setShowSearch] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -413,8 +413,8 @@ export default function TerminalPanel({ // The canvas applies a single scale(zoom) transform to the world div. That // CSS-upscales xterm's pre-rasterized glyph atlas, which looks pixelated at // zoom > 1. To stay sharp we mimic VS Code's webFrame-zoom trick: when zoom - // settles on a higher step, we bump xterm's fontSize to BASE * renderScale - // (forcing a fresh higher-resolution atlas) and counter-scale the render + // settles on a higher step, we bump xterm's fontSize to the configured base + // size * renderScale (forcing a fresh higher-resolution atlas) and counter-scale the render // box by 1/renderScale so the on-screen size — after the world div's outer // scale(zoom) — is unchanged. Cols × rows stay constant because both the // box and the cell grow by the same factor before fit() runs. @@ -463,14 +463,14 @@ export default function TerminalPanel({ // Mutating options.fontSize triggers xterm's internal renderer refresh, // which rebuilds the WebGL glyph atlas at the new resolution. - entry.terminal.options.fontSize = BASE_FONT_SIZE * renderScale + entry.terminal.options.fontSize = terminalBaseFontSize * renderScale terminalRegistry.fit(panelId) if (wasAtBottom) entry.terminal.scrollToBottom() } catch { // Ignore — fit can throw on zero-size frames during layout transitions. } - }, [renderScale, panelId]) + }, [renderScale, panelId, terminalBaseFontSize]) // ------------------------------------------------------------------------- // Repaint after the zoom settles