diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index f2cd117..8c81ca3 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -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 ─────────────────────────────────────────────────────────────────────────── diff --git a/src/ui/windows/folder_picker.h b/src/ui/windows/folder_picker.h new file mode 100644 index 0000000..b4b3c94 --- /dev/null +++ b/src/ui/windows/folder_picker.h @@ -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 +#include +#include +#include +#include +#include + +#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 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(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(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 s_subdirs; + static inline std::vector s_datfiles; + static inline std::function s_onPick; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 2625464..d6b79ec 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -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 s_rows; }; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 369e60f..0d4ede3 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";