Refactor app services and stabilize refresh/UI flows

- Add refresh scheduler and network refresh service boundaries for typed
  refresh results, ordered RPC collectors, applicators, and price parsing.
- Add daemon lifecycle and wallet security workflow helpers while preserving
  App-owned command RPC, decrypt, cancellation, and UI handoff behavior.
- Split balance, console, mining, amount formatting, and async task logic into
  focused modules with expanded Phase 4 test coverage.
- Fix market price loading by triggering price refresh immediately, avoiding
  queue-pressure drops, tracking loading/error state, and adding translations.
- Polish send, explorer, peers, settings, theme/schema, and related tab UI.
- Replace checked-in generated language headers with build-generated resources.
- Document the cleanup audit, UI static-state guidance, and architecture updates.
This commit is contained in:
dan_s
2026-04-29 12:47:57 -05:00
parent 9e1b1397ad
commit d684db446e
95 changed files with 8776 additions and 37563 deletions

View File

@@ -6,6 +6,7 @@
#include "../../app.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -121,33 +122,15 @@ void RenderAboutDialog(App* app, bool* p_open)
// Links
if (material::StyledButton(TR("about_website"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
#ifdef _WIN32
system("start https://dragonx.is");
#elif __APPLE__
system("open https://dragonx.is");
#else
system("xdg-open https://dragonx.is &");
#endif
util::Platform::openUrl("https://dragonx.is");
}
ImGui::SameLine();
if (material::StyledButton(TR("about_github"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
#ifdef _WIN32
system("start https://git.dragonx.is/dragonx/ObsidianDragon");
#elif __APPLE__
system("open https://git.dragonx.is/dragonx/ObsidianDragon");
#else
system("xdg-open https://git.dragonx.is/dragonx/ObsidianDragon &");
#endif
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
}
ImGui::SameLine();
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
#ifdef _WIN32
system("start https://explorer.dragonx.is");
#elif __APPLE__
system("open https://explorer.dragonx.is");
#else
system("xdg-open https://explorer.dragonx.is &");
#endif
util::Platform::openUrl("https://explorer.dragonx.is");
}
ImGui::Spacing();

View File

@@ -10,6 +10,7 @@
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <cstring>
#include <memory>
namespace dragonx {
@@ -35,6 +36,12 @@ static data::AddressBook& getAddressBook() {
return *s_address_book;
}
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
if (destSize == 0) return;
std::strncpy(dest, source.c_str(), destSize - 1);
dest[destSize - 1] = '\0';
}
void AddressBookDialog::show()
{
s_open = true;
@@ -66,15 +73,101 @@ void AddressBookDialog::render(App* app)
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button");
auto clearEditFields = []() {
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
s_edit_notes[0] = '\0';
};
auto loadEditFields = [](const data::AddressBookEntry& entry) {
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
};
auto renderEntryDialog = [&]() {
bool isEdit = s_show_edit_dialog;
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
if (!*open) return;
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
Layout::kDialogCompactBottomRatio(), id)) {
ImGui::Text("%s", TR("label"));
ImGui::SetNextItemWidth(formW);
ImGui::InputText(isEdit ? "##EditLabel" : "##AddLabel", s_edit_label, sizeof(s_edit_label));
ImGui::Spacing();
ImGui::Text("%s", TR("address_label"));
ImGui::SetNextItemWidth(formW);
ImGui::InputText(isEdit ? "##EditAddress" : "##AddAddress", s_edit_address, sizeof(s_edit_address));
if (!isEdit) {
ImGui::SameLine();
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
}
}
ImGui::Spacing();
ImGui::Text("%s", TR("notes_optional"));
ImGui::SetNextItemWidth(formW);
ImGui::InputTextMultiline(isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
float totalActionsW = actionW * 2.0f + actionGap;
material::BeginOverlayDialogFooter(totalActionsW);
if (!canSubmit) ImGui::BeginDisabled();
const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
if (isEdit) {
if (getAddressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
} else {
Notifications::instance().error(TR("address_book_update_failed"));
}
} else {
if (getAddressBook().addEntry(entry)) {
Notifications::instance().success(TR("address_book_added"));
s_show_add_dialog = false;
} else {
Notifications::instance().error(TR("address_book_exists"));
}
}
}
if (!canSubmit) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap);
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
*open = false;
}
material::EndOverlayDialog();
}
};
if (material::BeginOverlayDialog(TR("address_book_title"), &s_open, win.width, 0.94f)) {
auto& book = getAddressBook();
// Toolbar
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
s_show_add_dialog = true;
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
s_edit_notes[0] = '\0';
clearEditFields();
}
ImGui::SameLine();
@@ -86,9 +179,7 @@ void AddressBookDialog::render(App* app)
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
const auto& entry = book.entries()[s_selected_index];
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
loadEditFields(entry);
s_show_edit_dialog = true;
}
}
@@ -153,9 +244,7 @@ void AddressBookDialog::render(App* app)
// Double-click to edit
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
loadEditFields(entry);
s_show_edit_dialog = true;
}
@@ -186,123 +275,8 @@ void AddressBookDialog::render(App* app)
ImGui::TextDisabled(TR("address_book_count"), book.size());
material::EndOverlayDialog();
}
// Add dialog
if (s_show_add_dialog) {
ImGui::OpenPopup("Add Address");
}
// Re-use center for sub-dialogs
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("Add Address", &s_show_add_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
material::Type().text(material::TypeStyle::H6, TR("address_book_add"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Text("%s", TR("label"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputText("##AddLabel", s_edit_label, sizeof(s_edit_label));
ImGui::Spacing();
ImGui::Text("%s", TR("address_label"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputText("##AddAddress", s_edit_address, sizeof(s_edit_address));
ImGui::SameLine();
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) {
strncpy(s_edit_address, clipboard, sizeof(s_edit_address) - 1);
}
}
ImGui::Spacing();
ImGui::Text("%s", TR("notes_optional"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputTextMultiline("##AddNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool can_add = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
if (!can_add) ImGui::BeginDisabled();
if (material::StyledButton(TR("add"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
if (getAddressBook().addEntry(entry)) {
Notifications::instance().success(TR("address_book_added"));
s_show_add_dialog = false;
} else {
Notifications::instance().error(TR("address_book_exists"));
}
}
if (!can_add) ImGui::EndDisabled();
ImGui::SameLine();
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
s_show_add_dialog = false;
}
ImGui::EndPopup();
}
// Edit dialog
if (s_show_edit_dialog) {
ImGui::OpenPopup("Edit Address");
}
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
if (ImGui::BeginPopupModal("Edit Address", &s_show_edit_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
material::Type().text(material::TypeStyle::H6, TR("address_book_edit"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Text("%s", TR("label"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputText("##EditLabel", s_edit_label, sizeof(s_edit_label));
ImGui::Spacing();
ImGui::Text("%s", TR("address_label"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputText("##EditAddress", s_edit_address, sizeof(s_edit_address));
ImGui::Spacing();
ImGui::Text("%s", TR("notes_optional"));
ImGui::SetNextItemWidth(addrInput.width);
ImGui::InputTextMultiline("##EditNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
bool can_save = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
if (!can_save) ImGui::BeginDisabled();
if (material::StyledButton(TR("save"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
if (getAddressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
} else {
Notifications::instance().error(TR("address_book_update_failed"));
}
}
if (!can_save) ImGui::EndDisabled();
ImGui::SameLine();
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
s_show_edit_dialog = false;
}
ImGui::EndPopup();
}
renderEntryDialog();
}
} // namespace ui

View File

@@ -12,9 +12,8 @@
#include "../../app.h"
#include "../../util/i18n.h"
#include "../material/draw_helpers.h"
#include "../material/typography.h"
#include "../material/project_icons.h"
#include "../theme.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "imgui.h"
namespace dragonx {
@@ -22,8 +21,6 @@ namespace ui {
class AddressLabelDialog {
public:
static constexpr const char* kPickaxeGlyph = "\xEE\x80\x81";
static bool drawIconByName(ImDrawList* dl,
const std::string& name,
ImVec2 center,
@@ -31,23 +28,7 @@ public:
ImU32 color,
ImFont* iconFont,
float iconFontSize) {
if (name == "pickaxe") {
ImFont* pickaxeFont = material::Typography::instance().pickaxeFontForSize(iconFontSize);
if (!pickaxeFont) return false;
ImVec2 iSz = pickaxeFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, kPickaxeGlyph);
dl->AddText(pickaxeFont, iconFontSize,
ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, kPickaxeGlyph);
return true;
}
const char* glyph = iconGlyphForName(name);
if (!glyph || !iconFont) return false;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, glyph);
dl->AddText(iconFont, iconFontSize,
ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, glyph);
return true;
return material::project_icons::drawByName(dl, name, center, color, iconFont, iconFontSize);
}
static void show(App* app, const std::string& address, bool isZ) {
@@ -64,8 +45,8 @@ public:
s_label[sizeof(s_label) - 1] = '\0';
std::string existingIcon = app->getAddressIcon(address);
for (int i = 0; i < kIconCount; ++i) {
if (kIconNames[i] == existingIcon) {
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
if (material::project_icons::walletIconName(i) == existingIcon) {
s_selectedIcon = i;
break;
}
@@ -134,13 +115,14 @@ public:
// Build filtered index list
std::vector<int> visible;
visible.reserve(kIconCount);
visible.reserve(material::project_icons::walletIconCount());
{
// Simple case-insensitive substring match on icon name
std::string needle(s_iconSearch);
for (char& c : needle) c = (char)std::tolower((unsigned char)c);
for (int i = 0; i < kIconCount; ++i) {
if (needle.empty() || std::strstr(kIconNames[i], needle.c_str()) != nullptr)
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
const char* iconName = material::project_icons::walletIconName(i);
if (needle.empty() || std::strstr(iconName, needle.c_str()) != nullptr)
visible.push_back(i);
}
}
@@ -162,6 +144,7 @@ public:
int col = 0;
for (int vi = 0; vi < (int)visible.size(); ++vi) {
int i = visible[vi];
const char* iconName = material::project_icons::walletIconName(i);
if (col != 0) ImGui::SameLine(0, 4.0f * dp);
ImVec2 pos = ImGui::GetCursorScreenPos();
ImVec2 mn = pos;
@@ -179,7 +162,7 @@ public:
// Icon centered in cell
drawIconByName(dl,
kIconNames[i],
iconName,
ImVec2(mn.x + cellSz * 0.5f, mn.y + cellSz * 0.5f),
iconFsz,
sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()),
@@ -189,7 +172,7 @@ public:
ImGui::PushID(i);
ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz));
if (ImGui::IsItemClicked()) s_selectedIcon = i;
if (hov) ImGui::SetTooltip("%s", kIconNames[i]);
if (hov) ImGui::SetTooltip("%s", iconName);
ImGui::PopID();
col = (col + 1) % cols;
@@ -239,7 +222,7 @@ public:
// Apply changes
s_app->setAddressLabel(s_address, s_label);
if (s_selectedIcon >= 0)
s_app->setAddressIcon(s_address, kIconNames[s_selectedIcon]);
s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon));
else
s_app->setAddressIcon(s_address, "");
s_open = false;
@@ -260,123 +243,10 @@ private:
static inline int s_selectedIcon = -1;
static inline char s_iconSearch[64] = {};
// Icon palette — wallet-relevant Material Design icons
static inline const char* kIconNames[] = {
// Finance / Crypto
"savings", "account_balance", "account_balance_wallet", "wallet",
"payments", "credit_card", "local_atm", "diamond",
"attach_money", "currency_bitcoin", "currency_exchange", "balance",
"calculate", "trending_up", "euro", "leaderboard",
"paid", "sell", "receipt", "percent",
"price_change", "price_check", "toll", "money",
// Charts / Analytics
"show_chart", "candlestick_chart", "bar_chart", "pie_chart",
"area_chart", "stacked_bar_chart", "waterfall_chart", "scatter_plot",
"query_stats", "speed", "donut_large",
// Mining / Tools
"pickaxe",
"hardware", "construction", "handyman", "build",
"carpenter", "plumbing", "home_repair_service", "precision_manufacturing",
"factory", "warehouse", "inventory", "recycling",
"oil_barrel", "offline_bolt", "thunderstorm", "terminal",
"storage", "memory", "developer_board",
// Security / Auth
"shield", "security", "lock", "swap_horiz",
"verified", "verified_user", "key", "badge",
// Commerce / Business
"store", "storefront", "shopping_bag", "business",
"work", "real_estate_agent", "gavel", "local_shipping",
// Home / Property
"home", "apartment", "cottage", "landscape",
// People / Identity
"account_circle", "face", "manage_accounts", "groups", "mood",
// Travel / Transport
"rocket_launch", "flight", "directions_car", "travel_explore",
"explore", "location_on", "map", "luggage", "anchor",
// Nature / Outdoors
"public", "language", "forest", "park",
"water_drop", "beach_access", "energy_savings_leaf", "solar_power",
// Social / Lifestyle
"favorite", "star", "celebration", "casino",
"auto_awesome", "emoji_events", "military_tech", "flag",
// Tech / Science
"bolt", "tungsten", "lightbulb", "insights",
"hub", "token", "electric_bolt", "science", "biotech",
// Organisation
"category", "label", "school", "local_hospital", "local_florist",
// Food / Drink
"coffee", "restaurant", "wine_bar", "liquor",
"outdoor_grill", "nightlife", "sports_bar",
// Recreation / Health
"pets", "fitness_center", "spa", "self_improvement",
"psychology", "sports_soccer", "sports_esports",
"hiking", "palette", "museum", "church", "surfing",
// Community
"redeem", "handshake", "healing", "volunteer",
"stadium", "temple_buddhist", "theater_comedy", "watch",
};
static inline const char* kIconGlyphs[] = {
// Finance / Crypto
ICON_MD_SAVINGS, ICON_MD_ACCOUNT_BALANCE, ICON_MD_ACCOUNT_BALANCE_WALLET, ICON_MD_WALLET,
ICON_MD_PAYMENTS, ICON_MD_CREDIT_CARD, ICON_MD_LOCAL_ATM, ICON_MD_DIAMOND,
ICON_MD_ATTACH_MONEY, ICON_MD_CURRENCY_BITCOIN, ICON_MD_CURRENCY_EXCHANGE, ICON_MD_BALANCE,
ICON_MD_CALCULATE, ICON_MD_TRENDING_UP, ICON_MD_EURO, ICON_MD_LEADERBOARD,
ICON_MD_PAID, ICON_MD_SELL, ICON_MD_RECEIPT, ICON_MD_PERCENT,
ICON_MD_PRICE_CHANGE, ICON_MD_PRICE_CHECK, ICON_MD_TOLL, ICON_MD_MONEY,
// Charts / Analytics
ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART, ICON_MD_BAR_CHART, ICON_MD_PIE_CHART,
ICON_MD_AREA_CHART, ICON_MD_STACKED_BAR_CHART, ICON_MD_WATERFALL_CHART, ICON_MD_SCATTER_PLOT,
ICON_MD_QUERY_STATS, ICON_MD_SPEED, ICON_MD_DONUT_LARGE,
// Mining / Tools
nullptr,
ICON_MD_HARDWARE, ICON_MD_CONSTRUCTION, ICON_MD_HANDYMAN, ICON_MD_BUILD,
ICON_MD_CARPENTER, ICON_MD_PLUMBING, ICON_MD_HOME_REPAIR_SERVICE, ICON_MD_PRECISION_MANUFACTURING,
ICON_MD_FACTORY, ICON_MD_WAREHOUSE, ICON_MD_INVENTORY, ICON_MD_RECYCLING,
ICON_MD_OIL_BARREL, ICON_MD_OFFLINE_BOLT, ICON_MD_THUNDERSTORM, ICON_MD_TERMINAL,
ICON_MD_STORAGE, ICON_MD_MEMORY, ICON_MD_DEVELOPER_BOARD,
// Security / Auth
ICON_MD_SHIELD, ICON_MD_SECURITY, ICON_MD_LOCK, ICON_MD_SWAP_HORIZ,
ICON_MD_VERIFIED, ICON_MD_VERIFIED_USER, ICON_MD_KEY, ICON_MD_BADGE,
// Commerce / Business
ICON_MD_STORE, ICON_MD_STOREFRONT, ICON_MD_SHOPPING_BAG, ICON_MD_BUSINESS,
ICON_MD_WORK, ICON_MD_REAL_ESTATE_AGENT, ICON_MD_GAVEL, ICON_MD_LOCAL_SHIPPING,
// Home / Property
ICON_MD_HOME, ICON_MD_APARTMENT, ICON_MD_COTTAGE, ICON_MD_LANDSCAPE,
// People / Identity
ICON_MD_ACCOUNT_CIRCLE, ICON_MD_FACE, ICON_MD_MANAGE_ACCOUNTS, ICON_MD_GROUPS, ICON_MD_MOOD,
// Travel / Transport
ICON_MD_ROCKET_LAUNCH, ICON_MD_FLIGHT, ICON_MD_DIRECTIONS_CAR, ICON_MD_TRAVEL_EXPLORE,
ICON_MD_EXPLORE, ICON_MD_LOCATION_ON, ICON_MD_MAP, ICON_MD_LUGGAGE, ICON_MD_ANCHOR,
// Nature / Outdoors
ICON_MD_PUBLIC, ICON_MD_LANGUAGE, ICON_MD_FOREST, ICON_MD_PARK,
ICON_MD_WATER_DROP, ICON_MD_BEACH_ACCESS, ICON_MD_ENERGY_SAVINGS_LEAF, ICON_MD_SOLAR_POWER,
// Social / Lifestyle
ICON_MD_FAVORITE, ICON_MD_STAR, ICON_MD_CELEBRATION, ICON_MD_CASINO,
ICON_MD_AUTO_AWESOME, ICON_MD_EMOJI_EVENTS, ICON_MD_MILITARY_TECH, ICON_MD_FLAG,
// Tech / Science
ICON_MD_BOLT, ICON_MD_TUNGSTEN, ICON_MD_LIGHTBULB, ICON_MD_INSIGHTS,
ICON_MD_HUB, ICON_MD_TOKEN, ICON_MD_ELECTRIC_BOLT, ICON_MD_SCIENCE, ICON_MD_BIOTECH,
// Organisation
ICON_MD_CATEGORY, ICON_MD_LABEL, ICON_MD_SCHOOL, ICON_MD_LOCAL_HOSPITAL, ICON_MD_LOCAL_FLORIST,
// Food / Drink
ICON_MD_LOCAL_CAFE, ICON_MD_RESTAURANT, ICON_MD_WINE_BAR, ICON_MD_LIQUOR,
ICON_MD_OUTDOOR_GRILL, ICON_MD_NIGHTLIFE, ICON_MD_SPORTS_BAR,
// Recreation / Health
ICON_MD_PETS, ICON_MD_FITNESS_CENTER, ICON_MD_SPA, ICON_MD_SELF_IMPROVEMENT,
ICON_MD_PSYCHOLOGY, ICON_MD_SPORTS_SOCCER, ICON_MD_SPORTS_ESPORTS,
ICON_MD_HIKING, ICON_MD_PALETTE, ICON_MD_MUSEUM, ICON_MD_CHURCH, ICON_MD_SURFING,
// Community
ICON_MD_REDEEM, ICON_MD_HANDSHAKE, ICON_MD_HEALING, ICON_MD_VOLUNTEER_ACTIVISM,
ICON_MD_STADIUM, ICON_MD_TEMPLE_BUDDHIST, ICON_MD_THEATER_COMEDY, ICON_MD_WATCH,
};
static constexpr int kIconCount = static_cast<int>(std::size(kIconNames));
public:
// Expose for the address list to look up icon glyphs by name
static const char* iconGlyphForName(const std::string& name) {
for (int i = 0; i < kIconCount; ++i)
if (kIconNames[i] == name) return kIconGlyphs[i];
return nullptr;
return material::project_icons::glyphForName(name);
}
};

View File

@@ -0,0 +1,106 @@
#include "balance_address_list.h"
#include <algorithm>
#include <cctype>
#include <cstdio>
namespace dragonx {
namespace ui {
namespace {
bool containsIgnoreCase(const std::string& haystack, const std::string& needle)
{
if (needle.empty()) return true;
auto it = std::search(haystack.begin(), haystack.end(),
needle.begin(), needle.end(),
[](char a, char b) {
return std::tolower(static_cast<unsigned char>(a)) ==
std::tolower(static_cast<unsigned char>(b));
});
return it != haystack.end();
}
} // namespace
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter)
{
if (!input.info) return false;
return containsIgnoreCase(input.info->address, filter) ||
containsIgnoreCase(input.info->label, filter) ||
containsIgnoreCase(input.label, filter);
}
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
const std::string& filter,
bool hideZeroBalances,
bool showHidden)
{
std::vector<AddressListRow> rows;
rows.reserve(inputs.size());
for (const auto& input : inputs) {
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;
rows.push_back({input.info, input.isZ, input.hidden, input.favorite,
input.label, input.icon, input.sortOrder});
}
std::sort(rows.begin(), rows.end(),
[](const AddressListRow& a, const AddressListRow& b) {
bool aHasOrder = a.sortOrder >= 0;
bool bHasOrder = b.sortOrder >= 0;
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
if (a.favorite != b.favorite) return a.favorite > b.favorite;
if (a.isZ != b.isZ) return a.isZ > b.isZ;
return a.info->balance > b.info->balance;
});
return rows;
}
AddressRowLayout ComputeAddressRowLayout(float rowX,
float rowY,
float rowWidth,
float rowHeight,
float rowPadLeft,
float rowIconSize,
float spacingMd,
float spacingSm,
float spacingXs)
{
AddressRowLayout layout;
layout.contentStartX = rowX + rowPadLeft;
layout.contentStartY = rowY + spacingMd;
layout.buttonSize = rowHeight - spacingMd * 2.0f;
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
const float rightEdge = rowX + rowWidth;
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
layout.visibilityButton = {visibilityX, buttonY, layout.buttonSize, layout.buttonSize};
layout.contentRight = visibilityX - spacingSm;
layout.labelX = layout.contentStartX + rowIconSize * 2.0f + spacingMd;
return layout;
}
std::string FormatAddressUsdValue(double balance, double priceUsd)
{
if (priceUsd <= 0.0 || balance <= 0.0) return {};
char buffer[32];
const double usdValue = balance * priceUsd;
if (usdValue >= 1.0) {
std::snprintf(buffer, sizeof(buffer), "$%.2f", usdValue);
} else if (usdValue >= 0.01) {
std::snprintf(buffer, sizeof(buffer), "$%.4f", usdValue);
} else {
std::snprintf(buffer, sizeof(buffer), "$%.6f", usdValue);
}
return buffer;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,65 @@
#pragma once
#include "data/wallet_state.h"
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
struct AddressListInput {
const AddressInfo* info = nullptr;
bool isZ = false;
bool hidden = false;
bool favorite = false;
std::string label;
std::string icon;
int sortOrder = -1;
};
struct AddressListRow {
const AddressInfo* info = nullptr;
bool isZ = false;
bool hidden = false;
bool favorite = false;
std::string label;
std::string icon;
int sortOrder = -1;
};
struct AddressRowRect {
float x = 0.0f;
float y = 0.0f;
float width = 0.0f;
float height = 0.0f;
};
struct AddressRowLayout {
float contentStartX = 0.0f;
float contentStartY = 0.0f;
float labelX = 0.0f;
float contentRight = 0.0f;
float buttonSize = 0.0f;
AddressRowRect favoriteButton;
AddressRowRect visibilityButton;
};
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter);
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
const std::string& filter,
bool hideZeroBalances,
bool showHidden);
AddressRowLayout ComputeAddressRowLayout(float rowX,
float rowY,
float rowWidth,
float rowHeight,
float rowPadLeft,
float rowIconSize,
float spacingMd,
float spacingSm,
float spacingXs);
std::string FormatAddressUsdValue(double balance, double priceUsd);
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,52 @@
#include "balance_recent_tx.h"
#include "../../config/version.h"
#include <cmath>
#include <cstdio>
#include <ctime>
namespace dragonx {
namespace ui {
namespace {
std::string truncateRecentAddress(const std::string& address, int maxLen)
{
if (address.length() <= static_cast<size_t>(maxLen)) return address;
int half = (maxLen - 3) / 2;
return address.substr(0, half) + "..." + address.substr(address.length() - half);
}
std::string formatRecentAmount(const std::string& type, double amount)
{
char buffer[32];
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
type == "send" ? "-" : "+",
std::abs(amount), DRAGONX_TICKER);
return std::string(buffer);
}
std::string recentTimeAgo(int64_t timestamp)
{
if (timestamp <= 0) return "";
int64_t now = static_cast<int64_t>(std::time(nullptr));
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return std::to_string(diff) + "s ago";
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
return std::to_string(diff / 86400) + "d ago";
}
}
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
{
return {
tx.getTypeDisplay(),
truncateRecentAddress(tx.address, addressMaxLen),
formatRecentAmount(tx.type, tx.amount),
recentTimeAgo(tx.timestamp)
};
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,20 @@
#pragma once
#include "../../data/wallet_state.h"
#include <string>
namespace dragonx {
namespace ui {
struct RecentTxDisplay {
std::string typeText;
std::string addressText;
std::string amountText;
std::string timeText;
};
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen);
} // namespace ui
} // namespace dragonx

View File

@@ -3,6 +3,9 @@
// Released under the GPLv3
#include "balance_tab.h"
#include "balance_address_list.h"
#include "balance_tab_helpers.h"
#include "balance_recent_tx.h"
#include "key_export_dialog.h"
#include "qr_popup_dialog.h"
#include "address_label_dialog.h"
@@ -32,92 +35,12 @@
namespace dragonx {
namespace ui {
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
static std::string TrId(const char* tr_key, const char* id) {
std::string s = TR(tr_key);
s += "##";
s += id;
return s;
}
// Case-insensitive substring search
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
if (search.empty()) return true;
std::string s = str, q = search;
std::transform(s.begin(), s.end(), s.begin(), ::tolower);
std::transform(q.begin(), q.end(), q.begin(), ::tolower);
return s.find(q) != std::string::npos;
}
// Relative time string ("2m ago", "3h ago", etc.)
static std::string timeAgo(int64_t timestamp) {
if (timestamp <= 0) return "";
int64_t now = (int64_t)std::time(nullptr);
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return std::to_string(diff) + "s ago";
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
return std::to_string(diff / 86400) + "d ago";
}
// Draw a small transaction-type icon (send=up, receive=down, mined=construction)
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
float cx, float cy, float /*s*/, ImU32 col)
{
using namespace material;
ImFont* iconFont = Type().iconSmall();
const char* icon;
if (type == "send") {
icon = ICON_MD_CALL_MADE;
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
} else {
icon = ICON_MD_CONSTRUCTION;
}
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
}
// Animated balance state — lerps smoothly toward target
static double s_dispTotal = 0.0;
static double s_dispShielded = 0.0;
static double s_dispTransparent = 0.0;
static double s_dispUnconfirmed = 0.0;
// Helper to truncate address for display
static std::string truncateAddress(const std::string& addr, int maxLen = 32) {
if (addr.length() <= static_cast<size_t>(maxLen)) return addr;
int half = (maxLen - 3) / 2;
return addr.substr(0, half) + "..." + addr.substr(addr.length() - half);
}
// Helper to draw a sparkline polyline within a bounding box
static void DrawSparkline(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax,
const std::vector<double>& data, ImU32 color,
float thickness = 1.5f)
{
if (data.size() < 2) return;
double lo = *std::min_element(data.begin(), data.end());
double hi = *std::max_element(data.begin(), data.end());
double range = hi - lo;
if (range < 1e-12) range = 1.0;
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
int n = (int)data.size();
std::vector<ImVec2> pts;
pts.reserve(n);
for (int i = 0; i < n; i++) {
float x = pMin.x + (float)i / (float)(n - 1) * w;
float y = pMax.y - (float)((data[i] - lo) / range) * h;
pts.push_back(ImVec2(x, y));
}
dl->AddPolyline(pts.data(), n, color, ImDrawFlags_None, thickness);
}
// Forward declarations for all layout functions
static void RenderBalanceClassic(App* app);
static void RenderBalanceDonut(App* app);
@@ -1207,51 +1130,37 @@ static void RenderBalanceClassic(App* app)
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float rowY = rowPos.y + rowH * 0.5f;
// Icon
ImU32 iconCol;
if (tx.type == "send")
iconCol = Error();
else if (tx.type == "receive")
iconCol = Success();
else
iconCol = Warning();
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
// Type label
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx_x, rowPos.y + 2 * dp),
OnSurfaceMedium(), tx.getTypeDisplay().c_str());
OnSurfaceMedium(), display.typeText.c_str());
// Address (truncated)
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
dl->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2 * dp),
OnSurfaceDisabled(), trAddr.c_str());
OnSurfaceDisabled(), display.addressText.c_str());
// Amount (right-aligned area)
char amtBuf[32];
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
tx.type == "send" ? "-" : "+",
std::abs(tx.amount), DRAGONX_TICKER);
ImVec2 amtSz = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, amtBuf);
capFont->LegacySize, 10000, 0, display.amountText.c_str());
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size, S.drawElement("tabs.balance", "amount-right-margin").size * hs);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2 * dp),
tx.type == "send" ? Error()
: Success(),
amtBuf);
recentTxAmountColor(tx.type),
display.amountText.c_str());
// Time ago
std::string ago = timeAgo(tx.timestamp);
ImVec2 agoSz = capFont->CalcTextSizeA(
capFont->LegacySize, 10000, 0, ago.c_str());
capFont->LegacySize, 10000, 0, display.timeText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
OnSurfaceDisabled(), ago.c_str());
OnSurfaceDisabled(), display.timeText.c_str());
// Clickable row — hover highlight + navigate to History
float rowW = ImGui::GetContentRegionAvail().x;
@@ -1380,45 +1289,22 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
static float s_copiedTime = 0.0f;
// ---- Build and filter address rows ----
struct AddrRow {
const AddressInfo* info;
bool isZ, hidden, favorite;
std::string label, icon;
int sortOrder;
};
std::vector<AddrRow> rows;
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
std::vector<AddressListInput> rowInputs;
rowInputs.reserve(state.z_addresses.size() + state.t_addresses.size());
auto addRows = [&](const auto& addrs, bool isZ) {
for (const auto& a : addrs) {
std::string filter(addr_search);
std::string addrLabel = app->getAddressLabel(a.address);
if (!containsIgnoreCase(a.address, filter) &&
!containsIgnoreCase(a.label, filter) &&
!containsIgnoreCase(addrLabel, filter)) continue;
bool isHidden = app->isAddressHidden(a.address);
if (isHidden && !s_showHidden) continue;
bool isFav = app->isAddressFavorite(a.address);
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue;
rows.push_back({&a, isZ, isHidden, isFav,
addrLabel, app->getAddressIcon(a.address),
app->getAddressSortOrder(a.address)});
rowInputs.push_back({&a, isZ, isHidden, isFav,
addrLabel, app->getAddressIcon(a.address),
app->getAddressSortOrder(a.address)});
}
};
addRows(state.z_addresses, true);
addRows(state.t_addresses, false);
// Sort: custom order (if any) → favorites → Z first → balance desc
std::sort(rows.begin(), rows.end(),
[](const AddrRow& a, const AddrRow& b) -> bool {
bool aHasOrder = a.sortOrder >= 0;
bool bHasOrder = b.sortOrder >= 0;
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
if (a.favorite != b.favorite) return a.favorite > b.favorite;
if (a.isZ != b.isZ) return a.isZ > b.isZ;
return a.info->balance > b.info->balance;
});
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
// ---- Toolbar: search + checkboxes + create buttons ----
float avail = ImGui::GetContentRegionAvail().x;
@@ -1677,24 +1563,21 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
float cx = rowPos.x + rowPadLeft;
float cy = rowPos.y + Layout::spacingMd();
auto rowLayout = ComputeAddressRowLayout(
rowPos.x, rowPos.y, innerW, rowH, rowPadLeft, rowIconSz,
Layout::spacingMd(), Layout::spacingSm(), Layout::spacingXs());
float cx = rowLayout.contentStartX;
float cy = rowLayout.contentStartY;
// ---- Button zone (right edge): [eye] [star] ----
float btnH = rowH - Layout::spacingMd() * 2.0f;
float btnW = btnH;
float btnGap = Layout::spacingSm();
float btnY = rowPos.y + (rowH - btnH) * 0.5f;
float rightEdge = rowPos.x + innerW;
float starX = rightEdge - btnW - Layout::spacingXs();
float eyeX = starX - btnGap - btnW;
float btnRound = 6.0f * dp;
bool btnClicked = false;
if (!isDragged) {
// Star button
{
ImVec2 bMin(starX, btnY), bMax(starX + btnW, btnY + btnH);
const auto& starRect = rowLayout.favoriteButton;
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
@@ -1703,7 +1586,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon);
ImU32 starCol = row.favorite ? favGoldIcon : (bHov ? OnSurface() : OnSurfaceDisabled());
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(starX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), starCol, starIcon);
ImVec2(starRect.x + (starRect.width - iSz.x) * 0.5f,
starRect.y + (starRect.height - iSz.y) * 0.5f), starCol, starIcon);
if (bHov && mouseClicked) {
if (row.favorite) app->unfavoriteAddress(addr.address);
else app->favoriteAddress(addr.address);
@@ -1715,10 +1599,11 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
// Eye button (zero balance or hidden)
bool showEye = true;
// Always reserve space for both buttons so content doesn't shift
float contentRight = eyeX - Layout::spacingSm();
float contentRight = rowLayout.contentRight;
if (showEye) {
ImVec2 bMin(eyeX, btnY), bMax(eyeX + btnW, btnY + btnH);
const auto& eyeRect = rowLayout.visibilityButton;
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
@@ -1727,7 +1612,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon);
ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled();
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(eyeX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), iconCol, hideIcon);
ImVec2(eyeRect.x + (eyeRect.width - iSz.x) * 0.5f,
eyeRect.y + (eyeRect.height - iSz.y) * 0.5f), iconCol, hideIcon);
if (bHov && mouseClicked) {
if (row.hidden) app->unhideAddress(addr.address);
else app->hideAddress(addr.address);
@@ -1756,7 +1642,7 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
}
// ---- Type label (first line) ----
float labelX = cx + rowIconSz * 2.0f + Layout::spacingMd();
float labelX = rowLayout.labelX;
{
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
@@ -1805,18 +1691,13 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
// ---- USD value (second line, right-aligned) ----
{
double priceUsd = state.market.price_usd;
if (priceUsd > 0.0 && addr.balance > 0.0) {
char usdBuf[32];
double usdVal = addr.balance * priceUsd;
if (usdVal >= 1.0) snprintf(usdBuf, sizeof(usdBuf), "$%.2f", usdVal);
else if (usdVal >= 0.01) snprintf(usdBuf, sizeof(usdBuf), "$%.4f", usdVal);
else snprintf(usdBuf, sizeof(usdBuf), "$%.6f", usdVal);
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdBuf);
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
if (!usdText.empty()) {
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(contentRight - usdSz.x,
cy + body2->LegacySize + Layout::spacingXs()),
OnSurfaceDisabled(), usdBuf);
OnSurfaceDisabled(), usdText.c_str());
}
}
@@ -2006,39 +1887,30 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
const auto& tx = txs[i];
ImVec2 rowPos = ImGui::GetCursorScreenPos();
float rowY = rowPos.y + rowH * 0.5f;
ImU32 iconCol;
if (tx.type == "send") iconCol = Error();
else if (tx.type == "receive") iconCol = Success();
else iconCol = Warning();
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), tx.getTypeDisplay().c_str());
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
dl->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), trAddr.c_str());
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
char amtBuf[32];
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
tx.type == "send" ? "-" : "+",
std::abs(tx.amount), DRAGONX_TICKER);
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, amtBuf);
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.amountText.c_str());
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
float amtX = rightEdge - amtSz.x - std::max(
S.drawElement("tabs.balance", "amount-right-min-margin").size,
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(amtX, rowPos.y + 2 * dp),
tx.type == "send" ? Error() : Success(), amtBuf);
recentTxAmountColor(tx.type), display.amountText.c_str());
std::string ago = timeAgo(tx.timestamp);
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, ago.c_str());
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
OnSurfaceDisabled(), ago.c_str());
OnSurfaceDisabled(), display.timeText.c_str());
float rowW = ImGui::GetContentRegionAvail().x;
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);

