diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfd..5777003 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -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 } // ============================================================================ @@ -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) @@ -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 }; } @@ -222,8 +233,16 @@ export class CanvasRenderer { // Color Conversion // ========================================================================== + private colorCache = new Map(); + 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; } // ========================================================================== @@ -355,8 +374,10 @@ export class CanvasRenderer { // Track rows with hyperlinks that need redraw when hover changes const hyperlinkRows = new Set(); 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 @@ -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 @@ -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) { @@ -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); } diff --git a/lib/terminal.ts b/lib/terminal.ts index eeb7acd..ce68a8b 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -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); @@ -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 @@ -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(''); + } } /** @@ -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) { @@ -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(); } } @@ -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(); @@ -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) { @@ -1069,6 +1086,7 @@ export class Terminal implements ITerminalCore { } // Continue animation + this.scheduleRender(); this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); }; @@ -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(); } /**