mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 08:02:33 +00:00
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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user