Skip to content
Open
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
145 changes: 145 additions & 0 deletions demo/viewport-repro.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>ghostty-web: how many times a frame reads the grid</title>
<style>
body { background: #16161a; color: #d4d4d4; font: 13px ui-monospace, monospace; margin: 16px; }
#terminal { display: inline-block; }
table { border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #444; padding: 4px 10px; text-align: right; }
th:first-child, td:first-child { text-align: left; }
#out { margin-top: 12px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>How many times does one frame read the grid?</h1>
<p>
CanvasRenderer.render() calls buffer.getLine(y) for every row it paints.
getLine() builds the whole viewport and returns one row of it. This page
counts the grid reads per frame and times render(), with and without a single
viewport read per frame.
</p>
<p>
Run it with <code>bun run dev</code> and open <code>/demo/viewport-repro.html</code>.
Add <code>?src=/path/to/ghostty-web.js</code> to point it at a built bundle instead.
</p>
<div id="terminal"></div>
<div id="out">running...</div>

<script type="module">
const params = new URLSearchParams(location.search);
const SRC = params.get('src') ?? '../lib/index.ts';
const COLS = Number(params.get('cols') ?? 114);
const ROWS = Number(params.get('rows') ?? 42);
const SECONDS = Number(params.get('seconds') ?? 4);
const out = document.getElementById('out');

const { init, Terminal } = await import(SRC);
await init();

const term = new Terminal({
cols: COLS, rows: ROWS, fontSize: 14, cursorBlink: false,
fontFamily: 'ui-monospace, Menlo, monospace',
theme: { background: '#16161a', foreground: '#d4d4d4' },
});
term.open(document.getElementById('terminal'));

const vt = term.wasmTerm;
const vtProto = Object.getPrototypeOf(vt);
const rendererProto = Object.getPrototypeOf(term.renderer);

// Count grid reads. getViewport walks or parses every cell in the grid.
let gridReads = 0;
const realGetViewport = vtProto.getViewport;
vtProto.getViewport = function (...a) { gridReads++; return realGetViewport.apply(this, a); };

// The change under test, as a runtime wrapper so both arms run in one page.
// This is the same lifetime the patch gives it: one render() call.
const stockGetLine = vtProto.getLine;
let inFrame = false;
let frameViewport = null;
const memoGetLine = function (y) {
if (!inFrame) return stockGetLine.call(this, y);
if (y < 0 || y >= this._rows) return null;
if (frameViewport === null) frameViewport = this.getViewport();
const start = y * this._cols;
return frameViewport.slice(start, start + this._cols).map((c) => ({ ...c }));
};

// Time render() and mark the frame.
let renderMs = [];
let readsPerFrame = [];
let collecting = false;
const realRender = rendererProto.render;
rendererProto.render = function (...a) {
inFrame = true; frameViewport = null;
const r0 = gridReads;
const t0 = performance.now();
try { return realRender.apply(this, a); }
finally {
const dt = performance.now() - t0;
inFrame = false; frameViewport = null;
if (collecting) { renderMs.push(dt); readsPerFrame.push(gridReads - r0); }
}
};

// Steady full-screen churn, the load a terminal animation puts on the renderer.
const CHARS = 'abcdefghijklmnopqrstuvwxyz0123456789#*+=~';
let seed = 1;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
function paintScreen() {
let s = '\x1b[H';
for (let y = 0; y < ROWS; y++) {
let line = '';
for (let x = 0; x < COLS; x++) {
line += rnd() < 0.35 ? CHARS[(rnd() * CHARS.length) | 0] : ' ';
}
s += `\x1b[${y + 1};1H\x1b[38;2;${(rnd()*255)|0};200;120m` + line;
}
term.write(s);
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const stats = (a) => {
const s = [...a].sort((x, y) => x - y);
return {
n: a.length,
mean: a.reduce((x, y) => x + y, 0) / a.length,
p95: s[Math.floor(s.length * 0.95)],
};
};

async function arm(name) {
let timer = setInterval(paintScreen, 16);
await sleep(700); // warm up, no measurement
renderMs = []; readsPerFrame = []; collecting = true;
await sleep(SECONDS * 1000);
collecting = false;
clearInterval(timer);
await sleep(200);
return { name, ms: stats(renderMs), reads: stats(readsPerFrame) };
}

const rows = [];
vtProto.getLine = stockGetLine;
rows.push(await arm('stock: getLine() per row'));
vtProto.getLine = memoGetLine;
rows.push(await arm('patched: one viewport read per frame'));

const gl = document.createElement('canvas').getContext('webgl2');
const dbg = gl && gl.getExtension('WEBGL_debug_renderer_info');
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : 'unknown';

out.innerHTML =
`<p>grid ${COLS}x${ROWS} &middot; ${SECONDS}s per arm &middot; GPU: ${gpu}</p>` +
'<table><tr><th>arm</th><th>grid reads / frame</th><th>render() mean ms</th><th>render() p95 ms</th><th>frames</th></tr>' +
rows.map((r) =>
`<tr><td>${r.name}</td><td>${r.reads.mean.toFixed(1)}</td>` +
`<td>${r.ms.mean.toFixed(2)}</td><td>${r.ms.p95.toFixed(2)}</td><td>${r.ms.n}</td></tr>`).join('') +
'</table>';
window.__reproResult = { gpu, cols: COLS, rows: ROWS, arms: rows };
window.__reproDone = true;
</script>
</body>
</html>
48 changes: 42 additions & 6 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import { CellFlags } from './types';
// Interface for objects that can be rendered
export interface IRenderable {
getLine(y: number): GhosttyCell[] | null;
/**
* Read every visible cell in one pass, returning a cols*rows array in row
* order. Optional. When it is present the renderer reads the viewport once
* per frame and slices each row out of it, instead of calling getLine() for
* every row it paints.
*/
getViewport?(): GhosttyCell[];
getCursor(): { x: number; y: number; visible: boolean };
getDimensions(): { cols: number; rows: number };
isRowDirty(y: number): boolean;
Expand Down Expand Up @@ -301,22 +308,51 @@ export class CanvasRenderer {
this.lastViewportY = viewportY;
}

// Read the viewport once for this frame.
//
// getLine() is a compatibility shim: it builds the whole viewport and then
// returns one row of it. Calling it per row therefore rebuilds the grid
// once for every row the frame paints, so a frame that paints R rows costs
// R full grid reads when one is enough.
//
// The memo lives for this call only. render() runs to completion before any
// write can land, so it cannot serve stale cells, and no caller outside
// render() is affected.
//
// Scrolled frames still take the old path, because those rows come from the
// scrollback provider rather than from the viewport.
let frameViewport: GhosttyCell[] | null | undefined;
const readLine = (y: number): GhosttyCell[] | null => {
if (viewportY > 0 || typeof buffer.getViewport !== 'function') {
return buffer.getLine(y);
}
if (frameViewport === undefined) {
frameViewport = buffer.getViewport();
}
if (!frameViewport) return buffer.getLine(y);
if (y < 0 || y >= dims.rows) return null;
const start = y * dims.cols;
// Same copy getLine() makes, so callers still never hold a reference
// into the reused cell pool.
return frameViewport.slice(start, start + dims.cols).map((cell) => ({ ...cell }));
};

// Check if cursor position changed or if blinking (need to redraw cursor line)
const cursorMoved =
cursor.x !== this.lastCursorPosition.x || cursor.y !== this.lastCursorPosition.y;
if (cursorMoved || this.cursorBlink) {
// Mark cursor lines as needing redraw
if (!forceAll && !buffer.isRowDirty(cursor.y)) {
// Need to redraw cursor line
const line = buffer.getLine(cursor.y);
const line = readLine(cursor.y);
if (line) {
this.renderLine(line, cursor.y, dims.cols);
}
}
if (cursorMoved && this.lastCursorPosition.y !== cursor.y) {
// Also redraw old cursor line if cursor moved to different line
if (!forceAll && !buffer.isRowDirty(this.lastCursorPosition.y)) {
const line = buffer.getLine(this.lastCursorPosition.y);
const line = readLine(this.lastCursorPosition.y);
if (line) {
this.renderLine(line, this.lastCursorPosition.y, dims.cols);
}
Expand Down Expand Up @@ -374,11 +410,11 @@ export class CanvasRenderer {
} else {
// This row is from visible screen
const screenRow = y - Math.floor(viewportY);
line = buffer.getLine(screenRow);
line = readLine(screenRow);
}
} else {
// At bottom - fetch from visible screen
line = buffer.getLine(y);
line = readLine(y);
}

if (line) {
Expand Down Expand Up @@ -466,11 +502,11 @@ export class CanvasRenderer {
} else {
// This row is from visible screen (lower part of viewport)
const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y;
line = buffer.getLine(screenRow);
line = readLine(screenRow);
}
} else {
// At bottom - fetch from visible screen
line = buffer.getLine(y);
line = readLine(y);
}

if (line) {
Expand Down