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 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 16:00:10 -05:00
co-authored by Claude Opus 4.8
parent 32ea4d0524
commit a631e4421a
4 changed files with 93 additions and 17 deletions
+8 -17
View File
@@ -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<size_t>(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:
+12
View File
@@ -5,6 +5,7 @@
#include "text_format.h"
#include "i18n.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <ctime>
@@ -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<unsigned char>(std::tolower(static_cast<unsigned char>(a[i])));
const unsigned char cb = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(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;
+36
View File
@@ -8,6 +8,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>
#include <string_view>
@@ -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 <class Measure>
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<std::size_t>(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
+37
View File
@@ -12,6 +12,7 @@
#include <sys/wait.h>
#include <unistd.h>
#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<char>(std::tolower(static_cast<unsigned char>(c)));
for (auto& c : b) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
int r = a.compare(b); return r < 0 ? -1 : r > 0 ? 1 : 0;
};
const std::vector<std::string> 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<size_t>(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<char>('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();