// 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 "../../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 #include #include #include #include #include #include namespace dragonx { namespace ui { using namespace material; static std::string TrId(const char* key, const char* id) { return std::string(TR(key)) + "##" + id; } // Helper to truncate strings static std::string truncateString(const std::string& str, int maxLen = 16) { if (str.length() <= static_cast(maxLen)) return str; int half = (maxLen - 3) / 2; return str.substr(0, half) + "..." + str.substr(str.length() - half); } // Case-insensitive string search static bool containsIgnoreCase(const std::string& str, const std::string& search) { return dragonx::util::containsIgnoreCase(str, search); } // 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"); std::time_t t = static_cast(timestamp); char buf[64]; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", std::localtime(&t)); return buf; } // 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_CALL_MADE; } 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); } 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(); // 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(); std::string txLoadingText = app->transactionRefreshProgressText(); bool txLoading = !txLoadingText.empty(); // Expanded row index for inline detail static int s_expanded_row = -1; // Pagination state static int s_current_page = 0; static int s_prev_filter_hash = 0; // detect filter changes to reset page // ================================================================ // Summary Cards — Received | Sent | Mined // ================================================================ { 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. std::unordered_map> summaryTxidMap; for (size_t i = 0; i < state.transactions.size(); i++) summaryTxidMap[state.transactions[i].txid].push_back(i); std::vector 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); } } 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 card --- { ImVec2 cMin = origin; ImVec2 cMax(cMin.x + cardW, cMin.y + cardH); DrawGlassPanel(dl, cMin, cMax, glassSpec); float cx = cMin.x + innerPad; float cy = cMin.y + Layout::spacingMd(); // Icon DrawTxIcon(dl, "receive", cx + iconSz, cy + iconSz * 1.33f, iconSz, greenCol); float labelX = cx + iconSz * 3.0f; dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("received_upper")); cy += ovFont->LegacySize + Layout::spacingSm(); snprintf(buf, sizeof(buf), TR("txs_count"), recvCount); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf); cy += capFont->LegacySize + Layout::spacingXs(); snprintf(buf, sizeof(buf), "+%.4f %s", recvTotal, DRAGONX_TICKER); dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), greenCol, 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 == 2) ? 0 : 2; } } // --- Sent card --- { float xOff = cardW + cardGap; ImVec2 cMin(origin.x + xOff, origin.y); 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, "send", cx + iconSz, cy + iconSz * 1.33f, iconSz, redCol); float labelX = cx + iconSz * 3.0f; dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("sent_upper")); cy += ovFont->LegacySize + Layout::spacingSm(); snprintf(buf, sizeof(buf), TR("txs_count"), sendCount); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf); cy += capFont->LegacySize + Layout::spacingXs(); snprintf(buf, sizeof(buf), "-%.4f %s", sendTotal, DRAGONX_TICKER); dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), redCol, 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 == 1) ? 0 : 1; } } // --- Mined card --- { float xOff = 2 * (cardW + cardGap); ImVec2 cMin(origin.x + xOff, origin.y); 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, "mined", cx + iconSz, cy + iconSz * 1.33f, iconSz, goldCol); float labelX = cx + iconSz * 3.0f; dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, cy), OnSurfaceMedium(), TR("mined_upper")); cy += ovFont->LegacySize + Layout::spacingSm(); snprintf(buf, sizeof(buf), TR("txs_count"), minedCount); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf); cy += capFont->LegacySize + Layout::spacingXs(); snprintf(buf, sizeof(buf), "+%.4f %s", minedTotal, DRAGONX_TICKER); dl->AddText(body2, body2->LegacySize, ImVec2(cx, cy), goldCol, 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 == 3) ? 0 : 3; } } // Selected card accent if (type_filter > 0) { 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); 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); } ImGui::Dummy(ImVec2(availWidth, cardH)); ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // ================================================================ // Search & Filter bar // ================================================================ static char search_filter[128] = ""; 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") }; ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types)); // Sort selector ImGui::SameLine(0, filterGap); ImGui::SetNextItemWidth(comboW); const char* sorts[] = { TR("sort_date_newest"), TR("sort_date_oldest"), TR("sort_amount_high"), TR("sort_amount_low") }; 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()); int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4; const char* dotStr[] = {"", ".", "..", "..."}; ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s%s", txLoadingText.c_str(), dotStr[dots]); } 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, not every frame. The cache key // is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed // rows (count, last update time, and each tx's confirmations / timestamp / type+address // first char). 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 s_display_cache; static std::uint64_t s_display_cache_key = 0; static bool s_display_cache_valid = false; std::uint64_t displayKey; { std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; }; mix(state.transactions.size()); mix(static_cast(state.last_tx_update)); mix(static_cast(s_sort_mode)); // re-sort the cache when the mode changes for (const auto& t : state.transactions) { mix(static_cast(t.confirmations)); mix(static_cast(t.timestamp)); mix(t.type.empty() ? 0u : static_cast(t.type[0])); mix(t.address.empty() ? 0u : static_cast(t.address[0])); } displayKey = h; } if (!s_display_cache_valid || displayKey != s_display_cache_key) { s_display_cache.clear(); std::vector& display_txns = s_display_cache; // Map txid -> indices in state.transactions std::unordered_map> txid_map; for (size_t i = 0; i < state.transactions.size(); i++) { txid_map[state.transactions[i].txid].push_back(i); } std::vector 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; const auto& siblings = txid_map[tx.txid]; if (siblings.size() >= 2) { int send_i = -1, recv_i = -1; for (size_t si : siblings) { if (consumed[si]) continue; 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) recv_i = (int)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 = displayKey; s_display_cache_valid = true; } // The merged + sorted list (rebuilt above only on change). Filtering/search/pagination below // run per-frame over this cache — they're cheap (a linear scan) and depend on interactive // filter/search state, so they are intentionally not memoized. const std::vector& display_txns = s_display_cache; // Apply type + search filters std::vector filtered_indices; 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" && dtx.display_type != "shield") continue; 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 (!search_str.empty()) { if (!containsIgnoreCase(dtx.address, search_str) && !containsIgnoreCase(dtx.txid, search_str) && !containsIgnoreCase(dtx.memo, search_str) && !(dtx.is_shield && containsIgnoreCase(std::string("shielded"), search_str))) { continue; } } filtered_indices.push_back(i); } filtered_count = static_cast(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 when filters change int filterHash = type_filter * 1000003 + s_sort_mode * 7919 + filtered_count * 31 + static_cast(search_str.size()); if (filterHash != s_prev_filter_hash) { s_current_page = 0; s_prev_filter_hash = filterHash; } 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); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm()); ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f)); 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::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 45))); // First page ImGui::BeginDisabled(s_current_page == 0); ImGui::PushFont(Type().iconSmall()); if (ImGui::Button(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW))) { s_current_page = 0; s_expanded_row = -1; } ImGui::PopFont(); ImGui::EndDisabled(); ImGui::SameLine(0, gap); // Previous page ImGui::BeginDisabled(s_current_page == 0); ImGui::PushFont(Type().iconSmall()); if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW))) { s_current_page--; s_expanded_row = -1; } ImGui::PopFont(); 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); ImGui::PushFont(Type().iconSmall()); if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW))) { s_current_page++; s_expanded_row = -1; } ImGui::PopFont(); ImGui::EndDisabled(); ImGui::SameLine(0, gap); // Last page ImGui::BeginDisabled(s_current_page >= totalPages - 1); ImGui::PushFont(Type().iconSmall()); if (ImGui::Button(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW))) { s_current_page = totalPages - 1; s_expanded_row = -1; } ImGui::PopFont(); ImGui::EndDisabled(); ImGui::PopStyleColor(3); ImGui::PopStyleVar(2); } } 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()) { ImGui::Dummy(ImVec2(0, 20)); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected")); } else if (state.transactions.empty()) { ImGui::Dummy(ImVec2(0, 20)); if (txLoading) { int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4; const char* dotStr[] = {"", ".", "..", "..."}; snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), dotStr[dots]); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf); } else { Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions")); } } else if (filtered_indices.empty()) { ImGui::Dummy(ImVec2(0, 20)); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), 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]; bool is_expanded = (s_expanded_row == static_cast(i)); ImGui::PushID(static_cast(i)); ImVec2 rowPos = ImGui::GetCursorScreenPos(); ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH); // Determine type info bool shieldedDisplay = tx.display_type == "shield"; ImU32 iconCol; const char* typeStr; if (shieldedDisplay) { iconCol = redCol; typeStr = TR("sent_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 ? "send" : 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 = truncateString(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 = (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 = WithAlpha(Success(), 140); } // Position status badge in the middle-right area ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr); const char* shieldedStr = TR("shielded_type"); ImVec2 shieldSz = shieldedDisplay ? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr) : ImVec2(0, 0); float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f; float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : 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; if (shieldedDisplay) { float shieldX = statusX + (stackW - shieldSz.x) * 0.5f; ImU32 shieldCol = Primary(); ImU32 shieldBg = (shieldCol & 0x00FFFFFFu) | (static_cast(30) << 24); ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f); ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(), shieldPillMin.y + capFont->LegacySize + Layout::spacingXs()); dl->AddRectFilled(shieldPillMin, shieldPillMax, shieldBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size); dl->AddText(capFont, capFont->LegacySize, ImVec2(shieldX, cy), shieldCol, shieldedStr); } // Background pill ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast(30) << 24); 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, pillBg, schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size); 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)) { s_expanded_row = is_expanded ? -1 : static_cast(i); } // Tooltip if (ImGui::IsItemHovered()) { ImGui::SetTooltip("%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