diff --git a/src/grid.zig b/src/grid.zig index 117e42c..1609319 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 ───────────────────────────────────────────────────────────────────── @@ -145,37 +146,10 @@ 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. + /// Returns the display width of a Unicode codepoint (0, 1, or 2). 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.terminalDisplayWidth(cp)); } // ── Apply VT Event ─────────────────────────────────────────────────────── @@ -185,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) { @@ -756,8 +735,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 +754,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 +1035,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..dfbc5e8 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.terminalDisplayWidth(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; } @@ -257,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"); @@ -315,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 ──────────────────────────────────────────────────────────────────── @@ -473,3 +510,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 9d9456c..5081ca9 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"); @@ -81,6 +82,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 +416,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 +439,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 +453,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 +580,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 +708,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 +717,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 +890,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 +921,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 +942,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 +1011,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 +1201,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; @@ -1192,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.terminalDisplayWidth(cell.char)); cur_row = row; - cur_col = col + 1; + cur_col = col + @max(disp_w, 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); diff --git a/src/status_bar.zig b/src/status_bar.zig index 81d5047..38dc961 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,26 @@ pub fn writeString( i += 1; continue; }; + 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; + } 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 +293,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..2763694 --- /dev/null +++ b/src/unicode_width.zig @@ -0,0 +1,459 @@ +const std = @import("std"); + +// ─── 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. + +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; + 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), terminalDisplayWidth('A')); + try std.testing.expectEqual(@as(u8, 1), terminalDisplayWidth('z')); +} + +test "CJK width 2" { + 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), 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)); +}