From 8c0a22b7bc699a8e22d60371bc6864b2779d7de1 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 10 Oct 2026 09:50:03 -0500 Subject: [PATCH] fix(ui): cut ellipsis truncation on UTF-8 code-point boundaries TruncateToWidth, the middle "front...back" ellipsis for addresses, pool URLs, paths, labels and the status bar, cut at byte offsets: a multi-byte character at either cut was split into invalid UTF-8 and drawn as replacement characters (e.g. a translated Cyrillic or CJK status). It also tried front lengths from n/2 down until one fit, building and measuring a new string for each: up to O(n^2) per truncated label per frame. util::textfit::middleEllipsis keeps the same contract and is byte-identical for ASCII, but snaps the front cut down and the back cut up to code-point boundaries, and finds the largest fitting front length by bisection into one reused buffer: O(log n) measures for single-line text. Text containing a newline keeps the linear scan, because its measured width (the widest line) does not grow steadily with the cut. text_fit.h also gains largestFittingPrefix, a bisection over code-point boundaries; its first caller is the node-status banner in a following change. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + src/ui/material/draw_helpers.h | 16 ++-- src/util/text_fit.h | 96 +++++++++++++++++++ tests/test_phase4.cpp | 168 +++++++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src/util/text_fit.h diff --git a/CMakeLists.txt b/CMakeLists.txt index eb2fd80..7db4ac2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -716,6 +716,7 @@ set(APP_HEADERS src/util/coalescing_writer.h src/util/local_time.h src/util/background_sampler.h + src/util/text_fit.h src/util/amount_format.h src/util/base64.h src/util/single_instance.h diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index 900764e..c22275e 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -17,6 +17,7 @@ #include "../effects/imgui_acrylic.h" #include "../theme.h" #include "../../util/noise_texture.h" +#include "../../util/text_fit.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" #include "imgui_internal.h" @@ -60,17 +61,14 @@ inline ImU32 ReadableError() { // Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within // maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already -// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this. +// fits (or maxWidth is non-positive). Cuts land on UTF-8 code-point boundaries; single-line text +// costs O(log n) measures (util::textfit::middleEllipsis). Display-only — never mutate the +// underlying value with this. inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) { if (text.empty() || !font || maxWidth <= 0.0f) return text; - if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text; - const int n = static_cast(text.size()); - for (int f = n / 2; f >= 3; --f) { - const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal - std::string t = text.substr(0, f) + "..." + text.substr(n - b); - if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t; - } - return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text; + return util::textfit::middleEllipsis(text, maxWidth, [&](const char* b, const char* e) { + return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, b, e).x; + }); } // Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase. diff --git a/src/util/text_fit.h b/src/util/text_fit.h new file mode 100644 index 0000000..1edf58a --- /dev/null +++ b/src/util/text_fit.h @@ -0,0 +1,96 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Pure (no ImGui) helpers for display-only ellipsis truncation. Callers inject the width function, so unit +// tests need no font. Every cut lands on a UTF-8 code-point boundary (never inside a multi-byte sequence). +#pragma once + +#include +#include +#include +#include + +namespace dragonx { +namespace util { +namespace textfit { + +inline bool isCont(char c) { return (static_cast(c) & 0xC0) == 0x80; } + +// Largest code-point boundary <= i (i clamped to n; n is a boundary). +inline std::size_t snapDown(const char* s, std::size_t n, std::size_t i) { + if (i >= n) return n; + while (i > 0 && isCont(s[i])) --i; + return i; +} + +// Smallest code-point boundary >= i (clamped to n). +inline std::size_t snapUp(const char* s, std::size_t n, std::size_t i) { + if (i > n) i = n; + while (i < n && isCont(s[i])) ++i; + return i; +} + +// Largest code-point boundary k in [0, n] with fits(k), by bisection: ceil(log2(n + 1)) fits() calls. +// Exact when fits is monotone (true up to some length, false after), which holds for single-line text in any +// font with non-negative advances. Whatever fits does, the result r satisfies fits(r) or r == 0. +template +std::size_t largestFittingPrefix(const char* s, std::size_t n, Fits&& fits) { + std::size_t lo = 0, hi = n; + while (lo < hi) { + const std::size_t mid = lo + (hi - lo + 1) / 2; + if (fits(snapDown(s, n, mid))) lo = mid; else hi = mid - 1; + } + return snapDown(s, n, lo); +} + +// Middle ellipsis "front...back" so measure(result) <= maxWidth; measure(b, e) = pixel width of [b, e). +// Same contract as the old ui::material::TruncateToWidth scan (largest front length f in [3, n/2] that fits, +// back = max(f - 2, 3); n < 6, or nothing fitting at n == 6 -> text; nothing fitting at n > 6 -> the f = 3 +// candidate) and byte-identical to it for ASCII. Cuts snap to code points (front end down, back start up), +// so valid UTF-8 in -> valid UTF-8 out. Single-line text: O(log n) measures into one reused buffer. +template +std::string middleEllipsis(const std::string& text, float maxWidth, Measure&& measure) { + const char* s = text.data(); + const std::size_t n = text.size(); + if (n == 0 || maxWidth <= 0.0f) return text; + if (measure(s, s + n) <= maxWidth) return text; + if (n < 6) return text; // old scan (f = n/2 .. 3) never ran, no fallback + std::string cand; + cand.reserve(n + 3); + auto build = [&](std::size_t f) { + const std::size_t b = (f >= 5) ? f - 2 : 3; // == old (f - 2 > 3) ? f - 2 : 3 + const std::size_t pe = snapDown(s, n, f); + const std::size_t ss = snapUp(s, n, n - b); + cand.assign(s, pe); + cand += "..."; + cand.append(s + ss, n - ss); + }; + auto fits = [&](std::size_t f) { + build(f); + return measure(cand.data(), cand.data() + cand.size()) <= maxWidth; + }; + std::size_t best = 3; // the old n > 6 fallback is exactly the f = 3 candidate + if (std::memchr(s, '\n', n) == nullptr) { + // Single line: width is non-decreasing in f, so the largest fit (== the old first fit scanning down + // from n/2) is found by bisection. + std::size_t lo = 3, hi = n / 2; + while (lo < hi) { + const std::size_t mid = lo + (hi - lo + 1) / 2; + if (fits(mid)) lo = mid; else hi = mid - 1; + } + best = lo; + } else { + // Multi-line: the measured width is the WIDEST line, which is not monotone in f -> keep the old scan. + for (std::size_t f = n / 2; f > 3; --f) { + if (fits(f)) { best = f; break; } + } + } + if (n == 6 && !fits(3)) return text; // old: n == 6 returned f = 3 only if it fit + build(best); + return cand; +} + +} // namespace textfit +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index f2415fc..aa3263f 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -67,6 +67,7 @@ #include "util/address_validation.h" #include "util/seed_phrase.h" #include "util/local_time.h" +#include "util/text_fit.h" #include "util/image_decode_core.h" #include "daemon/daemon_startup_diagnosis.h" #include "util/amount_format.h" @@ -15007,6 +15008,172 @@ void testChatThreadLayout() k2 = k; k2.curFontSize = 16; EXPECT_TRUE(!(k == k2)); } } + +// ---- util/text_fit.h fakes: ImGui-like metrics, no font ---- +// ASCII 7.25 px, any other code point 13.5 px, '\r' 0 px (ImGui skips it), continuation bytes 0. +float tfLineW(const char* b, const char* e) +{ + float w = 0.0f; + for (const char* p = b; p < e; ++p) { + const unsigned char c = static_cast(*p); + if ((c & 0xC0) == 0x80 || c == '\r') continue; + w += (c < 0x80) ? 7.25f : 13.5f; + } + return w; +} + +// CalcTextSizeA: the widest '\n' line, unrounded. +float tfMultiW(const char* b, const char* e) +{ + float best = 0.0f; + const char* ls = b; + for (const char* p = b; ; ++p) { + if (p == e || *p == '\n') { + best = std::max(best, tfLineW(ls, p)); + if (p == e) break; + ls = p + 1; + } + } + return best; +} + +// Strict UTF-8: lead/continuation structure, no truncated sequences, no overlongs, no surrogates, <= U+10FFFF. +bool tfValidUtf8(const std::string& s) +{ + std::size_t i = 0; + const std::size_t n = s.size(); + while (i < n) { + const unsigned char c = static_cast(s[i]); + if (c < 0x80) { ++i; continue; } + std::size_t len = 0; + unsigned cp = 0; + if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1Fu; } + else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0Fu; } + else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07u; } + else return false; + if (i + len > n) return false; + for (std::size_t k = 1; k < len; ++k) { + const unsigned char cc = static_cast(s[i + k]); + if ((cc & 0xC0) != 0x80) return false; + cp = (cp << 6) | (cc & 0x3Fu); + } + if ((len == 2 && cp < 0x80) || (len == 3 && cp < 0x800) || (len == 4 && (cp < 0x10000 || cp > 0x10FFFF))) + return false; + if (cp >= 0xD800 && cp <= 0xDFFF) return false; + i += len; + } + return true; +} + +// VERBATIM body of the old ui::material::TruncateToWidth, measuring with tfMultiW instead of CalcTextSizeA. +std::string tfLegacyTruncate(const std::string& text, float maxWidth) +{ + auto measure = [](const std::string& t) { return tfMultiW(t.c_str(), t.c_str() + std::strlen(t.c_str())); }; + if (text.empty() || maxWidth <= 0.0f) return text; + if (measure(text) <= maxWidth) return text; + const int n = static_cast(text.size()); + for (int f = n / 2; f >= 3; --f) { + const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal + std::string t = text.substr(0, f) + "..." + text.substr(n - b); + if (measure(t) <= maxWidth) return t; + } + return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text; +} + +void testTextFit() +{ + namespace tf = dragonx::util::textfit; + // 1. middleEllipsis == the old scan, byte for byte, on ASCII (single- and multi-line). + { + const std::string base = "abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + std::vector cases; + for (std::size_t n = 0; n <= base.size(); ++n) { + cases.push_back(base.substr(0, n)); + cases.push_back(std::string(n, 'a')); + } + for (std::size_t n = 6; n <= base.size(); ++n) { + // '\n' near the cut points pins the linear-scan (non-monotone) branch + for (std::size_t pos : {std::size_t(1), n / 2 - 1, n / 2, n / 2 + 1, n - 1}) { + std::string s = base.substr(0, n); + s[pos] = '\n'; + cases.push_back(s); + } + std::string two = base.substr(0, n); + two[2] = '\n'; + two[n - 3] = '\n'; + cases.push_back(two); + } + int mismatches = 0; + for (const auto& s : cases) + for (float w = -1.0f; w <= 400.0f; w += 0.5f) + if (tf::middleEllipsis(s, w, tfMultiW) != tfLegacyTruncate(s, w)) ++mismatches; + EXPECT_EQ(mismatches, 0); + } + // 2. middleEllipsis on multi-byte text: valid UTF-8 out, and the fitting truncated results grow with maxW. + { + const std::string cyr = "Привет мир, это очень длинный адрес"; + const std::vector inputs = { + cyr, + "資金を送る前に確認してください", + "caf\xC3\xA9 r\xC3\xA9sum\xC3\xA9 na\xC3\xAFve d\xC3\xA9j\xC3\xA0", + "\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5", + }; + int invalid = 0, shapeBad = 0, nonMonotone = 0; + for (const auto& in : inputs) { + EXPECT_TRUE(tfValidUtf8(in)); + float prevFitW = -1.0f; + for (float w = 1.0f; w <= 400.0f; w += 1.0f) { + const std::string r = tf::middleEllipsis(in, w, tfMultiW); + if (!tfValidUtf8(r)) ++invalid; + if (r != in && r.find("...") == std::string::npos) ++shapeBad; + const float rw = tfMultiW(r.data(), r.data() + r.size()); + if (r != in && rw <= w) { // fitting + truncated (the forced fallback is excluded) + if (rw < prevFitW) ++nonMonotone; + prevFitW = rw; + } + } + } + EXPECT_EQ(invalid, 0); + EXPECT_EQ(shapeBad, 0); + EXPECT_EQ(nonMonotone, 0); + bool legacyBroke = false; // documents the bug being fixed: byte cuts split a code point + for (float w = 1.0f; w <= 400.0f && !legacyBroke; w += 1.0f) + legacyBroke = !tfValidUtf8(tfLegacyTruncate(cyr, w)); + EXPECT_TRUE(legacyBroke); + } + // 3. largestFittingPrefix == a linear reference, lands on a boundary, <= ceil(log2(n + 1)) probes. + { + const std::vector inputs = { + "The quick brown fox jumps over the lazy dog", + "資金を送る前に確認してください", + "Ошибка подключения к узлу", + "\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5", + }; + int refMismatch = 0, notBoundary = 0, tooManyProbes = 0; + for (const auto& in : inputs) { + const char* s = in.data(); + const std::size_t n = in.size(); + int maxProbes = 0; + while ((std::size_t(1) << maxProbes) < n + 1) ++maxProbes; + for (float T = 0.0f; T <= 300.0f; T += 0.5f) { + int probes = 0; + auto fits = [&](std::size_t l) { ++probes; return tfLineW(s, s + l) <= T; }; + const std::size_t got = tf::largestFittingPrefix(s, n, fits); + std::size_t ref = 0; + for (std::size_t k = n + 1; k-- > 0;) { + const bool boundary = (k == n) || !tf::isCont(s[k]); + if (boundary && tfLineW(s, s + k) <= T) { ref = k; break; } + } + if (got != ref) ++refMismatch; + if (got != n && tf::isCont(s[got])) ++notBoundary; + if (probes > maxProbes) ++tooManyProbes; + } + } + EXPECT_EQ(refMismatch, 0); + EXPECT_EQ(notBoundary, 0); + EXPECT_EQ(tooManyProbes, 0); + } +} } // namespace int main() @@ -15254,6 +15421,7 @@ int main() testGeneratedResourceBehavior(); testLocalTime(); testChatThreadLayout(); + testTextFit(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n";