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"); 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 // Ensure ObsidianDragon config directory and template files exist
util::Platform::ensureObsidianDragonSetup(); util::Platform::ensureObsidianDragonSetup();
@@ -190,6 +205,39 @@ bool App::init()
return true; 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() void App::update()
{ {
PERF_SCOPE("Update.Total"); PERF_SCOPE("Update.Total");
@@ -206,6 +254,9 @@ void App::update()
if (worker_) { if (worker_) {
worker_->drainResults(); worker_->drainResults();
} }
if (fast_worker_) {
fast_worker_->drainResults();
}
// Auto-lock check (only when connected + encrypted + unlocked) // Auto-lock check (only when connected + encrypted + unlocked)
if (state_.connected && state_.isUnlocked()) { if (state_.connected && state_.isUnlocked()) {
@@ -218,23 +269,6 @@ void App::update()
state_.rebuildAddressList(); 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 // Update timers
refresh_timer_ += io.DeltaTime; refresh_timer_ += io.DeltaTime;
price_timer_ += io.DeltaTime; price_timer_ += io.DeltaTime;
@@ -775,7 +809,12 @@ void App::render()
ui::RenderMarketTab(this); ui::RenderMarketTab(this);
break; break;
case ui::NavPage::Console: 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; break;
case ui::NavPage::Settings: case ui::NavPage::Settings:
ui::RenderSettingsPage(this); ui::RenderSettingsPage(this);
@@ -1723,6 +1762,9 @@ void App::beginShutdown()
if (worker_) { if (worker_) {
worker_->requestStop(); worker_->requestStop();
} }
if (fast_worker_) {
fast_worker_->requestStop();
}
// Stop xmrig pool miner before stopping the daemon // Stop xmrig pool miner before stopping the daemon
if (xmrig_manager_ && xmrig_manager_->isRunning()) { if (xmrig_manager_ && xmrig_manager_->isRunning()) {
@@ -2260,6 +2302,9 @@ void App::shutdown()
if (worker_) { if (worker_) {
worker_->stop(); worker_->stop();
} }
if (fast_worker_) {
fast_worker_->stop();
}
if (settings_) { if (settings_) {
settings_->save(); settings_->save();
} }
@@ -2269,6 +2314,9 @@ void App::shutdown()
if (rpc_) { if (rpc_) {
rpc_->disconnect(); rpc_->disconnect();
} }
if (fast_rpc_) {
fast_rpc_->disconnect();
}
return; return;
} }
@@ -2288,10 +2336,16 @@ void App::shutdown()
if (worker_) { if (worker_) {
worker_->stop(); worker_->stop();
} }
if (fast_worker_) {
fast_worker_->stop();
}
// Disconnect RPC after worker is fully stopped (safe — no curl in flight) // Disconnect RPC after worker is fully stopped (safe — no curl in flight)
if (rpc_) { if (rpc_) {
rpc_->disconnect(); rpc_->disconnect();
} }
if (fast_rpc_) {
fast_rpc_->disconnect();
}
} }
// =========================================================================== // ===========================================================================
@@ -2310,7 +2364,8 @@ bool App::hasPinVault() const {
} }
bool App::hasPendingRPCResults() const { bool App::hasPendingRPCResults() const {
return worker_ && worker_->hasPendingResults(); return (worker_ && worker_->hasPendingResults())
|| (fast_worker_ && fast_worker_->hasPendingResults());
} }
void App::restartDaemon() void App::restartDaemon()
{ {

View File

@@ -11,6 +11,7 @@
#include <atomic> #include <atomic>
#include <chrono> #include <chrono>
#include "data/wallet_state.h" #include "data/wallet_state.h"
#include "rpc/connection.h"
#include "ui/sidebar.h" #include "ui/sidebar.h"
#include "ui/windows/console_tab.h" #include "ui/windows/console_tab.h"
#include "imgui.h" #include "imgui.h"
@@ -20,7 +21,6 @@ namespace dragonx {
namespace rpc { namespace rpc {
class RPCClient; class RPCClient;
class RPCWorker; class RPCWorker;
struct ConnectionConfig;
} }
namespace config { class Settings; } namespace config { class Settings; }
namespace daemon { class EmbeddedDaemon; class XmrigManager; } namespace daemon { class EmbeddedDaemon; class XmrigManager; }
@@ -79,6 +79,15 @@ public:
*/ */
void update(); 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) * @brief Render the application UI (called every frame)
*/ */
@@ -313,6 +322,16 @@ private:
// Subsystems // Subsystems
std::unique_ptr<rpc::RPCClient> rpc_; std::unique_ptr<rpc::RPCClient> rpc_;
std::unique_ptr<rpc::RPCWorker> worker_; 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<config::Settings> settings_;
std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_; std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_; 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 { worker_->post([this, config, daemonStarting, externalDetected]() -> rpc::RPCWorker::MainCb {
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword); 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) { if (connected) {
saved_config_ = config; // save for fast-lane connection
onConnected(); onConnected();
} else { } else {
if (daemonStarting) { if (daemonStarting) {
@@ -194,6 +195,29 @@ void App::onConnected()
// Addresses are unknown on fresh connect — force a fetch // Addresses are unknown on fresh connect — force a fetch
addresses_dirty_ = true; 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 // Initial data refresh
refreshData(); refreshData();
refreshMarketData(); refreshMarketData();
@@ -204,6 +228,14 @@ void App::onDisconnected(const std::string& reason)
state_.connected = false; state_.connected = false;
state_.clear(); state_.clear();
connection_status_ = reason; 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 // Prevent overlapping refreshes — skip if one is still running
if (refresh_in_progress_.exchange(true)) return; 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.) // P4a: Skip transactions if no new blocks since last full fetch
if (addresses_dirty_) { const int currentBlocks = state_.sync.blocks;
refreshAddresses(); const bool doTransactions = (last_tx_block_height_ < 0
} || currentBlocks != last_tx_block_height_
|| state_.transactions.empty());
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();
}
// Clear the guard after all tasks are posted (they'll execute sequentially // Snapshot z-addresses for transaction fetch (needed on worker thread)
// on the worker thread, so the last one to finish signals completion). std::vector<std::string> txZAddrs;
// We post a sentinel task that clears the flag after all refresh work. if (doTransactions) {
worker_->post([this]() -> rpc::RPCWorker::MainCb { for (const auto& za : state_.z_addresses) {
return [this]() { if (!za.address.empty()) txZAddrs.push_back(za.address);
}
}
// 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);
}
}
}
// 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 (...) {}
}
refresh_in_progress_.store(false, std::memory_order_release); refresh_in_progress_.store(false, std::memory_order_release);
}; };
}); });
@@ -605,7 +1026,12 @@ void App::refreshTransactions()
void App::refreshMiningInfo() 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 // Prevent worker queue pileup — skip if previous refresh hasn't finished
if (mining_refresh_in_progress_.exchange(true)) return; 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). // p2p_port are static for the lifetime of a connection (set in onConnected).
bool doSlowRefresh = (mining_slow_counter_++ % 5 == 0); 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; json miningInfo, localHashrateJson;
bool miningOk = false, hashrateOk = false; bool miningOk = false, hashrateOk = false;
// Fast path: only getlocalsolps (single RPC call, ~1ms) — returns H/s (RandomX) // Fast path: only getlocalsolps (single RPC call, ~1ms) — returns H/s (RandomX)
try { try {
localHashrateJson = rpc_->call("getlocalsolps"); localHashrateJson = rpc->call("getlocalsolps");
hashrateOk = true; hashrateOk = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what()); DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
@@ -638,7 +1064,7 @@ void App::refreshMiningInfo()
// Slow path: getmininginfo every ~5s // Slow path: getmininginfo every ~5s
if (doSlowRefresh) { if (doSlowRefresh) {
try { try {
miningInfo = rpc_->call("getmininginfo"); miningInfo = rpc->call("getmininginfo");
miningOk = true; miningOk = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
DEBUG_LOGF("getMiningInfo error: %s\n", e.what()); 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; if (!rpc_ || !rpc_->isConnected() || !worker_) return;
lock_unlock_in_progress_ = true; 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; bool ok = false;
std::string err_msg; std::string err_msg;
try { try {
rpc_->call("walletpassphrase", {passphrase, timeout}); r->call("walletpassphrase", {passphrase, timeout});
ok = true; ok = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
err_msg = e.what(); err_msg = e.what();
@@ -256,10 +259,13 @@ void App::lockWallet() {
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
lock_unlock_in_progress_ = true; 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; bool ok = false;
try { try {
rpc_->call("walletlock"); r->call("walletlock");
ok = true; ok = true;
} catch (...) {} } catch (...) {}
@@ -279,11 +285,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
encrypt_in_progress_ = true; encrypt_in_progress_ = true;
encrypt_status_ = "Changing passphrase..."; 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; bool ok = false;
std::string err_msg; std::string err_msg;
try { try {
rpc_->call("walletpassphrasechange", {oldPass, newPass}); r->call("walletpassphrasechange", {oldPass, newPass});
ok = true; ok = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
err_msg = e.what(); err_msg = e.what();
@@ -609,8 +617,11 @@ void App::renderLockScreen() {
memset(lock_pin_buf_, 0, sizeof(lock_pin_buf_)); memset(lock_pin_buf_, 0, sizeof(lock_pin_buf_));
lock_unlock_in_progress_ = true; lock_unlock_in_progress_ = true;
if (worker_) { // Use fast-lane worker for priority unlock.
worker_->post([this, pin, timeout]() -> 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();
if (w) {
w->post([this, r, pin, timeout]() -> rpc::RPCWorker::MainCb {
// Heavy Argon2id derivation runs here (worker thread) // Heavy Argon2id derivation runs here (worker thread)
std::string passphrase; std::string passphrase;
bool vaultOk = vault_ && vault_->retrieve(pin, passphrase); bool vaultOk = vault_ && vault_->retrieve(pin, passphrase);
@@ -634,8 +645,8 @@ void App::renderLockScreen() {
bool rpcOk = false; bool rpcOk = false;
std::string rpcErr; std::string rpcErr;
try { try {
if (rpc_ && rpc_->isConnected()) { if (r && r->isConnected()) {
rpc_->call("walletpassphrase", {passphrase, timeout}); r->call("walletpassphrase", {passphrase, timeout});
rpcOk = true; rpcOk = true;
} else { } else {
rpcErr = "Not connected to daemon"; 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_tls")) pool_tls_ = j["pool_tls"].get<bool>();
if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].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("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()) if (j.contains("window_width") && j["window_width"].is_number_integer())
window_width_ = j["window_width"].get<int>(); window_width_ = j["window_width"].get<int>();
if (j.contains("window_height") && j["window_height"].is_number_integer()) 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_tls"] = pool_tls_;
j["pool_hugepages"] = pool_hugepages_; j["pool_hugepages"] = pool_hugepages_;
j["pool_mode"] = pool_mode_; j["pool_mode"] = pool_mode_;
j["font_scale"] = font_scale_;
if (window_width_ > 0 && window_height_ > 0) { if (window_width_ > 0 && window_height_ > 0) {
j["window_width"] = window_width_; j["window_width"] = window_width_;
j["window_height"] = window_height_; j["window_height"] = window_height_;

View File

@@ -202,6 +202,10 @@ public:
bool getPoolMode() const { return pool_mode_; } bool getPoolMode() const { return pool_mode_; }
void setPoolMode(bool v) { pool_mode_ = v; } 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) // Window size persistence (logical pixels at 1x scale)
int getWindowWidth() const { return window_width_; } int getWindowWidth() const { return window_width_; }
int getWindowHeight() const { return window_height_; } int getWindowHeight() const { return window_height_; }
@@ -254,6 +258,9 @@ private:
bool pool_hugepages_ = true; bool pool_hugepages_ = true;
bool pool_mode_ = false; // false=solo, true=pool 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) // Window size (logical pixels at 1x scale; 0 = use default 1200×775)
int window_width_ = 0; int window_width_ = 0;
int window_height_ = 0; int window_height_ = 0;

View File

@@ -778,6 +778,37 @@ int main(int argc, char* argv[])
dragonx::util::PerfLog::instance().init(perfPath); 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 // Handle pending payment URI from command line
if (!pendingURI.empty()) { if (!pendingURI.empty()) {
DEBUG_LOGF("Processing payment URI: %s\n", pendingURI.c_str()); 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; if (in_resize_render) return true;
in_resize_render = true; in_resize_render = true;
// Pre-frame: font atlas rebuilds before NewFrame()
ctx->app->preFrame();
#ifdef DRAGONX_USE_DX11 #ifdef DRAGONX_USE_DX11
ctx->dx->resize(event->window.data1, event->window.data2); ctx->dx->resize(event->window.data1, event->window.data2);
ImGui_ImplDX11_NewFrame(); ImGui_ImplDX11_NewFrame();
@@ -1259,6 +1293,43 @@ int main(int argc, char* argv[])
// --- PerfLog: begin frame --- // --- PerfLog: begin frame ---
dragonx::util::PerfLog::instance().beginFrame(); 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 // Start the Dear ImGui frame
PERF_BEGIN(_perfNewFrame); PERF_BEGIN(_perfNewFrame);
#ifdef DRAGONX_USE_DX11 #ifdef DRAGONX_USE_DX11

View File

@@ -36,16 +36,68 @@ namespace Layout {
// DPI Scaling (must be first — other accessors multiply by dpiScale()) // 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 * Returns the DPI scale set during typography initialization (e.g. 2.0 for
* 200 % Windows scaling). All pixel constants from TOML are in *logical* * 200 % Windows scaling). Use this when you need the pure hardware DPI
* pixels and must be multiplied by this factor before being used as ImGui * without the user's accessibility font scale applied.
* coordinates (which are physical pixels on Windows Per-Monitor DPI v2). */
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() { 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) { inline float hScale(float availWidth) {
const auto& S = schema::UI(); const auto& S = schema::UI();
float dp = dpiScale(); float rawDp = rawDpiScale(); // reference uses hardware DPI only
float rw = S.drawElement("responsive", "ref-width").sizeOr(1200.0f) * dp; 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 minH = S.drawElement("responsive", "min-h-scale").sizeOr(0.5f);
float maxH = S.drawElement("responsive", "max-h-scale").sizeOr(1.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); float logical = std::clamp(availWidth / rw, minH, maxH);
return logical * dp; return logical * dp;
} }
@@ -185,8 +238,9 @@ inline float hScale() {
*/ */
inline float vScale(float availHeight) { inline float vScale(float availHeight) {
const auto& S = schema::UI(); const auto& S = schema::UI();
float dp = dpiScale(); float rawDp = rawDpiScale(); // reference uses hardware DPI only
float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * dp; 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 minV = S.drawElement("responsive", "min-v-scale").sizeOr(0.5f);
float maxV = S.drawElement("responsive", "max-v-scale").sizeOr(1.4f); float maxV = S.drawElement("responsive", "max-v-scale").sizeOr(1.4f);
float logical = std::clamp(availHeight / rh, minV, maxV); float logical = std::clamp(availHeight / rh, minV, maxV);
@@ -205,8 +259,9 @@ inline float vScale() {
*/ */
inline float densityScale(float availHeight) { inline float densityScale(float availHeight) {
const auto& S = schema::UI(); const auto& S = schema::UI();
float dp = dpiScale(); float rawDp = rawDpiScale(); // reference uses hardware DPI only
float rh = S.drawElement("responsive", "ref-height").sizeOr(700.0f) * dp; 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 minDen = S.drawElement("responsive", "min-density").sizeOr(0.6f);
float maxDen = S.drawElement("responsive", "max-density").sizeOr(1.2f); float maxDen = S.drawElement("responsive", "max-density").sizeOr(1.2f);
float logical = std::clamp(availHeight / rh, minDen, maxDen); 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). // and DisplayFramebufferScale is 1.0 (no automatic upscaling).
// The window is resized by dpiScale in main.cpp so that fonts at // The window is resized by dpiScale in main.cpp so that fonts at
// size*dpiScale fit proportionally (no overflow). // size*dpiScale fit proportionally (no overflow).
float scale = dpiScale * Layout::kFontScale(); // Layout::userFontScale() is the user-chosen accessibility multiplier
DEBUG_LOGF("Typography: Loading Material Design type scale (DPI: %.2f, fontScale: %.2f, combined: %.2f)\n", dpiScale, Layout::kFontScale(), scale); // (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. // For ImGui, we need to load fonts at specific pixel sizes.
// Font sizes come from Layout:: accessors (backed by UISchema JSON) // 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 // Low-spec mode
static bool sp_low_spec_mode = false; 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, // Snapshot of effect settings saved when low-spec is toggled ON,
// restored when toggled OFF so user state isn't lost. // restored when toggled OFF so user state isn't lost.
struct LowSpecSnapshot { struct LowSpecSnapshot {
@@ -155,6 +158,8 @@ static void loadSettingsPageState(config::Settings* settings) {
sp_theme_effects_enabled = settings->getThemeEffectsEnabled(); sp_theme_effects_enabled = settings->getThemeEffectsEnabled();
sp_low_spec_mode = settings->getLowSpecMode(); sp_low_spec_mode = settings->getLowSpecMode();
effects::setLowSpecMode(sp_low_spec_mode); 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_keep_daemon_running = settings->getKeepDaemonRunning();
sp_stop_external_daemon = settings->getStopExternalDaemon(); sp_stop_external_daemon = settings->getStopExternalDaemon();
sp_debug_categories = settings->getDebugCategories(); sp_debug_categories = settings->getDebugCategories();
@@ -200,6 +205,7 @@ static void saveSettingsPageState(config::Settings* settings) {
settings->setScanlineEnabled(sp_scanline_enabled); settings->setScanlineEnabled(sp_scanline_enabled);
settings->setThemeEffectsEnabled(sp_theme_effects_enabled); settings->setThemeEffectsEnabled(sp_theme_effects_enabled);
settings->setLowSpecMode(sp_low_spec_mode); settings->setLowSpecMode(sp_low_spec_mode);
settings->setFontScale(sp_font_scale);
settings->setKeepDaemonRunning(sp_keep_daemon_running); settings->setKeepDaemonRunning(sp_keep_daemon_running);
settings->setStopExternalDaemon(sp_stop_external_daemon); settings->setStopExternalDaemon(sp_stop_external_daemon);
settings->setDebugCategories(sp_debug_categories); 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 // Bottom padding
ImGui::Dummy(ImVec2(0, bottomPad)); ImGui::Dummy(ImVec2(0, bottomPad));
ImGui::Unindent(pad); ImGui::Unindent(pad);

View File

@@ -231,11 +231,29 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
ImGui::PopFont(); ImGui::PopFont();
ImGui::EndChild(); 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 // 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 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, 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 // CRT scanline effect over output area — aligned to text lines
@@ -552,7 +570,12 @@ void ConsoleTab::renderOutput()
auto& S = schema::UI(); auto& S = schema::UI();
std::lock_guard<std::mutex> lock(lines_mutex_); 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 // Inner padding for glass panel
float padX = Layout::spacingMd(); float padX = Layout::spacingMd();
@@ -560,7 +583,7 @@ void ConsoleTab::renderOutput()
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padY); ImGui::SetCursorPosY(ImGui::GetCursorPosY() + padY);
ImGui::Indent(padX); ImGui::Indent(padX);
float line_height = ImGui::GetTextLineHeightWithSpacing(); float line_height = ImGui::GetTextLineHeight();
output_line_height_ = line_height; // store for scanline alignment output_line_height_ = line_height; // store for scanline alignment
output_origin_ = ImGui::GetCursorScreenPos(); output_origin_ = ImGui::GetCursorScreenPos();
output_scroll_y_ = ImGui::GetScrollY(); output_scroll_y_ = ImGui::GetScrollY();
@@ -598,33 +621,69 @@ void ConsoleTab::renderOutput()
} }
int visible_count = static_cast<int>(visible_indices_.size()); int visible_count = static_cast<int>(visible_indices_.size());
// Calculate wrapped heights for each visible line // Calculate wrapped heights AND build sub-row segments for each visible line.
// This is needed because TextWrapped creates variable-height content // 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; 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); wrapped_heights_.resize(visible_count);
cumulative_y_offsets_.resize(visible_count); cumulative_y_offsets_.resize(visible_count);
visible_wrap_segments_.resize(visible_count);
total_wrapped_height_ = 0.0f; total_wrapped_height_ = 0.0f;
cached_wrap_width_ = wrap_width; cached_wrap_width_ = wrap_width;
for (int vi = 0; vi < visible_count; vi++) { for (int vi = 0; vi < visible_count; vi++) {
int i = visible_indices_[vi]; int i = visible_indices_[vi];
const std::string& text = lines_[i].text; 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()) { if (text.empty()) {
sz = ImVec2(0.0f, line_height); segs.push_back({0, 0, 0.0f, line_height});
} else { cumulative_y_offsets_[vi] = total_wrapped_height_;
sz = ImGui::CalcTextSize(text.c_str(), nullptr, false, wrap_width); wrapped_heights_[vi] = line_height + interLineGap;
// Add a small margin for item spacing total_wrapped_height_ += wrapped_heights_[vi];
sz.y = std::max(sz.y, line_height); 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_; cumulative_y_offsets_[vi] = total_wrapped_height_;
wrapped_heights_[vi] = sz.y; wrapped_heights_[vi] = segY + interLineGap;
total_wrapped_height_ += sz.y; total_wrapped_height_ += wrapped_heights_[vi];
} }
// Use raw IO for mouse handling to bypass child window event consumption // 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) // Disable auto-scroll when user scrolls up (wheel scroll)
if (mouse_in_output && io.MouseWheel > 0.0f) { if (mouse_in_output && io.MouseWheel > 0.0f) {
auto_scroll_ = false; 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 // Set cursor to text selection when hovering
if (mouse_in_output) { if (mouse_in_output) {
@@ -696,8 +760,9 @@ void ConsoleTab::renderOutput()
TextPos sel_start_pos = selectionStart(); TextPos sel_start_pos = selectionStart();
TextPos sel_end_pos = selectionEnd(); TextPos sel_end_pos = selectionEnd();
// Render lines with selection highlighting // Render lines with selection highlighting.
// Use manual rendering instead of ImGuiListClipper to support variable-height wrapped lines // 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 scroll_y = ImGui::GetScrollY();
float window_height = ImGui::GetWindowHeight(); float window_height = ImGui::GetWindowHeight();
float visible_top = scroll_y; float visible_top = scroll_y;
@@ -724,10 +789,12 @@ void ConsoleTab::renderOutput()
ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible])); ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible]));
} }
ImDrawList* dl = ImGui::GetWindowDrawList();
ImU32 selColor = WithAlpha(Secondary(), 80);
// Render visible lines // 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++) { 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()) && if (vi < static_cast<int>(cumulative_y_offsets_.size()) &&
cumulative_y_offsets_[vi] > visible_bottom) { cumulative_y_offsets_[vi] > visible_bottom) {
break; break;
@@ -736,60 +803,59 @@ void ConsoleTab::renderOutput()
int i = visible_indices_[vi]; int i = visible_indices_[vi];
const auto& line = lines_[i]; const auto& line = lines_[i];
ImVec2 text_pos = ImGui::GetCursorScreenPos(); const auto& segs = visible_wrap_segments_[vi];
float this_line_height = (vi < static_cast<int>(wrapped_heights_.size())) ImVec2 lineOrigin = ImGui::GetCursorScreenPos();
? wrapped_heights_[vi] : line_height; 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) { if (has_selection_ && i >= sel_start_pos.line && i <= sel_end_pos.line) {
int sel_col_start = 0; lineSelected = true;
int sel_col_end = static_cast<int>(line.text.size()); 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());
}
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) { // Selection highlight for this sub-row
sel_col_start = sel_start_pos.col; if (lineSelected && selByteStart < seg.byteEnd && selByteEnd > seg.byteStart) {
} int hlStart = std::max(selByteStart, seg.byteStart) - seg.byteStart;
if (i == sel_end_pos.line) { int hlEnd = std::min(selByteEnd, seg.byteEnd) - seg.byteStart;
sel_col_end = sel_end_pos.col; 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) { // Render text segment
// Calculate pixel positions for highlight if (seg.byteStart < seg.byteEnd) {
float x_start = 0; dl->AddText(font, fontSize,
float x_end = 0; ImVec2(lineOrigin.x, rowY),
line.color, segStart, segEnd);
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
);
} }
} }
ImGui::PushStyleColor(ImGuiCol_Text, ImColor(line.color).Value); // Advance ImGui cursor by the total wrapped height of this line
ImGui::PushTextWrapPos(ImGui::GetContentRegionAvail().x - padX); ImGui::Dummy(ImVec2(0, totalH));
ImGui::TextWrapped("%s", line.text.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
} }
// Add spacer for lines after last visible (to maintain correct content height) // Add spacer for lines after last visible (to maintain correct content height)
@@ -806,9 +872,10 @@ void ConsoleTab::renderOutput()
ImGui::Unindent(padX); ImGui::Unindent(padX);
ImGui::PopStyleVar(); ImGui::PopStyleVar();
// Add bottom padding so the last line sits above the fade-out zone. // Bottom padding keeps the last line above the fade-out zone.
// Only the fade zone height is used — no extra padding beyond that, // Always present so that scrollMaxY stays stable when auto-scroll
// so text can still overflow into the fade and scrolling stays snappy. // 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, float fadeZone = std::min(Type().caption()->LegacySize * 3.0f,
ImGui::GetWindowHeight() * 0.18f); ImGui::GetWindowHeight() * 0.18f);
@@ -911,14 +978,11 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_he
return {0, 0}; 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; 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; int visible_line = 0;
if (!cumulative_y_offsets_.empty()) { 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; int lo = 0, hi = static_cast<int>(cumulative_y_offsets_.size()) - 1;
while (lo < hi) { while (lo < hi) {
int mid = (lo + hi + 1) / 2; int mid = (lo + hi + 1) / 2;
@@ -929,12 +993,9 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos, float line_he
} }
} }
visible_line = lo; 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 < 0) visible_line = 0;
if (visible_line >= static_cast<int>(visible_indices_.size())) { if (visible_line >= static_cast<int>(visible_indices_.size())) {
visible_line = static_cast<int>(visible_indices_.size()) - 1; 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; return pos;
} }
// Map visible line index to actual line index
pos.line = visible_indices_[visible_line]; pos.line = visible_indices_[visible_line];
// Calculate column from X position
const std::string& text = lines_[pos.line].text; 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; pos.col = 0;
return pos; return pos;
} }
// Binary search for the character position // Find which sub-row (wrap segment) the mouse Y falls into
// Walk character by character for accuracy const auto& segs = visible_wrap_segments_[visible_line];
pos.col = 0; float lineRelY = relative_y - cumulative_y_offsets_[visible_line];
for (int c = 0; c < static_cast<int>(text.size()); c++) { int segIdx = 0;
ImVec2 sz = ImGui::CalcTextSize(text.c_str(), text.c_str() + c + 1); for (int s = 0; s < static_cast<int>(segs.size()); s++) {
float char_mid = (c > 0) if (lineRelY >= segs[s].yOffset)
? (ImGui::CalcTextSize(text.c_str(), text.c_str() + c).x + sz.x) * 0.5f segIdx = s;
: sz.x * 0.5f; }
if (relative_x < char_mid) { const auto& seg = segs[segIdx];
pos.col = c;
return pos; // Calculate column within this segment from X position
} float relative_x = screen_pos.x - output_origin_.x;
pos.col = c + 1; 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; return pos;
} }

View File

@@ -113,6 +113,7 @@ private:
char input_buffer_[4096] = {0}; char input_buffer_[4096] = {0};
bool auto_scroll_ = true; bool auto_scroll_ = true;
bool scroll_to_bottom_ = false; 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) int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator)
size_t last_daemon_output_size_ = 0; size_t last_daemon_output_size_ = 0;
size_t last_xmrig_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 total_wrapped_height_ = 0.0f; // Total height of all visible lines
mutable float cached_wrap_width_ = 0.0f; // Wrap width used for cached heights 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 // Commands popup
bool show_commands_popup_ = false; bool show_commands_popup_ = false;
}; };

View File

@@ -1021,43 +1021,49 @@ void RenderMiningTab(App* app)
} }
// Catmull-Rom spline interpolation for smooth curve // 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; std::vector<ImVec2> points;
if (n <= 2) { if (n <= 2) {
points = rawPts; points = rawPts;
} else { } else {
const int subdivs = 8; // segments between each pair of data points points.reserve(n * 4); // conservative estimate
points.reserve((n - 1) * subdivs + 1);
for (size_t i = 0; i + 1 < n; i++) { 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 p0 = rawPts[i > 0 ? i - 1 : 0];
ImVec2 p1 = rawPts[i]; ImVec2 p1 = rawPts[i];
ImVec2 p2 = rawPts[i + 1]; ImVec2 p2 = rawPts[i + 1];
ImVec2 p3 = rawPts[i + 2 < n ? i + 2 : n - 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++) { for (int s = 0; s < subdivs; s++) {
float t = (float)s / (float)subdivs; float t = (float)s / (float)subdivs;
float t2 = t * t; float t2 = t * t;
float t3 = t2 * t; float t3 = t2 * t;
// Catmull-Rom basis
float q0 = -t3 + 2.0f * t2 - t; float q0 = -t3 + 2.0f * t2 - t;
float q1 = 3.0f * t3 - 5.0f * t2 + 2.0f; float q1 = 3.0f * t3 - 5.0f * t2 + 2.0f;
float q2 = -3.0f * t3 + 4.0f * t2 + t; float q2 = -3.0f * t3 + 4.0f * t2 + t;
float q3 = t3 - t2; float q3 = t3 - t2;
float sx = 0.5f * (p0.x * q0 + p1.x * q1 + p2.x * q2 + p3.x * q3); 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); 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(ImVec2(sx, sy));
} }
} }
points.push_back(rawPts[n - 1]); // final point points.push_back(rawPts[n - 1]); // final point
} }
// Fill under curve // Fill under curve (single concave polygon to avoid AA seam shimmer)
for (size_t i = 0; i + 1 < points.size(); i++) { if (points.size() >= 2) {
ImVec2 quad[4] = { for (size_t i = 0; i < points.size(); i++)
points[i], points[i + 1], dl->PathLineTo(points[i]);
ImVec2(points[i + 1].x, plotBottom), dl->PathLineTo(ImVec2(points.back().x, plotBottom));
ImVec2(points[i].x, plotBottom) dl->PathLineTo(ImVec2(points.front().x, plotBottom));
}; dl->PathFillConcave(WithAlpha(Success(), 25));
dl->AddConvexPolyFilled(quad, 4, WithAlpha(Success(), 25));
} }
// Green line // Green line