Files
ObsidianDragon/src/ui/windows/balance_components.cpp
DanS a598217975 fix(ui): cut-off/clipping — recent-tx collisions, updater note wrap, request-payment URI, console filter, icon-grid scroll
Fixes the cut-off/clipping bugs from the layout audit (all visible at the default
1280/1024 window sizes):

- Receive "Recent Received" rows: the amount collided with the relative-time
  ("+15.7500 DRGX14 days ago") and the type label touched the address at narrow
  widths. Use the shared short time format (formatTimeAgoShort, matching Overview),
  chain the amount's right edge off the measured time width, and start the address
  after the measured type-label width — so neither pair can collide.
- Daemon & xmrig updater verify-note: drawn unwrapped and clipped at the card's right
  edge; wrap it (PushTextWrapPos) within the already-reserved height.
- Request Payment: the three footer buttons shared one fixed width (clipping "Copy
  Full Address"); size each to its own label. The Payment URI overflowed a plain
  field; render it in a bordered read-only box (bounded, un-chunked).
- Console: the filter input shrank below its own placeholder (gone entirely at 1024);
  give it a min width >= the placeholder and drop the "N lines" count when the row
  can't fit both.
- Address-label "Choose Icon" grid: had NoScrollbar hiding most of the catalog with
  no cue; give it a real scrollbar.
- Overview "Recent Transactions": drop the 4th row at 1024 (it clipped off-screen) by
  capping to rows that fully fit the reserved height.
- Sidebar: reserve the unread-badge width in the nav-label centering so History/Chat
  labels no longer collide with their badge.

Verified at 1024 and 1280 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (clean). Skipped the legacy settings_window overlay footer
(dead code / removal candidate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 10:11:55 -05:00

912 lines
47 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "balance_components.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 "../../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 <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();
float dt = ImGui::GetIO().DeltaTime;
float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed;
auto lerp = [](double& disp, double target, float dt, float spd) {
double diff = target - disp;
if (std::abs(diff) < 1e-9) { disp = target; return; }
disp += diff * (double)(dt * spd);
if (std::abs(target - disp) < 1e-9) disp = target;
};
lerp(s_dispTotal, app->getTotalBalance(), dt, speed);
lerp(s_dispShielded, app->getShieldedBalance(), dt, speed);
lerp(s_dispTransparent, app->getTransparentBalance(), dt, speed);
lerp(s_dispUnconfirmed, state.unconfirmed_balance, dt, speed);
}
// 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();
// 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;
// ---- 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);
// ---- 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;
}
}
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 kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
S.drawElement("tabs.balance", "buttons-position").size * hs);
ImGui::SameLine(std::max(kMinButtonsPosition,
ImGui::GetWindowWidth() - totalButtonsWidth -
S.drawElement("tabs.balance", "button-row-right-margin").sizeOr(16.0f)));
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_t_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());
});
}
ImGui::EndDisabled();
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) ch = 60;
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());
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)
ImU32 favGoldFill = S.resolveColor("var(--favorite-fill)", IM_COL32(255, 200, 50, 40));
ImU32 favGoldBorder = S.resolveColor("var(--favorite-border)", IM_COL32(255, 200, 50, 100));
ImU32 favGoldIcon = S.resolveColor("var(--favorite-icon)", IM_COL32(255, 200, 50, 255));
ImU32 favGoldOutline= S.resolveColor("var(--favorite-outline)", IM_COL32(255, 200, 50, 120));
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));
// Compute Y positions for all rows (pass 1)
std::vector<float> rowY(rows.size());
float cursorStartY = ImGui::GetCursorScreenPos().y;
for (int i = 0; i < (int)rows.size(); ++i)
rowY[i] = 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 > rowY[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 ----
for (int row_idx = 0; row_idx < (int)rows.size(); row_idx++) {
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 = rowY[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("transparent"),
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("transparent"),
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;
s_dropMode = (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("transparent");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
const char* miningTag = row.mining ? 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());
}
}
}
// ---- Address (second line) ----
{
float addrAvailW = contentRight - 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);
}
// ---- USD value (second line, right-aligned) ----
{
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
if (!usdText.empty()) {
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdSz.x,
cy + body2->LegacySize + Layout::spacingXs()),
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, rowY[row_idx]));
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
if (ImGui::IsItemHovered() && !s_dragActive) {
material::Tooltip("%s", addr.address.c_str());
}
// Context menu
const auto& acrTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("AddressContext", 0, acrTheme.menu)) {
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);
}
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("t_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);
}
// 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 activeHash = app->activeWalletIdentityHash();
int shown = 0;
for (int pi = 0; pi < (int)pf.size(); pi++) {
// Only this wallet's groups (or legacy/unscoped), matching the Market summary —
// don't offer to add this address to another wallet's group.
if (!(pf[pi].scope.empty() || activeHash.empty() || pf[pi].scope == activeHash))
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();
}
ImGui::PopID();
// Separator between rows
if (row_idx < (int)rows.size() - 1 && selected_row != row_idx) {
float sepY = rowY[row_idx] + rowH;
dl->AddLine(
ImVec2(rowPos.x + rowPadLeft + rowIconSz * 2.0f, sepY),
ImVec2(rowPos.x + innerW - Layout::spacingLg(), sepY),
dividerCol);
}
}
// 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
{
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);
}
}
// 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