From fb46e4d027562f9de2f804489b3a86c11be840d6 Mon Sep 17 00:00:00 2001 From: Anthony Towns Date: Tue, 24 Mar 2026 14:13:45 +1000 Subject: [PATCH 1/3] feat: Better support for -txindex=0 nodes When fetching the coinbase for a block, specify the blockhash so it doesn't need txindex to identify a miner. --- src/search.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/search.cpp b/src/search.cpp index 67d5e7d..57ed653 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -29,8 +29,9 @@ TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const if (blk.contains("tx") && blk["tx"].is_array() && !blk["tx"].empty()) { std::string coinbase_txid = blk["tx"][0].get(); try { - auto coinbase_tx = search_rpc.call("getrawtransaction", - {json(coinbase_txid), json(true)})["result"]; + auto coinbase_tx = + search_rpc.call("getrawtransaction", + {json(coinbase_txid), json(true), json(hash)})["result"]; if (coinbase_tx.contains("vin") && coinbase_tx["vin"].is_array() && !coinbase_tx["vin"].empty()) { std::string cb_hex = coinbase_tx["vin"][0].value("coinbase", ""); From 51d8bf5641f8421b871a442075c8d752659eb2d7 Mon Sep 17 00:00:00 2001 From: Anthony Towns Date: Sat, 21 Mar 2026 21:50:31 +1000 Subject: [PATCH 2/3] feat: add block tx list and mempool search to backend Extend TxSearchState with a per-block transaction list (TxListEntry) and a mempool flag. Add perform_block_search() using getblock verbosity 3 to fetch full tx objects with prevout values, enabling fee calculation without extra RPC calls. Add perform_mempool_search() using verbose getrawmempool, with cluster mempool chunk feerate support and sorting by mining priority. Also accept an optional blockhash hint in perform_tx_search() so confirmed tx lookups work without txindex, and bump sat/vB display precision from 1 to 2 decimal places. --- src/format.hpp | 2 +- src/search.cpp | 119 ++++++++++++++++++++++++++++++++++++++++++++++--- src/search.hpp | 10 ++++- src/state.hpp | 26 +++++++---- 4 files changed, 140 insertions(+), 17 deletions(-) diff --git a/src/format.hpp b/src/format.hpp index a567790..853bb63 100644 --- a/src/format.hpp +++ b/src/format.hpp @@ -84,7 +84,7 @@ inline std::string fmt_hashrate(double h) { inline std::string fmt_satsvb(double btc_per_kvb) { double sats_per_vb = btc_per_kvb * 1e5; // BTC/kvB → sat/vB std::ostringstream ss; - ss << std::fixed << std::setprecision(1) << sats_per_vb << " sat/vB"; + ss << std::fixed << std::setprecision(2) << sats_per_vb << " sat/vB"; return ss.str(); } diff --git a/src/search.cpp b/src/search.cpp index 57ed653..c5cfe65 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -1,12 +1,15 @@ -#include "search.hpp" +#include + #include "format.hpp" +#include "search.hpp" // ============================================================================ // Transaction / block lookup — pure: takes config + query, returns result. // No shared state, no threads, no UI side-effects. Suitable for testing. // ============================================================================ TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const std::string& query, - bool query_is_height, int64_t tip) { + bool query_is_height, int64_t tip, + const std::string& blockhash_hint) { TxSearchState result; result.txid = query; try { @@ -14,7 +17,7 @@ TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const search_cfg.timeout_seconds = 5; RpcClient search_rpc(search_cfg, auth); - // Helper: populate result with block data from getblock (verbosity 1) + // Helper: populate result with block metadata from getblock (verbosity 1). auto fetch_block = [&](const std::string& hash) { auto blk = search_rpc.call("getblock", {json(hash), json(1)})["result"]; result.blk_hash = blk.value("hash", hash); @@ -71,10 +74,12 @@ TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const result.confirmed = false; result.found = true; } catch (...) { - // 2. Try confirmed tx (requires txindex=1) + // 2. Try confirmed tx (txindex=1, or blockhash hint) try { - auto tx = - search_rpc.call("getrawtransaction", {json(query), json(true)})["result"]; + json tx_params = blockhash_hint.empty() + ? json{json(query), json(true)} + : json{json(query), json(true), json(blockhash_hint)}; + auto tx = search_rpc.call("getrawtransaction", tx_params)["result"]; result.vsize = tx.value("vsize", 0LL); result.weight = tx.value("weight", 0LL); @@ -126,3 +131,105 @@ TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const } return result; } + +TxSearchState perform_block_search(const RpcConfig& cfg, const RpcAuth& auth, int64_t height) { + TxSearchState result; + result.txid = std::to_string(height); + try { + RpcConfig search_cfg = cfg; + search_cfg.timeout_seconds = 30; // verbosity-3 getblock can be 30-50MB + RpcClient search_rpc(search_cfg, auth); + + auto hash_r = search_rpc.call("getblockhash", {height})["result"]; + std::string hash = hash_r.get(); + + // Verbosity 3: full tx objects with prevout values for fee calculation. + auto blk = search_rpc.call("getblock", {json(hash), json(3)})["result"]; + result.blk_hash = blk.value("hash", hash); + result.blk_height = blk.value("height", 0LL); + result.blk_time = blk.value("time", 0LL); + result.blk_ntx = blk.value("nTx", 0LL); + result.blk_size = blk.value("size", 0LL); + result.blk_weight = blk.value("weight", 0LL); + result.blk_difficulty = blk.value("difficulty", 0.0); + result.blk_confirmations = blk.value("confirmations", 0LL); + if (blk.contains("tx") && blk["tx"].is_array()) { + bool first = true; + for (const auto& tx : blk["tx"]) { + if (!tx.is_object()) + continue; + TxListEntry entry; + entry.txid = tx.value("txid", ""); + entry.vsize = tx.value("vsize", 0LL); + bool is_coinbase = tx.contains("vin") && tx["vin"].is_array() && + !tx["vin"].empty() && tx["vin"][0].contains("coinbase"); + if (first) { + if (is_coinbase) + result.blk_miner = extract_miner(tx["vin"][0].value("coinbase", "")); + first = false; + } + if (!is_coinbase && tx.contains("vin") && tx.contains("vout")) { + double in_val = 0.0, out_val = 0.0; + for (const auto& inp : tx["vin"]) + if (inp.contains("prevout")) + in_val += inp["prevout"].value("value", 0.0); + for (const auto& out : tx["vout"]) + out_val += out.value("value", 0.0); + double fee_btc = in_val - out_val; + if (fee_btc > 0.0 && entry.vsize > 0) + entry.feerate = fee_btc * 1e8 / static_cast(entry.vsize); + } + result.blk_tx_list.push_back(std::move(entry)); + } + if (result.blk_miner.empty()) + result.blk_miner = "—"; + } + result.is_block = true; + result.found = true; + } catch (const std::exception& e) { + result.error = e.what(); + } + return result; +} + +TxSearchState perform_mempool_search(const RpcConfig& cfg, const RpcAuth& auth) { + TxSearchState result; + result.txid = "mempool"; + try { + RpcConfig search_cfg = cfg; + search_cfg.timeout_seconds = 60; // large mempool JSON can be 50MB+ + RpcClient search_rpc(search_cfg, auth); + // verbose=true returns an object keyed by txid with fee/size data. + auto txids = search_rpc.call("getrawmempool", {true})["result"]; + result.is_mempool = true; + result.found = true; + if (txids.is_object()) { + for (const auto& [txid, info] : txids.items()) { + TxListEntry entry; + entry.txid = txid; + entry.vsize = info.value("vsize", 0LL); + if (info.contains("fees") && info["fees"].is_object()) { + const auto& fees = info["fees"]; + int64_t chunkweight = info.value("chunkweight", 0LL); + if (fees.contains("chunk") && chunkweight > 0) { + // Cluster mempool: chunk feerate reflects mining priority. + entry.feerate = + fees.value("chunk", 0.0) * 1e8 * 4.0 / static_cast(chunkweight); + } else if (entry.vsize > 0) { + entry.feerate = + fees.value("base", 0.0) * 1e8 / static_cast(entry.vsize); + } + } + result.blk_tx_list.push_back(std::move(entry)); + } + // Sort highest feerate first (mining priority order). + std::sort( + result.blk_tx_list.begin(), result.blk_tx_list.end(), + [](const TxListEntry& a, const TxListEntry& b) { return a.feerate > b.feerate; }); + } + } catch (const std::exception& e) { + result.error = e.what(); + result.is_mempool = true; // keep flag so error is visible in the UI + } + return result; +} diff --git a/src/search.hpp b/src/search.hpp index 459e319..51bd6e5 100644 --- a/src/search.hpp +++ b/src/search.hpp @@ -7,5 +7,13 @@ // Pure transaction/block lookup — no shared state, no UI side-effects. // query_is_height: true when query is a decimal block height string. +// blockhash_hint: if non-empty, passed to getrawtransaction to avoid needing txindex. TxSearchState perform_tx_search(const RpcConfig& cfg, const RpcAuth& auth, const std::string& query, - bool query_is_height, int64_t tip); + bool query_is_height, int64_t tip, + const std::string& blockhash_hint = ""); + +// Fetch detailed block data (verbosity 3) with tx list and fees, for Explorer browse. +TxSearchState perform_block_search(const RpcConfig& cfg, const RpcAuth& auth, int64_t height); + +// Fetch the current mempool tx list via getrawmempool. +TxSearchState perform_mempool_search(const RpcConfig& cfg, const RpcAuth& auth); diff --git a/src/state.hpp b/src/state.hpp index 54c2f3d..2050c7b 100644 --- a/src/state.hpp +++ b/src/state.hpp @@ -115,6 +115,12 @@ struct SoftFork { int64_t bip9_threshold = 0; }; +struct TxListEntry { + std::string txid; + int64_t vsize = 0; // virtual bytes (0 if unknown) + double feerate = 0.0; // sat/vB (0 for coinbase or if unknown) +}; + struct TxVin { std::string txid; int vout = 0; @@ -152,15 +158,17 @@ struct TxSearchState { int vout_count = 0; double total_output = 0.0; // BTC, sum of all outputs // Block result fields - std::string blk_hash; - int64_t blk_height = 0; - int64_t blk_time = 0; - int64_t blk_ntx = 0; - int64_t blk_size = 0; - int64_t blk_weight = 0; - double blk_difficulty = 0.0; - std::string blk_miner; - int64_t blk_confirmations = 0; + std::string blk_hash; + int64_t blk_height = 0; + int64_t blk_time = 0; + int64_t blk_ntx = 0; + int64_t blk_size = 0; + int64_t blk_weight = 0; + double blk_difficulty = 0.0; + std::string blk_miner; + int64_t blk_confirmations = 0; + std::vector blk_tx_list; // txs in block order or by feerate desc (mempool) + bool is_mempool = false; // result of a getrawmempool search // Input/output navigation std::vector vin_list; std::vector vout_list; From ad53c113fe2681467496fe6bd90fc520ac1a7ee0 Mon Sep 17 00:00:00 2001 From: Anthony Towns Date: Sat, 21 Mar 2026 21:51:02 +1000 Subject: [PATCH 3/3] feat: Explorer tab with block browser and mempool browsing Rename the Mempool tab to Explorer and add an interactive block/mempool browser with three panes: 1. Block selector: a "Mempool" column alongside the existing animated recent-block columns. Left/Right navigates, Down focuses. 2. Summary pane: mempool stats when the mempool column is selected, or full block details (hash, time, difficulty, miner, etc.) when a block is selected. 3. Transaction list: scrollable list of txs sorted by feerate (for mempool) or in block order. Shows index, feerate (sat/vB), and txid. Up/Down navigates, Enter drills down into the existing tx overlay, PageUp/PageDown for fast scrolling. Mempool data auto-refreshes every 30 seconds while browsing, with selection preservation across refreshes. Block data is fetched on demand and cached. --- src/main.cpp | 340 +++++++++++++++++++++++++++++++++++++++---------- src/render.cpp | 216 +++++++++++++++++++++++++++---- src/render.hpp | 3 +- 3 files changed, 467 insertions(+), 92 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 6aba3c0..00c6ffe 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -218,7 +218,7 @@ static int run(int argc, char* argv[]) { AppState state; std::mutex state_mtx; - // Transaction search state + // Transaction search state (for / search → overlay) TxSearchState search_state; std::vector search_history; std::mutex search_mtx; @@ -227,6 +227,14 @@ static int run(int argc, char* argv[]) { std::atomic search_in_flight{false}; std::thread search_thread; + // Explorer browse state (block/mempool inline display) + TxSearchState browse_block_ss; // current block browse result + TxSearchState browse_mempool_ss; // current mempool browse result + std::mutex browse_mtx; + std::atomic browse_in_flight{false}; + std::atomic last_mempool_refresh_time{0}; + std::thread browse_thread; + // Broadcast tools state BroadcastState broadcast_state; std::mutex broadcast_mtx; @@ -294,8 +302,13 @@ static int run(int argc, char* argv[]) { int addednodes_sel = -1; int banlist_sel = -1; - // Mempool tab: selected block index (-1 = none, 0 = newest/leftmost) - int mempool_sel = -1; + // Explorer tab: 0 = mempool block, 1..N = real blocks (recent_blocks[sel-1]) + int mempool_sel = 0; + int mempool_pane = -1; // -1 = unfocused, 0 = blocks pane, 1 = tx list pane + int mempool_tx_sel = -1; // selected tx in tx list pane + int explorer_win_size = 8; // visible tx rows; updated each render + std::string mempool_restore_txid; // txid to restore after mempool refresh + int mempool_restore_idx = -1; // fallback position if txid not found std::atomic running{true}; @@ -303,13 +316,15 @@ static int run(int argc, char* argv[]) { auto screen = ScreenInteractive::Fullscreen(); // Tabs - std::vector tab_labels = {"Dashboard", "Mempool", "Network", "Peers", "Tools"}; + std::vector tab_labels = {"Dashboard", "Explorer", "Network", "Peers", "Tools"}; int tab_index = 0; + int prev_tab_index = 0; auto tab_toggle = Toggle(&tab_labels, &tab_index); - // Shared search trigger — switches to Mempool tab when switch_tab is true - auto trigger_tx_search = [&](const std::string& query, bool switch_tab) { + // Search trigger for / searches — result always shown as overlay. + auto trigger_tx_search = [&](const std::string& query, bool switch_tab, + const std::string& blockhash_hint = "", bool push_history = true) { if (search_in_flight.load()) return; search_in_flight = true; @@ -319,7 +334,7 @@ static int run(int argc, char* argv[]) { std::lock_guard lock(search_mtx); if (switch_tab) { search_history.clear(); - } else if (!search_state.txid.empty()) { + } else if (push_history && !search_state.txid.empty()) { search_history.push_back(search_state); } search_state = TxSearchState{}; @@ -337,14 +352,13 @@ static int run(int argc, char* argv[]) { tip_at_search = state.blocks; } - // Determine whether the query is a block height (all digits) or a hash/txid bool query_is_height = !query.empty() && std::ranges::all_of(query, [](unsigned char c) { return std::isdigit(c) != 0; }); - search_thread = std::thread([&, query, query_is_height, tip_at_search] { + search_thread = std::thread([&, query, query_is_height, tip_at_search, blockhash_hint] { TxSearchState result = - perform_tx_search(cfg, auth, query, query_is_height, tip_at_search); + perform_tx_search(cfg, auth, query, query_is_height, tip_at_search, blockhash_hint); search_in_flight = false; if (!running.load()) return; @@ -356,6 +370,94 @@ static int run(int argc, char* argv[]) { }); }; + // Browse trigger — fetches block/mempool data for the Explorer inline panes. + auto trigger_browse_block = [&](int64_t height) { + if (browse_in_flight.load()) + return; + browse_in_flight = true; + if (browse_thread.joinable()) + browse_thread.join(); + + browse_thread = std::thread([&, height] { + TxSearchState result = perform_block_search(cfg, auth, height); + browse_in_flight = false; + if (!running.load()) + return; + { + std::lock_guard lock(browse_mtx); + if (result.found && result.is_block) + browse_block_ss = std::move(result); + } + screen.PostEvent(Event::Custom); + }); + }; + + auto trigger_browse_mempool = [&]() { + if (browse_in_flight.load()) + return; + browse_in_flight = true; + if (browse_thread.joinable()) + browse_thread.join(); + browse_thread = std::thread([&] { + TxSearchState result = perform_mempool_search(cfg, auth); + browse_in_flight = false; + if (!running.load()) + return; + { + std::lock_guard lock(browse_mtx); + if (result.found && result.is_mempool) { + browse_mempool_ss = std::move(result); + last_mempool_refresh_time = static_cast(std::time(nullptr)); + } + } + screen.PostEvent(Event::Custom); + }); + }; + + // Trigger block/mempool load for the current mempool_sel if not already loaded. + auto trigger_browse_if_needed = [&]() { + if (browse_in_flight.load()) + return; + if (mempool_sel == 0) { + bool need_load = false; + { + std::lock_guard lock(browse_mtx); + bool loaded = browse_mempool_ss.is_mempool && browse_mempool_ss.found; + if (!loaded) { + need_load = true; + } else if (static_cast(std::time(nullptr)) - + last_mempool_refresh_time.load() > + 30) { + // Stale: save current selection so we can restore it after refresh. + if (mempool_tx_sel >= 0 && + mempool_tx_sel < static_cast(browse_mempool_ss.blk_tx_list.size())) + mempool_restore_txid = browse_mempool_ss.blk_tx_list[mempool_tx_sel].txid; + mempool_restore_idx = mempool_tx_sel; + need_load = true; + } + } + if (!need_load) + return; + trigger_browse_mempool(); + } else { + int64_t expected_height = -1; + { + std::lock_guard lock(state_mtx); + int idx = mempool_sel - 1; + if (idx < static_cast(state.recent_blocks.size())) + expected_height = state.recent_blocks[idx].height; + } + if (expected_height < 0) + return; + { + std::lock_guard lock(browse_mtx); + if (browse_block_ss.is_block && browse_block_ss.blk_height == expected_height) + return; // already loaded + } + trigger_browse_block(expected_height); + } + }; + auto open_broadcast_dialog = [&] { tools_input_active = true; tools_sel = 0; @@ -697,13 +799,14 @@ static int run(int argc, char* argv[]) { } // Is a search result overlay currently visible? - bool overlay_visible; - bool overlay_is_confirmed_tx; // confirmed tx (not a block) - bool overlay_block_row_selected; // block # row highlighted (io_selected == 0) - bool overlay_inputs_row_sel; // inputs row highlighted - bool overlay_outputs_row_sel; // outputs row highlighted - bool overlay_inputs_open; // inputs sub-overlay is open - bool overlay_outputs_open; // outputs sub-overlay is open + bool overlay_visible; + bool overlay_is_confirmed_tx; // confirmed tx (not a block) + bool overlay_block_row_selected; // block # row highlighted (io_selected == 0) + bool overlay_inputs_row_sel; // inputs row highlighted + bool overlay_outputs_row_sel; // outputs row highlighted + bool overlay_inputs_open; // inputs sub-overlay is open + bool overlay_outputs_open; // outputs sub-overlay is open + TxSearchState search_snap; // copy of search_state for overlay render { std::lock_guard lock(search_mtx); overlay_visible = !search_state.txid.empty(); @@ -719,6 +822,8 @@ static int run(int argc, char* argv[]) { overlay_is_confirmed_tx && sel == outputs_idx && outputs_idx >= 0; overlay_inputs_open = overlay_is_confirmed_tx && search_state.inputs_overlay_open; overlay_outputs_open = overlay_is_confirmed_tx && search_state.outputs_overlay_open; + if (overlay_visible) + search_snap = search_state; } // Tab content @@ -728,21 +833,26 @@ static int run(int argc, char* argv[]) { tab_content = render_dashboard(snap); break; case 1: { - TxSearchState ss; - { - std::lock_guard lock(search_mtx); - ss = search_state; - } - - // Mempool content is always the background layer - auto base = vbox({render_mempool(snap, mempool_sel), filler()}) | flex; - - // No search yet — just show the mempool - if (ss.txid.empty()) { + // Hold browse_mtx for the Explorer tab render to avoid copying + // TxSearchState (blk_tx_list can be 100k+ entries for mempool). + std::lock_guard lock(browse_mtx); + const TxSearchState& browse = (mempool_sel == 0) ? browse_mempool_ss : browse_block_ss; + + // Panes 1-3 always rendered inline via render_mempool. + auto base = vbox({render_mempool(snap, mempool_sel, browse, mempool_tx_sel, + mempool_pane, explorer_win_size), + filler()}) | + flex; + + // No search overlay on Explorer tab — just show the browse panes. + if (!overlay_visible) { tab_content = std::move(base); break; } + // Overlay a search result on top of the Explorer base. + const auto& ss = search_snap; + // Abbreviated txid: first 20 + "…" + last 20 std::string txid_abbrev = ss.txid.size() > 40 ? ss.txid.substr(0, 20) + "…" + ss.txid.substr(ss.txid.size() - 20) @@ -1169,13 +1279,14 @@ static int run(int argc, char* argv[]) { : overlay_is_confirmed_tx ? hbox({text(" [↑/↓] navigate [Esc] dismiss [q] quit ") | color(Color::Yellow)}) : overlay_visible ? hbox({text(" [Esc] dismiss [q] quit ") | color(Color::Yellow)}) - : (tab_index == 1 && mempool_sel >= 0) - ? hbox({text(" [↵] view block [←/→] navigate [Esc] deselect [q] quit ") | + : (tab_index == 1 && mempool_pane == 1) + ? hbox({text(" [↑/↓] navigate [↵] lookup tx [Esc] back [q] quit ") | color(Color::Yellow)}) - : (tab_index == 1) - ? hbox({refresh_indicator, - text(" [↓] select [Tab/←/→] switch [/] search [q] quit ") | - color(Color::GrayDark)}) + : (tab_index == 1 && mempool_pane == 0) + ? hbox({text(" [↑/↓/←/→] navigate [/] search [q] quit ") | color(Color::Yellow)}) + : (tab_index == 1) ? hbox({refresh_indicator, + text(" [↓] select [←/→] navigate [/] search [q] quit ") | + color(Color::GrayDark)}) : (tab_index == 3 && peer_selected >= 0) ? hbox({refresh_indicator, text(" [\u2191/\u2193] navigate [\u23ce] details [a] " "added nodes [b] ban list [q] quit ") | @@ -1755,59 +1866,154 @@ static int run(int argc, char* argv[]) { return true; } } - // Mempool tab: block navigation (↓ to enter, ←/→ to navigate, Enter to view) + // Explorer tab navigation if (tab_index == 1) { + // Reset to unfocused when switching into the Explorer tab from another tab. + if (prev_tab_index != 1) { + mempool_pane = -1; + mempool_tx_sel = -1; + } + prev_tab_index = 1; + + // Check whether a search result overlay is showing. bool has_overlay; { std::lock_guard lock(search_mtx); has_overlay = !search_state.txid.empty(); } - if (!has_overlay) { - // arrow-down enters block navigation mode - if (event == Event::ArrowDown && mempool_sel < 0) { - int n; + + if (has_overlay) { + // Block L/R from switching tabs while overlay is open. + if (event == Event::ArrowLeft || event == Event::ArrowRight) + return true; + } else if (mempool_pane == 1) { + // ── Tx list pane navigation ───────────────────────────────── + int ntx; + { + std::lock_guard lock(browse_mtx); + const auto& bs = (mempool_sel == 0) ? browse_mempool_ss : browse_block_ss; + ntx = static_cast(bs.blk_tx_list.size()); + } + if (ntx == 0) + return true; // nothing to navigate (shouldn't happen) + if (event == Event::ArrowDown) { + mempool_tx_sel = std::min(mempool_tx_sel + 1, ntx - 1); + screen.PostEvent(Event::Custom); + return true; + } + if (event == Event::ArrowUp) { + if (mempool_tx_sel <= 0) + mempool_pane = 0; + else + mempool_tx_sel--; + screen.PostEvent(Event::Custom); + return true; + } + if (event == Event::PageDown) { + mempool_tx_sel = std::min(mempool_tx_sel + explorer_win_size, ntx - 1); + screen.PostEvent(Event::Custom); + return true; + } + if (event == Event::PageUp) { + mempool_tx_sel = std::max(mempool_tx_sel - explorer_win_size, 0); + screen.PostEvent(Event::Custom); + return true; + } + if (event == Event::Return && mempool_tx_sel >= 0 && mempool_tx_sel < ntx) { + std::string txid, blkhash; { - std::lock_guard lock(state_mtx); - n = static_cast(state.recent_blocks.size()); + std::lock_guard lock(browse_mtx); + const auto& bs = (mempool_sel == 0) ? browse_mempool_ss : browse_block_ss; + txid = bs.blk_tx_list[mempool_tx_sel].txid; + blkhash = bs.blk_hash; // empty for mempool txs } - if (n > 0) { - mempool_sel = 0; - screen.PostEvent(Event::Custom); - return true; + if (!txid.empty()) + trigger_tx_search(txid, false, blkhash); + return true; + } + if (event == Event::Escape) { + mempool_pane = 0; + mempool_tx_sel = -1; + screen.PostEvent(Event::Custom); + return true; + } + // Block L/R from switching tabs while in tx list. + if (event == Event::ArrowLeft || event == Event::ArrowRight) + return true; + } else { + // ── Blocks pane navigation ────────────────────────────────── + bool browse_loaded; + { + std::lock_guard lock(browse_mtx); + const auto& bs = (mempool_sel == 0) ? browse_mempool_ss : browse_block_ss; + browse_loaded = bs.found; + } + // On Custom events: restore mempool selection after refresh, + // then auto-trigger block/mempool load if needed. + if (event == Event::Custom) { + if (mempool_restore_idx >= 0 || !mempool_restore_txid.empty()) { + std::lock_guard lock(browse_mtx); + if (browse_mempool_ss.is_mempool && browse_mempool_ss.found) { + const auto& list = browse_mempool_ss.blk_tx_list; + int n = static_cast(list.size()); + int sel = -1; + for (int i = 0; i < n; ++i) { + if (list[i].txid == mempool_restore_txid) { + sel = i; + break; + } + } + if (sel < 0 && mempool_restore_idx >= 0 && n > 0) + sel = std::min(mempool_restore_idx, n - 1); + if (sel >= 0) + mempool_tx_sel = sel; + mempool_restore_txid.clear(); + mempool_restore_idx = -1; + } } + trigger_browse_if_needed(); + return false; // don't consume — let render proceed } - // arrow-left/arrow-right navigate blocks once in block navigation mode - if (mempool_sel >= 0 && (event == Event::ArrowLeft || event == Event::ArrowRight)) { - int n; - { - std::lock_guard lock(state_mtx); - n = static_cast(state.recent_blocks.size()); + // ↓ focus blocks pane (unfocused→focused) or enter tx list. + if (event == Event::ArrowDown) { + if (mempool_pane == -1) { + mempool_pane = 0; + trigger_browse_if_needed(); + } else if (browse_loaded) { + mempool_pane = 1; + mempool_tx_sel = 0; } - if (event == Event::ArrowLeft) - mempool_sel = std::max(mempool_sel - 1, 0); - else - mempool_sel = std::min(mempool_sel + 1, n - 1); screen.PostEvent(Event::Custom); return true; } - if (event == Event::Return && mempool_sel >= 0) { - std::string height_str; + // ↑ when blocks pane focused, unfocus back to top level. + if (mempool_pane == 0 && event == Event::ArrowUp) { + mempool_pane = -1; + screen.PostEvent(Event::Custom); + return true; + } + // ←/→ navigate blocks — only when blocks pane has focus. + if (mempool_pane == 0 && + (event == Event::ArrowLeft || event == Event::ArrowRight)) { + int n; { std::lock_guard lock(state_mtx); - if (mempool_sel < static_cast(state.recent_blocks.size())) - height_str = std::to_string(state.recent_blocks[mempool_sel].height); - } - if (!height_str.empty()) { - trigger_tx_search(height_str, false); - return true; + n = static_cast(state.recent_blocks.size()); } - } - if (event == Event::Escape && mempool_sel >= 0) { - mempool_sel = -1; + // TODO: allow scrolling the block bar when there are more + // blocks than fit on screen, instead of clamping here. + int max_visible = std::max(2, (Terminal::Size().dimx - 4) / 11) - 1; + int max_sel = std::min(n, max_visible); + int delta = (event == Event::ArrowLeft) ? -1 : 1; + mempool_sel = std::clamp(mempool_sel + delta, 0, max_sel); + mempool_tx_sel = -1; + trigger_browse_if_needed(); screen.PostEvent(Event::Custom); return true; } } + } else { + prev_tab_index = tab_index; } // Normal mode if (event == Event::Character('/')) { @@ -2035,6 +2241,8 @@ static int run(int argc, char* argv[]) { running = false; if (search_thread.joinable()) search_thread.join(); + if (browse_thread.joinable()) + browse_thread.join(); if (broadcast_thread.joinable()) broadcast_thread.join(); if (addnode_thread.joinable()) diff --git a/src/render.cpp b/src/render.cpp index 85126f9..6210723 100644 --- a/src/render.cpp +++ b/src/render.cpp @@ -112,30 +112,186 @@ Element render_dashboard(const AppState& s) { flex; } -// --- Mempool ---------------------------------------------------------------- -Element render_mempool(const AppState& s, int mempool_sel) { - auto stats_section = mempool_stats_box(s); +// --- Explorer pane helpers --------------------------------------------------- + +// Pane 2: summary section — mempool stats or block details. +// mempool_sel: 0 = mempool, >0 = real block at recent_blocks[mempool_sel-1]. +// ss: most recent block/mempool search result (may be stale or absent). +static Element render_summary_pane(const AppState& s, int mempool_sel, const TxSearchState& ss) { + // Mempool selected (or nothing highlighted yet): live stats from AppState. + if (mempool_sel <= 0) + return mempool_stats_box(s); + + int block_idx = mempool_sel - 1; + if (block_idx >= static_cast(s.recent_blocks.size())) + return section_box("Block", {text(" —") | color(Color::GrayDark)}); + + const auto& blk = s.recent_blocks[block_idx]; + + // Always render the full row set so height never changes while loading. + // Use RPC result fields when available, BlockStat fields as immediate fallback. + bool full = ss.is_block && ss.found && ss.blk_height == blk.height; + + auto now = static_cast(std::time(nullptr)); + int64_t age = blk.time > 0 ? std::max(int64_t{0}, now - blk.time) : int64_t{0}; + + std::string time_str = "—"; + std::string diff_str = "—"; + if (full && ss.blk_time > 0) { + auto t = static_cast(ss.blk_time); + auto* tm_ptr = std::localtime(&t); + std::tm tm = tm_ptr ? *tm_ptr : std::tm{}; + std::ostringstream os; + os << std::put_time(&tm, "%Y-%m-%d %H:%M:%S"); + time_str = os.str(); + std::ostringstream ds; + ds << std::fixed << std::setprecision(2) << ss.blk_difficulty / 1e12 << " T"; + diff_str = ds.str(); + age = std::max(int64_t{0}, now - ss.blk_time); + } - // Block visualization — vertical fill bars, one column per block. - Element blocks_section; + return section_box( + "Block", + { + label_value(" Height : ", fmt_height(blk.height)), + label_value(" Hash : ", full ? ss.blk_hash : "—"), + label_value(" Time : ", time_str), + label_value(" Age : ", blk.time > 0 ? fmt_age(age) : "—"), + label_value(" Transactions : ", fmt_int(full ? ss.blk_ntx : blk.txs)), + label_value(" Size : ", fmt_int(full ? ss.blk_size : blk.total_size) + " B"), + label_value(" Weight : ", + fmt_int(full ? ss.blk_weight : blk.total_weight) + " WU"), + label_value(" Difficulty : ", diff_str), + label_value(" Miner : ", full ? ss.blk_miner : "—"), + label_value(" Confirmations: ", full ? fmt_int(ss.blk_confirmations) : "—"), + }); +} + +// Pane 3: transaction list. +static Element render_tx_list_pane(const AppState& s, int mempool_sel, const TxSearchState& ss, + int tx_sel, bool focused, int win_size) { + std::string title = mempool_sel == 0 ? "Mempool Transactions" : "Transactions"; + bool is_mp_ss = ss.is_mempool; + + // Helper: pad rows to win_size so the pane height never changes while loading. + auto padded_placeholder = [&](const std::string& msg) { + Elements rows; + rows.push_back(text(msg) | color(Color::GrayDark)); + while (static_cast(rows.size()) < win_size) + rows.push_back(text("")); + return section_box(title, std::move(rows)); + }; + + if (ss.blk_tx_list.empty()) { + if (ss.is_mempool && !ss.found && !ss.error.empty()) + return padded_placeholder(" " + ss.error); + std::string hint = + mempool_sel == 0 ? (" " + fmt_int(s.mempool_tx) + " txs") : " Loading…"; + return padded_placeholder(hint); + } + + // Ensure the tx list belongs to the current selection. + bool list_matches = (mempool_sel == 0 && is_mp_ss) || + (mempool_sel > 0 && ss.is_block && + mempool_sel - 1 < static_cast(s.recent_blocks.size()) && + ss.blk_height == s.recent_blocks[mempool_sel - 1].height); + if (!list_matches) + return padded_placeholder(" Loading…"); + + int n = static_cast(ss.blk_tx_list.size()); + int win = std::min(n, win_size); + int top = 0; + if (tx_sel >= 0) { + top = std::max(0, tx_sel - win / 2); + top = std::min(top, n - win); + } + + Elements rows; + for (int i = top; i < top + win; ++i) { + const auto& entry = ss.blk_tx_list[i]; + bool is_cb = (!is_mp_ss && i == 0); // coinbase only for blocks + + std::ostringstream pfx; + if (is_cb) + pfx << " [ cb] "; + else + pfx << " [" << std::setw(5) << i << "] "; + + std::ostringstream fee_ss; + if (is_cb || entry.feerate == 0.0) + fee_ss << std::setw(8) << "--"; + else + fee_ss << std::fixed << std::setprecision(2) << std::setw(8) << entry.feerate; + + auto row = hbox({ + text(pfx.str()) | color(Color::GrayDark), + text(fee_ss.str()) | color(Color::GrayDark), + text(" s/vB "), + text(entry.txid) | (is_cb ? color(Color::GrayDark) : color(Color::Default)), + filler(), + }); + if (i == tx_sel) + row = std::move(row) | (focused ? inverted : bold); + rows.push_back(std::move(row)); + } + if (n > win) { + rows.push_back(hbox({filler(), text(std::to_string(top + 1) + "–" + + std::to_string(top + win) + " / " + std::to_string(n)) | + color(Color::GrayDark)})); + } + + if (focused) + title += " ▼"; + return section_box(title, rows); +} + +// --- Explorer (formerly Mempool) -------------------------------------------- +// mempool_sel: 0 = mempool block, 1..N = real blocks (recent_blocks[sel-1]). +// ss: most recent block/mempool browse result (not a tx result). +Element render_mempool(const AppState& s, int mempool_sel, const TxSearchState& ss, int tx_sel, + int active_pane, int& win_size_out) { + + const int BAR_HEIGHT = 6; + const int COL_WIDTH = 10; + const int64_t MAX_WEIGHT = 4'000'000LL; + + // ── Fake mempool column (always leftmost, not animated) ───────────────── + double mp_fill = std::min(1.0, static_cast(s.mempool_bytes) / 1e6); + int mp_filled = static_cast(std::round(mp_fill * BAR_HEIGHT)); + + Elements mp_bar; + for (int r = 0; r < BAR_HEIGHT; ++r) { + bool is_filled = r >= (BAR_HEIGHT - mp_filled); + mp_bar.push_back(is_filled ? text("██████████") | color(Color::Cyan) + : text("░░░░░░░░░░") | color(Color::GrayDark)); + } + bool mp_sel = (mempool_sel == 0); + auto mp_col = + vbox({ + vbox(std::move(mp_bar)), + mp_sel ? text("Mempool") | center | inverted | bold : text("Mempool") | center, + text(fmt_int(s.mempool_tx) + " tx") | center | color(Color::GrayDark), + text(fmt_bytes(s.mempool_bytes)) | center | color(Color::GrayDark), + text("now") | center | color(Color::GrayDark), + }) | + size(WIDTH, EQUAL, COL_WIDTH); + + // ── Real block columns (animated) ────────────────────────────────────── + Element blocks_section; + std::string blocks_title = active_pane == 0 ? "Recent Blocks ▼" : "Recent Blocks"; if (s.recent_blocks.empty()) { - blocks_section = - section_box("Recent Blocks", {text(" Fetching…") | color(Color::GrayDark)}); + blocks_section = section_box( + blocks_title, {text(""), hbox({text(" "), std::move(mp_col), + text(" Fetching…") | color(Color::GrayDark)})}); } else { - const int BAR_HEIGHT = 6; - const int COL_WIDTH = 10; - const int64_t MAX_WEIGHT = 4'000'000LL; - - // Determine animation phase. bool anim_slide = s.block_anim_active && !s.block_anim_old.empty(); - // During slide: render old blocks minus the last (it slides off the right edge). const std::vector& src = anim_slide ? s.block_anim_old : s.recent_blocks; int num = static_cast(src.size()); - int max_cols = std::max(1, (Terminal::Size().dimx - 4) / (COL_WIDTH + 1)); - int max_render = std::min(anim_slide ? std::max(0, num - 1) : num, max_cols); + // Reserve one column for the fake mempool block. + int max_cols = std::max(2, (Terminal::Size().dimx - 4) / (COL_WIDTH + 1)); + int max_render = std::min(anim_slide ? std::max(0, num - 1) : num, max_cols - 1); - // Slide offset grows from 0 → (COL_WIDTH+1) chars over SLIDE_FRAMES frames. int left_pad = 0; if (anim_slide) { double progress = (s.block_anim_frame + 1.0) / BLOCK_ANIM_SLIDE_FRAMES; @@ -165,7 +321,8 @@ Element render_mempool(const AppState& s, int mempool_sel) { if (!block_cols.empty()) block_cols.push_back(text(" ")); - bool is_selected = (i == mempool_sel); + // Real block loop index i maps to mempool_sel = i+1. + bool is_selected = (i + 1 == mempool_sel); block_cols.push_back( vbox({ vbox(std::move(bar)), @@ -178,19 +335,28 @@ Element render_mempool(const AppState& s, int mempool_sel) { size(WIDTH, EQUAL, COL_WIDTH)); } - // Compose row: optional slide-offset pad on the left, then blocks. - Element blocks_row = + Element real_row = left_pad > 0 ? hbox({text(std::string(left_pad, ' ')), hbox(std::move(block_cols))}) : hbox(std::move(block_cols)); - blocks_section = - section_box("Recent Blocks", {text(""), hbox({text(" "), std::move(blocks_row)})}); + blocks_section = section_box( + blocks_title, + {text(""), hbox({text(" "), std::move(mp_col), text(" "), std::move(real_row)})}); } - return vbox({ - stats_section, - blocks_section, - }); + auto summary_pane = render_summary_pane(s, mempool_sel, ss); + + // Measure actual heights so win_size stays correct if these panes ever change. + blocks_section->ComputeRequirement(); + summary_pane->ComputeRequirement(); + // 9 = outer chrome: title bar(3) + tab bar(3) + status bar(3) + // 4 = tx pane own overhead: border(2) + title(1) + scroll indicator(1) + int win_size = std::max(8, Terminal::Size().dimy - 9 - blocks_section->requirement().min_y - + summary_pane->requirement().min_y - 4); + win_size_out = win_size; + + auto tx_pane = render_tx_list_pane(s, mempool_sel, ss, tx_sel, active_pane == 1, win_size); + return vbox({blocks_section, summary_pane, tx_pane}); } // --- Network ---------------------------------------------------------------- diff --git a/src/render.hpp b/src/render.hpp index 815ae21..a686466 100644 --- a/src/render.hpp +++ b/src/render.hpp @@ -13,7 +13,8 @@ ftxui::Element label_value(const std::string& lbl, const std::string& val, ftxui::Color val_color = ftxui::Color::Default); ftxui::Element render_dashboard(const AppState& s); -ftxui::Element render_mempool(const AppState& s, int mempool_sel = -1); +ftxui::Element render_mempool(const AppState& s, int mempool_sel, const TxSearchState& ss, + int tx_sel, int active_pane, int& win_size_out); ftxui::Element render_network(const AppState& s, const std::vector& forks, bool forks_loading); ftxui::Element render_peers(const AppState& s, int selected = -1);