diff --git a/.gitignore b/.gitignore index 722d333..7112704 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ dist/ # WASM build output (built locally and in CI) ghostty-vt.wasm + +# Visual render test failure artifacts +demo/baselines/*.fail.png diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..808fbb2 Binary files /dev/null and b/bun.lockb differ diff --git a/demo/baselines/ansi-colors.png b/demo/baselines/ansi-colors.png new file mode 100644 index 0000000..348f11f Binary files /dev/null and b/demo/baselines/ansi-colors.png differ diff --git a/demo/baselines/basic-text.png b/demo/baselines/basic-text.png new file mode 100644 index 0000000..6b2ea9a Binary files /dev/null and b/demo/baselines/basic-text.png differ diff --git a/demo/baselines/cell-backgrounds.png b/demo/baselines/cell-backgrounds.png new file mode 100644 index 0000000..295f4f9 Binary files /dev/null and b/demo/baselines/cell-backgrounds.png differ diff --git a/demo/baselines/colorscript-art.png b/demo/baselines/colorscript-art.png new file mode 100644 index 0000000..ee97033 Binary files /dev/null and b/demo/baselines/colorscript-art.png differ diff --git a/demo/baselines/combined-styles.png b/demo/baselines/combined-styles.png new file mode 100644 index 0000000..5c38b49 Binary files /dev/null and b/demo/baselines/combined-styles.png differ diff --git a/demo/baselines/cursor-bar.png b/demo/baselines/cursor-bar.png new file mode 100644 index 0000000..14416ef Binary files /dev/null and b/demo/baselines/cursor-bar.png differ diff --git a/demo/baselines/cursor-block.png b/demo/baselines/cursor-block.png new file mode 100644 index 0000000..1b07cb0 Binary files /dev/null and b/demo/baselines/cursor-block.png differ diff --git a/demo/baselines/cursor-underline.png b/demo/baselines/cursor-underline.png new file mode 100644 index 0000000..813f598 Binary files /dev/null and b/demo/baselines/cursor-underline.png differ diff --git a/demo/baselines/hyperlink.png b/demo/baselines/hyperlink.png new file mode 100644 index 0000000..8eb1170 Binary files /dev/null and b/demo/baselines/hyperlink.png differ diff --git a/demo/baselines/inverse-video.png b/demo/baselines/inverse-video.png new file mode 100644 index 0000000..eaa53cb Binary files /dev/null and b/demo/baselines/inverse-video.png differ diff --git a/demo/baselines/invisible-text.png b/demo/baselines/invisible-text.png new file mode 100644 index 0000000..d828fc6 Binary files /dev/null and b/demo/baselines/invisible-text.png differ diff --git a/demo/baselines/powerline-prompt.png b/demo/baselines/powerline-prompt.png new file mode 100644 index 0000000..f26b6e8 Binary files /dev/null and b/demo/baselines/powerline-prompt.png differ diff --git a/demo/baselines/rgb-colors.png b/demo/baselines/rgb-colors.png new file mode 100644 index 0000000..a2ab2be Binary files /dev/null and b/demo/baselines/rgb-colors.png differ diff --git a/demo/baselines/text-styles.png b/demo/baselines/text-styles.png new file mode 100644 index 0000000..0893fc7 Binary files /dev/null and b/demo/baselines/text-styles.png differ diff --git a/demo/baselines/wide-chars.png b/demo/baselines/wide-chars.png new file mode 100644 index 0000000..3ad6287 Binary files /dev/null and b/demo/baselines/wide-chars.png differ diff --git a/demo/bin/render-test.ts b/demo/bin/render-test.ts new file mode 100644 index 0000000..093dc79 --- /dev/null +++ b/demo/bin/render-test.ts @@ -0,0 +1,285 @@ +#!/usr/bin/env bun +/** + * Headless visual regression test runner for the renderer. + * + * Usage: + * bun demo/bin/render-test.ts # Run tests against baselines + * bun demo/bin/render-test.ts --update # Update baselines from current renders + * + * Baselines are stored in demo/baselines/*.png + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +// Get script directory +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const DEMO_DIR = dirname(__dirname); +const BASELINES_DIR = join(DEMO_DIR, 'baselines'); +const PROJECT_ROOT = dirname(DEMO_DIR); + +// Parse args +const args = process.argv.slice(2); +const updateMode = args.includes('--update') || args.includes('-u'); +const helpMode = args.includes('--help') || args.includes('-h'); + +if (helpMode) { + console.log(` +Visual Render Test Runner + +Usage: + bun demo/bin/render-test.ts [options] + +Options: + --update, -u Update baselines from current renders + --help, -h Show this help message + +Baselines are stored in demo/baselines/*.png +`); + process.exit(0); +} + +// Ensure baselines directory exists +if (!existsSync(BASELINES_DIR)) { + mkdirSync(BASELINES_DIR, { recursive: true }); +} + +interface TestResult { + id: string; + name: string; + status: 'pass' | 'fail' | 'new' | 'error'; + diffPercent?: number; + error?: string; +} + +async function main() { + console.log('๐Ÿงช Visual Render Test Runner\n'); + + // Dynamic import puppeteer (install if needed) + let puppeteer: typeof import('puppeteer'); + try { + puppeteer = await import('puppeteer'); + } catch { + console.log('๐Ÿ“ฆ Installing puppeteer...'); + const proc = Bun.spawn(['bun', 'add', '-d', 'puppeteer'], { + cwd: PROJECT_ROOT, + stdout: 'inherit', + stderr: 'inherit', + }); + await proc.exited; + puppeteer = await import('puppeteer'); + } + + // Start local server + console.log('๐ŸŒ Starting local server...'); + const server = Bun.serve({ + port: 0, // Let OS pick a free port + async fetch(req) { + const url = new URL(req.url); + let filePath = join(PROJECT_ROOT, url.pathname); + + // Default to index.html for directories + if (filePath.endsWith('/')) { + filePath += 'index.html'; + } + + try { + const file = Bun.file(filePath); + if (await file.exists()) { + // Set content type based on extension + const ext = filePath.split('.').pop() || ''; + const contentTypes: Record = { + html: 'text/html', + js: 'application/javascript', + css: 'text/css', + json: 'application/json', + wasm: 'application/wasm', + png: 'image/png', + ttf: 'font/ttf', + }; + return new Response(file, { + headers: { 'Content-Type': contentTypes[ext] || 'application/octet-stream' }, + }); + } + } catch { + // Fall through to 404 + } + return new Response('Not found', { status: 404 }); + }, + }); + + const serverUrl = `http://localhost:${server.port}`; + console.log(` Server running at ${serverUrl}`); + + // Launch browser + console.log('๐Ÿš€ Launching headless browser...'); + const browser = await puppeteer.default.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + const page = await browser.newPage(); + + // Set viewport for consistent rendering + await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 }); + + try { + // Navigate to test page + console.log('๐Ÿ“„ Loading test page...\n'); + await page.goto(`${serverUrl}/demo/render-test.html`, { + waitUntil: 'networkidle0', + timeout: 30000, + }); + + // Wait for the page's runAllTests() to complete. + // render-test.html sets window.__testsComplete = true when done. + await page.waitForFunction('window.__testsComplete === true', { timeout: 60000 }); + + // Get test cases from the page + const testCases = await page.evaluate(() => { + // Access the module's test cases through the window exports + // We need to extract test info from the DOM since testCases is module-scoped + const cards = document.querySelectorAll('.test-case'); + return Array.from(cards).map((card) => { + const id = card.id.replace('test-', ''); + const name = card.querySelector('h3')?.textContent || id; + return { id, name }; + }); + }); + + if (testCases.length === 0) { + throw new Error('No test cases found. Make sure the page loaded correctly.'); + } + + console.log(`Found ${testCases.length} tests\n`); + + // Run tests and collect results + const results: TestResult[] = []; + let passed = 0; + let failed = 0; + let newTests = 0; + + for (const test of testCases) { + const baselinePath = join(BASELINES_DIR, `${test.id}.png`); + const hasBaseline = existsSync(baselinePath); + + // Get the canvas data URL from the page + const canvasDataUrl = await page.evaluate((testId: string) => { + const canvas = document.getElementById(`canvas-${testId}`) as HTMLCanvasElement; + return canvas?.toDataURL('image/png') || null; + }, test.id); + + if (!canvasDataUrl) { + results.push({ id: test.id, name: test.name, status: 'error', error: 'Canvas not found' }); + console.log(` โŒ ${test.name}: Canvas not found`); + failed++; + continue; + } + + // Convert data URL to buffer + const base64Data = canvasDataUrl.replace(/^data:image\/png;base64,/, ''); + const currentBuffer = Buffer.from(base64Data, 'base64'); + + if (updateMode) { + // Update mode: save current as baseline + writeFileSync(baselinePath, currentBuffer); + console.log(` ๐Ÿ“ธ ${test.name}: Baseline ${hasBaseline ? 'updated' : 'created'}`); + results.push({ id: test.id, name: test.name, status: 'new' }); + newTests++; + } else if (!hasBaseline) { + // No baseline exists + console.log(` ๐Ÿ†• ${test.name}: No baseline (run with --update to create)`); + results.push({ id: test.id, name: test.name, status: 'new' }); + newTests++; + } else { + // Compare with baseline + const baselineBuffer = readFileSync(baselinePath); + + // Simple byte comparison first + if (currentBuffer.equals(baselineBuffer)) { + console.log(` โœ… ${test.name}: Pass (identical)`); + results.push({ id: test.id, name: test.name, status: 'pass', diffPercent: 0 }); + passed++; + } else { + // Buffers differ - calculate difference percentage + const diffPercent = calculateDiffPercent(currentBuffer, baselineBuffer); + + if (diffPercent <= 0.1) { + // Within threshold + console.log(` โœ… ${test.name}: Pass (${diffPercent.toFixed(3)}% diff)`); + results.push({ id: test.id, name: test.name, status: 'pass', diffPercent }); + passed++; + } else { + console.log(` โŒ ${test.name}: Fail (${diffPercent.toFixed(3)}% diff)`); + results.push({ id: test.id, name: test.name, status: 'fail', diffPercent }); + failed++; + + // Save the current render for debugging + const failPath = join(BASELINES_DIR, `${test.id}.fail.png`); + writeFileSync(failPath, currentBuffer); + } + } + } + } + + // Summary + console.log('\n' + 'โ”€'.repeat(50)); + console.log(`\n๐Ÿ“Š Results: ${passed} passed, ${failed} failed, ${newTests} new\n`); + + if (updateMode) { + console.log(`โœจ Baselines ${newTests > 0 ? 'updated' : 'unchanged'} in demo/baselines/\n`); + } + + // Exit with appropriate code + await browser.close(); + server.stop(); + + if (failed > 0) { + process.exit(1); + } else if (newTests > 0 && !updateMode) { + console.log('โš ๏ธ New tests detected. Run with --update to create baselines.\n'); + process.exit(1); + } + } catch (error) { + console.error('Error:', error); + await browser.close(); + server.stop(); + process.exit(1); + } +} + +/** + * Calculate approximate difference percentage between two PNG buffers. + * This is a simple comparison - for production you might want pixelmatch. + */ +function calculateDiffPercent(buf1: Buffer, buf2: Buffer): number { + // Simple approach: compare decoded pixel data + // For a more accurate comparison, use a library like pixelmatch + + // Quick heuristic based on buffer size difference and content + const sizeDiff = Math.abs(buf1.length - buf2.length); + const maxSize = Math.max(buf1.length, buf2.length); + + if (sizeDiff > 0) { + // Different sizes means different images + return (sizeDiff / maxSize) * 100; + } + + // Compare bytes + let diffBytes = 0; + const minLen = Math.min(buf1.length, buf2.length); + for (let i = 0; i < minLen; i++) { + if (buf1[i] !== buf2[i]) { + diffBytes++; + } + } + + return (diffBytes / maxSize) * 100; +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/demo/render-test.html b/demo/render-test.html new file mode 100644 index 0000000..ccd2540 --- /dev/null +++ b/demo/render-test.html @@ -0,0 +1,1004 @@ + + + + + + Visual Render Tests - Ghostty WASM + + + +

