Compare commits
48 Commits
24e8fb4942
...
530bf24abf
| Author | SHA1 | Date | |
|---|---|---|---|
| 530bf24abf | |||
| e3b53258b7 | |||
| f9e4db4abf | |||
| bdccddfeee | |||
| d68bc27aac | |||
| 51667b5a27 | |||
| 96e05b4ba6 | |||
| 36386b2b84 | |||
| 2fc577fb18 | |||
| 5180f74a68 | |||
| fc5ce019e2 | |||
| 7826be4b13 | |||
| 5d6e78c719 | |||
| a1e4acfe86 | |||
| 419f8bb9cb | |||
| c5ef9e42ed | |||
| f0243307f1 | |||
| 95ddfa05ee | |||
| ecc0043559 | |||
| 3a9ff5f021 | |||
| 1faaa54a07 | |||
| 61f8c54742 | |||
| 48fc9ba8bb | |||
| 2552234610 | |||
| ff20a8a44e | |||
| 245d9bf976 | |||
| 1c248d727a | |||
| 57291ef28c | |||
| 18c1de87db | |||
| 0cdbbd024e | |||
| 779c9682d4 | |||
| 33735b1d52 | |||
| 19e83eb12a | |||
| f12153cdd0 | |||
| 8abc0ec113 | |||
| 5a677f6202 | |||
| efec3a50a1 | |||
| 4ed69d4e9f | |||
| e0d6d6bc4d | |||
| e9121fe49e | |||
| b5001ad4db | |||
| 1266b6e68e | |||
| 57f52b7a6c | |||
| a6964621a8 | |||
| 14f5fdc559 | |||
| 1a00ec3359 | |||
| 5c490c8d53 | |||
| d0586e5e75 |
@@ -452,14 +452,18 @@ set(APP_SOURCES
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
src/ui/windows/peers_tab.cpp
|
||||
src/ui/windows/network_tab.cpp
|
||||
src/ui/windows/lite_console_tab.cpp
|
||||
src/ui/windows/explorer_tab.cpp
|
||||
src/ui/windows/market_tab.cpp
|
||||
src/ui/windows/console_tab.cpp
|
||||
src/ui/windows/console_command_executor.cpp
|
||||
src/ui/windows/console_command_reference.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_scroll_controller.cpp
|
||||
src/ui/windows/console_selection_controller.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/console_text_layout.cpp
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.cpp
|
||||
src/ui/windows/about_dialog.cpp
|
||||
@@ -500,6 +504,8 @@ set(APP_SOURCES
|
||||
src/daemon/xmrig_manager.cpp
|
||||
src/util/bootstrap.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/pool_registry_core.cpp
|
||||
src/util/pool_stats_service.cpp
|
||||
src/util/xmrig_updater.cpp
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
@@ -576,9 +582,13 @@ set(APP_HEADERS
|
||||
src/ui/windows/peers_tab.h
|
||||
src/ui/windows/explorer_tab.h
|
||||
src/ui/windows/market_tab.h
|
||||
src/ui/windows/console_channel.h
|
||||
src/ui/windows/console_command_reference.h
|
||||
src/ui/windows/console_input_model.h
|
||||
src/ui/windows/console_model.h
|
||||
src/ui/windows/console_output_model.h
|
||||
src/ui/windows/console_scroll_controller.h
|
||||
src/ui/windows/console_selection_controller.h
|
||||
src/ui/windows/console_tab.h
|
||||
src/ui/windows/console_tab_helpers.h
|
||||
src/ui/windows/settings_window.h
|
||||
@@ -679,6 +689,7 @@ set_source_files_properties(
|
||||
"${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-R.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/UbuntuMono-R.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"
|
||||
@@ -1015,8 +1026,12 @@ if(BUILD_TESTING)
|
||||
src/ui/windows/balance_address_list.cpp
|
||||
src/ui/windows/balance_recent_tx.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_scroll_controller.cpp
|
||||
src/ui/windows/console_selection_controller.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/console_text_layout.cpp
|
||||
src/ui/windows/mining_benchmark.cpp
|
||||
src/ui/windows/mining_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
@@ -1035,6 +1050,7 @@ if(BUILD_TESTING)
|
||||
src/util/platform.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/pool_registry_core.cpp
|
||||
src/util/xmrig_updater.cpp
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
|
||||
BIN
res/fonts/UbuntuMono-R.ttf
Normal file
BIN
res/fonts/UbuntuMono-R.ttf
Normal file
Binary file not shown.
@@ -908,6 +908,7 @@ scroll-fade-zone = { size = 24.0 }
|
||||
|
||||
[tabs.console]
|
||||
input-area-padding = 8.0
|
||||
bg-darken-alpha = { size = 110.0 } # black overlay alpha (0-255) for the terminal-dark output + input
|
||||
output-line-spacing = 2.0
|
||||
output = { line-spacing = 2 }
|
||||
scroll-multiplier = { size = 3.0 }
|
||||
@@ -1324,10 +1325,13 @@ wallet-btn-padding = { size = 24.0 }
|
||||
rpc-label-min-width = { size = 70.0 }
|
||||
rpc-label-width = { size = 85.0 }
|
||||
security-combo-width = { size = 120.0 }
|
||||
node-grid-breakpoint = { size = 900.0 }
|
||||
port-input-min-width = { size = 60.0 }
|
||||
port-input-width-ratio = { size = 0.4 }
|
||||
idle-combo-width = { size = 64.0 }
|
||||
about-logo-size = { size = 64.0 }
|
||||
# Reserved height basis for the About-card logo; the logo is drawn scaled to the
|
||||
# card's actual height (aspect-preserved) and capped to this * aspect in width.
|
||||
about-logo-size = { size = 150.0 }
|
||||
|
||||
[components.main-layout]
|
||||
app-bar-height = { size = 64.0 }
|
||||
|
||||
187
src/app.cpp
187
src/app.cpp
@@ -22,6 +22,7 @@
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
#include "util/pool_registry.h"
|
||||
#include "ui/windows/main_window.h"
|
||||
#include "ui/windows/balance_tab.h"
|
||||
#include "ui/windows/send_tab.h"
|
||||
@@ -30,7 +31,7 @@
|
||||
#include "ui/windows/mining_tab.h"
|
||||
#include "ui/windows/peers_tab.h"
|
||||
#include "ui/windows/network_tab.h"
|
||||
#include "ui/windows/lite_console_tab.h"
|
||||
#include "ui/windows/console_command_executor.h"
|
||||
#include "ui/windows/explorer_tab.h"
|
||||
#include "ui/windows/market_tab.h"
|
||||
#include "ui/windows/settings_window.h"
|
||||
@@ -109,9 +110,120 @@ namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
App::App() = default;
|
||||
App::App()
|
||||
{
|
||||
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't
|
||||
// deterministic across launches.
|
||||
balance_rng_.seed(std::random_device{}());
|
||||
}
|
||||
App::~App() = default;
|
||||
|
||||
namespace {
|
||||
// How often auto-balance re-evaluates the pool while active. Long, because switching
|
||||
// restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness
|
||||
// in chooseWeightedPool makes actual switches rarer still.
|
||||
constexpr long long kRebalanceIntervalMs = 30LL * 60LL * 1000LL; // 30 minutes
|
||||
|
||||
long long steadyNowMs()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void App::updatePoolAutoBalance()
|
||||
{
|
||||
if (!settings_) return;
|
||||
if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only)
|
||||
if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
|
||||
if (!settings_->getPoolMode()) return; // only while POOL mode is selected
|
||||
|
||||
const long long now = steadyNowMs();
|
||||
const bool intervalDue = (last_balance_eval_ms_ == 0) ||
|
||||
(now - last_balance_eval_ms_ >= kRebalanceIntervalMs);
|
||||
|
||||
// Kick a background refresh when requested or due (never while one is in flight).
|
||||
if ((balance_refresh_pending_ || intervalDue) && !pool_stats_service_.busy()) {
|
||||
balance_refresh_pending_ = false;
|
||||
last_balance_eval_ms_ = now;
|
||||
balance_snapshot_seen_ = false;
|
||||
pool_stats_service_.refresh(util::knownPools());
|
||||
return; // results land on a later frame
|
||||
}
|
||||
|
||||
// Apply the freshly-completed snapshot exactly once.
|
||||
if (!pool_stats_service_.busy()) {
|
||||
const auto snap = pool_stats_service_.snapshot();
|
||||
if (snap.ready && !balance_snapshot_seen_) {
|
||||
balance_snapshot_seen_ = true;
|
||||
applyPoolAutoBalance(snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap)
|
||||
{
|
||||
const auto& pools = util::knownPools();
|
||||
|
||||
std::vector<util::PoolHashrate> samples;
|
||||
samples.reserve(pools.size());
|
||||
for (const auto& kp : pools) {
|
||||
const auto it = snap.byId.find(kp.id);
|
||||
samples.push_back(it != snap.byId.end()
|
||||
? it->second
|
||||
: util::PoolHashrate{kp.id, 0.0, false});
|
||||
}
|
||||
|
||||
const util::KnownPool* current = util::findKnownPoolByUrl(settings_->getPoolUrl());
|
||||
const std::string currentId = current ? current->id : std::string{};
|
||||
|
||||
const std::string chosenId = util::chooseWeightedPool(samples, currentId, balance_rng_);
|
||||
if (chosenId.empty() || chosenId == currentId) return; // keep the current pool
|
||||
|
||||
const util::KnownPool* chosen = nullptr;
|
||||
for (const auto& kp : pools)
|
||||
if (kp.id == chosenId) { chosen = &kp; break; }
|
||||
if (!chosen) return;
|
||||
|
||||
settings_->setPoolUrl(chosen->stratum);
|
||||
settings_->save();
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
std::string(TR("mining_auto_balanced_to")) + " " + chosen->label);
|
||||
|
||||
// Restart so the new pool (and its per-pool algo) takes effect immediately.
|
||||
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
|
||||
const int threads = xmrig_manager_->getRequestedThreads();
|
||||
stopPoolMining();
|
||||
startPoolMining(threads);
|
||||
}
|
||||
}
|
||||
|
||||
std::string App::poolMiningInstalledVersion()
|
||||
{
|
||||
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot
|
||||
return daemon::XmrigManager::installedVersion();
|
||||
}
|
||||
|
||||
// Console backend accessors — prefer the fast-lane RPC connection (so console commands
|
||||
// don't queue behind the refresh batch), falling back to the main connection.
|
||||
rpc::RPCClient* App::consoleRpc()
|
||||
{
|
||||
return (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
}
|
||||
rpc::RPCWorker* App::consoleWorker()
|
||||
{
|
||||
return (fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get();
|
||||
}
|
||||
daemon::EmbeddedDaemon* App::consoleDaemon()
|
||||
{
|
||||
return daemon_controller_ ? daemon_controller_->daemon() : nullptr;
|
||||
}
|
||||
daemon::XmrigManager* App::consoleXmrig()
|
||||
{
|
||||
return xmrig_manager_.get();
|
||||
}
|
||||
|
||||
bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
|
||||
{
|
||||
const char* label = context ? context : "App";
|
||||
@@ -284,37 +396,47 @@ bool App::init()
|
||||
worker_->start();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);
|
||||
|
||||
// Forward error/warning notifications to the console tab
|
||||
// Use ConsoleTab colors so the Errors filter works correctly
|
||||
// Warm the Mining tab's data at startup (both async / off the UI thread) so pool
|
||||
// hashrates and the installed miner version are shown as soon as the tab is opened,
|
||||
// instead of only after the user clicks a mine / auto-balance button.
|
||||
if (supportsPoolMining()) {
|
||||
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot `xmrig --version`
|
||||
pool_stats_service_.refresh(util::knownPools()); // snapshot lands on a later frame
|
||||
}
|
||||
|
||||
// Forward error/warning notifications to the console tab. The channel is the semantic
|
||||
// key — the Errors filter keys off ConsoleChannel::Error, and the color/accent bar are
|
||||
// derived from the channel at draw time.
|
||||
ui::Notifications::instance().setConsoleCallback(
|
||||
[this](const std::string& msg, bool is_error) {
|
||||
ImU32 color = is_error ? ui::ConsoleTab::COLOR_ERROR : ui::material::Warning();
|
||||
console_tab_.addLine(msg, color);
|
||||
console_tab_.addLine(msg, is_error ? ui::ConsoleChannel::Error
|
||||
: ui::ConsoleChannel::Warning);
|
||||
});
|
||||
|
||||
// Forward all app-level log messages (DEBUG_LOGF, LOGF, etc.) to the
|
||||
// console tab so they are visible in the UI, not just in the log file.
|
||||
util::Logger::instance().setCallback(
|
||||
[this](const std::string& msg) {
|
||||
// Classify by content: errors in red, warnings in warning color,
|
||||
// everything else in the default info color.
|
||||
ImU32 color = ui::ConsoleTab::COLOR_INFO;
|
||||
// Classify by content into a severity channel: errors, warnings, else the App
|
||||
// channel (the routine wallet log stream, controlled by the App messages filter).
|
||||
ui::ConsoleChannel channel = ui::ConsoleChannel::App;
|
||||
if (msg.find("[ERROR]") != std::string::npos ||
|
||||
msg.find("error") != std::string::npos ||
|
||||
msg.find("Error") != std::string::npos ||
|
||||
msg.find("failed") != std::string::npos ||
|
||||
msg.find("Failed") != std::string::npos) {
|
||||
color = ui::ConsoleTab::COLOR_ERROR;
|
||||
channel = ui::ConsoleChannel::Error;
|
||||
} else if (msg.find("[WARN]") != std::string::npos ||
|
||||
msg.find("warn") != std::string::npos) {
|
||||
color = ui::material::Warning();
|
||||
channel = ui::ConsoleChannel::Warning;
|
||||
}
|
||||
// Strip trailing newline so console tab lines look clean
|
||||
// Strip trailing newline so console tab lines look clean. The App channel
|
||||
// supplies the accent bar; no "[app] " text prefix needed.
|
||||
std::string trimmed = msg;
|
||||
while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r'))
|
||||
trimmed.pop_back();
|
||||
if (!trimmed.empty())
|
||||
console_tab_.addLine("[app] " + trimmed, color);
|
||||
console_tab_.addLine(trimmed, channel);
|
||||
});
|
||||
|
||||
// Check for first-run wizard — also re-run if blockchain data is missing
|
||||
@@ -706,6 +828,7 @@ void App::update()
|
||||
ps.pool_diff = xs.pool_diff;
|
||||
ps.pool_url = xs.pool_url;
|
||||
ps.algo = xs.algo;
|
||||
ps.version = xs.version;
|
||||
ps.connected = xs.connected;
|
||||
// Get memory directly from OS (more reliable than API)
|
||||
double memMB = xmrig_manager_->getMemoryUsageMB();
|
||||
@@ -723,6 +846,9 @@ void App::update()
|
||||
state_.pool_mining.xmrig_running = false;
|
||||
}
|
||||
|
||||
// Auto-balance: periodically re-pick the pool by hashrate (self-throttled).
|
||||
updatePoolAutoBalance();
|
||||
|
||||
// Populate solo mining log lines from daemon output
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
state_.mining.log_lines = daemon_controller_->recentLines(50);
|
||||
@@ -1381,9 +1507,18 @@ void App::render()
|
||||
// Apply header text opacity
|
||||
int a = (int)((float)((textCol >> 24) & 0xFF) * hdrOpacity);
|
||||
textCol = (textCol & 0x00FFFFFF) | ((ImU32)a << 24);
|
||||
dl->AddText(titleFont, titleFontSize,
|
||||
ImVec2(logoX, textY),
|
||||
textCol, "ObsidianDragon");
|
||||
|
||||
// Base brand word in a heavier (Ubuntu Medium) weight as the "semibold" —
|
||||
// the project ships no true 600 face. On the lite build the "Lite" suffix is
|
||||
// appended in the Regular title font.
|
||||
ImFont* baseFont = ui::material::Type().subtitle2(); // Ubuntu Medium
|
||||
if (!baseFont) baseFont = titleFont;
|
||||
const char* baseWord = "ObsidianDragon";
|
||||
dl->AddText(baseFont, titleFontSize, ImVec2(logoX, textY), textCol, baseWord);
|
||||
#if DRAGONX_LITE_BUILD
|
||||
float baseW = baseFont->CalcTextSizeA(titleFontSize, FLT_MAX, 0.0f, baseWord).x;
|
||||
dl->AddText(titleFont, titleFontSize, ImVec2(logoX + baseW, textY), textCol, "Lite");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1708,23 +1843,23 @@ void App::render()
|
||||
case ui::NavPage::LiteNetwork:
|
||||
ui::RenderLiteNetworkTab(this);
|
||||
break;
|
||||
case ui::NavPage::LiteConsole:
|
||||
ui::RenderLiteConsoleTab(this);
|
||||
break;
|
||||
case ui::NavPage::Explorer:
|
||||
ui::RenderExplorerTab(this);
|
||||
break;
|
||||
case ui::NavPage::Market:
|
||||
ui::RenderMarketTab(this);
|
||||
break;
|
||||
// One console UI for both variants; the executor supplies the backend (full-node
|
||||
// RPC vs lite). The two NavPages are mutually exclusive per build.
|
||||
case ui::NavPage::LiteConsole:
|
||||
case ui::NavPage::Console:
|
||||
// Use fast-lane worker for console commands to avoid head-of-line
|
||||
// blocking behind the consolidated refreshData() batch.
|
||||
// Fall back to main rpc/worker if fast-lane hasn't connected yet.
|
||||
console_tab_.render(daemon_controller_ ? daemon_controller_->daemon() : nullptr,
|
||||
(fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(),
|
||||
(fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get(),
|
||||
xmrig_manager_.get());
|
||||
if (!console_exec_) {
|
||||
if (isLiteBuild())
|
||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||
else
|
||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
||||
}
|
||||
console_tab_.render(*console_exec_);
|
||||
break;
|
||||
case ui::NavPage::Settings:
|
||||
ui::RenderSettingsPage(this);
|
||||
|
||||
41
src/app.h
41
src/app.h
@@ -19,6 +19,7 @@
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "util/async_task_manager.h"
|
||||
#include "util/pool_stats_service.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
@@ -154,6 +155,12 @@ public:
|
||||
// Accessors for subsystems
|
||||
rpc::RPCClient* rpc() { return rpc_.get(); }
|
||||
rpc::RPCWorker* worker() { return worker_.get(); }
|
||||
// Console backend accessors (fast-lane-preferring, defined in app.cpp where the
|
||||
// subsystem types are complete) used by the shared console executor.
|
||||
rpc::RPCClient* consoleRpc();
|
||||
rpc::RPCWorker* consoleWorker();
|
||||
daemon::EmbeddedDaemon* consoleDaemon();
|
||||
daemon::XmrigManager* consoleXmrig();
|
||||
config::Settings* settings() { return settings_.get(); }
|
||||
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||
@@ -204,6 +211,17 @@ public:
|
||||
return xmrig_manager_ && xmrig_manager_->isRunning();
|
||||
}
|
||||
|
||||
// Auto-balance: latest per-pool hashrate snapshot (for the mining tab pool list),
|
||||
// and a request to refresh it now (Refresh button / switching into Auto mode).
|
||||
util::PoolStatsService::Snapshot poolStatsSnapshot() const {
|
||||
return pool_stats_service_.snapshot();
|
||||
}
|
||||
void requestPoolBalanceRefresh() { balance_refresh_pending_ = true; }
|
||||
|
||||
// Installed miner version (detected from `xmrig --version`, cached; kicks the one-shot
|
||||
// detection on first call) so the mining tab can show it before mining starts.
|
||||
std::string poolMiningInstalledVersion();
|
||||
|
||||
// Mine-when-idle state query
|
||||
bool isIdleMiningActive() const { return idle_mining_active_; }
|
||||
|
||||
@@ -240,6 +258,10 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order);
|
||||
int getNextSortOrder() const;
|
||||
void swapAddressOrder(const std::string& a, const std::string& b);
|
||||
// Assign dense sort orders (0..N-1) to the given addresses in the given order and
|
||||
// persist once. Used by drag-reorder so a drop always takes effect (even from the
|
||||
// default un-ordered state, where a pairwise swap would be a no-op).
|
||||
void reorderAddresses(const std::vector<std::string>& orderedAddrs);
|
||||
bool isMiningAddress(const std::string& addr) const;
|
||||
void setMiningAddress(const std::string& addr, bool mining);
|
||||
void invalidateAddressValidationCache();
|
||||
@@ -269,6 +291,9 @@ public:
|
||||
void refreshMiningInfo();
|
||||
void refreshPeerInfo();
|
||||
void refreshMarketData();
|
||||
// Fetch the live exchange/pair list from CoinGecko once per session (venues are
|
||||
// near-static); populates state.market.exchanges. Safe to call every frame.
|
||||
void refreshExchanges();
|
||||
|
||||
/// @brief Per-category refresh intervals, adjusted by active tab
|
||||
using RefreshIntervals = services::NetworkRefreshService::Intervals;
|
||||
@@ -455,6 +480,11 @@ private:
|
||||
void pruneShieldedHistoryScanProgress();
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache);
|
||||
|
||||
// Auto-balance pool selection: drive the periodic hashrate refresh and apply a
|
||||
// freshly-completed snapshot (weighted-random pick + optional miner restart).
|
||||
void updatePoolAutoBalance();
|
||||
void applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap);
|
||||
|
||||
// Subsystems
|
||||
std::unique_ptr<rpc::RPCClient> rpc_;
|
||||
std::unique_ptr<rpc::RPCWorker> worker_;
|
||||
@@ -488,6 +518,14 @@ private:
|
||||
bool lite_startup_lock_checked_ = false;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
||||
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
||||
util::PoolStatsService pool_stats_service_;
|
||||
std::mt19937 balance_rng_;
|
||||
long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick
|
||||
bool balance_refresh_pending_ = false; // UI asked for an immediate refresh
|
||||
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
|
||||
bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
@@ -575,8 +613,9 @@ private:
|
||||
int coin_logo_h_ = 0;
|
||||
bool coin_logo_loaded_ = false;
|
||||
|
||||
// Console tab
|
||||
// Console tab + its backend executor (full-node RPC or lite backend), created lazily.
|
||||
ui::ConsoleTab console_tab_;
|
||||
std::unique_ptr<ui::ConsoleCommandExecutor> console_exec_;
|
||||
|
||||
// Pending payment from URI
|
||||
bool pending_payment_valid_ = false;
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
#include "util/pool_registry.h"
|
||||
#include "ui/notifications.h"
|
||||
#include "default_banlist_embedded.h"
|
||||
#include "util/amount_format.h"
|
||||
@@ -1630,6 +1631,51 @@ void App::refreshPrice()
|
||||
void App::refreshMarketData()
|
||||
{
|
||||
refreshPrice();
|
||||
refreshExchanges();
|
||||
}
|
||||
|
||||
void App::refreshExchanges()
|
||||
{
|
||||
if (!settings_ || !settings_->getFetchPrices()) return;
|
||||
if (!worker_) return;
|
||||
if (exchanges_fetch_started_) return; // once per session (venues are near-static)
|
||||
exchanges_fetch_started_ = true;
|
||||
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<data::ExchangeInfo> exchanges;
|
||||
try {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (curl) {
|
||||
std::string body;
|
||||
const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers";
|
||||
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
|
||||
size_t total = size * nmemb;
|
||||
userp->append((char*)contents, total);
|
||||
return total;
|
||||
};
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_cleanup(curl);
|
||||
if (res == CURLE_OK && http_code >= 200 && http_code < 300)
|
||||
exchanges = data::parseCoinGeckoTickers(body);
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
return [this, exchanges = std::move(exchanges)]() mutable {
|
||||
if (!exchanges.empty())
|
||||
state_.market.exchanges = std::move(exchanges);
|
||||
else
|
||||
exchanges_fetch_started_ = false; // allow a retry if it failed/empty
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1718,7 +1764,9 @@ void App::startPoolMining(int threads)
|
||||
daemon::XmrigManager::Config cfg;
|
||||
cfg.pool_url = settings_->getPoolUrl();
|
||||
cfg.worker_name = settings_->getPoolWorker();
|
||||
cfg.algo = settings_->getPoolAlgo();
|
||||
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
|
||||
// needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
|
||||
cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
|
||||
cfg.threads = threads; // Use the same thread selection as solo mining
|
||||
cfg.tls = settings_->getPoolTls();
|
||||
cfg.hugepages = settings_->getPoolHugepages();
|
||||
@@ -1932,6 +1980,7 @@ void App::createNewZAddress(std::function<void(const std::string&)> callback)
|
||||
info.type = "shielded";
|
||||
info.balance = 0.0;
|
||||
state_.z_addresses.push_back(info);
|
||||
state_.addresses.push_back(info); // keep combined view in sync immediately
|
||||
address_list_dirty_ = true;
|
||||
// Also trigger full refresh to get proper balances
|
||||
addresses_dirty_ = true;
|
||||
@@ -1979,6 +2028,7 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
|
||||
info.type = "transparent";
|
||||
info.balance = 0.0;
|
||||
state_.t_addresses.push_back(info);
|
||||
state_.addresses.push_back(info); // keep combined view in sync immediately
|
||||
address_list_dirty_ = true;
|
||||
// Also trigger full refresh to get proper balances
|
||||
addresses_dirty_ = true;
|
||||
@@ -2091,6 +2141,14 @@ void App::swapAddressOrder(const std::string& a, const std::string& b)
|
||||
}
|
||||
}
|
||||
|
||||
void App::reorderAddresses(const std::vector<std::string>& orderedAddrs)
|
||||
{
|
||||
if (!settings_) return;
|
||||
for (size_t i = 0; i < orderedAddrs.size(); ++i)
|
||||
settings_->setAddressSortOrder(orderedAddrs[i], static_cast<int>(i));
|
||||
settings_->save();
|
||||
}
|
||||
|
||||
bool App::isMiningAddress(const std::string& addr) const
|
||||
{
|
||||
return settings_ && settings_->isMiningAddress(addr);
|
||||
|
||||
@@ -57,6 +57,25 @@ const char* liteServerSelectionPreferenceModeName(
|
||||
return "sticky";
|
||||
}
|
||||
|
||||
Settings::PoolSelectMode parsePoolSelectMode(const json& value)
|
||||
{
|
||||
if (!value.is_string()) return Settings::PoolSelectMode::Manual;
|
||||
const std::string mode = value.get<std::string>();
|
||||
if (mode == "auto_balance" || mode == "auto") {
|
||||
return Settings::PoolSelectMode::AutoBalance;
|
||||
}
|
||||
return Settings::PoolSelectMode::Manual;
|
||||
}
|
||||
|
||||
const char* poolSelectModeName(Settings::PoolSelectMode mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case Settings::PoolSelectMode::Manual: return "manual";
|
||||
case Settings::PoolSelectMode::AutoBalance: return "auto_balance";
|
||||
}
|
||||
return "manual";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string Settings::getDefaultPath()
|
||||
@@ -257,6 +276,7 @@ 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("pool_select_mode")) pool_select_mode_ = parsePoolSelectMode(j["pool_select_mode"]);
|
||||
if (j.contains("mine_when_idle")) mine_when_idle_ = j["mine_when_idle"].get<bool>();
|
||||
if (j.contains("xmrig_version")) xmrig_version_ = j["xmrig_version"].get<std::string>();
|
||||
if (j.contains("mine_idle_delay")) mine_idle_delay_= std::max(30, j["mine_idle_delay"].get<int>());
|
||||
@@ -274,6 +294,19 @@ bool Settings::load(const std::string& path)
|
||||
for (const auto& w : j["saved_pool_workers"])
|
||||
if (w.is_string()) saved_pool_workers_.push_back(w.get<std::string>());
|
||||
}
|
||||
if (j.contains("portfolio_entries") && j["portfolio_entries"].is_array()) {
|
||||
portfolio_entries_.clear();
|
||||
for (const auto& e : j["portfolio_entries"]) {
|
||||
if (!e.is_object()) continue;
|
||||
PortfolioEntry entry;
|
||||
if (e.contains("label") && e["label"].is_string())
|
||||
entry.label = e["label"].get<std::string>();
|
||||
if (e.contains("addresses") && e["addresses"].is_array())
|
||||
for (const auto& a : e["addresses"])
|
||||
if (a.is_string()) entry.addresses.push_back(a.get<std::string>());
|
||||
if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
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())
|
||||
@@ -403,6 +436,7 @@ bool Settings::save(const std::string& path)
|
||||
j["pool_tls"] = pool_tls_;
|
||||
j["pool_hugepages"] = pool_hugepages_;
|
||||
j["pool_mode"] = pool_mode_;
|
||||
j["pool_select_mode"] = poolSelectModeName(pool_select_mode_);
|
||||
j["mine_when_idle"] = mine_when_idle_;
|
||||
j["xmrig_version"] = xmrig_version_;
|
||||
j["mine_idle_delay"]= mine_idle_delay_;
|
||||
@@ -416,6 +450,14 @@ bool Settings::save(const std::string& path)
|
||||
j["saved_pool_workers"] = json::array();
|
||||
for (const auto& w : saved_pool_workers_)
|
||||
j["saved_pool_workers"].push_back(w);
|
||||
j["portfolio_entries"] = json::array();
|
||||
for (const auto& e : portfolio_entries_) {
|
||||
json entry;
|
||||
entry["label"] = e.label;
|
||||
entry["addresses"] = json::array();
|
||||
for (const auto& a : e.addresses) entry["addresses"].push_back(a);
|
||||
j["portfolio_entries"].push_back(std::move(entry));
|
||||
}
|
||||
j["font_scale"] = font_scale_;
|
||||
j["settings_version"] = std::string(DRAGONX_VERSION);
|
||||
if (window_width_ > 0 && window_height_ > 0) {
|
||||
|
||||
@@ -60,12 +60,26 @@ public:
|
||||
Random
|
||||
};
|
||||
|
||||
// Pool selection mode for the mining tab: Manual (user picks the pool) or
|
||||
// AutoBalance (the wallet spreads miners across the official pools by hashrate).
|
||||
enum class PoolSelectMode {
|
||||
Manual,
|
||||
AutoBalance
|
||||
};
|
||||
|
||||
struct LiteServerPreference {
|
||||
std::string url;
|
||||
std::string label;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
// A user-defined portfolio entry: a custom label tied to a group of wallet addresses.
|
||||
// The Market tab's portfolio card sums these addresses' balances under the label.
|
||||
struct PortfolioEntry {
|
||||
std::string label;
|
||||
std::vector<std::string> addresses;
|
||||
};
|
||||
|
||||
// Theme
|
||||
std::string getTheme() const { return theme_; }
|
||||
void setTheme(const std::string& theme) { theme_ = theme; }
|
||||
@@ -244,6 +258,10 @@ public:
|
||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
||||
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
||||
|
||||
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
||||
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
||||
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
|
||||
|
||||
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
|
||||
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
|
||||
bool isLiteServerHidden(const std::string& url) const { return lite_hidden_servers_.count(url) > 0; }
|
||||
@@ -305,6 +323,8 @@ public:
|
||||
void setPoolHugepages(bool v) { pool_hugepages_ = v; }
|
||||
bool getPoolMode() const { return pool_mode_; }
|
||||
void setPoolMode(bool v) { pool_mode_ = v; }
|
||||
PoolSelectMode getPoolSelectMode() const { return pool_select_mode_; }
|
||||
void setPoolSelectMode(PoolSelectMode v) { pool_select_mode_ = v; }
|
||||
|
||||
// Installed DRG-XMRig release tag (for in-app miner update detection); empty if unknown/bundled.
|
||||
std::string getXmrigVersion() const { return xmrig_version_; }
|
||||
@@ -423,13 +443,15 @@ private:
|
||||
};
|
||||
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
|
||||
|
||||
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
|
||||
|
||||
bool verbose_logging_ = false;
|
||||
std::set<std::string> debug_categories_;
|
||||
bool theme_effects_enabled_ = true;
|
||||
bool low_spec_mode_ = false;
|
||||
bool reduce_motion_ = false;
|
||||
std::string selected_exchange_ = "TradeOgre";
|
||||
std::string selected_pair_ = "DRGX/BTC";
|
||||
std::string selected_exchange_ = "Nonkyc.io";
|
||||
std::string selected_pair_ = "DRGX/USDT";
|
||||
|
||||
// Pool mining
|
||||
std::string pool_url_ = "pool.dragonx.is:3433";
|
||||
@@ -439,6 +461,7 @@ private:
|
||||
bool pool_tls_ = false;
|
||||
bool pool_hugepages_ = true;
|
||||
bool pool_mode_ = false; // false=solo, true=pool
|
||||
PoolSelectMode pool_select_mode_ = PoolSelectMode::Manual; // manual vs auto-balance pool choice
|
||||
std::string xmrig_version_; // installed DRG-XMRig release tag (update detection)
|
||||
bool mine_when_idle_ = false; // auto-start mining when system idle
|
||||
int mine_idle_delay_= 120; // seconds of idle before mining starts
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "xmrig_manager.h"
|
||||
#include "../resources/embedded_resources.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -22,6 +23,7 @@
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/pool_registry.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
@@ -718,6 +720,11 @@ void XmrigManager::fetchStatsHttp() {
|
||||
|
||||
std::lock_guard<std::mutex> lk(stats_mutex_);
|
||||
|
||||
// Miner version (top-level in /2/summary) — lets the UI show the actually
|
||||
// running miner's version even when no release tag was persisted (bundled miner).
|
||||
if (resp.contains("version") && resp["version"].is_string())
|
||||
stats_.version = resp["version"].get<std::string>();
|
||||
|
||||
if (resp.contains("hashrate") && resp["hashrate"].contains("total")) {
|
||||
auto& total = resp["hashrate"]["total"];
|
||||
if (total.is_array() && total.size() >= 3) {
|
||||
@@ -768,10 +775,14 @@ void XmrigManager::fetchStatsHttp() {
|
||||
void XmrigManager::fetchPoolApiStats() {
|
||||
if (state_ != State::Running || pool_host_.empty()) return;
|
||||
|
||||
// Query the pool's public stats API
|
||||
std::string url = "https://" + pool_host_ + "/api/stats";
|
||||
std::string responseData;
|
||||
// Resolve the stats endpoint + JSON schema for this pool. Known pools carry their
|
||||
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
||||
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
||||
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
||||
const std::string url = known ? known->statsUrl
|
||||
: ("https://" + pool_host_ + "/api/stats");
|
||||
|
||||
std::string responseData;
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return;
|
||||
|
||||
@@ -782,31 +793,99 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents.
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; ObsidianDragon)");
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) return;
|
||||
|
||||
try {
|
||||
json resp = json::parse(responseData);
|
||||
|
||||
// Pool stats API format: { "pools": { "<name>": { "hashrate": ... } } }
|
||||
double poolHR = 0;
|
||||
if (resp.contains("pools") && resp["pools"].is_object()) {
|
||||
for (auto& [key, pool] : resp["pools"].items()) {
|
||||
if (pool.contains("hashrate") && pool["hashrate"].is_number()) {
|
||||
poolHR = pool["hashrate"].get<double>();
|
||||
break; // Use the first pool entry
|
||||
}
|
||||
}
|
||||
}
|
||||
bool ok = false;
|
||||
const double poolHR = util::parsePoolHashrate(
|
||||
known ? known->schema : util::PoolStatsSchema::DragonXIs,
|
||||
responseData, known ? known->miningcorePoolId : std::string{}, ok);
|
||||
if (!ok) return;
|
||||
|
||||
std::lock_guard<std::mutex> lk(stats_mutex_);
|
||||
stats_.pool_hashrate = poolHR;
|
||||
} catch (...) {
|
||||
// Malformed response — ignore
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Installed-miner version detection (`<binary> --version`, cached)
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
std::mutex g_installed_ver_mutex;
|
||||
std::string g_installed_ver;
|
||||
std::atomic<bool> g_ver_detect_started{false};
|
||||
|
||||
// Extract the first "D.D[.D...]" version token from `--version` output (skips the
|
||||
// build date, which uses '-' separators). Returns e.g. "6.21.0", or "" if none.
|
||||
std::string parseMinerVersion(const std::string& out)
|
||||
{
|
||||
for (size_t i = 0; i < out.size(); ++i) {
|
||||
if (std::isdigit(static_cast<unsigned char>(out[i]))) {
|
||||
size_t j = i;
|
||||
int dots = 0;
|
||||
while (j < out.size() &&
|
||||
(std::isdigit(static_cast<unsigned char>(out[j])) || out[j] == '.')) {
|
||||
if (out[j] == '.') ++dots;
|
||||
++j;
|
||||
}
|
||||
if (dots >= 1 && (j - i) >= 3) {
|
||||
// Include a trailing build suffix like "-hac" / "-drg1" (e.g. "6.25.1-hac"),
|
||||
// matching what the running miner's API reports.
|
||||
size_t end = j;
|
||||
if (end < out.size() && out[end] == '-') {
|
||||
size_t k = end + 1;
|
||||
while (k < out.size() && std::isalnum(static_cast<unsigned char>(out[k]))) ++k;
|
||||
if (k > end + 1) end = k;
|
||||
}
|
||||
return out.substr(i, end - i);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void XmrigManager::startVersionDetection()
|
||||
{
|
||||
if (g_ver_detect_started.exchange(true)) return; // one-shot
|
||||
std::thread([]() {
|
||||
const std::string bin = findXmrigBinary();
|
||||
std::string ver;
|
||||
if (!bin.empty()) {
|
||||
const std::string cmd = "\"" + bin + "\" --version 2>&1";
|
||||
#ifdef _WIN32
|
||||
FILE* fp = _popen(cmd.c_str(), "r");
|
||||
#else
|
||||
FILE* fp = popen(cmd.c_str(), "r");
|
||||
#endif
|
||||
if (fp) {
|
||||
std::string out;
|
||||
char buf[256];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
|
||||
#ifdef _WIN32
|
||||
_pclose(fp);
|
||||
#else
|
||||
pclose(fp);
|
||||
#endif
|
||||
ver = parseMinerVersion(out);
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
||||
g_installed_ver = ver;
|
||||
}).detach();
|
||||
}
|
||||
|
||||
std::string XmrigManager::installedVersion()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
||||
return g_installed_ver;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
|
||||
@@ -43,6 +43,7 @@ public:
|
||||
double pool_diff = 0;
|
||||
std::string pool_url;
|
||||
std::string algo;
|
||||
std::string version; // miner version reported by the running xmrig API
|
||||
bool connected = false;
|
||||
// Memory usage
|
||||
int64_t memory_free = 0; // bytes
|
||||
@@ -137,6 +138,18 @@ public:
|
||||
*/
|
||||
static std::string findXmrigBinary();
|
||||
|
||||
/**
|
||||
* @brief Kick a one-shot background `<binary> --version` detection (idempotent).
|
||||
* Lets the UI show the installed miner's version before mining is ever started.
|
||||
*/
|
||||
static void startVersionDetection();
|
||||
|
||||
/**
|
||||
* @brief Cached installed-miner version parsed by startVersionDetection().
|
||||
* Empty until detection completes (or if no binary/parse failed). Thread-safe.
|
||||
*/
|
||||
static std::string installedVersion();
|
||||
|
||||
private:
|
||||
bool generateConfig(const Config& cfg, const std::string& outPath);
|
||||
bool startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads);
|
||||
|
||||
@@ -4,11 +4,19 @@
|
||||
|
||||
#include "exchange_info.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
const std::vector<ExchangeInfo>& getExchangeRegistry()
|
||||
{
|
||||
// Offline seed / fallback. The live list from CoinGecko (parseCoinGeckoTickers)
|
||||
// supersedes this when available; keep the known venues here so the tab is never
|
||||
// empty without network.
|
||||
static const std::vector<ExchangeInfo> registry = {
|
||||
{
|
||||
"Nonkyc.io",
|
||||
@@ -17,9 +25,68 @@ const std::vector<ExchangeInfo>& getExchangeRegistry()
|
||||
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"OurBit",
|
||||
"https://www.ourbit.com",
|
||||
{
|
||||
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"},
|
||||
}
|
||||
},
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Extract the scheme://host origin from a URL (for ExchangeInfo.baseUrl).
|
||||
std::string originOf(const std::string& url)
|
||||
{
|
||||
const auto scheme = url.find("://");
|
||||
if (scheme == std::string::npos) return url;
|
||||
const auto host = url.find('/', scheme + 3);
|
||||
return (host == std::string::npos) ? url : url.substr(0, host);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
|
||||
{
|
||||
std::vector<ExchangeInfo> exchanges;
|
||||
try {
|
||||
const nlohmann::json j = nlohmann::json::parse(body);
|
||||
if (!j.contains("tickers") || !j["tickers"].is_array()) return {};
|
||||
|
||||
std::unordered_map<std::string, size_t> byName; // exchange name -> index
|
||||
for (const auto& t : j["tickers"]) {
|
||||
if (!t.is_object()) continue;
|
||||
const std::string base = t.value("base", std::string{});
|
||||
const std::string target = t.value("target", std::string{});
|
||||
std::string market;
|
||||
if (t.contains("market") && t["market"].is_object())
|
||||
market = t["market"].value("name", std::string{});
|
||||
const std::string tradeUrl = t.value("trade_url", std::string{});
|
||||
if (base.empty() || target.empty() || market.empty()) continue;
|
||||
|
||||
auto it = byName.find(market);
|
||||
if (it == byName.end()) {
|
||||
byName[market] = exchanges.size();
|
||||
exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}});
|
||||
it = byName.find(market);
|
||||
}
|
||||
ExchangePair pair{base, target, base + "/" + target, tradeUrl};
|
||||
// Skip duplicate pairs on the same exchange.
|
||||
bool dup = false;
|
||||
for (const auto& p : exchanges[it->second].pairs)
|
||||
if (p.displayName == pair.displayName) { dup = true; break; }
|
||||
if (!dup) exchanges[it->second].pairs.push_back(std::move(pair));
|
||||
}
|
||||
} catch (...) {
|
||||
return {};
|
||||
}
|
||||
// Drop exchanges that ended up with no pairs (defensive).
|
||||
exchanges.erase(std::remove_if(exchanges.begin(), exchanges.end(),
|
||||
[](const ExchangeInfo& e) { return e.pairs.empty(); }),
|
||||
exchanges.end());
|
||||
return exchanges;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -30,9 +30,19 @@ struct ExchangeInfo {
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Returns the static registry of supported exchanges + pairs
|
||||
* @brief Returns the static registry of supported exchanges + pairs.
|
||||
* Used as the offline fallback / seed when the live CoinGecko list is unavailable.
|
||||
*/
|
||||
const std::vector<ExchangeInfo>& getExchangeRegistry();
|
||||
|
||||
/**
|
||||
* @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry.
|
||||
*
|
||||
* Groups tickers by exchange (market.name), preserving first-seen order, into
|
||||
* ExchangeInfo entries (each pair carries base/target/displayName/trade_url). Returns
|
||||
* an empty vector on parse failure or when no usable tickers are present. Pure (no I/O).
|
||||
*/
|
||||
std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& json);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
|
||||
61
src/data/portfolio.h
Normal file
61
src/data/portfolio.h
Normal file
@@ -0,0 +1,61 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Portfolio helpers — pure balance math for the Market tab's configurable portfolio.
|
||||
// A portfolio entry is a user label tied to a group of wallet addresses (persisted in
|
||||
// Settings as PortfolioEntry); these helpers sum/query that group against the live
|
||||
// per-address balance list. No I/O, no ImGui — unit-testable.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h" // AddressInfo
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
// Sum the DRGX balance of `entryAddresses` by looking them up in the wallet's per-address
|
||||
// list. Addresses not present in the wallet contribute 0 (e.g. removed/rescanned). Pure.
|
||||
inline double SumPortfolioBalance(const std::vector<std::string>& entryAddresses,
|
||||
const std::vector<AddressInfo>& walletAddresses)
|
||||
{
|
||||
if (entryAddresses.empty()) return 0.0;
|
||||
std::unordered_set<std::string> want(entryAddresses.begin(), entryAddresses.end());
|
||||
double sum = 0.0;
|
||||
for (const auto& a : walletAddresses)
|
||||
if (want.count(a.address)) sum += a.balance;
|
||||
return sum;
|
||||
}
|
||||
|
||||
// Whether `address` is part of the entry's address group.
|
||||
inline bool PortfolioEntryContains(const std::vector<std::string>& entryAddresses,
|
||||
const std::string& address)
|
||||
{
|
||||
for (const auto& a : entryAddresses)
|
||||
if (a == address) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add `address` to the group if absent (returns true if it was added).
|
||||
inline bool PortfolioEntryAdd(std::vector<std::string>& entryAddresses, const std::string& address)
|
||||
{
|
||||
if (address.empty() || PortfolioEntryContains(entryAddresses, address)) return false;
|
||||
entryAddresses.push_back(address);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove `address` from the group if present (returns true if it was removed).
|
||||
inline bool PortfolioEntryRemove(std::vector<std::string>& entryAddresses, const std::string& address)
|
||||
{
|
||||
for (auto it = entryAddresses.begin(); it != entryAddresses.end(); ++it) {
|
||||
if (*it == address) { entryAddresses.erase(it); return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
|
||||
#include "exchange_info.h"
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
/**
|
||||
@@ -160,6 +162,10 @@ struct MarketInfo {
|
||||
// Price history for chart
|
||||
std::vector<double> price_history;
|
||||
static constexpr int MAX_HISTORY = 24; // 24 hours
|
||||
|
||||
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
|
||||
// falls back to data::getExchangeRegistry() while empty).
|
||||
std::vector<data::ExchangeInfo> exchanges;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -170,6 +176,7 @@ struct PoolMiningState {
|
||||
bool xmrig_running = false;
|
||||
std::string pool_url;
|
||||
std::string algo;
|
||||
std::string version; // running miner's version (from its API)
|
||||
|
||||
double hashrate_10s = 0;
|
||||
double hashrate_60s = 0;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf");
|
||||
INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf");
|
||||
INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf");
|
||||
INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
|
||||
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
|
||||
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
|
||||
|
||||
@@ -24,6 +24,9 @@ extern "C" {
|
||||
extern const unsigned char g_ubuntu_medium_data[];
|
||||
extern const unsigned int g_ubuntu_medium_size;
|
||||
|
||||
extern const unsigned char g_ubuntu_mono_data[];
|
||||
extern const unsigned int g_ubuntu_mono_size;
|
||||
|
||||
extern const unsigned char g_material_icons_data[];
|
||||
extern const unsigned int g_material_icons_size;
|
||||
|
||||
|
||||
@@ -1255,6 +1255,11 @@ void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
|
||||
{
|
||||
state.z_addresses = std::move(result.shieldedAddresses);
|
||||
state.t_addresses = std::move(result.transparentAddresses);
|
||||
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
|
||||
// Consumers that read state.addresses (Market-tab portfolio, auto-shield target
|
||||
// selection, pool-mining transparent fallback) depend on this being rebuilt — the
|
||||
// full-node refresh path is the only place that bulk-updates the address lists.
|
||||
state.rebuildAddressList();
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
|
||||
|
||||
@@ -184,6 +184,10 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
// ButtonLg: Medium
|
||||
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
|
||||
|
||||
// Monospace (Ubuntu Mono) at caption size — used by the console so terminal text +
|
||||
// pretty-printed JSON columns align. Same size as Caption to keep line heights matched.
|
||||
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono");
|
||||
|
||||
// --- Icon fonts at multiple sizes ---
|
||||
// These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering.
|
||||
iconFonts_[0] = loadIconFont(io, 14.0f * scale, "IconSmall");
|
||||
@@ -236,6 +240,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
fontData = g_ubuntu_medium_data;
|
||||
fontDataLen = g_ubuntu_medium_size;
|
||||
break;
|
||||
case kWeightMono:
|
||||
fontData = g_ubuntu_mono_data;
|
||||
fontDataLen = g_ubuntu_mono_size;
|
||||
break;
|
||||
case kWeightRegular:
|
||||
default:
|
||||
fontData = g_ubuntu_regular_data;
|
||||
@@ -271,10 +279,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
cfg.GlyphRanges = glyphRanges;
|
||||
|
||||
// Create a unique name for this font variant
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx",
|
||||
weight == kWeightLight ? "Light" :
|
||||
weight == kWeightMedium ? "Medium" : "Regular",
|
||||
size);
|
||||
const char* weightName = weight == kWeightLight ? "Light" :
|
||||
weight == kWeightMedium ? "Medium" :
|
||||
weight == kWeightMono ? "Mono" : "Regular";
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg);
|
||||
|
||||
|
||||
@@ -168,6 +168,10 @@ public:
|
||||
ImFont* caption() const { return getFont(TypeStyle::Caption); }
|
||||
ImFont* overline() const { return getFont(TypeStyle::Overline); }
|
||||
|
||||
// Monospace (Ubuntu Mono) at caption size — for the console, so JSON/columns align
|
||||
// like a real terminal. Falls back to the proportional caption font if not loaded.
|
||||
ImFont* mono() const { return mono_ ? mono_ : caption(); }
|
||||
|
||||
// Icon fonts — Material Design Icons merged at specific pixel sizes
|
||||
// Use these when you need to render an icon at a specific size via AddText/ImGui::Text.
|
||||
// Common sizes: iconSmall (14px), iconMed (18px), iconLarge (24px).
|
||||
@@ -253,6 +257,7 @@ private:
|
||||
static constexpr int kWeightLight = 300;
|
||||
static constexpr int kWeightRegular = 400;
|
||||
static constexpr int kWeightMedium = 500;
|
||||
static constexpr int kWeightMono = 401; // selects Ubuntu Mono (monospace, regular)
|
||||
|
||||
bool loaded_ = false;
|
||||
float dpiScale_ = 1.0f;
|
||||
@@ -260,6 +265,9 @@ private:
|
||||
// Fonts for each type style
|
||||
ImFont* fonts_[15] = {};
|
||||
|
||||
// Monospace font (Ubuntu Mono) at caption size — console/terminal text.
|
||||
ImFont* mono_ = nullptr;
|
||||
|
||||
// Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40)
|
||||
ImFont* iconFonts_[4] = {};
|
||||
ImFont* pickaxeFonts_[4] = {};
|
||||
|
||||
@@ -146,6 +146,7 @@ struct SettingsPageState {
|
||||
bool debug_expanded = false;
|
||||
bool effects_expanded = false;
|
||||
bool tools_expanded = false;
|
||||
bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields
|
||||
bool confirm_clear_ztx = false;
|
||||
bool confirm_delete_blockchain = false;
|
||||
bool confirm_rescan = false;
|
||||
@@ -1365,45 +1366,14 @@ void RenderSettingsPage(App* app) {
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx"));
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, bottomPad));
|
||||
ImGui::Unindent(pad);
|
||||
|
||||
ImVec2 cardMax(cardMin.x + availWidth, ImGui::GetCursorScreenPos().y);
|
||||
dl->ChannelsSetCurrent(0);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
dl->ChannelsMerge();
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
|
||||
// ====================================================================
|
||||
// BACKUP & DATA — card
|
||||
// ====================================================================
|
||||
{
|
||||
// --- Backup & Data (merged in from its former standalone card) ---
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("backup_data"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
dl->ChannelsSplit(2);
|
||||
dl->ChannelsSetCurrent(1);
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad));
|
||||
ImGui::Indent(pad);
|
||||
|
||||
float contentW = availWidth - pad * 2;
|
||||
float btnPad = S.drawElement("components.settings-page", "wallet-btn-padding").sizeOr(24.0f);
|
||||
|
||||
{
|
||||
float btnPad = S.drawElement("components.settings-page", "wallet-btn-padding").sizeOr(24.0f);
|
||||
const char* r1[] = {TR("settings_import_key"), TR("settings_export_key"), TR("settings_export_all"), TR("settings_backup"), TR("settings_export_csv")};
|
||||
const char* t1[] = {
|
||||
TR("tt_import_key"),
|
||||
TR("tt_export_key"),
|
||||
TR("tt_export_all"),
|
||||
TR("tt_backup"),
|
||||
TR("tt_export_csv")
|
||||
};
|
||||
const char* t1[] = { TR("tt_import_key"), TR("tt_export_key"), TR("tt_export_all"), TR("tt_backup"), TR("tt_export_csv") };
|
||||
const bool showFullNodeLifecycleActions = app->supportsFullNodeLifecycleActions();
|
||||
const char* wizLabel = TR("setup_wizard");
|
||||
const char* bsLabel = TR("download_bootstrap");
|
||||
@@ -1421,7 +1391,6 @@ void RenderSettingsPage(App* app) {
|
||||
|
||||
float scale = (totalW > contentW) ? contentW / totalW : 1.0f;
|
||||
if (scale < 1.0f) ImGui::SetWindowFontScale(scale);
|
||||
|
||||
float scaledSp = sp * scale;
|
||||
|
||||
if (TactileButton(r1[0], ImVec2(0, 0), btnFont))
|
||||
@@ -1445,7 +1414,7 @@ void RenderSettingsPage(App* app) {
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[4]);
|
||||
|
||||
if (showFullNodeLifecycleActions) {
|
||||
// Right-align Setup Wizard + Download Bootstrap
|
||||
// Right-align Setup Wizard + Download Bootstrap.
|
||||
float framePadX2 = ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
float curX = ImGui::GetCursorScreenPos().x;
|
||||
float wizBtnW = ImGui::CalcTextSize(wizLabel).x + framePadX2;
|
||||
@@ -1485,6 +1454,7 @@ void RenderSettingsPage(App* app) {
|
||||
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// NODE & SECURITY — card
|
||||
// ====================================================================
|
||||
@@ -1507,29 +1477,146 @@ void RenderSettingsPage(App* app) {
|
||||
return std::max(minBtnW, maxTextW + btnPad * 2);
|
||||
};
|
||||
|
||||
// --- NODE (left) + SECURITY (right) side by side ---
|
||||
// --- NODE & SECURITY: one full-width card, controls laid out horizontally ---
|
||||
// A single vertical flow of sub-sections (Node, RPC, Security, Daemon binary),
|
||||
// each with its overline header and its controls flowing left-to-right to fill
|
||||
// the card width. No 2x2 grid, no column clips, no masonry placement.
|
||||
{
|
||||
float colGap = Layout::spacingLg();
|
||||
float leftColW = (contentW - colGap) * 0.6f;
|
||||
float rightColW = (contentW - colGap) * 0.4f;
|
||||
ImVec2 sectionOrigin = ImGui::GetCursorScreenPos();
|
||||
float leftX = sectionOrigin.x;
|
||||
float rightX = sectionOrigin.x + leftColW + colGap;
|
||||
// The lite WALLET block below still positions its rows against a "left"
|
||||
// column origin/width; in the single-column layout that is simply the full
|
||||
// content column at the section origin.
|
||||
const float leftX = sectionOrigin.x;
|
||||
const float leftColW = contentW;
|
||||
|
||||
// ---- LEFT COLUMN: NODE ----
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, sectionOrigin.y));
|
||||
ImGui::PushClipRect(ImVec2(leftX, sectionOrigin.y),
|
||||
ImVec2(leftX + leftColW, sectionOrigin.y + 9999), true);
|
||||
ImFont* body2Info = S.resolveFont("body2");
|
||||
if (!body2Info) body2Info = Type().body2();
|
||||
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node"));
|
||||
// Shared SECURITY controls, rendered full-width with horizontal rows. Used by
|
||||
// both the lite branch (auto-lock + PIN only) and the full-node branch (which
|
||||
// also gets the RPC-backed encrypt/change/lock/remove block, includeRpcEncrypt).
|
||||
// `secX` is the row start X (== content-column left); `secColW` its width.
|
||||
auto renderSecuritySection = [&](float secX, float secColW, bool includeRpcEncrypt) {
|
||||
// Encrypt / Change passphrase / Lock / Remove — RPC-backed, full-node only.
|
||||
// In lite the wallet's encryption controls live in the WALLET block above.
|
||||
if (includeRpcEncrypt) {
|
||||
bool isEncrypted = app->state().isEncrypted();
|
||||
bool isLocked = app->state().isLocked();
|
||||
|
||||
float secBtnW = std::min(rowBtnW({TR("settings_encrypt_wallet"), TR("settings_change_passphrase"), TR("settings_lock_now")}), (secColW - Layout::spacingMd()) * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y));
|
||||
if (!isEncrypted) {
|
||||
if (TactileButton(TR("settings_encrypt_wallet"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showEncryptDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_encrypt"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_not_encrypted"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_change_passphrase"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showChangePassphraseDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pass"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (isLocked) {
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->lockWallet();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
|
||||
}
|
||||
// Remove Encryption button — trails the row.
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showDecryptDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-Lock timeout + PIN controls — continue on the same row as the encrypt
|
||||
// controls (full-node) so the whole SECURITY band fills the width; on lite
|
||||
// (no encrypt row) they start a fresh row at the section's left edge.
|
||||
{
|
||||
float comboW = S.drawElement("components.settings-page", "security-combo-width").sizeOr(120.0f);
|
||||
|
||||
int timeout = app->settings()->getAutoLockTimeout();
|
||||
const char* timeoutLabels[] = { TR("timeout_off"), TR("timeout_1min"), TR("timeout_5min"), TR("timeout_15min"), TR("timeout_30min"), TR("timeout_1hour") };
|
||||
int timeoutValues[] = { 0, 60, 300, 900, 1800, 3600 };
|
||||
int selTimeout = 0;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (timeoutValues[i] == timeout) { selTimeout = i; break; }
|
||||
}
|
||||
if (includeRpcEncrypt)
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
else
|
||||
ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("settings_auto_lock"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::PushItemWidth(comboW);
|
||||
if (ImGui::Combo("##autolock", &selTimeout, timeoutLabels, 6)) {
|
||||
app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]);
|
||||
app->settings()->save();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock"));
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||
bool isEncryptedPIN = app->state().isEncrypted();
|
||||
if (isEncryptedPIN) {
|
||||
bool hasPIN = app->hasPinVault();
|
||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (!hasPIN) {
|
||||
if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinSetupDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinChangeDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinRemoveDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
|
||||
}
|
||||
} else {
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.3f), "%s", TR("settings_encrypt_first_pin"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (app->isLiteBuild()) {
|
||||
// ============================ LITE ============================
|
||||
// Lite has no daemon: this card holds wallet lifecycle/encryption
|
||||
// (the "WALLET" block) followed by the shared SECURITY block, both
|
||||
// rendered full-width in a single column exactly as before.
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
{
|
||||
ImFont* body2Info = S.resolveFont("body2");
|
||||
if (!body2Info) body2Info = Type().body2();
|
||||
ImGui::PushFont(body2Info);
|
||||
|
||||
if (app->isLiteBuild()) {
|
||||
{
|
||||
float liteLabelW = std::min(leftColW * 0.35f, 132.0f);
|
||||
float liteInputW = std::max(80.0f, leftColW - liteLabelW - Layout::spacingSm());
|
||||
|
||||
@@ -1871,44 +1958,74 @@ void RenderSettingsPage(App* app) {
|
||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
|
||||
}
|
||||
} // lite WALLET block
|
||||
ImGui::PopFont();
|
||||
} // lite WALLET push-font scope
|
||||
|
||||
// ---- SECURITY (lite): auto-lock + PIN only ----
|
||||
// The wallet's passphrase encryption controls live in the WALLET block
|
||||
// above (lite backend), so this section shows only the shared auto-lock
|
||||
// timeout + PIN controls.
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/false);
|
||||
|
||||
// Advance to the true bottom of the single column.
|
||||
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y));
|
||||
|
||||
} else {
|
||||
// ========================= FULL NODE =========================
|
||||
// One full-width column; each sub-section's controls flow left-to-right.
|
||||
ImGui::PushFont(body2Info);
|
||||
|
||||
float rpcLblW = std::max(
|
||||
S.drawElement("components.settings-page", "rpc-label-min-width").size,
|
||||
std::min(leftColW * 0.35f, S.drawElement("components.settings-page", "rpc-label-width").size * hs));
|
||||
const float spMd = Layout::spacingMd();
|
||||
|
||||
std::string wallet_path = util::Platform::getDragonXDataDir() + "wallet.dat";
|
||||
uint64_t wallet_size = util::Platform::getFileSize(wallet_path);
|
||||
|
||||
// Data Dir + Wallet Size — same line with "Label: value" format
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
// -------------------- NODE / DATA --------------------
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
{
|
||||
// Measure the wallet size string first
|
||||
std::string size_str = (wallet_size > 0) ? util::Platform::formatFileSize(wallet_size) : TR("settings_not_found");
|
||||
std::string dirPath = util::Platform::getDragonXDataDir();
|
||||
std::string walletPath = dirPath + "wallet.dat";
|
||||
uint64_t wallet_size = util::Platform::getFileSize(walletPath);
|
||||
std::string size_str = (wallet_size > 0)
|
||||
? util::Platform::formatFileSize(wallet_size) : TR("settings_not_found");
|
||||
|
||||
// Calculate space taken by "Wallet Size: <size>" on the right
|
||||
float walletSizeLabelW = ImGui::CalcTextSize(TR("settings_wallet_size_label")).x + Layout::spacingXs();
|
||||
float walletSizeValW = ImGui::CalcTextSize(size_str.c_str()).x;
|
||||
float walletSizeTotalW = walletSizeLabelW + walletSizeValW + Layout::spacingLg();
|
||||
// Right-aligned "Open data folder" button trailing the row; the path
|
||||
// gets whatever width is left after the data-dir label, wallet-size
|
||||
// label+value, and the trailing button.
|
||||
ImFont* nodeBtnFont = S.resolveFont("button");
|
||||
if (!nodeBtnFont) nodeBtnFont = Type().button();
|
||||
// Measure with the BUTTON font (not the current font) so the width matches
|
||||
// the real TactileButton size — the old estimate mis-measured and the button
|
||||
// overflowed the card's right edge, worse at high DPI.
|
||||
float openBtnW = nodeBtnFont->CalcTextSizeA(nodeBtnFont->LegacySize, FLT_MAX, 0,
|
||||
TR("settings_open_data_dir")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
// "Open app folder" (the ObsidianDragon config folder), left of the data button.
|
||||
float appBtnW = nodeBtnFont->CalcTextSizeA(nodeBtnFont->LegacySize, FLT_MAX, 0,
|
||||
TR("settings_open_app_dir")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
float openGroupW = appBtnW + spMd + openBtnW;
|
||||
float dataDirLabelW = ImGui::CalcTextSize(TR("settings_data_dir")).x;
|
||||
float sizeLabelW = ImGui::CalcTextSize(TR("settings_wallet_size_label")).x;
|
||||
float sizeValueW = ImGui::CalcTextSize(size_str.c_str()).x;
|
||||
// Space reserved to the right of the path for the size block + both buttons.
|
||||
float trailW = sizeLabelW + Layout::spacingXs() + sizeValueW + spMd + openGroupW;
|
||||
float availForPath = contentW - dataDirLabelW - Layout::spacingXs() - spMd - trailW;
|
||||
if (availForPath < 60.0f) availForPath = contentW - dataDirLabelW - Layout::spacingXs();
|
||||
|
||||
// Available space for "Data Dir: <path>"
|
||||
float dataDirLabelW = ImGui::CalcTextSize(TR("settings_data_dir")).x + Layout::spacingXs();
|
||||
float availForPath = leftColW - walletSizeTotalW - dataDirLabelW;
|
||||
|
||||
ImGui::TextUnformatted(TR("settings_data_dir"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImVec2 pathSize = ImGui::CalcTextSize(dirPath.c_str());
|
||||
ImVec4 linkCol = ImGui::ColorConvertU32ToFloat4(Primary());
|
||||
ImVec4 linkHoverCol = linkCol;
|
||||
linkHoverCol.w = std::min(1.0f, linkCol.w + 0.2f);
|
||||
|
||||
float rowTopY = ImGui::GetCursorScreenPos().y; // top of the data-dir row
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("settings_data_dir"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImVec2 pathSize = ImGui::CalcTextSize(dirPath.c_str());
|
||||
bool hovered = false;
|
||||
ImGui::AlignTextToFramePadding();
|
||||
if (pathSize.x > availForPath && availForPath > 0) {
|
||||
float scale = availForPath / pathSize.x;
|
||||
float condensedSize = body2Info->LegacySize * std::max(scale, 0.65f);
|
||||
ImGui::SetWindowFontScale(condensedSize / body2Info->LegacySize);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextColored(linkCol, "%s", dirPath.c_str());
|
||||
hovered = ImGui::IsItemHovered();
|
||||
if (hovered) {
|
||||
@@ -1933,213 +2050,133 @@ void RenderSettingsPage(App* app) {
|
||||
if (ImGui::IsItemClicked())
|
||||
util::Platform::openFolder(dirPath);
|
||||
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
// Wallet Size label + value, trailing the path on the same row.
|
||||
ImGui::SameLine(0, spMd);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("settings_wallet_size_label"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
if (wallet_size > 0) {
|
||||
ImGui::AlignTextToFramePadding();
|
||||
if (wallet_size > 0)
|
||||
ImGui::TextUnformatted(size_str.c_str());
|
||||
} else {
|
||||
else
|
||||
ImGui::TextDisabled("%s", TR("settings_not_found"));
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit button to open the wallet + blockchain data folder in the OS file manager.
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
||||
// Folder buttons: [Open app folder] [Open data folder], right-aligned as a
|
||||
// group on the data-dir row (rowTopY), each an EXPLICIT width so the group's
|
||||
// right edge lands exactly on the card edge (no overflow at any DPI).
|
||||
float rowY = rowTopY;
|
||||
float rightEdge = sectionOrigin.x + contentW;
|
||||
float groupX = rightEdge - openGroupW;
|
||||
float afterSizeX = ImGui::GetItemRectMax().x + spMd;
|
||||
ImGui::SameLine(0, spMd);
|
||||
if (groupX > afterSizeX)
|
||||
ImGui::SetCursorScreenPos(ImVec2(groupX, rowY));
|
||||
// App (ObsidianDragon config) folder — opens ~/.config/ObsidianDragon etc.
|
||||
if (TactileButton(TR("settings_open_app_dir"), ImVec2(appBtnW, 0), nodeBtnFont)) {
|
||||
util::Platform::openFolder(util::Platform::getObsidianDragonDir());
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_app_dir"));
|
||||
ImGui::SameLine(0, spMd);
|
||||
if (TactileButton(TR("settings_open_data_dir"), ImVec2(openBtnW, 0), nodeBtnFont)) {
|
||||
util::Platform::openFolder(dirPath);
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_data_dir"));
|
||||
}
|
||||
|
||||
// -------------------- RPC (collapsible, like Tools & Actions) --------------------
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
{
|
||||
ImFont* rpcHdrFont = body2; // match the "Tools & Actions..." toggle style
|
||||
const char* rpcArrow = s_settingsState.rpc_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
|
||||
ImGui::PushFont(body2);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
|
||||
ImVec2 hdrPos = ImGui::GetCursorScreenPos();
|
||||
if (ImGui::Button("##RpcToggle", ImVec2(contentW, ImGui::GetFrameHeight())))
|
||||
s_settingsState.rpc_expanded = !s_settingsState.rpc_expanded;
|
||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
float textY = hdrPos.y + (ImGui::GetFrameHeight() - rpcHdrFont->LegacySize) * 0.5f;
|
||||
dl->AddText(rpcHdrFont, rpcHdrFont->LegacySize, ImVec2(hdrPos.x, textY), OnSurfaceMedium(), TR("rpc_connection"));
|
||||
ImFont* rpcIconFont = Type().iconSmall();
|
||||
if (!rpcIconFont) rpcIconFont = body2;
|
||||
float rpcArrowW = rpcIconFont->CalcTextSizeA(rpcIconFont->LegacySize, FLT_MAX, 0, rpcArrow).x;
|
||||
dl->AddText(rpcIconFont, rpcIconFont->LegacySize, ImVec2(hdrPos.x + contentW - rpcArrowW, textY), OnSurfaceMedium(), rpcArrow);
|
||||
ImGui::PopStyleColor(3);
|
||||
ImGui::PopFont();
|
||||
}
|
||||
if (s_settingsState.rpc_expanded) {
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
// Four fields (Host, Port, Username, Password) across one row when the
|
||||
// card is wide enough; otherwise 2x2. Each field is "label [input]".
|
||||
const char* hostLbl = TR("rpc_host");
|
||||
const char* portLbl = TR("rpc_port");
|
||||
const char* userLbl = TR("rpc_user");
|
||||
const char* passLbl = TR("rpc_pass");
|
||||
float labelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(portLbl).x +
|
||||
ImGui::CalcTextSize(userLbl).x + ImGui::CalcTextSize(passLbl).x;
|
||||
|
||||
// RPC connection — two columns: (Host | Username) and (Port | Password)
|
||||
float rpcHalfW = (leftColW - Layout::spacingMd()) * 0.5f;
|
||||
float rpcHalfLblW = std::min(rpcLblW, rpcHalfW * 0.4f);
|
||||
float rpcHalfInputW = std::max(40.0f, rpcHalfW - rpcHalfLblW);
|
||||
float rpcRightColX = leftX + rpcHalfW + Layout::spacingMd();
|
||||
|
||||
// Row 1: RPC Host + Username
|
||||
float row1Y = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, row1Y));
|
||||
const bool fourAcross = contentW >= 700.0f;
|
||||
auto field = [&](const char* label, const char* id, char* buf, size_t bufSz,
|
||||
float inputW, bool password) {
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("rpc_host"));
|
||||
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
|
||||
ImGui::SetNextItemWidth(rpcHalfInputW);
|
||||
ImGui::InputText("##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host));
|
||||
ImGui::TextUnformatted(label);
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::SetNextItemWidth(inputW);
|
||||
ImGui::InputText(id, buf, bufSz,
|
||||
password ? ImGuiInputTextFlags_Password : 0);
|
||||
};
|
||||
|
||||
if (fourAcross) {
|
||||
// fieldW = (contentW - labels - per-field label gaps - 3 inter-field gaps) / 4
|
||||
float inputTotal = contentW - labelsW - Layout::spacingXs() * 4 - spMd * 3;
|
||||
float inputW = std::max(60.0f, std::floor(inputTotal / 4.0f));
|
||||
field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
|
||||
|
||||
float afterRow1Y = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row1Y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("rpc_user"));
|
||||
ImGui::SameLine(rpcRightColX - sectionOrigin.x + rpcHalfLblW);
|
||||
ImGui::SetNextItemWidth(rpcHalfInputW);
|
||||
ImGui::InputText("##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user));
|
||||
ImGui::SameLine(0, spMd);
|
||||
field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
|
||||
ImGui::SameLine(0, spMd);
|
||||
field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
|
||||
ImGui::SameLine(0, spMd);
|
||||
field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
|
||||
} else {
|
||||
// 2x2: two fields per row.
|
||||
float halfLabelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(userLbl).x;
|
||||
float inputW = std::max(60.0f, std::floor(
|
||||
(contentW - halfLabelsW - Layout::spacingXs() * 2 - spMd) / 2.0f));
|
||||
field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host"));
|
||||
ImGui::SameLine(0, spMd);
|
||||
field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user"));
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow1Y, ImGui::GetCursorScreenPos().y)));
|
||||
|
||||
// Row 2: Port + Password
|
||||
float row2Y = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, row2Y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("rpc_port"));
|
||||
ImGui::SameLine(leftX - sectionOrigin.x + rpcHalfLblW);
|
||||
ImGui::SetNextItemWidth(rpcHalfInputW);
|
||||
ImGui::InputText("##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port));
|
||||
field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port"));
|
||||
|
||||
float afterRow2Y = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::SetCursorScreenPos(ImVec2(rpcRightColX, row2Y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("rpc_pass"));
|
||||
ImGui::SameLine(rpcRightColX - sectionOrigin.x + rpcHalfLblW);
|
||||
ImGui::SetNextItemWidth(rpcHalfInputW);
|
||||
ImGui::InputText("##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password),
|
||||
ImGuiInputTextFlags_Password);
|
||||
ImGui::SameLine(0, spMd);
|
||||
field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass"));
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, std::max(afterRow2Y, ImGui::GetCursorScreenPos().y)));
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||
TR("settings_auto_detected"));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("settings_auto_detected"));
|
||||
if (s_settingsState.rpc_plaintext_remote) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
ImGui::PushTextWrapPos(leftX + leftColW);
|
||||
ImGui::PushTextWrapPos(sectionOrigin.x + contentW);
|
||||
ImGui::TextWrapped("Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.");
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
float leftBottom = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::PopClipRect();
|
||||
|
||||
// ---- RIGHT COLUMN: SECURITY ----
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, sectionOrigin.y));
|
||||
ImGui::PushClipRect(ImVec2(rightX, sectionOrigin.y),
|
||||
ImVec2(rightX + rightColW, sectionOrigin.y + 9999), true);
|
||||
|
||||
// -------------------- SECURITY --------------------
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("security"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/true);
|
||||
|
||||
{
|
||||
// Encrypt / Change passphrase / Lock buttons
|
||||
bool isEncrypted = app->state().isEncrypted();
|
||||
bool isLocked = app->state().isLocked();
|
||||
|
||||
float secBtnW = std::min(rowBtnW({TR("settings_encrypt_wallet"), TR("settings_change_passphrase"), TR("settings_lock_now")}), (rightColW - Layout::spacingMd()) * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y));
|
||||
if (!isEncrypted) {
|
||||
if (TactileButton(TR("settings_encrypt_wallet"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showEncryptDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_encrypt"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_not_encrypted"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_change_passphrase"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showChangePassphraseDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pass"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (isLocked) {
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->lockWallet();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
|
||||
}
|
||||
// Remove Encryption button
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y + Layout::spacingXs()));
|
||||
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->showDecryptDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Auto-Lock timeout
|
||||
{
|
||||
float comboW = S.drawElement("components.settings-page", "security-combo-width").sizeOr(120.0f);
|
||||
|
||||
int timeout = app->settings()->getAutoLockTimeout();
|
||||
const char* timeoutLabels[] = { TR("timeout_off"), TR("timeout_1min"), TR("timeout_5min"), TR("timeout_15min"), TR("timeout_30min"), TR("timeout_1hour") };
|
||||
int timeoutValues[] = { 0, 60, 300, 900, 1800, 3600 };
|
||||
int selTimeout = 0;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (timeoutValues[i] == timeout) { selTimeout = i; break; }
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("settings_auto_lock"));
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::PushItemWidth(comboW);
|
||||
if (ImGui::Combo("##autolock", &selTimeout, timeoutLabels, 6)) {
|
||||
app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]);
|
||||
app->settings()->save();
|
||||
}
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock"));
|
||||
ImGui::PopItemWidth();
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// PIN unlock section
|
||||
bool isEncryptedPIN = app->state().isEncrypted();
|
||||
if (isEncryptedPIN) {
|
||||
bool hasPIN = app->hasPinVault();
|
||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (rightColW - Layout::spacingSm()) * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y));
|
||||
if (!hasPIN) {
|
||||
if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinSetupDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin"));
|
||||
} else {
|
||||
if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinChangeDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button")))
|
||||
app->showPinRemoveDialog();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
|
||||
}
|
||||
} else {
|
||||
ImGui::SetCursorScreenPos(ImVec2(rightX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.3f), "%s", TR("settings_encrypt_first_pin"));
|
||||
}
|
||||
}
|
||||
|
||||
float rightBottom = ImGui::GetCursorScreenPos().y;
|
||||
ImGui::PopClipRect();
|
||||
|
||||
// Advance cursor past both columns
|
||||
float maxBottom = std::max(leftBottom, rightBottom);
|
||||
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, maxBottom));
|
||||
|
||||
// ---- DAEMON BINARY — full-width row beneath the NODE + SECURITY columns ----
|
||||
// -------------------- DAEMON BINARY --------------------
|
||||
if (app->supportsFullNodeLifecycleActions()) {
|
||||
ImFont* dbBtnFont = S.resolveFont("button");
|
||||
if (!s_settingsState.daemon_info_loaded) {
|
||||
@@ -2164,45 +2201,43 @@ void RenderSettingsPage(App* app) {
|
||||
return std::string(buf);
|
||||
};
|
||||
|
||||
const float dbLeftX = sectionOrigin.x;
|
||||
const ImVec4 dbDim = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Installed | Bundled side by side across the full container width.
|
||||
const float dbStartY = ImGui::GetCursorScreenPos().y;
|
||||
const float dbLineH = ImGui::GetTextLineHeightWithSpacing();
|
||||
const float dbCol2X = dbLeftX + std::min(contentW * 0.4f, 340.0f);
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_installed"));
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH));
|
||||
// Inline single line: Installed <v> (<size> · <date>) · Bundled <v> (<size>) · <status>
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("daemon_installed"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
if (inst.exists) {
|
||||
ImGui::TextUnformatted(inst.version.empty() ? TR("unknown") : inst.version.c_str());
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH * 2));
|
||||
ImGui::TextColored(dbDim, "%s · %s",
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(dbDim, "(%s · %s)",
|
||||
util::Platform::formatFileSize(inst.size).c_str(),
|
||||
fmtDate(inst.modifiedEpoch).c_str());
|
||||
} else {
|
||||
ImGui::TextColored(dbDim, "%s", TR("daemon_not_installed"));
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_bundled"));
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY + dbLineH));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(dbDim, "·");
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("daemon_bundled"));
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
if (bun.available) {
|
||||
ImGui::TextUnformatted(bun.version.empty() ? TR("unknown") : bun.version.c_str());
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbCol2X, dbStartY + dbLineH * 2));
|
||||
ImGui::TextColored(dbDim, "%s", util::Platform::formatFileSize(bun.size).c_str());
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(dbDim, "(%s)", util::Platform::formatFileSize(bun.size).c_str());
|
||||
} else {
|
||||
ImGui::TextColored(dbDim, "%s", TR("daemon_none_bundled"));
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, dbStartY + dbLineH * 3 + Layout::spacingXs()));
|
||||
if (bun.available) {
|
||||
const bool sameSize = inst.exists && inst.size == bun.size;
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
ImGui::TextColored(dbDim, "·");
|
||||
ImGui::SameLine(0, Layout::spacingXs());
|
||||
if (!inst.exists)
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("daemon_status_missing"));
|
||||
else if (sameSize)
|
||||
@@ -2215,29 +2250,34 @@ void RenderSettingsPage(App* app) {
|
||||
// Refresh the cached daemon info once an install has completed.
|
||||
if (ui::DaemonUpdateDialog::consumeInstalled())
|
||||
s_settingsState.daemon_info_loaded = false;
|
||||
|
||||
// Action row: Check for updates | Refresh | Install bundled on the left,
|
||||
// with the four maintenance actions right-aligned on the same row.
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, ImGui::GetCursorScreenPos().y));
|
||||
float daemonBtnRowY = ImGui::GetCursorScreenPos().y;
|
||||
if (TactileButton(TR("daemon_update_check"), ImVec2(0, 0), dbBtnFont)) {
|
||||
ui::DaemonUpdateDialog::show(app, inst.exists ? inst.version : std::string());
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_update_check"));
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
// All node actions on one full-width toolbar row: daemon-binary actions first
|
||||
// (Install bundled | Refresh), then maintenance (Test connection | Rescan |
|
||||
// Delete blockchain | Repair wallet).
|
||||
ImGui::SetCursorScreenPos(ImVec2(dbLeftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::SameLine(0, spMd);
|
||||
// Unique ImGui ID (label still displays "Refresh") — the theme sections
|
||||
// also have "Refresh" buttons, so a bare label collides on this page.
|
||||
if (TactileButton((std::string(TR("refresh")) + "##daemonRefresh").c_str(), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.daemon_info_loaded = false;
|
||||
}
|
||||
ImGui::SameLine(0, spMd);
|
||||
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon() || !bun.available);
|
||||
if (TactileButton(TR("daemon_install_bundled"), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.confirm_reinstall_daemon = true;
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_daemon_install_bundled"));
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (TactileButton(TR("refresh"), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.daemon_info_loaded = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
|
||||
// The four maintenance actions (Test connection / Rescan / Delete blockchain
|
||||
// / Repair wallet), right-aligned on the SAME row as Install bundled — no
|
||||
// separate "Advanced" toggle. Every BeginDisabled/EndDisabled predicate is
|
||||
// preserved exactly. Wraps to a right-aligned next row when too narrow.
|
||||
auto renderDaemonMaintenanceButtons = [&]() {
|
||||
ImGui::BeginDisabled(!app->isConnected());
|
||||
if (TactileButton(TR("test_connection"), ImVec2(0, 0), dbBtnFont)) {
|
||||
if (app->rpc() && app->rpc()->isConnected() && app->worker()) {
|
||||
@@ -2256,7 +2296,9 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, spMd);
|
||||
ImGui::BeginDisabled(!app->isConnected());
|
||||
if (TactileButton(TR("rescan"), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.confirm_rescan = true;
|
||||
// Re-probe the available block range each time the dialog is opened.
|
||||
@@ -2265,18 +2307,48 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_rescan"));
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::SameLine(0, spMd);
|
||||
ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon());
|
||||
if (TactileButton(TR("delete_blockchain"), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.confirm_delete_blockchain = true;
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain"));
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
ImGui::SameLine(0, spMd);
|
||||
if (TactileButton(TR("repair_wallet"), ImVec2(0, 0), dbBtnFont)) {
|
||||
s_settingsState.confirm_repair_wallet = true;
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet"));
|
||||
ImGui::EndDisabled();
|
||||
};
|
||||
{
|
||||
// Group width (button font) to right-align the four maintenance actions.
|
||||
const char* mLabels[] = { TR("test_connection"), TR("rescan"),
|
||||
TR("delete_blockchain"), TR("repair_wallet") };
|
||||
float groupW = spMd * 3.0f;
|
||||
for (const char* l : mLabels)
|
||||
groupW += dbBtnFont->CalcTextSizeA(dbBtnFont->LegacySize, FLT_MAX, 0, l).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
float afterTestX = ImGui::GetItemRectMax().x + spMd; // right edge of Install bundled
|
||||
float rightEdge = sectionOrigin.x + contentW;
|
||||
float groupX = rightEdge - groupW;
|
||||
if (groupX >= afterTestX) {
|
||||
// Fits on the Install-bundled row, right-aligned.
|
||||
ImGui::SameLine(0, 0);
|
||||
ImGui::SetCursorScreenPos(ImVec2(groupX, daemonBtnRowY));
|
||||
} else {
|
||||
// Too narrow: wrap to the next row, right-aligned (clamped to left edge).
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
float wrapX = std::max(sectionOrigin.x, rightEdge - groupW);
|
||||
ImGui::SetCursorScreenPos(ImVec2(wrapX, ImGui::GetCursorScreenPos().y));
|
||||
}
|
||||
renderDaemonMaintenanceButtons();
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::PopFont();
|
||||
|
||||
// Advance to the true bottom of the single column.
|
||||
ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2373,19 +2445,17 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad));
|
||||
ImGui::Indent(pad);
|
||||
|
||||
// Logo on the left side of the about card
|
||||
// Logo on the left side of the about card. Deferred: reserve horizontal space
|
||||
// now, but draw the image after the card's final height is known so it scales to
|
||||
// the full card height (no empty space below it).
|
||||
ImTextureID logoTex = app->getLogoTexture();
|
||||
float logoAreaW = 0;
|
||||
if (logoTex != 0) {
|
||||
float logoMaxH = schema::UI().drawElement("components.settings-page", "about-logo-size").sizeOr(64.0f);
|
||||
float logoH = logoMaxH;
|
||||
float aspect = (app->getLogoHeight() > 0) ? (float)app->getLogoWidth() / (float)app->getLogoHeight() : 1.0f;
|
||||
float logoW = logoH * aspect;
|
||||
ImVec2 logoPos = ImGui::GetCursorScreenPos();
|
||||
dl->AddImage(logoTex,
|
||||
ImVec2(logoPos.x, logoPos.y),
|
||||
ImVec2(logoPos.x + logoW, logoPos.y + logoH));
|
||||
logoAreaW = logoW + Layout::spacingLg();
|
||||
float logoAspect = (app->getLogoHeight() > 0)
|
||||
? (float)app->getLogoWidth() / (float)app->getLogoHeight() : 1.0f;
|
||||
float logoReserveH = schema::UI().drawElement("components.settings-page", "about-logo-size").sizeOr(150.0f);
|
||||
if (logoTex != 0) {
|
||||
logoAreaW = logoReserveH * logoAspect + Layout::spacingLg();
|
||||
ImGui::Indent(logoAreaW);
|
||||
}
|
||||
|
||||
@@ -2434,6 +2504,10 @@ void RenderSettingsPage(App* app) {
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
// Top of the (full-width) buttons row — the deferred logo is clamped to end above
|
||||
// this Y so the tall left-column logo never overlaps the buttons.
|
||||
float aboutButtonsTopY = ImGui::GetCursorScreenPos().y;
|
||||
|
||||
// Buttons — consistent equal-width row (full card width)
|
||||
if (logoAreaW > 0) {
|
||||
ImGui::Unindent(logoAreaW);
|
||||
@@ -2471,6 +2545,20 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::Unindent(pad);
|
||||
|
||||
ImVec2 cardMax(cardMin.x + availWidth, ImGui::GetCursorScreenPos().y);
|
||||
|
||||
// Draw the logo now that the card height is known — aspect-preserved, capped to the
|
||||
// reserved width, and clamped to end just above the buttons row so it never overlaps
|
||||
// the text or the buttons. Still on the content channel (1), above the glass.
|
||||
if (logoTex != 0) {
|
||||
float reserveW = logoReserveH * logoAspect;
|
||||
// Height available above the buttons row (the fix for the logo/buttons overlap).
|
||||
float logoBottomLimit = aboutButtonsTopY - Layout::spacingSm();
|
||||
float logoH = std::max(16.0f, logoBottomLimit - logoPos.y);
|
||||
float logoW = logoH * logoAspect;
|
||||
if (logoW > reserveW) { logoW = reserveW; logoH = (logoAspect > 0.0f) ? logoW / logoAspect : logoH; }
|
||||
dl->AddImage(logoTex, logoPos, ImVec2(logoPos.x + logoW, logoPos.y + logoH));
|
||||
}
|
||||
|
||||
dl->ChannelsSetCurrent(0);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
dl->ChannelsMerge();
|
||||
@@ -2482,9 +2570,10 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
|
||||
// ====================================================================
|
||||
// DEBUG LOGGING — collapsible card with glass styling
|
||||
// DEBUG LOGGING — collapsible card (full-node only: these are dragonxd
|
||||
// daemon debug= categories written to DRAGONX.conf; lite has no daemon)
|
||||
// ====================================================================
|
||||
{
|
||||
if (app->supportsFullNodeLifecycleActions()) {
|
||||
// Clickable header row
|
||||
ImVec2 headerPos = ImGui::GetCursorScreenPos();
|
||||
const char* arrow = s_settingsState.debug_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE;
|
||||
|
||||
@@ -41,7 +41,8 @@ std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListIn
|
||||
if (!input.info) continue;
|
||||
if (!AddressListMatchesFilter(input, filter)) continue;
|
||||
if (input.hidden && !showHidden) continue;
|
||||
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
|
||||
// Keep mining-flagged addresses visible even at 0 balance (they receive payouts).
|
||||
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite && !input.mining) continue;
|
||||
rows.push_back({input.info, input.isZ, input.hidden, input.favorite, input.mining,
|
||||
input.label, input.icon, input.sortOrder});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../data/portfolio.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
@@ -343,12 +344,20 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
for (int i = 0; i < (int)rows.size(); ++i) {
|
||||
if (mousePos.y > rowY[i] + rowH * 0.5f) insertIdx = i + 1;
|
||||
}
|
||||
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1) {
|
||||
int targetIdx = (insertIdx > s_dragIdx) ? insertIdx - 1 : insertIdx;
|
||||
if (targetIdx >= 0 && targetIdx < (int)rows.size() && s_dragIdx < (int)rows.size()) {
|
||||
app->swapAddressOrder(rows[s_dragIdx].info->address,
|
||||
rows[targetIdx].info->address);
|
||||
}
|
||||
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1 &&
|
||||
s_dragIdx < (int)rows.size()) {
|
||||
// Build the new visible order (move the dragged row to insertIdx) and
|
||||
// persist dense sort orders 0..N-1 in one shot. This works even when the
|
||||
// rows were still at the default order (a pairwise swap would no-op).
|
||||
std::vector<std::string> newOrder;
|
||||
newOrder.reserve(rows.size());
|
||||
for (int i = 0; i < (int)rows.size(); ++i)
|
||||
if (i != s_dragIdx) newOrder.push_back(rows[i].info->address);
|
||||
int clamped = std::min(insertIdx, (int)rows.size());
|
||||
int destIdx = (insertIdx > s_dragIdx) ? clamped - 1 : clamped;
|
||||
destIdx = std::max(0, std::min(destIdx, (int)newOrder.size()));
|
||||
newOrder.insert(newOrder.begin() + destIdx, rows[s_dragIdx].info->address);
|
||||
app->reorderAddresses(newOrder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,6 +678,25 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (ImGui::MenuItem(TR("favorite_address")))
|
||||
app->favoriteAddress(addr.address);
|
||||
}
|
||||
// Portfolio membership — toggle this address in each Market-tab portfolio entry.
|
||||
ImGui::Separator();
|
||||
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
|
||||
const auto& pf = app->settings()->getPortfolioEntries();
|
||||
if (pf.empty()) {
|
||||
ImGui::MenuItem(TR("portfolio_no_entries"), nullptr, false, false);
|
||||
}
|
||||
for (int pi = 0; pi < (int)pf.size(); pi++) {
|
||||
bool inSet = dragonx::data::PortfolioEntryContains(pf[pi].addresses, addr.address);
|
||||
if (ImGui::MenuItem(pf[pi].label.c_str(), nullptr, inSet)) {
|
||||
auto entries = pf; // copy, mutate, persist
|
||||
if (inSet) dragonx::data::PortfolioEntryRemove(entries[pi].addresses, addr.address);
|
||||
else dragonx::data::PortfolioEntryAdd(entries[pi].addresses, addr.address);
|
||||
app->settings()->setPortfolioEntries(entries);
|
||||
app->settings()->save();
|
||||
}
|
||||
}
|
||||
ImGui::EndMenu();
|
||||
}
|
||||
effects::ImGuiAcrylic::EndAcrylicPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
|
||||
35
src/ui/windows/console_channel.h
Normal file
35
src/ui/windows/console_channel.h
Normal file
@@ -0,0 +1,35 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleChannel — the semantic classification of a console line. It is the canonical
|
||||
// key: producers (the command executor, app log forwarders, the result formatter) tag
|
||||
// each line with a channel, the UI derives both the text color and the left accent-bar
|
||||
// color from it at draw time, and the output filter keys off it. This replaces the older
|
||||
// approach of storing an ImU32 color and reverse-engineering the source from color
|
||||
// equality + a "[daemon] " text prefix.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
enum class ConsoleChannel {
|
||||
None = 0, // plain result / default text
|
||||
Command, // the "> cmd" echo
|
||||
Info, // informational / help text
|
||||
Success, // positive events (connected, accepted, ready)
|
||||
Warning, // non-fatal warnings (amber)
|
||||
Error, // errors
|
||||
Rpc, // RPC trace lines
|
||||
Daemon, // dragonxd log output
|
||||
Xmrig, // miner log output
|
||||
App, // app / wallet / lite-diagnostics log
|
||||
JsonKey, // result JSON: object key
|
||||
JsonString, // result JSON: string value
|
||||
JsonNumber, // result JSON: number / bool / null
|
||||
JsonBrace, // result JSON: braces / brackets
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
313
src/ui/windows/console_command_executor.cpp
Normal file
313
src/ui/windows/console_command_executor.cpp
Normal file
@@ -0,0 +1,313 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "console_command_executor.h"
|
||||
#include "console_channel.h"
|
||||
#include "console_input_model.h" // BuildConsoleRpcCall
|
||||
|
||||
#include "../../app.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../daemon/embedded_daemon.h"
|
||||
#include "../../daemon/xmrig_manager.h"
|
||||
#include "../../wallet/lite_wallet_controller.h"
|
||||
#include "../../wallet/lite_diagnostics.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/colors.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <sstream>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
using namespace material;
|
||||
|
||||
// ============================================================================
|
||||
// FullNodeConsoleExecutor
|
||||
// ============================================================================
|
||||
|
||||
bool FullNodeConsoleExecutor::isReady() const
|
||||
{
|
||||
rpc::RPCClient* rpc = app_->consoleRpc();
|
||||
return rpc && rpc->isConnected();
|
||||
}
|
||||
|
||||
void FullNodeConsoleExecutor::submit(const std::string& cmd)
|
||||
{
|
||||
rpc::RPCClient* rpc = app_->consoleRpc();
|
||||
if (!rpc || !rpc->isConnected()) {
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({std::string(TR("console_not_connected")), true});
|
||||
return;
|
||||
}
|
||||
|
||||
auto call = BuildConsoleRpcCall(cmd);
|
||||
if (!call.valid) return;
|
||||
const std::string method = call.method;
|
||||
const nlohmann::json params = call.params;
|
||||
|
||||
rpc::RPCWorker* worker = app_->consoleWorker();
|
||||
if (worker) {
|
||||
worker->post([rpc, method, params, this]() -> rpc::RPCWorker::MainCb {
|
||||
std::string result_str;
|
||||
bool is_error = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
result_str = rpc->callRaw(method, params);
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
is_error = true;
|
||||
}
|
||||
return [this, result_str, is_error]() {
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
std::string result_str;
|
||||
bool is_error = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Console tab / User command");
|
||||
result_str = rpc->callRaw(method, params);
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
is_error = true;
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
results_.push_back({result_str, is_error});
|
||||
}
|
||||
}
|
||||
|
||||
bool FullNodeConsoleExecutor::pollResult(std::string& result, bool& isError)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(results_mutex_);
|
||||
if (results_.empty()) return false;
|
||||
result = std::move(results_.front().first);
|
||||
isError = results_.front().second;
|
||||
results_.pop_front();
|
||||
return true;
|
||||
}
|
||||
|
||||
void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
|
||||
{
|
||||
using DState = daemon::EmbeddedDaemon::State;
|
||||
|
||||
daemon::EmbeddedDaemon* d = app_->consoleDaemon();
|
||||
if (d) {
|
||||
const DState cur = d->getState();
|
||||
const int prev = last_daemon_state_;
|
||||
if (cur == DState::Starting && prev == static_cast<int>(DState::Stopped)) {
|
||||
add("", ConsoleChannel::None);
|
||||
add(TR("console_starting_node"), ConsoleChannel::Info);
|
||||
add(TR("console_capturing_output"), ConsoleChannel::Info);
|
||||
add("", ConsoleChannel::None);
|
||||
} else if (cur == DState::Running && prev != static_cast<int>(DState::Running)) {
|
||||
add(TR("console_daemon_started"), ConsoleChannel::Info);
|
||||
} else if (cur == DState::Stopped && prev == static_cast<int>(DState::Running)) {
|
||||
add("", ConsoleChannel::None);
|
||||
add(TR("console_daemon_stopped"), ConsoleChannel::Info);
|
||||
} else if (cur == DState::Error) {
|
||||
add(std::string(TR("console_daemon_error")) + d->getLastError() + " ===", ConsoleChannel::Error);
|
||||
}
|
||||
last_daemon_state_ = static_cast<int>(cur);
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc = app_->consoleRpc();
|
||||
if (rpc) {
|
||||
const bool now = rpc->isConnected();
|
||||
if (now && !last_rpc_connected_) add(TR("console_connected"), ConsoleChannel::Info);
|
||||
else if (!now && last_rpc_connected_) add(TR("console_disconnected"), ConsoleChannel::Error);
|
||||
last_rpc_connected_ = now;
|
||||
}
|
||||
|
||||
if (d) {
|
||||
std::string out = d->getOutputSince(last_daemon_output_size_);
|
||||
if (!out.empty()) {
|
||||
std::istringstream ss(out);
|
||||
std::string line;
|
||||
while (std::getline(ss, line)) {
|
||||
if (line.empty()) continue;
|
||||
ConsoleChannel c = ConsoleChannel::Daemon;
|
||||
if (line.find("[ERROR]") != std::string::npos ||
|
||||
line.find("error:") != std::string::npos ||
|
||||
line.find("Error:") != std::string::npos)
|
||||
c = ConsoleChannel::Error;
|
||||
add(line, c); // channel conveys the source; no "[daemon] " prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
daemon::XmrigManager* x = app_->consoleXmrig();
|
||||
if (x && x->isRunning()) {
|
||||
std::string out = x->getOutputSince(last_xmrig_output_size_);
|
||||
if (!out.empty()) {
|
||||
std::istringstream ss(out);
|
||||
std::string line;
|
||||
while (std::getline(ss, line)) {
|
||||
if (line.empty()) continue;
|
||||
ConsoleChannel c = ConsoleChannel::Xmrig;
|
||||
if (line.find("error") != std::string::npos ||
|
||||
line.find("ERROR") != std::string::npos ||
|
||||
line.find("failed") != std::string::npos)
|
||||
c = ConsoleChannel::Error;
|
||||
else if (line.find("accepted") != std::string::npos)
|
||||
c = ConsoleChannel::Success;
|
||||
add(line, c); // channel conveys the source; no "[xmrig] " prefix
|
||||
}
|
||||
}
|
||||
} else {
|
||||
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
||||
}
|
||||
}
|
||||
|
||||
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||
{
|
||||
add(TR("console_available_commands"), ConsoleChannel::Info);
|
||||
add(TR("console_help_clear"), ConsoleChannel::None);
|
||||
add(TR("console_help_help"), ConsoleChannel::None);
|
||||
add("", ConsoleChannel::None);
|
||||
add(TR("console_common_rpc"), ConsoleChannel::Info);
|
||||
add(TR("console_help_getinfo"), ConsoleChannel::None);
|
||||
add(TR("console_help_getbalance"), ConsoleChannel::None);
|
||||
add(TR("console_help_gettotalbalance"), ConsoleChannel::None);
|
||||
add(TR("console_help_getblockcount"), ConsoleChannel::None);
|
||||
add(TR("console_help_getpeerinfo"), ConsoleChannel::None);
|
||||
add(TR("console_help_setgenerate"), ConsoleChannel::None);
|
||||
add(TR("console_help_getmininginfo"), ConsoleChannel::None);
|
||||
add(TR("console_help_stop"), ConsoleChannel::None);
|
||||
add("", ConsoleChannel::None);
|
||||
add(TR("console_click_commands"), ConsoleChannel::Info);
|
||||
add(TR("console_tab_completion"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
|
||||
{
|
||||
ConsoleStatusLine s;
|
||||
daemon::EmbeddedDaemon* d = app_->consoleDaemon();
|
||||
if (!d) return s; // empty text -> toolbar shows the generic "no daemon" label
|
||||
using DState = daemon::EmbeddedDaemon::State;
|
||||
switch (d->getState()) {
|
||||
case DState::Stopped: s.text = TR("console_status_stopped"); s.color = IM_COL32(150,150,150,255); break;
|
||||
case DState::Starting: s.text = TR("console_status_starting"); s.color = Warning(); s.pulse = true; break;
|
||||
case DState::Running: s.text = TR("console_status_running"); s.color = Success(); break;
|
||||
case DState::Stopping: s.text = TR("console_status_stopping"); s.color = Warning(); s.pulse = true; break;
|
||||
case DState::Error: s.text = TR("console_status_error"); s.color = Error(); break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LiteConsoleExecutor
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
// Classify a lite diagnostics line into a channel for at-a-glance scanning.
|
||||
ConsoleChannel liteLogChannel(const std::string& line)
|
||||
{
|
||||
const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; };
|
||||
if (has("failed") || has(" unreachable") || has("blocked") || has("could not") ||
|
||||
has("Error") || has("error"))
|
||||
return ConsoleChannel::Error;
|
||||
if (has(": connected") || has("opened") || has("wallet ready") || has("Ready"))
|
||||
return ConsoleChannel::Success;
|
||||
return ConsoleChannel::App; // lite diagnostics / wallet log
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool LiteConsoleExecutor::isReady() const
|
||||
{
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
return lw && lw->walletOpen();
|
||||
}
|
||||
|
||||
bool LiteConsoleExecutor::busy() const
|
||||
{
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
return lw && lw->consoleCommandInProgress();
|
||||
}
|
||||
|
||||
void LiteConsoleExecutor::submit(const std::string& cmd)
|
||||
{
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (!lw) return;
|
||||
lw->runConsoleCommand(cmd); // rejection (busy) surfaces via the busy() indicator
|
||||
}
|
||||
|
||||
bool LiteConsoleExecutor::pollResult(std::string& result, bool& isError)
|
||||
{
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (!lw) return false;
|
||||
wallet::LiteConsoleResult res;
|
||||
if (!lw->takeConsoleResult(res)) return false;
|
||||
result = res.response.empty() ? std::string("(no output)") : res.response;
|
||||
isError = !res.ok;
|
||||
return true;
|
||||
}
|
||||
|
||||
void LiteConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
|
||||
{
|
||||
auto& diag = wallet::LiteDiagnostics::instance();
|
||||
const std::uint64_t gen = diag.generation();
|
||||
if (gen == diag_gen_) return;
|
||||
|
||||
const auto snap = diag.snapshot();
|
||||
std::size_t startIdx = 0;
|
||||
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
|
||||
const std::uint64_t added = gen - diag_gen_;
|
||||
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
|
||||
}
|
||||
for (std::size_t i = startIdx; i < snap.size(); ++i)
|
||||
add(snap[i], liteLogChannel(snap[i]));
|
||||
diag_gen_ = gen;
|
||||
}
|
||||
|
||||
void LiteConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||
{
|
||||
add(TR("console_available_commands"), ConsoleChannel::Info);
|
||||
add(TR("console_help_clear"), ConsoleChannel::None);
|
||||
add(TR("console_help_help"), ConsoleChannel::None);
|
||||
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
||||
{
|
||||
std::vector<ConsoleStatusLine> out;
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (lw && lw->walletOpen()) {
|
||||
const SyncInfo& sync = app_->state().sync;
|
||||
char buf[96];
|
||||
if (sync.syncing && !sync.isSynced()) {
|
||||
double vp = sync.verification_progress;
|
||||
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
||||
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
|
||||
vp * 100.0, sync.blocks, sync.headers);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
|
||||
}
|
||||
out.push_back({std::string(buf), OnSurfaceMedium(), false});
|
||||
}
|
||||
const std::string& err = app_->liteOpenError();
|
||||
if (!err.empty() && (!lw || !lw->walletOpen()))
|
||||
out.push_back({std::string("Last error: ") + err, Error(), false});
|
||||
return out;
|
||||
}
|
||||
|
||||
ConsoleStatusLine LiteConsoleExecutor::toolbarStatus() const
|
||||
{
|
||||
ConsoleStatusLine s;
|
||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||
if (!lw) { s.text = "No backend"; s.color = Error(); return s; }
|
||||
if (lw->walletOpen()) { s.text = "Connected"; s.color = Success(); }
|
||||
else if (lw->openInProgress()) { s.text = "Connecting"; s.color = Warning(); s.pulse = true; }
|
||||
else { s.text = "Disconnected"; s.color = Error(); }
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
128
src/ui/windows/console_command_executor.h
Normal file
128
src/ui/windows/console_command_executor.h
Normal file
@@ -0,0 +1,128 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleCommandExecutor — the pluggable backend behind the shared ConsoleTab UI. It
|
||||
// abstracts the only real differences between the full-node console (dragonxd log + RPC
|
||||
// command execution) and the lite console (lite diagnostics ring + backend command),
|
||||
// so both variants use one rich terminal widget instead of two implementations.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "console_channel.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
class App;
|
||||
namespace ui {
|
||||
|
||||
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
|
||||
|
||||
struct ConsoleStatusLine {
|
||||
std::string text;
|
||||
ImU32 color = IM_COL32(200, 200, 200, 255);
|
||||
bool pulse = false; // animate the toolbar dot (starting/connecting)
|
||||
};
|
||||
|
||||
// Which log-filter toggles the toolbar should show for a given backend. The full node has a
|
||||
// daemon/xmrig log + an RPC trace; the lite backend has neither (its diagnostics ring maps to
|
||||
// the App/Error channels), so it only wants the errors-only + app-messages toggles.
|
||||
struct ConsoleLogFilterCaps {
|
||||
bool daemon = false; // daemon + xmrig output toggle
|
||||
bool errorsOnly = false; // errors-only toggle
|
||||
bool rpcTrace = false; // RPC method/source trace toggle
|
||||
bool appMessages = false; // app / diagnostics log toggle
|
||||
bool any() const { return daemon || errorsOnly || rpcTrace || appMessages; }
|
||||
};
|
||||
|
||||
class ConsoleCommandExecutor {
|
||||
public:
|
||||
virtual ~ConsoleCommandExecutor() = default;
|
||||
|
||||
// True when commands can be run (RPC connected / lite wallet open).
|
||||
virtual bool isReady() const = 0;
|
||||
// A command is currently in flight (input is disabled while true).
|
||||
virtual bool busy() const { return false; }
|
||||
// Submit a user command for async execution. The UI has already echoed "> cmd" and
|
||||
// handled the built-ins (clear/help/quit). Results arrive later via pollResult().
|
||||
virtual void submit(const std::string& cmd) = 0;
|
||||
// Pop one completed command result (raw text + error flag) if available; the UI
|
||||
// formats/colors it via FormatConsoleRpcResultLines. Returns false if none pending.
|
||||
virtual bool pollResult(std::string& result, bool& isError) = 0;
|
||||
|
||||
// Pull any new passive log lines (daemon/xmrig output, or the lite diagnostics ring)
|
||||
// and hand each to `add`. Called once per frame.
|
||||
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
|
||||
|
||||
// UI-chrome capabilities.
|
||||
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
|
||||
// Which log-filter toggles the toolbar should show (default: none).
|
||||
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
|
||||
|
||||
// Print the backend-appropriate `help` output via `add`.
|
||||
virtual void printHelp(const ConsoleAddLineFn& add) = 0;
|
||||
|
||||
// Extra status lines drawn above the toolbar (lite shows sync + last error).
|
||||
virtual std::vector<ConsoleStatusLine> statusLines() const { return {}; }
|
||||
// Short status shown in the toolbar (daemon state / lite connection). Empty text =>
|
||||
// the toolbar shows its generic "no daemon" label.
|
||||
virtual ConsoleStatusLine toolbarStatus() const { return {}; }
|
||||
};
|
||||
|
||||
// ── Full-node: dragonxd log ingestion + RPC command execution ────────────────
|
||||
class FullNodeConsoleExecutor : public ConsoleCommandExecutor {
|
||||
public:
|
||||
explicit FullNodeConsoleExecutor(App* app) : app_(app) {}
|
||||
|
||||
bool isReady() const override;
|
||||
void submit(const std::string& cmd) override;
|
||||
bool pollResult(std::string& result, bool& isError) override;
|
||||
void pollLogLines(const ConsoleAddLineFn& add) override;
|
||||
bool hasRpcReference() const override { return true; }
|
||||
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
|
||||
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
|
||||
void printHelp(const ConsoleAddLineFn& add) override;
|
||||
ConsoleStatusLine toolbarStatus() const override;
|
||||
|
||||
private:
|
||||
App* app_;
|
||||
size_t last_daemon_output_size_ = 0;
|
||||
size_t last_xmrig_output_size_ = 0;
|
||||
int last_daemon_state_ = -1; // daemon::EmbeddedDaemon::State as int
|
||||
bool last_rpc_connected_ = false;
|
||||
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
||||
std::mutex results_mutex_;
|
||||
};
|
||||
|
||||
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
||||
class LiteConsoleExecutor : public ConsoleCommandExecutor {
|
||||
public:
|
||||
explicit LiteConsoleExecutor(App* app) : app_(app) {}
|
||||
|
||||
bool isReady() const override;
|
||||
bool busy() const override;
|
||||
void submit(const std::string& cmd) override;
|
||||
bool pollResult(std::string& result, bool& isError) override;
|
||||
void pollLogLines(const ConsoleAddLineFn& add) override;
|
||||
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
|
||||
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
|
||||
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
|
||||
void printHelp(const ConsoleAddLineFn& add) override;
|
||||
std::vector<ConsoleStatusLine> statusLines() const override;
|
||||
ConsoleStatusLine toolbarStatus() const override;
|
||||
|
||||
private:
|
||||
App* app_;
|
||||
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1); // last consumed diagnostics generation
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -236,5 +236,42 @@ std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& re
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
|
||||
{
|
||||
std::vector<int> spans(lines.size(), 0);
|
||||
|
||||
auto leadingSpaces = [](const std::string& s) -> std::size_t {
|
||||
std::size_t n = 0;
|
||||
while (n < s.size() && s[n] == ' ') ++n;
|
||||
return n;
|
||||
};
|
||||
auto lastNonSpace = [](const std::string& s) -> char {
|
||||
for (std::size_t i = s.size(); i-- > 0; )
|
||||
if (s[i] != ' ' && s[i] != '\t') return s[i];
|
||||
return '\0';
|
||||
};
|
||||
auto firstNonSpace = [](const std::string& s) -> char {
|
||||
for (char c : s)
|
||||
if (c != ' ' && c != '\t') return c;
|
||||
return '\0';
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i < lines.size(); ++i) {
|
||||
const char last = lastNonSpace(lines[i]);
|
||||
if (last != '{' && last != '[') continue; // not a block opener
|
||||
const std::size_t indent = leadingSpaces(lines[i]);
|
||||
const char wantClose = (last == '{') ? '}' : ']';
|
||||
// Matching closer = the next line at the same indentation whose first non-space char
|
||||
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
|
||||
for (std::size_t j = i + 1; j < lines.size(); ++j) {
|
||||
if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) {
|
||||
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -50,5 +50,12 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError);
|
||||
|
||||
// For a block of pretty-printed JSON lines, return a per-line fold span: for a line that
|
||||
// opens a collapsible block (its last non-space char is '{' or '[') the value is the offset
|
||||
// to its matching closing-bracket line at the same indentation; 0 for every other line and
|
||||
// for empty/one-line blocks not worth folding (span < 2). Pure; used to drive console
|
||||
// JSON folding.
|
||||
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
56
src/ui/windows/console_model.cpp
Normal file
56
src/ui/windows/console_model.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "console_model.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel, int foldSpan)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ingest_mutex_);
|
||||
ConsoleModelLine line;
|
||||
line.text = text;
|
||||
line.channel = channel;
|
||||
line.foldSpan = foldSpan;
|
||||
pending_.push_back(std::move(line));
|
||||
}
|
||||
|
||||
ConsoleModel::DrainResult ConsoleModel::drain()
|
||||
{
|
||||
std::vector<ConsoleModelLine> incoming;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(ingest_mutex_);
|
||||
if (pending_.empty()) return {};
|
||||
incoming.swap(pending_);
|
||||
}
|
||||
|
||||
DrainResult result;
|
||||
result.added = incoming.size();
|
||||
for (auto& line : incoming)
|
||||
lines_.push_back(std::move(line));
|
||||
|
||||
while (lines_.size() > max_lines_) {
|
||||
lines_.pop_front();
|
||||
++result.popped;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void ConsoleModel::clear()
|
||||
{
|
||||
lines_.clear();
|
||||
}
|
||||
|
||||
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
||||
{
|
||||
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
||||
lines_[i].collapsed = !lines_[i].collapsed;
|
||||
return lines_[i].collapsed;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
87
src/ui/windows/console_model.h
Normal file
87
src/ui/windows/console_model.h
Normal file
@@ -0,0 +1,87 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleModel — the thread-safe line store behind ConsoleTab.
|
||||
//
|
||||
// The console is fed from multiple threads: user commands + drained daemon/lite log lines
|
||||
// arrive on the main (render) thread, but RPC-trace and app-logger lines can be produced on
|
||||
// background worker threads. Previously a single mutex guarded both the line container AND
|
||||
// the UI-only interaction state (selection, scroll, auto-scroll counters), and it was held
|
||||
// across the whole render pass — yet some of that UI state was still touched outside the
|
||||
// lock in render(), racing the background producers.
|
||||
//
|
||||
// ConsoleModel splits the two concerns. Producers on ANY thread call ingest(), which only
|
||||
// briefly locks a small pending queue. The main thread calls drain() once per frame to move
|
||||
// pending lines into the visible deque and apply the line cap. The visible deque is then
|
||||
// only ever read/written on the main thread, so ConsoleTab needs no lock during render and
|
||||
// its selection/scroll fields become pure main-thread state.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "console_channel.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleModelLine {
|
||||
std::string text;
|
||||
ConsoleChannel channel = ConsoleChannel::None;
|
||||
// JSON folding: for a line that opens a collapsible block, `foldSpan` is the offset to
|
||||
// its matching closing-bracket line (>= 2); 0 for non-openers. `collapsed` is the user's
|
||||
// fold toggle (main-thread UI state).
|
||||
int foldSpan = 0;
|
||||
bool collapsed = false;
|
||||
};
|
||||
|
||||
class ConsoleModel {
|
||||
public:
|
||||
static constexpr std::size_t kDefaultMaxLines = 10000;
|
||||
|
||||
explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines) : max_lines_(maxLines) {}
|
||||
|
||||
// Append a line from ANY thread. Cheap: locks only the pending queue, does not touch
|
||||
// the visible deque. The line becomes visible after the next drain(). `foldSpan` marks a
|
||||
// JSON block opener (offset to its matching closer); 0 for ordinary lines.
|
||||
void ingest(const std::string& text, ConsoleChannel channel, int foldSpan = 0);
|
||||
|
||||
struct DrainResult {
|
||||
std::size_t added = 0; // lines appended to the visible deque this drain
|
||||
std::size_t popped = 0; // lines evicted from the front by the line cap
|
||||
};
|
||||
|
||||
// Main thread only. Move pending lines into the visible deque, then enforce the cap.
|
||||
// Returns how many were added and how many were evicted from the front (so the caller
|
||||
// can shift any selection indices that referenced the evicted lines).
|
||||
DrainResult drain();
|
||||
|
||||
// Main thread only. Clear the visible deque. Does NOT discard queued pending lines —
|
||||
// new output still appears — matching the console's view-only `clear` semantics.
|
||||
void clear();
|
||||
|
||||
// Main thread only. Toggle a foldable line's collapsed state (no-op if out of range or
|
||||
// not a block opener). Returns the new collapsed state.
|
||||
bool toggleCollapsed(std::size_t i);
|
||||
|
||||
// Visible-model access — main thread only (no lock; the deque is main-thread-owned).
|
||||
const std::deque<ConsoleModelLine>& lines() const { return lines_; }
|
||||
std::size_t size() const { return lines_.size(); }
|
||||
bool empty() const { return lines_.empty(); }
|
||||
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
|
||||
const ConsoleModelLine& back() const { return lines_.back(); }
|
||||
|
||||
private:
|
||||
const std::size_t max_lines_;
|
||||
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
|
||||
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
|
||||
std::mutex ingest_mutex_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -16,14 +16,14 @@ std::string lowerCopy(std::string value)
|
||||
}
|
||||
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
ConsoleChannel channel,
|
||||
const ConsoleOutputFilter& filter)
|
||||
{
|
||||
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
|
||||
if (!filter.rpcTraceEnabled && lineColor == filter.rpcTraceColor) return false;
|
||||
// "[app] ..." lines share COLOR_INFO with other info text, so match them by prefix.
|
||||
if (!filter.appMessagesEnabled && lineText.rfind("[app] ", 0) == 0) return false;
|
||||
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
|
||||
if (!filter.daemonMessagesEnabled &&
|
||||
(channel == ConsoleChannel::Daemon || channel == ConsoleChannel::Xmrig)) return false;
|
||||
if (!filter.rpcTraceEnabled && channel == ConsoleChannel::Rpc) return false;
|
||||
if (!filter.appMessagesEnabled && channel == ConsoleChannel::App) return false;
|
||||
if (filter.errorsOnly && channel != ConsoleChannel::Error) return false;
|
||||
if (!filter.text.empty()) {
|
||||
std::string needle = lowerCopy(filter.text);
|
||||
std::string haystack = lowerCopy(lineText);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include "console_channel.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -9,17 +9,15 @@ namespace ui {
|
||||
|
||||
struct ConsoleOutputFilter {
|
||||
std::string text;
|
||||
bool daemonMessagesEnabled = true;
|
||||
bool errorsOnly = false;
|
||||
bool rpcTraceEnabled = false;
|
||||
bool appMessagesEnabled = true; // "[app] ..." wallet log lines (matched by prefix, not color)
|
||||
ImU32 daemonColor = 0;
|
||||
ImU32 errorColor = 0;
|
||||
ImU32 rpcTraceColor = 0;
|
||||
bool daemonMessagesEnabled = true; // Daemon + Xmrig (node log) channels
|
||||
bool errorsOnly = false; // keep only the Error channel
|
||||
bool rpcTraceEnabled = false; // Rpc channel
|
||||
bool appMessagesEnabled = true; // App channel
|
||||
};
|
||||
|
||||
// Whether a line with the given channel + text passes the active filter.
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
ConsoleChannel channel,
|
||||
const ConsoleOutputFilter& filter);
|
||||
|
||||
} // namespace ui
|
||||
|
||||
39
src/ui/windows/console_scroll_controller.cpp
Normal file
39
src/ui/windows/console_scroll_controller.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "console_scroll_controller.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
void ConsoleScrollController::setAutoScroll(bool on)
|
||||
{
|
||||
auto_scroll_ = on;
|
||||
if (on) new_lines_ = 0; // re-pinning to the bottom clears the backlog counter
|
||||
}
|
||||
|
||||
void ConsoleScrollController::onUserScrolledUp()
|
||||
{
|
||||
auto_scroll_ = false;
|
||||
cooldown_ = kScrollUpCooldownSeconds;
|
||||
}
|
||||
|
||||
void ConsoleScrollController::tickCooldown(float dt)
|
||||
{
|
||||
if (cooldown_ > 0.0f) cooldown_ -= dt;
|
||||
}
|
||||
|
||||
void ConsoleScrollController::considerReenable(bool atBottom)
|
||||
{
|
||||
if (!auto_scroll_ && cooldown_ <= 0.0f && atBottom)
|
||||
pinToBottom();
|
||||
}
|
||||
|
||||
void ConsoleScrollController::onLinesAdded(int n)
|
||||
{
|
||||
if (!auto_scroll_ && n > 0) new_lines_ += n;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
63
src/ui/windows/console_scroll_controller.h
Normal file
63
src/ui/windows/console_scroll_controller.h
Normal file
@@ -0,0 +1,63 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleScrollController — the console's auto-scroll state machine.
|
||||
//
|
||||
// It owns three coupled pieces of state that were previously loose fields on the god-class:
|
||||
// - auto_scroll_ : whether output is pinned to the bottom (follows new lines)
|
||||
// - a cooldown : a short grace period after a wheel-up so smooth-scroll can animate away
|
||||
// from the bottom before the "am I at the bottom?" re-enable check runs
|
||||
// - new_lines_ : how many lines arrived while scrolled up (drives the jump-to-bottom pill)
|
||||
//
|
||||
// It is ImGui-free: the view measures scroll positions and hands in a plain `atBottom` bool
|
||||
// and a frame delta; the controller decides when to re-pin. That keeps the interplay
|
||||
// (wheel-up pauses, cooldown gates the re-enable, new-line counting only while scrolled up)
|
||||
// unit testable.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
class ConsoleScrollController {
|
||||
public:
|
||||
// Seconds to ignore the at-bottom re-enable check after a user wheel-up, so an in-flight
|
||||
// smooth-scroll can move away from the bottom first.
|
||||
static constexpr float kScrollUpCooldownSeconds = 0.5f;
|
||||
|
||||
bool autoScroll() const { return auto_scroll_; }
|
||||
int newLines() const { return new_lines_; }
|
||||
|
||||
// User toggled the "auto-scroll" checkbox. Enabling re-pins to the bottom.
|
||||
void setAutoScroll(bool on);
|
||||
|
||||
// User scrolled up with the wheel — stop following output and start the cooldown.
|
||||
void onUserScrolledUp();
|
||||
|
||||
// Advance the cooldown timer by one frame.
|
||||
void tickCooldown(float dt);
|
||||
|
||||
// The view reports whether the viewport is currently at the bottom. If auto-scroll is off
|
||||
// and the cooldown has elapsed, reaching the bottom re-enables auto-scroll.
|
||||
void considerReenable(bool atBottom);
|
||||
|
||||
// `n` new lines were appended to the model this frame; count them only while scrolled up.
|
||||
void onLinesAdded(int n);
|
||||
|
||||
// Called each frame while pinned to the bottom (the view issues the actual scroll).
|
||||
void resetNewLines() { new_lines_ = 0; }
|
||||
|
||||
// User clicked the jump-to-bottom pill.
|
||||
void jumpToBottom() { pinToBottom(); }
|
||||
|
||||
private:
|
||||
void pinToBottom() { auto_scroll_ = true; new_lines_ = 0; }
|
||||
|
||||
bool auto_scroll_ = true;
|
||||
float cooldown_ = 0.0f;
|
||||
int new_lines_ = 0;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
89
src/ui/windows/console_selection_controller.cpp
Normal file
89
src/ui/windows/console_selection_controller.cpp
Normal file
@@ -0,0 +1,89 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "console_selection_controller.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
bool ConsoleSelectionController::isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b)
|
||||
{
|
||||
if (a.line < b.line) return true;
|
||||
if (a.line > b.line) return false;
|
||||
return a.col <= b.col;
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::beginDrag(ConsoleTextPos pos)
|
||||
{
|
||||
anchor_ = pos;
|
||||
caret_ = pos;
|
||||
dragging_ = true;
|
||||
active_ = false; // not a real selection until the caret moves off the anchor
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::updateDrag(ConsoleTextPos pos)
|
||||
{
|
||||
caret_ = pos;
|
||||
// Becomes a real selection once the caret leaves the anchor; never downgrades back.
|
||||
if (caret_.line != anchor_.line || caret_.col != anchor_.col)
|
||||
active_ = true;
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::endDrag(ConsoleTextPos pos)
|
||||
{
|
||||
caret_ = pos;
|
||||
dragging_ = false;
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::selectAll(int lineCount, int lastLineByteLen)
|
||||
{
|
||||
if (lineCount <= 0) return;
|
||||
anchor_ = {0, 0};
|
||||
caret_ = {lineCount - 1, lastLineByteLen};
|
||||
active_ = true;
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::clear()
|
||||
{
|
||||
active_ = false;
|
||||
dragging_ = false;
|
||||
anchor_ = {-1, 0};
|
||||
caret_ = {-1, 0};
|
||||
}
|
||||
|
||||
ConsoleTextPos ConsoleSelectionController::rangeStart() const
|
||||
{
|
||||
return isBeforeOrEqual(anchor_, caret_) ? anchor_ : caret_;
|
||||
}
|
||||
|
||||
ConsoleTextPos ConsoleSelectionController::rangeEnd() const
|
||||
{
|
||||
return isBeforeOrEqual(anchor_, caret_) ? caret_ : anchor_;
|
||||
}
|
||||
|
||||
void ConsoleSelectionController::shiftForEviction(int popped)
|
||||
{
|
||||
if (popped <= 0 || !active_) return;
|
||||
anchor_.line -= popped;
|
||||
caret_.line -= popped;
|
||||
if (anchor_.line < 0 && caret_.line < 0) {
|
||||
// Entire selection was in the removed range.
|
||||
active_ = false;
|
||||
dragging_ = false;
|
||||
} else {
|
||||
if (anchor_.line < 0) { anchor_.line = 0; anchor_.col = 0; }
|
||||
if (caret_.line < 0) { caret_.line = 0; caret_.col = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
std::string ConsoleSelectionController::extract(int lineCount, const ConsoleLineAccessor& getLine,
|
||||
const std::vector<int>& visibleIndices) const
|
||||
{
|
||||
if (!active_) return "";
|
||||
return ExtractConsoleSelection(lineCount, getLine, visibleIndices,
|
||||
rangeStart(), rangeEnd());
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
65
src/ui/windows/console_selection_controller.h
Normal file
65
src/ui/windows/console_selection_controller.h
Normal file
@@ -0,0 +1,65 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleSelectionController — owns the console's text-selection state (anchor + caret) and
|
||||
// all the pure logic around it: the mouse-drag lifecycle, select-all, ordering, extraction,
|
||||
// and the index shift applied when lines are evicted from the top by the buffer cap.
|
||||
//
|
||||
// It is deliberately ImGui-free. Screen->text hit-testing stays in the view (it needs the
|
||||
// current frame's wrapped layout + output origin); the view feeds the resulting
|
||||
// ConsoleTextPos values in here. That keeps the fiddly byte-offset / eviction math unit
|
||||
// testable in isolation from the renderer.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "console_text_layout.h" // ConsoleTextPos, ConsoleLineAccessor, ExtractConsoleSelection
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
class ConsoleSelectionController {
|
||||
public:
|
||||
// Mouse-drag lifecycle. Positions are raw {line, col}: line = index into the full model,
|
||||
// col = byte offset within that line.
|
||||
void beginDrag(ConsoleTextPos pos); // button press
|
||||
void updateDrag(ConsoleTextPos pos); // movement while held
|
||||
void endDrag(ConsoleTextPos pos); // release
|
||||
|
||||
// Select the whole buffer, [0,0] .. [lineCount-1, lastLineByteLen]. No-op if empty.
|
||||
void selectAll(int lineCount, int lastLineByteLen);
|
||||
|
||||
// Drop the selection entirely (also ends any in-progress drag).
|
||||
void clear();
|
||||
|
||||
bool active() const { return active_; } // a selection currently exists
|
||||
bool dragging() const { return dragging_; } // a drag is in progress
|
||||
|
||||
// Ordered bounds, start <= end. Only meaningful while active().
|
||||
ConsoleTextPos rangeStart() const;
|
||||
ConsoleTextPos rangeEnd() const;
|
||||
|
||||
// After the cap evicts `popped` lines from the front, shift the selection up to track
|
||||
// the same text. Drops the selection if it fell entirely off the top.
|
||||
void shiftForEviction(int popped);
|
||||
|
||||
// Selected text, or "" when inactive. `visibleIndices` are the raw line indices currently
|
||||
// shown (the selection spans raw lines but copies only the visible ones).
|
||||
std::string extract(int lineCount, const ConsoleLineAccessor& getLine,
|
||||
const std::vector<int>& visibleIndices) const;
|
||||
|
||||
// Position ordering: is a at or before b in reading order?
|
||||
static bool isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b);
|
||||
|
||||
private:
|
||||
ConsoleTextPos anchor_{-1, 0}; // where the drag began / the selection origin
|
||||
ConsoleTextPos caret_{-1, 0}; // current drag position / the selection focus
|
||||
bool active_ = false;
|
||||
bool dragging_ = false;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,11 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "console_channel.h"
|
||||
#include "console_model.h"
|
||||
#include "console_scroll_controller.h"
|
||||
#include "console_selection_controller.h"
|
||||
#include "console_text_layout.h"
|
||||
#include "../layout.h"
|
||||
#include "../../daemon/embedded_daemon.h"
|
||||
#include "../../daemon/xmrig_manager.h"
|
||||
@@ -18,10 +23,14 @@
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
class ConsoleCommandExecutor;
|
||||
struct ConsoleLogFilterCaps;
|
||||
|
||||
/**
|
||||
* @brief Console tab for daemon output and command input
|
||||
* @brief Console tab — a rich terminal shared by the full-node and lite variants.
|
||||
*
|
||||
* Shows dragonxd output and allows executing RPC commands.
|
||||
* The command execution + log sources are provided by a ConsoleCommandExecutor, so this
|
||||
* one widget serves both dragonxd (RPC) and the lite backend.
|
||||
*/
|
||||
class ConsoleTab {
|
||||
public:
|
||||
@@ -29,12 +38,9 @@ public:
|
||||
~ConsoleTab();
|
||||
|
||||
/**
|
||||
* @brief Render the console tab
|
||||
* @param daemon Pointer to embedded daemon (may be null)
|
||||
* @param rpc Pointer to RPC client for command execution
|
||||
* @param xmrig Pointer to xmrig manager for pool mining output (may be null)
|
||||
* @brief Render the console tab against a backend executor.
|
||||
*/
|
||||
void render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig = nullptr);
|
||||
void render(ConsoleCommandExecutor& exec);
|
||||
|
||||
/**
|
||||
* @brief Render the RPC Command Reference popup at top-level scope.
|
||||
@@ -45,7 +51,7 @@ public:
|
||||
/**
|
||||
* @brief Add a line to the console output
|
||||
*/
|
||||
void addLine(const std::string& line, ImU32 color = IM_COL32(200, 200, 200, 255));
|
||||
void addLine(const std::string& line, ConsoleChannel channel = ConsoleChannel::None);
|
||||
void addRpcTraceLine(const std::string& source, const std::string& method);
|
||||
|
||||
/**
|
||||
@@ -53,11 +59,6 @@ public:
|
||||
*/
|
||||
void clear();
|
||||
|
||||
/**
|
||||
* @brief Check if auto-scroll is enabled
|
||||
*/
|
||||
bool isAutoScrollEnabled() const { return auto_scroll_; }
|
||||
|
||||
// Scanline effect toggle (set from settings)
|
||||
static bool s_scanline_enabled;
|
||||
|
||||
@@ -88,52 +89,64 @@ public:
|
||||
static ImU32 COLOR_RPC;
|
||||
|
||||
private:
|
||||
struct ConsoleLine {
|
||||
std::string text;
|
||||
ImU32 color;
|
||||
};
|
||||
// Text color for a channel, resolved from the current theme (COLOR_* + material). Each
|
||||
// line stores only its semantic ConsoleChannel (in ConsoleModel); the text color and
|
||||
// the left accent-bar color are both derived from it at draw time.
|
||||
ImU32 channelTextColor(ConsoleChannel channel) const;
|
||||
// Saturated accent color for a channel — the left accent-bar color, also used to tint the
|
||||
// matching toolbar filter toggle. 0 for channels that get no accent (result/JSON/None).
|
||||
static ImU32 channelAccentColor(ConsoleChannel channel);
|
||||
|
||||
void executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc::RPCWorker* worker);
|
||||
void addCommandResult(const std::string& cmd, const std::string& result, bool is_error = false);
|
||||
void renderToolbar(daemon::EmbeddedDaemon* daemon);
|
||||
void renderOutput();
|
||||
void renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker);
|
||||
// Drain the thread-safe ingest queue into the visible model (once per frame, main
|
||||
// thread), applying the line cap + selection/scroll bookkeeping for the new lines.
|
||||
void drainModel();
|
||||
|
||||
// Format a completed command result (JSON role -> channel) into console lines.
|
||||
void addFormattedResult(const std::string& result, bool is_error);
|
||||
void renderStatusHeader(ConsoleCommandExecutor& exec);
|
||||
void renderCommandsPopup();
|
||||
|
||||
// Selection helpers
|
||||
void handleSelection();
|
||||
std::string getSelectedText() const;
|
||||
void clearSelection();
|
||||
// renderToolbar() draws the top bar; these are its sub-steps:
|
||||
void renderToolbar(ConsoleCommandExecutor& exec);
|
||||
void drawToolbarStatus(ConsoleCommandExecutor& exec); // backend status dot + label
|
||||
void drawLogFilterToggles(const ConsoleLogFilterCaps& caps); // color-coded filter checkboxes
|
||||
void drawFilterInput(); // text filter + match count
|
||||
void drawZoomControls(); // zoom -/+ buttons
|
||||
|
||||
// Convert screen position to line/column
|
||||
struct TextPos {
|
||||
int line = -1;
|
||||
int col = 0;
|
||||
};
|
||||
TextPos screenToTextPos(ImVec2 screen_pos, float line_height) const;
|
||||
bool isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const;
|
||||
TextPos selectionStart() const; // Returns the earlier of sel_anchor_ and sel_end_
|
||||
TextPos selectionEnd() const; // Returns the later of sel_anchor_ and sel_end_
|
||||
// renderInput() draws the command line; dispatch is factored out:
|
||||
void renderInput(ConsoleCommandExecutor& exec);
|
||||
// Echo + handle a submitted command line (built-ins + submit). True if non-empty/handled.
|
||||
bool submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd);
|
||||
|
||||
std::deque<ConsoleLine> lines_;
|
||||
// renderOutput() orchestrates the scrollable output area; these are its sub-steps:
|
||||
void renderOutput();
|
||||
void computeVisibleLines(bool& hasTextFilter, std::string& filterLower); // -> visible_indices_
|
||||
void handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput); // wheel/selection/keys
|
||||
void drawVisibleLines(float padX, float lineHeight, bool hasTextFilter,
|
||||
const std::string& filterLower); // bars/guides/highlight/text
|
||||
void drawNewOutputIndicator(); // jump-to-bottom pill
|
||||
void drawOutputContextMenu(); // right-click menu
|
||||
|
||||
// Map a screen point to a raw {line, col} position via this frame's wrapped layout.
|
||||
// (View-side because it needs layout_ + output_origin_; feeds the selection controller.)
|
||||
ConsoleTextPos screenToTextPos(ImVec2 screen_pos) const;
|
||||
|
||||
// The current selection's text (via selection_ over the visible model). "" if inactive.
|
||||
std::string selectedText() const;
|
||||
|
||||
// Thread-safe line store: producers on any thread call model_.ingest(); the main
|
||||
// thread drains it once per frame in render(). The visible deque is main-thread-only.
|
||||
ConsoleModel model_;
|
||||
std::vector<std::string> command_history_;
|
||||
int history_index_ = -1;
|
||||
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;
|
||||
bool shown_startup_message_ = false;
|
||||
daemon::EmbeddedDaemon::State last_daemon_state_ = daemon::EmbeddedDaemon::State::Stopped;
|
||||
bool last_rpc_connected_ = false;
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
// Text selection state
|
||||
bool is_selecting_ = false;
|
||||
bool has_selection_ = false;
|
||||
TextPos sel_anchor_; // Where the mouse was first pressed
|
||||
TextPos sel_end_; // Where the mouse currently is / was released
|
||||
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
|
||||
ConsoleScrollController scroll_;
|
||||
|
||||
// Text-selection state + logic (anchor/caret, ordering, extraction, eviction shift).
|
||||
ConsoleSelectionController selection_;
|
||||
float output_scroll_y_ = 0.0f; // Track scroll position for selection
|
||||
ImVec2 output_origin_ = {0, 0}; // Top-left of output area
|
||||
float output_line_height_ = 0.0f; // Text line height (for scanline alignment)
|
||||
@@ -145,28 +158,16 @@ private:
|
||||
};
|
||||
std::vector<ScanlineRow> scanline_rows_;
|
||||
|
||||
std::mutex lines_mutex_;
|
||||
|
||||
// Output filter
|
||||
char filter_text_[128] = {0};
|
||||
mutable int filter_match_count_ = 0; // lines matching the text filter (for the toolbar)
|
||||
std::string context_token_; // hash/address under the cursor at right-click
|
||||
mutable std::vector<int> visible_indices_; // Cached for selection mapping
|
||||
|
||||
// Wrapped line height caching (for variable-height text wrapping)
|
||||
mutable std::vector<float> wrapped_heights_; // Height of each visible line (accounts for wrapping)
|
||||
mutable std::vector<float> cumulative_y_offsets_; // Cumulative Y offset for each visible line
|
||||
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
|
||||
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
||||
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
||||
// consumed by the renderer + hit-testing.
|
||||
mutable ConsoleLayout layout_;
|
||||
|
||||
// Commands popup
|
||||
bool show_commands_popup_ = false;
|
||||
|
||||
162
src/ui/windows/console_text_layout.cpp
Normal file
162
src/ui/windows/console_text_layout.cpp
Normal file
@@ -0,0 +1,162 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure implementation of the console wrap layout / hit-test / selection. No ImGui — the
|
||||
// algorithm mirrors the former inline code in ConsoleTab exactly, with text measurement
|
||||
// injected via ConsoleTextMeasure.
|
||||
|
||||
#include "console_text_layout.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
|
||||
float wrapWidth, float lineHeight, float interLineGap,
|
||||
const ConsoleTextMeasure& measure)
|
||||
{
|
||||
ConsoleLayout L;
|
||||
if (visibleCount < 0) visibleCount = 0;
|
||||
L.segments.resize(visibleCount);
|
||||
L.heights.resize(visibleCount);
|
||||
L.cumulativeY.resize(visibleCount);
|
||||
L.totalHeight = 0.0f;
|
||||
|
||||
for (int vi = 0; vi < visibleCount; vi++) {
|
||||
const std::string& text = getVisibleText(vi);
|
||||
auto& segs = L.segments[vi];
|
||||
segs.clear();
|
||||
|
||||
if (text.empty()) {
|
||||
segs.push_back({0, 0, 0.0f, lineHeight});
|
||||
L.cumulativeY[vi] = L.totalHeight;
|
||||
L.heights[vi] = lineHeight + interLineGap;
|
||||
L.totalHeight += L.heights[vi];
|
||||
continue;
|
||||
}
|
||||
|
||||
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 = measure.wrapPosition(cur, textEnd, wrapWidth);
|
||||
if (wrapPos <= cur) wrapPos = cur + 1; // ensure forward progress
|
||||
if (*cur == '\n') { cur++; continue; } // skip a newline ending the prior seg
|
||||
|
||||
int byteStart = static_cast<int>(cur - textStart);
|
||||
int byteEnd = static_cast<int>(wrapPos - textStart);
|
||||
if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; // trim trailing '\n'
|
||||
|
||||
segs.push_back({byteStart, byteEnd, segY, lineHeight});
|
||||
segY += lineHeight;
|
||||
cur = wrapPos;
|
||||
}
|
||||
|
||||
if (segs.empty()) {
|
||||
segs.push_back({0, 0, 0.0f, lineHeight});
|
||||
segY = lineHeight;
|
||||
}
|
||||
|
||||
L.cumulativeY[vi] = L.totalHeight;
|
||||
L.heights[vi] = segY + interLineGap;
|
||||
L.totalHeight += L.heights[vi];
|
||||
}
|
||||
|
||||
return L;
|
||||
}
|
||||
|
||||
ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount,
|
||||
const ConsoleLineAccessor& getVisibleText,
|
||||
float relX, float relY,
|
||||
const ConsoleTextMeasure& measure)
|
||||
{
|
||||
ConsoleHit hit{0, 0};
|
||||
if (visibleCount <= 0 || layout.cumulativeY.empty()) return hit;
|
||||
|
||||
// Binary search for the visible line containing relY.
|
||||
int visibleLine = 0;
|
||||
{
|
||||
int lo = 0, hi = static_cast<int>(layout.cumulativeY.size()) - 1;
|
||||
while (lo < hi) {
|
||||
int mid = (lo + hi + 1) / 2;
|
||||
if (layout.cumulativeY[mid] <= relY) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
visibleLine = lo;
|
||||
}
|
||||
|
||||
if (visibleLine < 0) visibleLine = 0;
|
||||
if (visibleLine >= visibleCount) {
|
||||
visibleLine = visibleCount - 1;
|
||||
hit.visibleRow = visibleLine;
|
||||
hit.col = static_cast<int>(getVisibleText(visibleLine).size());
|
||||
return hit;
|
||||
}
|
||||
|
||||
hit.visibleRow = visibleLine;
|
||||
const std::string& text = getVisibleText(visibleLine);
|
||||
if (text.empty()) { hit.col = 0; return hit; }
|
||||
|
||||
// Which wrap sub-row does relY fall into.
|
||||
const auto& segs = layout.segments[visibleLine];
|
||||
float lineRelY = relY - layout.cumulativeY[visibleLine];
|
||||
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];
|
||||
|
||||
if (relX <= 0.0f) { hit.col = seg.byteStart; return hit; }
|
||||
|
||||
const char* segStart = text.c_str() + seg.byteStart;
|
||||
int segLen = seg.byteEnd - seg.byteStart;
|
||||
hit.col = seg.byteEnd; // default: past the end of the segment
|
||||
for (int c = 0; c < segLen; c++) {
|
||||
float wCur = measure.width(segStart, segStart + c + 1);
|
||||
float wPrev = (c > 0) ? measure.width(segStart, segStart + c) : 0.0f;
|
||||
float charMid = (wPrev + wCur) * 0.5f;
|
||||
if (relX < charMid) { hit.col = seg.byteStart + c; return hit; }
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
|
||||
const std::vector<int>& visibleIndices,
|
||||
ConsoleTextPos start, ConsoleTextPos end)
|
||||
{
|
||||
if (start.line < 0 || start.line >= lineCount) return "";
|
||||
|
||||
// Only restrict to visible lines when a filter is active (fewer visible than total).
|
||||
const bool hasFilter = !visibleIndices.empty() &&
|
||||
static_cast<int>(visibleIndices.size()) < lineCount;
|
||||
std::vector<bool> visible;
|
||||
if (hasFilter) {
|
||||
visible.assign(lineCount, false);
|
||||
for (int idx : visibleIndices)
|
||||
if (idx >= 0 && idx < lineCount) visible[idx] = true;
|
||||
}
|
||||
|
||||
std::string result;
|
||||
bool firstLine = true;
|
||||
for (int i = start.line; i <= end.line && i < lineCount; i++) {
|
||||
if (hasFilter && !visible[i]) continue;
|
||||
|
||||
const std::string& text = getLine(i);
|
||||
int colStart = 0;
|
||||
int colEnd = static_cast<int>(text.size());
|
||||
if (i == start.line) colStart = std::min(start.col, static_cast<int>(text.size()));
|
||||
if (i == end.line) colEnd = std::min(end.col, static_cast<int>(text.size()));
|
||||
|
||||
if (!firstLine) result += "\n";
|
||||
firstLine = false;
|
||||
|
||||
if (colStart < colEnd) result += text.substr(colStart, colEnd - colStart);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
72
src/ui/windows/console_text_layout.h
Normal file
72
src/ui/windows/console_text_layout.h
Normal file
@@ -0,0 +1,72 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// ConsoleTextLayout — pure (ImGui-free) word-wrap layout + hit-testing + selection
|
||||
// extraction for the console output. Text measurement is injected via ConsoleTextMeasure
|
||||
// so the production path uses ImFont exactly as before while tests drive it with a
|
||||
// fixed-width stub. Extracted from ConsoleTab::renderOutput / screenToTextPos /
|
||||
// getSelectedText so the trickiest console logic (byte-offset selection across wrapped
|
||||
// rows) becomes unit-testable.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// One visual sub-row of a (possibly wrapped) logical line.
|
||||
struct ConsoleWrapSegment {
|
||||
int byteStart = 0; // byte offset into the line's text
|
||||
int byteEnd = 0; // byte offset past the last char on this sub-row
|
||||
float yOffset = 0; // Y offset of this sub-row relative to the line's top
|
||||
float height = 0; // visual height of this sub-row
|
||||
};
|
||||
|
||||
// The wrap layout for a set of visible lines.
|
||||
struct ConsoleLayout {
|
||||
std::vector<std::vector<ConsoleWrapSegment>> segments; // per visible line
|
||||
std::vector<float> heights; // per visible line (includes the inter-line gap)
|
||||
std::vector<float> cumulativeY; // cumulative Y offset of each visible line
|
||||
float totalHeight = 0.0f;
|
||||
};
|
||||
|
||||
struct ConsoleTextPos { int line = -1; int col = 0; }; // line = raw index; col = byte offset
|
||||
struct ConsoleHit { int visibleRow = 0; int col = 0; }; // visibleRow = index into the visible set
|
||||
|
||||
// Text measurement backend. Production wraps ImFont; tests inject a fixed-width stub.
|
||||
struct ConsoleTextMeasure {
|
||||
virtual ~ConsoleTextMeasure() = default;
|
||||
// Pixel width of the byte range [begin, end).
|
||||
virtual float width(const char* begin, const char* end) const = 0;
|
||||
// The byte position at which [begin, end) should wrap for the given width — mirrors
|
||||
// ImFont::CalcWordWrapPositionA. Returns `end` when the whole range fits.
|
||||
virtual const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const = 0;
|
||||
};
|
||||
|
||||
// Accessor returning a visible/raw line's text by index (avoids copying the buffer).
|
||||
using ConsoleLineAccessor = std::function<const std::string&(int)>;
|
||||
|
||||
// Build the wrap layout for `visibleCount` lines (text via getVisibleText(0..N-1)).
|
||||
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
|
||||
float wrapWidth, float lineHeight, float interLineGap,
|
||||
const ConsoleTextMeasure& measure);
|
||||
|
||||
// Map a point relative to the layout origin to a (visibleRow, byte col). The caller maps
|
||||
// visibleRow -> a raw line index. Returns {0,0} when there are no visible lines.
|
||||
ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount,
|
||||
const ConsoleLineAccessor& getVisibleText,
|
||||
float relX, float relY,
|
||||
const ConsoleTextMeasure& measure);
|
||||
|
||||
// Extract the selected text between raw positions [start, end], restricted to visible
|
||||
// lines when a filter is active, joining lines with '\n'. Pure.
|
||||
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
|
||||
const std::vector<int>& visibleIndices,
|
||||
ConsoleTextPos start, ConsoleTextPos end);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,274 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "lite_console_tab.h"
|
||||
#include "console_input_model.h" // reuse the generic command-history helpers
|
||||
|
||||
#include "../../app.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../wallet/lite_diagnostics.h"
|
||||
#include "../../wallet/lite_wallet_controller.h"
|
||||
#include "../layout.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/colors.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
using namespace material;
|
||||
|
||||
namespace {
|
||||
|
||||
struct ConsoleLine {
|
||||
std::string text;
|
||||
ImU32 color;
|
||||
};
|
||||
|
||||
// Unified, in-memory console buffer: it interleaves the automatic diagnostics log (lifecycle /
|
||||
// connection / sync events) with the user's interactive commands and their responses, the same
|
||||
// way the full-node Console interleaves the daemon log with RPC I/O. NOTE: this buffer can hold
|
||||
// secret command output (e.g. `seed`/`export`) and is NEVER persisted to LiteDiagnostics.
|
||||
std::vector<ConsoleLine> s_lines;
|
||||
std::uint64_t s_diagGen = static_cast<std::uint64_t>(-1); // last consumed diagnostics generation
|
||||
char s_input[512] = "";
|
||||
std::vector<std::string> s_history;
|
||||
int s_historyIndex = -1;
|
||||
bool s_autoScroll = true;
|
||||
bool s_scrollToBottom = false;
|
||||
bool s_focusInput = false;
|
||||
|
||||
// Colour error/success lines for at-a-glance scanning (substring match on the messages the
|
||||
// controller emits). Anything else renders in the muted default colour.
|
||||
ImU32 logColor(const std::string& line)
|
||||
{
|
||||
const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; };
|
||||
if (has("failed") || has(" unreachable") || has("blocked") || has("could not") ||
|
||||
has("Error") || has("error"))
|
||||
return Error();
|
||||
if (has(": connected") || has("opened") || has("wallet ready") || has("Ready"))
|
||||
return Success();
|
||||
return OnSurfaceMedium();
|
||||
}
|
||||
|
||||
// Append text as one or more lines (splitting on '\n' so multi-line JSON responses render row by row).
|
||||
void appendLines(const std::string& text, ImU32 color)
|
||||
{
|
||||
std::size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
std::size_t nl = text.find('\n', start);
|
||||
std::string line = text.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
|
||||
while (!line.empty() && (line.back() == '\r')) line.pop_back();
|
||||
s_lines.push_back({std::move(line), color});
|
||||
if (nl == std::string::npos) break;
|
||||
start = nl + 1;
|
||||
}
|
||||
}
|
||||
|
||||
std::string lowerFirstToken(const std::string& cmd)
|
||||
{
|
||||
std::size_t b = cmd.find_first_not_of(" \t");
|
||||
if (b == std::string::npos) return {};
|
||||
std::size_t e = cmd.find_first_of(" \t", b);
|
||||
std::string tok = cmd.substr(b, e == std::string::npos ? std::string::npos : e - b);
|
||||
std::transform(tok.begin(), tok.end(), tok.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return tok;
|
||||
}
|
||||
|
||||
// InputText history navigation (Up/Down) — reuses the generic console history helpers.
|
||||
int inputCallback(ImGuiInputTextCallbackData* data)
|
||||
{
|
||||
if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
|
||||
const bool up = (data->EventKey == ImGuiKey_UpArrow);
|
||||
s_historyIndex = NavigateConsoleHistoryIndex(s_historyIndex, s_history.size(), up);
|
||||
const std::string entry = ConsoleHistoryEntry(s_history, s_historyIndex);
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
if (!entry.empty()) data->InsertChars(0, entry.c_str());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RenderLiteConsoleTab(App* app)
|
||||
{
|
||||
if (!app) return;
|
||||
wallet::LiteWalletController* lw = app->liteWallet();
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────────
|
||||
Type().text(TypeStyle::H6, TR("lite_console_title"));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_console_intro"));
|
||||
ImGui::Spacing();
|
||||
|
||||
// ── Live status (read straight from the controller — always meaningful) ───────
|
||||
{
|
||||
const char* connText;
|
||||
ImU32 connCol;
|
||||
if (!lw) {
|
||||
connText = "Lite backend not linked in this build (rebuild with --lite-backend)";
|
||||
connCol = Error();
|
||||
} else if (lw->walletOpen()) {
|
||||
connText = "Connected";
|
||||
connCol = Success();
|
||||
} else if (lw->openInProgress()) {
|
||||
connText = "Connecting\xE2\x80\xA6"; // ellipsis
|
||||
connCol = Warning();
|
||||
} else {
|
||||
connText = "Disconnected";
|
||||
connCol = Error();
|
||||
}
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("lite_console_status"));
|
||||
ImGui::SameLine();
|
||||
Type().textColored(TypeStyle::Body2, connCol, connText);
|
||||
|
||||
if (lw && lw->walletOpen()) {
|
||||
const SyncInfo& sync = app->state().sync;
|
||||
char buf[96];
|
||||
if (sync.syncing && !sync.isSynced()) {
|
||||
double vp = sync.verification_progress;
|
||||
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
||||
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
|
||||
vp * 100.0, sync.blocks, sync.headers);
|
||||
} else {
|
||||
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
|
||||
}
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
|
||||
}
|
||||
const std::string& openErr = app->liteOpenError();
|
||||
if (!openErr.empty() && (!lw || !lw->walletOpen())) {
|
||||
Type().textColored(TypeStyle::Caption, Error(),
|
||||
(std::string("Last error: ") + openErr).c_str());
|
||||
}
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
// ── Ingest new automatic diagnostics events into the unified buffer ───────────
|
||||
auto& diag = wallet::LiteDiagnostics::instance();
|
||||
const std::uint64_t gen = diag.generation();
|
||||
if (gen != s_diagGen) {
|
||||
const auto snap = diag.snapshot();
|
||||
// First pass dumps the whole ring; later passes append only the delta (generation counts
|
||||
// total events ever added; the ring is bounded, so cap the delta at the snapshot size).
|
||||
std::size_t startIdx = 0;
|
||||
if (s_diagGen != static_cast<std::uint64_t>(-1)) {
|
||||
const std::uint64_t added = gen - s_diagGen;
|
||||
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
|
||||
}
|
||||
for (std::size_t i = startIdx; i < snap.size(); ++i)
|
||||
s_lines.push_back({snap[i], logColor(snap[i])});
|
||||
if (gen != s_diagGen && !snap.empty()) s_scrollToBottom = true;
|
||||
s_diagGen = gen;
|
||||
}
|
||||
|
||||
// ── Drain a completed interactive command ────────────────────────────────────
|
||||
if (lw) {
|
||||
wallet::LiteConsoleResult res;
|
||||
if (lw->takeConsoleResult(res)) {
|
||||
appendLines(res.response.empty() ? "(no output)" : res.response,
|
||||
res.ok ? OnSurface() : Error());
|
||||
s_scrollToBottom = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toolbar ─────────────────────────────────────────────────────────────────
|
||||
if (ImGui::Button(TR("lite_console_clear"))) {
|
||||
s_lines.clear();
|
||||
s_diagGen = diag.generation(); // don't re-dump the whole ring on the next frame
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("lite_console_copy"))) {
|
||||
std::string all;
|
||||
for (const auto& l : s_lines) { all += l.text; all.push_back('\n'); }
|
||||
ImGui::SetClipboardText(all.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
ImGui::Checkbox(TR("lite_console_autoscroll"), &s_autoScroll);
|
||||
ImGui::Spacing();
|
||||
|
||||
// ── Output log (terminal-styled scroll region) ───────────────────────────────
|
||||
const float inputRowH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm();
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 90));
|
||||
ImGui::BeginChild("##LiteConsoleLog", ImVec2(0, -inputRowH), true,
|
||||
ImGuiWindowFlags_HorizontalScrollbar);
|
||||
ImGui::PushFont(Type().caption());
|
||||
if (s_lines.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceDisabled());
|
||||
ImGui::TextUnformatted(TR("lite_console_empty"));
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
for (const auto& line : s_lines) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, line.color);
|
||||
ImGui::TextUnformatted(line.text.c_str()); // not format-interpreted — safe for any content
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
}
|
||||
if (s_scrollToBottom && s_autoScroll) ImGui::SetScrollHereY(1.0f);
|
||||
else if (s_autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f);
|
||||
s_scrollToBottom = false;
|
||||
ImGui::PopFont();
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
// ── Command input row ────────────────────────────────────────────────────────
|
||||
const bool busy = lw && lw->consoleCommandInProgress();
|
||||
auto submit = [&]() {
|
||||
std::string cmd = s_input;
|
||||
// trim
|
||||
const std::size_t b = cmd.find_first_not_of(" \t");
|
||||
if (b == std::string::npos) { s_input[0] = '\0'; return; }
|
||||
cmd = cmd.substr(b);
|
||||
while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();
|
||||
|
||||
s_lines.push_back({"> " + cmd, Primary()});
|
||||
AppendConsoleHistory(s_history, cmd);
|
||||
s_historyIndex = -1;
|
||||
|
||||
const std::string first = lowerFirstToken(cmd);
|
||||
if (first == "clear" || first == "cls") {
|
||||
// The backend's `clear` wipes wallet tx history — NOT the screen. Intercept it as a
|
||||
// view-clear (what the user expects) so a stray "clear" can't destroy history.
|
||||
s_lines.clear();
|
||||
s_diagGen = diag.generation();
|
||||
} else if (first == "quit" || first == "exit") {
|
||||
appendLines(TR("lite_console_quit_note"), OnSurfaceMedium());
|
||||
} else if (!lw) {
|
||||
appendLines("Lite backend not linked in this build.", Error());
|
||||
} else if (!lw->runConsoleCommand(cmd)) {
|
||||
appendLines(TR("lite_console_busy"), Warning());
|
||||
}
|
||||
s_input[0] = '\0';
|
||||
s_scrollToBottom = true;
|
||||
s_focusInput = true; // keep focus for the next command
|
||||
};
|
||||
|
||||
ImGui::PushFont(Type().caption());
|
||||
Type().textColored(TypeStyle::Caption, busy ? Warning() : Primary(),
|
||||
busy ? TR("lite_console_running") : "\xE2\x80\xBA"); // ‹running…› / "›"
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
if (busy) ImGui::BeginDisabled();
|
||||
const ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
|
||||
ImGuiInputTextFlags_CallbackHistory;
|
||||
if (s_focusInput) { ImGui::SetKeyboardFocusHere(); s_focusInput = false; }
|
||||
if (ImGui::InputTextWithHint("##LiteConsoleInput", TR("lite_console_input_hint"),
|
||||
s_input, sizeof(s_input), flags, inputCallback)) {
|
||||
submit();
|
||||
}
|
||||
if (busy) ImGui::EndDisabled();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,18 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Lite-wallet "Console" tab: a read-only, terminal-styled view of the lite diagnostics log
|
||||
// (server connection attempts, wallet open/create/restore, sync milestones, errors). Lite
|
||||
// builds only. See src/ui/windows/lite_console_tab.cpp.
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
class App;
|
||||
namespace ui {
|
||||
|
||||
void RenderLiteConsoleTab(App* app);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "../../config/version.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../data/portfolio.h"
|
||||
#include "../../data/exchange_info.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
@@ -36,20 +37,21 @@ static double s_last_refresh_time = 0.0;
|
||||
// Exchange / pair selection
|
||||
static int s_exchange_idx = 0;
|
||||
static int s_pair_idx = 0;
|
||||
static float s_pair_scroll = 0.0f;
|
||||
static float s_pair_scroll_target = 0.0f;
|
||||
static bool s_pair_dragging = false;
|
||||
static float s_pair_drag_start_x = 0.0f;
|
||||
static float s_pair_drag_start_scroll = 0.0f;
|
||||
static bool s_market_state_loaded = false;
|
||||
|
||||
// The effective exchange registry: the live CoinGecko list when available, else the
|
||||
// compiled-in fallback.
|
||||
static const std::vector<data::ExchangeInfo>& EffectiveRegistry(const MarketInfo& market)
|
||||
{
|
||||
return market.exchanges.empty() ? data::getExchangeRegistry() : market.exchanges;
|
||||
}
|
||||
|
||||
// Helper: load selected exchange/pair from settings
|
||||
static void LoadMarketState(config::Settings* settings)
|
||||
static void LoadMarketState(config::Settings* settings, const std::vector<data::ExchangeInfo>& registry)
|
||||
{
|
||||
if (s_market_state_loaded || !settings) return;
|
||||
s_market_state_loaded = true;
|
||||
|
||||
const auto& registry = data::getExchangeRegistry();
|
||||
std::string savedExchange = settings->getSelectedExchange();
|
||||
std::string savedPair = settings->getSelectedPair();
|
||||
|
||||
@@ -88,6 +90,134 @@ static std::string FormatPrice(double price)
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// ---- Portfolio editor (modal) state ----
|
||||
static bool s_portfolio_editor_open = false;
|
||||
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
|
||||
static char s_pf_label[64] = {0};
|
||||
static std::vector<std::string> s_pf_addrs;
|
||||
|
||||
// Populate the working buffers for a new (index < 0) or existing entry.
|
||||
static void PortfolioBeginEdit(App* app, int index)
|
||||
{
|
||||
const auto& entries = app->settings()->getPortfolioEntries();
|
||||
s_pf_editing = index;
|
||||
if (index >= 0 && index < (int)entries.size()) {
|
||||
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
|
||||
s_pf_addrs = entries[index].addresses;
|
||||
} else {
|
||||
s_pf_label[0] = '\0';
|
||||
s_pf_addrs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
|
||||
// of wallet addresses. Persists to Settings on every mutation (like the address book).
|
||||
static void RenderPortfolioEditor(App* app)
|
||||
{
|
||||
if (!s_portfolio_editor_open) return;
|
||||
float dialogW = std::min(560.0f, ImGui::GetMainViewport()->Size.x * 0.9f);
|
||||
if (!material::BeginOverlayDialog(TR("portfolio_manage_title"), &s_portfolio_editor_open, dialogW)) {
|
||||
return;
|
||||
}
|
||||
|
||||
config::Settings* settings = app->settings();
|
||||
const auto& state = app->getWalletState();
|
||||
|
||||
auto persist = [&](const std::vector<config::Settings::PortfolioEntry>& entries) {
|
||||
settings->setPortfolioEntries(entries);
|
||||
settings->save();
|
||||
};
|
||||
|
||||
if (s_pf_editing == -1) {
|
||||
// ---- LIST MODE ----
|
||||
const auto& entries = settings->getPortfolioEntries();
|
||||
if (entries.empty()) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("portfolio_no_entries"));
|
||||
}
|
||||
for (int i = 0; i < (int)entries.size(); i++) {
|
||||
ImGui::PushID(i);
|
||||
double bal = data::SumPortfolioBalance(entries[i].addresses, state.addresses);
|
||||
char row[160];
|
||||
snprintf(row, sizeof(row), "%s \xE2\x80\x94 %.4f %s (%d)",
|
||||
entries[i].label.c_str(), bal, DRAGONX_TICKER, (int)entries[i].addresses.size());
|
||||
ImGui::TextUnformatted(row);
|
||||
ImGui::SameLine();
|
||||
float bw = 72.0f;
|
||||
float avail = ImGui::GetContentRegionAvail().x;
|
||||
if (avail > bw * 2 + Layout::spacingSm())
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - bw * 2 - Layout::spacingSm());
|
||||
if (ImGui::Button(TR("portfolio_edit"), ImVec2(bw, 0))) PortfolioBeginEdit(app, i);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("portfolio_delete"), ImVec2(bw, 0))) {
|
||||
auto e = entries; e.erase(e.begin() + i); persist(e);
|
||||
ImGui::PopID();
|
||||
break;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
if (ImGui::Button(TR("portfolio_add_entry"))) PortfolioBeginEdit(app, -2);
|
||||
} else {
|
||||
// ---- EDIT MODE ----
|
||||
ImGui::TextUnformatted(TR("portfolio_label"));
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Quick group selectors.
|
||||
if (ImGui::Button(TR("portfolio_all_shielded"))) {
|
||||
for (const auto& a : state.addresses)
|
||||
if (a.type == "shielded") data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("portfolio_all_transparent"))) {
|
||||
for (const auto& a : state.addresses)
|
||||
if (a.type == "transparent") data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("portfolio_clear_sel"))) s_pf_addrs.clear();
|
||||
|
||||
char selc[48];
|
||||
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Address checklist.
|
||||
ImGui::BeginChild("##pfAddrList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() * 1.5f), true);
|
||||
for (const auto& a : state.addresses) {
|
||||
bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address);
|
||||
char line[192];
|
||||
snprintf(line, sizeof(line), "%s (%.4f %s)", a.address.c_str(), a.balance, DRAGONX_TICKER);
|
||||
ImGui::PushID(a.address.c_str());
|
||||
if (ImGui::Checkbox(line, &inSet)) {
|
||||
if (inSet) data::PortfolioEntryAdd(s_pf_addrs, a.address);
|
||||
else data::PortfolioEntryRemove(s_pf_addrs, a.address);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
bool canSave = s_pf_label[0] != '\0';
|
||||
ImGui::BeginDisabled(!canSave);
|
||||
if (ImGui::Button(TR("portfolio_save"))) {
|
||||
auto entries = settings->getPortfolioEntries();
|
||||
config::Settings::PortfolioEntry e;
|
||||
e.label = s_pf_label;
|
||||
e.addresses = s_pf_addrs;
|
||||
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
|
||||
else entries.push_back(e);
|
||||
persist(entries);
|
||||
s_pf_editing = -1;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("portfolio_cancel"))) s_pf_editing = -1;
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
void RenderMarketTab(App* app)
|
||||
{
|
||||
auto& S = schema::UI();
|
||||
@@ -104,11 +234,14 @@ void RenderMarketTab(App* app)
|
||||
const auto& state = app->getWalletState();
|
||||
const auto& market = state.market;
|
||||
|
||||
// Load persisted exchange/pair on first frame
|
||||
LoadMarketState(app->settings());
|
||||
// Kick a once-per-session live exchange-list fetch (self-guarded), then source the
|
||||
// registry from it (falling back to the compiled-in list until it arrives).
|
||||
app->refreshExchanges();
|
||||
const auto& registry = EffectiveRegistry(market);
|
||||
|
||||
// Load persisted exchange/pair on first frame
|
||||
LoadMarketState(app->settings(), registry);
|
||||
|
||||
// Exchange registry
|
||||
const auto& registry = data::getExchangeRegistry();
|
||||
if (s_exchange_idx >= (int)registry.size()) s_exchange_idx = 0;
|
||||
const auto& currentExchange = registry[s_exchange_idx];
|
||||
if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0;
|
||||
@@ -143,7 +276,6 @@ void RenderMarketTab(App* app)
|
||||
+ ImGui::GetStyle().ItemSpacing.y * 2.0f;
|
||||
float mkGapOver = gap + ImGui::GetStyle().ItemSpacing.y;
|
||||
float mkOverhead = 3.0f * (mkSHdr + mkGapOver) + 2.0f * mkGapOver;
|
||||
float pairBarH = S.drawElement("tabs.market", "pair-bar-height").height;
|
||||
float mkCardBudget = std::max(200.0f, marketAvail.y - mkOverhead);
|
||||
|
||||
Layout::SectionBudget mb(mkCardBudget);
|
||||
@@ -159,7 +291,11 @@ void RenderMarketTab(App* app)
|
||||
float statsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
|
||||
float cardH = std::max(heroMinH + statsRowH + pad, (S.drawElement("tabs.market", "hero-card-height").size + statsRowH + pad) * vs);
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
// Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
|
||||
// chart below it. The chart block no longer draws its own panel or an inter-card gap.
|
||||
float mergedChartH = std::max(60.0f, chartElem.height * vs);
|
||||
ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
|
||||
DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);
|
||||
|
||||
float cx = cardMin.x + Layout::spacingLg();
|
||||
float cy = cardMin.y + Layout::spacingLg();
|
||||
@@ -198,20 +334,19 @@ void RenderMarketTab(App* app)
|
||||
ImVec2(cardMax.x - rnd * 0.5f, sepY),
|
||||
WithAlpha(OnSurface(), 15), 1.0f * dp);
|
||||
|
||||
// ---- STATS ROW (BTC Price | Volume | Market Cap) ----
|
||||
// ---- STATS ROW (24h Volume | Market Cap) ----
|
||||
// (BTC-price column + the "updated Ns ago" staleness line were dropped to
|
||||
// simplify; freshness is still shown by the attribution "Updated" line.)
|
||||
float statsY = sepY + Layout::spacingSm();
|
||||
float colW = (availWidth - Layout::spacingLg() * 2) / 3.0f;
|
||||
float colW = (availWidth - Layout::spacingLg() * 2) / 2.0f;
|
||||
|
||||
struct StatItem { const char* label; std::string value; ImU32 valueCol; };
|
||||
StatItem stats[3] = {
|
||||
{TR("market_btc_price"), "", OnSurface()},
|
||||
StatItem stats[2] = {
|
||||
{TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()},
|
||||
{TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()},
|
||||
};
|
||||
snprintf(buf, sizeof(buf), "%.10f", market.price_btc);
|
||||
stats[0].value = buf;
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
float sx = cardMin.x + Layout::spacingLg() + i * colW;
|
||||
float centerX = sx + colW * 0.5f;
|
||||
|
||||
@@ -227,26 +362,6 @@ void RenderMarketTab(App* app)
|
||||
ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str());
|
||||
}
|
||||
|
||||
// ---- STALENESS INDICATOR ----
|
||||
{
|
||||
auto fetchTime = market.last_fetch_time;
|
||||
if (fetchTime.time_since_epoch().count() > 0) {
|
||||
auto elapsed = std::chrono::steady_clock::now() - fetchTime;
|
||||
int ageSecs = (int)std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
||||
bool stale = ageSecs > 300; // 5 minutes
|
||||
if (ageSecs < 60)
|
||||
snprintf(buf, sizeof(buf), "%s %ds %s", stale ? ICON_MD_WARNING : "", ageSecs, TR("ago"));
|
||||
else
|
||||
snprintf(buf, sizeof(buf), "%s %dm %s", stale ? ICON_MD_WARNING : "", ageSecs / 60, TR("ago"));
|
||||
ImFont* staleFont = capFont;
|
||||
ImU32 staleCol = stale ? Warning() : WithAlpha(OnSurface(), 100);
|
||||
float staleY = statsY + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm();
|
||||
ImVec2 staleSz = staleFont->CalcTextSizeA(staleFont->LegacySize, FLT_MAX, 0, buf);
|
||||
dl->AddText(staleFont, staleFont->LegacySize,
|
||||
ImVec2(cardMin.x + Layout::spacingLg(), staleY), staleCol, buf);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TRADE BUTTON (top-right of card) ----
|
||||
if (!currentExchange.pairs.empty()) {
|
||||
const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str();
|
||||
@@ -312,13 +427,13 @@ void RenderMarketTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// No inter-card gap — the chart is drawn immediately below, inside the same panel.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PRICE CHART — Custom drawn inside glass panel (matches app design)
|
||||
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
|
||||
// ================================================================
|
||||
{
|
||||
// Plot the REAL accumulated price history (one point per refresh, oldest→newest).
|
||||
@@ -327,11 +442,11 @@ void RenderMarketTab(App* app)
|
||||
s_price_history = market.price_history;
|
||||
s_history_initialized = true;
|
||||
|
||||
// Chart height from schema
|
||||
// Chart height from schema. No glass panel here — it shares the combined hero+chart
|
||||
// card's panel drawn in PRICE SUMMARY above (mergedChartH uses this same height).
|
||||
float chartH = std::max(60.0f, chartElem.height * vs);
|
||||
ImVec2 chartMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
|
||||
DrawGlassPanel(dl, chartMin, chartMax, glassSpec);
|
||||
|
||||
if (!s_price_history.empty() && s_price_history.size() >= 2) {
|
||||
float chartPad = pad;
|
||||
@@ -513,250 +628,90 @@ void RenderMarketTab(App* app)
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// EXCHANGE SELECTOR — Combo dropdown + attribution
|
||||
// PAIR SELECTOR — every trading pair across all exchanges shown as
|
||||
// round buttons (flat; no exchange dropdown). Wraps to multiple rows.
|
||||
// ================================================================
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
|
||||
{
|
||||
float comboW = S.drawElement("tabs.market", "exchange-combo-width").size;
|
||||
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
||||
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
|
||||
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
|
||||
float innerGap = Layout::spacingSm();
|
||||
float sidePad = Layout::spacingMd();
|
||||
|
||||
ImGui::PushFont(body2);
|
||||
ImGui::PushItemWidth(comboW);
|
||||
if (ImGui::BeginCombo("##ExchangeCombo", currentExchange.name.c_str())) {
|
||||
for (int i = 0; i < (int)registry.size(); i++) {
|
||||
bool selected = (i == s_exchange_idx);
|
||||
if (ImGui::Selectable(registry[i].name.c_str(), selected)) {
|
||||
if (i != s_exchange_idx) {
|
||||
s_exchange_idx = i;
|
||||
s_pair_idx = 0;
|
||||
s_pair_scroll = 0.0f;
|
||||
s_pair_scroll_target = 0.0f;
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
float left = origin.x;
|
||||
float right = origin.x + availWidth;
|
||||
float cx = left;
|
||||
float cy = origin.y;
|
||||
int btnCounter = 0;
|
||||
|
||||
for (int ex = 0; ex < (int)registry.size(); ex++) {
|
||||
for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
|
||||
const std::string& pairName = registry[ex].pairs[pr].displayName;
|
||||
const std::string& exName = registry[ex].name;
|
||||
|
||||
float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
|
||||
float exW = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
|
||||
float cw = sidePad * 2.0f + pairW + innerGap + exW;
|
||||
|
||||
// Wrap to the next row when the pill would overflow (never on an empty row).
|
||||
if (cx + cw > right && cx > left) {
|
||||
cx = left;
|
||||
cy += chipH + chipSpacing;
|
||||
}
|
||||
|
||||
ImVec2 cMin(cx, cy);
|
||||
ImVec2 cMax(cx + cw, cy + chipH);
|
||||
bool selected = (ex == s_exchange_idx && pr == s_pair_idx);
|
||||
bool hov = material::IsRectHovered(cMin, cMax);
|
||||
ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
|
||||
: (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
|
||||
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
|
||||
ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
|
||||
ImU32 exCol = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();
|
||||
|
||||
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
|
||||
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
|
||||
|
||||
float tx = cx + sidePad;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
|
||||
exCol, exName.c_str());
|
||||
|
||||
ImGui::SetCursorScreenPos(cMin);
|
||||
snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
|
||||
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
|
||||
if (ex != s_exchange_idx || pr != s_pair_idx) {
|
||||
s_exchange_idx = ex;
|
||||
s_pair_idx = pr;
|
||||
s_history_initialized = false;
|
||||
app->settings()->setSelectedExchange(registry[i].name);
|
||||
if (!registry[i].pairs.empty())
|
||||
app->settings()->setSelectedPair(registry[i].pairs[0].displayName);
|
||||
app->settings()->setSelectedExchange(exName);
|
||||
app->settings()->setSelectedPair(pairName);
|
||||
app->settings()->save();
|
||||
app->refreshMarketData();
|
||||
}
|
||||
}
|
||||
if (selected) ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::PopItemWidth();
|
||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Attribution
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
cx += cw + chipSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the cursor past the wrapped rows.
|
||||
ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
|
||||
// Attribution (moved here from the removed exchange-selector row).
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
|
||||
|
||||
if (!market.last_updated.empty()) {
|
||||
ImGui::SameLine(0, 12);
|
||||
snprintf(buf, sizeof(buf), " \xc2\xb7 Updated %s", market.last_updated.c_str());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
ImGui::PopFont();
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// PAIR BAR — Horizontally scrolling chip selector (always visible)
|
||||
// ================================================================
|
||||
{
|
||||
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
||||
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
|
||||
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
|
||||
float fadeW = S.drawElement("tabs.market", "pair-bar-fade-width").size;
|
||||
float arrowSz = S.drawElement("tabs.market", "pair-bar-arrow-size").size;
|
||||
|
||||
ImVec2 barOrigin = ImGui::GetCursorScreenPos();
|
||||
float barW = availWidth;
|
||||
float barH = pairBarH;
|
||||
|
||||
// Compute total content width of all chips
|
||||
float totalChipW = 0.0f;
|
||||
std::vector<float> chipWidths;
|
||||
for (const auto& pair : currentExchange.pairs) {
|
||||
float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pair.displayName.c_str()).x;
|
||||
float cw = tw + Layout::spacingLg() * 2.0f;
|
||||
chipWidths.push_back(cw);
|
||||
totalChipW += cw + chipSpacing;
|
||||
}
|
||||
totalChipW -= chipSpacing; // no trailing spacing
|
||||
|
||||
float scrollableW = barW - arrowSz * 2.0f - Layout::spacingSm() * 2.0f;
|
||||
float maxScroll = std::max(0.0f, totalChipW - scrollableW);
|
||||
|
||||
// Smooth scroll lerp
|
||||
s_pair_scroll += (s_pair_scroll_target - s_pair_scroll) * 0.15f;
|
||||
if (std::abs(s_pair_scroll - s_pair_scroll_target) < 0.5f)
|
||||
s_pair_scroll = s_pair_scroll_target;
|
||||
|
||||
// Clamp
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
s_pair_scroll = std::clamp(s_pair_scroll, 0.0f, maxScroll);
|
||||
|
||||
// Left arrow button
|
||||
float arrowY = barOrigin.y + (barH - arrowSz) * 0.5f;
|
||||
bool canScrollLeft = s_pair_scroll_target > 0.01f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, arrowY));
|
||||
ImGui::BeginDisabled(!canScrollLeft);
|
||||
ImGui::PushFont(material::Typography::instance().iconSmall());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##PairLeft", ImVec2(arrowSz, arrowSz))) {
|
||||
// Scroll left by average chip width
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target -= avgChipW + chipSpacing;
|
||||
if (s_pair_scroll_target < 0) s_pair_scroll_target = 0;
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
// Chip area with clipping
|
||||
float chipAreaLeft = barOrigin.x + arrowSz + Layout::spacingSm();
|
||||
float chipAreaRight = barOrigin.x + barW - arrowSz - Layout::spacingSm();
|
||||
float chipY = barOrigin.y + (barH - chipH) * 0.5f;
|
||||
|
||||
dl->PushClipRect(ImVec2(chipAreaLeft, barOrigin.y),
|
||||
ImVec2(chipAreaRight, barOrigin.y + barH), true);
|
||||
|
||||
// Render chips
|
||||
float cx = chipAreaLeft - s_pair_scroll;
|
||||
bool anyClicked = false;
|
||||
for (int i = 0; i < (int)currentExchange.pairs.size(); i++) {
|
||||
float cw = chipWidths[i];
|
||||
ImVec2 cMin(cx, chipY);
|
||||
ImVec2 cMax(cx + cw, chipY + chipH);
|
||||
|
||||
bool selected = (i == s_pair_idx);
|
||||
ImU32 chipBg = selected ? WithAlpha(Primary(), 200) : WithAlpha(OnSurface(), 20);
|
||||
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
|
||||
ImU32 chipText = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
|
||||
|
||||
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
|
||||
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
|
||||
|
||||
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0,
|
||||
currentExchange.pairs[i].displayName.c_str());
|
||||
float textX = cx + (cw - textSz.x) * 0.5f;
|
||||
float textY = chipY + (chipH - textSz.y) * 0.5f;
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, textY), chipText,
|
||||
currentExchange.pairs[i].displayName.c_str());
|
||||
|
||||
// Click detection via invisible button
|
||||
ImGui::SetCursorScreenPos(cMin);
|
||||
snprintf(buf, sizeof(buf), "##PairChip%d", i);
|
||||
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
|
||||
if (!s_pair_dragging || std::abs(ImGui::GetIO().MousePos.x - s_pair_drag_start_x) < 4.0f) {
|
||||
s_pair_idx = i;
|
||||
anyClicked = true;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[i].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
}
|
||||
}
|
||||
|
||||
cx += cw + chipSpacing;
|
||||
}
|
||||
|
||||
dl->PopClipRect();
|
||||
|
||||
// Fade overlays on edges
|
||||
ImU32 bgCol = IM_COL32(0, 0, 0, 0);
|
||||
ImU32 surfaceCol = Surface();
|
||||
if (s_pair_scroll > 1.0f) {
|
||||
// Left fade
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(chipAreaLeft, barOrigin.y), ImVec2(chipAreaLeft + fadeW, barOrigin.y + barH),
|
||||
surfaceCol, bgCol, bgCol, surfaceCol);
|
||||
}
|
||||
if (s_pair_scroll < maxScroll - 1.0f) {
|
||||
// Right fade
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(chipAreaRight - fadeW, barOrigin.y), ImVec2(chipAreaRight, barOrigin.y + barH),
|
||||
bgCol, surfaceCol, surfaceCol, bgCol);
|
||||
}
|
||||
|
||||
// Right arrow button
|
||||
bool canScrollRight = s_pair_scroll_target < maxScroll - 0.01f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x + barW - arrowSz, arrowY));
|
||||
ImGui::BeginDisabled(!canScrollRight);
|
||||
ImGui::PushFont(material::Typography::instance().iconSmall());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##PairRight", ImVec2(arrowSz, arrowSz))) {
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target += avgChipW + chipSpacing;
|
||||
if (s_pair_scroll_target > maxScroll) s_pair_scroll_target = maxScroll;
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
// Mouse wheel horizontal scroll when hovering pair bar
|
||||
ImVec2 mPos = ImGui::GetIO().MousePos;
|
||||
if (mPos.x >= barOrigin.x && mPos.x <= barOrigin.x + barW &&
|
||||
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
|
||||
float wheel = ImGui::GetIO().MouseWheel;
|
||||
if (wheel != 0.0f) {
|
||||
float avgChipW = totalChipW / currentExchange.pairs.size();
|
||||
s_pair_scroll_target -= wheel * (avgChipW + chipSpacing);
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse drag scrolling
|
||||
if (ImGui::IsMouseClicked(0) && mPos.x >= chipAreaLeft && mPos.x <= chipAreaRight &&
|
||||
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
|
||||
s_pair_dragging = true;
|
||||
s_pair_drag_start_x = mPos.x;
|
||||
s_pair_drag_start_scroll = s_pair_scroll_target;
|
||||
}
|
||||
if (s_pair_dragging) {
|
||||
if (ImGui::IsMouseDown(0)) {
|
||||
float dx = mPos.x - s_pair_drag_start_x;
|
||||
s_pair_scroll_target = std::clamp(s_pair_drag_start_scroll - dx, 0.0f, maxScroll);
|
||||
} else {
|
||||
s_pair_dragging = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) {
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow) && s_pair_idx > 0) {
|
||||
s_pair_idx--;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
anyClicked = true;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow) && s_pair_idx < (int)currentExchange.pairs.size() - 1) {
|
||||
s_pair_idx++;
|
||||
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
|
||||
app->settings()->save();
|
||||
s_history_initialized = false;
|
||||
app->refreshMarketData();
|
||||
anyClicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to keep selected chip visible
|
||||
if (anyClicked) {
|
||||
float chipLeft = 0;
|
||||
for (int i = 0; i < s_pair_idx; i++)
|
||||
chipLeft += chipWidths[i] + chipSpacing;
|
||||
float chipRight = chipLeft + chipWidths[s_pair_idx];
|
||||
if (chipLeft < s_pair_scroll_target)
|
||||
s_pair_scroll_target = chipLeft - chipSpacing;
|
||||
if (chipRight > s_pair_scroll_target + scrollableW)
|
||||
s_pair_scroll_target = chipRight - scrollableW + chipSpacing;
|
||||
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
|
||||
}
|
||||
|
||||
// Advance cursor past the bar
|
||||
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, barOrigin.y + barH));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
@@ -765,14 +720,31 @@ void RenderMarketTab(App* app)
|
||||
// ================================================================
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
|
||||
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
|
||||
{
|
||||
const char* ml = TR("portfolio_manage");
|
||||
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
|
||||
ImGui::SameLine();
|
||||
float availX = ImGui::GetContentRegionAvail().x;
|
||||
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
|
||||
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) s_portfolio_editor_open = true;
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
double total_balance = state.totalBalance;
|
||||
double private_balance = state.privateBalance;
|
||||
double transparent_balance = state.transparentBalance;
|
||||
|
||||
// Custom portfolio entries render below the "all funds" summary; grow the card to fit.
|
||||
const auto& pfEntries = app->settings()->getPortfolioEntries();
|
||||
float entryRowH = body2->LegacySize + Layout::spacingSm();
|
||||
float entriesBlockH = pfEntries.empty()
|
||||
? 0.0f
|
||||
: (Layout::spacingSm() * 2.0f + (float)pfEntries.size() * entryRowH);
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
float cardH = std::max(50.0f, portfolioBudgetH);
|
||||
float baseH = std::max(60.0f, portfolioBudgetH);
|
||||
float cardH = baseH + entriesBlockH;
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
@@ -787,6 +759,17 @@ void RenderMarketTab(App* app)
|
||||
snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
|
||||
|
||||
// 24h change on the holdings (the coin's 24h % applies to the value), shown
|
||||
// beside the USD figure and colored by direction.
|
||||
if (market.change_24h != 0.0) {
|
||||
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
|
||||
char chg[48];
|
||||
snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
|
||||
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx + usdW + Layout::spacingMd(), cy + 3), chgCol, chg);
|
||||
}
|
||||
|
||||
double portfolio_btc = total_balance * market.price_btc;
|
||||
snprintf(buf, sizeof(buf), "%.10f BTC", portfolio_btc);
|
||||
float valW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
|
||||
@@ -839,13 +822,36 @@ void RenderMarketTab(App* app)
|
||||
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
float actualCardH = (total_balance > 0) ? std::max(60.0f, portfolioBudgetH) : cardH;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + actualCardH));
|
||||
// ---- CUSTOM PORTFOLIO ENTRIES (below the all-funds summary) ----
|
||||
if (!pfEntries.empty()) {
|
||||
float ey = cardMin.y + baseH - Layout::spacingSm();
|
||||
dl->AddLine(ImVec2(cardMin.x + glassSpec.rounding * 0.5f, ey),
|
||||
ImVec2(cardMax.x - glassSpec.rounding * 0.5f, ey), WithAlpha(OnSurface(), 20), 1.0f);
|
||||
ey += Layout::spacingSm();
|
||||
for (const auto& e : pfEntries) {
|
||||
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(cx, ey), OnSurface(), e.label.c_str());
|
||||
char amt[96];
|
||||
if (market.price_usd > 0)
|
||||
snprintf(amt, sizeof(amt), "%.4f %s ($%.2f)", bal, DRAGONX_TICKER, bal * market.price_usd);
|
||||
else
|
||||
snprintf(amt, sizeof(amt), "%.4f %s", bal, DRAGONX_TICKER);
|
||||
float amtW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, amt).x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMax.x - amtW - pad, ey + 1), OnSurfaceMedium(), amt);
|
||||
ey += entryRowH;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + cardH));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
}
|
||||
|
||||
ImGui::EndChild(); // ##MarketScroll
|
||||
|
||||
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
|
||||
RenderPortfolioEditor(app);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -528,7 +528,18 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
|
||||
// --- Miner update: "Update <latest>" button + current version (left of benchmark) ---
|
||||
if (s_pool_mode && !benchRunning) {
|
||||
const std::string curVer = app->settings()->getXmrigVersion();
|
||||
// "Current" prefers the live version reported by the running miner (so a
|
||||
// bundled miner with no persisted release tag still shows a version).
|
||||
// Remembered for the session so it persists after the miner stops; falls
|
||||
// back to the persisted release tag (set by the updater), else "none".
|
||||
static std::string s_live_miner_ver;
|
||||
if (!state.pool_mining.version.empty())
|
||||
s_live_miner_ver = state.pool_mining.version;
|
||||
// Live running version → detected installed version (before mining) →
|
||||
// persisted release tag (updater) → "none".
|
||||
std::string curVer = s_live_miner_ver;
|
||||
if (curVer.empty()) curVer = app->poolMiningInstalledVersion();
|
||||
if (curVer.empty()) curVer = app->settings()->getXmrigVersion();
|
||||
char xbtn[64];
|
||||
if (!s_xmrig_latest_tag.empty())
|
||||
snprintf(xbtn, sizeof(xbtn), "%s %s", TR("xmrig_update_short"), s_xmrig_latest_tag.c_str());
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "../../config/version.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/pool_registry.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
@@ -148,6 +149,18 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
float inputsStartX = tMax.x + Layout::spacingLg();
|
||||
ImGui::SetCursorScreenPos(ImVec2(inputsStartX, tMin.y + vertOff));
|
||||
|
||||
// Manual vs Auto-balance pool selection. In Auto mode the wallet picks the
|
||||
// pool by hashrate (App::updatePoolAutoBalance), so the URL group is shown
|
||||
// disabled and mirrors the auto-selected pool.
|
||||
const bool autoMode = app->settings()->getPoolSelectMode()
|
||||
== config::Settings::PoolSelectMode::AutoBalance;
|
||||
if (autoMode) {
|
||||
const std::string cur = app->settings()->getPoolUrl();
|
||||
if (cur != s_pool_url) {
|
||||
strncpy(s_pool_url, cur.c_str(), sizeof(s_pool_url) - 1);
|
||||
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8 * dp, 4 * dp));
|
||||
|
||||
@@ -168,7 +181,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
float urlGroupStartY = ImGui::GetCursorScreenPos().y;
|
||||
float urlGroupW = urlW + perGroupExtra;
|
||||
|
||||
// === Pool URL input ===
|
||||
// === Pool URL input === (disabled in Auto mode — the wallet picks the pool)
|
||||
if (autoMode) ImGui::BeginDisabled();
|
||||
ImGui::SetNextItemWidth(urlW);
|
||||
if (ImGui::InputTextWithHint("##PoolURL", TR("mining_pool_url"), s_pool_url, sizeof(s_pool_url))) {
|
||||
s_pool_settings_dirty = true;
|
||||
@@ -240,6 +254,45 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 2 * dp));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||
if (ImGui::BeginPopup("##SavedPoolsPopup")) {
|
||||
// Suggested (official) pools — one click to select, shown above the
|
||||
// user's saved favorites.
|
||||
{
|
||||
const float sPopupInnerW = ImGui::GetContentRegionAvail().x;
|
||||
const float sRowH = ImGui::GetFrameHeight();
|
||||
const float sTextPadX = 8 * dp;
|
||||
ImGui::SetCursorPosX(sTextPadX);
|
||||
ImGui::PushFont(Type().caption());
|
||||
ImGui::TextDisabled("%s", TR("mining_suggested_pools"));
|
||||
ImGui::PopFont();
|
||||
ImFont* sRowFont = ImGui::GetFont();
|
||||
const float sRowFontSz = ImGui::GetFontSize();
|
||||
for (const auto& kp : util::knownPools()) {
|
||||
ImGui::PushID(kp.id.c_str());
|
||||
ImVec2 rowMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rowMax(rowMin.x + sPopupInnerW, rowMin.y + sRowH);
|
||||
ImGui::InvisibleButton("##sugg", ImVec2(sPopupInnerW, sRowH));
|
||||
bool rowHov = ImGui::IsItemHovered();
|
||||
bool rowClk = ImGui::IsItemClicked();
|
||||
bool isCurrent = (std::string(s_pool_url) == kp.stratum);
|
||||
ImDrawList* pdl = ImGui::GetWindowDrawList();
|
||||
if (rowHov) pdl->AddRectFilled(rowMin, rowMax, StateHover());
|
||||
float textY = rowMin.y + (sRowH - sRowFontSz) * 0.5f;
|
||||
pdl->AddText(sRowFont, sRowFontSz, ImVec2(rowMin.x + sTextPadX, textY),
|
||||
isCurrent ? Primary() : OnSurface(), kp.stratum.c_str());
|
||||
if (rowHov) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", kp.label.c_str());
|
||||
}
|
||||
if (rowClk) {
|
||||
strncpy(s_pool_url, kp.stratum.c_str(), sizeof(s_pool_url) - 1);
|
||||
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::Separator();
|
||||
}
|
||||
const auto& savedUrls = app->settings()->getSavedPoolUrls();
|
||||
if (savedUrls.empty()) {
|
||||
ImGui::SetCursorPosX(8 * dp);
|
||||
@@ -338,6 +391,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::PopStyleVar(3); // WindowRounding, WindowPadding, ItemSpacing for URL popup
|
||||
if (autoMode) ImGui::EndDisabled();
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
float wrkGroupStartX = ImGui::GetCursorScreenPos().x;
|
||||
float wrkGroupStartY = ImGui::GetCursorScreenPos().y;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
#include "mining_stats.h"
|
||||
#include "mining_tab_helpers.h" // FormatHashrate, EstimateHoursToBlock
|
||||
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/pool_registry.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -17,6 +20,7 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -29,164 +33,464 @@ using namespace material;
|
||||
static bool s_show_pool_log = false;
|
||||
static bool s_show_solo_log = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sparkline chart of a hashrate history, drawn inside [regionMin, regionMax]
|
||||
// (the plot area within a card, already inset by padding). Extracted so both
|
||||
// the solo full-width card and the pool split card share one implementation.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void DrawHashrateSparkline(ImDrawList* dl, ImVec2 regionMin, ImVec2 regionMax,
|
||||
const std::vector<double>& chartHistory,
|
||||
ImFont* capFont, float dp)
|
||||
{
|
||||
if (chartHistory.size() < 2) return;
|
||||
|
||||
const float chartBot = regionMax.y;
|
||||
double yMin = *std::min_element(chartHistory.begin(), chartHistory.end());
|
||||
double yMax = *std::max_element(chartHistory.begin(), chartHistory.end());
|
||||
if (yMax <= yMin) { yMax = yMin + 1.0; }
|
||||
double yRange = yMax - yMin;
|
||||
double yPad2 = yRange * 0.1;
|
||||
yMin -= yPad2;
|
||||
yMax += yPad2;
|
||||
|
||||
const float plotLeft = regionMin.x;
|
||||
const float plotRight = regionMax.x;
|
||||
const float plotTop = regionMin.y + capFont->LegacySize + 4 * dp;
|
||||
const float plotBottom = chartBot - capFont->LegacySize * 2 - 16 * dp;
|
||||
const float plotW = plotRight - plotLeft;
|
||||
const float plotH = std::max(1.0f, plotBottom - plotTop);
|
||||
|
||||
size_t n = chartHistory.size();
|
||||
float stepW = (n > 1) ? plotW / (float)(n - 1) : plotW;
|
||||
|
||||
std::vector<ImVec2> rawPts(n);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float x = plotLeft + (float)i * stepW;
|
||||
float y = plotBottom - (float)((chartHistory[i] - yMin) / (yMax - yMin)) * plotH;
|
||||
rawPts[i] = ImVec2(x, y);
|
||||
}
|
||||
|
||||
// Catmull-Rom spline interpolation for a smooth curve (adaptive subdivision).
|
||||
std::vector<ImVec2> points;
|
||||
if (n <= 2) {
|
||||
points = rawPts;
|
||||
} else {
|
||||
points.reserve(n * 4);
|
||||
for (size_t i = 0; i + 1 < n; i++) {
|
||||
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];
|
||||
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;
|
||||
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);
|
||||
sy = std::clamp(sy, plotTop, plotBottom);
|
||||
points.push_back(ImVec2(sx, sy));
|
||||
}
|
||||
}
|
||||
points.push_back(rawPts[n - 1]);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
dl->AddPolyline(points.data(), (int)points.size(), WithAlpha(Success(), 200),
|
||||
ImDrawFlags_None,
|
||||
schema::UI().drawElement("tabs.mining", "chart-line-thickness").size);
|
||||
|
||||
// Y-axis labels
|
||||
std::string yMaxStr = FormatHashrate(yMax);
|
||||
std::string yMinStr = FormatHashrate(yMin);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotTop - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(), yMaxStr.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotBottom + 4 * dp), OnSurfaceDisabled(), yMinStr.c_str());
|
||||
|
||||
// X-axis labels
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(plotLeft, chartBot - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(),
|
||||
chartHistory.size() >= 300 ? TR("mining_chart_5m_ago") :
|
||||
chartHistory.size() >= 60 ? TR("mining_chart_1m_ago") : TR("mining_chart_start"));
|
||||
std::string nowLbl = TR("mining_chart_now");
|
||||
ImVec2 nowSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, nowLbl.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(plotRight - nowSz.x, chartBot - capFont->LegacySize - 2 * dp),
|
||||
OnSurfaceDisabled(), nowLbl.c_str());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only, selectable/copyable log view filling [regionMin, size].
|
||||
// ---------------------------------------------------------------------------
|
||||
static void DrawMiningLogView(ImVec2 regionMin, float regionW, float regionH,
|
||||
const std::vector<std::string>& logLines, const char* inputId)
|
||||
{
|
||||
static std::string s_log_buf;
|
||||
s_log_buf.clear();
|
||||
for (const auto& line : logLines)
|
||||
if (!line.empty()) { s_log_buf += line; s_log_buf += '\n'; }
|
||||
|
||||
ImGui::SetCursorScreenPos(regionMin);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushFont(Type().body2());
|
||||
ImGui::InputTextMultiline(inputId, const_cast<char*>(s_log_buf.c_str()), s_log_buf.size() + 1,
|
||||
ImVec2(regionW, regionH), ImGuiInputTextFlags_ReadOnly);
|
||||
ImGui::PopFont();
|
||||
ImGui::PopStyleColor(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The left pool card (pool mode only): Manual/Auto toggle, a metric stack, and
|
||||
// a fixed-height scrolling pool list. Pool rows are clickable to select in
|
||||
// Manual mode (routed through the parent's dirty flag → save + restart) and
|
||||
// show a "mining here" dot in Auto mode. The list scrolls, so any number of
|
||||
// pools keeps the card — and everything below it — at a constant height.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* dl,
|
||||
ImFont* capFont, ImFont* sub1, ImFont* ovFont,
|
||||
float dp, float gap, float pad,
|
||||
ImVec2 cardMin, ImVec2 cardMax,
|
||||
char (&s_pool_url)[256], bool& s_pool_settings_dirty)
|
||||
{
|
||||
const bool autoMode = app->settings()->getPoolSelectMode()
|
||||
== config::Settings::PoolSelectMode::AutoBalance;
|
||||
|
||||
const float x = cardMin.x + pad;
|
||||
const float w = (cardMax.x - cardMin.x) - pad * 2;
|
||||
float y = cardMin.y + pad * 0.75f;
|
||||
|
||||
const float toggleH = sub1->LegacySize + 10 * dp;
|
||||
const float rowH = sub1->LegacySize + 7 * dp;
|
||||
|
||||
// --- Manual | Auto-balance toggle ---
|
||||
{
|
||||
ImFont* segFont = Type().caption();
|
||||
const char* segA = TR("mining_select_manual");
|
||||
const char* segB = TR("mining_select_auto");
|
||||
const float rnd = 4 * dp;
|
||||
const float half = w * 0.5f;
|
||||
const ImVec2 aMin(x, y), aMax(x + half, y + toggleH);
|
||||
const ImVec2 bMin(x + half, y), bMax(x + w, y + toggleH);
|
||||
dl->AddRectFilled(aMin, bMax, WithAlpha(OnSurface(), 15), rnd);
|
||||
dl->AddRect(aMin, bMax, WithAlpha(OnSurface(), 40), rnd);
|
||||
auto seg = [&](const ImVec2& mn, const ImVec2& mx, const char* label, bool selected) {
|
||||
if (selected) dl->AddRectFilled(mn, mx, WithAlpha(Primary(), 180), rnd);
|
||||
else if (material::IsRectHovered(mn, mx)) dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 20), rnd);
|
||||
ImVec2 sz = segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, label);
|
||||
dl->AddText(segFont, segFont->LegacySize,
|
||||
ImVec2(mn.x + (mx.x - mn.x - sz.x) * 0.5f, mn.y + (mx.y - mn.y - sz.y) * 0.5f),
|
||||
selected ? IM_COL32(255, 255, 255, 230) : OnSurfaceMedium(), label);
|
||||
};
|
||||
seg(aMin, aMax, segA, !autoMode);
|
||||
seg(bMin, bMax, segB, autoMode);
|
||||
ImGui::SetCursorScreenPos(aMin);
|
||||
ImGui::InvisibleButton("##CardSelManual", ImVec2(half, toggleH));
|
||||
if (ImGui::IsItemClicked() && autoMode) {
|
||||
app->settings()->setPoolSelectMode(config::Settings::PoolSelectMode::Manual);
|
||||
app->settings()->save();
|
||||
}
|
||||
if (material::IsRectHovered(aMin, aMax)) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("mining_manual_desc"));
|
||||
}
|
||||
ImGui::SetCursorScreenPos(bMin);
|
||||
ImGui::InvisibleButton("##CardSelAuto", ImVec2(w - half, toggleH));
|
||||
if (ImGui::IsItemClicked() && !autoMode) {
|
||||
app->settings()->setPoolSelectMode(config::Settings::PoolSelectMode::AutoBalance);
|
||||
app->settings()->save();
|
||||
app->requestPoolBalanceRefresh();
|
||||
}
|
||||
if (material::IsRectHovered(bMin, bMax)) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("mining_auto_balance_desc"));
|
||||
}
|
||||
}
|
||||
y += toggleH + gap;
|
||||
|
||||
// --- Metric stack (label left, value right) ---
|
||||
auto metricRow = [&](const char* label, const std::string& val, ImU32 valCol) {
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(x, y + (rowH - ovFont->LegacySize) * 0.5f), OnSurfaceMedium(), label);
|
||||
ImVec2 vSz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, val.c_str());
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(x + w - vSz.x, y + (rowH - sub1->LegacySize) * 0.5f), valCol, val.c_str());
|
||||
y += rowH;
|
||||
};
|
||||
|
||||
const auto& pm = state.pool_mining;
|
||||
metricRow(TR("mining_local_hashrate"), FormatHashrate(pm.hashrate_10s),
|
||||
pm.xmrig_running ? Success() : OnSurfaceDisabled());
|
||||
metricRow(TR("mining_pool_hashrate"), FormatHashrate(pm.pool_hashrate),
|
||||
pm.pool_hashrate > 0 ? OnSurface() : OnSurfaceDisabled());
|
||||
{
|
||||
char sharesBuf[64];
|
||||
snprintf(sharesBuf, sizeof(sharesBuf), "%lld / %lld",
|
||||
(long long)pm.accepted, (long long)pm.rejected);
|
||||
metricRow(TR("mining_shares"), sharesBuf, OnSurface());
|
||||
}
|
||||
{
|
||||
char uptBuf[64];
|
||||
int64_t up = pm.uptime_sec;
|
||||
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "N/A");
|
||||
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
|
||||
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
|
||||
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
|
||||
}
|
||||
y += gap * 0.5f;
|
||||
|
||||
// --- POOLS (N) header + Refresh ---
|
||||
{
|
||||
char hdr[48];
|
||||
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
||||
(int)util::knownPools().size());
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
||||
|
||||
ImFont* icoFont = Type().iconSmall();
|
||||
const char* icon = ICON_MD_REFRESH;
|
||||
ImVec2 iSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, icon);
|
||||
float btnS = ovFont->LegacySize + 6 * dp;
|
||||
ImVec2 rbMin(x + w - btnS, y - 2 * dp);
|
||||
ImVec2 rbMax(x + w, y - 2 * dp + btnS);
|
||||
ImGui::SetCursorScreenPos(rbMin);
|
||||
ImGui::InvisibleButton("##PoolRefresh", ImVec2(btnS, btnS));
|
||||
bool rhov = ImGui::IsItemHovered();
|
||||
if (rhov) {
|
||||
dl->AddRectFilled(rbMin, rbMax, StateHover(), 3 * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("mining_refresh"));
|
||||
}
|
||||
dl->AddText(icoFont, icoFont->LegacySize,
|
||||
ImVec2((rbMin.x + rbMax.x - iSz.x) * 0.5f, (rbMin.y + rbMax.y - iSz.y) * 0.5f),
|
||||
OnSurfaceMedium(), icon);
|
||||
if (ImGui::IsItemClicked()) app->requestPoolBalanceRefresh();
|
||||
}
|
||||
y += ovFont->LegacySize + 4 * dp;
|
||||
|
||||
// --- Scrollable pool list (fixed height → constant card height for any N) ---
|
||||
const float listH = std::max(rowH, cardMax.y - y - pad * 0.75f);
|
||||
ImGui::SetCursorScreenPos(ImVec2(x, y));
|
||||
ImGui::BeginChild("##PoolList", ImVec2(w, listH), false, ImGuiWindowFlags_NoBackground);
|
||||
{
|
||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||
const auto snap = app->poolStatsSnapshot();
|
||||
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl());
|
||||
const float childW = ImGui::GetContentRegionAvail().x;
|
||||
const float listRowH = capFont->LegacySize + 10 * dp;
|
||||
|
||||
for (const auto& kp : util::knownPools()) {
|
||||
ImGui::PushID(kp.id.c_str());
|
||||
const bool isCurrent = current && current->id == kp.id;
|
||||
const auto it = snap.byId.find(kp.id);
|
||||
const bool haveHr = (it != snap.byId.end() && it->second.ok);
|
||||
|
||||
ImVec2 rMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rMax(rMin.x + childW, rMin.y + listRowH);
|
||||
|
||||
bool hov = false, clk = false;
|
||||
if (!autoMode) {
|
||||
ImGui::InvisibleButton("##prow", ImVec2(childW, listRowH));
|
||||
hov = ImGui::IsItemHovered();
|
||||
clk = ImGui::IsItemClicked();
|
||||
} else {
|
||||
ImGui::Dummy(ImVec2(childW, listRowH));
|
||||
}
|
||||
|
||||
if (isCurrent) cdl->AddRectFilled(rMin, rMax, WithAlpha(Primary(), 30), 3 * dp);
|
||||
else if (hov) cdl->AddRectFilled(rMin, rMax, StateHover(), 3 * dp);
|
||||
|
||||
const float cy = rMin.y + listRowH * 0.5f;
|
||||
cdl->AddCircleFilled(ImVec2(rMin.x + 6 * dp, cy), 3.0f * dp,
|
||||
isCurrent ? Success() : OnSurfaceDisabled());
|
||||
|
||||
const float textY = rMin.y + (listRowH - capFont->LegacySize) * 0.5f;
|
||||
// Show the short host label (the narrow card can't fit host:port); the
|
||||
// full stratum URL is in the hover tooltip.
|
||||
cdl->AddText(capFont, capFont->LegacySize, ImVec2(rMin.x + 16 * dp, textY),
|
||||
isCurrent ? Primary() : OnSurface(), kp.label.c_str());
|
||||
|
||||
char right[64];
|
||||
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
||||
snprintf(right, sizeof(right), "%s %.0f%%", hrStr.c_str(), kp.feePercent);
|
||||
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
||||
cdl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
||||
|
||||
if (!autoMode && hov) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", kp.stratum.c_str());
|
||||
}
|
||||
if (clk) {
|
||||
strncpy(s_pool_url, kp.stratum.c_str(), sizeof(s_pool_url) - 1);
|
||||
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
ImDrawList* dl, ImFont* capFont, ImFont* sub1, ImFont* ovFont,
|
||||
float dp, float vs, float gap, float pad, float availWidth,
|
||||
const GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode)
|
||||
const GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode,
|
||||
App* app, char (&s_pool_url)[256], bool& s_pool_settings_dirty)
|
||||
{
|
||||
const bool s_pool_mode = poolMode; // alias: keep the moved body's identifier verbatim
|
||||
(void)vs;
|
||||
const bool s_pool_mode = poolMode;
|
||||
ImU32 greenCol = Success();
|
||||
|
||||
// Determine view mode first
|
||||
bool showLogView = s_pool_mode
|
||||
? (s_show_pool_log && !state.pool_mining.log_lines.empty())
|
||||
: (s_show_solo_log && !mining.log_lines.empty());
|
||||
bool hasLogContent = s_pool_mode
|
||||
? !state.pool_mining.log_lines.empty()
|
||||
: !mining.log_lines.empty();
|
||||
// Use pool hashrate history when in pool mode, solo otherwise
|
||||
const std::vector<double>& chartHistory = s_pool_mode
|
||||
? state.pool_mining.hashrate_history
|
||||
: mining.hashrate_history;
|
||||
bool hasChartContent = chartHistory.size() >= 2;
|
||||
|
||||
// Stat row height (single line: overline + value)
|
||||
float statRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm();
|
||||
float totalCardH = statRowH + chartBudgetH + pad;
|
||||
bool& showLogFlag = s_pool_mode ? s_show_pool_log : s_show_solo_log;
|
||||
|
||||
// ================================================================
|
||||
// POOL MODE — split card: [ left pool card ][ chart / log ]
|
||||
// ================================================================
|
||||
if (s_pool_mode) {
|
||||
// Left card height driven by its content so the pool list always fits;
|
||||
// the whole row is at least as tall as the old stats+chart card.
|
||||
const float toggleH = sub1->LegacySize + 10 * dp;
|
||||
const float mRowH = sub1->LegacySize + 7 * dp;
|
||||
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
|
||||
float leftContentH = pad * 0.75f + toggleH + gap
|
||||
+ mRowH * 4.0f + gap * 0.5f
|
||||
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
|
||||
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
float leftW = std::clamp(availWidth * 0.25f, 180.0f * dp, availWidth * 0.45f);
|
||||
float colGap = gap;
|
||||
float rightW = availWidth - leftW - colGap;
|
||||
|
||||
ImVec2 leftMin = cardMin;
|
||||
ImVec2 leftMax(cardMin.x + leftW, cardMin.y + cardH);
|
||||
ImVec2 rightMin(leftMax.x + colGap, cardMin.y);
|
||||
ImVec2 rightMax(cardMin.x + availWidth, cardMin.y + cardH);
|
||||
|
||||
DrawGlassPanel(dl, leftMin, leftMax, glassSpec);
|
||||
DrawGlassPanel(dl, rightMin, rightMax, glassSpec);
|
||||
|
||||
RenderLeftPoolCard(app, state, dl, capFont, sub1, ovFont, dp, gap, pad,
|
||||
leftMin, leftMax, s_pool_url, s_pool_settings_dirty);
|
||||
|
||||
// Right panel: live log (if toggled + available) else the sparkline.
|
||||
if (showLogView) {
|
||||
float logPad = pad * 0.5f;
|
||||
DrawMiningLogView(ImVec2(rightMin.x + logPad, rightMin.y + logPad),
|
||||
rightW - logPad * 2, cardH - logPad * 2,
|
||||
state.pool_mining.log_lines, "##PoolLogText");
|
||||
} else if (hasChartContent) {
|
||||
DrawHashrateSparkline(dl,
|
||||
ImVec2(rightMin.x + pad, rightMin.y + statRowH * 0.5f),
|
||||
ImVec2(rightMax.x - pad, rightMax.y),
|
||||
chartHistory, capFont, dp);
|
||||
} else {
|
||||
const char* hint = TR("mining_chart_start");
|
||||
ImVec2 hs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, hint);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2((rightMin.x + rightMax.x - hs.x) * 0.5f,
|
||||
(rightMin.y + rightMax.y - hs.y) * 0.5f),
|
||||
OnSurfaceDisabled(), hint);
|
||||
}
|
||||
|
||||
// Chart/log toggle button, top-right of the RIGHT panel.
|
||||
if (hasLogContent || hasChartContent) {
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* toggleIcon = showLogFlag ? ICON_MD_SHOW_CHART : ICON_MD_ARTICLE;
|
||||
const char* toggleTip = showLogFlag ? TR("mining_show_chart") : TR("mining_show_log");
|
||||
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, toggleIcon);
|
||||
float btnSize = iconSz.y + 8 * dp;
|
||||
ImVec2 btnMin(rightMax.x - pad - btnSize, rightMin.y + pad * 0.5f);
|
||||
ImVec2 btnMax(btnMin.x + btnSize, btnMin.y + btnSize);
|
||||
ImVec2 btnCenter((btnMin.x + btnMax.x) * 0.5f, (btnMin.y + btnMax.y) * 0.5f);
|
||||
bool hov = IsRectHovered(btnMin, btnMax);
|
||||
if (hov) {
|
||||
dl->AddCircleFilled(btnCenter, btnSize * 0.5f, StateHover());
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", toggleTip);
|
||||
}
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(btnCenter.x - iconSz.x * 0.5f, btnCenter.y - iconSz.y * 0.5f),
|
||||
OnSurfaceMedium(), toggleIcon);
|
||||
if (hov && ImGui::IsMouseClicked(0)) showLogFlag = !showLogFlag;
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(cardMin);
|
||||
ImGui::Dummy(ImVec2(availWidth, cardH));
|
||||
ImGui::Dummy(ImVec2(0, gap));
|
||||
return;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SOLO MODE — full-width card: stat columns on top, chart/log below
|
||||
// ================================================================
|
||||
float totalCardH = statRowH + chartBudgetH + pad;
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + totalCardH);
|
||||
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
|
||||
|
||||
bool& showLogFlag = s_pool_mode ? s_show_pool_log : s_show_solo_log;
|
||||
|
||||
if (showLogView) {
|
||||
// --- Full-card log view (selectable + copyable) ---
|
||||
const std::vector<std::string>& logLines = s_pool_mode
|
||||
? state.pool_mining.log_lines
|
||||
: mining.log_lines;
|
||||
|
||||
// Build a single string buffer for InputTextMultiline
|
||||
static std::string s_log_buf;
|
||||
s_log_buf.clear();
|
||||
for (const auto& line : logLines) {
|
||||
if (!line.empty()) {
|
||||
s_log_buf += line;
|
||||
s_log_buf += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
float logPad = pad * 0.5f;
|
||||
float logW = availWidth - logPad * 2;
|
||||
float logH = totalCardH - logPad * 2;
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x + logPad, cardMin.y + logPad));
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0));
|
||||
ImFont* monoFont = Type().body2();
|
||||
ImGui::PushFont(monoFont);
|
||||
|
||||
const char* inputId = s_pool_mode ? "##PoolLogText" : "##SoloLogText";
|
||||
ImGui::InputTextMultiline(inputId,
|
||||
const_cast<char*>(s_log_buf.c_str()), s_log_buf.size() + 1,
|
||||
ImVec2(logW, logH),
|
||||
ImGuiInputTextFlags_ReadOnly);
|
||||
|
||||
ImGui::PopFont();
|
||||
ImGui::PopStyleColor(2);
|
||||
|
||||
// Reset cursor to end of card after the input
|
||||
DrawMiningLogView(ImVec2(cardMin.x + logPad, cardMin.y + logPad),
|
||||
availWidth - logPad * 2, totalCardH - logPad * 2,
|
||||
mining.log_lines, "##SoloLogText");
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(availWidth, 0)); // Register position with layout system
|
||||
} else {
|
||||
// --- Stats + Chart view ---
|
||||
|
||||
// Pool vs Solo stats — different columns
|
||||
std::string col1Str, col2Str, col3Str, col4Str;
|
||||
const char* col1Label;
|
||||
const char* col2Label;
|
||||
const char* col3Label;
|
||||
const char* col4Label = nullptr;
|
||||
ImU32 col1Col, col2Col, col3Col, col4Col = OnSurface();
|
||||
int numStats = 3;
|
||||
|
||||
if (s_pool_mode) {
|
||||
col1Label = TR("mining_local_hashrate");
|
||||
col1Str = FormatHashrate(state.pool_mining.hashrate_10s);
|
||||
col1Col = state.pool_mining.xmrig_running ? greenCol : OnSurfaceDisabled();
|
||||
|
||||
col2Label = TR("mining_pool_hashrate");
|
||||
col2Str = FormatHashrate(state.pool_mining.pool_hashrate);
|
||||
col2Col = state.pool_mining.pool_hashrate > 0 ? OnSurface() : OnSurfaceDisabled();
|
||||
|
||||
col3Label = TR("mining_shares");
|
||||
char sharesBuf[64];
|
||||
snprintf(sharesBuf, sizeof(sharesBuf), "%lld / %lld",
|
||||
(long long)state.pool_mining.accepted,
|
||||
(long long)state.pool_mining.rejected);
|
||||
col3Str = sharesBuf;
|
||||
col3Col = OnSurface();
|
||||
|
||||
col4Label = TR("mining_uptime");
|
||||
int64_t up = state.pool_mining.uptime_sec;
|
||||
char uptBuf[64];
|
||||
if (up <= 0)
|
||||
snprintf(uptBuf, sizeof(uptBuf), "N/A");
|
||||
else if (up < 3600)
|
||||
snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
|
||||
else
|
||||
snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
|
||||
col4Str = uptBuf;
|
||||
col4Col = OnSurface();
|
||||
numStats = 4;
|
||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||
} else {
|
||||
double est_hours = EstimateHoursToBlock(mining.localHashrate, mining.networkHashrate, mining.difficulty);
|
||||
|
||||
col1Label = TR("mining_local_hashrate");
|
||||
col1Str = FormatHashrate(mining.localHashrate);
|
||||
col1Col = mining.generate ? greenCol : OnSurfaceDisabled();
|
||||
const char* col1Label = TR("mining_local_hashrate");
|
||||
std::string col1Str = FormatHashrate(mining.localHashrate);
|
||||
ImU32 col1Col = mining.generate ? greenCol : OnSurfaceDisabled();
|
||||
const char* col2Label = TR("mining_network");
|
||||
std::string col2Str = FormatHashrate(mining.networkHashrate);
|
||||
const char* col3Label = TR("mining_est_block");
|
||||
std::string col3Str = FormatEstTime(est_hours);
|
||||
int numStats = 3;
|
||||
|
||||
col2Label = TR("mining_network");
|
||||
col2Str = FormatHashrate(mining.networkHashrate);
|
||||
col2Col = OnSurface();
|
||||
|
||||
col3Label = TR("mining_est_block");
|
||||
col3Str = FormatEstTime(est_hours);
|
||||
col3Col = OnSurface();
|
||||
}
|
||||
|
||||
// Draw stat values as inline columns at top of card
|
||||
{
|
||||
float statColW = (availWidth - pad * 2) / (float)numStats;
|
||||
float sy = cardMin.y + pad * 0.5f;
|
||||
|
||||
struct StatEntry { const char* label; const char* value; ImU32 col; };
|
||||
char c1Buf[64], c2Buf[64], c3Buf[64], c4Buf[64];
|
||||
snprintf(c1Buf, sizeof(c1Buf), "%s", col1Str.c_str());
|
||||
snprintf(c2Buf, sizeof(c2Buf), "%s", col2Str.c_str());
|
||||
snprintf(c3Buf, sizeof(c3Buf), "%s", col3Str.c_str());
|
||||
if (numStats > 3) snprintf(c4Buf, sizeof(c4Buf), "%s", col4Str.c_str());
|
||||
struct StatEntry { const char* label; std::string value; ImU32 col; };
|
||||
StatEntry stats[] = {
|
||||
{ col1Label, c1Buf, col1Col },
|
||||
{ col2Label, c2Buf, col2Col },
|
||||
{ col3Label, c3Buf, col3Col },
|
||||
{ col4Label ? col4Label : "", c4Buf, col4Col },
|
||||
{ col1Label, col1Str, col1Col },
|
||||
{ col2Label, col2Str, OnSurface() },
|
||||
{ col3Label, col3Str, OnSurface() },
|
||||
};
|
||||
|
||||
for (int si = 0; si < numStats; si++) {
|
||||
float sx = cardMin.x + pad + si * statColW;
|
||||
float centerX = sx + statColW * 0.5f;
|
||||
|
||||
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[si].label);
|
||||
dl->AddText(ovFont, ovFont->LegacySize,
|
||||
ImVec2(centerX - lblSz.x * 0.5f, sy), OnSurfaceMedium(), stats[si].label);
|
||||
|
||||
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[si].value);
|
||||
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[si].value.c_str());
|
||||
float valY = sy + ovFont->LegacySize + Layout::spacingXs();
|
||||
dl->AddText(sub1, sub1->LegacySize,
|
||||
ImVec2(centerX - valSz.x * 0.5f, valY), stats[si].col, stats[si].value);
|
||||
ImVec2(centerX - valSz.x * 0.5f, valY), stats[si].col, stats[si].value.c_str());
|
||||
|
||||
// Trend arrow for hashrate (first column only)
|
||||
if (si == 0 && chartHistory.size() >= 6) {
|
||||
size_t hn = chartHistory.size();
|
||||
double recent = (chartHistory[hn-1] + chartHistory[hn-2] + chartHistory[hn-3]) / 3.0;
|
||||
@@ -208,118 +512,16 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sparkline chart below stats
|
||||
if (hasChartContent) {
|
||||
float chartTop = cardMin.y + statRowH;
|
||||
float chartBot = cardMax.y;
|
||||
|
||||
// Compute Y range
|
||||
double yMin = *std::min_element(chartHistory.begin(), chartHistory.end());
|
||||
double yMax = *std::max_element(chartHistory.begin(), chartHistory.end());
|
||||
if (yMax <= yMin) { yMax = yMin + 1.0; }
|
||||
double yRange = yMax - yMin;
|
||||
double yPad2 = yRange * 0.1;
|
||||
yMin -= yPad2;
|
||||
yMax += yPad2;
|
||||
|
||||
float plotLeft = cardMin.x + pad;
|
||||
float plotRight = cardMax.x - pad;
|
||||
float plotTop = chartTop + capFont->LegacySize + 4 * dp;
|
||||
float plotBottom = chartBot - capFont->LegacySize * 2 - 16 * dp;
|
||||
float plotW = plotRight - plotLeft;
|
||||
float plotH = std::max(1.0f, plotBottom - plotTop);
|
||||
|
||||
// Build raw data points — evenly spaced across the plot.
|
||||
// No smooth-scroll animation: the chart updates in-place
|
||||
// when new data arrives without any interim compression.
|
||||
size_t n = chartHistory.size();
|
||||
float stepW = (n > 1) ? plotW / (float)(n - 1) : plotW;
|
||||
|
||||
std::vector<ImVec2> rawPts(n);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float x = plotLeft + (float)i * stepW;
|
||||
float y = plotBottom - (float)((chartHistory[i] - yMin) / (yMax - yMin)) * plotH;
|
||||
rawPts[i] = ImVec2(x, y);
|
||||
DrawHashrateSparkline(dl,
|
||||
ImVec2(cardMin.x + pad, cardMin.y + statRowH),
|
||||
ImVec2(cardMax.x - pad, cardMax.y),
|
||||
chartHistory, capFont, dp);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
points.reserve(n * 4); // conservative estimate
|
||||
for (size_t i = 0; i + 1 < n; i++) {
|
||||
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;
|
||||
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 (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
|
||||
dl->AddPolyline(points.data(), (int)points.size(),
|
||||
WithAlpha(Success(), 200), ImDrawFlags_None, schema::UI().drawElement("tabs.mining", "chart-line-thickness").size);
|
||||
|
||||
// Y-axis labels
|
||||
std::string yMaxStr = FormatHashrate(yMax);
|
||||
std::string yMinStr = FormatHashrate(yMin);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotTop - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(), yMaxStr.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotBottom + 4 * dp), OnSurfaceDisabled(), yMinStr.c_str());
|
||||
|
||||
// X-axis labels
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(plotLeft, chartBot - capFont->LegacySize - 2 * dp),
|
||||
OnSurfaceDisabled(),
|
||||
chartHistory.size() >= 300 ? TR("mining_chart_5m_ago") :
|
||||
chartHistory.size() >= 60 ? TR("mining_chart_1m_ago") : TR("mining_chart_start"));
|
||||
std::string nowLbl = TR("mining_chart_now");
|
||||
ImVec2 nowSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, nowLbl.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(plotRight - nowSz.x, chartBot - capFont->LegacySize - 2 * dp),
|
||||
OnSurfaceDisabled(), nowLbl.c_str());
|
||||
}
|
||||
|
||||
// Advance cursor past the card (stats/chart view only)
|
||||
ImGui::Dummy(ImVec2(availWidth, totalCardH));
|
||||
}
|
||||
|
||||
// --- Toggle button in top-right corner ---
|
||||
// Rendered after content so the Hand cursor takes priority over
|
||||
// the InputTextMultiline text-cursor when hovering the button.
|
||||
if (hasLogContent || hasChartContent) {
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* toggleIcon = showLogFlag ? ICON_MD_SHOW_CHART : ICON_MD_ARTICLE;
|
||||
@@ -331,7 +533,6 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
ImVec2 btnMin(btnX, btnY);
|
||||
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
|
||||
ImVec2 btnCenter((btnMin.x + btnMax.x) * 0.5f, (btnMin.y + btnMax.y) * 0.5f);
|
||||
|
||||
bool hov = IsRectHovered(btnMin, btnMax);
|
||||
if (hov) {
|
||||
dl->AddCircleFilled(btnCenter, btnSize * 0.5f, StateHover());
|
||||
|
||||
@@ -9,16 +9,20 @@
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
class App;
|
||||
namespace ui {
|
||||
|
||||
// Renders the mining "Hashrate + Stats" card (stat values on top, hashrate chart or live log
|
||||
// below). Extracted verbatim from the monolithic mining tab; the immediate-mode layout context is
|
||||
// passed in so the rendering flows in sequence exactly as before. `poolMode` is the parent's
|
||||
// solo/pool toggle.
|
||||
// Renders the mining "Hashrate + Stats" card. In SOLO mode this is a full-width card (stat values
|
||||
// on top, hashrate chart or live log below). In POOL mode it splits into a left card (Manual/Auto
|
||||
// toggle + pool metric stack + a fixed-height scrolling pool list) and a right region (chart or
|
||||
// log) — so toggling Manual/Auto or adding pools never reflows the tab. `poolMode` is the parent's
|
||||
// solo/pool toggle; `s_pool_url` / `s_pool_settings_dirty` are the parent's pool-selection buffer
|
||||
// and dirty flag (a pool-row click writes them, reusing the parent's save+restart path).
|
||||
void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
ImDrawList* dl, ImFont* capFont, ImFont* sub1, ImFont* ovFont,
|
||||
float dp, float vs, float gap, float pad, float availWidth,
|
||||
const material::GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode);
|
||||
const material::GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode,
|
||||
App* app, char (&s_pool_url)[256], bool& s_pool_settings_dirty);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -278,7 +278,8 @@ static void RenderMiningTabContent(App* app)
|
||||
// (Or full-card log view when toggled in pool mode)
|
||||
// ================================================================
|
||||
RenderMiningStats(state, mining, dl, capFont, sub1, ovFont, dp, vs, gap, pad,
|
||||
availWidth, glassSpec, chartBudgetH, s_pool_mode);
|
||||
availWidth, glassSpec, chartBudgetH, s_pool_mode,
|
||||
app, s_pool_url, s_pool_settings_dirty);
|
||||
|
||||
// ================================================================
|
||||
// EARNINGS — Horizontal row card (Today | Yesterday | All Time | Est. Daily)
|
||||
|
||||
@@ -257,6 +257,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["setup_wizard"] = "Run Setup Wizard...";
|
||||
|
||||
// RPC / Explorer settings
|
||||
strings_["rpc_connection"] = "RPC Connection...";
|
||||
strings_["rpc_host"] = "Host";
|
||||
strings_["rpc_port"] = "Port";
|
||||
strings_["rpc_user"] = "Username";
|
||||
@@ -403,6 +404,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["tt_open_dir"] = "Click to open in file explorer";
|
||||
strings_["settings_open_data_dir"] = "Open data folder";
|
||||
strings_["tt_open_data_dir"] = "Open the folder with your wallet and blockchain data in the file manager";
|
||||
strings_["settings_open_app_dir"] = "Open app folder";
|
||||
strings_["tt_open_app_dir"] = "Open the ObsidianDragon folder (settings, themes, logs) in the file manager";
|
||||
strings_["tt_rpc_host"] = "Hostname of the DragonX daemon";
|
||||
strings_["tt_rpc_user"] = "RPC authentication username";
|
||||
strings_["tt_rpc_port"] = "Port for daemon RPC connections";
|
||||
@@ -947,6 +950,8 @@ void I18n::loadBuiltinEnglish()
|
||||
// --- Console Tab ---
|
||||
strings_["console_auto_scroll"] = "Auto-scroll";
|
||||
strings_["console_available_commands"] = "Available commands:";
|
||||
strings_["console_quit_note"] = "'quit'/'exit' aren't needed here — just close the window.";
|
||||
strings_["lite_console_help_passthrough"] = "Any other input runs as a lite-wallet console command.";
|
||||
strings_["console_capturing_output"] = "Capturing daemon output...";
|
||||
strings_["console_clear"] = "Clear";
|
||||
strings_["console_clear_console"] = "Clear Console";
|
||||
@@ -980,6 +985,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["console_help_setgenerate"] = " setgenerate - Control mining";
|
||||
strings_["console_help_stop"] = " stop - Stop the daemon";
|
||||
strings_["console_line_count"] = "%zu lines";
|
||||
strings_["console_matches"] = "matches";
|
||||
strings_["console_copy_value"] = "Copy";
|
||||
strings_["console_new_lines"] = "%d new lines";
|
||||
strings_["console_no_daemon"] = "No daemon";
|
||||
strings_["console_not_connected"] = "Error: Not connected to daemon";
|
||||
@@ -1052,13 +1059,31 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["market_24h"] = "24h";
|
||||
strings_["market_24h_volume"] = "24H VOLUME";
|
||||
strings_["market_6h"] = "6h";
|
||||
strings_["market_attribution"] = "Price data from NonKYC";
|
||||
strings_["market_attribution"] = "Price data from CoinGecko";
|
||||
strings_["market_btc_price"] = "BTC PRICE";
|
||||
strings_["market_no_history"] = "No price history available";
|
||||
strings_["market_no_price"] = "No price data";
|
||||
strings_["market_now"] = "Now";
|
||||
strings_["market_pct_shielded"] = "%.0f%% Shielded";
|
||||
strings_["market_portfolio"] = "PORTFOLIO";
|
||||
strings_["market_portfolio"] = "MY DRGX";
|
||||
strings_["portfolio_all_funds"] = "All funds";
|
||||
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
|
||||
strings_["portfolio_manage_title"] = "Manage portfolio";
|
||||
strings_["portfolio_add_entry"] = "Add entry";
|
||||
strings_["portfolio_new_entry"] = "New entry";
|
||||
strings_["portfolio_label"] = "Label";
|
||||
strings_["portfolio_addresses_sel"] = "%d selected";
|
||||
strings_["portfolio_all_shielded"] = "All shielded";
|
||||
strings_["portfolio_all_transparent"] = "All transparent";
|
||||
strings_["portfolio_clear_sel"] = "Clear";
|
||||
strings_["portfolio_no_entries"] = "No custom entries yet. Add one to track a group of addresses.";
|
||||
strings_["portfolio_edit"] = "Edit";
|
||||
strings_["portfolio_delete"] = "Delete";
|
||||
strings_["portfolio_save"] = "Save";
|
||||
strings_["portfolio_cancel"] = "Cancel";
|
||||
strings_["portfolio_add_to"] = "Add to portfolio";
|
||||
strings_["portfolio_remove_from"] = "Remove from portfolio";
|
||||
strings_["market_24h_change"] = "24h";
|
||||
strings_["market_price_loading"] = "Loading price data...";
|
||||
strings_["market_price_unavailable"] = "Price data unavailable";
|
||||
strings_["market_refresh_price"] = "Refresh price data";
|
||||
@@ -1110,6 +1135,17 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["mining_pool"] = "Pool";
|
||||
strings_["mining_pool_hashrate"] = "Pool Hashrate";
|
||||
strings_["mining_pool_url"] = "Pool URL";
|
||||
// Pool selection mode + auto-balance (mining tab).
|
||||
strings_["mining_select_manual"] = "Manual";
|
||||
strings_["mining_select_auto"] = "Auto-balance";
|
||||
strings_["mining_manual_desc"] = "Manual: mine to the pool you pick from the list below.";
|
||||
strings_["mining_auto_balance_desc"] = "Spreads miners across the official pools by hashrate to help decentralize the network. Checks each pool periodically.";
|
||||
strings_["mining_suggested_pools"] = "Suggested pools";
|
||||
strings_["mining_pools_header"] = "POOLS";
|
||||
strings_["mining_pool_fee"] = "Fee";
|
||||
strings_["mining_mining_here"] = "mining here";
|
||||
strings_["mining_refresh"] = "Refresh";
|
||||
strings_["mining_auto_balanced_to"] = "Auto-balanced mining to";
|
||||
strings_["mining_recent_blocks"] = "RECENT BLOCKS";
|
||||
strings_["mining_recent_payouts"] = "RECENT POOL PAYOUTS";
|
||||
strings_["mining_remove"] = "Remove";
|
||||
@@ -1207,17 +1243,6 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["daemon_update_downgrade_note"] = "Older versions may be incompatible with your current chain data. Installing a different version takes effect after a daemon restart.";
|
||||
|
||||
// --- Lite Network tab (server browser) ---
|
||||
strings_["lite_console_title"] = "Console";
|
||||
strings_["lite_console_intro"] = "Diagnostic log + interactive console. Type a command (e.g. info, balance, list) and press Enter; type help for the full list.";
|
||||
strings_["lite_console_status"] = "Status:";
|
||||
strings_["lite_console_clear"] = "Clear";
|
||||
strings_["lite_console_copy"] = "Copy";
|
||||
strings_["lite_console_autoscroll"] = "Auto-scroll";
|
||||
strings_["lite_console_empty"] = "No output yet. Type a command (try 'help') and press Enter.";
|
||||
strings_["lite_console_input_hint"] = "Type a command (help, info, balance, list, sync, new zs)…";
|
||||
strings_["lite_console_running"] = "running…";
|
||||
strings_["lite_console_busy"] = "A command is already running — wait for it to finish.";
|
||||
strings_["lite_console_quit_note"] = "'quit'/'exit' don't apply here — the embedded backend stays running with the app.";
|
||||
strings_["lite_net_title"] = "Lite Servers";
|
||||
strings_["lite_net_intro"] = "Pick a server to use, or let the wallet choose one at random. Changes apply immediately.";
|
||||
strings_["lite_net_use_random"] = "Use a random server each time";
|
||||
|
||||
81
src/util/pool_registry.h
Normal file
81
src/util/pool_registry.h
Normal file
@@ -0,0 +1,81 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// PoolRegistry — the small table of official DragonX mining pools plus the pure
|
||||
// (no-I/O) helpers used to compare their hashrate and pick one for auto-balance.
|
||||
//
|
||||
// A pool is more than a URL: the two official pools speak DIFFERENT stats APIs
|
||||
// (pool.dragonx.is = a custom /api/stats; pool.dragonx.cc = Miningcore /api/pools)
|
||||
// and mine with DIFFERENT algo strings (rx/hush vs rx/dragonx), so each entry
|
||||
// carries how to read its hashrate and which algo xmrig must be told to use.
|
||||
//
|
||||
// Everything here is pure and unit-tested (see tests/test_phase4.cpp); the live
|
||||
// HTTP fetch lives in pool_stats_service.{h,cpp}.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
// How to read a pool's total hashrate from its public stats JSON.
|
||||
enum class PoolStatsSchema {
|
||||
DragonXIs, // GET /api/stats -> pools.<name>.hashrate (custom)
|
||||
Miningcore, // GET /api/pools -> pools[id==poolId].poolStats.poolHashrate (Miningcore)
|
||||
};
|
||||
|
||||
struct KnownPool {
|
||||
std::string id; // stable internal id, e.g. "dragonx-cc-pplns"
|
||||
std::string label; // human label for the UI (host, sans port)
|
||||
std::string stratum; // host:port the miner connects to
|
||||
std::string algo; // xmrig algo string for THIS pool
|
||||
std::string statsUrl; // absolute https URL of the stats endpoint
|
||||
PoolStatsSchema schema = PoolStatsSchema::DragonXIs;
|
||||
std::string miningcorePoolId; // Miningcore pool id (empty for DragonXIs)
|
||||
double feePercent = 0.0; // pool fee, for display only
|
||||
bool official = true;
|
||||
};
|
||||
|
||||
// A pool's live total hashrate sample (H/s). ok=false when the fetch/parse failed.
|
||||
struct PoolHashrate {
|
||||
std::string id;
|
||||
double hashrateHs = 0.0;
|
||||
bool ok = false;
|
||||
};
|
||||
|
||||
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
|
||||
// meaningless to balance against). Stable order.
|
||||
const std::vector<KnownPool>& knownPools();
|
||||
|
||||
// The known pool whose stratum matches `url` (host, and port when both specify one),
|
||||
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
|
||||
const KnownPool* findKnownPoolByUrl(const std::string& url);
|
||||
|
||||
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
|
||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
|
||||
|
||||
// Parse a pool's total hashrate (H/s) out of its stats JSON per `schema`.
|
||||
// `miningcorePoolId` selects the pool entry for the Miningcore schema (ignored
|
||||
// otherwise). Sets ok=false and returns 0 on any parse failure / missing field.
|
||||
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
|
||||
const std::string& miningcorePoolId, bool& ok);
|
||||
|
||||
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
|
||||
// proportional to hashrate (smaller pools favored), so miners spread out instead of
|
||||
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
|
||||
// empty) gets a stickiness multiplier so re-balancing rarely churns the miner.
|
||||
// Returns the chosen pool id; returns `currentId` (or the sole usable id) when fewer
|
||||
// than two pools are usable.
|
||||
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
||||
const std::string& currentId,
|
||||
std::mt19937& rng);
|
||||
|
||||
// Stickiness applied to the incumbent pool's weight in chooseWeightedPool.
|
||||
inline constexpr double kIncumbentStayBias = 2.0;
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
208
src/util/pool_registry_core.cpp
Normal file
208
src/util/pool_registry_core.cpp
Normal file
@@ -0,0 +1,208 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Pure (no-I/O) implementation of the pool registry + hashrate helpers. Links
|
||||
// nlohmann_json but NOT libcurl, so it can be exercised directly from the test
|
||||
// binary (mirrors util/xmrig_updater_core.cpp).
|
||||
|
||||
#include "pool_registry.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
const std::vector<KnownPool>& knownPools()
|
||||
{
|
||||
// NOTE: only PPLNS (shared) pools belong here. The .cc SOLO pool
|
||||
// (dragonx-solo, ports 5555/6666) is intentionally omitted — its hashrate
|
||||
// is not comparable for network balancing.
|
||||
static const std::vector<KnownPool> pools = {
|
||||
KnownPool{
|
||||
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
|
||||
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
|
||||
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
|
||||
},
|
||||
KnownPool{
|
||||
// The mining (stratum) host is us.dragonx.cc — pool.dragonx.cc is the
|
||||
// Cloudflare-proxied web/API host and does NOT accept stratum on :3333.
|
||||
// Stats still come from pool.dragonx.cc/api/pools (proxied HTTP is fine).
|
||||
"dragonx-cc-pplns", "pool.dragonx.cc", "us.dragonx.cc:3333", "rx/dragonx",
|
||||
"https://pool.dragonx.cc/api/pools", PoolStatsSchema::Miningcore,
|
||||
/*miningcorePoolId=*/"dragonx-pplns", /*feePercent=*/3.0, /*official=*/true,
|
||||
},
|
||||
};
|
||||
return pools;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trimmed(const std::string& s)
|
||||
{
|
||||
const auto b = s.find_first_not_of(" \t\r\n");
|
||||
if (b == std::string::npos) return {};
|
||||
const auto e = s.find_last_not_of(" \t\r\n");
|
||||
return s.substr(b, e - b + 1);
|
||||
}
|
||||
|
||||
// Extract "host[:port]" from a URL that may carry a scheme, userinfo, path or query.
|
||||
// Our pools are plain host:port endpoints (no IPv6 literals), so a last-colon split
|
||||
// for the port is sufficient.
|
||||
std::string hostPortOf(const std::string& url)
|
||||
{
|
||||
std::string s = trimmed(url);
|
||||
if (const auto scheme = s.find("://"); scheme != std::string::npos)
|
||||
s = s.substr(scheme + 3);
|
||||
if (const auto at = s.find('@'); at != std::string::npos)
|
||||
s = s.substr(at + 1);
|
||||
if (const auto cut = s.find_first_of("/?#"); cut != std::string::npos)
|
||||
s = s.substr(0, cut);
|
||||
return s;
|
||||
}
|
||||
|
||||
void splitHostPort(const std::string& hostport, std::string& host, std::string& port)
|
||||
{
|
||||
if (const auto colon = hostport.rfind(':'); colon == std::string::npos) {
|
||||
host = hostport;
|
||||
port.clear();
|
||||
} else {
|
||||
host = hostport.substr(0, colon);
|
||||
port = hostport.substr(colon + 1);
|
||||
}
|
||||
std::transform(host.begin(), host.end(), host.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
}
|
||||
|
||||
// Same endpoint when hosts match and (either side omits a port, or the ports match).
|
||||
bool sameEndpoint(const std::string& a, const std::string& b)
|
||||
{
|
||||
std::string ha, pa, hb, pb;
|
||||
splitHostPort(hostPortOf(a), ha, pa);
|
||||
splitHostPort(hostPortOf(b), hb, pb);
|
||||
if (ha.empty() || ha != hb) return false;
|
||||
if (pa.empty() || pb.empty()) return true;
|
||||
return pa == pb;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const KnownPool* findKnownPoolByUrl(const std::string& url)
|
||||
{
|
||||
for (const auto& p : knownPools())
|
||||
if (sameEndpoint(p.stratum, url)) return &p;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
|
||||
{
|
||||
if (const KnownPool* p = findKnownPoolByUrl(url)) return p->algo;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
|
||||
const std::string& miningcorePoolId, bool& ok)
|
||||
{
|
||||
ok = false;
|
||||
try {
|
||||
const json j = json::parse(jsonStr);
|
||||
|
||||
if (schema == PoolStatsSchema::DragonXIs) {
|
||||
// { "pools": { "dragonx": { "hashrate": <num>, ... }, ... } }
|
||||
if (j.contains("pools") && j["pools"].is_object()) {
|
||||
const auto& pools = j["pools"];
|
||||
auto readHr = [&](const json& pool, double& out) -> bool {
|
||||
if (pool.is_object() && pool.contains("hashrate") &&
|
||||
pool["hashrate"].is_number()) {
|
||||
out = pool["hashrate"].get<double>();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
double hr = 0.0;
|
||||
if (pools.contains("dragonx") && readHr(pools["dragonx"], hr)) {
|
||||
ok = true;
|
||||
return hr;
|
||||
}
|
||||
for (auto it = pools.begin(); it != pools.end(); ++it) {
|
||||
if (readHr(it.value(), hr)) {
|
||||
ok = true;
|
||||
return hr;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // Miningcore
|
||||
// { "pools": [ { "id": "...", "poolStats": { "poolHashrate": <num> } }, ... ] }
|
||||
if (j.contains("pools") && j["pools"].is_array()) {
|
||||
const json* chosen = nullptr;
|
||||
for (const auto& pool : j["pools"]) {
|
||||
if (!pool.is_object()) continue;
|
||||
if (!miningcorePoolId.empty()) {
|
||||
if (pool.value("id", std::string{}) == miningcorePoolId) {
|
||||
chosen = &pool;
|
||||
break;
|
||||
}
|
||||
} else if (!chosen) {
|
||||
chosen = &pool; // first pool when no id requested
|
||||
}
|
||||
}
|
||||
if (chosen && chosen->contains("poolStats") &&
|
||||
(*chosen)["poolStats"].is_object() &&
|
||||
(*chosen)["poolStats"].contains("poolHashrate") &&
|
||||
(*chosen)["poolStats"]["poolHashrate"].is_number()) {
|
||||
ok = true;
|
||||
return (*chosen)["poolStats"]["poolHashrate"].get<double>();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
// fall through — ok stays false
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
|
||||
const std::string& currentId,
|
||||
std::mt19937& rng)
|
||||
{
|
||||
std::vector<const PoolHashrate*> usable;
|
||||
usable.reserve(pools.size());
|
||||
for (const auto& p : pools)
|
||||
if (p.ok) usable.push_back(&p);
|
||||
|
||||
if (usable.empty()) return currentId;
|
||||
if (usable.size() == 1) return usable.front()->id;
|
||||
|
||||
// weight = 1/(hr + eps): smaller pools get more weight; eps keeps a zero-hashrate
|
||||
// pool finite (and most-favored). The incumbent gets a stickiness multiplier so a
|
||||
// periodic re-balance only rarely restarts the miner.
|
||||
constexpr double kEps = 1.0; // H/s
|
||||
std::vector<double> weights;
|
||||
weights.reserve(usable.size());
|
||||
double total = 0.0;
|
||||
for (const auto* p : usable) {
|
||||
double w = 1.0 / (std::max(0.0, p->hashrateHs) + kEps);
|
||||
if (!currentId.empty() && p->id == currentId) w *= kIncumbentStayBias;
|
||||
weights.push_back(w);
|
||||
total += w;
|
||||
}
|
||||
if (total <= 0.0) return usable.front()->id;
|
||||
|
||||
std::uniform_real_distribution<double> dist(0.0, total);
|
||||
const double roll = dist(rng);
|
||||
double cum = 0.0;
|
||||
for (std::size_t i = 0; i < usable.size(); ++i) {
|
||||
cum += weights[i];
|
||||
if (roll <= cum) return usable[i]->id;
|
||||
}
|
||||
return usable.back()->id;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
113
src/util/pool_stats_service.cpp
Normal file
113
src/util/pool_stats_service.cpp
Normal file
@@ -0,0 +1,113 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "pool_stats_service.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
namespace {
|
||||
|
||||
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
|
||||
{
|
||||
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
// Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't
|
||||
// block on an in-flight fetch.
|
||||
int xferInfoCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
||||
{
|
||||
const auto* self = static_cast<const PoolStatsService*>(clientp);
|
||||
return (self && self->cancelRequested()) ? 1 : 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PoolStatsService::~PoolStatsService()
|
||||
{
|
||||
cancel_requested_ = true;
|
||||
if (worker_.joinable()) worker_.join();
|
||||
}
|
||||
|
||||
void PoolStatsService::refresh(const std::vector<KnownPool>& pools)
|
||||
{
|
||||
if (worker_running_.exchange(true)) return; // already refreshing
|
||||
if (worker_.joinable()) worker_.join(); // reap the previous finished worker
|
||||
worker_ = std::thread([this, pools]() {
|
||||
run(pools);
|
||||
worker_running_ = false;
|
||||
});
|
||||
}
|
||||
|
||||
std::string PoolStatsService::httpGet(const std::string& url)
|
||||
{
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return {};
|
||||
|
||||
std::string result;
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents — present a
|
||||
// browser-like UA and accept compressed responses.
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT,
|
||||
"Mozilla/5.0 (compatible; ObsidianDragon)");
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 8L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferInfoCb);
|
||||
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
|
||||
|
||||
struct curl_slist* hdrs = nullptr;
|
||||
hdrs = curl_slist_append(hdrs, "Accept: application/json");
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
|
||||
|
||||
const CURLcode res = curl_easy_perform(curl);
|
||||
long httpCode = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
|
||||
curl_slist_free_all(hdrs);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) return {};
|
||||
return result;
|
||||
}
|
||||
|
||||
void PoolStatsService::run(std::vector<KnownPool> pools)
|
||||
{
|
||||
std::map<std::string, PoolHashrate> results;
|
||||
for (const auto& p : pools) {
|
||||
if (cancel_requested_.load()) break;
|
||||
PoolHashrate hr;
|
||||
hr.id = p.id;
|
||||
const std::string body = httpGet(p.statsUrl);
|
||||
if (!body.empty()) {
|
||||
bool ok = false;
|
||||
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
|
||||
hr.ok = ok;
|
||||
hr.hashrateHs = ok ? v : 0.0;
|
||||
}
|
||||
results[p.id] = hr;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
snapshot_.byId = std::move(results);
|
||||
snapshot_.ready = true;
|
||||
}
|
||||
|
||||
PoolStatsService::Snapshot PoolStatsService::snapshot() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mutex_);
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
59
src/util/pool_stats_service.h
Normal file
59
src/util/pool_stats_service.h
Normal file
@@ -0,0 +1,59 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// PoolStatsService — fetch every known pool's live total hashrate on a background
|
||||
// thread and expose a thread-safe snapshot. Modelled on util/XmrigUpdater: a
|
||||
// std::thread + atomics + a mutex-guarded result, polled once per frame by the UI /
|
||||
// the auto-balance driver. Schema-aware parsing is delegated to pool_registry.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "pool_registry.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
class PoolStatsService {
|
||||
public:
|
||||
struct Snapshot {
|
||||
std::map<std::string, PoolHashrate> byId; // keyed by KnownPool.id
|
||||
bool ready = false; // at least one refresh completed
|
||||
};
|
||||
|
||||
PoolStatsService() = default;
|
||||
~PoolStatsService();
|
||||
PoolStatsService(const PoolStatsService&) = delete;
|
||||
PoolStatsService& operator=(const PoolStatsService&) = delete;
|
||||
|
||||
// Fetch every pool's hashrate on a background thread. No-op if a refresh is
|
||||
// already in flight.
|
||||
void refresh(const std::vector<KnownPool>& pools);
|
||||
|
||||
bool busy() const { return worker_running_.load(); }
|
||||
Snapshot snapshot() const;
|
||||
|
||||
// Internal: consulted by the libcurl xferinfo callback so an in-flight fetch can
|
||||
// abort promptly on shutdown. Public only so the C callback can reach it.
|
||||
bool cancelRequested() const { return cancel_requested_.load(); }
|
||||
|
||||
private:
|
||||
void run(std::vector<KnownPool> pools);
|
||||
std::string httpGet(const std::string& url);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Snapshot snapshot_;
|
||||
std::atomic<bool> worker_running_{false};
|
||||
std::atomic<bool> cancel_requested_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -13,8 +13,12 @@
|
||||
#include "ui/windows/balance_address_list.h"
|
||||
#include "ui/windows/balance_recent_tx.h"
|
||||
#include "ui/windows/console_input_model.h"
|
||||
#include "ui/windows/console_model.h"
|
||||
#include "ui/windows/console_output_model.h"
|
||||
#include "ui/windows/console_scroll_controller.h"
|
||||
#include "ui/windows/console_selection_controller.h"
|
||||
#include "ui/windows/console_tab_helpers.h"
|
||||
#include "ui/windows/console_text_layout.h"
|
||||
#include "ui/windows/mining_benchmark.h"
|
||||
#include "ui/windows/mining_pool_panel.h"
|
||||
#include "ui/windows/mining_tab_helpers.h"
|
||||
@@ -23,6 +27,7 @@
|
||||
#include "util/payment_uri.h"
|
||||
#include "util/platform.h"
|
||||
#include "util/xmrig_updater.h"
|
||||
#include "util/pool_registry.h"
|
||||
#include "util/daemon_updater.h"
|
||||
#include "util/lite_server_probe.h"
|
||||
#include "wallet/lite_connection_service.h"
|
||||
@@ -34,6 +39,7 @@
|
||||
#include "wallet/lite_wallet_state_mapper.h"
|
||||
#include "config/settings.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "data/portfolio.h"
|
||||
#include "fake_lite_backend.h"
|
||||
|
||||
#include <chrono>
|
||||
@@ -2281,6 +2287,424 @@ void testDaemonLifecycleAdapters()
|
||||
fs::remove_all(dataDir);
|
||||
}
|
||||
|
||||
// Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a
|
||||
// fixed-width measurement stub so the byte-offset selection math is unit-testable.
|
||||
void testConsoleTextLayout()
|
||||
{
|
||||
using namespace dragonx::ui;
|
||||
struct StubMeasure : ConsoleTextMeasure {
|
||||
float charW = 10.0f;
|
||||
float width(const char* b, const char* e) const override {
|
||||
return static_cast<float>(e - b) * charW;
|
||||
}
|
||||
const char* wrapPosition(const char* b, const char* e, float w) const override {
|
||||
int maxChars = static_cast<int>(w / charW);
|
||||
if (maxChars < 1) maxChars = 1;
|
||||
return (e - b) <= maxChars ? e : b + maxChars;
|
||||
}
|
||||
} stub;
|
||||
|
||||
std::vector<std::string> lines = { "", "abc", "abcdefghij" }; // 0, 3, 10 chars
|
||||
auto get = [&lines](int i) -> const std::string& { return lines[i]; };
|
||||
const float lineH = 12.0f, gap = 2.0f;
|
||||
|
||||
// wrapWidth 50 (charW 10 -> 5 chars/row)
|
||||
ConsoleLayout L = BuildConsoleLayout(static_cast<int>(lines.size()), get, 50.0f, lineH, gap, stub);
|
||||
EXPECT_EQ(L.segments.size(), static_cast<size_t>(3));
|
||||
EXPECT_EQ(L.segments[0].size(), static_cast<size_t>(1)); // empty -> one empty seg
|
||||
EXPECT_EQ(L.segments[0][0].byteEnd, 0);
|
||||
EXPECT_NEAR(L.heights[0], lineH + gap, 0.01);
|
||||
EXPECT_EQ(L.segments[1].size(), static_cast<size_t>(1)); // "abc" fits
|
||||
EXPECT_EQ(L.segments[1][0].byteEnd, 3);
|
||||
EXPECT_EQ(L.segments[2].size(), static_cast<size_t>(2)); // 10 chars, 5/row -> 2 rows
|
||||
EXPECT_EQ(L.segments[2][0].byteEnd, 5);
|
||||
EXPECT_EQ(L.segments[2][1].byteStart, 5);
|
||||
EXPECT_EQ(L.segments[2][1].byteEnd, 10);
|
||||
EXPECT_NEAR(L.heights[2], 2 * lineH + gap, 0.01);
|
||||
EXPECT_NEAR(L.cumulativeY[2], 2 * (lineH + gap), 0.01);
|
||||
EXPECT_NEAR(L.totalHeight, 3 * (lineH + gap) + lineH, 0.01);
|
||||
|
||||
// Hit-test: second wrap row of line 2, column 7.
|
||||
ConsoleHit hit = HitTestConsoleLayout(L, 3, get, /*relX*/ 20.0f, /*relY*/ 45.0f, stub);
|
||||
EXPECT_EQ(hit.visibleRow, 2);
|
||||
EXPECT_EQ(hit.col, 7);
|
||||
// Past the bottom clamps to the last line's end.
|
||||
ConsoleHit last = HitTestConsoleLayout(L, 3, get, 1000.0f, 9999.0f, stub);
|
||||
EXPECT_EQ(last.visibleRow, 2);
|
||||
EXPECT_EQ(last.col, 10);
|
||||
|
||||
// Selection extraction (multi-line, no filter, then filtered to line 0 only).
|
||||
std::vector<std::string> lines2 = { "hello", "world" };
|
||||
auto get2 = [&lines2](int i) -> const std::string& { return lines2[i]; };
|
||||
EXPECT_EQ(ExtractConsoleSelection(2, get2, {}, {0, 1}, {1, 3}), std::string("ello\nwor"));
|
||||
EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
|
||||
}
|
||||
|
||||
// ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog).
|
||||
void testConsoleScrollController()
|
||||
{
|
||||
using dragonx::ui::ConsoleScrollController;
|
||||
|
||||
// Starts pinned to the bottom; new lines while pinned are not counted as a backlog.
|
||||
{
|
||||
ConsoleScrollController s;
|
||||
EXPECT_TRUE(s.autoScroll());
|
||||
s.onLinesAdded(5);
|
||||
EXPECT_EQ(s.newLines(), 0);
|
||||
}
|
||||
|
||||
// Wheel-up detaches, starts the cooldown, and begins counting backlog lines.
|
||||
{
|
||||
ConsoleScrollController s;
|
||||
s.onUserScrolledUp();
|
||||
EXPECT_FALSE(s.autoScroll());
|
||||
s.onLinesAdded(3);
|
||||
s.onLinesAdded(2);
|
||||
EXPECT_EQ(s.newLines(), 5);
|
||||
|
||||
// At-bottom is ignored until the cooldown elapses.
|
||||
s.considerReenable(true);
|
||||
EXPECT_FALSE(s.autoScroll());
|
||||
s.tickCooldown(ConsoleScrollController::kScrollUpCooldownSeconds + 0.01f);
|
||||
// Elapsed, but only re-pins when actually at the bottom.
|
||||
s.considerReenable(false);
|
||||
EXPECT_FALSE(s.autoScroll());
|
||||
s.considerReenable(true);
|
||||
EXPECT_TRUE(s.autoScroll());
|
||||
EXPECT_EQ(s.newLines(), 0); // re-pinning clears the backlog
|
||||
}
|
||||
|
||||
// Checkbox toggle: enabling clears the backlog, disabling preserves state.
|
||||
{
|
||||
ConsoleScrollController s;
|
||||
s.onUserScrolledUp();
|
||||
s.onLinesAdded(4);
|
||||
EXPECT_EQ(s.newLines(), 4);
|
||||
s.setAutoScroll(true);
|
||||
EXPECT_TRUE(s.autoScroll());
|
||||
EXPECT_EQ(s.newLines(), 0);
|
||||
s.setAutoScroll(false);
|
||||
EXPECT_FALSE(s.autoScroll());
|
||||
}
|
||||
|
||||
// Jump-to-bottom pill re-pins and clears the backlog.
|
||||
{
|
||||
ConsoleScrollController s;
|
||||
s.onUserScrolledUp();
|
||||
s.onLinesAdded(9);
|
||||
s.jumpToBottom();
|
||||
EXPECT_TRUE(s.autoScroll());
|
||||
EXPECT_EQ(s.newLines(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift.
|
||||
void testConsoleSelectionController()
|
||||
{
|
||||
using dragonx::ui::ConsoleSelectionController;
|
||||
using dragonx::ui::ConsoleTextPos;
|
||||
|
||||
// Ordering helper.
|
||||
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 2}, {1, 5}));
|
||||
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 5}, {2, 0}));
|
||||
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({3, 4}, {3, 4}));
|
||||
EXPECT_FALSE(ConsoleSelectionController::isBeforeOrEqual({2, 0}, {1, 9}));
|
||||
|
||||
// Drag lifecycle: press starts a drag but is not yet a real selection.
|
||||
{
|
||||
ConsoleSelectionController sel;
|
||||
sel.beginDrag({2, 3});
|
||||
EXPECT_TRUE(sel.dragging());
|
||||
EXPECT_FALSE(sel.active());
|
||||
sel.updateDrag({2, 3}); // no movement -> still not active
|
||||
EXPECT_FALSE(sel.active());
|
||||
sel.updateDrag({4, 1}); // moved -> real selection
|
||||
EXPECT_TRUE(sel.active());
|
||||
sel.endDrag({4, 1});
|
||||
EXPECT_FALSE(sel.dragging());
|
||||
EXPECT_TRUE(sel.active());
|
||||
// Ordered range is anchor..caret regardless of drag direction.
|
||||
EXPECT_EQ(sel.rangeStart().line, 2);
|
||||
EXPECT_EQ(sel.rangeStart().col, 3);
|
||||
EXPECT_EQ(sel.rangeEnd().line, 4);
|
||||
EXPECT_EQ(sel.rangeEnd().col, 1);
|
||||
}
|
||||
|
||||
// Upward drag: caret before anchor -> range is still ordered low..high.
|
||||
{
|
||||
ConsoleSelectionController sel;
|
||||
sel.beginDrag({5, 2});
|
||||
sel.updateDrag({1, 0});
|
||||
EXPECT_EQ(sel.rangeStart().line, 1);
|
||||
EXPECT_EQ(sel.rangeEnd().line, 5);
|
||||
}
|
||||
|
||||
// Select-all + clear.
|
||||
{
|
||||
ConsoleSelectionController sel;
|
||||
sel.selectAll(0, 0); // empty buffer -> no-op
|
||||
EXPECT_FALSE(sel.active());
|
||||
sel.selectAll(3, 7); // lines 0..2, last line 7 bytes
|
||||
EXPECT_TRUE(sel.active());
|
||||
EXPECT_EQ(sel.rangeStart().line, 0);
|
||||
EXPECT_EQ(sel.rangeStart().col, 0);
|
||||
EXPECT_EQ(sel.rangeEnd().line, 2);
|
||||
EXPECT_EQ(sel.rangeEnd().col, 7);
|
||||
sel.clear();
|
||||
EXPECT_FALSE(sel.active());
|
||||
EXPECT_FALSE(sel.dragging());
|
||||
}
|
||||
|
||||
// Eviction shift: partial (top of selection clamped), full (dropped), and no-op.
|
||||
{
|
||||
ConsoleSelectionController sel;
|
||||
sel.selectAll(10, 4); // [0,0]..[9,4]
|
||||
sel.shiftForEviction(3); // 3 lines fell off the top
|
||||
EXPECT_TRUE(sel.active());
|
||||
EXPECT_EQ(sel.rangeStart().line, 0); // was line 0 -> clamped to 0
|
||||
EXPECT_EQ(sel.rangeStart().col, 0);
|
||||
EXPECT_EQ(sel.rangeEnd().line, 6); // 9 - 3
|
||||
EXPECT_EQ(sel.rangeEnd().col, 4);
|
||||
sel.shiftForEviction(0); // no-op
|
||||
EXPECT_EQ(sel.rangeEnd().line, 6);
|
||||
sel.shiftForEviction(100); // whole selection evicted -> dropped
|
||||
EXPECT_FALSE(sel.active());
|
||||
}
|
||||
|
||||
// extract(): inactive -> "", active -> the joined selected text.
|
||||
{
|
||||
std::vector<std::string> lines = {"hello", "world"};
|
||||
auto get = [&lines](int i) -> const std::string& { return lines[i]; };
|
||||
ConsoleSelectionController sel;
|
||||
EXPECT_EQ(sel.extract(2, get, {}), std::string(""));
|
||||
sel.beginDrag({0, 1});
|
||||
sel.updateDrag({1, 3});
|
||||
EXPECT_EQ(sel.extract(2, get, {}), std::string("ello\nwor"));
|
||||
// Filtered so only line 0 is visible -> selection clips to it.
|
||||
EXPECT_EQ(sel.extract(2, get, std::vector<int>{0}), std::string("ello"));
|
||||
}
|
||||
}
|
||||
|
||||
// ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap.
|
||||
void testConsoleModel()
|
||||
{
|
||||
using dragonx::ui::ConsoleModel;
|
||||
using dragonx::ui::ConsoleChannel;
|
||||
|
||||
// Basic ingest -> drain: order + channels preserved, counts reported.
|
||||
{
|
||||
ConsoleModel m;
|
||||
m.ingest("one", ConsoleChannel::Command);
|
||||
m.ingest("two", ConsoleChannel::Info);
|
||||
m.ingest("three", ConsoleChannel::Error);
|
||||
auto dr = m.drain();
|
||||
EXPECT_EQ(dr.added, static_cast<size_t>(3));
|
||||
EXPECT_EQ(dr.popped, static_cast<size_t>(0));
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(3));
|
||||
EXPECT_EQ(m[0].text, std::string("one"));
|
||||
EXPECT_TRUE(m[0].channel == ConsoleChannel::Command);
|
||||
EXPECT_TRUE(m[2].channel == ConsoleChannel::Error);
|
||||
EXPECT_EQ(m.back().text, std::string("three"));
|
||||
|
||||
// Draining again with nothing pending is a no-op.
|
||||
auto empty = m.drain();
|
||||
EXPECT_EQ(empty.added, static_cast<size_t>(0));
|
||||
EXPECT_EQ(empty.popped, static_cast<size_t>(0));
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(3));
|
||||
}
|
||||
|
||||
// Line cap: front lines are evicted, popped is reported for selection adjustment.
|
||||
{
|
||||
ConsoleModel m(3); // cap = 3
|
||||
for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None);
|
||||
auto dr = m.drain();
|
||||
EXPECT_EQ(dr.added, static_cast<size_t>(5));
|
||||
EXPECT_EQ(dr.popped, static_cast<size_t>(2));
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(3));
|
||||
EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted
|
||||
EXPECT_EQ(m[2].text, std::string("4"));
|
||||
}
|
||||
|
||||
// clear() drops the visible lines but not lines still queued for the next drain.
|
||||
{
|
||||
ConsoleModel m;
|
||||
m.ingest("a", ConsoleChannel::None);
|
||||
m.drain();
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(1));
|
||||
m.ingest("b", ConsoleChannel::None); // queued, not yet visible
|
||||
m.clear();
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(0));
|
||||
m.drain();
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(1)); // "b" survived the clear
|
||||
EXPECT_EQ(m[0].text, std::string("b"));
|
||||
}
|
||||
|
||||
// Concurrency: many producer threads ingest while the main thread drains; no line lost.
|
||||
{
|
||||
ConsoleModel m(1000000); // large cap so nothing is evicted
|
||||
const int kThreads = 8, kPerThread = 2000;
|
||||
std::atomic<bool> go{false};
|
||||
std::vector<std::thread> producers;
|
||||
for (int t = 0; t < kThreads; ++t) {
|
||||
producers.emplace_back([&m, &go, t]() {
|
||||
while (!go.load()) { /* spin until start */ }
|
||||
for (int i = 0; i < kPerThread; ++i)
|
||||
m.ingest("x", ConsoleChannel::None);
|
||||
});
|
||||
}
|
||||
go.store(true);
|
||||
// Interleave drains on this thread while producers run.
|
||||
size_t drained = 0;
|
||||
while (drained < static_cast<size_t>(kThreads * kPerThread)) {
|
||||
drained += m.drain().added;
|
||||
}
|
||||
for (auto& th : producers) th.join();
|
||||
drained += m.drain().added; // final sweep for anything queued after the last drain
|
||||
EXPECT_EQ(drained, static_cast<size_t>(kThreads * kPerThread));
|
||||
EXPECT_EQ(m.size(), static_cast<size_t>(kThreads * kPerThread));
|
||||
}
|
||||
|
||||
// JSON fold state: ingest carries a fold span; toggleCollapsed flips only openers.
|
||||
{
|
||||
ConsoleModel m;
|
||||
m.ingest("{", ConsoleChannel::JsonBrace, 3);
|
||||
m.ingest(" \"a\": 1", ConsoleChannel::JsonKey, 0);
|
||||
m.ingest("}", ConsoleChannel::JsonBrace, 0);
|
||||
m.drain();
|
||||
EXPECT_EQ(m[0].foldSpan, 3);
|
||||
EXPECT_FALSE(m[0].collapsed);
|
||||
EXPECT_TRUE(m.toggleCollapsed(0)); // opener -> collapsed
|
||||
EXPECT_TRUE(m[0].collapsed);
|
||||
EXPECT_FALSE(m.toggleCollapsed(0)); // -> expanded
|
||||
EXPECT_FALSE(m[0].collapsed);
|
||||
EXPECT_FALSE(m.toggleCollapsed(1)); // not an opener -> no-op
|
||||
EXPECT_FALSE(m.toggleCollapsed(99)); // out of range -> no-op
|
||||
}
|
||||
}
|
||||
|
||||
// Portfolio helpers — sum/query a labeled address group against per-address balances.
|
||||
void testPortfolioHelpers()
|
||||
{
|
||||
using dragonx::AddressInfo;
|
||||
using dragonx::data::SumPortfolioBalance;
|
||||
using dragonx::data::PortfolioEntryContains;
|
||||
using dragonx::data::PortfolioEntryAdd;
|
||||
using dragonx::data::PortfolioEntryRemove;
|
||||
|
||||
std::vector<AddressInfo> wallet;
|
||||
auto mk = [](const char* addr, double bal, const char* type) {
|
||||
AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
|
||||
};
|
||||
wallet.push_back(mk("R-t1", 1.5, "transparent"));
|
||||
wallet.push_back(mk("R-t2", 2.0, "transparent"));
|
||||
wallet.push_back(mk("zs-s1", 10.0, "shielded"));
|
||||
wallet.push_back(mk("zs-s2", 0.25, "shielded"));
|
||||
|
||||
// Empty group sums to 0.
|
||||
EXPECT_NEAR(SumPortfolioBalance({}, wallet), 0.0, 1e-9);
|
||||
// A group of two known addresses.
|
||||
EXPECT_NEAR(SumPortfolioBalance({"R-t1", "zs-s1"}, wallet), 11.5, 1e-9);
|
||||
// Unknown addresses contribute 0 (not present in the wallet).
|
||||
EXPECT_NEAR(SumPortfolioBalance({"R-t2", "does-not-exist"}, wallet), 2.0, 1e-9);
|
||||
// All shielded.
|
||||
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "zs-s2"}, wallet), 10.25, 1e-9);
|
||||
|
||||
// Membership + add/remove semantics.
|
||||
std::vector<std::string> group = {"R-t1"};
|
||||
EXPECT_TRUE(PortfolioEntryContains(group, "R-t1"));
|
||||
EXPECT_FALSE(PortfolioEntryContains(group, "R-t2"));
|
||||
EXPECT_TRUE(PortfolioEntryAdd(group, "R-t2")); // added
|
||||
EXPECT_FALSE(PortfolioEntryAdd(group, "R-t2")); // already present -> no-op
|
||||
EXPECT_EQ(group.size(), static_cast<size_t>(2));
|
||||
EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed
|
||||
EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op
|
||||
EXPECT_EQ(group.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(group[0], std::string("R-t2"));
|
||||
}
|
||||
|
||||
// Regression: the Market-tab portfolio (and other consumers) read the combined
|
||||
// WalletState::addresses view, which the full-node refresh path builds from the
|
||||
// authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild
|
||||
// was never called, so `addresses` stayed empty and every portfolio group summed
|
||||
// to 0 and showed no addresses to pick. Guard that the rebuilt view carries the
|
||||
// z/t union with balances, so SumPortfolioBalance resolves right-click-added
|
||||
// addresses.
|
||||
void testWalletStateAddressListRebuild()
|
||||
{
|
||||
using dragonx::AddressInfo;
|
||||
using dragonx::data::SumPortfolioBalance;
|
||||
|
||||
dragonx::WalletState state;
|
||||
auto mk = [](const char* addr, double bal, const char* type) {
|
||||
AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
|
||||
};
|
||||
state.z_addresses.push_back(mk("zs-s1", 10.0, "shielded"));
|
||||
state.z_addresses.push_back(mk("zs-s2", 0.25, "shielded"));
|
||||
state.t_addresses.push_back(mk("R-t1", 1.5, "transparent"));
|
||||
|
||||
// Before the rebuild the combined view is empty -> the reported bug: a portfolio
|
||||
// group of real addresses sums to 0 because none are found in state.addresses.
|
||||
EXPECT_EQ(state.addresses.size(), static_cast<size_t>(0));
|
||||
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 0.0, 1e-9);
|
||||
|
||||
// After the rebuild the combined view is the z/t union with balances.
|
||||
state.rebuildAddressList();
|
||||
EXPECT_EQ(state.addresses.size(), static_cast<size_t>(3));
|
||||
// z entries come first, then t entries (rebuild order).
|
||||
EXPECT_EQ(state.addresses.front().address, std::string("zs-s1"));
|
||||
EXPECT_EQ(state.addresses.back().address, std::string("R-t1"));
|
||||
// A group spanning both pools now resolves its balances.
|
||||
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 11.5, 1e-9);
|
||||
|
||||
// In-place z/t balance mutation (pending-send delta) is reflected on the next rebuild.
|
||||
state.z_addresses[0].balance = 4.0;
|
||||
state.rebuildAddressList();
|
||||
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 5.5, 1e-9);
|
||||
}
|
||||
|
||||
// ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines.
|
||||
void testConsoleFoldSpans()
|
||||
{
|
||||
using dragonx::ui::ComputeConsoleFoldSpans;
|
||||
|
||||
// Nested object: outer '{' spans the whole block; inner "obj": { spans its two-line body.
|
||||
std::vector<std::string> obj = {
|
||||
"{", // 0 -> closer at 6, span 6
|
||||
" \"a\": 1,", // 1
|
||||
" \"obj\": {", // 2 -> closer at 4, span 2
|
||||
" \"b\": 2", // 3
|
||||
" },", // 4 (closer for 2)
|
||||
" \"c\": 3", // 5
|
||||
"}" // 6 (closer for 0)
|
||||
};
|
||||
auto spans = ComputeConsoleFoldSpans(obj);
|
||||
EXPECT_EQ(spans.size(), static_cast<size_t>(7));
|
||||
EXPECT_EQ(spans[0], 6);
|
||||
EXPECT_EQ(spans[1], 0);
|
||||
EXPECT_EQ(spans[2], 2);
|
||||
EXPECT_EQ(spans[3], 0);
|
||||
EXPECT_EQ(spans[4], 0); // a closer is not an opener
|
||||
EXPECT_EQ(spans[5], 0);
|
||||
EXPECT_EQ(spans[6], 0);
|
||||
|
||||
// Array opener '['.
|
||||
std::vector<std::string> arr = { "\"outputs\": [", " 1,", " 2", "]" };
|
||||
EXPECT_EQ(ComputeConsoleFoldSpans(arr)[0], 3);
|
||||
|
||||
// Empty block (opener immediately followed by closer) is not worth folding (span < 2).
|
||||
std::vector<std::string> empty = { "{", "}" };
|
||||
EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0);
|
||||
|
||||
// Plain non-JSON lines: no openers.
|
||||
std::vector<std::string> plain = { "hello", "world" };
|
||||
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[0], 0);
|
||||
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[1], 0);
|
||||
|
||||
// A string value merely containing a brace does not open a block (last char isn't '{').
|
||||
std::vector<std::string> str = { "\"note\": \"a { brace\",", "\"x\": 1" };
|
||||
EXPECT_EQ(ComputeConsoleFoldSpans(str)[0], 0);
|
||||
}
|
||||
|
||||
void testRendererHelpers()
|
||||
{
|
||||
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
|
||||
@@ -2299,26 +2723,34 @@ void testRendererHelpers()
|
||||
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
|
||||
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
|
||||
|
||||
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true, 10, 20, 30};
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
|
||||
// The output filter keys off the line's semantic ConsoleChannel (not a color / prefix).
|
||||
using CC = dragonx::ui::ConsoleChannel;
|
||||
// {text, daemonMessagesEnabled, errorsOnly, rpcTraceEnabled, appMessagesEnabled}
|
||||
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true};
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", CC::Error, filter)); // substring "error"
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Daemon line", CC::Daemon, filter)); // no "error" substring
|
||||
filter.text.clear();
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
||||
// Daemon/Xmrig channels hidden while daemonMessagesEnabled=false.
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("hashrate 1000", CC::Xmrig, filter));
|
||||
// Rpc channel hidden while rpcTraceEnabled=false, shown once enabled.
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
|
||||
filter.rpcTraceEnabled = true;
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
|
||||
filter.daemonMessagesEnabled = true;
|
||||
// Errors-only keeps only the Error channel.
|
||||
filter.errorsOnly = true;
|
||||
filter.text.clear();
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", 20, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", 30, filter));
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", CC::Error, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", CC::Info, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));
|
||||
|
||||
// "[app] ..." lines are filtered by prefix (they share the info color), not by color.
|
||||
// App channel is toggled by appMessagesEnabled; other channels are unaffected by it.
|
||||
filter.errorsOnly = false;
|
||||
filter.appMessagesEnabled = false;
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[app] Price updated", 30, filter));
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] trace", 30, filter));
|
||||
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("trace", CC::Rpc, filter));
|
||||
filter.appMessagesEnabled = true;
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[app] Price updated", 30, filter));
|
||||
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));
|
||||
|
||||
std::vector<dragonx::AddressInfo> poolAddresses;
|
||||
poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
|
||||
@@ -2490,6 +2922,27 @@ void testBalanceAddressListModel()
|
||||
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
|
||||
EXPECT_EQ(rows[0].info->address, std::string("zs-high"));
|
||||
|
||||
// Mining-flagged addresses stay visible at 0 balance even with hide-zero on.
|
||||
dragonx::AddressInfo tMine;
|
||||
tMine.address = "R-miner"; tMine.label = "Payout"; tMine.balance = 0.0; tMine.type = "transparent";
|
||||
std::vector<dragonx::ui::AddressListInput> mineInputs = {
|
||||
{&addresses[0], true, false, false, false, "Vault", "", -1}, // funded
|
||||
{&tMine, false, false, false, /*mining=*/true, "Payout", "", -1}, // 0-balance mining
|
||||
};
|
||||
auto mineRows = dragonx::ui::BuildAddressListRows(mineInputs, "", /*hideZero=*/true, false);
|
||||
EXPECT_EQ(mineRows.size(), static_cast<size_t>(2));
|
||||
bool sawMiner = false;
|
||||
for (const auto& r : mineRows) if (r.info->address == std::string("R-miner")) sawMiner = true;
|
||||
EXPECT_TRUE(sawMiner);
|
||||
|
||||
// Explicit sort order overrides starred: a non-favorite ordered row outranks a favorite.
|
||||
std::vector<dragonx::ui::AddressListInput> ordInputs = {
|
||||
{&addresses[0], true, false, false, false, "Vault", "", 0}, // ordered (0)
|
||||
{&addresses[2], false, false, true, false, "Favorite", "", -1}, // favorite, unordered
|
||||
};
|
||||
auto ordRows = dragonx::ui::BuildAddressListRows(ordInputs, "", false, false);
|
||||
EXPECT_EQ(ordRows[0].info->address, std::string("zs-high"));
|
||||
|
||||
auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f,
|
||||
12.0f, 16.0f, 6.0f, 4.0f, 2.0f);
|
||||
EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
|
||||
@@ -4826,6 +5279,97 @@ void testXmrigLiveInstall()
|
||||
std::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
// Mining pool registry: URL matching + per-pool algo resolution.
|
||||
void testPoolRegistryLookup()
|
||||
{
|
||||
using namespace dragonx::util;
|
||||
EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(2));
|
||||
|
||||
// The algo follows the pool. Note pool.dragonx.cc's stratum host is us.dragonx.cc
|
||||
// (the .cc domain is only the Cloudflare-proxied web/API host).
|
||||
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/dragonx"));
|
||||
EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush"));
|
||||
// Bare host (no port) still matches; scheme + path are tolerated.
|
||||
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc", "rx/hush"), std::string("rx/dragonx"));
|
||||
EXPECT_EQ(resolvePoolAlgo("stratum+tcp://us.dragonx.cc:3333/x", "rx/hush"),
|
||||
std::string("rx/dragonx"));
|
||||
// Unknown host -> fallback algo.
|
||||
EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush"));
|
||||
|
||||
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") != nullptr);
|
||||
EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr);
|
||||
// A mismatched explicit port must NOT match a known pool.
|
||||
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:9999") == nullptr);
|
||||
}
|
||||
|
||||
// Schema-aware pool hashrate parsing (the two pools speak different APIs).
|
||||
void testPoolHashrateParsing()
|
||||
{
|
||||
using namespace dragonx::util;
|
||||
bool ok = false;
|
||||
|
||||
// pool.dragonx.is custom schema: pools.dragonx.hashrate
|
||||
const std::string isJson =
|
||||
R"({"pools":{"dragonx":{"hashrate":27670.14,"workerCount":12}},"global":{"hashrate":30000.0}})";
|
||||
double hr = parsePoolHashrate(PoolStatsSchema::DragonXIs, isJson, "", ok);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_NEAR(hr, 27670.14, 0.01);
|
||||
|
||||
// Miningcore schema: the requested pool id selects the entry (not just the first).
|
||||
const std::string ccJson =
|
||||
R"({"pools":[)"
|
||||
R"({"id":"dragonx-solo","poolStats":{"poolHashrate":88780.0}},)"
|
||||
R"({"id":"dragonx-pplns","poolStats":{"poolHashrate":1585.9}}]})";
|
||||
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_NEAR(hr, 1585.9, 0.01);
|
||||
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-solo", ok);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_NEAR(hr, 88780.0, 0.01);
|
||||
|
||||
// Missing pool id / malformed / wrong-schema input all fail closed (no silent 0).
|
||||
parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
|
||||
EXPECT_FALSE(ok);
|
||||
parsePoolHashrate(PoolStatsSchema::DragonXIs, "not json", "", ok);
|
||||
EXPECT_FALSE(ok);
|
||||
parsePoolHashrate(PoolStatsSchema::Miningcore, "{}", "dragonx-pplns", ok);
|
||||
EXPECT_FALSE(ok);
|
||||
parsePoolHashrate(PoolStatsSchema::DragonXIs, ccJson, "", ok); // .cc JSON, .is parser
|
||||
EXPECT_FALSE(ok);
|
||||
}
|
||||
|
||||
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
|
||||
void testPoolWeightedSelection()
|
||||
{
|
||||
using namespace dragonx::util;
|
||||
std::mt19937 rng(12345u); // fixed seed -> deterministic
|
||||
|
||||
std::vector<PoolHashrate> pools = {
|
||||
{"big", 100000.0, true}, // large -> low weight
|
||||
{"small", 1000.0, true}, // small -> high weight (favored)
|
||||
};
|
||||
int smallCount = 0, bigCount = 0;
|
||||
for (int i = 0; i < 2000; ++i) {
|
||||
const std::string pick = chooseWeightedPool(pools, /*currentId=*/"", rng);
|
||||
if (pick == "small") ++smallCount;
|
||||
else if (pick == "big") ++bigCount;
|
||||
}
|
||||
EXPECT_TRUE(smallCount > bigCount * 5); // ~100:1 by weight
|
||||
|
||||
// Only one usable pool -> always chosen; no usable pool -> keep current.
|
||||
std::vector<PoolHashrate> one = {{"a", 5.0, true}, {"b", 1.0, false}};
|
||||
EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a"));
|
||||
std::vector<PoolHashrate> none = {{"a", 5.0, false}, {"b", 1.0, false}};
|
||||
EXPECT_EQ(chooseWeightedPool(none, "keepme", rng), std::string("keepme"));
|
||||
|
||||
// Incumbent stickiness: with equal hashrates the current pool wins the majority.
|
||||
std::vector<PoolHashrate> equal = {{"x", 1000.0, true}, {"y", 1000.0, true}};
|
||||
int stay = 0;
|
||||
for (int i = 0; i < 2000; ++i)
|
||||
if (chooseWeightedPool(equal, "x", rng) == "x") ++stay;
|
||||
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -4846,6 +5390,13 @@ int main()
|
||||
testDaemonShutdownPolicy();
|
||||
testDaemonLifecycleExecution();
|
||||
testDaemonLifecycleAdapters();
|
||||
testConsoleTextLayout();
|
||||
testConsoleModel();
|
||||
testPortfolioHelpers();
|
||||
testWalletStateAddressListRebuild();
|
||||
testConsoleFoldSpans();
|
||||
testConsoleScrollController();
|
||||
testConsoleSelectionController();
|
||||
testRendererHelpers();
|
||||
testConsoleInputModel();
|
||||
testMiningBenchmarkModel();
|
||||
@@ -4903,6 +5454,9 @@ int main()
|
||||
testDaemonReleaseListParsing();
|
||||
testLiteServerHostParsing();
|
||||
testLiteOfficialServerDetection();
|
||||
testPoolRegistryLookup();
|
||||
testPoolHashrateParsing();
|
||||
testPoolWeightedSelection();
|
||||
testAtomicFileWrite();
|
||||
testAddressChecksumValidation();
|
||||
testLiteServerProbeLive();
|
||||
|
||||
Reference in New Issue
Block a user