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 <mmarabel@users.noreply.github.com>

* fix(terminal): preserve block fills across DOM row replacements

---------

Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-02 04:56:59 -07:00
committed by GitHub
co-authored by mmarabel
parent cb03a0545a
commit a21d328cb9
7 changed files with 416 additions and 0 deletions
@@ -144,6 +144,7 @@ export function createPaneDOM(
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
domBlockFillCleanup: null,
terminalScrollIntentDisposable: null,
mouseEncodingTrackerDisposable,
linkifierMouseLeaveResetDisposable: null,
@@ -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,
@@ -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()
@@ -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.
@@ -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 (
@@ -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()
})
})
@@ -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<HTMLElement>('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<HTMLElement>): 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<HTMLElement>()
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()
}