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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<unsigned char>(*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<unsigned char>(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<unsigned char>(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<int>(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<std::string> 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<std::string> 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<std::string> 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";
|
||||
|
||||
Reference in New Issue
Block a user