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