From a631e4421ad6785b1ea59f7142ecf0a6d69391cf Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 16:00:10 -0500 Subject: [PATCH] refactor(contacts): share case-insensitive compare, truncation helpers The contacts list carried its own lowercase-and-compare sort, its own lowercase substring search, and an inline linear middle-truncation loop for fitting an address to a column width. These duplicated logic that belongs in the shared text helpers. - A new allocation-free case-insensitive compare replaces the per-sort lowercasing; it lowercases ASCII per byte and leaves bytes >= 0x80 alone, so UTF-8 labels sort exactly as before. - Search now uses the shared case-insensitive contains helper. - Middle-truncation to a pixel width moves to a shared template that binary-searches the fit (about log2(n) width measurements instead of up to n) while producing the same result as the old linear shrink. Tests: the compare matches lowercase-then-compare across ASCII, UTF-8 bytes and length ties, and the fit helper matches the old linear loop across lengths and widths. Co-Authored-By: Claude Opus 4.8 --- src/ui/windows/contacts_tab.cpp | 25 +++++++--------------- src/util/text_format.cpp | 12 +++++++++++ src/util/text_format.h | 36 ++++++++++++++++++++++++++++++++ tests/test_phase4.cpp | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 284d987..89b8926 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -69,9 +69,9 @@ static std::string toLower(std::string s) { static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) { if (needleLower.empty()) return true; - return toLower(e.label).find(needleLower) != std::string::npos - || toLower(e.address).find(needleLower) != std::string::npos - || toLower(e.notes).find(needleLower) != std::string::npos; + return util::containsIgnoreCase(e.label, needleLower) + || util::containsIgnoreCase(e.address, needleLower) + || util::containsIgnoreCase(e.notes, needleLower); } // Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE @@ -285,18 +285,9 @@ static bool isShieldedAddr(const std::string& a) { // trimming is safe. static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW, int minFront, int minBack) { - const std::string floor = util::truncateMiddle(s, minFront, minBack); - auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; }; - if (w(s) <= maxW) return s; // fits in full — no truncation needed at all - if (maxW <= 0.0f) return floor; // no room to measure against — fall back to the floor - const std::string ell = "\xE2\x80\xA6"; - size_t head = s.size() / 2, tail = s.size() - head; - while (head + tail > static_cast(minFront + minBack)) { - std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail); - if (w(cand) <= maxW) return cand; - if (head >= tail) --head; else --tail; - } - return floor; // couldn't fit even at the configured floor — use the fixed-length result + return util::truncateMiddleToFit(s, minFront, minBack, maxW, [&](const std::string& t) { + return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; + }); } // Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per @@ -1213,7 +1204,7 @@ void RenderContactsTab(App* app) case 2: ka = &entries[a].notes; kb = &entries[b].notes; break; default: ka = &entries[a].label; kb = &entries[b].label; break; } - int cmp = toLower(*ka).compare(toLower(*kb)); + int cmp = util::compareIgnoreCase(*ka, *kb); return asc ? (cmp < 0) : (cmp > 0); }); } @@ -1281,7 +1272,7 @@ void RenderContactsTab(App* app) { const auto& entries = book.entries(); std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) { - return toLower(entries[a].label).compare(toLower(entries[b].label)) < 0; + return util::compareIgnoreCase(entries[a].label, entries[b].label) < 0; }); } // Frosted-glass list container so the contacts area blurs the backdrop (peers_tab pattern: diff --git a/src/util/text_format.cpp b/src/util/text_format.cpp index ad368e5..c6f5137 100644 --- a/src/util/text_format.cpp +++ b/src/util/text_format.cpp @@ -5,6 +5,7 @@ #include "text_format.h" #include "i18n.h" +#include #include #include #include @@ -90,6 +91,17 @@ std::string truncateMiddle(const std::string& s, int front, int back) return s.substr(0, front) + "..." + s.substr(s.length() - back); } +int compareIgnoreCase(std::string_view a, std::string_view b) +{ + const std::size_t n = std::min(a.size(), b.size()); + for (std::size_t i = 0; i < n; ++i) { + const unsigned char ca = static_cast(std::tolower(static_cast(a[i]))); + const unsigned char cb = static_cast(std::tolower(static_cast(b[i]))); + if (ca != cb) return ca < cb ? -1 : 1; + } + return a.size() == b.size() ? 0 : (a.size() < b.size() ? -1 : 1); +} + bool containsIgnoreCase(std::string_view haystack, std::string_view needle) { if (needle.empty()) return true; diff --git a/src/util/text_format.h b/src/util/text_format.h index f8ee7e6..0b6731c 100644 --- a/src/util/text_format.h +++ b/src/util/text_format.h @@ -8,6 +8,7 @@ #pragma once #include +#include #include #include @@ -37,6 +38,11 @@ std::string formatTimeAgoShort(std::int64_t timestamp); // An empty needle matches everything. Safe for per-frame filtering of large lists. bool containsIgnoreCase(std::string_view haystack, std::string_view needle); +// Case-insensitive ASCII three-way compare, allocation-free. Equivalent to +// toLower(a).compare(toLower(b)) with C-locale tolower per byte (bytes >= 0x80 are left alone and +// compared as unsigned), so UTF-8 labels sort exactly as before. Returns <0, 0, >0. +int compareIgnoreCase(std::string_view a, std::string_view b); + // Middle-ellipsis truncation, half/half form: "prefix...suffix" so the total (including "...") // is at most maxLen. Guards maxLen <= 3 (returns s unchanged) to avoid unsigned underflow on the // (maxLen - 3) split. Strings already at/under maxLen are returned unchanged. @@ -46,5 +52,35 @@ std::string truncateMiddle(const std::string& s, int maxLen); // Returns s unchanged when it is no longer than front + back + 3 (the "..." would not save space). std::string truncateMiddle(const std::string& s, int front, int back); +// Middle-ellipsis fit-to-width. Same result as a linear shrink (alternating head/tail removal from the +// half/half split, ellipsis U+2026), but ~log2(n) measurements instead of up to n. `width(const std::string&)` +// returns the rendered width. Assumes width is non-increasing as characters are removed (true for ImGui +// text: positive advances, no kerning). Falls back to truncateMiddle(s, minFront, minBack) when nothing fits. +template +std::string truncateMiddleToFit(const std::string& s, int minFront, int minBack, float maxW, Measure&& width) +{ + if (width(s) <= maxW) return s; // fits in full + if (maxW <= 0.0f) return truncateMiddle(s, minFront, minBack); // nothing to measure against + const std::size_t n = s.size(); + const std::size_t minKeep = static_cast(minFront + minBack); + if (n <= minKeep) return truncateMiddle(s, minFront, minBack); + const std::size_t head0 = n / 2, tail0 = n - head0; + // Candidate after removing k chars (k = 0 is "s with an ellipsis in the middle"): removal strictly + // alternates, from the head first when head >= tail, else from the tail. + auto build = [&](std::size_t k) { + std::size_t h, t; + if (head0 >= tail0) { h = head0 - (k + 1) / 2; t = tail0 - k / 2; } // even n: head first + else { t = tail0 - (k + 1) / 2; h = head0 - k / 2; } // odd n: tail first + return s.substr(0, h) + "\xE2\x80\xA6" + s.substr(n - t); + }; + std::size_t lo = 0, hi = n - minKeep - 1; + if (width(build(hi)) > maxW) return truncateMiddle(s, minFront, minBack); + while (lo < hi) { + const std::size_t mid = lo + (hi - lo) / 2; + if (width(build(mid)) <= maxW) hi = mid; else lo = mid + 1; + } + return build(lo); +} + } // namespace util } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index a1ccd28..4e179b3 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -12,6 +12,7 @@ #include #include #endif +#include "util/text_format.h" #include "chat/chat_crypto.h" #include "chat/chat_identity.h" #include "chat/chat_service.h" @@ -18062,6 +18063,41 @@ void testFillStrip() { EXPECT_NEAR(tri, trap, 1e-3f); } +void testContactsTextHelpers() { + // compareIgnoreCase == lower+compare, including UTF-8 bytes and length ties + auto ref = [](std::string a, std::string b) { + for (auto& c : a) c = static_cast(std::tolower(static_cast(c))); + for (auto& c : b) c = static_cast(std::tolower(static_cast(c))); + int r = a.compare(b); return r < 0 ? -1 : r > 0 ? 1 : 0; + }; + const std::vector v = {"", "a", "A", "ab", "AB", "Zed", "zed", "\xC3\x89t\xC3\xA9", "\xC3\xA9", "z", "Z1", "z10"}; + for (auto& x : v) for (auto& y : v) { + int r = dragonx::util::compareIgnoreCase(x, y); r = r < 0 ? -1 : r > 0 ? 1 : 0; + EXPECT_EQ(r, ref(x, y)); + } + // truncateMiddleToFit == the old linear loop for every width + auto meas = [](const std::string& t) { float w = 0; for (unsigned char c : t) w += 5 + (c % 7); return w; }; + auto oldFit = [&](const std::string& s, int mf, int mb, float maxW) { + const std::string floor = dragonx::util::truncateMiddle(s, mf, mb); + if (meas(s) <= maxW) return s; + if (maxW <= 0.0f) return floor; + const std::string ell = "\xE2\x80\xA6"; + size_t head = s.size() / 2, tail = s.size() - head; + while (head + tail > static_cast(mf + mb)) { + std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail); + if (meas(cand) <= maxW) return cand; + if (head >= tail) --head; else --tail; + } + return floor; + }; + for (size_t len : {8u, 20u, 77u, 78u, 79u}) { + std::string s(len, 'a'); + for (size_t i = 0; i < len; ++i) s[i] = static_cast('a' + (i * 7) % 26); + for (float maxW = -1; maxW < 700; maxW += 13.0f) + EXPECT_EQ(dragonx::util::truncateMiddleToFit(s, 5, 5, maxW, meas), oldFit(s, 5, 5, maxW)); + } +} + } // namespace int main() @@ -18365,6 +18401,7 @@ int main() testLocalTime(); testChatThreadLayout(); testTextFit(); + testContactsTextHelpers(); testTextFitBanner(); testLoadLanguageFromLooseFiles(); testWriteFileIfDifferent();