From 0764b69143381738f28c59bddc6461c0a4cf2712 Mon Sep 17 00:00:00 2001 From: Karn Date: Tue, 18 Aug 2026 00:09:10 +0530 Subject: [PATCH] fix(web): stop pty sizing loop on fractional cell widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contentSize read offsetWidth, which rounds the screen's fractional Retina width to whole pixels. The error, divided into a cell width and multiplied back across the pane, flips cellsThatFit between N and N+1 depending on the current dimensions — the pty answers each flip with the other, redrawing the prompt several times a second at unlucky pane widths (split panes, browser sidebar open). Read the exact size both renderers write into the screen's style attribute instead. --- web/src/emulator/emulator.test.ts | 25 +++++++++++++++++++++++++ web/src/emulator/xterm.ts | 22 ++++++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index 5a5ca89..cc1cd65 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -277,6 +277,31 @@ describe('Emulator interface', () => { em.dispose() }) + it('reports the exact laid-out size, not whole pixels', () => { + // The renderer writes the screen's true size into its style attribute, + // and on a Retina display that value is fractional: a cell is a whole + // count of device pixels, divided by the pixel ratio. offsetWidth rounds + // it to an integer, and that half-pixel, divided into a cell width and + // multiplied back out across the pane, is enough to flip cellsThatFit + // between N and N+1 columns depending on the dimensions the screen + // happens to wear — each answer reshapes the pty, the new dimensions + // re-round the other way, and the pane flickers with prompt redraws + // forever. + const el = document.createElement('div') + document.body.appendChild(el) + const em = createXtermEmulator({ cols: 100, rows: 24 }) + em.attachTo(el) + // jsdom lays nothing out, so the renderer's write is stated by hand: + // 100 columns of a 7.8125px cell, 24 rows of a 17.02083…px line. + const screen = el.querySelector('.xterm-screen')! + screen.style.width = '781.25px' + screen.style.height = '408.5px' + + expect(em.contentSize()).toEqual({ width: 781.25, height: 408.5 }) + em.dispose() + el.remove() + }) + it('takes a colour palette at build time and again afterwards', () => { // Both matter. The option is what stops a terminal painting one frame in // xterm's own colours before flue's land; setTheme is what lets a running diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index 7a8fdb8..6869fe8 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -383,10 +383,24 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { if (disposed) return null const screen = term.element?.querySelector(SCREEN_SELECTOR) if (!(screen instanceof HTMLElement)) return null - // offsetWidth/offsetHeight rather than getBoundingClientRect, because a - // non-primary view is scaled by CSS: the rect would report the scaled - // box, and dividing the pane by that converges on nothing. - const size = { width: screen.offsetWidth, height: screen.offsetHeight } + // The style attribute, which both renderers write the screen's exact + // size into, rather than either measurement the browser offers. The + // rect is out because a non-primary view is scaled by CSS: it reports + // the scaled box, and dividing the pane by that converges on nothing. + // And offsetWidth — unscaled, the obvious next choice — rounds to + // whole pixels, when on a Retina display the true width is fractional: + // a cell is a whole count of device pixels divided by the pixel ratio. + // The half-pixel it drops, divided into a cell width and multiplied + // back out across the pane, can flip cellsThatFit between N and N+1 + // columns depending on the dimensions the screen currently wears; the + // pty then answers each flip with the other one — a sizing loop that + // redraws the prompt several times a second for as long as the pane + // keeps that width. (Style, not "the obvious CSS word for it": see the + // Tailwind scanner note in src/styles.css.) + const size = { + width: parseFloat(screen.style.width) || screen.offsetWidth, + height: parseFloat(screen.style.height) || screen.offsetHeight, + } return size.width > 0 && size.height > 0 ? size : null },