feat(wallet): persist history and surface pending sends
Add an encrypted SQLite transaction history cache with cached tip metadata and per-address shielded scan progress so startup and full refreshes avoid re-scanning every z-address while still invalidating on wallet/address/rescan changes. Improve wallet history loading by paging transparent transactions, preserving cached shielded and sent rows, keeping recent/unconfirmed activity visible, and classifying mining-address receives. Show z_sendmany opid sends immediately in History and Overview, pin pending rows through refreshes, and apply optimistic address/balance debits until opids resolve. Add timestamped RPC console tracing by source/method without logging params or results, reduce redundant refresh/RPC calls, and cache Explorer recent block summaries in SQLite. Expand focused tests for transaction cache encryption, scan-progress persistence/invalidation, history preservation, operation-status parsing, pending send visibility, and Explorer/RPC refresh behavior.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
#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"
|
||||
@@ -25,6 +26,8 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <atomic>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -39,18 +42,13 @@ static char s_search_buf[128] = {};
|
||||
static bool s_search_loading = false;
|
||||
static std::string s_search_error;
|
||||
|
||||
// Recent blocks cache
|
||||
struct BlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
static std::vector<BlockSummary> s_recent_blocks;
|
||||
static int s_last_known_height = 0;
|
||||
using BlockSummary = ExplorerBlockSummary;
|
||||
static ExplorerBlockCache s_recent_block_cache;
|
||||
static std::set<int> s_pending_block_heights;
|
||||
static int s_recent_page = 0;
|
||||
static int s_recent_max_page = 0;
|
||||
static std::atomic<int> s_pending_block_fetches{0};
|
||||
static bool s_recent_disk_cache_validation_pending = false;
|
||||
|
||||
// Block detail modal
|
||||
static bool s_show_detail_modal = false;
|
||||
@@ -116,6 +114,34 @@ static std::string truncateHashToFit(const std::string& hash, ImFont* font, floa
|
||||
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<std::string>();
|
||||
} 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)
|
||||
@@ -127,6 +153,21 @@ static std::string formatSize(int 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();
|
||||
@@ -211,6 +252,7 @@ static void fetchBlockDetail(App* app, int height) {
|
||||
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<std::string>();
|
||||
result = rpc->call("getblock", {hash});
|
||||
@@ -236,12 +278,14 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
bool gotTx = false;
|
||||
// Try as block hash first
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
blockResult = rpc->call("getblock", {hash});
|
||||
gotBlock = true;
|
||||
} catch (...) {}
|
||||
// If not a block hash, try as txid
|
||||
if (!gotBlock) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||
gotTx = true;
|
||||
} catch (...) {}
|
||||
@@ -267,43 +311,39 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
});
|
||||
}
|
||||
|
||||
static bool fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
static bool fetchRecentBlock(App* app, int height) {
|
||||
auto* worker = app->worker();
|
||||
auto* rpc = app->rpc();
|
||||
if (!worker || !rpc || s_pending_block_fetches > 0) return false;
|
||||
if (height < 1 || !worker || !rpc) return false;
|
||||
if (s_pending_block_heights.find(height) != s_pending_block_heights.end()) return false;
|
||||
|
||||
if (s_recent_blocks.empty()) s_recent_blocks.resize(count);
|
||||
s_pending_block_fetches = 1; // single batched fetch
|
||||
s_pending_block_heights.insert(height);
|
||||
s_pending_block_fetches.fetch_add(1);
|
||||
|
||||
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<BlockSummary> results(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
int h = currentHeight - i;
|
||||
if (h < 1) continue;
|
||||
try {
|
||||
auto hashResult = rpc->call("getblockhash", {h});
|
||||
auto hash = hashResult.get<std::string>();
|
||||
auto result = rpc->call("getblock", {hash});
|
||||
auto& bs = results[i];
|
||||
bs.height = result.value("height", 0);
|
||||
bs.hash = result.value("hash", "");
|
||||
bs.time = result.value("time", (int64_t)0);
|
||||
bs.size = result.value("size", 0);
|
||||
bs.difficulty = result.value("difficulty", 0.0);
|
||||
if (result.contains("tx") && result["tx"].is_array())
|
||||
bs.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
}
|
||||
return [results = std::move(results)]() mutable {
|
||||
bool gotAny = false;
|
||||
for (const auto& block : results) {
|
||||
if (block.height > 0) {
|
||||
gotAny = true;
|
||||
break;
|
||||
}
|
||||
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<std::string>();
|
||||
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<int>(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);
|
||||
}
|
||||
if (gotAny) s_recent_blocks = std::move(results);
|
||||
s_pending_block_fetches = 0;
|
||||
};
|
||||
});
|
||||
return true;
|
||||
@@ -318,6 +358,7 @@ static void fetchMempoolInfo(App* app) {
|
||||
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);
|
||||
@@ -338,7 +379,10 @@ static void fetchTxDetail(App* app, const std::string& txid) {
|
||||
s_tx_detail = json();
|
||||
worker->post([rpc, txid]() -> rpc::RPCWorker::MainCb {
|
||||
json result;
|
||||
try { result = rpc->call("getrawtransaction", {txid, 1}); }
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Transaction detail");
|
||||
result = rpc->call("getrawtransaction", {txid, 1});
|
||||
}
|
||||
catch (...) {}
|
||||
return [result]() {
|
||||
s_tx_loading = false;
|
||||
@@ -377,6 +421,16 @@ 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);
|
||||
@@ -384,11 +438,12 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
|
||||
|
||||
// Clamp so search bar never overflows
|
||||
float maxInputW = availWidth - btnW - pad * 3 - Type().iconMed()->LegacySize;
|
||||
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());
|
||||
@@ -429,6 +484,32 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
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));
|
||||
@@ -443,6 +524,7 @@ 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;
|
||||
@@ -451,109 +533,185 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImFont* ovFont = Type().overline();
|
||||
ImFont* capFont = Type().caption();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
ImFont* heroFont = Type().h2();
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
|
||||
float cardW = (availWidth - gap) * 0.5f;
|
||||
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm();
|
||||
float cardH = pad * 0.5f + headerH + rowH * 3 + Layout::spacingSm() * 2 + pad * 0.5f;
|
||||
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];
|
||||
|
||||
// ── Chain Card (left) ──
|
||||
{
|
||||
ImVec2 cardMin = basePos;
|
||||
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 colW = (cardW - pad * 2) / 2.0f;
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
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"));
|
||||
|
||||
// Row 1: Height | Difficulty
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.sync.blocks);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
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);
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("difficulty"));
|
||||
snprintf(buf, sizeof(buf), "%.4f", state.mining.difficulty);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float progress = std::clamp(static_cast<float>(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);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
|
||||
// Row 2: Hashrate | Notarized
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_hashrate"));
|
||||
double hr = state.mining.networkHashrate;
|
||||
if (hr >= 1e9)
|
||||
snprintf(buf, sizeof(buf), "%.2f GH/s", hr / 1e9);
|
||||
else if (hr >= 1e6)
|
||||
snprintf(buf, sizeof(buf), "%.2f MH/s", hr / 1e6);
|
||||
else if (hr >= 1e3)
|
||||
snprintf(buf, sizeof(buf), "%.2f KH/s", hr / 1e3);
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%.0f H/s", hr);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
float hashLabelY = cardMax.y - pad - hashLineH;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
|
||||
cx = cardMin.x + pad + colW;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_notarized"));
|
||||
snprintf(buf, sizeof(buf), "%d", state.notarized);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), buf);
|
||||
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);
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 3: Best Block Hash — adaptive truncation to fit card
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
float maxHashW = cardW - pad * 2;
|
||||
std::string hashDisp = truncateHashToFit(state.sync.best_blockhash, sub1, maxHashW);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), hashDisp.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mempool Card (right) ──
|
||||
{
|
||||
ImVec2 cardMin(basePos.x + cardW + gap, basePos.y);
|
||||
auto drawMetricGrid = [&](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_mempool"));
|
||||
struct MetricSpec {
|
||||
const char* icon;
|
||||
const char* label;
|
||||
std::string value;
|
||||
std::string detail;
|
||||
ImU32 accent;
|
||||
};
|
||||
|
||||
float ry = cardMin.y + pad * 0.5f + headerH;
|
||||
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);
|
||||
|
||||
// Row 1: Transactions
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_txs"));
|
||||
snprintf(buf, sizeof(buf), "%d", s_mempool_tx_count);
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), 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<int>(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());
|
||||
}
|
||||
}
|
||||
ry += rowH + Layout::spacingSm();
|
||||
};
|
||||
|
||||
// Row 2: Size
|
||||
{
|
||||
float cx = cardMin.x + pad;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, ry), OnSurfaceMedium(), TR("explorer_mempool_size"));
|
||||
std::string sizeStr = formatSize(static_cast<int>(s_mempool_size));
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), sizeStr.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()));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -564,19 +722,13 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImFont* body2 = Type().body2();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
|
||||
float rowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
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;
|
||||
|
||||
// Filter out empty entries
|
||||
std::vector<const BlockSummary*> blocks;
|
||||
for (const auto& bs : s_recent_blocks) {
|
||||
if (bs.height > 0) blocks.push_back(&bs);
|
||||
}
|
||||
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
float maxRows = 10.0f;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
|
||||
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());
|
||||
@@ -585,10 +737,84 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
// Header
|
||||
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;
|
||||
|
||||
int maxPage = (state.sync.blocks > 0) ? (state.sync.blocks - 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 = state.sync.blocks - s_recent_page * rowsPerPage;
|
||||
std::vector<int> 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<int, BlockSummary> cachedBlocks;
|
||||
if (!pageHeights.empty()) {
|
||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
||||
}
|
||||
|
||||
bool canFetchBlocks = 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<int> nextPageHeights;
|
||||
nextPageHeights.reserve(rowsPerPage);
|
||||
int nextStartHeight = state.sync.blocks - (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<int, BlockSummary> 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<RecentBlockRow> blocks;
|
||||
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;
|
||||
@@ -598,7 +824,6 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float colHash = colHeight + innerW * 0.56f;
|
||||
float colTime = colHeight + innerW * 0.82f;
|
||||
|
||||
float hdrY = cardMin.y + pad * 0.5f + ovFont->LegacySize + Layout::spacingSm();
|
||||
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"));
|
||||
@@ -607,13 +832,11 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + colTime, hdrY), OnSurfaceMedium(), TR("explorer_block_time"));
|
||||
|
||||
// Scrollable child region for rows
|
||||
float rowAreaTop = hdrY + capFont->LegacySize + Layout::spacingXs();
|
||||
float rowAreaH = cardMax.y - rowAreaTop - pad * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, rowAreaTop));
|
||||
|
||||
int parentVtx = dl->VtxBuffer.Size;
|
||||
ImGui::BeginChild("##BlockRows", ImVec2(availWidth, rowAreaH), false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
|
||||
ImDrawList* childDL = ImGui::GetWindowDrawList();
|
||||
@@ -624,12 +847,14 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
char buf[128];
|
||||
float rowInset = 2 * dp;
|
||||
|
||||
if (blocks.empty() && s_pending_block_fetches > 0) {
|
||||
if (blocks.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
ImGui::TextDisabled("%s", TR("loading"));
|
||||
} else {
|
||||
for (size_t i = 0; i < blocks.size(); i++) {
|
||||
const auto* bs = blocks[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;
|
||||
|
||||
@@ -651,19 +876,19 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
}
|
||||
|
||||
// Hover highlight — clear clickable feedback
|
||||
if (hovered) {
|
||||
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 (s_show_detail_modal && s_detail_height == bs->height) {
|
||||
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 (clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
if (bs && clicked && app->rpc() && app->rpc()->isConnected()) {
|
||||
fetchBlockDetail(app, bs->height);
|
||||
}
|
||||
|
||||
@@ -671,30 +896,32 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
float textY2 = rowPos.y + (rowH - body2->LegacySize) * 0.5f;
|
||||
|
||||
// Height — emphasized with larger font and primary color
|
||||
snprintf(buf, sizeof(buf), "#%d", bs->height);
|
||||
snprintf(buf, sizeof(buf), "#%d", blockHeight);
|
||||
childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY),
|
||||
hovered ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
(hovered && bs) ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf);
|
||||
|
||||
// Tx count
|
||||
snprintf(buf, sizeof(buf), "%d tx", bs->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 = formatSize(bs->size);
|
||||
std::string sizeStr = bs ? formatSize(bs->size) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colSize, textY2), OnSurface(), sizeStr.c_str());
|
||||
|
||||
// Difficulty
|
||||
snprintf(buf, sizeof(buf), "%.2f", bs->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 = truncateHashToFit(bs->hash, body2, hashMaxW);
|
||||
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 = relativeTime(bs->time);
|
||||
const char* timeStr = bs ? relativeTime(bs->time) : "...";
|
||||
childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTime, textY2), OnSurfaceMedium(), timeStr);
|
||||
}
|
||||
}
|
||||
@@ -1023,30 +1250,14 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
void RenderExplorerTab(App* app)
|
||||
{
|
||||
const auto& state = app->getWalletState();
|
||||
auto* rpc = app->rpc();
|
||||
|
||||
ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
ImGui::BeginChild("##ExplorerScroll", avail, false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
ApplySmoothScroll();
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
|
||||
// Auto-refresh recent blocks when chain height changes, but avoid
|
||||
// starting expensive block fetches while the user is viewing details.
|
||||
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height &&
|
||||
!s_show_detail_modal && !s_detail_loading && !s_tx_loading) {
|
||||
if (rpc && rpc->isConnected()) {
|
||||
if (fetchRecentBlocks(app, state.sync.blocks)) {
|
||||
s_last_known_height = state.sync.blocks;
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderSearchBar(app, availWidth);
|
||||
renderChainStats(app, availWidth);
|
||||
renderSearchBar(app, availWidth);
|
||||
renderRecentBlocks(app, availWidth);
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
Reference in New Issue
Block a user