From 6955c4f8e8952afa5a186bb6c87c97b2613bc567 Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 29 Jun 2026 00:43:53 +0200 Subject: [PATCH] another attempt at tab blanking/flicker --- .../src/components/appRoot.component.ts | 15 +++++ .../src/components/splitTab.component.scss | 6 ++ tabby-terminal/src/frontends/xtermFrontend.ts | 58 ++++++++++++++++++- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/tabby-core/src/components/appRoot.component.ts b/tabby-core/src/components/appRoot.component.ts index 8f781711..f2c18dda 100644 --- a/tabby-core/src/components/appRoot.component.ts +++ b/tabby-core/src/components/appRoot.component.ts @@ -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') diff --git a/tabby-core/src/components/splitTab.component.scss b/tabby-core/src/components/splitTab.component.scss index 04a71a99..582abafb 100644 --- a/tabby-core/src/components/splitTab.component.scss +++ b/tabby-core/src/components/splitTab.component.scss @@ -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; +} diff --git a/tabby-terminal/src/frontends/xtermFrontend.ts b/tabby-terminal/src/frontends/xtermFrontend.ts index 2c90fc10..bb6f6d57 100644 --- a/tabby-terminal/src/frontends/xtermFrontend.ts +++ b/tabby-terminal/src/frontends/xtermFrontend.ts @@ -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) }