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>
This commit is contained in:
2026-07-10 14:11:39 -05:00
parent 5eff3adc3c
commit 06c8b99bce
4 changed files with 317 additions and 29 deletions

View File

@@ -404,6 +404,22 @@ void App::buildSweepCatalog()
add("modal-console-commands", ui::NavPage::Console,
[](App& a) { a.console_tab_.sweepSetCommandsPopup(true); },
[](App& a) { a.console_tab_.sweepSetCommandsPopup(false); });
// In-app folder picker (Wallets → Scan another folder). Populate a small demo tree so the
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
add("modal-folder-picker", ui::NavPage::Settings,
[](App& a) {
std::error_code ec;
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
fs::create_directories(demo + "/" + sub, ec);
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
if (!fs::exists(demo + "/" + f, ec))
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
ui::WalletsDialog::show(&a);
ui::FolderPicker::open(demo, [](const std::string&) {});
},
[](App&) { ui::FolderPicker::close(); ui::WalletsDialog::hide(); });
}
// ── State machine ───────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,277 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// In-app, Material-styled folder picker. A modal overlay that lets the user browse the filesystem
// and pick a directory (used by the Wallets dialog's "Scan another folder" action instead of a
// raw path input field). Single overlay at a time — the overlay framework doesn't nest, so the
// caller suppresses its own overlay while FolderPicker::isOpen().
#pragma once
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
#include "imgui.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
namespace dragonx {
namespace ui {
class FolderPicker {
public:
// Open the picker starting at `startDir` (falls back to home if empty/invalid). `onPick` is
// invoked with the chosen absolute path when the user confirms; nothing runs on cancel.
static void open(const std::string& startDir, std::function<void(const std::string&)> onPick) {
namespace fs = std::filesystem;
std::error_code ec;
if (startDir.empty()) {
// Reopen where the user last browsed (nice for repeated scans); else start at home.
if (s_dir.empty() || !fs::is_directory(fs::path(s_dir), ec))
navigate(util::Platform::getHomeDir());
else
navigate(s_dir);
} else {
fs::path p(startDir);
if (!fs::is_directory(p, ec)) p = fs::path(util::Platform::getHomeDir());
navigate(p.string());
}
s_onPick = std::move(onPick);
s_open = true;
}
static bool isOpen() { return s_open; }
static void close() { s_open = false; }
static void render() {
if (!s_open) return;
using namespace material;
const float dp = Layout::dpiScale();
ImFont* rowFont = Type().body1();
ImFont* icoFont = Type().iconSmall();
ImFont* metaFont = Type().caption();
// ---- Fixed-height card sized to a stable row budget (no auto-height feedback) ----------
const int kVisRows = 9;
const float rowH = rowFont->LegacySize + Layout::spacingMd();
const float listPad = Layout::spacingXs();
const float listH = kVisRows * rowH + listPad * 2.0f;
const float headH = Type().h6()->LegacySize + Layout::spacingXs();
const float pathH = ImGui::GetFrameHeight() + Layout::spacingSm();
const float statusH = metaFont->LegacySize + Layout::spacingXs();
const float footH = Layout::spacingSm() + 1.0f + Layout::spacingSm()
+ ImGui::GetFrameHeightWithSpacing();
const float padV = 52.0f;
const float cardH = (headH + pathH + listH + statusH + footH + padV) / dp;
OverlayDialogSpec ov;
ov.title = TR("picker_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 720.0f;
ov.cardHeight = cardH;
ov.idSuffix = "folderpicker";
if (!BeginOverlayDialog(ov)) return;
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false;
const float fullW = ImGui::GetContentRegionAvail().x;
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
// End-truncate, popping whole UTF-8 code points.
auto fit = [&](std::string s, ImFont* f, float maxW){
bool t=false;
while (s.size()>1 && tw(f,s)>maxW){
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;
};
// Left-truncate a path (keep the tail — the current folder + nearest parents).
auto fitPath = [&](std::string s, ImFont* f, float maxW){
const std::string ell = "\xE2\x80\xA6";
if (tw(f,s) <= maxW) return s;
while (s.size()>1 && tw(f, ell+s) > maxW){
s.erase(s.begin());
while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
}
return ell + s;
};
// ---- Path bar: Up + Home + current path strip -----------------------------------------
std::string pendingNav;
{
const float bh = ImGui::GetFrameHeight();
IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover();
st.bgRounding = 5.0f * dp;
std::filesystem::path cur(s_dir);
const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur;
st.tooltip = TR("picker_up");
if (IconButton("##pickerUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent)
pendingNav = cur.parent_path().string();
ImGui::SameLine(0.0f, Layout::spacingXs());
IconButtonStyle hst = st; hst.tooltip = TR("picker_home");
if (IconButton("##pickerHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst))
pendingNav = util::Platform::getHomeDir();
ImGui::SameLine(0.0f, Layout::spacingSm());
// Framed path strip
ImVec2 sMin = ImGui::GetCursorScreenPos();
const float stripW = ImGui::GetContentRegionAvail().x;
ImVec2 sMax(sMin.x + stripW, sMin.y + bh);
ImDrawList* wdl = ImGui::GetWindowDrawList();
wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp);
wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f);
const float tpad = Layout::spacingSm();
wdl->AddText(rowFont, rowFont->LegacySize,
ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f),
OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str());
ImGui::Dummy(ImVec2(stripW, bh));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Directory list: folders (clickable) then *.dat wallets present (informational) ----
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(listPad, listPad));
ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20));
ImGui::BeginChild("##pickerList", ImVec2(fullW, listH), true);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
ImDrawList* dl = ImGui::GetWindowDrawList();
const float rowW = ImGui::GetContentRegionAvail().x;
const float padX = Layout::spacingSm();
if (s_subdirs.empty() && s_datfiles.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Indent(padX);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("picker_empty"));
ImGui::Unindent(padX);
}
// Folder rows
for (std::size_t i = 0; i < s_subdirs.size(); ++i) {
ImVec2 rMin = ImGui::GetCursorScreenPos();
ImVec2 rMax(rMin.x + rowW, rMin.y + rowH);
ImGui::PushID((int)i);
const bool clicked = ImGui::InvisibleButton("##dir", ImVec2(rowW, rowH));
const bool hov = ImGui::IsItemHovered();
ImGui::PopID();
if (hov) {
dl->AddRectFilled(rMin, rMax, WithAlpha(OnSurface(), 22), 6.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
const float midY = (rMin.y + rMax.y) * 0.5f;
const float icoSz = rowFont->LegacySize * 1.05f;
float x = rMin.x + padX;
dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f),
hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER);
x += icoSz + Layout::spacingSm();
// chevron affordance on the right
const float chSz = metaFont->LegacySize;
const float chX = rMax.x - padX - chSz;
if (hov)
dl->AddText(icoFont, chSz, ImVec2(chX, midY - chSz * 0.5f), OnSurfaceMedium(), ICON_MD_CHEVRON_RIGHT);
std::string nm = fit(s_subdirs[i], rowFont, (chX - Layout::spacingSm()) - x);
dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f),
OnSurface(), nm.c_str());
if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string();
}
// Wallet-file rows (informational — dimmed, not clickable)
for (std::size_t i = 0; i < s_datfiles.size(); ++i) {
ImVec2 rMin = ImGui::GetCursorScreenPos();
const float midY = rMin.y + rowH * 0.5f;
const float icoSz = rowFont->LegacySize * 1.05f;
float x = rMin.x + padX;
dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), OnSurfaceDisabled(),
ICON_MD_ACCOUNT_BALANCE_WALLET);
x += icoSz + Layout::spacingSm();
std::string nm = fit(s_datfiles[i], rowFont, (rMin.x + rowW - padX) - x);
dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f),
OnSurfaceDisabled(), nm.c_str());
ImGui::Dummy(ImVec2(rowW, rowH));
}
ImGui::PopStyleVar(); // ItemSpacing
ImGui::EndChild();
ImGui::PopStyleColor(); // ChildBg
ImGui::PopStyleVar(); // WindowPadding
// ---- Status: how many wallet files are in the selected folder --------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (!s_datfiles.empty()) {
char buf[96];
snprintf(buf, sizeof(buf), TR("picker_dat_count"), (int)s_datfiles.size());
Type().textColored(TypeStyle::Caption, Success(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("picker_dat_none"));
}
// ---- Footer: Select this folder / Cancel ----------------------------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Separator();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (StyledButton(TR("picker_select"), ImVec2(200.0f * dp, 0))) {
if (s_onPick) s_onPick(s_dir);
s_open = false;
}
ImGui::SameLine();
if (StyledButton(TR("cancel"), ImVec2(120.0f * dp, 0))) s_open = false;
EndOverlayDialog();
if (!pendingNav.empty()) navigate(pendingNav);
}
private:
static void navigate(const std::string& dir) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path p(dir);
if (!fs::is_directory(p, ec)) return;
fs::path abs = fs::absolute(p, ec);
s_dir = ec ? dir : abs.lexically_normal().string();
s_subdirs.clear();
s_datfiles.clear();
fs::directory_iterator it(p, ec), end;
int guard = 0;
for (; !ec && it != end && guard < 4000; it.increment(ec), ++guard) {
std::error_code fec;
std::string name = it->path().filename().string();
if (name.empty()) continue;
if (it->is_directory(fec)) {
s_subdirs.push_back(name);
} else if (it->is_regular_file(fec)) {
if (name.size() > 4 && name.substr(name.size() - 4) == ".dat")
s_datfiles.push_back(name);
}
}
auto ci = [](const std::string& a, const std::string& b){
return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(),
[](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); });
};
std::sort(s_subdirs.begin(), s_subdirs.end(), ci);
std::sort(s_datfiles.begin(), s_datfiles.end(), ci);
}
static inline bool s_open = false;
static inline std::string s_dir;
static inline std::vector<std::string> s_subdirs;
static inline std::vector<std::string> s_datfiles;
static inline std::function<void(const std::string&)> s_onPick;
};
} // namespace ui
} // namespace dragonx

