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:
35
include/nocal/tui/unicode.hpp
Normal file
35
include/nocal/tui/unicode.hpp
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
270
src/tui/unicode.cpp
Normal 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 (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<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
248
tests/unicode_tests.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user