From 9e94952e0a5ce8650cc81c472f9225be09a94af9 Mon Sep 17 00:00:00 2001 From: dan_s Date: Tue, 17 Mar 2026 18:49:46 -0500 Subject: [PATCH] v1.1.0: explorer tab, bootstrap fixes, full theme overlay merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explorer tab: - New block explorer tab with search, chain stats, mempool info, recent blocks table, block detail modal with tx expansion - Sidebar nav entry, i18n strings, ui.toml layout values Bootstrap fixes: - Move wizard Done handler into render() — was dead code, preventing startEmbeddedDaemon() and tryConnect() from firing post-wizard - Stop deleting BDB database/ dir during cleanup — caused LSN mismatch that salvaged wallet.dat into wallet.{timestamp}.bak - Add banlist.dat, db.log, .lock to cleanup file list - Fatal extraction failure for blocks/ and chainstate/ files - Verification progress: split SHA-256 (0-50%) and MD5 (50-100%) Theme system: - Expand overlay merge to apply ALL sections (tabs, dialogs, components, screens, flat sections), not just theme+backdrop+effects - Add screens and security section parsing to UISchema - Build-time theme expansion via expand_themes.py (CMake + build.sh) Other: - Version bump to 1.1.0 - WalletState::clear() resets all fields (sync, daemon info, etc.) - Sidebar item-height 42 → 36 --- CMakeLists.txt | 31 +- build.sh | 6 +- res/ObsidianDragon.manifest | 2 +- res/themes/ui.toml | 14 +- scripts/expand_themes.py | 122 ++++ src/app.cpp | 26 +- src/app_wizard.cpp | 15 - src/config/version.h | 6 +- src/data/wallet_state.h | 8 + src/ui/schema/ui_schema.cpp | 56 +- src/ui/sidebar.h | 3 + src/ui/windows/explorer_tab.cpp | 1047 +++++++++++++++++++++++++++++++ src/ui/windows/explorer_tab.h | 15 + src/util/bootstrap.cpp | 70 ++- src/util/bootstrap.h | 8 +- src/util/i18n.cpp | 19 + 16 files changed, 1388 insertions(+), 60 deletions(-) create mode 100644 scripts/expand_themes.py create mode 100644 src/ui/windows/explorer_tab.cpp create mode 100644 src/ui/windows/explorer_tab.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3689097..58038a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ cmake_minimum_required(VERSION 3.20) project(ObsidianDragon - VERSION 1.0.2 + VERSION 1.1.0 LANGUAGES C CXX DESCRIPTION "DragonX Cryptocurrency Wallet" ) @@ -243,6 +243,7 @@ set(APP_SOURCES src/ui/windows/transactions_tab.cpp src/ui/windows/mining_tab.cpp src/ui/windows/peers_tab.cpp + src/ui/windows/explorer_tab.cpp src/ui/windows/market_tab.cpp src/ui/windows/console_tab.cpp src/ui/windows/settings_window.cpp @@ -320,6 +321,7 @@ set(APP_HEADERS src/ui/windows/transactions_tab.h src/ui/windows/mining_tab.h src/ui/windows/peers_tab.h + src/ui/windows/explorer_tab.h src/ui/windows/market_tab.h src/ui/windows/settings_window.h src/ui/windows/about_dialog.h @@ -540,10 +542,29 @@ embed_resource( # Note: xmrig is embedded via build.sh (embedded_data.h) for Windows builds, # following the same pattern as daemon embedding. -# Copy theme files at BUILD time (not just cmake configure time) -# so edits to res/themes/*.toml are picked up by 'make' without re-running cmake. +# Expand and copy theme files at BUILD time — skin files get layout sections +# from ui.toml appended automatically so users can see/edit all properties. +# Source skin files stay minimal; the merged output goes to build/bin/res/themes/. +find_package(Python3 QUIET COMPONENTS Interpreter) +if(NOT Python3_FOUND) + find_program(Python3_EXECUTABLE NAMES python3 python) +endif() file(GLOB THEME_FILES ${CMAKE_SOURCE_DIR}/res/themes/*.toml) -if(THEME_FILES) +if(THEME_FILES AND Python3_EXECUTABLE) + add_custom_command( + OUTPUT ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/themes/.expanded + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/expand_themes.py + ${CMAKE_SOURCE_DIR}/res/themes + ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/themes + COMMAND ${CMAKE_COMMAND} -E touch ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/themes/.expanded + DEPENDS ${THEME_FILES} ${CMAKE_SOURCE_DIR}/scripts/expand_themes.py + COMMENT "Expanding theme files (merging layout from ui.toml)" + ) + add_custom_target(copy_themes ALL DEPENDS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/themes/.expanded) + add_dependencies(ObsidianDragon copy_themes) + message(STATUS " Theme files: ${THEME_FILES} (build-time expansion via Python)") +elseif(THEME_FILES) + # Fallback: plain copy if Python is not available foreach(THEME_FILE ${THEME_FILES}) get_filename_component(THEME_FILENAME ${THEME_FILE} NAME) add_custom_command( @@ -558,7 +579,7 @@ if(THEME_FILES) endforeach() add_custom_target(copy_themes ALL DEPENDS ${THEME_OUTPUTS}) add_dependencies(ObsidianDragon copy_themes) - message(STATUS " Theme files: ${THEME_FILES}") + message(STATUS " Theme files: ${THEME_FILES} (plain copy, Python not found)") endif() # Copy image files (including backgrounds/ subdirectories and logos/) diff --git a/build.sh b/build.sh index ac3ad96..e974db0 100755 --- a/build.sh +++ b/build.sh @@ -553,9 +553,13 @@ HDR echo "};" >> "$GEN/embedded_data.h" # ── Overlay themes ─────────────────────────────────────────────── + # Expand skin files with layout sections from ui.toml before embedding echo -e "\n// ---- Bundled overlay themes ----" >> "$GEN/embedded_data.h" + local THEME_STAGE_DIR="$bd/_expanded_themes" + mkdir -p "$THEME_STAGE_DIR" + python3 "$SCRIPT_DIR/scripts/expand_themes.py" "$SCRIPT_DIR/res/themes" "$THEME_STAGE_DIR" local THEME_TABLE="" THEME_COUNT=0 - for tf in "$SCRIPT_DIR/res/themes"/*.toml; do + for tf in "$THEME_STAGE_DIR"/*.toml; do local tbn=$(basename "$tf") [[ "$tbn" == "ui.toml" ]] && continue local tsym=$(echo "$tbn" | sed 's/[^a-zA-Z0-9]/_/g') diff --git a/res/ObsidianDragon.manifest b/res/ObsidianDragon.manifest index abd5e8a..7856e20 100644 --- a/res/ObsidianDragon.manifest +++ b/res/ObsidianDragon.manifest @@ -5,7 +5,7 @@ diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 728da3c..5c95deb 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -894,6 +894,18 @@ pair-bar-arrow-size = { size = 28.0 } exchange-combo-width = { size = 180.0 } exchange-top-gap = { size = 0.0 } +[tabs.explorer] +search-input-width = { size = 400.0 } +search-button-width = { size = 100.0 } +search-bar-height = { size = 32.0 } +row-height = { size = 32.0 } +row-rounding = { size = 4.0 } +tx-row-height = { size = 28.0 } +label-column = { size = 160.0 } +detail-modal-width = { size = 700.0 } +detail-max-height = { size = 600.0 } +scroll-fade-zone = { size = 24.0 } + [tabs.console] input-area-padding = 8.0 output-line-spacing = 2.0 @@ -1218,7 +1230,7 @@ collapse-anim-speed = { size = 10.0 } auto-collapse-threshold = { size = 800.0 } section-gap = { size = 4.0 } section-label-pad-left = { size = 16.0 } -item-height = { size = 42.0 } +item-height = { size = 36.0 } item-pad-x = { size = 8.0 } min-height = { size = 360.0 } margin-top = { size = -12 } diff --git a/scripts/expand_themes.py b/scripts/expand_themes.py new file mode 100644 index 0000000..cb3e797 --- /dev/null +++ b/scripts/expand_themes.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Build-time theme expander — merges layout sections from ui.toml into skin files. + +Called by CMake during build. Reads source skin files + ui.toml, writes merged +output files to the build directory. Source files are never modified. + +Usage: + python3 expand_themes.py + +For each .toml file in source_themes_dir (except ui.toml), the script: + 1. Copies the skin file contents (theme/palette/backdrop/effects) + 2. Appends all layout sections from ui.toml (fonts, tabs, components, etc.) + 3. Writes the merged result to output_themes_dir/ + +ui.toml itself is copied unchanged. +""" + +import re +import sys +import os + +# Sections to SKIP when extracting from ui.toml (theme-specific, already in skins) +SKIP_SECTIONS = {"theme", "theme.palette", "backdrop", "effects"} + + +def extract_layout_sections(ui_toml_path): + """Extract non-theme sections from ui.toml as a string.""" + sections = [] + current_section = None + current_lines = [] + section_re = re.compile(r'^\[{1,2}([^\]]+)\]{1,2}\s*$') + + with open(ui_toml_path, 'r') as f: + for line in f: + m = section_re.match(line.strip()) + if m: + if current_section is not None or current_lines: + sections.append((current_section, current_lines)) + current_section = m.group(1).strip() + current_lines = [line] + else: + current_lines.append(line) + if current_section is not None or current_lines: + sections.append((current_section, current_lines)) + + layout_parts = [] + for section_name, lines in sections: + if section_name is None: + # Preamble: only include top-level key=value lines + kv_lines = [l for l in lines + if l.strip() and not l.strip().startswith('#') and '=' in l] + if kv_lines: + layout_parts.append(''.join(kv_lines)) + continue + if section_name in SKIP_SECTIONS: + continue + layout_parts.append(''.join(lines)) + + return '\n'.join(layout_parts) + + +def main(): + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + src_dir = sys.argv[1] + out_dir = sys.argv[2] + ui_toml = os.path.join(src_dir, "ui.toml") + + if not os.path.exists(ui_toml): + print(f"ERROR: ui.toml not found at {ui_toml}") + sys.exit(1) + + os.makedirs(out_dir, exist_ok=True) + + layout_content = extract_layout_sections(ui_toml) + + separator = ( + "\n# ===========================================================================\n" + "# Layout & Component Properties\n" + "# All values below can be customized per-theme. Edit and save to see\n" + "# changes reflected in the app in real time via hot-reload.\n" + "# ===========================================================================\n\n" + ) + + for fname in sorted(os.listdir(src_dir)): + if not fname.endswith('.toml'): + continue + src_path = os.path.join(src_dir, fname) + dst_path = os.path.join(out_dir, fname) + + if fname == "ui.toml": + # Copy ui.toml unchanged + with open(src_path, 'r') as f: + content = f.read() + with open(dst_path, 'w') as f: + f.write(content) + print(f" COPY {fname}") + continue + + # Skin file — append layout sections + with open(src_path, 'r') as f: + skin = f.read() + if not skin.endswith('\n'): + skin += '\n' + + merged = skin + separator + layout_content + if not merged.endswith('\n'): + merged += '\n' + + with open(dst_path, 'w') as f: + f.write(merged) + lines = merged.count('\n') + print(f" MERGE {fname} → {lines} lines") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/src/app.cpp b/src/app.cpp index 719913f..bc00313 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -17,6 +17,7 @@ #include "ui/windows/transactions_tab.h" #include "ui/windows/mining_tab.h" #include "ui/windows/peers_tab.h" +#include "ui/windows/explorer_tab.h" #include "ui/windows/market_tab.h" #include "ui/windows/settings_window.h" #include "ui/windows/about_dialog.h" @@ -705,6 +706,19 @@ void App::render() return; } + // Handle wizard completion — start daemon and connect + if (wizard_phase_ == WizardPhase::Done) { + wizard_phase_ = WizardPhase::None; + if (!state_.connected) { + if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { + startEmbeddedDaemon(); + } + tryConnect(); + } + settings_->setWizardCompleted(true); + settings_->save(); + } + // Process deferred encryption from wizard (runs in background) processDeferredEncryption(); @@ -1069,6 +1083,9 @@ void App::render() case ui::NavPage::Peers: ui::RenderPeersTab(this); break; + case ui::NavPage::Explorer: + ui::RenderExplorerTab(this); + break; case ui::NavPage::Market: ui::RenderMarketTab(this); break; @@ -1892,10 +1909,11 @@ void App::setCurrentTab(int tab) { ui::NavPage::Receive, // 2 = Receive ui::NavPage::History, // 3 = Transactions ui::NavPage::Mining, // 4 = Mining - ui::NavPage::Peers, // 5 = Peers - ui::NavPage::Market, // 6 = Market - ui::NavPage::Console, // 7 = Console - ui::NavPage::Settings, // 8 = Settings + ui::NavPage::Peers, // 5 = Peers + ui::NavPage::Market, // 6 = Market + ui::NavPage::Console, // 7 = Console + ui::NavPage::Explorer, // 8 = Explorer + ui::NavPage::Settings, // 9 = Settings }; if (tab >= 0 && tab < static_cast(sizeof(kTabMap)/sizeof(kTabMap[0]))) current_page_ = kTabMap[tab]; diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index 9d6e80e..69ebec4 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -94,21 +94,6 @@ void App::renderFirstRunWizard() { ImU32 bgCol = ui::material::Surface(); dl->AddRectFilled(winPos, ImVec2(winPos.x + winSize.x, winPos.y + winSize.y), bgCol); - // Handle Done/None — wizard complete - if (wizard_phase_ == WizardPhase::Done || wizard_phase_ == WizardPhase::None) { - wizard_phase_ = WizardPhase::None; - if (!state_.connected) { - if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { - startEmbeddedDaemon(); - } - tryConnect(); - } - settings_->setWizardCompleted(true); - settings_->save(); - ImGui::End(); - return; - } - // --- Determine which of the 3 masonry sections is focused --- // 0 = Appearance, 1 = Bootstrap, 2 = Encrypt + PIN int focusIdx = 0; diff --git a/src/config/version.h b/src/config/version.h index 92b248c..9aece2b 100644 --- a/src/config/version.h +++ b/src/config/version.h @@ -7,10 +7,10 @@ // !! DO NOT EDIT version.h — it is generated from version.h.in by CMake. // !! Change the version in CMakeLists.txt: project(... VERSION x.y.z ...) -#define DRAGONX_VERSION "1.0.2" +#define DRAGONX_VERSION "1.1.0" #define DRAGONX_VERSION_MAJOR 1 -#define DRAGONX_VERSION_MINOR 0 -#define DRAGONX_VERSION_PATCH 2 +#define DRAGONX_VERSION_MINOR 1 +#define DRAGONX_VERSION_PATCH 0 #define DRAGONX_APP_NAME "ObsidianDragon" #define DRAGONX_ORG_NAME "Hush" diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 53344fe..1b1dd51 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -249,7 +249,15 @@ struct WalletState { void clear() { connected = false; + daemon_version = 0; + daemon_subversion.clear(); + protocol_version = 0; + p2p_port = 0; + longestchain = 0; + notarized = 0; + sync = SyncInfo{}; privateBalance = transparentBalance = totalBalance = 0.0; + unconfirmedBalance = 0.0; encrypted = false; locked = false; unlocked_until = 0; diff --git a/src/ui/schema/ui_schema.cpp b/src/ui/schema/ui_schema.cpp index d393dae..a5b2614 100644 --- a/src/ui/schema/ui_schema.cpp +++ b/src/ui/schema/ui_schema.cpp @@ -75,12 +75,18 @@ bool UISchema::loadFromFile(const std::string& path) { parseSections(static_cast(components), "components"); } + // Parse screens as a 3-level section (screens.loading, screens.first-run, etc.) + if (auto* screens = root["screens"].as_table()) { + parseSections(static_cast(screens), "screens"); + } + // Parse flat sections (2-level: sectionName.elementName → {style object}) for (const auto& flatSection : {"business", "animations", "console", "backdrop", "shutdown", "notifications", "status-bar", "qr-code", "content-area", "style", "responsive", "spacing", "spacing-tokens", "button", "input", "fonts", - "inline-dialogs", "sidebar", "panels", "typography", "effects"}) { + "inline-dialogs", "sidebar", "panels", "typography", + "effects", "security"}) { if (auto* sec = root[flatSection].as_table()) { parseFlatSection(static_cast(sec), flatSection); } @@ -157,12 +163,18 @@ bool UISchema::loadFromString(const std::string& tomlStr, const std::string& lab parseSections(static_cast(components), "components"); } + // Parse screens as a 3-level section (screens.loading, screens.first-run, etc.) + if (auto* screens = root["screens"].as_table()) { + parseSections(static_cast(screens), "screens"); + } + // Parse flat sections (2-level: sectionName.elementName → {style object}) for (const auto& flatSection : {"business", "animations", "console", "backdrop", "shutdown", "notifications", "status-bar", "qr-code", "content-area", "style", "responsive", "spacing", "spacing-tokens", "button", "input", "fonts", - "inline-dialogs", "sidebar", "panels", "typography", "effects"}) { + "inline-dialogs", "sidebar", "panels", "typography", + "effects", "security"}) { if (auto* sec = root[flatSection].as_table()) { parseFlatSection(static_cast(sec), flatSection); } @@ -183,7 +195,7 @@ bool UISchema::loadFromString(const std::string& tomlStr, const std::string& lab } // ============================================================================ -// Overlay merge (visual-only: theme + backdrop) +// Overlay merge — all sections (theme + layout + effects) // ============================================================================ bool UISchema::mergeOverlayFromFile(const std::string& path) { @@ -211,14 +223,40 @@ bool UISchema::mergeOverlayFromFile(const std::string& path) { parseTheme(static_cast(theme)); } - // Merge backdrop section (gradient colors, alpha/transparency values) - if (auto* sec = root["backdrop"].as_table()) { - parseFlatSection(static_cast(sec), "backdrop"); + // Merge breakpoints + globals + if (auto* bp = root["breakpoints"].as_table()) { + parseBreakpoints(static_cast(bp)); + } + if (auto* globals = root["globals"].as_table()) { + parseGlobals(static_cast(globals)); } - // Merge effects section (theme visual effects configuration) - if (auto* sec = root["effects"].as_table()) { - parseFlatSection(static_cast(sec), "effects"); + // Merge tabs, dialogs, components (3-level sections) + if (auto* tabs = root["tabs"].as_table()) { + parseSections(static_cast(tabs), "tabs"); + } + if (auto* dialogs = root["dialogs"].as_table()) { + parseSections(static_cast(dialogs), "dialogs"); + } + if (auto* components = root["components"].as_table()) { + parseSections(static_cast(components), "components"); + } + + // Merge screens (3-level section) + if (auto* screens = root["screens"].as_table()) { + parseSections(static_cast(screens), "screens"); + } + + // Merge all flat sections (2-level) + for (const auto& flatSection : {"business", "animations", "console", + "backdrop", "shutdown", "notifications", "status-bar", + "qr-code", "content-area", "style", "responsive", + "spacing", "spacing-tokens", "button", "input", "fonts", + "inline-dialogs", "sidebar", "panels", "typography", + "effects", "security"}) { + if (auto* sec = root[flatSection].as_table()) { + parseFlatSection(static_cast(sec), flatSection); + } } overlayPath_ = path; diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 605a72b..1e80fd9 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -30,6 +30,7 @@ enum class NavPage { // --- separator --- Console, Peers, + Explorer, Settings, Count_ }; @@ -51,6 +52,7 @@ inline const NavItem kNavItems[] = { { "Market", NavPage::Market, nullptr, "market", nullptr }, { "Console", NavPage::Console, "ADVANCED","console", "advanced" }, { "Network", NavPage::Peers, nullptr, "network", nullptr }, + { "Explorer", NavPage::Explorer, nullptr, "explorer", nullptr }, { "Settings", NavPage::Settings, nullptr, "settings", nullptr }, }; static_assert(sizeof(kNavItems) / sizeof(kNavItems[0]) == (int)NavPage::Count_, @@ -76,6 +78,7 @@ inline const char* GetNavIconMD(NavPage page) case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Console: return ICON_MD_TERMINAL; case NavPage::Peers: return ICON_MD_HUB; + case NavPage::Explorer: return ICON_MD_EXPLORE; case NavPage::Settings: return ICON_MD_SETTINGS; default: return ICON_MD_HOME; } diff --git a/src/ui/windows/explorer_tab.cpp b/src/ui/windows/explorer_tab.cpp new file mode 100644 index 0000000..ca9301a --- /dev/null +++ b/src/ui/windows/explorer_tab.cpp @@ -0,0 +1,1047 @@ +// 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 "../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 + +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; + +// 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 s_recent_blocks; +static int s_last_known_height = 0; +static std::atomic s_pending_block_fetches{0}; + +// 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 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; +} + +// 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 { + 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 void fetchBlockDetailByHash(App* app, const std::string& hash) { + 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, hash]() -> rpc::RPCWorker::MainCb { + json blockResult; + json txResult; + std::string blockErr; + bool gotBlock = false; + bool gotTx = false; + // Try as block hash first + try { + blockResult = rpc->call("getblock", {hash}); + gotBlock = true; + } catch (...) {} + // If not a block hash, try as txid + if (!gotBlock) { + try { + txResult = rpc->call("getrawtransaction", {hash, 1}); + gotTx = true; + } catch (...) {} + } + return [blockResult, txResult, gotBlock, gotTx]() { + if (gotBlock) { + applyBlockDetailResult(blockResult, ""); + } else if (gotTx && !txResult.is_null()) { + s_detail_loading = false; + s_search_loading = false; + s_tx_detail = txResult; + s_tx_loading = false; + s_expanded_tx_idx = 0; + s_search_error.clear(); + s_show_detail_modal = false; + } else { + s_detail_loading = false; + s_search_loading = false; + s_search_error = "No block or transaction found for this hash"; + s_show_detail_modal = false; + } + }; + }); +} + +static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) { + auto* worker = app->worker(); + auto* rpc = app->rpc(); + if (!worker || !rpc || s_pending_block_fetches > 0) return; + + s_recent_blocks.clear(); + s_recent_blocks.resize(count); + s_pending_block_fetches = 1; // single batched fetch + + worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb { + std::vector 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(); + 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(result["tx"].size()); + } catch (...) {} + } + return [results]() { + s_recent_blocks = results; + s_pending_block_fetches = 0; + }; + }); +} + +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 { + 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 { result = rpc->call("getrawtransaction", {txid, 1}); } + catch (...) {} + return [result]() { + s_tx_loading = false; + s_tx_detail = result; + }; + }); +} + +static void performSearch(App* app, const std::string& query) { + if (query.empty()) return; + + // Integer = block height + bool isNumeric = std::all_of(query.begin(), query.end(), ::isdigit); + if (isNumeric && !query.empty()) { + int height = std::stoi(query); + fetchBlockDetail(app, height); + return; + } + + // 64-char hex = block hash or txid + 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) { + fetchBlockDetailByHash(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(); + + 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 - pad * 3 - Type().iconMed()->LegacySize; + if (inputW > maxInputW) inputW = maxInputW; + if (inputW < 80.0f) inputW = 80.0f; + + ImGui::Spacing(); + + // 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); + 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)); + + if ((enter || clicked) && app->rpc() && app->rpc()->isConnected()) { + std::string query(s_search_buf); + while (!query.empty() && query.front() == ' ') query.erase(query.begin()); + while (!query.empty() && query.back() == ' ') query.pop_back(); + performSearch(app, query); + } + + // Loading spinner + if (s_search_loading) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", TR("loading")); + } + + // 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(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + GlassPanelSpec glassSpec; + glassSpec.rounding = Layout::glassRounding(); + + ImFont* ovFont = Type().overline(); + ImFont* capFont = Type().caption(); + ImFont* sub1 = Type().subtitle1(); + + 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; + + ImVec2 basePos = ImGui::GetCursorScreenPos(); + char buf[128]; + + // ── Chain Card (left) ── + { + ImVec2 cardMin = basePos; + 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")); + + float colW = (cardW - pad * 2) / 2.0f; + float ry = cardMin.y + pad * 0.5f + headerH; + + // 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); + + 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); + } + 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); + + 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); + } + 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); + 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")); + + float ry = cardMin.y + pad * 0.5f + headerH; + + // 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); + } + 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(s_mempool_size)); + dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, ry + capFont->LegacySize + Layout::spacingXs()), OnSurface(), sizeStr.c_str()); + } + } + + 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(); + + 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 rowH = 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 blocks; + for (const auto& bs : s_recent_blocks) { + if (bs.height > 0) blocks.push_back(&bs); + } + + // Fixed card height — content scrolls inside + float maxRows = 10.0f; + float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows; + float tableH = headerH + contentH + pad; + + ImVec2 cardMin = ImGui::GetCursorScreenPos(); + 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")); + + // 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; + + 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")); + 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 + 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); + 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() && s_pending_block_fetches > 0) { + ImGui::SetCursorPosY(Layout::spacingMd()); + ImGui::TextDisabled("%s", TR("loading")); + } else { + for (size_t i = 0; i < blocks.size(); i++) { + const auto* bs = blocks[i]; + 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) { + 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) { + childDL->AddRectFilled(rowMin, rowMax, WithAlpha(Primary(), 25), rowRound); + } + + // Click opens block detail modal + if (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", bs->height); + childDL->AddText(sub1, sub1->LegacySize, ImVec2(cardMin.x + colHeight, textY), + hovered ? Primary() : schema::UI().resolveColor("var(--primary-light)"), buf); + + // Tx count + snprintf(buf, sizeof(buf), "%d tx", bs->tx_count); + childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colTxs, textY2), OnSurface(), buf); + + // Size + std::string sizeStr = 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); + 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); + childDL->AddText(body2, body2->LegacySize, ImVec2(cardMin.x + colHash, textY2), + OnSurfaceMedium(), hashDisp.c_str()); + + // Time + const char* timeStr = 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) +{ + const auto& state = app->getWalletState(); + auto* rpc = app->rpc(); + + ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::BeginChild("##ExplorerScroll", avail, false, + ImGuiWindowFlags_NoBackground); + ApplySmoothScroll(); + + float availWidth = ImGui::GetContentRegionAvail().x; + + // Auto-refresh recent blocks when chain height changes + if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height) { + s_last_known_height = state.sync.blocks; + if (rpc && rpc->isConnected()) { + fetchRecentBlocks(app, state.sync.blocks); + fetchMempoolInfo(app); + } + } + + renderSearchBar(app, availWidth); + renderChainStats(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 diff --git a/src/ui/windows/explorer_tab.h b/src/ui/windows/explorer_tab.h new file mode 100644 index 0000000..0359f1b --- /dev/null +++ b/src/ui/windows/explorer_tab.h @@ -0,0 +1,15 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +namespace dragonx { +class App; + +namespace ui { + +void RenderExplorerTab(App* app); + +} // namespace ui +} // namespace dragonx diff --git a/src/util/bootstrap.cpp b/src/util/bootstrap.cpp index 9e320c2..ec2f1ef 100644 --- a/src/util/bootstrap.cpp +++ b/src/util/bootstrap.cpp @@ -405,7 +405,13 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir) if (!mz_zip_reader_extract_to_file(&zip, i, destPath.c_str(), 0)) { DEBUG_LOGF("[Bootstrap] Failed to extract: %s\n", filename.c_str()); - // Non-fatal: continue with remaining files + // Critical chain data must extract successfully + if (filename.rfind("blocks/", 0) == 0 || filename.rfind("chainstate/", 0) == 0) { + setProgress(State::Failed, "Failed to extract critical file: " + filename); + mz_zip_reader_end(&zip); + return false; + } + // Non-fatal for other files: continue with remaining } } @@ -431,7 +437,13 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir) // --------------------------------------------------------------------------- void Bootstrap::cleanChainData(const std::string& dataDir) { - // Directories to remove completely + // Directories to remove completely. + // NOTE: "database" is intentionally NOT removed here. It contains BDB + // environment/log files for wallet.dat. Deleting it while keeping wallet.dat + // creates a BDB LSN mismatch that causes the daemon to "salvage" the wallet + // (renaming it to wallet.{timestamp}.bak and creating an empty replacement). + // The daemon's DB_RECOVER flag in CDBEnv::Open() handles stale environment + // files gracefully when the environment dir still exists. for (const char* subdir : {"blocks", "chainstate", "notarizations"}) { fs::path p = fs::path(dataDir) / subdir; std::error_code ec; @@ -441,14 +453,14 @@ void Bootstrap::cleanChainData(const std::string& dataDir) { } } // Individual files to remove (will be replaced by bootstrap) - for (const char* file : {"fee_estimates.dat", "peers.dat"}) { + for (const char* file : {"fee_estimates.dat", "peers.dat", "banlist.dat", "db.log", ".lock"}) { fs::path p = fs::path(dataDir) / file; std::error_code ec; if (fs::exists(p, ec)) { fs::remove(p, ec); } } - // NEVER remove: wallet.dat, debug.log, .lock, *.conf + // NEVER remove: wallet.dat, debug.log, *.conf } // --------------------------------------------------------------------------- @@ -505,7 +517,7 @@ std::string Bootstrap::parseChecksumFile(const std::string& content) { return {}; } -std::string Bootstrap::computeSHA256(const std::string& filePath) { +std::string Bootstrap::computeSHA256(const std::string& filePath, float pctBase, float pctRange) { FILE* fp = fopen(filePath.c_str(), "rb"); if (!fp) return {}; @@ -530,12 +542,18 @@ std::string Bootstrap::computeSHA256(const std::string& filePath) { // Update progress every ~4MB if (fileSize > 0 && (processed % (4 * 1024 * 1024)) < (long long)sizeof(buf)) { - float pct = (float)(100.0 * processed / fileSize); + float filePct = (float)(100.0 * processed / fileSize); + float overallPct = pctBase + pctRange * (float)processed / (float)fileSize; char msg[128]; snprintf(msg, sizeof(msg), "Verifying SHA-256... %.0f%% (%s / %s)", - pct, formatSize((double)processed).c_str(), + filePct, formatSize((double)processed).c_str(), formatSize((double)fileSize).c_str()); - setProgress(State::Verifying, msg, (double)processed, (double)fileSize); + { + std::lock_guard lk(mutex_); + progress_.state = State::Verifying; + progress_.status_text = msg; + progress_.percent = overallPct; + } } } fclose(fp); @@ -636,7 +654,7 @@ static void md5_final(MD5Context* ctx, uint8_t digest[16]) { } // anon namespace -std::string Bootstrap::computeMD5(const std::string& filePath) { +std::string Bootstrap::computeMD5(const std::string& filePath, float pctBase, float pctRange) { FILE* fp = fopen(filePath.c_str(), "rb"); if (!fp) return {}; @@ -661,12 +679,18 @@ std::string Bootstrap::computeMD5(const std::string& filePath) { // Update progress every ~4MB if (fileSize > 0 && (processed % (4 * 1024 * 1024)) < (long long)sizeof(buf)) { - float pct = (float)(100.0 * processed / fileSize); + float filePct = (float)(100.0 * processed / fileSize); + float overallPct = pctBase + pctRange * (float)processed / (float)fileSize; char msg[128]; snprintf(msg, sizeof(msg), "Verifying MD5... %.0f%% (%s / %s)", - pct, formatSize((double)processed).c_str(), + filePct, formatSize((double)processed).c_str(), formatSize((double)fileSize).c_str()); - setProgress(State::Verifying, msg, (double)processed, (double)fileSize); + { + std::lock_guard lk(mutex_); + progress_.state = State::Verifying; + progress_.status_text = msg; + progress_.percent = overallPct; + } } } fclose(fp); @@ -705,11 +729,23 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b return true; } + // Determine progress ranges: if both checksums exist, split 0-50% / 50-100% + float sha256Base = 0.0f, sha256Range = 0.0f; + float md5Base = 0.0f, md5Range = 0.0f; + if (haveSHA256 && haveMD5) { + sha256Base = 0.0f; sha256Range = 50.0f; + md5Base = 50.0f; md5Range = 50.0f; + } else if (haveSHA256) { + sha256Base = 0.0f; sha256Range = 100.0f; + } else { + md5Base = 0.0f; md5Range = 100.0f; + } + // --- SHA-256 --- if (haveSHA256) { - setProgress(State::Verifying, "Verifying SHA-256..."); + setProgress(State::Verifying, "Verifying SHA-256...", 0, 1); // percent = 0 std::string expected = parseChecksumFile(sha256Content); - std::string actual = computeSHA256(zipPath); + std::string actual = computeSHA256(zipPath, sha256Base, sha256Range); if (cancel_requested_) return false; @@ -735,9 +771,9 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b // --- MD5 --- if (haveMD5) { - setProgress(State::Verifying, "Verifying MD5..."); + setProgress(State::Verifying, "Verifying MD5...", (double)md5Base, 100.0); std::string expected = parseChecksumFile(md5Content); - std::string actual = computeMD5(zipPath); + std::string actual = computeMD5(zipPath, md5Base, md5Range); if (cancel_requested_) return false; @@ -761,7 +797,7 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b DEBUG_LOGF("[Bootstrap] MD5 verified: %s\n", actual.c_str()); } - setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93"); + setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0); return true; } diff --git a/src/util/bootstrap.h b/src/util/bootstrap.h index 7268cbe..3740b41 100644 --- a/src/util/bootstrap.h +++ b/src/util/bootstrap.h @@ -88,12 +88,12 @@ private: std::string downloadSmallFile(const std::string& url); /// Compute SHA-256 of a file, return lowercase hex digest. - /// Updates progress with "Verifying SHA-256..." status during computation. - std::string computeSHA256(const std::string& filePath); + /// pctBase/pctRange map file progress onto a portion of the overall percent. + std::string computeSHA256(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f); /// Compute MD5 of a file, return lowercase hex digest. - /// Updates progress with "Verifying MD5..." status during computation. - std::string computeMD5(const std::string& filePath); + /// pctBase/pctRange map file progress onto a portion of the overall percent. + std::string computeMD5(const std::string& filePath, float pctBase = 0.0f, float pctRange = 100.0f); /// Parse the first hex token from a checksum file (handles " " format). static std::string parseChecksumFile(const std::string& content); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 45603d8..fc66c27 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1068,6 +1068,25 @@ void I18n::loadBuiltinEnglish() strings_["import_key_progress"] = "Importing %d/%d..."; strings_["click_to_copy"] = "Click to copy"; strings_["block_hash_copied"] = "Block hash copied"; + + // Explorer tab + strings_["explorer"] = "Explorer"; + strings_["explorer_search"] = "Search"; + strings_["explorer_chain_stats"] = "Chain"; + strings_["explorer_mempool"] = "Mempool"; + strings_["explorer_mempool_txs"] = "Transactions"; + strings_["explorer_mempool_size"] = "Size"; + strings_["explorer_recent_blocks"] = "Recent Blocks"; + strings_["explorer_block_detail"] = "Block"; + strings_["explorer_block_height"] = "Height"; + strings_["explorer_block_hash"] = "Hash"; + strings_["explorer_block_txs"] = "Transactions"; + strings_["explorer_block_size"] = "Size"; + strings_["explorer_block_time"] = "Time"; + strings_["explorer_block_merkle"] = "Merkle Root"; + strings_["explorer_tx_outputs"] = "Outputs"; + strings_["explorer_tx_size"] = "Size"; + strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash"; } const char* I18n::translate(const char* key) const