View File

@@ -29,6 +29,7 @@
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "folder_picker.h"
namespace dragonx {
namespace ui {
@@ -39,13 +40,15 @@ public:
s_open = true;
s_app = app;
s_needScan = true;
s_newFolder[0] = '\0';
}
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();
@@ -62,16 +65,16 @@ public:
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::spacingSm();
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 = 3.0f * Layout::spacingSm()
const float belowH = 4.0f * Layout::spacingSm()
+ (capRow + ctrlRow) // Create label + input row
+ (s_showAddFolder ? (capRow + ctrlRow) : capRow) // folder row OR toggle link
+ ctrlRow // full-width "scan folder" button
+ (style.ItemSpacing.y + 1.0f) // separator
+ ctrlRow // footer buttons
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
@@ -206,12 +209,13 @@ public:
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Create a new wallet (primary) ----------------------------------------------------
// ---- 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(-150.0f * dp);
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(120.0f * dp, 0))) {
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
std::string name = normalizeWalletName(s_newName);
std::error_code ec;
if (name.empty()) {
@@ -228,29 +232,17 @@ public:
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Add-folder tucked behind a toggle so it doesn't compete with Create --------------
if (!s_showAddFolder) {
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
ImGui::TextUnformatted(TR("wallets_add_folder_toggle"));
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsItemClicked()) s_showAddFolder = true;
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_add_folder"));
ImGui::SetNextItemWidth(-150.0f * dp);
ImGui::InputTextWithHint("##walletFolder", TR("wallets_folder_hint"), s_newFolder, sizeof(s_newFolder));
ImGui::SameLine();
if (StyledButton(TR("wallets_add"), ImVec2(120.0f * dp, 0))) {
// ---- 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;
std::string dir = s_newFolder;
if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
s_newFolder[0] = '\0';
s_needScan = true;
} else {
Notifications::instance().warning(TR("wallets_folder_invalid"));
}
}
});
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -352,9 +344,7 @@ private:
static inline bool s_open = false;
static inline App* s_app = nullptr;
static inline bool s_needScan = false;
static inline char s_newFolder[512] = "";
static inline char s_newName[128] = "";
static inline bool s_showAddFolder = false;
static inline std::vector<WalletRow> s_rows;
};