View File

@@ -0,0 +1,117 @@
#include "balance_tab_helpers.h"
#include "../../config/version.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/type.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <ctime>
namespace dragonx {
namespace ui {
std::string TrId(const char* trKey, const char* id)
{
std::string value = TR(trKey);
value += "##";
value += id;
return value;
}
bool containsIgnoreCase(const std::string& value, const std::string& search)
{
if (search.empty()) return true;
std::string haystack = value;
std::string needle = search;
std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
std::transform(needle.begin(), needle.end(), needle.begin(), ::tolower);
return haystack.find(needle) != std::string::npos;
}
std::string timeAgo(int64_t timestamp)
{
if (timestamp <= 0) return "";
int64_t now = static_cast<int64_t>(std::time(nullptr));
int64_t diff = now - timestamp;
if (diff < 0) diff = 0;
if (diff < 60) return std::to_string(diff) + "s ago";
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
return std::to_string(diff / 86400) + "d ago";
}
std::string truncateAddress(const std::string& address, int maxLen)
{
if (address.length() <= static_cast<size_t>(maxLen)) return address;
int half = (maxLen - 3) / 2;
return address.substr(0, half) + "..." + address.substr(address.length() - half);
}
ImU32 recentTxIconColor(const std::string& type)
{
using namespace material;
if (type == "send") return Error();
if (type == "receive") return Success();
return Warning();
}
ImU32 recentTxAmountColor(const std::string& type)
{
using namespace material;
return type == "send" ? Error() : Success();
}
std::string formatRecentTxAmount(const std::string& type, double amount)
{
char buffer[32];
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
type == "send" ? "-" : "+",
std::abs(amount), DRAGONX_TICKER);
return std::string(buffer);
}
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
float centerX, float centerY, float, ImU32 color)
{
using namespace material;
ImFont* iconFont = Type().iconSmall();
const char* icon = ICON_MD_CONSTRUCTION;
if (type == "send") {
icon = ICON_MD_CALL_MADE;
} else if (type == "receive") {
icon = ICON_MD_CALL_RECEIVED;
}
ImVec2 size = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
drawList->AddText(iconFont, iconFont->LegacySize,
ImVec2(centerX - size.x * 0.5f, centerY - size.y * 0.5f), color, icon);
}
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
const std::vector<double>& data, ImU32 color, float thickness)
{
if (data.size() < 2) return;
double low = *std::min_element(data.begin(), data.end());
double high = *std::max_element(data.begin(), data.end());
double range = high - low;
if (range < 1e-12) range = 1.0;
float width = max.x - min.x;
float height = max.y - min.y;
int count = static_cast<int>(data.size());
std::vector<ImVec2> points;
points.reserve(count);
for (int i = 0; i < count; i++) {
float x = min.x + static_cast<float>(i) / static_cast<float>(count - 1) * width;
float y = max.y - static_cast<float>((data[i] - low) / range) * height;
points.push_back(ImVec2(x, y));
}
drawList->AddPolyline(points.data(), count, color, ImDrawFlags_None, thickness);
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,27 @@
#pragma once
#include "imgui.h"
#include <cstdint>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
std::string TrId(const char* trKey, const char* id);
bool containsIgnoreCase(const std::string& value, const std::string& search);
std::string timeAgo(int64_t timestamp);
std::string truncateAddress(const std::string& address, int maxLen = 32);
ImU32 recentTxIconColor(const std::string& type);
ImU32 recentTxAmountColor(const std::string& type);
std::string formatRecentTxAmount(const std::string& type, double amount);
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
float centerX, float centerY, float size, ImU32 color);
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
const std::vector<double>& data, ImU32 color,
float thickness = 1.5f);
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,136 @@
#include "console_command_reference.h"
#include <cstddef>
namespace dragonx {
namespace ui {
namespace {
template <size_t N>
constexpr int CountOf(const ConsoleCommandEntry (&)[N])
{
return static_cast<int>(N);
}
const ConsoleCommandEntry kControlCommands[] = {
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
{"getinfo", "Get general info about the node", ""},
{"stop", "Stop the daemon", ""},
};
const ConsoleCommandEntry kNetworkCommands[] = {
{"getnetworkinfo", "Return P2P network state info", ""},
{"getpeerinfo", "Get data about each connected peer", ""},
{"getconnectioncount", "Get number of peer connections", ""},
{"getnettotals", "Get network traffic statistics", ""},
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
{"listbanned", "List all banned IPs/subnets", ""},
{"clearbanned", "Clear all banned IPs", ""},
{"ping", "Ping all peers to measure round-trip time", ""},
};
const ConsoleCommandEntry kBlockchainCommands[] = {
{"getblockchaininfo", "Get current blockchain state", ""},
{"getblockcount", "Get number of blocks in longest chain", ""},
{"getbestblockhash", "Get hash of the tip block", ""},
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
{"getblockhash", "Get block hash at a given height", "height"},
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
{"getdifficulty", "Get proof-of-work difficulty", ""},
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
{"getmempoolinfo", "Get mempool state info", ""},
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
{"coinsupply", "Get coin supply information", "[height]"},
{"getchaintips", "Get all known chain tips", ""},
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
};
const ConsoleCommandEntry kMiningCommands[] = {
{"getmininginfo", "Get mining-related information", ""},
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
{"getgenerate", "Check if the node is mining", ""},
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
};
const ConsoleCommandEntry kWalletCommands[] = {
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
{"getnewaddress", "Generate a new transparent address", ""},
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
{"listaddresses", "List all transparent addresses", ""},
{"z_listaddresses", "List all z-addresses", ""},
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
{"z_listoperationids", "List all async z operation IDs", ""},
{"getwalletinfo", "Get wallet state info", ""},
{"backupwallet", "Back up wallet to a file", "\"destination\""},
{"dumpprivkey", "Dump private key for an address", "\"address\""},
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
{"importwallet", "Import wallet from a dump file", "\"filename\""},
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
{"settxfee", "Set the transaction fee per kB", "amount"},
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
{"walletlock", "Lock the wallet", ""},
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
};
const ConsoleCommandEntry kRawTransactionCommands[] = {
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
};
const ConsoleCommandEntry kUtilityCommands[] = {
{"validateaddress", "Validate a transparent address", "\"address\""},
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
};
} // namespace
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
{
static const std::vector<ConsoleCommandCategory> categories = {
{"Control", kControlCommands, CountOf(kControlCommands)},
{"Network", kNetworkCommands, CountOf(kNetworkCommands)},
{"Blockchain", kBlockchainCommands, CountOf(kBlockchainCommands)},
{"Mining", kMiningCommands, CountOf(kMiningCommands)},
{"Wallet", kWalletCommands, CountOf(kWalletCommands)},
{"Raw Transactions", kRawTransactionCommands, CountOf(kRawTransactionCommands)},
{"Utility", kUtilityCommands, CountOf(kUtilityCommands)},
};
return categories;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,23 @@
#pragma once
#include <vector>
namespace dragonx {
namespace ui {
struct ConsoleCommandEntry {
const char* name;
const char* desc;
const char* params;
};
struct ConsoleCommandCategory {
const char* name;
const ConsoleCommandEntry* commands;
int count;
};
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,240 @@
#include "console_input_model.h"
#include <algorithm>
#include <cctype>
#include <sstream>
namespace dragonx {
namespace ui {
const std::vector<std::string>& ConsoleRpcCommandNames()
{
static const std::vector<std::string> commands = {
"help", "getinfo", "stop",
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
"addnode", "setban", "listbanned", "clearbanned", "ping",
"getblockchaininfo", "getblockcount", "getbestblockhash",
"getblock", "getblockhash", "getblockheader", "getdifficulty",
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
"getmininginfo", "setgenerate", "getgenerate",
"getnetworkhashps", "getblocksubsidy",
"getbalance", "z_gettotalbalance", "z_getbalances",
"getnewaddress", "z_getnewaddress",
"listaddresses", "z_listaddresses",
"sendtoaddress", "z_sendmany",
"listtransactions", "listunspent", "z_listunspent",
"z_getoperationstatus", "z_getoperationresult",
"getwalletinfo", "backupwallet",
"dumpprivkey", "importprivkey",
"z_exportkey", "z_importkey",
"signmessage", "settxfee",
"createrawtransaction", "decoderawtransaction",
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
"validateaddress", "z_validateaddress", "estimatefee",
"clear"
};
return commands;
}
void AppendConsoleHistory(std::vector<std::string>& history,
const std::string& command,
std::size_t maxEntries)
{
if (command.empty()) return;
if (history.empty() || history.back() != command) {
history.push_back(command);
while (history.size() > maxEntries) {
history.erase(history.begin());
}
}
}
int NavigateConsoleHistoryIndex(int currentIndex, std::size_t historySize, bool up)
{
if (historySize == 0) return -1;
if (up) {
if (currentIndex < 0) return static_cast<int>(historySize) - 1;
if (currentIndex > 0) return currentIndex - 1;
return currentIndex;
}
if (currentIndex >= 0) {
int next = currentIndex + 1;
if (next >= static_cast<int>(historySize)) return -1;
return next;
}
return currentIndex;
}
std::string ConsoleHistoryEntry(const std::vector<std::string>& history, int historyIndex)
{
if (historyIndex < 0 || historyIndex >= static_cast<int>(history.size())) return {};
return history[static_cast<std::size_t>(historyIndex)];
}
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input)
{
ConsoleCompletionResult result;
if (input.empty()) return result;
for (const auto& command : ConsoleRpcCommandNames()) {
if (command.compare(0, input.size(), input) == 0) {
result.matches.push_back(command);
}
}
if (result.matches.empty()) return result;
result.commonPrefix = result.matches.front();
for (std::size_t matchIndex = 1; matchIndex < result.matches.size(); ++matchIndex) {
const auto& match = result.matches[matchIndex];
std::size_t prefixLength = 0;
while (prefixLength < result.commonPrefix.size() &&
prefixLength < match.size() &&
result.commonPrefix[prefixLength] == match[prefixLength]) {
++prefixLength;
}
result.commonPrefix.resize(prefixLength);
}
return result;
}
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
std::size_t maxLineLength)
{
std::vector<std::string> lines;
std::string line = " ";
for (std::size_t matchIndex = 0; matchIndex < matches.size(); ++matchIndex) {
if (matchIndex > 0) line += " ";
line += matches[matchIndex];
if (line.length() > maxLineLength) {
lines.push_back(line);
line = " ";
}
}
if (line.length() > 2) {
lines.push_back(line);
}
return lines;
}
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
{
std::vector<std::string> args;
std::size_t index = 0;
while (index < command.size()) {
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
++index;
}
if (index >= command.size()) break;
std::string token;
if (command[index] == '"' || command[index] == '\'') {
char quote = command[index++];
while (index < command.size() && command[index] != quote) {
token += command[index++];
}
if (index < command.size()) ++index;
} else if (command[index] == '[' || command[index] == '{') {
char open = command[index];
char close = (open == '[') ? ']' : '}';
int depth = 0;
while (index < command.size()) {
if (command[index] == open) ++depth;
else if (command[index] == close) --depth;
token += command[index++];
if (depth == 0) break;
}
} else {
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
token += command[index++];
}
}
if (!token.empty()) args.push_back(token);
}
return args;
}
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
{
auto args = ParseConsoleCommandArgs(command);
ConsoleRpcCall call;
if (args.empty()) return call;
call.valid = true;
call.method = args.front();
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
const std::string& arg = args[argIndex];
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
auto parsed = nlohmann::json::parse(arg, nullptr, false);
if (!parsed.is_discarded()) {
call.params.push_back(parsed);
continue;
}
}
if (arg == "true") {
call.params.push_back(true);
} else if (arg == "false") {
call.params.push_back(false);
} else {
try {
if (arg.find('.') != std::string::npos) {
call.params.push_back(std::stod(arg));
} else {
call.params.push_back(std::stoll(arg));
}
} catch (...) {
call.params.push_back(arg);
}
}
}
return call;
}
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
bool isError)
{
if (isError) {
return {{"Error: " + result, ConsoleResultLineRole::Error}};
}
std::vector<ConsoleResultLine> lines;
std::string normalized = (result == "null") ? "(no result)" : result;
bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '[');
std::istringstream stream(normalized);
std::string line;
while (std::getline(stream, line)) {
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
if (isJson && !line.empty()) {
std::string trimmed = line;
std::size_t first = trimmed.find_first_not_of(" \t");
if (first != std::string::npos) trimmed = trimmed.substr(first);
if (!trimmed.empty()) {
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
if (trimmed[0] == '{' || trimmed[0] == '}' ||
trimmed[0] == '[' || trimmed[0] == ']') {
role = ConsoleResultLineRole::JsonBrace;
} else if (trimmed[0] == '"') {
if (trimmed.find("\": ") != std::string::npos ||
trimmed.find("\":") != std::string::npos) {
role = ConsoleResultLineRole::JsonKey;
} else {
role = ConsoleResultLineRole::JsonString;
}
} else if (std::isdigit(firstChar) || trimmed[0] == '-') {
role = ConsoleResultLineRole::JsonNumber;
} else if (trimmed == "true," || trimmed == "false," ||
trimmed == "true" || trimmed == "false" ||
trimmed == "null," || trimmed == "null") {
role = ConsoleResultLineRole::JsonNumber;
}
}
}
lines.push_back({line, role});
}
return lines;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,54 @@
#pragma once
#include <cstddef>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
struct ConsoleCompletionResult {
std::vector<std::string> matches;
std::string commonPrefix;
};
struct ConsoleRpcCall {
bool valid = false;
std::string method;
nlohmann::json params = nlohmann::json::array();
};
enum class ConsoleResultLineRole {
Result,
Error,
JsonKey,
JsonString,
JsonNumber,
JsonBrace
};
struct ConsoleResultLine {
std::string text;
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
};
const std::vector<std::string>& ConsoleRpcCommandNames();
void AppendConsoleHistory(std::vector<std::string>& history,
const std::string& command,
std::size_t maxEntries = 100);
int NavigateConsoleHistoryIndex(int currentIndex,
std::size_t historySize,
bool up);
std::string ConsoleHistoryEntry(const std::vector<std::string>& history,
int historyIndex);
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input);
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
std::size_t maxLineLength = 60);
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
bool isError);
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,33 @@
#include "console_output_model.h"
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
namespace {
std::string lowerCopy(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return value;
}
}
bool consoleLinePassesFilter(const std::string& lineText,
ImU32 lineColor,
const ConsoleOutputFilter& filter)
{
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
if (!filter.text.empty()) {
std::string needle = lowerCopy(filter.text);
std::string haystack = lowerCopy(lineText);
if (haystack.find(needle) == std::string::npos) return false;
}
return true;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,23 @@
#pragma once
#include "imgui.h"
#include <string>
namespace dragonx {
namespace ui {
struct ConsoleOutputFilter {
std::string text;
bool daemonMessagesEnabled = true;
bool errorsOnly = false;
ImU32 daemonColor = 0;
ImU32 errorColor = 0;
};
bool consoleLinePassesFilter(const std::string& lineText,
ImU32 lineColor,
const ConsoleOutputFilter& filter);
} // namespace ui
} // namespace dragonx

View File

@@ -6,6 +6,10 @@
// tab completion, daemon log display, and color-coded output.
#include "console_tab.h"
#include "console_command_reference.h"
#include "console_input_model.h"
#include "console_output_model.h"
#include "console_tab_helpers.h"
#include "../material/colors.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -213,13 +217,18 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Output area (scrollable) — glass panel background
float frameH = ImGui::GetFrameHeightWithSpacing();
float itemSp = ImGui::GetStyle().ItemSpacing.y;
float input_height = (Layout::spacingSm() + itemSp) // Dummy(0,sm) + spacing
+ frameH + Layout::spacingSm() + Layout::spacingXs() + schema::UI().drawElement("tabs.console", "input-cursor-offset").size; // input glass panel + cursor offset
float outputH = ImGui::GetContentRegionAvail().y - input_height;
float availHeight = ImGui::GetContentRegionAvail().y;
if (outputH < std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size)) outputH = std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
float input_height = ComputeConsoleInputHeight(
ImGui::GetFrameHeightWithSpacing(),
ImGui::GetStyle().ItemSpacing.y,
Layout::spacingSm(),
Layout::spacingXs(),
schema::UI().drawElement("tabs.console", "input-cursor-offset").size);
float outputH = ComputeConsoleOutputHeight(
availHeight,
input_height,
schema::UI().drawElement("tabs.console", "output-min-height").size,
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
ImDrawList* dlOut = ImGui::GetWindowDrawList();
ImVec2 outPanelMin = ImGui::GetCursorScreenPos();
@@ -600,28 +609,14 @@ void ConsoleTab::renderOutput()
output_scroll_y_ = ImGui::GetScrollY();
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
std::string filter_str(filter_text_);
bool has_text_filter = !filter_str.empty();
bool hide_daemon = !s_daemon_messages_enabled;
bool errors_only = s_errors_only_enabled;
bool has_filter = has_text_filter || hide_daemon || errors_only;
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
s_errors_only_enabled, COLOR_DAEMON, COLOR_ERROR};
bool has_text_filter = !outputFilter.text.empty();
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || outputFilter.errorsOnly;
visible_indices_.clear();
if (has_filter) {
std::string filter_lower;
if (has_text_filter) {
filter_lower = filter_str;
std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), ::tolower);
}
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
// Skip daemon lines when daemon toggle is off
if (hide_daemon && lines_[i].color == COLOR_DAEMON) continue;
// When errors-only is enabled, skip non-error lines
if (errors_only && lines_[i].color != COLOR_ERROR) continue;
if (has_text_filter) {
std::string lower = lines_[i].text;
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
if (lower.find(filter_lower) == std::string::npos) continue;
}
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].color, outputFilter)) continue;
visible_indices_.push_back(i);
}
} else {
@@ -636,8 +631,7 @@ void ConsoleTab::renderOutput()
// Each segment records which bytes of the source text appear on that visual
// row, so hit-testing and selection highlight can map screen positions to
// exact character offsets.
float wrap_width = ImGui::GetContentRegionAvail().x - padX * 2;
if (wrap_width < 50.0f) wrap_width = 50.0f;
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
@@ -1169,106 +1163,37 @@ void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker)
if (console->command_history_.empty()) return 0;
int prev_index = console->history_index_;
if (data->EventKey == ImGuiKey_UpArrow) {
if (console->history_index_ < 0) {
console->history_index_ = static_cast<int>(console->command_history_.size()) - 1;
} else if (console->history_index_ > 0) {
console->history_index_--;
}
} else if (data->EventKey == ImGuiKey_DownArrow) {
if (console->history_index_ >= 0) {
console->history_index_++;
if (console->history_index_ >= static_cast<int>(console->command_history_.size())) {
console->history_index_ = -1;
}
}
}
console->history_index_ = NavigateConsoleHistoryIndex(
console->history_index_,
console->command_history_.size(),
data->EventKey == ImGuiKey_UpArrow);
if (prev_index != console->history_index_) {
const char* history_str = (console->history_index_ >= 0)
? console->command_history_[console->history_index_].c_str()
: "";
std::string history = ConsoleHistoryEntry(console->command_history_, console->history_index_);
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, history_str);
data->InsertChars(0, history.c_str());
}
}
else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
// Tab completion for common RPC commands
static const char* commands[] = {
// Control
"help", "getinfo", "stop",
// Network
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
"addnode", "setban", "listbanned", "clearbanned", "ping",
// Blockchain
"getblockchaininfo", "getblockcount", "getbestblockhash",
"getblock", "getblockhash", "getblockheader", "getdifficulty",
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
// Mining
"getmininginfo", "setgenerate", "getgenerate",
"getnetworkhashps", "getblocksubsidy",
// Wallet
"getbalance", "z_gettotalbalance", "z_getbalances",
"getnewaddress", "z_getnewaddress",
"listaddresses", "z_listaddresses",
"sendtoaddress", "z_sendmany",
"listtransactions", "listunspent", "z_listunspent",
"z_getoperationstatus", "z_getoperationresult",
"getwalletinfo", "backupwallet",
"dumpprivkey", "importprivkey",
"z_exportkey", "z_importkey",
"signmessage", "settxfee",
// Raw Transactions
"createrawtransaction", "decoderawtransaction",
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
// Utility
"validateaddress", "z_validateaddress", "estimatefee",
// Built-in
"clear"
};
std::string input(data->Buf);
if (!input.empty()) {
// Collect all matches
std::vector<const char*> matches;
for (const char* cmd : commands) {
if (strncmp(cmd, input.c_str(), input.length()) == 0) {
matches.push_back(cmd);
}
}
auto completion = CompleteConsoleCommand(input);
if (matches.size() == 1) {
if (completion.matches.size() == 1) {
// Single match — complete it
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, matches[0]);
} else if (matches.size() > 1) {
data->InsertChars(0, completion.matches.front().c_str());
} else if (completion.matches.size() > 1) {
// Multiple matches — show list in console and complete common prefix
console->addLine(TR("console_completions"), ConsoleTab::COLOR_INFO);
std::string line = " ";
for (size_t m = 0; m < matches.size(); m++) {
if (m > 0) line += " ";
line += matches[m];
if (line.length() > 60) {
console->addLine(line, ConsoleTab::COLOR_RESULT);
line = " ";
}
}
if (line.length() > 2) {
for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
console->addLine(line, ConsoleTab::COLOR_RESULT);
}
// Complete to longest common prefix
std::string prefix = matches[0];
for (size_t m = 1; m < matches.size(); m++) {
size_t len = 0;
while (len < prefix.length() && len < strlen(matches[m]) &&
prefix[len] == matches[m][len]) len++;
prefix = prefix.substr(0, len);
}
if (prefix.length() > input.length()) {
if (completion.commonPrefix.length() > input.length()) {
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, prefix.c_str());
data->InsertChars(0, completion.commonPrefix.c_str());
}
}
}
@@ -1312,117 +1237,7 @@ void ConsoleTab::renderCommandsPopup()
ImGui::InputTextWithHint("##CmdSearch", TR("console_search_commands"), cmdFilter, sizeof(cmdFilter));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Command entries
struct CmdEntry { const char* name; const char* desc; const char* params; };
static const CmdEntry controlCmds[] = {
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
{"getinfo", "Get general info about the node", ""},
{"stop", "Stop the daemon", ""},
};
static const CmdEntry networkCmds[] = {
{"getnetworkinfo", "Return P2P network state info", ""},
{"getpeerinfo", "Get data about each connected peer", ""},
{"getconnectioncount", "Get number of peer connections", ""},
{"getnettotals", "Get network traffic statistics", ""},
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
{"listbanned", "List all banned IPs/subnets", ""},
{"clearbanned", "Clear all banned IPs", ""},
{"ping", "Ping all peers to measure round-trip time", ""},
};
static const CmdEntry blockchainCmds[] = {
{"getblockchaininfo", "Get current blockchain state", ""},
{"getblockcount", "Get number of blocks in longest chain", ""},
{"getbestblockhash", "Get hash of the tip block", ""},
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
{"getblockhash", "Get block hash at a given height", "height"},
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
{"getdifficulty", "Get proof-of-work difficulty", ""},
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
{"getmempoolinfo", "Get mempool state info", ""},
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
{"coinsupply", "Get coin supply information", "[height]"},
{"getchaintips", "Get all known chain tips", ""},
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
};
static const CmdEntry miningCmds[] = {
{"getmininginfo", "Get mining-related information", ""},
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
{"getgenerate", "Check if the node is mining", ""},
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
};
static const CmdEntry walletCmds[] = {
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
{"getnewaddress", "Generate a new transparent address", ""},
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
{"listaddresses", "List all transparent addresses", ""},
{"z_listaddresses", "List all z-addresses", ""},
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
{"z_listoperationids", "List all async z operation IDs", ""},
{"getwalletinfo", "Get wallet state info", ""},
{"backupwallet", "Back up wallet to a file", "\"destination\""},
{"dumpprivkey", "Dump private key for an address", "\"address\""},
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
{"importwallet", "Import wallet from a dump file", "\"filename\""},
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
{"settxfee", "Set the transaction fee per kB", "amount"},
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
{"walletlock", "Lock the wallet", ""},
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
};
static const CmdEntry rawTxCmds[] = {
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
};
static const CmdEntry utilCmds[] = {
{"validateaddress", "Validate a transparent address", "\"address\""},
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
};
struct CmdCategory { const char* name; const CmdEntry* commands; int count; };
static const CmdCategory categories[] = {
{"Control", controlCmds, IM_ARRAYSIZE(controlCmds)},
{"Network", networkCmds, IM_ARRAYSIZE(networkCmds)},
{"Blockchain", blockchainCmds, IM_ARRAYSIZE(blockchainCmds)},
{"Mining", miningCmds, IM_ARRAYSIZE(miningCmds)},
{"Wallet", walletCmds, IM_ARRAYSIZE(walletCmds)},
{"Raw Transactions", rawTxCmds, IM_ARRAYSIZE(rawTxCmds)},
{"Utility", utilCmds, IM_ARRAYSIZE(utilCmds)},
};
const auto& categories = consoleCommandCategories();
std::string filter(cmdFilter);
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
@@ -1602,12 +1417,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
{
using namespace material;
// Add to history (avoid duplicates)
if (command_history_.empty() || command_history_.back() != cmd) {
command_history_.push_back(cmd);
if (command_history_.size() > 100) {
command_history_.erase(command_history_.begin());
}
}
AppendConsoleHistory(command_history_, cmd, 100);
history_index_ = -1;
// Echo command
@@ -1645,77 +1455,11 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
return;
}
// Parse command and arguments (shell-like: handles quotes and JSON brackets)
std::vector<std::string> args;
{
size_t i = 0;
size_t len = cmd.size();
while (i < len) {
// Skip whitespace
while (i < len && (cmd[i] == ' ' || cmd[i] == '\t')) i++;
if (i >= len) break;
auto call = BuildConsoleRpcCall(cmd);
if (!call.valid) return;
std::string tok;
if (cmd[i] == '"' || cmd[i] == '\'') {
// Quoted string — collect until matching close quote
char quote = cmd[i++];
while (i < len && cmd[i] != quote) tok += cmd[i++];
if (i < len) i++; // skip closing quote
} else if (cmd[i] == '[' || cmd[i] == '{') {
// JSON array/object — collect until matching bracket
char open = cmd[i];
char close = (open == '[') ? ']' : '}';
int depth = 0;
while (i < len) {
if (cmd[i] == open) depth++;
else if (cmd[i] == close) depth--;
tok += cmd[i++];
if (depth == 0) break;
}
} else {
// Unquoted token — collect until whitespace
while (i < len && cmd[i] != ' ' && cmd[i] != '\t') tok += cmd[i++];
}
if (!tok.empty()) args.push_back(tok);
}
}
if (args.empty()) return;
std::string method = args[0];
nlohmann::json params = nlohmann::json::array();
// Convert remaining args to JSON params
for (size_t i = 1; i < args.size(); i++) {
const std::string& arg = args[i];
// Try to parse as JSON first (handles objects, arrays, etc.)
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
auto parsed = nlohmann::json::parse(arg, nullptr, false);
if (!parsed.is_discarded()) {
params.push_back(parsed);
continue;
}
}
// Try to parse as number or bool
if (arg == "true") {
params.push_back(true);
} else if (arg == "false") {
params.push_back(false);
} else {
try {
if (arg.find('.') != std::string::npos) {
params.push_back(std::stod(arg));
} else {
params.push_back(std::stoll(arg));
}
} catch (...) {
// Keep as string
params.push_back(arg);
}
}
}
std::string method = call.method;
nlohmann::json params = call.params;
// Execute RPC call on worker thread to avoid blocking UI
if (worker) {
@@ -1726,9 +1470,6 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
bool is_error = false;
try {
result_str = rpc->callRaw(method, params);
if (result_str == "null") {
result_str = "(no result)";
}
} catch (const std::exception& e) {
result_str = e.what();
is_error = true;
@@ -1736,51 +1477,22 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
return [result_str, is_error, self]() {
// Process results on main thread where ImGui colors are available
using namespace material;
if (is_error) {
self->addLine("Error: " + result_str, COLOR_ERROR);
return;
}
bool is_json = false;
if (!result_str.empty()) {
char first = result_str[0];
is_json = (first == '{' || first == '[');
}
ImU32 json_key_col = WithAlpha(Secondary(), 255);
ImU32 json_str_col = WithAlpha(Success(), 255);
ImU32 json_num_col = WithAlpha(Warning(), 255);
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
std::istringstream stream(result_str);
std::string line;
while (std::getline(stream, line)) {
if (is_json && !line.empty()) {
std::string trimmed = line;
size_t first = trimmed.find_first_not_of(" \t");
if (first != std::string::npos) trimmed = trimmed.substr(first);
ImU32 lineCol = COLOR_RESULT;
if (trimmed[0] == '{' || trimmed[0] == '}' ||
trimmed[0] == '[' || trimmed[0] == ']') {
lineCol = json_brace_col;
} else if (trimmed[0] == '\"') {
size_t colon = trimmed.find("\": ");
if (colon != std::string::npos || trimmed.find("\":") != std::string::npos) {
lineCol = json_key_col;
} else {
lineCol = json_str_col;
}
} else if (std::isdigit(trimmed[0]) || trimmed[0] == '-') {
lineCol = json_num_col;
} else if (trimmed == "true," || trimmed == "false," ||
trimmed == "true" || trimmed == "false" ||
trimmed == "null," || trimmed == "null") {
lineCol = json_num_col;
}
self->addLine(line, lineCol);
} else {
self->addLine(line, COLOR_RESULT);
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, is_error)) {
ImU32 lineCol = COLOR_RESULT;
switch (resultLine.role) {
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
case ConsoleResultLineRole::Result: break;
}
self->addLine(resultLine.text, lineCol);
}
};
});
@@ -1788,14 +1500,13 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
// Fallback: synchronous execution if no worker available
try {
std::string result_str = rpc->callRaw(method, params);
if (result_str == "null") result_str = "(no result)";
std::istringstream stream(result_str);
std::string line;
while (std::getline(stream, line)) {
addLine(line, COLOR_RESULT);
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
addLine(resultLine.text, COLOR_RESULT);
}
} catch (const std::exception& e) {
addLine("Error: " + std::string(e.what()), COLOR_ERROR);
for (const auto& resultLine : FormatConsoleRpcResultLines(e.what(), true)) {
addLine(resultLine.text, COLOR_ERROR);
}
}
}
}

