From 41a37525d4b9f3790f03cf1fc9210abf0cf54b5d Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Sun, 22 Mar 2026 06:27:43 -0700 Subject: [PATCH 01/17] fix: zero-initialize WASM page buffers to prevent memory corruption (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PageList only zeroed page buffers in debug/safe builds, relying on the OS to guarantee zeroed memory in release builds. On WASM there is no OS guarantee โ€” the allocator reuses freed memory as-is. This caused two bugs: 1. Stale cell data from freed terminals appearing in newly created ones 2. WASM memory corruption after freeing terminals that processed multi-codepoint grapheme clusters (flag emoji, skin tones, ZWJ sequences), crashing all subsequent terminal writes The fix makes @memset(page_buf, 0) unconditional on WASM in both initPages and createPageExt. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/terminal.test.ts | 58 ++++++++++++++++++++++++++++++++++ patches/ghostty-wasm-api.patch | 42 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..0abd988 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -2989,4 +2989,62 @@ describe('Synchronous open()', () => { term.dispose(); }); + + test('new terminal should not contain stale data from freed terminal', async () => { + if (!container) return; + + // Create first terminal and write content + const term1 = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term1.open(container); + term1.write('Hello stale data'); + + // Access the Ghostty instance to create a second raw terminal + const ghostty = (term1 as any).ghostty; + const wasmTerm1 = term1.wasmTerm!; + + // Free the first WASM terminal and create a new one through the same instance + wasmTerm1.free(); + const wasmTerm2 = ghostty.createTerminal(80, 24); + + // New terminal should have clean grid + const line = wasmTerm2.getLine(0); + expect(line).not.toBeNull(); + for (const cell of line!) { + expect(cell.codepoint).toBe(0); + } + expect(wasmTerm2.getScrollbackLength()).toBe(0); + wasmTerm2.free(); + + term1.dispose(); + }); + + // https://github.com/coder/ghostty-web/issues/141 + test('freeing terminal after writing multi-codepoint grapheme clusters should not corrupt WASM memory', async () => { + if (!container) return; + + const term1 = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term1.open(container); + const ghostty = (term1 as any).ghostty; + const wasmTerm1 = term1.wasmTerm!; + + // Write multi-codepoint grapheme clusters (flag emoji, skin tone, ZWJ sequence) + wasmTerm1.write('\u{1F1FA}\u{1F1F8}'); // ๐Ÿ‡บ๐Ÿ‡ธ regional indicator pair + wasmTerm1.write('\u{1F44B}\u{1F3FD}'); // ๐Ÿ‘‹๐Ÿฝ wave + skin tone modifier + wasmTerm1.write('\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'); // ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง ZWJ family + + // Free the terminal that processed grapheme clusters + wasmTerm1.free(); + + // Creating and writing to a new terminal on the same instance should not crash + const wasmTerm2 = ghostty.createTerminal(80, 24); + expect(() => wasmTerm2.write('Hello')).not.toThrow(); + + // Verify the write actually worked + const line = wasmTerm2.getLine(0); + expect(line).not.toBeNull(); + expect(line![0].codepoint).toBe('H'.codePointAt(0)!); + + wasmTerm2.free(); + term1.dispose(); + }); }); diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9..b354683 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -368,6 +368,48 @@ index 03a883e20..1336676d7 100644 // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { +diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig +index 29f414e03..6b5ab19ab 100644 +--- a/src/terminal/PageList.zig ++++ b/src/terminal/PageList.zig +@@ -5,6 +5,7 @@ const PageList = @This(); + + const std = @import("std"); + const build_options = @import("terminal_options"); ++const builtin = @import("builtin"); + const Allocator = std.mem.Allocator; + const assert = @import("../quirks.zig").inlineAssert; + const fastmem = @import("../fastmem.zig"); +@@ -338,10 +339,10 @@ fn initPages( + const page_buf = try pool.pages.create(); + // no errdefer because the pool deinit will clean these up + +- // In runtime safety modes we have to memset because the Zig allocator +- // interface will always memset to 0xAA for undefined. In non-safe modes +- // we use a page allocator and the OS guarantees zeroed memory. +- if (comptime std.debug.runtime_safety) @memset(page_buf, 0); ++ // On WASM, the allocator reuses freed memory without zeroing, so we must ++ // always zero page buffers. On other platforms, only required with runtime ++ // safety (allocators init to 0xAA); in release the OS guarantees zeroed memory. ++ if (comptime builtin.target.cpu.arch.isWasm() or std.debug.runtime_safety) @memset(page_buf, 0); + + // Initialize the first set of pages to contain our viewport so that + // the top of the first page is always the active area. +@@ -2673,9 +2674,11 @@ inline fn createPageExt( + else + page_alloc.free(page_buf); + +- // Required only with runtime safety because allocators initialize +- // to undefined, 0xAA. +- if (comptime std.debug.runtime_safety) @memset(page_buf, 0); ++ // On WASM, the allocator reuses freed memory without zeroing, so we must ++ // always zero page buffers to prevent stale grapheme/style data from ++ // corrupting the terminal state after a free+realloc cycle. ++ // On other platforms, only required with runtime safety (allocators init to 0xAA). ++ if (comptime builtin.target.cpu.arch.isWasm() or std.debug.runtime_safety) @memset(page_buf, 0); + + page.* = .{ + .data = .initBuf(.init(page_buf), layout), diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index bc92597f5..d0ee49c1b 100644 --- a/src/terminal/c/main.zig From 043f1b05e5077d3be35ddbeb6d7cc16e5e58afde Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Wed, 1 Apr 2026 11:38:28 -0700 Subject: [PATCH 02/17] Remove auto-focus from Terminal.open() Callers that want focus after opening should call terminal.focus() explicitly. Auto-focusing unconditionally causes focus to be stolen from other elements in multi-terminal UIs (e.g. when a background output terminal is opened while the user is typing in an input bar). Co-Authored-By: Claude Sonnet 4.6 --- lib/terminal.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/terminal.ts b/lib/terminal.ts index eeb7acd..ab31253 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -525,8 +525,6 @@ export class Terminal implements ITerminalCore { // Start render loop this.startRenderLoop(); - // Focus input (auto-focus so user can start typing immediately) - this.focus(); } catch (error) { // Clean up on error this.isOpen = false; From ba124a6fbb76ff65b539a31d08404ded723524f9 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Sun, 5 Apr 2026 00:38:34 -0700 Subject: [PATCH 03/17] Preserve scroll position when new output arrives When the user has scrolled up to review earlier output, keep the viewport locked on the same content as new lines arrive instead of snapping back to the bottom. Save the scrollback length before each write and adjust `viewportY` by the delta afterward. Clamp to the current scrollback length in case old lines are dropped by the buffer limit. When the user is already at the bottom (`viewportY === 0`), the adjustment is skipped and the viewport naturally follows new output. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/terminal.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/terminal.ts b/lib/terminal.ts index ab31253..b0fc79a 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -555,6 +555,14 @@ export class Terminal implements ITerminalCore { // preserve selection when new data arrives. Selection is cleared by user actions // like clicking or typing, not by incoming data. + // Save scroll state before writing. viewportY is relative to the + // bottom, so if new lines push content into scrollback we need to + // bump viewportY by the same amount to keep the viewport locked on + // the same content. + const savedViewportY = this.viewportY; + const savedScrollback = savedViewportY > 0 + ? this.wasmTerm!.getScrollbackLength() : 0; + // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); @@ -573,9 +581,14 @@ export class Terminal implements ITerminalCore { // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); - // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) - if (this.viewportY !== 0) { - this.scrollToBottom(); + // If the user had scrolled up, adjust viewportY so the viewport + // stays locked on the same content instead of drifting as new + // scrollback lines are added. Clamp to the current scrollback + // length in case old lines were dropped by the scrollback limit. + if (savedViewportY > 0) { + const newScrollback = this.wasmTerm!.getScrollbackLength(); + const delta = newScrollback - savedScrollback; + this.viewportY = Math.min(savedViewportY + Math.max(0, delta), newScrollback); } // Check for title changes (OSC 0, 1, 2 sequences) From 9d0870ead80574090dc1ab090062fc8d9023f8d1 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Tue, 7 Apr 2026 03:06:41 -0700 Subject: [PATCH 04/17] Fix GhosttyTerminalConfig.scrollbackLimit docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollback_limit is passed to ghostty's Terminal.max_scrollback, which is in bytes. The low-level config types described it as "number of scrollback lines", which is misleading โ€” a caller passing 10,000 expecting lines gets 10,000 bytes and falls below the 2-page PageList floor. Only the low-level GhosttyTerminalConfig / TerminalConfig docs are corrected here. The xterm.js-compat ITerminalOptions.scrollback field still inherits xterm.js-compat framing and a misleadingly xterm.js- shaped default (1000) despite plumbing directly to a bytes-valued field; fixing that properly requires a lines-to-bytes conversion in buildWasmConfig, which belongs in a separate change. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/types.ts b/lib/types.ts index 4d6eefa..390f3ae 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -532,6 +532,7 @@ export const COLORS_STRUCT_SIZE = 12; * All color values use 0xRRGGBB format. A value of 0 means "use default". */ export interface GhosttyTerminalConfig { + /** Scrollback buffer size in bytes. Passed to Terminal.max_scrollback. */ scrollbackLimit?: number; fgColor?: number; bgColor?: number; @@ -604,7 +605,7 @@ export interface Cursor { * Terminal configuration (passed to ghostty_terminal_new_with_config) */ export interface TerminalConfig { - scrollback_limit: number; // Number of scrollback lines (default: 10,000) + scrollback_limit: number; // Scrollback buffer size in bytes (default: 10,000) fg_color: RGB; // Default foreground color bg_color: RGB; // Default background color } From 3245b78935f6ef93695e274c65ef10395f57967f Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 17 Apr 2026 05:07:15 -0700 Subject: [PATCH 05/17] feat: allow injecting an existing GhosttyTerminal into Terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ITerminalOptions.wasmTerm to let callers keep a wasm terminal alive independently of the Terminal wrapper's lifetime. When provided: - The Terminal constructor adopts the injected wasm terminal instead of allocating a fresh one; cols/rows default to the injected dims. - Terminal.dispose() skips the free() call โ€” the caller retains ownership. - Terminal.reset() throws to preserve the ownership contract (reset would otherwise free a buffer the caller still holds). Motivates a terminal-emulator client that needs to preserve buffer state across view-layer mount cycles (e.g. reparenting into a different DOM tree without losing scrollback, cursor, alt-screen mode, or hyperlinks). Tests cover: adoption identity, buffer preservation across dispose + second-wrapper adoption, writes reaching the injected buffer, reset throwing, dimension defaulting / explicit resize, and regression of the normal owned-wasmTerm path. Co-Authored-By: Claude Opus 4 (1M context) --- lib/interfaces.ts | 18 ++++- lib/terminal.test.ts | 179 ++++++++++++++++++++++++++++++++++++++++++- lib/terminal.ts | 59 ++++++++++++-- 3 files changed, 247 insertions(+), 9 deletions(-) diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 5b2017d..291eb41 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -2,7 +2,7 @@ * xterm.js-compatible interfaces */ -import type { Ghostty } from './ghostty'; +import type { Ghostty, GhosttyTerminal } from './ghostty'; export interface ITerminalOptions { cols?: number; // Default: 80 @@ -25,6 +25,22 @@ export interface ITerminalOptions { // Internal: Ghostty WASM instance (optional, for test isolation) // If not provided, uses the module-level instance from init() ghostty?: Ghostty; + + /** + * Adopt an existing GhosttyTerminal instead of allocating a fresh one. + * + * When provided, `open()` skips wasm terminal creation and uses the injected + * terminal's buffer/scrollback/mode state directly. The caller retains + * ownership: `Terminal.dispose()` will NOT free the wasm terminal, and + * `Terminal.reset()` throws (call `free()` + `createTerminal()` yourself). + * + * When `cols`/`rows` are not provided, they default to the injected + * terminal's current dimensions. + * + * Used to persist terminal state across view lifetimes (e.g. keeping the + * buffer alive while the DOM renderer is unmounted and later remounted). + */ + wasmTerm?: GhosttyTerminal; } export interface ITheme { diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 0abd988..8e00a0e 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -10,7 +10,8 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import type { Terminal } from './terminal'; +import { Ghostty } from './ghostty'; +import { Terminal } from './terminal'; import { createIsolatedTerminal } from './test-helpers'; /** @@ -3048,3 +3049,179 @@ describe('Synchronous open()', () => { term1.dispose(); }); }); + +describe('Injected wasmTerm (ITerminalOptions.wasmTerm)', () => { + let container: HTMLElement; + + beforeEach(() => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null!; + } + }); + + test('adopts injected wasmTerm without allocating a new one', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + + const term = new Terminal({ ghostty, wasmTerm }); + term.open(container!); + + // The Terminal should have adopted the exact wasmTerm we passed in, + // not constructed a replacement. + expect(term.wasmTerm).toBe(wasmTerm); + + term.dispose(); + wasmTerm.free(); + }); + + test('preserves buffer contents written before injection', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + wasmTerm.write('Hello, injected world!'); + + const term = new Terminal({ ghostty, wasmTerm }); + term.open(container!); + + const line = term.wasmTerm!.getLine(0); + expect(line).not.toBeNull(); + expect(line![0].codepoint).toBe('H'.codePointAt(0)!); + expect(line![7].codepoint).toBe('i'.codePointAt(0)!); + + term.dispose(); + wasmTerm.free(); + }); + + test('dispose() does not free injected wasmTerm', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + wasmTerm.write('survive me'); + + const term = new Terminal({ ghostty, wasmTerm }); + term.open(container!); + term.dispose(); + + // wasmTerm must still be usable after the Terminal wrapper is gone. + expect(() => wasmTerm.write(' still here')).not.toThrow(); + const line = wasmTerm.getLine(0); + expect(line).not.toBeNull(); + expect(line![0].codepoint).toBe('s'.codePointAt(0)!); + + wasmTerm.free(); + }); + + test('state persists across dispose + new wrapper (the reattach case)', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + wasmTerm.write('persistent state'); + + // First wrapper: mount, then tear down the view. + const term1 = new Terminal({ ghostty, wasmTerm }); + term1.open(container!); + term1.dispose(); + + // Second wrapper on the same wasmTerm: should see the original buffer. + const container2 = document.createElement('div'); + document.body.appendChild(container2); + const term2 = new Terminal({ ghostty, wasmTerm }); + term2.open(container2); + + const line = term2.wasmTerm!.getLine(0); + expect(line).not.toBeNull(); + expect(line![0].codepoint).toBe('p'.codePointAt(0)!); + expect(line![11].codepoint).toBe('s'.codePointAt(0)!); // "state" + + term2.dispose(); + container2.remove(); + wasmTerm.free(); + }); + + test('writes through the Terminal wrapper land in the injected wasmTerm', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + + const term = new Terminal({ ghostty, wasmTerm }); + term.open(container!); + term.write('from wrapper'); + + // Same buffer โ€” read directly from the wasmTerm reference the caller holds. + const line = wasmTerm.getLine(0); + expect(line).not.toBeNull(); + expect(line![0].codepoint).toBe('f'.codePointAt(0)!); + + term.dispose(); + wasmTerm.free(); + }); + + test('reset() throws when wasmTerm was injected', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + + const term = new Terminal({ ghostty, wasmTerm }); + term.open(container!); + + expect(() => term.reset()).toThrow(/not supported when a wasmTerm was injected/); + + // wasmTerm should still be alive after the failed reset. + expect(() => wasmTerm.write('still alive')).not.toThrow(); + + term.dispose(); + wasmTerm.free(); + }); + + test('defaults cols/rows to injected wasmTerm dimensions', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(132, 43); + + const term = new Terminal({ ghostty, wasmTerm }); + expect(term.cols).toBe(132); + expect(term.rows).toBe(43); + + term.open(container!); + // wasmTerm dimensions stay put when they match. + expect(wasmTerm.cols).toBe(132); + expect(wasmTerm.rows).toBe(43); + + term.dispose(); + wasmTerm.free(); + }); + + test('explicit cols/rows override and resize the injected wasmTerm on open', async () => { + const ghostty = await Ghostty.load(); + const wasmTerm = ghostty.createTerminal(80, 24); + + const term = new Terminal({ ghostty, wasmTerm, cols: 100, rows: 30 }); + expect(term.cols).toBe(100); + expect(term.rows).toBe(30); + + term.open(container!); + expect(wasmTerm.cols).toBe(100); + expect(wasmTerm.rows).toBe(30); + + term.dispose(); + wasmTerm.free(); + }); + + test('ownsWasmTerm=true (no injection) still frees on dispose', async () => { + // Regression check: the default allocation path must not be broken. + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + term.open(container!); + const wasmTermRef = term.wasmTerm!; + term.dispose(); + + // After dispose, the Terminal clears its wasmTerm field. We can't safely + // call methods on wasmTermRef because the underlying memory is freed โ€” + // the assertion here is that dispose() went through the free path at all. + expect(term.wasmTerm).toBeUndefined(); + // Sanity-check: holding the reference does not crash the test harness. + // (Calling methods on it would be use-after-free.) + expect(wasmTermRef).toBeDefined(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index b0fc79a..be210b1 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -65,6 +65,9 @@ export class Terminal implements ITerminalCore { // Components (created on open()) private ghostty?: Ghostty; public wasmTerm?: GhosttyTerminal; // Made public for link providers + /** Whether this Terminal owns (and must free) its wasmTerm. False when + * wasmTerm was passed in via ITerminalOptions.wasmTerm. */ + private ownsWasmTerm: boolean = true; public renderer?: CanvasRenderer; // Made public for FitAddon private inputHandler?: InputHandler; private selectionManager?: SelectionManager; @@ -137,10 +140,18 @@ export class Terminal implements ITerminalCore { // Use provided Ghostty instance (for test isolation) or get module-level instance this.ghostty = options.ghostty ?? getGhostty(); + // Adopt an injected wasm terminal if provided. The caller retains ownership + // (cleanupComponents() will skip free(), reset() throws). When cols/rows + // are not explicitly set, inherit them from the injected terminal. + if (options.wasmTerm) { + this.wasmTerm = options.wasmTerm; + this.ownsWasmTerm = false; + } + // Create base options object with all defaults (excluding ghostty) const baseOptions = { - cols: options.cols ?? 80, - rows: options.rows ?? 24, + cols: options.cols ?? options.wasmTerm?.cols ?? 80, + rows: options.rows ?? options.wasmTerm?.rows ?? 24, cursorBlink: options.cursorBlink ?? false, cursorStyle: options.cursorStyle ?? 'block', theme: options.theme ?? {}, @@ -369,9 +380,22 @@ export class Terminal implements ITerminalCore { parent.setAttribute('aria-label', 'Terminal input'); parent.setAttribute('aria-multiline', 'true'); - // Create WASM terminal with current dimensions and config - const config = this.buildWasmConfig(); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + // Create WASM terminal with current dimensions and config. + // + // If a wasmTerm was injected via ITerminalOptions.wasmTerm, adopt it + // instead of allocating a fresh one. The resize-if-differs branch + // below only runs when the caller explicitly set cols/rows in the + // options alongside wasmTerm โ€” the constructor's cols/rows default + // to the injected terminal's own dims, so in the common case the + // check is a no-op. + if (this.wasmTerm) { + if (this.wasmTerm.cols !== this.cols || this.wasmTerm.rows !== this.rows) { + this.wasmTerm.resize(this.cols, this.rows); + } + } else { + const config = this.buildWasmConfig(); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + } // Create canvas element this.canvas = document.createElement('canvas'); @@ -725,10 +749,27 @@ export class Terminal implements ITerminalCore { /** * Reset terminal state + * + * Frees the current wasm terminal and allocates a fresh one with the + * same dimensions and config. This is the xterm.js-compatible RIS-style + * full reset โ€” user code calls `term.reset()` to wipe state. + * + * Throws when the wasm terminal was injected via ITerminalOptions.wasmTerm: + * reset() would have to free a buffer the caller owns, which silently + * breaks the ownership contract. Callers wanting to reset an externally + * owned wasmTerm should free() + createTerminal() + construct a new + * Terminal wrapper themselves. */ reset(): void { this.assertOpen(); + if (!this.ownsWasmTerm) { + throw new Error( + 'Terminal.reset() is not supported when a wasmTerm was injected via ' + + 'ITerminalOptions.wasmTerm. The caller owns the wasmTerm lifecycle.' + ); + } + // Free old WASM terminal and create new one if (this.wasmTerm) { this.wasmTerm.free(); @@ -1276,9 +1317,13 @@ export class Terminal implements ITerminalCore { this.linkDetector = undefined; } - // Free WASM terminal + // Free WASM terminal โ€” only if we own it. Injected wasmTerms + // (ITerminalOptions.wasmTerm) belong to the caller; they keep them alive + // across view lifetimes and are responsible for calling free() themselves. if (this.wasmTerm) { - this.wasmTerm.free(); + if (this.ownsWasmTerm) { + this.wasmTerm.free(); + } this.wasmTerm = undefined; } From 190ebe7b994f3101f55282ea68d6a472cfcc6b7b Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Mon, 20 Apr 2026 20:44:52 -0700 Subject: [PATCH 06/17] refactor(input): route every keydown through the Ghostty encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the "printable character" and "simple special keys" fast paths from InputHandler.handleKeyDown and routes every keydown through the Ghostty WASM key encoder. The old fast paths were a simplified model that diverged from both xterm.js and Ghostty's encoder for several keys: - Home and End ignored DECCKM (application cursor mode). xterm.js emits \x1b[H in normal mode and \x1bOH in application mode; the fast path emitted \x1b[H always. - Shift+Home / Shift+End / Shift+PageUp / Shift+PageDown / Shift+F1..F12 dropped the Shift modifier. xterm.js encodes it into the CSI sequence (e.g. \x1b[1;2H for Shift+Home); the fast path emitted the plain unmodified sequence. - Non-BMP characters (surrogate-pair emoji) were dropped entirely because of a length === 1 filter in the fallback utf8 path. Routing through the encoder brings ghostty-web into line with xterm.js on these. It also picks up three behaviors that xterm.js doesn't implement: - Shift+Enter distinguishable from Enter (\x1b[27;2;13~ instead of bare \r). This is a deliberate divergence from xterm.js, which emits \r for both. Modern line editors and REPLs use fixterms-style encoding and expect Shift+Enter to be distinguishable for multi-line input. - Kitty keyboard protocol flags affect every key when enabled. - xterm modifyOtherKeys state 2 affects every key when enabled. Consumers who need byte-for-byte xterm.js behavior on specific keys can intercept in attachCustomKeyEventHandler and emit the desired bytes via Terminal.input(data, /*wasUserInput*/ true). README.md's "Keyboard encoding" note shows the pattern. Note: preventDefault / stopPropagation fire on any key mapKeyCode recognizes, before the encode attempt โ€” so a failed or empty encode drops the keystroke silently rather than letting browser defaults fire (F11 fullscreen, Ctrl+W close tab, etc.). Deliberate divergence from native Ghostty's keyCallback policy of returning .ignored on empty encode; documented in a code comment. Co-Authored-By: Claude Opus 4 (1M context) --- README.md | 19 ++++ lib/input-handler.test.ts | 75 +++++++++++++ lib/input-handler.ts | 221 ++++++++++++-------------------------- 3 files changed, 165 insertions(+), 150 deletions(-) diff --git a/README.md b/README.md index 4c6f1fa..0b30e18 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,25 @@ xterm.js is everywhereโ€”VS Code, Hyper, countless web terminals. But it has fun xterm.js reimplements terminal emulation in JavaScript. Every escape sequence, every edge case, every Unicode quirkโ€”all hand-coded. Ghostty's emulator is the same battle-tested code that runs the native Ghostty app. +### Keyboard encoding + +Keyboard input is encoded by Ghostty's key encoder. Byte sequences largely match xterm.js's defaults โ€” Home/End honor DECCKM, Shift+nav and Shift+F-keys preserve the Shift modifier in the emitted CSI sequence, non-BMP characters pass through, Arrow keys honor cursor-application mode. Two deliberate differences: + +- **Shift+Enter is distinguishable from Enter** (emitted as `\x1b[27;2;13~` rather than bare `\r`, following fixterms), so modern line editors and REPLs can treat Shift+Enter as a newline-without-submit. +- **Kitty keyboard protocol and xterm modifyOtherKeys state 2 are supported** when an app enables them. xterm.js implements only the traditional escape sequences. + +If you need byte-for-byte xterm.js behavior for a specific key (e.g. Shift+Enter mapped to `\r` for tools that don't understand the fixterms sequence), intercept it in `attachCustomKeyEventHandler` and emit the bytes you want via `term.input(bytes, true)`: + +```ts +term.attachCustomKeyEventHandler((e) => { + if (e.key === 'Enter' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) { + term.input('\r', true); // fires onData with '\r' + return true; // suppress the default encoder path + } + return false; +}); +``` + ## Installation ```bash diff --git a/lib/input-handler.test.ts b/lib/input-handler.test.ts index f64e1da..5aab073 100644 --- a/lib/input-handler.test.ts +++ b/lib/input-handler.test.ts @@ -742,6 +742,38 @@ describe('InputHandler', () => { expect(dataReceived[2]).toBe('\x1bOD'); expect(dataReceived[3]).toBe('\x1bOC'); }); + + // The per-keystroke encoder-option sync caches the last value and + // short-circuits when unchanged. This test makes sure mode *changes* + // do propagate โ€” if the cache fails to invalidate, the second + // keystroke would emit the wrong sequence. + test('picks up DECCKM changes mid-session', () => { + let cursorApp = false; + const handler = new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + undefined, + (mode: number) => mode === 1 && cursorApp + ); + + simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp')); + expect(dataReceived[0]).toBe('\x1b[A'); + dataReceived.length = 0; + + cursorApp = true; + simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp')); + expect(dataReceived[0]).toBe('\x1bOA'); + dataReceived.length = 0; + + cursorApp = false; + simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp')); + expect(dataReceived[0]).toBe('\x1b[A'); + }); }); describe('Function Keys', () => { @@ -1192,4 +1224,47 @@ describe('InputHandler', () => { expect(dataReceived.length).toBe(0); }); }); + + // Regression tests for the encoder-bypass removal. Two representative + // cases cover the two distinct code paths the old fast paths poisoned: + // + // 1. Shift+Enter โ€” modifiers reach the encoder (the original bug class + // that caught Shift+Home, Shift+F1, etc.; one test is enough). + // 2. Surrogate-pair emoji โ€” multi-code-unit utf8 passes through + // (covers both non-ASCII and non-BMP in one shot). + describe('Regression: encoder bypass removal', () => { + test('Shift+Enter differs from plain Enter', () => { + const handler = new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + } + ); + + simulateKey(container, createKeyEvent('Enter', 'Enter')); + expect(dataReceived[0]).toBe('\r'); + + dataReceived.length = 0; + simulateKey(container, createKeyEvent('Enter', 'Enter', { shift: true })); + expect(dataReceived.length).toBe(1); + // Ghostty emits the modifyOtherKeys sequence for Shift+Enter by default. + expect(dataReceived[0]).toBe('\x1b[27;2;13~'); + }); + + test('surrogate-pair emoji is emitted as UTF-8', () => { + const handler = new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + } + ); + + simulateKey(container, createKeyEvent('KeyA', '๐Ÿ˜€')); + expect(dataReceived).toEqual(['๐Ÿ˜€']); + }); + }); }); diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 83d6f3f..4876189 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -207,6 +207,15 @@ export class InputHandler { private lastBeforeInputData: string | null = null; private lastBeforeInputTime = 0; private static readonly BEFORE_INPUT_IGNORE_MS = 100; + // Cache of encoder option values last pushed to the WASM encoder, so + // keystroke handling can skip the setOption WASM round-trip when nothing + // changed. `undefined` means "never synced"; any first query on a new + // handler will emit one setOption per option regardless of mode state. + private syncedEncoderOptions = new Map(); + // Reused across keystrokes to avoid the TextDecoder allocation per call. + // Once #8 merges and we migrate to encoder.encodeToString, this field + // goes away. + private decoder = new TextDecoder(); /** * Create a new InputHandler @@ -320,6 +329,17 @@ export class InputHandler { return KEY_MAP[code] ?? null; } + /** + * Push an encoder option value to WASM only if it differs from the last + * value we pushed. Terminal modes rarely change between keystrokes, so + * this saves two WASM round-trips per keystroke in the steady state. + */ + private syncEncoderOption(option: KeyEncoderOption, value: boolean | number): void { + if (this.syncedEncoderOptions.get(option) === value) return; + this.encoder.setOption(option, value); + this.syncedEncoderOptions.set(option, value); + } + /** * Extract modifier flags from KeyboardEvent * @param event - KeyboardEvent @@ -340,22 +360,6 @@ export class InputHandler { return mods; } - /** - * Check if this is a printable character with no special modifiers - * @param event - KeyboardEvent - * @returns true if printable character - */ - private isPrintableCharacter(event: KeyboardEvent): boolean { - // If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character - // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters - if (event.ctrlKey && !event.altKey) return false; - if (event.altKey && !event.ctrlKey) return false; - if (event.metaKey) return false; // Cmd key on Mac - - // If key produces a single printable character - return event.key.length === 1; - } - /** * Handle keydown event * @param event - KeyboardEvent @@ -402,156 +406,73 @@ export class InputHandler { return; } - // For printable characters without modifiers, send the character directly - // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc. - if (this.isPrintableCharacter(event)) { - event.preventDefault(); - this.onDataCallback(event.key); - this.recordKeyDownData(event.key); - return; - } - - // Map the physical key code + // Map the physical key code. Events with no corresponding Ghostty Key + // (media keys, etc.) are dropped silently. const key = this.mapKeyCode(event.code); - if (key === null) { - // Unknown key - ignore it - return; - } + if (key === null) return; - // Extract modifiers const mods = this.extractModifiers(event); - // Handle simple special keys that produce standard sequences - if (mods === Mods.NONE || mods === Mods.SHIFT) { - let simpleOutput: string | null = null; - - switch (key) { - case Key.ENTER: - simpleOutput = '\r'; // Carriage return - break; - case Key.TAB: - if (mods === Mods.SHIFT) { - simpleOutput = '\x1b[Z'; // Backtab - } else { - simpleOutput = '\t'; // Tab - } - break; - case Key.BACKSPACE: - simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace) - break; - case Key.ESCAPE: - simpleOutput = '\x1B'; // ESC - break; - // Arrow keys are handled by the encoder (respects application cursor mode) - // Navigation keys - case Key.HOME: - simpleOutput = '\x1B[H'; - break; - case Key.END: - simpleOutput = '\x1B[F'; - break; - case Key.INSERT: - simpleOutput = '\x1B[2~'; - break; - case Key.DELETE: - simpleOutput = '\x1B[3~'; - break; - case Key.PAGE_UP: - simpleOutput = '\x1B[5~'; - break; - case Key.PAGE_DOWN: - simpleOutput = '\x1B[6~'; - break; - // Function keys - case Key.F1: - simpleOutput = '\x1BOP'; - break; - case Key.F2: - simpleOutput = '\x1BOQ'; - break; - case Key.F3: - simpleOutput = '\x1BOR'; - break; - case Key.F4: - simpleOutput = '\x1BOS'; - break; - case Key.F5: - simpleOutput = '\x1B[15~'; - break; - case Key.F6: - simpleOutput = '\x1B[17~'; - break; - case Key.F7: - simpleOutput = '\x1B[18~'; - break; - case Key.F8: - simpleOutput = '\x1B[19~'; - break; - case Key.F9: - simpleOutput = '\x1B[20~'; - break; - case Key.F10: - simpleOutput = '\x1B[21~'; - break; - case Key.F11: - simpleOutput = '\x1B[23~'; - break; - case Key.F12: - simpleOutput = '\x1B[24~'; - break; - } + // Pass event.key as utf8 when it is a single Unicode scalar (a printable + // character, including non-ASCII and surrogate-pair emoji). Named keys + // like "Enter", "ArrowUp", "F1", "Dead" are longer strings and produce + // undefined here, so the encoder relies on the logical key alone. + // + // Case is preserved intentionally: the encoder uses the utf8 byte to + // pick the C0 sequence for Ctrl+letter, and needs the actual shifted + // character for the text-output path. + let utf8: string | undefined; + if (event.key.length > 0 && event.key !== 'Dead' && event.key !== 'Unidentified') { + const cp = event.key.codePointAt(0); + const scalarLen = cp !== undefined && cp > 0xffff ? 2 : 1; + if (event.key.length === scalarLen) utf8 = event.key; + } - if (simpleOutput !== null) { - event.preventDefault(); - this.onDataCallback(simpleOutput); - this.recordKeyDownData(simpleOutput); - return; - } + // Sync encoder options with terminal mode state before every encode. + // DEC mode 1 (DECCKM) โ†’ cursor-key application mode. + // DEC mode 66 (DECNKM) โ†’ keypad application mode. + // syncEncoderOption skips the WASM round-trip when the value hasn't + // changed since last keystroke, which is the common case. + if (this.getModeCallback) { + this.syncEncoderOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, this.getModeCallback(1)); + this.syncEncoderOption(KeyEncoderOption.KEYPAD_KEY_APPLICATION, this.getModeCallback(66)); } - // Determine action (we only care about PRESS for now, not RELEASE or REPEAT) - const action = KeyAction.PRESS; + // mapKeyCode succeeded โ†’ we own this key. Prevent browser default + // (search shortcuts, F11 fullscreen, Ctrl+W close tab, etc.) before + // attempting to encode, so a failed or empty encode drops the + // keystroke silently rather than letting it trigger a browser action. + // + // This is a deliberate divergence from native Ghostty, which returns + // `.ignored` from keyCallback when the encoder produces no output and + // lets the apprt decide whether to propagate the key (Surface.zig + // around line 2670). In a native context that lets OS-level shortcuts + // and apprt keybinds run; in a browser context "ignored" would mean + // the browser fires its own default action with no intermediate layer + // to filter, which is rarely what users typing into a terminal want. + // Empty-encode mapped keys are also rare in our path: mapKeyCode + // already filters unmapped keys, and most mapped keys produce non- + // empty encodings in default mode. + event.preventDefault(); + event.stopPropagation(); - // For non-printable keys or keys with modifiers, encode using Ghostty + let data: string; try { - // Sync encoder options with terminal mode state - // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences - if (this.getModeCallback) { - const appCursorMode = this.getModeCallback(1); - this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode); - } - - // For letter/number keys, even with modifiers, pass the base character - // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) - // For special keys (Enter, Arrow keys, etc.), don't pass utf8 - const utf8 = - event.key.length === 1 && event.key.charCodeAt(0) < 128 - ? event.key.toLowerCase() // Use lowercase for consistency - : undefined; - const encoded = this.encoder.encode({ - action, + action: KeyAction.PRESS, key, mods, utf8, }); - - // Convert Uint8Array to string - const decoder = new TextDecoder(); - const data = decoder.decode(encoded); - - // Prevent default browser behavior - event.preventDefault(); - event.stopPropagation(); - - // Emit the data - if (data.length > 0) { - this.onDataCallback(data); - this.recordKeyDownData(data); - } + data = encoded.length === 0 ? '' : this.decoder.decode(encoded); } catch (error) { - // Encoding failed - log but don't crash console.warn('Failed to encode key:', event.code, error); + return; + } + + if (data.length > 0) { + this.onDataCallback(data); + this.recordKeyDownData(data); } } From ace716fe4d8a0a14651aca933ef27185bcdf435b Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 11:21:34 -0700 Subject: [PATCH 07/17] fix(renderer): correct font metrics + procedural box-drawing for U+2500..U+259F MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell metrics: - Width: use the font's natural advance (no Math.ceil) so glyphs designed to tile across cells don't leave seams. Canvas backing-store dimensions are rounded only when sizing the canvas. - Height: take max(fontBoundingBox*, actualBoundingBox*) for ascent and descent independently from a probe string with descenders ('Mgjpqyโ”‚'), then ceil. Canvas2D has no API for "design metrics excluding leading," so combining both metric families is the most reliable way to fit every glyph regardless of which source a given font under-reports. Box drawing (U+2500..U+257F) and block elements (U+2580..U+259F) are now drawn as canvas paths instead of through the font. The font path left visible gaps because: - the font's advance often differs from our cell width, - the cell height we need for descender safety rarely matches the proportions a font designer chose for U+2502 / U+2588. Procedural rendering is the standard approach in Alacritty, kitty, wezterm, Ghostty native, and Windows Terminal; this implementation ports the algorithms from Ghostty's box.zig: - junction-aware arm endpoints (up_bottom, down_top, left_right, right_left) so heavy crossbars cover light arms cleanly and double lines form proper inner-L corners (โ•”โ•—โ•šโ•โ• โ•ฃโ•ฆโ•ฉโ•ฌ, all heavy/light mixed junctions โ”กโ”นโ•†โ•ƒ etc.), - quarter-circle arcs (โ•ญโ•ฎโ•ฏโ•ฐ) as cubic Beziers reaching the cell-edge midpoint so they join flush to neighboring straight cells, - dashed/dotted lines (โ”„โ”…โ”ˆโ”‰โ”†โ”‡โ”Šโ”‹โ•Œโ•โ•Žโ•) with integer-pixel gap distribution so multi-cell dashed runs tile cleanly, - diagonals (โ•ฑโ•ฒโ•ณ) with sub-pixel overshoot so anti-aliasing covers cell corners exactly, - heavy = 2 ร— light thickness, double = three light strokes wide with a one-light gap between the parallels. Code is split into lib/box-drawing/{index,common,blocks,lines}.ts, mirroring Ghostty's box.zig / block.zig / common.zig layout. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing/blocks.ts | 137 +++++++++ lib/box-drawing/common.ts | 24 ++ lib/box-drawing/index.ts | 53 ++++ lib/box-drawing/lines.ts | 595 ++++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 115 ++++++-- 5 files changed, 897 insertions(+), 27 deletions(-) create mode 100644 lib/box-drawing/blocks.ts create mode 100644 lib/box-drawing/common.ts create mode 100644 lib/box-drawing/index.ts create mode 100644 lib/box-drawing/lines.ts diff --git a/lib/box-drawing/blocks.ts b/lib/box-drawing/blocks.ts new file mode 100644 index 0000000..11f0a2d --- /dev/null +++ b/lib/box-drawing/blocks.ts @@ -0,0 +1,137 @@ +/** + * Block element renderer (U+2580..U+259F). + * + * These glyphs are pure rectangles (eighths, halves, quadrants) plus three + * shading levels. Each cell is filled with one or two `fillRect` calls + * sized to the cell, so adjacent block-element cells tile seamlessly. + */ + +/** + * Draw a U+2580..U+259F glyph into the cell at (x, y, w, h). + * Returns true if the codepoint was handled. + */ +export function drawBlockElement( + ctx: CanvasRenderingContext2D, + cp: number, + x: number, + y: number, + w: number, + h: number, + color: string +): boolean { + ctx.fillStyle = color; + switch (cp) { + case 0x2580: // โ–€ upper half + ctx.fillRect(x, y, w, h / 2); + return true; + case 0x2581: // โ– lower one eighth + ctx.fillRect(x, y + (h * 7) / 8, w, h / 8); + return true; + case 0x2582: // โ–‚ lower one quarter + ctx.fillRect(x, y + (h * 3) / 4, w, h / 4); + return true; + case 0x2583: // โ–ƒ lower three eighths + ctx.fillRect(x, y + (h * 5) / 8, w, (h * 3) / 8); + return true; + case 0x2584: // โ–„ lower half + ctx.fillRect(x, y + h / 2, w, h / 2); + return true; + case 0x2585: // โ–… lower five eighths + ctx.fillRect(x, y + (h * 3) / 8, w, (h * 5) / 8); + return true; + case 0x2586: // โ–† lower three quarters + ctx.fillRect(x, y + h / 4, w, (h * 3) / 4); + return true; + case 0x2587: // โ–‡ lower seven eighths + ctx.fillRect(x, y + h / 8, w, (h * 7) / 8); + return true; + case 0x2588: // โ–ˆ full block + ctx.fillRect(x, y, w, h); + return true; + case 0x2589: // โ–‰ left seven eighths + ctx.fillRect(x, y, (w * 7) / 8, h); + return true; + case 0x258a: // โ–Š left three quarters + ctx.fillRect(x, y, (w * 3) / 4, h); + return true; + case 0x258b: // โ–‹ left five eighths + ctx.fillRect(x, y, (w * 5) / 8, h); + return true; + case 0x258c: // โ–Œ left half + ctx.fillRect(x, y, w / 2, h); + return true; + case 0x258d: // โ– left three eighths + ctx.fillRect(x, y, (w * 3) / 8, h); + return true; + case 0x258e: // โ–Ž left one quarter + ctx.fillRect(x, y, w / 4, h); + return true; + case 0x258f: // โ– left one eighth + ctx.fillRect(x, y, w / 8, h); + return true; + case 0x2590: // โ– right half + ctx.fillRect(x + w / 2, y, w / 2, h); + return true; + case 0x2591: // โ–‘ light shade + ctx.save(); + ctx.globalAlpha *= 0.25; + ctx.fillRect(x, y, w, h); + ctx.restore(); + return true; + case 0x2592: // โ–’ medium shade + ctx.save(); + ctx.globalAlpha *= 0.5; + ctx.fillRect(x, y, w, h); + ctx.restore(); + return true; + case 0x2593: // โ–“ dark shade + ctx.save(); + ctx.globalAlpha *= 0.75; + ctx.fillRect(x, y, w, h); + ctx.restore(); + return true; + case 0x2594: // โ–” upper one eighth + ctx.fillRect(x, y, w, h / 8); + return true; + case 0x2595: // โ–• right one eighth + ctx.fillRect(x + (w * 7) / 8, y, w / 8, h); + return true; + case 0x2596: // โ–– quadrant lower left + ctx.fillRect(x, y + h / 2, w / 2, h / 2); + return true; + case 0x2597: // โ–— quadrant lower right + ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + return true; + case 0x2598: // โ–˜ quadrant upper left + ctx.fillRect(x, y, w / 2, h / 2); + return true; + case 0x2599: // โ–™ quadrant upper-left + lower-left + lower-right + ctx.fillRect(x, y, w / 2, h); + ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + return true; + case 0x259a: // โ–š quadrant upper-left + lower-right + ctx.fillRect(x, y, w / 2, h / 2); + ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + return true; + case 0x259b: // โ–› upper-left + upper-right + lower-left + ctx.fillRect(x, y, w / 2, h); + ctx.fillRect(x + w / 2, y, w / 2, h / 2); + return true; + case 0x259c: // โ–œ upper-left + upper-right + lower-right + ctx.fillRect(x, y, w, h / 2); + ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + return true; + case 0x259d: // โ– quadrant upper right + ctx.fillRect(x + w / 2, y, w / 2, h / 2); + return true; + case 0x259e: // โ–ž upper-right + lower-left + ctx.fillRect(x + w / 2, y, w / 2, h / 2); + ctx.fillRect(x, y + h / 2, w / 2, h / 2); + return true; + case 0x259f: // โ–Ÿ upper-right + lower-left + lower-right + ctx.fillRect(x + w / 2, y, w / 2, h); + ctx.fillRect(x, y + h / 2, w / 2, h / 2); + return true; + } + return false; +} diff --git a/lib/box-drawing/common.ts b/lib/box-drawing/common.ts new file mode 100644 index 0000000..4e0c7e6 --- /dev/null +++ b/lib/box-drawing/common.ts @@ -0,0 +1,24 @@ +/** + * Shared types and helpers for box-drawing and block-element rendering. + * + * The thickness model matches Ghostty's `Thickness` enum: a base "light" + * thickness derived from the cell size, with heavy = 2 ร— light. Double + * lines are two parallel light strokes separated by a gap of one light + * thickness (so a double stroke spans 3 ร— light total). + */ + +// Edge weight: none, light (single thin line), heavy (single thick line), +// or double (two parallel thin lines with a 1-light gap between them). +export type Weight = 0 | 1 | 2 | 3; +export const N: Weight = 0; +export const L: Weight = 1; +export const H: Weight = 2; +export const D: Weight = 3; + +export function lightThickness(h: number): number { + return Math.max(1, Math.round(h * 0.07)); +} + +export function heavyThickness(h: number): number { + return lightThickness(h) * 2; +} diff --git a/lib/box-drawing/index.ts b/lib/box-drawing/index.ts new file mode 100644 index 0000000..958508f --- /dev/null +++ b/lib/box-drawing/index.ts @@ -0,0 +1,53 @@ +/** + * Box-drawing and Block-element renderer. + * + * Glyphs in U+2500..U+259F (box drawing + block elements) are designed to + * tile seamlessly across cells. Letting the font render them is fragile: + * - The font's advance width may not match our cell width exactly, + * leaving gaps (or overlaps) between adjacent box-drawing chars. + * - The font's chosen ascent/descent for these glyphs rarely matches + * the cell height we need for descender-safe text rendering, so + * vertical lines and full blocks leave gaps between rows. + * - Different fonts encode these glyphs with different proportions, so + * visual consistency is poor. + * + * Drawing them as canvas paths sized to the cell is the standard fix โ€” + * Alacritty, kitty, wezterm, Ghostty native, and Windows Terminal all do + * this. It keeps the cell-height choice decoupled from the font's + * box-drawing glyph design. + */ + +import { drawBlockElement } from './blocks'; +import { drawBoxLine } from './lines'; + +/** + * Returns true if the codepoint is a box-drawing or block-element glyph + * that we render directly. Caller should skip the font path in that case. + */ +export function isBoxOrBlock(codepoint: number): boolean { + return codepoint >= 0x2500 && codepoint <= 0x259f; +} + +/** + * Render a box-drawing or block-element glyph into the cell at (x, y, w, h). + * `color` is the css color string used for the foreground stroke/fill. + * Returns true if the glyph was handled; false if the caller should fall + * back to font rendering. + */ +export function drawBoxOrBlock( + ctx: CanvasRenderingContext2D, + codepoint: number, + x: number, + y: number, + w: number, + h: number, + color: string +): boolean { + if (codepoint >= 0x2580 && codepoint <= 0x259f) { + return drawBlockElement(ctx, codepoint, x, y, w, h, color); + } + if (codepoint >= 0x2500 && codepoint <= 0x257f) { + return drawBoxLine(ctx, codepoint, x, y, w, h, color); + } + return false; +} diff --git a/lib/box-drawing/lines.ts b/lib/box-drawing/lines.ts new file mode 100644 index 0000000..319385f --- /dev/null +++ b/lib/box-drawing/lines.ts @@ -0,0 +1,595 @@ +/** + * Box drawing line renderer (U+2500..U+257F). + * + * Covers four sub-families: + * - Orthogonal lines, corners, T-junctions, crosses, stubs, double-line + * variants โ€” described by directional weights in the EDGES table and + * drawn by `drawEdges`. + * - Quarter-circle arcs (โ•ญโ•ฎโ•ฏโ•ฐ) โ€” drawn as cubic Bezier curves so they + * join cleanly to neighboring straight cells. + * - Dashed/dotted horizontal & vertical lines โ€” drawn with integer-pixel + * gap distribution so they tile cleanly across cells of any width. + * - Diagonals (โ•ฑโ•ฒโ•ณ) โ€” drawn with sub-pixel overshoot so the diagonal + * reaches the cell corner exactly under anti-aliasing. + * + * Ports the algorithm from Ghostty native's `box.zig:linesChar`. The + * junction-aware arm endpoints (`up_bottom`, `down_top`, `left_right`, + * `right_left`) are what make weighted corners and T-junctions look + * correct: a heavy crossbar fully covers a light arm, a double-line + * corner forms a clean inner "L" instead of two crossing parallels, etc. + */ + +import { D, H, L, N, heavyThickness, lightThickness } from './common'; +import type { Weight } from './common'; + +interface Edges { + l: Weight; + r: Weight; + u: Weight; + d: Weight; +} + +// Codepoint โ†’ directional weights for the orthogonal box-drawing glyphs. +// biome-ignore format: aligned table is more readable than reflowed +const EDGES = new Map([ + [0x2500, { l: L, r: L, u: N, d: N }], // โ”€ + [0x2501, { l: H, r: H, u: N, d: N }], // โ” + [0x2502, { l: N, r: N, u: L, d: L }], // โ”‚ + [0x2503, { l: N, r: N, u: H, d: H }], // โ”ƒ + [0x250c, { l: N, r: L, u: N, d: L }], // โ”Œ + [0x250d, { l: N, r: H, u: N, d: L }], // โ” + [0x250e, { l: N, r: L, u: N, d: H }], // โ”Ž + [0x250f, { l: N, r: H, u: N, d: H }], // โ” + [0x2510, { l: L, r: N, u: N, d: L }], // โ” + [0x2511, { l: H, r: N, u: N, d: L }], // โ”‘ + [0x2512, { l: L, r: N, u: N, d: H }], // โ”’ + [0x2513, { l: H, r: N, u: N, d: H }], // โ”“ + [0x2514, { l: N, r: L, u: L, d: N }], // โ”” + [0x2515, { l: N, r: H, u: L, d: N }], // โ”• + [0x2516, { l: N, r: L, u: H, d: N }], // โ”– + [0x2517, { l: N, r: H, u: H, d: N }], // โ”— + [0x2518, { l: L, r: N, u: L, d: N }], // โ”˜ + [0x2519, { l: H, r: N, u: L, d: N }], // โ”™ + [0x251a, { l: L, r: N, u: H, d: N }], // โ”š + [0x251b, { l: H, r: N, u: H, d: N }], // โ”› + [0x251c, { l: N, r: L, u: L, d: L }], // โ”œ + [0x251d, { l: N, r: H, u: L, d: L }], // โ” + [0x251e, { l: N, r: L, u: H, d: L }], // โ”ž + [0x251f, { l: N, r: L, u: L, d: H }], // โ”Ÿ + [0x2520, { l: N, r: L, u: H, d: H }], // โ”  + [0x2521, { l: N, r: H, u: H, d: L }], // โ”ก + [0x2522, { l: N, r: H, u: L, d: H }], // โ”ข + [0x2523, { l: N, r: H, u: H, d: H }], // โ”ฃ + [0x2524, { l: L, r: N, u: L, d: L }], // โ”ค + [0x2525, { l: H, r: N, u: L, d: L }], // โ”ฅ + [0x2526, { l: L, r: N, u: H, d: L }], // โ”ฆ + [0x2527, { l: L, r: N, u: L, d: H }], // โ”ง + [0x2528, { l: L, r: N, u: H, d: H }], // โ”จ + [0x2529, { l: H, r: N, u: H, d: L }], // โ”ฉ + [0x252a, { l: H, r: N, u: L, d: H }], // โ”ช + [0x252b, { l: H, r: N, u: H, d: H }], // โ”ซ + [0x252c, { l: L, r: L, u: N, d: L }], // โ”ฌ + [0x252d, { l: H, r: L, u: N, d: L }], // โ”ญ + [0x252e, { l: L, r: H, u: N, d: L }], // โ”ฎ + [0x252f, { l: H, r: H, u: N, d: L }], // โ”ฏ + [0x2530, { l: L, r: L, u: N, d: H }], // โ”ฐ + [0x2531, { l: H, r: L, u: N, d: H }], // โ”ฑ + [0x2532, { l: L, r: H, u: N, d: H }], // โ”ฒ + [0x2533, { l: H, r: H, u: N, d: H }], // โ”ณ + [0x2534, { l: L, r: L, u: L, d: N }], // โ”ด + [0x2535, { l: H, r: L, u: L, d: N }], // โ”ต + [0x2536, { l: L, r: H, u: L, d: N }], // โ”ถ + [0x2537, { l: H, r: H, u: L, d: N }], // โ”ท + [0x2538, { l: L, r: L, u: H, d: N }], // โ”ธ + [0x2539, { l: H, r: L, u: H, d: N }], // โ”น + [0x253a, { l: L, r: H, u: H, d: N }], // โ”บ + [0x253b, { l: H, r: H, u: H, d: N }], // โ”ป + [0x253c, { l: L, r: L, u: L, d: L }], // โ”ผ + [0x253d, { l: H, r: L, u: L, d: L }], // โ”ฝ + [0x253e, { l: L, r: H, u: L, d: L }], // โ”พ + [0x253f, { l: H, r: H, u: L, d: L }], // โ”ฟ + [0x2540, { l: L, r: L, u: H, d: L }], // โ•€ + [0x2541, { l: L, r: L, u: L, d: H }], // โ• + [0x2542, { l: L, r: L, u: H, d: H }], // โ•‚ + [0x2543, { l: H, r: L, u: H, d: L }], // โ•ƒ + [0x2544, { l: L, r: H, u: H, d: L }], // โ•„ + [0x2545, { l: H, r: L, u: L, d: H }], // โ•… + [0x2546, { l: L, r: H, u: L, d: H }], // โ•† + [0x2547, { l: H, r: H, u: H, d: L }], // โ•‡ + [0x2548, { l: H, r: H, u: L, d: H }], // โ•ˆ + [0x2549, { l: H, r: L, u: H, d: H }], // โ•‰ + [0x254a, { l: L, r: H, u: H, d: H }], // โ•Š + [0x254b, { l: H, r: H, u: H, d: H }], // โ•‹ + [0x2550, { l: D, r: D, u: N, d: N }], // โ• + [0x2551, { l: N, r: N, u: D, d: D }], // โ•‘ + [0x2552, { l: N, r: D, u: N, d: L }], // โ•’ + [0x2553, { l: N, r: L, u: N, d: D }], // โ•“ + [0x2554, { l: N, r: D, u: N, d: D }], // โ•” + [0x2555, { l: D, r: N, u: N, d: L }], // โ•• + [0x2556, { l: L, r: N, u: N, d: D }], // โ•– + [0x2557, { l: D, r: N, u: N, d: D }], // โ•— + [0x2558, { l: N, r: D, u: L, d: N }], // โ•˜ + [0x2559, { l: N, r: L, u: D, d: N }], // โ•™ + [0x255a, { l: N, r: D, u: D, d: N }], // โ•š + [0x255b, { l: D, r: N, u: L, d: N }], // โ•› + [0x255c, { l: L, r: N, u: D, d: N }], // โ•œ + [0x255d, { l: D, r: N, u: D, d: N }], // โ• + [0x255e, { l: N, r: D, u: L, d: L }], // โ•ž + [0x255f, { l: N, r: L, u: D, d: D }], // โ•Ÿ + [0x2560, { l: N, r: D, u: D, d: D }], // โ•  + [0x2561, { l: D, r: N, u: L, d: L }], // โ•ก + [0x2562, { l: L, r: N, u: D, d: D }], // โ•ข + [0x2563, { l: D, r: N, u: D, d: D }], // โ•ฃ + [0x2564, { l: D, r: D, u: N, d: L }], // โ•ค + [0x2565, { l: L, r: L, u: N, d: D }], // โ•ฅ + [0x2566, { l: D, r: D, u: N, d: D }], // โ•ฆ + [0x2567, { l: D, r: D, u: L, d: N }], // โ•ง + [0x2568, { l: L, r: L, u: D, d: N }], // โ•จ + [0x2569, { l: D, r: D, u: D, d: N }], // โ•ฉ + [0x256a, { l: D, r: D, u: L, d: L }], // โ•ช + [0x256b, { l: L, r: L, u: D, d: D }], // โ•ซ + [0x256c, { l: D, r: D, u: D, d: D }], // โ•ฌ + [0x2574, { l: L, r: N, u: N, d: N }], // โ•ด + [0x2575, { l: N, r: N, u: L, d: N }], // โ•ต + [0x2576, { l: N, r: L, u: N, d: N }], // โ•ถ + [0x2577, { l: N, r: N, u: N, d: L }], // โ•ท + [0x2578, { l: H, r: N, u: N, d: N }], // โ•ธ + [0x2579, { l: N, r: N, u: H, d: N }], // โ•น + [0x257a, { l: N, r: H, u: N, d: N }], // โ•บ + [0x257b, { l: N, r: N, u: N, d: H }], // โ•ป + [0x257c, { l: L, r: H, u: N, d: N }], // โ•ผ + [0x257d, { l: N, r: N, u: L, d: H }], // โ•ฝ + [0x257e, { l: H, r: L, u: N, d: N }], // โ•พ + [0x257f, { l: N, r: N, u: H, d: L }], // โ•ฟ +]); + +// Dashed lines: number of dashes per stroke, weight, and orientation. +interface Dashed { + count: 2 | 3 | 4; + weight: Weight; + vertical: boolean; +} + +// biome-ignore format: aligned table +const DASHED = new Map([ + [0x2504, { count: 3, weight: L, vertical: false }], // โ”„ + [0x2505, { count: 3, weight: H, vertical: false }], // โ”… + [0x2506, { count: 3, weight: L, vertical: true }], // โ”† + [0x2507, { count: 3, weight: H, vertical: true }], // โ”‡ + [0x2508, { count: 4, weight: L, vertical: false }], // โ”ˆ + [0x2509, { count: 4, weight: H, vertical: false }], // โ”‰ + [0x250a, { count: 4, weight: L, vertical: true }], // โ”Š + [0x250b, { count: 4, weight: H, vertical: true }], // โ”‹ + [0x254c, { count: 2, weight: L, vertical: false }], // โ•Œ + [0x254d, { count: 2, weight: H, vertical: false }], // โ• + [0x254e, { count: 2, weight: L, vertical: true }], // โ•Ž + [0x254f, { count: 2, weight: H, vertical: true }], // โ• +]); + +// Quarter-circle arcs. Corner identifies which quadrant of the cell +// holds the arc, matching Ghostty's `Corner` enum. +type Corner = 'tl' | 'tr' | 'bl' | 'br'; + +const ARC = new Map([ + // โ•ญ down and right: arc lives in the BOTTOM-RIGHT quadrant, connecting + // a stroke going DOWN out of the cell to a stroke going RIGHT. + [0x256d, 'br'], + [0x256e, 'bl'], // โ•ฎ down-left + [0x256f, 'tl'], // โ•ฏ up-left + [0x2570, 'tr'], // โ•ฐ up-right +]); + +/** + * Draw a U+2500..U+257F glyph into the cell at (x, y, w, h). + * Returns true if the codepoint was handled. + */ +export function drawBoxLine( + ctx: CanvasRenderingContext2D, + cp: number, + x: number, + y: number, + w: number, + h: number, + color: string +): boolean { + // Diagonals. + if (cp === 0x2571 || cp === 0x2572 || cp === 0x2573) { + drawDiagonal(ctx, cp, x, y, w, h, color); + return true; + } + + const arc = ARC.get(cp); + if (arc !== undefined) { + drawArc(ctx, arc, x, y, w, h, color); + return true; + } + + const dash = DASHED.get(cp); + if (dash !== undefined) { + drawDashed(ctx, dash, x, y, w, h, color); + return true; + } + + const e = EDGES.get(cp); + if (e === undefined) return false; + + drawEdges(ctx, e, x, y, w, h, color); + return true; +} + +// ---------------------------------------------------------------------------- +// Orthogonal box drawing +// +// Ports Ghostty's `box.zig:linesChar` (lines 399-636). Each of the four +// arms (up/right/down/left) is drawn as one or two rectangles. The key +// detail is the junction-aware endpoint of each arm: +// +// - `up_bottom` is how far DOWN from the cell top the up-arm extends. +// It's the bottom of the up rectangle. +// - `down_top` is how far DOWN the down-arm starts. +// - `left_right` and `right_left` are the analogous values along x. +// +// These are computed so that: +// - a heavy crossbar fully covers a light perpendicular arm at the join, +// - a light arm stops at the top/left edge of the perpendicular crossbar +// in symmetric junctions (so we don't double-paint the crossbar), +// - in double-line corners, each parallel of the double stroke stops at +// the inner edge of the orthogonal stroke, forming a clean inner "L". + +function drawEdges( + ctx: CanvasRenderingContext2D, + e: Edges, + ox: number, + oy: number, + w: number, + h: number, + color: string +): void { + const lt = lightThickness(h); + const ht = heavyThickness(h); + + // Horizontal stroke positions (y coordinates). + const h_light_top = (h - lt) / 2; + const h_light_bottom = h_light_top + lt; + const h_heavy_top = (h - ht) / 2; + const h_heavy_bottom = h_heavy_top + ht; + const h_double_top = h_light_top - lt; + const h_double_bottom = h_light_bottom + lt; + + // Vertical stroke positions (x coordinates). + const v_light_left = (w - lt) / 2; + const v_light_right = v_light_left + lt; + const v_heavy_left = (w - ht) / 2; + const v_heavy_right = v_heavy_left + ht; + const v_double_left = v_light_left - lt; + const v_double_right = v_light_right + lt; + + // Bottom of the up-arm. + let up_bottom: number; + if (e.l === H || e.r === H) { + up_bottom = h_heavy_bottom; + } else if (e.l !== e.r || e.d === e.u) { + up_bottom = e.l === D || e.r === D ? h_double_bottom : h_light_bottom; + } else if (e.l === N && e.r === N) { + up_bottom = h_light_bottom; + } else { + up_bottom = h_light_top; + } + + // Top of the down-arm. + let down_top: number; + if (e.l === H || e.r === H) { + down_top = h_heavy_top; + } else if (e.l !== e.r || e.u === e.d) { + down_top = e.l === D || e.r === D ? h_double_top : h_light_top; + } else if (e.l === N && e.r === N) { + down_top = h_light_top; + } else { + down_top = h_light_bottom; + } + + // Right edge of the left-arm. + let left_right: number; + if (e.u === H || e.d === H) { + left_right = v_heavy_right; + } else if (e.u !== e.d || e.l === e.r) { + left_right = e.u === D || e.d === D ? v_double_right : v_light_right; + } else if (e.u === N && e.d === N) { + left_right = v_light_right; + } else { + left_right = v_light_left; + } + + // Left edge of the right-arm. + let right_left: number; + if (e.u === H || e.d === H) { + right_left = v_heavy_left; + } else if (e.u !== e.d || e.r === e.l) { + right_left = e.u === D || e.d === D ? v_double_left : v_light_left; + } else if (e.u === N && e.d === N) { + right_left = v_light_left; + } else { + right_left = v_light_right; + } + + ctx.fillStyle = color; + const rect = (x0: number, y0: number, x1: number, y1: number) => { + ctx.fillRect(ox + x0, oy + y0, x1 - x0, y1 - y0); + }; + + // UP arm. + switch (e.u) { + case L: + rect(v_light_left, 0, v_light_right, up_bottom); + break; + case H: + rect(v_heavy_left, 0, v_heavy_right, up_bottom); + break; + case D: { + const left_bottom = e.l === D ? h_light_top : up_bottom; + const right_bottom = e.r === D ? h_light_top : up_bottom; + rect(v_double_left, 0, v_light_left, left_bottom); + rect(v_light_right, 0, v_double_right, right_bottom); + break; + } + } + + // RIGHT arm. + switch (e.r) { + case L: + rect(right_left, h_light_top, w, h_light_bottom); + break; + case H: + rect(right_left, h_heavy_top, w, h_heavy_bottom); + break; + case D: { + const top_left = e.u === D ? v_light_right : right_left; + const bottom_left = e.d === D ? v_light_right : right_left; + rect(top_left, h_double_top, w, h_light_top); + rect(bottom_left, h_light_bottom, w, h_double_bottom); + break; + } + } + + // DOWN arm. + switch (e.d) { + case L: + rect(v_light_left, down_top, v_light_right, h); + break; + case H: + rect(v_heavy_left, down_top, v_heavy_right, h); + break; + case D: { + const left_top = e.l === D ? h_light_bottom : down_top; + const right_top = e.r === D ? h_light_bottom : down_top; + rect(v_double_left, left_top, v_light_left, h); + rect(v_light_right, right_top, v_double_right, h); + break; + } + } + + // LEFT arm. + switch (e.l) { + case L: + rect(0, h_light_top, left_right, h_light_bottom); + break; + case H: + rect(0, h_heavy_top, left_right, h_heavy_bottom); + break; + case D: { + const top_right = e.u === D ? v_light_left : left_right; + const bottom_right = e.d === D ? v_light_left : left_right; + rect(0, h_double_top, top_right, h_light_top); + rect(0, h_light_bottom, bottom_right, h_double_bottom); + break; + } + } +} + +// ---------------------------------------------------------------------------- +// Arcs (โ•ญโ•ฎโ•ฏโ•ฐ) +// +// Ports Ghostty's arc drawing (box.zig:691-777). The arc is a cubic +// Bezier with control fraction 0.25 inside a quadrant of the cell. The +// radius reaches the cell-edge midpoint (r = min(w,h)/2), so the arc +// joins flush to a straight `โ”€` or `โ”‚` in the next cell. + +function drawArc( + ctx: CanvasRenderingContext2D, + corner: Corner, + ox: number, + oy: number, + w: number, + h: number, + color: string +): void { + const lt = lightThickness(h); + const center_x = (w - lt) / 2 + lt / 2; + const center_y = (h - lt) / 2 + lt / 2; + const r = Math.min(w, h) / 2; + const s = 0.25; // control point fraction toward the corner + + ctx.save(); + ctx.translate(ox, oy); + ctx.strokeStyle = color; + ctx.lineWidth = lt; + ctx.lineCap = 'butt'; + ctx.beginPath(); + + switch (corner) { + case 'tl': + ctx.moveTo(center_x, 0); + ctx.lineTo(center_x, center_y - r); + ctx.bezierCurveTo( + center_x, + center_y - s * r, + center_x - s * r, + center_y, + center_x - r, + center_y + ); + ctx.lineTo(0, center_y); + break; + case 'tr': + ctx.moveTo(center_x, 0); + ctx.lineTo(center_x, center_y - r); + ctx.bezierCurveTo( + center_x, + center_y - s * r, + center_x + s * r, + center_y, + center_x + r, + center_y + ); + ctx.lineTo(w, center_y); + break; + case 'bl': + ctx.moveTo(center_x, h); + ctx.lineTo(center_x, center_y + r); + ctx.bezierCurveTo( + center_x, + center_y + s * r, + center_x - s * r, + center_y, + center_x - r, + center_y + ); + ctx.lineTo(0, center_y); + break; + case 'br': + ctx.moveTo(center_x, h); + ctx.lineTo(center_x, center_y + r); + ctx.bezierCurveTo( + center_x, + center_y + s * r, + center_x + s * r, + center_y, + center_x + r, + center_y + ); + ctx.lineTo(w, center_y); + break; + } + + ctx.stroke(); + ctx.restore(); +} + +// ---------------------------------------------------------------------------- +// Dashed lines +// +// Ports Ghostty's `dashHorizontal`/`dashVertical` (box.zig:779-895). The +// dashes are sized so that: +// - half-sized gaps sit on either side of the run, so adjacent dashed +// cells tile into one continuous dashed line, +// - leftover sub-pixels are distributed to dash widths (not gaps), so +// irregularity hides in dash length rather than gap spacing. + +function drawDashed( + ctx: CanvasRenderingContext2D, + dash: Dashed, + ox: number, + oy: number, + w: number, + h: number, + color: string +): void { + ctx.fillStyle = color; + const t = dash.weight === H ? heavyThickness(h) : lightThickness(h); + const count = dash.count; + // Use light thickness as the desired gap so dashes look balanced + // against the stroke weight of neighboring lines. + const desired_gap = lightThickness(h); + + if (dash.vertical) { + drawDashRun(ctx, ox + (w - t) / 2, oy, t, h, count, desired_gap, true); + } else { + drawDashRun(ctx, ox, oy + (h - t) / 2, w, t, count, desired_gap, false); + } +} + +function drawDashRun( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + count: number, + desired_gap: number, + vertical: boolean +): void { + const span = vertical ? h : w; + + // Below this size the dashes degenerate to nothing โ€” fall back to a + // solid line so the run still tiles with its neighbors. + if (span < count + count) { + ctx.fillRect(x, y, w, h); + return; + } + + // Cap the gap so dashes never shrink below half the available run. + const gap_width = Math.min(desired_gap, Math.floor(span / (2 * count))); + const total_gap = gap_width * count; // half-gaps on each side + gaps between + const total_dash = Math.floor(span - total_gap); + const dash_width = Math.floor(total_dash / count); + let extra = total_dash - dash_width * count; + + // Start half a gap in so the run is centered. + let pos = Math.floor(gap_width / 2); + for (let i = 0; i < count; i++) { + let len = dash_width; + if (extra > 0) { + extra -= 1; + len += 1; + } + if (vertical) { + ctx.fillRect(x, y + pos, w, len); + } else { + ctx.fillRect(x + pos, y, len, h); + } + pos += len + gap_width; + } +} + +// ---------------------------------------------------------------------------- +// Diagonals +// +// Ports Ghostty's diagonal-line code (box.zig:638-688). The line +// overshoots each corner by a fraction of a pixel along the slope so +// that anti-aliasing covers the cell corner exactly and adjacent +// diagonals tile without 1-pixel gaps at the join. + +function drawDiagonal( + ctx: CanvasRenderingContext2D, + cp: number, + ox: number, + oy: number, + w: number, + h: number, + color: string +): void { + const lt = lightThickness(h); + const slope_x = Math.min(1, w / h); + const slope_y = Math.min(1, h / w); + + ctx.save(); + ctx.translate(ox, oy); + ctx.strokeStyle = color; + ctx.lineWidth = lt; + ctx.lineCap = 'butt'; + ctx.beginPath(); + + if (cp === 0x2571 || cp === 0x2573) { + // โ•ฑ โ•ณ: forward slash (top-right to bottom-left, with overshoot). + ctx.moveTo(w + 0.5 * slope_x, -0.5 * slope_y); + ctx.lineTo(-0.5 * slope_x, h + 0.5 * slope_y); + } + if (cp === 0x2572 || cp === 0x2573) { + // โ•ฒ โ•ณ: back slash (top-left to bottom-right, with overshoot). + ctx.moveTo(-0.5 * slope_x, -0.5 * slope_y); + ctx.lineTo(w + 0.5 * slope_x, h + 0.5 * slope_y); + } + + ctx.stroke(); + ctx.restore(); +} diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfd..d8817d0 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -10,6 +10,7 @@ * - Dirty line optimization for 60 FPS */ +import { drawBoxOrBlock, isBoxOrBlock } from './box-drawing'; import type { ITheme } from './interfaces'; import type { SelectionManager } from './selection-manager'; import type { GhosttyCell, ILink } from './types'; @@ -195,18 +196,48 @@ export class CanvasRenderer { // 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 + // Width is the font's natural advance. Any rounding here becomes a + // visible seam between cells for tiling glyphs (box drawing, blocks), + // so we keep it fractional and round only when sizing the canvas. + // + // Height has to fit two things: + // - All glyphs in the font, including descenders on g/p/y/j/q. + // fontBoundingBox{Ascent,Descent} describe the font's design + // metrics across every glyph, but Canvas2D doesn't expose a way + // to separate "design metrics" from "leading," so for some fonts + // these values include extra space. + // - actualBoundingBox* of a probe string with descenders gives the + // real rendered extent โ€” usually tighter, but in some fonts the + // reported descent is shorter than what individual glyphs use + // (browsers under-report). + // Taking max() of both for ascent and descent independently gives a + // height that fits every glyph, regardless of which metric a given + // browser/font under-reports. We then ceil to whole pixels so rows + // don't accumulate sub-pixel drift. + // + // Box drawing and block elements (U+2500..U+259F) don't get rendered + // through the font โ€” see lib/box-drawing.ts. They're drawn as canvas + // paths sized to the cell, so they tile regardless of how the font's + // glyphs of those codepoints would have extended. This separation + // lets us pick a descender-safe cell height without worrying about + // tiling. + const m = ctx.measureText('M'); + const probe = ctx.measureText('Mgjpqyโ”‚'); + + const width = m.width; + + const fbAscent = probe.fontBoundingBoxAscent ?? 0; + const fbDescent = probe.fontBoundingBoxDescent ?? 0; + const abAscent = probe.actualBoundingBoxAscent ?? 0; + const abDescent = probe.actualBoundingBoxDescent ?? 0; + + const rawAscent = Math.max(fbAscent, abAscent) || this.fontSize * 0.8; + const rawDescent = Math.max(fbDescent, abDescent) || this.fontSize * 0.25; + + const ascent = Math.ceil(rawAscent); + const descent = Math.ceil(rawDescent); + const height = ascent + descent; + const baseline = ascent; return { width, height, baseline }; } @@ -241,9 +272,12 @@ export class CanvasRenderer { 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; + // Set actual canvas size (scaled for DPI). Round to integer device + // pixels โ€” the canvas backing store can't store fractional dimensions, + // and unrounded values would cause the resize-detection check below + // to perpetually disagree with what the canvas actually stores. + this.canvas.width = Math.round(cssWidth * this.devicePixelRatio); + this.canvas.height = Math.round(cssHeight * this.devicePixelRatio); // Scale context to match DPI (setting canvas.width/height resets the context) this.ctx.scale(this.devicePixelRatio, this.devicePixelRatio); @@ -285,10 +319,11 @@ export class CanvasRenderer { 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; + // Resize canvas if dimensions changed. Compare against the rounded + // device-pixel sizes that `resize()` actually writes into the canvas. + const expectedW = Math.round(dims.cols * this.metrics.width * this.devicePixelRatio); + const expectedH = Math.round(dims.rows * this.metrics.height * this.devicePixelRatio); + const needsResize = this.canvas.width !== expectedW || this.canvas.height !== expectedH; if (needsResize) { this.resize(dims.cols, dims.rows); @@ -638,16 +673,42 @@ export class CanvasRenderer { const textX = cellX; const textY = cellY + this.metrics.baseline; - // Get the character to render - use grapheme lookup for complex scripts - let char: string; - if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { - // Cell has additional codepoints - get full grapheme cluster - char = this.currentBuffer.getGraphemeString(y, x); + // Box-drawing and block-element glyphs (U+2500..U+259F) are designed + // to tile across cells. We draw them as canvas paths sized to the + // cell rather than going through the font, because: + // - Font advance widths don't always match our cell width exactly, + // leaving seams between adjacent glyphs. + // - Cell height is chosen for descender safety, not for whatever + // proportions the font designer gave U+2502 etc. + // This is the standard approach in modern terminal renderers + // (Alacritty, kitty, wezterm, Ghostty native). + if ( + cell.grapheme_len === 0 && + cell.codepoint && + isBoxOrBlock(cell.codepoint) && + drawBoxOrBlock( + this.ctx, + cell.codepoint, + cellX, + cellY, + cellWidth, + this.metrics.height, + this.ctx.fillStyle as string + ) + ) { + // Drawn directly; skip the font path. } else { - // Simple cell - single codepoint - char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null + // Get the character to render - use grapheme lookup for complex scripts + let char: string; + if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { + // Cell has additional codepoints - get full grapheme cluster + char = this.currentBuffer.getGraphemeString(y, x); + } else { + // Simple cell - single codepoint + char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null + } + this.ctx.fillText(char, textX, textY); } - this.ctx.fillText(char, textX, textY); // Reset alpha if (cell.flags & CellFlags.FAINT) { From b51440637e0ee124a93d6076275b80acd5c18255 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 11:46:53 -0700 Subject: [PATCH 08/17] =?UTF-8?q?fix(box-drawing):=20derive=20stroke=20thi?= =?UTF-8?q?ckness=20from=20font's=20'=E2=94=80'=20glyph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously light/heavy thickness were a fixed fraction of cell height (0.07h for light), which doesn't match the font designer's intent. The font's own U+2500 'โ”€' glyph already encodes the chosen box-stroke weight; measuring its `actualBoundingBox*` extent recovers it directly. Per-font measurements at 14pt confirm the variation: Monaco 1.27 px Menlo 1.18 px Consolas 1.18 px Courier New 1.00 px `measureFont` now stores `boxThickness` on `FontMetrics`, and the box-drawing/block-element renderer takes it as `lightPx`. Heavy is 2 ร— light; double lines are two parallel light strokes separated by one light gap (3 ร— light total) โ€” same model Ghostty uses. Falls back to ~7% of font size when the font lacks U+2500 (some browsers report 0 width for missing glyphs). Note on sprite-atlas caching (the other "skip" item in the prior review): a Canvas2D microbench across 10K mixed-glyph renders showed direct rendering is 2-9% *faster* than an offscreen-canvas atlas with `drawImage`. Canvas2D `fillRect`/`stroke` are already GPU-accelerated; the cache lookup + `drawImage` path adds overhead without buying batching. Native renderers (Ghostty, Alacritty) need atlases because they render through their own pipelines without GPU primitives at the call level. Not implementing the atlas. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing/common.ts | 15 ++++++--------- lib/box-drawing/index.ts | 17 ++++++++++++----- lib/box-drawing/lines.ts | 36 +++++++++++++++++++----------------- lib/renderer.ts | 22 ++++++++++++++++++++-- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/lib/box-drawing/common.ts b/lib/box-drawing/common.ts index 4e0c7e6..e1b502e 100644 --- a/lib/box-drawing/common.ts +++ b/lib/box-drawing/common.ts @@ -2,9 +2,10 @@ * Shared types and helpers for box-drawing and block-element rendering. * * The thickness model matches Ghostty's `Thickness` enum: a base "light" - * thickness derived from the cell size, with heavy = 2 ร— light. Double - * lines are two parallel light strokes separated by a gap of one light - * thickness (so a double stroke spans 3 ร— light total). + * thickness measured from the font's own U+2500 'โ”€' glyph (passed in + * by the caller as `lightPx`), with heavy = 2 ร— light. Double lines are + * two parallel light strokes separated by a gap of one light thickness + * (so a double stroke spans 3 ร— light total). */ // Edge weight: none, light (single thin line), heavy (single thick line), @@ -15,10 +16,6 @@ export const L: Weight = 1; export const H: Weight = 2; export const D: Weight = 3; -export function lightThickness(h: number): number { - return Math.max(1, Math.round(h * 0.07)); -} - -export function heavyThickness(h: number): number { - return lightThickness(h) * 2; +export function heavyThickness(lightPx: number): number { + return lightPx * 2; } diff --git a/lib/box-drawing/index.ts b/lib/box-drawing/index.ts index 958508f..ffff3e2 100644 --- a/lib/box-drawing/index.ts +++ b/lib/box-drawing/index.ts @@ -30,9 +30,15 @@ export function isBoxOrBlock(codepoint: number): boolean { /** * Render a box-drawing or block-element glyph into the cell at (x, y, w, h). - * `color` is the css color string used for the foreground stroke/fill. - * Returns true if the glyph was handled; false if the caller should fall - * back to font rendering. + * + * - `color` is the css color string used for the foreground stroke/fill. + * - `lightPx` is the font-derived light box-stroke thickness in CSS + * pixels (heavy is 2ร— this; double is two parallels separated by + * one light gap, totaling 3ร— this). Use the `boxThickness` value + * measured in `CanvasRenderer.measureFont`. + * + * Returns true if the glyph was handled; false if the caller should + * fall back to font rendering. */ export function drawBoxOrBlock( ctx: CanvasRenderingContext2D, @@ -41,13 +47,14 @@ export function drawBoxOrBlock( y: number, w: number, h: number, - color: string + color: string, + lightPx: number ): boolean { if (codepoint >= 0x2580 && codepoint <= 0x259f) { return drawBlockElement(ctx, codepoint, x, y, w, h, color); } if (codepoint >= 0x2500 && codepoint <= 0x257f) { - return drawBoxLine(ctx, codepoint, x, y, w, h, color); + return drawBoxLine(ctx, codepoint, x, y, w, h, color, lightPx); } return false; } diff --git a/lib/box-drawing/lines.ts b/lib/box-drawing/lines.ts index 319385f..92d8102 100644 --- a/lib/box-drawing/lines.ts +++ b/lib/box-drawing/lines.ts @@ -19,7 +19,7 @@ * corner forms a clean inner "L" instead of two crossing parallels, etc. */ -import { D, H, L, N, heavyThickness, lightThickness } from './common'; +import { D, H, L, N, heavyThickness } from './common'; import type { Weight } from './common'; interface Edges { @@ -181,6 +181,7 @@ const ARC = new Map([ /** * Draw a U+2500..U+257F glyph into the cell at (x, y, w, h). + * `lightPx` is the font-derived light stroke thickness in CSS pixels. * Returns true if the codepoint was handled. */ export function drawBoxLine( @@ -190,30 +191,30 @@ export function drawBoxLine( y: number, w: number, h: number, - color: string + color: string, + lightPx: number ): boolean { - // Diagonals. if (cp === 0x2571 || cp === 0x2572 || cp === 0x2573) { - drawDiagonal(ctx, cp, x, y, w, h, color); + drawDiagonal(ctx, cp, x, y, w, h, color, lightPx); return true; } const arc = ARC.get(cp); if (arc !== undefined) { - drawArc(ctx, arc, x, y, w, h, color); + drawArc(ctx, arc, x, y, w, h, color, lightPx); return true; } const dash = DASHED.get(cp); if (dash !== undefined) { - drawDashed(ctx, dash, x, y, w, h, color); + drawDashed(ctx, dash, x, y, w, h, color, lightPx); return true; } const e = EDGES.get(cp); if (e === undefined) return false; - drawEdges(ctx, e, x, y, w, h, color); + drawEdges(ctx, e, x, y, w, h, color, lightPx); return true; } @@ -243,10 +244,10 @@ function drawEdges( oy: number, w: number, h: number, - color: string + color: string, + lt: number ): void { - const lt = lightThickness(h); - const ht = heavyThickness(h); + const ht = heavyThickness(lt); // Horizontal stroke positions (y coordinates). const h_light_top = (h - lt) / 2; @@ -401,9 +402,9 @@ function drawArc( oy: number, w: number, h: number, - color: string + color: string, + lt: number ): void { - const lt = lightThickness(h); const center_x = (w - lt) / 2 + lt / 2; const center_y = (h - lt) / 2 + lt / 2; const r = Math.min(w, h) / 2; @@ -492,14 +493,15 @@ function drawDashed( oy: number, w: number, h: number, - color: string + color: string, + lt: number ): void { ctx.fillStyle = color; - const t = dash.weight === H ? heavyThickness(h) : lightThickness(h); + const t = dash.weight === H ? heavyThickness(lt) : lt; const count = dash.count; // Use light thickness as the desired gap so dashes look balanced // against the stroke weight of neighboring lines. - const desired_gap = lightThickness(h); + const desired_gap = lt; if (dash.vertical) { drawDashRun(ctx, ox + (w - t) / 2, oy, t, h, count, desired_gap, true); @@ -566,9 +568,9 @@ function drawDiagonal( oy: number, w: number, h: number, - color: string + color: string, + lt: number ): void { - const lt = lightThickness(h); const slope_x = Math.min(1, w / h); const slope_y = Math.min(1, h / w); diff --git a/lib/renderer.ts b/lib/renderer.ts index d8817d0..7c1a371 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -55,6 +55,13 @@ 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 + /** + * Light box-drawing stroke thickness in CSS pixels. Measured from the + * font's actual U+2500 'โ”€' glyph extent (the font designer's chosen + * thickness for box-drawing lines). Box-drawing rendering uses this + * for "light" weight; "heavy" is 2ร— this. + */ + boxThickness: number; } // ============================================================================ @@ -239,7 +246,17 @@ export class CanvasRenderer { const height = ascent + descent; const baseline = ascent; - return { width, height, baseline }; + // Box-drawing stroke thickness, measured from the font's actual + // U+2500 'โ”€' glyph. This gives the font designer's intended weight + // for box-drawing lines, which varies meaningfully across fonts + // (Monaco @14pt โ†’ 1.27, Menlo @14pt โ†’ 1.18, Courier @14pt โ†’ 1.00). + // Falls back to ~7% of font size if the font lacks the glyph (some + // browsers report 0 then) โ€” close to the typical underline weight. + const dash = ctx.measureText('โ”€'); + const dashHeight = (dash.actualBoundingBoxAscent ?? 0) + (dash.actualBoundingBoxDescent ?? 0); + const boxThickness = Math.max(1, Math.round(dashHeight || this.fontSize * 0.07)); + + return { width, height, baseline, boxThickness }; } /** @@ -693,7 +710,8 @@ export class CanvasRenderer { cellY, cellWidth, this.metrics.height, - this.ctx.fillStyle as string + this.ctx.fillStyle as string, + this.metrics.boxThickness ) ) { // Drawn directly; skip the font path. From b707a0178e5ace2457033206c216b02390fe07be Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 13:20:20 -0700 Subject: [PATCH 09/17] refactor(box-drawing): port block.zig structure to blocks.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-codepoint inline `fillRect` calls with the same helper trio Ghostty's block.zig uses: - Named fraction constants (ONE_EIGHTH, ONE_QUARTER, โ€ฆ, SEVEN_EIGHTHS) matching block.zig:19-28. - `block(alignment, wFrac, hFrac)` for axis-aligned partial-cell fills, matching `blockShade` (block.zig:121-152). The `Alignment` is a string union ('upper' | 'lower' | 'left' | 'right') mirroring Ghostty's named Alignment constants (common.zig:92-95). - `quadrant({tl, tr, bl, br})` for the โ––โ–—โ–˜โ–™โ–šโ–›โ–œโ–โ–žโ–Ÿ family, matching Ghostty's `quadrant` (block.zig:168-177). - `fullShade(color, alpha)` for โ–‘โ–’โ–“. Switch arms become one-liners that read as the family they belong to (lower-eighths progression, left-eighths progression, single-quadrant, multi-quadrant), instead of 32 hand-rolled fillRect calls with scattered (h*5)/8 / (h*3)/4 literals. Pure refactor, no behavior change. All 32 codepoints produce the same shape they did before; verified visually against the previous output. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing/blocks.ts | 276 +++++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 81 deletions(-) diff --git a/lib/box-drawing/blocks.ts b/lib/box-drawing/blocks.ts index 11f0a2d..8f9a783 100644 --- a/lib/box-drawing/blocks.ts +++ b/lib/box-drawing/blocks.ts @@ -1,11 +1,125 @@ /** * Block element renderer (U+2580..U+259F). * - * These glyphs are pure rectangles (eighths, halves, quadrants) plus three - * shading levels. Each cell is filled with one or two `fillRect` calls - * sized to the cell, so adjacent block-element cells tile seamlessly. + * Ports the structure of Ghostty's `block.zig`: named fraction constants + * (`block.zig:19-28`), a generic `block(alignment, wFrac, hFrac)` helper + * (`block.zig:111-152`), a `quadrant({tl, tr, bl, br})` helper for the + * multi-corner combinations (`block.zig:168-177`), and a shade helper + * for โ–‘โ–’โ–“ that maps to the same alpha levels Ghostty bakes into its + * sprite atlas (`common.zig:42-51`: 0x40 / 0x80 / 0xc0 = 0.251 / 0.502 + * / 0.753). */ +// Named fractions, matching block.zig:19-28. +const ONE_EIGHTH = 1 / 8; +const ONE_QUARTER = 1 / 4; +const THREE_EIGHTHS = 3 / 8; +const HALF = 1 / 2; +const FIVE_EIGHTHS = 5 / 8; +const THREE_QUARTERS = 3 / 4; +const SEVEN_EIGHTHS = 7 / 8; + +/** + * Where in the cell to anchor a partial-cell `block`. + * - `'upper'`: full width, anchored to the cell top. + * - `'lower'`: full width, anchored to the cell bottom. + * - `'left'`: full height, anchored to the cell left. + * - `'right'`: full height, anchored to the cell right. + * + * Mirrors Ghostty's `Alignment` named constants (`common.zig:92-95`). + */ +type Alignment = 'upper' | 'lower' | 'left' | 'right'; + +interface Quads { + tl?: boolean; + tr?: boolean; + bl?: boolean; + br?: boolean; +} + +/** + * Fill a fractional sub-rectangle of the cell, anchored per `alignment`. + * Sizes are given as fractions of the cell so the same call shape works + * for halves, eighths, and full-cell rendering. + */ +function block( + ctx: CanvasRenderingContext2D, + ox: number, + oy: number, + cw: number, + ch: number, + color: string, + alignment: Alignment, + wFrac: number, + hFrac: number +): void { + const w = cw * wFrac; + const h = ch * hFrac; + let dx = 0; + let dy = 0; + switch (alignment) { + case 'upper': + dx = (cw - w) / 2; + dy = 0; + break; + case 'lower': + dx = (cw - w) / 2; + dy = ch - h; + break; + case 'left': + dx = 0; + dy = (ch - h) / 2; + break; + case 'right': + dx = cw - w; + dy = (ch - h) / 2; + break; + } + ctx.fillStyle = color; + ctx.fillRect(ox + dx, oy + dy, w, h); +} + +/** + * Fill any subset of the cell's four 2x2 quadrants. Ports Ghostty's + * `quadrant` (`block.zig:168-177`) for the โ––โ–—โ–˜โ–™โ–šโ–›โ–œโ–โ–žโ–Ÿ family. + */ +function quadrant( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, + q: Quads +): void { + ctx.fillStyle = color; + const hw = w / 2; + const hh = h / 2; + if (q.tl) ctx.fillRect(x, y, hw, hh); + if (q.tr) ctx.fillRect(x + hw, y, hw, hh); + if (q.bl) ctx.fillRect(x, y + hh, hw, hh); + if (q.br) ctx.fillRect(x + hw, y + hh, hw, hh); +} + +/** + * Fill the entire cell at a fractional opacity. Used for โ–‘โ–’โ–“. + */ +function fullShade( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, + alpha: number +): void { + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); + ctx.restore(); +} + /** * Draw a U+2580..U+259F glyph into the cell at (x, y, w, h). * Returns true if the codepoint was handled. @@ -19,118 +133,118 @@ export function drawBlockElement( h: number, color: string ): boolean { - ctx.fillStyle = color; switch (cp) { + // โ–€โ–„โ–Œโ– โ€” halves. case 0x2580: // โ–€ upper half - ctx.fillRect(x, y, w, h / 2); + block(ctx, x, y, w, h, color, 'upper', 1, HALF); return true; - case 0x2581: // โ– lower one eighth - ctx.fillRect(x, y + (h * 7) / 8, w, h / 8); + case 0x2584: // โ–„ lower half + block(ctx, x, y, w, h, color, 'lower', 1, HALF); return true; - case 0x2582: // โ–‚ lower one quarter - ctx.fillRect(x, y + (h * 3) / 4, w, h / 4); + case 0x258c: // โ–Œ left half + block(ctx, x, y, w, h, color, 'left', HALF, 1); return true; - case 0x2583: // โ–ƒ lower three eighths - ctx.fillRect(x, y + (h * 5) / 8, w, (h * 3) / 8); + case 0x2590: // โ– right half + block(ctx, x, y, w, h, color, 'right', HALF, 1); return true; - case 0x2584: // โ–„ lower half - ctx.fillRect(x, y + h / 2, w, h / 2); + + // โ–”โ–• โ€” top and right one-eighth strokes. + case 0x2594: // โ–” upper 1/8 + block(ctx, x, y, w, h, color, 'upper', 1, ONE_EIGHTH); return true; - case 0x2585: // โ–… lower five eighths - ctx.fillRect(x, y + (h * 3) / 8, w, (h * 5) / 8); + case 0x2595: // โ–• right 1/8 + block(ctx, x, y, w, h, color, 'right', ONE_EIGHTH, 1); return true; - case 0x2586: // โ–† lower three quarters - ctx.fillRect(x, y + h / 4, w, (h * 3) / 4); + + // โ–โ–‚โ–ƒโ–…โ–†โ–‡ โ€” lower-eighths family. Each fills the bottom n/8 of the cell. + case 0x2581: // โ– lower 1/8 + block(ctx, x, y, w, h, color, 'lower', 1, ONE_EIGHTH); return true; - case 0x2587: // โ–‡ lower seven eighths - ctx.fillRect(x, y + h / 8, w, (h * 7) / 8); + case 0x2582: // โ–‚ lower 2/8 + block(ctx, x, y, w, h, color, 'lower', 1, ONE_QUARTER); return true; - case 0x2588: // โ–ˆ full block - ctx.fillRect(x, y, w, h); + case 0x2583: // โ–ƒ lower 3/8 + block(ctx, x, y, w, h, color, 'lower', 1, THREE_EIGHTHS); return true; - case 0x2589: // โ–‰ left seven eighths - ctx.fillRect(x, y, (w * 7) / 8, h); + case 0x2585: // โ–… lower 5/8 + block(ctx, x, y, w, h, color, 'lower', 1, FIVE_EIGHTHS); return true; - case 0x258a: // โ–Š left three quarters - ctx.fillRect(x, y, (w * 3) / 4, h); + case 0x2586: // โ–† lower 6/8 + block(ctx, x, y, w, h, color, 'lower', 1, THREE_QUARTERS); return true; - case 0x258b: // โ–‹ left five eighths - ctx.fillRect(x, y, (w * 5) / 8, h); + case 0x2587: // โ–‡ lower 7/8 + block(ctx, x, y, w, h, color, 'lower', 1, SEVEN_EIGHTHS); return true; - case 0x258c: // โ–Œ left half - ctx.fillRect(x, y, w / 2, h); + + // โ–ˆ full block. + case 0x2588: + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); return true; - case 0x258d: // โ– left three eighths - ctx.fillRect(x, y, (w * 3) / 8, h); + + // โ–‰โ–Šโ–‹โ–โ–Žโ– โ€” left-eighths family. Each fills the left n/8 of the cell. + case 0x2589: // โ–‰ left 7/8 + block(ctx, x, y, w, h, color, 'left', SEVEN_EIGHTHS, 1); return true; - case 0x258e: // โ–Ž left one quarter - ctx.fillRect(x, y, w / 4, h); + case 0x258a: // โ–Š left 6/8 + block(ctx, x, y, w, h, color, 'left', THREE_QUARTERS, 1); return true; - case 0x258f: // โ– left one eighth - ctx.fillRect(x, y, w / 8, h); + case 0x258b: // โ–‹ left 5/8 + block(ctx, x, y, w, h, color, 'left', FIVE_EIGHTHS, 1); return true; - case 0x2590: // โ– right half - ctx.fillRect(x + w / 2, y, w / 2, h); + case 0x258d: // โ– left 3/8 + block(ctx, x, y, w, h, color, 'left', THREE_EIGHTHS, 1); return true; + case 0x258e: // โ–Ž left 2/8 + block(ctx, x, y, w, h, color, 'left', ONE_QUARTER, 1); + return true; + case 0x258f: // โ– left 1/8 + block(ctx, x, y, w, h, color, 'left', ONE_EIGHTH, 1); + return true; + + // โ–‘โ–’โ–“ โ€” shades. case 0x2591: // โ–‘ light shade - ctx.save(); - ctx.globalAlpha *= 0.25; - ctx.fillRect(x, y, w, h); - ctx.restore(); + fullShade(ctx, x, y, w, h, color, 0.25); return true; case 0x2592: // โ–’ medium shade - ctx.save(); - ctx.globalAlpha *= 0.5; - ctx.fillRect(x, y, w, h); - ctx.restore(); + fullShade(ctx, x, y, w, h, color, 0.5); return true; case 0x2593: // โ–“ dark shade - ctx.save(); - ctx.globalAlpha *= 0.75; - ctx.fillRect(x, y, w, h); - ctx.restore(); - return true; - case 0x2594: // โ–” upper one eighth - ctx.fillRect(x, y, w, h / 8); + fullShade(ctx, x, y, w, h, color, 0.75); return true; - case 0x2595: // โ–• right one eighth - ctx.fillRect(x + (w * 7) / 8, y, w / 8, h); - return true; - case 0x2596: // โ–– quadrant lower left - ctx.fillRect(x, y + h / 2, w / 2, h / 2); + + // โ––โ–—โ–˜โ– โ€” single-quadrant blocks. + case 0x2596: // โ–– lower-left + quadrant(ctx, x, y, w, h, color, { bl: true }); return true; - case 0x2597: // โ–— quadrant lower right - ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + case 0x2597: // โ–— lower-right + quadrant(ctx, x, y, w, h, color, { br: true }); return true; - case 0x2598: // โ–˜ quadrant upper left - ctx.fillRect(x, y, w / 2, h / 2); + case 0x2598: // โ–˜ upper-left + quadrant(ctx, x, y, w, h, color, { tl: true }); return true; - case 0x2599: // โ–™ quadrant upper-left + lower-left + lower-right - ctx.fillRect(x, y, w / 2, h); - ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + case 0x259d: // โ– upper-right + quadrant(ctx, x, y, w, h, color, { tr: true }); return true; - case 0x259a: // โ–š quadrant upper-left + lower-right - ctx.fillRect(x, y, w / 2, h / 2); - ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + + // โ–™โ–šโ–›โ–œโ–žโ–Ÿ โ€” multi-quadrant combinations. + case 0x2599: // โ–™ + quadrant(ctx, x, y, w, h, color, { tl: true, bl: true, br: true }); return true; - case 0x259b: // โ–› upper-left + upper-right + lower-left - ctx.fillRect(x, y, w / 2, h); - ctx.fillRect(x + w / 2, y, w / 2, h / 2); + case 0x259a: // โ–š + quadrant(ctx, x, y, w, h, color, { tl: true, br: true }); return true; - case 0x259c: // โ–œ upper-left + upper-right + lower-right - ctx.fillRect(x, y, w, h / 2); - ctx.fillRect(x + w / 2, y + h / 2, w / 2, h / 2); + case 0x259b: // โ–› + quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, bl: true }); return true; - case 0x259d: // โ– quadrant upper right - ctx.fillRect(x + w / 2, y, w / 2, h / 2); + case 0x259c: // โ–œ + quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, br: true }); return true; - case 0x259e: // โ–ž upper-right + lower-left - ctx.fillRect(x + w / 2, y, w / 2, h / 2); - ctx.fillRect(x, y + h / 2, w / 2, h / 2); + case 0x259e: // โ–ž + quadrant(ctx, x, y, w, h, color, { tr: true, bl: true }); return true; - case 0x259f: // โ–Ÿ upper-right + lower-left + lower-right - ctx.fillRect(x + w / 2, y, w / 2, h); - ctx.fillRect(x, y + h / 2, w / 2, h / 2); + case 0x259f: // โ–Ÿ + quadrant(ctx, x, y, w, h, color, { tr: true, bl: true, br: true }); return true; } return false; From ca7a32089fb0bd4deb861117647aef0d75df5cc1 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 13:31:10 -0700 Subject: [PATCH 10/17] fix(box-drawing): vertical-dash asymmetry + dash gap, add test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review caught two real bugs against Ghostty's `box.zig`: 1. **Vertical dashes started at the wrong offset.** Ghostty's `dashVertical` (box.zig:907-909) starts at y=0 and pushes the full extra gap to the bottom, with the explicit comment that "a single full-sized extra gap is preferred to two half-sized ones for vertical to allow better joining to solid characters". Our port used `Math.floor(gap_width / 2)` for both axes, which mis-tiled vertical dashed lines (โ”† โ”‡ โ”Š โ”‹ โ•Ž โ•) against neighboring solid `โ”‚`/`โ”ƒ` cells. 2. **`desired_gap` didn't match Ghostty.** Every dispatch site in `box.zig` (lines 73, 81, 89, 97, โ€ฆ) passes `@max(4, light)` as the desired gap. Our port passed plain `light`, producing tighter and visually different dashes at small light thicknesses. Both fixed in `drawDashed` / `drawDashRun`. Also adds `lib/box-drawing/box-drawing.test.ts` (24 tests) covering: - coverage: every codepoint in U+2500..U+259F produces drawing ops (catches silent fallback-to-font regressions for the whole range), - block elements: shape and edge alignment for halves, eighths, quadrants, and shades, - lines: union-of-arms covers the cell, light/heavy/double thickness ratios, junction-aware corners (regression check that โ•” doesn't draw crossing parallels into the upper-left quadrant), - arcs: stroked Bezier path, no fillRect, - dashes: dash count per glyph + the vertical/horizontal asymmetry fixed in #1, - diagonals: stroke-based, no fillRect. Tests use a recording stub that captures every `ctx` call as a structured op, since happy-dom's CanvasRenderingContext2D doesn't rasterize. This catches behavioral changes without needing a real canvas raster comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing/box-drawing.test.ts | 402 ++++++++++++++++++++++++++++ lib/box-drawing/lines.ts | 35 ++- 2 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 lib/box-drawing/box-drawing.test.ts diff --git a/lib/box-drawing/box-drawing.test.ts b/lib/box-drawing/box-drawing.test.ts new file mode 100644 index 0000000..9f54a79 --- /dev/null +++ b/lib/box-drawing/box-drawing.test.ts @@ -0,0 +1,402 @@ +/** + * Tests for procedural box-drawing and block-element rendering. + * + * happy-dom's CanvasRenderingContext2D doesn't actually rasterize, so + * we test against a recording stub that captures every drawing call as + * a structured op. This catches: + * - coverage gaps (every codepoint in U+2500..U+259F should produce + * at least one fillRect or stroke), + * - regressions in branch logic (the recorded op sequence should + * stay stable across refactors), + * - junction-aware endpoint correctness (we hand-check a handful of + * known-tricky glyphs against expected coordinates). + */ + +import { describe, expect, test } from 'bun:test'; +import { drawBoxOrBlock, isBoxOrBlock } from './index'; + +type Op = + | { kind: 'fillStyle'; v: string } + | { kind: 'strokeStyle'; v: string } + | { kind: 'lineWidth'; v: number } + | { kind: 'lineCap'; v: string } + | { kind: 'globalAlpha'; v: number } + | { kind: 'fillRect'; x: number; y: number; w: number; h: number } + | { kind: 'save' } + | { kind: 'restore' } + | { kind: 'beginPath' } + | { kind: 'moveTo'; x: number; y: number } + | { kind: 'lineTo'; x: number; y: number } + | { kind: 'bezierCurveTo'; cp1x: number; cp1y: number; cp2x: number; cp2y: number; x: number; y: number } + | { kind: 'stroke' } + | { kind: 'translate'; x: number; y: number }; + +interface RecordingCtx { + ops: Op[]; + // Mirrored from CanvasRenderingContext2D for type compat. + fillStyle: string; + strokeStyle: string; + lineWidth: number; + lineCap: string; + globalAlpha: number; + fillRect(x: number, y: number, w: number, h: number): void; + save(): void; + restore(): void; + beginPath(): void; + moveTo(x: number, y: number): void; + lineTo(x: number, y: number): void; + bezierCurveTo( + cp1x: number, + cp1y: number, + cp2x: number, + cp2y: number, + x: number, + y: number + ): void; + stroke(): void; + translate(x: number, y: number): void; +} + +function makeCtx(): RecordingCtx { + const ops: Op[] = []; + let fillStyleBacking = '#000'; + let strokeStyleBacking = '#000'; + let lineWidthBacking = 1; + let lineCapBacking = 'butt'; + let globalAlphaBacking = 1; + return { + ops, + get fillStyle() { + return fillStyleBacking; + }, + set fillStyle(v: string) { + fillStyleBacking = v; + ops.push({ kind: 'fillStyle', v }); + }, + get strokeStyle() { + return strokeStyleBacking; + }, + set strokeStyle(v: string) { + strokeStyleBacking = v; + ops.push({ kind: 'strokeStyle', v }); + }, + get lineWidth() { + return lineWidthBacking; + }, + set lineWidth(v: number) { + lineWidthBacking = v; + ops.push({ kind: 'lineWidth', v }); + }, + get lineCap() { + return lineCapBacking; + }, + set lineCap(v: string) { + lineCapBacking = v; + ops.push({ kind: 'lineCap', v }); + }, + get globalAlpha() { + return globalAlphaBacking; + }, + set globalAlpha(v: number) { + globalAlphaBacking = v; + ops.push({ kind: 'globalAlpha', v }); + }, + fillRect(x, y, w, h) { + ops.push({ kind: 'fillRect', x, y, w, h }); + }, + save() { + ops.push({ kind: 'save' }); + }, + restore() { + ops.push({ kind: 'restore' }); + }, + beginPath() { + ops.push({ kind: 'beginPath' }); + }, + moveTo(x, y) { + ops.push({ kind: 'moveTo', x, y }); + }, + lineTo(x, y) { + ops.push({ kind: 'lineTo', x, y }); + }, + bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) { + ops.push({ kind: 'bezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y }); + }, + stroke() { + ops.push({ kind: 'stroke' }); + }, + translate(x, y) { + ops.push({ kind: 'translate', x, y }); + }, + }; +} + +// Standard cell for tests: 10x20 with a 1px light stroke. +const CW = 10; +const CH = 20; +const LT = 1; +const COLOR = '#fff'; + +function draw(cp: number, lightPx = LT) { + const ctx = makeCtx(); + const handled = drawBoxOrBlock( + ctx as unknown as CanvasRenderingContext2D, + cp, + 0, + 0, + CW, + CH, + COLOR, + lightPx + ); + return { ctx, handled }; +} + +function rectsOnly(ops: Op[]): { x: number; y: number; w: number; h: number }[] { + return ops.flatMap((o) => (o.kind === 'fillRect' ? [{ x: o.x, y: o.y, w: o.w, h: o.h }] : [])); +} + +describe('box-drawing', () => { + describe('isBoxOrBlock', () => { + test('matches U+2500..U+259F', () => { + expect(isBoxOrBlock(0x2500)).toBe(true); + expect(isBoxOrBlock(0x257f)).toBe(true); + expect(isBoxOrBlock(0x2580)).toBe(true); + expect(isBoxOrBlock(0x259f)).toBe(true); + }); + test('rejects neighbors', () => { + expect(isBoxOrBlock(0x24ff)).toBe(false); + expect(isBoxOrBlock(0x25a0)).toBe(false); + expect(isBoxOrBlock(0x4e00)).toBe(false); + expect(isBoxOrBlock(0x20)).toBe(false); + }); + }); + + describe('coverage', () => { + // Every codepoint in the range should produce drawing ops, with no + // exceptions. A regression here means a glyph is silently falling + // back to font rendering. + test('every codepoint U+2500..U+259F draws something', () => { + const missing: number[] = []; + for (let cp = 0x2500; cp <= 0x259f; cp++) { + const { ctx, handled } = draw(cp); + const drewSomething = ctx.ops.some( + (o) => o.kind === 'fillRect' || o.kind === 'stroke' + ); + if (!handled || !drewSomething) missing.push(cp); + } + expect(missing).toEqual([]); + }); + }); + + describe('block elements (U+2580..U+259F)', () => { + test('โ–€ U+2580 upper half = top-half rect', () => { + const { ctx } = draw(0x2580); + expect(rectsOnly(ctx.ops)).toEqual([{ x: 0, y: 0, w: CW, h: CH / 2 }]); + }); + test('โ–„ U+2584 lower half = bottom-half rect', () => { + const { ctx } = draw(0x2584); + expect(rectsOnly(ctx.ops)).toEqual([{ x: 0, y: CH / 2, w: CW, h: CH / 2 }]); + }); + test('โ–ˆ U+2588 full block = full-cell rect', () => { + const { ctx } = draw(0x2588); + expect(rectsOnly(ctx.ops)).toEqual([{ x: 0, y: 0, w: CW, h: CH }]); + }); + test('โ– U+258F left 1/8 = thin left rect', () => { + const { ctx } = draw(0x258f); + expect(rectsOnly(ctx.ops)).toEqual([{ x: 0, y: 0, w: CW / 8, h: CH }]); + }); + test('โ–• U+2595 right 1/8 = thin right rect, touches right edge', () => { + const { ctx } = draw(0x2595); + const rects = rectsOnly(ctx.ops); + expect(rects).toHaveLength(1); + const [r] = rects; + expect(r.x + r.w).toBeCloseTo(CW, 9); + expect(r.w).toBeCloseTo(CW / 8, 9); + expect(r.h).toBe(CH); + }); + test('โ– U+2581 lower 1/8 = bottom rect, touches bottom edge', () => { + const { ctx } = draw(0x2581); + const rects = rectsOnly(ctx.ops); + expect(rects).toHaveLength(1); + const [r] = rects; + expect(r.y + r.h).toBeCloseTo(CH, 9); + }); + test('โ–™ U+2599 = three quadrants (tl + bl + br)', () => { + // Per block.zig:93: tl + bl + br. The quadrant() helper emits + // one rect per active quadrant, in tl/tr/bl/br order. + const { ctx } = draw(0x2599); + const rects = rectsOnly(ctx.ops); + expect(rects).toEqual([ + { x: 0, y: 0, w: CW / 2, h: CH / 2 }, // tl + { x: 0, y: CH / 2, w: CW / 2, h: CH / 2 }, // bl + { x: CW / 2, y: CH / 2, w: CW / 2, h: CH / 2 }, // br + ]); + }); + test('โ–‘ U+2591 light shade applies 0.25 alpha multiplier', () => { + const { ctx } = draw(0x2591); + const alphaOp = ctx.ops.find((o) => o.kind === 'globalAlpha'); + expect(alphaOp).toBeTruthy(); + // We do `globalAlpha *= 0.25`, starting from 1 โ†’ 0.25. + if (alphaOp && alphaOp.kind === 'globalAlpha') { + expect(alphaOp.v).toBeCloseTo(0.25, 9); + } + // And the alpha is applied within save/restore. + expect(ctx.ops[0]?.kind).toBe('save'); + expect(ctx.ops[ctx.ops.length - 1]?.kind).toBe('restore'); + }); + }); + + describe('line drawing (U+2500..U+257F)', () => { + // drawEdges emits one fillRect per non-empty arm (left, right, up, + // down). Adjacent arms overlap at the cell center to cover the + // junction. So a plain horizontal `โ”€` is 2 rects (left arm + right + // arm), not 1. We assert the union of rects, not individual counts. + test('โ”€ U+2500 light horizontal: arms cover full width at vertical center', () => { + const { ctx } = draw(0x2500); + const rects = rectsOnly(ctx.ops); + expect(rects.length).toBeGreaterThanOrEqual(1); + // All rects sit at the vertical center, light-thick high. + for (const r of rects) { + expect(r.h).toBe(LT); + expect(r.y + r.h / 2).toBeCloseTo(CH / 2, 9); + } + // Union covers full cell width. + const minX = Math.min(...rects.map((r) => r.x)); + const maxX = Math.max(...rects.map((r) => r.x + r.w)); + expect(minX).toBe(0); + expect(maxX).toBe(CW); + }); + test('โ”‚ U+2502 light vertical: arms cover full height at horizontal center', () => { + const { ctx } = draw(0x2502); + const rects = rectsOnly(ctx.ops); + expect(rects.length).toBeGreaterThanOrEqual(1); + for (const r of rects) { + expect(r.w).toBe(LT); + expect(r.x + r.w / 2).toBeCloseTo(CW / 2, 9); + } + const minY = Math.min(...rects.map((r) => r.y)); + const maxY = Math.max(...rects.map((r) => r.y + r.h)); + expect(minY).toBe(0); + expect(maxY).toBe(CH); + }); + test('โ” U+2501 heavy horizontal: rects are 2ร— light thickness', () => { + const { ctx } = draw(0x2501); + const rects = rectsOnly(ctx.ops); + expect(rects.length).toBeGreaterThanOrEqual(1); + for (const r of rects) { + expect(r.h).toBe(2 * LT); + } + }); + test('โ• U+2550 double horizontal: two parallel light strokes, 1-light gap', () => { + const { ctx } = draw(0x2550); + const rects = rectsOnly(ctx.ops); + // Two arms ร— two parallels per arm = 4 rects. + expect(rects).toHaveLength(4); + // All are light-thick. + for (const r of rects) { + expect(r.h).toBe(LT); + } + // The four rects collapse into two distinct y-bands. The gap + // between bands should equal one light thickness (Ghostty's + // double-line spec: total span = 3 ร— light). + const ys = [...new Set(rects.map((r) => r.y))].sort((a, b) => a - b); + expect(ys).toHaveLength(2); + expect(ys[1] - ys[0]).toBeCloseTo(2 * LT, 9); + }); + test('โ”ผ U+253C light cross = two perpendicular full-extent rects', () => { + const { ctx } = draw(0x253c); + const rects = rectsOnly(ctx.ops); + // โ”ผ: up + right + down + left, but symmetric junctions stop each + // arm at the edge of the perpendicular crossbar to avoid double + // painting. Up arm goes from y=0 to h_light_top, down arm goes + // from h_light_bottom to h, horizontal goes full width. + // Result: 2 vertical pieces + 1 full horizontal piece OR + // 1 full vertical + 2 horizontal pieces โ€” depending on join order. + // Either way, the union should cover the cross shape. + expect(rects.length).toBeGreaterThanOrEqual(2); + // Crossbar pixel must be covered. + const crossBarCovered = rects.some( + (r) => + r.x <= CW / 2 && + r.x + r.w >= CW / 2 && + r.y <= CH / 2 && + r.y + r.h >= CH / 2 + ); + expect(crossBarCovered).toBe(true); + }); + test('โ•” U+2554 double down-right corner forms inner L (no crossing parallels)', () => { + // Bug regression check: in the buggy version, the right and down + // double parallels would extend all the way to the cell center, + // crossing each other. Ghostty stops each parallel at the inner + // edge of the orthogonal stroke. + const { ctx } = draw(0x2554); + const rects = rectsOnly(ctx.ops); + // Should be 4 rects: top horizontal, bottom horizontal, left + // vertical, right vertical โ€” each with junction-aware endpoints. + expect(rects.length).toBe(4); + // No rect should occupy the upper-left quadrant interior (โ‰ˆ + // 1/3 of cell from top-left). + const inUpperLeft = (r: typeof rects[0]) => + r.x + r.w <= CW / 3 && r.y + r.h <= CH / 3; + expect(rects.every((r) => !inUpperLeft(r))).toBe(true); + }); + }); + + describe('arcs (โ•ญโ•ฎโ•ฏโ•ฐ)', () => { + test('โ•ญ U+256D draws bezier path with stroke, not fill', () => { + const { ctx } = draw(0x256d); + // Arcs use stroke, not fill โ€” they emit beginPath/moveTo/lineTo/ + // bezierCurveTo/stroke and no fillRect. + expect(ctx.ops.some((o) => o.kind === 'bezierCurveTo')).toBe(true); + expect(ctx.ops.some((o) => o.kind === 'stroke')).toBe(true); + expect(ctx.ops.some((o) => o.kind === 'fillRect')).toBe(false); + }); + }); + + describe('dashes', () => { + test('โ”„ U+2504 horizontal triple-dash: 3 rects, half-gap on each side', () => { + const { ctx } = draw(0x2504); + const rects = rectsOnly(ctx.ops); + expect(rects).toHaveLength(3); + // First dash starts at half a gap in. + const desiredGap = Math.max(4, LT); + const cap = Math.floor(CW / (2 * 3)); + const gap = Math.min(desiredGap, cap); + expect(rects[0].x).toBe(Math.floor(gap / 2)); + }); + test('โ”† U+2506 vertical triple-dash: 3 rects, starts at top (no half-gap)', () => { + const { ctx } = draw(0x2506); + const rects = rectsOnly(ctx.ops); + expect(rects).toHaveLength(3); + // Per Ghostty box.zig:907-909: vertical dashes start at y=0 with + // the full extra gap pushed to the bottom. This is the asymmetry + // the original port missed. + expect(rects[0].y).toBe(0); + }); + test('โ”ˆ U+2508 horizontal quad-dash: 4 rects', () => { + const { ctx } = draw(0x2508); + expect(rectsOnly(ctx.ops)).toHaveLength(4); + }); + test('โ•Œ U+254C horizontal double-dash: 2 rects', () => { + const { ctx } = draw(0x254c); + expect(rectsOnly(ctx.ops)).toHaveLength(2); + }); + }); + + describe('diagonals (โ•ฑโ•ฒโ•ณ)', () => { + test('โ•ฑ U+2571 forward slash = single stroked line', () => { + const { ctx } = draw(0x2571); + const lines = ctx.ops.filter((o) => o.kind === 'lineTo' || o.kind === 'moveTo'); + expect(lines).toHaveLength(2); // one moveTo, one lineTo + expect(ctx.ops.some((o) => o.kind === 'stroke')).toBe(true); + }); + test('โ•ณ U+2573 cross = two stroked lines', () => { + const { ctx } = draw(0x2573); + const moves = ctx.ops.filter((o) => o.kind === 'moveTo'); + const lines = ctx.ops.filter((o) => o.kind === 'lineTo'); + expect(moves).toHaveLength(2); + expect(lines).toHaveLength(2); + }); + }); +}); diff --git a/lib/box-drawing/lines.ts b/lib/box-drawing/lines.ts index 92d8102..c8b3d22 100644 --- a/lib/box-drawing/lines.ts +++ b/lib/box-drawing/lines.ts @@ -479,12 +479,20 @@ function drawArc( // ---------------------------------------------------------------------------- // Dashed lines // -// Ports Ghostty's `dashHorizontal`/`dashVertical` (box.zig:779-895). The -// dashes are sized so that: -// - half-sized gaps sit on either side of the run, so adjacent dashed -// cells tile into one continuous dashed line, -// - leftover sub-pixels are distributed to dash widths (not gaps), so -// irregularity hides in dash length rather than gap spacing. +// Ports Ghostty's `dashHorizontal`/`dashVertical` (box.zig:779-928). The +// horizontal and vertical variants are subtly different โ€” both run with +// `count` total gaps, but the gaps land in different places: +// +// - Horizontal: half a gap on each side of the run, full gaps between +// dashes. Lets adjacent dashed cells tile into one continuous run. +// - Vertical: zero gap at the top, full gap at the bottom, full gaps +// between dashes. Per Ghostty's comment (box.zig:878-881): "a +// single full-sized extra gap is preferred to two half-sized ones +// for vertical to allow better joining to solid characters without +// creating visible half-sized gaps." +// +// Leftover sub-pixels are distributed to dash widths (not gaps), so +// irregularity hides in dash length rather than gap spacing. function drawDashed( ctx: CanvasRenderingContext2D, @@ -499,9 +507,10 @@ function drawDashed( ctx.fillStyle = color; const t = dash.weight === H ? heavyThickness(lt) : lt; const count = dash.count; - // Use light thickness as the desired gap so dashes look balanced - // against the stroke weight of neighboring lines. - const desired_gap = lt; + // Match Ghostty's per-dispatch desired_gap of `max(4, light)` (box.zig:73, + // 81, 89, 97, ...). At small light thicknesses, plain `lt` produces + // gaps that are too tight to read as "dashed" against neighboring lines. + const desired_gap = Math.max(4, lt); if (dash.vertical) { drawDashRun(ctx, ox + (w - t) / 2, oy, t, h, count, desired_gap, true); @@ -531,13 +540,15 @@ function drawDashRun( // Cap the gap so dashes never shrink below half the available run. const gap_width = Math.min(desired_gap, Math.floor(span / (2 * count))); - const total_gap = gap_width * count; // half-gaps on each side + gaps between + const total_gap = gap_width * count; const total_dash = Math.floor(span - total_gap); const dash_width = Math.floor(total_dash / count); let extra = total_dash - dash_width * count; - // Start half a gap in so the run is centered. - let pos = Math.floor(gap_width / 2); + // Horizontal: start at half a gap so the run is centered. + // Vertical: start at zero with the full extra gap pushed to the bottom + // (Ghostty's `dashVertical`, box.zig:907-909). + let pos = vertical ? 0 : Math.floor(gap_width / 2); for (let i = 0; i < count; i++) { let len = dash_width; if (extra > 0) { From 40b02b2357771a17f64196a3e1296212d3705abc Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 13:35:48 -0700 Subject: [PATCH 11/17] refactor(box-drawing): consolidate into a single file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse `lib/box-drawing/{index,common,blocks,lines}.ts` into one `lib/box-drawing.ts`. The split-file layout was over-structuring for the size of the module โ€” the code is one self-contained renderer for one Unicode range, and the cross-file imports were noise. The single file is organized in clearly-delimited sections: 1. Common types (Weight, N/L/H/D, heavyThickness) 2. Public API (isBoxOrBlock, drawBoxOrBlock) 3. Block elements U+2580..U+259F (block/quadrant/fullShade helpers + drawBlockElement dispatch) 4. Box-drawing lines U+2500..U+257F (drawBoxLine dispatch + EDGES /DASHED/ARC tables + drawEdges/drawArc/drawDashed/drawDiagonal) No behavior change. The renderer's import path (`./box-drawing`) still resolves correctly. The test file moved from `lib/box-drawing/box-drawing.test.ts` to `lib/box-drawing.test.ts` and updated its import. All 369 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/{box-drawing => }/box-drawing.test.ts | 2 +- lib/{box-drawing/lines.ts => box-drawing.ts} | 376 +++++++++++++++++-- lib/box-drawing/blocks.ts | 251 ------------- lib/box-drawing/common.ts | 21 -- lib/box-drawing/index.ts | 60 --- 5 files changed, 353 insertions(+), 357 deletions(-) rename lib/{box-drawing => }/box-drawing.test.ts (99%) rename lib/{box-drawing/lines.ts => box-drawing.ts} (61%) delete mode 100644 lib/box-drawing/blocks.ts delete mode 100644 lib/box-drawing/common.ts delete mode 100644 lib/box-drawing/index.ts diff --git a/lib/box-drawing/box-drawing.test.ts b/lib/box-drawing.test.ts similarity index 99% rename from lib/box-drawing/box-drawing.test.ts rename to lib/box-drawing.test.ts index 9f54a79..ddccbfb 100644 --- a/lib/box-drawing/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -13,7 +13,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { drawBoxOrBlock, isBoxOrBlock } from './index'; +import { drawBoxOrBlock, isBoxOrBlock } from './box-drawing'; type Op = | { kind: 'fillStyle'; v: string } diff --git a/lib/box-drawing/lines.ts b/lib/box-drawing.ts similarity index 61% rename from lib/box-drawing/lines.ts rename to lib/box-drawing.ts index c8b3d22..c7bb889 100644 --- a/lib/box-drawing/lines.ts +++ b/lib/box-drawing.ts @@ -1,26 +1,359 @@ /** - * Box drawing line renderer (U+2500..U+257F). + * Box-drawing and Block-element renderer (U+2500..U+259F). * - * Covers four sub-families: - * - Orthogonal lines, corners, T-junctions, crosses, stubs, double-line - * variants โ€” described by directional weights in the EDGES table and - * drawn by `drawEdges`. - * - Quarter-circle arcs (โ•ญโ•ฎโ•ฏโ•ฐ) โ€” drawn as cubic Bezier curves so they - * join cleanly to neighboring straight cells. - * - Dashed/dotted horizontal & vertical lines โ€” drawn with integer-pixel - * gap distribution so they tile cleanly across cells of any width. - * - Diagonals (โ•ฑโ•ฒโ•ณ) โ€” drawn with sub-pixel overshoot so the diagonal - * reaches the cell corner exactly under anti-aliasing. + * Glyphs in this range are designed to tile seamlessly across cells. + * Letting the font render them is fragile: + * - The font's advance width may not match our cell width exactly, + * leaving gaps (or overlaps) between adjacent box-drawing chars. + * - The font's chosen ascent/descent for these glyphs rarely matches + * the cell height we need for descender-safe text rendering, so + * vertical lines and full blocks leave gaps between rows. + * - Different fonts encode these glyphs with different proportions, so + * visual consistency is poor. * - * Ports the algorithm from Ghostty native's `box.zig:linesChar`. The - * junction-aware arm endpoints (`up_bottom`, `down_top`, `left_right`, - * `right_left`) are what make weighted corners and T-junctions look - * correct: a heavy crossbar fully covers a light arm, a double-line - * corner forms a clean inner "L" instead of two crossing parallels, etc. + * We draw them as canvas paths sized to the cell instead. This is the + * standard fix โ€” Alacritty, kitty, wezterm, Ghostty native, and Windows + * Terminal all do this. The implementation here ports Ghostty's + * `box.zig` (U+2500..U+257F) and `block.zig` (U+2580..U+259F) into + * Canvas2D, including: + * - junction-aware arm endpoints for clean weighted T-junctions and + * double-line corners (`drawEdges`), + * - cubic-Bezier arcs that join flush to straight neighbors (`drawArc`), + * - axis-asymmetric dashed-line layout that tiles across cells + * (`drawDashed`), + * - sub-pixel diagonal overshoot for clean tiling under anti-aliasing + * (`drawDiagonal`), + * - a `block(alignment, wFrac, hFrac)` / `quadrant({tl,tr,bl,br})` + * pair for the U+2580..U+259F family. */ -import { D, H, L, N, heavyThickness } from './common'; -import type { Weight } from './common'; +// ============================================================================ +// Common types +// ============================================================================ + +// Edge weight: none, light (single thin line), heavy (single thick line), +// or double (two parallel thin lines with a 1-light gap between them). +type Weight = 0 | 1 | 2 | 3; +const N: Weight = 0; +const L: Weight = 1; +const H: Weight = 2; +const D: Weight = 3; + +function heavyThickness(lightPx: number): number { + return lightPx * 2; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Returns true if the codepoint is a box-drawing or block-element glyph + * that we render directly. Caller should skip the font path in that case. + */ +export function isBoxOrBlock(codepoint: number): boolean { + return codepoint >= 0x2500 && codepoint <= 0x259f; +} + +/** + * Render a box-drawing or block-element glyph into the cell at (x, y, w, h). + * + * - `color` is the css color string used for the foreground stroke/fill. + * - `lightPx` is the font-derived light box-stroke thickness in CSS + * pixels (heavy is 2ร— this; double is two parallels separated by + * one light gap, totaling 3ร— this). Use the `boxThickness` value + * measured in `CanvasRenderer.measureFont`. + * + * Returns true if the glyph was handled; false if the caller should + * fall back to font rendering. + */ +export function drawBoxOrBlock( + ctx: CanvasRenderingContext2D, + codepoint: number, + x: number, + y: number, + w: number, + h: number, + color: string, + lightPx: number +): boolean { + if (codepoint >= 0x2580 && codepoint <= 0x259f) { + return drawBlockElement(ctx, codepoint, x, y, w, h, color); + } + if (codepoint >= 0x2500 && codepoint <= 0x257f) { + return drawBoxLine(ctx, codepoint, x, y, w, h, color, lightPx); + } + return false; +} + +// ============================================================================ +// Block elements (U+2580..U+259F) +// +// Ports the structure of Ghostty's `block.zig`: named fraction constants +// (`block.zig:19-28`), a generic `block(alignment, wFrac, hFrac)` helper +// (`block.zig:111-152`), a `quadrant({tl,tr,bl,br})` helper for the +// multi-corner combinations (`block.zig:168-177`), and a shade helper +// for โ–‘โ–’โ–“ that maps to the same alpha levels Ghostty bakes into its +// sprite atlas (`common.zig:42-51`: 0x40 / 0x80 / 0xc0 = 0.251 / 0.502 / +// 0.753). +// ============================================================================ + +// Named fractions, matching block.zig:19-28. +const ONE_EIGHTH = 1 / 8; +const ONE_QUARTER = 1 / 4; +const THREE_EIGHTHS = 3 / 8; +const HALF = 1 / 2; +const FIVE_EIGHTHS = 5 / 8; +const THREE_QUARTERS = 3 / 4; +const SEVEN_EIGHTHS = 7 / 8; + +/** + * Where in the cell to anchor a partial-cell `block`. + * - `'upper'`: full width, anchored to the cell top. + * - `'lower'`: full width, anchored to the cell bottom. + * - `'left'`: full height, anchored to the cell left. + * - `'right'`: full height, anchored to the cell right. + * + * Mirrors Ghostty's `Alignment` named constants (`common.zig:92-95`). + */ +type Alignment = 'upper' | 'lower' | 'left' | 'right'; + +interface Quads { + tl?: boolean; + tr?: boolean; + bl?: boolean; + br?: boolean; +} + +/** + * Fill a fractional sub-rectangle of the cell, anchored per `alignment`. + * Sizes are given as fractions of the cell so the same call shape works + * for halves, eighths, and full-cell rendering. + */ +function block( + ctx: CanvasRenderingContext2D, + ox: number, + oy: number, + cw: number, + ch: number, + color: string, + alignment: Alignment, + wFrac: number, + hFrac: number +): void { + const w = cw * wFrac; + const h = ch * hFrac; + let dx = 0; + let dy = 0; + switch (alignment) { + case 'upper': + dx = (cw - w) / 2; + dy = 0; + break; + case 'lower': + dx = (cw - w) / 2; + dy = ch - h; + break; + case 'left': + dx = 0; + dy = (ch - h) / 2; + break; + case 'right': + dx = cw - w; + dy = (ch - h) / 2; + break; + } + ctx.fillStyle = color; + ctx.fillRect(ox + dx, oy + dy, w, h); +} + +/** + * Fill any subset of the cell's four 2x2 quadrants. Ports Ghostty's + * `quadrant` (`block.zig:168-177`) for the โ––โ–—โ–˜โ–™โ–šโ–›โ–œโ–โ–žโ–Ÿ family. + */ +function quadrant( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, + q: Quads +): void { + ctx.fillStyle = color; + const hw = w / 2; + const hh = h / 2; + if (q.tl) ctx.fillRect(x, y, hw, hh); + if (q.tr) ctx.fillRect(x + hw, y, hw, hh); + if (q.bl) ctx.fillRect(x, y + hh, hw, hh); + if (q.br) ctx.fillRect(x + hw, y + hh, hw, hh); +} + +/** + * Fill the entire cell at a fractional opacity. Used for โ–‘โ–’โ–“. + */ +function fullShade( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + color: string, + alpha: number +): void { + ctx.save(); + ctx.globalAlpha *= alpha; + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); + ctx.restore(); +} + +function drawBlockElement( + ctx: CanvasRenderingContext2D, + cp: number, + x: number, + y: number, + w: number, + h: number, + color: string +): boolean { + switch (cp) { + // โ–€โ–„โ–Œโ– โ€” halves. + case 0x2580: // โ–€ upper half + block(ctx, x, y, w, h, color, 'upper', 1, HALF); + return true; + case 0x2584: // โ–„ lower half + block(ctx, x, y, w, h, color, 'lower', 1, HALF); + return true; + case 0x258c: // โ–Œ left half + block(ctx, x, y, w, h, color, 'left', HALF, 1); + return true; + case 0x2590: // โ– right half + block(ctx, x, y, w, h, color, 'right', HALF, 1); + return true; + + // โ–”โ–• โ€” top and right one-eighth strokes. + case 0x2594: // โ–” upper 1/8 + block(ctx, x, y, w, h, color, 'upper', 1, ONE_EIGHTH); + return true; + case 0x2595: // โ–• right 1/8 + block(ctx, x, y, w, h, color, 'right', ONE_EIGHTH, 1); + return true; + + // โ–โ–‚โ–ƒโ–…โ–†โ–‡ โ€” lower-eighths family. Each fills the bottom n/8 of the cell. + case 0x2581: // โ– lower 1/8 + block(ctx, x, y, w, h, color, 'lower', 1, ONE_EIGHTH); + return true; + case 0x2582: // โ–‚ lower 2/8 + block(ctx, x, y, w, h, color, 'lower', 1, ONE_QUARTER); + return true; + case 0x2583: // โ–ƒ lower 3/8 + block(ctx, x, y, w, h, color, 'lower', 1, THREE_EIGHTHS); + return true; + case 0x2585: // โ–… lower 5/8 + block(ctx, x, y, w, h, color, 'lower', 1, FIVE_EIGHTHS); + return true; + case 0x2586: // โ–† lower 6/8 + block(ctx, x, y, w, h, color, 'lower', 1, THREE_QUARTERS); + return true; + case 0x2587: // โ–‡ lower 7/8 + block(ctx, x, y, w, h, color, 'lower', 1, SEVEN_EIGHTHS); + return true; + + // โ–ˆ full block. + case 0x2588: + ctx.fillStyle = color; + ctx.fillRect(x, y, w, h); + return true; + + // โ–‰โ–Šโ–‹โ–โ–Žโ– โ€” left-eighths family. Each fills the left n/8 of the cell. + case 0x2589: // โ–‰ left 7/8 + block(ctx, x, y, w, h, color, 'left', SEVEN_EIGHTHS, 1); + return true; + case 0x258a: // โ–Š left 6/8 + block(ctx, x, y, w, h, color, 'left', THREE_QUARTERS, 1); + return true; + case 0x258b: // โ–‹ left 5/8 + block(ctx, x, y, w, h, color, 'left', FIVE_EIGHTHS, 1); + return true; + case 0x258d: // โ– left 3/8 + block(ctx, x, y, w, h, color, 'left', THREE_EIGHTHS, 1); + return true; + case 0x258e: // โ–Ž left 2/8 + block(ctx, x, y, w, h, color, 'left', ONE_QUARTER, 1); + return true; + case 0x258f: // โ– left 1/8 + block(ctx, x, y, w, h, color, 'left', ONE_EIGHTH, 1); + return true; + + // โ–‘โ–’โ–“ โ€” shades. + case 0x2591: // โ–‘ light shade + fullShade(ctx, x, y, w, h, color, 0.25); + return true; + case 0x2592: // โ–’ medium shade + fullShade(ctx, x, y, w, h, color, 0.5); + return true; + case 0x2593: // โ–“ dark shade + fullShade(ctx, x, y, w, h, color, 0.75); + return true; + + // โ––โ–—โ–˜โ– โ€” single-quadrant blocks. + case 0x2596: // โ–– lower-left + quadrant(ctx, x, y, w, h, color, { bl: true }); + return true; + case 0x2597: // โ–— lower-right + quadrant(ctx, x, y, w, h, color, { br: true }); + return true; + case 0x2598: // โ–˜ upper-left + quadrant(ctx, x, y, w, h, color, { tl: true }); + return true; + case 0x259d: // โ– upper-right + quadrant(ctx, x, y, w, h, color, { tr: true }); + return true; + + // โ–™โ–šโ–›โ–œโ–žโ–Ÿ โ€” multi-quadrant combinations. + case 0x2599: // โ–™ + quadrant(ctx, x, y, w, h, color, { tl: true, bl: true, br: true }); + return true; + case 0x259a: // โ–š + quadrant(ctx, x, y, w, h, color, { tl: true, br: true }); + return true; + case 0x259b: // โ–› + quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, bl: true }); + return true; + case 0x259c: // โ–œ + quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, br: true }); + return true; + case 0x259e: // โ–ž + quadrant(ctx, x, y, w, h, color, { tr: true, bl: true }); + return true; + case 0x259f: // โ–Ÿ + quadrant(ctx, x, y, w, h, color, { tr: true, bl: true, br: true }); + return true; + } + return false; +} + +// ============================================================================ +// Box-drawing lines (U+2500..U+257F) +// +// Four sub-families: +// - Orthogonal lines, corners, T-junctions, crosses, stubs, and double +// variants โ€” described by directional weights in the EDGES table and +// drawn by `drawEdges`. +// - Quarter-circle arcs (โ•ญโ•ฎโ•ฏโ•ฐ) โ€” drawn as cubic Bezier curves so they +// join cleanly to neighboring straight cells. +// - Dashed/dotted horizontal & vertical lines โ€” drawn with integer- +// pixel gap distribution that tiles cleanly across cells. +// - Diagonals (โ•ฑโ•ฒโ•ณ) โ€” drawn with sub-pixel overshoot so the diagonal +// reaches the cell corner exactly under anti-aliasing. +// +// Ports the algorithm from Ghostty native's `box.zig:linesChar`. The +// junction-aware arm endpoints (`up_bottom`, `down_top`, `left_right`, +// `right_left`) are what make weighted corners and T-junctions look +// correct: a heavy crossbar fully covers a light arm, a double-line +// corner forms a clean inner "L" instead of two crossing parallels, etc. +// ============================================================================ interface Edges { l: Weight; @@ -179,12 +512,7 @@ const ARC = new Map([ [0x2570, 'tr'], // โ•ฐ up-right ]); -/** - * Draw a U+2500..U+257F glyph into the cell at (x, y, w, h). - * `lightPx` is the font-derived light stroke thickness in CSS pixels. - * Returns true if the codepoint was handled. - */ -export function drawBoxLine( +function drawBoxLine( ctx: CanvasRenderingContext2D, cp: number, x: number, diff --git a/lib/box-drawing/blocks.ts b/lib/box-drawing/blocks.ts deleted file mode 100644 index 8f9a783..0000000 --- a/lib/box-drawing/blocks.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Block element renderer (U+2580..U+259F). - * - * Ports the structure of Ghostty's `block.zig`: named fraction constants - * (`block.zig:19-28`), a generic `block(alignment, wFrac, hFrac)` helper - * (`block.zig:111-152`), a `quadrant({tl, tr, bl, br})` helper for the - * multi-corner combinations (`block.zig:168-177`), and a shade helper - * for โ–‘โ–’โ–“ that maps to the same alpha levels Ghostty bakes into its - * sprite atlas (`common.zig:42-51`: 0x40 / 0x80 / 0xc0 = 0.251 / 0.502 - * / 0.753). - */ - -// Named fractions, matching block.zig:19-28. -const ONE_EIGHTH = 1 / 8; -const ONE_QUARTER = 1 / 4; -const THREE_EIGHTHS = 3 / 8; -const HALF = 1 / 2; -const FIVE_EIGHTHS = 5 / 8; -const THREE_QUARTERS = 3 / 4; -const SEVEN_EIGHTHS = 7 / 8; - -/** - * Where in the cell to anchor a partial-cell `block`. - * - `'upper'`: full width, anchored to the cell top. - * - `'lower'`: full width, anchored to the cell bottom. - * - `'left'`: full height, anchored to the cell left. - * - `'right'`: full height, anchored to the cell right. - * - * Mirrors Ghostty's `Alignment` named constants (`common.zig:92-95`). - */ -type Alignment = 'upper' | 'lower' | 'left' | 'right'; - -interface Quads { - tl?: boolean; - tr?: boolean; - bl?: boolean; - br?: boolean; -} - -/** - * Fill a fractional sub-rectangle of the cell, anchored per `alignment`. - * Sizes are given as fractions of the cell so the same call shape works - * for halves, eighths, and full-cell rendering. - */ -function block( - ctx: CanvasRenderingContext2D, - ox: number, - oy: number, - cw: number, - ch: number, - color: string, - alignment: Alignment, - wFrac: number, - hFrac: number -): void { - const w = cw * wFrac; - const h = ch * hFrac; - let dx = 0; - let dy = 0; - switch (alignment) { - case 'upper': - dx = (cw - w) / 2; - dy = 0; - break; - case 'lower': - dx = (cw - w) / 2; - dy = ch - h; - break; - case 'left': - dx = 0; - dy = (ch - h) / 2; - break; - case 'right': - dx = cw - w; - dy = (ch - h) / 2; - break; - } - ctx.fillStyle = color; - ctx.fillRect(ox + dx, oy + dy, w, h); -} - -/** - * Fill any subset of the cell's four 2x2 quadrants. Ports Ghostty's - * `quadrant` (`block.zig:168-177`) for the โ––โ–—โ–˜โ–™โ–šโ–›โ–œโ–โ–žโ–Ÿ family. - */ -function quadrant( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - color: string, - q: Quads -): void { - ctx.fillStyle = color; - const hw = w / 2; - const hh = h / 2; - if (q.tl) ctx.fillRect(x, y, hw, hh); - if (q.tr) ctx.fillRect(x + hw, y, hw, hh); - if (q.bl) ctx.fillRect(x, y + hh, hw, hh); - if (q.br) ctx.fillRect(x + hw, y + hh, hw, hh); -} - -/** - * Fill the entire cell at a fractional opacity. Used for โ–‘โ–’โ–“. - */ -function fullShade( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - color: string, - alpha: number -): void { - ctx.save(); - ctx.globalAlpha *= alpha; - ctx.fillStyle = color; - ctx.fillRect(x, y, w, h); - ctx.restore(); -} - -/** - * Draw a U+2580..U+259F glyph into the cell at (x, y, w, h). - * Returns true if the codepoint was handled. - */ -export function drawBlockElement( - ctx: CanvasRenderingContext2D, - cp: number, - x: number, - y: number, - w: number, - h: number, - color: string -): boolean { - switch (cp) { - // โ–€โ–„โ–Œโ– โ€” halves. - case 0x2580: // โ–€ upper half - block(ctx, x, y, w, h, color, 'upper', 1, HALF); - return true; - case 0x2584: // โ–„ lower half - block(ctx, x, y, w, h, color, 'lower', 1, HALF); - return true; - case 0x258c: // โ–Œ left half - block(ctx, x, y, w, h, color, 'left', HALF, 1); - return true; - case 0x2590: // โ– right half - block(ctx, x, y, w, h, color, 'right', HALF, 1); - return true; - - // โ–”โ–• โ€” top and right one-eighth strokes. - case 0x2594: // โ–” upper 1/8 - block(ctx, x, y, w, h, color, 'upper', 1, ONE_EIGHTH); - return true; - case 0x2595: // โ–• right 1/8 - block(ctx, x, y, w, h, color, 'right', ONE_EIGHTH, 1); - return true; - - // โ–โ–‚โ–ƒโ–…โ–†โ–‡ โ€” lower-eighths family. Each fills the bottom n/8 of the cell. - case 0x2581: // โ– lower 1/8 - block(ctx, x, y, w, h, color, 'lower', 1, ONE_EIGHTH); - return true; - case 0x2582: // โ–‚ lower 2/8 - block(ctx, x, y, w, h, color, 'lower', 1, ONE_QUARTER); - return true; - case 0x2583: // โ–ƒ lower 3/8 - block(ctx, x, y, w, h, color, 'lower', 1, THREE_EIGHTHS); - return true; - case 0x2585: // โ–… lower 5/8 - block(ctx, x, y, w, h, color, 'lower', 1, FIVE_EIGHTHS); - return true; - case 0x2586: // โ–† lower 6/8 - block(ctx, x, y, w, h, color, 'lower', 1, THREE_QUARTERS); - return true; - case 0x2587: // โ–‡ lower 7/8 - block(ctx, x, y, w, h, color, 'lower', 1, SEVEN_EIGHTHS); - return true; - - // โ–ˆ full block. - case 0x2588: - ctx.fillStyle = color; - ctx.fillRect(x, y, w, h); - return true; - - // โ–‰โ–Šโ–‹โ–โ–Žโ– โ€” left-eighths family. Each fills the left n/8 of the cell. - case 0x2589: // โ–‰ left 7/8 - block(ctx, x, y, w, h, color, 'left', SEVEN_EIGHTHS, 1); - return true; - case 0x258a: // โ–Š left 6/8 - block(ctx, x, y, w, h, color, 'left', THREE_QUARTERS, 1); - return true; - case 0x258b: // โ–‹ left 5/8 - block(ctx, x, y, w, h, color, 'left', FIVE_EIGHTHS, 1); - return true; - case 0x258d: // โ– left 3/8 - block(ctx, x, y, w, h, color, 'left', THREE_EIGHTHS, 1); - return true; - case 0x258e: // โ–Ž left 2/8 - block(ctx, x, y, w, h, color, 'left', ONE_QUARTER, 1); - return true; - case 0x258f: // โ– left 1/8 - block(ctx, x, y, w, h, color, 'left', ONE_EIGHTH, 1); - return true; - - // โ–‘โ–’โ–“ โ€” shades. - case 0x2591: // โ–‘ light shade - fullShade(ctx, x, y, w, h, color, 0.25); - return true; - case 0x2592: // โ–’ medium shade - fullShade(ctx, x, y, w, h, color, 0.5); - return true; - case 0x2593: // โ–“ dark shade - fullShade(ctx, x, y, w, h, color, 0.75); - return true; - - // โ––โ–—โ–˜โ– โ€” single-quadrant blocks. - case 0x2596: // โ–– lower-left - quadrant(ctx, x, y, w, h, color, { bl: true }); - return true; - case 0x2597: // โ–— lower-right - quadrant(ctx, x, y, w, h, color, { br: true }); - return true; - case 0x2598: // โ–˜ upper-left - quadrant(ctx, x, y, w, h, color, { tl: true }); - return true; - case 0x259d: // โ– upper-right - quadrant(ctx, x, y, w, h, color, { tr: true }); - return true; - - // โ–™โ–šโ–›โ–œโ–žโ–Ÿ โ€” multi-quadrant combinations. - case 0x2599: // โ–™ - quadrant(ctx, x, y, w, h, color, { tl: true, bl: true, br: true }); - return true; - case 0x259a: // โ–š - quadrant(ctx, x, y, w, h, color, { tl: true, br: true }); - return true; - case 0x259b: // โ–› - quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, bl: true }); - return true; - case 0x259c: // โ–œ - quadrant(ctx, x, y, w, h, color, { tl: true, tr: true, br: true }); - return true; - case 0x259e: // โ–ž - quadrant(ctx, x, y, w, h, color, { tr: true, bl: true }); - return true; - case 0x259f: // โ–Ÿ - quadrant(ctx, x, y, w, h, color, { tr: true, bl: true, br: true }); - return true; - } - return false; -} diff --git a/lib/box-drawing/common.ts b/lib/box-drawing/common.ts deleted file mode 100644 index e1b502e..0000000 --- a/lib/box-drawing/common.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Shared types and helpers for box-drawing and block-element rendering. - * - * The thickness model matches Ghostty's `Thickness` enum: a base "light" - * thickness measured from the font's own U+2500 'โ”€' glyph (passed in - * by the caller as `lightPx`), with heavy = 2 ร— light. Double lines are - * two parallel light strokes separated by a gap of one light thickness - * (so a double stroke spans 3 ร— light total). - */ - -// Edge weight: none, light (single thin line), heavy (single thick line), -// or double (two parallel thin lines with a 1-light gap between them). -export type Weight = 0 | 1 | 2 | 3; -export const N: Weight = 0; -export const L: Weight = 1; -export const H: Weight = 2; -export const D: Weight = 3; - -export function heavyThickness(lightPx: number): number { - return lightPx * 2; -} diff --git a/lib/box-drawing/index.ts b/lib/box-drawing/index.ts deleted file mode 100644 index ffff3e2..0000000 --- a/lib/box-drawing/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Box-drawing and Block-element renderer. - * - * Glyphs in U+2500..U+259F (box drawing + block elements) are designed to - * tile seamlessly across cells. Letting the font render them is fragile: - * - The font's advance width may not match our cell width exactly, - * leaving gaps (or overlaps) between adjacent box-drawing chars. - * - The font's chosen ascent/descent for these glyphs rarely matches - * the cell height we need for descender-safe text rendering, so - * vertical lines and full blocks leave gaps between rows. - * - Different fonts encode these glyphs with different proportions, so - * visual consistency is poor. - * - * Drawing them as canvas paths sized to the cell is the standard fix โ€” - * Alacritty, kitty, wezterm, Ghostty native, and Windows Terminal all do - * this. It keeps the cell-height choice decoupled from the font's - * box-drawing glyph design. - */ - -import { drawBlockElement } from './blocks'; -import { drawBoxLine } from './lines'; - -/** - * Returns true if the codepoint is a box-drawing or block-element glyph - * that we render directly. Caller should skip the font path in that case. - */ -export function isBoxOrBlock(codepoint: number): boolean { - return codepoint >= 0x2500 && codepoint <= 0x259f; -} - -/** - * Render a box-drawing or block-element glyph into the cell at (x, y, w, h). - * - * - `color` is the css color string used for the foreground stroke/fill. - * - `lightPx` is the font-derived light box-stroke thickness in CSS - * pixels (heavy is 2ร— this; double is two parallels separated by - * one light gap, totaling 3ร— this). Use the `boxThickness` value - * measured in `CanvasRenderer.measureFont`. - * - * Returns true if the glyph was handled; false if the caller should - * fall back to font rendering. - */ -export function drawBoxOrBlock( - ctx: CanvasRenderingContext2D, - codepoint: number, - x: number, - y: number, - w: number, - h: number, - color: string, - lightPx: number -): boolean { - if (codepoint >= 0x2580 && codepoint <= 0x259f) { - return drawBlockElement(ctx, codepoint, x, y, w, h, color); - } - if (codepoint >= 0x2500 && codepoint <= 0x257f) { - return drawBoxLine(ctx, codepoint, x, y, w, h, color, lightPx); - } - return false; -} From ef45ab1dbc18c1c50db12bd2e82542c119389f51 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 14:52:45 -0700 Subject: [PATCH 12/17] fix: drop redundant canvas resize in terminal.ts; tighten box-drawing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass review caught five issues: 1. **HIGH โ€” `terminal.ts` stomps the canvas backing store** (lines 259-262 and 722-725, pre-existing in `e879eef`). After `renderer.resize(...)` correctly sets both the CSS size and the device-pixel-scaled backing-store size, terminal.ts redundantly re-sets them with `metrics.width * cols` (no DPR scaling, fractional values truncated). With the new fractional `metrics.width` from the font-metrics fix, this drops sub-pixel and breaks high-DPI rendering after font/size changes. Fix: delete the redundant assignments โ€” the renderer already handles canvas sizing. 2. **MEDIUM โ€” vacuous regression check for โ•”.** The previous "no crossing parallels" assertion checked whether any rect was entirely contained in the cell's upper-left ninth โ€” which can never be true given the actual rect extents. Replaced with concrete coordinate assertions on all four expected rects, plus a positive check that the inner-corner test point isn't covered by any rect. 3. **MEDIUM โ€” dash tests re-derived the implementation.** The horizontal dash assertion recomputed `min(desired_gap, floor(span/(2*count)))` in the test and compared it to the implementation's output, so any shared bug would pass. Replaced with hand-computed expected coordinates for a known cell size, plus invariant checks (half-gap-on-each-side for horizontal, full-gap-pushed-to-bottom for vertical per box.zig:878-881). 4. **MEDIUM โ€” coverage test wouldn't catch dispatch swaps.** "Every codepoint draws something" passes if โ–– and โ–— get swapped in the case list. Added `test.each` per-codepoint quadrant assertions for all 9 quadrant glyphs, so any swap surfaces. 5. **LOW โ€” degenerate-dash fallback used dash weight, not light.** When the cell is too small to hold the dash run, the fallback should draw a LIGHT line (Ghostty's `vlineMiddle(.light)` / `hlineMiddle(.light)`, box.zig:812/891), not a heavy bar for heavy dashes. Fixed and added a test. Also updated the doc comment claiming `0x40/0x80/0xc0 = 0.251/0.502/ 0.753` matches our `0.25/0.5/0.75` "exactly" โ€” they differ by under 0.003. Visually indistinguishable but the comment was overstated. 379 tests pass (was 369; +10 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.test.ts | 156 ++++++++++++++++++++++++++++++++-------- lib/box-drawing.ts | 24 +++++-- lib/terminal.ts | 21 ++---- 3 files changed, 149 insertions(+), 52 deletions(-) diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index ddccbfb..ce5713e 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -233,6 +233,28 @@ describe('box-drawing', () => { { x: CW / 2, y: CH / 2, w: CW / 2, h: CH / 2 }, // br ]); }); + + // Per-codepoint quadrant assertions catch dispatch swaps (e.g. if + // โ––โ†”โ–— get switched in the case list, the bare "draws something" + // coverage test wouldn't notice โ€” this would). + const tl = { x: 0, y: 0, w: CW / 2, h: CH / 2 }; + const tr = { x: CW / 2, y: 0, w: CW / 2, h: CH / 2 }; + const bl = { x: 0, y: CH / 2, w: CW / 2, h: CH / 2 }; + const br = { x: CW / 2, y: CH / 2, w: CW / 2, h: CH / 2 }; + test.each([ + [0x2596, 'lower-left โ––', [bl]], + [0x2597, 'lower-right โ–—', [br]], + [0x2598, 'upper-left โ–˜', [tl]], + [0x259d, 'upper-right โ–', [tr]], + [0x259a, 'tl + br โ–š', [tl, br]], + [0x259e, 'tr + bl โ–ž', [tr, bl]], + [0x259b, 'tl + tr + bl โ–›', [tl, tr, bl]], + [0x259c, 'tl + tr + br โ–œ', [tl, tr, br]], + [0x259f, 'tr + bl + br โ–Ÿ', [tr, bl, br]], + ])('U+%s quadrant glyph %s', (cp, _name, expected) => { + expect(rectsOnly(draw(cp).ctx.ops)).toEqual(expected); + }); + test('โ–‘ U+2591 light shade applies 0.25 alpha multiplier', () => { const { ctx } = draw(0x2591); const alphaOp = ctx.ops.find((o) => o.kind === 'globalAlpha'); @@ -325,21 +347,50 @@ describe('box-drawing', () => { ); expect(crossBarCovered).toBe(true); }); - test('โ•” U+2554 double down-right corner forms inner L (no crossing parallels)', () => { - // Bug regression check: in the buggy version, the right and down - // double parallels would extend all the way to the cell center, - // crossing each other. Ghostty stops each parallel at the inner - // edge of the orthogonal stroke. + test('โ•” U+2554 double down-right corner: junction-aware inner L', () => { + // Regression check for the junction-aware-endpoints fix. With + // CW=10, CH=20, LT=1, the four expected rects are derived from + // box.zig's `linesChar` algorithm: + // v_light_left=4.5, v_light_right=5.5 + // v_double_left=3.5, v_double_right=6.5 + // h_light_top=9.5, h_light_bottom=10.5 + // h_double_top=8.5, h_double_bottom=11.5 + // + // The OUTER L (top-left of the corner) is formed by rect (1) + + // rect (3) meeting at (v_double_left, h_double_top). The INNER + // strokes stop at the perpendicular's inner edge (rect 2 starts + // at v_light_right, rect 4 starts at h_light_bottom), so the + // upper-left interior of the corner is left empty โ€” that's what + // makes a clean double-line corner instead of crossing parallels. const { ctx } = draw(0x2554); const rects = rectsOnly(ctx.ops); - // Should be 4 rects: top horizontal, bottom horizontal, left - // vertical, right vertical โ€” each with junction-aware endpoints. - expect(rects.length).toBe(4); - // No rect should occupy the upper-left quadrant interior (โ‰ˆ - // 1/3 of cell from top-left). - const inUpperLeft = (r: typeof rects[0]) => - r.x + r.w <= CW / 3 && r.y + r.h <= CH / 3; - expect(rects.every((r) => !inUpperLeft(r))).toBe(true); + expect(rects).toEqual([ + // Top outer horizontal: from outer-left to right edge. + { x: 3.5, y: 8.5, w: 6.5, h: 1 }, + // Top inner horizontal: starts at v_light_right (the inner + // corner), so it does NOT cross the upper-left quadrant. + { x: 5.5, y: 10.5, w: 4.5, h: 1 }, + // Left outer vertical: from outer-top to bottom edge. + { x: 3.5, y: 8.5, w: 1, h: 11.5 }, + // Right inner vertical: starts at h_light_bottom, doesn't + // cross the upper-left quadrant. + { x: 5.5, y: 10.5, w: 1, h: 9.5 }, + ]); + + // The buggy version had every parallel extending to cell center, + // so a rect would have covered the open inner area. Sanity-check + // by asserting no rect touches the inner-corner test point that + // should remain empty. + const innerOpenX = 4.5; // just above v_light_right + const innerOpenY = 10; // just below h_light_top + for (const r of rects) { + const covers = + r.x <= innerOpenX && + innerOpenX < r.x + r.w && + r.y <= innerOpenY && + innerOpenY < r.y + r.h; + expect(covers).toBe(false); + } }); }); @@ -355,32 +406,79 @@ describe('box-drawing', () => { }); describe('dashes', () => { - test('โ”„ U+2504 horizontal triple-dash: 3 rects, half-gap on each side', () => { + // Concrete expected coordinates rather than re-deriving the + // implementation in the test. These were hand-computed from + // Ghostty's `dashHorizontal`/`dashVertical` algorithm against the + // standard CW=10, CH=20, LT=1 cell. + + test('โ”„ U+2504 horizontal triple-dash: half-gaps on each side, extra in dashes', () => { + // desired_gap = max(4, 1) = 4, cap = floor(10/6) = 1, gap = 1 + // total_gap = 3, total_dash = 7, dash_w = 2, extra = 1 (goes to dash 0) + // pos starts at floor(1/2) = 0 + // โ†’ dash 0 at x=0 w=3, dash 1 at x=4 w=2, dash 2 at x=7 w=2 const { ctx } = draw(0x2504); const rects = rectsOnly(ctx.ops); - expect(rects).toHaveLength(3); - // First dash starts at half a gap in. - const desiredGap = Math.max(4, LT); - const cap = Math.floor(CW / (2 * 3)); - const gap = Math.min(desiredGap, cap); - expect(rects[0].x).toBe(Math.floor(gap / 2)); + const cy = (CH - LT) / 2; + expect(rects).toEqual([ + { x: 0, y: cy, w: 3, h: LT }, + { x: 4, y: cy, w: 2, h: LT }, + { x: 7, y: cy, w: 2, h: LT }, + ]); + // Half-gap-on-each-side invariant: the leftmost dash starts at + // floor(gap/2) and the rightmost dash ends at total_run - + // floor(gap/2). Same gap on both sides โ†’ adjacent dashed cells + // tile. + const lastDash = rects[rects.length - 1]; + expect(rects[0].x).toBe(0); + expect(CW - (lastDash.x + lastDash.w)).toBe(1); }); - test('โ”† U+2506 vertical triple-dash: 3 rects, starts at top (no half-gap)', () => { + + test('โ”† U+2506 vertical triple-dash: zero gap at top, full gap at bottom', () => { + // desired_gap=4, cap=floor(20/6)=3, gap=3 + // total_gap=9, total_dash=11, dash_h=3, extra=2 + // pos starts at 0 + // โ†’ dash 0 at y=0 h=4, dash 1 at y=7 h=4, dash 2 at y=14 h=3 const { ctx } = draw(0x2506); const rects = rectsOnly(ctx.ops); - expect(rects).toHaveLength(3); - // Per Ghostty box.zig:907-909: vertical dashes start at y=0 with - // the full extra gap pushed to the bottom. This is the asymmetry - // the original port missed. + const cx = (CW - LT) / 2; + expect(rects).toEqual([ + { x: cx, y: 0, w: LT, h: 4 }, + { x: cx, y: 7, w: LT, h: 4 }, + { x: cx, y: 14, w: LT, h: 3 }, + ]); + // The Ghostty asymmetry invariant (box.zig:878-881): no gap on + // top, full gap below the last dash. expect(rects[0].y).toBe(0); + const last = rects[rects.length - 1]; + expect(CH - (last.y + last.h)).toBe(3); // == gap_width }); + test('โ”ˆ U+2508 horizontal quad-dash: 4 rects', () => { - const { ctx } = draw(0x2508); - expect(rectsOnly(ctx.ops)).toHaveLength(4); + expect(rectsOnly(draw(0x2508).ctx.ops)).toHaveLength(4); }); test('โ•Œ U+254C horizontal double-dash: 2 rects', () => { - const { ctx } = draw(0x254c); - expect(rectsOnly(ctx.ops)).toHaveLength(2); + expect(rectsOnly(draw(0x254c).ctx.ops)).toHaveLength(2); + }); + test('heavy-dash degenerate fallback uses LIGHT thickness, not heavy', () => { + // When the cell is too small to hold `count + count` of anything, + // the implementation falls back to a solid line. Ghostty falls + // back to a LIGHT line regardless of dash weight (vlineMiddle/ + // hlineMiddle take .light), so a heavy dash at a tiny cell size + // shouldn't suddenly turn into a heavy bar. + const ctx = makeCtx(); + drawBoxOrBlock( + ctx as unknown as CanvasRenderingContext2D, + 0x2505, // โ”โ”โ” heavy triple dash + 0, + 0, + 2, // tiny cell โ€” degenerate + 20, + COLOR, + 1 + ); + const rects = rectsOnly(ctx.ops); + expect(rects).toHaveLength(1); + expect(rects[0].h).toBe(1); // LIGHT, not 2 (heavy) }); }); diff --git a/lib/box-drawing.ts b/lib/box-drawing.ts index c7bb889..2c35e28 100644 --- a/lib/box-drawing.ts +++ b/lib/box-drawing.ts @@ -93,9 +93,10 @@ export function drawBoxOrBlock( // (`block.zig:19-28`), a generic `block(alignment, wFrac, hFrac)` helper // (`block.zig:111-152`), a `quadrant({tl,tr,bl,br})` helper for the // multi-corner combinations (`block.zig:168-177`), and a shade helper -// for โ–‘โ–’โ–“ that maps to the same alpha levels Ghostty bakes into its -// sprite atlas (`common.zig:42-51`: 0x40 / 0x80 / 0xc0 = 0.251 / 0.502 / -// 0.753). +// for โ–‘โ–’โ–“. Ghostty bakes alpha levels of 0x40 / 0x80 / 0xc0 into its +// sprite atlas (`common.zig:42-51`), i.e. 0.251 / 0.502 / 0.753; we +// use 0.25 / 0.5 / 0.75, which differs by under 0.003 โ€” visually +// indistinguishable but worth noting. // ============================================================================ // Named fractions, matching block.zig:19-28. @@ -841,9 +842,9 @@ function drawDashed( const desired_gap = Math.max(4, lt); if (dash.vertical) { - drawDashRun(ctx, ox + (w - t) / 2, oy, t, h, count, desired_gap, true); + drawDashRun(ctx, ox + (w - t) / 2, oy, t, h, count, desired_gap, lt, true); } else { - drawDashRun(ctx, ox, oy + (h - t) / 2, w, t, count, desired_gap, false); + drawDashRun(ctx, ox, oy + (h - t) / 2, w, t, count, desired_gap, lt, false); } } @@ -855,14 +856,23 @@ function drawDashRun( h: number, count: number, desired_gap: number, + lt: number, vertical: boolean ): void { const span = vertical ? h : w; // Below this size the dashes degenerate to nothing โ€” fall back to a - // solid line so the run still tiles with its neighbors. + // solid LIGHT line, matching Ghostty's `vlineMiddle(.light)` / + // `hlineMiddle(.light)` (box.zig:812, 891). Heavy dashes degenerate + // to a light line, not a heavy bar. if (span < count + count) { - ctx.fillRect(x, y, w, h); + if (vertical) { + const cx = x + (w - lt) / 2; + ctx.fillRect(cx, y, lt, h); + } else { + const cy = y + (h - lt) / 2; + ctx.fillRect(x, cy, w, lt); + } return; } diff --git a/lib/terminal.ts b/lib/terminal.ts index be210b1..5eb0132 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -251,16 +251,11 @@ export class Terminal implements ITerminalCore { this.selectionManager.clearSelection(); } - // Resize canvas to match new font metrics + // Resize canvas to match new font metrics. The renderer sets both + // the CSS size and the device-pixel-scaled backing-store size; we + // must not stomp those here. this.renderer.resize(this.cols, this.rows); - // Update canvas element dimensions to match renderer - const metrics = this.renderer.getMetrics(); - this.canvas.width = metrics.width * this.cols; - this.canvas.height = metrics.height * this.rows; - this.canvas.style.width = `${metrics.width * this.cols}px`; - this.canvas.style.height = `${metrics.height * this.rows}px`; - // Force full re-render with new font this.renderer.render(this.wasmTerm, true, this.viewportY, this); } @@ -714,16 +709,10 @@ export class Terminal implements ITerminalCore { // Resize WASM terminal (may reallocate buffers, invalidating TypedArray views) this.wasmTerm!.resize(cols, rows); - // Resize renderer + // Resize renderer (sets both CSS size and device-pixel-scaled + // backing-store size โ€” we must not stomp those here). this.renderer!.resize(cols, rows); - // Update canvas dimensions - const metrics = this.renderer!.getMetrics(); - this.canvas!.width = metrics.width * cols; - this.canvas!.height = metrics.height * rows; - this.canvas!.style.width = `${metrics.width * cols}px`; - this.canvas!.style.height = `${metrics.height * rows}px`; - // Fire resize event this.resizeEmitter.fire({ cols, rows }); From 44e2c17d06bc60a416708369c839b66d44bc0bec Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 21:25:59 -0700 Subject: [PATCH 13/17] fix(renderer): clear() coordinate-space mismatch + doc/test polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-pass review caught: 1. **HIGH โ€” `renderer.clear()` mixes coordinate spaces.** After `resize()` calls `ctx.scale(dpr, dpr)`, all context coordinates are CSS pixels, but `clearRect`/`fillRect` were being passed `canvas.width`/`canvas.height` (device pixels). Internally clamped so it didn't visibly misbehave, but conceptually the same bug pattern as the canvas-stomp fix in `ef45ab1`. Pre-existing, but worth fixing in the same pass. Now divides by DPR. 2. **MEDIUM โ€” `boxThickness` doc was misleading at typical sizes.** The comment listed pre-rounding measurements (Monaco @14pt โ†’ 1.27, Menlo โ†’ 1.18, Courier โ†’ 1.00) implying meaningful per-font variation, but `Math.round` collapses all three to 1 at 14pt. Variation does come through at larger sizes (28pt: Monaco โ†’ 3, Menlo โ†’ 2, Courier โ†’ 2), so the comment now uses 28pt examples and notes the small-font rounding behaviour explicitly. 3. **MEDIUM โ€” quadrant `test.each` list could mislead a future editor.** โ–™ (U+2599) is intentionally tested separately above and omitted from the list; added a comment so a future contributor doesn't add it back as a duplicate. 4. **LOW โ€” shade-test alpha tolerance was 1e-9.** A future change to match Ghostty's exact `0x40/255 = 0.2509โ€ฆ` alpha would have broken the test for no good reason. Relaxed to `toBeCloseTo(0.25, 2)` which covers both values. Other findings from the review (gap_width edge case, saturating subtraction in drawEdges, comment about font being set per-cell) were either non-issues at realistic sizes, defensive-only, or taste-level and not worth code churn. 379 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.test.ts | 11 +++++++---- lib/renderer.ts | 24 +++++++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index ce5713e..64fd8a4 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -236,7 +236,9 @@ describe('box-drawing', () => { // Per-codepoint quadrant assertions catch dispatch swaps (e.g. if // โ––โ†”โ–— get switched in the case list, the bare "draws something" - // coverage test wouldn't notice โ€” this would). + // coverage test wouldn't notice โ€” this would). 9 of 10 quadrant + // glyphs are listed here; โ–™ (U+2599) is covered by the dedicated + // test above so it's not duplicated here. const tl = { x: 0, y: 0, w: CW / 2, h: CH / 2 }; const tr = { x: CW / 2, y: 0, w: CW / 2, h: CH / 2 }; const bl = { x: 0, y: CH / 2, w: CW / 2, h: CH / 2 }; @@ -255,13 +257,14 @@ describe('box-drawing', () => { expect(rectsOnly(draw(cp).ctx.ops)).toEqual(expected); }); - test('โ–‘ U+2591 light shade applies 0.25 alpha multiplier', () => { + test('โ–‘ U+2591 light shade applies ~25% alpha multiplier', () => { const { ctx } = draw(0x2591); const alphaOp = ctx.ops.find((o) => o.kind === 'globalAlpha'); expect(alphaOp).toBeTruthy(); - // We do `globalAlpha *= 0.25`, starting from 1 โ†’ 0.25. + // Tolerance covers both our 0.25 and Ghostty's 0x40/255 = 0.2509โ€ฆ + // A future change to match Ghostty exactly should still pass. if (alphaOp && alphaOp.kind === 'globalAlpha') { - expect(alphaOp.v).toBeCloseTo(0.25, 9); + expect(alphaOp.v).toBeCloseTo(0.25, 2); } // And the alpha is applied within save/restore. expect(ctx.ops[0]?.kind).toBe('save'); diff --git a/lib/renderer.ts b/lib/renderer.ts index 7c1a371..072d405 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -247,11 +247,14 @@ export class CanvasRenderer { const baseline = ascent; // Box-drawing stroke thickness, measured from the font's actual - // U+2500 'โ”€' glyph. This gives the font designer's intended weight - // for box-drawing lines, which varies meaningfully across fonts - // (Monaco @14pt โ†’ 1.27, Menlo @14pt โ†’ 1.18, Courier @14pt โ†’ 1.00). - // Falls back to ~7% of font size if the font lacks the glyph (some - // browsers report 0 then) โ€” close to the typical underline weight. + // U+2500 'โ”€' glyph. The pre-rounding value reflects the font + // designer's intended weight (Monaco @28pt โ†’ 2.54, Menlo @28pt + // โ†’ 2.35, Courier @28pt โ†’ 2.01). Math.round means small fonts + // collapse to 1px regardless of font (Monaco/Menlo/Courier all + // round to 1 at 14pt), but at larger sizes the variation comes + // through. Falls back to ~7% of font size if the font lacks the + // glyph (some browsers report 0) โ€” close to typical underline + // weight. min 1 so the thinnest possible stroke is still visible. const dash = ctx.measureText('โ”€'); const dashHeight = (dash.actualBoundingBoxAscent ?? 0) + (dash.actualBoundingBoxDescent ?? 0); const boxThickness = Math.max(1, Math.round(dashHeight || this.fontSize * 0.07)); @@ -1064,11 +1067,18 @@ export class CanvasRenderer { * Clear entire canvas */ public clear(): void { + // The context is DPR-scaled by `resize()`, so its drawing coordinates + // are CSS pixels. `canvas.width`/`canvas.height` are device pixels; + // dividing by DPR converts them to CSS pixels for the clearRect/ + // fillRect calls. Without the division, we'd be asking the canvas + // to clear/fill DPRร— the actual area (clamped internally, but wrong). + const cssWidth = this.canvas.width / this.devicePixelRatio; + const cssHeight = this.canvas.height / this.devicePixelRatio; // clearRect first because fillRect composites rather than replaces, // so transparent/translucent backgrounds wouldn't clear previous content. - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.clearRect(0, 0, cssWidth, cssHeight); this.ctx.fillStyle = this.theme.background; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.fillRect(0, 0, cssWidth, cssHeight); } /** From 0a3f01d0d09ed76a13c4b6d3e2281cc821323031 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Fri, 8 May 2026 23:20:26 -0700 Subject: [PATCH 14/17] fix(box-drawing): defensive bounds + cleanup remaining review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address remaining findings from third-pass review: - **drawDashRun gap_width lower bound** (Finding #2): Math.floor on fractional `span/(2*count)` could theoretically return 0 at the exact boundary, though the early-return guarantees `span >= 2*count` mathematically. Added `Math.max(1, ...)` to make the invariant explicit, matching Ghostty's `assert(dash_width >= 1)` on integer arithmetic (box.zig:824). - **Degenerate-dash fractional-threshold note** (Finding #3): Ghostty compares integer `cell_width < count + count`; we compare fractional `span`. Added a comment noting the boundary behaviour may differ for fractional values just above the threshold. - **Saturating subtraction in drawEdges** (Finding #6): Ghostty uses `-|` so values can't go negative when light_px > cell. Our `-` can produce negative `h_double_top` etc. at degenerate-tiny cells. Added `Math.max(0, ...)` clamps to all six h_*/v_* coordinates that subtract. - **resize() font-property comment** (Finding #7): Noted that `ctx.font` is intentionally not re-set after the canvas-width reset, because `renderCellText` sets it per-cell to handle italic /bold. - **Coverage-test docstring** (Finding #9): Spelled out that the "every codepoint draws something" check is exhaustive but intentionally weak โ€” it catches missing dispatch entries, not wrong ones; per-glyph shape assertions later in the file do the correctness checking. - **renderCellText box-or-block dispatch** (Finding #10): Hoisted the `cell.grapheme_len === 0 && cell.codepoint && isBoxOrBlock(...)` predicate into a named local and collapsed the grapheme/codepoint fallback into a single ternary. 379 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.test.ts | 10 +++++++--- lib/box-drawing.ts | 35 ++++++++++++++++++++++++++--------- lib/renderer.ts | 27 ++++++++++++++------------- 3 files changed, 47 insertions(+), 25 deletions(-) diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index 64fd8a4..cffba10 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -173,9 +173,13 @@ describe('box-drawing', () => { }); describe('coverage', () => { - // Every codepoint in the range should produce drawing ops, with no - // exceptions. A regression here means a glyph is silently falling - // back to font rendering. + // Exhaustive check that every codepoint in the range U+2500..U+259F + // (160 codepoints) produces at least one drawing op. A regression + // means a glyph is silently falling back to font rendering. This + // test is weak on its own โ€” a dispatch swap (โ––โ†”โ–—) would still + // pass โ€” so the per-glyph shape assertions later in this file + // do the actual correctness checking. This test catches *missing* + // dispatch entries, not *wrong* ones. test('every codepoint U+2500..U+259F draws something', () => { const missing: number[] = []; for (let cp = 0x2500; cp <= 0x259f; cp++) { diff --git a/lib/box-drawing.ts b/lib/box-drawing.ts index 2c35e28..13a63d9 100644 --- a/lib/box-drawing.ts +++ b/lib/box-drawing.ts @@ -578,20 +578,24 @@ function drawEdges( ): void { const ht = heavyThickness(lt); - // Horizontal stroke positions (y coordinates). - const h_light_top = (h - lt) / 2; + // Horizontal stroke positions (y coordinates). At realistic cell + // sizes (lt โ‰ˆ 1, h โ‰ˆ 20) all of these are well-positive, but we + // clamp at 0 to match Ghostty's saturating-subtraction (`-|`, + // box.zig:408-435) so a degenerate-tiny cell doesn't produce + // negative-coordinate rects. + const h_light_top = Math.max(0, (h - lt) / 2); const h_light_bottom = h_light_top + lt; - const h_heavy_top = (h - ht) / 2; + const h_heavy_top = Math.max(0, (h - ht) / 2); const h_heavy_bottom = h_heavy_top + ht; - const h_double_top = h_light_top - lt; + const h_double_top = Math.max(0, h_light_top - lt); const h_double_bottom = h_light_bottom + lt; - // Vertical stroke positions (x coordinates). - const v_light_left = (w - lt) / 2; + // Vertical stroke positions (x coordinates). Same clamp. + const v_light_left = Math.max(0, (w - lt) / 2); const v_light_right = v_light_left + lt; - const v_heavy_left = (w - ht) / 2; + const v_heavy_left = Math.max(0, (w - ht) / 2); const v_heavy_right = v_heavy_left + ht; - const v_double_left = v_light_left - lt; + const v_double_left = Math.max(0, v_light_left - lt); const v_double_right = v_light_right + lt; // Bottom of the up-arm. @@ -865,6 +869,13 @@ function drawDashRun( // solid LIGHT line, matching Ghostty's `vlineMiddle(.light)` / // `hlineMiddle(.light)` (box.zig:812, 891). Heavy dashes degenerate // to a light line, not a heavy bar. + // + // Note: Ghostty compares integer cell sizes (`metrics.cell_width < + // count + count`); ours compares the fractional `span`. At the exact + // boundary (e.g. span = 4.0 with count = 2) the two implementations + // make the same decision, but for fractional values just above + // (e.g. 4.001) we'll proceed with full dash math while Ghostty + // would still produce a sensible result on integer pixels too. if (span < count + count) { if (vertical) { const cx = x + (w - lt) / 2; @@ -877,7 +888,13 @@ function drawDashRun( } // Cap the gap so dashes never shrink below half the available run. - const gap_width = Math.min(desired_gap, Math.floor(span / (2 * count))); + // The early-return above guarantees `span >= 2*count`, so + // `floor(span/(2*count)) >= 1` mathematically. The Math.max(1, ...) + // is defensive โ€” at fractional spans near the boundary the floor + // can't actually return 0 but the bound makes the invariant + // explicit (Ghostty asserts the same on integer arithmetic at + // box.zig:824). + const gap_width = Math.min(desired_gap, Math.max(1, Math.floor(span / (2 * count)))); const total_gap = gap_width * count; const total_dash = Math.floor(span - total_gap); const dash_width = Math.floor(total_dash / count); diff --git a/lib/renderer.ts b/lib/renderer.ts index 072d405..495f71f 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -302,7 +302,10 @@ export class CanvasRenderer { // 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 + // Re-set text rendering properties (the canvas.width/height write + // above reset them). `ctx.font` is intentionally NOT set here + // because `renderCellText` sets it per-cell to handle italic/bold + // โ€” restoring it here would just be a wasted write. this.ctx.textBaseline = 'alphabetic'; this.ctx.textAlign = 'left'; @@ -702,10 +705,10 @@ export class CanvasRenderer { // proportions the font designer gave U+2502 etc. // This is the standard approach in modern terminal renderers // (Alacritty, kitty, wezterm, Ghostty native). + const isSimpleBoxOrBlock = + cell.grapheme_len === 0 && cell.codepoint && isBoxOrBlock(cell.codepoint); if ( - cell.grapheme_len === 0 && - cell.codepoint && - isBoxOrBlock(cell.codepoint) && + isSimpleBoxOrBlock && drawBoxOrBlock( this.ctx, cell.codepoint, @@ -719,15 +722,13 @@ export class CanvasRenderer { ) { // Drawn directly; skip the font path. } else { - // Get the character to render - use grapheme lookup for complex scripts - let char: string; - if (cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString) { - // Cell has additional codepoints - get full grapheme cluster - char = this.currentBuffer.getGraphemeString(y, x); - } else { - // Simple cell - single codepoint - char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null - } + // Multi-codepoint grapheme (e.g. emoji, ZWJ sequence): look up + // the full cluster from the buffer. Otherwise: a single + // codepoint, or 32 (space) if the cell is empty. + const char = + cell.grapheme_len > 0 && this.currentBuffer?.getGraphemeString + ? this.currentBuffer.getGraphemeString(y, x) + : String.fromCodePoint(cell.codepoint || 32); this.ctx.fillText(char, textX, textY); } From c3da27b7e7e5cd7024dc3753e0ee41081815d45a Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Sat, 9 May 2026 11:34:34 -0700 Subject: [PATCH 15/17] fix(renderer): keep FontMetrics.boxThickness as an additive change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth-pass review caught: - **HIGH โ€” `FontMetrics.boxThickness` was a TypeScript breaking change.** The exported interface gained `boxThickness: number` as a required field, which would break any downstream consumer that constructs or implements `FontMetrics` (test mocks, custom renderers). Marked the field optional and added a fallback at the call site (`fontSize * 0.07`) so external code that doesn't supply a value still gets a reasonable default. The built-in `measureFont` always populates it. - **LOW โ€” Misleading saturating-subtraction comment.** The `Math.max(0, ...)` clamp prevents negative coordinates but doesn't prevent overdraw past the cell edge when `lt > h` โ€” same as Ghostty's `-|`. Updated the comment to spell that out. - **LOW โ€” `drawArc` center expression looked like it had an invariant it doesn't.** `(w - lt) / 2 + lt / 2` is verbatim from `box.zig:704-705` where Ghostty's integer division can differ from `w/2` for odd values; in JS it's mathematically equivalent. Annotated the line so a future reader doesn't read significance into the formulation. - **LOW โ€” `drawDashRun` widened `count` to `number`.** Restored the `2 | 3 | 4` narrow type from `Dashed.count` for type safety on the inner loop. Other fourth-pass findings (sub-pixel leakage in Math.floor for fractional cell heights, JSON.stringify hover-range comparison) were either non-issues at our integer-ceiled metrics or pre-existing code not in this PR's scope. The reviewer also independently verified 17 random EDGES entries against `box.zig` switch dispatch (all match), and confirmed all the prior-round fixes (vertical-dash asymmetry, desired_gap, junction endpoints, clear() coord space, terminal.ts canvas-stomp removal) are correct. 379 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.ts | 11 +++++++++-- lib/renderer.ts | 13 +++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/box-drawing.ts b/lib/box-drawing.ts index 13a63d9..7327ba1 100644 --- a/lib/box-drawing.ts +++ b/lib/box-drawing.ts @@ -582,7 +582,9 @@ function drawEdges( // sizes (lt โ‰ˆ 1, h โ‰ˆ 20) all of these are well-positive, but we // clamp at 0 to match Ghostty's saturating-subtraction (`-|`, // box.zig:408-435) so a degenerate-tiny cell doesn't produce - // negative-coordinate rects. + // negative-coordinate rects. Note: at `lt > h` derived values like + // `h_heavy_bottom = h_heavy_top + ht` can still extend past `h` โ€” + // that's also faithful to Ghostty (overdraw, not negative coords). const h_light_top = Math.max(0, (h - lt) / 2); const h_light_bottom = h_light_top + lt; const h_heavy_top = Math.max(0, (h - ht) / 2); @@ -738,6 +740,11 @@ function drawArc( color: string, lt: number ): void { + // Verbatim port of Ghostty's `(cell_width - thick_px) / 2 + thick_px / 2` + // (box.zig:704-705). On Ghostty's integer arithmetic this differs from + // `cell_width / 2` when `(cell - thick)` is odd; in JS floating-point + // the two are mathematically equal. Kept in this shape so the diff + // against box.zig stays line-for-line obvious. const center_x = (w - lt) / 2 + lt / 2; const center_y = (h - lt) / 2 + lt / 2; const r = Math.min(w, h) / 2; @@ -858,7 +865,7 @@ function drawDashRun( y: number, w: number, h: number, - count: number, + count: 2 | 3 | 4, desired_gap: number, lt: number, vertical: boolean diff --git a/lib/renderer.ts b/lib/renderer.ts index 495f71f..4dcc601 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -60,8 +60,13 @@ export interface FontMetrics { * font's actual U+2500 'โ”€' glyph extent (the font designer's chosen * thickness for box-drawing lines). Box-drawing rendering uses this * for "light" weight; "heavy" is 2ร— this. + * + * Optional in the public type to keep this an additive (non-breaking) + * change for downstream consumers that construct or mock FontMetrics + * objects. The built-in renderer always populates it; external code + * that calls `drawBoxOrBlock` directly must supply a value. */ - boxThickness: number; + boxThickness?: number; } // ============================================================================ @@ -707,6 +712,10 @@ export class CanvasRenderer { // (Alacritty, kitty, wezterm, Ghostty native). const isSimpleBoxOrBlock = cell.grapheme_len === 0 && cell.codepoint && isBoxOrBlock(cell.codepoint); + // boxThickness is optional in the public FontMetrics type for + // backward compat, but the built-in measureFont always sets it. + // Fall back to a font-size-derived value as a safety net. + const boxThickness = this.metrics.boxThickness ?? Math.max(1, Math.round(this.fontSize * 0.07)); if ( isSimpleBoxOrBlock && drawBoxOrBlock( @@ -717,7 +726,7 @@ export class CanvasRenderer { cellWidth, this.metrics.height, this.ctx.fillStyle as string, - this.metrics.boxThickness + boxThickness ) ) { // Drawn directly; skip the font path. From 2e20094a6035297f39a29c64d3cccccf4b5043f2 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Sun, 10 May 2026 01:13:09 -0700 Subject: [PATCH 16/17] fix(box-drawing): export public API + harden inputs + tighten weak tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth-pass review caught: - **`drawBoxOrBlock` / `isBoxOrBlock` weren't exported**, contradicting the FontMetrics doc that referenced "external code that calls `drawBoxOrBlock` directly". Added them to `lib/index.ts`. - **`drawBoxOrBlock` had no input hardening for public callers.** A 0ร—0 cell fed `0/0 = NaN` into `drawArc`'s slope math; a fractional `lightPx` produced sub-pixel dash positions that don't tile. Added `if (w <= 0 || h <= 0) return false` and `Math.max(1, Math.round(lightPx))` at the entry. Internal callers already passed valid values; this just hardens the public API. - **โ”ผ U+253C cross test was too weak**: only checked `rects.length >= 2` and that the cell-center pixel was covered. A refactor that dropped the LEFT or DOWN arm would still satisfy both. Now asserts exactly 4 rects and that vertical strokes collectively span y=0..CH at the horizontal center, and horizontal strokes collectively span x=0..CW at the vertical center โ€” so a missing arm fails immediately. - **Coverage test predicate accepted degenerate fillRects.** A bug that emitted a 0ร—0 fillRect for some glyph would have passed the "drewSomething" check. Tightened to require `w > 0 && h > 0` (or a stroke). 379 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.test.ts | 55 ++++++++++++++++++++++++++--------------- lib/box-drawing.ts | 10 +++++++- lib/index.ts | 1 + 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index cffba10..174c9b1 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -180,12 +180,17 @@ describe('box-drawing', () => { // pass โ€” so the per-glyph shape assertions later in this file // do the actual correctness checking. This test catches *missing* // dispatch entries, not *wrong* ones. - test('every codepoint U+2500..U+259F draws something', () => { + test('every codepoint U+2500..U+259F draws something visible', () => { const missing: number[] = []; for (let cp = 0x2500; cp <= 0x259f; cp++) { const { ctx, handled } = draw(cp); + // Require a *non-degenerate* fillRect or a stroke โ€” a 0ร—0 + // fillRect would slip through the looser `o.kind === ...` + // check. const drewSomething = ctx.ops.some( - (o) => o.kind === 'fillRect' || o.kind === 'stroke' + (o) => + (o.kind === 'fillRect' && o.w > 0 && o.h > 0) || + o.kind === 'stroke' ); if (!handled || !drewSomething) missing.push(cp); } @@ -333,26 +338,36 @@ describe('box-drawing', () => { expect(ys).toHaveLength(2); expect(ys[1] - ys[0]).toBeCloseTo(2 * LT, 9); }); - test('โ”ผ U+253C light cross = two perpendicular full-extent rects', () => { + test('โ”ผ U+253C light cross = all four arms cover their respective edges', () => { const { ctx } = draw(0x253c); const rects = rectsOnly(ctx.ops); - // โ”ผ: up + right + down + left, but symmetric junctions stop each - // arm at the edge of the perpendicular crossbar to avoid double - // painting. Up arm goes from y=0 to h_light_top, down arm goes - // from h_light_bottom to h, horizontal goes full width. - // Result: 2 vertical pieces + 1 full horizontal piece OR - // 1 full vertical + 2 horizontal pieces โ€” depending on join order. - // Either way, the union should cover the cross shape. - expect(rects.length).toBeGreaterThanOrEqual(2); - // Crossbar pixel must be covered. - const crossBarCovered = rects.some( - (r) => - r.x <= CW / 2 && - r.x + r.w >= CW / 2 && - r.y <= CH / 2 && - r.y + r.h >= CH / 2 - ); - expect(crossBarCovered).toBe(true); + // โ”ผ has all four arms (l/r/u/d = light). Each arm is a separate + // fillRect, so we expect exactly 4 rects. The earlier "โ‰ฅ 2" + // check would silently pass if the up- or right-arm switch + // dropped, so we assert each arm's outer edge is reached. + expect(rects).toHaveLength(4); + + // Vertical strokes (up + down) must collectively span y=0..CH at + // the horizontal center. + const cy = CW / 2; + const verticalCoverage = rects + .filter((r) => r.x <= cy && cy <= r.x + r.w) + .map((r) => [r.y, r.y + r.h] as const); + const minY = Math.min(...verticalCoverage.map(([t]) => t)); + const maxY = Math.max(...verticalCoverage.map(([, b]) => b)); + expect(minY).toBe(0); + expect(maxY).toBe(CH); + + // Horizontal strokes (left + right) must collectively span + // x=0..CW at the vertical center. + const cyH = CH / 2; + const horizontalCoverage = rects + .filter((r) => r.y <= cyH && cyH <= r.y + r.h) + .map((r) => [r.x, r.x + r.w] as const); + const minX = Math.min(...horizontalCoverage.map(([l]) => l)); + const maxX = Math.max(...horizontalCoverage.map(([, r]) => r)); + expect(minX).toBe(0); + expect(maxX).toBe(CW); }); test('โ•” U+2554 double down-right corner: junction-aware inner L', () => { // Regression check for the junction-aware-endpoints fix. With diff --git a/lib/box-drawing.ts b/lib/box-drawing.ts index 7327ba1..077a483 100644 --- a/lib/box-drawing.ts +++ b/lib/box-drawing.ts @@ -77,11 +77,19 @@ export function drawBoxOrBlock( color: string, lightPx: number ): boolean { + // Defensive clamps for arbitrary public-API callers. The built-in + // renderer always passes positive `w`/`h` and an integer `lightPx`, + // but a public consumer might not. A 0ร—0 cell would feed NaN + // (0/0) into `drawArc`'s slope math; a fractional lightPx would + // produce sub-pixel dash positions that don't tile. + if (!(w > 0) || !(h > 0)) return false; + const px = Math.max(1, Math.round(lightPx)); + if (codepoint >= 0x2580 && codepoint <= 0x259f) { return drawBlockElement(ctx, codepoint, x, y, w, h, color); } if (codepoint >= 0x2500 && codepoint <= 0x257f) { - return drawBoxLine(ctx, codepoint, x, y, w, h, color, lightPx); + return drawBoxLine(ctx, codepoint, x, y, w, h, color, px); } return false; } diff --git a/lib/index.ts b/lib/index.ts index b46e05b..36ee94c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -85,6 +85,7 @@ export type { KeyEvent, GhosttyCell, RGB, Cursor, TerminalHandle } from './types // Low-level components (for custom integrations) export { CanvasRenderer } from './renderer'; export type { RendererOptions, FontMetrics, IRenderable } from './renderer'; +export { drawBoxOrBlock, isBoxOrBlock } from './box-drawing'; export { InputHandler } from './input-handler'; export { EventEmitter } from './event-emitter'; export { SelectionManager } from './selection-manager'; From f390d37c522e373b12973540e9d90eb7badb26c6 Mon Sep 17 00:00:00 2001 From: Sauyon Lee Date: Sun, 10 May 2026 01:18:11 -0700 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20sixth-pass=20nits=20=E2=80=94=20te?= =?UTF-8?q?st=20variable=20naming,=20JSDoc,=20type=20cleanliness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth review pass surfaced two LOW + one INFO: - โ”ผ test's "verticalCoverage"/"horizontalCoverage" filter accepted both axes because the predicate `r.x <= cy && cy <= r.x + r.w` matches any rect that crosses the cell's horizontal centerline โ€” including the horizontal arms. Test still failed correctly (length assertion + min-y = 0 still uniquely required the up-arm), but the variable names were misleading. Filter now uses the rect's narrow dimension (`r.w <= 2*LT` for vertical arms, `r.h <= 2*LT` for horizontal), which unambiguously partitions the four arms. - `drawBoxOrBlock` JSDoc didn't mention input clamping. Now documents that `lightPx` is silently rounded to the nearest integer โ‰ฅ 1, and that `w`/`h` โ‰ค 0 returns false without drawing. - `cell.codepoint && isBoxOrBlock(cell.codepoint)` had inferred type `0 | true | false`. Tightened to `cell.codepoint > 0 && ...` for clean boolean inference. 379 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/box-drawing.test.ts | 40 ++++++++++++++++++++-------------------- lib/box-drawing.ts | 9 +++++++-- lib/renderer.ts | 2 +- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/lib/box-drawing.test.ts b/lib/box-drawing.test.ts index 174c9b1..978b162 100644 --- a/lib/box-drawing.test.ts +++ b/lib/box-drawing.test.ts @@ -341,31 +341,31 @@ describe('box-drawing', () => { test('โ”ผ U+253C light cross = all four arms cover their respective edges', () => { const { ctx } = draw(0x253c); const rects = rectsOnly(ctx.ops); - // โ”ผ has all four arms (l/r/u/d = light). Each arm is a separate - // fillRect, so we expect exactly 4 rects. The earlier "โ‰ฅ 2" - // check would silently pass if the up- or right-arm switch - // dropped, so we assert each arm's outer edge is reached. + // โ”ผ has all four arms (l/r/u/d = light). Each arm is one + // fillRect so we expect exactly 4 rects. A bug that drops the + // up- or right-arm switch case must fail this test, so we + // assert per-arm coverage in addition to the rect count. expect(rects).toHaveLength(4); - // Vertical strokes (up + down) must collectively span y=0..CH at - // the horizontal center. - const cy = CW / 2; - const verticalCoverage = rects - .filter((r) => r.x <= cy && cy <= r.x + r.w) - .map((r) => [r.y, r.y + r.h] as const); - const minY = Math.min(...verticalCoverage.map(([t]) => t)); - const maxY = Math.max(...verticalCoverage.map(([, b]) => b)); + // Vertical-axis arms (up + down) are narrow rects roughly + // light-thick wide; horizontal-axis arms are wide rects roughly + // light-thick tall. Filter by the rect's narrow dimension to + // separate them โ€” this is unambiguous because none of the + // light-cross arms share both dimensions. + const verticalArms = rects.filter((r) => r.w <= 2 * LT); + const horizontalArms = rects.filter((r) => r.h <= 2 * LT); + expect(verticalArms.length).toBeGreaterThanOrEqual(1); + expect(horizontalArms.length).toBeGreaterThanOrEqual(1); + + // Up arm reaches y=0 and down arm reaches y=CH. + const minY = Math.min(...verticalArms.map((r) => r.y)); + const maxY = Math.max(...verticalArms.map((r) => r.y + r.h)); expect(minY).toBe(0); expect(maxY).toBe(CH); - // Horizontal strokes (left + right) must collectively span - // x=0..CW at the vertical center. - const cyH = CH / 2; - const horizontalCoverage = rects - .filter((r) => r.y <= cyH && cyH <= r.y + r.h) - .map((r) => [r.x, r.x + r.w] as const); - const minX = Math.min(...horizontalCoverage.map(([l]) => l)); - const maxX = Math.max(...horizontalCoverage.map(([, r]) => r)); + // Left arm reaches x=0 and right arm reaches x=CW. + const minX = Math.min(...horizontalArms.map((r) => r.x)); + const maxX = Math.max(...horizontalArms.map((r) => r.x + r.w)); expect(minX).toBe(0); expect(maxX).toBe(CW); }); diff --git a/lib/box-drawing.ts b/lib/box-drawing.ts index 077a483..6b711cb 100644 --- a/lib/box-drawing.ts +++ b/lib/box-drawing.ts @@ -62,10 +62,15 @@ export function isBoxOrBlock(codepoint: number): boolean { * - `lightPx` is the font-derived light box-stroke thickness in CSS * pixels (heavy is 2ร— this; double is two parallels separated by * one light gap, totaling 3ร— this). Use the `boxThickness` value - * measured in `CanvasRenderer.measureFont`. + * measured in `CanvasRenderer.measureFont`. Defensively rounded + * to the nearest integer โ‰ฅ 1 inside this function โ€” fractional + * values produce sub-pixel dash positions that don't tile across + * adjacent cells, so the API silently normalizes. * * Returns true if the glyph was handled; false if the caller should - * fall back to font rendering. + * fall back to font rendering. Returns false (no draw) if `w` or `h` + * is non-positive, since a zero-area cell can't contain visible + * geometry and would feed `0/0 = NaN` into the arc slope math. */ export function drawBoxOrBlock( ctx: CanvasRenderingContext2D, diff --git a/lib/renderer.ts b/lib/renderer.ts index 4dcc601..bfa54d9 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -711,7 +711,7 @@ export class CanvasRenderer { // This is the standard approach in modern terminal renderers // (Alacritty, kitty, wezterm, Ghostty native). const isSimpleBoxOrBlock = - cell.grapheme_len === 0 && cell.codepoint && isBoxOrBlock(cell.codepoint); + cell.grapheme_len === 0 && cell.codepoint > 0 && isBoxOrBlock(cell.codepoint); // boxThickness is optional in the public FontMetrics type for // backward compat, but the built-in measureFont always sets it. // Fall back to a font-size-derived value as a safety net.