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/box-drawing.test.ts b/lib/box-drawing.test.ts new file mode 100644 index 0000000..978b162 --- /dev/null +++ b/lib/box-drawing.test.ts @@ -0,0 +1,522 @@ +/** + * 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 './box-drawing'; + +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', () => { + // 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 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.w > 0 && o.h > 0) || + 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 + ]); + }); + + // 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). 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 }; + 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 ~25% alpha multiplier', () => { + const { ctx } = draw(0x2591); + const alphaOp = ctx.ops.find((o) => o.kind === 'globalAlpha'); + expect(alphaOp).toBeTruthy(); + // 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, 2); + } + // 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 = 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 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-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); + + // 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); + }); + 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); + 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); + } + }); + }); + + 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', () => { + // 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); + 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: 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); + 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', () => { + expect(rectsOnly(draw(0x2508).ctx.ops)).toHaveLength(4); + }); + test('╌ U+254C horizontal double-dash: 2 rects', () => { + 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) + }); + }); + + 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.ts b/lib/box-drawing.ts new file mode 100644 index 0000000..6b711cb --- /dev/null +++ b/lib/box-drawing.ts @@ -0,0 +1,983 @@ +/** + * Box-drawing and Block-element renderer (U+2500..U+259F). + * + * 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. + * + * 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. + */ + +// ============================================================================ +// 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`. 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. 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, + codepoint: number, + x: number, + y: number, + w: number, + h: number, + 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, px); + } + 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 ░▒▓. 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. +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; + 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 +]); + +function drawBoxLine( + ctx: CanvasRenderingContext2D, + cp: number, + x: number, + y: number, + w: number, + h: number, + color: string, + lightPx: number +): boolean { + if (cp === 0x2571 || cp === 0x2572 || cp === 0x2573) { + 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, lightPx); + return true; + } + + const dash = DASHED.get(cp); + if (dash !== undefined) { + 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, lightPx); + 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, + lt: number +): void { + const ht = heavyThickness(lt); + + // 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. 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); + const h_heavy_bottom = h_heavy_top + ht; + const h_double_top = Math.max(0, h_light_top - lt); + const h_double_bottom = h_light_bottom + lt; + + // 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 = Math.max(0, (w - ht) / 2); + const v_heavy_right = v_heavy_left + ht; + const v_double_left = Math.max(0, 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, + 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; + 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-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, + dash: Dashed, + ox: number, + oy: number, + w: number, + h: number, + color: string, + lt: number +): void { + ctx.fillStyle = color; + const t = dash.weight === H ? heavyThickness(lt) : lt; + const count = dash.count; + // 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, lt, true); + } else { + drawDashRun(ctx, ox, oy + (h - t) / 2, w, t, count, desired_gap, lt, false); + } +} + +function drawDashRun( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + count: 2 | 3 | 4, + 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 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; + ctx.fillRect(cx, y, lt, h); + } else { + const cy = y + (h - lt) / 2; + ctx.fillRect(x, cy, w, lt); + } + return; + } + + // Cap the gap so dashes never shrink below half the available run. + // 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); + let extra = total_dash - dash_width * count; + + // 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) { + 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, + lt: number +): void { + 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/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'; 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); } } 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/renderer.ts b/lib/renderer.ts index 3b51bfd..bfa54d9 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'; @@ -54,6 +55,18 @@ 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. + * + * 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; } // ============================================================================ @@ -195,20 +208,63 @@ 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 - - return { width, height, baseline }; + // 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; + + // Box-drawing stroke thickness, measured from the font's actual + // 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)); + + return { width, height, baseline, boxThickness }; } /** @@ -241,14 +297,20 @@ 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); - // 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'; @@ -285,10 +347,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 +701,45 @@ 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). + const isSimpleBoxOrBlock = + 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. + const boxThickness = this.metrics.boxThickness ?? Math.max(1, Math.round(this.fontSize * 0.07)); + if ( + isSimpleBoxOrBlock && + drawBoxOrBlock( + this.ctx, + cell.codepoint, + cellX, + cellY, + cellWidth, + this.metrics.height, + this.ctx.fillStyle as string, + boxThickness + ) + ) { + // Drawn directly; skip the font path. } 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); } - this.ctx.fillText(char, textX, textY); // Reset alpha if (cell.flags & CellFlags.FAINT) { @@ -985,11 +1077,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); } /** diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..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'; /** @@ -2989,4 +2990,238 @@ 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(); + }); +}); + +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 eeb7acd..5eb0132 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 ?? {}, @@ -240,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); } @@ -369,9 +375,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'); @@ -525,8 +544,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; @@ -557,6 +574,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); @@ -575,9 +600,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) @@ -679,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 }); @@ -714,10 +738,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(); @@ -1265,9 +1306,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; } 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 } 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