// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "explorer_tab.h" #include "../../app.h" #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" #include "../../data/wallet_state.h" #include "../../util/i18n.h" #include "../explorer/explorer_block_cache.h" #include "../schema/ui_schema.h" #include "../material/type.h" #include "../material/draw_helpers.h" #include "../material/colors.h" #include "../layout.h" #include "../notifications.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" #include #include #include #include #include #include #include #include #include #include namespace dragonx { namespace ui { using json = nlohmann::json; using namespace material; // ─── Static state ─────────────────────────────────────────────── // Search static char s_search_buf[128] = {}; static bool s_search_loading = false; static std::string s_search_error; static double s_search_change_time = -1.0; // GetTime() of the last edit (debounce for live search) static std::string s_search_last_query; // last query actually run (avoids re-running it) static constexpr double kSearchDebounceSeconds = 0.35; // When > 0, the recent-blocks list is anchored here (a search result) instead of the chain tip. // Searching re-anchors the LIST rather than opening the detail modal — that stays for row clicks. static int s_search_target_height = -1; // A query that performSearch() can resolve to a concrete result: a block height (all digits) or a // complete 64-char block hash / txid. Partial hex is intentionally NOT resolvable, so live search // stays quiet (no "invalid query") until the user finishes typing or pasting a full hash. static bool isResolvableQuery(const std::string& q) { if (q.empty()) return false; if (std::all_of(q.begin(), q.end(), ::isdigit)) return true; return q.size() == 64 && std::all_of(q.begin(), q.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }); } using BlockSummary = ExplorerBlockSummary; static ExplorerBlockCache s_recent_block_cache; static std::set s_pending_block_heights; static int s_recent_page = 0; static int s_recent_max_page = 0; // Fuzzy search: a non-numeric / partial query filters the list to cached blocks whose hash (or // height text) contains it. Results are memoized per query so we don't hit SQLite every frame. static std::string s_active_search_query; static std::string s_fuzzy_query_cached; static std::vector s_fuzzy_results; static constexpr int kMaxFuzzyResults = 100; static std::atomic s_pending_block_fetches{0}; static bool s_recent_disk_cache_validation_pending = false; // Block detail modal static bool s_show_detail_modal = false; static bool s_detail_loading = false; static int s_detail_height = 0; static std::string s_detail_hash; static int64_t s_detail_time = 0; static int s_detail_confirmations = 0; static int s_detail_size = 0; static double s_detail_difficulty = 0.0; static std::string s_detail_bits; static std::string s_detail_merkle; static std::string s_detail_prev_hash; static std::string s_detail_next_hash; static std::vector s_detail_txids; // Expanded transaction detail static int s_expanded_tx_idx = -1; static bool s_tx_loading = false; static json s_tx_detail; // Mempool info static int s_mempool_tx_count = 0; static int64_t s_mempool_size = 0; static bool s_mempool_loading = false; // ─── Helpers ──────────────────────────────────────────────────── static const char* relativeTime(int64_t timestamp) { static char buf[64]; int64_t now = std::time(nullptr); int64_t diff = now - timestamp; if (diff < 0) diff = 0; if (diff < 60) snprintf(buf, sizeof(buf), "%lld sec ago", (long long)diff); else if (diff < 3600) snprintf(buf, sizeof(buf), "%lld min ago", (long long)(diff / 60)); else if (diff < 86400) snprintf(buf, sizeof(buf), "%lld hr ago", (long long)(diff / 3600)); else snprintf(buf, sizeof(buf), "%lld days ago", (long long)(diff / 86400)); return buf; } static std::string truncateHash(const std::string& hash, int front, int back) { if (hash.length() <= static_cast(front + back + 3)) return hash; return hash.substr(0, front) + "..." + hash.substr(hash.length() - back); } // Adaptive hash truncation based on available pixel width static std::string truncateHashToFit(const std::string& hash, ImFont* font, float maxWidth) { ImVec2 fullSz = font->CalcTextSizeA(font->LegacySize, 10000.0f, 0.0f, hash.c_str()); if (fullSz.x <= maxWidth) return hash; // Binary search for the right truncation int front = 6, back = 4; for (int f = (int)hash.size() / 2; f >= 4; f -= 2) { int b = std::max(4, f - 2); std::string t = hash.substr(0, f) + "..." + hash.substr(hash.size() - b); ImVec2 sz = font->CalcTextSizeA(font->LegacySize, 10000.0f, 0.0f, t.c_str()); if (sz.x <= maxWidth) { front = f; back = b; break; } } return truncateHash(hash, front, back); } static void validateRecentBlockCache(App* app, const WalletState& state) { if (s_recent_disk_cache_validation_pending) return; auto validation = s_recent_block_cache.prepareValidation(state.sync.blocks, state.sync.best_blockhash); if (!validation.needed) return; auto* worker = app ? app->worker() : nullptr; auto* rpc = app ? app->rpc() : nullptr; if (!worker || !rpc || !rpc->isConnected()) return; int currentHeight = state.sync.blocks; std::string currentHash = state.sync.best_blockhash; s_recent_disk_cache_validation_pending = true; worker->post([rpc, validation, currentHeight, currentHash = std::move(currentHash)]() mutable -> rpc::RPCWorker::MainCb { std::string actualHash; try { rpc::RPCClient::TraceScope trace("Explorer tab / Cache validation"); actualHash = rpc->call("getblockhash", {validation.height}).get(); } catch (...) {} return [validation, currentHeight, currentHash = std::move(currentHash), actualHash = std::move(actualHash)]() mutable { s_recent_disk_cache_validation_pending = false; s_recent_block_cache.applySavedTipValidation(validation, actualHash, currentHeight, currentHash); }; }); } static std::string formatSize(int bytes) { char b[32]; if (bytes >= 1048576) snprintf(b, sizeof(b), "%.2f MB", bytes / (1024.0 * 1024.0)); else if (bytes >= 1024) snprintf(b, sizeof(b), "%.2f KB", bytes / 1024.0); else snprintf(b, sizeof(b), "%d B", bytes); return b; } static std::string formatHashrate(double hashrate) { char b[32]; if (hashrate >= 1e12) snprintf(b, sizeof(b), "%.2f TH/s", hashrate / 1e12); else if (hashrate >= 1e9) snprintf(b, sizeof(b), "%.2f GH/s", hashrate / 1e9); else if (hashrate >= 1e6) snprintf(b, sizeof(b), "%.2f MH/s", hashrate / 1e6); else if (hashrate >= 1e3) snprintf(b, sizeof(b), "%.2f KH/s", hashrate / 1e3); else snprintf(b, sizeof(b), "%.0f H/s", hashrate); return b; } // Copy icon button — draws a small icon that copies text to clipboard on click static void copyButton(const char* id, const std::string& text, float x, float y) { ImFont* iconFont = Type().iconSmall(); ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 100.0f, 0.0f, ICON_MD_CONTENT_COPY); float btnSize = iconSz.x + Layout::spacingSm() * 2; ImGui::SetCursorScreenPos(ImVec2(x, y)); ImGui::PushID(id); ImGui::InvisibleButton("##copy", ImVec2(btnSize, iconSz.y + Layout::spacingXs())); bool hovered = ImGui::IsItemHovered(); bool clicked = ImGui::IsItemClicked(0); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddText(iconFont, iconFont->LegacySize, ImVec2(x + Layout::spacingSm(), y), hovered ? Primary() : OnSurfaceMedium(), ICON_MD_CONTENT_COPY); if (hovered) ImGui::SetTooltip("%s", TR("click_to_copy")); if (clicked) { ImGui::SetClipboardText(text.c_str()); Notifications::instance().success(TR("copied_to_clipboard")); } ImGui::PopID(); } // Draw a label: value pair on one line inside a card static void drawLabelValue(ImDrawList* dl, float x, float y, float labelW, const char* label, const char* value, ImFont* labelFont, ImFont* valueFont) { dl->AddText(labelFont, labelFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), label); dl->AddText(valueFont, valueFont->LegacySize, ImVec2(x + labelW, y), OnSurface(), value); } // ─── Async RPC Helpers ────────────────────────────────────────── // All RPC work is dispatched to the RPCWorker background thread. // Results are delivered back to the UI thread via MainCb lambdas. static void applyBlockDetailResult(const json& result, const std::string& error) { s_detail_loading = false; s_search_loading = false; if (!error.empty()) { s_search_error = error; s_show_detail_modal = false; return; } if (result.is_null()) { s_search_error = "Invalid response from daemon"; s_show_detail_modal = false; return; } s_detail_height = result.value("height", 0); s_detail_hash = result.value("hash", ""); s_detail_time = result.value("time", (int64_t)0); s_detail_confirmations = result.value("confirmations", 0); s_detail_size = result.value("size", 0); s_detail_difficulty = result.value("difficulty", 0.0); s_detail_bits = result.value("bits", ""); s_detail_merkle = result.value("merkleroot", ""); s_detail_prev_hash = result.value("previousblockhash", ""); s_detail_next_hash = result.value("nextblockhash", ""); s_detail_txids.clear(); if (result.contains("tx") && result["tx"].is_array()) { for (const auto& tx : result["tx"]) { if (tx.is_string()) s_detail_txids.push_back(tx.get()); } } s_show_detail_modal = true; s_expanded_tx_idx = -1; s_search_error.clear(); } static void fetchBlockDetail(App* app, int height) { auto* worker = app->worker(); auto* rpc = app->rpc(); if (!worker || !rpc) return; s_detail_loading = true; s_search_loading = true; s_search_error.clear(); worker->post([rpc, height]() -> rpc::RPCWorker::MainCb { json result; std::string error; try { rpc::RPCClient::TraceScope trace("Explorer tab / Block detail"); auto hashResult = rpc->call("getblockhash", {height}); std::string hash = hashResult.get(); result = rpc->call("getblock", {hash}); } catch (const std::exception& e) { error = e.what(); } return [result, error]() { applyBlockDetailResult(result, error); }; }); } static bool fetchRecentBlock(App* app, int height) { auto* worker = app->worker(); auto* rpc = app->rpc(); if (height < 1 || !worker || !rpc) return false; if (s_pending_block_heights.find(height) != s_pending_block_heights.end()) return false; s_pending_block_heights.insert(height); s_pending_block_fetches.fetch_add(1); worker->post([rpc, height]() -> rpc::RPCWorker::MainCb { BlockSummary block; try { rpc::RPCClient::TraceScope trace("Explorer tab / Recent blocks"); auto hashResult = rpc->call("getblockhash", {height}); auto hash = hashResult.get(); auto result = rpc->call("getblock", {hash}); block.height = result.value("height", 0); block.hash = result.value("hash", ""); block.time = result.value("time", (int64_t)0); block.size = result.value("size", 0); block.difficulty = result.value("difficulty", 0.0); if (result.contains("tx") && result["tx"].is_array()) block.tx_count = static_cast(result["tx"].size()); } catch (...) {} return [height, block = std::move(block)]() mutable { if (block.height > 0) { s_recent_block_cache.storeBlock(block); } s_pending_block_heights.erase(height); if (s_pending_block_fetches.load() > 0) { s_pending_block_fetches.fetch_sub(1); } }; }); return true; } static void fetchMempoolInfo(App* app) { auto* worker = app->worker(); auto* rpc = app->rpc(); if (!worker || !rpc || s_mempool_loading) return; s_mempool_loading = true; worker->post([rpc]() -> rpc::RPCWorker::MainCb { int txCount = 0; int64_t bytes = 0; try { rpc::RPCClient::TraceScope trace("Explorer tab / Mempool summary"); auto result = rpc->call("getmempoolinfo", json::array()); txCount = result.value("size", 0); bytes = result.value("bytes", (int64_t)0); } catch (...) {} return [txCount, bytes]() { s_mempool_tx_count = txCount; s_mempool_size = bytes; s_mempool_loading = false; }; }); } static void fetchTxDetail(App* app, const std::string& txid) { auto* worker = app->worker(); auto* rpc = app->rpc(); if (!worker || !rpc) return; s_tx_loading = true; s_tx_detail = json(); worker->post([rpc, txid]() -> rpc::RPCWorker::MainCb { json result; try { rpc::RPCClient::TraceScope trace("Explorer tab / Transaction detail"); result = rpc->call("getrawtransaction", {txid, 1}); } catch (...) {} return [result]() { s_tx_loading = false; s_tx_detail = result; }; }); } // Resolve a 64-char hash to a block height and re-anchor the list there (no modal). If the hash is a // txid rather than a block, fall back to the inline transaction view (also not a modal). static void navigateToHash(App* app, const std::string& hash) { auto* worker = app->worker(); auto* rpc = app->rpc(); if (!worker || !rpc || !rpc->isConnected()) return; s_search_loading = true; s_search_error.clear(); worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb { json blockResult, txResult; bool gotBlock = false, gotTx = false; try { rpc::RPCClient::TraceScope trace("Explorer tab / Search"); blockResult = rpc->call("getblock", {hash}); gotBlock = true; } catch (...) {} if (!gotBlock) { try { rpc::RPCClient::TraceScope trace("Explorer tab / Search"); txResult = rpc->call("getrawtransaction", {hash, 1}); gotTx = true; } catch (...) {} } return [blockResult, txResult, gotBlock, gotTx]() { s_search_loading = false; if (gotBlock) { int h = blockResult.value("height", -1); if (h > 0) { s_search_target_height = h; s_recent_page = 0; } s_show_detail_modal = false; } else if (gotTx && !txResult.is_null()) { // A txid: show it inline (existing tx detail view), not a modal. s_tx_detail = txResult; s_tx_loading = false; s_expanded_tx_idx = 0; s_show_detail_modal = false; } else { s_search_error = "No block or transaction found for this hash"; } }; }); } // Search re-anchors the recent-blocks LIST to the queried block instead of popping the detail modal. static void performSearch(App* app, const std::string& query) { if (query.empty()) { s_search_target_height = -1; return; } // Integer = block height → just point the list at it. if (std::all_of(query.begin(), query.end(), ::isdigit)) { long h = 0; try { h = std::stol(query); } catch (...) { return; } int tip = app->getWalletState().sync.blocks; if (h < 1) h = 1; if (tip > 0 && h > tip) h = tip; s_search_target_height = static_cast(h); s_recent_page = 0; s_search_error.clear(); return; } // 64-char hex = block hash or txid → resolve to a height and re-anchor (or inline tx). bool isHex64 = query.size() == 64 && std::all_of(query.begin(), query.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }); if (isHex64) { navigateToHash(app, query); return; } s_search_error = TR("explorer_invalid_query"); } // ─── Render Sections ──────────────────────────────────────────── static void renderSearchBar(App* app, float availWidth) { auto& S = schema::UI(); float pad = Layout::cardInnerPadding(); ImFont* capFont = Type().caption(); float navBtnSz = std::max(24.0f * Layout::dpiScale(), capFont->LegacySize + 8.0f * Layout::dpiScale()); if (s_recent_page > s_recent_max_page) s_recent_page = s_recent_max_page; if (s_recent_page < 0) s_recent_page = 0; char pageBuf[32]; snprintf(pageBuf, sizeof(pageBuf), "%d / %d", s_recent_page + 1, s_recent_max_page + 1); float pageW = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, pageBuf).x; float navGap = Layout::spacingXs(); float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f; float inputW = std::min( S.drawElement("tabs.explorer", "search-input-width").size, availWidth * 0.65f); float btnW = S.drawElement("tabs.explorer", "search-button-width").size; float barH = S.drawElement("tabs.explorer", "search-bar-height").size; // Clamp so search bar never overflows float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize; if (inputW > maxInputW) inputW = maxInputW; if (inputW < 80.0f) inputW = 80.0f; ImGui::Spacing(); float rowStartX = ImGui::GetCursorScreenPos().x; // Icon ImGui::PushFont(Type().iconMed()); float iconH = Type().iconMed()->LegacySize; float cursorY = ImGui::GetCursorScreenPos().y; // Vertically center icon with input ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x, cursorY + (barH - iconH) * 0.5f)); ImGui::TextUnformatted(ICON_MD_SEARCH); ImGui::PopFont(); ImGui::SameLine(); // Input — match height to barH ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x, cursorY)); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(ImGui::GetStyle().FramePadding.x, (barH - ImGui::GetFontSize()) * 0.5f)); ImGui::SetNextItemWidth(inputW); bool enter = ImGui::InputText("##ExplorerSearch", s_search_buf, sizeof(s_search_buf), ImGuiInputTextFlags_EnterReturnsTrue); bool edited = ImGui::IsItemEdited(); // must read immediately after the input item ImGui::PopStyleVar(); ImGui::SameLine(); // Search button — same height as input ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x, cursorY)); bool clicked = material::StyledButton(TR("explorer_search"), ImVec2(btnW, barH)); std::string query(s_search_buf); while (!query.empty() && query.front() == ' ') query.erase(query.begin()); while (!query.empty() && query.back() == ' ') query.pop_back(); s_active_search_query = query; // drives the fuzzy list filter in renderRecentBlocks if (edited) { s_search_change_time = ImGui::GetTime(); // (re)start the debounce on each keystroke s_search_error.clear(); // don't show a stale error mid-type if (query.empty()) { // Cleared the box → drop the anchor and return to recent (tip) blocks immediately. s_search_target_height = -1; s_search_last_query.clear(); s_search_change_time = -1.0; } } bool doSearch = enter || clicked; // Live search: once the user pauses and the query is resolvable, re-anchor the list (no modal) // without waiting for Enter. if (!doSearch && s_search_change_time >= 0.0 && (ImGui::GetTime() - s_search_change_time) >= kSearchDebounceSeconds && isResolvableQuery(query) && query != s_search_last_query) { doSearch = true; } if (doSearch && !query.empty()) { s_search_change_time = -1.0; // consume the debounce s_search_last_query = query; performSearch(app, query); // numeric is offline-friendly; hash lookup checks rpc itself } // Loading spinner if (s_search_loading) { ImGui::SameLine(); ImGui::TextDisabled("%s", TR("loading")); } float navX = rowStartX + availWidth - navW; float navY = cursorY + std::max(0.0f, (barH - navBtnSz) * 0.5f); ImGui::SetCursorScreenPos(ImVec2(navX, navY)); ImGui::BeginDisabled(s_recent_page <= 0); ImGui::PushID("RecentBlocksSearchPrev"); if (TactileButton(ICON_MD_CHEVRON_LEFT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) { --s_recent_page; } ImGui::PopID(); ImGui::EndDisabled(); ImGui::SameLine(0, navGap); ImVec2 pagePos = ImGui::GetCursorScreenPos(); ImGui::GetWindowDrawList()->AddText(capFont, capFont->LegacySize, ImVec2(pagePos.x, pagePos.y + (navBtnSz - capFont->LegacySize) * 0.5f), OnSurfaceMedium(), pageBuf); ImGui::Dummy(ImVec2(pageW, navBtnSz)); ImGui::SameLine(0, navGap); ImGui::BeginDisabled(s_recent_page >= s_recent_max_page); ImGui::PushID("RecentBlocksSearchNext"); if (TactileButton(ICON_MD_CHEVRON_RIGHT, ImVec2(navBtnSz, navBtnSz), Type().iconSmall())) { ++s_recent_page; } ImGui::PopID(); ImGui::EndDisabled(); ImGui::SetCursorScreenPos(ImVec2(rowStartX, cursorY + barH)); // Error if (!s_search_error.empty()) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f)); ImGui::TextWrapped("%s", s_search_error.c_str()); ImGui::PopStyleColor(); } ImGui::Spacing(); } static void renderChainStats(App* app, float availWidth) { const auto& state = app->getWalletState(); float pad = Layout::cardInnerPadding(); float gap = Layout::cardGap(); float dp = Layout::dpiScale(); ImDrawList* dl = ImGui::GetWindowDrawList(); GlassPanelSpec glassSpec; glassSpec.rounding = Layout::glassRounding(); ImFont* ovFont = Type().overline(); ImFont* capFont = Type().caption(); ImFont* sub1 = Type().subtitle1(); ImFont* heroFont = Type().h2(); ImFont* iconFont = Type().iconSmall(); float headerH = ovFont->LegacySize + Layout::spacingSm(); float heroLineH = capFont->LegacySize + Layout::spacingXs() + heroFont->LegacySize; float hashLineH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize; float cardH = std::max(148.0f * dp, pad * 1.5f + headerH + heroLineH + hashLineH + Layout::spacingLg() * 2.0f); bool stacked = availWidth < 760.0f * dp; float chainW = stacked ? availWidth : (availWidth - gap) * 0.60f; float metricsW = stacked ? availWidth : availWidth - chainW - gap; ImVec2 basePos = ImGui::GetCursorScreenPos(); char buf[128]; auto drawStatusPill = [&](const ImVec2& cardMin, float cardW) { bool connected = app->rpc() && app->rpc()->isConnected(); ImU32 pillCol = connected ? Success() : Error(); std::string statusText; if (!connected) { statusText = TR("not_connected"); } else if (state.sync.syncing && state.sync.headers > 0) { snprintf(buf, sizeof(buf), "%s %.1f%%", TR("syncing"), state.sync.verification_progress * 100.0); statusText = buf; pillCol = Warning(); } else { statusText = TR("connected"); } float maxTextW = cardW * 0.34f; statusText = truncateHashToFit(statusText, capFont, maxTextW); ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, statusText.c_str()); float pillPadX = Layout::spacingSm(); float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f; float pillW = textSz.x + pillPadX * 2.0f; ImVec2 pillMin(cardMin.x + cardW - pad - pillW, cardMin.y + pad * 0.5f); ImVec2 pillMax(pillMin.x + pillW, pillMin.y + pillH); dl->AddRectFilled(pillMin, pillMax, WithAlpha(pillCol, 32), pillH * 0.5f); dl->AddRect(pillMin, pillMax, WithAlpha(pillCol, 110), pillH * 0.5f, 0, 1.0f * dp); dl->AddText(capFont, capFont->LegacySize, ImVec2(pillMin.x + pillPadX, pillMin.y + Layout::spacingXs()), pillCol, statusText.c_str()); }; auto drawChainTip = [&](const ImVec2& cardMin, float cardW) { ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); DrawGlassPanel(dl, cardMin, cardMax, glassSpec); dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats")); drawStatusPill(cardMin, cardW); float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg(); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height")); snprintf(buf, sizeof(buf), "%d", state.sync.blocks); float valueY = labelY + capFont->LegacySize + Layout::spacingXs(); DrawTextShadow(dl, heroFont, heroFont->LegacySize, ImVec2(cardMin.x + pad, valueY), OnSurface(), buf); if (state.sync.syncing && state.sync.headers > 0) { float progress = std::clamp(static_cast(state.sync.verification_progress), 0.0f, 1.0f); float barY = valueY + heroFont->LegacySize + Layout::spacingMd(); float barH = std::max(3.0f * dp, 3.0f); float barW = cardW - pad * 2.0f; dl->AddRectFilled(ImVec2(cardMin.x + pad, barY), ImVec2(cardMin.x + pad + barW, barY + barH), WithAlpha(OnSurface(), 18), barH * 0.5f); dl->AddRectFilled(ImVec2(cardMin.x + pad, barY), ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f); } float hashLabelY = cardMax.y - pad - hashLineH; dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block")); float hashY = hashLabelY + capFont->LegacySize + Layout::spacingXs(); float copyW = iconFont->LegacySize + Layout::spacingSm() * 2.0f; float hashMaxW = cardW - pad * 2.0f - (state.sync.best_blockhash.empty() ? 0.0f : copyW + Layout::spacingSm()); std::string hashDisp = state.sync.best_blockhash.empty() ? std::string("--") : truncateHashToFit(state.sync.best_blockhash, sub1, hashMaxW); dl->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + pad, hashY), OnSurface(), hashDisp.c_str()); if (!state.sync.best_blockhash.empty()) { ImVec2 savedCursor = ImGui::GetCursorScreenPos(); copyButton("ChainTipBestHash", state.sync.best_blockhash, cardMax.x - pad - copyW, hashY); ImGui::SetCursorScreenPos(savedCursor); } }; auto drawMetricGrid = [&](const ImVec2& cardMin, float cardW) { ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); DrawGlassPanel(dl, cardMin, cardMax, glassSpec); struct MetricSpec { const char* icon; const char* label; std::string value; std::string detail; ImU32 accent; }; snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty); std::string difficulty = buf; snprintf(buf, sizeof(buf), "%d", state.notarized); std::string notarized = buf; snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count); std::string mempoolTxs = s_mempool_loading ? std::string("...") : std::string(buf); MetricSpec metrics[4] = { { ICON_MD_SPEED, TR("difficulty"), difficulty, "", Warning() }, { ICON_MD_SHOW_CHART, TR("peers_hashrate"), formatHashrate(state.mining.networkHashrate), "", Success() }, { ICON_MD_CONFIRMATION_NUMBER, TR("peers_notarized"), notarized, "", Primary() }, { ICON_MD_STORAGE, TR("explorer_mempool"), mempoolTxs, s_mempool_loading ? std::string("") : formatSize(static_cast(s_mempool_size)), Secondary() }, }; float gridX = cardMin.x + pad; float gridY = cardMin.y + pad; float gridW = cardW - pad * 2.0f; float gridH = cardH - pad * 2.0f; float cellW = gridW * 0.5f; float cellH = gridH * 0.5f; ImU32 dividerCol = WithAlpha(OnSurface(), 22); dl->AddLine(ImVec2(gridX + cellW, gridY), ImVec2(gridX + cellW, gridY + gridH), dividerCol, 1.0f * dp); dl->AddLine(ImVec2(gridX, gridY + cellH), ImVec2(gridX + gridW, gridY + cellH), dividerCol, 1.0f * dp); for (int i = 0; i < 4; ++i) { int col = i % 2; int row = i / 2; ImVec2 cellMin(gridX + col * cellW, gridY + row * cellH); ImVec2 cellMax(cellMin.x + cellW, cellMin.y + cellH); float inset = Layout::spacingSm(); float textX = cellMin.x + inset; float textY = cellMin.y + inset; dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cellMax.x - inset - iconFont->LegacySize, textY), WithAlpha(metrics[i].accent, 180), metrics[i].icon); dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, textY), OnSurfaceMedium(), metrics[i].label); float valueY = textY + capFont->LegacySize + Layout::spacingXs(); float valueMaxW = cellW - inset * 2.0f; std::string value = truncateHashToFit(metrics[i].value, sub1, valueMaxW); dl->AddText(sub1, sub1->LegacySize, ImVec2(textX, valueY), OnSurface(), value.c_str()); if (!metrics[i].detail.empty()) { std::string detail = truncateHashToFit(metrics[i].detail, capFont, valueMaxW); dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, valueY + sub1->LegacySize + Layout::spacingXs()), OnSurfaceDisabled(), detail.c_str()); } } }; drawChainTip(basePos, chainW); if (stacked) { drawMetricGrid(ImVec2(basePos.x, basePos.y + cardH + gap), metricsW); ImGui::Dummy(ImVec2(availWidth, cardH * 2.0f + gap + Layout::spacingMd())); } else { drawMetricGrid(ImVec2(basePos.x + chainW + gap, basePos.y), metricsW); ImGui::Dummy(ImVec2(availWidth, cardH + Layout::spacingMd())); } } static void renderRecentBlocks(App* app, float availWidth) { auto& S = schema::UI(); float pad = Layout::cardInnerPadding(); float dp = Layout::dpiScale(); const auto& state = app->getWalletState(); validateRecentBlockCache(app, state); ImDrawList* dl = ImGui::GetWindowDrawList(); GlassPanelSpec glassSpec; glassSpec.rounding = Layout::glassRounding(); ImFont* ovFont = Type().overline(); ImFont* capFont = Type().caption(); ImFont* body2 = Type().body2(); ImFont* sub1 = Type().subtitle1(); float baseRowH = S.drawElement("tabs.explorer", "row-height").size; float rowRound = S.drawElement("tabs.explorer", "row-rounding").size; float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f; // Stretch card to fill the remaining tab height; rows scroll inside. float maxRows = 10.0f; float contentH = capFont->LegacySize + Layout::spacingXs() + baseRowH * maxRows; float minTableH = headerH + contentH + pad; float remainingH = ImGui::GetContentRegionAvail().y; float tableH = std::max(minTableH, remainingH - Layout::spacingSm()); ImVec2 cardMin = ImGui::GetCursorScreenPos(); ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH); DrawGlassPanel(dl, cardMin, cardMax, glassSpec); dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_recent_blocks")); float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm(); float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs(); float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f; int rowsPerPage = std::max(1, (int)std::ceil(rowAreaH / std::max(1.0f, baseRowH))); float rowH = baseRowH; // Anchor the list at a search result when one is active, otherwise at the chain tip. int anchorTip = (s_search_target_height > 0 && s_search_target_height <= state.sync.blocks) ? s_search_target_height : state.sync.blocks; int maxPage = (anchorTip > 0) ? (anchorTip - 1) / rowsPerPage : 0; s_recent_max_page = maxPage; if (s_recent_page > maxPage) s_recent_page = maxPage; if (s_recent_page < 0) s_recent_page = 0; int startHeight = anchorTip - s_recent_page * rowsPerPage; std::vector pageHeights; pageHeights.reserve(rowsPerPage); for (int i = 0; i < rowsPerPage; ++i) { int height = startHeight - i; if (height < 1) break; pageHeights.push_back(height); } std::map cachedBlocks; if (!pageHeights.empty()) { cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front()); } // Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash // (or height text) contains it. Exact queries (a height or a full 64-char hash) still navigate // via performSearch/the anchor above. Results are memoized per query (no per-frame SQLite). const std::string& fq = s_active_search_query; bool fqDigits = !fq.empty() && std::all_of(fq.begin(), fq.end(), ::isdigit); bool fqFullHash = fq.size() == 64 && std::all_of(fq.begin(), fq.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); }); bool fuzzyMode = !fq.empty() && !fqDigits && !fqFullHash; if (fuzzyMode) { if (fq != s_fuzzy_query_cached) { s_fuzzy_results = s_recent_block_cache.searchBlocks(fq, kMaxFuzzyResults); s_fuzzy_query_cached = fq; } s_recent_max_page = 0; // fuzzy results aren't paginated; they scroll } bool canFetchBlocks = !fuzzyMode && state.sync.blocks > 0 && !s_show_detail_modal && !s_detail_loading && !s_tx_loading; if (canFetchBlocks) { bool currentPageComplete = true; bool scheduledFetch = false; auto* rpc = app->rpc(); if (rpc && rpc->isConnected()) { for (int height : pageHeights) { if (cachedBlocks.find(height) == cachedBlocks.end()) { currentPageComplete = false; scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch; } } if (currentPageComplete && s_recent_page < maxPage) { std::vector nextPageHeights; nextPageHeights.reserve(rowsPerPage); int nextStartHeight = anchorTip - (s_recent_page + 1) * rowsPerPage; for (int i = 0; i < rowsPerPage; ++i) { int height = nextStartHeight - i; if (height < 1) break; nextPageHeights.push_back(height); } std::map nextCachedBlocks; if (!nextPageHeights.empty()) { nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front()); } for (int height : nextPageHeights) { if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) { scheduledFetch = fetchRecentBlock(app, height) || scheduledFetch; } } } } if (scheduledFetch) { fetchMempoolInfo(app); } } struct RecentBlockRow { int height = 0; const BlockSummary* block = nullptr; }; std::vector blocks; if (fuzzyMode) { blocks.reserve(s_fuzzy_results.size()); for (const auto& b : s_fuzzy_results) { blocks.push_back({b.height, &b}); } } else { blocks.reserve(pageHeights.size()); for (int height : pageHeights) { auto it = cachedBlocks.find(height); blocks.push_back({height, it != cachedBlocks.end() ? &it->second : nullptr}); } } // Responsive column layout — give height more room, use remaining for data float innerW = availWidth - pad * 2; float colHeight = pad; float colTxs = colHeight + innerW * 0.14f; float colSize = colHeight + innerW * 0.24f; float colDiff = colHeight + innerW * 0.38f; float colHash = colHeight + innerW * 0.56f; float colTime = colHeight + innerW * 0.82f; dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colHeight, hdrY), OnSurfaceMedium(), TR("explorer_block_height")); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTxs, hdrY), OnSurfaceMedium(), TR("explorer_block_txs")); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colSize, hdrY), OnSurfaceMedium(), TR("explorer_block_size")); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colDiff, hdrY), OnSurfaceMedium(), TR("difficulty")); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colHash, hdrY), OnSurfaceMedium(), TR("explorer_block_hash")); dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTime, hdrY), OnSurfaceMedium(), TR("explorer_block_time")); // Scrollable child region for rows ImGui::SetCursorScreenPos(ImVec2(cardMin.x, rowAreaTop)); int parentVtx = dl->VtxBuffer.Size; ImGui::BeginChild("##BlockRows", ImVec2(availWidth, rowAreaH), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse); ApplySmoothScroll(); ImDrawList* childDL = ImGui::GetWindowDrawList(); int childVtx = childDL->VtxBuffer.Size; float scrollY = ImGui::GetScrollY(); float scrollMaxY = ImGui::GetScrollMaxY(); char buf[128]; float rowInset = 2 * dp; if (blocks.empty()) { ImGui::SetCursorPosY(Layout::spacingMd()); ImGui::TextDisabled("%s", fuzzyMode ? TR("explorer_no_results") : TR("loading")); } else { for (size_t i = 0; i < blocks.size(); i++) { const auto& row = blocks[i]; const auto* bs = row.block; int blockHeight = bs ? bs->height : row.height; ImVec2 rowPos = ImGui::GetCursorScreenPos(); float rowW = ImGui::GetContentRegionAvail().x - rowInset * 2; ImGui::SetCursorScreenPos(ImVec2(rowPos.x + rowInset, rowPos.y)); // InvisibleButton for proper interaction ImGui::PushID(static_cast(i)); ImGui::InvisibleButton("##blkRow", ImVec2(rowW, rowH)); bool hovered = ImGui::IsItemHovered(); bool clicked = ImGui::IsItemClicked(0); ImGui::PopID(); ImVec2 rowMin(rowPos.x + rowInset, rowPos.y); ImVec2 rowMax(rowMin.x + rowW, rowMin.y + rowH); // Subtle alternating row background for readability if (i % 2 == 0) { childDL->AddRectFilled(rowMin, rowMax, WithAlpha(OnSurface(), 6), rowRound); } // Hover highlight — clear clickable feedback if (hovered && bs) { childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 20), rowRound); childDL->AddRect(rowMin, rowMax, WithAlpha(Primary(), 40), rowRound); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } // Selected highlight if (bs && s_show_detail_modal && s_detail_height == bs->height) { childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 25), rowRound); } // Click opens block detail modal if (bs && clicked && app->rpc() && app->rpc()->isConnected()) { fetchBlockDetail(app, bs->height); } float textY = rowPos.y + (rowH - sub1->LegacySize) * 0.5f; float textY2 = rowPos.y + (rowH - body2->LegacySize) * 0.5f; // Height — emphasized with larger font and primary color snprintf(buf, sizeof(buf), "#%d", blockHeight); childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY), (hovered && bs) ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf); // Tx count if (bs) snprintf(buf, sizeof(buf), "%d tx", bs->tx_count); else snprintf(buf, sizeof(buf), "..."); childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTxs, textY2), OnSurface(), buf); // Size std::string sizeStr = bs ? formatSize(bs->size) : "..."; childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colSize, textY2), OnSurface(), sizeStr.c_str()); // Difficulty if (bs) snprintf(buf, sizeof(buf), "%.2f", bs->difficulty); else snprintf(buf, sizeof(buf), "..."); childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colDiff, textY2), OnSurface(), buf); // Hash — adaptive to available column width float hashMaxW = (colTime - colHash) - Layout::spacingSm(); std::string hashDisp = bs ? truncateHashToFit(bs->hash, body2, hashMaxW) : "..."; childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colHash, textY2), OnSurfaceMedium(), hashDisp.c_str()); // Time const char* timeStr = bs ? relativeTime(bs->time) : "..."; childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTime, textY2), OnSurfaceMedium(), timeStr); } } ImGui::EndChild(); float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size; ApplyScrollEdgeMask(dl, parentVtx, childDL, childVtx, rowAreaTop, rowAreaTop + rowAreaH, fadeZone, scrollY, scrollMaxY); ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); ImGui::Dummy(ImVec2(availWidth, Layout::spacingMd())); } static void renderBlockDetailModal(App* app) { if (!s_show_detail_modal && !s_detail_loading) return; auto& S = schema::UI(); float pad = Layout::cardInnerPadding(); ImFont* ovFont = Type().overline(); ImFont* capFont = Type().caption(); ImFont* sub1 = Type().subtitle1(); ImFont* body2 = Type().body2(); float modalW = S.drawElement("tabs.explorer", "detail-modal-width").sizeOr(700.0f); if (s_detail_loading && !s_show_detail_modal) { // Show a loading modal while fetching if (BeginOverlayDialog(TR("loading"), &s_detail_loading, 300.0f, 0.85f)) { ImGui::Spacing(); int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4; const char* dotStr[] = {"", ".", "..", "..."}; char loadBuf[64]; snprintf(loadBuf, sizeof(loadBuf), "%s%s", TR("loading"), dotStr[dots]); ImGui::TextDisabled("%s", loadBuf); ImGui::Spacing(); EndOverlayDialog(); } return; } if (!BeginOverlayDialog(TR("explorer_block_detail"), &s_show_detail_modal, modalW, 0.90f)) return; float contentW = ImGui::GetContentRegionAvail().x; float hashMaxW = contentW - pad * 2 - Type().iconSmall()->LegacySize; char buf[256]; // ── Header: "Block #123456" + nav buttons ── { ImGui::PushFont(sub1); snprintf(buf, sizeof(buf), "%s #%d", TR("explorer_block_detail"), s_detail_height); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", buf); ImGui::PopFont(); // Nav buttons on same line ImGui::SameLine(contentW - Layout::spacingXl() * 3); // Prev if (s_detail_height > 1) { ImGui::PushFont(Type().iconMed()); ImGui::PushID("prevBlock"); if (ImGui::SmallButton(ICON_MD_CHEVRON_LEFT)) { if (app->rpc() && app->rpc()->isConnected()) fetchBlockDetail(app, s_detail_height - 1); } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Previous block"); ImGui::PopID(); ImGui::PopFont(); ImGui::SameLine(); } // Next if (!s_detail_next_hash.empty()) { ImGui::PushFont(Type().iconMed()); ImGui::PushID("nextBlock"); if (ImGui::SmallButton(ICON_MD_CHEVRON_RIGHT)) { if (app->rpc() && app->rpc()->isConnected()) fetchBlockDetail(app, s_detail_height + 1); } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Next block"); ImGui::PopID(); ImGui::PopFont(); } } ImGui::Separator(); ImGui::Spacing(); // Show loading state inside the modal when navigating between blocks if (s_detail_loading) { int dots = ((int)(ImGui::GetTime() * 3.0f)) % 4; const char* dotStr[] = {"", ".", "..", "..."}; snprintf(buf, sizeof(buf), "%s%s", TR("loading"), dotStr[dots]); ImGui::TextDisabled("%s", buf); EndOverlayDialog(); return; } // ── Block Hash ── { ImGui::PushFont(capFont); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", TR("explorer_block_hash")); ImGui::PopFont(); std::string hashDisp = truncateHashToFit(s_detail_hash, body2, hashMaxW); ImGui::PushFont(body2); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(schema::UI().resolveColor("var(--secondary-light)"))); ImGui::TextWrapped("%s", hashDisp.c_str()); ImGui::PopStyleColor(); ImGui::PopFont(); ImGui::SameLine(); copyButton("cpHash", s_detail_hash, ImGui::GetCursorScreenPos().x, ImGui::GetCursorScreenPos().y); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", s_detail_hash.c_str()); } ImGui::Spacing(); // ── Info grid ── ImDrawList* dl = ImGui::GetWindowDrawList(); float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize; float labelW = S.drawElement("tabs.explorer", "label-column").size; { ImVec2 gridPos = ImGui::GetCursorScreenPos(); float gx = gridPos.x; float gy = gridPos.y; float halfW = contentW * 0.5f; // Row 1: Timestamp | Confirmations drawLabelValue(dl, gx, gy, labelW, TR("block_timestamp"), "", capFont, sub1); if (s_detail_time > 0) { std::time_t t = static_cast(s_detail_time); char time_buf[64]; std::strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", std::localtime(&t)); dl->AddText(sub1, sub1->LegacySize, ImVec2(gx + labelW, gy), OnSurface(), time_buf); } dl->AddText(capFont, capFont->LegacySize, ImVec2(gx + halfW, gy), OnSurfaceMedium(), TR("confirmations")); snprintf(buf, sizeof(buf), "%d", s_detail_confirmations); dl->AddText(sub1, sub1->LegacySize, ImVec2(gx + halfW + labelW * 0.7f, gy), OnSurface(), buf); gy += rowH + Layout::spacingSm(); // Row 2: Size | Difficulty drawLabelValue(dl, gx, gy, labelW, TR("block_size"), formatSize(s_detail_size).c_str(), capFont, sub1); dl->AddText(capFont, capFont->LegacySize, ImVec2(gx + halfW, gy), OnSurfaceMedium(), TR("difficulty")); snprintf(buf, sizeof(buf), "%.4f", s_detail_difficulty); dl->AddText(sub1, sub1->LegacySize, ImVec2(gx + halfW + labelW * 0.7f, gy), OnSurface(), buf); gy += rowH + Layout::spacingSm(); // Row 3: Bits drawLabelValue(dl, gx, gy, labelW, TR("block_bits"), s_detail_bits.c_str(), capFont, sub1); gy += rowH + Layout::spacingSm(); // Row 4: Merkle Root dl->AddText(capFont, capFont->LegacySize, ImVec2(gx, gy), OnSurfaceMedium(), TR("explorer_block_merkle")); gy += capFont->LegacySize + Layout::spacingXs(); { float merkleMaxW = contentW - Type().iconSmall()->LegacySize - Layout::spacingMd(); std::string merkleTrunc = truncateHashToFit(s_detail_merkle, body2, merkleMaxW); dl->AddText(body2, body2->LegacySize, ImVec2(gx, gy), OnSurfaceMedium(), merkleTrunc.c_str()); } gy += body2->LegacySize + Layout::spacingXs(); // Advance ImGui cursor past the drawlist content ImGui::SetCursorScreenPos(ImVec2(gridPos.x, gy)); } // Copy merkle button copyButton("cpMerkle", s_detail_merkle, ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x - Layout::spacingXl(), ImGui::GetCursorScreenPos().y - body2->LegacySize - Layout::spacingSm()); ImGui::Spacing(); // ── Transactions ── { ImGui::PushFont(ovFont); snprintf(buf, sizeof(buf), "%s (%d)", TR("explorer_block_txs"), (int)s_detail_txids.size()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", buf); ImGui::PopFont(); ImGui::Spacing(); float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size; ImU32 linkCol = schema::UI().resolveColor("var(--secondary-light)"); for (int i = 0; i < (int)s_detail_txids.size(); i++) { const auto& txid = s_detail_txids[i]; bool isExpanded = (s_expanded_tx_idx == i); ImGui::PushID(i); // Expand/collapse icon + txid on one row via InvisibleButton ImFont* iconFont = Type().iconSmall(); const char* expandIcon = isExpanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE; ImVec2 rowStart = ImGui::GetCursorScreenPos(); float txContentW = ImGui::GetContentRegionAvail().x; ImGui::InvisibleButton("##txRow", ImVec2(txContentW, txRowH)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); bool rightClicked = ImGui::IsItemClicked(1); ImDrawList* txDL = ImGui::GetWindowDrawList(); // Hover highlight if (rowHovered) { txDL->AddRectFilled(rowStart, ImVec2(rowStart.x + txContentW, rowStart.y + txRowH), WithAlpha(OnSurface(), 10), S.drawElement("tabs.explorer", "row-rounding").size); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); ImGui::SetTooltip("%s", txid.c_str()); } // Draw icon + text float iconY = rowStart.y + (txRowH - iconFont->LegacySize) * 0.5f; txDL->AddText(iconFont, iconFont->LegacySize, ImVec2(rowStart.x, iconY), OnSurfaceMedium(), expandIcon); float txTextX = rowStart.x + iconFont->LegacySize + Layout::spacingXs(); float txMaxW = txContentW - iconFont->LegacySize - Layout::spacingXl() * 3; std::string txTrunc = truncateHashToFit(txid, body2, txMaxW); float textY = rowStart.y + (txRowH - body2->LegacySize) * 0.5f; txDL->AddText(body2, body2->LegacySize, ImVec2(txTextX, textY), linkCol, txTrunc.c_str()); // Copy icon at end of row float copyX = rowStart.x + txContentW - Layout::spacingXl(); if (rowHovered) { float copyY = rowStart.y + (txRowH - iconFont->LegacySize) * 0.5f; txDL->AddText(iconFont, iconFont->LegacySize, ImVec2(copyX, copyY), OnSurfaceMedium(), ICON_MD_CONTENT_COPY); } if (rowClicked) { if (isExpanded) { s_expanded_tx_idx = -1; } else { s_expanded_tx_idx = i; fetchTxDetail(app, txid); } } if (rightClicked) { ImGui::SetClipboardText(txid.c_str()); Notifications::instance().success(TR("copied_to_clipboard")); } // ── Expanded transaction detail ── if (isExpanded) { float indent = Layout::spacingXl(); if (s_tx_loading) { ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); ImGui::TextDisabled("%s", TR("loading")); } else if (!s_tx_detail.is_null()) { ImGui::Indent(indent); // Full TxID with copy ImGui::PushFont(capFont); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "TxID:"); ImGui::PopFont(); ImGui::PushFont(body2); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(linkCol)); ImGui::TextWrapped("%s", txid.c_str()); ImGui::PopStyleColor(); ImGui::PopFont(); // Size if (s_tx_detail.contains("size")) { ImGui::PushFont(capFont); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s: %s", TR("explorer_tx_size"), formatSize(s_tx_detail.value("size", 0)).c_str()); ImGui::PopFont(); } // Outputs if (s_tx_detail.contains("vout") && s_tx_detail["vout"].is_array()) { ImGui::PushFont(capFont); snprintf(buf, sizeof(buf), "%s (%d):", TR("explorer_tx_outputs"), (int)s_tx_detail["vout"].size()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", buf); ImGui::PopFont(); ImGui::PushFont(body2); for (const auto& vout : s_tx_detail["vout"]) { double val = vout.value("value", 0.0); int n = vout.value("n", 0); std::string addr = "shielded"; if (vout.contains("scriptPubKey") && vout["scriptPubKey"].contains("addresses") && vout["scriptPubKey"]["addresses"].is_array() && !vout["scriptPubKey"]["addresses"].empty()) { addr = vout["scriptPubKey"]["addresses"][0].get(); } float addrMaxW = ImGui::GetContentRegionAvail().x * 0.5f; std::string addrDisp = truncateHashToFit(addr, body2, addrMaxW); snprintf(buf, sizeof(buf), "[%d] %s %.8f DRGX", n, addrDisp.c_str(), val); ImGui::TextUnformatted(buf); } ImGui::PopFont(); } ImGui::Spacing(); ImGui::Unindent(indent); } } ImGui::PopID(); } if (s_detail_txids.size() > 100) { snprintf(buf, sizeof(buf), "... showing first 100 of %d", (int)s_detail_txids.size()); ImGui::TextDisabled("%s", buf); } } EndOverlayDialog(); } // ─── Main Entry Point ────────────────────────────────────────── void RenderExplorerTab(App* app) { ImVec2 avail = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##ExplorerScroll", avail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); float availWidth = ImGui::GetContentRegionAvail().x; renderChainStats(app, availWidth); renderSearchBar(app, availWidth); renderRecentBlocks(app, availWidth); ImGui::EndChild(); // Block detail modal — rendered outside the scroll region so it's fullscreen renderBlockDetailModal(app); } } // namespace ui } // namespace dragonx