// 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=). #pragma once #include #include #include #include #include #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(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(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(s.front()) <= ' ') s.erase(s.begin()); while (!s.empty() && static_cast(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(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(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 s_rows; }; } // namespace ui } // namespace dragonx