// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "faq_dialog.h" #include "faq_content.h" #include "../../app.h" #include "../../util/i18n.h" #include "../../util/text_format.h" #include "../../embedded/IconsMaterialDesign.h" #include "../schema/ui_schema.h" #include "../layout.h" #include "../material/type.h" #include "../material/colors.h" #include "../material/draw_helpers.h" #include "imgui.h" #include #include #include #include namespace dragonx { namespace ui { namespace { // Persists across frames (the dialog is re-entered each frame while open). Group 0 = Wallet, // 1 = Daemon. `expanded` keys are FaqEntry::questionKey (stable string literals from faq_content). struct FaqDialogState { int group = 0; char search[128] = ""; std::unordered_map expanded; }; FaqDialogState s_faq; bool entryMatches(const faq::FaqEntry& e, const char* query) { return util::containsIgnoreCase(TR(e.questionKey), query) || util::containsIgnoreCase(TR(e.answerKey), query); } // One answer body: wrapped, muted. Shared by the collapsible (non-search) and search paths. void renderAnswer(const char* answerKey) { ImGui::Indent(Layout::spacingMd()); ImGui::PushFont(material::Type().body2()); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium())); ImGui::TextWrapped("%s", TR(answerKey)); ImGui::PopStyleColor(); ImGui::PopFont(); ImGui::Unindent(Layout::spacingMd()); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); } } // namespace void RenderFaqDialog(App* app, bool* p_open) { auto& S = schema::UI(); auto win = S.window("dialogs.faq"); const float dp = Layout::dpiScale(); const bool daemonAvailable = app && app->supportsFullNodeLifecycleActions(); if (!daemonAvailable) s_faq.group = 0; // no Daemon tab in lite builds // Floating "BlurFloat" modal, matching the Wallets dialog: live-blur backdrop, no boxed card, a // plain heading (no ✕ — a Close button sits at the bottom). Roomy fixed width; height capped to the // viewport so the content flexes + scrolls on small / HiDPI screens. const float vpH = ImGui::GetMainViewport()->Size.y; const float wantH = (win.height > 0 ? win.height : 640.0f) * dp; material::OverlayDialogSpec spec; spec.title = TR("faq_title"); spec.p_open = p_open; spec.style = material::OverlayStyle::BlurFloat; spec.cardWidth = (win.width > 0 ? win.width : 860.0f); spec.cardHeight = std::min(wantH, vpH * 0.86f) / dp; spec.idSuffix = "faq"; if (!material::BeginOverlayDialog(spec)) { return; } // Esc closes (ImGui consumes Esc itself while the search box is being edited, so this only // fires when the field isn't capturing it). if (ImGui::IsKeyPressed(ImGuiKey_Escape)) *p_open = false; // Subtitle under the plain heading, matching the Wallets dialog's intro caption. material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR(daemonAvailable ? "faq_intro" : "faq_intro_lite")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); const float contentW = ImGui::GetContentRegionAvail().x; // ── Group tabs: Wallet | Daemon — only when there's a choice. Lite has just the Wallet group, so a // lone "Wallet" selector is redundant; skip it entirely (the group is already pinned to 0 above). if (daemonAvailable) { const float gap = ImGui::GetStyle().ItemSpacing.x; const float tabW = (contentW - gap) / 2.0f; auto tab = [&](const char* label, int idx) { const bool active = (s_faq.group == idx); if (active) { ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::Primary())); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnPrimary())); } if (material::TactileButton(label, ImVec2(tabW, 0))) s_faq.group = idx; if (active) ImGui::PopStyleColor(2); }; tab(TR("faq_group_wallet"), 0); ImGui::SameLine(); tab(TR("faq_group_daemon"), 1); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); // ── Search ── ImGui::SetNextItemWidth(contentW); ImGui::InputTextWithHint("##FaqSearch", TR("faq_search_hint"), s_faq.search, sizeof(s_faq.search)); const bool searching = s_faq.search[0] != '\0'; ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // ── Scrollable Q&A body ── (reserve room for the Close button footer below) const float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd(); float bodyH = ImGui::GetContentRegionAvail().y - footerH; if (bodyH < 80.0f * dp) bodyH = 80.0f * dp; // Inner padding gives the content breathing room and, on the right, a clear gap to the LEFT of the // scrollbar (WindowPadding.x is exactly that gap); the scrollbar itself is made chunkier than the // app default. NoScrollWithMouse + ApplySmoothScroll gives the wheel eased scrolling, matching the // Wallets dialog / Settings page (ApplySmoothScroll owns the wheel input and lerps ScrollY). ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingXs())); ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 16.0f * dp); ImGui::BeginChild("##FaqScroll", ImVec2(0, bodyH), false, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollWithMouse); material::ApplySmoothScroll(); ImDrawList* dl = ImGui::GetWindowDrawList(); const auto& groups = (s_faq.group == 1 && daemonAvailable) ? faq::daemonFaq() : faq::walletFaq(daemonAvailable); bool anyShown = false; bool firstSection = true; for (const auto& subcat : groups) { // Collect the entries visible under the current search. std::vector visible; for (const auto& e : subcat.entries) { if (!searching || entryMatches(e, s_faq.search)) visible.push_back(&e); } if (visible.empty()) continue; // Section break: generous space above every section after the first, so topic groups read as // clearly separated bands rather than one uniform list. if (!firstSection) ImGui::Dummy(ImVec2(0, Layout::spacingLg())); firstSection = false; anyShown = true; // Section header — accent overline + a thin full-width rule beneath it, anchoring the group // above its (brighter, normal-case) question rows. material::Type().textColored(material::TypeStyle::Overline, material::Primary(), TR(subcat.titleKey)); { const ImVec2 rp = ImGui::GetCursorScreenPos(); const float rw = ImGui::GetContentRegionAvail().x; dl->AddLine(ImVec2(rp.x, rp.y + dp), ImVec2(rp.x + rw, rp.y + dp), material::Divider(), 1.0f * dp); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); for (const auto* e : visible) { const float rowW = ImGui::GetContentRegionAvail().x; if (searching) { // Search results: show question + answer directly (no collapsing). ImGui::PushFont(material::Type().subtitle2()); ImGui::TextWrapped("%s", TR(e->questionKey)); ImGui::PopFont(); renderAnswer(e->answerKey); } else { bool& exp = s_faq.expanded[e->questionKey]; std::string id = std::string("##faq_") + e->questionKey; material::CollapsibleHeader(dl, id.c_str(), TR(e->questionKey), exp, rowW, material::Type().subtitle2(), material::OnSurface()); if (exp) renderAnswer(e->answerKey); } } } if (!anyShown) { ImGui::Dummy(ImVec2(0, Layout::spacingLg())); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium())); ImGui::TextWrapped("%s", TR("faq_no_results")); ImGui::PopStyleColor(); } ImGui::EndChild(); ImGui::PopStyleVar(2); // ScrollbarSize, WindowPadding // Close button footer (BlurFloat has no ✕ in the heading). const float closeW = 120.0f * dp; material::BeginOverlayDialogFooter(closeW, false); if (material::TactileButton(TR("close"), ImVec2(closeW, 0))) *p_open = false; material::EndOverlayDialog(); } } // namespace ui } // namespace dragonx