From 598464c62da1bfc323828dfa62b11b7c16842199 Mon Sep 17 00:00:00 2001 From: NeonMedusa <321822063@qq.com> Date: Sun, 20 Sep 2026 23:32:01 +0800 Subject: [PATCH 1/4] windows: combine UTF-16 surrogate pairs for non-BMP input KEY_EVENT_RECORD.UnicodeChar carries a UTF-16 code unit, so a non-BMP character (emoji) arrives as two consecutive KEY_EVENTs: a leading surrogate followed by a trailing one. The code-unit guard (unicode_char < 0xD800) rejected both halves and the character was silently dropped - emoji picker (Win+.) and pasting emoji produced no event, while BMP input worked. Buffer the leading surrogate and combine it with the trailing half (same approach as conhost's _leadingSurrogate in terminalInput.cpp). A stray leader is dropped when the next unit is not a trailer, and a lone trailer emits nothing. The pairing and state machine are plain functions (combineSurrogates, resolveSurrogate) covered by unit tests, so they also run on non-Windows CI. Fixes #40. --- src/backend/windows.zig | 101 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) 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')); +} From 22b5d1758bc9558c49d9bcf968568d9355d66bf8 Mon Sep 17 00:00:00 2001 From: NeonMedusa <321822063@qq.com> Date: Mon, 21 Sep 2026 00:10:17 +0800 Subject: [PATCH 2/4] widgets: lay out TextInput by display width, not code point count CJK and emoji occupy two terminal columns, but render advanced one column per code point. The next glyph was then written into the trailing cell of a wide character, and the buffer cleared the pair - wide characters never appeared in the field (the value itself was correct, only the rendering was wrong). Cursor column, scroll window and glyph placement now all count display columns via codepointWidth, matching the buffer's cell model. Covered by tests for wide-character layout, cursor-on-wide-char, column-based scrolling and placeholder clamping. --- src/widgets/text_input.zig | 219 ++++++++++++++++++++++++++++--------- 1 file changed, 165 insertions(+), 54 deletions(-) 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); + } + } +} From c19dfe0bf25019a51762e646bd25cc1414d7a27a Mon Sep 17 00:00:00 2001 From: NeonMedusa <321822063@qq.com> Date: Mon, 21 Sep 2026 03:43:16 +0800 Subject: [PATCH 3/4] widgets: lay out Tabs by display width, not code point count Tab titles advanced one column per code point, so a title containing CJK or emoji was written into the previous character's trailing cell and the cell buffer cleared the pair - wide titles rendered as blanks. Advance by codepointWidth and stop before a wide glyph that would straddle the right edge. Zero-width code points are skipped. --- src/widgets/tabs.zig | 45 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) 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); +} From add6df9785955640951da105d372b0c84518985f Mon Sep 17 00:00:00 2001 From: NeonMedusa <321822063@qq.com> Date: Mon, 21 Sep 2026 03:43:32 +0800 Subject: [PATCH 4/4] widgets: lay out Paragraph by display width, not code point count Same one-column-per-code-point bug as TextInput and Tabs: CJK and emoji occupy two columns, so text containing wide characters rendered as blanks or wrapped at the wrong column. Advance and wrap by codepointWidth. Previously this widget had no tests; add coverage for wide-character layout, wrapping at the edge, and newline/non-wrap handling. --- src/widgets/paragraph.zig | 60 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) 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); +}