console and mining tab visual improvements

This commit is contained in:
dan_s
2026-02-27 13:30:06 -06:00
parent b4e09cdf75
commit 6eeadcb595
13 changed files with 962 additions and 183 deletions

View File

@@ -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()
{

View File

@@ -11,6 +11,7 @@
#include <atomic>
#include <chrono>
#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::RPCClient> rpc_;
std::unique_ptr<rpc::RPCWorker> 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<rpc::RPCClient> fast_rpc_;
std::unique_ptr<rpc::RPCWorker> fast_worker_;
// Saved connection credentials (needed to open the fast-lane connection)
rpc::ConnectionConfig saved_config_;
std::unique_ptr<config::Settings> settings_;
std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;

View File

@@ -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<rpc::RPCClient>();
}
if (!fast_worker_) {
fast_worker_ = std::make_unique<rpc::RPCWorker>();
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();
// 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());
// Snapshot z-addresses for transaction fetch (needed on worker thread)
std::vector<std::string> txZAddrs;
if (doTransactions) {
for (const auto& za : state_.z_addresses) {
if (!za.address.empty()) txZAddrs.push_back(za.address);
}
}
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();
// P4b: Collect txids that are fully enriched (skip re-enrichment)
std::set<std::string> fullyEnriched;
if (doTransactions) {
for (const auto& tx : state_.transactions) {
if (tx.confirmations > 6 && tx.timestamp != 0) {
fullyEnriched.insert(tx.txid);
}
}
}
// Encryption state: skip if onConnected() already prefetched it
if (encryption_state_prefetched_) {
encryption_state_prefetched_ = false;
} else {
refreshWalletEncryptionState();
}
// 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<AddressInfo> 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<std::string>();
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<std::string, double> zBalances;
for (const auto& utxo : unspent) {
if (utxo.contains("address") && utxo.contains("amount")) {
zBalances[utxo["address"].get<std::string>()] += utxo["amount"].get<double>();
}
}
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<double>();
} catch (...) {}
}
}
// t-addresses
try {
json tList = rpc_->call("getaddressesbyaccount", json::array({""}));
for (const auto& addr : tList) {
AddressInfo info;
info.address = addr.get<std::string>();
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<std::string, double> tBalances;
for (const auto& utxo : utxos) {
tBalances[utxo["address"].get<std::string>()] += utxo["amount"].get<double>();
}
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<TransactionInfo> txns;
bool txOk = false;
if (doTransactions) {
txOk = true;
std::set<std::string> 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<std::string>();
if (tx.contains("category")) info.type = tx["category"].get<std::string>();
if (tx.contains("amount")) info.amount = tx["amount"].get<double>();
if (tx.contains("time")) info.timestamp = tx["time"].get<int64_t>();
if (tx.contains("confirmations")) info.confirmations = tx["confirmations"].get<int>();
if (tx.contains("address")) info.address = tx["address"].get<std::string>();
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<std::string>();
if (txid.empty()) continue;
if (note.contains("change") && note["change"].get<bool>()) 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<double>();
if (note.contains("confirmations")) info.confirmations = note["confirmations"].get<int>();
if (note.contains("time")) info.timestamp = note["time"].get<int64_t>();
if (note.contains("memoStr")) info.memo = note["memoStr"].get<std::string>();
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<bool>();
if (!outgoing) continue;
std::string destAddr;
if (output.contains("address"))
destAddr = output["address"].get<std::string>();
double value = 0.0;
if (output.contains("value"))
value = output["value"].get<double>();
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<std::string>();
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<int64_t>();
if (!rawtx.is_null() && rawtx.contains("confirmations"))
info.confirmations = rawtx["confirmations"].get<int>();
} 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<std::string>();
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<PeerInfo> peers;
std::vector<BannedPeer> 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<int>();
if (peer.contains("addr")) info.addr = peer["addr"].get<std::string>();
if (peer.contains("subver")) info.subver = peer["subver"].get<std::string>();
if (peer.contains("services")) info.services = peer["services"].get<std::string>();
if (peer.contains("version")) info.version = peer["version"].get<int>();
if (peer.contains("conntime")) info.conntime = peer["conntime"].get<int64_t>();
if (peer.contains("banscore")) info.banscore = peer["banscore"].get<int>();
if (peer.contains("pingtime")) info.pingtime = peer["pingtime"].get<double>();
if (peer.contains("bytessent")) info.bytessent = peer["bytessent"].get<int64_t>();
if (peer.contains("bytesrecv")) info.bytesrecv = peer["bytesrecv"].get<int64_t>();
if (peer.contains("startingheight")) info.startingheight = peer["startingheight"].get<int>();
if (peer.contains("synced_headers")) info.synced_headers = peer["synced_headers"].get<int>();
if (peer.contains("synced_blocks")) info.synced_blocks = peer["synced_blocks"].get<int>();
if (peer.contains("inbound")) info.inbound = peer["inbound"].get<bool>();
if (peer.contains("tls_cipher")) info.tls_cipher = peer["tls_cipher"].get<std::string>();
if (peer.contains("tls_verified")) info.tls_verified = peer["tls_verified"].get<bool>();
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<std::string>();
if (ban.contains("banned_until")) info.banned_until = ban["banned_until"].get<int64_t>();
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<std::string>());
if (totalBal.contains("transparent"))
state_.transparent_balance = std::stod(totalBal["transparent"].get<std::string>());
if (totalBal.contains("total"))
state_.total_balance = std::stod(totalBal["total"].get<std::string>());
state_.last_balance_update = std::time(nullptr);
}
if (blockOk) {
if (blockInfo.contains("blocks"))
state_.sync.blocks = blockInfo["blocks"].get<int>();
if (blockInfo.contains("headers"))
state_.sync.headers = blockInfo["headers"].get<int>();
if (blockInfo.contains("verificationprogress"))
state_.sync.verification_progress = blockInfo["verificationprogress"].get<double>();
state_.sync.syncing = (state_.sync.blocks < state_.sync.headers - 2);
if (blockInfo.contains("longestchain"))
state_.longestchain = blockInfo["longestchain"].get<int>();
if (blockInfo.contains("notarized"))
state_.notarized = blockInfo["notarized"].get<int>();
}
// 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<std::string>().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<int64_t>();
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 (...) {}
}
// 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]() {
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());

