diff --git a/README.md b/README.md index 85e51e0..d016241 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin - **Themes** — CSS custom properties with built-in Default, Solarized Dark, Monokai, and Light themes - **Alternate screen buffer** — `vim`, `less`, `htop`, and similar apps work correctly - **Scrollback history** — configurable ring buffer +- **Wide Unicode cells** — CJK, fullwidth, and emoji codepoints keep cursor-addressed redraws aligned - **24-bit color** — full RGB SGR support - **Auto-resize** — `ResizeObserver`-based terminal resizing - **WebSocket transport** — connect to a PTY backend with binary framing and reconnection diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 9c03113..44e1306 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -518,6 +518,7 @@ interface CellData { fg: number; // Foreground color index (256 = default) bg: number; // Background color index (256 = default) flags: number; // Style flags (bold, italic, underline, etc.) + width?: number; // 1 = narrow, 2 = wide leading cell, 0 = continuation } interface CursorState { diff --git a/apps/docs/src/app/core/page.mdx b/apps/docs/src/app/core/page.mdx index f58ae21..9517496 100644 --- a/apps/docs/src/app/core/page.mdx +++ b/apps/docs/src/app/core/page.mdx @@ -51,6 +51,8 @@ const cursor = bridge.getCursor(); // → { row: 1, col: 13, visible: true } ``` +Wide single-codepoint characters such as CJK, fullwidth forms, and emoji expose `width: 2` on the leading cell and `width: 0` on the continuation cell. Custom renderers should skip continuation cells. + ## WebSocketTransport Connect to a PTY backend over WebSocket with automatic reconnection and send buffering. diff --git a/apps/docs/src/app/ghostty/page.mdx b/apps/docs/src/app/ghostty/page.mdx index a84823d..48a7e4f 100644 --- a/apps/docs/src/app/ghostty/page.mdx +++ b/apps/docs/src/app/ghostty/page.mdx @@ -127,7 +127,7 @@ pnpm --filter @wterm/ghostty rebuild-wasm Unicode - Single codepoints + Single codepoints with wide-cell support Full grapheme clusters diff --git a/apps/docs/src/app/page.mdx b/apps/docs/src/app/page.mdx index 8e05adf..37e57d3 100644 --- a/apps/docs/src/app/page.mdx +++ b/apps/docs/src/app/page.mdx @@ -24,6 +24,7 @@ import { HeroSection } from "@/components/hero-terminal"; - **Themes** — CSS custom properties with built-in Default, Solarized Dark, Monokai, and Light themes - **Alternate screen buffer** — `vim`, `less`, `htop` work correctly - **Scrollback history** — configurable ring buffer +- **Wide Unicode cells** — CJK, fullwidth, and emoji codepoints stay aligned during cursor-addressed redraws - **24-bit color** — full RGB SGR support - **Auto-resize** — `ResizeObserver`-based terminal resizing - **WebSocket transport** — connect to a PTY backend with reconnection diff --git a/e2e/tests/terminal.spec.ts b/e2e/tests/terminal.spec.ts index 96bb665..f19998a 100644 --- a/e2e/tests/terminal.spec.ts +++ b/e2e/tests/terminal.spec.ts @@ -17,6 +17,25 @@ test.describe("rendering", () => { test("displays greeting text", async ({ page }) => { await expect(page.locator(".wterm")).toContainText("Welcome to wterm!"); }); + + test("keeps cursor-addressed redraws aligned after wide characters", async ({ + page, + }) => { + const terminal = page.locator(".wterm"); + await terminal.click(); + + await page.keyboard.type( + 'printf "\\033[2J\\033[H\\360\\237\\223\\201abcd\\033[1;4Hx"', + { delay: 5 }, + ); + await page.keyboard.press("Enter"); + + await expect(terminal).toContainText("📁axcd", { timeout: 5000 }); + await expect(terminal).not.toContainText("📁abxd"); + await expect( + terminal.locator(".term-wide").filter({ hasText: "📁" }), + ).toHaveCount(1); + }); }); test.describe("keyboard input", () => { diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md index b29c42d..9da624c 100644 --- a/packages/@wterm/core/README.md +++ b/packages/@wterm/core/README.md @@ -48,7 +48,7 @@ const bridge = await WasmBridge.load(); bridge.init(80, 24); bridge.writeString("Hello, world!\r\n"); -const cell = bridge.getCell(0, 0); // { char, fg, bg, flags } +const cell = bridge.getCell(0, 0); // { char, fg, bg, flags, width } const cursor = bridge.getCursor(); // { row, col, visible } ``` @@ -59,7 +59,7 @@ const cursor = bridge.getCursor(); // { row, col, visible } | `writeString(str)` | Write a UTF-8 string to the terminal | | `writeRaw(data: Uint8Array)` | Write raw bytes to the terminal | | `resize(cols, rows)` | Resize the terminal grid | -| `getCell(row, col)` | Get cell data (`{ char, fg, bg, flags }`) | +| `getCell(row, col)` | Get cell data (`{ char, fg, bg, flags, width }`) | | `getCursor()` | Get cursor state (`{ row, col, visible }`) | | `getCols()` / `getRows()` | Get current grid dimensions | | `isDirtyRow(row)` | Check if a row needs re-rendering | diff --git a/packages/@wterm/core/src/__tests__/wasm-bridge.test.ts b/packages/@wterm/core/src/__tests__/wasm-bridge.test.ts index 8a30ce9..bf255b1 100644 --- a/packages/@wterm/core/src/__tests__/wasm-bridge.test.ts +++ b/packages/@wterm/core/src/__tests__/wasm-bridge.test.ts @@ -42,6 +42,24 @@ describe("WasmBridge", () => { expect(bridge.getCell(0, 1).char).toBe(105); // 'i' }); + it("tracks wide cells and continuation cells", () => { + bridge.writeString("📁a"); + expect(bridge.getCell(0, 0).char).toBe(0x1f4c1); + expect(bridge.getCell(0, 0).width).toBe(2); + expect(bridge.getCell(0, 1).width).toBe(0); + expect(bridge.getCell(0, 2).char).toBe(97); // 'a' + expect(bridge.getCursor().col).toBe(3); + }); + + it("keeps cursor-positioned redraws aligned after wide characters", () => { + bridge.writeString("📁abcd"); + bridge.writeString("\x1b[1;4Hx"); + expect(bridge.getCell(0, 2).char).toBe(97); // 'a' + expect(bridge.getCell(0, 3).char).toBe(120); // 'x' + expect(bridge.getCell(0, 4).char).toBe(99); // 'c' + expect(bridge.getCell(0, 5).char).toBe(100); // 'd' + }); + it("writes to correct position after cursor movement", () => { bridge.writeString("AB\r\nCD"); expect(bridge.getCell(0, 0).char).toBe(65); // 'A' diff --git a/packages/@wterm/core/src/terminal-core.ts b/packages/@wterm/core/src/terminal-core.ts index f8c77d5..d5d0a4b 100644 --- a/packages/@wterm/core/src/terminal-core.ts +++ b/packages/@wterm/core/src/terminal-core.ts @@ -3,6 +3,8 @@ export interface CellData { fg: number; bg: number; flags: number; + /** Display width: 1 = narrow, 2 = wide leading cell, 0 = wide continuation cell. */ + width?: number; /** Resolved 24-bit foreground color (0xRRGGBB). Present when the core provides true color. */ fgRgb?: number; /** Resolved 24-bit background color (0xRRGGBB). Present when the core provides true color. */ diff --git a/packages/@wterm/core/src/wasm-bridge.ts b/packages/@wterm/core/src/wasm-bridge.ts index 981c300..96d15e3 100644 --- a/packages/@wterm/core/src/wasm-bridge.ts +++ b/packages/@wterm/core/src/wasm-bridge.ts @@ -120,6 +120,7 @@ export class WasmBridge implements TerminalCore { fg: dv.getUint16(offset + 4, true), bg: dv.getUint16(offset + 6, true), flags: dv.getUint8(offset + 8), + width: dv.getUint8(offset + 9), }; } @@ -187,6 +188,7 @@ export class WasmBridge implements TerminalCore { fg: dv.getUint16(off + 4, true), bg: dv.getUint16(off + 6, true), flags: dv.getUint8(off + 8), + width: dv.getUint8(off + 9), }; } diff --git a/packages/@wterm/core/wasm/wterm.wasm b/packages/@wterm/core/wasm/wterm.wasm index fc03d96..f7efe53 100755 Binary files a/packages/@wterm/core/wasm/wterm.wasm and b/packages/@wterm/core/wasm/wterm.wasm differ diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index 678ea63..1803568 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -25,8 +25,14 @@ function createMockBridge(cols: number, rows: number, grid: CellData[][] = []) { }; } -function makeCell(char: string, fg = 256, bg = 256, flags = 0): CellData { - return { char: char.codePointAt(0)!, fg, bg, flags }; +function makeCell( + char: string, + fg = 256, + bg = 256, + flags = 0, + width = 1, +): CellData { + return { char: char.codePointAt(0)!, fg, bg, flags, width }; } describe("Renderer", () => { @@ -66,6 +72,43 @@ describe("Renderer", () => { expect(text).toContain("i"); }); + it("renders wide cells once and skips continuation cells", () => { + const grid = [ + [ + makeCell(String.fromCodePoint(0x1f4c1), 256, 256, 0, 2), + { char: 0, fg: 256, bg: 256, flags: 0, width: 0 }, + makeCell("a"), + makeCell("b"), + ], + ]; + const bridge = createMockBridge(4, 1, grid); + const renderer = new Renderer(container); + renderer.render(bridge as any); + + const row = container.querySelector(".term-row"); + expect(row?.textContent).toBe(`${String.fromCodePoint(0x1f4c1)}ab`); + expect(container.querySelector(".term-wide")?.textContent).toBe( + String.fromCodePoint(0x1f4c1), + ); + }); + + it("places the cursor correctly after a wide cell", () => { + const grid = [ + [ + makeCell(String.fromCodePoint(0x1f4c1), 256, 256, 0, 2), + { char: 0, fg: 256, bg: 256, flags: 0, width: 0 }, + makeCell("a"), + makeCell("b"), + ], + ]; + const bridge = createMockBridge(4, 1, grid); + bridge.getCursor = () => ({ row: 0, col: 3, visible: true }); + const renderer = new Renderer(container); + renderer.render(bridge as any); + + expect(container.querySelector(".term-cursor")?.textContent).toBe("b"); + }); + it("applies cursor class to cursor position", () => { const grid = [[makeCell("A"), makeCell("B")]]; const bridge = createMockBridge(2, 1, grid); diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index a5ed237..043be38 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,4 +1,4 @@ -import type { TerminalCore } from "@wterm/core"; +import type { CellData, TerminalCore } from "@wterm/core"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -238,14 +238,7 @@ export class Renderer { private _buildRowContent( rowEl: HTMLDivElement, - getCell: (col: number) => { - char: number; - fg: number; - bg: number; - flags: number; - fgRgb?: number; - bgRgb?: number; - }, + getCell: (col: number) => CellData, lineLen: number, cursorCol: number, rowIndex: number, @@ -286,10 +279,55 @@ export class Renderer { } }; + const appendStyledSpan = ( + className: string, + style: string, + text: string, + ) => { + const classAttr = className ? ` class="${className}"` : ""; + const styleAttr = style ? ` style="${style}"` : ""; + html += `${escapeHTML(text)}`; + }; + for (let col = 0; col < this.cols; col++) { const cell = getCell(col); const inBounds = col < lineLen; const cp = inBounds ? cell.char : 0; + const width = inBounds ? (cell.width ?? 1) : 1; + + if (inBounds && width === 0) { + flushRun(col); + if (col === cursorCol) { + appendStyledSpan("term-cursor", "", " "); + } + runStyle = ""; + runText = ""; + runStart = col + 1; + continue; + } + + if (inBounds && width === 2) { + flushRun(col); + + const ch = cp >= 32 ? String.fromCodePoint(cp) : " "; + const style = buildCellStyle( + cell.fg, + cell.bg, + cell.flags, + cell.fgRgb, + cell.bgRgb, + ); + const cls = + cursorCol >= col && cursorCol < col + 2 + ? "term-wide term-cursor" + : "term-wide"; + appendStyledSpan(cls, style, ch); + + runStyle = ""; + runText = ""; + runStart = col + 2; + continue; + } if (inBounds && cp >= 0x2580 && cp <= 0x259f) { flushRun(col); diff --git a/packages/@wterm/dom/src/terminal.css b/packages/@wterm/dom/src/terminal.css index a88e475..7c552a1 100644 --- a/packages/@wterm/dom/src/terminal.css +++ b/packages/@wterm/dom/src/terminal.css @@ -68,6 +68,11 @@ overflow: hidden; } +.term-wide { + width: 2ch; + overflow: hidden; +} + .term-cursor { outline: 1px solid var(--term-cursor); outline-offset: -1px; diff --git a/src/cell.zig b/src/cell.zig index 0fc3fac..a51b1cc 100644 --- a/src/cell.zig +++ b/src/cell.zig @@ -9,13 +9,17 @@ pub const FLAG_REVERSE: u8 = 0x20; pub const FLAG_INVISIBLE: u8 = 0x40; pub const FLAG_STRIKETHROUGH: u8 = 0x80; +pub const WIDTH_CONTINUATION: u8 = 0; +pub const WIDTH_NARROW: u8 = 1; +pub const WIDTH_WIDE: u8 = 2; + /// 12-byte extern struct with C-compatible layout so JS can read directly from WASM memory. pub const Cell = extern struct { char: u32 = ' ', fg: u16 = DEFAULT_COLOR, bg: u16 = DEFAULT_COLOR, flags: u8 = 0, - _pad1: u8 = 0, + width: u8 = WIDTH_NARROW, _pad2: u8 = 0, _pad3: u8 = 0, diff --git a/src/grid.zig b/src/grid.zig index 1c30eb3..a325c99 100644 --- a/src/grid.zig +++ b/src/grid.zig @@ -1,4 +1,5 @@ -const Cell = @import("cell.zig").Cell; +const cell_mod = @import("cell.zig"); +const Cell = cell_mod.Cell; pub const MAX_COLS: u16 = 256; pub const MAX_ROWS: u16 = 256; @@ -59,8 +60,21 @@ pub const Grid = struct { pub fn clearRangeAs(self: *Grid, row: u16, start_col: u16, end_col: u16, blank: Cell) void { if (row >= self.rows) return; - const end = if (end_col > self.cols) self.cols else end_col; - var c = start_col; + var start = if (start_col > self.cols) self.cols else start_col; + var end = if (end_col > self.cols) self.cols else end_col; + + if (start < end) { + if (start < self.cols and self.cells[row][start].width == cell_mod.WIDTH_CONTINUATION and start > 0) { + start -= 1; + } + if (end < self.cols and self.cells[row][end].width == cell_mod.WIDTH_CONTINUATION) { + end += 1; + } else if (end > 0 and end < self.cols and self.cells[row][end - 1].width == cell_mod.WIDTH_WIDE) { + end += 1; + } + } + + var c = start; while (c < end) : (c += 1) { self.cells[row][c] = blank; } diff --git a/src/terminal.zig b/src/terminal.zig index 0164da3..4754145 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -2,6 +2,7 @@ const cell_mod = @import("cell.zig"); const grid_mod = @import("grid.zig"); const parser_mod = @import("parser.zig"); const scrollback_mod = @import("scrollback.zig"); +const unicode_width = @import("unicode_width.zig"); const Cell = cell_mod.Cell; const Grid = grid_mod.Grid; @@ -97,6 +98,89 @@ pub const Terminal = struct { return Cell{ .bg = self.current_bg }; } + fn continuationCell(self: *const Terminal) Cell { + return Cell{ + .char = 0, + .fg = self.current_fg, + .bg = self.current_bg, + .flags = self.current_flags, + .width = cell_mod.WIDTH_CONTINUATION, + }; + } + + fn clearWideCellAt(self: *Terminal, row: u16, col: u16, blank: Cell) void { + if (row >= self.rows or col >= self.cols) return; + const cell = self.grid.cells[row][col]; + if (cell.width == cell_mod.WIDTH_CONTINUATION) { + if (col > 0 and self.grid.cells[row][col - 1].width == cell_mod.WIDTH_WIDE) { + self.grid.cells[row][col - 1] = blank; + } + self.grid.cells[row][col] = blank; + self.grid.dirty[row] = 1; + return; + } + if (cell.width == cell_mod.WIDTH_WIDE) { + self.grid.cells[row][col] = blank; + if (col + 1 < self.cols and self.grid.cells[row][col + 1].width == cell_mod.WIDTH_CONTINUATION) { + self.grid.cells[row][col + 1] = blank; + } + self.grid.dirty[row] = 1; + } + } + + const WideRange = struct { + start: u16, + end: u16, + }; + + fn expandWideRange(self: *const Terminal, row: u16, start_col: u16, end_col: u16) WideRange { + if (row >= self.rows) return .{ .start = start_col, .end = end_col }; + var start = if (start_col > self.cols) self.cols else start_col; + var end = if (end_col > self.cols) self.cols else end_col; + if (start < end) { + if (start < self.cols and self.grid.cells[row][start].width == cell_mod.WIDTH_CONTINUATION and start > 0) { + start -= 1; + } + if (end < self.cols and self.grid.cells[row][end].width == cell_mod.WIDTH_CONTINUATION) { + end += 1; + } else if (end > 0 and end < self.cols and self.grid.cells[row][end - 1].width == cell_mod.WIDTH_WIDE) { + end += 1; + } + } + return .{ .start = start, .end = end }; + } + + fn sanitizeWideRow(self: *Terminal, row: u16, blank: Cell) void { + if (row >= self.rows) return; + var c: u16 = 0; + var changed = false; + while (c < self.cols) { + const cell = self.grid.cells[row][c]; + if (cell.width == cell_mod.WIDTH_CONTINUATION) { + if (c == 0 or self.grid.cells[row][c - 1].width != cell_mod.WIDTH_WIDE) { + self.grid.cells[row][c] = blank; + changed = true; + } + c += 1; + } else if (cell.width == cell_mod.WIDTH_WIDE) { + if (c + 1 >= self.cols or self.grid.cells[row][c + 1].width != cell_mod.WIDTH_CONTINUATION) { + self.grid.cells[row][c] = blank; + changed = true; + c += 1; + } else { + c += 2; + } + } else { + if (cell.width != cell_mod.WIDTH_NARROW) { + self.grid.cells[row][c].width = cell_mod.WIDTH_NARROW; + changed = true; + } + c += 1; + } + } + if (changed) self.grid.dirty[row] = 1; + } + fn logUnhandled(self: *Terminal, final: u8, private_marker: u8) void { var entry = DebugLogEntry{ .final_byte = final, @@ -218,6 +302,11 @@ pub const Terminal = struct { self.scroll_top = 0; self.scroll_bottom = rows; + var sr: u16 = 0; + while (sr < rows) : (sr += 1) { + self.sanitizeWideRow(sr, Cell{}); + } + if (self.cursor_col >= cols) self.cursor_col = cols - 1; if (self.cursor_row >= rows) self.cursor_row = rows - 1; @@ -251,17 +340,48 @@ pub const Terminal = struct { self.wrap_pending = false; } + var width = unicode_width.displayWidth(codepoint); + if (width == cell_mod.WIDTH_WIDE and self.cols < 2) { + width = cell_mod.WIDTH_NARROW; + } + + if (width == cell_mod.WIDTH_WIDE and self.cursor_col + 1 >= self.cols) { + if (self.auto_wrap) { + const blank = self.blankCell(); + self.clearWideCellAt(self.cursor_row, self.cursor_col, blank); + self.grid.setCell(self.cursor_row, self.cursor_col, blank); + self.cursor_col = 0; + self.doLinefeed(); + } else { + width = cell_mod.WIDTH_NARROW; + } + } + + const blank = self.blankCell(); + self.clearWideCellAt(self.cursor_row, self.cursor_col, blank); + if (width == cell_mod.WIDTH_WIDE) { + self.clearWideCellAt(self.cursor_row, self.cursor_col + 1, blank); + } + self.grid.setCell(self.cursor_row, self.cursor_col, Cell{ .char = @intCast(codepoint), .fg = self.current_fg, .bg = self.current_bg, .flags = self.current_flags, + .width = width, }); - if (self.cursor_col < self.cols - 1) { - self.cursor_col += 1; + if (width == cell_mod.WIDTH_WIDE) { + self.grid.setCell(self.cursor_row, self.cursor_col + 1, self.continuationCell()); + } + + if (self.cursor_col + width < self.cols) { + self.cursor_col += width; } else if (self.auto_wrap) { + self.cursor_col = self.cols - 1; self.wrap_pending = true; + } else { + self.cursor_col = self.cols - 1; } } @@ -662,34 +782,42 @@ pub const Terminal = struct { fn deleteChars(self: *Terminal, n: u16) void { const count = if (n == 0) 1 else n; const blank = self.blankCell(); - var col = self.cursor_col; - while (col + count < self.cols) : (col += 1) { - self.grid.cells[self.cursor_row][col] = self.grid.cells[self.cursor_row][col + count]; + const range = self.expandWideRange(self.cursor_row, self.cursor_col, self.cursor_col + count); + const delete_count = range.end - range.start; + var col = range.start; + while (col + delete_count < self.cols) : (col += 1) { + self.grid.cells[self.cursor_row][col] = self.grid.cells[self.cursor_row][col + delete_count]; } while (col < self.cols) : (col += 1) { self.grid.cells[self.cursor_row][col] = blank; } self.grid.dirty[self.cursor_row] = 1; + self.sanitizeWideRow(self.cursor_row, blank); } fn insertBlanks(self: *Terminal, n: u16) void { const count = if (n == 0) 1 else n; const blank = self.blankCell(); - if (self.cursor_col + count >= self.cols) { - self.grid.clearRangeAs(self.cursor_row, self.cursor_col, self.cols, blank); + var start = self.cursor_col; + if (start < self.cols and self.grid.cells[self.cursor_row][start].width == cell_mod.WIDTH_CONTINUATION and start > 0) { + start -= 1; + } + if (start + count >= self.cols) { + self.grid.clearRangeAs(self.cursor_row, start, self.cols, blank); return; } var col = self.cols - 1; - while (col >= self.cursor_col + count) : (col -= 1) { + while (col >= start + count) : (col -= 1) { self.grid.cells[self.cursor_row][col] = self.grid.cells[self.cursor_row][col - count]; if (col == 0) break; } - var c = self.cursor_col; - const end = if (self.cursor_col + count > self.cols) self.cols else self.cursor_col + count; + var c = start; + const end = if (start + count > self.cols) self.cols else start + count; while (c < end) : (c += 1) { self.grid.cells[self.cursor_row][c] = blank; } self.grid.dirty[self.cursor_row] = 1; + self.sanitizeWideRow(self.cursor_row, blank); } fn scrollUpN(self: *Terminal, n: u16) void { @@ -909,6 +1037,85 @@ test "basic print" { try @import("std").testing.expectEqual(@as(u16, 5), t.cursor_col); } +test "wide characters advance by two cells" { + const testing = @import("std").testing; + var t = Terminal.init(80, 24); + t.write("\xF0\x9F\x93\x81"); + try testing.expectEqual(@as(u16, 2), t.cursor_col); + try testing.expectEqual(cell_mod.WIDTH_WIDE, t.grid.getCell(0, 0).width); + try testing.expectEqual(cell_mod.WIDTH_CONTINUATION, t.grid.getCell(0, 1).width); + + t.write("abcd"); + t.write("\x1b[1;4Hx"); + try testing.expectEqual(@as(u32, 0x1F4C1), t.grid.getCell(0, 0).char); + try testing.expectEqual(@as(u32, 'a'), t.grid.getCell(0, 2).char); + try testing.expectEqual(@as(u32, 'x'), t.grid.getCell(0, 3).char); + try testing.expectEqual(@as(u32, 'c'), t.grid.getCell(0, 4).char); + try testing.expectEqual(@as(u32, 'd'), t.grid.getCell(0, 5).char); +} + +test "CJK and fullwidth characters advance by two cells" { + const testing = @import("std").testing; + var t = Terminal.init(80, 24); + t.write("\xE4\xB8\xAD"); + try testing.expectEqual(@as(u16, 2), t.cursor_col); + try testing.expectEqual(cell_mod.WIDTH_WIDE, t.grid.getCell(0, 0).width); + try testing.expectEqual(cell_mod.WIDTH_CONTINUATION, t.grid.getCell(0, 1).width); + + t.write("\xEF\xBC\xA1"); + try testing.expectEqual(@as(u16, 4), t.cursor_col); + try testing.expectEqual(cell_mod.WIDTH_WIDE, t.grid.getCell(0, 2).width); + try testing.expectEqual(cell_mod.WIDTH_CONTINUATION, t.grid.getCell(0, 3).width); +} + +test "printing over wide character clears both cells" { + const testing = @import("std").testing; + var t = Terminal.init(80, 24); + t.write("\xF0\x9F\x93\x81ab"); + t.write("\x1b[1;2Hx"); + try testing.expectEqual(@as(u32, ' '), t.grid.getCell(0, 0).char); + try testing.expectEqual(cell_mod.WIDTH_NARROW, t.grid.getCell(0, 0).width); + try testing.expectEqual(@as(u32, 'x'), t.grid.getCell(0, 1).char); + try testing.expectEqual(cell_mod.WIDTH_NARROW, t.grid.getCell(0, 1).width); + try testing.expectEqual(@as(u32, 'a'), t.grid.getCell(0, 2).char); +} + +test "delete chars keeps wide cells intact" { + const testing = @import("std").testing; + var t = Terminal.init(80, 24); + t.write("\xF0\x9F\x93\x81ab"); + t.write("\x1b[1;1H\x1b[P"); + try testing.expectEqual(@as(u32, 'a'), t.grid.getCell(0, 0).char); + try testing.expectEqual(@as(u32, 'b'), t.grid.getCell(0, 1).char); + try testing.expectEqual(cell_mod.WIDTH_NARROW, t.grid.getCell(0, 0).width); + try testing.expectEqual(cell_mod.WIDTH_NARROW, t.grid.getCell(0, 1).width); +} + +test "insert blanks shifts wide cells without splitting them" { + const testing = @import("std").testing; + var t = Terminal.init(80, 24); + t.write("ab\xF0\x9F\x93\x81"); + t.write("\x1b[1;3H\x1b[@"); + try testing.expectEqual(@as(u32, 'a'), t.grid.getCell(0, 0).char); + try testing.expectEqual(@as(u32, 'b'), t.grid.getCell(0, 1).char); + try testing.expectEqual(@as(u32, ' '), t.grid.getCell(0, 2).char); + try testing.expectEqual(@as(u32, 0x1F4C1), t.grid.getCell(0, 3).char); + try testing.expectEqual(cell_mod.WIDTH_WIDE, t.grid.getCell(0, 3).width); + try testing.expectEqual(cell_mod.WIDTH_CONTINUATION, t.grid.getCell(0, 4).width); +} + +test "wide character wraps before final column" { + const testing = @import("std").testing; + var t = Terminal.init(5, 2); + t.write("1234"); + t.write("\xF0\x9F\x93\x81"); + try testing.expectEqual(@as(u32, ' '), t.grid.getCell(0, 4).char); + try testing.expectEqual(@as(u32, 0x1F4C1), t.grid.getCell(1, 0).char); + try testing.expectEqual(cell_mod.WIDTH_CONTINUATION, t.grid.getCell(1, 1).width); + try testing.expectEqual(@as(u16, 1), t.cursor_row); + try testing.expectEqual(@as(u16, 2), t.cursor_col); +} + test "linefeed and carriage return" { var t = Terminal.init(80, 24); t.write("AB\r\nCD"); diff --git a/src/unicode_width.zig b/src/unicode_width.zig new file mode 100644 index 0000000..b0d28ef --- /dev/null +++ b/src/unicode_width.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +/// Return the terminal display width for a single Unicode codepoint. +/// +/// This intentionally handles single-codepoint width only. Grapheme clusters +/// that combine multiple codepoints still require a fuller Unicode renderer. +pub fn displayWidth(codepoint: u21) u8 { + if (isWide(codepoint)) return 2; + return 1; +} + +fn inRange(codepoint: u21, start: u21, end: u21) bool { + return codepoint >= start and codepoint <= end; +} + +pub fn isWide(codepoint: u21) bool { + return inRange(codepoint, 0x1100, 0x115F) or + codepoint == 0x2329 or + codepoint == 0x232A or + inRange(codepoint, 0x2E80, 0x303E) or + inRange(codepoint, 0x3040, 0xA4CF) or + inRange(codepoint, 0xAC00, 0xD7A3) or + inRange(codepoint, 0xF900, 0xFAFF) or + inRange(codepoint, 0xFE10, 0xFE19) or + inRange(codepoint, 0xFE30, 0xFE6F) or + inRange(codepoint, 0xFF00, 0xFF60) or + inRange(codepoint, 0xFFE0, 0xFFE6) or + inRange(codepoint, 0x1F000, 0x1FAFF) or + inRange(codepoint, 0x20000, 0x3FFFD); +} + +test "Unicode width classifies narrow and wide codepoints" { + try std.testing.expectEqual(@as(u8, 1), displayWidth('A')); + try std.testing.expectEqual(@as(u8, 2), displayWidth(0x4E2D)); + try std.testing.expectEqual(@as(u8, 2), displayWidth(0xFF21)); + try std.testing.expectEqual(@as(u8, 2), displayWidth(0x1F4C1)); +}