summaryrefslogtreecommitdiff
path: root/src/tui_components.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_components.zig')
-rw-r--r--src/tui_components.zig94
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" {