View File

@@ -0,0 +1,31 @@
#include "console_tab_helpers.h"
#include <algorithm>
namespace dragonx {
namespace ui {
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
float itemSpacingY,
float spacingSm,
float spacingXs,
float cursorOffset)
{
return spacingSm + itemSpacingY + frameHeightWithSpacing + spacingSm + spacingXs + cursorOffset;
}
float ComputeConsoleOutputHeight(float availableHeight,
float inputHeight,
float minHeight,
float minHeightRatio)
{
return std::max(availableHeight - inputHeight, std::max(minHeight, availableHeight * minHeightRatio));
}
float ClampConsoleWrapWidth(float contentWidth, float paddingX)
{
return std::max(50.0f, contentWidth - paddingX * 2.0f);
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,18 @@
#pragma once
namespace dragonx {
namespace ui {
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
float itemSpacingY,
float spacingSm,
float spacingXs,
float cursorOffset);
float ComputeConsoleOutputHeight(float availableHeight,
float inputHeight,
float minHeight,
float minHeightRatio);
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
} // namespace ui
} // namespace dragonx

View File

@@ -267,13 +267,12 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
});
}
static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
static bool fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
auto* worker = app->worker();
auto* rpc = app->rpc();
if (!worker || !rpc || s_pending_block_fetches > 0) return;
if (!worker || !rpc || s_pending_block_fetches > 0) return false;
s_recent_blocks.clear();
s_recent_blocks.resize(count);
if (s_recent_blocks.empty()) s_recent_blocks.resize(count);
s_pending_block_fetches = 1; // single batched fetch
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
@@ -295,11 +294,19 @@ static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
bs.tx_count = static_cast<int>(result["tx"].size());
} catch (...) {}
}
return [results]() {
s_recent_blocks = results;
return [results = std::move(results)]() mutable {
bool gotAny = false;
for (const auto& block : results) {
if (block.height > 0) {
gotAny = true;
break;
}
}
if (gotAny) s_recent_blocks = std::move(results);
s_pending_block_fetches = 0;
};
});
return true;
}
static void fetchMempoolInfo(App* app) {
@@ -567,10 +574,12 @@ static void renderRecentBlocks(App* app, float availWidth) {
if (bs.height > 0) blocks.push_back(&bs);
}
// Fixed card height — content scrolls inside
// Stretch card to fill the remaining tab height; rows scroll inside.
float maxRows = 10.0f;
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
float tableH = headerH + contentH + pad;
float minTableH = headerH + contentH + pad;
float remainingH = ImGui::GetContentRegionAvail().y;
float tableH = std::max(minTableH, remainingH - Layout::spacingSm());
ImVec2 cardMin = ImGui::GetCursorScreenPos();
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
@@ -1024,12 +1033,15 @@ void RenderExplorerTab(App* app)
float availWidth = ImGui::GetContentRegionAvail().x;
// Auto-refresh recent blocks when chain height changes
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height) {
s_last_known_height = state.sync.blocks;
// Auto-refresh recent blocks when chain height changes, but avoid
// starting expensive block fetches while the user is viewing details.
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height &&
!s_show_detail_modal && !s_detail_loading && !s_tx_loading) {
if (rpc && rpc->isConnected()) {
fetchRecentBlocks(app, state.sync.blocks);
fetchMempoolInfo(app);
if (fetchRecentBlocks(app, state.sync.blocks)) {
s_last_known_height = state.sync.blocks;
fetchMempoolInfo(app);
}
}
}

