Files
ObsidianDragon/src/ui/windows/balance_components.cpp
T
DanSandClaude Opus 4.8 bcfc324426 feat(balance): labeled "Pending +X" chip with tooltip in more layouts
The unconfirmed balance was shown in only one layout as an unlabeled "+X" that
read as a mystery. It's now a labeled "Pending +X" chip with an "awaiting
confirmations" tooltip — the Classic card's badge is relabeled, and the shared
compact hero (Dashboard + Two-row layouts) now draws it too. (The remaining
hand-drawn layouts can reuse the same pattern as a follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 13:07:43 -05:00

1128 lines
60 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "balance_components.h"
#include "balance_lerp.h"
#include "balance_tab_helpers.h"
#include "balance_address_list.h"
#include "balance_recent_tx.h"
#include "key_export_dialog.h"
#include "qr_popup_dialog.h"
#include "address_label_dialog.h"
#include "address_transfer_dialog.h"
#include "mining_tab_helpers.h"
#include "send_tab.h"
#include "transactions_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../theme.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
#include "../effects/imgui_acrylic.h"
#include "../sidebar.h"
#include "../notifications.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <algorithm>
#include <cstring>
#include <cstdio>
#include <ctime>
#include <chrono>
#include "../../data/earnings_series.h"
#include <cmath>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
void UpdateBalanceLerp(App* app) {
using namespace material;
const auto& S = schema::UISchema::instance();
const float kBalanceLerpSpeed = S.drawElement("tabs.balance", "balance-lerp-speed").sizeOr(8.0f);
const auto& state = app->state();
const float dt = ImGui::GetIO().DeltaTime;
const bool instant = app->settings() && app->settings()->getReduceMotion();
StepDisplayLerp(s_dispTotal, app->getTotalBalance(), dt, kBalanceLerpSpeed, instant);
StepDisplayLerp(s_dispShielded, app->getShieldedBalance(), dt, kBalanceLerpSpeed, instant);
StepDisplayLerp(s_dispTransparent, app->getTransparentBalance(), dt, kBalanceLerpSpeed, instant);
StepDisplayLerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, kBalanceLerpSpeed, instant);
}
// Total period-earnings for the whole wallet, formatted per the earnings settings. Only shown when the
// global earnings toggle is on, so the default hero/layout appearance is unchanged. Own revision-keyed
// cache (independent of the per-row one in RenderSharedAddressList).
bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol) {
using namespace material;
if (!app->settings() || !app->settings()->getEarningsShowAll()) return false;
auto& S = schema::UI();
const auto& state = app->state();
auto* est = app->settings();
static dragonx::data::EarningsCache s_totalEarnCache;
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const int fmt = est->getEarningsFormat();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
const auto& r = s_totalEarnCache.get(ek, state.transactions, nowSec, nowMs);
const double ev = r.total;
const double fiat = ev * state.market.price_usd;
const bool haveFiat = state.market.price_usd > 0.0;
const int f = haveFiat ? fmt : 0;
char buf[64];
if (f == 0) snprintf(buf, sizeof(buf), "%+.4f", ev);
else if (f == 2) snprintf(buf, sizeof(buf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(buf, sizeof(buf), "%+.4f ($%.2f)", ev, fiat);
outText = buf;
outCol = ev > 1e-12 ? S.resolveColor("var(--accent-shielded)", Success())
: (ev < -1e-12 ? S.resolveColor("var(--accent-error)", ReadableError())
: OnSurfaceDisabled());
return true;
}
// Render compact hero line: logo + balance + USD + mining on one line
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
using namespace material;
char buf[64];
const float dp = Layout::dpiScale();
// Coin logo
ImTextureID logoTex = app->getCoinLogoTexture();
ImFont* sub1 = Type().subtitle1();
float logoSz = sub1->LegacySize + 4.0f * dp;
float lineH = (heroHeightOverride >= 0.0f) ? heroHeightOverride : logoSz;
if (logoTex != 0) {
ImVec2 pos = ImGui::GetCursorScreenPos();
dl->AddImage(logoTex,
ImVec2(pos.x, pos.y),
ImVec2(pos.x + lineH, pos.y + lineH),
ImVec2(0, 0), ImVec2(1, 1), IM_COL32(255, 255, 255, 255));
ImGui::Dummy(ImVec2(lineH + Layout::spacingSm(), lineH));
ImGui::SameLine();
}
// Total balance
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
ImVec2 pos = ImGui::GetCursorScreenPos();
float fontSize = (heroHeightOverride >= 0.0f) ? heroHeightOverride : sub1->LegacySize;
DrawTextShadow(dl, sub1, fontSize, pos, OnSurface(), buf);
ImVec2 sz = sub1->CalcTextSizeA(fontSize, 10000.0f, 0.0f, buf);
ImGui::Dummy(ImVec2(sz.x, lineH));
ImGui::SameLine();
// Ticker
ImFont* capFont = Type().caption();
float tickerFontSize = (heroHeightOverride >= 0.0f) ? heroHeightOverride * 0.6f : capFont->LegacySize;
float tickerY = pos.y + lineH - tickerFontSize;
dl->AddText(capFont, tickerFontSize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
OnSurfaceMedium(), DRAGONX_TICKER);
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
ImGui::SameLine();
// Pending (unconfirmed) chip — so these layouts show it too, labeled, not just the Classic card.
if (s_dispUnconfirmed > 1e-9) {
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
ImVec2 pp = ImGui::GetCursorScreenPos();
dl->AddText(capFont, tickerFontSize, ImVec2(pp.x, tickerY), WithAlpha(Warning(), 230), buf);
float pw = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, buf).x;
if (material::IsRectHovered(ImVec2(pp.x, tickerY), ImVec2(pp.x + pw, tickerY + tickerFontSize)))
material::Tooltip("%s", TR("baltab_pending_tip"));
ImGui::Dummy(ImVec2(pw + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
// Total period-earnings (left of the USD) when the global earnings toggle is on.
{
std::string earnText; ImU32 earnCol;
if (FormatTotalEarnings(app, earnText, earnCol)) {
dl->AddText(capFont, tickerFontSize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY), earnCol, earnText.c_str());
float ew = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, earnText.c_str()).x;
ImGui::Dummy(ImVec2(ew + Layout::spacingLg(), lineH));
ImGui::SameLine();
}
}
// USD value
const auto& state = app->state();
double usd_value = state.getBalanceUSD();
if (usd_value > 0.0)
snprintf(buf, sizeof(buf), "$%.2f", usd_value);
else
snprintf(buf, sizeof(buf), "$--.--");
dl->AddText(capFont, tickerFontSize,
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
OnSurfaceDisabled(), buf);
ImGui::NewLine();
}
// Render the shared address list section (used by all layouts)
void RenderSharedAddressList(App* app, float listH, float availW,
float glassRound, float hs, float vs, float reserveBelow) {
using namespace material;
const auto& S = schema::UISchema::instance();
const float dp = Layout::dpiScale();
const auto addrBtn = S.button("tabs.balance", "address-button");
const auto actionBtn = S.button("tabs.balance", "action-button");
const auto searchIn = S.input("tabs.balance", "search-input");
const auto addrTable = S.table("tabs.balance", "address-table");
const auto& state = app->state();
Type().text(TypeStyle::H6, TR("your_addresses"));
ImGui::Spacing();
// ---- Persistent state ----
static char addr_search[128] = "";
static bool s_hideZeroBalances = true;
static bool s_showHidden = false;
// Drag state
static int s_dragIdx = -1; // row being dragged (-1 = none)
static float s_dragOffsetY = 0.0f; // mouse offset from row top
static float s_dragStartY = 0.0f; // mouse Y at drag start
static bool s_dragActive = false; // drag distance threshold passed
static int s_dropTargetIdx = -1; // row hovered during drag
static int s_dropMode = 0; // 0 = transfer (released over a row's CENTER), 1 = reorder
// (released over a row's top/bottom EDGE). Disambiguates the
// two drag gestures by WHERE on the target row you release.
// Copy feedback
static int s_copiedRow = -1;
static float s_copiedTime = 0.0f;
static int s_ctxRow = -1; // row whose context popup is open: always submitted (clipper must not drop it)
// ---- Build and filter address rows ----
std::vector<AddressListInput> rowInputs;
rowInputs.reserve(state.z_addresses.size() + state.t_addresses.size());
auto addRows = [&](const auto& addrs, bool isZ) {
for (const auto& a : addrs) {
std::string addrLabel = app->getAddressLabel(a.address);
bool isHidden = app->isAddressHidden(a.address);
bool isFav = app->isAddressFavorite(a.address);
bool isMining = app->isMiningAddress(a.address);
rowInputs.push_back({&a, isZ, isHidden, isFav, isMining,
addrLabel, app->getAddressIcon(a.address),
app->getAddressSortOrder(a.address)});
}
};
addRows(state.z_addresses, true);
addRows(state.t_addresses, false);
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
// ---- Inline period-earnings: compute once (cached on tx_revision + interval/mode/period) ----
static dragonx::data::EarningsCache s_earnCache;
const dragonx::data::EarningsResult* earnResult = nullptr;
bool earnShowAll = false;
int earnFormat = 1; // 0=coin 1=coin+fiat 2=fiat
if (app->settings()) {
auto* est = app->settings();
earnShowAll = est->getEarningsShowAll();
earnFormat = est->getEarningsFormat();
auto iv = (dragonx::data::EarningsInterval)est->getEarningsInterval();
auto md = (dragonx::data::EarningsMode)est->getEarningsMode();
const std::int64_t nowSec = (std::int64_t)std::time(nullptr);
const std::int64_t nowMs = (std::int64_t)std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
dragonx::data::EarningsKey ek;
ek.txRevision = state.tx_revision;
ek.txCount = state.transactions.size();
ek.interval = iv;
ek.mode = md;
ek.periodStart = dragonx::data::earningsPeriodStart(nowSec, iv);
earnResult = &s_earnCache.get(ek, state.transactions, nowSec, nowMs);
}
// ---- Toolbar: search + checkboxes + create buttons ----
float avail = ImGui::GetContentRegionAvail().x;
float schemaMaxW = (searchIn.maxWidth >= 0) ? searchIn.maxWidth : 250.0f;
float schemaRatio = (searchIn.widthRatio >= 0) ? searchIn.widthRatio : 0.30f;
float searchW = std::min(schemaMaxW * hs, avail * schemaRatio);
ImGui::SetNextItemWidth(searchW);
ImGui::InputTextWithHint("##AddrSearch", TR("filter"), addr_search, sizeof(addr_search));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("hide_zero_balances", "hide0_v2").c_str(), &s_hideZeroBalances);
{
int hc = app->getHiddenAddressCount();
if (hc > 0) {
ImGui::SameLine(0, Layout::spacingLg());
char hlbl[64];
snprintf(hlbl, sizeof(hlbl), TR("show_hidden"), hc);
ImGui::Checkbox(hlbl, &s_showHidden);
} else {
s_showHidden = false;
}
}
// ---- Inline period-earnings controls: interval (1H/1D/1M/1Y) + mode (received / net) + a global
// show-all toggle, all drawn as Material segmented controls to match the Receive-tab filter. The
// create-address buttons moved below the list (footer) to make room. ----
if (app->settings()) {
auto* est = app->settings();
ImFont* capF = Type().caption();
ImFont* icoF = Type().iconSmall();
ImDrawList* wdl = ImGui::GetWindowDrawList();
const float segH = ImGui::GetFrameHeight();
ImGui::SameLine(0, Layout::spacingLg());
const float rowStartX = ImGui::GetCursorScreenPos().x;
const float rowY = ImGui::GetCursorScreenPos().y;
float x = rowStartX;
// Interval (1H/1D/1M/1Y) — text segments sized to the widest label, like the Receive filter.
const char* ivText[4] = { "1H", "1D", "1M", "1Y" };
float ivCellW = segH;
for (const char* t : ivText)
ivCellW = std::max(ivCellW, capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, t).x + 2.0f * Layout::spacingMd());
const float ivTotalW = ivCellW * 4;
int iv = est->getEarningsInterval();
int ivClk = material::SegmentedControl(wdl, ImVec2(x, rowY), ivTotalW, segH, ivText, 4, iv, capF, "##earniv", dp);
if (ivClk >= 0) { est->setEarningsInterval(ivClk); est->save(); }
if (ImGui::IsMouseHoveringRect(ImVec2(x, rowY), ImVec2(x + ivTotalW, rowY + segH)))
material::Tooltip("%s", TR("tt_earn_interval"));
x += ivTotalW + Layout::spacingMd();
// Mode (received-only vs net) — two icon segments.
const char* modeIcons[2] = { ICON_MD_CALL_RECEIVED, ICON_MD_SWAP_VERT };
float modeCellW = segH;
for (const char* t : modeIcons)
modeCellW = std::max(modeCellW, icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, t).x + 2.0f * Layout::spacingSm());
const float modeTotalW = modeCellW * 2;
int emode = est->getEarningsMode();
int modeClk = material::SegmentedControl(wdl, ImVec2(x, rowY), modeTotalW, segH, modeIcons, 2, emode, icoF, "##earnmode", dp);
if (modeClk >= 0) { est->setEarningsMode(modeClk); est->save(); }
for (int i = 0; i < 2; ++i) {
ImVec2 cMin(x + i * modeCellW, rowY), cMax(cMin.x + modeCellW, rowY + segH);
if (ImGui::IsMouseHoveringRect(cMin, cMax))
material::Tooltip("%s", i == 0 ? TR("tt_earn_mode_inflows") : TR("tt_earn_mode_net"));
}
x += modeTotalW + Layout::spacingMd();
// Global earnings-column toggle — a single matching segment (highlighted when on).
const char* togIcon[1] = { ICON_MD_TRENDING_UP };
float togW = std::max(segH, icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, togIcon[0]).x + 2.0f * Layout::spacingSm());
bool showAll = est->getEarningsShowAll();
int togClk = material::SegmentedControl(wdl, ImVec2(x, rowY), togW, segH, togIcon, 1, showAll ? 0 : -1, icoF, "##earnshowall", dp);
if (togClk == 0) { est->setEarningsShowAll(!showAll); est->save(); }
if (ImGui::IsMouseHoveringRect(ImVec2(x, rowY), ImVec2(x + togW, rowY + segH)))
material::Tooltip("%s", TR("tt_earn_show_all"));
x += togW;
// Reserve the controls' row as one layout item so the cursor drops below it (the segmented
// controls draw absolutely + hit-test with their own InvisibleButtons).
ImGui::SetCursorScreenPos(ImVec2(rowStartX, rowY));
ImGui::Dummy(ImVec2(x - rowStartX, segH));
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// ---- Glass panel container ----
float addrListH = listH;
// Cap the card to the space that actually remains here (measured AFTER the title + toolbar are laid
// out, so no chrome modelling is needed) minus what the caller reserves for the section below it
// (recent-tx). Without this, a fixed dp-scaled listH grows ~1.5x at high font scale and evicts the
// Recent Transactions list off the bottom of the fixed, non-scrolling tab host.
if (reserveBelow > 0.0f) {
float maxH = ImGui::GetContentRegionAvail().y - reserveBelow;
if (maxH < addrListH) addrListH = maxH;
}
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
ImVec2 listPanelMin = ImGui::GetCursorScreenPos();
ImVec2 listPanelMax(listPanelMin.x + availW, listPanelMin.y + addrListH);
GlassPanelSpec addrGlassSpec;
addrGlassSpec.rounding = glassRound;
DrawGlassPanel(dlPanel, listPanelMin, listPanelMax, addrGlassSpec);
float addrScrollY = 0.0f, addrScrollMaxY = 0.0f;
int addrParentVtx = dlPanel->VtxBuffer.Size;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingLg(), Layout::spacingSm()));
ImGui::BeginChild("AddressList", ImVec2(availW, addrListH), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* addrChildDL = ImGui::GetWindowDrawList();
int addrChildVtx = addrChildDL->VtxBuffer.Size;
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Empty states ----
if (!app->isConnected()) {
ImGui::Dummy(ImVec2(0, 16 * dp));
float cw = ImGui::GetContentRegionAvail().x;
ImVec2 ts = ImGui::CalcTextSize(TR("not_connected"));
ImGui::SetCursorPosX((cw - ts.x) * 0.5f);
ImGui::TextDisabled("%s", TR("not_connected"));
} else if (rows.empty()) {
float cw = ImGui::GetContentRegionAvail().x;
float ch = ImGui::GetContentRegionAvail().y;
if (ch < 60.0f * dp) ch = 60.0f * dp;
const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet");
ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg);
ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ch * 0.25f);
ImGui::TextDisabled("%s", emptyMsg);
} else {
// ---- PASS 1: Compute row layout ----
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd() + Layout::spacingMd();
static int selected_row = -1;
addrScrollY = ImGui::GetScrollY();
addrScrollMaxY = ImGui::GetScrollMaxY();
ImU32 greenCol = S.resolveColor("var(--accent-shielded)", Success());
ImU32 goldCol = S.resolveColor("var(--accent-transparent)", Warning());
ImU32 earnNegCol = S.resolveColor("var(--accent-error)", material::ReadableError()); // negative net earnings
float rowPadLeft = Layout::cardInnerPadding();
float rowPadRight = Layout::cardInnerPadding();
float rowIconSz = std::max(S.drawElement("tabs.balance", "address-icon-min-size").size,
S.drawElement("tabs.balance", "address-icon-size").size * hs);
float innerW = ImGui::GetContentRegionAvail().x;
// Theme colors for buttons (resolved once)
// User-adjustable opacity for the favorite gold visuals (Settings > Appearance). 1.0 =
// theme default (unchanged); scales whatever alpha the skin resolved, so it is skin-agnostic.
const float favOp = app->settings() ? app->settings()->getFavoriteOpacity() : 1.0f;
ImU32 favGoldFill = ScaleAlpha(S.resolveColor("var(--favorite-fill)", IM_COL32(255, 200, 50, 40)), favOp);
ImU32 favGoldBorder = ScaleAlpha(S.resolveColor("var(--favorite-border)", IM_COL32(255, 200, 50, 100)), favOp);
ImU32 favGoldIcon = ScaleAlpha(S.resolveColor("var(--favorite-icon)", IM_COL32(255, 200, 50, 255)), favOp);
ImU32 favGoldOutline= ScaleAlpha(S.resolveColor("var(--favorite-outline)", IM_COL32(255, 200, 50, 120)), favOp);
ImU32 btnFill = S.resolveColor("var(--row-button-fill)", IM_COL32(255, 255, 255, 12));
ImU32 btnFillHov = S.resolveColor("var(--row-button-fill-hover)", IM_COL32(255, 255, 255, 25));
ImU32 btnBorder = S.resolveColor("var(--row-button-border)", IM_COL32(255, 255, 255, 25));
ImU32 btnBorderHov = S.resolveColor("var(--row-button-border-hover)", IM_COL32(255, 255, 255, 50));
ImU32 rowHoverCol = S.resolveColor("var(--row-hover)", IM_COL32(255, 255, 255, 15));
ImU32 rowSelectCol = S.resolveColor("var(--row-select)", IM_COL32(255, 255, 255, 20));
ImU32 dividerCol = S.resolveColor("var(--sidebar-divider)", IM_COL32(255, 255, 255, 15));
// Row i's top is a pure function of its index (fixed pitch rowH): no per-frame table.
const float cursorStartY = ImGui::GetCursorScreenPos().y;
const float rowLineX = ImGui::GetCursorScreenPos().x; // every row's x (line start)
const float rowItemSpacingY = ImGui::GetStyle().ItemSpacing.y;
auto rowTop = [cursorStartY, rowH](int i) { return cursorStartY + i * rowH; };
// Drag logic — detect drag start, compute drag position
ImVec2 mousePos = ImGui::GetMousePos();
bool mouseDown = ImGui::IsMouseDown(ImGuiMouseButton_Left);
bool mouseClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
// Reset drop target each frame — it gets set only if mouse is over a row.
// Preserve the drop target on the release frame so the drop handler can use it.
if (s_dragActive && mouseDown) s_dropTargetIdx = -1;
if (s_dragIdx >= 0 && !mouseDown) {
// Mouse released — copy if it was a click (no drag activated)
if (!s_dragActive && s_dragIdx >= 0 && s_dragIdx < (int)rows.size()) {
const auto& clickRow = rows[s_dragIdx];
ImGui::SetClipboardText(clickRow.info->address.c_str());
selected_row = s_dragIdx;
s_copiedRow = s_dragIdx;
s_copiedTime = (float)ImGui::GetTime();
}
// Drop. Released over a row's CENTER (transfer mode) and that row has funds -> transfer;
// released over an EDGE, in a gap, or on a zero-balance row -> reorder.
if (s_dragActive) {
bool didTransfer = false;
if (s_dropMode == 0 && s_dropTargetIdx >= 0 && s_dropTargetIdx != s_dragIdx &&
s_dragIdx < (int)rows.size()) {
const auto& srcRow = rows[s_dragIdx];
const auto& dstRow = rows[s_dropTargetIdx];
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
AddressTransferDialog::TransferInfo ti;
ti.fromAddr = srcRow.info->address;
ti.toAddr = dstRow.info->address;
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
ti.toBalance = dstRow.info->balance; // destination display only
ti.fromIsZ = srcRow.isZ;
ti.toIsZ = dstRow.isZ;
AddressTransferDialog::show(app, ti);
didTransfer = true;
}
}
if (!didTransfer) {
// Reorder — compute insert position from mouseY.
int insertIdx = 0;
for (int i = 0; i < (int)rows.size(); ++i) {
if (mousePos.y > rowTop(i) + rowH * 0.5f) insertIdx = i + 1;
}
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1 &&
s_dragIdx < (int)rows.size()) {
// Build the new visible order (move the dragged row to insertIdx) and
// persist dense sort orders 0..N-1 in one shot. This works even when the
// rows were still at the default order (a pairwise swap would no-op).
std::vector<std::string> newOrder;
newOrder.reserve(rows.size());
for (int i = 0; i < (int)rows.size(); ++i)
if (i != s_dragIdx) newOrder.push_back(rows[i].info->address);
int clamped = std::min(insertIdx, (int)rows.size());
int destIdx = (insertIdx > s_dragIdx) ? clamped - 1 : clamped;
destIdx = std::max(0, std::min(destIdx, (int)newOrder.size()));
newOrder.insert(newOrder.begin() + destIdx, rows[s_dragIdx].info->address);
app->reorderAddresses(newOrder);
}
}
}
s_dragIdx = -1;
s_dragActive = false;
s_dropTargetIdx = -1;
s_dropMode = 0;
}
// ---- PASS 2: Render rows (visible rows only) ----
// ImGuiListClipper submits only rows that intersect the child's clip rect (and, itself, the rows of an
// ImGui nav request / the nav-focused row, so keyboard nav keeps working). Always submitted on top:
// - one guard row on each side of the visible window (a row draws its divider on the next row's top);
// - the row being dragged (its elevation + tooltip follow the mouse; its drag threshold is tested in
// its own body) and the row whose context popup is open (the popup exists only while submitted).
const int rowCount = (int)rows.size();
ImGuiListClipper rowClipper;
rowClipper.Begin(rowCount, rowH);
{
const AddressRowWindow guard = ComputeAddressRowWindow(dl->GetClipRectMin().y, dl->GetClipRectMax().y,
cursorStartY, rowH, rowCount);
rowClipper.IncludeItemsByIndex(guard.begin, guard.end);
}
if (s_dragIdx >= 0 && s_dragIdx < rowCount) rowClipper.IncludeItemByIndex(s_dragIdx);
if (s_ctxRow >= rowCount) s_ctxRow = -1;
if (s_ctxRow >= 0) rowClipper.IncludeItemByIndex(s_ctxRow);
while (rowClipper.Step()) {
for (int row_idx = rowClipper.DisplayStart; row_idx < rowClipper.DisplayEnd; row_idx++) {
// Replay the cursor the unclipped loop had here: the Layout::spacing*() tokens in this body read
// GetContentRegionAvail().y (densityScale), so every row keeps exactly its old geometry.
ImGui::SetCursorScreenPos(ImVec2(rowLineX,
AddressRowEntryCursorY(row_idx, cursorStartY, rowH, rowItemSpacingY)));
const auto& row = rows[row_idx];
const auto& addr = *row.info;
bool isDragged = (s_dragIdx == row_idx && s_dragActive);
// For dragged row, offset Y to follow mouse
float drawY = rowTop(row_idx);
if (isDragged) {
drawY = mousePos.y - s_dragOffsetY;
}
ImVec2 rowPos(ImGui::GetCursorScreenPos().x, drawY);
ImVec2 rowEnd(rowPos.x + innerW, drawY + rowH);
ImU32 typeCol = row.isZ ? greenCol : goldCol;
if (row.hidden) typeCol = OnSurfaceDisabled();
// Dragged row: draw with semi-transparent elevation
if (isDragged) {
dl->AddRectFilled(ImVec2(rowPos.x - 2*dp, rowPos.y - 2*dp),
ImVec2(rowEnd.x + 2*dp, rowEnd.y + 2*dp),
IM_COL32(0, 0, 0, 30), 6.0f * dp);
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(30, 30, 40, 120), 4.0f * dp);
// Tooltip following cursor — reflect the gesture: centre of a row = transfer to it,
// edge of a row = reorder (move here).
if (s_dropTargetIdx >= 0 && s_dropTargetIdx < (int)rows.size() && s_dropMode == 0) {
const auto& target = rows[s_dropTargetIdx];
material::Tooltip("%s\n%s\n\n%s %s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("addr_type_mining"),
TR("transfer_to"),
truncateAddress(target.info->address, 32).c_str());
} else {
material::Tooltip("%s\n%s\n\n%s",
truncateAddress(addr.address, 32).c_str(),
row.isZ ? TR("shielded") : TR("addr_type_mining"),
TR("address_reorder_hint"));
}
}
// Golden border for favorites — inset so it doesn't touch container
if (row.favorite) {
float inset = 2.0f * dp;
dl->AddRect(ImVec2(rowPos.x + inset, rowPos.y + inset),
ImVec2(rowEnd.x - inset, rowEnd.y - inset),
favGoldOutline, 4.0f * dp, 0, 1.5f * dp);
}
// Selection highlight
if (selected_row == row_idx && !isDragged) {
ImDrawFlags accentFlags = 0;
float accentRound = 2.0f * dp;
if (row_idx == 0) { accentFlags = ImDrawFlags_RoundCornersTopLeft; accentRound = glassRound; }
if (row_idx == (int)rows.size() - 1) { accentFlags |= ImDrawFlags_RoundCornersBottomLeft; accentRound = glassRound; }
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + 3 * dp, rowEnd.y), typeCol, accentRound, accentFlags);
dl->AddRectFilled(rowPos, rowEnd, rowSelectCol, 4.0f * dp);
}
// Hover effects
bool hovered = !isDragged && material::IsRectHovered(rowPos, rowEnd);
// Drop target feedback when dragging another row over this one. The cursor's vertical
// position WITHIN the row picks the gesture: top/bottom edge = reorder (show an insertion
// line), centre = transfer (highlight the whole row).
if (s_dragActive && s_dragIdx != row_idx && material::IsRectHovered(rowPos, rowEnd)) {
s_dropTargetIdx = row_idx;
const float relY = rowH > 0.0f ? (mousePos.y - rowPos.y) / rowH : 0.5f;
// A mining (transparent) address can't be paid, so it is never a transfer target: reorder only.
s_dropMode = (!row.isZ || relY < 0.30f || relY > 0.70f) ? 1 : 0;
if (s_dropMode == 0) {
// Transfer: highlight the whole target row.
dl->AddRectFilled(rowPos, rowEnd, WithAlpha(Primary(), 40), 4.0f * dp);
dl->AddRect(rowPos, rowEnd, WithAlpha(Primary(), 140), 4.0f * dp, 0, 2.0f * dp);
} else {
// Reorder: draw an insertion line at the near (top or bottom) edge.
const float lineY = (relY < 0.5f) ? rowPos.y : rowEnd.y;
dl->AddLine(ImVec2(rowPos.x, lineY), ImVec2(rowEnd.x, lineY),
WithAlpha(Primary(), 230), 2.5f * dp);
dl->AddCircleFilled(ImVec2(rowPos.x, lineY), 3.0f * dp, WithAlpha(Primary(), 230));
}
}
if (hovered && selected_row != row_idx && !s_dragActive) {
dl->AddRectFilled(rowPos, rowEnd, rowHoverCol, 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
auto rowLayout = ComputeAddressRowLayout(
rowPos.x, rowPos.y, innerW, rowH, rowPadLeft, rowIconSz,
Layout::spacingMd(), Layout::spacingSm(), Layout::spacingXs());
float cx = rowLayout.contentStartX;
float cy = rowLayout.contentStartY;
// ---- Button zone (right edge): [eye] [star] ----
float btnRound = 6.0f * dp;
bool btnClicked = false;
if (!isDragged) {
// Star button
{
const auto& starRect = rowLayout.favoriteButton;
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
// material::IsRectHovered so the button inherits overlay/popup input blocking
bool bHov = material::IsRectHovered(bMin, bMax);
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
ImFont* iconFont = Type().iconSmall();
const char* starIcon = row.favorite ? ICON_MD_STAR : ICON_MD_STAR_BORDER;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon);
ImU32 starCol = row.favorite ? favGoldIcon : (bHov ? OnSurface() : OnSurfaceDisabled());
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(starRect.x + (starRect.width - iSz.x) * 0.5f,
starRect.y + (starRect.height - iSz.y) * 0.5f), starCol, starIcon);
if (bHov && mouseClicked) {
if (row.favorite) app->unfavoriteAddress(addr.address);
else app->favoriteAddress(addr.address);
btnClicked = true;
}
if (bHov) material::Tooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address"));
}
// Eye button (zero balance or hidden)
bool showEye = true;
// Always reserve space for both buttons so content doesn't shift
float contentRight = rowLayout.contentRight;
if (showEye) {
const auto& eyeRect = rowLayout.visibilityButton;
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
// material::IsRectHovered so the button inherits overlay/popup input blocking
bool bHov = material::IsRectHovered(bMin, bMax);
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
ImFont* iconFont = Type().iconSmall();
const char* hideIcon = row.hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon);
ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled();
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(eyeRect.x + (eyeRect.width - iSz.x) * 0.5f,
eyeRect.y + (eyeRect.height - iSz.y) * 0.5f), iconCol, hideIcon);
if (bHov && mouseClicked) {
if (row.hidden) app->unhideAddress(addr.address);
else app->hideAddress(addr.address);
btnClicked = true;
}
if (bHov) material::Tooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address"));
}
// ---- Type icon or custom icon ----
float iconCx = cx + rowIconSz;
float iconCy = cy + body2->LegacySize * 0.5f;
{
ImFont* iconFont = Type().iconSmall();
bool drewCustom = false;
if (!row.icon.empty()) {
drewCustom = AddressLabelDialog::drawIconByName(
dl, row.icon, ImVec2(iconCx, iconCy), iconFont->LegacySize,
OnSurfaceMedium(), iconFont, iconFont->LegacySize);
}
if (!drewCustom) {
const char* glyph = row.isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, glyph);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), typeCol, glyph);
}
}
// ---- Type label (first line) ----
float labelX = rowLayout.labelX;
{
const char* typeLabel = row.isZ ? TR("shielded") : TR("addr_type_mining");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
// A transparent row is already typed "Mining"; the tag is for shielded payout addresses.
const char* miningTag = (row.mining && row.isZ) ? TR("mining_tag") : "";
char typeBuf[64];
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
// User label next to type — clip to the gap before the right-aligned
// balance so a long custom label can't overrun the balance on this line
// (mirrors the address-line width guard below).
if (!row.label.empty()) {
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
float userLabelX = labelX + typeLabelW + Layout::spacingLg();
// Balance is drawn right-aligned at contentRight; recompute its left edge here.
char balBufPeek[32];
snprintf(balBufPeek, sizeof(balBufPeek), "%.8f", addr.balance);
float balW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBufPeek).x;
float userLabelAvailW = (contentRight - balW - Layout::spacingMd()) - userLabelX;
std::string userLabel = material::TruncateToWidth(
row.label, capFont, capFont->LegacySize, userLabelAvailW);
if (userLabelAvailW > 0.0f) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(userLabelX, cy),
OnSurfaceMedium(), userLabel.c_str());
}
}
}
// ---- Line-2 right content widths: USD + optional period-earnings to its left ----
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
float usdW = usdText.empty() ? 0.0f
: capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str()).x;
const bool haveFiat = state.market.price_usd > 0.0;
bool earnShown = earnResult && app->settings() &&
(earnShowAll || app->settings()->isAddressEarningsShown(addr.address));
char earnBuf[48]; earnBuf[0] = '\0';
float earnW = 0.0f; ImU32 earnCol = OnSurfaceDisabled();
if (earnShown) {
double ev = dragonx::data::earningsForAddress(*earnResult, addr.address);
double fiat = ev * state.market.price_usd;
int fmt = (!haveFiat) ? 0 : earnFormat; // no price -> coin only
if (fmt == 0) snprintf(earnBuf, sizeof(earnBuf), "%+.4f", ev);
else if (fmt == 2) snprintf(earnBuf, sizeof(earnBuf), "%s$%.2f", ev < 0 ? "-" : "+", std::fabs(fiat));
else snprintf(earnBuf, sizeof(earnBuf), "%+.4f ($%.2f)", ev, fiat);
earnW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, earnBuf).x;
earnCol = ev > 1e-12 ? greenCol : (ev < -1e-12 ? earnNegCol : OnSurfaceDisabled());
if (row.hidden) earnCol = OnSurfaceDisabled();
}
// Left edge of the line-2 right content, so the address truncates before it.
const float line2RightStart = contentRight - usdW
- (earnShown && earnBuf[0] ? (earnW + Layout::spacingMd()) : 0.0f);
// ---- Address (second line) ----
{
float addrAvailW = line2RightStart - Layout::spacingMd() - labelX;
ImVec2 fullAddrSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, addr.address.c_str());
std::string display_addr;
if (fullAddrSz.x <= addrAvailW) {
display_addr = addr.address;
} else {
int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0)
? addrTable.columns.at("address").truncate : 32;
display_addr = truncateAddress(addr.address, addrTruncLen);
}
dl->AddText(capFont, capFont->LegacySize,
ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), display_addr.c_str());
}
// ---- Balance (right-aligned, first line) ----
{
char balBuf[32];
snprintf(balBuf, sizeof(balBuf), "%.8f", addr.balance);
ImVec2 balSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBuf);
float balX = contentRight - balSz.x;
ImU32 balCol = addr.balance > 0.0
? (row.isZ ? greenCol : OnSurface())
: OnSurfaceDisabled();
if (row.hidden) balCol = OnSurfaceDisabled();
DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(balX, cy), balCol, balBuf);
}
// ---- Line 2 right: period-earnings (left of) + USD value, right-aligned ----
{
const float line2Y = cy + body2->LegacySize + Layout::spacingXs();
if (earnShown && earnBuf[0]) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdW - Layout::spacingMd() - earnW, line2Y),
earnCol, earnBuf);
}
if (!usdText.empty()) {
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdW, line2Y),
OnSurfaceDisabled(), usdText.c_str());
}
}
// ---- Copy feedback flash ----
if (s_copiedRow == row_idx) {
float elapsed = (float)ImGui::GetTime() - s_copiedTime;
if (elapsed < 1.0f) {
float alpha = 1.0f - elapsed;
ImFont* capF = Type().caption();
const char* copiedTxt = TR("copied");
ImVec2 sz = capF->CalcTextSizeA(capF->LegacySize, 1000.0f, 0.0f, copiedTxt);
ImVec4 fc = ImGui::ColorConvertU32ToFloat4(Success());
fc.w = alpha;
float tx = rowPos.x + (innerW - sz.x) * 0.5f;
float ty = rowPos.y + (rowH - sz.y) * 0.5f;
dl->AddText(capF, capF->LegacySize, ImVec2(tx, ty),
ImGui::ColorConvertFloat4ToU32(fc), copiedTxt);
} else {
s_copiedRow = -1;
}
}
// ---- Click: begin drag tracking (copy deferred to mouse-up) ----
if (hovered && mouseClicked && !btnClicked) {
s_dragIdx = row_idx;
s_dragOffsetY = mousePos.y - rowPos.y;
s_dragStartY = mousePos.y;
s_dragActive = false;
}
// Activate drag after a small threshold
if (s_dragIdx == row_idx && !s_dragActive && mouseDown) {
if (std::abs(mousePos.y - s_dragStartY) > 6.0f * dp) {
s_dragActive = true;
s_dropTargetIdx = -1;
}
}
} // end !isDragged
// Advance cursor for interaction area
ImGui::PushID(row_idx);
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowTop(row_idx)));
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
if (ImGui::IsItemHovered() && !s_dragActive) {
if (row.isZ) material::Tooltip("%s", addr.address.c_str());
else material::Tooltip("%s\n\n%s", addr.address.c_str(), TR("mining_addr_explainer"));
}
// Context menu
const auto& acrTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("AddressContext", 0, acrTheme.menu)) {
s_ctxRow = row_idx;
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(addr.address.c_str());
s_copiedRow = row_idx;
s_copiedTime = (float)ImGui::GetTime();
}
if (ImGui::MenuItem(TR("send_from_this_address"))) {
SetSendFromAddress(addr.address);
app->setCurrentPage(NavPage::Send);
}
if (ImGui::MenuItem(TR("view_transactions"))) {
SetTransactionsAddressFilter(addr.address);
app->setCurrentPage(NavPage::History);
}
ImGui::Separator();
if (ImGui::MenuItem(TR("set_label"))) {
AddressLabelDialog::show(app, addr.address, row.isZ);
}
if (ImGui::MenuItem(row.mining ? TR("unmark_mining_address") : TR("mark_mining_address"))) {
app->setMiningAddress(addr.address, !row.mining);
}
if (ImGui::MenuItem(TR("export_private_key")))
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private);
if (row.isZ) {
if (ImGui::MenuItem(TR("export_viewing_key")))
KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Viewing);
}
if (ImGui::MenuItem(TR("show_qr_code")))
QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("mining_address"));
ImGui::Separator();
if (row.hidden) {
if (ImGui::MenuItem(TR("restore_address")))
app->unhideAddress(addr.address);
} else if (addr.balance < 1e-9) {
if (ImGui::MenuItem(TR("hide_address")))
app->hideAddress(addr.address);
}
if (row.favorite) {
if (ImGui::MenuItem(TR("remove_favorite")))
app->unfavoriteAddress(addr.address);
} else {
if (ImGui::MenuItem(TR("favorite_address")))
app->favoriteAddress(addr.address);
}
// Per-address earnings column toggle (when the global show-all toggle is off).
if (app->settings()) {
const bool shown = app->settings()->isAddressEarningsShown(addr.address);
if (ImGui::MenuItem(shown ? TR("hide_earnings") : TR("show_earnings"))) {
if (shown) app->settings()->hideAddressEarnings(addr.address);
else app->settings()->showAddressEarnings(addr.address);
app->settings()->save();
}
}
// Portfolio membership — toggle this address in each Market-tab portfolio entry.
ImGui::Separator();
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
const auto& pf = app->settings()->getPortfolioEntries();
const std::string scopeId = app->portfolioScopeId();
int shown = 0;
for (int pi = 0; pi < (int)pf.size(); pi++) {
// Only this wallet's groups (or global / not-yet-migrated legacy), matching the Market
// summary - don't offer to add this address to another wallet's group.
if (!pf[pi].visibleInWallet(scopeId))
continue;
// PushID keeps the item ID unique even when two groups share a label (a
// label-derived MenuItem ID would collide); a blank label shows a placeholder
// instead of rendering as an invisible row.
ImGui::PushID(pi);
bool inSet = dragonx::data::PortfolioEntryContains(pf[pi].addresses, addr.address);
const char* lbl = pf[pi].label.empty() ? TR("portfolio_new_entry") : pf[pi].label.c_str();
if (ImGui::MenuItem(lbl, nullptr, inSet)) {
auto entries = pf; // copy, mutate, persist
if (inSet) dragonx::data::PortfolioEntryRemove(entries[pi].addresses, addr.address);
else dragonx::data::PortfolioEntryAdd(entries[pi].addresses, addr.address);
app->settings()->setPortfolioEntries(entries);
app->settings()->save();
}
ImGui::PopID();
shown++;
}
if (shown == 0)
ImGui::MenuItem(TR("portfolio_no_entries"), nullptr, false, false);
ImGui::EndMenu();
}
effects::ImGuiAcrylic::EndAcrylicPopup();
} else if (s_ctxRow == row_idx) {
s_ctxRow = -1; // its popup closed
}
ImGui::PopID();
// Separator between rows
if (row_idx < (int)rows.size() - 1 && selected_row != row_idx) {
float sepY = rowTop(row_idx) + rowH;
dl->AddLine(
ImVec2(rowPos.x + rowPadLeft + rowIconSz * 2.0f, sepY),
ImVec2(rowPos.x + innerW - Layout::spacingLg(), sepY),
dividerCol);
}
}
} // while (rowClipper.Step())
// Advance cursor past all rows
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
cursorStartY + (float)rows.size() * rowH));
// ---- Keyboard navigation ----
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl && !s_dragActive) {
bool nav = false;
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) || ImGui::IsKeyPressed(ImGuiKey_K)) {
if (selected_row > 0) { selected_row--; nav = true; }
else if (selected_row < 0 && !rows.empty()) { selected_row = 0; nav = true; }
}
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsKeyPressed(ImGuiKey_J)) {
if (selected_row < (int)rows.size() - 1) { selected_row++; nav = true; }
else if (selected_row < 0 && !rows.empty()) { selected_row = 0; nav = true; }
}
if (ImGui::IsKeyPressed(ImGuiKey_Enter) && selected_row >= 0 && selected_row < (int)rows.size()) {
ImGui::SetClipboardText(rows[selected_row].info->address.c_str());
s_copiedRow = selected_row;
s_copiedTime = (float)ImGui::GetTime();
}
if (ImGui::IsKeyPressed(ImGuiKey_F2) && selected_row >= 0 && selected_row < (int)rows.size()) {
const auto& r = rows[selected_row];
AddressLabelDialog::show(app, r.info->address, r.isZ);
}
(void)nav;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::EndChild();
ImGui::PopStyleVar();
// Scroll fade
{
float fadeZone = std::min(
(Type().body2()->LegacySize + Type().caption()->LegacySize +
Layout::spacingLg() + Layout::spacingMd()) * 1.2f,
addrListH * 0.18f);
ApplyScrollEdgeMask(dlPanel, addrParentVtx, addrChildDL, addrChildVtx,
listPanelMin.y, listPanelMax.y, fadeZone, addrScrollY, addrScrollMaxY);
}
// Address count + create-address buttons (relocated below the list to free the toolbar row for
// the earnings controls). Count on the left, New Z / New mining right-aligned on the same row.
{
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
char countBuf[128];
int totalAddrs = (int)(state.z_addresses.size() + state.t_addresses.size());
snprintf(countBuf, sizeof(countBuf), TR("showing_x_of_y"),
(int)rows.size(), totalAddrs);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), countBuf);
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
float totalButtonsWidth = buttonWidth * 2 + spacing;
float rightMargin = S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f);
ImGui::SameLine(std::max(ImGui::GetCursorPosX(),
ImGui::GetWindowWidth() - totalButtonsWidth - rightMargin));
bool sharedAddrSyncing = state.sync.syncing && !state.sync.isSynced();
ImGui::BeginDisabled(sharedAddrSyncing);
if (s_generating_z_address) {
char genLabel[64];
snprintf(genLabel, sizeof(genLabel), "%s%s##shared_z", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font));
} else if (TactileButton(TR("new_z_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
s_generating_z_address = true;
app->createNewZAddress([](const std::string& addr) {
s_generating_z_address = false;
DEBUG_LOGF("Created new z-address: %s\n", addr.c_str());
});
}
ImGui::SameLine();
if (TactileButton(TR("new_mining_address"), ImVec2(buttonWidth, 0),
S.resolveFont(addrBtn.font.empty() ? "button" : addrBtn.font))) {
app->createNewTAddress([](const std::string& addr) {
DEBUG_LOGF("Created new t-address: %s\n", addr.c_str());
});
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_new_mining_address"));
ImGui::EndDisabled();
}
}
// Render the shared recent transactions section (used by all layouts)
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs) {
using namespace material;
const auto& S = schema::UISchema::instance();
const float dp = Layout::dpiScale();
const auto actionBtn = S.button("tabs.balance", "action-button");
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
const auto& state = app->state();
float headerStartY = ImGui::GetCursorPosY();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
ImGui::SameLine();
if (TactileSmallButton(TR("view_all"), S.resolveFont(actionBtn.font.empty() ? "button" : actionBtn.font))) {
app->setCurrentPage(NavPage::History);
}
ImGui::Spacing();
float headerHeight = ImGui::GetCursorPosY() - headerStartY;
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
float availableListH = ImGui::GetContentRegionAvail().y;
float listH = std::max({scaledRowH, recentH, availableListH});
ImGui::BeginChild("##BalanceSharedRecentRows", ImVec2(0, listH), false,
ImGuiWindowFlags_NoBackground);
const auto& txs = state.transactions;
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
// Only draw as many rows as fully fit within the reserved section height (header + rows);
// dropping the overflow row is fine since "View All" already links to full History.
int maxRows = std::max(1, (int)((recentH - headerHeight) / rowH));
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
if (count == 0) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
} else {
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* capFont = Type().caption();
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
for (int i = 0; i < count; i++) {
const auto& tx = txs[i];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float rowY = rowPos.y + rowH * 0.5f;
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
// Start the address column past the MEASURED type-label width (plus a fixed gap) so it can
// never overlap the label — a fixed schema offset shrinks below the label width at narrow
// widths (hs < 1) and collides. Mirrors how amtX/agoSz measure their own text below.
ImVec2 typeSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.typeText.c_str());
float addrX = tx_x + typeSz.x + Layout::spacingMd();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.amountText.c_str());
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(
S.drawElement("tabs.balance", "amount-right-min-margin").size,
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2 * dp),
recentTxAmountColor(tx.type), display.amountText.c_str());
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs, rowPos.y + 2 * dp),
OnSurfaceDisabled(), display.timeText.c_str());
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
if (material::IsRectHovered(rowPos, rowEnd)) {
dl->AddRectFilled(rowPos, rowEnd, SurfaceOverlay(15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(0))
app->setCurrentPage(NavPage::History);
}
ImGui::Dummy(ImVec2(0, rowH));
}
}
ImGui::EndChild();
}
// Render sync progress bar (used by multiple layouts)
void RenderSyncBar(App* app, ImDrawList* dl, float vs) {
using namespace material;
const auto& S = schema::UISchema::instance();
const auto syncBar = S.drawElement("tabs.balance", "sync-bar");
const auto& state = app->state();
const float dp = Layout::dpiScale();
if (state.sync.syncing && state.sync.headers > 0) {
float prog = static_cast<float>(state.sync.verification_progress);
if (prog > 1.0f) prog = 1.0f;
float barH = (syncBar.height >= 0) ? syncBar.height : 3.0f;
float barW = ImGui::GetContentRegionAvail().x;
ImVec2 barPos = ImGui::GetCursorScreenPos();
dl->AddRectFilled(barPos,
ImVec2(barPos.x + barW, barPos.y + barH),
SurfaceOverlay(15), 1.0f * dp);
dl->AddRectFilled(barPos,
ImVec2(barPos.x + barW * prog, barPos.y + barH),
WithAlpha(Warning(), 200), 1.0f * dp);
ImGui::Dummy(ImVec2(barW, barH));
}
}
} // namespace ui
} // namespace dragonx