From 6eeadcb595c70cda24a48ffb83130f61c35dd6d8 Mon Sep 17 00:00:00 2001 From: dan_s Date: Fri, 27 Feb 2026 13:30:06 -0600 Subject: [PATCH] console and mining tab visual improvements --- src/app.cpp | 93 +++++-- src/app.h | 21 +- src/app_network.cpp | 490 ++++++++++++++++++++++++++++++--- src/app_security.cpp | 31 ++- src/config/settings.cpp | 3 + src/config/settings.h | 7 + src/main.cpp | 71 +++++ src/ui/layout.h | 79 +++++- src/ui/material/typography.cpp | 8 +- src/ui/pages/settings_page.cpp | 34 +++ src/ui/windows/console_tab.cpp | 266 +++++++++++------- src/ui/windows/console_tab.h | 12 + src/ui/windows/mining_tab.cpp | 30 +- 13 files changed, 962 insertions(+), 183 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index df574c6..96b20f4 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -104,6 +104,21 @@ bool App::init() DEBUG_LOGF("Warning: Could not load settings, using defaults\n"); } + // Apply saved user font scale so fonts are correct on first reload + { + float fs = settings_->getFontScale(); + if (fs > 1.0f) { + ui::Layout::setUserFontScale(fs); + // Fonts were loaded at default scale in Init(); rebuild now. + auto& typo = ui::material::Typography::instance(); + ImGuiIO& io = ImGui::GetIO(); + typo.reload(io, typo.getDpiScale()); + // Consume the flag so App::update() doesn't double-reload + ui::Layout::consumeUserFontReload(); + DEBUG_LOGF("App: Applied saved font scale %.1fx\n", fs); + } + } + // Ensure ObsidianDragon config directory and template files exist util::Platform::ensureObsidianDragonSetup(); @@ -190,6 +205,39 @@ bool App::init() return true; } +// ============================================================================ +// Pre-frame: font atlas rebuilds (must run BEFORE ImGui::NewFrame) +// ============================================================================ +void App::preFrame() +{ + ImGuiIO& io = ImGui::GetIO(); + + // Hot-reload unified UI schema + { + PERF_SCOPE("PreFrame.SchemaHotReload"); + ui::schema::UISchema::instance().pollForChanges(); + ui::schema::UISchema::instance().applyIfDirty(); + } + + // Refresh balance layout config after schema reload + ui::RefreshBalanceLayoutConfig(); + + // If font sizes changed in the TOML, rebuild the font atlas + if (ui::schema::UISchema::instance().consumeFontsChanged()) { + auto& typo = ui::material::Typography::instance(); + typo.reload(io, typo.getDpiScale()); + DEBUG_LOGF("App: Font atlas rebuilt after hot-reload\n"); + } + + // If the user changed font scale in Settings, rebuild the font atlas + if (ui::Layout::consumeUserFontReload()) { + auto& typo = ui::material::Typography::instance(); + typo.reload(io, typo.getDpiScale()); + DEBUG_LOGF("App: Font atlas rebuilt after user font-scale change (%.1fx)\n", + ui::Layout::userFontScale()); + } +} + void App::update() { PERF_SCOPE("Update.Total"); @@ -206,6 +254,9 @@ void App::update() if (worker_) { worker_->drainResults(); } + if (fast_worker_) { + fast_worker_->drainResults(); + } // Auto-lock check (only when connected + encrypted + unlocked) if (state_.connected && state_.isUnlocked()) { @@ -218,23 +269,6 @@ void App::update() state_.rebuildAddressList(); } - // Hot-reload unified UI schema - { - PERF_SCOPE("Update.SchemaHotReload"); - ui::schema::UISchema::instance().pollForChanges(); - ui::schema::UISchema::instance().applyIfDirty(); - } - - // Refresh balance layout config after schema reload - ui::RefreshBalanceLayoutConfig(); - - // If font sizes changed in the JSON, rebuild the font atlas - if (ui::schema::UISchema::instance().consumeFontsChanged()) { - auto& typo = ui::material::Typography::instance(); - typo.reload(io, typo.getDpiScale()); - DEBUG_LOGF("App: Font atlas rebuilt after hot-reload\n"); - } - // Update timers refresh_timer_ += io.DeltaTime; price_timer_ += io.DeltaTime; @@ -775,7 +809,12 @@ void App::render() ui::RenderMarketTab(this); break; case ui::NavPage::Console: - console_tab_.render(embedded_daemon_.get(), rpc_.get(), worker_.get(), xmrig_manager_.get()); + // Use fast-lane worker for console commands to avoid head-of-line + // blocking behind the consolidated refreshData() batch. + console_tab_.render(embedded_daemon_.get(), + fast_rpc_ ? fast_rpc_.get() : rpc_.get(), + fast_worker_ ? fast_worker_.get() : worker_.get(), + xmrig_manager_.get()); break; case ui::NavPage::Settings: ui::RenderSettingsPage(this); @@ -1723,6 +1762,9 @@ void App::beginShutdown() if (worker_) { worker_->requestStop(); } + if (fast_worker_) { + fast_worker_->requestStop(); + } // Stop xmrig pool miner before stopping the daemon if (xmrig_manager_ && xmrig_manager_->isRunning()) { @@ -2260,6 +2302,9 @@ void App::shutdown() if (worker_) { worker_->stop(); } + if (fast_worker_) { + fast_worker_->stop(); + } if (settings_) { settings_->save(); } @@ -2269,6 +2314,9 @@ void App::shutdown() if (rpc_) { rpc_->disconnect(); } + if (fast_rpc_) { + fast_rpc_->disconnect(); + } return; } @@ -2288,10 +2336,16 @@ void App::shutdown() if (worker_) { worker_->stop(); } + if (fast_worker_) { + fast_worker_->stop(); + } // Disconnect RPC after worker is fully stopped (safe — no curl in flight) if (rpc_) { rpc_->disconnect(); } + if (fast_rpc_) { + fast_rpc_->disconnect(); + } } // =========================================================================== @@ -2310,7 +2364,8 @@ bool App::hasPinVault() const { } bool App::hasPendingRPCResults() const { - return worker_ && worker_->hasPendingResults(); + return (worker_ && worker_->hasPendingResults()) + || (fast_worker_ && fast_worker_->hasPendingResults()); } void App::restartDaemon() { diff --git a/src/app.h b/src/app.h index e4ec8c9..2ae2577 100644 --- a/src/app.h +++ b/src/app.h @@ -11,6 +11,7 @@ #include #include #include "data/wallet_state.h" +#include "rpc/connection.h" #include "ui/sidebar.h" #include "ui/windows/console_tab.h" #include "imgui.h" @@ -20,7 +21,6 @@ namespace dragonx { namespace rpc { class RPCClient; class RPCWorker; - struct ConnectionConfig; } namespace config { class Settings; } namespace daemon { class EmbeddedDaemon; class XmrigManager; } @@ -79,6 +79,15 @@ public: */ void update(); + /** + * @brief Pre-frame tasks that must run BEFORE ImGui::NewFrame(). + * + * Font atlas rebuilds (hot-reload, user font scale changes) must + * happen before NewFrame() because NewFrame() caches font pointers. + * Rebuilding mid-frame causes dangling pointer crashes. + */ + void preFrame(); + /** * @brief Render the application UI (called every frame) */ @@ -313,6 +322,16 @@ private: // Subsystems std::unique_ptr rpc_; std::unique_ptr worker_; + + // Fast-lane: dedicated RPC connection + worker for 1-second mining polls. + // Runs on its own thread with its own curl handle so it never blocks behind + // the main refresh batch. + std::unique_ptr fast_rpc_; + std::unique_ptr fast_worker_; + + // Saved connection credentials (needed to open the fast-lane connection) + rpc::ConnectionConfig saved_config_; + std::unique_ptr settings_; std::unique_ptr embedded_daemon_; std::unique_ptr xmrig_manager_; diff --git a/src/app_network.cpp b/src/app_network.cpp index 8f71d86..37e3fa2 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -84,8 +84,9 @@ void App::tryConnect() worker_->post([this, config, daemonStarting, externalDetected]() -> rpc::RPCWorker::MainCb { bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword); - return [this, connected, daemonStarting, externalDetected]() { + return [this, config, connected, daemonStarting, externalDetected]() { if (connected) { + saved_config_ = config; // save for fast-lane connection onConnected(); } else { if (daemonStarting) { @@ -194,6 +195,29 @@ void App::onConnected() // Addresses are unknown on fresh connect — force a fetch addresses_dirty_ = true; + // Start the fast-lane RPC connection (dedicated to 1-second mining polls). + // Uses its own curl handle + worker thread so getlocalsolps never blocks + // behind the main refresh batch. + if (!fast_rpc_) { + fast_rpc_ = std::make_unique(); + } + if (!fast_worker_) { + fast_worker_ = std::make_unique(); + fast_worker_->start(); + } + // Connect on the fast worker's own thread (non-blocking to main) + fast_worker_->post([this]() -> rpc::RPCWorker::MainCb { + bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port, + saved_config_.rpcuser, saved_config_.rpcpassword); + return [ok]() { + if (!ok) { + DEBUG_LOGF("[FastLane] Failed to connect secondary RPC client\\n"); + } else { + DEBUG_LOGF("[FastLane] Secondary RPC client connected\\n"); + } + }; + }); + // Initial data refresh refreshData(); refreshMarketData(); @@ -204,6 +228,14 @@ void App::onDisconnected(const std::string& reason) state_.connected = false; state_.clear(); connection_status_ = reason; + + // Tear down the fast-lane connection + if (fast_worker_) { + fast_worker_->stop(); + } + if (fast_rpc_) { + fast_rpc_->disconnect(); + } } // ============================================================================ @@ -217,35 +249,424 @@ void App::refreshData() // Prevent overlapping refreshes — skip if one is still running if (refresh_in_progress_.exchange(true)) return; - refreshBalance(); + // Capture decision flags on the main thread before posting to worker. + const bool doAddresses = addresses_dirty_; + const bool doPeers = (current_page_ == ui::NavPage::Peers); + const bool doEncrypt = !encryption_state_prefetched_; + if (encryption_state_prefetched_) encryption_state_prefetched_ = false; - // Addresses: only re-fetch when explicitly dirtied (new address, send, etc.) - if (addresses_dirty_) { - refreshAddresses(); - } - - refreshTransactions(); - - // Mining: handled by the 1-second fast_refresh_timer_ — skip here to - // avoid queuing a redundant call every 5 seconds. - - // Peers: only fetch when the Peers tab is visible - if (current_page_ == ui::NavPage::Peers) { - refreshPeerInfo(); - } - - // Encryption state: skip if onConnected() already prefetched it - if (encryption_state_prefetched_) { - encryption_state_prefetched_ = false; - } else { - refreshWalletEncryptionState(); - } + // P4a: Skip transactions if no new blocks since last full fetch + const int currentBlocks = state_.sync.blocks; + const bool doTransactions = (last_tx_block_height_ < 0 + || currentBlocks != last_tx_block_height_ + || state_.transactions.empty()); - // Clear the guard after all tasks are posted (they'll execute sequentially - // on the worker thread, so the last one to finish signals completion). - // We post a sentinel task that clears the flag after all refresh work. - worker_->post([this]() -> rpc::RPCWorker::MainCb { - return [this]() { + // Snapshot z-addresses for transaction fetch (needed on worker thread) + std::vector txZAddrs; + if (doTransactions) { + for (const auto& za : state_.z_addresses) { + if (!za.address.empty()) txZAddrs.push_back(za.address); + } + } + + // P4b: Collect txids that are fully enriched (skip re-enrichment) + std::set fullyEnriched; + if (doTransactions) { + for (const auto& tx : state_.transactions) { + if (tx.confirmations > 6 && tx.timestamp != 0) { + fullyEnriched.insert(tx.txid); + } + } + } + + // Single consolidated worker task — all RPC calls happen back-to-back + // on a single thread with no inter-task queue overhead. + worker_->post([this, doAddresses, doPeers, doEncrypt, doTransactions, + currentBlocks, txZAddrs = std::move(txZAddrs), + fullyEnriched = std::move(fullyEnriched)]() -> rpc::RPCWorker::MainCb { + // ================================================================ + // Phase 1: Balance + blockchain info + // ================================================================ + json totalBal, blockInfo; + bool balOk = false, blockOk = false; + + try { + totalBal = rpc_->call("z_gettotalbalance"); + balOk = true; + } catch (const std::exception& e) { + DEBUG_LOGF("Balance error: %s\n", e.what()); + } + try { + blockInfo = rpc_->call("getblockchaininfo"); + blockOk = true; + } catch (const std::exception& e) { + DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); + } + + // ================================================================ + // Phase 2: Addresses (only when dirtied) + // ================================================================ + std::vector zAddrs, tAddrs; + bool addrOk = false; + if (doAddresses) { + addrOk = true; + // z-addresses + try { + json zList = rpc_->call("z_listaddresses"); + for (const auto& addr : zList) { + AddressInfo info; + info.address = addr.get(); + info.type = "shielded"; + zAddrs.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("z_listaddresses error: %s\n", e.what()); + } + // z-balances via z_listunspent (single call) + try { + json unspent = rpc_->call("z_listunspent"); + std::map zBalances; + for (const auto& utxo : unspent) { + if (utxo.contains("address") && utxo.contains("amount")) { + zBalances[utxo["address"].get()] += utxo["amount"].get(); + } + } + for (auto& info : zAddrs) { + auto it = zBalances.find(info.address); + if (it != zBalances.end()) info.balance = it->second; + } + } catch (const std::exception& e) { + DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what()); + for (auto& info : zAddrs) { + try { + json bal = rpc_->call("z_getbalance", json::array({info.address})); + if (!bal.is_null()) info.balance = bal.get(); + } catch (...) {} + } + } + // t-addresses + try { + json tList = rpc_->call("getaddressesbyaccount", json::array({""})); + for (const auto& addr : tList) { + AddressInfo info; + info.address = addr.get(); + info.type = "transparent"; + tAddrs.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what()); + } + // t-balances via listunspent + try { + json utxos = rpc_->call("listunspent"); + std::map tBalances; + for (const auto& utxo : utxos) { + tBalances[utxo["address"].get()] += utxo["amount"].get(); + } + for (auto& info : tAddrs) { + auto it = tBalances.find(info.address); + if (it != tBalances.end()) info.balance = it->second; + } + } catch (const std::exception& e) { + DEBUG_LOGF("listunspent error: %s\n", e.what()); + } + } + + // ================================================================ + // Phase 3: Transactions (only when new blocks) + // ================================================================ + std::vector txns; + bool txOk = false; + if (doTransactions) { + txOk = true; + std::set knownTxids; + + // Phase 3a: transparent transactions + try { + json result = rpc_->call("listtransactions", json::array({"", 9999})); + for (const auto& tx : result) { + TransactionInfo info; + if (tx.contains("txid")) info.txid = tx["txid"].get(); + if (tx.contains("category")) info.type = tx["category"].get(); + if (tx.contains("amount")) info.amount = tx["amount"].get(); + if (tx.contains("time")) info.timestamp = tx["time"].get(); + if (tx.contains("confirmations")) info.confirmations = tx["confirmations"].get(); + if (tx.contains("address")) info.address = tx["address"].get(); + knownTxids.insert(info.txid); + txns.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("listtransactions error: %s\n", e.what()); + } + + // Phase 3b: shielded receives + for (const auto& addr : txZAddrs) { + try { + json zresult = rpc_->call("z_listreceivedbyaddress", json::array({addr, 0})); + if (zresult.is_null() || !zresult.is_array()) continue; + for (const auto& note : zresult) { + std::string txid; + if (note.contains("txid")) txid = note["txid"].get(); + if (txid.empty()) continue; + if (note.contains("change") && note["change"].get()) continue; + bool dominated = false; + for (const auto& existing : txns) { + if (existing.txid == txid && existing.type == "receive") { + dominated = true; break; + } + } + if (dominated) continue; + TransactionInfo info; + info.txid = txid; + info.type = "receive"; + info.address = addr; + if (note.contains("amount")) info.amount = note["amount"].get(); + if (note.contains("confirmations")) info.confirmations = note["confirmations"].get(); + if (note.contains("time")) info.timestamp = note["time"].get(); + if (note.contains("memoStr")) info.memo = note["memoStr"].get(); + knownTxids.insert(txid); + txns.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("z_listreceivedbyaddress error for %s: %s\n", + addr.substr(0, 12).c_str(), e.what()); + } + } + + // Phase 3c: detect shielded sends via z_viewtransaction + for (const std::string& txid : knownTxids) { + if (fullyEnriched.count(txid)) continue; + try { + json vtx = rpc_->call("z_viewtransaction", json::array({txid})); + if (vtx.is_null() || !vtx.is_object()) continue; + if (vtx.contains("outputs") && vtx["outputs"].is_array()) { + for (const auto& output : vtx["outputs"]) { + bool outgoing = false; + if (output.contains("outgoing")) + outgoing = output["outgoing"].get(); + if (!outgoing) continue; + std::string destAddr; + if (output.contains("address")) + destAddr = output["address"].get(); + double value = 0.0; + if (output.contains("value")) + value = output["value"].get(); + bool alreadyTracked = false; + for (const auto& existing : txns) { + if (existing.txid == txid && existing.type == "send" + && std::abs(existing.amount + value) < 0.00000001) { + alreadyTracked = true; break; + } + } + if (alreadyTracked) continue; + TransactionInfo info; + info.txid = txid; + info.type = "send"; + info.address = destAddr; + info.amount = -value; + if (output.contains("memoStr")) + info.memo = output["memoStr"].get(); + for (const auto& existing : txns) { + if (existing.txid == txid) { + info.confirmations = existing.confirmations; + info.timestamp = existing.timestamp; + break; + } + } + if (info.timestamp == 0) { + try { + json rawtx = rpc_->call("gettransaction", json::array({txid})); + if (!rawtx.is_null() && rawtx.contains("time")) + info.timestamp = rawtx["time"].get(); + if (!rawtx.is_null() && rawtx.contains("confirmations")) + info.confirmations = rawtx["confirmations"].get(); + } catch (...) {} + } + if (vtx.contains("spends") && vtx["spends"].is_array()) { + for (const auto& spend : vtx["spends"]) { + if (spend.contains("address")) { + info.from_address = spend["address"].get(); + break; + } + } + } + txns.push_back(info); + } + } + } catch (const std::exception& e) { + (void)e; // z_viewtransaction may not be available for all txids + } + } + + std::sort(txns.begin(), txns.end(), + [](const TransactionInfo& a, const TransactionInfo& b) { + return a.timestamp > b.timestamp; + }); + } + + // ================================================================ + // Phase 4: Peers (only when tab is active) + // ================================================================ + std::vector peers; + std::vector bannedPeers; + bool peerOk = false; + if (doPeers) { + peerOk = true; + try { + json result = rpc_->call("getpeerinfo"); + for (const auto& peer : result) { + PeerInfo info; + if (peer.contains("id")) info.id = peer["id"].get(); + if (peer.contains("addr")) info.addr = peer["addr"].get(); + if (peer.contains("subver")) info.subver = peer["subver"].get(); + if (peer.contains("services")) info.services = peer["services"].get(); + if (peer.contains("version")) info.version = peer["version"].get(); + if (peer.contains("conntime")) info.conntime = peer["conntime"].get(); + if (peer.contains("banscore")) info.banscore = peer["banscore"].get(); + if (peer.contains("pingtime")) info.pingtime = peer["pingtime"].get(); + if (peer.contains("bytessent")) info.bytessent = peer["bytessent"].get(); + if (peer.contains("bytesrecv")) info.bytesrecv = peer["bytesrecv"].get(); + if (peer.contains("startingheight")) info.startingheight = peer["startingheight"].get(); + if (peer.contains("synced_headers")) info.synced_headers = peer["synced_headers"].get(); + if (peer.contains("synced_blocks")) info.synced_blocks = peer["synced_blocks"].get(); + if (peer.contains("inbound")) info.inbound = peer["inbound"].get(); + if (peer.contains("tls_cipher")) info.tls_cipher = peer["tls_cipher"].get(); + if (peer.contains("tls_verified")) info.tls_verified = peer["tls_verified"].get(); + peers.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("getPeerInfo error: %s\n", e.what()); + } + try { + json result = rpc_->call("listbanned"); + for (const auto& ban : result) { + BannedPeer info; + if (ban.contains("address")) info.address = ban["address"].get(); + if (ban.contains("banned_until")) info.banned_until = ban["banned_until"].get(); + bannedPeers.push_back(info); + } + } catch (const std::exception& e) { + DEBUG_LOGF("listBanned error: %s\n", e.what()); + } + } + + // ================================================================ + // Phase 5: Wallet encryption state + // ================================================================ + json walletInfo; + bool encryptOk = false; + if (doEncrypt) { + try { + walletInfo = rpc_->call("getwalletinfo"); + encryptOk = true; + } catch (...) {} + } + + // ================================================================ + // Single main-thread callback — apply ALL results at once + // ================================================================ + return [this, totalBal, blockInfo, balOk, blockOk, + zAddrs = std::move(zAddrs), tAddrs = std::move(tAddrs), addrOk, + txns = std::move(txns), txOk, currentBlocks, + peers = std::move(peers), bannedPeers = std::move(bannedPeers), peerOk, + walletInfo, encryptOk]() { + // --- Balance --- + try { + if (balOk) { + if (totalBal.contains("private")) + state_.shielded_balance = std::stod(totalBal["private"].get()); + if (totalBal.contains("transparent")) + state_.transparent_balance = std::stod(totalBal["transparent"].get()); + if (totalBal.contains("total")) + state_.total_balance = std::stod(totalBal["total"].get()); + state_.last_balance_update = std::time(nullptr); + } + if (blockOk) { + if (blockInfo.contains("blocks")) + state_.sync.blocks = blockInfo["blocks"].get(); + if (blockInfo.contains("headers")) + state_.sync.headers = blockInfo["headers"].get(); + if (blockInfo.contains("verificationprogress")) + state_.sync.verification_progress = blockInfo["verificationprogress"].get(); + state_.sync.syncing = (state_.sync.blocks < state_.sync.headers - 2); + if (blockInfo.contains("longestchain")) + state_.longestchain = blockInfo["longestchain"].get(); + if (blockInfo.contains("notarized")) + state_.notarized = blockInfo["notarized"].get(); + } + // Auto-shield transparent funds if enabled + if (balOk && settings_ && settings_->getAutoShield() && + state_.transparent_balance > 0.0001 && !state_.sync.syncing && + !auto_shield_pending_.exchange(true)) { + std::string targetZAddr; + for (const auto& addr : state_.addresses) { + if (addr.isShielded()) { + targetZAddr = addr.address; + break; + } + } + if (!targetZAddr.empty() && rpc_) { + DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n", + state_.transparent_balance, targetZAddr.c_str()); + rpc_->z_shieldCoinbase("*", targetZAddr, 0.0001, 50, + [this](const json& result) { + if (result.contains("opid")) { + DEBUG_LOGF("[AutoShield] Started: %s\n", + result["opid"].get().c_str()); + } + auto_shield_pending_ = false; + }, + [this](const std::string& err) { + DEBUG_LOGF("[AutoShield] Error: %s\n", err.c_str()); + auto_shield_pending_ = false; + }); + } else { + auto_shield_pending_ = false; + } + } + } catch (const std::exception& e) { + DEBUG_LOGF("[refreshData] balance callback error: %s\n", e.what()); + } + + // --- Addresses --- + if (addrOk) { + state_.z_addresses = std::move(zAddrs); + state_.t_addresses = std::move(tAddrs); + address_list_dirty_ = true; + addresses_dirty_ = false; + } + + // --- Transactions --- + if (txOk) { + state_.transactions = std::move(txns); + state_.last_tx_update = std::time(nullptr); + last_tx_block_height_ = currentBlocks; + } + + // --- Peers --- + if (peerOk) { + state_.peers = std::move(peers); + state_.bannedPeers = std::move(bannedPeers); + state_.last_peer_update = std::time(nullptr); + } + + // --- Encryption state --- + if (encryptOk) { + try { + if (walletInfo.contains("unlocked_until")) { + state_.encrypted = true; + int64_t until = walletInfo["unlocked_until"].get(); + state_.unlocked_until = until; + state_.locked = (until == 0); + } else { + state_.encrypted = false; + state_.locked = false; + state_.unlocked_until = 0; + } + state_.encryption_state_known = true; + } catch (...) {} + } + refresh_in_progress_.store(false, std::memory_order_release); }; }); @@ -605,7 +1026,12 @@ void App::refreshTransactions() void App::refreshMiningInfo() { - if (!worker_ || !rpc_) return; + // Use the dedicated fast-lane worker + connection so mining polls + // never block behind the main refresh batch. Falls back to the main + // worker if the fast lane isn't ready yet (e.g. during initial connect). + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + if (!w || !rpc) return; // Prevent worker queue pileup — skip if previous refresh hasn't finished if (mining_refresh_in_progress_.exchange(true)) return; @@ -623,13 +1049,13 @@ void App::refreshMiningInfo() // p2p_port are static for the lifetime of a connection (set in onConnected). bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0); - worker_->post([this, daemonMemMb, doSlowRefresh]() -> rpc::RPCWorker::MainCb { + w->post([this, rpc, daemonMemMb, doSlowRefresh]() -> rpc::RPCWorker::MainCb { json miningInfo, localHashrateJson; bool miningOk = false, hashrateOk = false; // Fast path: only getlocalsolps (single RPC call, ~1ms) — returns H/s (RandomX) try { - localHashrateJson = rpc_->call("getlocalsolps"); + localHashrateJson = rpc->call("getlocalsolps"); hashrateOk = true; } catch (const std::exception& e) { DEBUG_LOGF("getLocalHashrate error: %s\n", e.what()); @@ -638,7 +1064,7 @@ void App::refreshMiningInfo() // Slow path: getmininginfo every ~5s if (doSlowRefresh) { try { - miningInfo = rpc_->call("getmininginfo"); + miningInfo = rpc->call("getmininginfo"); miningOk = true; } catch (const std::exception& e) { DEBUG_LOGF("getMiningInfo error: %s\n", e.what()); diff --git a/src/app_security.cpp b/src/app_security.cpp index 00c57c8..f88dd7a 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -212,11 +212,14 @@ void App::unlockWallet(const std::string& passphrase, int timeout) { if (!rpc_ || !rpc_->isConnected() || !worker_) return; lock_unlock_in_progress_ = true; - worker_->post([this, passphrase, timeout]() -> rpc::RPCWorker::MainCb { + // Use fast-lane worker to bypass head-of-line blocking behind refreshData. + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + w->post([this, r, passphrase, timeout]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string err_msg; try { - rpc_->call("walletpassphrase", {passphrase, timeout}); + r->call("walletpassphrase", {passphrase, timeout}); ok = true; } catch (const std::exception& e) { err_msg = e.what(); @@ -256,10 +259,13 @@ void App::lockWallet() { if (lock_unlock_in_progress_) return; // Prevent duplicate async calls lock_unlock_in_progress_ = true; - worker_->post([this]() -> rpc::RPCWorker::MainCb { + // Use fast-lane worker to avoid blocking behind refreshData. + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + w->post([this, r]() -> rpc::RPCWorker::MainCb { bool ok = false; try { - rpc_->call("walletlock"); + r->call("walletlock"); ok = true; } catch (...) {} @@ -279,11 +285,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas encrypt_in_progress_ = true; encrypt_status_ = "Changing passphrase..."; - worker_->post([this, oldPass, newPass]() -> rpc::RPCWorker::MainCb { + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + w->post([this, r, oldPass, newPass]() -> rpc::RPCWorker::MainCb { bool ok = false; std::string err_msg; try { - rpc_->call("walletpassphrasechange", {oldPass, newPass}); + r->call("walletpassphrasechange", {oldPass, newPass}); ok = true; } catch (const std::exception& e) { err_msg = e.what(); @@ -609,8 +617,11 @@ void App::renderLockScreen() { memset(lock_pin_buf_, 0, sizeof(lock_pin_buf_)); lock_unlock_in_progress_ = true; - if (worker_) { - worker_->post([this, pin, timeout]() -> rpc::RPCWorker::MainCb { + // Use fast-lane worker for priority unlock. + auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); + auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + if (w) { + w->post([this, r, pin, timeout]() -> rpc::RPCWorker::MainCb { // Heavy Argon2id derivation runs here (worker thread) std::string passphrase; bool vaultOk = vault_ && vault_->retrieve(pin, passphrase); @@ -634,8 +645,8 @@ void App::renderLockScreen() { bool rpcOk = false; std::string rpcErr; try { - if (rpc_ && rpc_->isConnected()) { - rpc_->call("walletpassphrase", {passphrase, timeout}); + if (r && r->isConnected()) { + r->call("walletpassphrase", {passphrase, timeout}); rpcOk = true; } else { rpcErr = "Not connected to daemon"; diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 15985b3..10a4b79 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -145,6 +145,8 @@ bool Settings::load(const std::string& path) if (j.contains("pool_tls")) pool_tls_ = j["pool_tls"].get(); if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get(); if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get(); + if (j.contains("font_scale") && j["font_scale"].is_number()) + font_scale_ = std::max(1.0f, std::min(1.5f, j["font_scale"].get())); if (j.contains("window_width") && j["window_width"].is_number_integer()) window_width_ = j["window_width"].get(); if (j.contains("window_height") && j["window_height"].is_number_integer()) @@ -215,6 +217,7 @@ bool Settings::save(const std::string& path) j["pool_tls"] = pool_tls_; j["pool_hugepages"] = pool_hugepages_; j["pool_mode"] = pool_mode_; + j["font_scale"] = font_scale_; if (window_width_ > 0 && window_height_ > 0) { j["window_width"] = window_width_; j["window_height"] = window_height_; diff --git a/src/config/settings.h b/src/config/settings.h index 8d4542f..87ebb67 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -202,6 +202,10 @@ public: bool getPoolMode() const { return pool_mode_; } void setPoolMode(bool v) { pool_mode_ = v; } + // Font scale (user accessibility setting, 1.0–1.5) + float getFontScale() const { return font_scale_; } + void setFontScale(float v) { font_scale_ = std::max(1.0f, std::min(1.5f, v)); } + // Window size persistence (logical pixels at 1x scale) int getWindowWidth() const { return window_width_; } int getWindowHeight() const { return window_height_; } @@ -254,6 +258,9 @@ private: bool pool_hugepages_ = true; bool pool_mode_ = false; // false=solo, true=pool + // Font scale (user accessibility, 1.0–3.0; 1.0 = default) + float font_scale_ = 1.0f; + // Window size (logical pixels at 1x scale; 0 = use default 1200×775) int window_width_ = 0; int window_height_ = 0; diff --git a/src/main.cpp b/src/main.cpp index 9e95555..562fd6a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -778,6 +778,37 @@ int main(int argc, char* argv[]) dragonx::util::PerfLog::instance().init(perfPath); } + // If the user had a font scale > 1.0 saved, app.init() rebuilt fonts + // at that scale. Resize the window now so the larger UI fits. + { + float fs = dragonx::ui::Layout::userFontScale(); + if (fs > 1.01f) { + int curW = 0, curH = 0; + SDL_GetWindowSize(window, &curW, &curH); + int newW = (int)lroundf(curW * fs); + int newH = (int)lroundf(curH * fs); + + // Clamp to display work area + SDL_DisplayID did = SDL_GetDisplayForWindow(window); + if (did) { + SDL_Rect usable; + if (SDL_GetDisplayUsableBounds(did, &usable)) { + newW = std::min(newW, usable.w); + newH = std::min(newH, usable.h); + } + } + + float hwDpi = dragonx::ui::Layout::rawDpiScale(); + SDL_SetWindowSize(window, newW, newH); + SDL_SetWindowMinimumSize(window, + (int)(1024 * hwDpi * fs), + (int)(720 * hwDpi * fs)); + SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED); + DEBUG_LOGF("Font-scale startup: window %dx%d -> %dx%d (fontScale %.1f)\n", + curW, curH, newW, newH, fs); + } + } + // Handle pending payment URI from command line if (!pendingURI.empty()) { DEBUG_LOGF("Processing payment URI: %s\n", pendingURI.c_str()); @@ -825,6 +856,9 @@ int main(int argc, char* argv[]) if (in_resize_render) return true; in_resize_render = true; + // Pre-frame: font atlas rebuilds before NewFrame() + ctx->app->preFrame(); + #ifdef DRAGONX_USE_DX11 ctx->dx->resize(event->window.data1, event->window.data2); ImGui_ImplDX11_NewFrame(); @@ -1259,6 +1293,43 @@ int main(int argc, char* argv[]) // --- PerfLog: begin frame --- dragonx::util::PerfLog::instance().beginFrame(); + // Pre-frame: font atlas rebuilds and schema hot-reload must + // happen BEFORE NewFrame() because NewFrame() caches font ptrs. + float prevFontScale = dragonx::ui::Layout::userFontScale(); + app.preFrame(); + + // If font scale changed (user dragged the slider), resize window + { + float curFS = dragonx::ui::Layout::userFontScale(); + if (std::fabs(curFS - prevFontScale) > 0.001f) { + int curW = 0, curH = 0; + SDL_GetWindowSize(window, &curW, &curH); + float ratio = curFS / prevFontScale; + int newW = (int)lroundf(curW * ratio); + int newH = (int)lroundf(curH * ratio); + + // Clamp to display work area + SDL_DisplayID did = SDL_GetDisplayForWindow(window); + if (did) { + SDL_Rect usable; + if (SDL_GetDisplayUsableBounds(did, &usable)) { + newW = std::min(newW, usable.w); + newH = std::min(newH, usable.h); + } + } + + float hwDpi = dragonx::ui::Layout::rawDpiScale(); + SDL_SetWindowSize(window, newW, newH); + SDL_SetWindowMinimumSize(window, + (int)(1024 * hwDpi * curFS), + (int)(720 * hwDpi * curFS)); + lastKnownW = newW; + lastKnownH = newH; + DEBUG_LOGF("Font-scale resize: %dx%d -> %dx%d (%.1fx -> %.1fx)\n", + curW, curH, newW, newH, prevFontScale, curFS); + } + } + // Start the Dear ImGui frame PERF_BEGIN(_perfNewFrame); #ifdef DRAGONX_USE_DX11 diff --git a/src/ui/layout.h b/src/ui/layout.h index 268393b..1551186 100644 --- a/src/ui/layout.h +++ b/src/ui/layout.h @@ -36,16 +36,68 @@ namespace Layout { // DPI Scaling (must be first — other accessors multiply by dpiScale()) // ============================================================================ +// ============================================================================ +// User Font Scale (accessibility, 1.0–3.0, persisted in Settings) +// ============================================================================ + +namespace detail { + inline float& userFontScaleRef() { static float s = 1.0f; return s; } + inline bool& fontReloadNeededRef() { static bool s = false; return s; } +} + /** - * @brief Get the current display DPI scale factor. + * @brief Get the user's font scale preference (1.0–3.0). + * Multiplied into font loading so glyphs render at the chosen size. + */ +inline float userFontScale() { return detail::userFontScaleRef(); } + +/** + * @brief Set the user's font scale and flag a font reload. + * Called from the settings UI; the main loop detects the flag and + * calls Typography::reload(). + */ +inline void setUserFontScale(float v) { + v = std::max(1.0f, std::min(1.5f, v)); + if (v != detail::userFontScaleRef()) { + detail::userFontScaleRef() = v; + detail::fontReloadNeededRef() = true; + } +} + +/** + * @brief Consume the pending font-reload flag (returns true once). + */ +inline bool consumeUserFontReload() { + bool v = detail::fontReloadNeededRef(); + detail::fontReloadNeededRef() = false; + return v; +} + +// ============================================================================ +// DPI Scaling (must be after userFontScale — dpiScale includes it) +// ============================================================================ + +/** + * @brief Get the raw hardware DPI scale factor (no user font scale). * * Returns the DPI scale set during typography initialization (e.g. 2.0 for - * 200 % Windows scaling). All pixel constants from TOML are in *logical* - * pixels and must be multiplied by this factor before being used as ImGui - * coordinates (which are physical pixels on Windows Per-Monitor DPI v2). + * 200 % Windows scaling). Use this when you need the pure hardware DPI + * without the user's accessibility font scale applied. + */ +inline float rawDpiScale() { + return dragonx::ui::material::Typography::instance().getDpiScale(); +} + +/** + * @brief Get the effective DPI scale factor including user font scale. + * + * Returns rawDpiScale() * userFontScale(). At userFontScale() == 1.0 + * this is identical to the hardware DPI. All pixel constants from TOML + * are in *logical* pixels and should be multiplied by this factor so that + * containers grow proportionally when the user increases font scale. */ inline float dpiScale() { - return dragonx::ui::material::Typography::instance().getDpiScale(); + return rawDpiScale() * userFontScale(); } /** @@ -165,11 +217,12 @@ inline LayoutTier currentTier(float availW, float availH) { */ inline float hScale(float availWidth) { const auto& S = schema::UI(); - float dp = dpiScale(); - float rw = S.drawElement("responsive", "ref-width").sizeOr(1200.0f) * dp; + float rawDp = rawDpiScale(); // reference uses hardware DPI only + float dp = dpiScale(); // output includes user font scale + float rw = S.drawElement("responsive", "ref-width").sizeOr(1200.0f) * rawDp; float minH = S.drawElement("responsive", "min-h-scale").sizeOr(0.5f); float maxH = S.drawElement("responsive", "max-h-scale").sizeOr(1.5f); - // Clamp the logical (DPI-neutral) portion, then apply DPI. + // Clamp the logical (DPI-neutral) portion, then apply effective DPI. float logical = std::clamp(availWidth / rw, minH, maxH); return logical * dp; } @@ -185,8 +238,9 @@ inline float hScale() { */ inline float vScale(float availHeight) { const auto& S = schema::UI(); - float dp = dpiScale(); - float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * dp; + float rawDp = rawDpiScale(); // reference uses hardware DPI only + float dp = dpiScale(); // output includes user font scale + float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * rawDp; float minV = S.drawElement("responsive", "min-v-scale").sizeOr(0.5f); float maxV = S.drawElement("responsive", "max-v-scale").sizeOr(1.4f); float logical = std::clamp(availHeight / rh, minV, maxV); @@ -205,8 +259,9 @@ inline float vScale() { */ inline float densityScale(float availHeight) { const auto& S = schema::UI(); - float dp = dpiScale(); - float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * dp; + float rawDp = rawDpiScale(); // reference uses hardware DPI only + float dp = dpiScale(); // output includes user font scale + float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * rawDp; float minDen = S.drawElement("responsive", "min-density").sizeOr(0.6f); float maxDen = S.drawElement("responsive", "max-density").sizeOr(1.2f); float logical = std::clamp(availHeight / rh, minDen, maxDen); diff --git a/src/ui/material/typography.cpp b/src/ui/material/typography.cpp index ec28ae5..ebd30e8 100644 --- a/src/ui/material/typography.cpp +++ b/src/ui/material/typography.cpp @@ -118,8 +118,12 @@ bool Typography::load(ImGuiIO& io, float dpiScale) // and DisplayFramebufferScale is 1.0 (no automatic upscaling). // The window is resized by dpiScale in main.cpp so that fonts at // size*dpiScale fit proportionally (no overflow). - float scale = dpiScale * Layout::kFontScale(); - DEBUG_LOGF("Typography: Loading Material Design type scale (DPI: %.2f, fontScale: %.2f, combined: %.2f)\n", dpiScale, Layout::kFontScale(), scale); + // Layout::userFontScale() is the user-chosen accessibility multiplier + // (1.0–3.0) persisted in Settings; it makes glyphs physically larger + // without any bitmap up-scaling (sharp at every size). + float scale = dpiScale * Layout::kFontScale() * Layout::userFontScale(); + DEBUG_LOGF("Typography: Loading Material Design type scale (DPI: %.2f, fontScale: %.2f, userFontScale: %.2f, combined: %.2f)\n", + dpiScale, Layout::kFontScale(), Layout::userFontScale(), scale); // For ImGui, we need to load fonts at specific pixel sizes. // Font sizes come from Layout:: accessors (backed by UISchema JSON) diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 8360e0a..099f083 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -84,6 +84,9 @@ static bool sp_gradient_background = false; // Low-spec mode static bool sp_low_spec_mode = false; +// Font scale (user accessibility, 1.0–3.0) +static float sp_font_scale = 1.0f; + // Snapshot of effect settings saved when low-spec is toggled ON, // restored when toggled OFF so user state isn't lost. struct LowSpecSnapshot { @@ -155,6 +158,8 @@ static void loadSettingsPageState(config::Settings* settings) { sp_theme_effects_enabled = settings->getThemeEffectsEnabled(); sp_low_spec_mode = settings->getLowSpecMode(); effects::setLowSpecMode(sp_low_spec_mode); + sp_font_scale = settings->getFontScale(); + Layout::setUserFontScale(sp_font_scale); // sync with Layout on load sp_keep_daemon_running = settings->getKeepDaemonRunning(); sp_stop_external_daemon = settings->getStopExternalDaemon(); sp_debug_categories = settings->getDebugCategories(); @@ -200,6 +205,7 @@ static void saveSettingsPageState(config::Settings* settings) { settings->setScanlineEnabled(sp_scanline_enabled); settings->setThemeEffectsEnabled(sp_theme_effects_enabled); settings->setLowSpecMode(sp_low_spec_mode); + settings->setFontScale(sp_font_scale); settings->setKeepDaemonRunning(sp_keep_daemon_running); settings->setStopExternalDaemon(sp_stop_external_daemon); settings->setDebugCategories(sp_debug_categories); @@ -899,6 +905,34 @@ void RenderSettingsPage(App* app) { } } + // ============================================================ + // Font Scale slider (always enabled, not affected by low-spec) + // ============================================================ + { + ImGui::PushFont(body2); + ImGui::Spacing(); + ImGui::TextUnformatted("Font Scale"); + float fontSliderW = std::min(availWidth - pad * 2, 260.0f * dp); + ImGui::SetNextItemWidth(fontSliderW); + float prev_font_scale = sp_font_scale; + { + char fs_fmt[16]; + snprintf(fs_fmt, sizeof(fs_fmt), "%.1fx", sp_font_scale); + ImGui::SliderFloat("##FontScale", &sp_font_scale, 1.0f, 1.5f, fs_fmt, + ImGuiSliderFlags_AlwaysClamp); + } + // Snap to nearest 0.1 and apply live as the user drags. + // Font atlas rebuild is deferred to preFrame() (before NewFrame), + // so updating every tick is safe — no dangling font pointers. + sp_font_scale = std::round(sp_font_scale * 10.0f) / 10.0f; + if (sp_font_scale != prev_font_scale) { + Layout::setUserFontScale(sp_font_scale); + saveSettingsPageState(app->settings()); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Scale all text and UI (1.0x = default, up to 1.5x)."); + ImGui::PopFont(); + } + // Bottom padding ImGui::Dummy(ImVec2(0, bottomPad)); ImGui::Unindent(pad); diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 44679d0..4ce692c 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -231,11 +231,29 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc ImGui::PopFont(); ImGui::EndChild(); + // Auto-toggle auto-scroll based on scroll position: + // At the bottom → re-enable; scrolled up → already disabled by wheel handler. + // After wheel-up, wait for the cooldown so smooth-scroll can animate + // away from the bottom before we check position again. + if (scroll_up_cooldown_ > 0.0f) + scroll_up_cooldown_ -= ImGui::GetIO().DeltaTime; + if (!auto_scroll_ && scroll_up_cooldown_ <= 0.0f && consoleScrollMaxY > 0.0f) { + float tolerance = Type().caption()->LegacySize * 1.5f; + if (consoleScrollY >= consoleScrollMaxY - tolerance) { + auto_scroll_ = true; + new_lines_since_scroll_ = 0; + } + } + // CSS-style clipping mask + // When auto-scroll is off, force bottom fade to always show by + // inflating scrollMax so the mask thinks there's content below. { float fadeZone = std::min(Type().caption()->LegacySize * 3.0f, outputH * 0.18f); + float effectiveScrollMax = auto_scroll_ ? consoleScrollMaxY + : std::max(consoleScrollMaxY, consoleScrollY + 10.0f); ApplyScrollEdgeMask(dlOut, consoleParentVtx, consoleChildDL, consoleChildVtx, - outPanelMin.y, outPanelMax.y, fadeZone, consoleScrollY, consoleScrollMaxY); + outPanelMin.y, outPanelMax.y, fadeZone, consoleScrollY, effectiveScrollMax); } // CRT scanline effect over output area — aligned to text lines @@ -552,7 +570,12 @@ void ConsoleTab::renderOutput() auto& S = schema::UI(); std::lock_guard lock(lines_mutex_); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, S.drawElement("tabs.console", "output").size)); + // Zero item spacing so Dummy items advance the cursor by exactly their + // height. The inter-line gap is added explicitly to wrapped_heights_ + // so that cumulative_y_offsets_ stays perfectly in sync with actual + // cursor positions (avoiding selection-offset drift). + float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // Inner padding for glass panel float padX = Layout::spacingMd(); @@ -560,7 +583,7 @@ void ConsoleTab::renderOutput() ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padY); ImGui::Indent(padX); - float line_height = ImGui::GetTextLineHeightWithSpacing(); + float line_height = ImGui::GetTextLineHeight(); output_line_height_ = line_height; // store for scanline alignment output_origin_ = ImGui::GetCursorScreenPos(); output_scroll_y_ = ImGui::GetScrollY(); @@ -598,33 +621,69 @@ void ConsoleTab::renderOutput() } int visible_count = static_cast(visible_indices_.size()); - // Calculate wrapped heights for each visible line - // This is needed because TextWrapped creates variable-height content + // Calculate wrapped heights AND build sub-row segments for each visible line. + // Each segment records which bytes of the source text appear on that visual + // row, so hit-testing and selection highlight can map screen positions to + // exact character offsets. float wrap_width = ImGui::GetContentRegionAvail().x - padX * 2; - if (wrap_width < 50.0f) wrap_width = 50.0f; // Minimum wrap width + if (wrap_width < 50.0f) wrap_width = 50.0f; + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); wrapped_heights_.resize(visible_count); cumulative_y_offsets_.resize(visible_count); + visible_wrap_segments_.resize(visible_count); total_wrapped_height_ = 0.0f; cached_wrap_width_ = wrap_width; for (int vi = 0; vi < visible_count; vi++) { int i = visible_indices_[vi]; const std::string& text = lines_[i].text; + auto& segs = visible_wrap_segments_[vi]; + segs.clear(); - // Calculate wrapped text size - CalcTextSize with wrap_width > 0 - ImVec2 sz; if (text.empty()) { - sz = ImVec2(0.0f, line_height); - } else { - sz = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap_width); - // Add a small margin for item spacing - sz.y = std::max(sz.y, line_height); + segs.push_back({0, 0, 0.0f, line_height}); + cumulative_y_offsets_[vi] = total_wrapped_height_; + wrapped_heights_[vi] = line_height + interLineGap; + total_wrapped_height_ += wrapped_heights_[vi]; + continue; + } + + // Walk the text using ImFont::CalcWordWrapPositionA to find + // exactly where ImGui would break each visual row. + const char* textStart = text.c_str(); + const char* textEnd = textStart + text.size(); + const char* cur = textStart; + float segY = 0.0f; + + while (cur < textEnd) { + const char* wrapPos = font->CalcWordWrapPositionA( + fontSize / font->LegacySize, cur, textEnd, wrap_width); + // Ensure forward progress (at least one character) + if (wrapPos <= cur) wrapPos = cur + 1; + // Skip a leading newline character that ends the previous segment + if (*cur == '\n') { cur++; continue; } + + int byteStart = static_cast(cur - textStart); + int byteEnd = static_cast(wrapPos - textStart); + // Trim trailing newline from this segment + if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; + + segs.push_back({byteStart, byteEnd, segY, line_height}); + segY += line_height; + cur = wrapPos; + } + + if (segs.empty()) { + segs.push_back({0, 0, 0.0f, line_height}); + segY = line_height; } cumulative_y_offsets_[vi] = total_wrapped_height_; - wrapped_heights_[vi] = sz.y; - total_wrapped_height_ += sz.y; + wrapped_heights_[vi] = segY + interLineGap; + total_wrapped_height_ += wrapped_heights_[vi]; } // Use raw IO for mouse handling to bypass child window event consumption @@ -642,7 +701,12 @@ void ConsoleTab::renderOutput() // Disable auto-scroll when user scrolls up (wheel scroll) if (mouse_in_output && io.MouseWheel > 0.0f) { auto_scroll_ = false; + scroll_up_cooldown_ = 0.5f; // give smooth-scroll time to animate away } + // Scrolling down to the very bottom re-enables auto-scroll. + // Actual position check happens after EndChild() using captured + // scroll values, but is skipped on the frame where wheel-up + // was detected (scroll position hasn't caught up yet). // Set cursor to text selection when hovering if (mouse_in_output) { @@ -696,8 +760,9 @@ void ConsoleTab::renderOutput() TextPos sel_start_pos = selectionStart(); TextPos sel_end_pos = selectionEnd(); - // Render lines with selection highlighting - // Use manual rendering instead of ImGuiListClipper to support variable-height wrapped lines + // Render lines with selection highlighting. + // Each line is split into pre-computed wrap segments rendered individually + // via AddText so that hit-testing and highlights map 1:1 to visual positions. float scroll_y = ImGui::GetScrollY(); float window_height = ImGui::GetWindowHeight(); float visible_top = scroll_y; @@ -724,10 +789,12 @@ void ConsoleTab::renderOutput() ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible])); } + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 selColor = WithAlpha(Secondary(), 80); + // Render visible lines - int last_rendered_vi = first_visible - 1; // Track actual last rendered line + int last_rendered_vi = first_visible - 1; for (int vi = first_visible; vi < visible_count; vi++) { - // Early exit if we're past the visible region if (vi < static_cast(cumulative_y_offsets_.size()) && cumulative_y_offsets_[vi] > visible_bottom) { break; @@ -736,60 +803,59 @@ void ConsoleTab::renderOutput() int i = visible_indices_[vi]; const auto& line = lines_[i]; - ImVec2 text_pos = ImGui::GetCursorScreenPos(); - float this_line_height = (vi < static_cast(wrapped_heights_.size())) - ? wrapped_heights_[vi] : line_height; + const auto& segs = visible_wrap_segments_[vi]; + ImVec2 lineOrigin = ImGui::GetCursorScreenPos(); + float totalH = wrapped_heights_[vi]; - // Draw selection highlight for this line + // Determine byte-level selection range for this line + int selByteStart = 0, selByteEnd = 0; + bool lineSelected = false; if (has_selection_ && i >= sel_start_pos.line && i <= sel_end_pos.line) { - int sel_col_start = 0; - int sel_col_end = static_cast(line.text.size()); + lineSelected = true; + selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0; + selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col + : static_cast(line.text.size()); + } + + for (const auto& seg : segs) { + float rowY = lineOrigin.y + seg.yOffset; + const char* segStart = line.text.c_str() + seg.byteStart; + const char* segEnd = line.text.c_str() + seg.byteEnd; - if (i == sel_start_pos.line) { - sel_col_start = sel_start_pos.col; - } - if (i == sel_end_pos.line) { - sel_col_end = sel_end_pos.col; + // Selection highlight for this sub-row + if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) { + int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart; + int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart; + int segLen = seg.byteEnd - seg.byteStart; + + float xStart = 0.0f; + if (hlStart > 0) { + xStart = font->CalcTextSizeA(fontSize, FLT_MAX, 0, + segStart, segStart + hlStart).x; + } + float xEnd = font->CalcTextSizeA(fontSize, FLT_MAX, 0, + segStart, segStart + hlEnd).x; + // Extend to window edge when selection reaches end of segment + if (hlEnd >= segLen && selByteEnd >= static_cast(line.text.size())) { + xEnd = std::max(xEnd + 8.0f, ImGui::GetWindowWidth()); + } + + dl->AddRectFilled( + ImVec2(lineOrigin.x + xStart, rowY), + ImVec2(lineOrigin.x + xEnd, rowY + seg.height), + selColor); } - if (sel_col_start < sel_col_end) { - // Calculate pixel positions for highlight - float x_start = 0; - float x_end = 0; - - if (sel_col_start > 0 && sel_col_start <= static_cast(line.text.size())) { - ImVec2 sz = ImGui::CalcTextSize(line.text.c_str(), - line.text.c_str() + sel_col_start); - x_start = sz.x; - } - if (sel_col_end <= static_cast(line.text.size())) { - ImVec2 sz = ImGui::CalcTextSize(line.text.c_str(), - line.text.c_str() + sel_col_end); - x_end = sz.x; - } else { - x_end = ImGui::CalcTextSize(line.text.c_str()).x; - } - - // If full line selected, extend highlight to window edge - if (sel_col_end >= static_cast(line.text.size())) { - x_end = std::max(x_end + S.drawElement("tabs.console", "selection-extension").size, ImGui::GetWindowWidth()); - } - - // Use actual wrapped height for selection highlight - ImVec2 rect_min(text_pos.x + x_start, text_pos.y); - ImVec2 rect_max(text_pos.x + x_end, text_pos.y + this_line_height); - ImGui::GetWindowDrawList()->AddRectFilled( - rect_min, rect_max, - WithAlpha(Secondary(), 80) // Selection highlight - ); + // Render text segment + if (seg.byteStart < seg.byteEnd) { + dl->AddText(font, fontSize, + ImVec2(lineOrigin.x, rowY), + line.color, segStart, segEnd); } } - ImGui::PushStyleColor(ImGuiCol_Text, ImColor(line.color).Value); - ImGui::PushTextWrapPos(ImGui::GetContentRegionAvail().x - padX); - ImGui::TextWrapped("%s", line.text.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); + // Advance ImGui cursor by the total wrapped height of this line + ImGui::Dummy(ImVec2(0, totalH)); } // Add spacer for lines after last visible (to maintain correct content height) @@ -806,9 +872,10 @@ void ConsoleTab::renderOutput() ImGui::Unindent(padX); ImGui::PopStyleVar(); - // Add bottom padding so the last line sits above the fade-out zone. - // Only the fade zone height is used — no extra padding beyond that, - // so text can still overflow into the fade and scrolling stays snappy. + // Bottom padding keeps the last line above the fade-out zone. + // Always present so that scrollMaxY stays stable when auto-scroll + // toggles — otherwise the geometry shift clamps the user back to + // bottom and a single scroll-up tick can't escape. { float fadeZone = std::min(Type().caption()->LegacySize * 3.0f, ImGui::GetWindowHeight() * 0.18f); @@ -911,14 +978,11 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_he return {0, 0}; } - // Calculate which VISIBLE line based on Y position relative to output origin - // Use cumulative_y_offsets_ for accurate wrapped text positioning float relative_y = screen_pos.y - output_origin_.y; - // Find the visible line using cumulative Y offsets (binary search) + // Binary search for the visible line that contains this Y position int visible_line = 0; if (!cumulative_y_offsets_.empty()) { - // Binary search for the line that contains this Y position int lo = 0, hi = static_cast(cumulative_y_offsets_.size()) - 1; while (lo < hi) { int mid = (lo + hi + 1) / 2; @@ -929,12 +993,9 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_he } } visible_line = lo; - } else { - // Fallback to fixed line height if offsets not calculated - visible_line = static_cast(relative_y / line_height); } - // Clamp visible line to valid range + // Clamp visible line if (visible_line < 0) visible_line = 0; if (visible_line >= static_cast(visible_indices_.size())) { visible_line = static_cast(visible_indices_.size()) - 1; @@ -943,34 +1004,49 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_he return pos; } - // Map visible line index to actual line index pos.line = visible_indices_[visible_line]; - - // Calculate column from X position const std::string& text = lines_[pos.line].text; - float relative_x = screen_pos.x - output_origin_.x; - if (relative_x <= 0 || text.empty()) { + if (text.empty()) { pos.col = 0; return pos; } - // Binary search for the character position - // Walk character by character for accuracy - pos.col = 0; - for (int c = 0; c < static_cast(text.size()); c++) { - ImVec2 sz = ImGui::CalcTextSize(text.c_str(), text.c_str() + c + 1); - float char_mid = (c > 0) - ? (ImGui::CalcTextSize(text.c_str(), text.c_str() + c).x + sz.x) * 0.5f - : sz.x * 0.5f; - if (relative_x < char_mid) { - pos.col = c; - return pos; - } - pos.col = c + 1; + // Find which sub-row (wrap segment) the mouse Y falls into + const auto& segs = visible_wrap_segments_[visible_line]; + float lineRelY = relative_y - cumulative_y_offsets_[visible_line]; + int segIdx = 0; + for (int s = 0; s < static_cast(segs.size()); s++) { + if (lineRelY >= segs[s].yOffset) + segIdx = s; + } + const auto& seg = segs[segIdx]; + + // Calculate column within this segment from X position + float relative_x = screen_pos.x - output_origin_.x; + if (relative_x <= 0.0f) { + pos.col = seg.byteStart; + return pos; + } + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + const char* segStart = text.c_str() + seg.byteStart; + const char* segEnd = text.c_str() + seg.byteEnd; + int segLen = seg.byteEnd - seg.byteStart; + + // Walk characters within this segment for accurate positioning + pos.col = seg.byteEnd; // default: past end of segment + for (int c = 0; c < segLen; c++) { + float wCur = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c + 1).x; + float wPrev = (c > 0) ? font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c).x : 0.0f; + float charMid = (wPrev + wCur) * 0.5f; + if (relative_x < charMid) { + pos.col = seg.byteStart + c; + return pos; + } } - pos.col = static_cast(text.size()); return pos; } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 52dfe5c..508240e 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -113,6 +113,7 @@ private: char input_buffer_[4096] = {0}; bool auto_scroll_ = true; bool scroll_to_bottom_ = false; + float scroll_up_cooldown_ = 0.0f; // seconds to wait before re-enabling auto-scroll int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator) size_t last_daemon_output_size_ = 0; size_t last_xmrig_output_size_ = 0; @@ -140,6 +141,17 @@ private: mutable float total_wrapped_height_ = 0.0f; // Total height of all visible lines mutable float cached_wrap_width_ = 0.0f; // Wrap width used for cached heights + // Sub-row layout: each visible line is split into wrap segments so + // selection and hit-testing know the exact screen position of every + // character. + struct WrapSegment { + int byteStart; // byte offset into ConsoleLine::text + int byteEnd; // byte offset past last char in this segment + float yOffset; // Y offset of this segment relative to the line's top + float height; // visual height of this segment + }; + mutable std::vector> visible_wrap_segments_; // [vi] -> segments + // Commands popup bool show_commands_popup_ = false; }; diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 420be9f..f96ac33 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -1021,43 +1021,49 @@ void RenderMiningTab(App* app) } // Catmull-Rom spline interpolation for smooth curve + // Subdivisions are adaptive: more when points are far apart, + // none when points are already sub-2px apart. std::vector points; if (n <= 2) { points = rawPts; } else { - const int subdivs = 8; // segments between each pair of data points - points.reserve((n - 1) * subdivs + 1); + points.reserve(n * 4); // conservative estimate for (size_t i = 0; i + 1 < n; i++) { - // Four control points: p0, p1, p2, p3 ImVec2 p0 = rawPts[i > 0 ? i - 1 : 0]; ImVec2 p1 = rawPts[i]; ImVec2 p2 = rawPts[i + 1]; ImVec2 p3 = rawPts[i + 2 < n ? i + 2 : n - 1]; + + // Adaptive subdivision: ~1 segment per 3px of distance + float dx = p2.x - p1.x, dy = p2.y - p1.y; + float dist = sqrtf(dx * dx + dy * dy); + int subdivs = std::clamp((int)(dist / 3.0f), 1, 16); + for (int s = 0; s < subdivs; s++) { float t = (float)s / (float)subdivs; float t2 = t * t; float t3 = t2 * t; - // Catmull-Rom basis float q0 = -t3 + 2.0f * t2 - t; float q1 = 3.0f * t3 - 5.0f * t2 + 2.0f; float q2 = -3.0f * t3 + 4.0f * t2 + t; float q3 = t3 - t2; float sx = 0.5f * (p0.x * q0 + p1.x * q1 + p2.x * q2 + p3.x * q3); float sy = 0.5f * (p0.y * q0 + p1.y * q1 + p2.y * q2 + p3.y * q3); + // Clamp Y to plot bounds to prevent Catmull-Rom overshoot + sy = std::clamp(sy, plotTop, plotBottom); points.push_back(ImVec2(sx, sy)); } } points.push_back(rawPts[n - 1]); // final point } - // Fill under curve - for (size_t i = 0; i + 1 < points.size(); i++) { - ImVec2 quad[4] = { - points[i], points[i + 1], - ImVec2(points[i + 1].x, plotBottom), - ImVec2(points[i].x, plotBottom) - }; - dl->AddConvexPolyFilled(quad, 4, WithAlpha(Success(), 25)); + // Fill under curve (single concave polygon to avoid AA seam shimmer) + if (points.size() >= 2) { + for (size_t i = 0; i < points.size(); i++) + dl->PathLineTo(points[i]); + dl->PathLineTo(ImVec2(points.back().x, plotBottom)); + dl->PathLineTo(ImVec2(points.front().x, plotBottom)); + dl->PathFillConcave(WithAlpha(Success(), 25)); } // Green line