Files
ObsidianDragon/src/ui/windows/wallets_dialog.h
DanS 06c8b99bce feat(wallets): in-app folder picker + roomier, full-width list controls
Wallets dialog refinements:
- More space between wallet cards (cardGap: spacingSm -> spacingMd).
- "Scan another folder" is now always visible as a full-width button (no
  expandable toggle) that opens the new picker.
- The Create-wallet row and the scan button span the full list width, so their
  right edges align with the wallet cards / Open|Import buttons.

New in-app folder picker (folder_picker.h): a Material-styled modal replacing
the raw path input. Browse the filesystem (Up / Home / click a sub-folder),
see the *.dat wallet files present + a count, and pick a folder to scan. The
picker takes over the modal surface while open (the overlay framework doesn't
nest); it remembers the last browsed location across opens. Fixed-height card
(no auto-height feedback), UTF-8-aware truncation, full-node icons confirmed
in the embedded MaterialIcons range. Added a modal-folder-picker sweep surface;
verified at 100% + 150%, dark + light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:11:39 -05:00

353 lines
18 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Wallet-files list + switcher (multi-wallet P2). Lists wallet files in the datadir (and any
// user-added folders) with cached metadata (size from disk; balance / address count / last-opened
// from the wallet index, since those can't be read off a wallet.dat without loading it). "Open"
// switches the active wallet via App::switchToWallet (daemon restart on -wallet=<name>).
#pragma once
#include <cctype>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
#include "imgui.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../notifications.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "folder_picker.h"
namespace dragonx {
namespace ui {
class WalletsDialog {
public:
static void show(App* app) {
s_open = true;
s_app = app;
s_needScan = true;
}
static void hide() { s_open = false; }
static void render() {
if (!s_open || !s_app) return;
// The folder picker (opened by "Scan another folder") takes over the modal surface while
// it's up — the overlay framework doesn't nest, so only one overlay renders at a time.
if (FolderPicker::isOpen()) { FolderPicker::render(); return; }
using namespace material;
App* app = s_app;
const float dp = Layout::dpiScale();
ImGuiStyle& style = ImGui::GetStyle();
if (s_needScan) { scan(); s_needScan = false; }
// ---- Content-sized card: a stack of wallet cards, sized to the wallet count (up to
// kMaxVisibleRows, then the list scrolls). Computed from the KNOWN row count so the
// dialog stays fixed-height (no auto-height feedback that slid content off on a move). --
const int kMaxVisibleRows = 7;
ImFont* nameFont = Type().subtitle1();
ImFont* metaFont = Type().caption();
const int visRows = std::min(std::max(1, (int)s_rows.size()), kMaxVisibleRows);
const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm)
const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards
// ItemSpacing is zeroed inside the list child, so the content is exactly cards + inter-card
// gaps; a small bottom pad keeps the last card off the clip edge (no spurious scrollbar).
const float listH = visRows * walRowH + (float)std::max(0, visRows - 1) * cardGap + Layout::spacingSm();
const float ctrlRow = ImGui::GetFrameHeightWithSpacing();
const float capRow = Type().caption()->LegacySize + style.ItemSpacing.y;
const float belowH = 4.0f * Layout::spacingSm()
+ (capRow + ctrlRow) // Create label + input row
+ ctrlRow // full-width "scan folder" button
+ (style.ItemSpacing.y + 1.0f) // separator
+ ctrlRow // footer buttons
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title
+ Type().caption()->LegacySize + Layout::spacingSm(); // intro + gap
const float padV = 48.0f; // content-child padding (top+bottom) + margin
const float cardH = (headH + listH + belowH + padV) / dp;
OverlayDialogSpec ov;
ov.title = TR("wallets_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 780.0f;
ov.cardHeight = cardH;
ov.idSuffix = "wallets";
if (BeginOverlayDialog(ov)) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_intro"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
// ---- Wallet cards (Material-style rows; no table) -------------------------------------
const float listW = ImGui::GetContentRegionAvail().x;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::BeginChild("##walletList", ImVec2(listW, listH), false);
ImGui::PopStyleVar();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // inter-card gaps are explicit
ImDrawList* dl = ImGui::GetWindowDrawList();
const float rowW = ImGui::GetContentRegionAvail().x;
ImFont* icoFont = Type().iconSmall();
const char* dot = " \xC2\xB7 ";
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
auto fit = [&](std::string s, ImFont* f, float maxW){
bool t=false;
while (s.size()>1 && tw(f,s)>maxW){
// Pop a whole UTF-8 code point (never split a multibyte char — external .dat names
// and some locale metadata are non-ASCII).
while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (s.size()>1) s.pop_back();
t=true;
}
if (t) s += "\xE2\x80\xA6";
return s;
};
for (std::size_t i = 0; i < s_rows.size(); ++i) {
const WalletRow& r = s_rows[i];
const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName);
const bool isCurrent = r.inDatadir && r.fileName == active;
ImVec2 rMin = ImGui::GetCursorScreenPos();
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
bool hov = ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(rMin, rMax);
// Card background + state
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
DrawGlassPanel(dl, rMin, rMax, g);
if (isCurrent) {
dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f);
dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f, 0, 1.6f * dp);
} else if (hov) {
dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
}
const float padX = Layout::spacingMd();
const float iconSz = nameFont->LegacySize * 1.15f;
const float midY = (rMin.y + rMax.y) * 0.5f;
float x = rMin.x + padX;
dl->AddText(icoFont, iconSz, ImVec2(x, midY - iconSz * 0.5f),
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
x += iconSz + Layout::spacingMd();
// Action-button slot on the right — reserve room so text truncates before it.
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
const float btnX = rMax.x - padX - btnW;
const float textR = btnX - Layout::spacingMd();
// Name (+ info icon for external files)
const float topY = rMin.y + cardPadY;
float extra = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra));
dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
if (!r.inDatadir) {
ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize));
dl->AddText(icoFont, metaFont->LegacySize, ip, OnSurfaceMedium(), ICON_MD_INFO);
if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
}
// Metadata line (size · N addresses · balance DRGX · last opened)
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
char b[64];
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
ms += dot;
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
ms = fit(ms, metaFont, std::max(24.0f * dp, textR - x));
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), ms.c_str());
// Action: Active chip (current) / Open / Import
ImGui::PushID(static_cast<int>(i));
if (isCurrent) {
const char* al = TR("wallets_active");
float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
} else if (r.inDatadir) {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
app->switchToWallet(r.fileName);
s_open = false;
}
} else {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_import"), ImVec2(btnW, btnH)))
importAndOpen(app, r);
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
}
ImGui::PopID();
// Register the card's footprint with a Dummy (not an interactive item, so nothing
// overlaps the action button's ID) and add an explicit gap between cards (ItemSpacing
// is 0, so no double spacing; the last card has no trailing gap).
ImGui::SetCursorScreenPos(rMin);
ImGui::Dummy(ImVec2(rowW, walRowH));
if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
}
ImGui::PopStyleVar(); // ItemSpacing
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Create a new wallet (primary) — the input + button span the full list width -----
const float createBtnW = 140.0f * dp;
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_new_label"));
ImGui::SetNextItemWidth(listW - createBtnW - style.ItemSpacing.x);
ImGui::InputTextWithHint("##newWalletName", TR("wallets_new_hint"), s_newName, sizeof(s_newName));
ImGui::SameLine();
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
std::string name = normalizeWalletName(s_newName);
std::error_code ec;
if (name.empty()) {
Notifications::instance().warning(TR("wallets_name_invalid"));
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
Notifications::instance().warning(TR("wallets_exists"));
} else {
s_newName[0] = '\0';
Notifications::instance().info(TR("wallets_creating"));
app->switchToWallet(name);
s_open = false;
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Scan another folder — always visible, full-width, opens the in-app folder picker -
if (StyledButton(TR("wallets_scan_folder"), ImVec2(listW, 0))) {
FolderPicker::open("", [app](const std::string& dir) {
std::error_code ec;
if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
s_needScan = true;
} else {
Notifications::instance().warning(TR("wallets_folder_invalid"));
}
});
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Separator();
// ---- Refresh: icon-only, de-emphasized next to the text actions -----------------------
{
float bh = ImGui::GetFrameHeight();
IconButtonStyle rst;
rst.color = OnSurfaceMedium();
rst.hoverBg = StateHover();
rst.bgRounding = 4.0f * dp;
rst.tooltip = TR("refresh");
if (IconButton("##walletsRefresh", ICON_MD_REFRESH, Type().iconSmall(), ImVec2(bh, bh), rst))
s_needScan = true;
}
ImGui::SameLine();
if (StyledButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
ImGui::SameLine();
if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
EndOverlayDialog();
}
}
private:
struct WalletRow {
std::string fileName;
std::string dir;
bool inDatadir = false;
long long sizeBytes = 0;
};
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
// input has no usable characters.
static std::string normalizeWalletName(const std::string& raw) {
std::string s = raw;
while (!s.empty() && static_cast<unsigned char>(s.front()) <= ' ') s.erase(s.begin());
while (!s.empty() && static_cast<unsigned char>(s.back()) <= ' ') s.pop_back();
auto slash = s.find_last_of("/\\");
if (slash != std::string::npos) s = s.substr(slash + 1);
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s = s.substr(0, s.size() - 4);
std::string clean;
for (char c : s)
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_') clean += c;
if (clean.empty()) return "";
if (clean.rfind("wallet", 0) != 0) clean = "wallet-" + clean;
return clean + ".dat";
}
// Copy an out-of-datadir wallet file into the datadir (the daemon only loads plain filenames
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
static void importAndOpen(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
std::string dest = normalizeWalletName(r.fileName);
if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
const std::string destPath = datadir + "/" + dest;
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
Notifications::instance().success(TR("wallets_imported"));
app->switchToWallet(dest);
s_open = false;
}
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
// and each user-added folder (any *.dat). Exception-safe: iterates with error_codes.
static void scan() {
s_rows.clear();
namespace fs = std::filesystem;
auto addFrom = [](const std::string& dir, bool inDatadir) {
std::error_code ec;
if (dir.empty() || !fs::is_directory(dir, ec)) return;
fs::directory_iterator it(dir, ec), end;
for (; !ec && it != end; it.increment(ec)) {
std::error_code fec;
if (!it->is_regular_file(fec)) continue;
std::string name = it->path().filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue;
if (inDatadir && name.rfind("wallet", 0) != 0) continue;
WalletRow r;
r.fileName = name;
r.dir = dir;
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(it->path(), fec));
s_rows.push_back(std::move(r));
}
};
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true);
if (s_app)
for (const auto& f : s_app->walletIndex().extraFolders())
addFrom(f, /*inDatadir=*/false);
}
static inline bool s_open = false;
static inline App* s_app = nullptr;
static inline bool s_needScan = false;
static inline char s_newName[128] = "";
static inline std::vector<WalletRow> s_rows;
};
} // namespace ui
} // namespace dragonx