diff options
Diffstat (limited to 'src/tui_components.zig')
| -rw-r--r-- | src/tui_components.zig | 94 |
1 files changed, 82 insertions, 12 deletions
diff --git a/src/tui_components.zig b/src/tui_components.zig index de53f04..48f9377 100644 --- a/src/tui_components.zig +++ b/src/tui_components.zig @@ -38,18 +38,75 @@ const KeyCode = key.KeyCode; // Shared helpers // =========================================================================== -/// Number of display columns occupied by `text`, counted as one column per -/// UTF-8 codepoint. `text` here is assumed to be PLAIN (no escape sequences); -/// components wrap on plain text and only add styling escapes afterward, so -/// this is a faithful visible width. Mirrors the engine's P1 approximation -/// (1 col per codepoint; wide CJK/emoji width is a deferred refinement). +/// Terminal display width of a single Unicode codepoint: +/// 0 — combining marks, variation selectors, ZWJ, zero-width characters +/// 2 — wide East-Asian (CJK, Hangul, fullwidth) and most emoji (U+1Fxxx) +/// 1 — everything else +pub fn codepointWidth(cp: u21) usize { + // Zero-width: combining marks (U+0300–U+036F), Mn/Cf ranges, ZWJ (U+200D), + // variation selectors (U+FE00–U+FE0F, U+E0100–U+E01EF), etc. + if (cp == 0x200D) return 0; // ZWJ + if (cp >= 0x0300 and cp <= 0x036F) return 0; // combining diacriticals + if (cp >= 0x0483 and cp <= 0x0489) return 0; // combining Cyrillic + if (cp >= 0x0591 and cp <= 0x05BD) return 0; // Hebrew points + if (cp >= 0x0610 and cp <= 0x061A) return 0; // Arabic extended + if (cp >= 0x064B and cp <= 0x065F) return 0; // Arabic combining + if (cp >= 0x1AB0 and cp <= 0x1AFF) return 0; // combining ext. A + if (cp >= 0x1DC0 and cp <= 0x1DFF) return 0; // combining supplement + if (cp >= 0x20D0 and cp <= 0x20FF) return 0; // combining for symbols + if (cp >= 0xFE00 and cp <= 0xFE0F) return 0; // variation selectors + if (cp >= 0xFE20 and cp <= 0xFE2F) return 0; // combining half marks + if (cp >= 0xE0100 and cp <= 0xE01EF) return 0; // variation sel. supplement + + // Wide (2 columns): CJK unified/extension, Hangul, fullwidth forms, most emoji. + if (cp >= 0x1100 and cp <= 0x115F) return 2; // Hangul Jamo + if (cp >= 0x2E80 and cp <= 0x303E) return 2; // CJK radicals / Kangxi + if (cp >= 0x3041 and cp <= 0x33BF) return 2; // Hiragana / Katakana / CJK compat + if (cp >= 0x33FF and cp <= 0x33FF) return 2; + if (cp >= 0x3400 and cp <= 0x4DBF) return 2; // CJK extension A + if (cp >= 0x4E00 and cp <= 0x9FFF) return 2; // CJK unified + if (cp >= 0xA000 and cp <= 0xA4CF) return 2; // Yi + if (cp >= 0xA960 and cp <= 0xA97F) return 2; // Hangul Jamo ext A + if (cp >= 0xAC00 and cp <= 0xD7AF) return 2; // Hangul syllables + if (cp >= 0xD7B0 and cp <= 0xD7FF) return 2; // Hangul Jamo ext B + if (cp >= 0xF900 and cp <= 0xFAFF) return 2; // CJK compat ideographs + if (cp >= 0xFE10 and cp <= 0xFE1F) return 2; // vertical forms + if (cp >= 0xFE30 and cp <= 0xFE4F) return 2; // CJK compat forms + if (cp >= 0xFF01 and cp <= 0xFF60) return 2; // fullwidth / halfwidth + if (cp >= 0xFFE0 and cp <= 0xFFE6) return 2; // fullwidth signs + if (cp >= 0x1B000 and cp <= 0x1B0FF) return 2; // Kana supplement + if (cp >= 0x1F004 and cp <= 0x1F004) return 2; // mahjong tile + if (cp >= 0x1F0CF and cp <= 0x1F0CF) return 2; // playing card joker + if (cp >= 0x1F200 and cp <= 0x1F2FF) return 2; // enclosed ideographic + if (cp >= 0x1F300 and cp <= 0x1F64F) return 2; // misc symbols, emoticons + if (cp >= 0x1F680 and cp <= 0x1F6FF) return 2; // transport / map + if (cp >= 0x1F700 and cp <= 0x1F77F) return 2; // alchemical + if (cp >= 0x1F780 and cp <= 0x1F7FF) return 2; // geometric shapes ext + if (cp >= 0x1F800 and cp <= 0x1F8FF) return 2; // supplemental arrows C + if (cp >= 0x1F900 and cp <= 0x1F9FF) return 2; // supplemental symbols + if (cp >= 0x1FA00 and cp <= 0x1FA6F) return 2; // chess / other + if (cp >= 0x1FA70 and cp <= 0x1FAFF) return 2; // symbols and pictographs ext A + if (cp >= 0x20000 and cp <= 0x2A6DF) return 2; // CJK extension B + if (cp >= 0x2A700 and cp <= 0x2CEAF) return 2; // CJK extensions C/D/E + if (cp >= 0x2CEB0 and cp <= 0x2EBEF) return 2; // CJK extension F + if (cp >= 0x2F800 and cp <= 0x2FA1F) return 2; // CJK compat supplement + if (cp >= 0x30000 and cp <= 0x3134F) return 2; // CJK extension G + + return 1; +} + +/// Number of display columns occupied by `text`. `text` must be PLAIN (no +/// escape sequences); components wrap on plain text and only add styling +/// escapes afterward. pub fn displayWidth(text: []const u8) usize { var cols: usize = 0; var i: usize = 0; while (i < text.len) { const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; - cols += 1; - i += @min(seq_len, text.len - i); + const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + cols += codepointWidth(cp); + i += adv; } return cols; } @@ -60,11 +117,14 @@ pub fn displayWidth(text: []const u8) usize { pub fn truncateToCols(text: []const u8, max_cols: usize) []const u8 { var cols: usize = 0; var i: usize = 0; - while (i < text.len and cols < max_cols) { + while (i < text.len) { const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + const w = codepointWidth(cp); + if (cols + w > max_cols) break; // adding this glyph would exceed limit i += adv; - cols += 1; + cols += w; } return text[0..i]; } @@ -96,6 +156,8 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8) while (i < text.len) { const seq_len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; const adv = @min(seq_len, text.len - i); + const cp = std.unicode.utf8Decode(text[i .. i + adv]) catch '?'; + const cp_w = codepointWidth(cp); const is_space = adv == 1 and text[i] == ' '; if (is_space and line_cols == width) { @@ -109,7 +171,7 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8) i += adv; continue; } - if (line_cols + 1 > width) { + if (line_cols + cp_w > width) { // Adding this glyph would overflow; break the line first. if (last_break) |brk| { // Break at the last space: emit up to (not including) it, and @@ -127,7 +189,7 @@ fn wrapParagraph(text: []const u8, width: usize, out: *std.ArrayList([]const u8) } if (is_space) last_break = i; - line_cols += 1; + line_cols += cp_w; i += adv; } // Flush the final line (always emit, even if empty/trailing fragment). @@ -244,7 +306,8 @@ fn renderBlock( const vis = truncateToCols(line, inner_w); const text_cols = displayWidth(vis); // Right-pad so bg colour fills to the right edge. - const right_pad = width - pad_x - text_cols; + // Saturating subtract guards against wide-char overshoot edge cases. + const right_pad = width -| pad_x -| text_cols; const right_spaces = try alloc.alloc(u8, right_pad); defer alloc.free(right_spaces); @memset(right_spaces, ' '); @@ -1697,6 +1760,13 @@ test "displayWidth counts codepoints; truncateToCols respects boundaries" { // Never splits a multibyte codepoint. const t = truncateToCols("é", 1); try testing.expectEqualStrings("é", t); + + // Wide emoji count as 2 columns. + try testing.expectEqual(@as(usize, 2), codepointWidth('🤖')); + try testing.expectEqual(@as(usize, 9), displayWidth("hello 🤖 ")); + // truncateToCols must not split a 2-wide emoji across a 1-col boundary. + try testing.expectEqualStrings("hello ", truncateToCols("hello 🤖", 7)); + try testing.expectEqualStrings("hello 🤖", truncateToCols("hello 🤖", 8)); } test "wrapParagraph word-wraps and hard-splits long words" { |
