diff --git a/src/renderer/src/lib/pane-manager/pane-inline-images.ts b/src/renderer/src/lib/pane-manager/pane-inline-images.ts index c13ecc05ba7b..b0faf27631b2 100644 --- a/src/renderer/src/lib/pane-manager/pane-inline-images.ts +++ b/src/renderer/src/lib/pane-manager/pane-inline-images.ts @@ -11,6 +11,7 @@ import { setTerminalImageAddonLoadHandlers } from './terminal-image-addon-loader' import { buildInlineImageAddonOptions } from './terminal-inline-image-options' +import { repaintTerminalAfterImageAdded } from './terminal-image-added-repaint' // Panes whose setting is on but that opened before the lazy addon chunk // resolved. The loader's onLoaded handler drains them into a real attach. @@ -19,6 +20,7 @@ const panesAwaitingImageAddon = new Set() // Keyed on the terminal, not the pane: PTY connections hold a toPublicPane() // wrapper, which does not carry imageAddon. const terminalsRenderingInlineImages = new WeakSet() +const imageRepaintByPane = new WeakMap() /** True only once the addon is really attached — not while its chunk loads or after it fails. */ export function terminalRendersInlineImages(terminal: Terminal): boolean { @@ -66,6 +68,10 @@ export function attachInlineImages(pane: ManagedPaneInternal): void { imageAddon = new ImageAddonConstructor(options) pane.terminal.loadAddon(imageAddon) pane.imageAddon = imageAddon + const repaint = repaintTerminalAfterImageAdded(pane.terminal, imageAddon) + if (repaint) { + imageRepaintByPane.set(pane, repaint) + } terminalsRenderingInlineImages.add(pane.terminal) } catch (err) { console.warn('[terminal] inline-image addon failed to attach for pane', pane.id, err) @@ -85,6 +91,8 @@ export function detachInlineImages(pane: ManagedPaneInternal): void { panesAwaitingImageAddon.delete(pane) pane.imageAttachmentDeferred = false terminalsRenderingInlineImages.delete(pane.terminal) + imageRepaintByPane.get(pane)?.dispose() + imageRepaintByPane.delete(pane) if (pane.imageAddon) { try { pane.imageAddon.dispose() diff --git a/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.test.ts b/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.test.ts new file mode 100644 index 000000000000..790c0b9461bb --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { repaintTerminalAfterImageAdded } from './terminal-image-added-repaint' + +function makeAddon() { + const listeners = new Set<() => void>() + return { + fire: () => listeners.forEach((listener) => listener()), + onImageAdded: (listener: () => void) => { + listeners.add(listener) + return { dispose: () => listeners.delete(listener) } + } + } +} + +describe('repaintTerminalAfterImageAdded', () => { + it('repaints the whole viewport synchronously once per burst of added images', async () => { + const coreRefresh = vi.fn() + const terminal = { rows: 24, refresh: vi.fn(), _core: { refresh: coreRefresh } } + const addon = makeAddon() + repaintTerminalAfterImageAdded(terminal, addon) + + addon.fire() + addon.fire() + expect(coreRefresh).not.toHaveBeenCalled() + await Promise.resolve() + + expect(coreRefresh).toHaveBeenCalledTimes(1) + expect(coreRefresh).toHaveBeenCalledWith(0, 23, true) + expect(terminal.refresh).not.toHaveBeenCalled() + + addon.fire() + await Promise.resolve() + expect(coreRefresh).toHaveBeenCalledTimes(2) + }) + + it('falls back to the public refresh when the core refresh is unavailable', async () => { + const terminal = { rows: 10, refresh: vi.fn() } + const addon = makeAddon() + repaintTerminalAfterImageAdded(terminal, addon) + + addon.fire() + await Promise.resolve() + + expect(terminal.refresh).toHaveBeenCalledWith(0, 9) + }) + + it('stops repainting once disposed, including a repaint already queued', async () => { + const coreRefresh = vi.fn() + const terminal = { rows: 24, refresh: vi.fn(), _core: { refresh: coreRefresh } } + const addon = makeAddon() + const subscription = repaintTerminalAfterImageAdded(terminal, addon) + + addon.fire() + subscription?.dispose() + addon.fire() + await Promise.resolve() + + expect(coreRefresh).not.toHaveBeenCalled() + }) + + it('does nothing for an addon build without the image-added event', () => { + const terminal = { rows: 24, refresh: vi.fn() } + expect(repaintTerminalAfterImageAdded(terminal, {})).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.ts b/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.ts new file mode 100644 index 000000000000..52721aa4bd52 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-image-added-repaint.ts @@ -0,0 +1,59 @@ +type RepaintableTerminal = { + rows: number + refresh(start: number, end: number): void + _core?: { refresh?(start: number, end: number, sync?: boolean): void } +} + +type ImageAddedSource = { + onImageAdded?: (listener: () => void) => { dispose(): void } +} + +/** + * Repaint the viewport once after the image addon registers an image. + * + * Why: an image's rows are dirtied in a write that Orca's synchronous per-write + * repair may not cover (async decoders even place tiles after that write's + * parse), so they reach only xterm's frame-debounced refresh. A throttled or + * hidden window may never run that frame, leaving every image row but the + * cursor's unpainted until something else repaints the grid. + */ +export function repaintTerminalAfterImageAdded( + terminal: RepaintableTerminal, + addon: ImageAddedSource +): { dispose(): void } | null { + if (typeof addon.onImageAdded !== 'function') { + return null + } + let scheduled = false + let disposed = false + const repaint = (): void => { + scheduled = false + if (disposed || terminal.rows < 1) { + return + } + try { + const end = terminal.rows - 1 + if (typeof terminal._core?.refresh === 'function') { + terminal._core.refresh(0, end, true) + } else { + terminal.refresh(0, end) + } + } catch { + // A pane torn down between the image and this microtask has nothing to paint. + } + } + const subscription = addon.onImageAdded(() => { + if (scheduled || disposed) { + return + } + // Why a microtask: one repaint per decoded burst, outside the addon's addImage call. + scheduled = true + queueMicrotask(repaint) + }) + return { + dispose: () => { + disposed = true + subscription.dispose() + } + } +}