perf(ui): dedupe time-ago + allocation-free case-insensitive filter (audit #1-3)
Per-frame hot paths in the immediate-mode UI were allocating needlessly:
- Address filtering in the balance tab rebuilt a std::string filter per address AND
containsIgnoreCase() lower-cased two fresh copies per call — ~6×N allocations/frame
on large wallets. New util::containsIgnoreCase(string_view, string_view) is
allocation-free, and the filter is now built once outside the loop.
- Four duplicated "time ago" implementations (balance_tab_helpers, balance_recent_tx,
send_tab, transactions_tab) are consolidated into util::formatTimeAgo (localized long
form) + util::formatTimeAgoShort (compact "5s ago"), preserving each call site's exact
display style. Both use snprintf, no per-row string concatenation.
- The send-tab address-suggestion scan (a walk over the whole tx list) is memoized on the
typed text + tx count, so it no longer recomputes every frame while the user pauses.
New src/util/text_format.{h,cpp}; the two existing containsIgnoreCase/timeAgo definitions
now delegate to it. Added to both the app and test targets (test target also gains i18n.cpp,
which text_format's localized path needs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -484,6 +484,7 @@ set(APP_SOURCES
|
||||
src/util/base64.cpp
|
||||
src/util/single_instance.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/util/platform.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/texture_loader.cpp
|
||||
@@ -991,6 +992,8 @@ if(BUILD_TESTING)
|
||||
src/util/payment_uri.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "balance_recent_tx.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../util/text_format.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -27,14 +28,7 @@ std::string formatRecentAmount(const std::string& type, double amount)
|
||||
|
||||
std::string recentTimeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
return util::formatTimeAgoShort(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "../../config/settings.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../theme.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
@@ -654,10 +655,12 @@ static void RenderBalanceClassic(App* app)
|
||||
};
|
||||
std::vector<AddrRow> rows;
|
||||
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
// The search text is constant across the loop and containsIgnoreCase is now allocation-free,
|
||||
// so build the filter view ONCE (was a per-address std::string + two tolower-copies/frame).
|
||||
const std::string_view addrFilter(addr_search);
|
||||
for (const auto& a : state.z_addresses) {
|
||||
std::string filter(addr_search);
|
||||
if (!containsIgnoreCase(a.address, filter) &&
|
||||
!containsIgnoreCase(a.label, filter))
|
||||
if (!util::containsIgnoreCase(a.address, addrFilter) &&
|
||||
!util::containsIgnoreCase(a.label, addrFilter))
|
||||
continue;
|
||||
bool isHidden = app->isAddressHidden(a.address);
|
||||
if (isHidden && !s_showHidden) continue;
|
||||
@@ -667,9 +670,8 @@ static void RenderBalanceClassic(App* app)
|
||||
rows.push_back({&a, true, isHidden, isFav, app->isMiningAddress(a.address)});
|
||||
}
|
||||
for (const auto& a : state.t_addresses) {
|
||||
std::string filter(addr_search);
|
||||
if (!containsIgnoreCase(a.address, filter) &&
|
||||
!containsIgnoreCase(a.label, filter))
|
||||
if (!util::containsIgnoreCase(a.address, addrFilter) &&
|
||||
!util::containsIgnoreCase(a.label, addrFilter))
|
||||
continue;
|
||||
bool isHidden = app->isAddressHidden(a.address);
|
||||
if (isHidden && !s_showHidden) continue;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "../../config/version.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
|
||||
@@ -22,26 +23,15 @@ std::string TrId(const char* trKey, const char* id)
|
||||
return value;
|
||||
}
|
||||
|
||||
// Thin wrappers over the shared, allocation-free helpers (kept for existing call sites).
|
||||
bool containsIgnoreCase(const std::string& value, const std::string& search)
|
||||
{
|
||||
if (search.empty()) return true;
|
||||
std::string haystack = value;
|
||||
std::string needle = search;
|
||||
std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
|
||||
std::transform(needle.begin(), needle.end(), needle.begin(), ::tolower);
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
return util::containsIgnoreCase(value, search);
|
||||
}
|
||||
|
||||
std::string timeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
return util::formatTimeAgoShort(timestamp);
|
||||
}
|
||||
|
||||
std::string truncateAddress(const std::string& address, int maxLen)
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../util/address_validation.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../notifications.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
@@ -163,14 +164,7 @@ static bool IsValidTransparentAddr(const char* a) {
|
||||
}
|
||||
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
return dragonx::util::formatTimeAgoShort(timestamp);
|
||||
}
|
||||
|
||||
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
|
||||
@@ -324,14 +318,24 @@ static void RenderAddressSuggestions(const WalletState& state, float width, cons
|
||||
bool is_valid_t = IsValidTransparentAddr(s_to_address);
|
||||
if (is_valid_z || is_valid_t) return;
|
||||
|
||||
std::vector<std::string> suggestions;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "send" || tx.address.empty()) continue;
|
||||
if (tx.address.find(partial) != std::string::npos) {
|
||||
bool dup = false;
|
||||
for (const auto& s : suggestions) { if (s == tx.address) { dup = true; break; } }
|
||||
if (!dup) suggestions.push_back(tx.address);
|
||||
if (suggestions.size() >= (size_t)schema::UI().drawElement("tabs.send", "max-suggestions").size) break;
|
||||
// Memoize the suggestion scan: it walks the whole transaction list, so rebuilding it every
|
||||
// frame (the user pauses on a partial address for many frames) is wasted work. Recompute only
|
||||
// when the typed text or the transaction count changes.
|
||||
static std::vector<std::string> suggestions;
|
||||
static std::string s_sugKey;
|
||||
const std::string sugKey = partial + "#" + std::to_string(state.transactions.size());
|
||||
if (sugKey != s_sugKey) {
|
||||
s_sugKey = sugKey;
|
||||
suggestions.clear();
|
||||
const size_t maxSug = (size_t)schema::UI().drawElement("tabs.send", "max-suggestions").size;
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.type != "send" || tx.address.empty()) continue;
|
||||
if (tx.address.find(partial) != std::string::npos) {
|
||||
bool dup = false;
|
||||
for (const auto& s : suggestions) { if (s == tx.address) { dup = true; break; } }
|
||||
if (!dup) suggestions.push_back(tx.address);
|
||||
if (suggestions.size() >= maxSug) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suggestions.empty()) return;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../theme.h"
|
||||
@@ -45,12 +46,7 @@ static std::string truncateString(const std::string& str, int maxLen = 16) {
|
||||
|
||||
// Case-insensitive string search
|
||||
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
|
||||
if (search.empty()) return true;
|
||||
std::string str_lower = str;
|
||||
std::string search_lower = search;
|
||||
std::transform(str_lower.begin(), str_lower.end(), str_lower.begin(), ::tolower);
|
||||
std::transform(search_lower.begin(), search_lower.end(), search_lower.begin(), ::tolower);
|
||||
return str_lower.find(search_lower) != std::string::npos;
|
||||
return dragonx::util::containsIgnoreCase(str, search);
|
||||
}
|
||||
|
||||
// A display-ready transaction that may be a merged autoshield pair.
|
||||
@@ -81,17 +77,9 @@ std::string DisplayTx::getTimeString() const {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Relative time string
|
||||
// Relative time string (localized long form, e.g. "5 minutes ago")
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
char buf[32];
|
||||
if (diff < 60) { snprintf(buf, sizeof(buf), TR("time_seconds_ago"), (long long)diff); return buf; }
|
||||
if (diff < 3600) { snprintf(buf, sizeof(buf), TR("time_minutes_ago"), (long long)(diff / 60)); return buf; }
|
||||
if (diff < 86400) { snprintf(buf, sizeof(buf), TR("time_hours_ago"), (long long)(diff / 3600)); return buf; }
|
||||
snprintf(buf, sizeof(buf), TR("time_days_ago"), (long long)(diff / 86400)); return buf;
|
||||
return dragonx::util::formatTimeAgo(timestamp);
|
||||
}
|
||||
|
||||
// Draw a small transaction-type icon
|
||||
|
||||
65
src/util/text_format.cpp
Normal file
65
src/util/text_format.cpp
Normal file
@@ -0,0 +1,65 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "text_format.h"
|
||||
#include "i18n.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
namespace {
|
||||
std::int64_t secondsAgo(std::int64_t timestamp)
|
||||
{
|
||||
std::int64_t now = static_cast<std::int64_t>(std::time(nullptr));
|
||||
std::int64_t diff = now - timestamp;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string formatTimeAgo(std::int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return {};
|
||||
const std::int64_t diff = secondsAgo(timestamp);
|
||||
char buf[48];
|
||||
if (diff < 60) std::snprintf(buf, sizeof(buf), tr("time_seconds_ago"), static_cast<int>(diff));
|
||||
else if (diff < 3600) std::snprintf(buf, sizeof(buf), tr("time_minutes_ago"), static_cast<int>(diff / 60));
|
||||
else if (diff < 86400) std::snprintf(buf, sizeof(buf), tr("time_hours_ago"), static_cast<int>(diff / 3600));
|
||||
else std::snprintf(buf, sizeof(buf), tr("time_days_ago"), static_cast<int>(diff / 86400));
|
||||
return buf;
|
||||
}
|
||||
|
||||
std::string formatTimeAgoShort(std::int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return {};
|
||||
const std::int64_t diff = secondsAgo(timestamp);
|
||||
char buf[32];
|
||||
if (diff < 60) std::snprintf(buf, sizeof(buf), "%llds ago", static_cast<long long>(diff));
|
||||
else if (diff < 3600) std::snprintf(buf, sizeof(buf), "%lldm ago", static_cast<long long>(diff / 60));
|
||||
else if (diff < 86400) std::snprintf(buf, sizeof(buf), "%lldh ago", static_cast<long long>(diff / 3600));
|
||||
else std::snprintf(buf, sizeof(buf), "%lldd ago", static_cast<long long>(diff / 86400));
|
||||
return buf;
|
||||
}
|
||||
|
||||
bool containsIgnoreCase(std::string_view haystack, std::string_view needle)
|
||||
{
|
||||
if (needle.empty()) return true;
|
||||
if (needle.size() > haystack.size()) return false;
|
||||
const auto low = [](char c) { return static_cast<char>(std::tolower(static_cast<unsigned char>(c))); };
|
||||
const std::size_t last = haystack.size() - needle.size();
|
||||
for (std::size_t i = 0; i <= last; ++i) {
|
||||
std::size_t j = 0;
|
||||
for (; j < needle.size(); ++j) {
|
||||
if (low(haystack[i + j]) != low(needle[j])) break;
|
||||
}
|
||||
if (j == needle.size()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
28
src/util/text_format.h
Normal file
28
src/util/text_format.h
Normal file
@@ -0,0 +1,28 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Small shared text helpers used across the immediate-mode UI. Centralised here so the per-frame
|
||||
// hot paths use one allocation-free implementation instead of several copies.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
// Localized relative time, e.g. "5 minutes ago" (via i18n). Empty when timestamp <= 0.
|
||||
std::string formatTimeAgo(std::int64_t timestamp);
|
||||
|
||||
// Compact, non-localized relative time for tight UI: "5s ago" / "3m ago" / "2h ago" / "4d ago".
|
||||
std::string formatTimeAgoShort(std::int64_t timestamp);
|
||||
|
||||
// Case-insensitive substring test that does NOT allocate (unlike lower-casing copies + find).
|
||||
// An empty needle matches everything. Safe for per-frame filtering of large lists.
|
||||
bool containsIgnoreCase(std::string_view haystack, std::string_view needle);
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user