Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions web/src/emulator/emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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
Expand Down
22 changes: 18 additions & 4 deletions web/src/emulator/xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},

Expand Down