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: