From 044df524fada26a406985bd859a4411fb46b705a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:07:39 +0000 Subject: [PATCH 1/3] fix(ui): honor status_bar_position in compose and PTY sizing Config documented a top/bottom status bar, but the server always drew it on the last row and laid out pane content as if the bar were at the bottom. Add clientLayout() to compute tab bar, content region, and status row from config; use it in composeForClient, resizePtysForClient, and focus_pane. Respect status_bar when sizing new PTY grids. Add layout unit tests. Co-authored-by: midasdf --- src/server.zig | 157 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 34 deletions(-) diff --git a/src/server.zig b/src/server.zig index 9d9456c..9dc37e2 100644 --- a/src/server.zig +++ b/src/server.zig @@ -81,6 +81,41 @@ const DEFAULT_ROWS: u16 = 24; const MAX_CLIENTS: u8 = 8; const RECV_BUF_SIZE: usize = protocol.HEADER_SIZE + protocol.MAX_PAYLOAD_LEN; +/// Tab bar + optional status bar + pane content vertical layout for one client. +fn clientLayout(cfg: *const config_mod.Config, cs_rows: u16) struct { + tab_bar_rows: u16, + status_bar_rows: u16, + content_start_row: u16, + content_rows: u16, + status_bar_screen_row: ?u16, +} { + const tab_bar_rows: u16 = if (cs_rows > 0) 1 else 0; + const status_bar_rows: u16 = if (cfg.status_bar and cs_rows > 0) 1 else 0; + const reserved = tab_bar_rows + status_bar_rows; + const content_rows = cs_rows -| reserved; + + const status_top = std.mem.eql(u8, cfg.status_bar_position, "top"); + const content_start_row: u16 = if (status_top) + tab_bar_rows + status_bar_rows + else + tab_bar_rows; + + const status_bar_screen_row: ?u16 = if (status_bar_rows == 0) + null + else if (status_top) + tab_bar_rows + else + cs_rows -| 1; + + return .{ + .tab_bar_rows = tab_bar_rows, + .status_bar_rows = status_bar_rows, + .content_start_row = content_start_row, + .content_rows = content_rows, + .status_bar_screen_row = status_bar_screen_row, + }; +} + // ─── PaneState ──────────────────────────────────────────────────────────────── /// Per-pane runtime state (PTY + VT parser + scrollback + grid screen). @@ -380,7 +415,6 @@ pub const Server = struct { self.sendFrameTo(cs, .hello_ack, &.{}) catch {}; // Hide cursor and force full redraw self.sendFrameTo(cs, .render, "\x1b[?25l") catch {}; - } else if (isClientTag(tag)) { const client_id = clientIdFromTag(tag); const cs = self.clients.get(client_id) orelse continue; @@ -404,7 +438,10 @@ pub const Server = struct { while (cs.recv_len - consumed >= protocol.HEADER_SIZE) { const hdr_slice: *const [protocol.HEADER_SIZE]u8 = cs.recv_buf[consumed..][0..protocol.HEADER_SIZE]; - const hdr = protocol.decodeHeader(hdr_slice) catch { consumed += 1; continue; }; + const hdr = protocol.decodeHeader(hdr_slice) catch { + consumed += 1; + continue; + }; const frame_end = consumed + protocol.HEADER_SIZE + hdr.payload_len; if (cs.recv_len < frame_end) break; const payload = cs.recv_buf[consumed + protocol.HEADER_SIZE .. frame_end]; @@ -415,14 +452,12 @@ pub const Server = struct { std.mem.copyForwards(u8, cs.recv_buf[0..], cs.recv_buf[consumed..cs.recv_len]); } cs.recv_len = if (consumed > cs.recv_len) 0 else cs.recv_len - consumed; - } else if (tag == TAG_SIGNAL) { // SIGTERM or SIGINT — save layout before exit var ssi: linux.signalfd_siginfo = undefined; _ = posix.read(sig_fd, std.mem.asBytes(&ssi)) catch {}; session_mod.saveSession(self.allocator, self.session_name, &self.tab_manager) catch {}; self.running = false; - } else if (isPtyTag(tag)) { // PTY output for a pane const pane_id = paneIdFromTag(tag); @@ -544,11 +579,13 @@ pub const Server = struct { .focus_pane => { if (payload.len < 1) return; const dir = std.meta.intToEnum(protocol.Direction, payload[0]) catch return; - const status_bar_rows: u16 = if (self.config.status_bar and cs.rows > 0) 1 else 0; - const tab_bar_rows: u16 = if (cs.rows > 0) 1 else 0; - const reserved = tab_bar_rows + status_bar_rows; - const content_rows = if (cs.rows > reserved) cs.rows - reserved else cs.rows; - const total_region = pane_mod.Region{ .row = tab_bar_rows, .col = 0, .rows = content_rows, .cols = cs.cols }; + const lay = clientLayout(&self.config, cs.rows); + const total_region = pane_mod.Region{ + .row = lay.content_start_row, + .col = 0, + .rows = lay.content_rows, + .cols = cs.cols, + }; const regions = active_tab.pane_tree.calculateRegions(total_region) catch return; defer self.allocator.free(regions); if (active_tab.pane_tree.focusDirection(active_pane_id, dir, regions)) |new_id| { @@ -670,7 +707,6 @@ pub const Server = struct { // Initial size: use active client dimensions minus reserved UI rows/cols. // The grid will be properly resized to the actual pane region on the first compose(). - const reserved_rows: u16 = 2 + 2; // tab bar + status bar + border top/bottom const reserved_cols: u16 = 2; // border left/right var c_cols: u16 = DEFAULT_COLS; var c_rows: u16 = DEFAULT_ROWS; @@ -680,6 +716,9 @@ pub const Server = struct { if (ac.rows > 0) c_rows = ac.rows; } } + const tab_r: u16 = if (c_rows > 0) 1 else 0; + const status_r: u16 = if (self.config.status_bar and c_rows > 0) 1 else 0; + const reserved_rows: u16 = tab_r + status_r + 2; // UI chrome + pane border top/bottom const init_cols: u16 = if (c_cols > reserved_cols) c_cols - reserved_cols else 1; const init_rows: u16 = if (c_rows > reserved_rows) c_rows - reserved_rows else 1; const pty = try pty_mod.Pty.spawn(shell_z, init_cols, init_rows); @@ -850,11 +889,13 @@ pub const Server = struct { // ── resizePtysForClient ────────────────────────────────────────────── fn resizePtysForClient(self: *Server, cs: *ClientState) void { - const status_bar_rows: u16 = if (self.config.status_bar and cs.rows > 0) 1 else 0; - const tab_bar_rows: u16 = if (cs.rows > 0) 1 else 0; - const reserved = tab_bar_rows + status_bar_rows; - const content_rows = cs.rows -| reserved; - const total_region = pane_mod.Region{ .row = tab_bar_rows, .col = 0, .rows = content_rows, .cols = cs.cols }; + const lay = clientLayout(&self.config, cs.rows); + const total_region = pane_mod.Region{ + .row = lay.content_start_row, + .col = 0, + .rows = lay.content_rows, + .cols = cs.cols, + }; const active_tab = self.tab_manager.activeTab(cs.active_tab); const regions = active_tab.pane_tree.calculateRegions(total_region) catch return; defer self.allocator.free(regions); @@ -879,16 +920,12 @@ pub const Server = struct { // Clear back buffer cs.screen.clear(); - const status_bar_rows: u16 = if (self.config.status_bar and cs.rows > 0) 1 else 0; - const tab_bar_rows: u16 = if (cs.rows > 0) 1 else 0; - const reserved_rows = tab_bar_rows + status_bar_rows; - const content_rows = cs.rows -| reserved_rows; - const content_start_row = tab_bar_rows; + const lay = clientLayout(&self.config, cs.rows); const total_region = pane_mod.Region{ - .row = content_start_row, + .row = lay.content_start_row, .col = 0, - .rows = content_rows, + .rows = lay.content_rows, .cols = cs.cols, }; @@ -904,7 +941,7 @@ pub const Server = struct { const tab_names = tab_names_buf[0..tab_names_len]; // Render tab bar in row 0 - if (tab_bar_rows > 0 and cs.screen.rows > 0) { + if (lay.tab_bar_rows > 0 and cs.screen.rows > 0) { const tab_bar_start: usize = 0; const tab_bar_end = @as(usize, cs.screen.cols); if (tab_bar_end <= cs.screen.back.len) { @@ -973,9 +1010,8 @@ pub const Server = struct { } } - // Render status bar - if (status_bar_rows > 0 and cs.rows > 0) { - const bar_row = cs.rows - 1; + // Render status bar (row from config: top = below tab bar, bottom = last row) + if (lay.status_bar_screen_row) |bar_row| { if (bar_row < cs.screen.rows) { const bar_start = @as(usize, bar_row) * cs.screen.cols; const bar_end = bar_start + cs.screen.cols; @@ -1164,14 +1200,38 @@ pub const Server = struct { // Attributes const a = cell.attr; - if (a.bold) { @memcpy(buf[pos..][0..2], ";1"); pos += 2; } - if (a.dim) { @memcpy(buf[pos..][0..2], ";2"); pos += 2; } - if (a.italic) { @memcpy(buf[pos..][0..2], ";3"); pos += 2; } - if (a.underline) { @memcpy(buf[pos..][0..2], ";4"); pos += 2; } - if (a.blink) { @memcpy(buf[pos..][0..2], ";5"); pos += 2; } - if (a.inverse) { @memcpy(buf[pos..][0..2], ";7"); pos += 2; } - if (a.hidden) { @memcpy(buf[pos..][0..2], ";8"); pos += 2; } - if (a.strikethrough) { @memcpy(buf[pos..][0..2], ";9"); pos += 2; } + if (a.bold) { + @memcpy(buf[pos..][0..2], ";1"); + pos += 2; + } + if (a.dim) { + @memcpy(buf[pos..][0..2], ";2"); + pos += 2; + } + if (a.italic) { + @memcpy(buf[pos..][0..2], ";3"); + pos += 2; + } + if (a.underline) { + @memcpy(buf[pos..][0..2], ";4"); + pos += 2; + } + if (a.blink) { + @memcpy(buf[pos..][0..2], ";5"); + pos += 2; + } + if (a.inverse) { + @memcpy(buf[pos..][0..2], ";7"); + pos += 2; + } + if (a.hidden) { + @memcpy(buf[pos..][0..2], ";8"); + pos += 2; + } + if (a.strikethrough) { + @memcpy(buf[pos..][0..2], ";9"); + pos += 2; + } buf[pos] = 'm'; pos += 1; @@ -1324,6 +1384,35 @@ pub const Server = struct { // ─── Tests ──────────────────────────────────────────────────────────────────── +test "clientLayout bottom matches tab then content then status row" { + var cfg = config_mod.Config{}; + const lay = clientLayout(&cfg, 24); + try std.testing.expectEqual(@as(u16, 1), lay.tab_bar_rows); + try std.testing.expectEqual(@as(u16, 1), lay.status_bar_rows); + try std.testing.expectEqual(@as(u16, 1), lay.content_start_row); + try std.testing.expectEqual(@as(u16, 22), lay.content_rows); + try std.testing.expectEqual(@as(u16, 23), lay.status_bar_screen_row.?); +} + +test "clientLayout top places status below tab bar and content below both" { + var cfg = config_mod.Config{ .status_bar_position = "top" }; + const lay = clientLayout(&cfg, 24); + try std.testing.expectEqual(@as(u16, 1), lay.tab_bar_rows); + try std.testing.expectEqual(@as(u16, 1), lay.status_bar_rows); + try std.testing.expectEqual(@as(u16, 1), lay.status_bar_screen_row.?); + try std.testing.expectEqual(@as(u16, 2), lay.content_start_row); + try std.testing.expectEqual(@as(u16, 22), lay.content_rows); +} + +test "clientLayout disabled status bar frees a row for content" { + var cfg = config_mod.Config{ .status_bar = false }; + const lay = clientLayout(&cfg, 24); + try std.testing.expectEqual(@as(u16, 0), lay.status_bar_rows); + try std.testing.expectEqual(@as(?u16, null), lay.status_bar_screen_row); + try std.testing.expectEqual(@as(u16, 1), lay.content_start_row); + try std.testing.expectEqual(@as(u16, 23), lay.content_rows); +} + test "Server struct compiles" { // Ensure the Server type is valid and fields are accessible. const info = @typeInfo(Server); From 02718f07edf5f0c478e50674e110d5844e96a834 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:18:44 +0000 Subject: [PATCH 2/3] fix(ui): correct East Asian width in title bar, status bar, and render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add unicode_width.eastAsianDisplayWidth shared with grid title rendering - drawBorderWithTitle decodes UTF-8 and places wide-char spacer cells - status_bar writeString advances two cells for width-2 codepoints - sendDirtyRegionsTo tracks column by display width after emitting glyphs Tests cover titled border with 漢 and status bar writeString wide output. Co-authored-by: midasdf --- src/grid.zig | 39 +++++-------------------------- src/main.zig | 2 ++ src/render.zig | 53 ++++++++++++++++++++++++++++++++++++------ src/server.zig | 10 ++++---- src/status_bar.zig | 23 ++++++++++++++++++ src/unicode_width.zig | 54 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 src/unicode_width.zig diff --git a/src/grid.zig b/src/grid.zig index 117e42c..fe0a312 100644 --- a/src/grid.zig +++ b/src/grid.zig @@ -1,5 +1,6 @@ const std = @import("std"); const vt = @import("vt.zig"); +const unicode_width = @import("unicode_width.zig"); const Cell = vt.Cell; // ─── Row ───────────────────────────────────────────────────────────────────── @@ -146,36 +147,9 @@ pub const Grid = struct { // ── Character width ─────────────────────────────────────────────────────── /// Returns the display width of a Unicode codepoint (1 or 2). - /// CJK characters, fullwidth forms, and some symbols are 2 cells wide. fn charWidth(cp: u21) u16 { - // CJK Unified Ideographs - if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; - // CJK Unified Ideographs Extension A - if (cp >= 0x3400 and cp <= 0x4DBF) return 2; - // CJK Compatibility Ideographs - if (cp >= 0xF900 and cp <= 0xFAFF) return 2; - // Hiragana - if (cp >= 0x3040 and cp <= 0x309F) return 2; - // Katakana - if (cp >= 0x30A0 and cp <= 0x30FF) return 2; - // Fullwidth Forms - if (cp >= 0xFF01 and cp <= 0xFF60) return 2; - if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; - // Halfwidth Katakana (1-wide) - if (cp >= 0xFF65 and cp <= 0xFF9F) return 1; - // CJK Symbols and Punctuation - if (cp >= 0x3000 and cp <= 0x303F) return 2; - // Hangul Syllables - if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; - // Enclosed CJK Letters - if (cp >= 0x3200 and cp <= 0x32FF) return 2; - // CJK Compatibility - if (cp >= 0x3300 and cp <= 0x33FF) return 2; - // Bopomofo - if (cp >= 0x3100 and cp <= 0x312F) return 2; - // CJK Extension B+ - if (cp >= 0x20000 and cp <= 0x2FA1F) return 2; - return 1; + if (cp == 0) return 1; // NUL from VT (rare); wide spacers use a separate code path + return @as(u16, unicode_width.eastAsianDisplayWidth(cp)); } // ── Apply VT Event ─────────────────────────────────────────────────────── @@ -756,8 +730,8 @@ pub const Grid = struct { if (cursor_canonical_idx == current_canonical and cursor_in_canonical >= offset and (cursor_in_canonical <= chunk_end or - // Cursor can be past trimmed content (contentLen trims trailing spaces) - chunk_end == line_cells.len)) + // Cursor can be past trimmed content (contentLen trims trailing spaces) + chunk_end == line_cells.len)) { // Cursor at chunk_end means it's at the right edge; // if it's exactly at the boundary and there's more content, @@ -775,7 +749,6 @@ pub const Grid = struct { current_canonical += 1; } - // Phase 5: Adjust to viewport height while (self.rows.items.len < new_rows) { try self.rows.append(alloc, try Row.init(alloc, new_cols, true)); @@ -1057,7 +1030,7 @@ test "reflow preserves content through narrow-wide cycle" { try std.testing.expectEqual(@as(u21, 'h'), grid.getCell(0, 0).char); try std.testing.expectEqual(@as(u21, 'l'), grid.getCell(0, 9).char); - // Resize back to wide (20 cols) — should restore "hello world" + // Resize back to wide (20 cols) — should restore "hello world" try grid.resize(20, 5); try std.testing.expectEqual(@as(u21, 'h'), grid.getCell(0, 0).char); try std.testing.expectEqual(@as(u21, 'd'), grid.getCell(0, 10).char); diff --git a/src/main.zig b/src/main.zig index 6686bb5..4f126ec 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,7 @@ pub const status_bar = @import("status_bar.zig"); pub const client = @import("client.zig"); pub const server = @import("server.zig"); pub const session = @import("session.zig"); +pub const unicode_width = @import("unicode_width.zig"); // ─── CLI parsing ────────────────────────────────────────────────────────────── @@ -544,6 +545,7 @@ test { _ = client; _ = server; _ = session; + _ = unicode_width; } test "getSocketDir replaces {uid}" { diff --git a/src/render.zig b/src/render.zig index edf287b..7b7d8ae 100644 --- a/src/render.zig +++ b/src/render.zig @@ -1,6 +1,7 @@ const std = @import("std"); const vt = @import("vt.zig"); const pane_mod = @import("pane.zig"); +const unicode_width = @import("unicode_width.zig"); // ─── Cell ───────────────────────────────────────────────────────────────────── @@ -145,17 +146,40 @@ pub const Screen = struct { c += 1; } - // Title text (truncated to fit, leaving room for trailing space + at least one dash) - const max_title = if (inner_width > 3) inner_width - 3 else 0; - const title_len: u16 = @intCast(@min(title.len, max_title)); - var ti: u16 = 0; - while (ti < title_len and c < c_right) : (ti += 1) { - self.cellAt(r_top, c).* = Cell{ .char = title[ti], .fg = title_fg, .attr = attr }; + // Title text (UTF-8; display-width aware; room for trailing space + ≥1 dash) + const max_title_cells: u16 = if (inner_width > 3) inner_width - 3 else 0; + var used_cells: u16 = 0; + var ti: usize = 0; + while (ti < title.len and c < c_right and used_cells < max_title_cells) { + const cp_len = std.unicode.utf8ByteSequenceLength(title[ti]) catch break; + if (ti + cp_len > title.len) break; + const cp = std.unicode.utf8Decode(title[ti .. ti + cp_len]) catch { + ti += 1; + continue; + }; + const w: u16 = @intCast(unicode_width.eastAsianDisplayWidth(cp)); + if (w == 0) { + ti += cp_len; + continue; + } + if (used_cells + w > max_title_cells) break; + if (w == 1) { + if (c >= c_right) break; + } else { + if (c + 1 >= c_right) break; + } + self.cellAt(r_top, c).* = Cell{ .char = cp, .fg = title_fg, .attr = attr }; c += 1; + if (w == 2) { + self.cellAt(r_top, c).* = Cell{ .char = 0, .fg = title_fg, .attr = attr }; + c += 1; + } + used_cells += w; + ti += cp_len; } // Trailing space after title (only if title was written) - if (title_len > 0 and c < c_right) { + if (used_cells > 0 and c < c_right) { self.cellAt(r_top, c).* = Cell{ .char = ' ', .fg = border_fg, .attr = attr }; c += 1; } @@ -473,3 +497,18 @@ test "drawBorderWithTitle" { // Interior untouched try testing.expect(Cell.eql(screen.cellAt(r_top + 1, c_left + 1).*, Cell{})); } + +test "drawBorderWithTitle UTF-8 wide char uses two cells" { + var screen = try Screen.init(testing.allocator, 30, 10); + defer screen.deinit(); + + const region = pane_mod.Region{ .row = 0, .col = 0, .rows = 5, .cols = 16 }; + screen.drawBorderWithTitle(region, "漢", false); + + const r_top = region.row; + const c_left = region.col; + // After leading space: one CJK char + spacer + try testing.expectEqual(@as(u21, ' '), screen.cellAt(r_top, c_left + 1).char); + try testing.expectEqual(@as(u21, 0x6F22), screen.cellAt(r_top, c_left + 2).char); + try testing.expectEqual(@as(u21, 0), screen.cellAt(r_top, c_left + 3).char); +} diff --git a/src/server.zig b/src/server.zig index 9dc37e2..6eb9185 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1,6 +1,7 @@ const std = @import("std"); const posix = std.posix; const linux = std.os.linux; +const unicode_width = @import("unicode_width.zig"); const protocol = @import("protocol.zig"); const pty_mod = @import("pty.zig"); const vt_mod = @import("vt.zig"); @@ -1252,12 +1253,11 @@ pub const Server = struct { }; pos += utf8_len; - // Advance tracked cursor by 1. For wide (2-cell) chars, the next - // iteration handles the spacer cell (char==0) which advances by 1 - // more, giving a total advancement of 2. This relies on - // composeForClient placing spacer cells correctly. + // Advance tracked column by display width so the next CUP is correct + // for wide characters (spacer column is skipped in the loop body). + const disp_w: u16 = @intCast(unicode_width.eastAsianDisplayWidth(cell.char)); cur_row = row; - cur_col = col + 1; + cur_col = col + @max(disp_w, 1); } } diff --git a/src/status_bar.zig b/src/status_bar.zig index 81d5047..f0e511c 100644 --- a/src/status_bar.zig +++ b/src/status_bar.zig @@ -2,6 +2,7 @@ const std = @import("std"); const render = @import("render.zig"); const mode_mod = @import("mode.zig"); const vt = @import("vt.zig"); +const unicode_width = @import("unicode_width.zig"); // ─── Color constants ────────────────────────────────────────────────────────── @@ -109,12 +110,25 @@ pub fn writeString( i += 1; continue; }; + const w = unicode_width.eastAsianDisplayWidth(cp); + if (w == 0) { + i += cp_len; + continue; + } cells[offset.*] = render.Cell{ .char = cp, .fg = fg, .bg = bg, }; offset.* += 1; + if (w == 2 and offset.* < cells.len) { + cells[offset.*] = render.Cell{ + .char = 0, + .fg = fg, + .bg = bg, + }; + offset.* += 1; + } i += cp_len; } } @@ -278,6 +292,15 @@ test "renderTabBar renders tab names" { } } +test "writeString wide CJK uses spacer cell" { + var cells: [8]render.Cell = undefined; + var off: u16 = 0; + writeString(&cells, &off, "漢", COLOR_WHITE, BAR_BG); + try testing.expectEqual(@as(u16, 2), off); + try testing.expectEqual(@as(u21, 0x6F22), cells[0].char); + try testing.expectEqual(@as(u21, 0), cells[1].char); +} + test "renderTabBar single tab" { var cells: [40]render.Cell = undefined; const tab_names = [_][]const u8{"Tab 1"}; diff --git a/src/unicode_width.zig b/src/unicode_width.zig new file mode 100644 index 0000000..b3e8f2d --- /dev/null +++ b/src/unicode_width.zig @@ -0,0 +1,54 @@ +const std = @import("std"); + +// ─── East Asian display width ───────────────────────────────────────────────── + +/// Terminal cell width (1 or 2) for a Unicode scalar value. +/// Matches `grid` wide-character rules: CJK, fullwidth forms, etc. are 2 cells. +/// Returns 0 only for `cp == 0` (spacer / unset). +pub fn eastAsianDisplayWidth(cp: u21) u8 { + if (cp == 0) return 0; + // CJK Unified Ideographs + if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; + // CJK Unified Ideographs Extension A + if (cp >= 0x3400 and cp <= 0x4DBF) return 2; + // CJK Compatibility Ideographs + if (cp >= 0xF900 and cp <= 0xFAFF) return 2; + // Hiragana + if (cp >= 0x3040 and cp <= 0x309F) return 2; + // Katakana + if (cp >= 0x30A0 and cp <= 0x30FF) return 2; + // Fullwidth Forms + if (cp >= 0xFF01 and cp <= 0xFF60) return 2; + if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; + // Halfwidth Katakana (1-wide) + if (cp >= 0xFF65 and cp <= 0xFF9F) return 1; + // CJK Symbols and Punctuation + if (cp >= 0x3000 and cp <= 0x303F) return 2; + // Hangul Syllables + if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; + // Enclosed CJK Letters + if (cp >= 0x3200 and cp <= 0x32FF) return 2; + // CJK Compatibility + if (cp >= 0x3300 and cp <= 0x33FF) return 2; + // Bopomofo + if (cp >= 0x3100 and cp <= 0x312F) return 2; + // CJK Extension B+ + if (cp >= 0x20000 and cp <= 0x2FA1F) return 2; + return 1; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +test "ASCII width 1" { + try std.testing.expectEqual(@as(u8, 1), eastAsianDisplayWidth('A')); + try std.testing.expectEqual(@as(u8, 1), eastAsianDisplayWidth('z')); +} + +test "CJK width 2" { + try std.testing.expectEqual(@as(u8, 2), eastAsianDisplayWidth(0x3042)); // あ + try std.testing.expectEqual(@as(u8, 2), eastAsianDisplayWidth(0x6F22)); // 漢 +} + +test "spacer width 0" { + try std.testing.expectEqual(@as(u8, 0), eastAsianDisplayWidth(0)); +} From 7075b621eba256001609c1e404ca519d2d93a135 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Apr 2026 16:28:56 +0000 Subject: [PATCH 3/3] feat(unicode): wcwidth-style widths, emoji blocks, serializeCell wide pad - Expand unicode_width with zero-width combining ranges, emoji/pictograph width-2 blocks, and terminalDisplayWidth() API - Grid .print: width 0 codepoints skip (no spurious column advance) - Status bar / titles / server render already use terminalDisplayWidth - serializeCell: skip width-0; append space after width-2 glyphs for raw TTY Tests: combining, ZWJ, VS16, emoji, serializeCell trailing space. Co-authored-by: midasdf --- src/grid.zig | 9 +- src/render.zig | 15 +- src/server.zig | 2 +- src/status_bar.zig | 3 +- src/unicode_width.zig | 479 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 466 insertions(+), 42 deletions(-) diff --git a/src/grid.zig b/src/grid.zig index fe0a312..1609319 100644 --- a/src/grid.zig +++ b/src/grid.zig @@ -146,10 +146,10 @@ pub const Grid = struct { // ── Character width ─────────────────────────────────────────────────────── - /// Returns the display width of a Unicode codepoint (1 or 2). + /// Returns the display width of a Unicode codepoint (0, 1, or 2). fn charWidth(cp: u21) u16 { if (cp == 0) return 1; // NUL from VT (rare); wide spacers use a separate code path - return @as(u16, unicode_width.eastAsianDisplayWidth(cp)); + return @as(u16, unicode_width.terminalDisplayWidth(cp)); } // ── Apply VT Event ─────────────────────────────────────────────────────── @@ -159,6 +159,11 @@ pub const Grid = struct { switch (ev) { .print => |ch| { const w = charWidth(ch); + if (w == 0) { + // Combining marks / ZWJ / VS: terminal consumes without advancing column; + // single-codepoint cells cannot represent overlays — skip. + return null; + } // Auto-wrap: if cursor + char width exceeds right margin if (self.cursor_col + w > self.cols) { diff --git a/src/render.zig b/src/render.zig index 7b7d8ae..dfbc5e8 100644 --- a/src/render.zig +++ b/src/render.zig @@ -157,7 +157,7 @@ pub const Screen = struct { ti += 1; continue; }; - const w: u16 = @intCast(unicode_width.eastAsianDisplayWidth(cp)); + const w: u16 = @intCast(unicode_width.terminalDisplayWidth(cp)); if (w == 0) { ti += cp_len; continue; @@ -281,10 +281,15 @@ pub const Screen = struct { // ─── serializeCell ──────────────────────────────────────────────────────────── /// Write a single Cell as terminal escape sequences (SGR + UTF-8 codepoint). +/// Wide characters (EA/emoji width 2) are followed by a space so the host +/// terminal advances two columns, matching our grid/spacer model. pub fn serializeCell(cell: Cell, writer: anytype) !void { // Skip spacer cells (second cell of wide characters) if (cell.char == 0) return; + const disp_w = unicode_width.terminalDisplayWidth(cell.char); + if (disp_w == 0) return; // combining / VS — not representable as one cell here + // Build SGR: reset, then apply fg, bg, attr. try writer.writeAll("\x1b[0"); @@ -339,6 +344,14 @@ pub fn serializeCell(cell: Cell, writer: anytype) !void { return; }; try writer.writeAll(buf[0..len]); + if (disp_w == 2) try writer.writeByte(' '); +} + +test "serializeCell wide char emits trailing space" { + var list: std.ArrayList(u8) = .{}; + defer list.deinit(std.testing.allocator); + try serializeCell(Cell{ .char = 0x3042 }, list.writer(std.testing.allocator)); + try std.testing.expect(std.mem.endsWith(u8, list.items, " ")); } // ─── Tests ──────────────────────────────────────────────────────────────────── diff --git a/src/server.zig b/src/server.zig index 6eb9185..5081ca9 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1255,7 +1255,7 @@ pub const Server = struct { // Advance tracked column by display width so the next CUP is correct // for wide characters (spacer column is skipped in the loop body). - const disp_w: u16 = @intCast(unicode_width.eastAsianDisplayWidth(cell.char)); + const disp_w: u16 = @intCast(unicode_width.terminalDisplayWidth(cell.char)); cur_row = row; cur_col = col + @max(disp_w, 1); } diff --git a/src/status_bar.zig b/src/status_bar.zig index f0e511c..38dc961 100644 --- a/src/status_bar.zig +++ b/src/status_bar.zig @@ -110,8 +110,9 @@ pub fn writeString( i += 1; continue; }; - const w = unicode_width.eastAsianDisplayWidth(cp); + const w = unicode_width.terminalDisplayWidth(cp); if (w == 0) { + // Combining / VS / ZWJ — no cell in our model; skip (same as grid .print). i += cp_len; continue; } diff --git a/src/unicode_width.zig b/src/unicode_width.zig index b3e8f2d..2763694 100644 --- a/src/unicode_width.zig +++ b/src/unicode_width.zig @@ -1,54 +1,459 @@ const std = @import("std"); -// ─── East Asian display width ───────────────────────────────────────────────── +// ─── Range tables (Unicode 15–aligned wcwidth-style heuristics) ─────────────── +// Combining / enclosing / format characters with terminal column width 0. +// Not exhaustive; extend ranges as real-world gaps show up. -/// Terminal cell width (1 or 2) for a Unicode scalar value. -/// Matches `grid` wide-character rules: CJK, fullwidth forms, etc. are 2 cells. -/// Returns 0 only for `cp == 0` (spacer / unset). -pub fn eastAsianDisplayWidth(cp: u21) u8 { +const zero_width_ranges: []const [2]u21 = &.{ + .{ 0x00AD, 0x00AD }, // soft hyphen (ambiguous; terminals often 0) + .{ 0x034F, 0x034F }, // combining grapheme joiner + .{ 0x061C, 0x061C }, // Arabic letter mark + .{ 0x115F, 0x1160 }, // Hangul filler + .{ 0x17B4, 0x17B5 }, // Khmer vowel inherent + .{ 0x180B, 0x180D }, // Mongolian free variation + .{ 0x180E, 0x180E }, // Mongolian vowel separator + .{ 0x200B, 0x200F }, // ZWSP, ZWNJ, ZWJ, LRM, RLM + .{ 0x202A, 0x202E }, // embed / override (bidi) + .{ 0x2060, 0x2064 }, // word joiner, invisible plus, etc. + .{ 0x2066, 0x206F }, // bidi isolate + .{ 0xFE00, 0xFE0F }, // variation selectors 1–16 + .{ 0xFEFF, 0xFEFF }, // BOM / ZWNBSP + .{ 0x0300, 0x036F }, // combining diacriticals + .{ 0x0483, 0x0489 }, + .{ 0x0591, 0x05BD }, + .{ 0x05BF, 0x05BF }, + .{ 0x05C1, 0x05C2 }, + .{ 0x05C4, 0x05C5 }, + .{ 0x05C7, 0x05C7 }, + .{ 0x0610, 0x061A }, + .{ 0x064B, 0x065F }, + .{ 0x0670, 0x0670 }, + .{ 0x06D6, 0x06DC }, + .{ 0x06DF, 0x06E4 }, + .{ 0x06E7, 0x06E8 }, + .{ 0x06EA, 0x06ED }, + .{ 0x0711, 0x0711 }, + .{ 0x0730, 0x074A }, + .{ 0x07A6, 0x07B0 }, + .{ 0x07EB, 0x07F3 }, + .{ 0x07FD, 0x07FD }, + .{ 0x0816, 0x0819 }, + .{ 0x081B, 0x0823 }, + .{ 0x0825, 0x0827 }, + .{ 0x0829, 0x082D }, + .{ 0x0859, 0x085B }, + .{ 0x08D3, 0x08E1 }, + .{ 0x08E3, 0x0902 }, + .{ 0x093A, 0x093A }, + .{ 0x093C, 0x093C }, + .{ 0x0941, 0x0948 }, + .{ 0x094D, 0x094D }, + .{ 0x0951, 0x0957 }, + .{ 0x0962, 0x0963 }, + .{ 0x0981, 0x0981 }, + .{ 0x09BC, 0x09BC }, + .{ 0x09BE, 0x09BE }, + .{ 0x09C1, 0x09C4 }, + .{ 0x09CD, 0x09CD }, + .{ 0x09D7, 0x09D7 }, + .{ 0x09E2, 0x09E3 }, + .{ 0x09FE, 0x09FE }, + .{ 0x0A01, 0x0A02 }, + .{ 0x0A3C, 0x0A3C }, + .{ 0x0A41, 0x0A42 }, + .{ 0x0A47, 0x0A48 }, + .{ 0x0A4B, 0x0A4D }, + .{ 0x0A51, 0x0A51 }, + .{ 0x0A70, 0x0A71 }, + .{ 0x0A75, 0x0A75 }, + .{ 0x0A81, 0x0A82 }, + .{ 0x0ABC, 0x0ABC }, + .{ 0x0AC1, 0x0AC5 }, + .{ 0x0AC7, 0x0AC8 }, + .{ 0x0ACD, 0x0ACD }, + .{ 0x0AE2, 0x0AE3 }, + .{ 0x0AFA, 0x0AFF }, + .{ 0x0B01, 0x0B01 }, + .{ 0x0B3C, 0x0B3C }, + .{ 0x0B3F, 0x0B3F }, + .{ 0x0B41, 0x0B44 }, + .{ 0x0B4D, 0x0B4D }, + .{ 0x0B55, 0x0B56 }, + .{ 0x0B62, 0x0B63 }, + .{ 0x0B82, 0x0B82 }, + .{ 0x0BC0, 0x0BC0 }, + .{ 0x0BCD, 0x0BCD }, + .{ 0x0C00, 0x0C00 }, + .{ 0x0C04, 0x0C04 }, + .{ 0x0C3C, 0x0C3C }, + .{ 0x0C3E, 0x0C40 }, + .{ 0x0C46, 0x0C48 }, + .{ 0x0C4A, 0x0C4D }, + .{ 0x0C55, 0x0C56 }, + .{ 0x0C62, 0x0C63 }, + .{ 0x0C78, 0x0C7E }, + .{ 0x0CBC, 0x0CBC }, + .{ 0x0CBF, 0x0CBF }, + .{ 0x0CC6, 0x0CC6 }, + .{ 0x0CCC, 0x0CCD }, + .{ 0x0CE2, 0x0CE3 }, + .{ 0x0D00, 0x0D01 }, + .{ 0x0D3B, 0x0D3C }, + .{ 0x0D41, 0x0D44 }, + .{ 0x0D4D, 0x0D4D }, + .{ 0x0D62, 0x0D63 }, + .{ 0x0D81, 0x0D81 }, + .{ 0x0DCA, 0x0DCA }, + .{ 0x0DD2, 0x0DD4 }, + .{ 0x0DD6, 0x0DD6 }, + .{ 0x0E31, 0x0E31 }, + .{ 0x0E34, 0x0E3A }, + .{ 0x0E47, 0x0E4E }, + .{ 0x0EB1, 0x0EB1 }, + .{ 0x0EB4, 0x0EBC }, + .{ 0x0EC8, 0x0ECD }, + .{ 0x0F18, 0x0F19 }, + .{ 0x0F35, 0x0F35 }, + .{ 0x0F37, 0x0F37 }, + .{ 0x0F39, 0x0F39 }, + .{ 0x0F71, 0x0F7E }, + .{ 0x0F80, 0x0F84 }, + .{ 0x0F86, 0x0F87 }, + .{ 0x0F8D, 0x0F97 }, + .{ 0x0F99, 0x0FBC }, + .{ 0x0FC6, 0x0FC6 }, + .{ 0x102D, 0x1030 }, + .{ 0x1032, 0x1037 }, + .{ 0x1039, 0x103A }, + .{ 0x103D, 0x103E }, + .{ 0x1058, 0x1059 }, + .{ 0x105E, 0x1060 }, + .{ 0x1071, 0x1074 }, + .{ 0x1082, 0x1082 }, + .{ 0x1085, 0x1086 }, + .{ 0x108D, 0x108D }, + .{ 0x109D, 0x109D }, + .{ 0x135D, 0x135F }, + .{ 0x1712, 0x1714 }, + .{ 0x1732, 0x1734 }, + .{ 0x1752, 0x1753 }, + .{ 0x1772, 0x1773 }, + .{ 0x17B4, 0x17B5 }, + .{ 0x17B7, 0x17BD }, + .{ 0x17C6, 0x17C6 }, + .{ 0x17C9, 0x17D3 }, + .{ 0x17DD, 0x17DD }, + .{ 0x180B, 0x180D }, + .{ 0x1885, 0x1886 }, + .{ 0x18A9, 0x18A9 }, + .{ 0x1920, 0x1922 }, + .{ 0x1927, 0x1928 }, + .{ 0x1932, 0x1932 }, + .{ 0x1939, 0x193B }, + .{ 0x1A17, 0x1A18 }, + .{ 0x1A1B, 0x1A1B }, + .{ 0x1A56, 0x1A56 }, + .{ 0x1A58, 0x1A5E }, + .{ 0x1A60, 0x1A60 }, + .{ 0x1A62, 0x1A62 }, + .{ 0x1A65, 0x1A6C }, + .{ 0x1A73, 0x1A7C }, + .{ 0x1A7F, 0x1A7F }, + .{ 0x1AB0, 0x1ACE }, + .{ 0x1B00, 0x1B03 }, + .{ 0x1B34, 0x1B34 }, + .{ 0x1B36, 0x1B3A }, + .{ 0x1B3C, 0x1B3C }, + .{ 0x1B42, 0x1B42 }, + .{ 0x1B6B, 0x1B73 }, + .{ 0x1B80, 0x1B81 }, + .{ 0x1BA2, 0x1BA5 }, + .{ 0x1BA8, 0x1BA9 }, + .{ 0x1BAB, 0x1BAD }, + .{ 0x1BE6, 0x1BE6 }, + .{ 0x1BE8, 0x1BE9 }, + .{ 0x1BED, 0x1BED }, + .{ 0x1BEF, 0x1BF1 }, + .{ 0x1C2C, 0x1C33 }, + .{ 0x1C36, 0x1C37 }, + .{ 0x1CD0, 0x1CD2 }, + .{ 0x1CD4, 0x1CE0 }, + .{ 0x1CE2, 0x1CE8 }, + .{ 0x1CED, 0x1CED }, + .{ 0x1CF4, 0x1CF4 }, + .{ 0x1CF8, 0x1CF9 }, + .{ 0x1DC0, 0x1DFF }, + .{ 0x20D0, 0x20F0 }, + .{ 0x2CEF, 0x2CF1 }, + .{ 0x2D7F, 0x2D7F }, + .{ 0x2DE0, 0x2DFF }, + .{ 0x302A, 0x302F }, + .{ 0x3099, 0x309A }, + .{ 0xA66F, 0xA672 }, + .{ 0xA674, 0xA67D }, + .{ 0xA69E, 0xA69F }, + .{ 0xA6F0, 0xA6F1 }, + .{ 0xA802, 0xA802 }, + .{ 0xA806, 0xA806 }, + .{ 0xA80B, 0xA80B }, + .{ 0xA825, 0xA826 }, + .{ 0xA82C, 0xA82C }, + .{ 0xA8C4, 0xA8C5 }, + .{ 0xA8E0, 0xA8F1 }, + .{ 0xA8FF, 0xA8FF }, + .{ 0xA926, 0xA92D }, + .{ 0xA947, 0xA951 }, + .{ 0xA980, 0xA982 }, + .{ 0xA9B3, 0xA9B3 }, + .{ 0xA9B6, 0xA9B9 }, + .{ 0xA9BC, 0xA9BC }, + .{ 0xA9E5, 0xA9E5 }, + .{ 0xAA29, 0xAA2E }, + .{ 0xAA31, 0xAA32 }, + .{ 0xAA35, 0xAA36 }, + .{ 0xAA43, 0xAA43 }, + .{ 0xAA4C, 0xAA4C }, + .{ 0xAA7C, 0xAA7C }, + .{ 0xAAB0, 0xAAB0 }, + .{ 0xAAB2, 0xAAB4 }, + .{ 0xAAB7, 0xAAB8 }, + .{ 0xAABE, 0xAABF }, + .{ 0xAAC1, 0xAAC1 }, + .{ 0xAAEC, 0xAAED }, + .{ 0xAAF6, 0xAAF6 }, + .{ 0xABE5, 0xABE5 }, + .{ 0xABE8, 0xABE8 }, + .{ 0xABED, 0xABED }, + .{ 0xFB1E, 0xFB1E }, + .{ 0xFE20, 0xFE2F }, + .{ 0xFF9E, 0xFF9F }, // halfwidth katakana voicing (often 0 in wcwidth) + .{ 0x101FD, 0x101FD }, + .{ 0x102E0, 0x102E0 }, + .{ 0x10376, 0x1037A }, + .{ 0x10A01, 0x10A03 }, + .{ 0x10A05, 0x10A06 }, + .{ 0x10A0C, 0x10A0F }, + .{ 0x10A38, 0x10A3A }, + .{ 0x10A3F, 0x10A3F }, + .{ 0x10AE5, 0x10AE6 }, + .{ 0x10D24, 0x10D27 }, + .{ 0x10EAB, 0x10EAC }, + .{ 0x10F46, 0x10F50 }, + .{ 0x10F82, 0x10F85 }, + .{ 0x11001, 0x11001 }, + .{ 0x11038, 0x11046 }, + .{ 0x1107F, 0x11081 }, + .{ 0x110B3, 0x110B6 }, + .{ 0x110B9, 0x110BA }, + .{ 0x11100, 0x11102 }, + .{ 0x11127, 0x1112B }, + .{ 0x1112D, 0x11134 }, + .{ 0x11173, 0x11173 }, + .{ 0x11180, 0x11181 }, + .{ 0x111B6, 0x111BE }, + .{ 0x111C9, 0x111CC }, + .{ 0x111CF, 0x111CF }, + .{ 0x1122F, 0x11231 }, + .{ 0x11234, 0x11234 }, + .{ 0x11236, 0x11237 }, + .{ 0x1123E, 0x1123E }, + .{ 0x112DF, 0x112DF }, + .{ 0x112E3, 0x112EA }, + .{ 0x11300, 0x11301 }, + .{ 0x1133B, 0x1133C }, + .{ 0x11340, 0x11340 }, + .{ 0x11366, 0x1136C }, + .{ 0x11370, 0x11374 }, + .{ 0x11438, 0x1143F }, + .{ 0x11442, 0x11442 }, + .{ 0x11443, 0x11445 }, + .{ 0x11446, 0x11446 }, + .{ 0x1145E, 0x1145E }, + .{ 0x114B3, 0x114B8 }, + .{ 0x114BA, 0x114BA }, + .{ 0x114BF, 0x114C0 }, + .{ 0x114C2, 0x114C3 }, + .{ 0x115B2, 0x115B5 }, + .{ 0x115BC, 0x115BD }, + .{ 0x115BF, 0x115C0 }, + .{ 0x115DC, 0x115DD }, + .{ 0x11633, 0x1163A }, + .{ 0x1163D, 0x1163D }, + .{ 0x1163F, 0x11640 }, + .{ 0x116AB, 0x116AB }, + .{ 0x116AD, 0x116AD }, + .{ 0x116B0, 0x116B5 }, + .{ 0x116B7, 0x116B7 }, + .{ 0x1171D, 0x1171F }, + .{ 0x11722, 0x11725 }, + .{ 0x11727, 0x1172B }, + .{ 0x1182F, 0x11837 }, + .{ 0x11839, 0x1183A }, + .{ 0x1193F, 0x1193F }, + .{ 0x11941, 0x11941 }, + .{ 0x11942, 0x11942 }, + .{ 0x119D1, 0x119D7 }, + .{ 0x119DA, 0x119DF }, + .{ 0x119E0, 0x119E0 }, + .{ 0x11A01, 0x11A0A }, + .{ 0x11A35, 0x11A36 }, + .{ 0x11A39, 0x11A3B }, + .{ 0x11A3F, 0x11A40 }, + .{ 0x11A51, 0x11A5B }, + .{ 0x11A8A, 0x11A96 }, + .{ 0x11A98, 0x11A99 }, + .{ 0x11C30, 0x11C36 }, + .{ 0x11C38, 0x11C3D }, + .{ 0x11C3F, 0x11C3F }, + .{ 0x11C92, 0x11CA7 }, + .{ 0x11CAA, 0x11CB0 }, + .{ 0x11CB2, 0x11CB3 }, + .{ 0x11CB5, 0x11CB6 }, + .{ 0x11D31, 0x11D36 }, + .{ 0x11D3A, 0x11D3A }, + .{ 0x11D3C, 0x11D3D }, + .{ 0x11D3F, 0x11D45 }, + .{ 0x11D47, 0x11D47 }, + .{ 0x11D90, 0x11D91 }, + .{ 0x11D95, 0x11D95 }, + .{ 0x11D97, 0x11D97 }, + .{ 0x11EF3, 0x11EF4 }, + .{ 0x16AF0, 0x16AF4 }, + .{ 0x16B30, 0x16B36 }, + .{ 0x16F4F, 0x16F4F }, + .{ 0x16F8F, 0x16F92 }, + .{ 0x16FE4, 0x16FE4 }, + .{ 0x1BC9D, 0x1BC9E }, + .{ 0x1CF00, 0x1CF2D }, + .{ 0x1CF30, 0x1CF46 }, + .{ 0x1CFA, 0x1CFA }, + .{ 0x1D165, 0x1D169 }, + .{ 0x1D16D, 0x1D172 }, + .{ 0x1D17B, 0x1D182 }, + .{ 0x1D185, 0x1D18B }, + .{ 0x1D1AA, 0x1D1AD }, + .{ 0x1D242, 0x1D244 }, + .{ 0x1DA00, 0x1DA36 }, + .{ 0x1DA3B, 0x1DA6C }, + .{ 0x1DA75, 0x1DA75 }, + .{ 0x1DA84, 0x1DA84 }, + .{ 0x1DA9B, 0x1DA9F }, + .{ 0x1DAA1, 0x1DAAF }, + .{ 0x1E000, 0x1E006 }, + .{ 0x1E008, 0x1E018 }, + .{ 0x1E01B, 0x1E021 }, + .{ 0x1E023, 0x1E024 }, + .{ 0x1E026, 0x1E02A }, + .{ 0x1E08F, 0x1E08F }, + .{ 0x1E130, 0x1E136 }, + .{ 0x1E2AE, 0x1E2AE }, + .{ 0x1E2EC, 0x1E2EF }, + .{ 0x1E4EC, 0x1E4EF }, + .{ 0x1E8D0, 0x1E8D6 }, + .{ 0x1E944, 0x1E94A }, + .{ 0xE0100, 0xE01EF }, // variation selectors 17–256 +}; + +fn inAnyRange(cp: u21, ranges: []const [2]u21) bool { + for (ranges) |r| { + if (cp >= r[0] and cp <= r[1]) return true; + } + return false; +} + +/// East Asian "wide" blocks (terminal width 2), excluding halfwidth exceptions. +fn isEastAsianWide(cp: u21) bool { + if (cp >= 0xFF01 and cp <= 0xFF60) return true; + if (cp >= 0xFFE0 and cp <= 0xFFE6) return true; + if (cp >= 0xFF65 and cp <= 0xFF9F) return false; // halfwidth katakana + if (cp >= 0x4E00 and cp <= 0x9FFF) return true; + if (cp >= 0x3400 and cp <= 0x4DBF) return true; + if (cp >= 0xF900 and cp <= 0xFAFF) return true; + if (cp >= 0x3040 and cp <= 0x309F) return true; + if (cp >= 0x30A0 and cp <= 0x30FF) return true; + if (cp >= 0x3000 and cp <= 0x303F) return true; + if (cp >= 0xAC00 and cp <= 0xD7AF) return true; + if (cp >= 0x3200 and cp <= 0x32FF) return true; + if (cp >= 0x3300 and cp <= 0x33FF) return true; + if (cp >= 0x3100 and cp <= 0x312F) return true; + if (cp >= 0x20000 and cp <= 0x2FA1F) return true; + return false; +} + +/// Emoji and pictographs commonly rendered as width 2 in modern terminals. +fn isEmojiOrPictographWide(cp: u21) bool { + if (cp >= 0x1F1E6 and cp <= 0x1F1FF) return true; // regional indicators (flags) + if (cp >= 0x1F300 and cp <= 0x1FAFF) return true; // misc symbols & pictographs, etc. + if (cp >= 0x1F000 and cp <= 0x1F02F) return true; // mahjong, domino + if (cp >= 0x1F0A0 and cp <= 0x1F0FF) return true; // playing cards + if (cp >= 0x2700 and cp <= 0x27BF) return true; // dingbats (emoji subset) + if (cp == 0x231A or cp == 0x231B) return true; // watch, hourglass + if (cp >= 0x23E9 and cp <= 0x23F3) return true; // media / UI symbols + if (cp >= 0x23F8 and cp <= 0x23FA) return true; + if (cp >= 0x25FE and cp <= 0x25FF) return true; // ◾ ◿ + if (cp >= 0x2614 and cp <= 0x2615) return true; // umbrella, coffee + if (cp >= 0x2648 and cp <= 0x2653) return true; // zodiac + if (cp == 0x267F) return true; // wheelchair + if (cp >= 0x2693 and cp <= 0x2693) return true; + if (cp >= 0x26A1 and cp <= 0x26A1) return true; + if (cp >= 0x26AA and cp <= 0x26AB) return true; + if (cp >= 0x26BD and cp <= 0x26BE) return true; + if (cp >= 0x26C4 and cp <= 0x26C5) return true; + if (cp >= 0x26CE and cp <= 0x26CE) return true; + if (cp >= 0x26D4 and cp <= 0x26D4) return true; + if (cp >= 0x26EA and cp <= 0x26EA) return true; + if (cp >= 0x26F2 and cp <= 0x26F3) return true; + if (cp >= 0x26F5 and cp <= 0x26F5) return true; + if (cp >= 0x26FA and cp <= 0x26FA) return true; + if (cp >= 0x26FD and cp <= 0x26FD) return true; + return false; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/// Width in terminal cells: 0 (non-spacing / ignorable), 1 (narrow), or 2 (wide). +/// Not identical to libc `wcwidth` for every codepoint; tuned for TUI + emoji. +pub fn terminalDisplayWidth(cp: u21) u8 { if (cp == 0) return 0; - // CJK Unified Ideographs - if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; - // CJK Unified Ideographs Extension A - if (cp >= 0x3400 and cp <= 0x4DBF) return 2; - // CJK Compatibility Ideographs - if (cp >= 0xF900 and cp <= 0xFAFF) return 2; - // Hiragana - if (cp >= 0x3040 and cp <= 0x309F) return 2; - // Katakana - if (cp >= 0x30A0 and cp <= 0x30FF) return 2; - // Fullwidth Forms - if (cp >= 0xFF01 and cp <= 0xFF60) return 2; - if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; - // Halfwidth Katakana (1-wide) - if (cp >= 0xFF65 and cp <= 0xFF9F) return 1; - // CJK Symbols and Punctuation - if (cp >= 0x3000 and cp <= 0x303F) return 2; - // Hangul Syllables - if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; - // Enclosed CJK Letters - if (cp >= 0x3200 and cp <= 0x32FF) return 2; - // CJK Compatibility - if (cp >= 0x3300 and cp <= 0x33FF) return 2; - // Bopomofo - if (cp >= 0x3100 and cp <= 0x312F) return 2; - // CJK Extension B+ - if (cp >= 0x20000 and cp <= 0x2FA1F) return 2; + if (inAnyRange(cp, zero_width_ranges)) return 0; + if (isEastAsianWide(cp)) return 2; + if (isEmojiOrPictographWide(cp)) return 2; return 1; } +/// Alias for `terminalDisplayWidth` (historical name). +pub fn eastAsianDisplayWidth(cp: u21) u8 { + return terminalDisplayWidth(cp); +} + // ─── Tests ─────────────────────────────────────────────────────────────────── test "ASCII width 1" { - try std.testing.expectEqual(@as(u8, 1), eastAsianDisplayWidth('A')); - try std.testing.expectEqual(@as(u8, 1), eastAsianDisplayWidth('z')); + try std.testing.expectEqual(@as(u8, 1), terminalDisplayWidth('A')); + try std.testing.expectEqual(@as(u8, 1), terminalDisplayWidth('z')); } test "CJK width 2" { - try std.testing.expectEqual(@as(u8, 2), eastAsianDisplayWidth(0x3042)); // あ - try std.testing.expectEqual(@as(u8, 2), eastAsianDisplayWidth(0x6F22)); // 漢 + try std.testing.expectEqual(@as(u8, 2), terminalDisplayWidth(0x3042)); // あ + try std.testing.expectEqual(@as(u8, 2), terminalDisplayWidth(0x6F22)); // 漢 } test "spacer width 0" { - try std.testing.expectEqual(@as(u8, 0), eastAsianDisplayWidth(0)); + try std.testing.expectEqual(@as(u8, 0), terminalDisplayWidth(0)); +} + +test "combining acute width 0" { + try std.testing.expectEqual(@as(u8, 0), terminalDisplayWidth(0x0301)); +} + +test "ZWJ width 0" { + try std.testing.expectEqual(@as(u8, 0), terminalDisplayWidth(0x200D)); +} + +test "emoji smile width 2" { + try std.testing.expectEqual(@as(u8, 2), terminalDisplayWidth(0x1F600)); +} + +test "VS16 width 0" { + try std.testing.expectEqual(@as(u8, 0), terminalDisplayWidth(0xFE0F)); }