Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
28 kB · 876 lines
TypeScript
at renderstate
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877/** * Canvas Renderer for Terminal Display * * High-performance canvas-based renderer that draws the terminal using * Ghostty's WASM terminal emulator. Features: * - Font metrics measurement with DPI scaling * - Full color support (256-color palette + RGB) * - All text styles (bold, italic, underline, strikethrough, etc.) * - Multiple cursor styles (block, underline, bar) * - Dirty line optimization for 60 FPS */
import type { ITheme } from './interfaces';import type { SelectionManager } from './selection-manager';import type { GhosttyCell, ILink } from './types';import { CellFlags } from './types';
// Interface for objects that can be renderedexport interface IRenderable { getLine(y: number): GhosttyCell[] | null; getCursor(): { x: number; y: number; visible: boolean }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; /** Returns true if a full redraw is needed (e.g., screen change) */ needsFullRedraw?(): boolean; clearDirty(): void;}
export interface IScrollbackProvider { getScrollbackLine(offset: number): GhosttyCell[] | null; getScrollbackLength(): number;}
// ============================================================================// Type Definitions// ============================================================================
export interface RendererOptions { fontSize?: number; // Default: 15 fontFamily?: string; // Default: 'monospace' cursorStyle?: 'block' | 'underline' | 'bar'; // Default: 'block' cursorBlink?: boolean; // Default: false theme?: ITheme; devicePixelRatio?: number; // Default: window.devicePixelRatio}
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}
// ============================================================================// Default Theme// ============================================================================
export const DEFAULT_THEME: Required<ITheme> = { foreground: '#d4d4d4', background: '#1e1e1e', cursor: '#ffffff', cursorAccent: '#1e1e1e', selectionBackground: 'rgba(255, 255, 255, 0.3)', selectionForeground: '#d4d4d4', black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff',};
// ============================================================================// CanvasRenderer Class// ============================================================================
export class CanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private fontSize: number; private fontFamily: string; private cursorStyle: 'block' | 'underline' | 'bar'; private cursorBlink: boolean; private theme: Required<ITheme>; private devicePixelRatio: number; private metrics: FontMetrics; private palette: string[];
// Cursor blinking state private cursorVisible: boolean = true; private cursorBlinkInterval?: number; private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 };
// Viewport tracking (for scrolling) private lastViewportY: number = 0;
// Selection manager (for rendering selection overlay) private selectionManager?: SelectionManager;
// Link rendering state private hoveredHyperlinkId: number = 0; private previousHoveredHyperlinkId: number = 0;
// Regex link hover tracking (for links without hyperlink_id) private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null = null; private previousHoveredLinkRange: { startX: number; startY: number; endX: number; endY: number; } | null = null;
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { this.canvas = canvas; const ctx = canvas.getContext('2d', { alpha: false }); if (!ctx) { throw new Error('Failed to get 2D rendering context'); } this.ctx = ctx;
// Apply options this.fontSize = options.fontSize ?? 15; this.fontFamily = options.fontFamily ?? 'monospace'; this.cursorStyle = options.cursorStyle ?? 'block'; this.cursorBlink = options.cursorBlink ?? false; this.theme = { ...DEFAULT_THEME, ...options.theme }; this.devicePixelRatio = options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
// Build color palette (16 ANSI colors) this.palette = [ this.theme.black, this.theme.red, this.theme.green, this.theme.yellow, this.theme.blue, this.theme.magenta, this.theme.cyan, this.theme.white, this.theme.brightBlack, this.theme.brightRed, this.theme.brightGreen, this.theme.brightYellow, this.theme.brightBlue, this.theme.brightMagenta, this.theme.brightCyan, this.theme.brightWhite, ];
// Measure font metrics this.metrics = this.measureFont();
// Setup cursor blinking if enabled if (this.cursorBlink) { this.startCursorBlink(); } }
// ========================================================================== // Font Metrics Measurement // ==========================================================================
private measureFont(): FontMetrics { // Use an offscreen canvas for measurement const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d')!;
// Set font (use actual pixel size for accurate measurement) ctx.font = `${this.fontSize}px ${this.fontFamily}`;
// 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
return { width, height, baseline }; }
/** * Remeasure font metrics (call after font loads or changes) */ public remeasureFont(): void { this.metrics = this.measureFont(); }
// ========================================================================== // Color Conversion // ==========================================================================
private rgbToCSS(r: number, g: number, b: number): string { return `rgb(${r}, ${g}, ${b})`; }
// ========================================================================== // Canvas Sizing // ==========================================================================
/** * Resize canvas to fit terminal dimensions */ public resize(cols: number, rows: number): void { const cssWidth = cols * this.metrics.width; const cssHeight = rows * this.metrics.height;
// Set CSS size (what user sees) this.canvas.style.width = `${cssWidth}px`; this.canvas.style.height = `${cssHeight}px`;
// Set actual canvas size (scaled for DPI) this.canvas.width = cssWidth * this.devicePixelRatio; this.canvas.height = cssHeight * this.devicePixelRatio;
// Scale context to match DPI (setting canvas.width/height resets the context) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio);
// Set text rendering properties for crisp text this.ctx.textBaseline = 'alphabetic'; this.ctx.textAlign = 'left';
// Fill background after resize this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, 0, cssWidth, cssHeight); }
// ========================================================================== // Main Rendering // ==========================================================================
/** * Render the terminal buffer to canvas */ public render( buffer: IRenderable, forceAll: boolean = false, viewportY: number = 0, scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0;
// Check if buffer needs full redraw (e.g., screen change between normal/alternate) if (buffer.needsFullRedraw?.()) { forceAll = true; }
// Resize canvas if dimensions changed const needsResize = this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio || this.canvas.height !== dims.rows * this.metrics.height * this.devicePixelRatio;
if (needsResize) { this.resize(dims.cols, dims.rows); forceAll = true; // Force full render after resize }
// Force re-render when viewport changes (scrolling) if (viewportY !== this.lastViewportY) { forceAll = true; this.lastViewportY = viewportY; }
// 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); 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); if (line) { this.renderLine(line, this.lastCursorPosition.y, dims.cols); } } } }
// Check if we need to redraw selection-related lines const hasSelection = this.selectionManager && this.selectionManager.hasSelection(); const selectionRows = new Set<number>();
// Mark current selection rows for redraw (includes programmatic selections) if (hasSelection) { const coords = this.selectionManager!.getSelectionCoords(); if (coords) { for (let row = coords.startRow; row <= coords.endRow; row++) { selectionRows.add(row); } } }
// Always mark dirty selection rows for redraw (to clear old overlay) if (this.selectionManager) { const dirtyRows = this.selectionManager.getDirtySelectionRows(); if (dirtyRows.size > 0) { for (const row of dirtyRows) { selectionRows.add(row); } // Clear the dirty rows tracking after marking for redraw this.selectionManager.clearDirtySelectionRows(); } }
// 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);
if (hyperlinkChanged) { // Find rows containing the old or new hovered hyperlink // Must check the correct buffer based on viewportY (scrollback vs screen) for (let y = 0; y < dims.rows; y++) { let line: GhosttyCell[] | null = null;
// Same logic as rendering: fetch from scrollback or screen if (viewportY > 0) { if (y < viewportY && scrollbackProvider) { // This row is from scrollback // Floor viewportY for array access (handles fractional values during smooth scroll) const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; line = scrollbackProvider.getScrollbackLine(scrollbackOffset); } else { // This row is from visible screen const screenRow = y - Math.floor(viewportY); line = buffer.getLine(screenRow); } } else { // At bottom - fetch from visible screen line = buffer.getLine(y); }
if (line) { for (const cell of line) { if ( cell.hyperlink_id === this.hoveredHyperlinkId || cell.hyperlink_id === this.previousHoveredHyperlinkId ) { hyperlinkRows.add(y); break; // Found hyperlink in this row } } } } // Update previous state this.previousHoveredHyperlinkId = this.hoveredHyperlinkId; }
// Track rows affected by link range changes (for regex URLs) if (linkRangeChanged) { // Add rows from old range if (this.previousHoveredLinkRange) { for ( let y = this.previousHoveredLinkRange.startY; y <= this.previousHoveredLinkRange.endY; y++ ) { hyperlinkRows.add(y); } } // Add rows from new range if (this.hoveredLinkRange) { for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) { hyperlinkRows.add(y); } } this.previousHoveredLinkRange = this.hoveredLinkRange; }
// Track if anything was actually rendered let anyLinesRendered = false;
// Render each line for (let y = 0; y < dims.rows; y++) { // When scrolled, always force render all lines since we're showing scrollback const needsRender = viewportY > 0 ? true : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y);
if (!needsRender) { continue; }
anyLinesRendered = true;
// Fetch line from scrollback or visible screen let line: GhosttyCell[] | null = null; if (viewportY > 0) { // Scrolled up - need to fetch from scrollback + visible screen // When scrolled up N lines, we want to show: // - Scrollback lines (from the end) + visible screen lines
// Check if this row should come from scrollback or visible screen if (y < viewportY && scrollbackProvider) { // This row is from scrollback (upper part of viewport) // Get from end of scrollback buffer // Floor viewportY for array access (handles fractional values during smooth scroll) const scrollbackOffset = scrollbackLength - Math.floor(viewportY) + y; line = scrollbackProvider.getScrollbackLine(scrollbackOffset); } else { // This row is from visible screen (lower part of viewport) const screenRow = viewportY > 0 ? y - Math.floor(viewportY) : y; line = buffer.getLine(screenRow); } } else { // At bottom - fetch from visible screen line = buffer.getLine(y); }
if (line) { this.renderLine(line, y, dims.cols); } }
// Render selection highlight AFTER all text (so it overlays) // Only render if we actually rendered some lines if (hasSelection && anyLinesRendered) { // Draw selection overlay - only when we've redrawn the underlying text this.renderSelection(dims.cols); }
// Link underlines are drawn during cell rendering (see renderCell)
// Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { this.renderCursor(cursor.x, cursor.y); }
// Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) if (scrollbackProvider && scrollbarOpacity > 0) { this.renderScrollbar(viewportY, scrollbackLength, dims.rows, scrollbarOpacity); }
// Update last cursor position this.lastCursorPosition = { x: cursor.x, y: cursor.y };
// ALWAYS clear dirty flags after rendering, regardless of forceAll. // This is critical - if we don't clear after a full redraw, the dirty // state persists and the next frame might not detect new changes properly. buffer.clearDirty(); }
/** * Render a single line */ private renderLine(line: GhosttyCell[], y: number, cols: number): void { const lineY = y * this.metrics.height;
// Clear line background this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, lineY, cols * this.metrics.width, this.metrics.height);
// Render each cell for (let x = 0; x < line.length; x++) { const cell = line[x];
// Skip padding cells for wide characters if (cell.width === 0) { continue; }
this.renderCell(cell, x, y); } }
/** * Render a single cell */ private renderCell(cell: GhosttyCell, x: number, y: number): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; // Handle wide chars (width=2)
// Extract colors and handle inverse let fg_r = cell.fg_r, fg_g = cell.fg_g, fg_b = cell.fg_b; let bg_r = cell.bg_r, bg_g = cell.bg_g, bg_b = cell.bg_b;
if (cell.flags & CellFlags.INVERSE) { [fg_r, fg_g, fg_b, bg_r, bg_g, bg_b] = [bg_r, bg_g, bg_b, fg_r, fg_g, fg_b]; }
// Only draw cell background if it's different from the default (black) // This lets the theme background (drawn in renderLine) show through for default cells const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; if (!isDefaultBg) { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); }
// Skip rendering if invisible if (cell.flags & CellFlags.INVISIBLE) { return; }
// 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 color this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b);
// Apply faint effect if (cell.flags & CellFlags.FAINT) { this.ctx.globalAlpha = 0.5; }
// Draw text const textX = cellX; const textY = cellY + this.metrics.baseline; const char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null this.ctx.fillText(char, textX, textY);
// Reset alpha if (cell.flags & CellFlags.FAINT) { this.ctx.globalAlpha = 1.0; }
// Draw underline if (cell.flags & CellFlags.UNDERLINE) { const underlineY = cellY + this.metrics.baseline + 2; this.ctx.strokeStyle = this.ctx.fillStyle; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(cellX, underlineY); this.ctx.lineTo(cellX + cellWidth, underlineY); this.ctx.stroke(); }
// Draw strikethrough if (cell.flags & CellFlags.STRIKETHROUGH) { const strikeY = cellY + this.metrics.height / 2; this.ctx.strokeStyle = this.ctx.fillStyle; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(cellX, strikeY); this.ctx.lineTo(cellX + cellWidth, strikeY); this.ctx.stroke(); }
// Draw hyperlink underline (for OSC8 hyperlinks) if (cell.hyperlink_id > 0) { const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId;
// Only show underline when hovered (cleaner look) if (isHovered) { const underlineY = cellY + this.metrics.baseline + 2; this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(cellX, underlineY); this.ctx.lineTo(cellX + cellWidth, underlineY); this.ctx.stroke(); } }
// Draw regex link underline (for plain text URLs) if (this.hoveredLinkRange) { const range = this.hoveredLinkRange; // Check if this cell is within the hovered link range const isInRange = (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) || (y > range.startY && y < range.endY) || (y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX));
if (isInRange) { const underlineY = cellY + this.metrics.baseline + 2; this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(cellX, underlineY); this.ctx.lineTo(cellX + cellWidth, underlineY); this.ctx.stroke(); } } }
/** * Render cursor */ private renderCursor(x: number, y: number): void { const cursorX = x * this.metrics.width; const cursorY = y * this.metrics.height;
this.ctx.fillStyle = this.theme.cursor;
switch (this.cursorStyle) { case 'block': // Full cell block this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); break;
case 'underline': // Underline at bottom of cell const underlineHeight = Math.max(2, Math.floor(this.metrics.height * 0.15)); this.ctx.fillRect( cursorX, cursorY + this.metrics.height - underlineHeight, this.metrics.width, underlineHeight ); break;
case 'bar': // Vertical bar at left of cell const barWidth = Math.max(2, Math.floor(this.metrics.width * 0.15)); this.ctx.fillRect(cursorX, cursorY, barWidth, this.metrics.height); break; } }
// ========================================================================== // Cursor Blinking // ==========================================================================
private startCursorBlink(): void { // xterm.js uses ~530ms blink interval this.cursorBlinkInterval = window.setInterval(() => { this.cursorVisible = !this.cursorVisible; // Note: Render loop should redraw cursor line automatically }, 530); }
private stopCursorBlink(): void { if (this.cursorBlinkInterval !== undefined) { clearInterval(this.cursorBlinkInterval); this.cursorBlinkInterval = undefined; } this.cursorVisible = true; }
// ========================================================================== // Public API // ==========================================================================
/** * Update theme colors */ public setTheme(theme: ITheme): void { this.theme = { ...DEFAULT_THEME, ...theme };
// Rebuild palette this.palette = [ this.theme.black, this.theme.red, this.theme.green, this.theme.yellow, this.theme.blue, this.theme.magenta, this.theme.cyan, this.theme.white, this.theme.brightBlack, this.theme.brightRed, this.theme.brightGreen, this.theme.brightYellow, this.theme.brightBlue, this.theme.brightMagenta, this.theme.brightCyan, this.theme.brightWhite, ]; }
/** * Update font size */ public setFontSize(size: number): void { this.fontSize = size; this.metrics = this.measureFont(); }
/** * Update font family */ public setFontFamily(family: string): void { this.fontFamily = family; this.metrics = this.measureFont(); }
/** * Update cursor style */ public setCursorStyle(style: 'block' | 'underline' | 'bar'): void { this.cursorStyle = style; }
/** * Enable/disable cursor blinking */ public setCursorBlink(enabled: boolean): void { if (enabled && !this.cursorBlink) { this.cursorBlink = true; this.startCursorBlink(); } else if (!enabled && this.cursorBlink) { this.cursorBlink = false; this.stopCursorBlink(); } }
/** * Get current font metrics */
/** * Render scrollbar (Phase 2) * Shows scroll position and allows click/drag interaction * @param opacity Opacity level (0-1) for fade in/out effect */ private renderScrollbar( viewportY: number, scrollbackLength: number, visibleRows: number, opacity: number = 1 ): void { // Don't render if fully transparent or no scrollback if (opacity <= 0 || scrollbackLength === 0) return;
const ctx = this.ctx; const canvasHeight = this.canvas.height / this.devicePixelRatio; const canvasWidth = this.canvas.width / this.devicePixelRatio;
// Scrollbar dimensions const scrollbarWidth = 8; const scrollbarX = canvasWidth - scrollbarWidth - 4; const scrollbarPadding = 4; const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2;
// Calculate scrollbar thumb size and position const totalLines = scrollbackLength + visibleRows; const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight);
// Position: 0 = at bottom, scrollbackLength = at top const scrollPosition = viewportY / scrollbackLength; // 0 to 1 const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition);
// Draw scrollbar track (subtle background) with opacity ctx.fillStyle = `rgba(128, 128, 128, ${0.1 * opacity})`; ctx.fillRect(scrollbarX, scrollbarPadding, scrollbarWidth, scrollbarTrackHeight);
// Draw scrollbar thumb with opacity const isScrolled = viewportY > 0; const baseOpacity = isScrolled ? 0.5 : 0.3; ctx.fillStyle = `rgba(128, 128, 128, ${baseOpacity * opacity})`; ctx.fillRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight); } public getMetrics(): FontMetrics { return { ...this.metrics }; }
/** * Get canvas element (needed by SelectionManager) */ public getCanvas(): HTMLCanvasElement { return this.canvas; }
/** * Set selection manager (for rendering selection overlay) */ public setSelectionManager(manager: SelectionManager): void { this.selectionManager = manager; }
/** * Set the currently hovered hyperlink ID for rendering underlines */ public setHoveredHyperlinkId(hyperlinkId: number): void { this.hoveredHyperlinkId = hyperlinkId; }
/** * Set the currently hovered link range for rendering underlines (for regex-detected URLs) * Pass null to clear the hover state */ public setHoveredLinkRange( range: { startX: number; startY: number; endX: number; endY: number; } | null ): void { this.hoveredLinkRange = range; }
/** * Get character cell width (for coordinate conversion) */ public get charWidth(): number { return this.metrics.width; }
/** * Get character cell height (for coordinate conversion) */ public get charHeight(): number { return this.metrics.height; }
/** * Clear entire canvas */ public clear(): void { this.ctx.fillStyle = this.theme.background; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); }
/** * Render selection overlay */ private renderSelection(cols: number): void { const coords = this.selectionManager!.getSelectionCoords(); if (!coords) return;
const { startCol, startRow, endCol, endRow } = coords;
// Use semi-transparent fill for selection this.ctx.save(); this.ctx.fillStyle = this.theme.selectionBackground; this.ctx.globalAlpha = 0.5; // Make it semi-transparent so text is visible
for (let row = startRow; row <= endRow; row++) { const colStart = row === startRow ? startCol : 0; const colEnd = row === endRow ? endCol : cols - 1;
const x = colStart * this.metrics.width; const y = row * this.metrics.height; const width = (colEnd - colStart + 1) * this.metrics.width; const height = this.metrics.height;
this.ctx.fillRect(x, y, width, height); }
this.ctx.restore(); }
/** * Cleanup resources */ public dispose(): void { this.stopCursorBlink(); }}