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));
+}