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
63 changes: 46 additions & 17 deletions lib/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ export interface RendererOptions {
}

export interface FontMetrics {
width: number; // Character cell width in CSS pixels
height: number; // Character cell height in CSS pixels
baseline: number; // Distance from top to text baseline
width: number; // Character cell width in CSS pixels (multiple of 1/devicePixelRatio)
height: number; // Character cell height in CSS pixels (multiple of 1/devicePixelRatio)
baseline: number; // Distance from top to text baseline in CSS pixels
}

// ============================================================================
Expand Down Expand Up @@ -106,6 +106,11 @@ export class CanvasRenderer {
// Cursor blinking state
private cursorVisible: boolean = true;
private cursorBlinkInterval?: number;
/** Called on each blink tick so the terminal can schedule a render. */
public cursorBlinkCallback?: () => void;

// Font style cache — avoid expensive ctx.font= on every cell
private lastFontKey: number = -1;
private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };

// Viewport tracking (for scrolling)
Expand Down Expand Up @@ -197,16 +202,22 @@ export class CanvasRenderer {

// Measure width using 'M' (typically widest character)
const widthMetrics = ctx.measureText('M');
const width = Math.ceil(widthMetrics.width);

// Measure height using ascent + descent with padding for glyph overflow
const ascent = widthMetrics.actualBoundingBoxAscent || this.fontSize * 0.8;
const descent = widthMetrics.actualBoundingBoxDescent || this.fontSize * 0.2;

// Add 2px padding to height to account for glyphs that overflow (like 'f', 'd', 'g', 'p')
// and anti-aliasing pixels
const height = Math.ceil(ascent + descent) + 2;
const baseline = Math.ceil(ascent) + 1; // Offset baseline by half the padding
// Round up to the nearest device pixel (not CSS pixel) so that cell boundaries
// fall on exact physical pixel boundaries at any devicePixelRatio.
// Without this, non-integer DPR values (e.g. 1.25/1.5/1.75 from browser zoom)
// produce fractional physical coordinates at cell edges, which causes the canvas
// rasterizer to antialias clearRect/fillRect calls there. Combined with alpha:true
// on the canvas, those partially-transparent edge pixels composite against the page
// background and appear as thin black seams between rows/columns.
const dpr = this.devicePixelRatio;
const width = Math.ceil(widthMetrics.width * dpr) / dpr;
const height = Math.ceil((ascent + descent + 2) * dpr) / dpr;
const baseline = Math.ceil((ascent + 1) * dpr) / dpr;

return { width, height, baseline };
}
Expand All @@ -222,8 +233,16 @@ export class CanvasRenderer {
// Color Conversion
// ==========================================================================

private colorCache = new Map<number, string>();

private rgbToCSS(r: number, g: number, b: number): string {
return `rgb(${r}, ${g}, ${b})`;
const key = (r << 16) | (g << 8) | b;
let css = this.colorCache.get(key);
if (css === undefined) {
css = `rgb(${r},${g},${b})`;
this.colorCache.set(key, css);
}
return css;
}

// ==========================================================================
Expand Down Expand Up @@ -355,8 +374,10 @@ export class CanvasRenderer {
// Track rows with hyperlinks that need redraw when hover changes
const hyperlinkRows = new Set<number>();
const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId;
const linkRangeChanged =
JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange);
const a = this.hoveredLinkRange, b = this.previousHoveredLinkRange;
const linkRangeChanged = a !== b && (
!a || !b || a.startX !== b.startX || a.startY !== b.startY || a.endX !== b.endX || a.endY !== b.endY
);

if (hyperlinkChanged) {
// Find rows containing the old or new hovered hyperlink
Expand Down Expand Up @@ -537,6 +558,7 @@ export class CanvasRenderer {

// PASS 2: Draw all cell text and decorations
// Now text can safely extend beyond cell boundaries (for complex scripts)
this.lastFontKey = -1; // Reset font cache per line
for (let x = 0; x < line.length; x++) {
const cell = line[x];
if (cell.width === 0) continue; // Skip spacer cells for wide characters
Expand Down Expand Up @@ -602,11 +624,17 @@ export class CanvasRenderer {
// Check if this cell is selected
const isSelected = this.isInSelection(x, y);

// Set text style
let fontStyle = '';
if (cell.flags & CellFlags.ITALIC) fontStyle += 'italic ';
if (cell.flags & CellFlags.BOLD) fontStyle += 'bold ';
this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`;
// Set text style — only change ctx.font when style actually differs
const bold = (cell.flags & CellFlags.BOLD) !== 0;
const italic = (cell.flags & CellFlags.ITALIC) !== 0;
const fontKey = (bold ? 2 : 0) | (italic ? 1 : 0);
if (fontKey !== this.lastFontKey) {
this.lastFontKey = fontKey;
let fontStyle = '';
if (italic) fontStyle += 'italic ';
if (bold) fontStyle += 'bold ';
this.ctx.font = `${fontStyle}${this.fontSize}px ${this.fontFamily}`;
}

// Set text color - use override, selection foreground, or normal color
if (colorOverride) {
Expand Down Expand Up @@ -767,7 +795,8 @@ export class CanvasRenderer {
// xterm.js uses ~530ms blink interval
this.cursorBlinkInterval = window.setInterval(() => {
this.cursorVisible = !this.cursorVisible;
// Note: Render loop should redraw cursor line automatically
// Trigger render callback so the terminal redraws the cursor
this.cursorBlinkCallback?.();
}, 530);
}

Expand Down
108 changes: 62 additions & 46 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,9 @@ export class Terminal implements ITerminalCore {
theme: this.options.theme,
});

// Wire cursor blink → scheduleRender so blink refreshes the display
this.renderer.cursorBlinkCallback = () => this.scheduleRender();

// Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling)
this.renderer.resize(this.cols, this.rows);

Expand Down Expand Up @@ -491,9 +494,10 @@ export class Terminal implements ITerminalCore {
// Connect selection manager to renderer
this.renderer.setSelectionManager(this.selectionManager);

// Forward selection change events
// Forward selection change events and trigger render
this.selectionManager.onSelectionChange(() => {
this.selectionChangeEmitter.fire();
this.scheduleRender();
});

// Initialize link detection system
Expand Down Expand Up @@ -549,50 +553,59 @@ export class Terminal implements ITerminalCore {
this.writeInternal(data, callback);
}

// Deferred side-effect flags — accumulated across writes, flushed once per render
private pendingLinkInvalidation = false;
private pendingTitleCheck = false;

/**
* Internal write implementation (extracted from write())
*
* Hot path: WASM write + DSR responses are the only synchronous work.
* Everything else (bell, link cache, title) is deferred or cheap to check.
*/
private writeInternal(data: string | Uint8Array, callback?: () => void): void {
// Note: We intentionally do NOT clear selection on write - most modern terminals
// preserve selection when new data arrives. Selection is cleared by user actions
// like clicking or typing, not by incoming data.

// Write directly to WASM terminal (handles VT parsing internally)
this.wasmTerm!.write(data);

// Process any responses generated by the terminal (e.g., DSR cursor position)
// These need to be sent back to the PTY via onData
this.processTerminalResponses();

// Check for bell character (BEL, \x07)
// WASM doesn't expose bell events, so we detect it in the data stream
if (typeof data === 'string' && data.includes('\x07')) {
this.bellEmitter.fire();
// Bell detection — cheap byte scan, fire immediately (audible feedback)
if (typeof data === 'string') {
if (data.includes('\x07')) this.bellEmitter.fire();
if (data.includes('\x1b]')) this.pendingTitleCheck = true;
} else if (data instanceof Uint8Array && data.includes(0x07)) {
this.bellEmitter.fire();
}

// Invalidate link cache (content changed)
this.linkDetector?.invalidateCache();
// Defer expensive operations — they run once before the next render
this.pendingLinkInvalidation = true;

// Phase 2: Auto-scroll to bottom on new output (xterm.js behavior)
// Auto-scroll to bottom on new output
if (this.viewportY !== 0) {
this.scrollToBottom();
}

// Check for title changes (OSC 0, 1, 2 sequences)
// This is a simplified implementation - Ghostty WASM may provide this
if (typeof data === 'string' && data.includes('\x1b]')) {
this.checkForTitleChange(data);
}

// Call callback if provided
if (callback) {
// Queue callback after next render
requestAnimationFrame(callback);
}

// Render will happen on next animation frame
// Coalesce: multiple writes within the same frame share a single render
this.scheduleRender();
}

/** Flush deferred side-effects before rendering. Called once per render frame. */
private flushWriteSideEffects(): void {
if (this.pendingLinkInvalidation) {
this.pendingLinkInvalidation = false;
this.linkDetector?.invalidateCache();
}
if (this.pendingTitleCheck) {
this.pendingTitleCheck = false;
// Re-check title from WASM state rather than scanning every write chunk
this.checkForTitleChange('');
}
}

/**
Expand Down Expand Up @@ -916,6 +929,7 @@ export class Terminal implements ITerminalCore {
if (newViewportY !== this.viewportY) {
this.viewportY = newViewportY;
this.scrollEmitter.fire(this.viewportY);
this.scheduleRender();

// Show scrollbar when scrolling (with auto-hide)
if (scrollbackLength > 0) {
Expand All @@ -940,6 +954,7 @@ export class Terminal implements ITerminalCore {
if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) {
this.viewportY = scrollbackLength;
this.scrollEmitter.fire(this.viewportY);
this.scheduleRender();
this.showScrollbar();
}
}
Expand All @@ -951,6 +966,7 @@ export class Terminal implements ITerminalCore {
if (this.viewportY !== 0) {
this.viewportY = 0;
this.scrollEmitter.fire(this.viewportY);
this.scheduleRender();
// Show scrollbar briefly when scrolling to bottom
if (this.getScrollbackLength() > 0) {
this.showScrollbar();
Expand All @@ -969,6 +985,7 @@ export class Terminal implements ITerminalCore {
if (newViewportY !== this.viewportY) {
this.viewportY = newViewportY;
this.scrollEmitter.fire(this.viewportY);
this.scheduleRender();

// Show scrollbar when scrolling to specific line
if (scrollbackLength > 0) {
Expand Down Expand Up @@ -1069,6 +1086,7 @@ export class Terminal implements ITerminalCore {
}

// Continue animation
this.scheduleRender();
this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll);
};

Expand Down Expand Up @@ -1150,35 +1168,33 @@ export class Terminal implements ITerminalCore {
}

/**
* Start the render loop
* Schedule a single render frame (idle until something calls this).
* Multiple calls between frames are coalesced automatically.
*/
private startRenderLoop(): void {
if (this.animationFrameId) return; // already running
const loop = () => {
if (!this.isDisposed && this.isOpen) {
// Render using WASM's native dirty tracking
// The render() method:
// 1. Calls update() once to sync state and check dirty flags
// 2. Only redraws dirty rows when forceAll=false
// 3. Always calls clearDirty() at the end
this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);

// Check for cursor movement (Phase 2: onCursorMove event)
// Note: getCursor() reads from already-updated render state (from render() above)
const cursor = this.wasmTerm!.getCursor();
if (cursor.y !== this.lastCursorY) {
this.lastCursorY = cursor.y;
this.cursorMoveEmitter.fire();
}
private scheduleRender(): void {
if (this.animationFrameId || this.isDisposed || !this.isOpen) return;
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = undefined;
if (this.isDisposed || !this.isOpen) return;

// Note: onRender event is intentionally not fired in the render loop
// to avoid performance issues. For now, consumers can use requestAnimationFrame
// if they need frame-by-frame updates.
// Flush deferred write side-effects once before rendering
this.flushWriteSideEffects();

this.animationFrameId = requestAnimationFrame(loop);
this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity);

const cursor = this.wasmTerm!.getCursor();
if (cursor.y !== this.lastCursorY) {
this.lastCursorY = cursor.y;
this.cursorMoveEmitter.fire();
}
};
loop();
});
}

/**
* @deprecated Use scheduleRender() instead. Kept for call-site compatibility.
*/
private startRenderLoop(): void {
this.scheduleRender();
}

/**
Expand Down