From a21d328cb9aabb51d7bd2eecd387c81053a818cd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:56:59 -0700 Subject: [PATCH] fix(terminal): fill OpenCode DOM block glyphs in repainted rows (#24582) * fix(terminal): fill DOM block glyphs only in repainted rows Adapt the block-fill approach from PR #15955 and bound painting to xterm render ranges without observer or animation-frame rescans. Co-authored-by: mmarabel * fix(terminal): preserve block fills across DOM row replacements --------- Co-authored-by: mmarabel --- .../src/lib/pane-manager/pane-dom-creation.ts | 1 + .../lib/pane-manager/pane-lifecycle.test.ts | 2 + .../src/lib/pane-manager/pane-lifecycle.ts | 4 + .../lib/pane-manager/pane-manager-types.ts | 1 + .../pane-terminal-gpu-acceleration.ts | 5 + .../terminal-dom-block-fill.test.ts | 231 ++++++++++++++++++ .../pane-manager/terminal-dom-block-fill.ts | 172 +++++++++++++ 7 files changed, 416 insertions(+) create mode 100644 src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 6313a81cca7..cb7ef826b87 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -144,6 +144,7 @@ export function createPaneDOM( paneDragCleanup, compositionHandler: null, focusClassSyncCleanup: null, + domBlockFillCleanup: null, terminalScrollIntentDisposable: null, mouseEncodingTrackerDisposable, linkifierMouseLeaveResetDisposable: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index c42a56d391f..52b44eade33 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -44,10 +44,12 @@ function createPane(): ManagedPaneInternal { id: 1, leafId, stablePaneId: leafId, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: stand-in for xterm's Terminal exposing only the members these lifecycle paths touch. terminal: { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + onRender: vi.fn(() => ({ dispose: vi.fn() })), cols: 80, rows: 24 } as never, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 4f9ac4a0be9..2e1122f8852 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -15,6 +15,7 @@ import { } from './terminal-linkifier-hover-reset-on-mouseleave' import { installTerminalLinkifierHoverResetOnWrite } from './terminal-linkifier-hover-reset-on-write' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' +import { attachDomBlockFill } from './terminal-dom-block-fill' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { rebuildAttachedWebgl } from './pane-webgl-reattach' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -104,6 +105,7 @@ export function openTerminal( pane.compositionHandler = installTerminalImeCandidateAnchor(terminal) pane.focusClassSyncCleanup = attachDomRendererFocusClassSync(terminal.element) + pane.domBlockFillCleanup = attachDomBlockFill(terminal) // Configure the first atlas with ligatures instead of immediately rebuilding it. if (ligatures) { @@ -201,6 +203,8 @@ export function disposePane( pane.paneDragCleanup = null pane.focusClassSyncCleanup?.() pane.focusClassSyncCleanup = null + pane.domBlockFillCleanup?.() + pane.domBlockFillCleanup = null pane.terminalScrollIntentDisposable?.dispose() pane.terminalScrollIntentDisposable = null pane.mouseEncodingTrackerDisposable?.dispose() diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index fe4fcb5bc6f..3065a3c3704 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -200,6 +200,7 @@ export type ManagedPaneInternal = { compositionHandler: (() => void) | null // Stored so disposePane() can remove DOM-renderer focus synchronization. focusClassSyncCleanup?: (() => void) | null + domBlockFillCleanup?: (() => void) | null // Stored so disposePane() can remove user-scroll intent listeners. terminalScrollIntentDisposable?: IDisposable | null // Stored so disposePane() can drop the mouse-encoding parser handlers. diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts index 099e3a40c82..d096352eb4e 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-gpu-acceleration.ts @@ -6,6 +6,7 @@ import { shouldUseTerminalWebgl } from './pane-webgl-renderer' import { safeFit } from './pane-tree-ops' +import { applyDomBlockFills } from './terminal-dom-block-fill' import { resetPaneWebglContextLosses } from './pane-webgl-context-loss-policy' export function applyTerminalGpuAcceleration( @@ -32,6 +33,10 @@ export function applyTerminalGpuAcceleration( } if (!shouldUseTerminalWebgl(pane)) { disposeWebgl(pane, { refreshDimensions: true }) + const root = pane.terminal.element + if (root) { + applyDomBlockFills(root) + } continue } if ( diff --git a/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts new file mode 100644 index 00000000000..3a111750268 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.test.ts @@ -0,0 +1,231 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + applyDomBlockFills, + attachDomBlockFill, + backgroundImageForUniformBlockRun +} from './terminal-dom-block-fill' + +describe('backgroundImageForUniformBlockRun', () => { + it('fills the top half of the cell for a run of upper-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▀▀▀')).toBe( + 'linear-gradient(to bottom, var(--orca-block-fg) 50%, transparent 50%)' + ) + }) + + it('fills the bottom half of the cell for lower-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▄▄')).toBe( + 'linear-gradient(to bottom, transparent 50%, var(--orca-block-fg) 50%)' + ) + }) + + it('fills the whole cell for full blocks', () => { + expect(backgroundImageForUniformBlockRun('█')).toBe( + 'linear-gradient(to bottom, transparent 0%, var(--orca-block-fg) 0%)' + ) + }) + + it('fills the left half for left-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▌')).toBe( + 'linear-gradient(to right, var(--orca-block-fg) 50%, transparent 50%)' + ) + }) + + it('fills the right half for right-half blocks', () => { + expect(backgroundImageForUniformBlockRun('▐')).toBe( + 'linear-gradient(to right, transparent 50%, var(--orca-block-fg) 50%)' + ) + }) + + it('fills the top eighth for upper one-eighth blocks', () => { + expect(backgroundImageForUniformBlockRun('▔')).toBe( + 'linear-gradient(to bottom, var(--orca-block-fg) 12.5%, transparent 12.5%)' + ) + }) + + it('fills the right eighth for right one-eighth blocks', () => { + expect(backgroundImageForUniformBlockRun('▕')).toBe( + 'linear-gradient(to right, transparent 87.5%, var(--orca-block-fg) 87.5%)' + ) + }) + + it('skips mixed runs, spaces, and ASCII', () => { + expect(backgroundImageForUniformBlockRun('▀█')).toBeNull() + expect(backgroundImageForUniformBlockRun('▀ ▀')).toBeNull() + expect(backgroundImageForUniformBlockRun('Ask')).toBeNull() + expect(backgroundImageForUniformBlockRun('')).toBeNull() + }) +}) + +describe('applyDomBlockFills', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + function mountSpan(text: string, color = 'rgb(30, 30, 30)'): HTMLSpanElement { + const rows = document.createElement('div') + rows.className = 'xterm-rows' + const span = document.createElement('span') + span.textContent = text + span.style.color = color + span.style.backgroundColor = 'rgb(10, 10, 10)' + const row = document.createElement('div') + row.appendChild(span) + rows.appendChild(row) + document.body.appendChild(rows) + return span + } + + it('paints a uniform ▀ composer border with a cell-filling gradient', () => { + const span = mountSpan('▀'.repeat(12)) + + applyDomBlockFills(document) + + expect(span.style.color).toBe('transparent') + expect(span.style.backgroundImage).toContain('linear-gradient(to bottom') + expect(span.style.backgroundSize).toBe(`${100 / 12}% 100%`) + expect(span.style.backgroundRepeat).toBe('repeat-x') + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(30, 30, 30)') + }) + + it('repeats a per-cell horizontal fill across a multi-cell left-half run', () => { + const span = mountSpan('▌▌') + + applyDomBlockFills(document) + + expect(span.style.backgroundImage).toContain('linear-gradient(to right') + expect(span.style.backgroundSize).toBe('50% 100%') + expect(span.style.backgroundRepeat).toBe('repeat-x') + }) + + it('repeats a per-cell horizontal fill across a multi-cell right-half run', () => { + const span = mountSpan('▐▐') + + applyDomBlockFills(document) + + expect(span.style.backgroundImage).toBe( + 'linear-gradient(to right, transparent 50%, var(--orca-block-fg) 50%)' + ) + expect(span.style.backgroundSize).toBe('50% 100%') + expect(span.style.backgroundRepeat).toBe('repeat-x') + }) + + it('does not restyle mixed block/letter spans', () => { + const span = mountSpan('▀▀█ Ask') + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(30, 30, 30)') + expect(span.style.backgroundImage).toBe('') + }) + + it('visits only the rows xterm repainted', () => { + const first = mountSpan('▀▀▀') + const rows = first.parentElement!.parentElement! + const secondRow = document.createElement('div') + const second = document.createElement('span') + second.textContent = '▄▄▄' + second.style.color = 'rgb(30, 30, 30)' + secondRow.appendChild(second) + rows.appendChild(secondRow) + const untouched = vi.spyOn(first, 'textContent', 'get') + + applyDomBlockFills(document, { start: 1, end: 1 }) + + expect(untouched).not.toHaveBeenCalled() + expect(first.style.backgroundImage).toBe('') + expect(second.style.backgroundImage).toContain('linear-gradient') + }) + + it('clears a previous fill when the span is reused for ordinary text', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.backgroundImage).toBe('') + expect(span.style.backgroundSize).toBe('') + expect(span.style.backgroundRepeat).toBe('') + expect(span.style.color).toBe('rgb(30, 30, 30)') + }) + + it('uses a color set on a reused filled span instead of the saved one', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.style.color = 'rgb(200, 0, 0)' + applyDomBlockFills(document) + expect(span.style.color).toBe('transparent') + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(200, 0, 0)') + + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(200, 0, 0)') + }) + + it('keeps a color set after the fill when the span becomes ordinary text', () => { + const span = mountSpan('▀▀▀') + applyDomBlockFills(document) + span.style.color = 'rgb(0, 200, 0)' + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('rgb(0, 200, 0)') + expect(span.style.backgroundImage).toBe('') + }) + + it('does not pin an inherited color inline when clearing a fill', () => { + const span = mountSpan('▀▀▀', '') + span.parentElement!.style.color = 'rgb(5, 5, 5)' + applyDomBlockFills(document) + expect(span.style.getPropertyValue('--orca-block-fg')).toBe('rgb(5, 5, 5)') + + span.textContent = 'tab agents' + applyDomBlockFills(document) + expect(span.style.color).toBe('') + }) +}) + +describe('attachDomBlockFill', () => { + it('fills row replacements from focus/selection without rescanning untouched rows', async () => { + const element = document.createElement('div') + const rows = document.createElement('div') + rows.className = 'xterm-rows' + element.appendChild(rows) + const untouched = document.createElement('div') + const original = document.createElement('span') + original.textContent = 'ordinary text' + untouched.appendChild(original) + rows.appendChild(untouched) + const changed = document.createElement('div') + rows.appendChild(changed) + const detach = attachDomBlockFill({ element }) + const unread = vi.spyOn(original, 'textContent', 'get') + const span = document.createElement('span') + span.textContent = '▀▀' + span.style.color = 'rgb(1, 2, 3)' + changed.replaceChildren(span) + + await vi.waitFor(() => expect(span.style.color).toBe('transparent')) + expect(unread).not.toHaveBeenCalled() + detach() + const replacement = document.createElement('span') + replacement.textContent = '▄▄' + replacement.style.color = 'rgb(1, 2, 3)' + changed.replaceChildren(replacement) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(replacement.style.color).toBe('rgb(1, 2, 3)') + }) + + it('fills a new DOM renderer container after a GPU mode change', async () => { + const element = document.createElement('div') + const detach = attachDomBlockFill({ element }) + const rows = document.createElement('div') + rows.className = 'xterm-rows' + const row = document.createElement('div') + const span = document.createElement('span') + span.textContent = '▀▀' + span.style.color = 'rgb(1, 2, 3)' + row.appendChild(span) + rows.appendChild(row) + element.appendChild(rows) + + await vi.waitFor(() => expect(span.style.color).toBe('transparent')) + detach() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts new file mode 100644 index 00000000000..989d4f3c9d7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-dom-block-fill.ts @@ -0,0 +1,172 @@ +const BLOCK_FG_VAR = '--orca-block-fg' +const FILL_ATTR = 'data-orca-block-fill' +// The span's inline color before the fill ('' when it had none), restored on clear. +const INLINE_FG_ATTR = 'data-orca-block-inline-fg' + +function gradient(axis: 'bottom' | 'right', solidPercent: number, fromStart: boolean): string { + const dir = axis === 'bottom' ? 'to bottom' : 'to right' + const fg = `var(${BLOCK_FG_VAR})` + if (fromStart) { + return `linear-gradient(${dir}, ${fg} ${solidPercent}%, transparent ${solidPercent}%)` + } + const gap = 100 - solidPercent + return `linear-gradient(${dir}, transparent ${gap}%, ${fg} ${gap}%)` +} + +/** CSS background-image that fills the cell the way WebGL custom glyphs do. */ +export function backgroundImageForUniformBlockRun(text: string): string | null { + if (text.length === 0) { + return null + } + const ch = text[0]! + for (let i = 1; i < text.length; i++) { + if (text[i] !== ch) { + return null + } + } + const code = ch.codePointAt(0) ?? 0 + if (code === 0x2580) { + return gradient('bottom', 50, true) + } + if (code >= 0x2581 && code <= 0x2588) { + const eighths = code - 0x2580 + return gradient('bottom', eighths * 12.5, false) + } + if (code >= 0x2589 && code <= 0x258f) { + const eighths = 8 - (code - 0x2588) + return gradient('right', eighths * 12.5, true) + } + if (code === 0x2590) { + return gradient('right', 50, false) + } + if (code === 0x2594) { + return gradient('bottom', 12.5, true) + } + if (code === 0x2595) { + return gradient('right', 12.5, false) + } + return null +} + +function clearBlockFill(span: HTMLElement): void { + if (!span.hasAttribute(FILL_ATTR)) { + return + } + const originalColor = span.getAttribute(INLINE_FG_ATTR) + // Any other inline color was set after the fill and is the span's current foreground. + const ownsColor = span.style.color === 'transparent' + span.removeAttribute(FILL_ATTR) + span.removeAttribute(INLINE_FG_ATTR) + span.style.removeProperty('background-image') + span.style.removeProperty('background-size') + span.style.removeProperty('background-repeat') + span.style.removeProperty(BLOCK_FG_VAR) + if (!ownsColor) { + return + } + if (originalColor) { + span.style.color = originalColor + } else { + span.style.removeProperty('color') + } +} + +type RenderedRows = { start: number; end: number } + +export function applyDomBlockFills(root: ParentNode, range?: RenderedRows): void { + if (typeof root.querySelectorAll !== 'function') { + return + } + const rows = root.querySelector('.xterm-rows') + if (!rows) { + return + } + const start = range?.start ?? 0 + const end = Math.min(range?.end ?? rows.children.length - 1, rows.children.length - 1) + for (let row = start; row <= end; row++) { + fillBlockRow(rows.children[row]!) + } +} + +function fillBlockRow(row: Element): void { + for (const span of row.querySelectorAll('span')) { + fillBlockSpan(span) + } +} + +function fillBlockSpan(span: HTMLElement): void { + const text = span.textContent ?? '' + const fill = backgroundImageForUniformBlockRun(text) + if (!fill) { + clearBlockFill(span) + return + } + const inlineFg = span.style.color + const ownsColor = span.hasAttribute(FILL_ATTR) && inlineFg === 'transparent' + if (ownsColor && span.getAttribute(FILL_ATTR) === text) { + return + } + const fg = ownsColor + ? span.style.getPropertyValue(BLOCK_FG_VAR) + : inlineFg || (typeof getComputedStyle === 'function' ? getComputedStyle(span).color : '') + if (!fg) { + return + } + if (!ownsColor) { + span.setAttribute(INLINE_FG_ATTR, inlineFg) + } + span.setAttribute(FILL_ATTR, text) + span.style.setProperty(BLOCK_FG_VAR, fg) + span.style.color = 'transparent' + span.style.backgroundImage = fill + span.style.backgroundSize = `${100 / text.length}% 100%` + span.style.backgroundRepeat = 'repeat-x' +} + +function collectAddedBlockRows(node: Node, rows: Set): void { + if (!(node instanceof HTMLElement)) { + return + } + if (node.parentElement?.classList.contains('xterm-rows')) { + rows.add(node) + return + } + const containers = node.matches('.xterm-rows') ? [node] : node.querySelectorAll('.xterm-rows') + for (const container of containers) { + for (const row of container.children) { + if (row instanceof HTMLElement) { + rows.add(row) + } + } + } +} + +export function attachDomBlockFill(terminal: { element?: HTMLElement | undefined }): () => void { + const root = terminal.element + if (!root || typeof MutationObserver === 'undefined') { + return () => undefined + } + applyDomBlockFills(root) + // DOM focus, selection and link paints replace rows without xterm's public onRender event. + const observer = new MutationObserver((records) => { + const rows = new Set() + for (const record of records) { + const row = + record.target instanceof HTMLElement ? record.target.closest('.xterm-rows > div') : null + if (row instanceof HTMLElement) { + rows.add(row) + continue + } + for (const node of record.addedNodes) { + collectAddedBlockRows(node, rows) + } + } + for (const row of rows) { + if (root.contains(row)) { + fillBlockRow(row) + } + } + }) + observer.observe(root, { childList: true, subtree: true }) + return () => observer.disconnect() +}