refactor: shared Unicode display-width utility with grapheme cluster support

Consolidate two inconsistent codepoint_width implementations from
Screen.cpp and EventEditor.cpp into nocal/tui/unicode.hpp. The unified
table covers all CJK, Hangul, emoji, symbol, and combining mark ranges.
display_width and fit_text handle grapheme clusters (ZWJ sequences,
skin-tone/hair modifiers, regional indicator pairs, variation selectors)
so calendar text with emoji or East Asian characters renders correctly.
This commit is contained in:
2026-07-23 18:03:33 +01:00
parent 673a2c66c9
commit a159933baa
6 changed files with 571 additions and 182 deletions

View File

@@ -0,0 +1,35 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
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

View File

@@ -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,

View File

@@ -1,4 +1,5 @@
#include "nocal/tui/EventEditor.hpp"
#include "nocal/tui/unicode.hpp"
#include <algorithm>
#include <array>
@@ -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<unsigned char>(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<unsigned char>(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<std::size_t>(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));

View File

@@ -1,4 +1,5 @@
#include "nocal/tui/Screen.hpp"
#include "nocal/tui/unicode.hpp"
#include <algorithm>
#include <array>
@@ -25,67 +26,6 @@ constexpr std::array<std::string_view, 7> weekday_long{
constexpr std::array<std::string_view, 7> 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<unsigned char>(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<std::size_t>(continuation) >= text.size()) {
length = 1;
return 0xfffd;
}
for (int offset = 1; offset <= continuation; ++offset) {
const auto byte = static_cast<unsigned char>(text[at + static_cast<std::size_t>(offset)]);
if ((byte & 0xc0) != 0x80) {
length = 1;
return 0xfffd;
}
result = (result << 6) | (byte & 0x3f);
}
length = static_cast<std::size_t>(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<std::size_t>(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::size_t>(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<std::string> 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);

270
src/tui/unicode.cpp Normal file
View File

@@ -0,0 +1,270 @@
#include "nocal/tui/unicode.hpp"
#include <algorithm>
#include <cstdint>
#include <string>
#include <string_view>
namespace nocal::tui {
namespace {
// Check if a codepoint is a skin tone modifier (0x1f3fb0x1f3ff)
[[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 (0x1f9b00x1f9b3, 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 (0x1f1e60x1f1ff)
[[nodiscard]] bool is_regional_indicator(std::uint32_t cp) noexcept
{
return cp >= 0x1f1e6U && cp <= 0x1f1ffU;
}
// Check if a codepoint is a variation selector (0xfe000xfe0f)
[[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<unsigned char>(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<unsigned char>(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<std::size_t>(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::size_t>(std::max(0, width - used)); remaining > 0) {
result.append(remaining, ' ');
}
return result;
}
} // namespace nocal::tui

248
tests/unicode_tests.cpp Normal file
View File

@@ -0,0 +1,248 @@
#include "nocal/tui/unicode.hpp"
#include <iostream>
#include <string>
#include <string_view>
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;
}