Visual Render Tests

+

Renderer regression tests comparing against baseline images

+ +
+ Usage: Run bun test:render:web then open + http://localhost:3000/demo/render-test
+ To update baselines: bun test:render:update +
+ +
+ +
+
+ 0 +
Passed
+
+
+ 0 +
Failed
+
+
+ 0 +
New
+
+
+
+ +
+ + + + diff --git a/lib/renderer.ts b/lib/renderer.ts index dafb01e..918d5ee 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -18,7 +18,7 @@ import { CellFlags } from './types'; // Interface for objects that can be rendered export interface IRenderable { getLine(y: number): GhosttyCell[] | null; - getCursor(): { x: number; y: number; visible: boolean }; + getCursor(): { x: number; y: number; visible: boolean; style?: 'block' | 'underline' | 'bar' }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; /** Returns true if a full redraw is needed (e.g., screen change) */ @@ -187,26 +187,55 @@ export class CanvasRenderer { // Font Metrics Measurement // ========================================================================== + /** + * Build a CSS font string with proper quoting for font families with spaces. + * Example: "Fira Code, monospace" -> '"Fira Code", monospace' + */ + private buildFontString(style: string = ''): string { + // Quote font family names that contain spaces but aren't already quoted + const quotedFamily = this.fontFamily + .split(',') + .map((f) => { + const trimmed = f.trim(); + // Already quoted or a generic family (no spaces) + if (trimmed.startsWith('"') || trimmed.startsWith("'") || !trimmed.includes(' ')) { + return trimmed; + } + // Quote it + return `"${trimmed}"`; + }) + .join(', '); + + return `${style}${this.fontSize}px ${quotedFamily}`; + } + 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}`; + ctx.font = this.buildFontString(); // 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 + // Use font-level metrics (fontBoundingBox) rather than glyph-specific metrics (actualBoundingBox). + // This ensures the cell height accommodates ALL glyphs in the font, including powerline + // characters (U+E0B0, U+E0B6, etc.) which are designed to fill the full cell height. + // Fall back to actual metrics if font metrics aren't available. + const ascent = + widthMetrics.fontBoundingBoxAscent || + widthMetrics.actualBoundingBoxAscent || + this.fontSize * 0.8; + const descent = + widthMetrics.fontBoundingBoxDescent || + widthMetrics.actualBoundingBoxDescent || + this.fontSize * 0.2; + + const height = Math.ceil(ascent + descent); + const baseline = Math.ceil(ascent); return { width, height, baseline }; } @@ -485,7 +514,9 @@ export class CanvasRenderer { // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { - this.renderCursor(cursor.x, cursor.y); + // Use cursor style from buffer if provided, otherwise use renderer default + const cursorStyle = cursor.style ?? this.cursorStyle; + this.renderCursor(cursor.x, cursor.y, cursorStyle); } // Render scrollbar if scrolled or scrollback exists (with opacity for fade effect) @@ -589,7 +620,7 @@ export class CanvasRenderer { * Render a cell's text and decorations (Pass 2 of two-pass rendering) * Selection foreground color is applied here to match the selection background. */ - private renderCellText(cell: GhosttyCell, x: number, y: number): void { + private renderCellText(cell: GhosttyCell, x: number, y: number, colorOverride?: string): void { const cellX = x * this.metrics.width; const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; @@ -606,24 +637,26 @@ export class CanvasRenderer { 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}`; + this.ctx.font = this.buildFontString(fontStyle); - // Set text color - use selection foreground if selected - if (isSelected) { + // Extract colors and handle inverse + let fg_r = cell.fg_r, + fg_g = cell.fg_g, + fg_b = cell.fg_b; + + if (cell.flags & CellFlags.INVERSE) { + // When inverted, foreground becomes background + fg_r = cell.bg_r; + fg_g = cell.bg_g; + fg_b = cell.bg_b; + } + + // Set text color - use override if provided, otherwise selection or cell color + if (colorOverride) { + this.ctx.fillStyle = colorOverride; + } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; } else { - // Extract colors and handle inverse - let fg_r = cell.fg_r, - fg_g = cell.fg_g, - fg_b = cell.fg_b; - - if (cell.flags & CellFlags.INVERSE) { - // When inverted, foreground becomes background - fg_r = cell.bg_r; - fg_g = cell.bg_g; - fg_b = cell.bg_b; - } - this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); } @@ -645,7 +678,18 @@ export class CanvasRenderer { // Simple cell - single codepoint char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null } - this.ctx.fillText(char, textX, textY); + + // Handle special characters that need pixel-perfect rendering: + // - Block drawing characters (U+2580-U+259F): rectangles for gap-free ASCII art + // - Powerline glyphs (U+E0B0-U+E0BF): vector shapes to match exact cell height + const codepoint = cell.codepoint || 32; + if (this.renderBlockChar(codepoint, cellX, cellY, cellWidth)) { + // Block character was rendered as a rectangle, skip font rendering + } else if (this.renderPowerlineGlyph(codepoint, cellX, cellY, cellWidth)) { + // Powerline glyph was rendered as a vector shape, skip font rendering + } else { + this.ctx.fillText(char, textX, textY); + } // Reset alpha if (cell.flags & CellFlags.FAINT) { @@ -711,19 +755,234 @@ export class CanvasRenderer { } } + /** + * Render block drawing characters as filled rectangles for pixel-perfect rendering. + * Returns true if the character was handled, false if it should be rendered as text. + */ + private renderBlockChar( + codepoint: number, + cellX: number, + cellY: number, + cellWidth: number + ): boolean { + const height = this.metrics.height; + + // Block Elements (U+2580-U+259F) + switch (codepoint) { + case 0x2580: // โ–€ UPPER HALF BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth, height / 2); + return true; + case 0x2581: // โ– LOWER ONE EIGHTH BLOCK + this.ctx.fillRect(cellX, cellY + (height * 7) / 8, cellWidth, height / 8); + return true; + case 0x2582: // โ–‚ LOWER ONE QUARTER BLOCK + this.ctx.fillRect(cellX, cellY + (height * 3) / 4, cellWidth, height / 4); + return true; + case 0x2583: // โ–ƒ LOWER THREE EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY + (height * 5) / 8, cellWidth, (height * 3) / 8); + return true; + case 0x2584: // โ–„ LOWER HALF BLOCK + this.ctx.fillRect(cellX, cellY + height / 2, cellWidth, height / 2); + return true; + case 0x2585: // โ–… LOWER FIVE EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY + (height * 3) / 8, cellWidth, (height * 5) / 8); + return true; + case 0x2586: // โ–† LOWER THREE QUARTERS BLOCK + this.ctx.fillRect(cellX, cellY + height / 4, cellWidth, (height * 3) / 4); + return true; + case 0x2587: // โ–‡ LOWER SEVEN EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY + height / 8, cellWidth, (height * 7) / 8); + return true; + case 0x2588: // โ–ˆ FULL BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth, height); + return true; + case 0x2589: // โ–‰ LEFT SEVEN EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY, (cellWidth * 7) / 8, height); + return true; + case 0x258a: // โ–Š LEFT THREE QUARTERS BLOCK + this.ctx.fillRect(cellX, cellY, (cellWidth * 3) / 4, height); + return true; + case 0x258b: // โ–‹ LEFT FIVE EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY, (cellWidth * 5) / 8, height); + return true; + case 0x258c: // โ–Œ LEFT HALF BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth / 2, height); + return true; + case 0x258d: // โ– LEFT THREE EIGHTHS BLOCK + this.ctx.fillRect(cellX, cellY, (cellWidth * 3) / 8, height); + return true; + case 0x258e: // โ–Ž LEFT ONE QUARTER BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth / 4, height); + return true; + case 0x258f: // โ– LEFT ONE EIGHTH BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth / 8, height); + return true; + case 0x2590: // โ– RIGHT HALF BLOCK + this.ctx.fillRect(cellX + cellWidth / 2, cellY, cellWidth / 2, height); + return true; + case 0x2594: // โ–” UPPER ONE EIGHTH BLOCK + this.ctx.fillRect(cellX, cellY, cellWidth, height / 8); + return true; + case 0x2595: // โ–• RIGHT ONE EIGHTH BLOCK + this.ctx.fillRect(cellX + (cellWidth * 7) / 8, cellY, cellWidth / 8, height); + return true; + default: + return false; + } + } + + /** + * Render Powerline glyphs as vector shapes for pixel-perfect cell height. + * Powerline glyphs (U+E0B0-U+E0BF) are designed to span the full cell height, + * but font rendering often makes them slightly taller/shorter than the cell. + * Drawing them as paths ensures they exactly fill the cell bounds. + * Returns true if the character was handled, false if it should be rendered as text. + */ + private renderPowerlineGlyph( + codepoint: number, + cellX: number, + cellY: number, + cellWidth: number + ): boolean { + const height = this.metrics.height; + const ctx = this.ctx; + + switch (codepoint) { + case 0xe0b0: // Right-pointing triangle (hard divider) + ctx.beginPath(); + ctx.moveTo(cellX, cellY); + ctx.lineTo(cellX + cellWidth, cellY + height / 2); + ctx.lineTo(cellX, cellY + height); + ctx.closePath(); + ctx.fill(); + return true; + + case 0xe0b1: // Right-pointing angle (soft divider, thin) + ctx.beginPath(); + ctx.moveTo(cellX, cellY); + ctx.lineTo(cellX + cellWidth, cellY + height / 2); + ctx.lineTo(cellX, cellY + height); + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = 1; + ctx.stroke(); + return true; + + case 0xe0b2: // Left-pointing triangle (hard divider) + ctx.beginPath(); + ctx.moveTo(cellX + cellWidth, cellY); + ctx.lineTo(cellX, cellY + height / 2); + ctx.lineTo(cellX + cellWidth, cellY + height); + ctx.closePath(); + ctx.fill(); + return true; + + case 0xe0b3: // Left-pointing angle (soft divider, thin) + ctx.beginPath(); + ctx.moveTo(cellX + cellWidth, cellY); + ctx.lineTo(cellX, cellY + height / 2); + ctx.lineTo(cellX + cellWidth, cellY + height); + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = 1; + ctx.stroke(); + return true; + + case 0xe0b4: // Right semicircle (filled) + ctx.beginPath(); + ctx.moveTo(cellX, cellY); + // Ellipse curving right: center at left edge, radii = cellWidth (x) and height/2 (y) + ctx.ellipse( + cellX, + cellY + height / 2, + cellWidth, + height / 2, + 0, + -Math.PI / 2, + Math.PI / 2, + false + ); + ctx.closePath(); + ctx.fill(); + return true; + + case 0xe0b5: // Right semicircle (outline) + ctx.beginPath(); + ctx.moveTo(cellX, cellY); + ctx.ellipse( + cellX, + cellY + height / 2, + cellWidth, + height / 2, + 0, + -Math.PI / 2, + Math.PI / 2, + false + ); + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = 1; + ctx.stroke(); + return true; + + case 0xe0b6: // Left semicircle (filled) - rounded left cap + ctx.beginPath(); + ctx.moveTo(cellX + cellWidth, cellY); + // Ellipse curving left: center at right edge, radii = cellWidth (x) and height/2 (y) + ctx.ellipse( + cellX + cellWidth, + cellY + height / 2, + cellWidth, + height / 2, + 0, + -Math.PI / 2, + Math.PI / 2, + true + ); + ctx.closePath(); + ctx.fill(); + return true; + + case 0xe0b7: // Left semicircle (outline) + ctx.beginPath(); + ctx.moveTo(cellX + cellWidth, cellY); + ctx.ellipse( + cellX + cellWidth, + cellY + height / 2, + cellWidth, + height / 2, + 0, + -Math.PI / 2, + Math.PI / 2, + true + ); + ctx.strokeStyle = ctx.fillStyle; + ctx.lineWidth = 1; + ctx.stroke(); + return true; + + default: + return false; + } + } + /** * Render cursor */ - private renderCursor(x: number, y: number): void { + private renderCursor(x: number, y: number, style?: 'block' | 'underline' | 'bar'): void { const cursorX = x * this.metrics.width; const cursorY = y * this.metrics.height; + const cursorStyle = style ?? this.cursorStyle; this.ctx.fillStyle = this.theme.cursor; - switch (this.cursorStyle) { + switch (cursorStyle) { case 'block': // Full cell block this.ctx.fillRect(cursorX, cursorY, this.metrics.width, this.metrics.height); + + // Re-draw the character under the cursor with cursorAccent color + const line = this.currentBuffer?.getLine(y); + if (line?.[x]) { + this.renderCellText(line[x], x, y, this.theme.cursorAccent); + } break; case 'underline': diff --git a/package.json b/package.json index 0b93cab..eae028f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "dev": "vite --port 8000", "demo": "node demo/bin/demo.js", "demo:dev": "node demo/bin/demo.js --dev", + "test:render": "bun demo/bin/render-test.ts", + "test:render:update": "bun demo/bin/render-test.ts --update", + "test:render:web": "bunx serve . -p 3000", "prebuild": "bun install", "build": "bun run clean && bun run build:wasm && bun run build:lib && bun run build:wasm-copy", "build:wasm": "./scripts/build-wasm.sh", @@ -69,6 +72,7 @@ "@xterm/xterm": "^5.5.0", "mitata": "^1.0.34", "prettier": "^3.6.2", + "puppeteer": "^24.37.5", "typescript": "^5.9.3", "vite": "^4.5.0", "vite-plugin-dts": "^4.5.4"