View File

@@ -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";

View File

@@ -145,6 +145,8 @@ bool Settings::load(const std::string& path)
if (j.contains("pool_tls")) pool_tls_ = j["pool_tls"].get<bool>();
if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get<bool>();
if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get<bool>();
if (j.contains("font_scale") && j["font_scale"].is_number())
font_scale_ = std::max(1.0f, std::min(1.5f, j["font_scale"].get<float>()));
if (j.contains("window_width") && j["window_width"].is_number_integer())
window_width_ = j["window_width"].get<int>();
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_;

View File

@@ -202,6 +202,10 @@ public:
bool getPoolMode() const { return pool_mode_; }
void setPoolMode(bool v) { pool_mode_ = v; }
// Font scale (user accessibility setting, 1.01.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.03.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;

View File

@@ -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

View File

@@ -36,16 +36,68 @@ namespace Layout {
// DPI Scaling (must be first — other accessors multiply by dpiScale())
// ============================================================================
// ============================================================================
// User Font Scale (accessibility, 1.03.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.03.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);

View File

@@ -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.03.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)

View File

@@ -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.03.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);

View File

@@ -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<std::mutex> 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<int>(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<int>(cur - textStart);
int byteEnd = static_cast<int>(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<int>(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<int>(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<int>(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<int>(line.text.size());
}
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;
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;
// 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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(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<int>(visible_indices_.size())) {
visible_line = static_cast<int>(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<int>(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<int>(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<int>(text.size());
return pos;
}

View File

@@ -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<std::vector<WrapSegment>> visible_wrap_segments_; // [vi] -> segments
// Commands popup
bool show_commands_popup_ = false;
};

View File

@@ -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<ImVec2> 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