Files
ObsidianDragon/src/ui/windows/transactions_tab.cpp
T
DanSandClaude Opus 4.8 8eee351faa fix(history): show shielding moves as neutral "Shielded", not red "Sent"
A coinbase/transparent→shielded move (often auto-shield) was drawn with a red
"Sent" label and send arrow yet a green "+X" amount — an internally
contradictory row that reads as "money leaving" to anyone who auto-shielded. It
now renders as a neutral "Shielded" row (shield icon + the shielded accent
colour for label and amount), and since a shield is your own funds going
private — not a spend — it's excluded from the Sent card total and the Sent
filter (it merges into one row visible under "All").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 08:57:38 -05:00

1008 lines
55 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "transactions_tab.h"
#include "transaction_details_dialog.h"
#include "export_transactions_dialog.h"
#include "transactions_tab_helpers.h"
#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"
#include "../effects/imgui_acrylic.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../material/colors.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <ctime>
#include <cmath>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
namespace ui {
using namespace material;
static std::string TrId(const char* key, const char* id) {
return std::string(TR(key)) + "##" + id;
}
// Case-insensitive string search
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
return dragonx::util::containsIgnoreCase(str, search);
}
// Pending "view transactions for this address" request. Set by SetTransactionsAddressFilter()
// from Contacts / the Balance address list / the Receive tab, consumed on the next
// RenderTransactionsTab frame to pre-fill the search box (see the consume block below).
static std::string s_pending_addr_filter;
static bool s_pending_addr_filter_set = false;
void SetTransactionsAddressFilter(const std::string& address) {
s_pending_addr_filter = address;
s_pending_addr_filter_set = true;
}
// A display-ready transaction that may be a merged autoshield pair.
// For non-merged entries, send_idx or recv_idx is -1.
struct DisplayTx {
std::string txid;
std::string display_type; // "send", "receive", "mined", "immature", "shield"
double amount = 0.0;
int64_t timestamp = 0;
int confirmations = 0;
std::string address; // primary display address
std::string from_address;
std::string memo;
int orig_idx = -1; // index into state.transactions (first/primary)
int send_idx = -1; // index of send leg (shield only)
int recv_idx = -1; // index of recv leg (shield only)
bool is_shield = false;
bool isConfirmed() const { return confirmations >= 1; }
std::string getTimeString() const;
};
std::string DisplayTx::getTimeString() const {
if (timestamp <= 0) return TR("pending");
return dragonx::util::formatClockDateTime(timestamp); // honors the app-wide 24h/12h clock
}
// Relative time string (localized long form, e.g. "5 minutes ago")
static std::string timeAgo(int64_t timestamp) {
return dragonx::util::formatTimeAgo(timestamp);
}
// Draw a small transaction-type icon
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
float cx, float cy, float /*s*/, ImU32 col)
{
using namespace material;
ImFont* iconFont = Type().iconSmall();
const char* icon;
if (type == "send") {
icon = ICON_MD_CALL_MADE;
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else if (type == "shield") {
icon = ICON_MD_SHIELD; // a shield, not an outgoing arrow — it's an internal privacy move
} else {
icon = ICON_MD_CONSTRUCTION;
}
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
}
// Draw one Received/Sent/Mined summary card: icon + label + count + signed total, with a
// hover outline and click-to-toggle type filter. All three cards are identical apart from the
// per-card data passed here. cMin is the card's top-left; filterValue is the type_filter value
// this card selects (clicking again clears it).
static void drawSummaryCard(ImDrawList* dl, ImVec2 cMin, float cardW, float cardH,
float innerPad, float iconSz, const GlassPanelSpec& glassSpec,
ImFont* ovFont, ImFont* capFont, ImFont* body2,
const char* iconType, ImU32 accentCol, const char* labelKey,
int count, double total, const char* signPrefix,
int filterValue, int& type_filter)
{
char buf[128];
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
DrawGlassPanel(dl, cMin, cMax, glassSpec);
float cx = cMin.x + innerPad;
float cy = cMin.y + Layout::spacingMd();
DrawTxIcon(dl, iconType, cx + iconSz, cy + iconSz * 1.33f, iconSz, accentCol);
float labelX = cx + iconSz * 3.0f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR(labelKey));
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), TR("txs_count"), count);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf);
cy += capFont->LegacySize + Layout::spacingXs();
snprintf(buf, sizeof(buf), "%s%.4f %s", signPrefix, total, DRAGONX_TICKER);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), accentCol, buf);
if (material::IsRectHovered(cMin, cMax)) {
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, 40), glassSpec.rounding, 0, 1.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0)) type_filter = (type_filter == filterValue) ? 0 : filterValue;
}
}
void RenderTransactionsTab(App* app)
{
auto& S = schema::UISchema::instance();
const auto searchInput = S.input("tabs.transactions", "search-input");
const auto filterCombo = S.combo("tabs.transactions", "filter-combo");
const auto filterGapEl = S.drawElement("tabs.transactions", "filter-gap");
const auto txTable = S.table("tabs.transactions", "transaction-table");
const auto addrLabel = S.label("tabs.transactions", "address-label");
const auto& state = app->state();
// Txids that carried a chat message (sent or received) drive the "Message" badge and the Chat filter.
// Looked up through the chat store's revision-keyed txid index (rebuilt only when the store mutates; an
// empty store — chat disabled / no identity — is O(1)), not copied per frame. Both variants populate the
// store before this tab renders, so the same lookup serves full-node and lite.
const chat::ChatStore& chatStore = app->chatService().store();
// Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x);
const float vs = Layout::vScale(contentAvail.y);
const float glassRound = Layout::glassRounding();
const float innerPad = Layout::cardInnerPadding();
const float cGap = Layout::cardGap();
// Non-scrolling container — content resizes to fit available height
ImVec2 txAvail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##TxScroll", txAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImDrawList* dl = ImGui::GetWindowDrawList();
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
ImFont* ovFont = Type().overline();
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
char buf[128];
ImU32 greenCol = Success();
ImU32 redCol = Error();
ImU32 goldCol = Warning();
// Neutral colour for a shielding move (your own funds going transparent→private): neither the
// red of a spend nor the green of income, so a shield never reads as "money leaving".
ImU32 shieldCol = schema::UI().resolveColor("var(--accent-shielded)", OnSurfaceMedium());
std::string txLoadingText = app->transactionRefreshProgressText();
bool txLoading = !txLoadingText.empty();
// Open inline detail, keyed by row identity (history::rowKey), NOT by list index: the display list is rebuilt
// and re-sorted on every change, so an index would land on a different transaction. Empty = none open.
static std::string s_expanded_key;
// Pagination state
static int s_current_page = 0;
static history::FilterInputs s_prev_filter; // detect filter-input changes to reset page
// ================================================================
// Summary Cards — Received | Sent | Mined
// ================================================================
{
// Change signal for state.transactions, shared by the summary-card totals (below) and the display-list
// memoization (further down): WalletState::tx_revision, bumped by every writer of the list (see
// wallet_state.h) and never reset. Sort-independent: the display cache additionally compares s_sort_mode,
// so a sort change re-sorts without invalidating the totals (which don't depend on order). Replaces a
// per-frame O(N) FNV pass over every field of every transaction.
const std::uint64_t contentKey = state.tx_revision;
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
// (a sort-combo change re-sorts without regrouping).
static history::TxidGroups s_groups;
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
if (s_groups_key != contentKey) {
s_groups = history::groupByTxid(state.transactions);
s_groups_key = contentKey;
}
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
// numbers that only change when the tx list changes, so memoize them behind contentKey and
// recompute only when it moves. File-scope-style function statics persist across wallet
// switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which
// bumps contentKey, so the sentinel initial key + change detection covers that for free.
static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0;
static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0;
static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes
if (contentKey != s_summaryKey) {
int recvCount = 0, sendCount = 0, minedCount = 0;
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.
const history::TxidGroups& groups = s_groups;
std::vector<bool> isShieldLeg(state.transactions.size(), false);
for (size_t head = 0; head < state.transactions.size(); head++) {
if (groups.first[head] != (int)head || groups.next[head] < 0) continue; // once per txid shared by 2+ rows
int send_i = -1, recv_i = -1;
for (int si = (int)head; si >= 0; si = groups.next[si]) {
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = si;
else if (stx.type == "receive" && recv_i < 0 &&
!stx.address.empty() && stx.address[0] == 'z') recv_i = si;
}
if (send_i >= 0 && recv_i >= 0) {
isShieldLeg[send_i] = true;
isShieldLeg[recv_i] = true;
// A shield moves your OWN funds transparent→private — it isn't a spend, so it is NOT
// counted toward the Sent card/total (and no longer appears under the Sent filter).
// It merges into one neutral "Shielded" row, visible under "All".
}
}
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 cardGap = cGap;
float cardW = (availWidth - 2 * cardGap) / 3.0f;
float cardH = Layout::cardHeight(70.0f, vs);
float iconSz = std::max(4.0f, schema::UI().drawElement("tabs.transactions", "summary-icon-size").size * hs);
ImVec2 origin = ImGui::GetCursorScreenPos();
// Clickable type filter (clicking a card sets the type filter)
static int type_filter = 0;
// Sort mode: 0 = newest first, 1 = oldest first, 2 = largest amount, 3 = smallest amount.
static int s_sort_mode = 0;
// --- Received / Sent / Mined cards ---
// filter values: Received = 2, Sent = 1, Mined = 3 (see idx_map for the accent bar below)
drawSummaryCard(dl, origin, cardW, cardH, innerPad, iconSz, glassSpec,
ovFont, capFont, body2, "receive", greenCol, "received_upper",
recvCount, recvTotal, "+", 2, type_filter);
drawSummaryCard(dl, ImVec2(origin.x + (cardW + cardGap), origin.y), cardW, cardH,
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "send", redCol, "sent_upper",
sendCount, sendTotal, "-", 1, type_filter);
drawSummaryCard(dl, ImVec2(origin.x + 2 * (cardW + cardGap), origin.y), cardW, cardH,
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper",
minedCount, minedTotal, "+", 3, type_filter);
// Selected card accent (only the three summary cards map to a card position — the Chat filter
// (4) has no card, so guard the idx_map lookup to 1..3).
if (type_filter > 0 && type_filter <= 3) {
int idx_map[] = {-1, 1, 0, 2};
int idx = idx_map[type_filter];
float xOff = idx * (cardW + cardGap);
ImVec2 acMin(origin.x + xOff, origin.y + cardH - 3 * vs);
ImVec2 acMax(origin.x + xOff + cardW, origin.y + cardH);
ImU32 acCol = (type_filter == 1) ? redCol : (type_filter == 2) ? greenCol : goldCol;
dl->AddRectFilled(acMin, acMax, acCol, 2.0f * hs);
}
ImGui::Dummy(ImVec2(availWidth, cardH));
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ================================================================
// Search & Filter bar
// ================================================================
static char search_filter[128] = "";
// Consume a pending "view transactions for this address" request: pre-fill the search box
// with the address and reset the type filter to All so no matching row is hidden. The
// changed search_str resets pagination/expansion via filterInputsChanged() below.
if (s_pending_addr_filter_set) {
strncpy(search_filter, s_pending_addr_filter.c_str(), sizeof(search_filter) - 1);
search_filter[sizeof(search_filter) - 1] = '\0';
type_filter = 0;
s_pending_addr_filter_set = false;
}
float searchMaxW = (searchInput.maxWidth >= 0) ? searchInput.maxWidth : 300.0f;
float searchRatio = (searchInput.widthRatio >= 0) ? searchInput.widthRatio : 0.30f;
float searchWidth = std::min(searchMaxW * hs, availWidth * searchRatio);
ImGui::SetNextItemWidth(searchWidth);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
ImGui::InputTextWithHint("##TxSearch", TR("search_placeholder"), search_filter, sizeof(search_filter));
ImGui::PopStyleVar();
float filterGap = std::max(8.0f, ((filterGapEl.size > 0) ? filterGapEl.size : 20.0f) * hs);
ImGui::SameLine(0, filterGap);
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
ImGui::SetNextItemWidth(comboW);
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"),
TR("mined_filter"), TR("chat_filter") };
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
// Sort selector — the sort labels ("Newest first"/localized) run longer than the type
// labels, so this combo needs its own width. Size it to the widest localized sort option
// measured with the active (default) font — the same font Combo renders with — plus ImGui's
// combo chrome (2x horizontal frame padding + the dropdown arrow button, GetFrameHeight()).
// CalcTextSize and the style paddings are already DPI-scaled, so this grows at font_scale 1.5
// without any hs multiply; clamp to at least the shared type-filter comboW.
ImGui::SameLine(0, filterGap);
const char* sorts[] = { TR("sort_date_newest"), TR("sort_date_oldest"),
TR("sort_amount_high"), TR("sort_amount_low") };
float sortTextW = 0.0f;
for (const char* s : sorts) sortTextW = std::max(sortTextW, ImGui::CalcTextSize(s).x);
float sortComboW = std::max(comboW,
sortTextW + ImGui::GetStyle().FramePadding.x * 2.0f
+ ImGui::GetFrameHeight());
ImGui::SetNextItemWidth(sortComboW);
ImGui::Combo("##TxSort", &s_sort_mode, sorts, IM_ARRAYSIZE(sorts));
ImGui::SameLine(0, filterGap);
if (TactileButton(TrId("refresh", "tx").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
app->refreshNow();
}
ImGui::SameLine(0, filterGap);
if (TactileButton(TrId("export_csv", "tx").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
ExportTransactionsDialog::show();
}
if (txLoading) {
ImGui::SameLine(0, filterGap);
ImGui::PushFont(Type().iconSmall());
float pulse = 0.55f + 0.45f * std::sin((float)ImGui::GetTime() * 3.0f);
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, pulse), ICON_MD_HOURGLASS_EMPTY);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), material::LoadingDots());
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm() + Layout::spacingXs()));
// ================================================================
// Transaction list — DrawList-based rows in scrollable child
// ================================================================
int filtered_count = 0;
std::string search_str(search_filter);
// Build display list, merging autoshield send+receive pairs.
// Two entries sharing the same txid where one is "send" and
// one is "receive" to a z-address are combined into a single
// "shield" entry. We first index by txid, then build the list.
//
// 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 (contentKey = tx_revision) or the sort
// mode changes, not every frame. The build reads nothing but state.transactions and s_sort_mode.
// A new block bumps confirmations (a real change), so the list rebuilds; between changes (the common
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
// the refresh service, but we re-sort here to apply the "pending first" ordering.
static std::vector<DisplayTx> s_display_cache;
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from
static int s_display_cache_sort = 0; // sort mode the cache was built with
static bool s_display_cache_valid = false;
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
++s_display_gen;
s_display_cache.clear();
std::vector<DisplayTx>& display_txns = s_display_cache;
display_txns.reserve(state.transactions.size());
const history::TxidGroups& groups = s_groups; // built above for this tx_revision
std::vector<bool> consumed(state.transactions.size(), false);
for (size_t i = 0; i < state.transactions.size(); i++) {
if (consumed[i]) continue;
const auto& tx = state.transactions[i];
// Try to find an autoshield pair (same txid, send+receive to z-addr)
bool merged = false;
if (groups.hasSiblings(i)) {
int send_i = -1, recv_i = -1;
for (int si = groups.first[i]; si >= 0; si = groups.next[si]) {
if (consumed[si]) continue;
const auto& stx = state.transactions[si];
if (stx.type == "send" && send_i < 0) send_i = si;
else if (stx.type == "receive" && recv_i < 0) recv_i = si;
}
if (send_i >= 0 && recv_i >= 0) {
const auto& stx = state.transactions[send_i];
const auto& rtx = state.transactions[recv_i];
// Confirm receive goes to a z-address (shielded)
bool recv_is_shielded = !rtx.address.empty() && rtx.address[0] == 'z';
if (recv_is_shielded) {
DisplayTx dtx;
dtx.txid = tx.txid;
dtx.display_type = "shield";
dtx.is_shield = true;
dtx.amount = rtx.amount; // positive receive amount
dtx.timestamp = std::max(stx.timestamp, rtx.timestamp);
// Both legs are the SAME transaction, so they share one real confirmation
// count — but the send leg (from z_viewtransaction) often comes through
// with confirmations=0. min() would then make a long-confirmed shield look
// pending (conf==0), which the sort floats to the very top, out of date
// order. Take the populated value.
dtx.confirmations = std::max(stx.confirmations, rtx.confirmations);
dtx.address = rtx.address; // shielded destination
dtx.from_address = stx.address.empty() ? stx.from_address : stx.address;
dtx.memo = rtx.memo.empty() ? stx.memo : rtx.memo;
dtx.orig_idx = send_i;
dtx.send_idx = send_i;
dtx.recv_idx = recv_i;
consumed[send_i] = true;
consumed[recv_i] = true;
display_txns.push_back(std::move(dtx));
merged = true;
}
}
}
if (!merged) {
consumed[i] = true;
DisplayTx dtx;
dtx.txid = tx.txid;
dtx.display_type = tx.type;
dtx.amount = tx.amount;
dtx.timestamp = tx.timestamp;
dtx.confirmations = tx.confirmations;
dtx.address = tx.address;
dtx.from_address = tx.from_address;
dtx.memo = tx.memo;
dtx.orig_idx = (int)i;
display_txns.push_back(std::move(dtx));
}
}
// Sort per the selected mode. txid is the final deterministic tiebreak so same-block
// transactions keep a stable order across rebuilds (otherwise equal keys reorder every
// time a new block bumps confirmations).
const int sortMode = s_sort_mode;
std::sort(display_txns.begin(), display_txns.end(),
[sortMode](const DisplayTx& a, const DisplayTx& b) {
switch (sortMode) {
case 1: // Oldest first
if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp;
return a.txid < b.txid;
case 2: { // Largest amount first
double aa = std::abs(a.amount), ba = std::abs(b.amount);
if (aa != ba) return aa > ba;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
case 3: { // Smallest amount first
double aa = std::abs(a.amount), ba = std::abs(b.amount);
if (aa != ba) return aa < ba;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
default: { // 0: Newest first — pending (0-conf) on top, then by date
bool aPending = (a.confirmations == 0);
bool bPending = (b.confirmations == 0);
if (aPending != bPending) return aPending;
if (a.timestamp != b.timestamp) return a.timestamp > b.timestamp;
return a.txid > b.txid;
}
}
});
s_display_cache_key = contentKey;
s_display_cache_sort = s_sort_mode;
s_display_cache_valid = true;
}
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
// slices); the type/search filter is memoized too.
const std::vector<DisplayTx>& display_txns = s_display_cache;
// Apply type + search filters. MEMOIZED: the result depends only on the display list (display generation),
// the type filter, the exact search text and — for the Chat filter only — the chat store revision. All of
// those are settled earlier this frame (cards/combos/search box draw above). It used to be an O(N) pass with
// up to three case-insensitive substring searches per row, every frame.
static std::vector<size_t> s_filtered_cache;
static bool s_filtered_valid = false;
static std::uint64_t s_filtered_display_gen = ~0ull;
static int s_filtered_type = 0;
static std::string s_filtered_search;
static std::uint64_t s_filtered_chat_rev = 0;
const std::uint64_t chatRev = (type_filter == 4) ? chatStore.revision() : 0;
if (s_filtered_display_gen != s_display_gen || !s_filtered_valid || type_filter != s_filtered_type ||
search_str != s_filtered_search || chatRev != s_filtered_chat_rev) {
s_filtered_cache.clear();
for (size_t i = 0; i < display_txns.size(); i++) {
const auto& dtx = display_txns[i];
if (type_filter != 0) {
if (type_filter == 1 && dtx.display_type != "send") continue; // Sent = real spends; shields aren't sends
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
if (type_filter == 4 && !chatStore.hasChatTxid(dtx.txid)) continue; // chat-only
}
if (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) &&
!containsIgnoreCase(dtx.from_address, search_str) &&
!containsIgnoreCase(dtx.txid, search_str) &&
!containsIgnoreCase(dtx.memo, search_str) &&
!(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) {
continue;
}
}
s_filtered_cache.push_back(i);
}
s_filtered_display_gen = s_display_gen;
s_filtered_type = type_filter;
s_filtered_search = search_str;
s_filtered_chat_rev = chatRev;
s_filtered_valid = true;
}
const std::vector<size_t>& filtered_indices = s_filtered_cache;
filtered_count = static_cast<int>(filtered_indices.size());
// Pagination — slice filtered results into pages
int perPage = std::max(10, (int)schema::UI().drawElement("tabs.transactions", "rows-per-page").sizeOr(50.0f));
int totalPages = std::max(1, (filtered_count + perPage - 1) / perPage);
// Reset page (and collapse the open row) only when a filter INPUT changes (type, sort, or the search text).
// The row count is data: a new/removed row just clamps the page below instead of bouncing to page 1.
if (history::filterInputsChanged(s_prev_filter, type_filter, s_sort_mode, search_str)) {
s_current_page = 0;
s_expanded_key.clear();
}
if (filtered_count == 0) s_expanded_key.clear(); // wallet switch / emptied list: don't resurrect an old expansion
if (s_current_page >= totalPages) s_current_page = totalPages - 1;
if (s_current_page < 0) s_current_page = 0;
int pageStart = s_current_page * perPage;
int pageEnd = std::min(pageStart + perPage, filtered_count);
// ---- Heading line: "TRANSACTIONS" left, pagination right ----
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transactions_upper"));
if (totalPages > 1) {
float paginationH = ImGui::GetFrameHeight();
float btnW = paginationH;
float gap = Layout::spacingSm();
float pageNumW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, " 999 / 999 ").x + Layout::spacingLg();
float totalPagW = btnW * 4 + pageNumW + gap * 4;
// Right-align: position cursor so the group ends at the right edge
ImGui::SameLine();
float startX = ImGui::GetContentRegionMax().x - totalPagW;
ImGui::SetCursorPosX(startX);
// First page — TactileButton (shared glass press, matches the explorer pager)
ImGui::BeginDisabled(s_current_page == 0);
if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) {
s_current_page = 0;
s_expanded_key.clear();
}
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Previous page
ImGui::BeginDisabled(s_current_page == 0);
if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) {
s_current_page--;
s_expanded_key.clear();
}
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Page indicator — render centered text over a fixed-width dummy
{
snprintf(buf, sizeof(buf), "%d / %d", s_current_page + 1, totalPages);
ImVec2 pageSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
ImVec2 regionPos = ImGui::GetCursorScreenPos();
// Reserve the fixed width without advancing to a new line
ImGui::InvisibleButton("##pageNum", ImVec2(pageNumW, paginationH));
// Draw the text centered within that reserved area
ImVec2 textPos(
regionPos.x + (pageNumW - pageSz.x) * 0.5f,
regionPos.y + (paginationH - pageSz.y) * 0.5f
);
ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize, textPos, OnSurfaceMedium(), buf);
}
ImGui::SameLine(0, gap);
// Next page
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) {
s_current_page++;
s_expanded_key.clear();
}
ImGui::EndDisabled();
ImGui::SameLine(0, gap);
// Last page
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) {
s_current_page = totalPages - 1;
s_expanded_key.clear();
}
ImGui::EndDisabled();
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Glass panel wrapping the list area — scale reserve with vScale
float scaledReserve = (txTable.bottomReserve > 0) ? txTable.bottomReserve : std::max(schema::UI().drawElement("tabs.transactions", "bottom-reserve-min").size, schema::UI().drawElement("tabs.transactions", "bottom-reserve-base").size * vs);
float listH = ImGui::GetContentRegionAvail().y - scaledReserve;
float minListH = std::max(schema::UI().drawElement("tabs.transactions", "list-min-height").size, schema::UI().drawElement("tabs.transactions", "list-base-height").size * vs);
if (listH < minListH) listH = minListH;
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
ImVec2 listPanelMax(listPanelMin.x + availWidth, listPanelMin.y + listH);
DrawGlassPanel(dl, listPanelMin, listPanelMax, glassSpec);
// Scroll state for clipping mask (captured inside child, used after EndChild)
float scrollY = 0.0f;
float scrollMaxY = 0.0f;
// Vertex start indices for CSS-style clipping mask (alpha fade at edges)
int vtxMaskStart = dl->VtxBuffer.Size;
ImGui::BeginChild("##TxList", ImVec2(availWidth, listH), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* childDL = ImGui::GetWindowDrawList();
int childVtxStart = childDL->VtxBuffer.Size;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
{
if (!app->isConnected()) {
material::DrawEmptyState(ICON_MD_CLOUD_OFF,
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
} else if (state.transactions.empty()) {
if (txLoading) {
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), material::LoadingDots());
material::DrawEmptyState(ICON_MD_HOURGLASS_EMPTY, buf);
} else {
material::DrawEmptyState(ICON_MD_RECEIPT_LONG, TR("no_transactions"));
}
} else if (filtered_indices.empty()) {
material::DrawEmptyState(ICON_MD_SEARCH_OFF, TR("no_matching"));
} else {
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
float innerW = ImGui::GetContentRegionAvail().x;
float rowIconSz = std::max(3.5f, schema::UI().drawElement("tabs.transactions", "row-icon-size").size * hs);
float rowPadLeft = Layout::spacingLg();
// Scroll state for gradient overlay fade (drawn after EndChild)
scrollY = ImGui::GetScrollY();
scrollMaxY = ImGui::GetScrollMaxY();
// Render only the current page slice (pagination already bounds per-frame work)
for (int fi = pageStart; fi < pageEnd; fi++) {
size_t i = filtered_indices[fi];
const auto& tx = display_txns[i];
const std::string rk = history::rowKey(tx.txid, tx.display_type, tx.address, tx.amount); // <= perPage rows/frame
bool is_expanded = !s_expanded_key.empty() && s_expanded_key == rk;
ImGui::PushID(static_cast<int>(i));
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
const bool isChatTx = chatStore.hasChatTxid(tx.txid); // carried a chat message
ImU32 iconCol;
const char* typeStr;
if (shieldedDisplay) {
iconCol = shieldCol; typeStr = TR("shielded_self_type");
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = TR("recv_type");
} else if (tx.display_type == "send") {
iconCol = redCol; typeStr = TR("sent_type");
} else if (tx.display_type == "immature") {
iconCol = Warning(); typeStr = TR("immature_type");
} else {
iconCol = goldCol; typeStr = TR("mined_type");
}
// Expanded selection accent
if (is_expanded) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + schema::UI().drawElement("tabs.transactions", "row-accent-width").size, rowEnd.y), Primary(), schema::UI().drawElement("tabs.transactions", "accent-bar-rounding").size);
}
// Hover glow
bool hovered = material::IsRectHovered(rowPos, rowEnd);
if (hovered && !is_expanded) {
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), schema::UI().drawElement("tabs.transactions", "row-hover-rounding").size);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
// Icon
DrawTxIcon(dl, shieldedDisplay ? "shield" : tx.display_type,
cx + rowIconSz, cy + body2->LegacySize * 0.5f, rowIconSz, iconCol);
// Type label
float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), iconCol, typeStr);
// Time (next to type)
std::string ago = timeAgo(tx.timestamp);
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeStr).x;
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX + typeW + Layout::spacingLg(), cy),
OnSurfaceDisabled(), ago.c_str());
// Address (second line, left side)
std::string addr_display = util::truncateMiddle(tx.address, (addrLabel.truncate > 0) ? addrLabel.truncate : 20);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), addr_display.c_str());
// Amount (right-aligned, first line)
ImU32 amtCol = shieldedDisplay ? shieldCol : ((tx.amount >= 0) ? greenCol : redCol);
if (tx.amount >= 0)
snprintf(buf, sizeof(buf), "+%.8f", tx.amount);
else
snprintf(buf, sizeof(buf), "%.8f", tx.amount);
ImVec2 amtSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, buf);
float amtX = rowPos.x + innerW - amtSz.x - Layout::spacingLg();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(amtX, cy), amtCol, buf,
1.0f, 1.0f, IM_COL32(0, 0, 0, 120));
// USD equivalent (right-aligned, second line)
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0) {
double usdVal = std::abs(tx.amount) * priceUsd;
if (usdVal >= 1.0)
snprintf(buf, sizeof(buf), "$%.2f", usdVal);
else if (usdVal >= 0.01)
snprintf(buf, sizeof(buf), "$%.4f", usdVal);
else
snprintf(buf, sizeof(buf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rowPos.x + innerW - usdSz.x - Layout::spacingLg(), cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), buf);
}
// Status badge (centered area, second line)
{
const char* statusStr;
ImU32 statusCol;
if (tx.confirmations == 0) {
statusStr = TR("pending"); statusCol = Warning();
} else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
statusStr = buf; statusCol = Warning();
} else if (tx.confirmations >= 100 && (tx.display_type == "generate" || tx.display_type == "mined")) {
statusStr = TR("mature"); statusCol = greenCol;
} else {
statusStr = TR("confirmed"); statusCol = Success();
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
// Optional "top" badge stacked above the status pill: "Message" for a chat tx or
// "Shielded" for an autoshield. Chat txs are send/receive (not the "shield" type),
// so the two are mutually exclusive; chat takes precedence.
const bool showTopBadge = isChatTx || shieldedDisplay;
const char* topBadgeStr = isChatTx ? TR("tx_chat_badge") : TR("shielded_type");
const ImU32 topBadgeCol = isChatTx ? Secondary() : Primary();
ImVec2 topSz = showTopBadge
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, topBadgeStr)
: ImVec2(0, 0);
float topPillW = topSz.x + Layout::spacingSm() * 2.0f;
float stackW = showTopBadge ? std::max(sSz.x, topPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
float statusTextX = statusX + (stackW - sSz.x) * 0.5f;
// Pills use a visible tinted fill + a 1px border so each state separates from the
// row background on every skin (faint alpha-30 fills used to vanish on dark-red /
// near-white / gradient skins). Fill 48, border 90 of the state color's RGB.
const float pillRound = schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size;
if (showTopBadge) {
float topX = statusX + (stackW - topSz.x) * 0.5f;
ImVec2 topPillMin(topX - Layout::spacingSm(), cy - 1.0f);
ImVec2 topPillMax(topX + topSz.x + Layout::spacingSm(),
topPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(topX, cy), topBadgeCol, topBadgeStr);
}
// Background pill
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusTextX + sSz.x + Layout::spacingSm(), pillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(pillMin, pillMax,
(statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(pillMin, pillMax,
(statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(statusTextX, cy + body2->LegacySize + Layout::spacingXs()), statusCol, statusStr);
}
// Click to expand/collapse + invisible button for interaction
ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH));
if (ImGui::IsItemClicked(0)) {
if (is_expanded) s_expanded_key.clear(); else s_expanded_key = rk;
}
// Tooltip
if (ImGui::IsItemHovered()) {
material::Tooltip("%s\n%s\n%s", tx.address.c_str(),
tx.txid.c_str(), tx.getTimeString().c_str());
}
// Context menu
const auto& acrylicTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
ImGui::SetClipboardText(tx.address.c_str());
}
if (ImGui::MenuItem(TR("copy_txid"))) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::Separator();
if (ImGui::MenuItem(TR("view_on_explorer"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
util::Platform::openUrl(url);
}
if (ImGui::MenuItem(TR("view_details"))) {
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
}
effects::ImGuiAcrylic::EndAcrylicPopup();
}
// ---- Inline detail expansion ----
if (is_expanded) {
ImVec2 detailPos = ImGui::GetCursorScreenPos();
// We'll draw the glass panel after measuring the content
float detailPad = Layout::spacingLg();
float detailW = innerW - detailPad * 2;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + detailPad);
ImGui::BeginGroup();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + detailW);
// From address
if (!tx.from_address.empty()) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("from_upper"));
ImGui::TextWrapped("%s", tx.from_address.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// To address
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(),
tx.display_type == "send" ? TR("to_upper") : (tx.display_type == "shield" ? TR("shielded_to") : TR("address_upper")));
ImGui::TextWrapped("%s", tx.address.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// TxID (full, copyable)
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
ImGui::TextWrapped("%s", tx.txid.c_str());
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Memo
if (!tx.memo.empty()) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("memo_upper"));
ImGui::TextWrapped("%s", tx.memo.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// Confirmations + time
snprintf(buf, sizeof(buf), TR("confirmations_display"),
tx.confirmations, tx.getTimeString().c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Action buttons
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::SameLine();
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.address.c_str());
}
ImGui::SameLine();
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
util::Platform::openUrl(url);
}
ImGui::SameLine();
if (TactileSmallButton(TrId("full_details", "detail").c_str(), S.resolveFont("button"))) {
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
TransactionDetailsDialog::show(state.transactions[tx.orig_idx]);
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
ImGui::PopTextWrapPos();
ImGui::EndGroup();
// Draw glass panel behind detail area
ImVec2 detailEnd = ImGui::GetCursorScreenPos();
float detailH = detailEnd.y - detailPos.y + Layout::spacingMd();
GlassPanelSpec detailGlass;
detailGlass.rounding = glassRound * 0.75f;
detailGlass.fillAlpha = 25;
DrawGlassPanel(dl, ImVec2(detailPos.x + Layout::spacingSm() + Layout::spacingXs(), detailPos.y),
ImVec2(detailPos.x + innerW - Layout::spacingSm() - Layout::spacingXs(), detailPos.y + detailH), detailGlass);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
}
// Subtle divider between rows
if (fi < pageEnd - 1 && !is_expanded) {
ImVec2 divStart = ImGui::GetCursorScreenPos();
dl->AddLine(ImVec2(divStart.x + rowPadLeft + rowIconSz * 2.0f, divStart.y),
ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y),
IM_COL32(255, 255, 255, 15));
}
ImGui::PopID();
}
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::EndChild();
// CSS-style clipping mask
{
float fadeZone = std::min(
(body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd()) * 1.2f,
listH * 0.18f);
ApplyScrollEdgeMask(dl, vtxMaskStart, childDL, childVtxStart,
listPanelMin.y, listPanelMax.y, fadeZone, scrollY, scrollMaxY);
}
// Status line with page info
snprintf(buf, sizeof(buf), TR("showing_transactions"),
filtered_count > 0 ? pageStart + 1 : 0, pageEnd, filtered_count, state.transactions.size());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
ImGui::EndChild(); // ##TxScroll
}
} // namespace ui
} // namespace dragonx