View File

@@ -306,7 +306,20 @@ void RenderMarketTab(App* app)
ImGui::SetCursorScreenPos(savedCur);
}
} else {
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), TR("market_price_unavailable"));
const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), status);
if (!market.price_loading && !market.price_error.empty()) {
std::string errorText = market.price_error;
float maxErrorW = cardMax.x - cx - Layout::spacingLg();
while (errorText.size() > 4 &&
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
errorText.pop_back();
}
if (errorText.size() < market.price_error.size()) errorText += "...";
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy + 10 + sub1->LegacySize + Layout::spacingXs()),
Warning(), errorText.c_str());
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));

View File

@@ -0,0 +1,211 @@
#include "mining_benchmark.h"
#include <algorithm>
#include <cmath>
namespace dragonx {
namespace ui {
void ThreadBenchmark::reset()
{
phase = Phase::Idle;
candidates.clear();
current_index = 0;
results.clear();
phase_timer = 0.0f;
prev_window_avg = 0.0;
window_sum = 0.0;
window_samples = 0;
window_timer = 0.0f;
consecutive_stable = 0;
measure_sum = 0.0;
measure_samples = 0;
optimal_threads = 0;
optimal_hashrate = 0.0;
was_pool_running = false;
prev_threads = 0;
total_warmup_secs = 0.0f;
}
void ThreadBenchmark::buildCandidates(int maxThreads)
{
candidates.clear();
int start = std::max(1, maxThreads / 2);
for (int threads = start; threads <= maxThreads; ++threads) {
candidates.push_back(threads);
}
}
float ThreadBenchmark::avgWarmupSecs() const
{
if (current_index > 0) {
return total_warmup_secs / static_cast<float>(current_index);
}
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f;
}
float ThreadBenchmark::perTestSecs() const
{
return avgWarmupSecs() + MEASURE_SECS;
}
float ThreadBenchmark::totalEstimatedSecs() const
{
int count = static_cast<int>(candidates.size());
if (count <= 0) return 0.0f;
float completedTime = total_warmup_secs
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
int remaining = count - current_index;
float remainingTime = static_cast<float>(remaining) * (avgWarmupSecs() + MEASURE_SECS)
+ static_cast<float>(std::max(0, remaining - 1)) * COOLDOWN_SECS;
return completedTime + remainingTime;
}
float ThreadBenchmark::elapsedSecs() const
{
float completed = total_warmup_secs
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
return completed + phase_timer;
}
float ThreadBenchmark::progress() const
{
float total = totalEstimatedSecs();
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
}
void ThreadBenchmark::resetStabilityTracking()
{
prev_window_avg = 0.0;
window_sum = 0.0;
window_samples = 0;
window_timer = 0.0f;
consecutive_stable = 0;
}
bool ThreadBenchmark::active() const
{
return phase != Phase::Idle && phase != Phase::Done;
}
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
float deltaSeconds,
double poolHashrate10s)
{
ThreadBenchmarkUpdate update;
if (!benchmark.active()) return update;
benchmark.phase_timer += deltaSeconds;
switch (benchmark.phase) {
case ThreadBenchmark::Phase::Starting:
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
int threads = benchmark.candidates[benchmark.current_index];
update.stopPoolMining = true;
update.startPoolMining = true;
update.startThreads = threads;
benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
benchmark.phase_timer = 0.0f;
benchmark.resetStabilityTracking();
benchmark.measure_sum = 0.0;
benchmark.measure_samples = 0;
} else {
benchmark.phase = ThreadBenchmark::Phase::Done;
}
break;
case ThreadBenchmark::Phase::WarmingUp: {
bool pastMin = benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
bool pastMax = benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
if (poolHashrate10s > 0.0) {
benchmark.window_sum += poolHashrate10s;
benchmark.window_samples++;
}
benchmark.window_timer += deltaSeconds;
bool stable = false;
if (pastMin && benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS &&
benchmark.window_samples > 0) {
double currentAverage = benchmark.window_sum / benchmark.window_samples;
if (benchmark.prev_window_avg > 0.0) {
double change = std::abs(currentAverage - benchmark.prev_window_avg)
/ benchmark.prev_window_avg;
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
benchmark.consecutive_stable++;
else
benchmark.consecutive_stable = 0;
if (benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
stable = true;
}
benchmark.prev_window_avg = currentAverage;
benchmark.window_sum = 0.0;
benchmark.window_samples = 0;
benchmark.window_timer = 0.0f;
}
if (stable || pastMax) {
benchmark.total_warmup_secs += benchmark.phase_timer;
benchmark.phase = ThreadBenchmark::Phase::Measuring;
benchmark.phase_timer = 0.0f;
benchmark.measure_sum = 0.0;
benchmark.measure_samples = 0;
}
break;
}
case ThreadBenchmark::Phase::Measuring:
if (poolHashrate10s > 0.0) {
benchmark.measure_sum += poolHashrate10s;
benchmark.measure_samples++;
}
if (benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
int threads = benchmark.candidates[benchmark.current_index];
double average = (benchmark.measure_samples > 0)
? benchmark.measure_sum / benchmark.measure_samples
: 0.0;
benchmark.results.push_back({threads, average});
if (average > benchmark.optimal_hashrate) {
benchmark.optimal_hashrate = average;
benchmark.optimal_threads = threads;
}
benchmark.phase = ThreadBenchmark::Phase::Advancing;
benchmark.phase_timer = 0.0f;
}
break;
case ThreadBenchmark::Phase::Advancing:
update.stopPoolMining = true;
benchmark.current_index++;
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
benchmark.phase_timer = 0.0f;
} else {
benchmark.phase = ThreadBenchmark::Phase::Done;
if (benchmark.optimal_threads > 0) {
update.saveOptimalThreads = true;
update.optimalThreads = benchmark.optimal_threads;
if (benchmark.was_pool_running) {
update.startPoolMining = true;
update.startThreads = benchmark.optimal_threads;
}
}
}
break;
case ThreadBenchmark::Phase::CoolingDown:
if (benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
benchmark.phase = ThreadBenchmark::Phase::Starting;
benchmark.phase_timer = 0.0f;
}
break;
default:
break;
}
return update;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,66 @@
#pragma once
#include <vector>
namespace dragonx {
namespace ui {
struct ThreadBenchmark {
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
struct Result {
int threads = 0;
double hashrate = 0.0;
};
static constexpr float MIN_WARMUP_SECS = 90.0f;
static constexpr float MAX_WARMUP_SECS = 300.0f;
static constexpr float MEASURE_SECS = 30.0f;
static constexpr float COOLDOWN_SECS = 5.0f;
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
static constexpr float STABILITY_THRESHOLD = 0.05f;
static constexpr int STABLE_WINDOWS_NEEDED = 3;
Phase phase = Phase::Idle;
std::vector<int> candidates;
int current_index = 0;
std::vector<Result> results;
float phase_timer = 0.0f;
double prev_window_avg = 0.0;
double window_sum = 0.0;
int window_samples = 0;
float window_timer = 0.0f;
int consecutive_stable = 0;
double measure_sum = 0.0;
int measure_samples = 0;
int optimal_threads = 0;
double optimal_hashrate = 0.0;
bool was_pool_running = false;
int prev_threads = 0;
float total_warmup_secs = 0.0f;
void reset();
void buildCandidates(int maxThreads);
float avgWarmupSecs() const;
float perTestSecs() const;
float totalEstimatedSecs() const;
float elapsedSecs() const;
float progress() const;
void resetStabilityTracking();
bool active() const;
};
struct ThreadBenchmarkUpdate {
bool stopPoolMining = false;
bool startPoolMining = false;
int startThreads = 0;
bool saveOptimalThreads = false;
int optimalThreads = 0;
};
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
float deltaSeconds,
double poolHashrate10s);
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,36 @@
#include "mining_pool_panel.h"
#include <algorithm>
namespace dragonx {
namespace ui {
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted)
{
return !alreadyDefaulted && (currentWorker.empty() || currentWorker == "x");
}
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
{
for (const auto& addr : addresses) {
if (addr.type == "shielded" && !addr.address.empty()) {
return addr.address;
}
}
return {};
}
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
const std::string& value)
{
if (value.empty()) return false;
return std::find(savedValues.begin(), savedValues.end(), value) != savedValues.end();
}
const char* defaultPoolUrl()
{
return "pool.dragonx.is:3433";
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,18 @@
#pragma once
#include "../../data/wallet_state.h"
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
const std::string& value);
const char* defaultPoolUrl();
} // namespace ui
} // namespace dragonx

