perf: memoize per-frame render hot paths (console, transactions, recent lists)
Immediate-mode render functions re-run every frame; these rebuilt O(N) state each time even when nothing changed. From a 6-lens perf audit, each finding verified on a hot-path basis: - Console: ConsoleModel gains revision(); the full-model filter scan and the glyph-by-glyph text-layout pass (BuildConsoleLayout) rebuild only when the model / filter / wrap-width / zoom change — previously it re-shaped up to 10,000 lines every frame even when idle/scrolled. clear() force-invalidates the memo mid-render (no OOB on the just-emptied visible set). - Transactions: the summary-card totals memoize behind the tab's existing FNV-1a fingerprint (also folds away a now-duplicate O(N) display-key pass). - Send / Receive recent lists: early-exit the prefix scan (state.transactions is kept newest-first) instead of filtering the whole tx history every frame. - network_refresh_service: O(new x total) txid find-and-replace -> hash map. - Sidebar unconfirmed-tx badge cached on last_tx_update + tx count; the daemon-memory probe (/proc scan on Linux, popen on macOS) throttled to ~1.5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
src/app.cpp
25
src/app.cpp
@@ -1806,7 +1806,10 @@ void App::render()
|
|||||||
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
|
// long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is
|
||||||
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
|
// confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf
|
||||||
// legs of already-confirmed transactions and double-counts multi-leg ones.
|
// legs of already-confirmed transactions and double-counts multi-leg ones.
|
||||||
{
|
// Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh;
|
||||||
|
// size() catches same-second content changes). Otherwise this built two unordered_sets over the whole
|
||||||
|
// wallet tx list every frame just to size a badge.
|
||||||
|
if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) {
|
||||||
std::unordered_set<std::string> confirmedTxids;
|
std::unordered_set<std::string> confirmedTxids;
|
||||||
for (const auto& tx : state_.transactions) {
|
for (const auto& tx : state_.transactions) {
|
||||||
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
|
if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid);
|
||||||
@@ -1817,8 +1820,11 @@ void App::render()
|
|||||||
unconfirmedTxids.insert(tx.txid);
|
unconfirmedTxids.insert(tx.txid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sbStatus.unconfirmedTxCount = static_cast<int>(unconfirmedTxids.size());
|
sb_unconf_count_ = static_cast<int>(unconfirmedTxids.size());
|
||||||
|
sb_unconf_key_ts_ = state_.last_tx_update;
|
||||||
|
sb_unconf_key_n_ = state_.transactions.size();
|
||||||
}
|
}
|
||||||
|
sbStatus.unconfirmedTxCount = sb_unconf_count_;
|
||||||
|
|
||||||
// Sidebar minimum height from ui.toml schema (DPI-scaled).
|
// Sidebar minimum height from ui.toml schema (DPI-scaled).
|
||||||
const float sbMinHeight = sbde("min-height", 360.0f);
|
const float sbMinHeight = sbde("min-height", 360.0f);
|
||||||
@@ -5695,17 +5701,26 @@ bool App::stopDaemonForBootstrap()
|
|||||||
|
|
||||||
double App::getDaemonMemoryUsageMB() const
|
double App::getDaemonMemoryUsageMB() const
|
||||||
{
|
{
|
||||||
|
// The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths
|
||||||
|
// scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS.
|
||||||
|
const double now = ImGui::GetTime();
|
||||||
|
if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5)
|
||||||
|
return daemon_mem_cached_mb_;
|
||||||
|
daemon_mem_probe_at_ = now;
|
||||||
|
|
||||||
|
double result = 0.0;
|
||||||
// If we have an embedded daemon with a tracked process handle, use it
|
// If we have an embedded daemon with a tracked process handle, use it
|
||||||
// directly — more reliable than a process scan since we own the handle.
|
// directly — more reliable than a process scan since we own the handle.
|
||||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||||
double mb = daemon_controller_->memoryUsageMB();
|
double mb = daemon_controller_->memoryUsageMB();
|
||||||
daemon_mem_diag_ = "embedded";
|
daemon_mem_diag_ = "embedded";
|
||||||
if (mb > 0.0) return mb;
|
result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB();
|
||||||
} else {
|
} else {
|
||||||
daemon_mem_diag_ = "process scan";
|
daemon_mem_diag_ = "process scan";
|
||||||
|
result = util::Platform::getDaemonMemoryUsageMB(); // external daemon
|
||||||
}
|
}
|
||||||
// Fall back to platform-level process scan (external daemon)
|
daemon_mem_cached_mb_ = result;
|
||||||
return util::Platform::getDaemonMemoryUsageMB();
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1108,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
? &hushChatReceivedOutputs
|
? &hushChatReceivedOutputs
|
||||||
: nullptr;
|
: nullptr;
|
||||||
|
|
||||||
|
// Index result.transactions by (txid, type) once so the two replace-loops below
|
||||||
|
// can find-and-replace in O(1) instead of a nested linear scan over the full
|
||||||
|
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
|
||||||
|
// holds the index of the FIRST occurrence of each key (preserving the linear
|
||||||
|
// scan's break-on-first-match), and is kept in sync on every append so a later
|
||||||
|
// entry in the same cycle still finds an earlier appended one — exactly as the
|
||||||
|
// re-scanned vector did before.
|
||||||
|
auto txKey = [](const TransactionInfo& tx) {
|
||||||
|
return tx.txid + '\x1f' + tx.type;
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, std::size_t> byKey;
|
||||||
|
byKey.reserve(result.transactions.size());
|
||||||
|
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
|
||||||
|
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
std::set<std::string> recentTxids;
|
std::set<std::string> recentTxids;
|
||||||
std::vector<TransactionInfo> recentTransactions;
|
std::vector<TransactionInfo> recentTransactions;
|
||||||
@@ -1115,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||||
|
|
||||||
for (auto& recent : recentTransactions) {
|
for (auto& recent : recentTransactions) {
|
||||||
bool replaced = false;
|
auto it = byKey.find(txKey(recent));
|
||||||
for (auto& existing : result.transactions) {
|
if (it != byKey.end()) {
|
||||||
if (existing.txid == recent.txid && existing.type == recent.type) {
|
result.transactions[it->second] = recent;
|
||||||
existing = recent;
|
} else {
|
||||||
replaced = true;
|
byKey.emplace(txKey(recent), result.transactions.size());
|
||||||
break;
|
result.transactions.push_back(std::move(recent));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!replaced) result.transactions.push_back(std::move(recent));
|
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||||
@@ -1149,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
snapshot.miningAddresses,
|
snapshot.miningAddresses,
|
||||||
hushChatReceivedOutputsPtr);
|
hushChatReceivedOutputsPtr);
|
||||||
for (auto& scanned : scannedTransactions) {
|
for (auto& scanned : scannedTransactions) {
|
||||||
bool replaced = false;
|
auto it = byKey.find(txKey(scanned));
|
||||||
for (auto& existing : result.transactions) {
|
if (it != byKey.end()) {
|
||||||
if (existing.txid == scanned.txid && existing.type == scanned.type) {
|
result.transactions[it->second] = scanned;
|
||||||
existing = scanned;
|
} else {
|
||||||
replaced = true;
|
byKey.emplace(txKey(scanned), result.transactions.size());
|
||||||
break;
|
result.transactions.push_back(std::move(scanned));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!replaced) result.transactions.push_back(std::move(scanned));
|
|
||||||
}
|
}
|
||||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||||
++result.shieldedAddressesScanned;
|
++result.shieldedAddressesScanned;
|
||||||
|
|||||||
@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
|
|||||||
lines_.pop_front();
|
lines_.pop_front();
|
||||||
++result.popped;
|
++result.popped;
|
||||||
}
|
}
|
||||||
|
++revision_; // deque changed — lets the view memoize its filter/layout passes
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConsoleModel::clear()
|
void ConsoleModel::clear()
|
||||||
{
|
{
|
||||||
lines_.clear();
|
lines_.clear();
|
||||||
|
++revision_;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
||||||
{
|
{
|
||||||
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
||||||
lines_[i].collapsed = !lines_[i].collapsed;
|
lines_[i].collapsed = !lines_[i].collapsed;
|
||||||
|
++revision_; // fold change alters which lines are visible
|
||||||
return lines_[i].collapsed;
|
return lines_[i].collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "console_channel.h"
|
#include "console_channel.h"
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -76,11 +77,16 @@ public:
|
|||||||
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
||||||
const ConsoleModelLine& back() const { return lines_.back(); }
|
const ConsoleModelLine& back() const { return lines_.back(); }
|
||||||
|
|
||||||
|
// Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear,
|
||||||
|
// fold toggle). The view memoizes its per-frame filter + text-layout passes against this.
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const std::size_t max_lines_;
|
const std::size_t max_lines_;
|
||||||
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
|
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
|
||||||
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
|
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
|
||||||
std::mutex ingest_mutex_;
|
std::mutex ingest_mutex_;
|
||||||
|
std::uint64_t revision_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -820,11 +820,23 @@ void ConsoleTab::renderOutput()
|
|||||||
// segment records which bytes of the source text appear on that visual row, so
|
// segment records which bytes of the source text appear on that visual row, so
|
||||||
// hit-testing and selection highlight can map screen positions to exact char offsets.
|
// hit-testing and selection highlight can map screen positions to exact char offsets.
|
||||||
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
||||||
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
// Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or
|
||||||
layout_ = BuildConsoleLayout(
|
// zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every
|
||||||
static_cast<int>(visible_indices_.size()),
|
// frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for
|
||||||
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
// drawVisibleLines / screenToTextPos when we skip the rebuild.
|
||||||
wrap_width, line_height, interLineGap, measure);
|
std::uint64_t layoutKey = vis_generation_ * 1000003ull;
|
||||||
|
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(wrap_width * 16.0f);
|
||||||
|
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(line_height * 16.0f);
|
||||||
|
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(interLineGap * 16.0f);
|
||||||
|
layoutKey = layoutKey * 131ull + static_cast<std::uint64_t>(s_console_zoom * 1000.0f);
|
||||||
|
if (layoutKey != layout_key_) {
|
||||||
|
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||||
|
layout_ = BuildConsoleLayout(
|
||||||
|
static_cast<int>(visible_indices_.size()),
|
||||||
|
[this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; },
|
||||||
|
wrap_width, line_height, interLineGap, measure);
|
||||||
|
layout_key_ = layoutKey;
|
||||||
|
}
|
||||||
|
|
||||||
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
|
// Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses
|
||||||
// the child window's event consumption.
|
// the child window's event consumption.
|
||||||
@@ -891,6 +903,20 @@ void ConsoleTab::renderOutput()
|
|||||||
|
|
||||||
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
|
void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower)
|
||||||
{
|
{
|
||||||
|
// Memoize: rebuild the visible set only when the model changed or the filter state changed.
|
||||||
|
// Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every
|
||||||
|
// frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the
|
||||||
|
// key moves, so an early return leaves last frame's (still-correct) results in place.
|
||||||
|
std::uint64_t visKey = model_.revision() * 1000003ull;
|
||||||
|
for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast<unsigned char>(*p);
|
||||||
|
visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u);
|
||||||
|
visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u);
|
||||||
|
visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u);
|
||||||
|
visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u);
|
||||||
|
if (visKey == vis_key_) return; // nothing that affects the visible set changed
|
||||||
|
vis_key_ = visKey;
|
||||||
|
++vis_generation_; // the layout pass keys off this
|
||||||
|
|
||||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||||
s_errors_only_enabled, s_rpc_trace_enabled,
|
s_errors_only_enabled, s_rpc_trace_enabled,
|
||||||
s_app_messages_enabled};
|
s_app_messages_enabled};
|
||||||
@@ -2079,6 +2105,12 @@ void ConsoleTab::clear()
|
|||||||
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
|
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
|
||||||
visible_indices_.clear();
|
visible_indices_.clear();
|
||||||
selection_.clear();
|
selection_.clear();
|
||||||
|
// Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty
|
||||||
|
// visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout
|
||||||
|
// memo would skip the rebuild and keep stale geometry for the just-emptied set.
|
||||||
|
vis_key_ = ~0ull;
|
||||||
|
layout_key_ = ~0ull;
|
||||||
|
++vis_generation_;
|
||||||
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
||||||
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "../../rpc/rpc_client.h"
|
#include "../../rpc/rpc_client.h"
|
||||||
#include "../../rpc/rpc_worker.h"
|
#include "../../rpc/rpc_worker.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
@@ -188,6 +189,14 @@ private:
|
|||||||
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
|
bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it)
|
||||||
std::string filter_lower_; // lowercased filter needle for match highlighting
|
std::string filter_lower_; // lowercased filter needle for match highlighting
|
||||||
|
|
||||||
|
// Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame):
|
||||||
|
// computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter
|
||||||
|
// state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the
|
||||||
|
// resulting visible-set generation + wrap width + line height/zoom.
|
||||||
|
std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines
|
||||||
|
std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt
|
||||||
|
std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout
|
||||||
|
|
||||||
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
||||||
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
||||||
// consumed by the renderer + hit-testing.
|
// consumed by the renderer + hit-testing.
|
||||||
|
|||||||
@@ -370,20 +370,25 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
|
|||||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||||
ImU32 recvCol = Success();
|
ImU32 recvCol = Success();
|
||||||
|
|
||||||
// Collect matching transactions
|
|
||||||
std::vector<const TransactionInfo*> recvs;
|
|
||||||
for (const auto& tx : state.transactions) {
|
|
||||||
if (tx.type != "receive" && tx.type != "mined") continue;
|
|
||||||
recvs.push_back(&tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
|
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
|
||||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||||
|
// Compute maxRows BEFORE the collect loop so the scan can stop early (below).
|
||||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||||
if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first
|
|
||||||
|
// Collect matching transactions. state.transactions is newest-first, so the first
|
||||||
|
// maxRows matches ARE exactly the rows we render — stop scanning once we have them
|
||||||
|
// instead of filtering the entire history every frame (mirrors RenderSharedRecentTx
|
||||||
|
// in balance_components.cpp).
|
||||||
|
std::vector<const TransactionInfo*> recvs;
|
||||||
|
recvs.reserve(maxRows);
|
||||||
|
for (const auto& tx : state.transactions) {
|
||||||
|
if (tx.type != "receive" && tx.type != "mined") continue;
|
||||||
|
recvs.push_back(&tx);
|
||||||
|
if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
||||||
ImGuiWindowFlags_NoBackground);
|
ImGuiWindowFlags_NoBackground);
|
||||||
|
|||||||
@@ -1120,20 +1120,25 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
|||||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||||
ImU32 sendCol = Error();
|
ImU32 sendCol = Error();
|
||||||
|
|
||||||
// Collect matching transactions
|
|
||||||
std::vector<const TransactionInfo*> sends;
|
|
||||||
for (const auto& tx : state.transactions) {
|
|
||||||
if (tx.type != "send" && tx.type != "shield") continue;
|
|
||||||
sends.push_back(&tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grow the list to fill the dead space beneath the (fixed-height) compose card:
|
// Grow the list to fill the dead space beneath the (fixed-height) compose card:
|
||||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||||
|
// Row budget is hoisted above the collect loop (it only needs the remaining region
|
||||||
|
// height + row height, no per-row state) so the scan can early-exit.
|
||||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||||
if (sends.size() > maxRows) sends.resize(maxRows); // newest-first
|
|
||||||
|
// Collect matching transactions. state.transactions is newest-first, so scan only a
|
||||||
|
// bounded prefix and stop once maxRows matches are gathered (mirrors the early-exit in
|
||||||
|
// balance_components.cpp:RenderSharedRecentTx) instead of filtering the whole history.
|
||||||
|
std::vector<const TransactionInfo*> sends;
|
||||||
|
sends.reserve(maxRows);
|
||||||
|
for (const auto& tx : state.transactions) {
|
||||||
|
if (tx.type != "send" && tx.type != "shield") continue;
|
||||||
|
sends.push_back(&tx);
|
||||||
|
if (sends.size() == maxRows) break; // newest-first: enough rows to fill the region
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
|
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
|
||||||
ImGuiWindowFlags_NoBackground);
|
ImGuiWindowFlags_NoBackground);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -186,53 +187,106 @@ void RenderTransactionsTab(App* app)
|
|||||||
// Summary Cards — Received | Sent | Mined
|
// Summary Cards — Received | Sent | Mined
|
||||||
// ================================================================
|
// ================================================================
|
||||||
{
|
{
|
||||||
int recvCount = 0, sendCount = 0, minedCount = 0;
|
// Content fingerprint over state.transactions, shared by the summary-card totals (below) and
|
||||||
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
|
// the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash
|
||||||
|
// computed ONCE per frame and folds in every field the *totals* and the *displayed rows*
|
||||||
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
|
// depend on: tx count, last update time, and per-tx txid (drives autoshield pairing),
|
||||||
// that pair is a single internal shielding move — shown as one "Shield" row in the
|
// amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is
|
||||||
// list below — not income or spending. Counting both legs double-counts the amount
|
// sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top
|
||||||
// into BOTH the Sent and Received totals, so the cards disagree with the list.
|
// so a sort change re-sorts the display cache without invalidating the totals (which don't
|
||||||
// Mirror the list's pairing logic and exclude both legs from the totals.
|
// depend on order). NB vs. the old display key: this additionally folds full txid + amount +
|
||||||
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
|
// full type/address (not just first char), which the summary totals require — the old key
|
||||||
for (size_t i = 0; i < state.transactions.size(); i++)
|
// omitted them, so it could not have gated the totals correctly.
|
||||||
summaryTxidMap[state.transactions[i].txid].push_back(i);
|
std::uint64_t contentKey;
|
||||||
std::vector<bool> isShieldLeg(state.transactions.size(), false);
|
{
|
||||||
for (const auto& kv : summaryTxidMap) {
|
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
|
||||||
if (kv.second.size() < 2) continue;
|
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
|
||||||
int send_i = -1, recv_i = -1;
|
auto mixBytes = [&mix](const std::string& s) {
|
||||||
for (size_t si : kv.second) {
|
mix(s.size());
|
||||||
const auto& stx = state.transactions[si];
|
for (unsigned char c : s) mix(static_cast<std::uint64_t>(c));
|
||||||
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
};
|
||||||
else if (stx.type == "receive" && recv_i < 0 &&
|
mix(state.transactions.size());
|
||||||
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
|
mix(static_cast<std::uint64_t>(state.last_tx_update));
|
||||||
}
|
for (const auto& t : state.transactions) {
|
||||||
if (send_i >= 0 && recv_i >= 0) {
|
mixBytes(t.txid);
|
||||||
isShieldLeg[send_i] = true;
|
std::uint64_t amtBits = 0;
|
||||||
isShieldLeg[recv_i] = true;
|
std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double
|
||||||
// The list shows the merged shield under the "Sent" filter, so count it there too —
|
mix(amtBits);
|
||||||
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
|
mix(static_cast<std::uint64_t>(t.confirmations));
|
||||||
// (receive-leg) amount, which is what the merged row displays.
|
mix(static_cast<std::uint64_t>(t.timestamp));
|
||||||
sendCount++;
|
mixBytes(t.type);
|
||||||
sendTotal += std::abs(state.transactions[recv_i].amount);
|
mixBytes(t.address);
|
||||||
}
|
}
|
||||||
|
contentKey = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (size_t i = 0; i < state.transactions.size(); i++) {
|
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||||
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
|
// numbers that only change when the tx list changes, so memoize them behind contentKey and
|
||||||
const auto& tx = state.transactions[i];
|
// recompute only when it moves. File-scope-style function statics persist across wallet
|
||||||
if (tx.type == "receive") {
|
// switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
|
||||||
recvCount++;
|
// bumps contentKey, so the sentinel initial key + change detection covers that for free.
|
||||||
recvTotal += std::abs(tx.amount);
|
static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
|
||||||
} else if (tx.type == "send") {
|
static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
|
||||||
sendCount++;
|
static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes
|
||||||
sendTotal += std::abs(tx.amount);
|
|
||||||
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
|
if (contentKey != s_summaryKey) {
|
||||||
minedCount++;
|
int recvCount = 0, sendCount = 0, minedCount = 0;
|
||||||
minedTotal += std::abs(tx.amount);
|
double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0;
|
||||||
|
|
||||||
|
// Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg):
|
||||||
|
// that pair is a single internal shielding move — shown as one "Shield" row in the
|
||||||
|
// list below — not income or spending. Counting both legs double-counts the amount
|
||||||
|
// into BOTH the Sent and Received totals, so the cards disagree with the list.
|
||||||
|
// Mirror the list's pairing logic and exclude both legs from the totals.
|
||||||
|
std::unordered_map<std::string, std::vector<size_t>> summaryTxidMap;
|
||||||
|
for (size_t i = 0; i < state.transactions.size(); i++)
|
||||||
|
summaryTxidMap[state.transactions[i].txid].push_back(i);
|
||||||
|
std::vector<bool> isShieldLeg(state.transactions.size(), false);
|
||||||
|
for (const auto& kv : summaryTxidMap) {
|
||||||
|
if (kv.second.size() < 2) continue;
|
||||||
|
int send_i = -1, recv_i = -1;
|
||||||
|
for (size_t si : kv.second) {
|
||||||
|
const auto& stx = state.transactions[si];
|
||||||
|
if (stx.type == "send" && send_i < 0) send_i = (int)si;
|
||||||
|
else if (stx.type == "receive" && recv_i < 0 &&
|
||||||
|
!stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si;
|
||||||
|
}
|
||||||
|
if (send_i >= 0 && recv_i >= 0) {
|
||||||
|
isShieldLeg[send_i] = true;
|
||||||
|
isShieldLeg[recv_i] = true;
|
||||||
|
// The list shows the merged shield under the "Sent" filter, so count it there too —
|
||||||
|
// otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded
|
||||||
|
// (receive-leg) amount, which is what the merged row displays.
|
||||||
|
sendCount++;
|
||||||
|
sendTotal += std::abs(state.transactions[recv_i].amount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < state.transactions.size(); i++) {
|
||||||
|
if (isShieldLeg[i]) continue; // internal shielding move — not received or sent
|
||||||
|
const auto& tx = state.transactions[i];
|
||||||
|
if (tx.type == "receive") {
|
||||||
|
recvCount++;
|
||||||
|
recvTotal += std::abs(tx.amount);
|
||||||
|
} else if (tx.type == "send") {
|
||||||
|
sendCount++;
|
||||||
|
sendTotal += std::abs(tx.amount);
|
||||||
|
} else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") {
|
||||||
|
minedCount++;
|
||||||
|
minedTotal += std::abs(tx.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_recvCount = recvCount; s_recvTotal = recvTotal;
|
||||||
|
s_sendCount = sendCount; s_sendTotal = sendTotal;
|
||||||
|
s_minedCount = minedCount; s_minedTotal = minedTotal;
|
||||||
|
s_summaryKey = contentKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render from the cached totals (identical values — only the recomputation is skipped).
|
||||||
|
const int recvCount = s_recvCount, sendCount = s_sendCount, minedCount = s_minedCount;
|
||||||
|
const double recvTotal = s_recvTotal, sendTotal = s_sendTotal, minedTotal = s_minedTotal;
|
||||||
|
|
||||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||||
float cardGap = cGap;
|
float cardGap = cGap;
|
||||||
float cardW = (availWidth - 2 * cardGap) / 3.0f;
|
float cardW = (availWidth - 2 * cardGap) / 3.0f;
|
||||||
@@ -346,31 +400,24 @@ void RenderTransactionsTab(App* app)
|
|||||||
//
|
//
|
||||||
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only
|
// This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only
|
||||||
// rebuilds when the underlying transactions actually change, not every frame. The cache key
|
// rebuilds when the underlying transactions actually change, not every frame. The cache key
|
||||||
// is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed
|
// reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a
|
||||||
// rows (count, last update time, and each tx's confirmations / timestamp / type+address
|
// fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+
|
||||||
// first char). A new block bumps every confirmation, so the key changes and we rebuild;
|
// type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort
|
||||||
// between changes (the common case while the user reads/scrolls) we reuse the cache. The
|
// change. contentKey already folds in more than the display list strictly needs (amount,
|
||||||
// result is already sorted newest-first by the refresh service, but we re-sort here to apply
|
// full txid/type/address) — a superset, so any change that would have flipped the old
|
||||||
// the "pending first" ordering — also folded into the memoized build.
|
// per-first-char key still flips this one. A new block bumps every confirmation, so the key
|
||||||
|
// changes and we rebuild; between changes (the common case while the user reads/scrolls) we
|
||||||
|
// reuse the cache. The result is already sorted newest-first by the refresh service, but we
|
||||||
|
// re-sort here to apply the "pending first" ordering — also folded into the memoized build.
|
||||||
static std::vector<DisplayTx> s_display_cache;
|
static std::vector<DisplayTx> s_display_cache;
|
||||||
static std::uint64_t s_display_cache_key = 0;
|
static std::uint64_t s_display_cache_key = 0;
|
||||||
static bool s_display_cache_valid = false;
|
static bool s_display_cache_valid = false;
|
||||||
|
|
||||||
std::uint64_t displayKey;
|
// Derive the display key from the shared contentKey (avoids a second full pass over the
|
||||||
{
|
// transactions) plus the sort mode — the only display-affecting input contentKey omits.
|
||||||
std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis
|
std::uint64_t displayKey =
|
||||||
auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; };
|
(contentKey ^ (static_cast<std::uint64_t>(s_sort_mode) + 0x9E3779B97F4A7C15ULL))
|
||||||
mix(state.transactions.size());
|
* 1099511628211ULL;
|
||||||
mix(static_cast<std::uint64_t>(state.last_tx_update));
|
|
||||||
mix(static_cast<std::uint64_t>(s_sort_mode)); // re-sort the cache when the mode changes
|
|
||||||
for (const auto& t : state.transactions) {
|
|
||||||
mix(static_cast<std::uint64_t>(t.confirmations));
|
|
||||||
mix(static_cast<std::uint64_t>(t.timestamp));
|
|
||||||
mix(t.type.empty() ? 0u : static_cast<unsigned char>(t.type[0]));
|
|
||||||
mix(t.address.empty() ? 0u : static_cast<unsigned char>(t.address[0]));
|
|
||||||
}
|
|
||||||
displayKey = h;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
|
if (!s_display_cache_valid || displayKey != s_display_cache_key) {
|
||||||
s_display_cache.clear();
|
s_display_cache.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user