diff --git a/demo/viewport-repro.html b/demo/viewport-repro.html
new file mode 100644
index 00000000..39f3ba17
--- /dev/null
+++ b/demo/viewport-repro.html
@@ -0,0 +1,145 @@
+
+
+
+
+ghostty-web: how many times a frame reads the grid
+
+
+
+How many times does one frame read the grid?
+
+ 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.
+
+
+ Run it with bun run dev and open /demo/viewport-repro.html.
+ Add ?src=/path/to/ghostty-web.js to point it at a built bundle instead.
+
+
+running...
+
+
+
+
diff --git a/lib/renderer.ts b/lib/renderer.ts
index 3b51bfdd..c2decafa 100644
--- a/lib/renderer.ts
+++ b/lib/renderer.ts
@@ -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;
@@ -301,6 +308,35 @@ 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;
@@ -308,7 +344,7 @@ export class CanvasRenderer {
// 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);
}
@@ -316,7 +352,7 @@ export class CanvasRenderer {
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);
}
@@ -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) {
@@ -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) {