// 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 #include #include #include #include #include "../../data/earnings_series.h" #include #include #include 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::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 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::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 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(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