View File

@@ -3,6 +3,9 @@
// Released under the GPLv3
#include "mining_tab.h"
#include "mining_benchmark.h"
#include "mining_tab_helpers.h"
#include "mining_pool_panel.h"
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../config/version.h"
@@ -43,130 +46,10 @@ static int s_drag_anchor_thread = 0; // thread# where drag started
// Earnings filter: 0 = All, 1 = Solo, 2 = Pool
static int s_earnings_filter = 0;
// Thread benchmark state
struct ThreadBenchmark {
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
Phase phase = Phase::Idle;
std::vector<int> candidates;
int current_index = 0;
struct Result {
int threads;
double hashrate;
};
std::vector<Result> results;
float phase_timer = 0.0f;
// Warmup: wait at least MIN then check for hashrate stability; cap at MAX.
// Laptops need 90s+ for thermal throttling to fully manifest.
static constexpr float MIN_WARMUP_SECS = 90.0f;
static constexpr float MAX_WARMUP_SECS = 300.0f;
static constexpr float MEASURE_SECS = 30.0f;
static constexpr float COOLDOWN_SECS = 5.0f;
// Stability detection — compare rolling 10s hashrate windows.
// Require STABLE_WINDOWS_NEEDED consecutive stable readings.
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
static constexpr float STABILITY_THRESHOLD = 0.05f; // 5% change → stable
static constexpr int STABLE_WINDOWS_NEEDED = 3;
double prev_window_avg = 0.0;
double window_sum = 0.0;
int window_samples = 0;
float window_timer = 0.0f;
int consecutive_stable = 0; // count of consecutive stable windows
// Measurement: average-based (sustained performance, not peak burst)
double measure_sum = 0.0;
int measure_samples = 0;
int optimal_threads = 0;
double optimal_hashrate = 0.0;
bool was_pool_running = false;
int prev_threads = 0;
// Track actual warmup durations for better time estimates
float total_warmup_secs = 0.0f;
void reset() {
phase = Phase::Idle;
candidates.clear();
current_index = 0;
results.clear();
phase_timer = 0.0f;
prev_window_avg = 0.0;
window_sum = 0.0;
window_samples = 0;
window_timer = 0.0f;
consecutive_stable = 0;
measure_sum = 0.0;
measure_samples = 0;
optimal_threads = 0;
optimal_hashrate = 0.0;
was_pool_running = false;
prev_threads = 0;
total_warmup_secs = 0.0f;
}
void buildCandidates(int max_threads) {
candidates.clear();
// Start at half the cores — lower counts are rarely optimal and
// testing them first would waste time warming up the CPU before
// reaching the thread counts that actually matter.
int start = std::max(1, max_threads / 2);
for (int t = start; t <= max_threads; t++)
candidates.push_back(t);
}
/// Average warmup duration based on tests completed so far
float avgWarmupSecs() const {
if (current_index > 0)
return total_warmup_secs / (float)current_index;
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f; // initial estimate
}
/// Estimated seconds per test (uses observed warmup average)
float perTestSecs() const {
return avgWarmupSecs() + MEASURE_SECS;
}
float totalEstimatedSecs() const {
int n = (int)candidates.size();
if (n <= 0) return 0.0f;
// Completed tests use actual time; remaining use estimate
float completed_time = total_warmup_secs
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
int remaining = n - current_index;
float remaining_time = (float)remaining * (avgWarmupSecs() + MEASURE_SECS)
+ (float)std::max(0, remaining - 1) * COOLDOWN_SECS;
return completed_time + remaining_time;
}
float elapsedSecs() const {
float completed = total_warmup_secs
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
return completed + phase_timer;
}
float progress() const {
float total = totalEstimatedSecs();
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
}
void resetStabilityTracking() {
prev_window_avg = 0.0;
window_sum = 0.0;
window_samples = 0;
window_timer = 0.0f;
consecutive_stable = 0;
}
};
static ThreadBenchmark s_benchmark;
bool IsMiningBenchmarkActive() {
return s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done;
return s_benchmark.active();
}
// Pool mode state
@@ -178,59 +61,6 @@ static bool s_pool_state_loaded = false;
static bool s_show_pool_log = false; // Toggle: false=chart, true=log
static bool s_show_solo_log = false; // Toggle: false=chart, true=log (solo mode)
// Get max threads based on hardware
static int GetMaxMiningThreads()
{
int hw_threads = std::thread::hardware_concurrency();
return std::max(1, hw_threads);
}
// Format hashrate with appropriate units
static std::string FormatHashrate(double hashrate)
{
char buf[64];
if (hashrate >= 1e12) {
snprintf(buf, sizeof(buf), "%.2f TH/s", hashrate / 1e12);
} else if (hashrate >= 1e9) {
snprintf(buf, sizeof(buf), "%.2f GH/s", hashrate / 1e9);
} else if (hashrate >= 1e6) {
snprintf(buf, sizeof(buf), "%.2f MH/s", hashrate / 1e6);
} else if (hashrate >= 1e3) {
snprintf(buf, sizeof(buf), "%.2f KH/s", hashrate / 1e3);
} else {
snprintf(buf, sizeof(buf), "%.2f H/s", hashrate);
}
return std::string(buf);
}
// Calculate estimated hours to find a block
static double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
{
if (localHashrate <= 0 || networkHashrate <= 0) return 0;
double blocksPerHour = 3600.0 / 75.0;
double yourShare = localHashrate / networkHashrate;
if (yourShare <= 0) return 0;
return 1.0 / (blocksPerHour * yourShare);
}
// Format estimated time
static std::string FormatEstTime(double est_hours)
{
char buf[64];
if (est_hours <= 0) {
return "N/A";
} else if (est_hours < 1.0) {
snprintf(buf, sizeof(buf), "~%.0f min", est_hours * 60.0);
} else if (est_hours < 24.0) {
snprintf(buf, sizeof(buf), "~%.1f hrs", est_hours);
} else if (est_hours < 168.0) {
snprintf(buf, sizeof(buf), "~%.1f days", est_hours / 24.0);
} else {
snprintf(buf, sizeof(buf), "~%.1f weeks", est_hours / 168.0);
}
return std::string(buf);
}
static void RenderMiningTabContent(App* app);
void RenderMiningTab(App* app)
@@ -279,9 +109,9 @@ static void RenderMiningTabContent(App* app)
if (!s_threads_initialized) {
int saved = app->settings()->getPoolThreads();
if (mining.generate)
s_selected_threads = std::max(1, mining.genproclimit);
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
else if (saved > 0)
s_selected_threads = std::min(saved, max_threads);
s_selected_threads = ClampMiningThreads(saved, max_threads);
else
s_selected_threads = 1;
s_threads_initialized = true;
@@ -295,11 +125,11 @@ static void RenderMiningTabContent(App* app)
// than threads_active from the xmrig API which lags during restarts.
int reqThreads = app->getXmrigRequestedThreads();
if (reqThreads > 0)
s_selected_threads = std::min(reqThreads, max_threads);
s_selected_threads = ClampMiningThreads(reqThreads, max_threads);
else if (state.pool_mining.threads_active > 0)
s_selected_threads = std::min(state.pool_mining.threads_active, max_threads);
s_selected_threads = ClampMiningThreads(state.pool_mining.threads_active, max_threads);
} else if (mining.generate && mining.genproclimit > 0) {
s_selected_threads = std::min(mining.genproclimit, max_threads);
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
}
}
@@ -328,15 +158,8 @@ static void RenderMiningTabContent(App* app)
{
static bool s_pool_worker_defaulted = false;
std::string workerStr(s_pool_worker);
if (!s_pool_worker_defaulted && !state.addresses.empty() &&
(workerStr.empty() || workerStr == "x")) {
std::string defaultAddr;
for (const auto& addr : state.addresses) {
if (addr.type == "shielded" && !addr.address.empty()) {
defaultAddr = addr.address;
break;
}
}
if (shouldDefaultPoolWorker(workerStr, s_pool_worker_defaulted) && !state.addresses.empty()) {
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
if (!defaultAddr.empty()) {
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
@@ -368,136 +191,27 @@ static void RenderMiningTabContent(App* app)
// Determine active mining state for UI
// Include pool mining running state even when user just switched to solo,
// so the button shows STOP/STOPPING while xmrig shuts down.
bool isMiningActive = s_pool_mode
? state.pool_mining.xmrig_running
: (mining.generate || state.pool_mining.xmrig_running);
bool isMiningActive = IsPoolMiningActive(s_pool_mode,
state.pool_mining.xmrig_running,
mining.generate);
// ================================================================
// Thread Benchmark state machine — runs pool mining at each candidate
// thread count to find the optimal setting for this CPU.
// ================================================================
if (s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
s_benchmark.phase != ThreadBenchmark::Phase::Done) {
float dt = ImGui::GetIO().DeltaTime;
s_benchmark.phase_timer += dt;
switch (s_benchmark.phase) {
case ThreadBenchmark::Phase::Starting:
// Start pool mining at current candidate
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
int t = s_benchmark.candidates[s_benchmark.current_index];
app->stopPoolMining();
app->startPoolMining(t);
s_benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
s_benchmark.phase_timer = 0.0f;
s_benchmark.resetStabilityTracking();
s_benchmark.measure_sum = 0.0;
s_benchmark.measure_samples = 0;
} else {
s_benchmark.phase = ThreadBenchmark::Phase::Done;
}
break;
case ThreadBenchmark::Phase::WarmingUp: {
// Adaptive warmup: wait for hashrate to stabilize (thermal steady state).
// After MIN_WARMUP (90s), compare rolling 10s hashrate windows.
// Require 3 consecutive windows within 5% to confirm equilibrium.
// Laptops can take 2-3+ minutes for thermal throttling to fully
// manifest, so a single stable window isn't sufficient.
bool past_min = s_benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
bool past_max = s_benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
// Accumulate samples into current window
if (state.pool_mining.hashrate_10s > 0.0) {
s_benchmark.window_sum += state.pool_mining.hashrate_10s;
s_benchmark.window_samples++;
}
s_benchmark.window_timer += dt;
bool stable = false;
if (past_min && s_benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS
&& s_benchmark.window_samples > 0) {
double current_avg = s_benchmark.window_sum / s_benchmark.window_samples;
if (s_benchmark.prev_window_avg > 0.0) {
double change = std::abs(current_avg - s_benchmark.prev_window_avg)
/ s_benchmark.prev_window_avg;
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
s_benchmark.consecutive_stable++;
else
s_benchmark.consecutive_stable = 0; // reset on instability
if (s_benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
stable = true;
}
// Shift window
s_benchmark.prev_window_avg = current_avg;
s_benchmark.window_sum = 0.0;
s_benchmark.window_samples = 0;
s_benchmark.window_timer = 0.0f;
}
if (stable || past_max) {
s_benchmark.total_warmup_secs += s_benchmark.phase_timer;
s_benchmark.phase = ThreadBenchmark::Phase::Measuring;
s_benchmark.phase_timer = 0.0f;
s_benchmark.measure_sum = 0.0;
s_benchmark.measure_samples = 0;
}
break;
}
case ThreadBenchmark::Phase::Measuring:
// Sample average hashrate — reflects sustained (thermally throttled) performance
if (state.pool_mining.hashrate_10s > 0.0) {
s_benchmark.measure_sum += state.pool_mining.hashrate_10s;
s_benchmark.measure_samples++;
}
if (s_benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
int t = s_benchmark.candidates[s_benchmark.current_index];
double avg = (s_benchmark.measure_samples > 0)
? s_benchmark.measure_sum / s_benchmark.measure_samples
: 0.0;
s_benchmark.results.push_back({t, avg});
if (avg > s_benchmark.optimal_hashrate) {
s_benchmark.optimal_hashrate = avg;
s_benchmark.optimal_threads = t;
}
s_benchmark.phase = ThreadBenchmark::Phase::Advancing;
s_benchmark.phase_timer = 0.0f;
}
break;
case ThreadBenchmark::Phase::Advancing:
if (s_benchmark.active()) {
auto benchmarkUpdate = AdvanceThreadBenchmark(
s_benchmark, ImGui::GetIO().DeltaTime, state.pool_mining.hashrate_10s);
if (benchmarkUpdate.stopPoolMining) {
app->stopPoolMining();
s_benchmark.current_index++;
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
// Cool down before next test to reduce thermal throttling bias
s_benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
s_benchmark.phase_timer = 0.0f;
} else {
// Done — apply optimal thread count
s_benchmark.phase = ThreadBenchmark::Phase::Done;
if (s_benchmark.optimal_threads > 0) {
s_selected_threads = s_benchmark.optimal_threads;
app->settings()->setPoolThreads(s_selected_threads);
app->settings()->save();
}
// Restart mining if it was running before, using optimal count
if (s_benchmark.was_pool_running && s_benchmark.optimal_threads > 0) {
app->startPoolMining(s_benchmark.optimal_threads);
}
}
break;
case ThreadBenchmark::Phase::CoolingDown:
// Idle pause — let CPU temps drop before starting next test
if (s_benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
s_benchmark.phase_timer = 0.0f;
}
break;
default:
break;
}
if (benchmarkUpdate.saveOptimalThreads) {
s_selected_threads = benchmarkUpdate.optimalThreads;
app->settings()->setPoolThreads(s_selected_threads);
app->settings()->save();
}
if (benchmarkUpdate.startPoolMining) {
app->startPoolMining(benchmarkUpdate.startThreads);
}
}
@@ -690,10 +404,7 @@ static void RenderMiningTabContent(App* app)
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
std::string currentUrl(s_pool_url);
bool alreadySaved = false;
for (const auto& u : app->settings()->getSavedPoolUrls()) {
if (u == currentUrl) { alreadySaved = true; break; }
}
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolUrls(), currentUrl);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
@@ -875,10 +586,7 @@ static void RenderMiningTabContent(App* app)
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
std::string currentWorker(s_pool_worker);
bool alreadySaved = false;
for (const auto& w : app->settings()->getSavedPoolWorkers()) {
if (w == currentWorker) { alreadySaved = true; break; }
}
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
@@ -1036,16 +744,10 @@ static void RenderMiningTabContent(App* app)
OnSurfaceMedium(), resetIcon);
if (btnClk) {
strncpy(s_pool_url, "pool.dragonx.is:3433", sizeof(s_pool_url) - 1);
strncpy(s_pool_url, defaultPoolUrl(), sizeof(s_pool_url) - 1);
// Default to user's first shielded (z) address for pool payouts.
// Leave blank if no z-address exists yet.
std::string defaultAddr;
for (const auto& addr : state.addresses) {
if (addr.type == "shielded" && !addr.address.empty()) {
defaultAddr = addr.address;
break;
}
}
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
s_pool_settings_dirty = true;

View File

@@ -0,0 +1,72 @@
#include "mining_tab_helpers.h"
#include <algorithm>
#include <cstdio>
#include <thread>
namespace dragonx {
namespace ui {
int GetMaxMiningThreads()
{
int hardwareThreads = static_cast<int>(std::thread::hardware_concurrency());
return std::max(1, hardwareThreads);
}
int ClampMiningThreads(int requestedThreads, int maxThreads)
{
int boundedMax = std::max(1, maxThreads);
return std::clamp(requestedThreads, 1, boundedMax);
}
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning)
{
return poolMode ? xmrigRunning : (soloMiningRunning || xmrigRunning);
}
std::string FormatHashrate(double hashrate)
{
char buffer[64];
if (hashrate >= 1e12) {
snprintf(buffer, sizeof(buffer), "%.2f TH/s", hashrate / 1e12);
} else if (hashrate >= 1e9) {
snprintf(buffer, sizeof(buffer), "%.2f GH/s", hashrate / 1e9);
} else if (hashrate >= 1e6) {
snprintf(buffer, sizeof(buffer), "%.2f MH/s", hashrate / 1e6);
} else if (hashrate >= 1e3) {
snprintf(buffer, sizeof(buffer), "%.2f KH/s", hashrate / 1e3);
} else {
snprintf(buffer, sizeof(buffer), "%.2f H/s", hashrate);
}
return std::string(buffer);
}
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
{
(void)difficulty;
if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0;
double blocksPerHour = 3600.0 / 75.0;
double share = localHashrate / networkHashrate;
if (share <= 0.0) return 0.0;
return 1.0 / (blocksPerHour * share);
}
std::string FormatEstTime(double estimatedHours)
{
char buffer[64];
if (estimatedHours <= 0.0) {
return "N/A";
} else if (estimatedHours < 1.0) {
snprintf(buffer, sizeof(buffer), "~%.0f min", estimatedHours * 60.0);
} else if (estimatedHours < 24.0) {
snprintf(buffer, sizeof(buffer), "~%.1f hrs", estimatedHours);
} else if (estimatedHours < 168.0) {
snprintf(buffer, sizeof(buffer), "~%.1f days", estimatedHours / 24.0);
} else {
snprintf(buffer, sizeof(buffer), "~%.1f weeks", estimatedHours / 168.0);
}
return std::string(buffer);
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,16 @@
#pragma once
#include <string>
namespace dragonx {
namespace ui {
int GetMaxMiningThreads();
int ClampMiningThreads(int requestedThreads, int maxThreads);
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
std::string FormatHashrate(double hashrate);
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
std::string FormatEstTime(double estimatedHours);
} // namespace ui
} // namespace dragonx

View File

@@ -564,7 +564,6 @@ void RenderPeersTab(App* app)
ImVec2 bMax(btnX + btnW, btnY + btnH);
bool btnHovered = material::IsRectHovered(bMin, bMax);
bool btnClicked = btnHovered && ImGui::IsMouseClicked(0);
// Glass panel background
GlassPanelSpec btnGlass;
@@ -635,7 +634,8 @@ void RenderPeersTab(App* app)
} else {
lblCol = btnHovered ? OnSurface() : WithAlpha(OnSurface(), 160);
}
float lblX = cx + iconSz * 0.5f + Layout::spacingXs();
float labelAreaX = bMin.x + padH + iconSz + Layout::spacingXs();
float lblX = labelAreaX + (maxLblW - lblSz.x) * 0.5f;
float lblY = cy - lblSz.y * 0.5f;
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(lblX, lblY), lblCol, label);
}

View File

@@ -219,14 +219,7 @@ static void RenderSourceDropdown(App* app, float width) {
// Auto-select the address with the largest balance on first load
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) {
int bestIdx = -1;
double bestBal = 0.0;
for (size_t i = 0; i < state.addresses.size(); i++) {
if (state.addresses[i].balance > bestBal && state.addresses[i].isSpendable()) {
bestBal = state.addresses[i].balance;
bestIdx = static_cast<int>(i);
}
}
int bestIdx = bestSpendableAddressIndex(state.addresses);
if (bestIdx >= 0) {
s_selected_from_idx = bestIdx;
snprintf(s_from_address, sizeof(s_from_address), "%s",
@@ -260,16 +253,7 @@ static void RenderSourceDropdown(App* app, float width) {
ImGui::TextDisabled("%s", TR("no_addresses_available"));
} else {
// Sort by balance descending, only show spendable addresses with balance
std::vector<size_t> sortedIdx;
sortedIdx.reserve(state.addresses.size());
for (size_t i = 0; i < state.addresses.size(); i++) {
if (state.addresses[i].balance > 0 && state.addresses[i].isSpendable())
sortedIdx.push_back(i);
}
std::sort(sortedIdx.begin(), sortedIdx.end(),
[&](size_t a, size_t b) {
return state.addresses[a].balance > state.addresses[b].balance;
});
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses);
if (sortedIdx.empty()) {
ImGui::TextDisabled("%s", TR("send_no_balance"));
@@ -731,14 +715,16 @@ void RenderSendConfirmPopup(App* app) {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_amount_details"));
ImVec2 cMin = ImGui::GetCursorScreenPos();
float cH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
float configuredH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
float contentH = Layout::spacingMd() * 2.0f + capFont->LegacySize * 2.0f + sub1->LegacySize + rowStep * 2.0f;
float cH = std::max(configuredH, contentH);
ImVec2 cMax(cMin.x + popW, cMin.y + cH);
GlassPanelSpec gs; gs.rounding = popGlassRound;
DrawGlassPanel(popDl, cMin, cMax, gs);
float cx = cMin.x + Layout::spacingMd() + Layout::spacingXs();
float cy = cMin.y + Layout::spacingSm() + Layout::spacingXs();
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
float cx = cMin.x + Layout::spacingLg();
float cy = cMin.y + Layout::spacingMd();
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_amount"));
snprintf(buf, sizeof(buf), "%.8f %s", s_amount, DRAGONX_TICKER);
@@ -756,11 +742,11 @@ void RenderSendConfirmPopup(App* app) {
snprintf(buf, sizeof(buf), "$%.6f", s_fee * market.price_usd);
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdX, cy), OnSurfaceDisabled(), buf);
}
cy += Layout::spacingSm();
popDl->AddLine(ImVec2(cx, cy + Layout::spacingMd()),
ImVec2(cx + popW - Layout::spacingXl(), cy + Layout::spacingMd()),
cy += rowStep * 0.5f;
popDl->AddLine(ImVec2(cx, cy),
ImVec2(cMax.x - Layout::spacingLg(), cy),
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "confirm-divider-thickness").size);
cy += rowStep;
cy += rowStep * 0.5f;
popDl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_total"));
snprintf(buf, sizeof(buf), "%.8f %s", total, DRAGONX_TICKER);

View File

@@ -6,6 +6,7 @@
#include "../../app.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../theme.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
@@ -175,16 +176,7 @@ void TransactionDetailsDialog::render(App* app)
if (material::StyledButton(TR("tx_view_explorer"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
// Platform-specific URL opening
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif __APPLE__
std::string cmd = "open \"" + url + "\"";
system(cmd.c_str());
#else
std::string cmd = "xdg-open \"" + url + "\" &";
system(cmd.c_str());
#endif
util::Platform::openUrl(url);
}
ImGui::SameLine();

View File

@@ -7,6 +7,7 @@
#include "export_transactions_dialog.h"
#include "../../app.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../config/settings.h"
#include "../../config/version.h"
#include "../theme.h"
@@ -722,15 +723,7 @@ void RenderTransactionsTab(App* app)
ImGui::Separator();
if (ImGui::MenuItem(TR("view_on_explorer"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif __APPLE__
std::string cmd = "open \"" + url + "\"";
system(cmd.c_str());
#else
std::string cmd = "xdg-open \"" + url + "\" &";
system(cmd.c_str());
#endif
util::Platform::openUrl(url);
}
if (ImGui::MenuItem(TR("view_details"))) {
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
@@ -798,15 +791,7 @@ void RenderTransactionsTab(App* app)
ImGui::SameLine();
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
#ifdef _WIN32
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif __APPLE__
std::string cmd2 = "open \"" + url + "\"";
system(cmd2.c_str());
#else
std::string cmd2 = "xdg-open \"" + url + "\" &";
system(cmd2.c_str());
#endif
util::Platform::openUrl(url);
}
ImGui::SameLine();
if (TactileSmallButton(TrId("full_details", "detail").c_str(), S.resolveFont("button"))) {