From 5a2ed6b50ce7f7126afbaa14e13f2a601f0834fa Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:19:03 +0900 Subject: [PATCH 1/4] feat: greyscale glyph antialiasing --- src/renderer/terminal-pane.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/terminal-pane.ts b/src/renderer/terminal-pane.ts index af19636..78812aa 100644 --- a/src/renderer/terminal-pane.ts +++ b/src/renderer/terminal-pane.ts @@ -168,6 +168,10 @@ 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. + allowTransparency: true, // Only the focused pane blinks; twenty blinking cursors is noise. cursorBlink: false, scrollback: options.appearance.scrollback, From 73647c760e58741d3de1b53cc2fd418a53c57651 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:19:03 +0900 Subject: [PATCH 2/4] feat: full hinting and whole-pixel glyph positions on Linux --- src/main/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/index.ts b/src/main/index.ts index 5456118..94ba692 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,6 +9,14 @@ 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. mac and Windows draw +// text through their own engines and ignore both. Experiment. +if (process.platform === 'linux') { + 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]) { From f1aabb04fba6e994cdef83a79b4ba8b609b116e9 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:24:13 +0900 Subject: [PATCH 3/4] feat: text rendering setting, greyscale or subpixel --- docs/engineering-notes.md | 26 +++++++++++++++++ src/main/app-settings.test.ts | 7 +++++ src/main/app-settings.ts | 14 +++++++-- src/main/index.ts | 7 +++-- src/renderer/platform.ts | 2 ++ src/renderer/session-runtime.ts | 6 +++- src/renderer/settings-view.dom.test.ts | 2 ++ src/renderer/settings-view.ts | 40 ++++++++++++++++++++++++++ src/renderer/terminal-pane.ts | 4 ++- src/shared/protocol.ts | 11 +++++++ src/shared/settings-defaults.ts | 3 ++ src/shared/ui-strings.ts | 8 ++++++ 12 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index ac190bb..ed9a559 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -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 diff --git a/src/main/app-settings.test.ts b/src/main/app-settings.test.ts index 86045c5..cc7e27e 100644 --- a/src/main/app-settings.test.ts +++ b/src/main/app-settings.test.ts @@ -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') diff --git a/src/main/app-settings.ts b/src/main/app-settings.ts index 84c54e0..7c09915 100644 --- a/src/main/app-settings.ts +++ b/src/main/app-settings.ts @@ -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 @@ -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 @@ -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']), } } @@ -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 { diff --git a/src/main/index.ts b/src/main/index.ts index 94ba692..0764ad2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,9 +10,10 @@ import { activateWindow, createMainWindow } from './window-manager' 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. mac and Windows draw -// text through their own engines and ignore both. Experiment. -if (process.platform === 'linux') { +// 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') } diff --git a/src/renderer/platform.ts b/src/renderer/platform.ts index 937c884..6683042 100644 --- a/src/renderer/platform.ts +++ b/src/renderer/platform.ts @@ -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' diff --git a/src/renderer/session-runtime.ts b/src/renderer/session-runtime.ts index caf08d9..90f188c 100644 --- a/src/renderer/session-runtime.ts +++ b/src/renderer/session-runtime.ts @@ -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: { @@ -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. @@ -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) }, diff --git a/src/renderer/settings-view.dom.test.ts b/src/renderer/settings-view.dom.test.ts index c580eaf..dba9011 100644 --- a/src/renderer/settings-view.dom.test.ts +++ b/src/renderer/settings-view.dom.test.ts @@ -20,6 +20,7 @@ const saveSettings = vi.fn<(next: AppSettings) => Promise>(async (n let onDisk: readonly TerminalTheme[] = [] vi.stubGlobal('termspace', { + platform: 'linux', listMonoFonts: async () => [], listUserThemes: async () => onDisk, shellIntegrationStatus: async () => null, @@ -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() } diff --git a/src/renderer/settings-view.ts b/src/renderer/settings-view.ts index 34af8aa..2acfccc 100644 --- a/src/renderer/settings-view.ts +++ b/src/renderer/settings-view.ts @@ -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 @@ -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' @@ -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)) diff --git a/src/renderer/terminal-pane.ts b/src/renderer/terminal-pane.ts index 78812aa..5663525 100644 --- a/src/renderer/terminal-pane.ts +++ b/src/renderer/terminal-pane.ts @@ -115,6 +115,8 @@ export interface TerminalAppearance { readonly scrollBoost: number /** Colour palette. */ readonly theme: TerminalTheme + /** 'grayscale' or 'subpixel'. Fixed once the terminal is open. */ + readonly textRendering: string } /* @@ -171,7 +173,7 @@ export function createTerminalPane(options: TerminalPaneOptions): TerminalPane { // 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. - allowTransparency: true, + allowTransparency: options.appearance.textRendering === 'grayscale', // Only the focused pane blinks; twenty blinking cursors is noise. cursorBlink: false, scrollback: options.appearance.scrollback, diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 0d233fe..1b739ac 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -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; the other platforms + * draw text their own way and ignore both. + */ + readonly textRendering: string /** * Interface language: 'en', 'ko', or empty for the system's. * diff --git a/src/shared/settings-defaults.ts b/src/shared/settings-defaults.ts index 487f198..dc182a3 100644 --- a/src/shared/settings-defaults.ts +++ b/src/shared/settings-defaults.ts @@ -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. */ diff --git a/src/shared/ui-strings.ts b/src/shared/ui-strings.ts index 0d305c3..c053ad2 100644 --- a/src/shared/ui-strings.ts +++ b/src/shared/ui-strings.ts @@ -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', @@ -502,6 +506,10 @@ const ko: Catalog = { fontDesc: '글자 폭이 일정한 글꼴만 보여 줍니다', fontDefault: '기본값', fontMissing: (name: string) => `${name} — 설치되어 있지 않음`, + textRenderingLabel: '글자 렌더링', + textRenderingDesc: '앱을 다시 시작하면 적용됩니다.', + textRenderingGrayscale: '회색조', + textRenderingSubpixel: '서브픽셀', paletteLabel: '팔레트', openSettingsFile: '설정 파일 열기', From 1575e47448fb520fca2695b339ddf755395d7e75 Mon Sep 17 00:00:00 2001 From: ba2slk <130782318+ba2slk@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:41:39 +0900 Subject: [PATCH 4/4] fix: the greyscale atlas is asked for on Linux only --- src/renderer/terminal-pane.ts | 4 +++- src/shared/protocol.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/terminal-pane.ts b/src/renderer/terminal-pane.ts index 5663525..e6240cb 100644 --- a/src/renderer/terminal-pane.ts +++ b/src/renderer/terminal-pane.ts @@ -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' @@ -173,7 +174,8 @@ export function createTerminalPane(options: TerminalPaneOptions): TerminalPane { // 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. - allowTransparency: options.appearance.textRendering === 'grayscale', + // 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, diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 1b739ac..7768919 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -129,8 +129,8 @@ export interface AppSettings { * 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; the other platforms - * draw text their own way and ignore both. + * command-line switch. Only Linux offers the choice, and only Linux acts on + * it; the other platforms draw greyscale text already. */ readonly textRendering: string /**