diff --git a/include/nocal/tui/unicode.hpp b/include/nocal/tui/unicode.hpp new file mode 100644 index 0000000..6272d7b --- /dev/null +++ b/include/nocal/tui/unicode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +namespace nocal::tui { + +struct DecodedCodepoint { + std::uint32_t value; + std::size_t length; +}; + +// Decode one UTF-8 codepoint at position `at` in `text`. +// Returns {0xfffd, 1} on error. Caller must ensure at < text.size(). +[[nodiscard]] DecodedCodepoint decode_codepoint(std::string_view text, std::size_t at) noexcept; + +// Terminal display width of a single codepoint: 0 (zero-width/combining), 1, or 2. +// Uses Unicode East Asian Width property with complete range tables. +[[nodiscard]] int codepoint_width(std::uint32_t value) noexcept; + +// Display width of a string. Handles grapheme clusters: +// - Emoji ZWJ sequences → width 2 +// - Emoji + skin tone / hair modifiers → width 2 +// - Regional indicator pairs (flags) → width 2 +// - Combining marks → absorbed by base +[[nodiscard]] int display_width(std::string_view text); + +// Truncate to `width` display cells. If `ellipsis` is true and the text is +// truncated, appends U+2026. Right-pads with spaces to reach `width`. +// Never breaks a grapheme cluster. +[[nodiscard]] std::string fit_text(std::string_view text, int width, bool ellipsis = true); + +} // namespace nocal::tui \ No newline at end of file diff --git a/meson.build b/meson.build index 0785931..0696abb 100644 --- a/meson.build +++ b/meson.build @@ -39,6 +39,7 @@ nocal_sources = files( 'src/tui/EventEditor.cpp', 'src/tui/Terminal.cpp', 'src/tui/TuiApp.cpp', + 'src/tui/unicode.cpp', ) nocal_lib = static_library('nocal-core', nocal_sources, include_directories: inc, dependencies: [sqlite, libcrypto, libcurl, libsecret, nlohmann_json]) @@ -105,10 +106,14 @@ test('tui', tui_tests) tui_focus_tests = executable('tui-focus-tests', 'tests/tui_focus_tests.cpp', dependencies: nocal_dep) test('tui-focus', tui_focus_tests) -editor_tests = executable('editor-tests', 'tests/editor_tests.cpp', - dependencies: nocal_dep) +editor_tests = executable('editor-tests', + 'tests/editor_tests.cpp', dependencies: nocal_dep) test('editor', editor_tests) +unicode_tests = executable('unicode-tests', + 'tests/unicode_tests.cpp', dependencies: nocal_dep) +test('nocal:unicode', unicode_tests) + test('cli-recovery', shell, args: [files('tests/cli_recovery_test.sh'), nocal_executable]) test('cli-transfer', shell, diff --git a/src/tui/EventEditor.cpp b/src/tui/EventEditor.cpp index 4752de6..6ee755a 100644 --- a/src/tui/EventEditor.cpp +++ b/src/tui/EventEditor.cpp @@ -1,4 +1,5 @@ #include "nocal/tui/EventEditor.hpp" +#include "nocal/tui/unicode.hpp" #include #include @@ -147,81 +148,6 @@ std::string single_line(std::string_view value) return result; } -struct DecodedCodepoint { - std::uint32_t value; - std::size_t length; -}; - -DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept -{ - const auto first = static_cast(text[at]); - if (first < 0x80U) return {first, 1}; - std::uint32_t value = 0; - std::size_t length = 1; - if ((first & 0xe0U) == 0xc0U) { - value = first & 0x1fU; - length = 2; - } else if ((first & 0xf0U) == 0xe0U) { - value = first & 0x0fU; - length = 3; - } else if ((first & 0xf8U) == 0xf0U) { - value = first & 0x07U; - length = 4; - } else { - return {0xfffdU, 1}; - } - if (at + length > text.size()) return {0xfffdU, 1}; - for (std::size_t offset = 1; offset < length; ++offset) { - const auto byte = static_cast(text[at + offset]); - if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1}; - value = (value << 6U) | (byte & 0x3fU); - } - return {value, length}; -} - -int codepoint_width(const std::uint32_t value) noexcept -{ - if (value == 0 || (value >= 0x0300U && value <= 0x036fU) || - (value >= 0x1ab0U && value <= 0x1affU) || - (value >= 0x1dc0U && value <= 0x1dffU) || - (value >= 0x20d0U && value <= 0x20ffU) || - (value >= 0xfe00U && value <= 0xfe0fU) || - (value >= 0xfe20U && value <= 0xfe2fU)) { - return 0; - } - if ((value >= 0x1100U && value <= 0x115fU) || value == 0x2329U || value == 0x232aU || - (value >= 0x2e80U && value <= 0xa4cfU) || - (value >= 0xac00U && value <= 0xd7a3U) || - (value >= 0xf900U && value <= 0xfaffU) || - (value >= 0xfe10U && value <= 0xfe19U) || - (value >= 0xfe30U && value <= 0xfe6fU) || - (value >= 0xff00U && value <= 0xff60U) || - (value >= 0xffe0U && value <= 0xffe6U) || - (value >= 0x2600U && value <= 0x27bfU) || - (value >= 0x1f300U && value <= 0x1faffU) || - (value >= 0x20000U && value <= 0x3fffdU)) { - return 2; - } - return 1; -} - -std::string fit(std::string_view text, const int width) -{ - if (width <= 0) return {}; - std::string result; - int used = 0; - for (std::size_t at = 0; at < text.size();) { - const auto decoded = decode_codepoint(text, at); - const auto cell_width = codepoint_width(decoded.value); - if (used + cell_width > width) break; - result.append(text.substr(at, decoded.length)); - at += decoded.length; - used += cell_width; - } - result.append(static_cast(width - used), ' '); - return result; -} - std::string style(std::string text, const std::string_view code, const bool ansi) { if (!ansi || text.empty()) return text; @@ -232,7 +158,7 @@ std::string interior_line(std::string content, const int width) { if (width <= 0) return {}; if (width == 1) return "│"; - return "│" + fit(content, width - 2) + "│"; + return "│" + fit_text(content, width - 2, false) + "│"; } std::string styled_interior_line(std::string content, const int width, @@ -240,7 +166,7 @@ std::string styled_interior_line(std::string content, const int width, { if (width <= 0) return {}; if (width == 1) return "│"; - return "│" + style(fit(content, width - 2), code, ansi) + "│"; + return "│" + style(fit_text(content, width - 2, false), code, ansi) + "│"; } std::string horizontal_line(const int width, const std::string_view left, @@ -508,7 +434,7 @@ std::string EventEditor::render(const int requested_width, const int requested_h if (selected) value += "_"; } std::string row = selected ? " › " : " "; - row += fit(labels[index], 11); + row += fit_text(labels[index], 11, false); row += " "; row += value; lines.push_back(styled_interior_line(std::move(row), width, selected ? "7" : "", ansi)); diff --git a/src/tui/Screen.cpp b/src/tui/Screen.cpp index 0e6e658..0f39154 100644 --- a/src/tui/Screen.cpp +++ b/src/tui/Screen.cpp @@ -1,4 +1,5 @@ #include "nocal/tui/Screen.hpp" +#include "nocal/tui/unicode.hpp" #include #include @@ -25,67 +26,6 @@ constexpr std::array weekday_long{ constexpr std::array weekday_short{ "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"}; -std::uint32_t decode_codepoint(const std::string_view text, const std::size_t at, - std::size_t& length) { - const auto first = static_cast(text[at]); - if (first < 0x80) { - length = 1; - return first; - } - int continuation = 0; - std::uint32_t result = 0; - if ((first & 0xe0) == 0xc0) { - continuation = 1; - result = first & 0x1f; - } else if ((first & 0xf0) == 0xe0) { - continuation = 2; - result = first & 0x0f; - } else if ((first & 0xf8) == 0xf0) { - continuation = 3; - result = first & 0x07; - } else { - length = 1; - return 0xfffd; - } - if (at + static_cast(continuation) >= text.size()) { - length = 1; - return 0xfffd; - } - for (int offset = 1; offset <= continuation; ++offset) { - const auto byte = static_cast(text[at + static_cast(offset)]); - if ((byte & 0xc0) != 0x80) { - length = 1; - return 0xfffd; - } - result = (result << 6) | (byte & 0x3f); - } - length = static_cast(continuation + 1); - return result; -} - -int codepoint_width(const std::uint32_t value) { - if ((value >= 0x0300 && value <= 0x036f) || (value >= 0xfe00 && value <= 0xfe0f)) { - return 0; - } - if ((value >= 0x1100 && value <= 0x115f) || (value >= 0x2e80 && value <= 0xa4cf) || - (value >= 0xac00 && value <= 0xd7a3) || (value >= 0xf900 && value <= 0xfaff) || - (value >= 0x1f300 && value <= 0x1faff)) { - return 2; - } - return 1; -} - -int display_width(const std::string_view text) { - int width = 0; - for (std::size_t at = 0; at < text.size();) { - std::size_t length = 1; - const auto cp = decode_codepoint(text, at, length); - width += codepoint_width(cp); - at += length; - } - return width; -} - std::string printable_text(const std::string_view text, const bool preserve_newlines = false) { std::string result{text}; for (auto& character : result) { @@ -97,40 +37,6 @@ std::string printable_text(const std::string_view text, const bool preserve_newl return result; } -std::string fit_text(const std::string_view text, const int requested_width, - const bool ellipsis = true) { - const int width = std::max(0, requested_width); - if (width == 0) { - return {}; - } - if (display_width(text) <= width) { - std::string result{text}; - result.append(static_cast(width - display_width(text)), ' '); - return result; - } - - const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0)); - std::string result; - int used = 0; - for (std::size_t at = 0; at < text.size();) { - std::size_t length = 1; - const auto cp = decode_codepoint(text, at, length); - const int cp_width = codepoint_width(cp); - if (used + cp_width > content_limit) { - break; - } - result.append(text.substr(at, length)); - used += cp_width; - at += length; - } - if (ellipsis && width > 1) { - result += "…"; - ++used; - } - result.append(static_cast(std::max(0, width - used)), ' '); - return result; -} - std::string centred(const std::string_view text, const int width) { const int visible = std::min(display_width(text), width); const int left = std::max(0, (width - visible) / 2); @@ -320,18 +226,17 @@ std::vector wrap_text(const std::string_view text, const int reques std::string part; int used = 0; while (word_at < word.size()) { - std::size_t length = 1; - const auto cp = decode_codepoint(word, word_at, length); - const auto cp_width = codepoint_width(cp); + const auto decoded = decode_codepoint(word, word_at); + const auto cp_width = codepoint_width(decoded.value); if (used + cp_width > width) { if (used == 0) { - part.append(word.substr(word_at, length)); - word_at += length; + part.append(word.substr(word_at, decoded.length)); + word_at += decoded.length; } break; } - part.append(word.substr(word_at, length)); - word_at += length; + part.append(word.substr(word_at, decoded.length)); + word_at += decoded.length; used += cp_width; } if (word_at < word.size() || !part.empty()) result.push_back(part); diff --git a/src/tui/unicode.cpp b/src/tui/unicode.cpp new file mode 100644 index 0000000..ce93289 --- /dev/null +++ b/src/tui/unicode.cpp @@ -0,0 +1,270 @@ +#include "nocal/tui/unicode.hpp" + +#include +#include +#include +#include + +namespace nocal::tui { +namespace { + +// Check if a codepoint is a skin tone modifier (0x1f3fb–0x1f3ff) +[[nodiscard]] bool is_skin_tone_modifier(std::uint32_t cp) noexcept +{ + return cp >= 0x1f3fbU && cp <= 0x1f3ffU; +} + +// Check if a codepoint is a hair style modifier (0x1f9b0–0x1f9b3, 0x1f9b6) +[[nodiscard]] bool is_hair_modifier(std::uint32_t cp) noexcept +{ + return (cp >= 0x1f9b0U && cp <= 0x1f9b3U) || cp == 0x1f9b6U; +} + +// Check if a codepoint is a regional indicator (0x1f1e6–0x1f1ff) +[[nodiscard]] bool is_regional_indicator(std::uint32_t cp) noexcept +{ + return cp >= 0x1f1e6U && cp <= 0x1f1ffU; +} + +// Check if a codepoint is a variation selector (0xfe00–0xfe0f) +[[nodiscard]] bool is_variation_selector(std::uint32_t cp) noexcept +{ + return cp >= 0xfe00U && cp <= 0xfe0fU; +} + +// Check if a codepoint is an emoji modifier that should be absorbed into a +// preceding emoji base character (skin tone or hair modifier). +[[nodiscard]] bool is_emoji_modifier(std::uint32_t cp) noexcept +{ + return is_skin_tone_modifier(cp) || is_hair_modifier(cp); +} + +// Decode one grapheme cluster starting at position `at` in `text`. +// Advances `at` past the entire cluster and returns the cluster's display width. +// Assumes at < text.size() on entry. +[[nodiscard]] int next_cluster_width(std::string_view text, std::size_t& at) +{ + const auto first = decode_codepoint(text, at); + const auto base = first.value; + std::size_t cluster_end = at + first.length; + + // Regional indicator pair: two in sequence form one grapheme of width 2 + if (is_regional_indicator(base)) { + if (cluster_end < text.size()) { + const auto next = decode_codepoint(text, cluster_end); + if (is_regional_indicator(next.value)) { + at = cluster_end + next.length; + return 2; + } + } + // Standalone regional indicator (shouldn't happen in well-formed text) + at = cluster_end; + return 1; + } + + // ZWJ at start of cluster: absorb the ZWJ and following character(s) + if (base == 0x200dU) { + // Look ahead for the joined character + if (cluster_end < text.size()) { + const auto joined = decode_codepoint(text, cluster_end); + cluster_end += joined.length; + // Absorb any trailing modifiers + while (cluster_end < text.size()) { + const auto mod = decode_codepoint(text, cluster_end); + if (is_emoji_modifier(mod.value)) { + cluster_end += mod.length; + } else { + break; + } + } + } + at = cluster_end; + return 2; + } + + // Determine the base width + const int base_width = codepoint_width(base); + + // Check if base is an emoji (width-2 char in emoji range) + const bool is_emoji_base = (base_width == 2 && base >= 0x2600U); + + // Absorb any combining marks, variation selectors, ZWJ sequences, and + // emoji modifiers that follow the base character. + bool found_zwj = false; + while (cluster_end < text.size()) { + const auto next = decode_codepoint(text, cluster_end); + const auto next_cp = next.value; + + if (next_cp == 0x200dU) { + // ZWJ: absorbed, but check what follows + found_zwj = true; + cluster_end += next.length; + // The next codepoint after ZWJ is part of the sequence + if (cluster_end < text.size()) { + const auto after_zwj = decode_codepoint(text, cluster_end); + cluster_end += after_zwj.length; + // Absorb any modifiers after the ZWJ-joined char + while (cluster_end < text.size()) { + const auto mod = decode_codepoint(text, cluster_end); + if (is_emoji_modifier(mod.value)) { + cluster_end += mod.length; + } else { + break; + } + } + } + } else if (is_variation_selector(next_cp) || codepoint_width(next_cp) == 0) { + // Variation selector or combining mark: absorbed + cluster_end += next.length; + } else if (is_emoji_modifier(next_cp)) { + // Emoji modifier: only absorbed if preceded by emoji base or ZWJ + if (is_emoji_base || found_zwj) { + cluster_end += next.length; + } else { + break; + } + } else if (next_cp == 0x200dU) { + // ZWJ: absorbed, but check what follows + found_zwj = true; + cluster_end += next.length; + // The next codepoint after ZWJ is part of the sequence + if (cluster_end < text.size()) { + const auto after_zwj = decode_codepoint(text, cluster_end); + cluster_end += after_zwj.length; + // Absorb any modifiers after the ZWJ-joined char + while (cluster_end < text.size()) { + const auto mod = decode_codepoint(text, cluster_end); + if (is_emoji_modifier(mod.value)) { + cluster_end += mod.length; + } else { + break; + } + } + } + } else { + break; + } + } + + // Final cluster width: base width (ZWJ sequences are always width 2, otherwise + // it's the base codepoint width) + at = cluster_end; + return found_zwj ? 2 : base_width; +} + +} // anonymous namespace + +DecodedCodepoint decode_codepoint(const std::string_view text, const std::size_t at) noexcept +{ + const auto first = static_cast(text[at]); + if (first < 0x80U) return {first, 1}; + std::uint32_t value = 0; + std::size_t length = 1; + if ((first & 0xe0U) == 0xc0U) { + value = first & 0x1fU; + length = 2; + } else if ((first & 0xf0U) == 0xe0U) { + value = first & 0x0fU; + length = 3; + } else if ((first & 0xf8U) == 0xf0U) { + value = first & 0x07U; + length = 4; + } else { + return {0xfffdU, 1}; + } + if (at + length > text.size()) return {0xfffdU, 1}; + for (std::size_t offset = 1; offset < length; ++offset) { + const auto byte = static_cast(text[at + offset]); + if ((byte & 0xc0U) != 0x80U) return {0xfffdU, 1}; + value = (value << 6U) | (byte & 0x3fU); + } + return {value, length}; +} + +int codepoint_width(const std::uint32_t value) noexcept +{ + // Zero-width / combining marks + if ((value >= 0x0300U && value <= 0x036fU) || + (value >= 0x1ab0U && value <= 0x1affU) || + (value >= 0x1dc0U && value <= 0x1dffU) || + (value >= 0x20d0U && value <= 0x20ffU) || + (value >= 0xfe00U && value <= 0xfe0fU) || + (value >= 0xfe20U && value <= 0xfe2fU) || + value == 0x200dU || + // Variation selectors supplement (Plane 14) + (value >= 0xe0100U && value <= 0xe01efU)) { + return 0; + } + + // Double-width codepoints + if ((value >= 0x1100U && value <= 0x115fU) || + value == 0x2329U || value == 0x232aU || + (value >= 0x2e80U && value <= 0xa4cfU) || + (value >= 0xac00U && value <= 0xd7a3U) || + (value >= 0xf900U && value <= 0xfaffU) || + (value >= 0xfe10U && value <= 0xfe19U) || + (value >= 0xfe30U && value <= 0xfe6fU) || + (value >= 0xff00U && value <= 0xff60U) || + (value >= 0xffe0U && value <= 0xffe6U) || + (value >= 0x2600U && value <= 0x27bfU) || + (value >= 0x1f300U && value <= 0x1faffU) || + (value >= 0x20000U && value <= 0x3fffdU)) { + return 2; + } + + return 1; +} + +int display_width(const std::string_view text) +{ + int width = 0; + std::size_t at = 0; + while (at < text.size()) { + width += next_cluster_width(text, at); + } + return width; +} + +std::string fit_text(const std::string_view text, const int requested_width, const bool ellipsis) +{ + const int width = std::max(0, requested_width); + if (width == 0) { + return {}; + } + + // If text fits, pad and return + if (display_width(text) <= width) { + std::string result{text}; + const auto remaining = static_cast(width - display_width(text)); + if (remaining > 0) { + result.append(remaining, ' '); + } + return result; + } + + const int content_limit = std::max(0, width - (ellipsis && width > 1 ? 1 : 0)); + std::string result; + int used = 0; + std::size_t at = 0; + while (at < text.size()) { + std::size_t cluster_start = at; + const int cluster_width = next_cluster_width(text, at); + if (used + cluster_width > content_limit) { + break; + } + result.append(text.substr(cluster_start, at - cluster_start)); + used += cluster_width; + } + + if (ellipsis && width > 1) { + result += "\u2026"; + ++used; + } + + if (const auto remaining = static_cast(std::max(0, width - used)); remaining > 0) { + result.append(remaining, ' '); + } + return result; +} + +} // namespace nocal::tui \ No newline at end of file diff --git a/tests/unicode_tests.cpp b/tests/unicode_tests.cpp new file mode 100644 index 0000000..6171622 --- /dev/null +++ b/tests/unicode_tests.cpp @@ -0,0 +1,248 @@ +#include "nocal/tui/unicode.hpp" + +#include +#include +#include + +namespace { + +int failures = 0; + +void check(const bool condition, const std::string_view message) +{ + if (!condition) { + ++failures; + std::cerr << "FAIL: " << message << '\n'; + } +} + +void test_decode_codepoint() +{ + // ASCII + { + const auto d = nocal::tui::decode_codepoint("A", 0); + check(d.value == 0x41U && d.length == 1, "ASCII decode"); + } + + // Two-byte UTF-8 (U+00A9 ©) + { + const auto d = nocal::tui::decode_codepoint("\u00a9", 0); + check(d.value == 0xa9U && d.length == 2, "2-byte decode"); + } + + // Three-byte UTF-8 (U+4E00 一) + { + const auto d = nocal::tui::decode_codepoint("\u4e00", 0); + check(d.value == 0x4e00U && d.length == 3, "3-byte decode"); + } + + // Four-byte UTF-8 (U+1F600 😀) + { + const auto d = nocal::tui::decode_codepoint("\U0001f600", 0); + check(d.value == 0x1f600U && d.length == 4, "4-byte decode"); + } + + // Invalid continuation byte → replacement character + { + const auto d = nocal::tui::decode_codepoint("\x80", 0); + check(d.value == 0xfffdU && d.length == 1, "invalid byte yields replacement"); + } + + // Truncated sequence + { + const auto d = nocal::tui::decode_codepoint("\xc0", 0); + check(d.value == 0xfffdU && d.length == 1, "truncated sequence yields replacement"); + } +} + +void test_codepoint_width() +{ + // ASCII + check(nocal::tui::codepoint_width(0x41U) == 1, "ASCII letter width 1"); + + // Combining marks + check(nocal::tui::codepoint_width(0x0300U) == 0, "combining grave accent width 0"); + check(nocal::tui::codepoint_width(0x036fU) == 0, "combining mark end width 0"); + check(nocal::tui::codepoint_width(0x1ab0U) == 0, "combining half mark start width 0"); + check(nocal::tui::codepoint_width(0x1affU) == 0, "combining half mark end width 0"); + check(nocal::tui::codepoint_width(0x1dc0U) == 0, "combining diacritical extended start width 0"); + check(nocal::tui::codepoint_width(0x1dffU) == 0, "combining diacritical extended end width 0"); + check(nocal::tui::codepoint_width(0x20d0U) == 0, "combining enclosing key symbols start width 0"); + check(nocal::tui::codepoint_width(0x20ffU) == 0, "combining enclosing key symbols end width 0"); + check(nocal::tui::codepoint_width(0xfe00U) == 0, "variation selector-1 width 0"); + check(nocal::tui::codepoint_width(0xfe0fU) == 0, "variation selector-16 width 0"); + check(nocal::tui::codepoint_width(0xfe20U) == 0, "combining ligature start width 0"); + check(nocal::tui::codepoint_width(0xfe2fU) == 0, "combining ligature end width 0"); + check(nocal::tui::codepoint_width(0x200dU) == 0, "ZWJ width 0"); + check(nocal::tui::codepoint_width(0x1f3fbU) == 2, "skin tone modifier standalone width 2"); + + // Double-width CJK + check(nocal::tui::codepoint_width(0x1100U) == 2, "Hangul Jamo start width 2"); + check(nocal::tui::codepoint_width(0x115fU) == 2, "Hangul Jamo end width 2"); + check(nocal::tui::codepoint_width(0x2329U) == 2, "left-pointing angle bracket width 2"); + check(nocal::tui::codepoint_width(0x232aU) == 2, "right-pointing angle bracket width 2"); + check(nocal::tui::codepoint_width(0x2e80U) == 2, "CJK radical start width 2"); + check(nocal::tui::codepoint_width(0xa4cfU) == 2, "Yi syllable end width 2"); + check(nocal::tui::codepoint_width(0xac00U) == 2, "Hangul syllable start width 2"); + check(nocal::tui::codepoint_width(0xd7a3U) == 2, "Hangul syllable end width 2"); + check(nocal::tui::codepoint_width(0xf900U) == 2, "CJK compat ideograph start width 2"); + check(nocal::tui::codepoint_width(0xfaffU) == 2, "CJK compat ideograph end width 2"); + check(nocal::tui::codepoint_width(0xfe10U) == 2, "vertical form start width 2"); + check(nocal::tui::codepoint_width(0xfe19U) == 2, "vertical form end width 2"); + check(nocal::tui::codepoint_width(0xfe30U) == 2, "CJK compat form start width 2"); + check(nocal::tui::codepoint_width(0xfe6fU) == 2, "CJK compat form end width 2"); + check(nocal::tui::codepoint_width(0xff00U) == 2, "fullwidth ASCII start width 2"); + check(nocal::tui::codepoint_width(0xff60U) == 2, "fullwidth ASCII end width 2"); + check(nocal::tui::codepoint_width(0xffe0U) == 2, "fullwidth symbol start width 2"); + check(nocal::tui::codepoint_width(0xffe6U) == 2, "fullwidth symbol end width 2"); + check(nocal::tui::codepoint_width(0x2600U) == 2, "misc symbol start width 2"); + check(nocal::tui::codepoint_width(0x27bfU) == 2, "misc symbol end width 2"); + check(nocal::tui::codepoint_width(0x1f300U) == 2, "misc symbol pictograph start width 2"); + check(nocal::tui::codepoint_width(0x1faffU) == 2, "misc symbol pictograph end width 2"); + check(nocal::tui::codepoint_width(0x20000U) == 2, "CJK ext B start width 2"); + check(nocal::tui::codepoint_width(0x3fffdU) == 2, "CJK ext B end width 2"); + + // Regular CJK character (U+4E00) + check(nocal::tui::codepoint_width(0x4e00U) == 2, "CJK ideograph width 2"); +} + +void test_display_width() +{ + using nocal::tui::display_width; + + // ASCII text: width equals byte count + check(display_width("hello") == 5, "ASCII text width"); + check(display_width("") == 0, "empty string width"); + + // CJK characters: each is width 2 + check(display_width("\u4e00") == 2, "single CJK width 2"); + check(display_width("\u4e00\u4e00") == 4, "two CJK width 4"); + check(display_width("a\u4e00b") == 4, "mixed ASCII+CJK width"); + + // Combining marks: absorbed by base + check(display_width("a\u0300") == 1, "letter+combining mark width 1"); + check(display_width("\u4e00\u0300") == 2, "CJK+combining mark width 2"); + + // Emoji: width 2 + check(display_width("\U0001f600") == 2, "emoji width 2"); + check(display_width("\U0001f600\U0001f601") == 4, "two emoji width 4"); + + // Skin tone modifiers: absorbed (e.g. 👋🏽) + check(display_width("\U0001f44b\U0001f3fd") == 2, "emoji+skin tone width 2"); + + // Regional indicator pairs: width 2 (e.g. 🇺🇸) + check(display_width("\U0001f1fa\U0001f1f8") == 2, "US flag width 2"); +} + +void test_fit_text() +{ + using nocal::tui::fit_text; + + // Empty string + check(fit_text("", 5) == " ", "empty fit to 5"); + check(fit_text("", 0) == "", "empty fit to 0"); + check(fit_text("", -1) == "", "empty fit to negative"); + + // Truncated to 0 width + check(fit_text("hello", 0) == "", "fit to 0 width"); + check(fit_text("hello", -5) == "", "fit to negative width"); + + // Text fits exactly + check(fit_text("hi", 2) == "hi", "exact fit"); + check(fit_text("hi", 3) == "hi ", "pad to 3"); + check(fit_text("hi", 5) == "hi ", "pad to 5"); + + // Truncation with ellipsis + check(fit_text("hello", 4) == "hel\u2026", "truncate with ellipsis width 4"); + check(fit_text("hello", 3) == "he\u2026", "truncate with ellipsis width 3"); + check(fit_text("hello", 2) == "h\u2026", "truncate with ellipsis width 2"); + check(fit_text("hello", 1) == "h", "truncate width 1 (no ellipsis)"); + + // Truncation without ellipsis + check(fit_text("hello", 3, false) == "hel", "truncate without ellipsis"); + + // CJK truncation (no ellipsis) + check(fit_text("\u4e00\u4e00\u4e00", 4, false) == "\u4e00\u4e00", "CJK truncate 2 chars no ellipsis"); + check(fit_text("\u4e00\u4e00\u4e00", 5, false) == "\u4e00\u4e00 ", "CJK fit 2 chars pad no ellipsis"); + + // CJK with ellipsis + check(fit_text("\u4e00\u4e00\u4e00", 5, true) == "\u4e00\u4e00\u2026", "CJK truncate with ellipsis"); +} + +void test_grapheme_clusters() +{ + using nocal::tui::display_width; + using nocal::tui::fit_text; + + // ZWJ sequences (e.g. family emoji) + // U+1F468 (man) + ZWJ + U+1F469 (woman) + ZWJ + U+1F467 (girl) + ZWJ + U+1F466 (boy) + // This is a single grapheme cluster of width 2 + const std::string family = "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466"; + check(display_width(family) == 2, "ZWJ family emoji width 2"); + + // Skin tone modifiers + // Hand wave + skin tone + const std::string wave = "\U0001f44b\U0001f3fd"; + check(display_width(wave) == 2, "wave+skin tone width 2"); + + // Hair style modifiers + // Person + red hair + const std::string red_hair = "\U0001f9d1\U0001f9b0"; + check(display_width(red_hair) == 2, "person+red hair width 2"); + + // fit_text with ZWJ: should not break inside cluster + check(fit_text(family, 2) == family, "fit ZWJ at exact width"); + // When truncating to 1, the entire cluster should be dropped (no partial cluster) + check(fit_text(family, 1) == " ", "fit ZWJ at width 1 drops cluster"); +} + +void test_regional_indicator_pairs() +{ + using nocal::tui::display_width; + using nocal::tui::fit_text; + + // US flag + const std::string us = "\U0001f1fa\U0001f1f8"; + check(display_width(us) == 2, "US flag width 2"); + + // Japan flag + const std::string jp = "\U0001f1ef\U0001f1f5"; + check(display_width(jp) == 2, "Japan flag width 2"); + + // fit_text should not split a flag pair + check(fit_text(us, 2) == us, "fit US flag at exact width"); + check(fit_text(us, 1) == " ", "fit US flag at width 1 drops pair"); +} + +void test_variation_selectors() +{ + using nocal::tui::display_width; + + // Emoji + variation selector + check(display_width("\u2600") == 2, "sun without VS width 2"); + check(display_width("\u2600\ufe0f") == 2, "sun with VS16 width 2"); + + // CJK + variation selector + check(display_width("\u4e00") == 2, "CJK without VS width 2"); + check(display_width("\u4e00\ufe00") == 2, "CJK with VS1 width 2"); +} + +} // anonymous namespace + +int main() +{ + test_decode_codepoint(); + test_codepoint_width(); + test_display_width(); + test_fit_text(); + test_grapheme_clusters(); + test_regional_indicator_pairs(); + test_variation_selectors(); + + if (failures > 0) { + std::cerr << "FAIL: " << failures << " test(s) failed\n"; + return 1; + } + std::cout << "OK: all unicode tests passed\n"; + return 0; +} \ No newline at end of file