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:
+34
-14
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15174,6 +15174,152 @@ void testTextFit()
|
||||
EXPECT_EQ(tooManyProbes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ImGui::CalcTextSize rounding: IM_TRUNC(x + 0.99999f).
|
||||
float tfRound(float x) { return static_cast<float>(static_cast<int>(x + 0.99999f)); }
|
||||
|
||||
// ImGui::CalcTextSize(t): the widest '\n' line, rounded.
|
||||
float tfImGuiW(const std::string& t) { return tfRound(tfMultiW(t.data(), t.data() + t.size())); }
|
||||
|
||||
// VERBATIM old node-status banner loop (app.cpp renderNodeStatusBanner), measuring with tfImGuiW.
|
||||
std::string tfLegacyBanner(const std::string& prefix, const std::string& text, const std::string& ell, float budget)
|
||||
{
|
||||
std::string detail = text;
|
||||
std::string shown = prefix + detail;
|
||||
if (tfImGuiW(shown) > budget) {
|
||||
while (!detail.empty() &&
|
||||
tfImGuiW(prefix + detail + ell) > 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;
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
// The same loop, but each step pops a WHOLE code point (continuation bytes, then the lead byte).
|
||||
std::string tfFixedBanner(const std::string& prefix, const std::string& text, const std::string& ell, float budget)
|
||||
{
|
||||
std::string detail = text;
|
||||
std::string shown = prefix + detail;
|
||||
if (tfImGuiW(shown) > budget) {
|
||||
while (!detail.empty() && tfImGuiW(prefix + detail + ell) > budget) {
|
||||
while (!detail.empty() && dragonx::util::textfit::isCont(detail.back())) detail.pop_back();
|
||||
if (!detail.empty()) detail.pop_back();
|
||||
}
|
||||
shown = prefix + detail + ell;
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
void testTextFitBanner()
|
||||
{
|
||||
namespace tf = dragonx::util::textfit;
|
||||
const std::string P = "\xC2\xB7 "; // "· "
|
||||
const std::string E = "\xE2\x80\xA6"; // "…"
|
||||
auto lineW = [](const char* b, const char* e) { return tfRound(tfLineW(b, e)); };
|
||||
// 4. ellipsizeTail == the old pop-one-char loop, byte for byte, on ASCII details.
|
||||
{
|
||||
const std::vector<std::string> details = {
|
||||
"", "x", "x\n", "\nleading blank line", "a\rb lone CR long long long",
|
||||
"line1\r\nline2 long long long", "short\nmuch much much longer second line", "trail\n\n",
|
||||
};
|
||||
int mismatches = 0;
|
||||
for (const auto& d : details)
|
||||
for (float b = 0.5f; b <= 600.0f; b += 0.5f)
|
||||
if (tf::ellipsizeTail(P, d, E, b, lineW) != tfLegacyBanner(P, d, E, b)) ++mismatches;
|
||||
EXPECT_EQ(mismatches, 0);
|
||||
|
||||
// ~1.4 KB DaemonCrashed-shaped report (embedded_daemon.cpp): header, blank line, launch details with a
|
||||
// long Command line, blank line, then ~500 chars of daemon output over 6 lines.
|
||||
std::string crash = "dragonxd exited unexpectedly (0xC0000005: access violation)";
|
||||
crash += "\n\nLaunch details:";
|
||||
crash += "\n Binary: C:\\Users\\someone\\AppData\\Roaming\\ObsidianDragon\\dragonxd.exe";
|
||||
crash += "\n Working dir: C:\\Users\\someone\\AppData\\Roaming\\Hush\\DRAGONX";
|
||||
std::string cmd = "\n Command: \"C:\\Users\\someone\\AppData\\Roaming\\ObsidianDragon\\dragonxd.exe\"";
|
||||
for (int k = 0; cmd.size() < 460; ++k) cmd += " -addnode=node" + std::to_string(k) + ".dragonx.is";
|
||||
crash += cmd;
|
||||
crash += "\n Binary size: 31457280 bytes";
|
||||
crash += "\n debug.log new bytes: 18234";
|
||||
crash += "\n\nDaemon output:\n...";
|
||||
for (int line = 0; line < 6; ++line) {
|
||||
std::string l = "2026-10-09 12:00:0" + std::to_string(line) + " UpdateTip: new best=00000a3f height=";
|
||||
while (l.size() < 82) l += static_cast<char>('a' + (l.size() % 26));
|
||||
crash += l;
|
||||
if (line < 5) crash += "\n";
|
||||
}
|
||||
EXPECT_TRUE(crash.size() > 1200);
|
||||
int crashMismatches = 0;
|
||||
for (float b = 0.5f; b <= 600.0f; b += 7.0f)
|
||||
if (tf::ellipsizeTail(P, crash, E, b, lineW) != tfLegacyBanner(P, crash, E, b)) ++crashMismatches;
|
||||
EXPECT_EQ(crashMismatches, 0);
|
||||
}
|
||||
// 4b. Prefix narrower than the ellipsis: the first line fits, a later line overflows and the ellipsis alone
|
||||
// no longer fits (ellipsizeTail returns prefix + ell). Unreachable with P = "· ", whose first line only
|
||||
// fits once the plain "…" does too.
|
||||
{
|
||||
const std::string E2 = E + E; // "……" = 27 px, wider than the first line "ab" (15 px)
|
||||
const std::vector<std::string> details = { "ab\ncdefgh", "ab\n\ncdefgh", "a\nbc\ncdefghij" };
|
||||
int mismatches = 0;
|
||||
for (const auto& d : details)
|
||||
for (float b = 0.5f; b <= 120.0f; b += 0.5f)
|
||||
if (tf::ellipsizeTail("", d, E2, b, lineW) != tfLegacyBanner("", d, E2, b)) ++mismatches;
|
||||
EXPECT_EQ(mismatches, 0);
|
||||
for (float b = 15.0f; b < 27.0f; b += 0.5f) // "ab" fits, "cdefgh" and "……" do not: ellipsis only
|
||||
EXPECT_EQ(tf::ellipsizeTail("", "ab\ncdefgh", E2, b, lineW), E2);
|
||||
}
|
||||
// 5. Multi-byte details: valid UTF-8 at every budget, == the whole-code-point loop; the old loop is not.
|
||||
{
|
||||
const std::string cyr = "Ошибка подключения к узлу: таймаут\nвторая строка";
|
||||
const std::vector<std::string> details = {
|
||||
cyr,
|
||||
"节点连接失败,请检查网络设置后重试",
|
||||
"\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5",
|
||||
};
|
||||
int invalid = 0, mismatches = 0;
|
||||
for (const auto& d : details) {
|
||||
for (float b = 0.5f; b <= 600.0f; b += 0.5f) {
|
||||
const std::string r = tf::ellipsizeTail(P, d, E, b, lineW);
|
||||
if (!tfValidUtf8(r)) ++invalid;
|
||||
if (r != tfFixedBanner(P, d, E, b)) ++mismatches;
|
||||
}
|
||||
}
|
||||
EXPECT_EQ(invalid, 0);
|
||||
EXPECT_EQ(mismatches, 0);
|
||||
bool legacyBroke = false; // pins the old dangling-lead-byte bug being fixed
|
||||
for (float b = 0.5f; b <= 600.0f && !legacyBroke; b += 0.5f)
|
||||
legacyBroke = !tfValidUtf8(tfLegacyBanner(P, cyr, E, b));
|
||||
EXPECT_TRUE(legacyBroke);
|
||||
}
|
||||
// 6. EllipsisFitCache: one compute per distinct (text, budget, FontId, font size, rasterizer density).
|
||||
{
|
||||
tf::EllipsisFitCache cache;
|
||||
int calls = 0;
|
||||
auto compute = [&](const std::string& t) { ++calls; return t + "#" + std::to_string(calls); };
|
||||
const std::string& r1 = cache.get("alpha", 100.0f, 3u, 16.0f, 1.0f, compute);
|
||||
EXPECT_EQ(r1, std::string("alpha#1"));
|
||||
EXPECT_EQ(cache.computes, 1);
|
||||
EXPECT_EQ(cache.get("alpha", 100.0f, 3u, 16.0f, 1.0f, compute), std::string("alpha#1"));
|
||||
EXPECT_EQ(cache.computes, 1); // same key: memo hit
|
||||
cache.get("beta", 100.0f, 3u, 16.0f, 1.0f, compute);
|
||||
EXPECT_EQ(cache.computes, 2); // text
|
||||
cache.get("beta", 120.0f, 3u, 16.0f, 1.0f, compute);
|
||||
EXPECT_EQ(cache.computes, 3); // budget
|
||||
cache.get("beta", 120.0f, 4u, 16.0f, 1.0f, compute);
|
||||
EXPECT_EQ(cache.computes, 4); // FontId (atlas rebuild, same size)
|
||||
cache.get("beta", 120.0f, 4u, 17.0f, 1.0f, compute);
|
||||
EXPECT_EQ(cache.computes, 5); // font size
|
||||
const std::string& r6 = cache.get("beta", 120.0f, 4u, 17.0f, 2.0f, compute);
|
||||
EXPECT_EQ(cache.computes, 6); // rasterizer density
|
||||
EXPECT_EQ(r6, std::string("beta#6")); // the reference holds the new value
|
||||
EXPECT_EQ(r1, std::string("beta#6")); // same slot: earlier references see it too
|
||||
EXPECT_EQ(cache.get("beta", 120.0f, 4u, 17.0f, 2.0f, compute), std::string("beta#6"));
|
||||
EXPECT_EQ(cache.computes, 6);
|
||||
EXPECT_EQ(calls, 6);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -15422,6 +15568,7 @@ int main()
|
||||
testLocalTime();
|
||||
testChatThreadLayout();
|
||||
testTextFit();
|
||||
testTextFitBanner();
|
||||
|
||||
if (g_failures != 0) {
|
||||
std::cerr << g_failures << " test assertion(s) failed\n";
|
||||
|
||||
Reference in New Issue
Block a user