diff --git a/src/backend/windows.zig b/src/backend/windows.zig index 7125cda..8974b56 100644 --- a/src/backend/windows.zig +++ b/src/backend/windows.zig @@ -208,6 +208,48 @@ const WAIT_TIMEOUT: DWORD = 0x00000102; const UTF8_CODE_PAGE: UINT = 65001; +/// Combines a UTF-16 surrogate pair into a code point. Returns null when +/// either half is outside its surrogate range. +fn combineSurrogates(hi: u16, lo: u16) ?u21 { + if (hi < 0xD800 or hi > 0xDBFF) return null; + if (lo < 0xDC00 or lo > 0xDFFF) return null; + return @intCast(0x10000 + ((@as(u21, hi) - 0xD800) << 10) + (@as(u21, lo) - 0xDC00)); +} + +/// Result of feeding one `KEY_EVENT_RECORD.UnicodeChar` code unit into the +/// surrogate state machine. +const SurrogateStep = union(enum) { + /// A leading surrogate was buffered; emit no event. + pending, + /// A complete code point is ready. + cp: u21, + /// Nothing to emit for this unit (trailing surrogate without a leader). + none, +}; + +/// UTF-16 input arrives as code units: a non-BMP character (emoji) is delivered +/// as two consecutive KEY_EVENTs, a leading surrogate followed by a trailing +/// one. `pending` holds the leading half across calls; `unit` is the code unit +/// just read. `pending` is always left cleared unless a new leader was stored. +fn resolveSurrogate(pending: *u16, unit: u16) SurrogateStep { + if (unit >= 0xD800 and unit <= 0xDBFF) { + pending.* = unit; + return .pending; + } + if (unit >= 0xDC00 and unit <= 0xDFFF) { + const hi = pending.*; + pending.* = 0; + if (hi != 0) { + if (combineSurrogates(hi, unit)) |cp| return .{ .cp = cp }; + } + return .none; + } + // Any other unit: a stray leader never combines, so drop it and let the + // caller process this unit normally. + pending.* = 0; + return .none; +} + pub const WindowsBackend = struct { allocator: Allocator, stdin_handle: HANDLE, @@ -218,6 +260,9 @@ pub const WindowsBackend = struct { in_alternate_screen: bool = false, mouse_enabled: bool = false, write_buffer: std.ArrayListUnmanaged(u8) = .empty, + /// Leading UTF-16 surrogate buffered while waiting for its trailing half + /// (see `resolveSurrogate`). 0 = none pending. + pending_high_surrogate: u16 = 0, original_console_info: CONSOLE_SCREEN_BUFFER_INFO = undefined, original_codepage: UINT = undefined, @@ -514,6 +559,15 @@ pub const WindowsBackend = struct { .shift = shift_pressed, }; + // Non-BMP input (emoji) is split across two KEY_EVENTs; combine + // the surrogate halves before the code-unit guard below, which + // would otherwise reject both. + switch (resolveSurrogate(&self.pending_high_surrogate, key_event.uChar.UnicodeChar)) { + .pending => return events.Event.none, + .cp => |cp| return events.Event{ .key = .{ .code = .{ .char = cp }, .modifiers = modifiers } }, + .none => {}, + } + // Map virtual key codes to KeyCode const key_code: events.KeyCode = switch (key_event.wVirtualKeyCode) { VK_RETURN => .enter, @@ -532,9 +586,10 @@ pub const WindowsBackend = struct { VK_INSERT => .insert, VK_F1...VK_F1 + 11 => |vk| .{ .f = @intCast(vk - VK_F1 + 1) }, else => blk: { - // Use the Unicode character if available + // Use the Unicode character if available (surrogates + // never reach this point; see `resolveSurrogate`). const unicode_char = key_event.uChar.UnicodeChar; - if (unicode_char > 0 and unicode_char < 0xD800) { + if (unicode_char > 0 and (unicode_char < 0xD800 or unicode_char > 0xDFFF)) { break :blk .{ .char = unicode_char }; } return events.Event.none; @@ -674,3 +729,45 @@ pub const WindowsBackend = struct { self.mouse_enabled = false; } }; + +test "combineSurrogates: valid pairs and boundaries" { + // πŸ˜€ U+1F600 = D83D DE00 + try std.testing.expectEqual(@as(?u21, 0x1F600), combineSurrogates(0xD83D, 0xDE00)); + // Boundary: U+10000 = D800 DC00, U+10FFFF = DBFF DFFF + try std.testing.expectEqual(@as(?u21, 0x10000), combineSurrogates(0xD800, 0xDC00)); + try std.testing.expectEqual(@as(?u21, 0x10FFFF), combineSurrogates(0xDBFF, 0xDFFF)); + // Non-surrogate halves are rejected + try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0x0041, 0xDE00)); + try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0xD83D, 0x0041)); + try std.testing.expectEqual(@as(?u21, null), combineSurrogates(0xDC00, 0xDC00)); +} + +test "resolveSurrogate: pairs, stray leaders and lone trailers" { + var pending: u16 = 0; + + // πŸ˜€ U+1F600: leader is buffered, trailer completes it + try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D)); + try std.testing.expectEqual(@as(u16, 0xD83D), pending); + try std.testing.expectEqual(SurrogateStep{ .cp = 0x1F600 }, resolveSurrogate(&pending, 0xDE00)); + try std.testing.expectEqual(@as(u16, 0), pending); + + // Stray leader followed by a normal character: leader is dropped, the + // character is processed normally by the caller + try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D)); + try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 'x')); + try std.testing.expectEqual(@as(u16, 0), pending); + + // Lone trailer: nothing to emit, no state left behind + try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 0xDE00)); + try std.testing.expectEqual(@as(u16, 0), pending); + + // Two leaders in a row: the second replaces the first + try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83D)); + try std.testing.expectEqual(SurrogateStep.pending, resolveSurrogate(&pending, 0xD83C)); + try std.testing.expectEqual(@as(u16, 0xD83C), pending); + // 🏏 U+1F3CF = D83C DFCF + try std.testing.expectEqual(SurrogateStep{ .cp = 0x1F3CF }, resolveSurrogate(&pending, 0xDFCF)); + + // Plain ASCII passes through the machine untouched + try std.testing.expectEqual(SurrogateStep.none, resolveSurrogate(&pending, 'a')); +} diff --git a/src/widgets/paragraph.zig b/src/widgets/paragraph.zig index de856e0..9074809 100644 --- a/src/widgets/paragraph.zig +++ b/src/widgets/paragraph.zig @@ -4,6 +4,7 @@ const style = @import("../style/mod.zig"); const Rect = render.Rect; const Buffer = render.Buffer; const Style = style.Style; +const codepointWidth = render.codepointWidth; pub const Paragraph = struct { text: []const u8, @@ -27,7 +28,12 @@ pub const Paragraph = struct { continue; } - if (x_offset >= area.width) { + const w: u16 = @intCast(codepointWidth(codepoint)); + if (w == 0) continue; + + // Wide characters occupy two columns; wrap before one that would + // straddle the right edge. + if (x_offset + w > area.width) { if (self.wrap) { y_offset += 1; x_offset = 0; @@ -44,8 +50,58 @@ pub const Paragraph = struct { if (y_offset < area.height) { buf.setChar(area.x + x_offset, area.y + y_offset, codepoint, self.style); - x_offset += 1; + x_offset += w; } } } }; + +test "Paragraph renders wide characters at correct columns" { + var buf = try render.Buffer.init(std.testing.allocator, 12, 1); + defer buf.deinit(); + + const para = Paragraph{ .text = "δ½ ε₯½πŸ˜Š" }; + para.render(.{ .x = 0, .y = 0, .width = 12, .height = 1 }, &buf); + + // δ½  (0-1), ε₯½ (2-3), 😊 (4-5) + try std.testing.expectEqual(@as(u21, 0x4F60), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(0, 0).?.width); + try std.testing.expectEqual(@as(u2, 0), buf.get(1, 0).?.width); // continuation + try std.testing.expectEqual(@as(u21, 0x597D), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(2, 0).?.width); + try std.testing.expectEqual(@as(u21, 0x1F60A), buf.get(4, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(4, 0).?.width); + try std.testing.expectEqual(@as(u2, 0), buf.get(5, 0).?.width); // continuation +} + +test "Paragraph wraps before a wide character that would straddle the edge" { + var buf = try render.Buffer.init(std.testing.allocator, 5, 2); + defer buf.deinit(); + + // "aδ½ b" in a 5-column area: 'a' (0), δ½  (1-2), 'b' (3). + const para = Paragraph{ .text = "aδ½ b" }; + para.render(.{ .x = 0, .y = 0, .width = 5, .height = 2 }, &buf); + try std.testing.expectEqual(@as(u21, 'a'), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, 0x4F60), buf.get(1, 0).?.char); + try std.testing.expectEqual(@as(u21, 'b'), buf.get(3, 0).?.char); + + // A 4-column area: 'a' (0), δ½  (1-2) fits, 'b' (3) fits. Edge case: + // "abδ½ " in width 3 must wrap δ½  to the next line instead of splitting it. + var buf2 = try render.Buffer.init(std.testing.allocator, 3, 2); + defer buf2.deinit(); + const para2 = Paragraph{ .text = "abδ½ " }; + para2.render(.{ .x = 0, .y = 0, .width = 3, .height = 2 }, &buf2); + try std.testing.expectEqual(@as(u21, 'a'), buf2.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, 'b'), buf2.get(1, 0).?.char); + try std.testing.expectEqual(@as(u21, 0x4F60), buf2.get(0, 1).?.char); // wrapped +} + +test "Paragraph respects newlines and non-wrap mode" { + var buf = try render.Buffer.init(std.testing.allocator, 6, 2); + defer buf.deinit(); + + const para = Paragraph{ .text = "ab\ncd", .wrap = false }; + para.render(.{ .x = 0, .y = 0, .width = 6, .height = 2 }, &buf); + try std.testing.expectEqual(@as(u21, 'a'), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, 'c'), buf.get(0, 1).?.char); +} diff --git a/src/widgets/tabs.zig b/src/widgets/tabs.zig index 2e39425..7c07b2f 100644 --- a/src/widgets/tabs.zig +++ b/src/widgets/tabs.zig @@ -4,6 +4,7 @@ const style = @import("../style/mod.zig"); const Rect = render.Rect; const Buffer = render.Buffer; const Style = style.Style; +const codepointWidth = render.codepointWidth; pub const Tabs = struct { titles: []const []const u8, @@ -45,13 +46,16 @@ pub const Tabs = struct { x += 1; } - // Title characters + // Title characters: advance by display width so a wide glyph is + // not written into the previous character's trailing cell. var view = std.unicode.Utf8View.initUnchecked(title); var iter = view.iterator(); while (iter.nextCodepoint()) |cp| { - if (x >= area.x + area.width) break; + const w: u16 = @intCast(codepointWidth(cp)); + if (w == 0) continue; + if (x + w > area.x + area.width) break; buf.setChar(x, y, cp, tab_style); - x += 1; + x += w; } // Right padding @@ -97,3 +101,38 @@ test "Tabs selectPrevious wraps" { tabs.selectPrevious(); try std.testing.expectEqual(@as(usize, 2), tabs.selected); } + +test "Tabs renders wide characters without clobbering them" { + var buf = try render.Buffer.init(std.testing.allocator, 24, 1); + defer buf.deinit(); + + const tabs = Tabs{ + .titles = &.{ "δΈ­ζ–‡", "B" }, + .selected = 0, + .padding = 0, + }; + tabs.render(.{ .x = 0, .y = 0, .width = 24, .height = 1 }, &buf); + + // "δΈ­ζ–‡" occupies columns 0-3, then the divider, then "B". + try std.testing.expectEqual(@as(u21, 0x4E2D), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(0, 0).?.width); + try std.testing.expectEqual(@as(u2, 0), buf.get(1, 0).?.width); // continuation + try std.testing.expectEqual(@as(u21, 0x6587), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(2, 0).?.width); + try std.testing.expectEqual(@as(u2, 0), buf.get(3, 0).?.width); // continuation + try std.testing.expectEqual(@as(u21, 0x2502), buf.get(4, 0).?.char); // β”‚ + try std.testing.expectEqual(@as(u21, 'B'), buf.get(5, 0).?.char); +} + +test "Tabs stops before a wide character that would straddle the edge" { + var buf = try render.Buffer.init(std.testing.allocator, 3, 1); + defer buf.deinit(); + + const tabs = Tabs{ .titles = &.{"δΈ­"}, .padding = 0 }; + tabs.render(.{ .x = 0, .y = 0, .width = 3, .height = 1 }, &buf); + + // 2-column glyph at column 0 fits; a second one would need columns 2-3 + // but the area is only 3 wide, so it must not be written. + try std.testing.expectEqual(@as(u21, 0x4E2D), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u21, ' '), buf.get(2, 0).?.char); +} diff --git a/src/widgets/text_input.zig b/src/widgets/text_input.zig index a76ba0d..797b1de 100644 --- a/src/widgets/text_input.zig +++ b/src/widgets/text_input.zig @@ -1,5 +1,6 @@ const std = @import("std"); const render = @import("../render/mod.zig"); +const codepointWidth = render.codepointWidth; const style = @import("../style/mod.zig"); const Rect = render.Rect; const Buffer = render.Buffer; @@ -20,7 +21,6 @@ pub fn TextInput(comptime max_bytes: usize) type { placeholder: []const u8 = "", placeholder_style: Style = .{}, - /// Return the current text as a slice. pub fn value(self: *const Self) []const u8 { return self.buf[0..self.len]; @@ -101,67 +101,57 @@ pub fn TextInput(comptime max_bytes: usize) type { return; } - // Determine visible window (scroll so cursor is on screen) - // We work in codepoints for display width simplicity. - var cp_count: u16 = 0; - { - var iter = std.unicode.Utf8View.initUnchecked(text).iterator(); - while (iter.nextCodepoint()) |_| cp_count += 1; - } - - // cursor codepoint index - var cursor_cp: u16 = 0; - { - var byte_pos: usize = 0; - var iter = std.unicode.Utf8View.initUnchecked(text).iterator(); - while (iter.nextCodepoint()) |_| : (byte_pos += 1) { - if (byte_pos >= self.cursor) break; - cursor_cp += 1; - byte_pos += 0; // already advanced by iterator; need exact byte tracking - } - } - // Simpler: count codepoints up to cursor byte offset - cursor_cp = 0; - { - var byte_pos: usize = 0; - var iter = std.unicode.Utf8View.initUnchecked(text).iterator(); - while (byte_pos < self.cursor) { - if (iter.nextCodepoint()) |cp_val| { - _ = cp_val; - cursor_cp += 1; - byte_pos = @intCast(iter.i); - } else break; + // Layout is column-based: CJK and emoji occupy two terminal + // columns, so advancing one column per code point would overwrite + // the trailing cell of a wide character with the next glyph. + // Columns occupied by the text before the cursor. + const cursor_col: usize = blk: { + var cols: usize = 0; + var i: usize = 0; + while (i < self.cursor and i < text.len) { + const d = decodeAt(text, i); + cols += codepointWidth(d.cp); + i += d.len; } - } + break :blk cols; + }; - // Scroll: show a window of `area.width` codepoints ending past cursor - const scroll: u16 = if (cursor_cp >= area.width) cursor_cp - area.width + 1 else 0; + // Scroll so the cursor column stays visible. + const width: usize = area.width; + const scroll: usize = if (cursor_col >= width) cursor_col - width + 1 else 0; + // Draw the visible window; `skip` counts columns scrolled off. var x: u16 = area.x; - var cp_idx: u16 = 0; - var byte_pos: usize = 0; - var iter = std.unicode.Utf8View.initUnchecked(text).iterator(); - - while (iter.nextCodepoint()) |cp_val| { - const cp_byte_start = byte_pos; - byte_pos = @intCast(iter.i); - _ = cp_byte_start; - - if (cp_idx < scroll) { - cp_idx += 1; + var col: usize = 0; + var cursor_drawn = false; + var i: usize = 0; + while (i < text.len) { + const d = decodeAt(text, i); + const w: usize = codepointWidth(d.cp); + const col_end = col + w; + // Characters fully scrolled off, or the cursor cell itself, are + // not painted as text (the cursor is drawn below). + if (col_end <= scroll) { + col = col_end; + i += d.len; continue; } - if (x >= area.x + area.width) break; - - const is_cursor = self.focused and (cp_idx == cursor_cp); - const cell_style = if (is_cursor) self.style.merge(self.cursor_style) else self.style; - buf.setChar(x, area.y, cp_val, cell_style); - x += 1; - cp_idx += 1; + if (x + @as(u16, @intCast(w)) > area.x + area.width) break; + + const is_cursor = self.focused and col == cursor_col; + if (is_cursor) { + buf.setChar(x, area.y, d.cp, self.style.merge(self.cursor_style)); + cursor_drawn = true; + } else { + buf.setChar(x, area.y, d.cp, self.style); + } + x += @intCast(w); + col = col_end; + i += d.len; } - // Draw cursor at end-of-text position - if (self.focused and x < area.x + area.width and cursor_cp == cp_count) { + // Cursor at end-of-text (or on a scrolled-into-view column). + if (self.focused and !cursor_drawn and x < area.x + area.width) { buf.setChar(x, area.y, ' ', self.style.merge(self.cursor_style)); x += 1; } @@ -174,6 +164,15 @@ pub fn TextInput(comptime max_bytes: usize) type { // ── Private helpers ─────────────────────────────────────────────────── + /// Decodes the code point at `bytes[index]` and its byte length. + /// Malformed input yields U+FFFD with length 1 (advance one byte). + fn decodeAt(bytes: []const u8, index: usize) struct { cp: u21, len: usize } { + const seq = std.unicode.utf8ByteSequenceLength(bytes[index]) catch return .{ .cp = 0xFFFD, .len = 1 }; + if (index + seq > bytes.len) return .{ .cp = 0xFFFD, .len = 1 }; + const cp = std.unicode.utf8Decode(bytes[index .. index + seq]) catch return .{ .cp = 0xFFFD, .len = 1 }; + return .{ .cp = cp, .len = seq }; + } + fn prevCodepointLen(self: *const Self) usize { // Walk backwards to find the start of the previous codepoint. var i = self.cursor; @@ -223,3 +222,115 @@ test "TextInput insert in middle" { input.insertCodepoint('B'); try std.testing.expectEqualStrings("ABC", input.value()); } + +test "TextInput renders wide characters at correct columns" { + var input = TextInput(64){}; + input.insertBytes("hi,δ½ ε₯½"); + input.insertCodepoint(0x1F60A); // 😊 emoji, 2 columns + + var buf = try render.Buffer.init(std.testing.allocator, 20, 1); + defer buf.deinit(); + input.render(.{ .x = 0, .y = 0, .width = 20, .height = 1 }, &buf); + + // Expected layout: h i , δ½  ε₯½ 😊 + // Columns: 0 1 2 3 4 5 6 7 8 + var cells: [20]u21 = undefined; + var widths: [20]u2 = undefined; + for (0..20) |i| { + const c = buf.get(@intCast(i), 0).?; + cells[i] = c.char; + widths[i] = c.width; + } + + try std.testing.expectEqual(@as(u21, 'h'), cells[0]); + try std.testing.expectEqual(@as(u21, 'i'), cells[1]); + try std.testing.expectEqual(@as(u21, ','), cells[2]); + try std.testing.expectEqual(@as(u21, 0x4F60), cells[3]); // δ½  + try std.testing.expectEqual(@as(u2, 2), widths[3]); + try std.testing.expectEqual(@as(u2, 0), widths[4]); // continuation cell + try std.testing.expectEqual(@as(u21, 0x597D), cells[5]); // ε₯½ + try std.testing.expectEqual(@as(u2, 2), widths[5]); + try std.testing.expectEqual(@as(u2, 0), widths[6]); // continuation cell + try std.testing.expectEqual(@as(u21, 0x1F60A), cells[7]); // 😊 + try std.testing.expectEqual(@as(u2, 2), widths[7]); + try std.testing.expectEqual(@as(u2, 0), widths[8]); // continuation cell + // The glyph right after a wide character must not have clobbered its pair. + try std.testing.expectEqual(@as(u2, 1), widths[9]); +} + +test "TextInput cursor column accounts for wide characters" { + var input = TextInput(64){}; + input.insertBytes("a"); + input.insertCodepoint(0x5F00); // εΌ€, 2 columns + input.insertBytes("b"); + // cursor at end (after 'b'): display column should be 1 + 2 + 1 = 4 + var buf = try render.Buffer.init(std.testing.allocator, 12, 1); + defer buf.deinit(); + input.render(.{ .x = 0, .y = 0, .width = 12, .height = 1 }, &buf); + // cursor cell (space with cursor style) should sit at column 4 + const c = buf.get(4, 0).?; + try std.testing.expectEqual(@as(u21, ' '), c.char); +} + +test "TextInput cursor on a wide character keeps it intact" { + var input = TextInput(64){}; + input.insertCodepoint(0x5F00); // εΌ€ (2 columns) + input.insertCodepoint(0x5FC3); // εΏƒ (2 columns) + input.moveCursorHome(); // cursor before εΌ€ + input.moveCursorRight(); // cursor before εΏƒ + + var buf = try render.Buffer.init(std.testing.allocator, 8, 1); + defer buf.deinit(); + input.render(.{ .x = 0, .y = 0, .width = 8, .height = 1 }, &buf); + + // εΌ€ at columns 0-1 (not covered by the cursor), εΏƒ at columns 2-3 (cursor). + try std.testing.expectEqual(@as(u21, 0x5F00), buf.get(0, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(0, 0).?.width); + try std.testing.expectEqual(@as(u21, 0x5FC3), buf.get(2, 0).?.char); + try std.testing.expectEqual(@as(u2, 2), buf.get(2, 0).?.width); + try std.testing.expectEqual(@as(u2, 0), buf.get(3, 0).?.width); // continuation +} + +test "TextInput scroll window is column-based" { + var input = TextInput(128){}; + // 6 wide characters (12 columns) then "ab"; area is 6 columns wide. + var n: usize = 0; + while (n < 6) : (n += 1) input.insertCodepoint(0x5F00); // εΌ€ Γ—6 + input.insertBytes("ab"); + input.moveCursorEnd(); + + var buf = try render.Buffer.init(std.testing.allocator, 6, 1); + defer buf.deinit(); + input.render(.{ .x = 0, .y = 0, .width = 6, .height = 1 }, &buf); + + // Cursor column = 14, width 6 -> scroll = 9. Visible columns 9..14: + // εΌ€ #5 occupies columns 8-9 (column 8 scrolled off, partially visible + // columns start at 9), then 'a' at 10, 'b' at 11, cursor at 12. + // The important invariant: 'a' and 'b' must be present, at columns 10/11. + var seen_a = false; + var seen_b = false; + for (0..6) |i| { + const c = buf.get(@intCast(i), 0).?.char; + if (c == 'a') seen_a = true; + if (c == 'b') seen_b = true; + } + try std.testing.expect(seen_a and seen_b); +} + +test "TextInput placeholder is width-clamped" { + var input = TextInput(64){}; + input.focused = false; + input.placeholder = "εΌ€εΌ€εΏƒεΏƒεΌ€εΏƒ"; + var buf = try render.Buffer.init(std.testing.allocator, 5, 1); + defer buf.deinit(); + input.render(.{ .x = 0, .y = 0, .width = 5, .height = 1 }, &buf); + // setStringTruncated must not leave a broken wide pair at the edge. + var col: u16 = 0; + while (col < 5) : (col += 1) { + const c = buf.get(col, 0).?; + if (c.width == 0) { + // continuation cell must follow a wide lead + try std.testing.expect(col > 0 and buf.get(col - 1, 0).?.width == 2); + } + } +}