diff --git a/src/ui/material/settings_controls.h b/src/ui/material/settings_controls.h new file mode 100644 index 0000000..8d11a92 --- /dev/null +++ b/src/ui/material/settings_controls.h @@ -0,0 +1,193 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Settings design-system controls — the polished chat-settings look (accent subsection headers, +// labeled rows with right-aligned controls, iOS-style segmented controls) promoted to reusable +// components, plus a tiered ActionButton (Primary/Secondary/Tertiary/Destructive) with optional +// leading Material icon and a ButtonFlow that wraps rows of buttons instead of shrinking them. + +#pragma once + +#include "draw_helpers.h" // colors, type, layout, icons, WithAlpha, ScaleAlpha, DrawButtonGlassOverlay +#include "imgui.h" + +#include + +namespace dragonx { +namespace ui { +namespace material { + +// Accent small-caps subsection header ("KEYS & BACKUP", "APPEARANCE"…) — the chat-settings section() look. +inline void SettingsSubheader(const char* text) { + const float dp = Layout::dpiScale(); + ImGui::Dummy(ImVec2(0.0f, 8.0f * dp)); + ImGui::PushFont(Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(Primary(), 235)); + ImGui::TextUnformatted(text); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0.0f, 2.0f * dp)); +} + +// A labeled settings row: label left, control right-aligned in a fixed column. Construct once per card +// section with the content width (0 = auto), then call .label(text) before drawing each control (leaves +// the cursor at the control origin and sets the next item width to the control column). +struct SettingsRow { + float leftX, rowW, ctrlW, rowGap; + explicit SettingsRow(float contentWidth, float controlWidth = 250.0f) { + const float dp = Layout::dpiScale(); + leftX = ImGui::GetCursorPosX(); + rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x; + ctrlW = controlWidth * dp; + rowGap = 5.0f * dp; + } + void label(const char* text) { + ImGui::Dummy(ImVec2(0.0f, rowGap)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(text); + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW))); + ImGui::SetNextItemWidth(ctrlW); + } +}; + +// iOS-style segmented control (rounded track + inset pill on the selection). Draws its own labeled row +// and returns the (possibly changed) index. +inline int SegmentedControl(SettingsRow& row, const char* label, const char* const* items, int count, int value) { + const float dp = Layout::dpiScale(); + row.label(label); + const ImVec2 origin = ImGui::GetCursorScreenPos(); + const float h = ImGui::GetFrameHeight(); + const float seg = row.ctrlW / static_cast(count); + const float round = 7.0f * dp; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(origin, ImVec2(origin.x + row.ctrlW, origin.y + h), WithAlpha(OnSurface(), 20), round); + int result = value; + ImGui::PushID(label); + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h); + ImGui::SetCursorScreenPos(mn); + if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i; + const bool hov = ImGui::IsItemHovered(); + if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const bool sel = (value == i); + if (sel) { + const float in = 2.0f * dp; + dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in), + WithAlpha(Primary(), 210), std::max(1.0f, round - in)); + } else if (hov) { + dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 26), round); + } + const ImVec2 ts = ImGui::CalcTextSize(items[i]); + const float lpad = 4.0f * dp; + const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad; + ImGui::PushClipRect(mn, mx, true); + dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f), + sel ? IM_COL32(255, 255, 255, 236) : OnSurfaceMedium(), items[i]); + ImGui::PopClipRect(); + ImGui::PopID(); + } + ImGui::PopID(); + ImGui::SetCursorScreenPos(origin); + ImGui::Dummy(ImVec2(row.ctrlW, h)); // reserve the control's rect for layout flow + return result; +} + +// ── Tiered action buttons ─────────────────────────────────────────────────── +// Primary = filled accent (the one main action of a group) +// Secondary = glass (default — common actions) +// Tertiary = ghost / low-emphasis (rarely used) +// Destructive = error-tinted outline (delete / reset) +enum class ActionTier { Primary, Secondary, Tertiary, Destructive }; + +// The width an ActionButton will occupy (for ButtonFlow / manual layout). +inline float ActionButtonWidth(const char* label, const char* icon, float minWidth = 0.0f) { + ImFont* lf = Type().button(); + ImFont* icf = Type().iconSmall(); + const float dp = Layout::dpiScale(); + const float padX = 12.0f * dp, gap = 6.0f * dp; + const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x; + const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f; + const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW; + return std::max(w, minWidth); +} + +// A tiered action button with an optional leading Material icon (ICON_MD_* or nullptr). Auto-sizes to +// its content (>= minWidth). Respects BeginDisabled() (dims via the style alpha, not clickable). +inline bool ActionButton(const char* id, const char* label, const char* icon, ActionTier tier, float minWidth = 0.0f) { + ImFont* lf = Type().button(); + ImFont* icf = Type().iconSmall(); + const float dp = Layout::dpiScale(); + const float gap = 6.0f * dp; + const float h = ImGui::GetFrameHeight(); + const bool hasIcon = icon && icon[0] && icf; + const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x; + const float iconW = hasIcon ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f; + const float w = ActionButtonWidth(label, icon, minWidth); + + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, h)); + const bool hov = ImGui::IsItemHovered(), act = ImGui::IsItemActive(); + if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 pMax(pos.x + w, pos.y + h); + const float round = ImGui::GetStyle().FrameRounding; + const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this + + ImU32 bg = 0, border = 0, fg = OnSurface(); + bool glass = false; + switch (tier) { + case ActionTier::Primary: + bg = WithAlpha(Primary(), act ? 255 : (hov ? 245 : 220)); + fg = IM_COL32(255, 255, 255, 240); + break; + case ActionTier::Secondary: + bg = WithAlpha(OnSurface(), hov ? 26 : 16); + border = WithAlpha(OnSurface(), 40); + glass = true; + fg = OnSurface(); + break; + case ActionTier::Tertiary: + bg = hov ? WithAlpha(OnSurface(), 18) : 0; + fg = OnSurfaceMedium(); + break; + case ActionTier::Destructive: + bg = hov ? WithAlpha(Error(), 32) : WithAlpha(Error(), 12); + border = WithAlpha(Error(), 90); + fg = Error(); + break; + } + if (bg) dl->AddRectFilled(pos, pMax, ScaleAlpha(bg, a), round); + if (border) dl->AddRect(pos, pMax, ScaleAlpha(border, a), round, 0, 1.0f); + if (glass) DrawButtonGlassOverlay(dl, pos, pMax, round, act, hov); + fg = ScaleAlpha(fg, a); + + const float contentW = iconW + (iconW > 0.0f ? gap : 0.0f) + labelW; + float cx = pos.x + (w - contentW) * 0.5f; + if (hasIcon) { + dl->AddText(icf, icf->LegacySize, ImVec2(cx, pos.y + (h - icf->LegacySize) * 0.5f), fg, icon); + cx += iconW + gap; + } + dl->AddText(lf, lf->LegacySize, ImVec2(cx, pos.y + (h - lf->LegacySize) * 0.5f), fg, label); + return pressed; +} + +// Places ActionButtons left→right, wrapping to a new row when the next one won't fit (instead of the +// old font-scale-to-fit). Call next(width) before each ActionButton. +struct ButtonFlow { + float availW, gap; float x = 0.0f; bool firstOnRow = true; + explicit ButtonFlow(float availWidth, float gapPx = 8.0f) : availW(availWidth) { + gap = gapPx * Layout::dpiScale(); + } + void next(float w) { + if (firstOnRow) { firstOnRow = false; x = w; return; } + if (x + gap + w <= availW) { ImGui::SameLine(0, gap); x += gap + w; } + else { x = w; } // natural newline wraps to the next row + } +}; + +} // namespace material +} // namespace ui +} // namespace dragonx diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 00f2a12..1a50796 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -31,6 +31,7 @@ #include "../effects/low_spec.h" #include "../effects/scroll_fade_shader.h" #include "../material/draw_helpers.h" +#include "../material/settings_controls.h" #include "../material/type.h" #include "../material/colors.h" #include "../windows/validate_address_dialog.h" @@ -1390,119 +1391,72 @@ void RenderSettingsPage(App* app) { if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx")); } - // --- Backup & Data (merged in from its former standalone card) --- + // --- Backup & Data --- ImGui::Dummy(ImVec2(0, Layout::spacingMd())); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("backup_data")); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); { - float btnPad = S.drawElement("components.settings-page", "wallet-btn-padding").sizeOr(24.0f); - const char* r1[] = {TR("settings_import_key"), TR("settings_export_key"), TR("settings_export_all"), TR("settings_backup"), TR("settings_export_csv")}; - const char* t1[] = { TR("tt_import_key"), TR("tt_export_key"), TR("tt_export_all"), TR("tt_backup"), TR("tt_export_csv") }; - const bool showFullNodeLifecycleActions = app->supportsFullNodeLifecycleActions(); - const char* wizLabel = TR("setup_wizard"); - const char* bsLabel = TR("download_bootstrap"); - float sp = Layout::spacingSm(); - ImFont* btnFont = S.resolveFont("button"); + using AT = material::ActionTier; + const bool fullNode = app->supportsFullNodeLifecycleActions(); + // Tier-ordered, icon-labelled buttons that WRAP to new rows (no more font-scale-to-fit). + // Emphasized actions (accent) cluster on top, then common actions, then low-emphasis exports. + auto btn = [&](material::ButtonFlow& fl, const char* id, const char* label, const char* icon, + AT tier, const char* tip) -> bool { + fl.next(material::ActionButtonWidth(label, icon)); + const bool p = material::ActionButton(id, label, icon, tier); + if (ImGui::IsItemHovered() && tip && tip[0]) material::Tooltip("%s", tip); + return p; + }; - float btnPadX = btnPad * 2; - float naturalW = 0; - for (int i = 0; i < 5; i++) - naturalW += ImGui::CalcTextSize(r1[i]).x + btnPadX; - float impViewW = ImGui::CalcTextSize(TR("settings_import_viewkey")).x + btnPadX; - naturalW += impViewW; // the extra "Import viewing key" button (rendered after Import key) - float wizW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(wizLabel).x + btnPadX : 0.0f; - float bsW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(bsLabel).x + btnPadX : 0.0f; - // Full-node-only "Seed phrase" + "Migrate to seed" buttons trail the Backup button. - float seedW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_backup_button")).x + btnPadX : 0.0f; - float migrateW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_migrate_button")).x + btnPadX : 0.0f; - float totalW = naturalW + sp * 6; // 6 base buttons (incl. Import viewing key) - if (showFullNodeLifecycleActions) totalW += wizW + bsW + seedW + migrateW + sp * 4; - - float scale = (totalW > contentW) ? contentW / totalW : 1.0f; - if (scale < 1.0f) ImGui::SetWindowFontScale(scale); - float scaledSp = sp * scale; - - if (TactileButton(r1[0], ImVec2(0, 0), btnFont)) + // Emphasized (Primary): import key + (full-node) seed / wallets / bootstrap. + material::ButtonFlow fPrim(contentW); + if (btn(fPrim, "##imp_key", TR("settings_import_key"), ICON_MD_KEY, AT::Primary, TR("tt_import_key"))) app->showImportKeyDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[0]); - // Watch-only shielded viewing-key import (separate button — different key type + scan-height). - ImGui::SameLine(0, scaledSp); - if (TactileButton(TR("settings_import_viewkey"), ImVec2(0, 0), btnFont)) - app->showImportViewingKeyDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey")); - ImGui::SameLine(0, scaledSp); - if (TactileButton(r1[1], ImVec2(0, 0), btnFont)) - app->showExportKeyDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[1]); - ImGui::SameLine(0, scaledSp); - if (TactileButton(r1[2], ImVec2(0, 0), btnFont)) - ExportAllKeysDialog::show(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[2]); - ImGui::SameLine(0, scaledSp); - if (TactileButton(r1[3], ImVec2(0, 0), btnFont)) - app->showBackupDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[3]); - if (showFullNodeLifecycleActions) { - // Back up the wallet's BIP39 seed phrase (full-node mnemonic wallets). - ImGui::SameLine(0, scaledSp); - if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont)) + if (fullNode) { + if (btn(fPrim, "##seed", TR("seed_backup_button"), ICON_MD_VPN_KEY, AT::Primary, TR("tt_seed_backup"))) app->showSeedBackupDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup")); - // Migrate to a new seed-phrase wallet (create in isolation, then sweep funds). A - // legacy (pre-seed-phrase) wallet glows the button to nudge the user toward migrating. - ImGui::SameLine(0, scaledSp); + if (btn(fPrim, "##wallets", TR("wallets_button"), ICON_MD_ACCOUNT_BALANCE_WALLET, AT::Primary, TR("tt_wallets_button"))) + ui::WalletsDialog::show(app); + if (btn(fPrim, "##bootstrap", TR("download_bootstrap"), ICON_MD_CLOUD_DOWNLOAD, AT::Primary, TR("tt_download_bootstrap"))) + BootstrapDownloadDialog::show(app); + } + + // Common (Secondary): viewing-key import, backup, (full-node) migrate + setup wizard. + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + material::ButtonFlow fSec(contentW); + if (btn(fSec, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY, AT::Secondary, TR("tt_import_viewkey"))) + app->showImportViewingKeyDialog(); + if (btn(fSec, "##backup", TR("settings_backup"), ICON_MD_BACKUP, AT::Secondary, TR("tt_backup"))) + app->showBackupDialog(); + if (fullNode) { const bool migrateGlow = app->isPreSeedWallet(); - if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont)) + if (btn(fSec, "##migrate", TR("seed_migrate_button"), ICON_MD_SWAP_HORIZ, AT::Secondary, TR("tt_seed_migrate"))) app->showSeedMigrationDialog(); - if (migrateGlow) { + if (migrateGlow) { // pulsing accent halo nudging a legacy wallet to migrate const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax(); const float gdp = Layout::dpiScale(); - const float pulse = 0.5f + 0.5f * std::sin((float)ImGui::GetTime() * 3.2f); // 0..1 + const float pulse = 0.5f + 0.5f * std::sin((float)ImGui::GetTime() * 3.2f); ImDrawList* gdl = ImGui::GetWindowDrawList(); - for (int g = 3; g >= 1; --g) { // soft, pulsing outward halo in the accent color + for (int g = 3; g >= 1; --g) { const float e = (float)g * 2.2f * gdp; const int a = (int)((70.0f + pulse * 95.0f) / (float)g); gdl->AddRect(ImVec2(gmn.x - e, gmn.y - e), ImVec2(gmx.x + e, gmx.y + e), material::WithAlpha(material::Primary(), a), 6.0f * gdp + e, 0, 1.6f * gdp); } } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate")); - // Multi-wallet: list wallet files + switch the active one. - ImGui::SameLine(0, scaledSp); - if (TactileButton(TR("wallets_button"), ImVec2(0, 0), btnFont)) - ui::WalletsDialog::show(app); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallets_button")); - } - ImGui::SameLine(0, scaledSp); - if (TactileButton(r1[4], ImVec2(0, 0), btnFont)) - ExportTransactionsDialog::show(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[4]); - - if (showFullNodeLifecycleActions) { - // Right-align Setup Wizard + Download Bootstrap. - float framePadX2 = ImGui::GetStyle().FramePadding.x * 2.0f; - float curX = ImGui::GetCursorScreenPos().x; - float wizBtnW = ImGui::CalcTextSize(wizLabel).x + framePadX2; - float bsBtnW = ImGui::CalcTextSize(bsLabel).x + framePadX2; - float rightEdge = cardMin.x + availWidth - pad; - float rightGroupW = bsBtnW + scaledSp + wizBtnW; - float groupX = rightEdge - rightGroupW; - if (groupX > curX) { - ImGui::SameLine(0, 0); - ImGui::SetCursorScreenPos(ImVec2(groupX, ImGui::GetCursorScreenPos().y)); - } else { - ImGui::SameLine(0, scaledSp); - } - if (TactileButton(bsLabel, ImVec2(0, 0), btnFont)) - BootstrapDownloadDialog::show(app); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap")); - ImGui::SameLine(0, scaledSp); - if (TactileButton(wizLabel, ImVec2(0, 0), btnFont)) + if (btn(fSec, "##wizard", TR("setup_wizard"), ICON_MD_AUTO_FIX_HIGH, AT::Secondary, TR("tt_wizard"))) app->restartWizard(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard")); } - if (scale < 1.0f) ImGui::SetWindowFontScale(1.0f); + // Low-emphasis (Tertiary): exports. + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + material::ButtonFlow fTer(contentW); + if (btn(fTer, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT, AT::Tertiary, TR("tt_export_key"))) + app->showExportKeyDialog(); + if (btn(fTer, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE, AT::Tertiary, TR("tt_export_all"))) + ExportAllKeysDialog::show(); + if (btn(fTer, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION, AT::Tertiary, TR("tt_export_csv"))) + ExportTransactionsDialog::show(); } }