ObsidianDragon - DragonX ImGui Wallet

Full-node GUI wallet for DragonX cryptocurrency.
Built with Dear ImGui, SDL3, and OpenGL3/DX11.

Features:
- Send/receive shielded and transparent transactions
- Autoshield with merged transaction display
- Built-in CPU mining (xmrig)
- Peer management and network monitoring
- Wallet encryption with PIN lock
- QR code generation for receive addresses
- Transaction history with pagination
- Console for direct RPC commands
- Cross-platform (Linux, Windows)
This commit is contained in:
2026-02-26 02:31:52 -06:00
commit 3aee55b49c
306 changed files with 177789 additions and 0 deletions

View File

@@ -0,0 +1,858 @@
// 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 "../../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 <ctime>
#include <cmath>
#include <unordered_map>
namespace dragonx {
namespace ui {
using namespace material;
// Helper to truncate strings
static std::string truncateString(const std::string& str, int maxLen = 16) {
if (str.length() <= static_cast<size_t>(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) {
if (search.empty()) return true;
std::string str_lower = str;
std::string search_lower = search;
std::transform(str_lower.begin(), str_lower.end(), str_lower.begin(), ::tolower);
std::transform(search_lower.begin(), search_lower.end(), search_lower.begin(), ::tolower);
return str_lower.find(search_lower) != std::string::npos;
}
// 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 "Pending";
std::time_t t = static_cast<std::time_t>(timestamp);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", std::localtime(&t));
return buf;
}
// Relative time string
static std::string timeAgo(int64_t timestamp) {
if (timestamp <= 0) return "";
int64_t now = (int64_t)std::time(nullptr);
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return std::to_string(diff) + "s ago";
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
return std::to_string(diff / 86400) + "d ago";
}
// 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;
} 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();
// 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;
for (const auto& tx : state.transactions) {
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;
// --- 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(), "RECEIVED");
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), "%d txs", 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(), "SENT");
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), "%d txs", 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(), "MINED");
cy += ovFont->LegacySize + Layout::spacingSm();
snprintf(buf, sizeof(buf), "%d txs", 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", "Search...", 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[] = { "All", "Sent", "Received", "Mined" };
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
ImGui::SameLine(0, filterGap);
if (TactileButton("Refresh", ImVec2(0, 0), S.resolveFont("button"))) {
app->refreshNow();
}
ImGui::SameLine(0, filterGap);
if (TactileButton("Export CSV", ImVec2(0, 0), S.resolveFont("button"))) {
ExportTransactionsDialog::show();
}
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.
std::vector<DisplayTx> display_txns;
{
// Map txid -> indices in state.transactions
std::unordered_map<std::string, std::vector<size_t>> txid_map;
for (size_t i = 0; i < state.transactions.size(); i++) {
txid_map[state.transactions[i].txid].push_back(i);
}
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;
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);
dtx.confirmations = std::min(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 by timestamp descending (same as raw list)
std::sort(display_txns.begin(), display_txns.end(),
[](const DisplayTx& a, const DisplayTx& b) {
return a.timestamp > b.timestamp;
});
}
// Apply type + search filters
std::vector<size_t> 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") continue;
if (type_filter == 2 && dtx.display_type != "receive" && dtx.display_type != "shield") 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<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 when filters change
int filterHash = type_filter * 1000003 + filtered_count * 31 + static_cast<int>(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(), "TRANSACTIONS");
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(), " Not connected to daemon...");
} else if (state.transactions.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), " No transactions found");
} else if (filtered_indices.empty()) {
ImGui::Dummy(ImVec2(0, 20));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), " No matching transactions");
} 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();
// Viewport culling bounds
float viewTop = scrollY;
float viewBot = scrollY + ImGui::GetWindowHeight();
// Render only the current page slice
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<int>(i));
ImGui::PushID(static_cast<int>(i));
ImVec2 rowPos = ImGui::GetCursorScreenPos();
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
// Determine type info
ImU32 iconCol;
const char* typeStr;
if (tx.display_type == "shield") {
iconCol = Primary(); typeStr = "Shielded";
} else if (tx.display_type == "receive") {
iconCol = greenCol; typeStr = "Recv";
} else if (tx.display_type == "send") {
iconCol = redCol; typeStr = "Sent";
} else if (tx.display_type == "immature") {
iconCol = Warning(); typeStr = "Immature";
} else {
iconCol = goldCol; typeStr = "Mined";
}
// 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, 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 = "Pending"; statusCol = Warning();
} else if (tx.confirmations < 10) {
snprintf(buf, sizeof(buf), "%d conf", tx.confirmations);
statusStr = buf; statusCol = Warning();
} else if (tx.confirmations >= 100 && (tx.display_type == "generate" || tx.display_type == "mined")) {
statusStr = "Mature"; statusCol = greenCol;
} else {
statusStr = "Confirmed"; statusCol = WithAlpha(Success(), 140);
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
float statusX = amtX - sSz.x - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
// Background pill
ImU32 pillBg = (statusCol & 0x00FFFFFFu) | (static_cast<ImU32>(30) << 24);
ImVec2 pillMin(statusX - Layout::spacingSm(), cy + body2->LegacySize + 1);
ImVec2 pillMax(statusX + 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(statusX, 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<int>(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("Copy Address") && !tx.address.empty()) {
ImGui::SetClipboardText(tx.address.c_str());
}
if (ImGui::MenuItem("Copy TxID")) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::Separator();
if (ImGui::MenuItem("View on Explorer")) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif __APPLE__
std::string cmd = "open \"" + url + "\"";
system(cmd.c_str());
#else
std::string cmd = "xdg-open \"" + url + "\" &";
system(cmd.c_str());
#endif
}
if (ImGui::MenuItem("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(), "FROM");
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" ? "TO" : (tx.display_type == "shield" ? "SHIELDED TO" : "ADDRESS"));
ImGui::TextWrapped("%s", tx.address.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// TxID (full, copyable)
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "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(), "MEMO");
ImGui::TextWrapped("%s", tx.memo.c_str());
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
}
// Confirmations + time
snprintf(buf, sizeof(buf), "%d confirmations | %s",
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("Copy TxID##detail", S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.txid.c_str());
}
ImGui::SameLine();
if (!tx.address.empty() && TactileSmallButton("Copy Address##detail", S.resolveFont("button"))) {
ImGui::SetClipboardText(tx.address.c_str());
}
ImGui::SameLine();
if (TactileSmallButton("Explorer##detail", S.resolveFont("button"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif __APPLE__
std::string cmd2 = "open \"" + url + "\"";
system(cmd2.c_str());
#else
std::string cmd2 = "xdg-open \"" + url + "\" &";
system(cmd2.c_str());
#endif
}
ImGui::SameLine();
if (TactileSmallButton("Full Details##detail", 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), "Showing %d\xe2\x80\x93%d of %d transactions (total: %zu)",
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