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
26 changes: 26 additions & 0 deletions docs/engineering-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,32 @@ painted in the panel background color, so as soon as the terminal background dif
looked like a black border inside the rounded corners. Removing the padding would press
the text against the edge, so the pane background was matched to the terminal background.

**Every glyph edge carried a colour fringe.** On Linux an opaque xterm canvas gets the
browser's subpixel (LCD) text antialiasing, and next to Ghostty with the same font and
palette the strokes read tinted and soft. `allowTransparency: true` makes the WebGL
glyph atlas greyscale — that is what the option changes here, not see-through panes.
Alone it left strokes thinner still (xterm.js #4212 is open on this), so on Linux the
app also asks Chromium for full hinting at whole-pixel positions
(`font-render-hinting=full`, `disable-font-subpixel-positioning`); mac and Windows draw
text through their own engines and ignore both. Measured against Ghostty by the share
of fully lit pixels per glyph, Latin lands within 1–2 points and Hangul about 9 behind.
Panes past the WebGL cap draw through the DOM renderer, which the atlas option does not
reach; with the switches on, a DOM pane and a WebGL pane came out identical at device
pixels (edge chroma spread 0.076 vs 0.078), and `bench:render` showed no frame-time
difference from the opaque build across 6, 12 and 18 streaming panes. The `subpixel`
value of the `textRendering` setting is the previous look, untouched, for eyes used to
it — a restart applies it, since the atlas mode is fixed when a terminal opens and the
hinting is a command-line switch.

**Thirteen streaming panes ran at eight frames a second.** Not this change: `main` does
the same. Twelve WebGL panes streaming `yes` hold above 100 fps; the thirteenth falls to
the DOM renderer, whose refresh rebuilds a row of elements per line and stalls the
renderer thread for about 100 ms each time — the focused pane and the keyboard stall
with it. Rare in practice, since panes past the cap mostly sit at a prompt, but a build
log or agent streaming out there drags the whole window. Left as is for now; the
cheapest fix would coalesce a DOM pane's output through the freeze queue and flush it a
few times a second.

---

## Layout
Expand Down
7 changes: 7 additions & 0 deletions src/main/app-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ describe('normalizeSettings', () => {
expect(normalizeSettings({ uiScale: Number.NaN }).uiScale).toBe(DEFAULT_SETTINGS.uiScale)
})

it('knows two text rendering modes and falls back to greyscale', () => {
expect(normalizeSettings({ textRendering: 'subpixel' }).textRendering).toBe('subpixel')
expect(normalizeSettings({ textRendering: 'grayscale' }).textRendering).toBe('grayscale')
expect(normalizeSettings({ textRendering: 'lcd' }).textRendering).toBe('grayscale')
expect(normalizeSettings({}).textRendering).toBe('grayscale')
})

it('accepts only locales we ship a catalogue for', () => {
expect(normalizeSettings({ locale: 'ko' }).locale).toBe('ko')
expect(normalizeSettings({ locale: 'en' }).locale).toBe('en')
Expand Down
14 changes: 12 additions & 2 deletions src/main/app-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { DEFAULT_SETTINGS }

/** What the focused pane's border may follow. */
export const FOCUS_BORDER_MODES = ['white', 'palette', 'custom'] as const
export const TEXT_RENDERING_MODES = ['grayscale', 'subpixel'] as const

/** Interface languages with a catalogue. Empty means the system's. */
export const LOCALES = ['', 'en', 'ko'] as const
Expand Down Expand Up @@ -78,6 +79,12 @@ function cleanFocusBorder(value: unknown): string {
: DEFAULT_SETTINGS.focusBorder
}

function cleanTextRendering(value: unknown): string {
return typeof value === 'string' && (TEXT_RENDERING_MODES as readonly string[]).includes(value)
? value
: DEFAULT_SETTINGS.textRendering
}

/** Reaches a CSS declaration, so nothing but a plain hex colour gets through. */
function cleanHexColor(value: unknown): string {
if (typeof value !== 'string') return DEFAULT_SETTINGS.focusBorderColor
Expand Down Expand Up @@ -110,6 +117,7 @@ export function normalizeSettings(raw: unknown): AppSettings {
focusBorder: cleanFocusBorder(input['focusBorder']),
focusBorderColor: cleanHexColor(input['focusBorderColor']),
locale: cleanLocale(input['locale']),
textRendering: cleanTextRendering(input['textRendering']),
}
}

Expand Down Expand Up @@ -138,13 +146,15 @@ const HEADER = `# Termspace settings
# focusBorder what colours the focused pane's border: white, palette, or custom
# focusBorderColor the colour custom uses, as #rrggbb
# locale interface language: en, ko, or empty to follow the system
# textRendering terminal glyph antialiasing: grayscale, or subpixel for the browser's LCD text (Linux only)
`

/**
* The same read, blocking.
*
* Only the locale needs this: it has to reach the renderer in the page URL,
* which is fixed before the window is created and cannot wait on a promise.
* The locale needs this: it has to reach the renderer in the page URL, which
* is fixed before the window is created and cannot wait on a promise. So does
* textRendering, whose switches must be on the command line before app ready.
*/
export function loadSettingsSync(env: NodeJS.ProcessEnv): AppSettings {
try {
Expand Down
9 changes: 9 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { activateWindow, createMainWindow } from './window-manager'
// XWayland, which hurts HiDPI scaling and IME behaviour.
app.commandLine.appendSwitch('ozone-platform-hint', 'auto')

// Linux only: FreeType text. Snap glyphs to whole pixels and hint them fully,
// or strokes fall between pixels and read thin and soft — the greyscale atlas
// depends on it. mac and Windows draw text through their own engines and
// ignore both. Command-line switches, so this reads the setting before ready.
if (process.platform === 'linux' && loadSettingsSync(process.env).textRendering === 'grayscale') {
app.commandLine.appendSwitch('font-render-hinting', 'full')
app.commandLine.appendSwitch('disable-font-subpixel-positioning')
}

// If the launching terminal closes, later writes throw EPIPE and an unhandled
// exception in main becomes an error dialog. Losing a log line is harmless.
for (const stream of [process.stdout, process.stderr]) {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@
import { api } from './api'

export const IS_MAC = api.platform === 'darwin'
/** The one platform where text rendering is a choice; see AppSettings.textRendering. */
export const IS_LINUX = api.platform === 'linux'
6 changes: 5 additions & 1 deletion src/renderer/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,8 @@ export function startSession(options: StartSessionOptions): SessionRuntime {
const paneSpec = paneSpecs.get(paneId)
if (paneSpec === undefined) return

const { fontSize, lineHeight, scrollback, fontFamily, scrollBoost } = options.settings()
const { fontSize, lineHeight, scrollback, fontFamily, scrollBoost, textRendering } =
options.settings()
const terminal = createTerminalPane({
paneId,
appearance: {
Expand All @@ -511,6 +512,7 @@ export function startSession(options: StartSessionOptions): SessionRuntime {
fontFamily,
scrollBoost,
theme: options.theme(),
textRendering,
},
onInput: (data) => api.write(paneId, data),
// The addon detaches itself on context loss; untrack it or it never returns.
Expand Down Expand Up @@ -846,6 +848,8 @@ export function startSession(options: StartSessionOptions): SessionRuntime {
fontFamily: next.fontFamily,
scrollBoost: next.scrollBoost,
theme: options.theme(),
// Fixed at open; a live change waits for the next start, like locale.
textRendering: next.textRendering,
}
for (const record of records.values()) record.terminal.applyAppearance(appearance)
},
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/settings-view.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const saveSettings = vi.fn<(next: AppSettings) => Promise<AppSettings>>(async (n
let onDisk: readonly TerminalTheme[] = []

vi.stubGlobal('termspace', {
platform: 'linux',
listMonoFonts: async () => [],
listUserThemes: async () => onDisk,
shellIntegrationStatus: async () => null,
Expand Down Expand Up @@ -74,6 +75,7 @@ describe('restoring one setting', () => {
open({})
for (const key of [
'fontSize', 'uiScale', 'copyOnSelect', 'fontFamily', 'theme', 'locale', 'focusBorder',
'textRendering',
] as const) {
expect(reset(key), key).not.toBeNull()
}
Expand Down
40 changes: 40 additions & 0 deletions src/renderer/settings-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { api } from './api'
import { normalizeHex } from './focus-border'
import { createKeybindingsPanel, type KeybindingsPanel } from './keybindings-view'
import { t } from './i18n'
import { IS_LINUX } from './platform'

interface Limit {
readonly min: number
Expand Down Expand Up @@ -580,6 +581,43 @@ export function createSettingsView(host: HTMLElement, hooks: SettingsHooks): Set
* Language. Applied at startup only, so the row says so rather than pretending
* the screen behind it will change.
*/
function textRenderingRow(value: string): HTMLElement {
const row = document.createElement('div')
row.className = 'settings__row'

const text = document.createElement('div')
text.className = 'settings__text'
const label = document.createElement('span')
label.textContent = t.settings.textRenderingLabel
const description = document.createElement('small')
description.textContent = t.settings.textRenderingDesc
text.append(label, description)

const control = document.createElement('div')
control.className = 'settings__control'

const select = document.createElement('select')
select.className = 'settings__select'
select.dataset['setting'] = 'textRendering'
for (const [id, name] of [
['grayscale', t.settings.textRenderingGrayscale],
['subpixel', t.settings.textRenderingSubpixel],
] as const) {
const option = document.createElement('option')
option.value = id
option.textContent = name
select.append(option)
}
select.value = value
select.addEventListener('change', () => {
commit({ ...hooks.settings(), textRendering: select.value })
})

control.append(select, resetButton('textRendering'))
row.append(text, control)
return row
}

function localeRow(value: string): HTMLElement {
const row = document.createElement('div')
row.className = 'settings__row'
Expand Down Expand Up @@ -670,6 +708,8 @@ export function createSettingsView(host: HTMLElement, hooks: SettingsHooks): Set
const values = document.createElement('div')
values.append(themeRow(settings.theme))
values.append(fontRow(settings.fontFamily))
// Elsewhere the platform draws text its own way and the choice does nothing.
if (IS_LINUX) values.append(textRenderingRow(settings.textRendering))
for (const field of FIELDS) values.append(fieldRow(field, settings[field.key]))
values.append(toggleRow(NOTIFICATIONS, settings.notifications))
values.append(toggleRow(INHERIT_WORKING_DIR, settings.inheritWorkingDir))
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/terminal-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { TerminalTheme } from '../shared/terminal-themes'
import { api } from './api'
import { guardImeDoubleCommit } from './ime-double-commit'
import { ImeTrace } from './ime-trace'
import { IS_LINUX } from './platform'
import { isLinkActivation } from './link-activation'
import { IS_MAC } from './platform'
import { shellQuote } from '../shared/shell-quote'
Expand Down Expand Up @@ -115,6 +116,8 @@ export interface TerminalAppearance {
readonly scrollBoost: number
/** Colour palette. */
readonly theme: TerminalTheme
/** 'grayscale' or 'subpixel'. Fixed once the terminal is open. */
readonly textRendering: string
}

/*
Expand Down Expand Up @@ -168,6 +171,11 @@ export function createTerminalPane(options: TerminalPaneOptions): TerminalPane {
fontSize: options.appearance.fontSize,
lineHeight: options.appearance.lineHeight,
allowProposedApi: true,
// Not for see-through panes: an opaque canvas gets subpixel (LCD) text
// antialiasing on Linux, which leaves colour fringes on every glyph edge.
// With alpha the glyph atlas is drawn greyscale, like other terminals.
// Elsewhere text is greyscale already, so the canvas stays opaque.
allowTransparency: IS_LINUX && options.appearance.textRendering === 'grayscale',
// Only the focused pane blinks; twenty blinking cursors is noise.
cursorBlink: false,
scrollback: options.appearance.scrollback,
Expand Down
11 changes: 11 additions & 0 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ export interface AppSettings {
readonly focusBorder: string
/** The colour for 'custom' mode, as #rrggbb. Ignored in the other two. */
readonly focusBorderColor: string
/**
* How terminal glyphs are antialiased: 'grayscale' or 'subpixel'.
*
* 'grayscale' draws the glyph atlas without colour fringes and, on Linux,
* hints glyphs fully at whole-pixel positions. 'subpixel' is the browser's
* LCD text as it was before this setting existed. Read at startup only: the
* atlas mode is fixed when a terminal opens, and the hinting is a
* command-line switch. Only Linux offers the choice, and only Linux acts on
* it; the other platforms draw greyscale text already.
*/
readonly textRendering: string
/**
* Interface language: 'en', 'ko', or empty for the system's.
*
Expand Down
3 changes: 3 additions & 0 deletions src/shared/settings-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
focusBorderColor: '#7a9bbf',
// Empty follows the system locale.
locale: '',
// What other terminals draw. 'subpixel' is the pre-1.1 look, kept for eyes
// used to it.
textRendering: 'grayscale',
}

/** True when this key still holds what the app ships with. */
Expand Down
8 changes: 8 additions & 0 deletions src/shared/ui-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ const en = {
fontDesc: 'Terminal font. Only fixed-width fonts are listed',
fontDefault: 'Default',
fontMissing: (name: string) => `${name} — not installed`,
textRenderingLabel: 'Text rendering',
textRenderingDesc: 'Applied the next time the app starts.',
textRenderingGrayscale: 'Greyscale',
textRenderingSubpixel: 'Subpixel',
paletteLabel: 'Palette',

openSettingsFile: 'Open settings file',
Expand Down Expand Up @@ -502,6 +506,10 @@ const ko: Catalog = {
fontDesc: '글자 폭이 일정한 글꼴만 보여 줍니다',
fontDefault: '기본값',
fontMissing: (name: string) => `${name} — 설치되어 있지 않음`,
textRenderingLabel: '글자 렌더링',
textRenderingDesc: '앱을 다시 시작하면 적용됩니다.',
textRenderingGrayscale: '회색조',
textRenderingSubpixel: '서브픽셀',
paletteLabel: '팔레트',

openSettingsFile: '설정 파일 열기',
Expand Down