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
1 change: 1 addition & 0 deletions src/client/vendor/ghostty-web/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ webtty edits to these files are tracked in git from the commit that added them;
- `core/write-pipeline.ts`, `core/viewport.ts`, `core/terminal-controllers.ts`, `core/opened-parts.ts`, `renderer.ts`, `engine/engine-types.ts`: output no longer moves a view scrolled back on the normal screen to the bottom (upstream's `write` did, calling it xterm.js' behaviour, which it is not); before each write `Viewport.pin` puts Ghostty's viewport on the view's top row (`GhosttyTerminal.pinViewport`), or on the part row above it a smooth scroll shows (`CanvasRenderer.requestedOffset`), and after it `Viewport.unpin` moves the view to where Ghostty took that row (`unpinViewport`), without an `onScroll` or the scrollbar, as Ghostty's `scroll-to-bottom = no-output`. `Viewport.scrollToBottom` is announced from line 0 while the part row shows. The alternate screen still goes to the bottom. A write no longer resets the view offset and marks every row dirty, and `renderer.ts` maps the engine's dirty rows to canvas rows from the view's line, so a redraw in place under a scrolled view repaints only what changed (ADR 066).
- `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `core/terminal-emitters.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; after a held frame the write pipeline feeds output up to where an update ends, paints and feeds the rest, and `Terminal.onUpdatePainted` (a new `TerminalEmitters.updatePainted`) tells the embedder; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067).
- `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064).
- `render/cell-painter.ts`: a line's text pass sets the font and the fill colour only when they change from the cell before, from four cached font strings, and a blank cell (a space or no codepoint, with no underline, strikethrough or link line) draws no text. Upstream built and set the font and set the colour for every cell and drew each space with `fillText`. Pixel-identical; under herdr's full-screen redraws, rows took 58% less time to paint in headless Chrome. The test canvas logs a painted row at its own clear, since a blank row draws no text.

## webtty-original files

Expand Down
54 changes: 46 additions & 8 deletions src/client/vendor/ghostty-web/render/cell-painter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export const LINK_UNDERLINE_COLOR = '#4A90E2';
* instead of swapping the renderer's. (webtty, ADR 043, ADR 061)
*/
export class CellPainter implements RowPainter, CellTextPainter {
/**
* The font and colour the line being drawn last set, so a run of cells in the same style
* sets neither again: setting either makes the canvas parse it. Null outside a line's text
* pass, where something else may have set them. (webtty)
*/
private lineFont: string | null = null;
private lineFill: string | null = null;
/** The four font strings (plain, italic, bold, bold italic) for `fontsFor`. */
private fonts: { fontSize: number; fontFamily: string; list: string[] } | null = null;

constructor(
private readonly settings: PaintSettings,
private readonly frame: FrameDecorations,
Expand Down Expand Up @@ -147,11 +157,15 @@ export class CellPainter implements RowPainter, CellTextPainter {

// PASS 2: Draw all cell text and decorations
// Now text can safely extend beyond cell boundaries (for complex scripts)
this.lineFont = '';
this.lineFill = '';
for (let x = 0; x < line.length; x++) {
const cell = line[x];
if (cell.width === 0) continue; // Skip spacer cells for wide characters
this.paintCellText(ctx, cell, x, y, undefined, grapheme);
}
this.lineFont = null;
this.lineFill = null;
}

/**
Expand Down Expand Up @@ -186,31 +200,44 @@ export class CellPainter implements RowPainter, CellTextPainter {
const cellX = x * metrics.width;
const cellY = y * metrics.height;
const cellWidth = metrics.width * cell.width;
const lines = cell.flags & (CellFlags.UNDERLINE | CellFlags.STRIKETHROUGH);
const linked = cellInRange(this.frame.link, x, y);
// A blank cell draws no glyph, and its background is the first pass': with no line
// over it, there is nothing to draw (webtty)
const blank = cell.grapheme_len === 0 && (cell.codepoint === 0 || cell.codepoint === 32);
if (blank && !lines && !linked) return;

// Set text style
let fontStyle = '';
if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic ';
if (cell.flags & CellFlags.BOLD) fontStyle += 'bold ';
ctx.font = `${fontStyle}${this.settings.fontSize}px ${this.settings.fontFamily}`;
const font = this.fontsFor()[(cell.flags & CellFlags.ITALIC ? 1 : 0) | (cell.flags & CellFlags.BOLD ? 2 : 0)];
if (font !== this.lineFont) {
ctx.font = font;
if (this.lineFont !== null) this.lineFont = font;
}

// Set text color - use override, selection foreground, search or normal color
ctx.fillStyle =
const fill =
colorOverride ||
cellForeground(
cell,
cellInRange(this.frame.selection, x, y),
highlightAt(this.frame.searchSpans[y], x),
theme
);
if (fill !== this.lineFill) {
ctx.fillStyle = fill;
if (this.lineFill !== null) this.lineFill = fill;
}

// Apply faint effect
const faint = cell.flags & CellFlags.FAINT;
if (faint) ctx.globalAlpha = 0.5;

// A cell with extra codepoints draws its whole grapheme cluster, a simple cell its
// one codepoint (a space if null)
const char = cell.grapheme_len > 0 ? grapheme(x) : String.fromCodePoint(cell.codepoint || 32);
ctx.fillText(char, cellX, cellY + metrics.baseline);
if (!blank) {
const char = cell.grapheme_len > 0 ? grapheme(x) : String.fromCodePoint(cell.codepoint);
ctx.fillText(char, cellX, cellY + metrics.baseline);
}

// Reset alpha
if (faint) ctx.globalAlpha = 1.0;
Expand All @@ -223,10 +250,21 @@ export class CellPainter implements RowPainter, CellTextPainter {
}
// Draw the hovered link's underline (OSC 8 or plain text URL), in canvas rows
// (webtty, ADR 043)
if (cellInRange(this.frame.link, x, y)) {
if (linked) {
drawLine(ctx, LINK_UNDERLINE_COLOR, cellX, cellX + cellWidth, cellY + metrics.baseline + 2);
}
}

/** The cell fonts for the current size and family, by ITALIC (1) and BOLD (2). */
private fontsFor(): string[] {
const { fontSize, fontFamily } = this.settings;
const fonts = this.fonts;
if (fonts?.fontSize === fontSize && fonts.fontFamily === fontFamily) return fonts.list;
const base = `${fontSize}px ${fontFamily}`;
const list = [base, `italic ${base}`, `bold ${base}`, `italic bold ${base}`];
this.fonts = { fontSize, fontFamily, list };
return list;
}
}

/** The lookup for a row that came without one: every cluster a space. */
Expand Down
38 changes: 36 additions & 2 deletions src/client/vendor/tests/render/cell-painter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ describe('CellPainter', () => {
`strokeStyle=${LINK_UNDERLINE_COLOR}`,
]);
});

test('a blank cell draws no text, but keeps its underline, strike and link line', () => {
const link = { startCol: 2, startRow: 0, endCol: 2, endRow: 0 };
const t = painter({ link });
const space = (flags = 0) => cell(flags, { codepoint: 32 });
t.p.paintRow(t.ctx, 0, 4, {
line: [space(), cell(0, { codepoint: 0 }), space(), space(CellFlags.UNDERLINE)],
grapheme: () => '?',
});
expect(t.calls.filter((c) => c.startsWith('fillText'))).toEqual([]);
expect(t.calls.filter((c) => c.startsWith('moveTo'))).toEqual([
'moveTo(16,13)',
'moveTo(24,13)',
]);
});

test('a run of cells in one style sets the font and colour once, and a lone cell each time', () => {
const t = painter();
const bold = cell(CellFlags.BOLD);
t.p.paintRow(t.ctx, 0, 5, { line: [cell(), cell(), bold, bold, cell()], grapheme: () => '?' });
const text = t.calls.slice(t.calls.indexOf('fillText(A,0,11)') - 2);
expect(text.filter((c) => !c.startsWith('fillText'))).toEqual([
'font=15px mono',
'fillStyle=rgb(200, 0, 0)',
'font=bold 15px mono',
'font=15px mono',
]);
// Outside a line, as for the cursor's cell, nothing is assumed of the context
t.calls.length = 0;
t.p.paintCellText(t.ctx, cell(), 0, 0, undefined, () => '');
t.p.paintCellText(t.ctx, cell(), 1, 0, undefined, () => '');
expect(t.calls.filter((c) => c.startsWith('font='))).toHaveLength(2);
});
});

describe('cells drawn through the renderer', () => {
Expand All @@ -185,9 +218,10 @@ describe('cells drawn through the renderer', () => {
renderer.render(term, true, 3, provider);
// Below the overscan row, canvas rows 1-3 show the last three scrollback lines
// (line i is the i-th written); rows 4-6 the top of the screen.
// Blank cells draw no text, so a row's text is its glyphs run together.
for (let row = 0; row < 3; row++) {
expect(drawn.get(row + 1)).toContain('é');
expect(drawn.get(row + 1)?.join('')).toContain(` ${sb - 3 + row} `);
const i = sb - 3 + row;
expect(drawn.get(row + 1)?.join('')).toBe(`${'x'.repeat(i % 7)}é${i}`);
}
expect(drawn.get(4)).toContain('é');
term.free();
Expand Down
11 changes: 3 additions & 8 deletions src/client/vendor/tests/support/raster-canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,6 @@ export function rasterCanvas(log: DrawLog = { rows: [], copies: 0 }) {
let stack: State[] = [];
let path: Rect | null = null;
let line: number[] = [];
// The row whose own clear was the last row-sized clear outside save(); its next text,
// outside save() too, makes it a painted line (a cursor's text is drawn inside save())
let pendingRow: number | null = null;
const reset = () => {
pixels = new Array(width * height).fill('');
st = fresh();
Expand Down Expand Up @@ -118,16 +115,14 @@ export function rasterCanvas(log: DrawLog = { rows: [], copies: 0 }) {
st.m = [a, d, e, f];
},
clearRect: (x: number, y: number, w: number, h: number) => {
if (stack.length === 0 && x === 0 && h === CELL_HEIGHT) pendingRow = y / CELL_HEIGHT;
// A row repaint starts with the row's own clear, outside save() (a cursor draws inside
// it); a blank row draws no text after it
if (stack.length === 0 && x === 0 && h === CELL_HEIGHT) log.rows.push(y / CELL_HEIGHT);
paint(device(x, y, w, h), '');
},
fillRect: (x: number, y: number, w: number, h: number) =>
paint(device(x, y, w, h), ink(st.fillStyle)),
fillText: (text: string, x: number, y: number) => {
if (stack.length === 0 && pendingRow === Math.floor(y / CELL_HEIGHT)) {
log.rows.push(pendingRow);
pendingRow = null;
}
const cell = device(x, y - 11, 8, 15);
if (st.clip && (cell[3] <= st.clip[1] || cell[1] >= st.clip[3])) return;
const up = text.includes('́') ? 4 : 0;
Expand Down
Loading