From e40962cdf2274c6aaaa83cadeb033d4af55ad449 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 10 Jun 2026 13:54:27 -0500 Subject: [PATCH] perf(ui): dedupe time-ago + allocation-free case-insensitive filter (audit #1-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CMakeLists.txt | 3 ++ src/ui/windows/balance_recent_tx.cpp | 10 +--- src/ui/windows/balance_tab.cpp | 14 +++--- src/ui/windows/balance_tab_helpers.cpp | 18 ++----- src/ui/windows/send_tab.cpp | 36 +++++++------- src/ui/windows/transactions_tab.cpp | 20 ++------ src/util/text_format.cpp | 65 ++++++++++++++++++++++++++ src/util/text_format.h | 28 +++++++++++ 8 files changed, 134 insertions(+), 60 deletions(-) create mode 100644 src/util/text_format.cpp create mode 100644 src/util/text_format.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1c10138..3d1b315 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/ui/windows/balance_recent_tx.cpp b/src/ui/windows/balance_recent_tx.cpp index d25799d..e4311c1 100644 --- a/src/ui/windows/balance_recent_tx.cpp +++ b/src/ui/windows/balance_recent_tx.cpp @@ -1,5 +1,6 @@ #include "balance_recent_tx.h" #include "../../config/version.h" +#include "../../util/text_format.h" #include #include @@ -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(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); } } diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index f110ab8..3bd6210 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -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 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; diff --git a/src/ui/windows/balance_tab_helpers.cpp b/src/ui/windows/balance_tab_helpers.cpp index d0d2b76..c7a677b 100644 --- a/src/ui/windows/balance_tab_helpers.cpp +++ b/src/ui/windows/balance_tab_helpers.cpp @@ -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(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) diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 86860a0..8c6e634 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -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 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 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; diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 66aa85b..d0e5368 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -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 diff --git a/src/util/text_format.cpp b/src/util/text_format.cpp new file mode 100644 index 0000000..26b774e --- /dev/null +++ b/src/util/text_format.cpp @@ -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 +#include +#include + +namespace dragonx { +namespace util { + +namespace { +std::int64_t secondsAgo(std::int64_t timestamp) +{ + std::int64_t now = static_cast(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(diff)); + else if (diff < 3600) std::snprintf(buf, sizeof(buf), tr("time_minutes_ago"), static_cast(diff / 60)); + else if (diff < 86400) std::snprintf(buf, sizeof(buf), tr("time_hours_ago"), static_cast(diff / 3600)); + else std::snprintf(buf, sizeof(buf), tr("time_days_ago"), static_cast(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(diff)); + else if (diff < 3600) std::snprintf(buf, sizeof(buf), "%lldm ago", static_cast(diff / 60)); + else if (diff < 86400) std::snprintf(buf, sizeof(buf), "%lldh ago", static_cast(diff / 3600)); + else std::snprintf(buf, sizeof(buf), "%lldd ago", static_cast(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(std::tolower(static_cast(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 diff --git a/src/util/text_format.h b/src/util/text_format.h new file mode 100644 index 0000000..b888d29 --- /dev/null +++ b/src/util/text_format.h @@ -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 +#include +#include + +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