The picker used a fixed-height card whose budget under-counted ImGui's inter-item spacing, so on larger windows / at 150% DPI the footer buttons overflowed the card's clip rect and got cut off. Rework it as a flex column: the card takes ~82% of the viewport height (like the console command-reference modal) and the directory list fills whatever space is left above a reserved footer block. The status line + Scan/Cancel buttons are now always pinned at the bottom and visible at any window size or DPI, and the browsing area is generous instead of a cramped fixed row count. Verified headless at 100% + 150%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
282 lines
13 KiB
C++
282 lines
13 KiB
C++
// 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();
|
|
|
|
const float rowH = rowFont->LegacySize + Layout::spacingMd();
|
|
const float listPad = Layout::spacingXs();
|
|
|
|
// The card takes a generous share of the window (a file browser wants room) — not a fixed
|
|
// row count. The list inside FLEXES to fill whatever height is left above the footer, so the
|
|
// footer is always pinned + visible and nothing clips, at any window size or DPI.
|
|
const float vpH = ImGui::GetMainViewport()->Size.y;
|
|
const float cardH = (vpH * 0.82f) / dp; // logical; the framework re-applies dp + caps at vp-32
|
|
|
|
OverlayDialogSpec ov;
|
|
ov.title = TR("picker_title");
|
|
ov.p_open = &s_open;
|
|
ov.style = OverlayStyle::BlurFloat;
|
|
ov.cardWidth = 760.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) ----
|
|
// Flex: the list fills the space between the path bar and the footer block. Reserving a
|
|
// (slightly generous) footer height keeps the status line + buttons pinned and never clipped.
|
|
const ImGuiStyle& gstyle = ImGui::GetStyle();
|
|
const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize // status line
|
|
+ Layout::spacingSm() + 1.0f + Layout::spacingSm() // separator block
|
|
+ ImGui::GetFrameHeight() // footer buttons
|
|
+ 6.0f * gstyle.ItemSpacing.y; // inter-item gaps
|
|
float listH = ImGui::GetContentRegionAvail().y - footerBlockH;
|
|
listH = std::max(listH, rowH * 3.0f + listPad * 2.0f);
|
|
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
|