another attempt at tab blanking/flicker

This commit is contained in:
Eugene
2026-06-29 00:43:53 +02:00
parent 3caf1a5c87
commit 6955c4f8e8
3 changed files with 76 additions and 3 deletions
@@ -191,6 +191,21 @@ export class AppRootComponent {
this.ready = true
this.app.emitReady()
})
// While the window is being dragged, suppress the split-pane layout
// transition (see splitTab.component.scss). Animating pane geometry on
// every resize frame triggers a full-layer repaint that flickers the
// terminal; the transition is only wanted for split/close/maximize.
let resizeEndTimeout: any = null
window.addEventListener('resize', () => {
document.body.classList.add('resizing')
if (resizeEndTimeout) {
clearTimeout(resizeEndTimeout)
}
resizeEndTimeout = setTimeout(() => {
document.body.classList.remove('resizing')
}, 200)
})
}
@HostListener('dragover')
@@ -28,3 +28,9 @@
::ng-deep .no-animations split-tab > .child {
transition: none;
}
// While the window is being resized, don't animate pane geometry — the
// transition would otherwise repaint the whole layer each frame and flicker.
::ng-deep .resizing split-tab > .child {
transition: none;
}
+55 -3
View File
@@ -212,7 +212,7 @@ export class XTermFrontend extends Frontend {
// - wheel/keyboard event listeners (below)
// - explicit scrollToBottom() calls
this.resizeHandler = () => {
const doResize = () => {
try {
if (this.xterm.element && getComputedStyle(this.xterm.element).getPropertyValue('height') !== 'auto') {
const savedPinned = this.pinnedToBottom
@@ -229,6 +229,13 @@ export class XTermFrontend extends Frontend {
const targetY = Math.min(savedViewportY, maxScroll)
this.xterm.scrollToLine(targetY)
}
// fitAddon.fit() resizes the renderer's drawing buffer,
// which blanks it synchronously, but xterm only repaints on
// the next animation frame — leaving one blank frame that
// reads as flicker during a window drag. Force the repaint
// now (after scrolling settles) to close that gap.
this.xtermCore._renderService?._renderRows(0, this.xterm.rows - 1)
}
} catch (e) {
// tends to throw when element wasn't shown yet
@@ -236,6 +243,36 @@ export class XTermFrontend extends Frontend {
}
}
// Rate-limit reflows during a window drag. The window 'resize' event and
// the ResizeObserver fire many times per frame; each reflow resizes the
// renderer's drawing buffer and re-uploads the glyph atlas texture. At
// full frame rate a fast drag issues reflows faster than the GPU can
// finish one, so frames composite with the text not yet repainted —
// visible as a flicker that only shows up when dragging quickly (slow
// drags leave enough time between reflows). Capping the reflow rate and
// always running a trailing fit keeps the final size correct without
// outrunning the renderer. Tune RESIZE_MIN_INTERVAL if needed.
const RESIZE_MIN_INTERVAL = 32
let resizePending = false
let lastResize = 0
const runResize = () => {
resizePending = false
lastResize = Date.now()
doResize()
}
this.resizeHandler = () => {
if (resizePending) {
return
}
resizePending = true
const wait = Math.max(0, RESIZE_MIN_INTERVAL - (Date.now() - lastResize))
if (wait > 0) {
setTimeout(() => requestAnimationFrame(runResize), wait)
} else {
requestAnimationFrame(runResize)
}
}
const oldKeyUp = this.xtermCore._keyUp.bind(this.xtermCore)
this.xtermCore._keyUp = (e: KeyboardEvent) => {
this.xtermCore.updateCursorStyle(e)
@@ -361,7 +398,7 @@ export class XTermFrontend extends Frontend {
event.stopPropagation()
})
this.resizeObserver = new window['ResizeObserver'](() => setTimeout(() => this.resizeHandler()))
this.resizeObserver = new window['ResizeObserver'](() => this.resizeHandler())
this.resizeObserver.observe(host)
}
@@ -634,9 +671,19 @@ export class XTermFrontend extends Frontend {
* hidden, and flushes any GPU context recovery deferred until now.
*/
reactivate (): void {
if (this.pendingRendererRecovery) {
// An app- or window-level GPU reset can blank the canvas without firing
// xterm's per-canvas contextlost event, so pendingRendererRecovery stays
// unset. Treat a WebGL frontend that has lost its addon as needing
// recovery too, so a shown-but-blank pane always gets its context back
// instead of relying on a manual window resize.
if (this.pendingRendererRecovery || this.enableWebGL && !this.webGLAddon) {
this.pendingRendererRecovery = true
this.recoverRenderer()
} else {
// The pane is shown with a live renderer, so any earlier transient
// losses shouldn't count against a future recovery — reset the budget
// to avoid permanently downgrading the pane to the DOM renderer.
this.rendererRecoveryAttempts = 0
this.redraw()
}
}
@@ -682,6 +729,11 @@ export class XTermFrontend extends Frontend {
private redraw (): void {
const renderService = this.xtermCore._renderService
renderService?.clear()
// handleResize() alone is a no-op when cols/rows are unchanged
// resizeHandler() runs a real itAddon.fit() followed
// by an unconditional viewport._refresh(),
// forcing a full repaint
this.resizeHandler()
renderService?.handleResize(this.xterm.cols, this.xterm.rows)
}