diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md index b29c42d..3dc1722 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/terminal-core.ts b/packages/@wterm/core/src/terminal-core.ts index f8c77d5..3c42e67 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 in terminal columns. Width 0 marks a continuation cell for a wide glyph. */ + width?: 0 | 1 | 2; /** 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..4cbedf4 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: 1, }; } @@ -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: 1, }; } diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index 678ea63..97519e6 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -66,6 +66,41 @@ describe("Renderer", () => { expect(text).toContain("i"); }); + it("skips wide-character continuation cells", () => { + const grid = [ + [ + makeCell("A"), + { ...makeCell("恂"), width: 2 as const }, + { char: 32, fg: 256, bg: 256, flags: 0, width: 0 as const }, + makeCell("B"), + ], + ]; + const bridge = createMockBridge(4, 1, grid); + const renderer = new Renderer(container); + renderer.render(bridge as any); + + expect(container.textContent).toContain("A恂B"); + expect(container.textContent).not.toContain("恂 B"); + }); + + it("keeps cursor placement after a wide-character continuation cell", () => { + const grid = [ + [ + makeCell("A"), + { ...makeCell("恂"), width: 2 as const }, + { char: 32, fg: 256, bg: 256, flags: 0, width: 0 as const }, + 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); + + const cursor = container.querySelector(".term-cursor"); + expect(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..f749fee 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, @@ -290,6 +283,13 @@ export class Renderer { const cell = getCell(col); const inBounds = col < lineLen; const cp = inBounds ? cell.char : 0; + if (inBounds && cell.width === 0) { + flushRun(col); + runStyle = ""; + runText = ""; + runStart = col + 1; + continue; + } if (inBounds && cp >= 0x2580 && cp <= 0x259f) { flushRun(col); diff --git a/packages/@wterm/ghostty/src/ghostty-core.ts b/packages/@wterm/ghostty/src/ghostty-core.ts index 0a67716..6ba7fd2 100644 --- a/packages/@wterm/ghostty/src/ghostty-core.ts +++ b/packages/@wterm/ghostty/src/ghostty-core.ts @@ -46,11 +46,25 @@ function packRgb(r: number, g: number, b: number): number { return (r << 16) | (g << 8) | b; } +function toCellData(cell: ReturnType): CellData { + const result: CellData = { + char: cell.codepoint || 32, + fg: DEFAULT_COLOR, + bg: DEFAULT_COLOR, + flags: cell.flags, + width: cell.width === 0 || cell.width === 2 ? cell.width : 1, + }; + if (cell.colorFlags & 1) result.fgRgb = packRgb(cell.fgR, cell.fgG, cell.fgB); + if (cell.colorFlags & 2) result.bgRgb = packRgb(cell.bgR, cell.bgG, cell.bgB); + return result; +} + const BLANK_CELL: CellData = { char: 32, fg: DEFAULT_COLOR, bg: DEFAULT_COLOR, flags: 0, + width: 1, }; export interface GhosttyOptions { @@ -143,20 +157,15 @@ export class GhosttyCore implements TerminalCore { if (byteOffset + CELL_BYTES > this._viewportBufSize) return BLANK_CELL; const cell = parseCell(view, byteOffset); - if (cell.codepoint === 0 && cell.flags === 0 && cell.colorFlags === 0) + if ( + cell.codepoint === 0 && + cell.flags === 0 && + cell.colorFlags === 0 && + cell.width !== 0 + ) return BLANK_CELL; - const result: CellData = { - char: cell.codepoint || 32, - fg: DEFAULT_COLOR, - bg: DEFAULT_COLOR, - flags: cell.flags, - }; - if (cell.colorFlags & 1) - result.fgRgb = packRgb(cell.fgR, cell.fgG, cell.fgB); - if (cell.colorFlags & 2) - result.bgRgb = packRgb(cell.bgR, cell.bgG, cell.bgB); - return result; + return toCellData(cell); } isDirtyRow(row: number): boolean { @@ -262,6 +271,7 @@ export class GhosttyCore implements TerminalCore { fg: DEFAULT_COLOR, bg: DEFAULT_COLOR, flags: cell.flags, + width: cell.width === 0 || cell.width === 2 ? cell.width : 1, fgRgb: packRgb(cell.fgR, cell.fgG, cell.fgB), bgRgb: packRgb(cell.bgR, cell.bgG, cell.bgB), };