From dda343fb280b78d94d4eff4db458db94fa5cdcd5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 16 May 2026 15:55:33 -0500 Subject: [PATCH] Fix wide Unicode cell rendering Co-authored-by: Nathan Brake <33383515+njbrake@users.noreply.github.com> --- README.md | 1 + apps/docs/src/app/api-reference/page.mdx | 1 + apps/docs/src/app/core/page.mdx | 2 + apps/docs/src/app/ghostty/page.mdx | 2 +- apps/docs/src/app/page.mdx | 1 + e2e/tests/terminal.spec.ts | 19 ++ packages/@wterm/core/README.md | 4 +- .../core/src/__tests__/wasm-bridge.test.ts | 18 ++ packages/@wterm/core/src/terminal-core.ts | 2 + packages/@wterm/core/src/wasm-bridge.ts | 2 + packages/@wterm/core/wasm/wterm.wasm | Bin 12948 -> 14739 bytes .../@wterm/dom/src/__tests__/renderer.test.ts | 47 +++- packages/@wterm/dom/src/renderer.ts | 56 ++++- packages/@wterm/dom/src/terminal.css | 5 + src/cell.zig | 6 +- src/grid.zig | 20 +- src/terminal.zig | 227 +++++++++++++++++- src/unicode_width.zig | 37 +++ 18 files changed, 422 insertions(+), 28 deletions(-) create mode 100644 src/unicode_width.zig 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 fc03d96260ad66a9cdf352143fdb52e385b0f1ac..f7efe53b51458f3ebaa8073d8099d1d7fbaa765a 100755 GIT binary patch delta 3553 zcma)9Z-`V?6u}W;9Y~*mvihd+t5w zp7Z;id-nCMYS$|@v1;Y3Yl9x!&2g7=+jCDo_$ObqqHpKcUE6kV-Mn?@!`mL&vV%?7 z+aB5WD697T`S8l-6XG*I=d*Q|lklgUWVnG3i)Ndf?H7CQniMxm9yLUbuf`}RMHK^^ z;Efk%8?44Bi^BzaJh))?tW@_03b5kur{fgIRLP&Gj`+*3e&GSVa8&IpFGw2VN~fYI{QfM066T~Gar)ah zh|w90gS?og=~+GP(a&G$=cZmJ^HHm1)x#bVp4aoX?2-0{!5}YlX3mjzBJqF>TTAD5 zEXzqRYE*Va=k_A*ap*y}xFa5C`5Ea%Y<+z@RIv>#h-vA;ika00IUFkTg3RlQB-U0^ zg-G0r*+OR8hB)-1bBQ*C{KBd2na9P6RJIBav>gBQkH?xD2zj2t0!~B?F zfeRC}dRI8-#iN2v3LGmvkaTarn-djd*z}^J`ee@PisWOWO7a{Q08`c2EL48~vi`Oi zJ>kFAXs9*)ck{e@xqnrXr?92x>_n~YtACI(O&N;=o1}XQ+<{YMg+dpi|DOaD^9lYe ztwK!6Gv9z`DabL-)J^4q@X82MJIjU-Z^)%EAZ3XImHw?su7?z&%f?!OzodPFz+x0DZ+P(ee)u1FhIeX zZGJxA=lqEJcELS8M>S*S7yM&t?cid5Slu_cWYv|}jJRjP16?S1>Vv@=Ki)h)D7iXb zE?ourE@52Y8NwMpc5{m;*CQcWPewH%373#)g8W8^oB?}!0BImhnCLkxTJvx5kwBMv^c0bR;*EU^eNJ z%^>IhSH`eKs=x5zP zMpt%IH}erk0Y^NXNT5i22(F%xz!2aSI0W5s_8M_i>*a{4bOld}xq7T|A0zIuA_n9( zy^6a^Al0B;G4|g^_|<7Skf4zIM<^G>-5)XerQ?{yft-ngh2fEjKpn~5*5mn z#1JGw4FNIjG>k>h6xJk81*#Z4j^evF7{3M=DK6P4iE({()z-f7c))nwES9tpk(Rm>OBK$mQ5Q;LY_* zzTnA+f_q)@5R|EQD0wA(-)@Il@8orZM0Hs!EwZv z*&H!I%>WiK-vy#D;n0Z0dQ1U{X$U!mP?)9<-6Fw{T`ZEUOBvz3#8qlZGf#Am$q!0X zXIZ*oIm=T)T3HmEzb}6d@lm^1JjCBqXIEU$Pc|zPyEy`?s#_rXqxyanpx8*OW|~1$ wZFM(kfUP4VE%E~uo&IN{PPIY&0HHYD`Rb#UFYx1P-KtwkoUs{(57%GzFT)aji~s-t delta 1740 zcma)6U1%It6u#%~?9L=J$xgbPHp!;lI}^6q)Fd0V*=WYfPDLcdPy~M-OT`#7iN&;{ z(rR~wSg=%3ub_QUQrjAA)Au!hXdKx*LF3G8Mk9;Xzl%J;Wa@Jj&ZnuP7uy@G4c7J}0NLjPn zw1%hi1nc*b)+Y9C-*|R}JtC~I&_(G;KHzllIQ+{UUayOpie{af~P;9UxT?6Qf@ z%Rh1twOxqX-f7zJM3W9vrdzjLX1vwfj_%2`cD8&^i_-f>-D0^3C`~mr;tU!gdAZx8 z#ZccVsH!VZd@s}u+GOv)^Ye<@5Gye$G^E3$Bq#Kos|WttT4GK`#ER0!T2N+PPXh6R z?6;zd(y;({Xnr+-q}4X~AuuDF%U^eOSM`}Vodp#d(4CV~Db{UCNDq3A^~V{UDM+({ z&q-R$1n~$_>2XQ}mkuQQSkBd;rpT9^adiRfc+OF_=*r0F@_lAq>^O+zrTlLBTmGxc zh9giASmTWveG2S%Re>%N(_G+tqYN#=KM9xO3jS9eR~DQeIga-e&Te@I&lgVVQTxq^ z8h}=v7-iARV@+x`z@(D=D2u*qMf&?itjb)SuNYZlbGUiyFs}Cqe4kfIH?&RqEt%{A z)t3lPU6ht#%0%I5==ykJ07?@UXsZU`)ELWwNrLvm&kJ`@>%|CU(iF@m`=T^@g%yjG zn`zU&5bo`LinMb?BtI;6OyCqAQyihYHV@b?7qEhUCEkdBnt=^g(9eOY)-ekY+J61R z1^kJM;Buj_km7*l@bGfqw-kQY&pEBgD}z;hJRT?wwRvO!mr%suyfU}K*GnGJd*Sy( zKTx=Q>pbT2-r*^FUtSs>rup#a;bVlolOu; { @@ -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)); +}