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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user