perf(ui): memoize the node-status banner ellipsis

While the node-status banner shows, its detail line was fitted every
frame by popping one character at a time and re-measuring the whole
"·  " + detail + "…" string: O(L^2) per frame in the detail length L,
which adds up on the ~1.5 KB Windows crash report. The pop also left a
dangling UTF-8 lead byte before the "…", so a cut in non-ASCII text drew
replacement characters in place of the ellipsis.

- util::textfit::ellipsizeTail builds the same string in O(L + m log m),
  m being the length of the line the cut lands in: ImGui measures
  multi-line text as its widest line, so the old loop could only stop
  inside the first line that overflows on its own. Byte-identical for
  ASCII; the cut now lands on a code-point boundary.
- EllipsisFitCache keeps the result until the detail, budget, font size,
  font (by FontId) or rasterizer density changes, so an unchanged frame
  costs one string compare.
- When ImGui would draw a line wider than the per-line split predicts
  (a malformed byte joined with the following newline, as in CP-1252
  output from the Windows daemon), the old whole-string loop is used for
  that text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent 8c0a22b7bc
commit 3f13d79ccb
3 changed files with 238 additions and 14 deletions
+34 -14
View File
@@ -75,6 +75,7 @@
#include "util/connect_stall.h"
#include "util/platform.h"
#include "util/text_format.h"
#include "util/text_fit.h"
#include "util/payment_uri.h"
#include "util/seed_phrase.h"
#include "util/texture_loader.h"
@@ -2683,20 +2684,39 @@ void App::renderNodeStatusBanner()
ImGui::SetCursorPosY(txtCy);
const float budget = winW - ImGui::GetCursorPosX() - actionReserve;
if (budget > ImGui::CalcTextSize("W").x) {
const std::string prefix = "\xC2\xB7 "; // "· "
std::string detail = banner.detail;
std::string shown = prefix + detail;
if (ImGui::CalcTextSize(shown.c_str()).x > budget) {
const std::string ell = "\xE2\x80\xA6"; // "…"
while (!detail.empty() &&
ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) {
detail.pop_back();
while (!detail.empty() &&
(static_cast<unsigned char>(detail.back()) & 0xC0) == 0x80)
detail.pop_back(); // drop the whole trailing UTF-8 code point
}
shown = prefix + detail + ell;
}
// "· " + detail, ellipsized with "…" to the budget: the same string the old pop-one-char loop built
// (O(L^2) per frame on the ~1.5 KB Windows crash report), now valid UTF-8 at the cut. Memoized per
// (detail, budget, font size, FontId + rasterizer density), so an unchanged frame costs one compare.
static util::textfit::EllipsisFitCache s_detailFit; // UI thread only (render path)
ImFont* detailFont = ImGui::GetFont();
const std::string& shown = s_detailFit.get(
banner.detail, budget, detailFont->FontId, ImGui::GetFontSize(),
detailFont->CurrentRasterizerDensity,
[&](const std::string& detail) {
const std::string prefix = "\xC2\xB7 "; // "· "
const std::string ell = "\xE2\x80\xA6"; // "…"
std::string r = util::textfit::ellipsizeTail(prefix, detail, ell, budget,
[](const char* b, const char* e) { return ImGui::CalcTextSize(b, e).x; });
if (ImGui::CalcTextSize(r.c_str()).x > budget) {
// ellipsizeTail splits lines on the raw '\n' byte, but ImGui decodes a malformed UTF-8
// byte together with a following '\n' (e.g. CP-1252 bytes E9 0D 0A in the Windows daemon tail)
// and draws one joined, wider line. Fall back to the old whole-string loop, which measures
// exactly what is drawn (also reached when nothing fits; same result there).
std::string d = detail;
r = prefix + d;
if (ImGui::CalcTextSize(r.c_str()).x > budget) {
while (!d.empty() &&
ImGui::CalcTextSize((prefix + d + ell).c_str()).x > budget) {
d.pop_back();
while (!d.empty() &&
(static_cast<unsigned char>(d.back()) & 0xC0) == 0x80)
d.pop_back(); // drop the whole trailing UTF-8 code point
}
r = prefix + d + ell;
}
}
return r;
});
ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium());
ImGui::TextUnformatted(shown.c_str());
ImGui::PopStyleColor();
+57
View File
@@ -91,6 +91,63 @@ std::string middleEllipsis(const std::string& text, float maxWidth, Measure&& me
return cand;
}
// The node-status banner fit "prefix + text + ell". Byte-identical for ASCII to the old loop that popped one
// character at a time off `text` until it fit: ImGui measures multi-line text as its WIDEST line, so that loop
// could only stop inside the first line that overflows on its own. O(L + m log m) instead of O(L^2), and the
// cut is always on a code-point boundary (the old pop left a dangling UTF-8 lead byte before the ellipsis).
// Returns prefix + text when every line fits, else prefix + text[0, k) + ell for the largest fitting
// code-point boundary k (k = 0 when nothing fits). lineWidth(b, e) = width of the single line [b, e) exactly
// as the renderer measures it (e.g. ImGui::CalcTextSize(b, e).x, rounding included).
template <class LineWidth>
std::string ellipsizeTail(const std::string& prefix, const std::string& text, const std::string& ell,
float budget, LineWidth&& lineWidth) {
const char* s = text.data();
const std::size_t n = text.size();
std::string buf;
auto width = [&](bool withPrefix, std::size_t b, std::size_t e, bool withEll) {
buf.clear();
if (withPrefix) buf += prefix;
buf.append(s + b, e - b);
if (withEll) buf += ell;
return lineWidth(buf.data(), buf.data() + buf.size());
};
std::size_t start = 0;
for (bool first = true;; first = false) {
const void* nl = std::memchr(s + start, '\n', n - start);
const std::size_t end = nl ? static_cast<std::size_t>(static_cast<const char*>(nl) - s) : n;
if (width(first, start, end, false) > budget) {
// Every earlier line fits; any cut past `end` keeps this whole line, so the cut is inside it. A cut
// at this line's start leaves "" + ell on it, so if ell alone overflows nothing fits at all.
if (!first && width(false, start, start, true) > budget) return prefix + ell;
const std::size_t len = largestFittingPrefix(s + start, end - start, [&](std::size_t l) {
return width(first, start, start + l, true) <= budget;
});
return prefix + text.substr(0, start + len) + ell;
}
if (!nl) return prefix + text;
start = end + 1;
}
}
// One-slot memo: "fit this text to this budget in this font". compute(text) -> std::string runs only when the
// text, budget, font (ImFont::FontId: new on every atlas rebuild, even at the same size/address), font size or
// rasterizer density changes.
struct EllipsisFitCache {
std::string src, shown;
float budget = 0.0f, fontSize = 0.0f, density = 0.0f;
std::uint32_t fontId = 0;
bool valid = false;
int computes = 0; // test hook
template <class Fn>
const std::string& get(const std::string& text, float b, std::uint32_t fid, float fs, float dens, Fn&& compute) {
if (!valid || b != budget || fid != fontId || fs != fontSize || dens != density || text != src) {
shown = compute(text);
src = text; budget = b; fontId = fid; fontSize = fs; density = dens; valid = true; ++computes;
}
return shown;
}
};
} // namespace textfit
} // namespace util
} // namespace dragonx