Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/renderer/lib/terminal/terminalLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
type RegistryEntry,
} from './registryState'
import {
getTerminalFontFamily,
getTerminalBaseFontSize,
getScrollback,
getScrollSensitivity,
getContrastRatio,
Expand Down Expand Up @@ -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(),
Expand Down
55 changes: 48 additions & 7 deletions src/renderer/lib/terminal/terminalRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,31 @@ 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
// line-index save/restore both exercise real code paths). scrollToLine /
// scrollToBottom mutate viewportY the way xterm does.
class FakeTerminal {
public writes: string[] = []
public options: { theme?: unknown } = {}
public options: Record<string, unknown>
public buffer = { active: { baseY: 0, cursorY: 0, viewportY: 0, getLine: () => undefined } }
public element: HTMLElement | undefined
public cols = 80
public rows = 24
constructor(options: Record<string, unknown> = {}) {
this.options = options
}
loadAddon(): void { /* no-op */ }
open(container: HTMLElement): void {
this.element = document.createElement('div')
Expand Down Expand Up @@ -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: () => () => {},
},
}))
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down
44 changes: 44 additions & 0 deletions src/renderer/lib/terminal/terminalSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions src/renderer/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -65,6 +62,9 @@ export default function TerminalPanel({
const fitTimerRef = useRef<ReturnType<typeof setTimeout> | 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('')
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading