Files
ObsidianDragon/src/ui/pages/settings_page.cpp
DanS de11850c74 refactor(audit): batch 4 — UI design-system helpers + i18n
Mechanical, behavior-preserving consolidations from the audit. Rendering
extractions were kept byte-equivalent; sites that couldn't be made identical
were left as-is (noted below).

Shared helpers:
- util::truncateMiddle(s,maxLen) / (s,front,back) in text_format.h — replaces
  6 file-local middle-ellipsis truncators + several inline substr sites
  (send/receive/transactions/balance_recent_tx/explorer + app.cpp + 3 dialogs).
  Carries the maxLen<=3 guard, fixing the latent unsigned-underflow copies.
- material::LoadingDots() in draw_helpers.h — one animated-ellipsis source for
  8 copy-pasted spinner sites (identical GetTime()*3 phase preserved).
- Reuse the existing FormatHashrate() in explorer_tab + peers_tab (dropped two
  inline hashrate ladders).
- material::DrawButtonGlassOverlay() — the glass-fill/rim/tactile overlay block
  shared by TactileButton / TactileSmallButton / schema TactileButton.
- material::CollapsibleHeader() — the invisible-button + label + chevron idiom
  (3 of 5 settings_page sites; RPC/Debug headers skipped — non-identical).
- material::GlassCardScope (RAII) — the ChannelsSplit/Indent/DrawGlassPanel/
  ChannelsMerge card scaffold (5 settings_page cards; About/send/receive skipped
  — different padding / logo interleaving).
- material::DialogWarningHeader()/DialogConfirmFooter() — warning header +
  50/50 Cancel/danger footer across the confirm dialogs; button height moved
  from a hardcoded 40px into ui.toml (components.overlay-dialog.confirm-btn-height).
- File-local enterLowSpec()/exitLowSpec() collapse the 3 low-spec snapshot copies.

i18n: wrapped hardcoded English in the shared balance render paths and the
whole Lite lifecycle/security section in TR(), with English defaults added to
loadBuiltinEnglish() (res/lang/*.json left for the translation tooling — TR
falls back to English, so output is unchanged).

Full-node + Lite build clean; ctest 1/1; hygiene clean. These are rendering
changes — the Settings page (cards/headers/confirm dialogs), all tactile
buttons, and the Lite settings section warrant a screenshot check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:29:12 -05:00

2821 lines
156 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "settings_page.h"
#include "../../app.h"
#include "../../config/version.h"
#include "../../config/settings.h"
#include "../../wallet/lite_wallet_lifecycle_ui_adapter.h"
#include "../../wallet/lite_wallet_server_selection_adapter.h"
#include "../../wallet/lite_wallet_controller.h"
#include <sodium.h>
#include "../../util/logger.h"
#include "../windows/balance_tab.h"
#include "../windows/console_tab.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../resources/embedded_resources.h"
#include <ctime>
#include "../../rpc/rpc_client.h"
#include "../../rpc/connection.h"
#include "../../rpc/rpc_worker.h"
#include "../theme.h"
#include "../layout.h"
#include "../schema/ui_schema.h"
#include "../schema/skin_manager.h"
#include "../notifications.h"
#include "../effects/imgui_acrylic.h"
#include "../effects/theme_effects.h"
#include "../effects/low_spec.h"
#include "../effects/scroll_fade_shader.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "../windows/validate_address_dialog.h"
#include "../windows/address_book_dialog.h"
#include "../windows/shield_dialog.h"
#include "../windows/request_payment_dialog.h"
#include "../windows/block_info_dialog.h"
#include "../windows/export_all_keys_dialog.h"
#include "../windows/export_transactions_dialog.h"
#include "../windows/bootstrap_download_dialog.h"
#include "../windows/daemon_download_dialog.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <nlohmann/json.hpp>
#include <vector>
#include <set>
#include <filesystem>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstring>
namespace dragonx {
namespace ui {
using namespace material;
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
static std::string TrId(const char* tr_key, const char* id) {
std::string s = TR(tr_key);
s += "##";
s += id;
return s;
}
// ============================================================================
// Settings state loaded from config::Settings on first render
// ============================================================================
struct LowSpecSnapshot {
bool valid = false;
bool acrylic_enabled;
float blur_amount;
float ui_opacity;
float window_opacity;
bool theme_effects_enabled;
bool scanline_enabled;
};
struct SettingsPageState {
bool initialized = false;
int language_index = 0;
bool save_ztxs = true;
bool allow_custom_fees = false;
bool auto_shield = false;
bool fetch_prices = true;
bool use_tor = false;
char rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
char rpc_port[16] = DRAGONX_DEFAULT_RPC_PORT;
char rpc_user[64] = "";
char rpc_password[64] = "";
bool rpc_plaintext_remote = false;
char tx_explorer[256] = "https://explorer.dragonx.is/tx/";
char addr_explorer[256] = "https://explorer.dragonx.is/address/";
bool acrylic_enabled = true;
float blur_amount = 1.5f;
float noise_opacity = 0.5f;
float ui_opacity = 1.0f;
float window_opacity = 1.0f;
std::string balance_layout = "classic";
bool scanline_enabled = true;
bool theme_effects_enabled = true;
bool gradient_background = false;
bool low_spec_mode = false;
bool reduce_motion = false;
float font_scale = 1.0f;
LowSpecSnapshot low_spec_snapshot;
bool keep_daemon_running = false;
bool stop_external_daemon = false;
bool lite_lifecycle_expanded = false;
int lite_lifecycle_operation = 0;
char lite_wallet_path[256] = "";
char lite_lifecycle_passphrase[128] = "";
char lite_restore_seed[512] = "";
int lite_restore_birthday = 0;
int lite_restore_account = 0;
bool lite_restore_overwrite = false;
std::string lite_lifecycle_status;
std::string lite_lifecycle_summary;
// True while an async create/open/restore is in flight (Settings drives the controller's
// async lifecycle path so a flaky server never freezes the UI; the result is polled each frame).
bool lite_lifecycle_pending = false;
// Backup & keys (only populated for an open lite wallet). lite_export_secret holds the
// revealed seed/private-keys backup and is SECRET: securely wiped on hide / new export /
// import. lite_import_key is the import input buffer (wiped right after submission).
std::string lite_export_secret;
std::string lite_export_label;
bool lite_export_is_seed = false; // revealed secret is a seed (show birthday + save-to-file)
unsigned long long lite_export_birthday = 0; // seed birthday (shown with the seed)
char lite_import_key[512] = "";
std::string lite_backup_status;
// Encryption passphrase inputs (SECRET: zeroed right after each action). lite_enc_pass is
// reused for Encrypt (unencrypted wallet) and Unlock (locked wallet); lite_dec_pass for Decrypt.
char lite_enc_pass[128] = "";
char lite_dec_pass[128] = "";
std::string lite_encryption_status;
bool mine_when_idle = false;
int mine_idle_delay = 120;
bool idle_thread_scaling = false;
int idle_threads_active = 0;
int idle_threads_idle = 0;
bool verbose_logging = false;
std::set<std::string> debug_categories;
bool debug_cats_dirty = false;
bool debug_expanded = false;
bool effects_expanded = false;
bool tools_expanded = false;
bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields
bool confirm_clear_ztx = false;
bool confirm_delete_blockchain = false;
bool confirm_rescan = false;
// Rescan dialog: probe the node's available block range so a bootstrapped/pruned node gets a
// runtime rescan from a snapshot-available height instead of the (failing) -rescan-from-genesis.
bool rescan_height_detecting = false;
bool rescan_height_detected = false;
bool rescan_full_history = true; // genesis present → traditional -rescan restart
int rescan_start_height = 0; // editable pre-fill for the runtime rescan
bool confirm_repair_wallet = false;
bool confirm_reinstall_daemon = false;
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
// since reading the installed binary to scan its version is a one-off disk read).
bool daemon_info_loaded = false;
dragonx::resources::DaemonBinaryInfo installed_daemon;
dragonx::resources::BundledDaemonInfo bundled_daemon;
bool confirm_restart_daemon = false;
bool confirm_lite_redownload = false;
effects::ScrollFadeShader fade_shader;
};
static SettingsPageState s_settingsState;
// Low-spec mode enter: snapshot the current effects prefs, then force the
// low-cost overrides. When applyEffects is true, also push the overrides to
// the live effects subsystems (the runtime-sync path passes false since it
// only mirrors an already-applied hotkey toggle into the checkbox state).
static void enterLowSpec(bool applyEffects) {
s_settingsState.low_spec_snapshot.valid = true;
s_settingsState.low_spec_snapshot.acrylic_enabled = s_settingsState.acrylic_enabled;
s_settingsState.low_spec_snapshot.blur_amount = s_settingsState.blur_amount;
s_settingsState.low_spec_snapshot.ui_opacity = s_settingsState.ui_opacity;
s_settingsState.low_spec_snapshot.window_opacity = s_settingsState.window_opacity;
s_settingsState.low_spec_snapshot.theme_effects_enabled = s_settingsState.theme_effects_enabled;
s_settingsState.low_spec_snapshot.scanline_enabled = s_settingsState.scanline_enabled;
s_settingsState.acrylic_enabled = false;
s_settingsState.blur_amount = 0.0f;
s_settingsState.ui_opacity = 1.0f;
s_settingsState.window_opacity = 1.0f;
s_settingsState.theme_effects_enabled = false;
s_settingsState.scanline_enabled = false;
if (applyEffects) {
effects::ImGuiAcrylic::ApplyBlurAmount(0.0f);
effects::ImGuiAcrylic::SetUIOpacity(1.0f);
effects::ThemeEffects::instance().setEnabled(false);
ConsoleTab::s_scanline_enabled = false;
}
}
// Low-spec mode exit: restore the previously snapshotted effects prefs and
// clear the snapshot. When applyEffects is true, also push them to the live
// effects subsystems. Callers guard this on low_spec_snapshot.valid.
static void exitLowSpec(bool applyEffects) {
s_settingsState.blur_amount = s_settingsState.low_spec_snapshot.blur_amount;
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
s_settingsState.ui_opacity = s_settingsState.low_spec_snapshot.ui_opacity;
s_settingsState.window_opacity = s_settingsState.low_spec_snapshot.window_opacity;
s_settingsState.theme_effects_enabled = s_settingsState.low_spec_snapshot.theme_effects_enabled;
s_settingsState.scanline_enabled = s_settingsState.low_spec_snapshot.scanline_enabled;
if (applyEffects) {
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity);
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled;
}
s_settingsState.low_spec_snapshot.valid = false;
}
// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input.
static int liteSeedWordCount(const char* s) {
int words = 0;
bool inWord = false;
for (; s && *s; ++s) {
const bool space = std::isspace(static_cast<unsigned char>(*s)) != 0;
if (space) inWord = false;
else if (!inWord) { inWord = true; ++words; }
}
return words;
}
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
switch (s_settingsState.lite_lifecycle_operation) {
case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting;
case 2: return wallet::LiteWalletLifecycleOperation::RestoreFromSeed;
default: return wallet::LiteWalletLifecycleOperation::CreateNew;
}
}
static void evaluateLiteLifecycleRequestFromPageState(App* app) {
if (!app || !app->settings()) return;
wallet::LiteWalletLifecycleUiExecutionInput input;
input.capabilities = app->walletCapabilities();
input.settingsLoaded = true;
input.requirePersistedServerSelectionIntent = true;
input.ui.selectedServerDisplayReady = true;
input.ui.lifecycleUiOwnerReady = true;
input.ui.operationConfirmed = true;
input.ui.privateDataRedactionReady = true;
input.ui.syncPlannerFeedReady = true;
input.request.requestProvided = true;
input.request.operation = liteLifecycleOperationFromPageState();
switch (input.request.operation) {
case wallet::LiteWalletLifecycleOperation::CreateNew:
input.request.createRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
break;
case wallet::LiteWalletLifecycleOperation::OpenExisting:
input.request.openRequest.walletPath = s_settingsState.lite_wallet_path;
input.request.openRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
break;
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path;
input.request.restoreRequest.seedPhrase = s_settingsState.lite_restore_seed;
input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday));
input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account));
input.request.restoreRequest.overwrite = s_settingsState.lite_restore_overwrite;
break;
}
// Wipe ALL secret material when leaving this function, on every path (real execution,
// validation-only fallback, or early return): the UI char buffers AND the std::string
// copies inside `input.request.*Request`. The controller wipes its own by-value request
// copy, but these page-local copies are separate; leaving them would defeat the wipe.
struct LiteSecretScrubber {
wallet::LiteWalletLifecycleUiExecutionInput& in;
~LiteSecretScrubber() {
sodium_memzero(s_settingsState.lite_lifecycle_passphrase,
sizeof(s_settingsState.lite_lifecycle_passphrase));
sodium_memzero(s_settingsState.lite_restore_seed,
sizeof(s_settingsState.lite_restore_seed));
wallet::secureWipeLiteSecret(in.request.createRequest.passphrase);
wallet::secureWipeLiteSecret(in.request.openRequest.passphrase);
wallet::secureWipeLiteSecret(in.request.restoreRequest.seedPhrase);
wallet::secureWipeLiteSecret(in.request.restoreRequest.passphrase);
}
} liteSecretScrubber{input};
// Restore needs a complete 24-word seed; reject early (the scrubber above still wipes the
// entered secret on this return path).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
if (words != 24) {
s_settingsState.lite_lifecycle_status =
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
s_settingsState.lite_lifecycle_summary.clear();
return;
}
}
// When a linked lite backend is present, execute the operation for real through the App-owned
// controller — ASYNCHRONOUSLY, with server failover, so an unreachable/flaky server never
// freezes the UI. The request (with its secrets) is moved into the controller; the page polls
// the outcome each frame in renderLiteLifecyclePending() below. (Secret wiping of the page char
// buffers + the moved-from request copies is handled by liteSecretScrubber at function exit.)
if (auto* lite = app->liteWallet()) {
bool started = false;
switch (input.request.operation) {
case wallet::LiteWalletLifecycleOperation::CreateNew:
started = lite->beginCreateWalletAsync(std::move(input.request.createRequest));
break;
case wallet::LiteWalletLifecycleOperation::OpenExisting:
started = lite->beginOpenWalletAsync(std::move(input.request.openRequest));
break;
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
started = lite->beginRestoreWalletAsync(std::move(input.request.restoreRequest));
break;
}
if (started) {
s_settingsState.lite_lifecycle_pending = true;
s_settingsState.lite_lifecycle_status = TR("lite_working");
s_settingsState.lite_lifecycle_summary.clear();
} else {
// Rejected before any thread launched (wallet already open, an attempt in flight, or no
// usable server). The controller's status carries the reason.
s_settingsState.lite_lifecycle_status = lite->status().message.empty()
? "Could not start the operation"
: lite->status().message;
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
return;
}
// No linked lite backend to execute against (full-node build, or the lite backend
// failed to load / rollout-disabled). The live path above returns when a backend is
// present, so reaching here means there is nothing to run.
s_settingsState.lite_lifecycle_summary.clear();
s_settingsState.lite_lifecycle_status = "Lite wallet backend unavailable";
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
// (APPEARANCE card now uses ChannelsSplit like all other cards)
static void loadSettingsPageState(config::Settings* settings) {
if (!settings) return;
s_settingsState.save_ztxs = settings->getSaveZtxs();
s_settingsState.allow_custom_fees = settings->getAllowCustomFees();
s_settingsState.auto_shield = settings->getAutoShield();
s_settingsState.fetch_prices = settings->getFetchPrices();
s_settingsState.use_tor = settings->getUseTor();
strncpy(s_settingsState.tx_explorer, settings->getTxExplorerUrl().c_str(), sizeof(s_settingsState.tx_explorer) - 1);
strncpy(s_settingsState.addr_explorer, settings->getAddressExplorerUrl().c_str(), sizeof(s_settingsState.addr_explorer) - 1);
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
std::string current_lang = settings->getLanguage();
if (current_lang.empty()) current_lang = "en";
s_settingsState.language_index = 0;
int idx = 0;
for (const auto& lang : languages) {
if (lang.first == current_lang) {
s_settingsState.language_index = idx;
break;
}
idx++;
}
// Load blur amount directly from saved multiplier
s_settingsState.blur_amount = settings->getBlurMultiplier();
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
s_settingsState.ui_opacity = settings->getUIOpacity();
s_settingsState.window_opacity = settings->getWindowOpacity();
s_settingsState.noise_opacity = settings->getNoiseOpacity();
s_settingsState.gradient_background = settings->getGradientBackground();
s_settingsState.balance_layout = settings->getBalanceLayout();
s_settingsState.scanline_enabled = settings->getScanlineEnabled();
ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled;
s_settingsState.theme_effects_enabled = settings->getThemeEffectsEnabled();
s_settingsState.low_spec_mode = settings->getLowSpecMode();
effects::setLowSpecMode(s_settingsState.low_spec_mode);
s_settingsState.reduce_motion = settings->getReduceMotion();
s_settingsState.font_scale = settings->getFontScale();
Layout::setUserFontScale(s_settingsState.font_scale); // sync with Layout on load
s_settingsState.keep_daemon_running = settings->getKeepDaemonRunning();
s_settingsState.stop_external_daemon = settings->getStopExternalDaemon();
// Lite-server selection is managed entirely by the Network tab (not the Settings page).
s_settingsState.mine_when_idle = settings->getMineWhenIdle();
s_settingsState.mine_idle_delay = settings->getMineIdleDelay();
s_settingsState.idle_thread_scaling = settings->getIdleThreadScaling();
s_settingsState.idle_threads_active = settings->getIdleThreadsActive();
s_settingsState.idle_threads_idle = settings->getIdleThreadsIdle();
s_settingsState.verbose_logging = settings->getVerboseLogging();
s_settingsState.debug_categories = settings->getDebugCategories();
s_settingsState.debug_cats_dirty = false;
s_settingsState.rpc_plaintext_remote = rpc::Connection::usesPlaintextRemote(rpc::Connection::autoDetectConfig());
// Apply loaded visual effects settings
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity);
effects::ImGuiAcrylic::SetNoiseOpacity(s_settingsState.noise_opacity);
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
s_settingsState.initialized = true;
}
static void saveSettingsPageState(config::Settings* settings) {
if (!settings) return;
settings->setTheme(settings->getSkinId());
settings->setSaveZtxs(s_settingsState.save_ztxs);
settings->setAllowCustomFees(s_settingsState.allow_custom_fees);
settings->setAutoShield(s_settingsState.auto_shield);
settings->setFetchPrices(s_settingsState.fetch_prices);
settings->setUseTor(s_settingsState.use_tor);
settings->setTxExplorerUrl(s_settingsState.tx_explorer);
settings->setAddressExplorerUrl(s_settingsState.addr_explorer);
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
auto it = languages.begin();
std::advance(it, s_settingsState.language_index);
if (it != languages.end()) {
settings->setLanguage(it->first);
}
// Visual effects settings
settings->setAcrylicEnabled(s_settingsState.acrylic_enabled);
settings->setAcrylicQuality(s_settingsState.blur_amount > 0.001f ? static_cast<int>(effects::AcrylicQuality::Low) : static_cast<int>(effects::AcrylicQuality::Off));
settings->setBlurMultiplier(s_settingsState.blur_amount);
settings->setUIOpacity(s_settingsState.ui_opacity);
settings->setWindowOpacity(s_settingsState.window_opacity);
settings->setNoiseOpacity(s_settingsState.noise_opacity);
settings->setGradientBackground(s_settingsState.gradient_background);
settings->setScanlineEnabled(s_settingsState.scanline_enabled);
settings->setThemeEffectsEnabled(s_settingsState.theme_effects_enabled);
settings->setLowSpecMode(s_settingsState.low_spec_mode);
settings->setReduceMotion(s_settingsState.reduce_motion);
settings->setFontScale(s_settingsState.font_scale);
settings->setKeepDaemonRunning(s_settingsState.keep_daemon_running);
settings->setStopExternalDaemon(s_settingsState.stop_external_daemon);
// Lite-server selection is owned by the Network tab; the Settings page no longer writes it.
settings->setMineWhenIdle(s_settingsState.mine_when_idle);
settings->setMineIdleDelay(s_settingsState.mine_idle_delay);
settings->setIdleThreadScaling(s_settingsState.idle_thread_scaling);
settings->setIdleThreadsActive(s_settingsState.idle_threads_active);
settings->setIdleThreadsIdle(s_settingsState.idle_threads_idle);
settings->setVerboseLogging(s_settingsState.verbose_logging);
settings->setDebugCategories(s_settingsState.debug_categories);
settings->save();
}
// ============================================================================
// Settings Page Renderer
// ============================================================================
void RenderSettingsPage(App* app) {
// Load settings state on first render
if (!s_settingsState.initialized && app->settings()) {
loadSettingsPageState(app->settings());
}
// Sync low-spec / theme-effects state from runtime each frame
// so that hotkey toggles are reflected in the checkboxes.
{
bool runtimeLowSpec = effects::isLowSpecMode();
if (s_settingsState.low_spec_mode != runtimeLowSpec) {
if (runtimeLowSpec) {
// Hotkey turned low-spec ON — save snapshot, override statics
enterLowSpec(false);
} else if (s_settingsState.low_spec_snapshot.valid) {
// Hotkey turned low-spec OFF — restore snapshot
exitLowSpec(false);
} else if (app->settings()) {
// No snapshot — read prefs from settings file
s_settingsState.blur_amount = app->settings()->getBlurMultiplier();
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
s_settingsState.ui_opacity = app->settings()->getUIOpacity();
s_settingsState.window_opacity = app->settings()->getWindowOpacity();
s_settingsState.theme_effects_enabled = app->settings()->getThemeEffectsEnabled();
s_settingsState.scanline_enabled = app->settings()->getScanlineEnabled();
}
s_settingsState.low_spec_mode = runtimeLowSpec;
}
bool runtimeThemeEffects = effects::ThemeEffects::instance().isEnabled();
if (s_settingsState.theme_effects_enabled != runtimeThemeEffects) {
s_settingsState.theme_effects_enabled = runtimeThemeEffects;
}
}
auto& S = schema::UI();
// Responsive layout — matches other tabs
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
float scrollbarMargin = ImGui::GetStyle().ScrollbarSize + Layout::spacingSm();
float availWidth = contentAvail.x - scrollbarMargin;
float hs = Layout::hScale(availWidth);
float vs = Layout::vScale(contentAvail.y);
float pad = Layout::cardInnerPadding();
float bottomPad = std::max(0.0f, pad - ImGui::GetStyle().ItemSpacing.y);
float gap = Layout::cardGap();
float glassRound = Layout::glassRounding();
char buf[256];
// Label column position — adaptive to width
float labelW = std::max(S.drawElement("components.settings-page", "label-min-width").size, S.drawElement("components.settings-page", "label-width").size * hs);
// Widen labelW if any translated label is wider (prevents overflow)
{
ImFont* mf = Type().body2();
const char* labelKeys[] = {"theme", "balance_layout", "language", "acrylic", "noise",
"ui_opacity", "window_opacity", "font_scale",
"rpc_host", "rpc_port", "rpc_user", "rpc_pass",
"transaction_url", "address_url"};
for (const char* k : labelKeys) {
float tw = mf->CalcTextSizeA(mf->LegacySize, FLT_MAX, 0, TR(k)).x + Layout::spacingMd();
if (tw > labelW) labelW = tw;
}
}
// Input field width — fill remaining space in card
float inputW = std::max(S.drawElement("components.settings-page", "input-min-width").size, availWidth - labelW - pad * 2);
// Scrollable content area — NoBackground matches other tabs
ImGui::BeginChild("##SettingsPageScroll", ImVec2(0, 0), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
// Capture the ACTUAL clip boundaries from inside the child window
ImVec2 childClipMin = ImGui::GetWindowPos();
ImVec2 childClipMax(childClipMin.x + ImGui::GetWindowSize().x,
childClipMin.y + ImGui::GetWindowSize().y);
const float dp = Layout::dpiScale();
const float fadeH = schema::UI().drawElement("components.settings-page", "edge-fade-zone").size * dp;
const float fadeOffTop = schema::UI().drawElement("components.settings-page", "edge-fade-offset-top").size * dp;
const float fadeOffBot = schema::UI().drawElement("components.settings-page", "edge-fade-offset-bottom").size * dp;
// Get draw list AFTER BeginChild so we draw on the child window's list
ImDrawList* dl = ImGui::GetWindowDrawList();
(void)dl; // used by cards below
// Capture ForegroundDrawList vertex start — DrawGlassPanel draws
// theme effects (rainbow border, shimmer, specular glare, edge trace)
// on the ForegroundDrawList; those bypass the shader fade so we'll
// apply a vertex-based alpha fade to them after rendering.
ImDrawList* fgDL = ImGui::GetForegroundDrawList();
int fgVtxStart = fgDL->VtxBuffer.Size;
// --- Shader-based scroll fade: bind custom fragment shader ---
// The shader multiplies output alpha by a smoothstep gradient based
// on screen Y, giving a true per-pixel alpha mask at scroll edges.
float settingsScrollY_pre = ImGui::GetScrollY();
float settingsScrollMaxY_pre = ImGui::GetScrollMaxY();
float settingsFadeTopY = childClipMin.y + fadeOffTop;
float settingsFadeBottomY = childClipMax.y - fadeOffBot;
float settingsFadeZoneTop = (settingsScrollY_pre > 1.0f) ? fadeH : 0.0f;
float settingsFadeZoneBot = (settingsScrollMaxY_pre > 0 && settingsScrollY_pre < settingsScrollMaxY_pre - 1.0f) ? fadeH : 0.0f;
if (fadeH > 0.0f && !s_settingsState.low_spec_mode && s_settingsState.fade_shader.init()) {
s_settingsState.fade_shader.fadeTopY = settingsFadeTopY;
s_settingsState.fade_shader.fadeBottomY = settingsFadeBottomY;
s_settingsState.fade_shader.fadeZoneTop = settingsFadeZoneTop;
s_settingsState.fade_shader.fadeZoneBottom = settingsFadeZoneBot;
s_settingsState.fade_shader.addBind(dl);
}
// Top margin from schema
float topMargin = schema::UI().drawElement("components.settings-page", "top-margin").size;
if (topMargin > 0.0f)
ImGui::Dummy(ImVec2(0, topMargin));
GlassPanelSpec glassSpec;
glassSpec.rounding = glassRound;
ImFont* capFont = Type().caption();
ImFont* body2 = Type().body2();
ImFont* sub1 = Type().subtitle1();
// ====================================================================
// THEME & LANGUAGE — card (draw-first approach; avoids ChannelsSplit
// which breaks BeginCombo popup rendering in some ImGui versions)
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("theme_language"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
float contentW = availWidth - pad * 2;
float comboGap = S.drawElement("components.settings-page", "combo-row-gap").size;
float compactBP = S.drawElement("components.settings-page", "compact-breakpoint").size;
bool wideLayout = availWidth >= compactBP;
float refreshBtnW = S.drawElement("components.settings-page", "refresh-btn-width").size;
// --- Skin data ---
auto& skinMgr = schema::SkinManager::instance();
const auto& skins = skinMgr.available();
std::string active_preview = "DragonX";
bool active_is_custom = false;
for (const auto& skin : skins) {
if (skin.id == skinMgr.activeSkinId()) {
active_preview = skin.name;
active_is_custom = !skin.bundled;
break;
}
}
// --- Language data ---
auto& i18n = util::I18n::instance();
const auto& languages = i18n.getAvailableLanguages();
std::vector<const char*> lang_names;
lang_names.reserve(languages.size());
for (const auto& lang : languages) {
lang_names.push_back(lang.second.c_str());
}
// --- Balance layout data ---
const auto& layouts = GetBalanceLayouts();
std::string balPreview = s_settingsState.balance_layout;
for (const auto& l : layouts) {
if (l.id == s_settingsState.balance_layout) { balPreview = l.name; break; }
}
// --- Theme combo popup (shared between wide and narrow paths) ---
auto renderThemeComboPopup = [&]() {
ImGui::TextDisabled("%s", TR("settings_builtin"));
ImGui::Separator();
for (size_t i = 0; i < skins.size(); i++) {
const auto& skin = skins[i];
if (!skin.bundled) continue;
bool is_selected = (skin.id == skinMgr.activeSkinId());
if (ImGui::Selectable(skin.name.c_str(), is_selected)) {
skinMgr.setActiveSkin(skin.id);
if (app->settings()) {
app->settings()->setSkinId(skin.id);
app->settings()->save();
}
}
if (is_selected) ImGui::SetItemDefaultFocus();
}
bool has_custom = false;
for (const auto& skin : skins) {
if (!skin.bundled) { has_custom = true; break; }
}
if (has_custom) {
ImGui::Spacing();
ImGui::TextDisabled("%s", TR("settings_custom"));
ImGui::Separator();
for (size_t i = 0; i < skins.size(); i++) {
const auto& skin = skins[i];
if (skin.bundled) continue;
bool is_selected = (skin.id == skinMgr.activeSkinId());
if (!skin.valid) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::BeginDisabled(true);
std::string lbl = skin.name + " (invalid)";
ImGui::Selectable(lbl.c_str(), false);
ImGui::EndDisabled();
ImGui::PopStyleColor();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", skin.validationError.c_str());
} else {
std::string lbl = skin.name;
if (!skin.author.empty()) lbl += " (" + skin.author + ")";
if (ImGui::Selectable(lbl.c_str(), is_selected)) {
skinMgr.setActiveSkin(skin.id);
if (app->settings()) {
app->settings()->setSkinId(skin.id);
app->settings()->save();
}
}
if (is_selected) ImGui::SetItemDefaultFocus();
}
}
}
};
if (wideLayout) {
// ============================================================
// Wide: 3 combos on one row + compact 3-column effects grid
// ============================================================
// --- Combo row: Theme | Layout | Language [Refresh] ---
{
ImGui::PushFont(body2);
float lblGap = Layout::spacingXs();
float lblThemeW = ImGui::CalcTextSize(TR("theme")).x + lblGap;
float lblLayoutW = ImGui::CalcTextSize(TR("balance_layout")).x + lblGap;
float lblLangW = ImGui::CalcTextSize(TR("language")).x + lblGap;
float totalFixed = lblThemeW + lblLayoutW + lblLangW
+ comboGap * 2 + Layout::spacingSm() + refreshBtnW;
float comboW = std::max(80.0f, (contentW - totalFixed) / 3.0f);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("theme"));
ImGui::SameLine(0, lblGap);
ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
renderThemeComboPopup();
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_theme_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("balance_layout"));
ImGui::SameLine(0, lblGap);
ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) {
for (const auto& l : layouts) {
if (!l.enabled) continue;
bool selected = (l.id == s_settingsState.balance_layout);
if (ImGui::Selectable(l.name.c_str(), selected)) {
s_settingsState.balance_layout = l.id;
if (app->settings()) {
app->settings()->setBalanceLayout(s_settingsState.balance_layout);
app->settings()->save();
}
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::SameLine(0, comboGap);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("language"));
ImGui::SameLine(0, lblGap);
ImGui::SetNextItemWidth(comboW);
if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(),
static_cast<int>(lang_names.size()))) {
auto it = languages.begin();
std::advance(it, s_settingsState.language_index);
i18n.loadLanguage(it->first);
if (app->settings()) {
app->settings()->setLanguage(it->first);
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
schema::SkinManager::instance().refresh();
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Font Scale slider (always visible) ---
{
ImGui::PushFont(body2);
ImGui::TextUnformatted(TR("font_scale"));
float fontSliderW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW);
ImGui::SetNextItemWidth(fontSliderW);
s_settingsState.font_scale = Layout::userFontScale();
float prev_font_scale = s_settingsState.font_scale;
{
char fs_fmt[16];
snprintf(fs_fmt, sizeof(fs_fmt), "%.2fx", s_settingsState.font_scale);
ImGui::SliderFloat("##FontScale", &s_settingsState.font_scale, 1.0f, 1.5f, fs_fmt,
ImGuiSliderFlags_AlwaysClamp);
}
s_settingsState.font_scale = std::max(1.0f, std::min(1.5f,
std::round(s_settingsState.font_scale * 20.0f) / 20.0f));
if (s_settingsState.font_scale != prev_font_scale)
Layout::setUserFontScaleVisual(s_settingsState.font_scale);
if (ImGui::IsItemDeactivatedAfterEdit()) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Collapsible: Advanced Effects... ---
material::CollapsibleHeader(dl, "##EffectsToggle", TR("advanced_effects"),
s_settingsState.effects_expanded, contentW,
body2, OnSurfaceMedium());
if (s_settingsState.effects_expanded) {
ImGui::PushFont(body2);
// Checkbox row: Low-spec | Console scanline | Theme effects | Gradient background
if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) {
effects::setLowSpecMode(s_settingsState.low_spec_mode);
if (s_settingsState.low_spec_mode) {
enterLowSpec(true);
} else if (s_settingsState.low_spec_snapshot.valid) {
exitLowSpec(true);
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("console_scanline", "scanline").c_str(), &s_settingsState.scanline_enabled)) {
ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled;
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "effects").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
// Row 1: Acrylic preset slider + Noise slider (side by side, labels above)
float effCtrlMinW = S.drawElement("components.settings-page", "effects-input-min-width").size;
float halfW = (contentW - Layout::spacingLg()) * 0.5f;
float ctrlW = std::max(effCtrlMinW, halfW);
float baseX = ImGui::GetCursorScreenPos().x;
float rightX = baseX + ctrlW + Layout::spacingLg();
ImGui::TextUnformatted(TR("acrylic"));
float row1Y = ImGui::GetCursorScreenPos().y;
ImGui::SetNextItemWidth(ctrlW);
{
char blur_fmt[16];
if (s_settingsState.blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
float afterRow1Y = ImGui::GetCursorScreenPos().y;
float lblH = ImGui::GetTextLineHeight() + ImGui::GetStyle().ItemSpacing.y;
ImGui::SetCursorScreenPos(ImVec2(rightX, row1Y - lblH));
ImGui::TextUnformatted(TR("noise"));
ImGui::SetCursorScreenPos(ImVec2(rightX, row1Y));
ImGui::SetNextItemWidth(ctrlW);
{
char noise_fmt[16];
if (s_settingsState.noise_opacity < 0.01f)
snprintf(noise_fmt, sizeof(noise_fmt), "%s", TR("slider_off"));
else
snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_settingsState.noise_opacity * 100.0f);
if (ImGui::SliderFloat("##NoiseOpacity", &s_settingsState.noise_opacity, 0.0f, 1.0f, noise_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
effects::ImGuiAcrylic::SetNoiseOpacity(s_settingsState.noise_opacity);
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow1Y));
ImGui::TextUnformatted(TR("ui_opacity"));
float row2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetNextItemWidth(ctrlW);
{
char uiop_fmt[16];
snprintf(uiop_fmt, sizeof(uiop_fmt), "%.0f%%%%", s_settingsState.ui_opacity * 100.0f);
if (ImGui::SliderFloat("##UIOpacity", &s_settingsState.ui_opacity, 0.3f, 1.0f, uiop_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity);
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
float afterRow2Y = ImGui::GetCursorScreenPos().y;
ImGui::SetCursorScreenPos(ImVec2(rightX, row2Y - lblH));
ImGui::TextUnformatted(TR("window_opacity"));
ImGui::SetCursorScreenPos(ImVec2(rightX, row2Y));
ImGui::SetNextItemWidth(ctrlW);
{
char winop_fmt[16];
snprintf(winop_fmt, sizeof(winop_fmt), "%.0f%%%%", s_settingsState.window_opacity * 100.0f);
if (ImGui::SliderFloat("##WindowOpacity", &s_settingsState.window_opacity, 0.3f, 1.0f, winop_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
saveSettingsPageState(app->settings());
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow2Y));
ImGui::EndDisabled(); // low-spec
ImGui::PopFont();
} // s_settingsState.effects_expanded
} else {
// ============================================================
// Narrow: stacked combos + 2-column effects (original layout)
// ============================================================
// --- Theme row ---
{
ImGui::PushFont(body2);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("theme"));
ImGui::SameLine(labelW);
float themeComboW = std::max(S.drawElement("components.settings-page", "theme-combo-min-width").size,
availWidth - pad * 2 - labelW - refreshBtnW - Layout::spacingSm());
ImGui::SetNextItemWidth(themeComboW);
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
renderThemeComboPopup();
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_theme_hotkey"));
if (active_is_custom) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "*");
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_theme"));
}
ImGui::SameLine();
if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) {
schema::SkinManager::instance().refresh();
Notifications::instance().info("Theme list refreshed");
}
if (ImGui::IsItemHovered()) {
material::Tooltip(TR("tt_scan_themes"),
schema::SkinManager::getUserSkinsDirectory().c_str());
}
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// --- Balance Layout row ---
{
ImGui::PushFont(body2);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("balance_layout"));
ImGui::SameLine(labelW);
ImGui::SetNextItemWidth(std::max(180.0f, inputW));
if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) {
for (const auto& l : layouts) {
if (!l.enabled) continue;
bool selected = (l.id == s_settingsState.balance_layout);
if (ImGui::Selectable(l.name.c_str(), selected)) {
s_settingsState.balance_layout = l.id;
if (app->settings()) {
app->settings()->setBalanceLayout(s_settingsState.balance_layout);
app->settings()->save();
}
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_layout_hotkey"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// --- Language row ---
{
ImGui::PushFont(body2);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("language"));
ImGui::SameLine(labelW);
ImGui::SetNextItemWidth(inputW);
if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(),
static_cast<int>(lang_names.size()))) {
auto it = languages.begin();
std::advance(it, s_settingsState.language_index);
i18n.loadLanguage(it->first);
if (app->settings()) {
app->settings()->setLanguage(it->first);
app->settings()->save();
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Font Scale slider (always visible) ---
{
ImGui::PushFont(body2);
ImGui::TextUnformatted(TR("font_scale"));
float fontSliderW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size,
availWidth - pad * 2);
ImGui::SetNextItemWidth(fontSliderW);
s_settingsState.font_scale = Layout::userFontScale();
float prev_font_scale = s_settingsState.font_scale;
{
char fs_fmt[16];
snprintf(fs_fmt, sizeof(fs_fmt), "%.2fx", s_settingsState.font_scale);
ImGui::SliderFloat("##FontScale", &s_settingsState.font_scale, 1.0f, 1.5f, fs_fmt,
ImGuiSliderFlags_AlwaysClamp);
}
s_settingsState.font_scale = std::max(1.0f, std::min(1.5f,
std::round(s_settingsState.font_scale * 20.0f) / 20.0f));
if (s_settingsState.font_scale != prev_font_scale)
Layout::setUserFontScaleVisual(s_settingsState.font_scale);
if (ImGui::IsItemDeactivatedAfterEdit()) {
Layout::setUserFontScale(s_settingsState.font_scale);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Collapsible: Advanced Effects... ---
{
float narrowContentW = availWidth - pad * 2;
material::CollapsibleHeader(dl, "##EffectsToggleN", TR("advanced_effects"),
s_settingsState.effects_expanded, narrowContentW,
body2, OnSurfaceMedium());
}
if (s_settingsState.effects_expanded) {
ImGui::PushFont(body2);
if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) {
effects::setLowSpecMode(s_settingsState.low_spec_mode);
if (s_settingsState.low_spec_mode) {
enterLowSpec(true);
} else if (s_settingsState.low_spec_snapshot.valid) {
exitLowSpec(true);
}
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec"));
if (ImGui::Checkbox(TrId("settings_gradient_bg", "gradient_bg").c_str(), &s_settingsState.gradient_background)) {
schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(s_settingsState.low_spec_mode);
if (ImGui::Checkbox(TrId("console_scanline", "scanline").c_str(), &s_settingsState.scanline_enabled)) {
ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled;
app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("theme_effects", "theme_fx").c_str(), &s_settingsState.theme_effects_enabled)) {
effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
float ctrlW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size,
availWidth - pad * 2.0f);
ImGui::TextUnformatted(TR("acrylic"));
ImGui::SetNextItemWidth(ctrlW);
{
char blur_fmt[16];
if (s_settingsState.blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur"));
ImGui::TextUnformatted(TR("noise"));
ImGui::SetNextItemWidth(ctrlW);
{
char noise_fmt[16];
if (s_settingsState.noise_opacity < 0.01f)
snprintf(noise_fmt, sizeof(noise_fmt), "%s", TR("slider_off"));
else
snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_settingsState.noise_opacity * 100.0f);
if (ImGui::SliderFloat("##NoiseOpacity", &s_settingsState.noise_opacity, 0.0f, 1.0f, noise_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
effects::ImGuiAcrylic::SetNoiseOpacity(s_settingsState.noise_opacity);
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise"));
ImGui::TextUnformatted(TR("ui_opacity"));
ImGui::SetNextItemWidth(ctrlW);
{
char uiop_fmt[16];
snprintf(uiop_fmt, sizeof(uiop_fmt), "%.0f%%%%", s_settingsState.ui_opacity * 100.0f);
if (ImGui::SliderFloat("##UIOpacity", &s_settingsState.ui_opacity, 0.3f, 1.0f, uiop_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity);
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity"));
ImGui::TextUnformatted(TR("window_opacity"));
ImGui::SetNextItemWidth(ctrlW);
{
char winop_fmt[16];
snprintf(winop_fmt, sizeof(winop_fmt), "%.0f%%%%", s_settingsState.window_opacity * 100.0f);
if (ImGui::SliderFloat("##WindowOpacity", &s_settingsState.window_opacity, 0.3f, 1.0f, winop_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
}
}
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity"));
ImGui::EndDisabled(); // low-spec
ImGui::PopFont();
} // s_settingsState.effects_expanded
}
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// WALLET — card (privacy/daemon toggles + collapsible tools)
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
const ImVec2& cardMin = card.cardMin;
float contentW = availWidth - pad * 2;
// Privacy, Network & Daemon checkboxes — all on one line, shrink text to fit
{
const bool showDaemonOptions = app->supportsFullNodeLifecycleActions();
float cbSpacing = Layout::spacingMd();
float fh = ImGui::GetFrameHeight();
float inner = ImGui::GetStyle().ItemInnerSpacing.x;
auto cbW = [&](const char* label) { return fh + inner + ImGui::CalcTextSize(label).x; };
float totalW = cbW(TR("save_z_transactions")) + cbSpacing
+ cbW(TR("auto_shield")) + cbSpacing
+ cbW(TR("use_tor")) + cbSpacing;
if (showDaemonOptions) {
totalW += cbW(TR("keep_daemon")) + cbSpacing
+ cbW(TR("stop_external")) + cbSpacing;
}
totalW += cbW(TR("verbose_logging"));
float scale = (totalW > contentW) ? contentW / totalW : 1.0f;
if (scale < 1.0f) ImGui::SetWindowFontScale(scale);
float sp = cbSpacing * scale;
ImGui::Checkbox(TrId("save_z_transactions", "save_ztx").c_str(), &s_settingsState.save_ztxs);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("auto_shield", "auto_shld").c_str(), &s_settingsState.auto_shield);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield"));
ImGui::SameLine(0, sp);
ImGui::Checkbox(TrId("use_tor", "tor").c_str(), &s_settingsState.use_tor);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor"));
if (showDaemonOptions) {
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("keep_daemon", "keep_dmn").c_str(), &s_settingsState.keep_daemon_running)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_keep_daemon"));
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("stop_external", "stop_ext").c_str(), &s_settingsState.stop_external_daemon)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_stop_external"));
}
ImGui::SameLine(0, sp);
if (ImGui::Checkbox(TrId("verbose_logging", "verbose").c_str(), &s_settingsState.verbose_logging)) {
dragonx::util::Logger::instance().setVerbose(s_settingsState.verbose_logging);
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_verbose"));
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// --- Collapsible: Tools & Actions... ---
material::CollapsibleHeader(dl, "##ToolsToggle", TR("tools_actions"),
s_settingsState.tools_expanded, contentW,
body2, OnSurfaceMedium());
if (s_settingsState.tools_expanded) {
float btnSpacing = Layout::spacingMd();
int btnsPerRow = (contentW >= 600.0f) ? 3 : 2;
float bw = (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow;
float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(100.0f);
bw = std::max(minBtnW, bw);
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
AddressBookDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button")))
ValidateAddressDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_request_payment"), ImVec2(bw, 0), S.resolveFont("button")))
RequestPaymentDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment"));
if (btnsPerRow >= 3) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } else { ImGui::SameLine(0, btnSpacing); }
if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining"));
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::MergeToAddress);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) {
s_settingsState.confirm_clear_ztx = true;
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx"));
}
// --- Backup & Data (merged in from its former standalone card) ---
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("backup_data"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
{
float btnPad = S.drawElement("components.settings-page", "wallet-btn-padding").sizeOr(24.0f);
const char* r1[] = {TR("settings_import_key"), TR("settings_export_key"), TR("settings_export_all"), TR("settings_backup"), TR("settings_export_csv")};
const char* t1[] = { TR("tt_import_key"), TR("tt_export_key"), TR("tt_export_all"), TR("tt_backup"), TR("tt_export_csv") };
const bool showFullNodeLifecycleActions = app->supportsFullNodeLifecycleActions();
const char* wizLabel = TR("setup_wizard");
const char* bsLabel = TR("download_bootstrap");
float sp = Layout::spacingSm();
ImFont* btnFont = S.resolveFont("button");
float btnPadX = btnPad * 2;
float naturalW = 0;
for (int i = 0; i < 5; i++)
naturalW += ImGui::CalcTextSize(r1[i]).x + btnPadX;
float wizW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(wizLabel).x + btnPadX : 0.0f;
float bsW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(bsLabel).x + btnPadX : 0.0f;
float totalW = naturalW + sp * 5;
if (showFullNodeLifecycleActions) totalW += wizW + bsW + sp * 2;
float scale = (totalW > contentW) ? contentW / totalW : 1.0f;
if (scale < 1.0f) ImGui::SetWindowFontScale(scale);
float scaledSp = sp * scale;
if (TactileButton(r1[0], ImVec2(0, 0), btnFont))
app->showImportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[0]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[1], ImVec2(0, 0), btnFont))
app->showExportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[1]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[2], ImVec2(0, 0), btnFont))
ExportAllKeysDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[2]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[3], ImVec2(0, 0), btnFont))
app->showBackupDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[3]);
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[4], ImVec2(0, 0), btnFont))
ExportTransactionsDialog::show();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[4]);
if (showFullNodeLifecycleActions) {
// Right-align Setup Wizard + Download Bootstrap.
float framePadX2 = ImGui::GetStyle().FramePadding.x * 2.0f;
float curX = ImGui::GetCursorScreenPos().x;
float wizBtnW = ImGui::CalcTextSize(wizLabel).x + framePadX2;
float bsBtnW = ImGui::CalcTextSize(bsLabel).x + framePadX2;
float rightEdge = cardMin.x + availWidth - pad;
float rightGroupW = bsBtnW + scaledSp + wizBtnW;
float groupX = rightEdge - rightGroupW;
if (groupX > curX) {
ImGui::SameLine(0, 0);
ImGui::SetCursorScreenPos(ImVec2(groupX, ImGui::GetCursorScreenPos().y));
} else {
ImGui::SameLine(0, scaledSp);
}
if (TactileButton(bsLabel, ImVec2(0, 0), btnFont))
BootstrapDownloadDialog::show(app);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap"));
ImGui::SameLine(0, scaledSp);
if (TactileButton(wizLabel, ImVec2(0, 0), btnFont))
app->restartWizard();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard"));
}
if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f);
}
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// NODE & SECURITY — card
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node_security"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
float contentW = availWidth - pad * 2;
float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(130.0f);
float btnPad = S.drawElement("components.settings-page", "wallet-btn-padding").sizeOr(24.0f);
auto rowBtnW = [&](std::initializer_list<const char*> labels) -> float {
float maxTextW = 0;
for (auto* l : labels) maxTextW = std::max(maxTextW, ImGui::CalcTextSize(l).x);
return std::max(minBtnW, maxTextW + btnPad * 2);
};
// --- NODE & SECURITY: one full-width card, controls laid out horizontally ---
// A single vertical flow of sub-sections (Node, RPC, Security, Daemon binary),
// each with its overline header and its controls flowing left-to-right to fill
// the card width. No 2x2 grid, no column clips, no masonry placement.
{
ImVec2 sectionOrigin = ImGui::GetCursorScreenPos();
// The lite WALLET block below still positions its rows against a "left"
// column origin/width; in the single-column layout that is simply the full
// content column at the section origin.
const float leftX = sectionOrigin.x;
const float leftColW = contentW;
ImFont* body2Info = S.resolveFont("body2");
if (!body2Info) body2Info = Type().body2();
// Shared SECURITY controls, rendered full-width with horizontal rows. Used by
// both the lite branch (auto-lock + PIN only) and the full-node branch (which
// also gets the RPC-backed encrypt/change/lock/remove block, includeRpcEncrypt).
// `secX` is the row start X (== content-column left); `secColW` its width.
auto renderSecuritySection = [&](float secX, float secColW, bool includeRpcEncrypt) {
// Encrypt / Change passphrase / Lock / Remove — RPC-backed, full-node only.
// In lite the wallet's encryption controls live in the WALLET block above.
if (includeRpcEncrypt) {
bool isEncrypted = app->state().isEncrypted();
bool isLocked = app->state().isLocked();
float secBtnW = std::min(rowBtnW({TR("settings_encrypt_wallet"), TR("settings_change_passphrase"), TR("settings_lock_now")}), (secColW - Layout::spacingMd()) * 0.5f);
ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y));
if (!isEncrypted) {
if (TactileButton(TR("settings_encrypt_wallet"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showEncryptDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_encrypt"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_not_encrypted"));
} else {
if (TactileButton(TR("settings_change_passphrase"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showChangePassphraseDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pass"));
ImGui::SameLine(0, Layout::spacingMd());
if (isLocked) {
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
} else {
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->lockWallet();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
}
// Remove Encryption button — trails the row.
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showDecryptDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
}
}
// Auto-Lock timeout + PIN controls — continue on the same row as the encrypt
// controls (full-node) so the whole SECURITY band fills the width; on lite
// (no encrypt row) they start a fresh row at the section's left edge.
{
float comboW = S.drawElement("components.settings-page", "security-combo-width").sizeOr(120.0f);
int timeout = app->settings()->getAutoLockTimeout();
const char* timeoutLabels[] = { TR("timeout_off"), TR("timeout_1min"), TR("timeout_5min"), TR("timeout_15min"), TR("timeout_30min"), TR("timeout_1hour") };
int timeoutValues[] = { 0, 60, 300, 900, 1800, 3600 };
int selTimeout = 0;
for (int i = 0; i < 6; i++) {
if (timeoutValues[i] == timeout) { selTimeout = i; break; }
}
if (includeRpcEncrypt)
ImGui::SameLine(0, Layout::spacingLg());
else
ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("settings_auto_lock"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::PushItemWidth(comboW);
if (ImGui::Combo("##autolock", &selTimeout, timeoutLabels, 6)) {
app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock"));
ImGui::PopItemWidth();
// PIN unlock controls, trailing the auto-lock combo on the same row.
bool isEncryptedPIN = app->state().isEncrypted();
if (isEncryptedPIN) {
bool hasPIN = app->hasPinVault();
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
ImGui::SameLine(0, Layout::spacingMd());
if (!hasPIN) {
if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinSetupDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin"));
} else {
if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinChangeDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin"));
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
app->showPinRemoveDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin"));
ImGui::SameLine(0, Layout::spacingMd());
ImGui::PushFont(Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
}
} else {
ImGui::SameLine(0, Layout::spacingMd());
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImVec4(1,1,1,0.3f), "%s", TR("settings_encrypt_first_pin"));
}
}
};
if (app->isLiteBuild()) {
// ============================ LITE ============================
// Lite has no daemon: this card holds wallet lifecycle/encryption
// (the "WALLET" block) followed by the shared SECURITY block, both
// rendered full-width in a single column exactly as before.
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
{
ImGui::PushFont(body2Info);
{
float liteLabelW = std::min(leftColW * 0.35f, 132.0f);
float liteInputW = std::max(80.0f, leftColW - liteLabelW - Layout::spacingSm());
// Lite-server selection lives in the dedicated Network tab now.
Type().textColored(TypeStyle::Body2, OnSurfaceMedium(),
TR("lite_servers_network_tab"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (ImGui::Button(TrId("lite_wallet_request", "LiteLifecycleToggle").c_str(), ImVec2(liteInputW, 0))) {
s_settingsState.lite_lifecycle_expanded = !s_settingsState.lite_lifecycle_expanded;
}
if (s_settingsState.lite_lifecycle_expanded) {
const char* lifecycleLabels[] = {TR("lite_op_create"), TR("lite_op_open"), TR("lite_op_restore")};
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_action"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
if (ImGui::BeginCombo("##LiteLifecycleOperation",
lifecycleLabels[std::max(0, std::min(2, s_settingsState.lite_lifecycle_operation))])) {
for (int operationIndex = 0; operationIndex < 3; ++operationIndex) {
const bool selected = s_settingsState.lite_lifecycle_operation == operationIndex;
if (ImGui::Selectable(lifecycleLabels[operationIndex], selected)) {
s_settingsState.lite_lifecycle_operation = operationIndex;
s_settingsState.lite_lifecycle_status.clear();
s_settingsState.lite_lifecycle_summary.clear();
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
if (s_settingsState.lite_lifecycle_operation == 1 ||
s_settingsState.lite_lifecycle_operation == 2) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_wallet_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteWalletPath", s_settingsState.lite_wallet_path,
sizeof(s_settingsState.lite_wallet_path));
}
if (s_settingsState.lite_lifecycle_operation == 2) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_seed_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteRestoreSeed", s_settingsState.lite_restore_seed,
sizeof(s_settingsState.lite_restore_seed),
ImGuiInputTextFlags_Password);
// Live 24-word count so the user knows the seed is complete before restoring.
{
const int wc = liteSeedWordCount(s_settingsState.lite_restore_seed);
char wbuf[32];
snprintf(wbuf, sizeof(wbuf), TR("lite_word_count"), wc);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, wc == 24 ? Success() : Warning(), wbuf);
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_birthday_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(std::min(160.0f, liteInputW));
ImGui::InputInt("##LiteRestoreBirthday", &s_settingsState.lite_restore_birthday);
if (s_settingsState.lite_restore_birthday < 0) s_settingsState.lite_restore_birthday = 0;
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
TR("lite_birthday_hint"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_account_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(std::min(160.0f, liteInputW));
ImGui::InputInt("##LiteRestoreAccount", &s_settingsState.lite_restore_account);
if (s_settingsState.lite_restore_account < 0) s_settingsState.lite_restore_account = 0;
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::Checkbox(TrId("lite_overwrite", "LiteRestoreOverwrite").c_str(),
&s_settingsState.lite_restore_overwrite);
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_passphrase_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteLifecyclePassphrase",
s_settingsState.lite_lifecycle_passphrase,
sizeof(s_settingsState.lite_lifecycle_passphrase),
ImGuiInputTextFlags_Password);
// Poll a completed async create/open/restore (driven by the App-owned
// controller; finalized on the main thread by App::update's
// pumpLifecycleResult()). While in flight the button is disabled.
if (s_settingsState.lite_lifecycle_pending) {
if (auto* lite = app->liteWallet()) {
if (!lite->lifecycleRequestInProgress()) {
s_settingsState.lite_lifecycle_pending = false;
const auto& result = lite->lastLifecycleResult();
s_settingsState.lite_lifecycle_summary = result.bridgeResponseRedacted;
if (result.walletReady) {
s_settingsState.lite_lifecycle_status = TR("lite_wallet_ready");
} else {
s_settingsState.lite_lifecycle_status = result.error.empty()
? result.status.message
: result.error;
Notifications::instance().warning(s_settingsState.lite_lifecycle_status);
}
}
} else {
s_settingsState.lite_lifecycle_pending = false; // backend went away
}
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
const bool liteLifecycleBusy = s_settingsState.lite_lifecycle_pending;
if (liteLifecycleBusy) ImGui::BeginDisabled();
if (TactileButton(TrId("lite_validate", "LiteLifecycleValidate").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
evaluateLiteLifecycleRequestFromPageState(app);
}
if (liteLifecycleBusy) ImGui::EndDisabled();
if (!s_settingsState.lite_lifecycle_status.empty()) {
ImGui::SameLine(0, Layout::spacingSm());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_lifecycle_status.c_str());
}
if (!s_settingsState.lite_lifecycle_summary.empty()) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_lifecycle_summary.c_str());
}
}
// Open the lite wallet data folder in the OS file manager (always available in
// lite). The lite backend stores its wallet in its OWN dir (silentdragonxlite),
// NOT the full-node getDragonXDataDir().
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getLiteWalletDataDir());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
// ---- Backup & keys (open wallet only) ----------------------------------
if (app->liteWallet() && app->liteWallet()->walletOpen()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, TR("lite_backup_keys"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_show_seed", "LiteExportSeed").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
auto r = app->liteWallet()->exportSeed();
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
if (r.ok) {
s_settingsState.lite_export_secret = r.seedPhrase;
s_settingsState.lite_export_label = TR("lite_seed_warning");
s_settingsState.lite_export_is_seed = true;
s_settingsState.lite_export_birthday = r.birthday;
s_settingsState.lite_backup_status.clear();
} else {
s_settingsState.lite_export_label.clear();
s_settingsState.lite_export_is_seed = false;
s_settingsState.lite_backup_status = r.error;
}
wallet::secureWipeLiteSecret(r.seedPhrase); // wipe the result copy
}
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TrId("lite_show_private_keys", "LiteExportKeys").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
auto r = app->liteWallet()->exportPrivateKeys();
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_is_seed = false; // keys, not a seed
if (r.ok) {
s_settingsState.lite_export_secret = r.privateKeysJson;
s_settingsState.lite_export_label = TR("lite_private_keys_warning");
s_settingsState.lite_backup_status.clear();
} else {
s_settingsState.lite_export_label.clear();
s_settingsState.lite_backup_status = r.error;
}
wallet::secureWipeLiteSecret(r.privateKeysJson);
}
// Revealed secret: shown read-only (no extra copies), with copy + wipe.
if (!s_settingsState.lite_export_secret.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_export_label.c_str());
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", s_settingsState.lite_export_secret.c_str());
ImGui::PopTextWrapPos();
// The seed's birthday is needed to restore quickly — show + back it up too.
if (s_settingsState.lite_export_is_seed) {
char bday[64];
snprintf(bday, sizeof(bday), TR("lite_birthday_backup"),
s_settingsState.lite_export_birthday);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), bday);
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_copy", "LiteExportCopy").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
app->copySecretToClipboard(s_settingsState.lite_export_secret);
}
ImGui::SameLine(0, Layout::spacingSm());
// Save the seed (+ birthday) to an owner-only (0600) file in the config dir.
if (s_settingsState.lite_export_is_seed &&
TactileButton(TrId("lite_save_to_file", "LiteExportSave").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
std::string path = util::Platform::getConfigDir() + "/lite-seed-backup.txt";
std::string content = s_settingsState.lite_export_secret + "\nBirthday: " +
std::to_string(s_settingsState.lite_export_birthday) + "\n";
const bool ok = util::Platform::writeFileAtomically(
path, content, /*restrictPermissions=*/true);
wallet::secureWipeLiteSecret(content);
s_settingsState.lite_backup_status = ok
? (std::string(TR("lite_saved_to")) + path)
: (std::string(TR("lite_could_not_write")) + path);
}
if (s_settingsState.lite_export_is_seed) ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton(TrId("lite_hide_wipe", "LiteExportHide").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_label.clear();
s_settingsState.lite_export_is_seed = false;
}
}
// Import a spending/viewing key (history appears after the next sync).
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_import_key_label"));
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteImportKey", s_settingsState.lite_import_key,
sizeof(s_settingsState.lite_import_key),
ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->importKey(s_settingsState.lite_import_key);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key));
s_settingsState.lite_backup_status =
r.ok ? TR("lite_key_imported") : r.error;
}
if (!s_settingsState.lite_backup_status.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_backup_status.c_str());
}
// ---- Security: passphrase encryption (encrypt / unlock / lock / decrypt) ----
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, TR("lite_security"));
const auto& wstate = app->getWalletState();
const float encLabelX = leftX - sectionOrigin.x + liteLabelW;
if (!wstate.isEncrypted()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_passphrase_label"));
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteEncryptPass", s_settingsState.lite_enc_pass,
sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_encrypt_wallet", "LiteEncrypt").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->encryptWallet(s_settingsState.lite_enc_pass);
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
s_settingsState.lite_encryption_status = r.ok ? TR("lite_wallet_encrypted") : r.error;
}
} else {
if (wstate.isLocked()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_unlock"));
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteUnlockPass", s_settingsState.lite_enc_pass,
sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed");
}
} else {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_lock_now", "LiteLock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.lite_encryption_status =
app->liteWallet()->lockWallet() ? TR("lite_wallet_locked") : TR("lite_lock_failed");
}
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("lite_passphrase_label"));
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteDecryptPass", s_settingsState.lite_dec_pass,
sizeof(s_settingsState.lite_dec_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_remove_encryption", "LiteDecrypt").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->decryptWallet(s_settingsState.lite_dec_pass);
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
s_settingsState.lite_encryption_status = r.ok ? TR("lite_encryption_removed") : r.error;
}
}
if (!s_settingsState.lite_encryption_status.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_encryption_status.c_str());
}
// ---- Maintenance: re-download blocks from the lite server ----
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, TR("lite_maintenance"));
const bool scanning = app->liteWallet()->scanInProgress();
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::BeginDisabled(scanning);
if (TactileButton(TR("lite_redownload_blocks"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_lite_redownload = true;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("tt_lite_redownload"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
scanning ? TR("lite_redownload_running") : TR("lite_redownload_desc"));
} else if (!s_settingsState.lite_export_secret.empty()) {
// Wallet closed while a backup/secret was revealed — don't leave it in memory.
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_label.clear();
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
}
} // lite WALLET block
ImGui::PopFont();
} // lite WALLET push-font scope
// ---- SECURITY (lite): auto-lock + PIN only ----
// The wallet's passphrase encryption controls live in the WALLET block
// above (lite backend), so this section shows only the shared auto-lock
// timeout + PIN controls.
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/false);
// Advance to the true bottom of the single column.
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y));
} else {
// ========================= FULL NODE =========================
// One full-width column; each sub-section's controls flow left-to-right.
ImGui::PushFont(body2Info);
const float spMd = Layout::spacingMd();
// -------------------- NODE / DATA --------------------
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
{
std::string dirPath = util::Platform::getDragonXDataDir();
std::string walletPath = dirPath + "wallet.dat";
uint64_t wallet_size = util::Platform::getFileSize(walletPath);
std::string size_str = (wallet_size > 0)
? util::Platform::formatFileSize(wallet_size) : TR("settings_not_found");
// Right-aligned "Open data folder" button trailing the row; the path
// gets whatever width is left after the data-dir label, wallet-size
// label+value, and the trailing button.
ImFont* nodeBtnFont = S.resolveFont("button");
if (!nodeBtnFont) nodeBtnFont = Type().button();
// Measure with the BUTTON font (not the current font) so the width matches
// the real TactileButton size — the old estimate mis-measured and the button
// overflowed the card's right edge, worse at high DPI.
float openBtnW = nodeBtnFont->CalcTextSizeA(nodeBtnFont->LegacySize, FLT_MAX, 0,
TR("settings_open_data_dir")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
// "Open app folder" (the ObsidianDragon config folder), left of the data button.
float appBtnW = nodeBtnFont->CalcTextSizeA(nodeBtnFont->LegacySize, FLT_MAX, 0,
TR("settings_open_app_dir")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
float openGroupW = appBtnW + spMd + openBtnW;
float dataDirLabelW = ImGui::CalcTextSize(TR("settings_data_dir")).x;
float sizeLabelW = ImGui::CalcTextSize(TR("settings_wallet_size_label")).x;
float sizeValueW = ImGui::CalcTextSize(size_str.c_str()).x;
// Space reserved to the right of the path for the size block + both buttons.
float trailW = sizeLabelW + Layout::spacingXs() + sizeValueW + spMd + openGroupW;
float availForPath = contentW - dataDirLabelW - Layout::spacingXs() - spMd - trailW;
if (availForPath < 60.0f) availForPath = contentW - dataDirLabelW - Layout::spacingXs();
ImVec4 linkCol = ImGui::ColorConvertU32ToFloat4(Primary());
ImVec4 linkHoverCol = linkCol;
linkHoverCol.w = std::min(1.0f, linkCol.w + 0.2f);
float rowTopY = ImGui::GetCursorScreenPos().y; // top of the data-dir row
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("settings_data_dir"));
ImGui::SameLine(0, Layout::spacingXs());
ImVec2 pathSize = ImGui::CalcTextSize(dirPath.c_str());
bool hovered = false;
ImGui::AlignTextToFramePadding();
if (pathSize.x > availForPath && availForPath > 0) {
float scale = availForPath / pathSize.x;
float condensedSize = body2Info->LegacySize * std::max(scale, 0.65f);
ImGui::SetWindowFontScale(condensedSize / body2Info->LegacySize);
ImGui::TextColored(linkCol, "%s", dirPath.c_str());
hovered = ImGui::IsItemHovered();
if (hovered) {
ImVec2 tMin = ImGui::GetItemRectMin();
ImVec2 tMax = ImGui::GetItemRectMax();
dl->AddLine(ImVec2(tMin.x, tMax.y), ImVec2(tMax.x, tMax.y), ImGui::GetColorU32(linkHoverCol));
}
ImGui::SetWindowFontScale(1.0f);
} else {
ImGui::TextColored(linkCol, "%s", dirPath.c_str());
hovered = ImGui::IsItemHovered();
if (hovered) {
ImVec2 tMin = ImGui::GetItemRectMin();
ImVec2 tMax = ImGui::GetItemRectMax();
dl->AddLine(ImVec2(tMin.x, tMax.y), ImVec2(tMax.x, tMax.y), ImGui::GetColorU32(linkHoverCol));
}
}
if (hovered) {
material::Tooltip("%s", TR("tt_open_dir"));
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
if (ImGui::IsItemClicked())
util::Platform::openFolder(dirPath);
// Wallet Size label + value, trailing the path on the same row.
ImGui::SameLine(0, spMd);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("settings_wallet_size_label"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::AlignTextToFramePadding();
if (wallet_size > 0)
ImGui::TextUnformatted(size_str.c_str());
else
ImGui::TextDisabled("%s", TR("settings_not_found"));
// Folder buttons: [Open app folder] [Open data folder], right-aligned as a
// group on the data-dir row (rowTopY), each an EXPLICIT width so the group's
// right edge lands exactly on the card edge (no overflow at any DPI).
float rowY = rowTopY;
float rightEdge = sectionOrigin.x + contentW;
float groupX = rightEdge - openGroupW;
float afterSizeX = ImGui::GetItemRectMax().x + spMd;
ImGui::SameLine(0, spMd);
if (groupX > afterSizeX)
ImGui::SetCursorScreenPos(ImVec2(groupX, rowY));
// App (ObsidianDragon config) folder — opens ~/.config/ObsidianDragon etc.
if (TactileButton(TR("settings_open_app_dir"), ImVec2(appBtnW, 0), nodeBtnFont)) {
util::Platform::openFolder(util::Platform::getObsidianDragonDir());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_app_dir"));
ImGui::SameLine(0, spMd);
if (TactileButton(TR("settings_open_data_dir"), ImVec2(openBtnW, 0), nodeBtnFont)) {
util::Platform::openFolder(dirPath);
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
}
// -------------------- RPC (collapsible, like Tools & Actions) --------------------
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
{
ImFont* rpcHdrFont = body2; // match the "Tools & Actions..." toggle style
const char* rpcArrow = s_settingsState.rpc_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
ImGui::PushFont(body2);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
ImVec2 hdrPos = ImGui::GetCursorScreenPos();
if (ImGui::Button("##RpcToggle", ImVec2(contentW, ImGui::GetFrameHeight())))
s_settingsState.rpc_expanded = !s_settingsState.rpc_expanded;
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
float textY = hdrPos.y + (ImGui::GetFrameHeight() - rpcHdrFont->LegacySize) * 0.5f;
dl->AddText(rpcHdrFont, rpcHdrFont->LegacySize, ImVec2(hdrPos.x, textY), OnSurfaceMedium(), TR("rpc_connection"));
ImFont* rpcIconFont = Type().iconSmall();
if (!rpcIconFont) rpcIconFont = body2;
float rpcArrowW = rpcIconFont->CalcTextSizeA(rpcIconFont->LegacySize, FLT_MAX, 0, rpcArrow).x;
dl->AddText(rpcIconFont, rpcIconFont->LegacySize, ImVec2(hdrPos.x + contentW - rpcArrowW, textY), OnSurfaceMedium(), rpcArrow);
ImGui::PopStyleColor(3);
ImGui::PopFont();
}
if (s_settingsState.rpc_expanded) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Four fields (Host, Port, Username, Password) across one row when the
// card is wide enough; otherwise 2x2. Each field is "label [input]".
const char* hostLbl = TR("rpc_host");
const char* portLbl = TR("rpc_port");
const char* userLbl = TR("rpc_user");
const char* passLbl = TR("rpc_pass");
float labelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(portLbl).x +
ImGui::CalcTextSize(userLbl).x + ImGui::CalcTextSize(passLbl).x;
const bool fourAcross = contentW >= 700.0f;
auto field = [&](const char* label, const char* id, char* buf, size_t bufSz,
float inputW, bool password) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputW);
ImGui::InputText(id, buf, bufSz,
password ? ImGuiInputTextFlags_Password : 0);
};
if (fourAcross) {
// fieldW = (contentW - labels - per-field label gaps - 3 inter-field gaps) / 4
float inputTotal = contentW - labelsW - Layout::spacingXs() * 4 - spMd * 3;
float inputW = std::max(60.0f, std::floor(inputTotal / 4.0f));
field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
ImGui::SameLine(0, spMd);
field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
ImGui::SameLine(0, spMd);
field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
ImGui::SameLine(0, spMd);
field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
} else {
// 2x2: two fields per row.
float halfLabelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(userLbl).x;
float inputW = std::max(60.0f, std::floor(
(contentW - halfLabelsW - Layout::spacingXs() * 2 - spMd) / 2.0f));
field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
ImGui::SameLine(0, spMd);
field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
ImGui::SameLine(0, spMd);
field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
}
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("settings_auto_detected"));
if (s_settingsState.rpc_plaintext_remote) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::PushTextWrapPos(sectionOrigin.x + contentW);
ImGui::TextWrapped("Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.");
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
}
// -------------------- SECURITY --------------------
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("security"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/true);
// -------------------- DAEMON BINARY --------------------
if (app->supportsFullNodeLifecycleActions()) {
ImFont* dbBtnFont = S.resolveFont("button");
if (!s_settingsState.daemon_info_loaded) {
s_settingsState.installed_daemon = dragonx::resources::getInstalledDaemonInfo();
s_settingsState.bundled_daemon = dragonx::resources::getBundledDaemonInfo();
s_settingsState.daemon_info_loaded = true;
}
const auto& inst = s_settingsState.installed_daemon;
const auto& bun = s_settingsState.bundled_daemon;
auto fmtDate = [](std::int64_t epoch) -> std::string {
if (epoch <= 0) return "";
std::time_t t = static_cast<std::time_t>(epoch);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tmv);
return std::string(buf);
};
const ImVec4 dbDim = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Inline single line: Installed <v> (<size> · <date>) · Bundled <v> (<size>) · <status>
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("daemon_installed"));
ImGui::SameLine(0, Layout::spacingXs());
if (inst.exists) {
ImGui::TextUnformatted(inst.version.empty() ? TR("unknown") : inst.version.c_str());
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(dbDim, "(%s · %s)",
util::Platform::formatFileSize(inst.size).c_str(),
fmtDate(inst.modifiedEpoch).c_str());
} else {
ImGui::TextColored(dbDim, "%s", TR("daemon_not_installed"));
}
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(dbDim, "·");
ImGui::SameLine(0, Layout::spacingXs());
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("daemon_bundled"));
ImGui::SameLine(0, Layout::spacingXs());
if (bun.available) {
ImGui::TextUnformatted(bun.version.empty() ? TR("unknown") : bun.version.c_str());
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(dbDim, "(%s)", util::Platform::formatFileSize(bun.size).c_str());
} else {
ImGui::TextColored(dbDim, "%s", TR("daemon_none_bundled"));
}
if (bun.available) {
const bool sameSize = inst.exists && inst.size == bun.size;
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(dbDim, "·");
ImGui::SameLine(0, Layout::spacingXs());
if (!inst.exists)
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_missing"));
else if (sameSize)
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("daemon_status_match"));
else
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_differ"));
}
// In-app node update: download + verify the latest dragonxd from the project Gitea.
// Refresh the cached daemon info once an install has completed.
if (ui::DaemonUpdateDialog::consumeInstalled())
s_settingsState.daemon_info_loaded = false;
// Action row: Check for updates | Refresh | Install bundled on the left,
// with the four maintenance actions right-aligned on the same row.
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float daemonBtnRowY = ImGui::GetCursorScreenPos().y;
if (TactileButton(TR("daemon_update_check"), ImVec2(0, 0), dbBtnFont)) {
ui::DaemonUpdateDialog::show(app, inst.exists ? inst.version : std::string());
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_update_check"));
ImGui::SameLine(0, spMd);
// Unique ImGui ID (label still displays "Refresh") — the theme sections
// also have "Refresh" buttons, so a bare label collides on this page.
if (TactileButton((std::string(TR("refresh")) + "##daemonRefresh").c_str(), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.daemon_info_loaded = false;
}
ImGui::SameLine(0, spMd);
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon() || !bun.available);
if (TactileButton(TR("daemon_install_bundled"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_reinstall_daemon = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_install_bundled"));
ImGui::EndDisabled();
// The four maintenance actions (Test connection / Rescan / Delete blockchain
// / Repair wallet), right-aligned on the SAME row as Install bundled — no
// separate "Advanced" toggle. Every BeginDisabled/EndDisabled predicate is
// preserved exactly. Wraps to a right-aligned next row when too narrow.
auto renderDaemonMaintenanceButtons = [&]() {
ImGui::BeginDisabled(!app->isConnected());
if (TactileButton(TR("test_connection"), ImVec2(0, 0), dbBtnFont)) {
if (app->rpc() && app->rpc()->isConnected() && app->worker()) {
app->worker()->post([rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
try {
rpc::RPCClient::TraceScope trace("Settings / Test connection");
rpc->call("getinfo");
return []() { Notifications::instance().success("RPC connection OK"); };
} catch (const std::exception& e) {
std::string err = e.what();
return [err]() { Notifications::instance().error("RPC error: " + err); };
}
});
} else {
Notifications::instance().warning("Not connected to daemon");
}
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn"));
ImGui::EndDisabled();
ImGui::SameLine(0, spMd);
ImGui::BeginDisabled(!app->isConnected());
if (TactileButton(TR("rescan"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_rescan = true;
// Re-probe the available block range each time the dialog is opened.
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = false;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_rescan"));
ImGui::EndDisabled();
ImGui::SameLine(0, spMd);
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
if (TactileButton(TR("delete_blockchain"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_delete_blockchain = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain"));
ImGui::SameLine(0, spMd);
if (TactileButton(TR("repair_wallet"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_repair_wallet = true;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet"));
ImGui::EndDisabled();
};
{
// Group width (button font) to right-align the four maintenance actions.
const char* mLabels[] = { TR("test_connection"), TR("rescan"),
TR("delete_blockchain"), TR("repair_wallet") };
float groupW = spMd * 3.0f;
for (const char* l : mLabels)
groupW += dbBtnFont->CalcTextSizeA(dbBtnFont->LegacySize, FLT_MAX, 0, l).x
+ ImGui::GetStyle().FramePadding.x * 2.0f;
float afterTestX = ImGui::GetItemRectMax().x + spMd; // right edge of Install bundled
float rightEdge = sectionOrigin.x + contentW;
float groupX = rightEdge - groupW;
if (groupX >= afterTestX) {
// Fits on the Install-bundled row, right-aligned.
ImGui::SameLine(0, 0);
ImGui::SetCursorScreenPos(ImVec2(groupX, daemonBtnRowY));
} else {
// Too narrow: wrap to the next row, right-aligned (clamped to left edge).
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
float wrapX = std::max(sectionOrigin.x, rightEdge - groupW);
ImGui::SetCursorScreenPos(ImVec2(wrapX, ImGui::GetCursorScreenPos().y));
}
renderDaemonMaintenanceButtons();
}
}
ImGui::PopFont();
// Advance to the true bottom of the single column.
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y));
}
}
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// EXPLORER & OPTIONS — full-width card
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("explorer_section"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
float contentW = availWidth - pad * 2;
ImGui::PushFont(body2);
// Row 1: Transaction URL | Address URL (side-by-side)
float halfW = (contentW - Layout::spacingLg()) * 0.5f;
float lblTxW = ImGui::CalcTextSize("Transaction URL").x + Layout::spacingXs();
float lblAddrW = ImGui::CalcTextSize("Address URL").x + Layout::spacingXs();
float inputTxW = std::max(80.0f, halfW - lblTxW);
float inputAddrW = std::max(80.0f, halfW - lblAddrW);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("transaction_url"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputTxW);
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
ImGui::SameLine(pad + halfW + Layout::spacingLg());
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("address_url"));
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputAddrW);
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Row 2: Checkboxes + Block Explorer button (on one line)
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
ImGui::SameLine(0, Layout::spacingLg());
if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://explorer.dragonx.is");
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer"));
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// ABOUT — card
// ====================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImVec2 cardMin = ImGui::GetCursorScreenPos();
dl->ChannelsSplit(2);
dl->ChannelsSetCurrent(1);
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad));
ImGui::Indent(pad);
// Logo on the left side of the about card. Deferred: reserve horizontal space
// now, but draw the image after the card's final height is known so it scales to
// the full card height (no empty space below it).
ImTextureID logoTex = app->getLogoTexture();
float logoAreaW = 0;
ImVec2 logoPos = ImGui::GetCursorScreenPos();
float logoAspect = (app->getLogoHeight() > 0)
? (float)app->getLogoWidth() / (float)app->getLogoHeight() : 1.0f;
float logoReserveH = schema::UI().drawElement("components.settings-page", "about-logo-size").sizeOr(150.0f);
if (logoTex != 0) {
logoAreaW = logoReserveH * logoAspect + Layout::spacingLg();
ImGui::Indent(logoAreaW);
}
float contentW = availWidth - pad * 2 - logoAreaW;
// App name + version on same line
ImGui::PushFont(sub1);
ImGui::TextUnformatted(DRAGONX_APP_NAME);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingLg());
ImGui::PushFont(body2);
snprintf(buf, sizeof(buf), "v%s", DRAGONX_VERSION);
ImGui::TextUnformatted(buf);
ImGui::SameLine(0, Layout::spacingLg());
snprintf(buf, sizeof(buf), "ImGui %s", IMGUI_VERSION);
ImGui::TextColored(ImVec4(1,1,1,0.4f), "%s", buf);
ImGui::PopFont();
// Daemon version
{
const auto& st = app->state();
if (st.daemon_version > 0) {
int dmaj = st.daemon_version / 1000000;
int dmin = (st.daemon_version / 10000) % 100;
int dpat = (st.daemon_version / 100) % 100;
ImGui::PushFont(body2);
snprintf(buf, sizeof(buf), "%s: %d.%d.%d", TR("daemon_version"), dmaj, dmin, dpat);
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", buf);
ImGui::PopFont();
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushFont(body2);
ImGui::PushTextWrapPos(cardMin.x + availWidth - pad - logoAreaW);
ImGui::TextUnformatted(TR("settings_about_text"));
ImGui::PopTextWrapPos();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::PushFont(capFont);
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_copyright"));
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// Top of the (full-width) buttons row — the deferred logo is clamped to end above
// this Y so the tall left-column logo never overlaps the buttons.
float aboutButtonsTopY = ImGui::GetCursorScreenPos().y;
// Buttons — consistent equal-width row (full card width)
if (logoAreaW > 0) {
ImGui::Unindent(logoAreaW);
}
{
float fullContentW = availWidth - pad * 2;
float aboutBtnW = (fullContentW - Layout::spacingMd() * 3) / 4.0f;
if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://dragonx.is");
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_website"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("report_bug", "about_bug").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues");
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
saveSettingsPageState(app->settings());
Notifications::instance().success("Settings saved");
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings"));
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
if (app->settings()) {
loadSettingsPageState(app->settings());
Notifications::instance().info("Settings reloaded from disk");
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings"));
}
ImGui::Dummy(ImVec2(0, bottomPad));
ImGui::Unindent(pad);
ImVec2 cardMax(cardMin.x + availWidth, ImGui::GetCursorScreenPos().y);
// Draw the logo now that the card height is known — aspect-preserved, capped to the
// reserved width, and clamped to end just above the buttons row so it never overlaps
// the text or the buttons. Still on the content channel (1), above the glass.
if (logoTex != 0) {
float reserveW = logoReserveH * logoAspect;
// Height available above the buttons row (the fix for the logo/buttons overlap).
float logoBottomLimit = aboutButtonsTopY - Layout::spacingSm();
float logoH = std::max(16.0f, logoBottomLimit - logoPos.y);
float logoW = logoH * logoAspect;
if (logoW > reserveW) { logoW = reserveW; logoH = (logoAspect > 0.0f) ? logoW / logoAspect : logoH; }
dl->AddImage(logoTex, logoPos, ImVec2(logoPos.x + logoW, logoPos.y + logoH));
}
dl->ChannelsSetCurrent(0);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
dl->ChannelsMerge();
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
ImGui::Dummy(ImVec2(availWidth, 0));
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================
// DEBUG LOGGING — collapsible card (full-node only: these are dragonxd
// daemon debug= categories written to DRAGONX.conf; lite has no daemon)
// ====================================================================
if (app->supportsFullNodeLifecycleActions()) {
// Clickable header row
ImVec2 headerPos = ImGui::GetCursorScreenPos();
const char* arrow = s_settingsState.debug_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
s_settingsState.debug_expanded = !s_settingsState.debug_expanded;
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
ImGui::PopStyleColor(3);
// Draw overline label + arrow on top of the invisible button
{
ImFont* ovFont = Type().overline();
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(headerPos.x, textY), OnSurfaceMedium(), TR("debug_logging"));
// Use the icon font for the expand/collapse arrow
ImFont* iconFont = Type().iconSmall();
if (!iconFont) iconFont = ovFont;
float arrowW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, arrow).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
}
if (s_settingsState.debug_expanded) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec);
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select"));
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// The 22 dragonxd debug categories
static const char* debugCats[] = {
"addrman", "alert", "bench", "coindb", "db", "estimatefee",
"http", "libevent", "lock", "mempool", "net",
"paymentdisclosure", "pow", "proxy", "prune", "rand",
"reindex", "rpc", "selectcoins", "tor", "zmq", "zrpc"
};
static const char* debugTips[] = {
"Peer address tracking and management",
"Alert system messages",
"Benchmark timings for operations",
"Coin database read/write operations",
"Berkeley DB operations",
"Fee estimation algorithm",
"HTTP RPC server activity",
"Libevent networking library",
"Lock contention debugging",
"Transaction memory pool activity",
"Network connections and messages",
"Payment disclosure protocol",
"Proof-of-work mining activity",
"SOCKS5 proxy connections",
"Block pruning operations",
"Random number generation",
"Blockchain reindexing progress",
"RPC command processing",
"Coin selection for transactions",
"Tor integration and circuit info",
"ZeroMQ notification system",
"Shielded (z-addr) RPC operations"
};
constexpr int numCats = sizeof(debugCats) / sizeof(debugCats[0]);
// Render as a 4-column grid of checkboxes
int columns = 4;
float dbgContentW = availWidth - pad * 2.0f;
float colW = dbgContentW / columns;
for (int i = 0; i < numCats; i++) {
if (i > 0 && (i % columns) != 0) {
ImGui::SameLine(pad + colW * (i % columns));
}
bool enabled = s_settingsState.debug_categories.count(debugCats[i]) > 0;
if (ImGui::Checkbox(debugCats[i], &enabled)) {
if (enabled) {
s_settingsState.debug_categories.insert(debugCats[i]);
} else {
s_settingsState.debug_categories.erase(debugCats[i]);
}
s_settingsState.debug_cats_dirty = true;
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", debugTips[i]);
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// "Restart daemon" button — only active when categories changed
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 218, 0, 255));
ImFont* iconFont = Type().iconSmall();
if (iconFont) {
ImGui::PushFont(iconFont);
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(ICON_MD_INFO);
ImGui::PopFont();
ImGui::SameLine(0, Layout::spacingXs());
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(TR("settings_debug_changed"));
ImGui::PopStyleColor();
ImGui::SameLine();
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_restart_daemon = true;
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
}
}
}
// --- Shader-based scroll fade: unbind (restore ImGui's default shader) ---
if (fadeH > 0.0f && !s_settingsState.low_spec_mode && s_settingsState.fade_shader.ready) {
effects::ScrollFadeShader::addUnbind(dl);
}
// --- Vertex-based alpha fade for ForegroundDrawList theme effects ---
// DrawGlassPanel draws rainbow borders, shimmer, specular glare, and
// edge traces on the ForegroundDrawList which bypasses the shader.
// Apply the same fade boundaries via vertex alpha manipulation.
if (fadeH > 0.0f) {
int fgVtxEnd = fgDL->VtxBuffer.Size;
float safeTop = settingsFadeTopY + settingsFadeZoneTop;
float safeBot = settingsFadeBottomY - settingsFadeZoneBot;
for (int vi = fgVtxStart; vi < fgVtxEnd; vi++) {
ImDrawVert& v = fgDL->VtxBuffer[vi];
if (v.pos.y >= safeTop && v.pos.y <= safeBot)
continue;
float alpha = 1.0f;
if (settingsFadeZoneTop > 0.0f) {
float dTop = v.pos.y - settingsFadeTopY;
if (dTop < settingsFadeZoneTop)
alpha = std::min(alpha, std::max(0.0f, dTop / settingsFadeZoneTop));
}
if (settingsFadeZoneBot > 0.0f) {
float dBot = settingsFadeBottomY - v.pos.y;
if (dBot < settingsFadeZoneBot)
alpha = std::min(alpha, std::max(0.0f, dBot / settingsFadeZoneBot));
}
if (alpha < 1.0f) {
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
a = static_cast<int>(a * alpha);
v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
}
}
}
ImGui::EndChild(); // ##SettingsPageScroll
// Confirmation dialog for clearing z-tx history
if (s_settingsState.confirm_clear_ztx) {
if (BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_settingsState.confirm_clear_ztx, 480.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.6f, 0.0f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning1"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning2"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TR("cancel"), TrId("clear_anyway", "clear_ztx_btn").c_str(),
true, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_clear_ztx = false;
}
if (doConfirm) {
std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json";
if (util::Platform::deleteFile(ztx_file)) {
Notifications::instance().success("Z-transaction history cleared");
} else {
Notifications::instance().info("No history file found");
}
s_settingsState.confirm_clear_ztx = false;
}
EndOverlayDialog();
}
}
// Confirmation dialog for deleting blockchain data
if (s_settingsState.confirm_delete_blockchain) {
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_delete_blockchain_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TR("cancel"), TrId("delete_blockchain_confirm", "del_bc_btn").c_str(),
true, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_delete_blockchain = false;
}
if (doConfirm) {
if (app->supportsFullNodeLifecycleActions())
app->deleteBlockchainData();
s_settingsState.confirm_delete_blockchain = false;
}
EndOverlayDialog();
}
}
// Confirm: rescan blockchain. On a normal (full-history) node this restarts the daemon with
// -rescan; on a bootstrapped/pruned node that would fail (pre-snapshot blocks are absent), so we
// probe the lowest available block height and run a runtime rescanblockchain from a confirmed,
// editable height instead.
if (s_settingsState.confirm_rescan) {
// Kick off the one-shot block-range probe the first frame the dialog is open.
if (!s_settingsState.rescan_height_detecting && !s_settingsState.rescan_height_detected) {
s_settingsState.rescan_height_detecting = true;
app->detectLowestAvailableBlockHeight([](bool ok, int lowest, bool fullHistory) {
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = true;
s_settingsState.rescan_full_history = (!ok) || fullHistory;
s_settingsState.rescan_start_height = (ok && !fullHistory) ? lowest : 0;
});
}
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
ImGui::Spacing();
const bool detecting = s_settingsState.rescan_height_detecting;
const bool bootstrapped = s_settingsState.rescan_height_detected && !s_settingsState.rescan_full_history;
if (detecting) {
ImGui::TextWrapped("%s", TR("rescan_detecting"));
} else if (bootstrapped) {
ImGui::TextWrapped("%s", TR("rescan_bootstrapped_msg"));
ImGui::Spacing();
ImGui::Text("%s", TR("rescan_from_height"));
ImGui::SetNextItemWidth(160.0f);
ImGui::InputInt("##rescanHeight", &s_settingsState.rescan_start_height);
if (s_settingsState.rescan_start_height < 0) s_settingsState.rescan_start_height = 0;
} else {
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(TrId("cancel", "rescan_cancel").c_str(), ImVec2(btnW, 40))) {
s_settingsState.confirm_rescan = false;
}
ImGui::SameLine();
ImGui::BeginDisabled(detecting);
if (ImGui::Button(TrId("rescan", "rescan_confirm").c_str(), ImVec2(btnW, 40))) {
if (bootstrapped) {
app->runtimeRescan(s_settingsState.rescan_start_height);
} else {
app->rescanBlockchain();
}
s_settingsState.confirm_rescan = false;
}
ImGui::EndDisabled();
EndOverlayDialog();
}
}
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
if (s_settingsState.confirm_repair_wallet) {
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TrId("cancel", "repair_wallet_cancel").c_str(),
TrId("repair_wallet", "repair_wallet_confirm").c_str(),
false, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_repair_wallet = false;
}
if (doConfirm) {
app->repairWallet();
s_settingsState.confirm_repair_wallet = false;
}
EndOverlayDialog();
}
}
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
if (s_settingsState.confirm_reinstall_daemon) {
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TrId("cancel", "reinstall_daemon_cancel").c_str(),
TrId("daemon_install_bundled", "reinstall_daemon_confirm").c_str(),
false, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_reinstall_daemon = false;
}
if (doConfirm) {
app->reinstallBundledDaemon();
s_settingsState.daemon_info_loaded = false; // refresh the panel after the swap
s_settingsState.confirm_reinstall_daemon = false;
}
EndOverlayDialog();
}
}
// Confirm: restart daemon (briefly drops the connection to apply changed options)
if (s_settingsState.confirm_restart_daemon) {
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TrId("cancel", "restartd_cancel").c_str(),
TrId("settings_restart_daemon", "restartd_confirm").c_str(),
false, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_restart_daemon = false;
}
if (doConfirm) {
s_settingsState.debug_cats_dirty = false;
app->restartDaemon();
s_settingsState.confirm_restart_daemon = false;
}
EndOverlayDialog();
}
}
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
if (s_settingsState.confirm_lite_redownload) {
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool doCancel = false, doConfirm = false;
material::DialogConfirmFooter(TrId("cancel", "lite_redl_cancel").c_str(),
TrId("lite_redownload_blocks", "lite_redl_confirm").c_str(),
false, doCancel, doConfirm);
if (doCancel) {
s_settingsState.confirm_lite_redownload = false;
}
if (doConfirm) {
if (auto* lite = app->liteWallet()) lite->startRescan();
s_settingsState.confirm_lite_redownload = false;
}
EndOverlayDialog();
}
}
}
} // namespace ui
} // namespace dragonx