diff --git a/src/app.cpp b/src/app.cpp index 436d9e3..ef70901 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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(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(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(); diff --git a/src/util/text_fit.h b/src/util/text_fit.h index 1edf58a..094870b 100644 --- a/src/util/text_fit.h +++ b/src/util/text_fit.h @@ -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 +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(static_cast(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 + 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index aa3263f..763b32f 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(static_cast(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(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 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('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 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 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";