View File

@@ -266,11 +266,8 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_never"] = "Never";
strings_["wallets_import_hint"] = "Import to open";
strings_["wallets_import_tt"] = "This wallet is outside the data directory. Opening it will copy it in first (coming soon).";
strings_["wallets_add_folder"] = "Also scan another folder for wallet files:";
strings_["wallets_add_folder_toggle"] = "+ Scan another folder for wallets\xE2\x80\xA6";
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Import to copy it in.";
strings_["wallets_folder_hint"] = "/path/to/folder with wallet .dat files";
strings_["wallets_add"] = "Add folder";
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
strings_["wallets_import"] = "Import";
strings_["wallets_new_label"] = "Create a new wallet:";
@@ -282,6 +279,14 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_import_failed"] = "Could not import that wallet file.";
strings_["wallets_creating"] = "Creating wallet — the node will restart…";
strings_["wallets_reveal"] = "Reveal folder";
// In-app folder picker (Scan another folder)
strings_["picker_title"] = "Select a folder to scan";
strings_["picker_up"] = "Up one level";
strings_["picker_home"] = "Home folder";
strings_["picker_empty"] = "This folder has no sub-folders or wallet files.";
strings_["picker_dat_count"] = "%d wallet file(s) in this folder";
strings_["picker_dat_none"] = "No wallet files directly in this folder";
strings_["picker_select"] = "Scan this folder";
strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it";
// Migrate-to-seed modal
strings_["mig_title"] = "Migrate to a seed wallet";