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:
@@ -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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
namespace textfit {
|
||||
|
||||
inline bool isCont(char c) { return (static_cast<unsigned char>(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 <class Fits>
|
||||
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 <class Measure>
|
||||
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
|
||||
Reference in New Issue
Block a user