From b37d3d97b63b42df8b90a0070ad7daf8af15bf79 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 19 Aug 2026 16:10:02 -0500 Subject: [PATCH 01/12] fix(send/receive): unify card width, justify receive footer, fix recipient-row button height/clip/glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send and Receive are now consistent in layout, and the Send recipient row's buttons render correctly. Card envelope (Send ⇄ Receive consistency): - Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's width + centering (fill the available column up to content-max-width, then center). Both tabs derive their card from it, so they can't drift again. Previously Send capped at 760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider than ~860dp; now they fill available width identically. Receive: - Justify the footer buttons edge-to-edge (equal shares over the live count) instead of left-clustering with dead space, matching Send's full-width footer rhythm. - Build the address-dropdown preview to the combo's real pixel width so the trailing balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation). Send recipient row (input | Paste | contacts-icon): - Pin the contacts icon button to the frame height so the larger iconMed font doesn't auto-size it taller than Paste/the input. - Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no longer overshoots the card and clips the icon's right border. draw_helpers (root cause, app-wide): - TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that passes an explicit size and a "##id" label; no-op for labels without "##". Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards byte-identical at L=1174/R=2773). ctest 1/1. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/layout.h | 11 +++++++++ src/ui/material/draw_helpers.h | 11 ++++++--- src/ui/windows/receive_tab.cpp | 44 +++++++++++++++++++++++++--------- src/ui/windows/send_tab.cpp | 25 ++++++++++++------- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/src/ui/layout.h b/src/ui/layout.h index 83bbf52..397e65e 100644 --- a/src/ui/layout.h +++ b/src/ui/layout.h @@ -179,6 +179,17 @@ inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", // [layout] content-max-width; default is generous so data-dense screens stay comfortable. inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(1600.0f) * dpiScale(); } +// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the +// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST +// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they +// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than +// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW. +struct CardBox { float width; float offsetX; }; +inline CardBox mainComposeCardBox(float availW) { + float w = std::min(availW, kContentMaxWidth()); + return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) }; +} + inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); } inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); } diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index 3d8dbf1..688fcfd 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -544,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMax = ImGui::GetItemRectMax(); - // For icon fonts, manually draw centered icon after getting button rect + // For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the + // VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way + // ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and + // shove the glyph left off-center (and try to draw the notdef id chars). if (isIconFont && size.x > 0 && size.y > 0) { - ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label); + const char* labelEnd = label; + while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd; + ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd); ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f); - dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label); + dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd); } float rounding = ImGui::GetStyle().FrameRounding; diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index 3f209a1..6464539 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -175,6 +175,11 @@ static void RenderAddressDropdown(App* app, float width) { } } + // Combo/button widths first — the preview truncates to the combo's real pixel width below. + float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width)); + float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width)); + float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2; + // Build preview string if (!app->isConnected()) { s_source_preview = TR(app->isLiteBuild() ? "lite_no_wallet_short" : "not_connected"); @@ -183,18 +188,27 @@ static void RenderAddressDropdown(App* app, float width) { const auto& addr = state.addresses[s_selected_address_idx]; bool isZ = addr.type == "shielded"; const char* tag = isZ ? "[Z]" : "[T]"; - std::string trunc = util::truncateMiddle(addr.address, - static_cast(std::max(schema::UI().drawElement("tabs.receive", "addr-preview-trunc-min").size, width / schema::UI().drawElement("tabs.receive", "addr-preview-trunc-divisor").size))); - snprintf(buf, sizeof(buf), "%s %s \xe2\x80\x94 %.8f %s", - tag, trunc.c_str(), addr.balance, DRAGONX_TICKER); + // Reserve pixel room for the tag prefix and the trailing balance, then middle-truncate the + // address to whatever remains — measured with the combo's own Body2 font. Char-count + // truncation kept MORE chars as the column widened, so at 150% the scaled font overflowed + // and the combo hard-clipped "— 12.00000000 DRGX" to "— 1"; measuring in pixels keeps the + // balance visible at any scale. + ImFont* comboFont = Type().getFont(TypeStyle::Body2); + float comboFontSz = comboFont->LegacySize; + char prefix[16]; snprintf(prefix, sizeof(prefix), "%s ", tag); + char suffix[64]; snprintf(suffix, sizeof(suffix), " \xe2\x80\x94 %.8f %s", addr.balance, DRAGONX_TICKER); + float fixedW = comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, prefix).x + + comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, suffix).x; + // Combo interior = dropdownW minus its dropdown-arrow button (~frame height) and both frame paddings. + float addrBudget = dropdownW - ImGui::GetFrameHeight() - ImGui::GetStyle().FramePadding.x * 2.0f - fixedW; + if (addrBudget < 24.0f) addrBudget = 24.0f; // floor: truncate to a stub rather than overflow + std::string trunc = material::TruncateToWidth(addr.address, comboFont, comboFontSz, addrBudget); + snprintf(buf, sizeof(buf), "%s%s%s", prefix, trunc.c_str(), suffix); s_source_preview = buf; } else { s_source_preview = TR("select_receiving_address"); } - float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width)); - float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width)); - float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2; ImGui::SetNextItemWidth(dropdownW); ImGui::PushFont(Type().getFont(TypeStyle::Body2)); if (ImGui::BeginCombo("##RecvAddr", s_source_preview.c_str())) { @@ -584,12 +598,14 @@ void RenderReceiveTab(App* app) // ================================================================ { // Cap + center the card so the address/amount column stops stretching while the - // QR plateaus. cardW never exceeds formW (only shrinks); leftover becomes margin. + // QR plateaus. Shared with the Send tab via mainComposeCardBox() so the two card + // envelopes are identical (they must never drift in width/position again). // The RECENT RECEIVED list below stays on the uncapped formW (handled separately). float cardDp = Layout::dpiScale(); - float cardW = std::min(formW, 860.0f * cardDp); + Layout::CardBox cardBox = Layout::mainComposeCardBox(formW); + float cardW = cardBox.width; float cardLeftX = ImGui::GetCursorScreenPos().x; - float cardOffsetX = std::max(0.0f, (formW - cardW) * 0.5f); + float cardOffsetX = cardBox.offsetX; if (cardOffsetX > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + cardOffsetX); @@ -911,7 +927,13 @@ void RenderReceiveTab(App* app) { float btnGap = Layout::spacingMd(); float btnH = std::max(schema::UI().drawElement("tabs.receive", "action-btn-min-height").size, schema::UI().drawElement("tabs.receive", "action-btn-height").size * vScale); - float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size, innerW * S.drawElement("tabs.receive", "action-btn-width-ratio").size); + // Justify the footer edge-to-edge like Send's [Review Send][Cancel] row instead of packing + // fixed-width buttons from the left (which left a large dead gap on the right). Split innerW + // into equal shares over the live button count: 2 by default (Clear Request + Explorer), + // 4 when an amount is requested (+ Copy URI + Share). + int nBtns = (s_request_amount > 0 ? 2 : 0) + 2; + float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size, + (innerW - (nBtns - 1) * btnGap) / (float)nBtns); bool firstBtn = true; diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 3d30188..b9609ec 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -1272,12 +1272,13 @@ void RenderSendTab(App* app) float contentStartY = ImGui::GetCursorPosY(); float formAvailW = ImGui::GetContentRegionAvail().x; - // The compose form reads best as a centered fixed-width column, not edge-to-edge. - // Cap the card to a readable form width and center it; the recent-sends list below - // deliberately keeps the full column width (formAvailW). - const float sendDp = Layout::dpiScale(); - float formCardW = std::min(formAvailW, 760.0f * sendDp); - float formOffsetX = std::max(0.0f, (formAvailW - formCardW) * 0.5f); + // Fill the available column up to the content-max-width cap, then center. Shared with the + // Receive tab via mainComposeCardBox() so the two card envelopes are identical in width and + // position (Send previously capped at 760dp vs Receive's 860dp, so Send rendered narrower). + // The recent-sends list below deliberately keeps the full column width (formAvailW). + Layout::CardBox formBox = Layout::mainComposeCardBox(formAvailW); + float formCardW = formBox.width; + float formOffsetX = formBox.offsetX; float formW = formAvailW; ImGui::BeginGroup(); @@ -1367,7 +1368,10 @@ void RenderSendTab(App* app) float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size); float contactsW = ImGui::GetFrameHeight(); // compact square icon button for the contact picker - ImGui::PushItemWidth(colW - pasteW - contactsW - Layout::spacingSm() * 2.0f); + // Reserve the TWO real SameLine gaps (each = ItemSpacing.x) between input|Paste|icon. + // Reserving spacingSm (a smaller token) under-counted the gap, so the row overshot colW by + // ~2*(ItemSpacing.x - spacingSm) and the icon's right border clipped past the card edge. + ImGui::PushItemWidth(colW - pasteW - contactsW - ImGui::GetStyle().ItemSpacing.x * 2.0f); // Show clipboard preview as transparent overlay when paste button is hovered bool paste_hovered = false; @@ -1425,9 +1429,12 @@ void RenderSendTab(App* app) } } - // Contact picker — pick a saved contact's address as the recipient. + // Contact picker — pick a saved contact's address as the recipient. Pin the height to the + // frame height (== the input + Paste height) so the larger iconMed font doesn't auto-size + // this square button taller than its row-mates. (Passing a non-zero size also routes + // TactileButton through its precise InvisibleButton + centered-glyph path.) ImGui::SameLine(); - if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, 0), + if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, ImGui::GetFrameHeight()), material::Type().iconMed())) ImGui::OpenPopup("##ContactPickerPopup"); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("send_contacts_button")); -- 2.34.1 From d0bd55b9c1d982074d2e1f8102a41ceb2aebdf7a Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 16:59:03 -0500 Subject: [PATCH 02/12] =?UTF-8?q?feat(ui):=20settings=20polish=20=E2=80=94?= =?UTF-8?q?=20button=20retune,=20daemon=20card,=20RPC=202-row,=20chat=20pr?= =?UTF-8?q?eview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings tabs brought closer to the approved mockup: - ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px padX, Primary → accent-outline chip, Secondary/card buttons more defined. - Daemon-binary card: compact status right-aligned on the DAEMON BINARY heading (Up to date / Version differs / Not installed), filled/rounded status box, neutral danger divider (was alarming red), roomier spacing. - RPC Connection: two-row column-aligned layout (Host | Port, then Username | Password) so the password no longer clips off the card edge. - Chat settings tab: live conversation preview below the Appearance / Messaging cards; "Focus input on open" checkbox reflowed onto the console color-toggle row. - Debug Options: "Current theme only" toggle restricts either screenshot sweep to the active theme instead of cycling every skin. - Tabs fill the full content width (content-max-width cap disabled) and the sidebar nav panel centers within the true visible area. - i18n: new keys for the above (untranslated keys fall back to English). Co-Authored-By: Claude Opus 4.8 (1M context) --- res/fonts/NotoSansCJK-Subset.ttf | Bin 671376 -> 675232 bytes res/lang/de.json | 41 +- res/lang/es.json | 41 +- res/lang/fr.json | 41 +- res/lang/ja.json | 37 +- res/lang/ko.json | 41 +- res/lang/pt.json | 41 +- res/lang/ru.json | 41 +- res/lang/zh.json | 37 +- src/app.cpp | 37 +- src/app.h | 4 + src/app_sweep.cpp | 4 + src/ui/layout.h | 13 +- src/ui/material/settings_controls.h | 14 +- src/ui/pages/settings_page.cpp | 1554 +++++++++++++-------------- src/ui/sidebar.h | 12 +- src/ui/windows/chat_tab.cpp | 55 +- src/ui/windows/chat_tab.h | 2 +- src/util/i18n.cpp | 57 +- 19 files changed, 1082 insertions(+), 990 deletions(-) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 2bb3213d7758faa87984afefe439eeadac89efdc..e2694514c82bc0f11ce283022ed0ec1ed8bed44a 100644 GIT binary patch delta 20765 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 ss&4V{KTy6YFz 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 Sr9+b02cL!{>5b$Z_J05gtT4|2 diff --git a/res/lang/de.json b/res/lang/de.json index 8d643cc..672d636 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender hat den Miner blockiert", "available": "Verfügbar", "backup_backing_up": "Sicherung läuft...", + "backup_col_backup": "SICHERUNG", + "backup_col_export": "EXPORTIEREN", + "backup_col_import": "IMPORTIEREN & WIEDERHERSTELLEN", "backup_create": "Sicherung erstellen", "backup_created": "Wallet-Sicherung erstellt", "backup_data": "SICHERUNG & DATEN", @@ -423,6 +426,7 @@ "daemon_bundled": "Gebündelt", "daemon_install_bundled": "Gebündelten installieren", "daemon_installed": "Installiert", + "daemon_maintenance_label": "WARTUNG", "daemon_none_bundled": "keiner in diesem Build", "daemon_not_installed": "nicht installiert", "daemon_status_differ": "Installierte Binärdatei unterscheidet sich von der gebündelten Version.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "Der Download wird vor der Installation anhand der veröffentlichten SHA-256-Prüfsumme des Releases und einer fest hinterlegten ed25519-Signatur verifiziert.", "daemon_update_verifying": "Wird verifiziert…", "daemon_update_version": "Version:", + "daemon_updates_label": "AKTUALISIERUNGEN", "daemon_version": "Daemon", "dark": "Dunkel", "data_stale_prefix": "Aktualisiert", @@ -1228,6 +1233,7 @@ "sb_waiting_daemon_err": "Warten auf dragonxd — %s", "sb_warming_up": "Aufwärmen...", "sb_witness_cache": "Zeugen werden neu aufgebaut", + "scale_effects": "SKALIERUNG & EFFEKTE", "screenshot_open_dir": "Speicherort öffnen", "screenshot_sweep": "Screenshot-Durchlauf ausführen", "screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.", @@ -1316,12 +1322,12 @@ "settings": "Einstellungen", "settings_about_text": "Eine geschirmte Kryptowährungs-Wallet für DragonX (DRGX), erstellt mit Dear ImGui für ein leichtes, portables Erlebnis.", "settings_acrylic_level": "Acrylstufe:", - "settings_address_book": "Adressbuch...", + "settings_address_book": "Adressbuch…", "settings_auto_detected": "Automatisch erkannt aus DRAGONX.conf", "settings_auto_lock": "AUTO-SPERRE", "settings_auto_shield_desc": "Transparente Guthaben automatisch an geschirmte Adressen verschieben", "settings_auto_shield_funds": "Transparente Guthaben automatisch abschirmen", - "settings_backup": "Sicherung...", + "settings_backup": "Sicherung…", "settings_block_explorer_urls": "Block-Explorer-URLs", "settings_builtin": "Integriert", "settings_change_passphrase": "Passphrase ändern", @@ -1335,7 +1341,7 @@ "settings_copy_diagnostics": "Diagnose kopieren", "settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz", "settings_custom": "Benutzerdefiniert", - "settings_data_dir": "Datenverzeichnis:", + "settings_data_dir": "Datenverzeichnis", "settings_debug_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden", "settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.", "settings_debug_select": "Kategorien auswählen, um Daemon-Fehlerprotokollierung zu aktivieren (-debug= Flags).", @@ -1343,18 +1349,18 @@ "settings_encrypt_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren", "settings_encrypt_wallet": "Wallet verschlüsseln", "settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.", - "settings_export_all": "Alle exportieren...", - "settings_export_csv": "CSV exportieren...", - "settings_export_key": "Schlüssel exportieren...", + "settings_export_all": "Alle exportieren…", + "settings_export_csv": "CSV exportieren…", + "settings_export_key": "Schlüssel exportieren…", "settings_gradient_bg": "Hintergrund-Verlauf", "settings_gradient_desc": "Strukturierte Hintergründe durch sanfte Verläufe ersetzen", "settings_idle_after": "nach", - "settings_import_key": "Privaten Schlüssel importieren...", - "settings_import_viewkey": "Anzeigeschlüssel importieren...", + "settings_import_key": "Privaten Schlüssel importieren…", + "settings_import_viewkey": "Anzeigeschlüssel importieren…", "settings_language_note": "Hinweis: Manche Texte erfordern einen Neustart zur Aktualisierung", "settings_lock_now": "Jetzt sperren", "settings_locked": "Gesperrt", - "settings_merge_to_address": "An Adresse zusammenführen...", + "settings_merge_to_address": "An Adresse zusammenführen…", "settings_noise_opacity": "Rauschdichte:", "settings_not_connected": "Nicht mit dem Daemon verbunden", "settings_not_encrypted": "Nicht verschlüsselt", @@ -1370,7 +1376,7 @@ "settings_reloaded": "Einstellungen von der Festplatte neu geladen", "settings_remove_encryption": "Verschlüsselung entfernen", "settings_remove_pin": "PIN entfernen", - "settings_request_payment": "Zahlung anfordern...", + "settings_request_payment": "Zahlung anfordern…", "settings_rescan_desc": "Blockchain nach fehlenden Transaktionen neu scannen", "settings_restart_daemon": "Daemon neu starten", "settings_rpc_connection": "RPC-Verbindung", @@ -1381,20 +1387,20 @@ "settings_save_shielded_local": "Geschirmten Transaktionsverlauf lokal speichern", "settings_saved": "Einstellungen gespeichert", "settings_set_pin": "PIN festlegen", - "settings_shield_mining": "Mining abschirmen...", + "settings_shield_mining": "Mining abschirmen…", "settings_solid_colors_desc": "Feste Farben anstelle von Unschärfe-Effekten verwenden (Barrierefreiheit)", "settings_theme_refreshed": "Themenliste aktualisiert", "settings_tor_desc": "Alle Verbindungen für erhöhte Privatsphäre über Tor leiten", "settings_unlocked": "Entsperrt", "settings_use_tor_network": "Tor für Netzwerkverbindungen verwenden", - "settings_validate_address": "Adresse überprüfen...", + "settings_validate_address": "Adresse überprüfen…", "settings_visual_effects": "Visuelle Effekte", "settings_wallet_file_size": "Wallet-Dateigröße: %s", "settings_wallet_info": "Wallet-Informationen", "settings_wallet_location": "Wallet-Speicherort: %s", "settings_wallet_maintenance": "Wallet-Wartung", "settings_wallet_not_found": "Wallet-Datei nicht gefunden", - "settings_wallet_size_label": "Wallet-Größe:", + "settings_wallet_size_label": "Wallet-Größe", "settings_ztx_cleared": "Z-Transaktionsverlauf gelöscht", "settings_ztx_not_found": "Keine Verlaufsdatei gefunden", "setup_wizard": "Einrichtungsassistent", @@ -1494,6 +1500,7 @@ "to_upper": "AN", "tools": "WERKZEUGE", "tools_actions": "Werkzeuge & Aktionen...", + "tools_actions_hdr": "WERKZEUGE & AKTIONEN", "total": "Gesamt", "total_balance_label": "Gesamtguthaben", "transaction_id": "TRANSAKTIONS-ID", @@ -1517,7 +1524,7 @@ "tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben", "tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen", "tt_block_explorer": "Den DragonX Block-Explorer im Browser öffnen", - "tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)", + "tt_blur": "Unschärfe-Stärke (0% = aus, 100% = maximum)", "tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern", "tt_change_pin": "Ihre Entsperr-PIN ändern", "tt_chat_bubble_accent": "Akzentfarbe für deine ausgehenden Nachrichtenblasen (oder dem aktuellen Theme folgen)", @@ -1580,7 +1587,7 @@ "tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down", "tt_merge": "Mehrere UTXOs einer Adresse zusammenführen", "tt_mine_idle": "Mining automatisch starten, wenn das\\nSystem inaktiv ist (keine Tastatur-/Mauseingabe)", - "tt_noise": "Körnungstextur-Intensität (0%% = aus, 100%% = maximum)", + "tt_noise": "Körnungstextur-Intensität (0% = aus, 100% = maximum)", "tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen", "tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen", "tt_open_dir": "Klicken, um im Dateimanager zu öffnen", @@ -1619,7 +1626,7 @@ "tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes", "tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten", "tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer", - "tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100%% = vollständig undurchsichtig, niedriger = durchsichtiger)", + "tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)", "tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist", "tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren", "tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln", @@ -1831,4 +1838,4 @@ "your_addresses": "Ihre Adressen", "z_address": "Z-Adresse", "z_addresses": "Z-Adressen" -} \ No newline at end of file +} diff --git a/res/lang/es.json b/res/lang/es.json index 848663e..85dbccf 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender bloqueó el minero", "available": "Disponible", "backup_backing_up": "Respaldando...", + "backup_col_backup": "COPIA DE SEGURIDAD", + "backup_col_export": "EXPORTAR", + "backup_col_import": "IMPORTAR Y RESTAURAR", "backup_create": "Crear Respaldo", "backup_created": "Respaldo de cartera creado", "backup_data": "RESPALDO Y DATOS", @@ -423,6 +426,7 @@ "daemon_bundled": "Incluido", "daemon_install_bundled": "Instalar integrado", "daemon_installed": "Instalado", + "daemon_maintenance_label": "MANTENIMIENTO", "daemon_none_bundled": "ninguno en esta compilación", "daemon_not_installed": "no instalado", "daemon_status_differ": "El binario instalado difiere de la versión incluida.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "La descarga se verifica frente al SHA-256 publicado de la versión y una firma ed25519 fijada antes de instalarla.", "daemon_update_verifying": "Verificando…", "daemon_update_version": "Versión:", + "daemon_updates_label": "ACTUALIZACIONES", "daemon_version": "Daemon", "dark": "Oscuro", "data_stale_prefix": "Actualizado", @@ -1228,6 +1233,7 @@ "sb_waiting_daemon_err": "Esperando a dragonxd — %s", "sb_warming_up": "Calentando...", "sb_witness_cache": "Reconstruyendo testigos", + "scale_effects": "ESCALA Y EFECTOS", "screenshot_open_dir": "Abrir ubicación", "screenshot_sweep": "Ejecutar barrido de capturas", "screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.", @@ -1316,12 +1322,12 @@ "settings": "Ajustes", "settings_about_text": "Una billetera de criptomonedas blindada para DragonX (DRGX), creada con Dear ImGui para una experiencia ligera y portátil.", "settings_acrylic_level": "Nivel de acrílico:", - "settings_address_book": "Libreta de direcciones...", + "settings_address_book": "Libreta de direcciones…", "settings_auto_detected": "Autodetectado de DRAGONX.conf", "settings_auto_lock": "BLOQUEO AUTOMÁTICO", "settings_auto_shield_desc": "Mover automáticamente fondos transparentes a direcciones blindadas", "settings_auto_shield_funds": "Blindar fondos transparentes automáticamente", - "settings_backup": "Respaldo...", + "settings_backup": "Respaldo…", "settings_block_explorer_urls": "URLs del explorador de bloques", "settings_builtin": "Integrado", "settings_change_passphrase": "Cambiar contraseña", @@ -1335,7 +1341,7 @@ "settings_copy_diagnostics": "Copiar diagnósticos", "settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3", "settings_custom": "Personalizado", - "settings_data_dir": "Dir. de datos:", + "settings_data_dir": "Dir. de datos", "settings_debug_changed": "Categorías de depuración cambiadas — reinicie el daemon para aplicar", "settings_debug_restart_note": "Los cambios surten efecto después de reiniciar el daemon.", "settings_debug_select": "Seleccione categorías para habilitar el registro de depuración del daemon (flags -debug=).", @@ -1343,18 +1349,18 @@ "settings_encrypt_first_pin": "Primero cifre la billetera para habilitar el PIN", "settings_encrypt_wallet": "Cifrar billetera", "settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.", - "settings_export_all": "Exportar todo...", - "settings_export_csv": "Exportar CSV...", - "settings_export_key": "Exportar clave...", + "settings_export_all": "Exportar todo…", + "settings_export_csv": "Exportar CSV…", + "settings_export_key": "Exportar clave…", "settings_gradient_bg": "Fondo degradado", "settings_gradient_desc": "Reemplazar fondos con texturas por degradados suaves", "settings_idle_after": "después de", - "settings_import_key": "Importar Clave Privada...", - "settings_import_viewkey": "Importar clave de visualización...", + "settings_import_key": "Importar Clave Privada…", + "settings_import_viewkey": "Importar clave de visualización…", "settings_language_note": "Nota: Parte del texto requiere reinicio para actualizarse", "settings_lock_now": "Bloquear ahora", "settings_locked": "Bloqueado", - "settings_merge_to_address": "Fusionar a dirección...", + "settings_merge_to_address": "Fusionar a dirección…", "settings_noise_opacity": "Opacidad de ruido:", "settings_not_connected": "No conectado al daemon", "settings_not_encrypted": "Sin cifrar", @@ -1370,7 +1376,7 @@ "settings_reloaded": "Configuración recargada desde el disco", "settings_remove_encryption": "Quitar cifrado", "settings_remove_pin": "Quitar PIN", - "settings_request_payment": "Solicitar pago...", + "settings_request_payment": "Solicitar pago…", "settings_rescan_desc": "Reescanear la cadena de bloques en busca de transacciones faltantes", "settings_restart_daemon": "Reiniciar daemon", "settings_rpc_connection": "Conexión RPC", @@ -1381,20 +1387,20 @@ "settings_save_shielded_local": "Guardar historial de transacciones blindadas localmente", "settings_saved": "Configuración guardada", "settings_set_pin": "Establecer PIN", - "settings_shield_mining": "Blindar minería...", + "settings_shield_mining": "Blindar minería…", "settings_solid_colors_desc": "Usar colores sólidos en lugar de efectos de desenfoque (accesibilidad)", "settings_theme_refreshed": "Lista de temas actualizada", "settings_tor_desc": "Enrutar todas las conexiones a través de Tor para mayor privacidad", "settings_unlocked": "Desbloqueado", "settings_use_tor_network": "Usar Tor para conexiones de red", - "settings_validate_address": "Validar dirección...", + "settings_validate_address": "Validar dirección…", "settings_visual_effects": "Efectos visuales", "settings_wallet_file_size": "Tamaño del archivo de billetera: %s", "settings_wallet_info": "Información de billetera", "settings_wallet_location": "Ubicación de billetera: %s", "settings_wallet_maintenance": "Mantenimiento de billetera", "settings_wallet_not_found": "Archivo de billetera no encontrado", - "settings_wallet_size_label": "Tamaño de billetera:", + "settings_wallet_size_label": "Tamaño de billetera", "settings_ztx_cleared": "Historial de transacciones Z borrado", "settings_ztx_not_found": "No se encontró archivo de historial", "setup_wizard": "Asistente de Configuración", @@ -1494,6 +1500,7 @@ "to_upper": "PARA", "tools": "HERRAMIENTAS", "tools_actions": "Herramientas y Acciones...", + "tools_actions_hdr": "HERRAMIENTAS Y ACCIONES", "total": "Total", "total_balance_label": "Saldo Total", "transaction_id": "ID DE TRANSACCIÓN", @@ -1517,7 +1524,7 @@ "tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad", "tt_backup": "Crear una copia de seguridad de su wallet.dat", "tt_block_explorer": "Abrir el explorador de bloques DragonX en su navegador", - "tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)", + "tt_blur": "Cantidad de desenfoque (0% = apagado, 100% = máximo)", "tt_change_pass": "Cambiar la contraseña de cifrado de la billetera", "tt_change_pin": "Cambiar su PIN de desbloqueo", "tt_chat_bubble_accent": "Color de acento para tus burbujas de mensaje salientes (o sigue el tema actual)", @@ -1580,7 +1587,7 @@ "tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down", "tt_merge": "Consolidar múltiples UTXOs en una dirección", "tt_mine_idle": "Iniciar minería automáticamente cuando el\\nsistema esté inactivo (sin entrada de teclado/ratón)", - "tt_noise": "Intensidad de textura granulada (0%% = apagado, 100%% = máximo)", + "tt_noise": "Intensidad de textura granulada (0% = apagado, 100% = máximo)", "tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos", "tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain", "tt_open_dir": "Clic para abrir en explorador de archivos", @@ -1619,7 +1626,7 @@ "tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas", "tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato", "tt_tx_url": "URL base para ver transacciones en un explorador de bloques", - "tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100%% = totalmente opaco, menor = más transparente)", + "tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)", "tt_validate": "Comprobar si una dirección DragonX es válida", "tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola", "tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos", @@ -1831,4 +1838,4 @@ "your_addresses": "Sus Direcciones", "z_address": "Dirección Z", "z_addresses": "Direcciones Z" -} \ No newline at end of file +} diff --git a/res/lang/fr.json b/res/lang/fr.json index e25216a..d878f78 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender a bloqué le mineur", "available": "Disponible", "backup_backing_up": "Sauvegarde en cours...", + "backup_col_backup": "SAUVEGARDE", + "backup_col_export": "EXPORTER", + "backup_col_import": "IMPORTER ET RESTAURER", "backup_create": "Créer une sauvegarde", "backup_created": "Sauvegarde du portefeuille créée", "backup_data": "SAUVEGARDE & DONNÉES", @@ -423,6 +426,7 @@ "daemon_bundled": "Intégré", "daemon_install_bundled": "Installer la version intégrée", "daemon_installed": "Installé", + "daemon_maintenance_label": "MAINTENANCE", "daemon_none_bundled": "aucun dans cette version", "daemon_not_installed": "non installé", "daemon_status_differ": "Le binaire installé diffère de la version intégrée.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "Le téléchargement est vérifié par rapport au SHA-256 publié de la version et à une signature ed25519 épinglée avant l'installation.", "daemon_update_verifying": "Vérification…", "daemon_update_version": "Version :", + "daemon_updates_label": "MISES À JOUR", "daemon_version": "Daemon", "dark": "Sombre", "data_stale_prefix": "Mis à jour", @@ -1228,6 +1233,7 @@ "sb_waiting_daemon_err": "En attente de dragonxd — %s", "sb_warming_up": "Démarrage...", "sb_witness_cache": "Reconstruction des témoins", + "scale_effects": "ÉCHELLE ET EFFETS", "screenshot_open_dir": "Ouvrir l'emplacement", "screenshot_sweep": "Lancer la capture d'écran", "screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.", @@ -1316,12 +1322,12 @@ "settings": "Paramètres", "settings_about_text": "Un portefeuille de cryptomonnaie blindé pour DragonX (DRGX), construit avec Dear ImGui pour une expérience légère et portable.", "settings_acrylic_level": "Niveau acrylique :", - "settings_address_book": "Carnet d'adresses...", + "settings_address_book": "Carnet d'adresses…", "settings_auto_detected": "Détecté automatiquement depuis DRAGONX.conf", "settings_auto_lock": "VERROUILLAGE AUTO", "settings_auto_shield_desc": "Déplacer automatiquement les fonds transparents vers des adresses blindées", "settings_auto_shield_funds": "Blindage automatique des fonds transparents", - "settings_backup": "Sauvegarde...", + "settings_backup": "Sauvegarde…", "settings_block_explorer_urls": "URLs de l'explorateur de blocs", "settings_builtin": "Intégré", "settings_change_passphrase": "Changer la phrase secrète", @@ -1335,7 +1341,7 @@ "settings_copy_diagnostics": "Copier les diagnostics", "settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3", "settings_custom": "Personnalisé", - "settings_data_dir": "Rép. de données :", + "settings_data_dir": "Rép. de données ", "settings_debug_changed": "Catégories de débogage modifiées — redémarrez le daemon pour appliquer", "settings_debug_restart_note": "Les modifications prennent effet après le redémarrage du daemon.", "settings_debug_select": "Sélectionnez les catégories pour activer la journalisation de débogage du daemon (flags -debug=).", @@ -1343,18 +1349,18 @@ "settings_encrypt_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN", "settings_encrypt_wallet": "Chiffrer le portefeuille", "settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.", - "settings_export_all": "Tout exporter...", - "settings_export_csv": "Exporter CSV...", - "settings_export_key": "Exporter la clé...", + "settings_export_all": "Tout exporter…", + "settings_export_csv": "Exporter CSV…", + "settings_export_key": "Exporter la clé…", "settings_gradient_bg": "Fond dégradé", "settings_gradient_desc": "Remplacer les arrière-plans texturés par des dégradés lisses", "settings_idle_after": "après", - "settings_import_key": "Importer une clé privée...", - "settings_import_viewkey": "Importer la clé de visualisation...", + "settings_import_key": "Importer une clé privée…", + "settings_import_viewkey": "Importer la clé de visualisation…", "settings_language_note": "Remarque : Certains textes nécessitent un redémarrage pour se mettre à jour", "settings_lock_now": "Verrouiller maintenant", "settings_locked": "Verrouillé", - "settings_merge_to_address": "Fusionner vers l'adresse...", + "settings_merge_to_address": "Fusionner vers l'adresse…", "settings_noise_opacity": "Opacité du bruit :", "settings_not_connected": "Non connecté au démon", "settings_not_encrypted": "Non chiffré", @@ -1370,7 +1376,7 @@ "settings_reloaded": "Paramètres rechargés depuis le disque", "settings_remove_encryption": "Supprimer le chiffrement", "settings_remove_pin": "Supprimer le PIN", - "settings_request_payment": "Demander un paiement...", + "settings_request_payment": "Demander un paiement…", "settings_rescan_desc": "Rescanner la blockchain pour les transactions manquantes", "settings_restart_daemon": "Redémarrer le daemon", "settings_rpc_connection": "Connexion RPC", @@ -1381,20 +1387,20 @@ "settings_save_shielded_local": "Enregistrer l'historique des transactions blindées localement", "settings_saved": "Paramètres enregistrés", "settings_set_pin": "Définir le PIN", - "settings_shield_mining": "Blindage minage...", + "settings_shield_mining": "Blindage minage…", "settings_solid_colors_desc": "Utiliser des couleurs unies au lieu des effets de flou (accessibilité)", "settings_theme_refreshed": "Liste des thèmes actualisée", "settings_tor_desc": "Acheminer toutes les connexions via Tor pour une confidentialité renforcée", "settings_unlocked": "Déverrouillé", "settings_use_tor_network": "Utiliser Tor pour les connexions réseau", - "settings_validate_address": "Valider l'adresse...", + "settings_validate_address": "Valider l'adresse…", "settings_visual_effects": "Effets visuels", "settings_wallet_file_size": "Taille du fichier portefeuille : %s", "settings_wallet_info": "Informations du portefeuille", "settings_wallet_location": "Emplacement du portefeuille : %s", "settings_wallet_maintenance": "Maintenance du portefeuille", "settings_wallet_not_found": "Fichier portefeuille introuvable", - "settings_wallet_size_label": "Taille du portefeuille :", + "settings_wallet_size_label": "Taille du portefeuille ", "settings_ztx_cleared": "Historique des transactions Z effacé", "settings_ztx_not_found": "Aucun fichier d'historique trouvé", "setup_wizard": "Assistant de configuration", @@ -1494,6 +1500,7 @@ "to_upper": "À", "tools": "OUTILS", "tools_actions": "Outils & Actions...", + "tools_actions_hdr": "OUTILS ET ACTIONS", "total": "Total", "total_balance_label": "Solde total", "transaction_id": "ID DE TRANSACTION", @@ -1517,7 +1524,7 @@ "tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité", "tt_backup": "Créer une sauvegarde de votre wallet.dat", "tt_block_explorer": "Ouvrir l'explorateur de blocs DragonX dans votre navigateur", - "tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)", + "tt_blur": "Quantité de flou (0% = désactivé, 100% = maximum)", "tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille", "tt_change_pin": "Changer votre PIN de déverrouillage", "tt_chat_bubble_accent": "Couleur d'accent de vos bulles de message sortantes (ou suivre le thème actuel)", @@ -1580,7 +1587,7 @@ "tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down", "tt_merge": "Consolider plusieurs UTXOs vers une adresse", "tt_mine_idle": "Démarrer le minage automatiquement quand le\\nsystème est inactif (aucune entrée clavier/souris)", - "tt_noise": "Intensité de texture grainée (0%% = désactivé, 100%% = maximum)", + "tt_noise": "Intensité de texture grainée (0% = désactivé, 100% = maximum)", "tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers", "tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers", "tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers", @@ -1619,7 +1626,7 @@ "tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème", "tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat", "tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs", - "tt_ui_opacity": "Opacité des cartes et de la barre latérale (100%% = entièrement opaque, plus bas = plus transparent)", + "tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)", "tt_validate": "Vérifier si une adresse DragonX est valide", "tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console", "tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre", @@ -1831,4 +1838,4 @@ "your_addresses": "Vos adresses", "z_address": "Adresse Z", "z_addresses": "Adresses Z" -} \ No newline at end of file +} diff --git a/res/lang/ja.json b/res/lang/ja.json index 7588451..dac35d6 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender がマイナーをブロックしました", "available": "利用可能", "backup_backing_up": "バックアップ中...", + "backup_col_backup": "バックアップ", + "backup_col_export": "エクスポート", + "backup_col_import": "インポートと復元", "backup_create": "バックアップを作成", "backup_created": "ウォレットのバックアップを作成しました", "backup_data": "バックアップとデータ", @@ -423,6 +426,7 @@ "daemon_bundled": "バンドル版", "daemon_install_bundled": "バンドル版をインストール", "daemon_installed": "インストール済み", + "daemon_maintenance_label": "メンテナンス", "daemon_none_bundled": "このビルドにはなし", "daemon_not_installed": "未インストール", "daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "ダウンロードは、インストール前にリリースで公開された SHA-256 と固定された ed25519 署名で検証されます。", "daemon_update_verifying": "検証中…", "daemon_update_version": "バージョン:", + "daemon_updates_label": "アップデート", "daemon_version": "デーモン", "dark": "ダーク", "data_stale_prefix": "更新", @@ -1225,6 +1230,7 @@ "sb_waiting_daemon_err": "dragonxd を待機中 — %s", "sb_warming_up": "ウォームアップ中...", "sb_witness_cache": "ウィットネスを再構築中", + "scale_effects": "スケールとエフェクト", "screenshot_open_dir": "場所を開く", "screenshot_sweep": "スクリーンショットスイープを実行", "screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。", @@ -1313,12 +1319,12 @@ "settings": "設定", "settings_about_text": "DragonX (DRGX) 用のシールド暗号通貨ウォレット。Dear ImGui で構築された軽量でポータブルな体験。", "settings_acrylic_level": "アクリルレベル:", - "settings_address_book": "アドレス帳...", + "settings_address_book": "アドレス帳…", "settings_auto_detected": "DRAGONX.conf から自動検出", "settings_auto_lock": "オートロック", "settings_auto_shield_desc": "透明資金を自動的にシールドアドレスに移動", "settings_auto_shield_funds": "透明資金を自動シールド", - "settings_backup": "バックアップ...", + "settings_backup": "バックアップ…", "settings_block_explorer_urls": "ブロックエクスプローラーURL", "settings_builtin": "内蔵", "settings_change_passphrase": "パスフレーズを変更", @@ -1340,18 +1346,18 @@ "settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください", "settings_encrypt_wallet": "ウォレットを暗号化", "settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。", - "settings_export_all": "すべてエクスポート...", - "settings_export_csv": "CSV エクスポート...", - "settings_export_key": "鍵をエクスポート...", + "settings_export_all": "すべてエクスポート…", + "settings_export_csv": "CSV エクスポート…", + "settings_export_key": "鍵をエクスポート…", "settings_gradient_bg": "グラデーション背景", "settings_gradient_desc": "テクスチャ背景を滑らかなグラデーションに置換", "settings_idle_after": "経過後", - "settings_import_key": "秘密鍵をインポート...", - "settings_import_viewkey": "閲覧鍵をインポート...", + "settings_import_key": "秘密鍵をインポート…", + "settings_import_viewkey": "閲覧鍵をインポート…", "settings_language_note": "注意:一部のテキストは更新に再起動が必要です", "settings_lock_now": "今すぐロック", "settings_locked": "ロック済み", - "settings_merge_to_address": "アドレスにマージ...", + "settings_merge_to_address": "アドレスにマージ…", "settings_noise_opacity": "ノイズ不透明度:", "settings_not_connected": "デーモンに接続されていません", "settings_not_encrypted": "暗号化されていません", @@ -1367,7 +1373,7 @@ "settings_reloaded": "ディスクから設定を再読み込みしました", "settings_remove_encryption": "暗号化を解除", "settings_remove_pin": "PIN を削除", - "settings_request_payment": "支払い請求...", + "settings_request_payment": "支払い請求…", "settings_rescan_desc": "欠落したトランザクションのためにブロックチェーンを再スキャン", "settings_restart_daemon": "デーモンを再起動", "settings_rpc_connection": "RPC 接続", @@ -1378,13 +1384,13 @@ "settings_save_shielded_local": "シールドトランザクション履歴をローカルに保存", "settings_saved": "設定を保存しました", "settings_set_pin": "PIN を設定", - "settings_shield_mining": "マイニングシールド...", + "settings_shield_mining": "マイニングシールド…", "settings_solid_colors_desc": "ぼかし効果の代わりに単色を使用(アクセシビリティ)", "settings_theme_refreshed": "テーマ一覧を更新しました", "settings_tor_desc": "プライバシー向上のため全接続を Tor 経由にする", "settings_unlocked": "ロック解除", "settings_use_tor_network": "ネットワーク接続に Tor を使用", - "settings_validate_address": "アドレス検証...", + "settings_validate_address": "アドレス検証…", "settings_visual_effects": "視覚効果", "settings_wallet_file_size": "ウォレットファイルサイズ:%s", "settings_wallet_info": "ウォレット情報", @@ -1491,6 +1497,7 @@ "to_upper": "宛先", "tools": "ツール", "tools_actions": "ツールとアクション...", + "tools_actions_hdr": "ツールと操作", "total": "合計", "total_balance_label": "総残高", "transaction_id": "取引ID", @@ -1514,7 +1521,7 @@ "tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動", "tt_backup": "wallet.dat のバックアップを作成", "tt_block_explorer": "ブラウザで DragonX ブロックエクスプローラーを開く", - "tt_blur": "ぼかし量(0%% = オフ、100%% = 最大)", + "tt_blur": "ぼかし量(0% = オフ、100% = 最大)", "tt_change_pass": "ウォレットの暗号化パスフレーズを変更", "tt_change_pin": "アンロック PIN を変更", "tt_chat_bubble_accent": "送信メッセージの吹き出しのアクセントカラー(または現在のテーマに従う)", @@ -1577,7 +1584,7 @@ "tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down", "tt_merge": "複数の UTXO を一つのアドレスに統合", "tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始", - "tt_noise": "グレインテクスチャ強度(0%% = オフ、100%% = 最大)", + "tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)", "tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く", "tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます", "tt_open_dir": "クリックしてファイルエクスプローラーで開く", @@ -1616,7 +1623,7 @@ "tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え", "tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング", "tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL", - "tt_ui_opacity": "カードとサイドバーの不透明度(100%% = 完全不透明、低い = より透過)", + "tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)", "tt_validate": "DragonX アドレスが有効かどうかを確認", "tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録", "tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます", @@ -1828,4 +1835,4 @@ "your_addresses": "あなたのアドレス", "z_address": "Zアドレス", "z_addresses": "Zアドレス" -} \ No newline at end of file +} diff --git a/res/lang/ko.json b/res/lang/ko.json index d95957e..0fab8e7 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender가 채굴기를 차단했습니다", "available": "사용 가능", "backup_backing_up": "백업 중...", + "backup_col_backup": "백업", + "backup_col_export": "내보내기", + "backup_col_import": "가져오기 및 복원", "backup_create": "백업 생성", "backup_created": "지갑 백업이 생성되었습니다", "backup_data": "백업 및 데이터", @@ -423,6 +426,7 @@ "daemon_bundled": "번들", "daemon_install_bundled": "번들 버전 설치", "daemon_installed": "설치됨", + "daemon_maintenance_label": "유지 관리", "daemon_none_bundled": "이 빌드에 없음", "daemon_not_installed": "설치되지 않음", "daemon_status_differ": "설치된 바이너리가 번들 버전과 다릅니다.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "다운로드는 설치 전에 릴리스에 게시된 SHA-256과 고정된 ed25519 서명으로 검증됩니다.", "daemon_update_verifying": "확인 중…", "daemon_update_version": "버전:", + "daemon_updates_label": "업데이트", "daemon_version": "데몬", "dark": "다크", "data_stale_prefix": "업데이트", @@ -1227,6 +1232,7 @@ "sb_waiting_daemon_err": "dragonxd 대기 중 — %s", "sb_warming_up": "워밍업 중...", "sb_witness_cache": "증인 재구축 중", + "scale_effects": "배율 및 효과", "screenshot_open_dir": "위치 열기", "screenshot_sweep": "스크린샷 스윕 실행", "screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.", @@ -1315,12 +1321,12 @@ "settings": "설정", "settings_about_text": "DragonX (DRGX)용 차폐 암호화폐 지갑으로, Dear ImGui로 제작되어 가볍고 휴대 가능합니다.", "settings_acrylic_level": "아크릴 레벨:", - "settings_address_book": "주소록...", + "settings_address_book": "주소록…", "settings_auto_detected": "DRAGONX.conf에서 자동 감지", "settings_auto_lock": "자동 잠금", "settings_auto_shield_desc": "투명 자금을 자동으로 차폐 주소로 이동", "settings_auto_shield_funds": "투명 자금 자동 차폐", - "settings_backup": "백업...", + "settings_backup": "백업…", "settings_block_explorer_urls": "블록 탐색기 URL", "settings_builtin": "내장", "settings_change_passphrase": "비밀번호 변경", @@ -1334,7 +1340,7 @@ "settings_copy_diagnostics": "진단 정보 복사", "settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스", "settings_custom": "사용자 지정", - "settings_data_dir": "데이터 디렉터리:", + "settings_data_dir": "데이터 디렉터리", "settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용", "settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.", "settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).", @@ -1342,18 +1348,18 @@ "settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요", "settings_encrypt_wallet": "지갑 암호화", "settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.", - "settings_export_all": "모두 내보내기...", - "settings_export_csv": "CSV 내보내기...", - "settings_export_key": "키 내보내기...", + "settings_export_all": "모두 내보내기…", + "settings_export_csv": "CSV 내보내기…", + "settings_export_key": "키 내보내기…", "settings_gradient_bg": "그라데이션 배경", "settings_gradient_desc": "텍스처 배경을 부드러운 그라데이션으로 교체", "settings_idle_after": "후", - "settings_import_key": "개인 키 가져오기...", - "settings_import_viewkey": "조회 키 가져오기...", + "settings_import_key": "개인 키 가져오기…", + "settings_import_viewkey": "조회 키 가져오기…", "settings_language_note": "참고: 일부 텍스트는 업데이트하려면 다시 시작해야 합니다", "settings_lock_now": "지금 잠금", "settings_locked": "잠김", - "settings_merge_to_address": "주소로 병합...", + "settings_merge_to_address": "주소로 병합…", "settings_noise_opacity": "노이즈 불투명도:", "settings_not_connected": "데몬에 연결되지 않음", "settings_not_encrypted": "암호화되지 않음", @@ -1369,7 +1375,7 @@ "settings_reloaded": "디스크에서 설정을 다시 불러왔습니다", "settings_remove_encryption": "암호화 제거", "settings_remove_pin": "PIN 제거", - "settings_request_payment": "결제 요청...", + "settings_request_payment": "결제 요청…", "settings_rescan_desc": "누락된 거래를 찾기 위해 블록체인 재스캔", "settings_restart_daemon": "데몬 재시작", "settings_rpc_connection": "RPC 연결", @@ -1380,20 +1386,20 @@ "settings_save_shielded_local": "차폐 거래 기록을 로컬에 저장", "settings_saved": "설정이 저장되었습니다", "settings_set_pin": "PIN 설정", - "settings_shield_mining": "채굴 차폐...", + "settings_shield_mining": "채굴 차폐…", "settings_solid_colors_desc": "블러 효과 대신 단색 사용 (접근성)", "settings_theme_refreshed": "테마 목록을 새로고침했습니다", "settings_tor_desc": "향상된 개인 정보 보호를 위해 모든 연결을 Tor를 통해 라우팅", "settings_unlocked": "잠금 해제", "settings_use_tor_network": "네트워크 연결에 Tor 사용", - "settings_validate_address": "주소 확인...", + "settings_validate_address": "주소 확인…", "settings_visual_effects": "시각 효과", "settings_wallet_file_size": "지갑 파일 크기: %s", "settings_wallet_info": "지갑 정보", "settings_wallet_location": "지갑 위치: %s", "settings_wallet_maintenance": "지갑 유지보수", "settings_wallet_not_found": "지갑 파일을 찾을 수 없음", - "settings_wallet_size_label": "지갑 크기:", + "settings_wallet_size_label": "지갑 크기", "settings_ztx_cleared": "Z-거래 내역이 삭제되었습니다", "settings_ztx_not_found": "내역 파일을 찾을 수 없습니다", "setup_wizard": "설정 마법사", @@ -1493,6 +1499,7 @@ "to_upper": "받는 곳", "tools": "도구", "tools_actions": "도구 및 작업...", + "tools_actions_hdr": "도구 및 작업", "total": "합계", "total_balance_label": "총 잔액", "transaction_id": "거래 ID", @@ -1516,7 +1523,7 @@ "tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동", "tt_backup": "wallet.dat 백업 만들기", "tt_block_explorer": "브라우저에서 DragonX 블록 탐색기 열기", - "tt_blur": "블러 양 (0%% = 끔, 100%% = 최대)", + "tt_blur": "블러 양 (0% = 끔, 100% = 최대)", "tt_change_pass": "지갑 암호화 비밀번호 변경", "tt_change_pin": "잠금 해제 PIN 변경", "tt_chat_bubble_accent": "보내는 메시지 말풍선의 강조 색상(또는 현재 테마를 따름)", @@ -1579,7 +1586,7 @@ "tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down", "tt_merge": "여러 UTXO를 하나의 주소로 통합", "tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작", - "tt_noise": "그레인 텍스처 강도 (0%% = 끔, 100%% = 최대)", + "tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)", "tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다", "tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다", "tt_open_dir": "파일 탐색기에서 열려면 클릭", @@ -1618,7 +1625,7 @@ "tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환", "tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅", "tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL", - "tt_ui_opacity": "카드 및 사이드바 불투명도 (100%% = 완전 불투명, 낮을수록 더 투명)", + "tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)", "tt_validate": "DragonX 주소가 유효한지 확인", "tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록", "tt_wallets_button": "지갑 파일 목록을 보고 전환합니다", @@ -1830,4 +1837,4 @@ "your_addresses": "내 주소", "z_address": "Z 주소", "z_addresses": "Z 주소" -} \ No newline at end of file +} diff --git a/res/lang/pt.json b/res/lang/pt.json index 8b97cdf..c770a5c 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender bloqueou o minerador", "available": "Disponível", "backup_backing_up": "Fazendo backup...", + "backup_col_backup": "BACKUP", + "backup_col_export": "EXPORTAR", + "backup_col_import": "IMPORTAR E RESTAURAR", "backup_create": "Criar Backup", "backup_created": "Backup da carteira criado", "backup_data": "BACKUP & DADOS", @@ -423,6 +426,7 @@ "daemon_bundled": "Empacotado", "daemon_install_bundled": "Instalar incluído", "daemon_installed": "Instalado", + "daemon_maintenance_label": "MANUTENÇÃO", "daemon_none_bundled": "nenhum nesta build", "daemon_not_installed": "não instalado", "daemon_status_differ": "O binário instalado difere da versão empacotada.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "O download é verificado contra o SHA-256 publicado do lançamento e uma assinatura ed25519 fixada antes da instalação.", "daemon_update_verifying": "Verificando…", "daemon_update_version": "Versão:", + "daemon_updates_label": "ATUALIZAÇÕES", "daemon_version": "Daemon", "dark": "Escuro", "data_stale_prefix": "Atualizado", @@ -1228,6 +1233,7 @@ "sb_waiting_daemon_err": "Aguardando dragonxd — %s", "sb_warming_up": "Aquecendo...", "sb_witness_cache": "Reconstruindo testemunhas", + "scale_effects": "ESCALA E EFEITOS", "screenshot_open_dir": "Abrir local", "screenshot_sweep": "Executar varredura de capturas de tela", "screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.", @@ -1316,12 +1322,12 @@ "settings": "Ajustes", "settings_about_text": "Uma carteira de criptomoeda blindada para DragonX (DRGX), criada com Dear ImGui para uma experiência leve e portátil.", "settings_acrylic_level": "Nível acrílico:", - "settings_address_book": "Livro de endereços...", + "settings_address_book": "Livro de endereços…", "settings_auto_detected": "Detectado automaticamente de DRAGONX.conf", "settings_auto_lock": "BLOQUEIO AUTOMÁTICO", "settings_auto_shield_desc": "Mover automaticamente fundos transparentes para endereços blindados", "settings_auto_shield_funds": "Blindar fundos transparentes automaticamente", - "settings_backup": "Backup...", + "settings_backup": "Backup…", "settings_block_explorer_urls": "URLs do explorador de blocos", "settings_builtin": "Integrado", "settings_change_passphrase": "Alterar frase secreta", @@ -1335,7 +1341,7 @@ "settings_copy_diagnostics": "Copiar diagnósticos", "settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3", "settings_custom": "Personalizado", - "settings_data_dir": "Dir. de dados:", + "settings_data_dir": "Dir. de dados", "settings_debug_changed": "Categorias de depuração alteradas — reinicie o daemon para aplicar", "settings_debug_restart_note": "As alterações entram em vigor após reiniciar o daemon.", "settings_debug_select": "Selecione categorias para ativar o registro de depuração do daemon (flags -debug=).", @@ -1343,18 +1349,18 @@ "settings_encrypt_first_pin": "Encripte a carteira primeiro para ativar o PIN", "settings_encrypt_wallet": "Encriptar carteira", "settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.", - "settings_export_all": "Exportar tudo...", - "settings_export_csv": "Exportar CSV...", - "settings_export_key": "Exportar chave...", + "settings_export_all": "Exportar tudo…", + "settings_export_csv": "Exportar CSV…", + "settings_export_key": "Exportar chave…", "settings_gradient_bg": "Fundo gradiente", "settings_gradient_desc": "Substituir fundos texturizados por gradientes suaves", "settings_idle_after": "após", - "settings_import_key": "Importar Chave Privada...", - "settings_import_viewkey": "Importar chave de visualização...", + "settings_import_key": "Importar Chave Privada…", + "settings_import_viewkey": "Importar chave de visualização…", "settings_language_note": "Nota: Alguns textos requerem reinício para atualizar", "settings_lock_now": "Bloquear agora", "settings_locked": "Bloqueado", - "settings_merge_to_address": "Fundir para endereço...", + "settings_merge_to_address": "Fundir para endereço…", "settings_noise_opacity": "Opacidade do ruído:", "settings_not_connected": "Não conectado ao daemon", "settings_not_encrypted": "Não encriptado", @@ -1370,7 +1376,7 @@ "settings_reloaded": "Configurações recarregadas do disco", "settings_remove_encryption": "Remover encriptação", "settings_remove_pin": "Remover PIN", - "settings_request_payment": "Solicitar pagamento...", + "settings_request_payment": "Solicitar pagamento…", "settings_rescan_desc": "Reescanear a blockchain em busca de transações ausentes", "settings_restart_daemon": "Reiniciar daemon", "settings_rpc_connection": "Conexão RPC", @@ -1381,20 +1387,20 @@ "settings_save_shielded_local": "Salvar histórico de transações blindadas localmente", "settings_saved": "Configurações salvas", "settings_set_pin": "Definir PIN", - "settings_shield_mining": "Blindar mineração...", + "settings_shield_mining": "Blindar mineração…", "settings_solid_colors_desc": "Usar cores sólidas em vez de efeitos de desfoque (acessibilidade)", "settings_theme_refreshed": "Lista de temas atualizada", "settings_tor_desc": "Rotear todas as conexões através do Tor para maior privacidade", "settings_unlocked": "Desbloqueado", "settings_use_tor_network": "Usar Tor para conexões de rede", - "settings_validate_address": "Validar endereço...", + "settings_validate_address": "Validar endereço…", "settings_visual_effects": "Efeitos visuais", "settings_wallet_file_size": "Tamanho do arquivo da carteira: %s", "settings_wallet_info": "Informações da carteira", "settings_wallet_location": "Localização da carteira: %s", "settings_wallet_maintenance": "Manutenção da carteira", "settings_wallet_not_found": "Arquivo da carteira não encontrado", - "settings_wallet_size_label": "Tamanho da carteira:", + "settings_wallet_size_label": "Tamanho da carteira", "settings_ztx_cleared": "Histórico de transações Z limpo", "settings_ztx_not_found": "Nenhum arquivo de histórico encontrado", "setup_wizard": "Assistente de Configuração", @@ -1494,6 +1500,7 @@ "to_upper": "PARA", "tools": "FERRAMENTAS", "tools_actions": "Ferramentas e Ações...", + "tools_actions_hdr": "FERRAMENTAS E AÇÕES", "total": "Total", "total_balance_label": "Saldo Total", "transaction_id": "ID DA TRANSAÇÃO", @@ -1517,7 +1524,7 @@ "tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade", "tt_backup": "Criar um backup do seu wallet.dat", "tt_block_explorer": "Abrir o explorador de blocos DragonX no seu navegador", - "tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)", + "tt_blur": "Quantidade de desfoque (0% = desligado, 100% = máximo)", "tt_change_pass": "Alterar a frase secreta de encriptação da carteira", "tt_change_pin": "Alterar seu PIN de desbloqueio", "tt_chat_bubble_accent": "Cor de destaque para seus balões de mensagem enviados (ou seguir o tema atual)", @@ -1580,7 +1587,7 @@ "tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down", "tt_merge": "Consolidar múltiplos UTXOs em um endereço", "tt_mine_idle": "Iniciar mineração automaticamente quando o\\nsistema estiver ocioso (sem entrada de teclado/mouse)", - "tt_noise": "Intensidade de textura granulada (0%% = desligado, 100%% = máximo)", + "tt_noise": "Intensidade de textura granulada (0% = desligado, 100% = máximo)", "tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos", "tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos", "tt_open_dir": "Clique para abrir no explorador de arquivos", @@ -1619,7 +1626,7 @@ "tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas", "tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato", "tt_tx_url": "URL base para visualizar transações em um explorador de blocos", - "tt_ui_opacity": "Opacidade de cartões e barra lateral (100%% = totalmente opaco, menor = mais transparente)", + "tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)", "tt_validate": "Verificar se um endereço DragonX é válido", "tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console", "tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles", @@ -1831,4 +1838,4 @@ "your_addresses": "Seus Endereços", "z_address": "Endereço Z", "z_addresses": "Endereços Z" -} \ No newline at end of file +} diff --git a/res/lang/ru.json b/res/lang/ru.json index b4defbe..f4d147e 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender заблокировал майнер", "available": "Доступно", "backup_backing_up": "Создание резервной копии...", + "backup_col_backup": "РЕЗЕРВНАЯ КОПИЯ", + "backup_col_export": "ЭКСПОРТ", + "backup_col_import": "ИМПОРТ И ВОССТАНОВЛЕНИЕ", "backup_create": "Создать резервную копию", "backup_created": "Резервная копия кошелька создана", "backup_data": "РЕЗЕРВНОЕ КОПИРОВАНИЕ И ДАННЫЕ", @@ -423,6 +426,7 @@ "daemon_bundled": "Встроенный", "daemon_install_bundled": "Установить встроенную", "daemon_installed": "Установлено", + "daemon_maintenance_label": "ОБСЛУЖИВАНИЕ", "daemon_none_bundled": "нет в этой сборке", "daemon_not_installed": "не установлен", "daemon_status_differ": "Установленный бинарный файл отличается от встроенной версии.", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "Перед установкой загрузка проверяется по опубликованному для релиза SHA-256 и закреплённой подписи ed25519.", "daemon_update_verifying": "Проверка…", "daemon_update_version": "Версия:", + "daemon_updates_label": "ОБНОВЛЕНИЯ", "daemon_version": "Демон", "dark": "Тёмная", "data_stale_prefix": "Обновлено", @@ -1228,6 +1233,7 @@ "sb_waiting_daemon_err": "Ожидание dragonxd — %s", "sb_warming_up": "Прогрев...", "sb_witness_cache": "Перестроение свидетелей", + "scale_effects": "МАСШТАБ И ЭФФЕКТЫ", "screenshot_open_dir": "Открыть расположение", "screenshot_sweep": "Запустить прогон скриншотов", "screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.", @@ -1316,12 +1322,12 @@ "settings": "Настройки", "settings_about_text": "Защищённый криптовалютный кошелёк для DragonX (DRGX), созданный на Dear ImGui для лёгкого и портативного использования.", "settings_acrylic_level": "Уровень акрила:", - "settings_address_book": "Адресная книга...", + "settings_address_book": "Адресная книга…", "settings_auto_detected": "Автоопределено из DRAGONX.conf", "settings_auto_lock": "АВТОБЛОКИРОВКА", "settings_auto_shield_desc": "Автоматически перемещать прозрачные средства на экранированные адреса", "settings_auto_shield_funds": "Автоматически экранировать прозрачные средства", - "settings_backup": "Резервная копия...", + "settings_backup": "Резервная копия…", "settings_block_explorer_urls": "URL-адреса обозревателя блоков", "settings_builtin": "Встроенные", "settings_change_passphrase": "Сменить пароль", @@ -1335,7 +1341,7 @@ "settings_copy_diagnostics": "Копировать диагностику", "settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3", "settings_custom": "Пользовательские", - "settings_data_dir": "Каталог данных:", + "settings_data_dir": "Каталог данных", "settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения", "settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.", "settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).", @@ -1343,18 +1349,18 @@ "settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN", "settings_encrypt_wallet": "Зашифровать кошелёк", "settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.", - "settings_export_all": "Экспортировать все...", - "settings_export_csv": "Экспорт CSV...", - "settings_export_key": "Экспортировать ключ...", + "settings_export_all": "Экспортировать все…", + "settings_export_csv": "Экспорт CSV…", + "settings_export_key": "Экспортировать ключ…", "settings_gradient_bg": "Градиент фона", "settings_gradient_desc": "Заменить текстурные фоны плавными градиентами", "settings_idle_after": "через", - "settings_import_key": "Импорт приватного ключа...", - "settings_import_viewkey": "Импортировать ключ просмотра...", + "settings_import_key": "Импорт приватного ключа…", + "settings_import_viewkey": "Импортировать ключ просмотра…", "settings_language_note": "Примечание: Некоторый текст требует перезапуска для обновления", "settings_lock_now": "Заблокировать сейчас", "settings_locked": "Заблокирован", - "settings_merge_to_address": "Объединить на адрес...", + "settings_merge_to_address": "Объединить на адрес…", "settings_noise_opacity": "Непрозрачность шума:", "settings_not_connected": "Нет соединения с демоном", "settings_not_encrypted": "Не зашифрован", @@ -1370,7 +1376,7 @@ "settings_reloaded": "Настройки перезагружены с диска", "settings_remove_encryption": "Удалить шифрование", "settings_remove_pin": "Удалить PIN", - "settings_request_payment": "Запросить платёж...", + "settings_request_payment": "Запросить платёж…", "settings_rescan_desc": "Пересканировать блокчейн для поиска пропущенных транзакций", "settings_restart_daemon": "Перезапустить демон", "settings_rpc_connection": "RPC-соединение", @@ -1381,20 +1387,20 @@ "settings_save_shielded_local": "Сохранять историю защищённых транзакций локально", "settings_saved": "Настройки сохранены", "settings_set_pin": "Установить PIN", - "settings_shield_mining": "Экранировать майнинг...", + "settings_shield_mining": "Экранировать майнинг…", "settings_solid_colors_desc": "Использовать сплошные цвета вместо эффектов размытия (доступность)", "settings_theme_refreshed": "Список тем обновлён", "settings_tor_desc": "Маршрутизировать все соединения через Tor для повышения конфиденциальности", "settings_unlocked": "Разблокирован", "settings_use_tor_network": "Использовать Tor для сетевых подключений", - "settings_validate_address": "Проверить адрес...", + "settings_validate_address": "Проверить адрес…", "settings_visual_effects": "Визуальные эффекты", "settings_wallet_file_size": "Размер файла кошелька: %s", "settings_wallet_info": "Информация о кошельке", "settings_wallet_location": "Расположение кошелька: %s", "settings_wallet_maintenance": "Обслуживание кошелька", "settings_wallet_not_found": "Файл кошелька не найден", - "settings_wallet_size_label": "Размер кошелька:", + "settings_wallet_size_label": "Размер кошелька", "settings_ztx_cleared": "История Z-транзакций очищена", "settings_ztx_not_found": "Файл истории не найден", "setup_wizard": "Мастер настройки", @@ -1494,6 +1500,7 @@ "to_upper": "КОМУ", "tools": "УТИЛИТЫ", "tools_actions": "Инструменты и действия...", + "tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ", "total": "Итого", "total_balance_label": "Общий баланс", "transaction_id": "ID ТРАНЗАКЦИИ", @@ -1517,7 +1524,7 @@ "tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности", "tt_backup": "Создать резервную копию вашего wallet.dat", "tt_block_explorer": "Открыть обозреватель блоков DragonX в браузере", - "tt_blur": "Степень размытия (0%% = выкл., 100%% = максимум)", + "tt_blur": "Степень размытия (0% = выкл., 100% = максимум)", "tt_change_pass": "Сменить пароль шифрования кошелька", "tt_change_pin": "Изменить PIN-код разблокировки", "tt_chat_bubble_accent": "Акцентный цвет для ваших исходящих пузырьков сообщений (или следовать текущей теме)", @@ -1580,7 +1587,7 @@ "tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down", "tt_merge": "Объединить несколько UTXO в один адрес", "tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)", - "tt_noise": "Интенсивность зернистой текстуры (0%% = выкл., 100%% = максимум)", + "tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)", "tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере", "tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна", "tt_open_dir": "Нажмите, чтобы открыть в проводнике", @@ -1619,7 +1626,7 @@ "tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем", "tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности", "tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков", - "tt_ui_opacity": "Непрозрачность карточек и боковой панели (100%% = полностью непрозрачно, ниже = прозрачнее)", + "tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)", "tt_validate": "Проверить, действителен ли адрес DragonX", "tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль", "tt_wallets_button": "Показать файлы кошельков и переключаться между ними", @@ -1831,4 +1838,4 @@ "your_addresses": "Ваши адреса", "z_address": "Z-адрес", "z_addresses": "Z-адреса" -} \ No newline at end of file +} diff --git a/res/lang/zh.json b/res/lang/zh.json index 615a6c5..f88ae5e 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -74,6 +74,9 @@ "av_title": "Windows Defender 已阻止矿工程序", "available": "可用", "backup_backing_up": "正在备份...", + "backup_col_backup": "备份", + "backup_col_export": "导出", + "backup_col_import": "导入与恢复", "backup_create": "创建备份", "backup_created": "钱包备份已创建", "backup_data": "备份与数据", @@ -423,6 +426,7 @@ "daemon_bundled": "内置", "daemon_install_bundled": "安装内置版本", "daemon_installed": "已安装", + "daemon_maintenance_label": "维护", "daemon_none_bundled": "此版本未内置", "daemon_not_installed": "未安装", "daemon_status_differ": "已安装的程序文件与内置版本不同。", @@ -458,6 +462,7 @@ "daemon_update_verify_note": "在安装前,会根据该版本发布的 SHA-256 和固定的 ed25519 签名对下载内容进行校验。", "daemon_update_verifying": "正在验证…", "daemon_update_version": "版本:", + "daemon_updates_label": "更新", "daemon_version": "守护进程", "dark": "深色", "data_stale_prefix": "更新于", @@ -1226,6 +1231,7 @@ "sb_waiting_daemon_err": "等待 dragonxd — %s", "sb_warming_up": "正在预热...", "sb_witness_cache": "正在重建见证", + "scale_effects": "缩放与效果", "screenshot_open_dir": "打开位置", "screenshot_sweep": "运行截图批处理", "screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。", @@ -1314,12 +1320,12 @@ "settings": "设置", "settings_about_text": "DragonX (DRGX) 屏蔽加密货币钱包,使用 Dear ImGui 构建,提供轻量、便携的体验。", "settings_acrylic_level": "亚克力级别:", - "settings_address_book": "地址簿...", + "settings_address_book": "地址簿…", "settings_auto_detected": "从 DRAGONX.conf 自动检测", "settings_auto_lock": "自动锁定", "settings_auto_shield_desc": "自动将透明资金转移到屏蔽地址", "settings_auto_shield_funds": "自动屏蔽透明资金", - "settings_backup": "备份...", + "settings_backup": "备份…", "settings_block_explorer_urls": "区块浏览器网址", "settings_builtin": "内置", "settings_change_passphrase": "更改密码", @@ -1341,18 +1347,18 @@ "settings_encrypt_first_pin": "请先加密钱包以启用 PIN", "settings_encrypt_wallet": "加密钱包", "settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。", - "settings_export_all": "全部导出...", - "settings_export_csv": "导出 CSV...", - "settings_export_key": "导出密钥...", + "settings_export_all": "全部导出…", + "settings_export_csv": "导出 CSV…", + "settings_export_key": "导出密钥…", "settings_gradient_bg": "渐变背景", "settings_gradient_desc": "用平滑渐变替换纹理背景", "settings_idle_after": "之后", - "settings_import_key": "导入私钥...", - "settings_import_viewkey": "导入查看密钥...", + "settings_import_key": "导入私钥…", + "settings_import_viewkey": "导入查看密钥…", "settings_language_note": "注意:部分文本需要重启才能更新", "settings_lock_now": "立即锁定", "settings_locked": "已锁定", - "settings_merge_to_address": "合并到地址...", + "settings_merge_to_address": "合并到地址…", "settings_noise_opacity": "噪点不透明度:", "settings_not_connected": "未连接到守护进程", "settings_not_encrypted": "未加密", @@ -1368,7 +1374,7 @@ "settings_reloaded": "已从磁盘重新加载设置", "settings_remove_encryption": "移除加密", "settings_remove_pin": "移除 PIN", - "settings_request_payment": "请求付款...", + "settings_request_payment": "请求付款…", "settings_rescan_desc": "重新扫描区块链以查找丢失的交易", "settings_restart_daemon": "重启守护进程", "settings_rpc_connection": "RPC 连接", @@ -1379,13 +1385,13 @@ "settings_save_shielded_local": "将屏蔽交易历史保存到本地", "settings_saved": "设置已保存", "settings_set_pin": "设置 PIN", - "settings_shield_mining": "屏蔽挖矿...", + "settings_shield_mining": "屏蔽挖矿…", "settings_solid_colors_desc": "使用纯色代替模糊效果(无障碍功能)", "settings_theme_refreshed": "主题列表已刷新", "settings_tor_desc": "通过 Tor 路由所有连接以增强隐私", "settings_unlocked": "已解锁", "settings_use_tor_network": "使用 Tor 进行网络连接", - "settings_validate_address": "验证地址...", + "settings_validate_address": "验证地址…", "settings_visual_effects": "视觉效果", "settings_wallet_file_size": "钱包文件大小:%s", "settings_wallet_info": "钱包信息", @@ -1492,6 +1498,7 @@ "to_upper": "至", "tools": "工具", "tools_actions": "工具与操作...", + "tools_actions_hdr": "工具与操作", "total": "合计", "total_balance_label": "总余额", "transaction_id": "交易 ID", @@ -1515,7 +1522,7 @@ "tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私", "tt_backup": "创建 wallet.dat 的备份", "tt_block_explorer": "在浏览器中打开 DragonX 区块浏览器", - "tt_blur": "模糊程度(0%% = 关闭,100%% = 最大)", + "tt_blur": "模糊程度(0% = 关闭,100% = 最大)", "tt_change_pass": "更改钱包加密密码", "tt_change_pin": "更改您的解锁 PIN", "tt_chat_bubble_accent": "你发出的消息气泡的强调色(或跟随当前主题)", @@ -1578,7 +1585,7 @@ "tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down", "tt_merge": "将多个 UTXO 合并到一个地址", "tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)", - "tt_noise": "颗粒纹理强度(0%% = 关闭,100%% = 最大)", + "tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)", "tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)", "tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹", "tt_open_dir": "点击在文件管理器中打开", @@ -1617,7 +1624,7 @@ "tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题", "tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名", "tt_tx_url": "在区块浏览器中查看交易的基础 URL", - "tt_ui_opacity": "卡片和侧边栏不透明度(100%% = 完全不透明,越低越透明)", + "tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)", "tt_validate": "检查 DragonX 地址是否有效", "tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡", "tt_wallets_button": "列出您的钱包文件并在它们之间切换", @@ -1829,4 +1836,4 @@ "your_addresses": "您的地址", "z_address": "Z 地址", "z_addresses": "Z 地址" -} \ No newline at end of file +} diff --git a/src/app.cpp b/src/app.cpp index 9de781f..79320f8 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1795,32 +1795,21 @@ void App::render() sbStatus.unconfirmedTxCount = static_cast(unconfirmedTxids.size()); } - // Sidebar margins from ui.toml schema (DPI-scaled like all sidebar values) - const float sbMarginTop = sbde("margin-top", 0.0f); - const float sbMarginBottom = sbde("margin-bottom", 0.0f); - const float sbMinHeight = sbde("min-height", 360.0f); + // Sidebar minimum height from ui.toml schema (DPI-scaled). + const float sbMinHeight = sbde("min-height", 360.0f); - // Ensure sidebar is tall enough to fit all buttons — shrink margins if needed - float sidebarH = contentH - sbMarginTop - sbMarginBottom; - float effectiveMarginTop = sbMarginTop; - if (sidebarH < sbMinHeight) { - float available = contentH - sbMinHeight; - if (available > 0.0f) { - float ratio = available / (sbMarginTop + sbMarginBottom); - effectiveMarginTop = sbMarginTop * ratio; - } else { - effectiveMarginTop = 0.0f; - } - sidebarH = std::max(contentH - effectiveMarginTop, sbMinHeight); - } - - // Sidebar navigation - // Save cursor Y before applying sidebar margin so the content area - // (placed via SameLine) starts at the original row position, not the - // margin-shifted one. + // Save cursor Y before the sidebar so the content area (restored below) starts at the original row. float preSidebarCursorY = ImGui::GetCursorPosY(); - if (effectiveMarginTop > 0.0f) - ImGui::SetCursorPosY(preSidebarCursorY + effectiveMarginTop); + + // Size the sidebar to span from its own top down to the status-bar top, so the nav panel centers + // within the TRUE visible area (equal top/bottom gaps). Do NOT derive it from contentH (inset by the + // content-area's edge-fade margins) and do NOT apply the legacy sidebar margin-top/-bottom (they are + // asymmetric, -12 / +40, and pushed the panel upward). Window-local reference (matches how the status + // bar is positioned — GetWindowPos/GetWindowSize, not GetMainViewport) so it is correct on every platform. + float sbWindowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y; + float sbStatusTopY = sbWindowBottom - statusBarH - mainPadBot; + float sbChildTopY = ImGui::GetCursorScreenPos().y; + float sidebarH = std::max(sbMinHeight, sbStatusTopY - sbChildTopY); bool prevCollapsed = sidebar_collapsed_; { PERF_SCOPE("Render.Sidebar"); diff --git a/src/app.h b/src/app.h index 323f1d1..93f0730 100644 --- a/src/app.h +++ b/src/app.h @@ -408,6 +408,9 @@ public: // each under every skin. Output: /screenshots-full//.png + an index. void startFullUiSweep(); std::string screenshotFullDir() const; + // Debug option: restrict either sweep to just the currently-active theme instead of cycling all. + bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; } + void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; } bool isScreenshotSweeping() const { return screenshot_sweep_active_; } bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; } const std::string& screenshotSweepPath() const { return sweep_current_path_; } @@ -1114,6 +1117,7 @@ private: // Debug screenshot sweep state. bool screenshot_sweep_active_ = false; + bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme bool sweep_capture_this_frame_ = false; int sweep_skin_idx_ = 0; int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index 000865e..963b278 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -704,6 +704,10 @@ void App::startSweepImpl(bool full) if (sk.valid) sweep_skins_.push_back(sk.id); if (sweep_skins_.empty()) return; + // Debug Options "Current theme only": sweep just the active skin instead of cycling every theme. + if (sweep_current_theme_only_) + sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId()); + sweep_full_ = full; if (full) { capture_mode_ = true; installDemoWalletData(); } buildSweepCatalog(); diff --git a/src/ui/layout.h b/src/ui/layout.h index 397e65e..54fe686 100644 --- a/src/ui/layout.h +++ b/src/ui/layout.h @@ -173,11 +173,11 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels", inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); } inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); } -// Overall content-column cap: the max width a tab's content should occupy before it is centered in wider -// windows. Prevents cards/forms/tables (which all derive their size from the content child's width) from -// stretching edge-to-edge at wide/ultrawide widths. <= 0 disables (fill full width). Tunable via ui.toml -// [layout] content-max-width; default is generous so data-dense screens stay comfortable. -inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(1600.0f) * dpiScale(); } +// Overall content-column cap: the max width a tab's content occupies before it is centered in wider +// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default — +// requested so large windows don't leave a big empty gutter on the right). Set a positive +// ui.toml [layout] content-max-width to re-enable a centered readable column. +inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); } // Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the // available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST @@ -186,7 +186,8 @@ inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "con // Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW. struct CardBox { float width; float offsetX; }; inline CardBox mainComposeCardBox(float availW) { - float w = std::min(availW, kContentMaxWidth()); + float cap = kContentMaxWidth(); + float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) }; } diff --git a/src/ui/material/settings_controls.h b/src/ui/material/settings_controls.h index 8d11a92..ee79e86 100644 --- a/src/ui/material/settings_controls.h +++ b/src/ui/material/settings_controls.h @@ -107,7 +107,7 @@ inline float ActionButtonWidth(const char* label, const char* icon, float minWid 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 padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal 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; @@ -133,19 +133,21 @@ inline bool ActionButton(const char* id, const char* label, const char* icon, Ac 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 round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame) 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); + // Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button. + bg = WithAlpha(Primary(), hov ? 52 : 38); + border = WithAlpha(Primary(), hov ? 150 : 110); + fg = Primary(); break; case ActionTier::Secondary: - bg = WithAlpha(OnSurface(), hov ? 26 : 16); - border = WithAlpha(OnSurface(), 40); + bg = WithAlpha(OnSurface(), hov ? 30 : 20); + border = WithAlpha(OnSurface(), 48); glass = true; fg = OnSurface(); break; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 8eecbee..952af1d 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -164,6 +164,7 @@ struct SettingsPageState { bool effects_expanded = false; bool tools_expanded = false; bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields + int current_tab = 0; // active settings category tab (see SettingsTab enum) bool confirm_clear_ztx = false; bool confirm_delete_blockchain = false; bool confirm_rescan = false; @@ -516,6 +517,7 @@ static void renderConsoleColorToggles(App* app) { app->settings()->save(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); + ImGui::SameLine(0, Layout::spacingLg()); // Console behavior (not a GPU effect): focus the command input when the tab opens. Bound straight to // settings — the App reads it at the page transition; no ConsoleTab static needed. bool autoFocus = app->settings()->getConsoleAutoFocus(); @@ -531,6 +533,79 @@ static void renderConsoleColorToggles(App* app) { // Settings Page Renderer // ============================================================================ +// A full-card-width, left-aligned, solid button (icon + label) drawn at an explicit (x,y). +// Used by the side-by-side "column card" tabs (Backup, Wallet) where content is positioned +// manually because ImGui's Indent (which GlassCardScope uses) is window-relative. +static bool renderCardButton(ImDrawList* dl, float x, float y, float w, float h, + const char* id, const char* label, const char* icon) { + using namespace material; + ImGui::SetCursorScreenPos(ImVec2(x, y)); + ImFont* lf = Type().button(); + ImFont* icf = Type().iconSmall(); + const float dpp = Layout::dpiScale(); + const float padX = 12.0f * dpp, ig = 6.0f * dpp; + const float bh = h; + const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, bh)); + const bool hov = ImGui::IsItemHovered(); + if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 pmin(x, y), pmax(x + w, y + bh); + const float round = 7.0f * dpp; // match ActionButton chips (mockup .btn radius: 7px) + dl->AddRectFilled(pmin, pmax, WithAlpha(OnSurface(), hov ? 30 : 20), round); + dl->AddRect(pmin, pmax, WithAlpha(OnSurface(), 48), round, 0, 1.0f); + const ImU32 fg = ImGui::GetColorU32(OnSurface()); + dl->PushClipRect(pmin, pmax, true); + float tx = x + padX; + if (icon && icon[0] && icf) { + dl->AddText(icf, icf->LegacySize, ImVec2(tx, y + (bh - icf->LegacySize) * 0.5f), fg, icon); + tx += icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x + ig; + } + dl->AddText(lf, lf->LegacySize, ImVec2(tx, y + (bh - lf->LegacySize) * 0.5f), fg, label); + dl->PopClipRect(); + return pressed; +} + +// ---- Category tabs (top-level settings navigation) ------------------------- +enum SettingsTab { TAB_APPEARANCE = 0, TAB_WALLET, TAB_BACKUP, TAB_NODE, TAB_EXPLORER, TAB_CHAT, TAB_ABOUT, TAB_COUNT }; + +// Pinned horizontal category tab bar, drawn above the settings scroll region. Each category is a +// pill; the active one gets an accent fill. Advances the ImGui cursor past the bar + a divider so +// the scrollable content begins below it. +static void renderSettingsTabBar(float availWidth) { + using namespace material; + struct T { int id; const char* label; const char* idstr; }; + static const T tabs[] = { + {TAB_APPEARANCE, "Appearance", "##stabA"}, {TAB_WALLET, "Wallet", "##stabW"}, + {TAB_BACKUP, "Backup & Data", "##stabB"}, {TAB_NODE, "Node & Security", "##stabN"}, + {TAB_EXPLORER, "Explorer", "##stabE"}, {TAB_CHAT, "Chat", "##stabC"}, + {TAB_ABOUT, "About", "##stabT"}, + }; + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImFont* f = Type().body2(); + const float dp = Layout::dpiScale(); + const float padX = 13.0f * dp, padY = 7.0f * dp, gap = 6.0f * dp, rnd = 8.0f * dp; + const float h = f->LegacySize + padY * 2.0f; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + float x = origin.x, y = origin.y; + for (const T& t : tabs) { + ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, t.label); + float w = ts.x + padX * 2.0f; + if (x > origin.x && x + w > origin.x + availWidth) { x = origin.x; y += h + gap; } // wrap + ImGui::SetCursorScreenPos(ImVec2(x, y)); + if (ImGui::InvisibleButton(t.idstr, ImVec2(w, h))) s_settingsState.current_tab = t.id; + const bool hovered = ImGui::IsItemHovered(); + const bool active = (s_settingsState.current_tab == t.id); + if (active) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + h), WithAlpha(Primary(), 34), rnd); + else if (hovered) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + h), IM_COL32(255, 255, 255, 12), rnd); + dl->AddText(f, f->LegacySize, ImVec2(x + (w - ts.x) * 0.5f, y + (h - ts.y) * 0.5f), + ImGui::GetColorU32((active || hovered) ? OnSurface() : OnSurfaceMedium()), t.label); + x += w + gap; + } + const float bottom = y + h; + dl->AddLine(ImVec2(origin.x, bottom + 5.0f * dp), ImVec2(origin.x + availWidth, bottom + 5.0f * dp), + ImGui::GetColorU32(Divider()), 1.0f); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom + 12.0f * dp)); +} + void RenderSettingsPage(App* app) { // Load settings state on first render if (!s_settingsState.initialized && app->settings()) { @@ -571,6 +646,9 @@ void RenderSettingsPage(App* app) { ImVec2 contentAvail = ImGui::GetContentRegionAvail(); float scrollbarMargin = ImGui::GetStyle().ScrollbarSize + Layout::spacingSm(); float availWidth = contentAvail.x - scrollbarMargin; + + // Settings fills the full content width (the global content-max-width cap is disabled). + float settingsLeftOffset = 0.0f; float hs = Layout::hScale(availWidth); float vs = Layout::vScale(contentAvail.y); float pad = Layout::cardInnerPadding(); @@ -596,10 +674,14 @@ void RenderSettingsPage(App* app) { } // Input field width — fill remaining space in card float inputW = std::max(S.drawElement("components.settings-page", "input-min-width").size, availWidth - labelW - pad * 2); + (void)inputW; // used by some sections; may be unused depending on active tab + + // Category tab bar — pinned above the scrollable content area (not part of the scroll). + renderSettingsTabBar(availWidth); // Scrollable content area — NoBackground matches other tabs - - ImGui::BeginChild("##SettingsPageScroll", ImVec2(0, 0), false, + if (settingsLeftOffset > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + settingsLeftOffset); + ImGui::BeginChild("##SettingsPageScroll", ImVec2(settingsLeftOffset > 0.0f ? availWidth + scrollbarMargin : 0.0f, 0), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse); ApplySmoothScroll(); @@ -647,24 +729,17 @@ void RenderSettingsPage(App* app) { GlassPanelSpec glassSpec; glassSpec.rounding = glassRound; + glassSpec.fillAlpha = 26; // lift the settings cards off the background (closer to the mockup's flat cards) + glassSpec.borderAlpha = 50; // crisper, more defined card border (mockup uses a visible 1px line) ImFont* capFont = Type().caption(); ImFont* body2 = Type().body2(); ImFont* sub1 = Type().subtitle1(); // ==================================================================== - // THEME & LANGUAGE — card (draw-first approach; avoids ChannelsSplit - // which breaks BeginCombo popup rendering in some ImGui versions) + // APPEARANCE — two stacked cards: THEME & LANGUAGE (2x2 dropdown grid) + // then SCALE & EFFECTS (font scale + effect toggles + Advanced sliders). // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("theme_language")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); - - float contentW = availWidth - pad * 2; - float comboGap = S.drawElement("components.settings-page", "combo-row-gap").size; - float compactBP = S.drawElement("components.settings-page", "compact-breakpoint").size; - bool wideLayout = availWidth >= compactBP * dp; // scale the breakpoint so the 3-combo row drops to the stacked layout at high font scale (else the combos clip) + if (s_settingsState.current_tab == TAB_APPEARANCE) { float refreshBtnW = S.drawElement("components.settings-page", "refresh-btn-width").size; // --- Skin data --- @@ -679,6 +754,7 @@ void RenderSettingsPage(App* app) { break; } } + (void)active_is_custom; // --- Language data --- auto& i18n = util::I18n::instance(); @@ -696,7 +772,7 @@ void RenderSettingsPage(App* app) { if (l.id == s_settingsState.balance_layout) { balPreview = l.name; break; } } - // --- Theme combo popup (shared between wide and narrow paths) --- + // --- Theme combo popup (shared) --- auto renderThemeComboPopup = [&]() { ImGui::TextDisabled("%s", TR("settings_builtin")); ImGui::Separator(); @@ -750,112 +826,120 @@ void RenderSettingsPage(App* app) { } }; - if (wideLayout) { - // ============================================================ - // Wide: 3 combos on one row + compact 3-column effects grid - // ============================================================ + // ============================================================ + // Card 1 — THEME & LANGUAGE (2x2 grid of labeled dropdowns) + // ============================================================ + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("theme_language")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + float contentW = availWidth - pad * 2; + float cellGap = Layout::spacingLg(); + bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale) + int cols = twoCol ? 2 : 1; + float colW = std::max(160.0f, twoCol ? (contentW - cellGap) * 0.5f : contentW); + float baseX = ImGui::GetCursorScreenPos().x; - // --- Combo row: Theme | Layout | Language [Refresh] --- - { - ImGui::PushFont(body2); - float lblGap = Layout::spacingXs(); - float lblThemeW = ImGui::CalcTextSize(TR("theme")).x + lblGap; - float lblLayoutW = ImGui::CalcTextSize(TR("balance_layout")).x + lblGap; - float lblLangW = ImGui::CalcTextSize(TR("language")).x + lblGap; - // Budget matches the RAW draws below (SameLine(0, comboGap) and ImVec2(refreshBtnW, 0)) — - // don't dpi-scale these terms or the budget over-reserves and the combos shrink needlessly. - float totalFixed = lblThemeW + lblLayoutW + lblLangW - + comboGap * 2 + Layout::spacingSm() + refreshBtnW; - float comboW = std::min(std::max(80.0f, (contentW - totalFixed) / 3.0f), 300.0f * dp); + const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") }; + ImGui::PushFont(body2); + float lblW = 0.0f; // label column — mockup puts the label BESIDE the control (.row), not above + for (int i = 0; i < 4; ++i) lblW = std::max(lblW, ImGui::CalcTextSize(cellLabels[i]).x); + lblW += Layout::spacingMd(); + float rowTop = ImGui::GetCursorScreenPos().y; + float rowBottom = rowTop; + for (int i = 0; i < 4; ++i) { + int col = i % cols; + if (col == 0 && i > 0) rowTop = rowBottom; // start a new grid row + float cx = baseX + col * (colW + cellGap); + + // Field label on the left, control filling the rest of the cell (mockup .row layout). + ImGui::SetCursorScreenPos(ImVec2(cx, rowTop)); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("theme")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { - renderThemeComboPopup(); - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_theme_hotkey")); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); + ImGui::TextUnformatted(cellLabels[i]); + ImGui::PopStyleColor(); + ImGui::SetCursorScreenPos(ImVec2(cx + lblW, rowTop)); + ImGui::SetNextItemWidth(colW - lblW); - ImGui::SameLine(0, comboGap); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("balance_layout")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { - for (const auto& l : layouts) { - if (!l.enabled) continue; - bool selected = (l.id == s_settingsState.balance_layout); - if (ImGui::Selectable(l.name.c_str(), selected)) { - s_settingsState.balance_layout = l.id; - if (app->settings()) { - app->settings()->setBalanceLayout(s_settingsState.balance_layout); - app->settings()->save(); + switch (i) { + case 0: // Theme + if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { renderThemeComboPopup(); ImGui::EndCombo(); } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_hotkey")); + break; + case 1: // Balance layout + if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { + for (const auto& l : layouts) { + if (!l.enabled) continue; + bool selected = (l.id == s_settingsState.balance_layout); + if (ImGui::Selectable(l.name.c_str(), selected)) { + s_settingsState.balance_layout = l.id; + if (app->settings()) { app->settings()->setBalanceLayout(l.id); app->settings()->save(); } + } + if (selected) ImGui::SetItemDefaultFocus(); } + ImGui::EndCombo(); } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_layout_hotkey")); - - ImGui::SameLine(0, comboGap); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("language")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), - static_cast(lang_names.size()))) { - auto it = languages.begin(); - std::advance(it, s_settingsState.language_index); - i18n.loadLanguage(it->first); - if (app->settings()) { - app->settings()->setLanguage(it->first); - app->settings()->save(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_layout_hotkey")); + break; + case 2: // Language + if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), + static_cast(lang_names.size()))) { + auto it = languages.begin(); + std::advance(it, s_settingsState.language_index); + i18n.loadLanguage(it->first); + if (app->settings()) { app->settings()->setLanguage(it->first); app->settings()->save(); } + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); + break; + case 3: { // Clock format + int cf = app->settings() ? app->settings()->getTimeFormat() : 0; + const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; + if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { + app->settings()->setTimeFormat(cf); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); + break; } } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); - ImGui::SameLine(0, Layout::spacingSm()); - if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { - schema::SkinManager::instance().refresh(); - Notifications::instance().info(TR("settings_theme_refreshed")); + float cellBottom = ImGui::GetCursorScreenPos().y; + rowBottom = (col == 0) ? cellBottom : std::max(rowBottom, cellBottom); + if (col == cols - 1 || i == 3) { + ImGui::SetCursorScreenPos(ImVec2(baseX, rowBottom + 11.0f * dp)); + rowBottom = ImGui::GetCursorScreenPos().y; } - if (ImGui::IsItemHovered()) { - material::Tooltip(TR("tt_scan_themes"), - schema::SkinManager::getUserSkinsDirectory().c_str()); - } - ImGui::PopFont(); } + ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Rescan the theme folder — minor action, tucked below the grid. + ImGui::SetCursorScreenPos(ImVec2(baseX, rowBottom)); + if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { + schema::SkinManager::instance().refresh(); + Notifications::instance().info(TR("settings_theme_refreshed")); + } + if (ImGui::IsItemHovered()) + material::Tooltip(TR("tt_scan_themes"), schema::SkinManager::getUserSkinsDirectory().c_str()); + } - // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("clock_format")); - ImGui::SameLine(0, Layout::spacingMd()); - int cf = app->settings() ? app->settings()->getTimeFormat() : 0; - const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; - ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); - if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { - app->settings()->setTimeFormat(cf); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Font Scale slider (always visible) --- + ImGui::Dummy(ImVec2(0, gap)); + + // ============================================================ + // Card 2 — SCALE & EFFECTS (font scale + effect toggles + Advanced sliders) + // ============================================================ + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("scale_effects")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + float contentW = availWidth - pad * 2; + + // --- Font Scale slider --- { ImGui::PushFont(body2); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); ImGui::TextUnformatted(TR("font_scale")); + ImGui::PopStyleColor(); float fontSliderW = std::min(std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW), 360.0f * dp); ImGui::SetNextItemWidth(fontSliderW); s_settingsState.font_scale = Layout::userFontScale(); @@ -878,17 +962,11 @@ void RenderSettingsPage(App* app) { ImGui::PopFont(); } - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - // --- Collapsible: Advanced Effects... --- - material::CollapsibleHeader(dl, "##EffectsToggle", TR("advanced_effects"), - s_settingsState.effects_expanded, contentW, - body2, OnSurfaceMedium()); - - if (s_settingsState.effects_expanded) { + // --- Effect toggles (always visible, horizontal wrapping flow) --- + { ImGui::PushFont(body2); - - // Effects checkboxes — wrap to new rows instead of overflowing on narrow windows. const float efFh = ImGui::GetFrameHeight(); const float efInner = ImGui::GetStyle().ItemInnerSpacing.x; float efX = 0.0f; bool efFirst = true; @@ -898,6 +976,7 @@ void RenderSettingsPage(App* app) { else if (efX + Layout::spacingLg() + w <= contentW) { ImGui::SameLine(0, Layout::spacingLg()); efX += Layout::spacingLg() + w; } else { efX = w; } }; + efFlow(TR("low_spec_mode")); if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) { effects::setLowSpecMode(s_settingsState.low_spec_mode); @@ -950,7 +1029,25 @@ void RenderSettingsPage(App* app) { if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); } + ImGui::EndDisabled(); // low-spec + ImGui::PopFont(); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + + // --- Collapsible: Advanced Effects... (console colors + 2x2 opacity/blur sliders) --- + material::CollapsibleHeader(dl, "##EffectsToggle", TR("advanced_effects"), + s_settingsState.effects_expanded, contentW, + body2, OnSurfaceMedium()); + + if (s_settingsState.effects_expanded) { + ImGui::PushFont(body2); + + ImGui::BeginDisabled(s_settingsState.low_spec_mode); + // Console output color toggles (own row — no GPU cost, enabled even in low-spec). + // renderConsoleColorToggles() temporarily End/BeginDisabled()s so its own checkboxes + // stay enabled — it MUST be called while exactly one BeginDisabled is active. renderConsoleColorToggles(app); // Row 1: Acrylic preset slider + Noise slider (side by side, labels above) @@ -1032,494 +1129,304 @@ void RenderSettingsPage(App* app) { ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow2Y)); - ImGui::EndDisabled(); // low-spec - ImGui::PopFont(); - } // s_settingsState.effects_expanded - } else { - // ============================================================ - // Narrow: stacked combos + 2-column effects (original layout) - // ============================================================ - - // --- Theme row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("theme")); - ImGui::SameLine(labelW); - - // Reserve the real gaps (default ItemSpacing, not spacingSm) plus the - // custom-skin "*" marker so the Refresh button never spills past the edge. - float themeComboW = std::max(S.drawElement("components.settings-page", "theme-combo-min-width").size, - availWidth - pad * 2 - labelW - refreshBtnW - ImGui::GetStyle().ItemSpacing.x - - (active_is_custom ? (ImGui::GetStyle().ItemSpacing.x + ImGui::CalcTextSize("*").x) : 0.0f)); - ImGui::SetNextItemWidth(themeComboW); - if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { - renderThemeComboPopup(); - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_theme_hotkey")); - if (active_is_custom) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "*"); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_theme")); - } - ImGui::SameLine(); - if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { - schema::SkinManager::instance().refresh(); - Notifications::instance().info(TR("settings_theme_refreshed")); - } - if (ImGui::IsItemHovered()) { - material::Tooltip(TR("tt_scan_themes"), - schema::SkinManager::getUserSkinsDirectory().c_str()); - } - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // --- Balance Layout row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("balance_layout")); - ImGui::SameLine(labelW); - ImGui::SetNextItemWidth(std::max(180.0f, inputW)); - if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { - for (const auto& l : layouts) { - if (!l.enabled) continue; - bool selected = (l.id == s_settingsState.balance_layout); - if (ImGui::Selectable(l.name.c_str(), selected)) { - s_settingsState.balance_layout = l.id; - if (app->settings()) { - app->settings()->setBalanceLayout(s_settingsState.balance_layout); - app->settings()->save(); - } - } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_layout_hotkey")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // --- Language row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("language")); - ImGui::SameLine(labelW); - ImGui::SetNextItemWidth(inputW); - if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), - static_cast(lang_names.size()))) { - auto it = languages.begin(); - std::advance(it, s_settingsState.language_index); - i18n.loadLanguage(it->first); - if (app->settings()) { - app->settings()->setLanguage(it->first); - app->settings()->save(); - } - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("clock_format")); - ImGui::SameLine(0, Layout::spacingMd()); - int cf = app->settings() ? app->settings()->getTimeFormat() : 0; - const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; - ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); - if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { - app->settings()->setTimeFormat(cf); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Font Scale slider (always visible) --- - { - ImGui::PushFont(body2); - ImGui::TextUnformatted(TR("font_scale")); - float fontSliderW = std::min(std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, - availWidth - pad * 2), 360.0f * dp); - ImGui::SetNextItemWidth(fontSliderW); - s_settingsState.font_scale = Layout::userFontScale(); - float prev_font_scale = s_settingsState.font_scale; - { - char fs_fmt[16]; - snprintf(fs_fmt, sizeof(fs_fmt), "%.2fx", s_settingsState.font_scale); - ImGui::SliderFloat("##FontScale", &s_settingsState.font_scale, 1.0f, 1.5f, fs_fmt, - ImGuiSliderFlags_AlwaysClamp); - } - s_settingsState.font_scale = std::max(1.0f, std::min(1.5f, - std::round(s_settingsState.font_scale * 20.0f) / 20.0f)); - if (s_settingsState.font_scale != prev_font_scale) - Layout::setUserFontScaleVisual(s_settingsState.font_scale); - if (ImGui::IsItemDeactivatedAfterEdit()) { - Layout::setUserFontScale(s_settingsState.font_scale); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Collapsible: Advanced Effects... --- - { - float narrowContentW = availWidth - pad * 2; - material::CollapsibleHeader(dl, "##EffectsToggleN", TR("advanced_effects"), - s_settingsState.effects_expanded, narrowContentW, - body2, OnSurfaceMedium()); - } - - if (s_settingsState.effects_expanded) { - ImGui::PushFont(body2); - - if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) { - effects::setLowSpecMode(s_settingsState.low_spec_mode); - if (s_settingsState.low_spec_mode) { - enterLowSpec(true); - } else if (s_settingsState.low_spec_snapshot.valid) { - exitLowSpec(true); - } - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec")); - - if (ImGui::Checkbox(TrId("settings_gradient_bg", "gradient_bg").c_str(), &s_settingsState.gradient_background)) { - schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg_alt")); - - if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) { - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion")); - - ImGui::BeginDisabled(s_settingsState.low_spec_mode); - - if (ImGui::Checkbox(TrId("console_scanline", "scanline").c_str(), &s_settingsState.scanline_enabled)) { - ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled; - app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline")); - - ImGui::SameLine(0, Layout::spacingLg()); - if (ImGui::Checkbox(TrId("theme_effects", "theme_fx").c_str(), &s_settingsState.theme_effects_enabled)) { - effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); - - ImGui::SameLine(0, Layout::spacingLg()); - { - bool anim = app->settings()->getAnimateAvatars(); - if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) { - app->settings()->setAnimateAvatars(anim); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); - } - - // Console output color toggles (own row — no GPU cost, enabled even in low-spec). - renderConsoleColorToggles(app); - - float ctrlW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, - availWidth - pad * 2.0f); - ImGui::TextUnformatted(TR("acrylic")); - ImGui::SetNextItemWidth(ctrlW); - { - char blur_fmt[16]; - if (s_settingsState.blur_amount < 0.01f) - snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off")); - else - snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f); - if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f; - s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f); - effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur")); - - ImGui::TextUnformatted(TR("noise")); - ImGui::SetNextItemWidth(ctrlW); - { - char noise_fmt[16]; - if (s_settingsState.noise_opacity < 0.01f) - snprintf(noise_fmt, sizeof(noise_fmt), "%s", TR("slider_off")); - else - snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_settingsState.noise_opacity * 100.0f); - if (ImGui::SliderFloat("##NoiseOpacity", &s_settingsState.noise_opacity, 0.0f, 1.0f, noise_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - effects::ImGuiAcrylic::SetNoiseOpacity(s_settingsState.noise_opacity); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise")); - - ImGui::TextUnformatted(TR("ui_opacity")); - ImGui::SetNextItemWidth(ctrlW); - { - char uiop_fmt[16]; - snprintf(uiop_fmt, sizeof(uiop_fmt), "%.0f%%%%", s_settingsState.ui_opacity * 100.0f); - if (ImGui::SliderFloat("##UIOpacity", &s_settingsState.ui_opacity, 0.3f, 1.0f, uiop_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity")); - - ImGui::TextUnformatted(TR("window_opacity")); - ImGui::SetNextItemWidth(ctrlW); - { - char winop_fmt[16]; - snprintf(winop_fmt, sizeof(winop_fmt), "%.0f%%%%", s_settingsState.window_opacity * 100.0f); - if (ImGui::SliderFloat("##WindowOpacity", &s_settingsState.window_opacity, 0.3f, 1.0f, winop_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity")); - ImGui::EndDisabled(); // low-spec ImGui::PopFont(); } // s_settingsState.effects_expanded } } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // WALLET — card (privacy/daemon toggles + collapsible tools) // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_WALLET) { + const bool showDaemonOptions = app->supportsFullNodeLifecycleActions(); - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + // Two side-by-side glass cards: OPTIONS (toggles) | DIAGNOSTICS (log + tools). + const float ccGap = Layout::cardGap(); + const float ccW = (availWidth - ccGap) * 0.5f; + const float cw = ccW - pad * 2; + const float ccTop = ImGui::GetCursorScreenPos().y; + const float ccBaseX = ImGui::GetCursorScreenPos().x; + float ccBottom = ccTop; - float contentW = availWidth - pad * 2; + // One foreground channel for both cards; panels painted afterwards at equal (tallest) height. + float cardBot[2] = { ccTop, ccTop }; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + auto cardHeader = [&](int col, const char* header) -> float { + const float cx = ccBaseX + col * (ccW + ccGap); + ImGui::SetCursorScreenPos(ImVec2(cx + pad, ccTop + pad)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), header); + return ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + }; + auto cardClose = [&](int col, float lastBottom) { + cardBot[col] = lastBottom + pad; + ccBottom = std::max(ccBottom, cardBot[col]); + }; - // Privacy, Network & Daemon checkboxes — wrap to new rows instead of shrinking the text. + ImGui::PushFont(body2); + const float fh = ImGui::GetFrameHeight(); // checkbox row height + const float bh = ImGui::GetFrameHeight() + 12.0f * dp; // taller, airier buttons (match mockup) + const float gp = Layout::spacingSm(); // roomier gap + + // ---- Card 0: OPTIONS (checkboxes in a 2-column grid — mockup .chks.two) ---- { - const bool showDaemonOptions = app->supportsFullNodeLifecycleActions(); - const float cbSpacing = Layout::spacingLg(); - const float fh = ImGui::GetFrameHeight(); - const float inner = ImGui::GetStyle().ItemInnerSpacing.x; - float cbX = 0.0f; bool cbFirst = true; - // Position the next checkbox: SameLine if it fits on the current row, else wrap. - auto cbFlow = [&](const char* label) { - const float w = fh + inner + ImGui::CalcTextSize(label).x; - if (cbFirst) { cbFirst = false; cbX = w; } - else if (cbX + cbSpacing + w <= contentW) { ImGui::SameLine(0, cbSpacing); cbX += cbSpacing + w; } - else { cbX = w; } + const float cx = ccBaseX + pad; + const float col2W = (cw - Layout::spacingLg()) * 0.5f; + float rowY = cardHeader(0, TR("wallet_options_hdr")); + int c = 0; float last = rowY; + auto CB = [&](const std::string& id, bool* val) -> bool { + ImGui::SetCursorScreenPos(ImVec2(cx + c * (col2W + Layout::spacingLg()), rowY)); + const bool changed = ImGui::Checkbox(id.c_str(), val); + last = rowY + fh; + if (c == 1) { rowY += fh + gp; c = 0; } else { c = 1; } + return changed; }; - cbFlow(TR("save_z_transactions")); - ImGui::Checkbox(TrId("save_z_transactions", "save_ztx").c_str(), &s_settingsState.save_ztxs); + CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx")); - cbFlow(TR("auto_shield")); - ImGui::Checkbox(TrId("auto_shield", "auto_shld").c_str(), &s_settingsState.auto_shield); + CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield")); - cbFlow(TR("use_tor")); - ImGui::Checkbox(TrId("use_tor", "tor").c_str(), &s_settingsState.use_tor); + CB(TrId("use_tor", "tor"), &s_settingsState.use_tor); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor")); if (showDaemonOptions) { - cbFlow(TR("keep_daemon")); - if (ImGui::Checkbox(TrId("keep_daemon", "keep_dmn").c_str(), &s_settingsState.keep_daemon_running)) + if (CB(TrId("keep_daemon", "keep_dmn"), &s_settingsState.keep_daemon_running)) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_keep_daemon")); - cbFlow(TR("stop_external")); - if (ImGui::Checkbox(TrId("stop_external", "stop_ext").c_str(), &s_settingsState.stop_external_daemon)) + if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon)) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external")); } - cbFlow(TR("verbose_logging")); - if (ImGui::Checkbox(TrId("verbose_logging", "verbose").c_str(), &s_settingsState.verbose_logging)) { + if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) { dragonx::util::Logger::instance().setVerbose(s_settingsState.verbose_logging); saveSettingsPageState(app->settings()); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose")); + cardClose(0, last); } - // W7 QoL: quick diagnostics actions — open the log folder, and copy a plaintext support bundle - // (version, variant, daemon/RPC/wallet/log state) to the clipboard. + // ---- Card 1: DIAGNOSTICS + Tools & Actions (2-column button grids) ---- { - const float diagBtnW = (contentW - Layout::spacingMd()) * 0.5f; - if (TactileButton(TR("settings_open_log_folder"), ImVec2(diagBtnW, 0), S.resolveFont("button"))) + const float cx = ccBaseX + (ccW + ccGap) + pad; + const float col2W = (cw - Layout::spacingLg()) * 0.5f; + float rowY = cardHeader(1, TR("wallet_diagnostics_hdr")); + int c = 0; float last = rowY; + auto BTN = [&](const char* id, const char* label, const char* icon) -> bool { + const float bx = cx + c * (col2W + Layout::spacingLg()); + const bool p = renderCardButton(dl, bx, rowY, col2W, bh, id, label, icon); + last = rowY + bh; + if (c == 1) { rowY += bh + gp; c = 0; } else { c = 1; } + return p; + }; + auto rowBreak = [&]() { if (c == 1) { rowY += bh + gp; c = 0; } }; + + if (BTN("##wlog", TR("settings_open_log_folder"), ICON_MD_FOLDER)) dragonx::util::Platform::openFolder(dragonx::util::Platform::getObsidianDragonDir()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_log_folder")); - ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TR("settings_copy_diagnostics"), ImVec2(diagBtnW, 0), S.resolveFont("button"))) { + if (BTN("##wdiag", TR("settings_copy_diagnostics"), ICON_MD_CONTENT_COPY)) { ImGui::SetClipboardText(app->buildDiagnosticsReport().c_str()); ui::Notifications::instance().info(TR("settings_diagnostics_copied"), 4.0f); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_copy_diagnostics")); - } + rowBreak(); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + rowY += Layout::spacingSm(); + ImGui::SetCursorScreenPos(ImVec2(cx, rowY)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("tools_actions_hdr")); + rowY = ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + c = 0; - // --- Collapsible: Tools & Actions... --- - material::CollapsibleHeader(dl, "##ToolsToggle", TR("tools_actions"), - s_settingsState.tools_expanded, contentW, - body2, OnSurfaceMedium()); - - if (s_settingsState.tools_expanded) { - float btnSpacing = Layout::spacingMd(); - int btnsPerRow = (contentW >= 600.0f) ? 3 : 2; - float bw = (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow; - float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(100.0f); - bw = std::max(minBtnW, bw); - // Grow the column so the longest translated label (e.g. German) isn't clipped inside - // ImGui::Button. Measure every label with the actual button font (LegacySize is already - // DPI-scaled — don't scale it again) and add the button's own FramePadding on both sides; - // if that exceeds bw, drop to fewer columns rather than overflow the card width. - { - ImFont* toolsFont = S.resolveFont("button"); - if (!toolsFont) toolsFont = Type().button(); - const char* toolLabels[] = { - TR("settings_address_book"), TR("settings_validate_address"), - TR("settings_request_payment"), TR("settings_shield_mining"), - TR("settings_merge_to_address"), TR("settings_clear_ztx"), - }; - float widestLabel = 0.0f; - for (const char* lbl : toolLabels) - widestLabel = std::max(widestLabel, - toolsFont->CalcTextSizeA(toolsFont->LegacySize, FLT_MAX, 0, lbl).x); - const float needW = widestLabel + ImGui::GetStyle().FramePadding.x * 2.0f - + 8.0f * Layout::dpiScale(); - // Shed columns until the widest label fits (or we're down to a single column). - while (btnsPerRow > 1 && - ((contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow) < needW) - --btnsPerRow; - bw = std::max({minBtnW, (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow, needW}); - } - - if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button"))) - app->setCurrentPage(ui::NavPage::Contacts); // now a top-level tab + if (BTN("##waddr", TR("settings_address_book"), ICON_MD_CONTACTS)) + app->setCurrentPage(ui::NavPage::Contacts); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book")); - ImGui::SameLine(0, btnSpacing); - if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button"))) + if (BTN("##wval", TR("settings_validate_address"), ICON_MD_CHECK_CIRCLE)) ValidateAddressDialog::show(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate")); - if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } - if (TactileButton(TR("settings_request_payment"), ImVec2(bw, 0), S.resolveFont("button"))) + if (BTN("##wreq", TR("settings_request_payment"), ICON_MD_QR_CODE)) RequestPaymentDialog::show(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment")); - if (btnsPerRow >= 3) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } else { ImGui::SameLine(0, btnSpacing); } - if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button"))) + if (BTN("##wshield", TR("settings_shield_mining"), ICON_MD_SHIELD)) ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining")); - if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } - if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button"))) + if (BTN("##wmerge", TR("settings_merge_to_address"), ICON_MD_CALL_MERGE)) ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); - ImGui::SameLine(0, btnSpacing); - if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) { + if (BTN("##wclear", TR("settings_clear_ztx"), ICON_MD_DELETE_SWEEP)) s_settingsState.confirm_clear_ztx = true; - } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx")); + rowBreak(); + cardClose(1, last); } - // --- Backup & Data --- - ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("backup_data")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + // Paint both cards at the same (tallest) height, then merge the channels. { - 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; - }; - - // 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 (fullNode) { - if (btn(fPrim, "##seed", TR("seed_backup_button"), ICON_MD_VPN_KEY, AT::Primary, TR("tt_seed_backup"))) - app->showSeedBackupDialog(); - 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); + const float eq = std::max(cardBot[0], cardBot[1]); + dl->ChannelsSetCurrent(0); + for (int col = 0; col < 2; ++col) { + const float cx = ccBaseX + col * (ccW + ccGap); + material::DrawGlassPanel(dl, ImVec2(cx, ccTop), ImVec2(cx + ccW, eq), glassSpec); } + dl->ChannelsMerge(); + } - // 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(); + ImGui::PopFont(); + // Reserve the full multi-card footprint with a Dummy so the scroll region grows to include + // the manually-positioned cards (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(ccBaseX, ccTop)); + ImGui::Dummy(ImVec2(availWidth, (ccBottom - ccTop) + Layout::spacingSm())); + } + + // ==================================================================== + // BACKUP & DATA — card (own category tab; split out of Wallet) + // ==================================================================== + if (s_settingsState.current_tab == TAB_BACKUP) { + const bool fullNode = app->supportsFullNodeLifecycleActions(); + + // Three side-by-side glass cards, each with its header inside (mockup-style grouping). + // Content is positioned manually (SetCursorScreenPos) because ImGui's Indent — which + // GlassCardScope relies on — is window-relative and would pull offset columns back to x0. + const float ccGap = Layout::cardGap(); + const int ccN = 3; + const float ccW = (availWidth - ccGap * (ccN - 1)) / (float)ccN; + const float cw = ccW - pad * 2; + const float ccTop = ImGui::GetCursorScreenPos().y; + const float ccBaseX = ImGui::GetCursorScreenPos().x; + float ccBottom = ccTop; + + ImGui::PushFont(body2); + const float bh = ImGui::GetFrameHeight() + 12.0f * dp; // taller, airier buttons (match mockup) + const float bgp = Layout::spacingSm(); // roomier gap between buttons + + // A full-card-width, left-aligned, solid button drawn at an explicit (x,y). + auto cardBtn = [&](float x, float y, float w, const char* id, const char* label, const char* icon) -> bool { + return renderCardButton(dl, x, y, w, bh, id, label, icon); + }; + // All cards render onto one foreground channel; the glass panels are painted afterwards at a + // single equal height (the tallest card) so side-by-side cards match — mockup grid-stretch look. + float cardBot[3] = { ccTop, ccTop, ccTop }; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + auto cardHeader = [&](int col, const char* header) -> float { + const float cx = ccBaseX + col * (ccW + ccGap); + float cy = ccTop + pad; + ImGui::SetCursorScreenPos(ImVec2(cx + pad, cy)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), header); + return ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + }; + auto cardClose = [&](int col, float lastBottom) { + cardBot[col] = lastBottom + pad; + ccBottom = std::max(ccBottom, cardBot[col]); + }; + + // ---- Card 0: Import & Restore ---- + { + const float cx = ccBaseX + pad; + float cy = cardHeader(0, TR("backup_col_import")); + float last = cy; + if (cardBtn(cx, cy, cw, "##imp_key", TR("settings_import_key"), ICON_MD_KEY)) app->showImportKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_key")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey")); + last = cy + bh; if (fullNode) { + cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##wallets", TR("wallets_button"), ICON_MD_ACCOUNT_BALANCE_WALLET)) ui::WalletsDialog::show(app); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallets_button")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##bootstrap", TR("download_bootstrap"), ICON_MD_CLOUD_DOWNLOAD)) BootstrapDownloadDialog::show(app); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap")); + last = cy + bh; + } + cardClose(0, last); + } + + // ---- Card 1: Backup ---- + { + const float cx = ccBaseX + (ccW + ccGap) + pad; + float cy = cardHeader(1, TR("backup_col_backup")); + float last = cy; + if (fullNode) { + if (cardBtn(cx, cy, cw, "##seed", TR("seed_backup_button"), ICON_MD_VPN_KEY)) app->showSeedBackupDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup")); + last = cy + bh; cy += bh + bgp; + } + if (cardBtn(cx, cy, cw, "##backup", TR("settings_backup"), ICON_MD_BACKUP)) app->showBackupDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_backup")); + last = cy + bh; + if (fullNode) { + cy += bh + bgp; const bool migrateGlow = app->isPreSeedWallet(); - if (btn(fSec, "##migrate", TR("seed_migrate_button"), ICON_MD_SWAP_HORIZ, AT::Secondary, TR("tt_seed_migrate"))) - app->showSeedMigrationDialog(); - if (migrateGlow) { // pulsing accent halo nudging a legacy wallet to migrate + if (cardBtn(cx, cy, cw, "##migrate", TR("seed_migrate_button"), ICON_MD_SWAP_HORIZ)) app->showSeedMigrationDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate")); + if (migrateGlow) { 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); - ImDrawList* gdl = ImGui::GetWindowDrawList(); 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); + dl->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 (btn(fSec, "##wizard", TR("setup_wizard"), ICON_MD_AUTO_FIX_HIGH, AT::Secondary, TR("tt_wizard"))) - app->restartWizard(); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##wizard", TR("setup_wizard"), ICON_MD_AUTO_FIX_HIGH)) app->restartWizard(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard")); + last = cy + bh; } - - // 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(); + cardClose(1, last); } + + // ---- Card 2: Export ---- + { + const float cx = ccBaseX + (ccW + ccGap) * 2.0f + pad; + float cy = cardHeader(2, TR("backup_col_export")); + float last = cy; + if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv")); + last = cy + bh; + cardClose(2, last); + } + + // Paint all three cards at the same (tallest) height, then merge the channels. + { + const float eq = std::max({cardBot[0], cardBot[1], cardBot[2]}); + dl->ChannelsSetCurrent(0); + for (int col = 0; col < 3; ++col) { + const float cx = ccBaseX + col * (ccW + ccGap); + material::DrawGlassPanel(dl, ImVec2(cx, ccTop), ImVec2(cx + ccW, eq), glassSpec); + } + dl->ChannelsMerge(); + } + + ImGui::PopFont(); + // Reserve the full multi-card footprint with a Dummy so the scroll region grows to include + // the manually-positioned cards (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(ccBaseX, ccTop)); + ImGui::Dummy(ImVec2(availWidth, (ccBottom - ccTop) + Layout::spacingSm())); } - ImGui::Dummy(ImVec2(0, gap)); - - // ==================================================================== // NODE & SECURITY — card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node_security")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + if (s_settingsState.current_tab == TAB_NODE) { + // Two side-by-side glass cards (NODE/SECURITY | DAEMON BINARY), drawn manually because + // GlassCardScope's Indent is window-relative and can't offset the right card. All the + // column *content* below is unchanged; only the card wrapper differs. + const float ndTop = ImGui::GetCursorScreenPos().y; + const float ndBaseX = ImGui::GetCursorScreenPos().x; + bool ndTwoCol = false; + float ndColW = 0.0f, ndColGap = 0.0f, ndLeftBottom = 0.0f, ndRightBottom = 0.0f, ndSingleBottom = ndTop; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + ImGui::SetCursorScreenPos(ImVec2(ndBaseX, ndTop + pad)); + ImGui::Indent(pad); float contentW = availWidth - pad * 2; float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(130.0f); @@ -1617,25 +1524,20 @@ void RenderSettingsPage(App* app) { for (int i = 0; i < 6; i++) { if (timeoutValues[i] == timeout) { selTimeout = i; break; } } - // In a narrow (two-column) card the encrypt controls + auto-lock + PIN don't fit on - // one row, so wrap the auto-lock/PIN group onto its own row at the section's left edge. - const bool secWrap = includeRpcEncrypt && secNarrow; - if (includeRpcEncrypt && !secWrap) { - ImGui::SameLine(0, Layout::spacingLg()); - } else { - if (includeRpcEncrypt) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); - } + // Auto-lock gets its own full-width row (label left, dropdown filling — mockup). + (void)secNarrow; + if (includeRpcEncrypt) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); ImGui::AlignTextToFramePadding(); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("settings_auto_lock")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::PushItemWidth(comboW); + const float alLblW = ImGui::CalcTextSize(TR("settings_auto_lock")).x; + ImGui::SameLine(0, Layout::spacingMd()); + ImGui::SetNextItemWidth(std::max(comboW, secColW - alLblW - Layout::spacingMd())); if (ImGui::Combo("##autolock", &selTimeout, timeoutLabels, 6)) { app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]); app->settings()->save(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock")); - ImGui::PopItemWidth(); // PIN unlock controls, trailing the auto-lock combo on the same row. bool isEncryptedPIN = app->state().isEncrypted(); @@ -1643,7 +1545,8 @@ void RenderSettingsPage(App* app) { bool hasPIN = app->hasPinVault(); float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f); - ImGui::SameLine(0, Layout::spacingMd()); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); if (!hasPIN) { if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button"))) app->showPinSetupDialog(); @@ -1666,7 +1569,8 @@ void RenderSettingsPage(App* app) { ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active")); } } else { - ImGui::SameLine(0, Layout::spacingMd()); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); ImGui::AlignTextToFramePadding(); ImGui::TextColored(ImVec4(1,1,1,0.3f), "%s", TR("settings_encrypt_first_pin")); } @@ -2069,6 +1973,7 @@ void RenderSettingsPage(App* app) { // Advance to the true bottom of the single column. ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y)); + ndSingleBottom = ImGui::GetCursorScreenPos().y; // lite = single card } else { // ========================= FULL NODE ========================= @@ -2080,9 +1985,14 @@ void RenderSettingsPage(App* app) { // Two-column layout when wide enough: Node / RPC / Security on the left, Daemon binary on // the right (fills the empty right side + shortens the card). One column when narrow. const bool nsHasDaemon = app->supportsFullNodeLifecycleActions(); - const float nsColGap = Layout::spacingXl(); const bool nsTwoCol = nsHasDaemon && contentW > 760.0f * Layout::dpiScale(); - const float nsColW = nsTwoCol ? (contentW - nsColGap) * 0.5f : contentW; + // The two columns become two SEPARATE glass panels: left [x0, x0+cardW], + // right [x0+cardW+cardGap, x0+availWidth]. For the panels to keep a clean cardGap + // between them, the content column must be cardW-2*pad and the right-column indent + // (nsColW+nsColGap) must equal cardW+cardGap — so nsColGap = cardGap + 2*pad. + const float nsColGap = Layout::cardGap() + 2.0f * pad; + const float nsColW = nsTwoCol ? ((availWidth - Layout::cardGap()) * 0.5f - 2.0f * pad) : contentW; + ndTwoCol = nsTwoCol; ndColW = nsColW; ndColGap = nsColGap; // hoist geometry for the two-panel draw const ImVec2 nsColTop = ImGui::GetCursorScreenPos(); // Window-local anchor for the right column. We shift it with Indent() (not a one-shot // SetCursorScreenPos): ImGui resets the cursor X to the window's left indent on every @@ -2095,7 +2005,7 @@ void RenderSettingsPage(App* app) { // -------------------- NODE / DATA -------------------- Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { const std::string dirPath = util::Platform::getDragonXDataDir(); const std::string walletPath = dirPath + "wallet.dat"; @@ -2108,24 +2018,21 @@ void RenderSettingsPage(App* app) { + Layout::spacingLg(); const ImU32 metaCol = OnSurfaceMedium(); - // Row 1: Data directory — a clickable link (opens the folder) + a copy button. + // Row 1: Data directory — label left; clickable path + copy button RIGHT-aligned + // (mockup .kv space-between ledger look). The path middle-ellipsizes to fit. ImGui::AlignTextToFramePadding(); ImGui::PushStyleColor(ImGuiCol_Text, metaCol); ImGui::TextUnformatted(TR("settings_data_dir")); ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(leftX + labelW); - ImGui::AlignTextToFramePadding(); - // In the two-column layout this shares one draw list with the Daemon-binary - // column (no clip rect between them), so a long OS data-dir path (Windows - // AppData / macOS Application Support) would overrun into it. Middle-ellipsize - // to the remaining column width (room left for the copy button); the full path - // stays available via the tooltip, click-to-open, and the copy button. ImFont* pathFont = ImGui::GetFont(); - const float pathAvailW = contentW - labelW - Layout::spacingSm() - - ImGui::GetFrameHeight() - Layout::spacingXs(); + const float copyW = ImGui::GetFrameHeight(); + const float pathAvailW = contentW - labelW - copyW - Layout::spacingSm() * 2.0f; const std::string dirShown = material::TruncateToWidth(dirPath, pathFont, pathFont->LegacySize, pathAvailW); + const float pathW = ImGui::CalcTextSize(dirShown.c_str()).x; + ImGui::SameLine(0, 0); + ImGui::SetCursorPosX(leftX + contentW - copyW - Layout::spacingSm() - pathW); + ImGui::AlignTextToFramePadding(); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirShown.c_str()); if (ImGui::IsItemHovered()) { const ImVec2 tmn = ImGui::GetItemRectMin(), tmx = ImGui::GetItemRectMax(); @@ -2144,16 +2051,20 @@ void RenderSettingsPage(App* app) { ImGui::SetClipboardText(dirPath.c_str()); } - // Row 2: Wallet size. + // Row 2: Wallet size — label left, value RIGHT-aligned. ImGui::AlignTextToFramePadding(); ImGui::PushStyleColor(ImGuiCol_Text, metaCol); ImGui::TextUnformatted(TR("settings_wallet_size_label")); ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(leftX + labelW); - ImGui::AlignTextToFramePadding(); - if (wallet_size > 0) ImGui::TextUnformatted(size_str.c_str()); - else ImGui::TextDisabled("%s", TR("settings_not_found")); + { + const char* wv = (wallet_size > 0) ? size_str.c_str() : TR("settings_not_found"); + const float wvW = ImGui::CalcTextSize(wv).x; + ImGui::SameLine(0, 0); + ImGui::SetCursorPosX(leftX + contentW - wvW); + ImGui::AlignTextToFramePadding(); + if (wallet_size > 0) ImGui::TextUnformatted(wv); + else ImGui::TextDisabled("%s", wv); + } // Row 3: folder buttons (their own row so the path gets the full width). ImGui::Dummy(ImVec2(0, Layout::spacingXs())); @@ -2198,55 +2109,41 @@ void RenderSettingsPage(App* app) { const char* portLbl = TR("rpc_port"); const char* userLbl = TR("rpc_user"); const char* passLbl = TR("rpc_pass"); - float labelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(portLbl).x + - ImGui::CalcTextSize(userLbl).x + ImGui::CalcTextSize(passLbl).x; + // Two rows, two column-aligned cells each: Host | Port, then Username | Password. + // Each input fills to its column's right edge so the two columns line up vertically. + const float colGap = spMd; + const float colW = std::floor((contentW - colGap) * 0.5f); + const float startX = ImGui::GetCursorPosX(); + const float leftColRight = startX + colW; + const float rightColRight = startX + contentW; - const bool fourAcross = contentW >= 700.0f; - auto field = [&](const char* label, const char* id, char* buf, size_t bufSz, - float inputW, bool password) { + // Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf, + // so these fields DISPLAY the live connection (editing them here did nothing). + auto cell = [&](const char* label, const char* id, char* buf, size_t bufSz, + float cellX, float cellRight, bool password) { + ImGui::SetCursorPosX(cellX); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label); ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputW); - // Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf, - // so these fields DISPLAY the live connection (editing them here did nothing). + ImGui::SetNextItemWidth(std::max(60.0f, cellRight - ImGui::GetCursorPosX())); ImGui::InputText(id, buf, bufSz, ImGuiInputTextFlags_ReadOnly | (password ? ImGuiInputTextFlags_Password : 0)); }; - if (fourAcross) { - // fieldW = (contentW - labels - per-field label gaps - 3 inter-field gaps) / 4 - float inputTotal = contentW - labelsW - Layout::spacingXs() * 4 - spMd * 3; - float inputW = std::min(std::max(60.0f, std::floor(inputTotal / 4.0f)), 220.0f * dp); - field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); - ImGui::SameLine(0, spMd); - field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - ImGui::SameLine(0, spMd); - field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); - ImGui::SameLine(0, spMd); - field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); - } else { - // 2x2: two fields per row. - float halfLabelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(userLbl).x; - float inputW = std::max(60.0f, std::floor( - (contentW - halfLabelsW - Layout::spacingXs() * 2 - spMd) / 2.0f)); - field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); - ImGui::SameLine(0, spMd); - field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); + // Row 1: Host | Port + cell(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), startX, leftColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); + ImGui::SameLine(); + cell(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), leftColRight + colGap, rightColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - ImGui::SameLine(0, spMd); - field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); - } + // Row 2: Username | Password + cell(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), startX, leftColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); + ImGui::SameLine(); + cell(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), leftColRight + colGap, rightColRight, true); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("settings_auto_detected")); if (s_settingsState.rpc_plaintext_remote) { @@ -2265,6 +2162,7 @@ void RenderSettingsPage(App* app) { renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/true); } // ---- end left column ---- const float nsLeftBottom = ImGui::GetCursorScreenPos().y; + ndLeftBottom = nsLeftBottom; if (nsTwoCol) { // Reset to the top, then indent so every line in the right column starts at the // column X (the indent persists across line-advances; the explicit SetCursorPosX @@ -2300,11 +2198,33 @@ void RenderSettingsPage(App* app) { return std::string(buf); }; - ImGui::Dummy(ImVec2(0, Layout::spacingLg())); - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Heading row: "DAEMON BINARY" on the left, a compact colored status right-aligned + // on the same line (moved up out of the status box, and shortened). + { + const ImVec2 hp = ImGui::GetCursorScreenPos(); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary")); + if (bun.available) { + const bool sameSize = inst.exists && inst.size == bun.size; + const char* stTxt = !inst.exists ? TR("daemon_status_none") + : sameSize ? TR("daemon_status_ok") + : TR("daemon_status_diff"); + const ImU32 stCol = (inst.exists && sameSize) ? Success() : Warning(); + ImFont* ov = Type().overline(); + const float stW = ov->CalcTextSizeA(ov->LegacySize, FLT_MAX, 0, stTxt).x; + dl->AddText(ov, ov->LegacySize, ImVec2(hp.x + contentW - stW, hp.y), stCol, stTxt); + } + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - // Version info (Installed / Bundled) as key/value rows, then a status chip. + const float ddp = Layout::dpiScale(); + + // --- Status, grouped in a filled box (mockup .statusbox) --- + const float boxPad = Layout::spacingMd(); // roomier inner padding (mockup ~9-11px) + const float boxLeftX = ImGui::GetCursorScreenPos().x; + ImGui::Dummy(ImVec2(0, boxPad)); + ImGui::BeginGroup(); + ImGui::Indent(boxPad); const float dLeftX = ImGui::GetCursorPosX(); const float dLabelW = std::max(ImGui::CalcTextSize(TR("daemon_installed")).x, ImGui::CalcTextSize(TR("daemon_bundled")).x) + Layout::spacingLg(); @@ -2314,7 +2234,10 @@ void RenderSettingsPage(App* app) { ImGui::TextUnformatted(label); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(dLeftX + dLabelW); + // Right-align the value to the box edge (mockup .kv). If it's too long to fit + // (e.g. the installed version+size+date), fall back to left-packing after the label. + const float dvW = ImGui::CalcTextSize(value.c_str()).x; + ImGui::SetCursorPosX(std::max(dLeftX + dLabelW, dLeftX + (contentW - 2.0f * boxPad) - dvW)); ImGui::AlignTextToFramePadding(); if (dim) ImGui::TextDisabled("%s", value.c_str()); else ImGui::TextUnformatted(value.c_str()); @@ -2338,30 +2261,25 @@ void RenderSettingsPage(App* app) { } else { dkv(TR("daemon_bundled"), TR("daemon_none_bundled"), true); } - if (bun.available) { - const bool sameSize = inst.exists && inst.size == bun.size; - const char* chipTxt = !inst.exists ? TR("daemon_status_missing") - : sameSize ? TR("daemon_status_match") - : TR("daemon_status_differ"); - const ImU32 chipCol = sameSize ? Success() : Warning(); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - const float dp = Layout::dpiScale(); - const ImVec2 cp = ImGui::GetCursorScreenPos(); - const float chpad = 8.0f * dp, chh = ImGui::GetFrameHeight(); - const ImVec2 cts = ImGui::CalcTextSize(chipTxt); - const float chw = cts.x + chpad * 2.0f; - dl->AddRectFilled(cp, ImVec2(cp.x + chw, cp.y + chh), material::WithAlpha(chipCol, 38), chh * 0.4f); - dl->AddRect(cp, ImVec2(cp.x + chw, cp.y + chh), material::WithAlpha(chipCol, 120), chh * 0.4f, 0, 1.0f); - dl->AddText(ImVec2(cp.x + chpad, cp.y + (chh - cts.y) * 0.5f), chipCol, chipTxt); - ImGui::Dummy(ImVec2(chw, chh)); + ImGui::Unindent(boxPad); + ImGui::EndGroup(); + { + const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax(); + const ImVec2 bmn(boxLeftX, gmn.y - boxPad), bmx(boxLeftX + contentW, gmx.y + boxPad); + // Subtle lifted fill (mockup .statusbox #26262a on the #121317 card) + near-invisible border. + dl->AddRectFilled(bmn, bmx, material::WithAlpha(material::OnSurface(), 8), 8.0f * ddp); + dl->AddRect(bmn, bmx, material::WithAlpha(material::OnSurface(), 20), 8.0f * ddp, 0, 1.0f); } + ImGui::Dummy(ImVec2(0, boxPad)); // Refresh the cached daemon info once an in-app install has completed. if (ui::DaemonUpdateDialog::consumeInstalled()) s_settingsState.daemon_info_loaded = false; - // Update actions: Check for updates (primary) | Refresh | Install bundled. - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + // --- UPDATES --- + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { using AT = material::ActionTier; material::ButtonFlow uf(contentW); @@ -2381,7 +2299,9 @@ void RenderSettingsPage(App* app) { ImGui::EndDisabled(); } - // Maintenance actions: Test / Rescan / Repair | Delete blockchain (destructive). + // --- MAINTENANCE --- + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_maintenance_label")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { using AT = material::ActionTier; @@ -2418,7 +2338,23 @@ void RenderSettingsPage(App* app) { if (material::ActionButton("##drepair", TR("repair_wallet"), ICON_MD_HEALING, AT::Secondary)) s_settingsState.confirm_repair_wallet = true; if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet")); - mf.next(material::ActionButtonWidth(TR("delete_blockchain"), ICON_MD_DELETE)); + ImGui::EndDisabled(); + } + + // --- Danger zone: Delete Blockchain, fenced off below a divider --- + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // mockup .danger margin-top: 8px + { + const ImVec2 dvp = ImGui::GetCursorScreenPos(); + // Neutral hairline (mockup .danger border-top #26262b) — not an alarming red rule. + dl->AddLine(dvp, ImVec2(dvp.x + contentW, dvp.y), + material::WithAlpha(material::OnSurface(), 22), 1.0f); + } + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // mockup .danger padding-top: 11px + { + using AT = material::ActionTier; + material::ButtonFlow df(contentW); + ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon()); + df.next(material::ActionButtonWidth(TR("delete_blockchain"), ICON_MD_DELETE)); if (material::ActionButton("##ddelete", TR("delete_blockchain"), ICON_MD_DELETE, AT::Destructive)) s_settingsState.confirm_delete_blockchain = true; if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain")); @@ -2427,239 +2363,265 @@ void RenderSettingsPage(App* app) { } } // ---- end right column ---- const float nsRightBottom = ImGui::GetCursorScreenPos().y; + ndRightBottom = nsRightBottom; ndSingleBottom = nsRightBottom; if (nsTwoCol) ImGui::Unindent(nsColW + nsColGap); // restore indent before the rest of the page ImGui::SetCursorScreenPos(ImVec2(nsColTop.x, nsTwoCol ? std::max(nsLeftBottom, nsRightBottom) : nsRightBottom)); ImGui::PopFont(); } + + // ---- Draw the glass card(s) behind the content, then merge the channels ---- + ImGui::Unindent(pad); + dl->ChannelsSetCurrent(0); + if (ndTwoCol) { + const float ndEq = std::max(ndLeftBottom, ndRightBottom); // equal-height cards (mockup grid stretch) + material::DrawGlassPanel(dl, ImVec2(ndBaseX, ndTop), + ImVec2(ndBaseX + 2.0f * pad + ndColW, ndEq + bottomPad), glassSpec); + material::DrawGlassPanel(dl, ImVec2(ndBaseX + ndColW + ndColGap, ndTop), + ImVec2(ndBaseX + availWidth, ndEq + bottomPad), glassSpec); + } else { + material::DrawGlassPanel(dl, ImVec2(ndBaseX, ndTop), + ImVec2(ndBaseX + availWidth, ndSingleBottom + bottomPad), glassSpec); + } + dl->ChannelsMerge(); + const float ndBot = ndTwoCol ? std::max(ndLeftBottom, ndRightBottom) : ndSingleBottom; + ImGui::SetCursorScreenPos(ImVec2(ndBaseX, ndBot + bottomPad)); } } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // EXPLORER & OPTIONS — full-width card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("explorer_section")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_EXPLORER) { + // Card 1 — URLS + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + float contentW = availWidth - pad * 2; + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("explorer_urls_hdr")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + ImGui::PushFont(body2); - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + // Transaction URL and Address URL — stacked rows, label left, input filling the card (mockup .row). + const float urlLblW = std::max(ImGui::CalcTextSize(TR("transaction_url")).x, + ImGui::CalcTextSize(TR("address_url")).x) + Layout::spacingMd(); + const float urlRowX = ImGui::GetCursorPosX(); + const float urlInputW = contentW - urlLblW; - float contentW = availWidth - pad * 2; - ImGui::PushFont(body2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("transaction_url")); + ImGui::SameLine(urlRowX + urlLblW); + ImGui::SetNextItemWidth(urlInputW); + ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer)); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url")); - // Row 1: Transaction URL | Address URL (side-by-side) - float halfW = (contentW - Layout::spacingLg()) * 0.5f; - float lblTxW = ImGui::CalcTextSize("Transaction URL").x + Layout::spacingXs(); - float lblAddrW = ImGui::CalcTextSize("Address URL").x + Layout::spacingXs(); - float inputTxW = std::min(std::max(80.0f, halfW - lblTxW), 460.0f * dp); - float inputAddrW = std::min(std::max(80.0f, halfW - lblAddrW), 460.0f * dp); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - // Row start X (indent-inclusive) — the Address column is placed relative - // to it, not to a fixed `pad`, so it lands correctly in the right column. - const float expRowX = ImGui::GetCursorPosX(); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("transaction_url")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputTxW); - ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer)); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url")); - ImGui::SameLine(expRowX + halfW + Layout::spacingLg()); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("address_url")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputAddrW); - ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer)); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url")); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("address_url")); + ImGui::SameLine(urlRowX + urlLblW); + ImGui::SetNextItemWidth(urlInputW); + ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer)); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url")); - ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Row 2: Checkboxes + Block Explorer button. Keep the two checkboxes - // side-by-side, but wrap the Block Explorer button onto its own row when - // it won't fit the (narrow, two-column) card — measured, so it's locale-safe. - const float expRowRight = ImGui::GetCursorScreenPos().x + contentW; - ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees")); - ImGui::SameLine(0, Layout::spacingLg()); - ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices")); - const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x - + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg(); - if (ImGui::GetItemRectMax().x + expBtnW <= expRowRight) - ImGui::SameLine(0, Layout::spacingLg()); - else - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) { - util::Platform::openUrl("https://explorer.dragonx.is"); + ImGui::PopFont(); } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer")); - } - ImGui::Dummy(ImVec2(0, gap)); + ImGui::Dummy(ImVec2(0, gap)); + + // Card 2 — OPTIONS + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + float contentW = availWidth - pad * 2; + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet_options_hdr")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + ImGui::PushFont(body2); + + // Checkboxes + Block Explorer button (button wraps to its own row when it won't fit). + const float expRowRight = ImGui::GetCursorScreenPos().x + contentW; + ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees")); + ImGui::SameLine(0, Layout::spacingLg()); + ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices")); + const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg(); + if (ImGui::GetItemRectMax().x + expBtnW <= expRowRight) + ImGui::SameLine(0, Layout::spacingLg()); + else + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) { + util::Platform::openUrl("https://explorer.dragonx.is"); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer")); + + ImGui::PopFont(); + } + } // ==================================================================== // CHAT & CONTACTS — card (same controls as the Chat tab's settings notch) // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("chat_settings_section")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + if (s_settingsState.current_tab == TAB_CHAT) { + // The shared control paints its own two cards (Appearance | Messaging) when drawCards=true. ImGui::PushFont(body2); - RenderChatSettingsControls(app, availWidth - pad * 2.0f); // card inner width (GlassCard doesn't narrow it) + RenderChatSettingsControls(app, availWidth, /*drawCards=*/true); ImGui::PopFont(); } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // ABOUT — card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_ABOUT) { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + const float contentW = availWidth - pad * 2; + const float adp = Layout::dpiScale(); + const float baseX = ImGui::GetCursorScreenPos().x; - ImVec2 cardMin = ImGui::GetCursorScreenPos(); - dl->ChannelsSplit(2); - dl->ChannelsSetCurrent(1); - ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad)); - ImGui::Indent(pad); - - // Logo on the left side of the about card. Deferred: reserve horizontal space - // now, but draw the image after the card's final height is known so it scales to - // the full card height (no empty space below it). + // --- Header: small logo + title / tagline / tech line --- + const ImVec2 logoTop = ImGui::GetCursorScreenPos(); + const float logoSz = 60.0f * adp; ImTextureID logoTex = app->getLogoTexture(); - float logoAreaW = 0; - ImVec2 logoPos = ImGui::GetCursorScreenPos(); - float logoAspect = (app->getLogoHeight() > 0) + const float logoAspect = (app->getLogoHeight() > 0) ? (float)app->getLogoWidth() / (float)app->getLogoHeight() : 1.0f; - float logoReserveH = schema::UI().drawElement("components.settings-page", "about-logo-size").sizeOr(150.0f) * dp; - if (logoTex != 0) { - logoAreaW = logoReserveH * logoAspect + Layout::spacingLg(); - ImGui::Indent(logoAreaW); - } + float logoAreaW = 0.0f; + if (logoTex != 0) { logoAreaW = logoSz + Layout::spacingLg(); ImGui::Indent(logoAreaW); } - float contentW = availWidth - pad * 2 - logoAreaW; - - // App name + version on same line ImGui::PushFont(sub1); ImGui::TextUnformatted(DRAGONX_APP_NAME); ImGui::PopFont(); - ImGui::SameLine(0, Layout::spacingLg()); + ImGui::SameLine(0, Layout::spacingSm()); ImGui::PushFont(body2); snprintf(buf, sizeof(buf), "v%s", DRAGONX_VERSION); - ImGui::TextUnformatted(buf); - ImGui::SameLine(0, Layout::spacingLg()); - snprintf(buf, sizeof(buf), "ImGui %s", IMGUI_VERSION); - ImGui::TextColored(ImVec4(1,1,1,0.4f), "%s", buf); + ImGui::TextColored(ImVec4(1, 1, 1, 0.5f), "%s", buf); ImGui::PopFont(); - // Daemon version - { - const auto& st = app->state(); - if (st.daemon_version > 0) { - int dmaj = st.daemon_version / 1000000; - int dmin = (st.daemon_version / 10000) % 100; - int dpat = (st.daemon_version / 100) % 100; - ImGui::PushFont(body2); - snprintf(buf, sizeof(buf), "%s: %d.%d.%d", TR("daemon_version"), dmaj, dmin, dpat); - ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", buf); - ImGui::PopFont(); - } - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - ImGui::PushFont(body2); - ImGui::PushTextWrapPos(cardMin.x + availWidth - pad - logoAreaW); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + (contentW - logoAreaW)); ImGui::TextUnformatted(TR("settings_about_text")); ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - ImGui::PushFont(capFont); - ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_copyright")); + snprintf(buf, sizeof(buf), "SDL3 \xC2\xB7 Dear ImGui %s \xC2\xB7 GPL-3.0", IMGUI_VERSION); + ImGui::TextColored(ImVec4(1, 1, 1, 0.4f), "%s", buf); ImGui::PopFont(); + if (logoTex != 0) ImGui::Unindent(logoAreaW); + + // Make the header at least as tall as the logo, then draw the logo centered in it. + float headerH = ImGui::GetCursorScreenPos().y - logoTop.y; + if (headerH < logoSz) { ImGui::Dummy(ImVec2(0, logoSz - headerH)); headerH = logoSz; } + if (logoTex != 0) { + float lw = logoSz, lh = logoSz; + if (logoAspect >= 1.0f) lh = logoSz / logoAspect; else lw = logoSz * logoAspect; + const float lx = logoTop.x + (logoSz - lw) * 0.5f; + const float ly = logoTop.y + (headerH - lh) * 0.5f; + dl->AddImage(logoTex, ImVec2(lx, ly), ImVec2(lx + lw, ly + lh)); + } + + // --- Divider --- + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + { + const ImVec2 dv = ImGui::GetCursorScreenPos(); + dl->AddLine(dv, ImVec2(dv.x + contentW, dv.y), ImGui::GetColorU32(material::Divider()), 1.0f); + } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - // Top of the (full-width) buttons row — the deferred logo is clamped to end above - // this Y so the tall left-column logo never overlaps the buttons. - float aboutButtonsTopY = ImGui::GetCursorScreenPos().y; + // --- Two columns: Credits (bullets) | License (paragraph + links) --- + const float colGap = Layout::spacingLg(); + const float colW = (contentW - colGap) * 0.5f; + const float colTop = ImGui::GetCursorScreenPos().y; + const float rx = baseX + colW + colGap; - // Buttons — consistent equal-width row (full card width) - if (logoAreaW > 0) { - ImGui::Unindent(logoAreaW); - } + // Left column — Credits + ImGui::SetCursorScreenPos(ImVec2(baseX, colTop)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about_credits")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { - float fullContentW = availWidth - pad * 2; - // 2x2 grid in the narrow (two-column) card so labels don't clip; - // one 1x4 row at full width. - const bool aboutGrid = fullContentW < 720.0f * Layout::dpiScale(); - float aboutBtnW = aboutGrid ? (fullContentW - Layout::spacingMd()) / 2.0f - : (fullContentW - Layout::spacingMd() * 3) / 4.0f; + ImGui::PushFont(body2); + static const char* kCredits[] = { + "The Hush Developers", + "ObsidianDragon Community", + "Dear ImGui \xE2\x80\x94 Omar Cornut", + "SDL3 \xE2\x80\x94 Sam Lantinga", + "HushChat \xC2\xB7 librustzcash \xC2\xB7 libsodium", + }; + for (size_t i = 0; i < std::size(kCredits); ++i) { + const char* c = kCredits[i]; + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float r = 2.5f * adp; + dl->AddCircleFilled(ImVec2(p.x + r, p.y + ImGui::GetTextLineHeight() * 0.5f), r, + material::WithAlpha(material::Primary(), 210)); + ImGui::SetCursorScreenPos(ImVec2(p.x + r * 2.0f + 8.0f * adp, p.y)); + ImGui::TextUnformatted(c); + if (i < std::size(kCredits) - 1) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + } + ImGui::PopFont(); + } + const float leftBottom = ImGui::GetCursorScreenPos().y; - if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + // Right column — License + links + ImGui::SetCursorScreenPos(ImVec2(rx, colTop)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about_license")); + { + ImGui::SetCursorScreenPos(ImVec2(rx, ImGui::GetCursorScreenPos().y + Layout::spacingSm())); + ImGui::PushFont(capFont); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1, 1, 0.6f)); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + colW); + ImGui::TextUnformatted(TR("about_license_text")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, Layout::spacingLg())); + { + using AT = material::ActionTier; + ImGui::SetCursorScreenPos(ImVec2(rx, ImGui::GetCursorScreenPos().y)); + material::ButtonFlow lf(colW); + lf.next(material::ActionButtonWidth(TR("website"), ICON_MD_PUBLIC)); + if (material::ActionButton("##aboutweb", TR("website"), ICON_MD_PUBLIC, AT::Secondary)) util::Platform::openUrl("https://dragonx.is"); - } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_website")); - ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("report_bug", "about_bug").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + lf.next(material::ActionButtonWidth(TR("about_source"), ICON_MD_CODE)); + if (material::ActionButton("##aboutsrc", TR("about_source"), ICON_MD_CODE, AT::Secondary)) + util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon"); + lf.next(material::ActionButtonWidth(TR("report_bug"), ICON_MD_BUG_REPORT)); + if (material::ActionButton("##aboutbug", TR("report_bug"), ICON_MD_BUG_REPORT, AT::Secondary)) util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues"); - } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug")); - if (aboutGrid) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); else ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + } + const float rightBottom = ImGui::GetCursorScreenPos().y; + + // Reconcile the two columns, then a card-wide settings-actions row. + ImGui::SetCursorScreenPos(ImVec2(baseX, std::max(leftBottom, rightBottom))); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + { + using AT = material::ActionTier; + material::ButtonFlow af(contentW); + af.next(material::ActionButtonWidth(TR("save_settings"), ICON_MD_SAVE)); + if (material::ActionButton("##aboutsave", TR("save_settings"), ICON_MD_SAVE, AT::Secondary)) { saveSettingsPageState(app->settings()); Notifications::instance().success(TR("settings_saved")); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings")); - ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { - if (app->settings()) { - loadSettingsPageState(app->settings()); - Notifications::instance().info(TR("settings_reloaded")); - } + af.next(material::ActionButtonWidth(TR("reset_to_defaults"), ICON_MD_RESTORE)); + if (material::ActionButton("##aboutreset", TR("reset_to_defaults"), ICON_MD_RESTORE, AT::Tertiary)) { + if (app->settings()) { loadSettingsPageState(app->settings()); Notifications::instance().info(TR("settings_reloaded")); } } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings")); } - - ImGui::Dummy(ImVec2(0, bottomPad)); - ImGui::Unindent(pad); - - ImVec2 cardMax(cardMin.x + availWidth, ImGui::GetCursorScreenPos().y); - - // Draw the logo now that the card height is known — aspect-preserved, capped to the - // reserved width, and clamped to end just above the buttons row so it never overlaps - // the text or the buttons. Still on the content channel (1), above the glass. - if (logoTex != 0) { - float reserveW = logoReserveH * logoAspect; - // Height available above the buttons row (the fix for the logo/buttons overlap). - float logoBottomLimit = aboutButtonsTopY - Layout::spacingSm(); - float logoH = std::max(16.0f, logoBottomLimit - logoPos.y); - float logoW = logoH * logoAspect; - if (logoW > reserveW) { logoW = reserveW; logoH = (logoAspect > 0.0f) ? logoW / logoAspect : logoH; } - dl->AddImage(logoTex, logoPos, ImVec2(logoPos.x + logoW, logoPos.y + logoH)); - } - - dl->ChannelsSetCurrent(0); - DrawGlassPanel(dl, cardMin, cardMax, glassSpec); - dl->ChannelsMerge(); - - ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); - ImGui::Dummy(ImVec2(availWidth, 0)); } - ImGui::Dummy(ImVec2(0, gap)); + if (s_settingsState.current_tab == TAB_NODE) + ImGui::Dummy(ImVec2(0, gap)); // ==================================================================== // DEBUG OPTIONS — collapsible card (full-node only: holds the screenshot sweep + the dragonxd - // daemon debug= categories written to DRAGONX.conf; lite has no daemon) + // daemon debug= categories written to DRAGONX.conf; lite has no daemon). Shown on the Node tab. // ==================================================================== - if (app->supportsFullNodeLifecycleActions()) { + if (app->supportsFullNodeLifecycleActions() && s_settingsState.current_tab == TAB_NODE) { // Clickable header row ImVec2 headerPos = ImGui::GetCursorScreenPos(); const char* arrow = s_settingsState.debug_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE; @@ -2721,6 +2683,14 @@ void RenderSettingsPage(App* app) { app->seedChatDemoData(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_demo_chat")); } + // Restrict either sweep to just the active theme instead of cycling every skin. + ImGui::SameLine(); + { + bool only = app->sweepCurrentThemeOnly(); + if (ImGui::Checkbox(TR("sweep_current_theme_only"), &only)) + app->setSweepCurrentThemeOnly(only); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_sweep_current_theme_only")); + } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Separator(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index b6d53fa..8badf4d 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -538,11 +538,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei float exitRelY = curY + bottomPadding; float panelH = exitRelY + stripH; - // Vertical centering — offset so panel is centered in the child window - float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f); - if (centerOffset + panelH > contentHeight) - centerOffset = std::max(0.0f, contentHeight - panelH); - // =================================================================== // PASS 2: Render using computed positions // =================================================================== @@ -552,6 +547,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 wp = ImGui::GetWindowPos(); + // Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight) + // to the visible area (child top -> status-bar top) using window-local geometry, so this yields + // equal top/bottom gaps at any height on every platform, no viewport dependency. + float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f); + if (centerOffset + panelH > contentHeight) + centerOffset = std::max(0.0f, contentHeight - panelH); + float panelLeft = wp.x + glassMarginL; float panelRight = wp.x + sidebarWidth - glassMarginR; float panelTopY = wp.y + centerOffset; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 6dbbb16..6427233 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -1885,20 +1885,37 @@ void ResetChatTab() s_show_chat_settings = false; } -void RenderChatSettingsControls(App* app, float contentWidth) +void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards) { auto* st = app ? app->settings() : nullptr; if (!st) return; const float dp = Layout::dpiScale(); const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably) - const float rowGap = 5.0f * dp; + const float rowGap = 10.0f * dp; + + // Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the + // Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false + // and keeps its plain single-surface layout — the controls themselves are identical either way. + ImDrawList* cardDL = ImGui::GetWindowDrawList(); + const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f; + material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding(); + float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f; + if (drawCards) { + cardTopScr = ImGui::GetCursorScreenPos().y; + cardBaseXScr = ImGui::GetCursorScreenPos().x; + cardDL->ChannelsSplit(2); + cardDL->ChannelsSetCurrent(1); + ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad)); + ImGui::Indent(cardPad); + } // Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard // whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth; // the chat modal's dialog content region is correct, so it passes 0 (auto). // leftX/rowW define the current column the rows lay out in; retargeted below // to split Appearance | Messaging into two columns when the card is wide. float leftX = ImGui::GetCursorPosX(); - float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x; + float rowW = drawCards ? (contentWidth - 2.0f * cardPad) + : ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x); // Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin. auto beginRow = [&](const char* label) { @@ -1966,7 +1983,7 @@ void RenderChatSettingsControls(App* app, float contentWidth) // Two internal columns when the card is wide enough: Appearance on the left, // Messaging on the right — fills the width and roughly halves the height. // (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column. - const float chatColGap = 24.0f * dp; + const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp); const bool chatTwoCol = rowW > 760.0f * dp; const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW; const float chatBaseLeftX = leftX; @@ -2023,6 +2040,7 @@ void RenderChatSettingsControls(App* app, float contentWidth) // line-start holds the column; retarget leftX so controls right-align in it). if (chatTwoCol) { chatLeftBottomY = ImGui::GetCursorPosY(); + cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel ImGui::SetCursorPosY(chatTopY); ImGui::Indent(chatColW + chatColGap); leftX = chatBaseLeftX + chatColW + chatColGap; @@ -2056,12 +2074,41 @@ void RenderChatSettingsControls(App* app, float contentWidth) } // Close the two-column band: un-indent and drop below the taller column. + const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen if (chatTwoCol) { ImGui::Unindent(chatColW + chatColGap); const float chatRightBottomY = ImGui::GetCursorPosY(); ImGui::SetCursorPosX(chatBaseLeftX); ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY)); } + + // Paint the glass card(s) behind the content, then merge the channels. + if (drawCards) { + ImGui::Unindent(cardPad); + cardDL->ChannelsSetCurrent(0); + const float cardW = (contentWidth - Layout::cardGap()) * 0.5f; + if (chatTwoCol) { + const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch) + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr), + ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec); + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr), + ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec); + } else { + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr), + ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec); + } + cardDL->ChannelsMerge(); + const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr; + // Reserve the card footprint with a Dummy so the parent scroll region grows to include it + // (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr)); + ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad)); + + // Live conversation preview below the two cards (Settings tab only — the chat modal renders + // its own preview column beside these controls, so it passes drawCards=false and skips this). + ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd())); + RenderChatSettingsPreview(app, contentWidth); + } } } // namespace ui diff --git a/src/ui/windows/chat_tab.h b/src/ui/windows/chat_tab.h index 0e673ee..71fe390 100644 --- a/src/ui/windows/chat_tab.h +++ b/src/ui/windows/chat_tab.h @@ -34,7 +34,7 @@ void RenderChatTab(App* app); * width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto * (use the current content region, correct inside the chat modal's dialog). */ -void RenderChatSettingsControls(App* app, float contentWidth = 0.0f); +void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCards = false); /** * @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 779269e..9a7d9ff 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -460,15 +460,23 @@ void I18n::loadBuiltinEnglish() // Settings sections strings_["appearance"] = "APPEARANCE"; strings_["theme_language"] = "THEME & LANGUAGE"; + strings_["scale_effects"] = "SCALE & EFFECTS"; strings_["advanced_effects"] = "Advanced Effects..."; strings_["tools_actions"] = "Tools & Actions..."; + strings_["tools_actions_hdr"] = "TOOLS & ACTIONS"; + strings_["wallet_options_hdr"] = "OPTIONS"; + strings_["wallet_diagnostics_hdr"] = "DIAGNOSTICS"; strings_["wallet"] = "WALLET"; strings_["node_security"] = "NODE & SECURITY"; strings_["node"] = "NODE"; strings_["security"] = "SECURITY"; strings_["explorer_section"] = "EXPLORER"; + strings_["explorer_urls_hdr"] = "URLS"; strings_["about"] = "About"; strings_["backup_data"] = "BACKUP & DATA"; + strings_["backup_col_import"] = "IMPORT & RESTORE"; + strings_["backup_col_backup"] = "BACKUP"; + strings_["backup_col_export"] = "EXPORT"; strings_["balance_layout"] = "Balance Layout"; strings_["low_spec_mode"] = "Low-spec mode"; strings_["simple_background"] = "Simple background"; @@ -496,9 +504,11 @@ void I18n::loadBuiltinEnglish() strings_["screenshot_sweep"] = "Run screenshot sweep"; strings_["screenshot_sweep_full"] = "Full UI sweep"; strings_["screenshot_open_dir"] = "Open location"; + strings_["sweep_current_theme_only"] = "Current theme only"; + strings_["tt_sweep_current_theme_only"] = "Sweep only the active theme instead of cycling every theme"; strings_["screenshot_sweep_desc"] = "Cycles every theme across every tab and saves a screenshot of each into per-tab subfolders under the config directory's screenshots folder (overwriting the previous sweep). Runs for a few seconds."; strings_["mine_when_idle"] = "Mine when idle"; - strings_["setup_wizard"] = "Run Setup Wizard..."; + strings_["setup_wizard"] = "Run Setup Wizard…"; // RPC / Explorer settings strings_["rpc_connection"] = "RPC Connection..."; @@ -512,21 +522,21 @@ void I18n::loadBuiltinEnglish() strings_["fetch_prices"] = "Fetch price data from CoinGecko"; strings_["block_explorer"] = "Block Explorer"; strings_["test_connection"] = "Test Connection"; - strings_["rescan"] = "Rescan Blockchain"; + strings_["rescan"] = "Rescan"; // Settings: buttons - strings_["settings_address_book"] = "Address Book..."; - strings_["settings_validate_address"] = "Validate Address..."; - strings_["settings_request_payment"] = "Request Payment..."; - strings_["settings_shield_mining"] = "Shield Mining..."; - strings_["settings_merge_to_address"] = "Merge to Address..."; + strings_["settings_address_book"] = "Address Book…"; + strings_["settings_validate_address"] = "Validate Address…"; + strings_["settings_request_payment"] = "Request Payment…"; + strings_["settings_shield_mining"] = "Shield Mining…"; + strings_["settings_merge_to_address"] = "Merge to Address…"; strings_["settings_clear_ztx"] = "Clear Z-Tx History"; - strings_["settings_import_key"] = "Import Private Key..."; - strings_["settings_import_viewkey"] = "Import Viewing Key..."; - strings_["settings_export_key"] = "Export Key..."; - strings_["settings_export_all"] = "Export All..."; - strings_["settings_backup"] = "Backup..."; - strings_["settings_export_csv"] = "Export CSV..."; + strings_["settings_import_key"] = "Import Private Key…"; + strings_["settings_import_viewkey"] = "Import Viewing Key…"; + strings_["settings_export_key"] = "Export Key…"; + strings_["settings_export_all"] = "Export All…"; + strings_["settings_backup"] = "Backup…"; + strings_["settings_export_csv"] = "Export CSV…"; strings_["settings_encrypt_wallet"] = "Encrypt Wallet"; strings_["settings_change_passphrase"] = "Change Passphrase"; strings_["settings_lock_now"] = "Lock Now"; @@ -655,8 +665,8 @@ void I18n::loadBuiltinEnglish() strings_["wiz_pin_confirm"] = "Confirm PIN:"; strings_["wiz_pin_invalid"] = "PIN must be 4-8 digits"; strings_["wiz_pin_mismatch"] = "PINs do not match"; - strings_["settings_data_dir"] = "Data Dir:"; - strings_["settings_wallet_size_label"] = "Wallet Size:"; + strings_["settings_data_dir"] = "Data Dir"; + strings_["settings_wallet_size_label"] = "Wallet Size"; strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply"; strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf"; strings_["settings_visual_effects"] = "Visual Effects"; @@ -670,7 +680,7 @@ void I18n::loadBuiltinEnglish() strings_["settings_wallet_info"] = "Wallet Info"; strings_["settings_block_explorer_urls"] = "Block Explorer URLs"; strings_["settings_configure_explorer"] = "Configure external block explorer links"; - strings_["settings_auto_lock"] = "AUTO-LOCK"; + strings_["settings_auto_lock"] = "Auto-lock"; strings_["timeout_off"] = "Off"; strings_["timeout_1min"] = "1 min"; strings_["timeout_5min"] = "5 min"; @@ -700,9 +710,9 @@ void I18n::loadBuiltinEnglish() strings_["tt_scanline"] = "CRT scanline effect in console"; strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme"; strings_["tt_animate_avatars"] = "Play animated (GIF / WebP) contact avatars; off shows the first frame only"; - strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)"; - strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)"; - strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)"; + strings_["tt_blur"] = "Blur amount (0% = off, 100% = maximum)"; + strings_["tt_noise"] = "Grain texture intensity (0% = off, 100% = maximum)"; + strings_["tt_ui_opacity"] = "Card and sidebar opacity (100% = fully opaque, lower = more see-through)"; strings_["tt_window_opacity"] = "Background opacity (lower = desktop visible through window)"; strings_["tt_font_scale"] = "Scale all text and UI (1.0x = default, up to 1.5x). Hotkey: Alt + Scroll Wheel"; strings_["tt_custom_theme"] = "Custom theme active"; @@ -805,12 +815,14 @@ void I18n::loadBuiltinEnglish() strings_["rescan_detecting"] = "Checking which blocks your node has on disk…"; strings_["rescan_bootstrapped_msg"] = "Your node was bootstrapped, so blocks below the snapshot aren't on disk and a rescan from genesis would fail. Rescan from a height your snapshot includes to reconcile your wallet's spent balance. Your wallet.dat and chain data are not deleted."; strings_["rescan_from_height"] = "Rescan from block height:"; - strings_["repair_wallet"] = "Repair Wallet"; + strings_["repair_wallet"] = "Repair"; strings_["tt_repair_wallet"] = "Wipe and rebuild the wallet's transaction records from the blockchain (fixes notes that fail to send after a rescan)"; strings_["confirm_repair_wallet_title"] = "Repair Wallet"; strings_["confirm_repair_wallet_msg"] = "This restarts the daemon with -zapwallettxes=2: it deletes all of the wallet's transaction and note records, then rebuilds them from the blockchain. Use this when transactions fail to build (\"Invalid sapling spend proof\" / \"shielded requirements not met\") even after a full rescan. It takes a long time and the wallet stays offline until it finishes."; strings_["confirm_repair_wallet_safe"] = "Your keys, addresses and balance are preserved — only the cached transaction records are rebuilt."; strings_["daemon_binary"] = "Daemon binary"; + strings_["daemon_updates_label"] = "UPDATES"; + strings_["daemon_maintenance_label"] = "MAINTENANCE"; strings_["daemon_installed"] = "Installed"; strings_["daemon_bundled"] = "Bundled"; strings_["daemon_not_installed"] = "not installed"; @@ -818,6 +830,10 @@ void I18n::loadBuiltinEnglish() strings_["daemon_status_match"] = "Installed binary matches the bundled version."; strings_["daemon_status_differ"] = "Installed binary differs from the bundled version."; strings_["daemon_status_missing"] = "No daemon installed — install the bundled version."; + // Compact status shown right-aligned on the DAEMON BINARY heading row. + strings_["daemon_status_ok"] = "Up to date"; + strings_["daemon_status_diff"] = "Version differs"; + strings_["daemon_status_none"] = "Not installed"; strings_["daemon_install_bundled"] = "Install bundled"; strings_["tt_daemon_install_bundled"] = "Stop the node, overwrite the installed dragonxd with the version bundled in this wallet build, then restart"; strings_["confirm_reinstall_daemon_title"] = "Install Bundled Daemon"; @@ -1500,6 +1516,7 @@ void I18n::loadBuiltinEnglish() strings_["about_chain"] = "Chain:"; strings_["about_connections"] = "Connections:"; strings_["about_credits"] = "Credits"; + strings_["about_source"] = "Source"; strings_["about_daemon"] = "Daemon:"; strings_["about_debug"] = "Debug"; strings_["about_edition"] = "ImGui Edition"; -- 2.34.1 From 558cfcbe56a095f0967fa9adff1601bca3736c04 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 17:06:04 -0500 Subject: [PATCH 03/12] fix(ui): restore ObsidianDragon logo in header and About tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and returned early (added in 1752500 "themed DragonX logo"), so the app/product branding — the top-left header (app.cpp AddImage) and the About tab (getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon logo. Drop that step so logo_tex_ resolves via the intended path: active-skin override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk, then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 79320f8..b66088b 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1502,16 +1502,11 @@ void App::ensureLogoTexture() } } - // 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white) - // at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin - // PNG path below is only a fallback if rasterization ever fails. - if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent, - detailCol, &logo_tex_, &logo_w_, &logo_h_)) { - DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent); - return; - } + // The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin + // mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else + // the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded). - // 1) Fallback — theme-override logo from the active skin + // 1) theme-override logo from the active skin const auto* activeSkin = ui::schema::SkinManager::instance().findById( ui::schema::SkinManager::instance().activeSkinId()); std::string logoPath; -- 2.34.1 From a7514becbc3c3c6e46d00e1fef03de5f3f691800 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 17:17:03 -0500 Subject: [PATCH 04/12] feat(ui): credit The DragonX Developers in the About tab Add "The DragonX Developers" to the About-tab credits (after The Hush Developers), acknowledging the DragonX chain/daemon this wallet drives. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/pages/settings_page.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 952af1d..1e89001 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -2543,6 +2543,7 @@ void RenderSettingsPage(App* app) { ImGui::PushFont(body2); static const char* kCredits[] = { "The Hush Developers", + "The DragonX Developers", "ObsidianDragon Community", "Dear ImGui \xE2\x80\x94 Omar Cornut", "SDL3 \xE2\x80\x94 Sam Lantinga", -- 2.34.1 From 6d26ccd0ede7da0e8b75f1e167319114f0a16c2b Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 17:41:25 -0500 Subject: [PATCH 05/12] feat(ui): large-wallet nudge in Node & Security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BDB wallet.dat bloats with shielded-note witness data and never shrinks in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a "Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress) dialog. Full-node only (lite has no wallet.dat here); threshold is a single named constant. i18n keys fall back to English for non-English locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/pages/settings_page.cpp | 19 +++++++++++++++++++ src/util/i18n.cpp | 3 +++ 2 files changed, 22 insertions(+) diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 1e89001..c9cd3c5 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -2066,6 +2066,25 @@ void RenderSettingsPage(App* app) { else ImGui::TextDisabled("%s", wv); } + // Large-wallet nudge: the BDB wallet.dat bloats with shielded-note witness data and + // never shrinks in place. Past a threshold, hint the user toward consolidating notes + // (Merge to Address) to curb further growth. Full-node only (lite has no wallet.dat here). + static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB + if (app->supportsFullNodeLifecycleActions() && wallet_size > kWalletBloatWarnBytes) { + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::PushStyleColor(ImGuiCol_Text, Warning()); + ImGui::PushTextWrapPos(leftX + contentW); + ImGui::TextWrapped("%s", TR("wallet_size_warn")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallet_size_warn")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (material::ActionButton("##walletconsolidate", TR("wallet_size_consolidate"), + ICON_MD_CALL_MERGE, material::ActionTier::Secondary)) + ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); + } + // Row 3: folder buttons (their own row so the path gets the full width). ImGui::Dummy(ImVec2(0, Layout::spacingXs())); material::ButtonFlow ff(contentW); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 9a7d9ff..0c684fa 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -667,6 +667,9 @@ void I18n::loadBuiltinEnglish() strings_["wiz_pin_mismatch"] = "PINs do not match"; strings_["settings_data_dir"] = "Data Dir"; strings_["settings_wallet_size_label"] = "Wallet Size"; + strings_["wallet_size_warn"] = "This wallet file is large. Consolidating your notes can curb further growth."; + strings_["tt_wallet_size_warn"] = "Shielded wallets grow with each note's witness data — merging many notes into one address reduces it. Back up first."; + strings_["wallet_size_consolidate"] = "Consolidate notes\xE2\x80\xA6"; strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply"; strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf"; strings_["settings_visual_effects"] = "Visual Effects"; -- 2.34.1 From 5daf2d83b678ba745b17bb92eb226bef03857ca5 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 18:09:36 -0500 Subject: [PATCH 06/12] feat(ui): large-wallet nudge as a one-time toast + clickable alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge to Address. The persisted large_wallet_warned flag keeps it once-only and re-arms if the file later shrinks back under the threshold. - AlertRecord gains an optional onClick + actionHint; Notifications::action() pushes a toast and a clickable history entry. renderAlertHistoryPanel() now renders the accent action link (under the message) and measures true content height so wrapped messages + the link aren't clipped. - App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per launch from update(); reuses the existing wallet_size_warn/consolidate strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 33 +++++++++++++++++++++++++++++---- src/app.h | 2 ++ src/app_network.cpp | 31 +++++++++++++++++++++++++++++++ src/config/settings.cpp | 2 ++ src/config/settings.h | 5 +++++ src/ui/notifications.h | 16 ++++++++++++++-- 6 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index b66088b..ee4d877 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -878,6 +878,9 @@ void App::update() // (a prior/unwitnessed salvage likely moved the coins into a wallet..bak). maybeWarnEmptyWalletWithFundedSiblings(); + // One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate). + maybeWarnLargeWallet(); + // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // glow for a legacy, pre-seed-phrase wallet. probeWalletSeedStatus(); @@ -2421,10 +2424,19 @@ void App::renderAlertHistoryPanel() return; } - // Scrollable list, newest first. Height adapts to the entry count but caps so a busy session - // scrolls inside the panel instead of blowing past the popup's max height. - const float perEntry = txtF->LegacySize * 2.0f + 14.0f * dp; // message line + time line + spacing - const float listH = std::min(300.0f * dp, static_cast(hist.size()) * perEntry); + // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages + // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls + // inside the panel instead of blowing past the popup's max height. + const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp); + float contentH = 0.0f; + for (const auto& a : hist) { + const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y; + contentH += std::max(msgH, static_cast(icoF->LegacySize)); // icon + wrapped message + contentH += txtF->LegacySize; // relative-age line + if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line + contentH += 8.0f * dp; // inter-entry spacing + } + const float listH = std::min(300.0f * dp, contentH); ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false); int idx = 0; for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) { @@ -2452,6 +2464,19 @@ void App::renderAlertHistoryPanel() ImGui::TextWrapped("%s", a.message.c_str()); ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); + // Optional clickable action (accent link), directly under the message so it stays prominent. + if (a.onClick && !a.actionHint.empty()) { + ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); + ImGui::PushStyleColor(ImGuiCol_Text, m::Primary()); + ImGui::TextUnformatted(a.actionHint.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary()); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); } + } // Relative age, dim, indented under the message. ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled()); diff --git a/src/app.h b/src/app.h index 93f0730..dc1f0fa 100644 --- a/src/app.h +++ b/src/app.h @@ -817,6 +817,7 @@ private: // install) to back up their seed phrase. Cheap early-outs keep it idle until it can act. void maybeRemindSeedBackup(); void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once + void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02) void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02) @@ -1007,6 +1008,7 @@ private: bool seed_backup_loading_ = false; bool seed_backup_no_mnemonic_ = false; bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe + bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch // Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed // once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a diff --git a/src/app_network.cpp b/src/app_network.cpp index 64b0eca..718f064 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -35,6 +35,7 @@ #include "rpc/connection.h" #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch +#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge #include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include @@ -4232,6 +4233,36 @@ void App::maybeRemindSeedBackup() }); } +// One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never +// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow +// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the +// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold. +void App::maybeWarnLargeWallet() +{ + if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat + if (!supportsFullNodeLifecycleActions() || !settings_) return; + if (!state_.connected || !state_.encryption_state_known) return; + if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; + if (large_wallet_checked_) return; // stat wallet.dat at most once per launch + large_wallet_checked_ = true; + + static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner) + const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat"; + const uint64_t sz = util::Platform::getFileSize(walletPath); + if (sz <= kWalletBloatWarnBytes) { + // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet). + if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); } + return; + } + if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode + settings_->setLargeWalletWarned(true); + settings_->save(); + ui::Notifications::instance().action( + TR("wallet_size_warn"), ui::NotificationType::Warning, + []() { ui::ShieldDialog::show(ui::ShieldDialog::Mode::MergeToAddress); }, + TR("wallet_size_consolidate"), 12.0f); +} + // Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it // happened on a prior run, or under an external daemon whose startup output we never captured, so // detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 7374515..fd58bc2 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -232,6 +232,7 @@ bool Settings::load(const std::string& path) } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); + loadScalar(j, "large_wallet_warned", large_wallet_warned_); if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) { empty_wallet_warning_acked_.clear(); for (const auto& w : j["empty_wallet_warning_acked"]) @@ -506,6 +507,7 @@ bool Settings::save(const std::string& path) } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; + j["large_wallet_warned"] = large_wallet_warned_; j["empty_wallet_warning_acked"] = json::array(); for (const auto& w : empty_wallet_warning_acked_) j["empty_wallet_warning_acked"].push_back(w); diff --git a/src/config/settings.h b/src/config/settings.h index 2fb7129..5d12aff 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -330,6 +330,10 @@ public: bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + // One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back). + bool getLargeWalletWarned() const { return large_wallet_warned_; } + void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; } + // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds" // warning has been dismissed. Keyed per active wallet file so switching to a different empty // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings). @@ -597,6 +601,7 @@ private: std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + bool large_wallet_warned_ = false; std::set empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed bool encryption_pending_ = false; long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt diff --git a/src/ui/notifications.h b/src/ui/notifications.h index fb628f1..25e764e 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -31,6 +31,8 @@ struct AlertRecord { std::string message; NotificationType type; std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display + std::function onClick; // optional: makes this bell-panel entry actionable + std::string actionHint; // optional: accent link label rendered for the action }; struct Notification { @@ -92,15 +94,25 @@ public: if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f); push(message, NotificationType::Error, duration); } + + // An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel. + // onClick fires when the user clicks the accent `actionHint` link in that panel. + void action(const std::string& message, NotificationType type, std::function onClick, + const std::string& actionHint, float duration = -1.0f) { + if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f); + push(message, type, duration, std::move(onClick), actionHint); + } - void push(const std::string& message, NotificationType type, float duration = 5.0f) { + void push(const std::string& message, NotificationType type, float duration = 5.0f, + std::function onClick = nullptr, const std::string& actionHint = "") { notifications_.emplace_back(message, type, duration); // Retain a copy in the persistent history (the toast above will fade in seconds; this // survives so the user can review what happened). Thread note: every push is on the UI // thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock, // consistent with the rest of this class. Do NOT push from a raw worker thread. - history_.push_back(AlertRecord{message, type, static_cast(std::time(nullptr))}); + history_.push_back(AlertRecord{message, type, static_cast(std::time(nullptr)), + std::move(onClick), actionHint}); ++total_pushed_; while (history_.size() > kMaxHistory) { history_.pop_front(); -- 2.34.1 From 08cfeb0e0886215c42b96f554055b9bc0d1c7c6d Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 20 Aug 2026 22:33:59 -0500 Subject: [PATCH 07/12] feat(ui): rework the consolidate/merge modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Merge to Address actually serve wallet-bloat consolidation and be far less opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet Settings banner + alert action) frames it as "Consolidate funds" and targets shielded notes — the bloat the nudge warns about. - Source selector: consolidate shielded notes (ANY_SAPLING), transparent (ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never reduced the shielded-witness bloat. Batch limit now applies to the right side. - Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent) and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when the set exceeds one batch. - Destination auto-selects the best spendable z-address (button enabled by default); empty wallets get an inline "Create shielded address" (z_getnewaddress). - Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO Limit" jargon) with sane defaults. - Inline confirm step before the fund-moving call (amount + input count + dest). - Live progress: self-polls z_getoperationstatus to show Consolidating… → Done/Failed, replacing the raw opid + manual "Check status" button. All three merge entry points now use the typed showMerge()/showConsolidate() (no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode keeps working. New i18n keys fall back to English. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app_network.cpp | 2 +- src/ui/pages/settings_page.cpp | 4 +- src/ui/windows/shield_dialog.cpp | 678 +++++++++++++++++++------------ src/ui/windows/shield_dialog.h | 8 +- src/util/i18n.cpp | 23 ++ 5 files changed, 447 insertions(+), 268 deletions(-) diff --git a/src/app_network.cpp b/src/app_network.cpp index 718f064..17015f8 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -4259,7 +4259,7 @@ void App::maybeWarnLargeWallet() settings_->save(); ui::Notifications::instance().action( TR("wallet_size_warn"), ui::NotificationType::Warning, - []() { ui::ShieldDialog::show(ui::ShieldDialog::Mode::MergeToAddress); }, + []() { ui::ShieldDialog::showConsolidate(); }, TR("wallet_size_consolidate"), 12.0f); } diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index c9cd3c5..bee107d 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1248,7 +1248,7 @@ void RenderSettingsPage(App* app) { ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining")); if (BTN("##wmerge", TR("settings_merge_to_address"), ICON_MD_CALL_MERGE)) - ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); + ShieldDialog::showMerge(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); if (BTN("##wclear", TR("settings_clear_ztx"), ICON_MD_DELETE_SWEEP)) s_settingsState.confirm_clear_ztx = true; @@ -2081,7 +2081,7 @@ void RenderSettingsPage(App* app) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); if (material::ActionButton("##walletconsolidate", TR("wallet_size_consolidate"), ICON_MD_CALL_MERGE, material::ActionTier::Secondary)) - ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); + ShieldDialog::showConsolidate(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); } diff --git a/src/ui/windows/shield_dialog.cpp b/src/ui/windows/shield_dialog.cpp index ae9b979..f7961fc 100644 --- a/src/ui/windows/shield_dialog.cpp +++ b/src/ui/windows/shield_dialog.cpp @@ -5,6 +5,7 @@ #include "shield_dialog.h" #include "../../app.h" #include "../../config/version.h" +#include "../../data/wallet_state.h" #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" #include "../../util/i18n.h" @@ -15,39 +16,98 @@ #include #include +#include namespace dragonx { namespace ui { -// Static state -static bool s_open = false; +// ── Static dialog state ───────────────────────────────────────────────────────────────────────── +static bool s_open = false; static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase; -static char s_from_address[512] = "*"; -static char s_to_address[512] = ""; +static bool s_consolidate = false; // opened from the wallet-bloat nudge (shielded preset + framing) +static int s_src = 2; // merge source: 0 = transparent, 1 = shielded, 2 = both +static char s_from_address[512] = "*"; +static char s_to_address[512] = ""; +static int s_selected_zaddr_idx = -1; static double s_fee = DRAGONX_DEFAULT_FEE; -static int s_utxo_limit = 50; // overridden by schema at runtime -static bool s_operation_pending = false; +static int s_utxo_limit = 50; // overridden by schema at runtime +static bool s_advanced = false; // Advanced (fee + batch size) disclosure +static bool s_confirm = false; // inline "confirm before moving funds" phase +static bool s_operation_pending = false; +static bool s_op_terminal = false; // async op reached success/failed — freeze inputs static std::string s_operation_id; static std::string s_status_message; -static int s_selected_zaddr_idx = -1; +static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds) +// Scope of what can be consolidated (fetched once on open, merge mode only). +static bool s_scope_loading = false; +static bool s_scope_loaded = false; +static int s_t_count = 0, s_z_count = 0; +static double s_t_amount = 0.0, s_z_amount = 0.0; +static bool s_creating_addr = false; // z_getnewaddress in flight (empty state) + +static void resetTransient() +{ + s_operation_pending = false; + s_op_terminal = false; + s_confirm = false; + s_status_message.clear(); + s_operation_id.clear(); + s_creating_addr = false; +} + +// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a +// consolidation (and how many batches it may take). Read-only; runs off the UI thread. +static void loadScope(App* app) +{ + if (!app || !app->worker()) return; + s_scope_loading = true; s_scope_loaded = false; + app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb { + int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error; + try { + rpc::RPCClient::TraceScope trace("Shield dialog / Scope count"); + nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0})); + if (us.is_array()) for (const auto& u : us) { + if (u.value("confirmations", 0) >= 1 && u.value("spendable", true)) { ++tC; tA += u.value("amount", 0.0); } + } + nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0})); + if (zs.is_array()) for (const auto& z : zs) { + if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); } + } + } catch (const std::exception& e) { error = e.what(); } + return [tC, zC, tA, zA, error]() { + s_scope_loading = false; s_scope_loaded = error.empty(); + s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA; + // Clamp the source to what actually has inputs (unless the user is mid-op). + const bool tOk = tC > 0, zOk = zC > 0; + if (!s_operation_pending) { + if (s_consolidate && zOk) s_src = 1; // bloat nudge → shielded + else if (s_src == 0 && !tOk) s_src = zOk ? 1 : 2; + else if (s_src == 1 && !zOk) s_src = tOk ? 0 : 2; + else if (!tOk && zOk) s_src = 1; + else if (tOk && !zOk) s_src = 0; + } + }; + }); +} void ShieldDialog::show(Mode mode) { s_mode = mode; s_open = true; - s_operation_pending = false; - s_status_message.clear(); - s_operation_id.clear(); - - if (mode == Mode::ShieldCoinbase) { - strncpy(s_from_address, "*", sizeof(s_from_address)); - } else { - s_from_address[0] = '\0'; - } + s_consolidate = false; // reset preset flags so stale statics don't leak across opens + s_src = 2; + resetTransient(); + s_from_address[0] = '\0'; + if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address)); s_to_address[0] = '\0'; + s_selected_zaddr_idx = -1; s_fee = DRAGONX_DEFAULT_FEE; s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size; - s_selected_zaddr_idx = -1; + if (s_utxo_limit < 1) s_utxo_limit = 50; + s_advanced = false; + s_scope_loaded = false; s_scope_loading = false; + s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0; + s_last_poll = 0.0; } void ShieldDialog::showShieldCoinbase(const std::string& fromAddress) @@ -59,14 +119,146 @@ void ShieldDialog::showShieldCoinbase(const std::string& fromAddress) void ShieldDialog::showMerge() { show(Mode::MergeToAddress); + s_consolidate = false; + s_src = 2; // generic merge: both sources +} + +void ShieldDialog::showConsolidate() +{ + show(Mode::MergeToAddress); + s_consolidate = true; + s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat) } void ShieldDialog::hide() { s_open = false; - s_operation_pending = false; - s_status_message.clear(); - s_operation_id.clear(); + resetTransient(); +} + +// Relevant count/amount for the currently-selected merge source. +static int srcCount() { return s_src == 0 ? s_t_count : s_src == 1 ? s_z_count : (s_t_count + s_z_count); } +static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_amount : (s_t_amount + s_z_amount); } + +static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; } + +static std::string shortAddr(const std::string& a) +{ + if (a.size() <= 20) return a; + return a.substr(0, 10) + "…" + a.substr(a.size() - 8); +} + +// Auto-pick the best spendable z-address as the default destination (fewest hops for the user). +static void autoSelectDestination(const WalletState& state) +{ + if (s_to_address[0] != '\0' || state.z_addresses.empty()) return; + int idx = bestSpendableAddressIndex(state.z_addresses); + if (idx < 0) idx = 0; + s_selected_zaddr_idx = idx; + strncpy(s_to_address, state.z_addresses[idx].address.c_str(), sizeof(s_to_address) - 1); +} + +// Fire the actual shield/merge op. Registers the opid with the shared poller (for balance refresh) +// AND kicks the modal's own live-progress poll. +static void submitOperation(App* app) +{ + s_operation_pending = true; + s_op_terminal = false; + s_status_message = TR("shield_submitting"); + s_last_poll = ImGui::GetTime(); + + if (s_mode == ShieldDialog::Mode::ShieldCoinbase) { + std::string from(s_from_address), to(s_to_address); + double fee = s_fee; int limit = s_utxo_limit; + if (!app->worker()) return; + app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb { + nlohmann::json result; std::string error; + try { + rpc::RPCClient::TraceScope trace("Shield dialog / Shield coinbase"); + result = rpc->call("z_shieldcoinbase", {from, to, fee, limit}); + } catch (const std::exception& e) { error = e.what(); } + return [app, result, error]() { + if (error.empty()) { + s_operation_id = result.value("opid", ""); + s_status_message = TR("merge_progress"); + Notifications::instance().success(TR("shield_started")); + app->trackOperation(s_operation_id); + } else { + s_operation_pending = false; s_op_terminal = true; + s_status_message = std::string(TR("shield_error_prefix")) + error; + Notifications::instance().error(std::string(TR("shield_send_failed")) + error); + } + }; + }); + return; + } + + // Merge / consolidate. Source → z_mergetoaddress fromaddress selector (this is the fix: shielded + // notes, not just transparent UTXOs — the wallet-bloat the nudge warns about is shielded witnesses). + std::vector fromAddrs; + if (s_src == 0) fromAddrs = { "ANY_TADDR" }; + else if (s_src == 1) fromAddrs = { "ANY_SAPLING" }; + else fromAddrs = { "*" }; + std::string to(s_to_address); + double fee = s_fee; int limit = s_utxo_limit; + if (!app->worker()) return; + app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb { + nlohmann::json addrs = nlohmann::json::array(); + for (const auto& a : fromAddrs) addrs.push_back(a); + nlohmann::json result; std::string error; + try { + rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate"); + // fromaddrs, toaddr, fee, transparent_limit, shielded_limit — cap both to the batch size. + result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit}); + } catch (const std::exception& e) { error = e.what(); } + return [app, result, error]() { + if (error.empty()) { + s_operation_id = result.value("opid", ""); + s_status_message = TR("merge_progress"); + Notifications::instance().success(TR("merge_started")); + app->trackOperation(s_operation_id); + } else { + s_operation_pending = false; s_op_terminal = true; + s_status_message = std::string(TR("shield_error_prefix")) + error; + Notifications::instance().error(std::string(TR("merge_send_failed")) + error); + } + }; + }); +} + +// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal +// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for +// balance refresh; this drives only the inline display.) +static void pollOperation(App* app) +{ + if (s_operation_id.empty() || s_op_terminal || !app->worker()) return; + const double now = ImGui::GetTime(); + if (now - s_last_poll < 2.0) return; + s_last_poll = now; + std::string opid = s_operation_id; + app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb { + nlohmann::json result; std::string error; + try { + rpc::RPCClient::TraceScope trace("Shield dialog / Op status"); + result = rpc->call("z_getoperationstatus", {nlohmann::json::array({opid})}); + } catch (const std::exception& e) { error = e.what(); } + return [result, error]() { + if (!error.empty() || !result.is_array() || result.empty()) return; // transient — retry next tick + const auto& op = result[0]; + const std::string status = op.value("status", ""); + if (status == "success") { + s_operation_pending = false; s_op_terminal = true; + s_status_message = TR("shield_completed"); + Notifications::instance().success(TR("shield_merge_done")); + } else if (status == "failed") { + std::string msg = op.value("error", nlohmann::json{}).value("message", std::string(TR("shield_unknown_error"))); + s_operation_pending = false; s_op_terminal = true; + s_status_message = std::string(TR("shield_op_failed")) + msg; + Notifications::instance().error(std::string(TR("shield_op_failed")) + msg); + } + // queued / executing → leave the "Consolidating…" message and keep polling. + }; + }); } void ShieldDialog::render(App* app) @@ -74,263 +266,221 @@ void ShieldDialog::render(App* app) if (!s_open) return; auto& S = schema::UI(); - auto win = S.window("dialogs.shield"); - auto addrLbl = S.label("dialogs.shield", "address-label"); - auto addrFrontLbl = S.label("dialogs.shield", "address-front-label"); - auto addrBackLbl = S.label("dialogs.shield", "address-back-label"); - auto feeInput = S.input("dialogs.shield", "fee-input"); - auto utxoInput = S.input("dialogs.shield", "utxo-limit-input"); - auto shieldBtn = S.button("dialogs.shield", "shield-button"); - auto cancelBtn = S.button("dialogs.shield", "cancel-button"); + auto win = S.window("dialogs.shield"); + auto addrLbl = S.label("dialogs.shield", "address-label"); + auto addrFront = S.label("dialogs.shield", "address-front-label"); + auto addrBack = S.label("dialogs.shield", "address-back-label"); + auto feeInput = S.input("dialogs.shield", "fee-input"); + auto utxoInput = S.input("dialogs.shield", "utxo-limit-input"); + auto shieldBtn = S.button("dialogs.shield", "shield-button"); + auto cancelBtn = S.button("dialogs.shield", "cancel-button"); + const float dp = Layout::dpiScale(); + const bool isMerge = (s_mode == Mode::MergeToAddress); - const char* title = (s_mode == Mode::ShieldCoinbase) - ? TR("shield_title") - : TR("merge_title"); + const char* title = s_consolidate ? TR("consolidate_title") + : isMerge ? TR("merge_title") + : TR("shield_title"); material::OverlayDialogSpec ov; ov.title = title; ov.p_open = &s_open; ov.style = material::OverlayStyle::BlurFloat; ov.cardWidth = win.width; ov.idSuffix = "shielddialog"; - if (material::BeginOverlayDialog(ov)) { - const auto& state = app->getWalletState(); + if (!material::BeginOverlayDialog(ov)) return; - // Description - if (s_mode == Mode::ShieldCoinbase) { - ImGui::TextWrapped("%s", TR("shield_description")); - } else { - ImGui::TextWrapped("%s", TR("merge_description")); + const auto& state = app->getWalletState(); + autoSelectDestination(state); + pollOperation(app); + if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app); + + // ── Description ────────────────────────────────────────────────────────────────────────────── + ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc") + : isMerge ? TR("merge_description") + : TR("shield_description")); + ImGui::Spacing(); + + const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress + + // ── Merge: scope + source selector ────────────────────────────────────────────────────────── + if (isMerge && !opInFlight) { + if (s_scope_loading) { + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), + TR("merge_scope_loading")); + } else if (s_scope_loaded) { + char buf[160]; + std::snprintf(buf, sizeof(buf), TR("merge_scope_fmt"), + s_t_count, s_z_count, fmtAmt(s_t_amount + s_z_amount).c_str()); + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), buf); } - ImGui::Spacing(); - // From address (for shield coinbase) - if (s_mode == Mode::ShieldCoinbase) { - material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address)); - ImGui::TextDisabled("%s", TR("shield_wildcard_hint")); + // Source selector — only offer the types that actually have inputs. + const bool tOk = s_t_count > 0, zOk = s_z_count > 0; + if (tOk && zOk) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("merge_source")); + ImGui::SameLine(0, Layout::spacingLg()); + ImGui::RadioButton(TR("merge_src_shielded"), &s_src, 1); ImGui::SameLine(); + ImGui::RadioButton(TR("merge_src_transparent"), &s_src, 0); ImGui::SameLine(); + ImGui::RadioButton(TR("merge_src_both"), &s_src, 2); ImGui::Spacing(); } - - // To address (z-address dropdown) - ImGui::Text("%s", TR("shield_to_address")); - - // Get z-addresses for dropdown - std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z"); - if (to_display.length() > static_cast(addrLbl.truncate)) { - to_display = to_display.substr(0, addrFrontLbl.truncate) + "..." + to_display.substr(to_display.length() - addrBackLbl.truncate); - } - - ImGui::SetNextItemWidth(-1); - if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) { - for (size_t i = 0; i < state.z_addresses.size(); i++) { - const auto& addr = state.z_addresses[i]; - std::string label = addr.address; - if (label.length() > static_cast(addrLbl.truncate)) { - label = label.substr(0, addrFrontLbl.truncate) + "..." + label.substr(label.length() - addrBackLbl.truncate); - } - - bool selected = (s_selected_zaddr_idx == static_cast(i)); - if (ImGui::Selectable(label.c_str(), selected)) { - s_selected_zaddr_idx = static_cast(i); - strncpy(s_to_address, addr.address.c_str(), sizeof(s_to_address) - 1); - } - if (selected) { - ImGui::SetItemDefaultFocus(); - } - } - ImGui::EndCombo(); - } - if (state.z_addresses.empty()) { - material::Type().textColored(material::TypeStyle::Caption, material::Warning(), - TR("shield_no_zaddr_hint")); - } - - ImGui::Spacing(); - - // Fee + UTXO limit share one row (two columns) to tighten vertical rhythm. - float pairColX = ImGui::GetContentRegionAvail().x * 0.5f; - - // Fee (left column) - ImGui::Text("%s", TR("fee_label")); - ImGui::SetNextItemWidth(feeInput.width * Layout::dpiScale()); - ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f"); - if (s_fee < 0.0) s_fee = 0.0; // no negative fee - if (s_fee > 1.0) s_fee = 1.0; // guard a fat-fingered huge fee (mirrors utxo clamp) - ImGui::SameLine(); - ImGui::TextDisabled("DRGX"); - - // UTXO limit (right column) — hint drops under the input (rather than beside it) since - // "Max UTXOs per operation" is too long to share the narrower half-width column with "DRGX". - ImGui::SameLine(pairColX); - ImGui::BeginGroup(); - ImGui::Text("%s", TR("shield_utxo_limit")); - ImGui::SetNextItemWidth(utxoInput.width * Layout::dpiScale()); - ImGui::InputInt("##Limit", &s_utxo_limit); - if (s_utxo_limit < 1) s_utxo_limit = 1; - if (s_utxo_limit > 100) s_utxo_limit = 100; - material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), - TR("shield_max_utxos")); - ImGui::EndGroup(); - - ImGui::Spacing(); - - // Status message - if (!s_status_message.empty()) { - if (s_operation_pending) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str()); - } else { - ImGui::TextWrapped("%s", s_status_message.c_str()); - } - ImGui::Spacing(); - } - - // Buttons — guard on connection/sync like the Send tab (a disconnected or mid-sync submit just - // fails at the daemon with a raw error). - bool sh_connected = app->isConnected(); - bool sh_syncing = state.sync.syncing; - bool can_submit = !s_operation_pending && s_to_address[0] != '\0' && sh_connected && !sh_syncing; - - // Center the primary + Cancel action row via the shared footer helper. We can't use - // DialogActionFooter here because the primary button carries a disabled-hover tooltip that must - // fire on ITS item (the helper draws primary+Close internally, leaving no hook between them), so - // we keep the two TactileButtons + the interleaved tooltip and only standardize the placement. - const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds"); - float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x; - material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false); - - if (!can_submit) ImGui::BeginDisabled(); - - if (material::TactileButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) { - s_operation_pending = true; - s_status_message = TR("shield_submitting"); - - if (s_mode == Mode::ShieldCoinbase) { - std::string from(s_from_address), to(s_to_address); - double fee = s_fee; - int limit = s_utxo_limit; - if (app->worker()) { - app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb { - nlohmann::json result; - std::string error; - try { - rpc::RPCClient::TraceScope trace("Send tab / Shield coinbase"); - result = rpc->call("z_shieldcoinbase", {from, to, fee, limit}); - } catch (const std::exception& e) { - error = e.what(); - } - return [app, result, error]() { - s_operation_pending = false; - if (error.empty()) { - s_operation_id = result.value("opid", ""); - s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id; - Notifications::instance().success(TR("shield_started")); - // Register with the shared poller so an async failure is - // surfaced (and balances refresh) even after this dialog closes. - app->trackOperation(s_operation_id); - } else { - s_status_message = std::string(TR("shield_error_prefix")) + error; - Notifications::instance().error(std::string(TR("shield_send_failed")) + error); - } - }; - }); - } - } else { - std::vector fromAddrs; - fromAddrs.push_back("ANY_TADDR"); - std::string to(s_to_address); - double fee = s_fee; - int limit = s_utxo_limit; - if (app->worker()) { - app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb { - nlohmann::json addrs = nlohmann::json::array(); - for (const auto& addr : fromAddrs) addrs.push_back(addr); - nlohmann::json result; - std::string error; - try { - rpc::RPCClient::TraceScope trace("Send tab / Merge funds"); - result = rpc->call("z_mergetoaddress", {addrs, to, fee, 0, limit}); - } catch (const std::exception& e) { - error = e.what(); - } - return [app, result, error]() { - s_operation_pending = false; - if (error.empty()) { - s_operation_id = result.value("opid", ""); - s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id; - Notifications::instance().success(TR("merge_started")); - // Register with the shared poller so an async failure is - // surfaced (and balances refresh) even after this dialog closes. - app->trackOperation(s_operation_id); - } else { - s_status_message = std::string(TR("shield_error_prefix")) + error; - Notifications::instance().error(std::string(TR("merge_send_failed")) + error); - } - }; - }); - } - } - } - - if (!can_submit) ImGui::EndDisabled(); - if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { - if (!sh_connected) material::Tooltip("%s", TR("send_tooltip_not_connected")); - else if (sh_syncing) material::Tooltip("%s", TR("send_tooltip_syncing")); - else if (s_to_address[0]=='\0') material::Tooltip("%s", TR("shield_select_z")); - } - - ImGui::SameLine(); - - if (material::TactileButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) { - s_open = false; - } - - // Show operation status if we have an opid - if (!s_operation_id.empty()) { - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - ImGui::Text(TR("shield_operation_id"), s_operation_id.c_str()); - - if (material::TactileButton(TR("shield_check_status"), ImVec2(0,0), S.resolveFont(shieldBtn.font))) { - std::string opid = s_operation_id; - if (app->worker()) { - app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb { - nlohmann::json result; - std::string error; - try { - rpc::RPCClient::TraceScope trace("Send tab / Shield operation status"); - nlohmann::json ids = nlohmann::json::array(); - ids.push_back(opid); - result = rpc->call("z_getoperationstatus", {ids}); - } catch (const std::exception& e) { - error = e.what(); - } - return [result, error]() { - if (error.empty() && result.is_array() && !result.empty()) { - auto& op = result[0]; - std::string status = op.value("status", "unknown"); - if (status == "success") { - s_status_message = TR("shield_completed"); - Notifications::instance().success(TR("shield_merge_done")); - } else if (status == "failed") { - std::string errMsg = op.value("error", nlohmann::json{}).value("message", TR("shield_unknown_error")); - s_status_message = std::string(TR("shield_op_failed")) + errMsg; - Notifications::instance().error(std::string(TR("shield_op_failed")) + errMsg); - } else if (status == "executing") { - s_status_message = TR("shield_in_progress"); - } else { - s_status_message = std::string(TR("shield_status_label")) + status; - } - } else if (!error.empty()) { - s_status_message = std::string(TR("shield_status_check_error")) + error; - } - }; - }); - } - } - } - material::EndOverlayDialog(); } + + // ── Shield coinbase: from address ─────────────────────────────────────────────────────────── + if (!isMerge && !opInFlight) { + material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address)); + ImGui::TextDisabled("%s", TR("shield_wildcard_hint")); + ImGui::Spacing(); + } + + // ── Destination (z-address) ───────────────────────────────────────────────────────────────── + if (!opInFlight) { + ImGui::TextUnformatted(TR("shield_to_address")); + if (state.z_addresses.empty()) { + material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint")); + ImGui::Spacing(); + if (s_creating_addr) { + ImGui::TextDisabled("%s", TR("merge_creating")); + } else if (material::TactileButton(TR("merge_create_zaddr"), ImVec2(0, 0), S.resolveFont(shieldBtn.font))) { + s_creating_addr = true; + if (app->worker()) app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb { + std::string addr, error; + try { + rpc::RPCClient::TraceScope trace("Shield dialog / New z-address"); + addr = rpc->call("z_getnewaddress", nlohmann::json::array()).get(); + } catch (const std::exception& e) { error = e.what(); } + return [app, addr, error]() { + s_creating_addr = false; + if (error.empty() && !addr.empty()) { + strncpy(s_to_address, addr.c_str(), sizeof(s_to_address) - 1); + Notifications::instance().success(TR("merge_addr_created")); + } else { + Notifications::instance().error(std::string(TR("shield_error_prefix")) + error); + } + }; + }); + } + } else { + std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z"); + if (to_display.length() > static_cast(addrLbl.truncate)) + to_display = to_display.substr(0, addrFront.truncate) + "..." + to_display.substr(to_display.length() - addrBack.truncate); + ImGui::SetNextItemWidth(-1); + if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) { + for (size_t i = 0; i < state.z_addresses.size(); i++) { + std::string label = state.z_addresses[i].address; + if (label.length() > static_cast(addrLbl.truncate)) + label = label.substr(0, addrFront.truncate) + "..." + label.substr(label.length() - addrBack.truncate); + bool selected = (s_selected_zaddr_idx == static_cast(i)); + if (ImGui::Selectable(label.c_str(), selected)) { + s_selected_zaddr_idx = static_cast(i); + strncpy(s_to_address, state.z_addresses[i].address.c_str(), sizeof(s_to_address) - 1); + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + ImGui::Spacing(); + + // ── Advanced (fee + batch size) ───────────────────────────────────────────────────────── + ImDrawList* dl = ImGui::GetWindowDrawList(); + material::CollapsibleHeader(dl, "##AdvToggle", TR("merge_advanced"), s_advanced, + ImGui::GetContentRegionAvail().x, material::Type().caption(), + material::OnSurfaceMedium()); + if (s_advanced) { + ImGui::Spacing(); + ImGui::TextUnformatted(TR("fee_label")); + ImGui::SetNextItemWidth(feeInput.width * dp); + ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f"); + if (s_fee < 0.0) s_fee = 0.0; + if (s_fee > 1.0) s_fee = 1.0; + ImGui::SameLine(); ImGui::TextDisabled("DRGX"); + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("merge_fee_hint")); + + ImGui::Spacing(); + ImGui::TextUnformatted(TR("merge_max_inputs")); + ImGui::SetNextItemWidth(utxoInput.width * dp); + ImGui::InputInt("##Limit", &s_utxo_limit); + if (s_utxo_limit < 1) s_utxo_limit = 1; + if (s_utxo_limit > 100) s_utxo_limit = 100; + } + + // Batch hint: one run only merges up to the limit; large sets need repeats. + if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) { + char hb[160]; + std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit); + ImGui::Spacing(); + material::Type().textColored(material::TypeStyle::Caption, material::Warning(), hb); + } + ImGui::Spacing(); + } + + // ── Live progress / status ────────────────────────────────────────────────────────────────── + if (!s_status_message.empty()) { + if (s_operation_pending && !s_op_terminal) + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str()); + else + ImGui::TextWrapped("%s", s_status_message.c_str()); + ImGui::Spacing(); + } + + // ── Footer ────────────────────────────────────────────────────────────────────────────────── + const bool connected = app->isConnected(); + const bool syncing = state.sync.syncing; + const bool haveDest = s_to_address[0] != '\0'; + + if (opInFlight) { + // After submit: just a Close button (progress shows above; op continues in the background). + material::BeginOverlayDialogFooter(cancelBtn.width, /*drawSeparator=*/false); + if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"), + ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) + s_open = false; + material::EndOverlayDialog(); + return; + } + + const char* primaryLabel = s_confirm ? TR("merge_confirm_btn") + : s_consolidate ? TR("consolidate_funds_btn") + : isMerge ? TR("merge_funds") + : TR("shield_funds"); + const char* secondaryLabel = s_confirm ? TR("merge_back") : TR("cancel"); + + // Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input + // count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here). + if (s_confirm && isMerge) { + char cb[200]; + std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"), + fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str()); + ImGui::TextWrapped("%s", cb); + ImGui::Spacing(); + } + + bool can_submit = haveDest && connected && !syncing; + if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false; + + float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x; + material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false); + + if (!can_submit) ImGui::BeginDisabled(); + if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) { + if (s_confirm) { submitOperation(app); } + else { s_confirm = true; } // first click → show the confirm summary + } + if (!can_submit) ImGui::EndDisabled(); + if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected")); + else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing")); + else if (!haveDest) material::Tooltip("%s", TR("shield_select_z")); + else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable")); + } + + ImGui::SameLine(); + if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) { + if (s_confirm) s_confirm = false; // Back → return to the form + else s_open = false; // Cancel → close + } + + material::EndOverlayDialog(); } } // namespace ui diff --git a/src/ui/windows/shield_dialog.h b/src/ui/windows/shield_dialog.h index 296c32c..e37cd86 100644 --- a/src/ui/windows/shield_dialog.h +++ b/src/ui/windows/shield_dialog.h @@ -33,10 +33,16 @@ public: static void showShieldCoinbase(const std::string& fromAddress = "*"); /** - * @brief Show merge to address dialog + * @brief Show merge to address dialog (generic — both transparent + shielded sources) */ static void showMerge(); + /** + * @brief Show the consolidate-funds flow preset for wallet-bloat reduction (shielded notes). + * Used by the large-wallet nudges (Settings banner + alert action). + */ + static void showConsolidate(); + /** * @brief Render the dialog (call each frame) */ diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 0c684fa..3230589 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -2241,6 +2241,29 @@ void I18n::loadBuiltinEnglish() strings_["merge_funds"] = "Merge Funds"; strings_["merge_started"] = "Merge operation started"; strings_["merge_title"] = "Merge to Address"; + // Consolidate-funds flow (rich merge modal + wallet-bloat preset). + strings_["consolidate_title"] = "Consolidate funds"; + strings_["consolidate_desc"] = "Combine many small inputs into a single shielded note. Fewer notes means a smaller wallet file and better privacy."; + strings_["consolidate_funds_btn"] = "Consolidate"; + strings_["merge_scope_loading"] = "Checking your inputs\xE2\x80\xA6"; + strings_["merge_scope_fmt"] = "%d transparent + %d shielded inputs \xC2\xB7 ~%s DRGX spendable"; + strings_["merge_source"] = "Consolidate"; + strings_["merge_src_transparent"] = "Transparent"; + strings_["merge_src_shielded"] = "Shielded"; + strings_["merge_src_both"] = "Both"; + strings_["merge_batch_fmt"] = "Merges up to %d inputs per run \xE2\x80\x94 repeat to finish the rest."; + strings_["merge_advanced"] = "Advanced"; + strings_["merge_max_inputs"] = "Max inputs per batch"; + strings_["merge_fee_hint"] = "Network fee for this transaction."; + strings_["merge_create_zaddr"] = "Create shielded address"; + strings_["merge_creating"] = "Creating address\xE2\x80\xA6"; + strings_["merge_addr_created"] = "Shielded address created."; + strings_["merge_confirm_fmt"] = "Consolidate ~%s DRGX from %d input(s) into %s?"; + strings_["merge_confirm_btn"] = "Confirm"; + strings_["merge_back"] = "Back"; + strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window."; + strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet."; + strings_["done"] = "Done"; // --- Transaction Details Dialog --- strings_["tx_confirmations"] = "%d confirmations"; -- 2.34.1 From 870793433be713553139746b04bf3ea6d815517d Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 22:45:51 -0500 Subject: [PATCH 08/12] fix(sync): stop large-wallet balance polling from starving block connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was held almost continuously, starving the single block-connection thread: the node connected blocks only in the gaps between polls and could fall further behind the tip than it caught up (observed live: gap growing 58→100 blocks while the GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download). Two hardening changes on top of the existing "skip balance while syncing" guard: - Hysteresis: keep the low-impact sync profile (and balance suppression) for a short settle window after catching up, so a large-wallet scan can't immediately re-starve connection and bounce the node back into syncing. Armed only on the syncing→caught-up edge, so a wallet synced from the start is never throttled at connect (effectivelySyncing()). - Adaptive balance cadence: time each z_gettotalbalance scan and require the next poll to wait at least (cost / 10%), so balance scanning never occupies more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations (send/shield) force the next poll through so the user's own action updates the balance immediately (balanceRefreshDue()). getblockchaininfo keeps its normal cadence throughout, so sync progress stays live. Build + test_phase4 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 4 +- src/app.h | 10 +++++ src/app_network.cpp | 56 +++++++++++++++++++++--- src/services/network_refresh_service.cpp | 12 ++++- src/services/network_refresh_service.h | 1 + 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index ee4d877..9828be1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -919,7 +919,9 @@ void App::update() // Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to // a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy). - if (state_.sync.syncing != refresh_policy_syncing_) { + // effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal + // per-tab cadence once that window elapses. + if (effectivelySyncing() != refresh_policy_syncing_) { applyRefreshPolicy(current_page_); } diff --git a/src/app.h b/src/app.h index dc1f0fa..93cc22a 100644 --- a/src/app.h +++ b/src/app.h @@ -1102,6 +1102,14 @@ private: bool daemon_start_error_shown_ = false; int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active + // Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is + // O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded + // wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll + // off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue(). + bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge + std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling) + double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan + bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle // Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept. std::uint64_t clipboard_secret_hash_ = 0; double clipboard_clear_deadline_ = 0.0; @@ -1439,6 +1447,8 @@ private: void refreshPrice(); void refreshWalletEncryptionState(); void applyRefreshPolicy(ui::NavPage page); + bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis) + bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost? bool currentPageNeedsWalletDataRefresh() const; bool shouldRunWalletTransactionRefresh() const; bool shouldRefreshTransactions() const; diff --git a/src/app_network.cpp b/src/app_network.cpp index 17015f8..b05ca64 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -837,13 +837,39 @@ void App::applyRefreshPolicy(ui::NavPage page) // While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so // the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block // transaction scans / balance polls slow block connection). This makes every tab sync as fast - // as the Console tab does today. Reverts to the per-tab profile once sync finishes. - refresh_policy_syncing_ = state_.sync.syncing; + // as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching + // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the + // node back into "syncing". Reverts to the per-tab profile once the settle window passes. + refresh_policy_syncing_ = effectivelySyncing(); network_refresh_.setIntervals(refresh_policy_syncing_ ? services::RefreshScheduler::kSyncProfile : getIntervalsForPage(page)); } +// True while the node is behind, and for a short settle window after it first catches up. The settle +// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that +// was synced from the start is never throttled at connect — only a node that just finished catching up. +bool App::effectivelySyncing() const +{ + if (state_.sync.syncing) return true; + if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up + return std::time(nullptr) < sync_settle_until_; +} + +// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since +// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A +// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s +// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for +// block connection. A wallet mutation bypasses this via force_balance_refresh_. +bool App::balanceRefreshDue() const +{ + constexpr double kBalanceDutyCycle = 0.10; + if (state_.last_balance_update == 0) return true; // never fetched + if (last_balance_scan_ms_ <= 0.0) return true; // no cost measured yet + const double minInterval = (last_balance_scan_ms_ / 1000.0) / kBalanceDutyCycle; + return std::difftime(std::time(nullptr), state_.last_balance_update) >= minInterval; +} + bool App::currentPageNeedsWalletDataRefresh() const { using NP = ui::NavPage; @@ -1666,9 +1692,15 @@ void App::refreshCoreData() ? fast_rpc_.get() : rpc_.get(); if (!w || !rpc) return; ui::NavPage tracePage = current_page_; - // Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock + - // cs_main). Captured on the main thread to avoid reading state_ off the worker thread. - const bool includeBalance = !state_.sync.syncing; + // Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main). + // Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block + // connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a + // large shielded wallet backs off automatically instead of re-scanning every couple of seconds. + // A wallet mutation (send/shield) forces the next poll through so the user's own action updates the + // balance immediately. Captured on the main thread to avoid reading state_ off the worker thread. + const bool includeBalance = !effectivelySyncing() && + (force_balance_refresh_ || balanceRefreshDue()); + if (includeBalance) force_balance_refresh_ = false; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh")); @@ -1678,6 +1710,19 @@ void App::refreshCoreData() NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); applyPendingSendBalanceDeltas(true); + // Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's + // cost (0 when balance was skipped), and arm the settle window only on the + // syncing→caught-up edge so a wallet synced from the start is never throttled at connect. + if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs; + const bool nowSyncing = state_.sync.syncing; + if (nowSyncing) { + sync_settle_until_ = 0; + } else if (was_core_syncing_) { + constexpr double kSyncSettleSeconds = 8.0; + sync_settle_until_ = std::time(nullptr) + static_cast(kSyncSettleSeconds); + } + was_core_syncing_ = nowSyncing; + // Mid-session connection-loss detection. During normal operation, both core // RPCs failing together means the daemon connection is dead (a busy daemon // fails them individually, not both at once). Warmup is excluded — both fail @@ -5300,6 +5345,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double // Force transaction list refresh so the sent tx appears immediately transactions_dirty_ = true; last_tx_block_height_ = -1; + force_balance_refresh_ = true; // the user's own send must update the balance now, past the throttle network_refresh_.markWalletMutationRefresh(); // z_sendmany only returned an opid: the transaction is built/signed/ // broadcast asynchronously by the daemon. Defer the user-facing diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index 39755e5..33d331f 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -294,8 +295,13 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre json blockInfo; bool balanceOk = false; bool blockOk = false; + double balanceScanMs = 0.0; if (includeBalance) { + // z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration — + // seconds on a large shielded wallet. Time it so the caller can throttle how often it polls + // (balanceRefreshDue()), keeping balance scans from starving block connection. + const auto balanceStart = std::chrono::steady_clock::now(); try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater totalBalance = rpc.call("z_gettotalbalance", json::array({0})); balanceOk = true; @@ -305,6 +311,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply. spendableBalance = rpc.call("z_gettotalbalance", json::array({1})); } catch (...) {} + balanceScanMs = std::chrono::duration( + std::chrono::steady_clock::now() - balanceStart).count(); } try { @@ -314,7 +322,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); } - return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk); + auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk); + result.balanceScanMs = balanceScanMs; + return result; } NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult( diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index 26f2215..514a345 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -111,6 +111,7 @@ public: std::optional verificationProgress; std::optional longestChain; std::optional notarized; + double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped) }; struct MiningRefreshResult { -- 2.34.1 From 29274c2f489d7876492e029666eb6b94f9d35f20 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 23:09:17 -0500 Subject: [PATCH 09/12] fix(ui): trim verbose startup notice; show daemon output on shutdown for external daemons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Loading "taking longer than expected" notice: shorten the body + hint so the startup screen reads less wordy (same info, ~half the text). - Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output" panel was always empty, leaving just a spinner. Fall back to tailing the daemon's debug.log so the user can watch the node flush the block index and exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 37 +++++++++++++++++++++++++++++++++++++ src/app.h | 7 +++++++ src/util/i18n.cpp | 4 ++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 9828be1..304d60b 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -5742,6 +5742,39 @@ void App::beginShutdown() }); } +std::vector App::tailDaemonDebugLog(int maxLines) const +{ + std::vector out; + if (maxLines <= 0) return out; + const std::string path = util::Platform::getDataDir() + "debug.log"; + std::error_code ec; + const auto sz = std::filesystem::file_size(path, ec); + if (ec || sz == 0) return out; + std::ifstream f(path, std::ios::binary); + if (!f) return out; + + // Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log. + const std::uintmax_t kTailBytes = 16 * 1024; + const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0; + f.seekg(static_cast(start), std::ios::beg); + std::string chunk(static_cast(sz - start), '\0'); + f.read(&chunk[0], static_cast(chunk.size())); + chunk.resize(static_cast(f.gcount())); + + std::vector lines; + std::string cur; + for (char c : chunk) { + if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); } + else if (c != '\r') cur.push_back(c); + } + if (!cur.empty()) lines.push_back(cur); + // When we seeked into the middle of the file the first line is a fragment — drop it. + if (start > 0 && !lines.empty()) lines.erase(lines.begin()); + if (static_cast(lines.size()) > maxLines) + lines.erase(lines.begin(), lines.end() - static_cast(maxLines)); + return lines; +} + void App::renderShutdownScreen() { using namespace ui::material; @@ -5974,6 +6007,10 @@ void App::renderShutdownScreen() // ------------------------------------------------------------------- if (daemon_controller_) { auto lines = daemon_controller_->recentLines(8); + // External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly + // so the user can still watch the node flush the block index and exit. + if (lines.empty()) + lines = tailDaemonDebugLog(8); if (!lines.empty()) { float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f); float panelX = cx - panelW * 0.5f; diff --git a/src/app.h b/src/app.h index 93cc22a..ba1f41c 100644 --- a/src/app.h +++ b/src/app.h @@ -175,6 +175,13 @@ public: */ void renderShutdownScreen(); + /** + * @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail). + * Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we + * attached to rather than spawned — so the user can still see the node flushing/exiting. + */ + std::vector tailDaemonDebugLog(int maxLines) const; + /** * @brief Render loading overlay in content area while daemon is starting/syncing * @param contentH Height of the content area child window diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 3230589..d1c9351 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1456,8 +1456,8 @@ void I18n::loadBuiltinEnglish() strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions."; strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions."; strings_["loading_stall_title"] = "Taking longer than expected"; - strings_["loading_stall_body"] = "The daemon has been initializing for %.0fs. This can be normal after an update or on first launch (loading the block index or rescanning) — it will connect automatically once ready."; - strings_["loading_stall_hint"] = "Still stuck? Open Settings and use Restart Daemon, or check the Console for details."; + strings_["loading_stall_body"] = "Initializing for %.0fs — normal after an update or first launch. Connects automatically when ready."; + strings_["loading_stall_hint"] = "Stuck? Settings → Restart Daemon, or check the Console."; strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1."; strings_["rpc_plaintext_remote_warning"] = "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS."; strings_["settings_open_log_folder"] = "Open log folder"; -- 2.34.1 From 7e8b99a82bbe8428079d037f7c50a7e8c30e77a9 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 23:32:03 -0500 Subject: [PATCH 10/12] feat(shutdown): confirm before stopping the daemon mid witness-cache rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping dragonxd while it's rebuilding the Sapling witness cache discards the in-progress work — BuildWitnessCache aborts on shutdown without persisting — so the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang). This bites especially with stop_external_daemon enabled, where wallet exit sends the node a stop. beginShutdown() now defers when it would StopDaemon while a rebuild is active and shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness / Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines). The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/app.h | 13 +++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/app.cpp b/src/app.cpp index 304d60b..4d25ca5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2224,6 +2224,7 @@ void App::render() renderDecryptWalletDialog(); renderPinDialogs(); renderSwitchStopDaemonDialog(); + renderDaemonStopConfirm(); renderBlockDbReindexDialog(); renderWalletRecoveredDialog(); renderEmptyWalletWarningDialog(); @@ -5652,6 +5653,16 @@ void App::beginShutdown() { // Only start shutdown once if (shutting_down_) return; + + // Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the + // daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch), + // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown() + // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_). + if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) { + pending_shutdown_confirm_ = true; + return; // NOT shutting down yet — the normal UI + modal keep rendering + } + shutting_down_ = true; quit_requested_ = true; shutdown_timer_ = 0.0f; @@ -5712,7 +5723,7 @@ void App::beginShutdown() } auto shutdownDecision = daemon_controller_->shutdownDecision( - settings_ && settings_->getKeepDaemonRunning(), + (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_, settings_ && settings_->getStopExternalDaemon()); if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); @@ -5775,6 +5786,100 @@ std::vector App::tailDaemonDebugLog(int maxLines) const return lines; } +bool App::daemonWitnessRebuildActive() const +{ + // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness + // data from" (start), "Setting Initial Sapling Witness" / "Reading blocks for witness rebuild" + // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished). Active iff the most + // recent relevant line is a start/progress line, not a completion. + const auto lines = tailDaemonDebugLog(80); + int state = 0; // 0 none, 1 active, 2 finished/aborted + for (const auto& l : lines) { + if (l.find("note witness cache(s) to height") != std::string::npos || + l.find("aborting witness rebuild") != std::string::npos || + l.find("aborted during witness rebuild") != std::string::npos) { + state = 2; + } else if (l.find("Reading blocks for witness rebuild") != std::string::npos || + l.find("Setting Initial Sapling Witness") != std::string::npos || + l.find("Cleared witness data from") != std::string::npos) { + state = 1; + } + } + return state == 1; +} + +bool App::shouldConfirmDaemonStop() const +{ + if (!daemon_controller_) return false; + // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with + // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing. + const auto decision = daemon_controller_->shutdownDecision( + settings_ && settings_->getKeepDaemonRunning(), + settings_ && settings_->getStopExternalDaemon()); + if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false; + return daemonWitnessRebuildActive(); +} + +void App::renderDaemonStopConfirm() +{ + using namespace ui::material; + if (pending_shutdown_confirm_) { + ImGui::OpenPopup("##DaemonStopConfirm"); + pending_shutdown_confirm_ = false; + daemon_stop_confirm_open_ = true; + } + if (!daemon_stop_confirm_open_) return; + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + bool proceed = false; + if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1()); + ImGui::TextUnformatted("Node is rebuilding its witness cache"); + if (Type().subtitle1()) ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f); + ImGui::TextUnformatted( + "Stopping the node now discards the in-progress rebuild and restarts it (several minutes) " + "the next time you open the wallet. You can keep the node running instead."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + ImGui::Spacing(); + + if (TactileButton("Keep node running & quit", ImVec2(0, 0))) { + shutdown_keep_daemon_override_ = true; + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160))); + const bool stopAnyway = TactileButton("Stop anyway & quit", ImVec2(0, 0)); + ImGui::PopStyleColor(3); + if (stopAnyway) { + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (TactileButton("Cancel", ImVec2(0, 0))) { + daemon_stop_confirm_open_ = false; // abort the quit; stay open + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } else { + daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel + } + + // Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set). + if (proceed) beginShutdown(); +} + void App::renderShutdownScreen() { using namespace ui::material; diff --git a/src/app.h b/src/app.h index ba1f41c..0bcd80d 100644 --- a/src/app.h +++ b/src/app.h @@ -182,6 +182,14 @@ public: */ std::vector tailDaemonDebugLog(int maxLines) const; + // True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort + // heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch. + bool daemonWitnessRebuildActive() const; + // Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress). + bool shouldConfirmDaemonStop() const; + // The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render(). + void renderDaemonStopConfirm(); + /** * @brief Render loading overlay in content area while daemon is starting/syncing * @param contentH Height of the content area child window @@ -911,6 +919,11 @@ private: bool address_list_dirty_ = false; // P8: dedup rebuildAddressList GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05) std::thread shutdown_thread_; + // Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm) + bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal + bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing + bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry + bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only float shutdown_timer_ = 0.0f; bool force_quit_confirm_ = false; std::chrono::steady_clock::time_point shutdown_start_time_; -- 2.34.1 From a2f84be2d4f81f49b12b28f5a0f8479f5499a05b Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 23:43:12 -0500 Subject: [PATCH 11/12] fix(win): stop console-window flash on launch (spawn daemon with CREATE_NO_WINDOW) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE allocates a console window that flashes on screen before SW_HIDE hides it — visible as a console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher); dragonxd logs to debug.log, not a console, so nothing is lost. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon/embedded_daemon.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index e4487eb..e922cbd 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -689,7 +689,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec debug_log_path_.c_str(), debug_log_offset_); } - // Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE). + // Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE + // allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is + // visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no + // window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console. // The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX) // — it must be in /dragonx/ to avoid conflicts with lock files and data. STARTUPINFOA si; @@ -699,7 +702,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE; ZeroMemory(&pi, sizeof(pi)); - + char* cmd_line = _strdup(cmd.c_str()); BOOL success = CreateProcessA( NULL, @@ -707,7 +710,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec NULL, NULL, FALSE, - CREATE_NEW_CONSOLE, + CREATE_NO_WINDOW, NULL, work_dir.c_str(), &si, -- 2.34.1 From 0942691eb370400a018ba0b4c6c1b996ab262de6 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 30 Aug 2026 23:58:54 -0500 Subject: [PATCH 12/12] fix(win): route shell-outs through a windowless helper (no cmd.exe flash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on Windows, popen on POSIX — and route the remaining shell-outs through it: - GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu") - xmrig discovery + version (findXmrigBinary "where xmrig.exe"; " --version", stderr merged) - wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app_network.cpp | 21 +++------ src/daemon/xmrig_manager.cpp | 55 ++++++----------------- src/util/platform.cpp | 87 +++++++++++++++++++++++++++++------- src/util/platform.h | 9 ++++ 4 files changed, 101 insertions(+), 71 deletions(-) diff --git a/src/app_network.cpp b/src/app_network.cpp index b05ca64..8641598 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -5043,21 +5043,12 @@ void App::rebuildWalletDatabase() const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp"; { std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh - // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. - std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; -#ifdef _WIN32 - cmd = "\"" + cmd + "\""; // cmd.exe strips the outermost quotes - FILE* fp = _popen(cmd.c_str(), "r"); -#else - FILE* fp = popen(cmd.c_str(), "r"); -#endif - std::string jout; - if (fp) { char b[512]; while (std::fgets(b, sizeof b, fp)) jout += b; } -#ifdef _WIN32 - const int rc = fp ? _pclose(fp) : -1; -#else - const int rc = fp ? pclose(fp) : -1; -#endif + // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless + // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the + // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed. + const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; + int rc = -1; + const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc); DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str()); // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys. diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp index 4d76c21..776896b 100644 --- a/src/daemon/xmrig_manager.cpp +++ b/src/daemon/xmrig_manager.cpp @@ -23,6 +23,7 @@ #include #include "../util/logger.h" +#include "../util/platform.h" #include "../util/pool_registry.h" #ifdef _WIN32 @@ -145,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() { return path; } - // Fallback: system PATH + // Fallback: system PATH — windowless so it never flashes a console. #ifdef _WIN32 - FILE* f = _popen("where xmrig.exe 2>nul", "r"); + std::string out = util::Platform::runHiddenCapture("where xmrig.exe"); #else - FILE* f = popen("which xmrig 2>/dev/null", "r"); -#endif - if (f) { - char line[512]; - if (fgets(line, sizeof(line), f)) { - std::string s(line); - while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) - s.pop_back(); - if (!s.empty() && fs::exists(s)) { -#ifdef _WIN32 - _pclose(f); -#else - pclose(f); -#endif - return s; - } - } -#ifdef _WIN32 - _pclose(f); -#else - pclose(f); + std::string out = util::Platform::runHiddenCapture("which xmrig"); #endif + { + std::string s = out; + const auto nl = s.find_first_of("\r\n"); // first line only + if (nl != std::string::npos) s.erase(nl); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back(); + if (!s.empty() && fs::exists(s)) return s; } return {}; @@ -927,24 +914,10 @@ void XmrigManager::startVersionDetection() const bool binShellSafe = !bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos; if (binShellSafe) { - const std::string cmd = "\"" + bin + "\" --version 2>&1"; -#ifdef _WIN32 - FILE* fp = _popen(cmd.c_str(), "r"); -#else - FILE* fp = popen(cmd.c_str(), "r"); -#endif - if (fp) { - std::string out; - char buf[256]; - size_t n; - while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n); -#ifdef _WIN32 - _pclose(fp); -#else - pclose(fp); -#endif - ver = parseMinerVersion(out); - } + // Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes. + const std::string cmd = "\"" + bin + "\" --version"; + const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true); + if (!out.empty()) ver = parseMinerVersion(out); } std::lock_guard lk(g_installed_ver_mutex); g_installed_ver = ver; diff --git a/src/util/platform.cpp b/src/util/platform.cpp index c0f7d4f..b177f65 100644 --- a/src/util/platform.cpp +++ b/src/util/platform.cpp @@ -867,6 +867,70 @@ int Platform::getSystemIdleSeconds() // GPU utilization detection // ============================================================================ +std::string Platform::runHiddenCapture(const std::string& cmdLine, bool mergeStderr, int* exitCode) +{ + if (exitCode) *exitCode = -1; +#ifdef _WIN32 + SECURITY_ATTRIBUTES sa; + ZeroMemory(&sa, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + HANDLE hRead = NULL, hWrite = NULL; + if (!CreatePipe(&hRead, &hWrite, &sa, 0)) return {}; + SetHandleInformation(hRead, HANDLE_FLAG_INHERIT, 0); // parent's read end stays private + + HANDLE hNul = INVALID_HANDLE_VALUE; + if (!mergeStderr) { + hNul = CreateFileA("NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + } + + STARTUPINFOA si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + si.hStdOutput = hWrite; + si.hStdError = mergeStderr ? hWrite : hNul; + + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + std::string cl = cmdLine; // CreateProcessA may modify lpCommandLine → needs a mutable buffer + std::string out; + if (CreateProcessA(NULL, cl.empty() ? NULL : &cl[0], NULL, NULL, TRUE, + CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) { + CloseHandle(hWrite); hWrite = NULL; // close our copy so ReadFile hits EOF when the child exits + if (hNul != INVALID_HANDLE_VALUE) { CloseHandle(hNul); hNul = INVALID_HANDLE_VALUE; } + char buf[4096]; + DWORD n = 0; + while (ReadFile(hRead, buf, sizeof(buf), &n, NULL) && n > 0) out.append(buf, n); + WaitForSingleObject(pi.hProcess, INFINITE); + if (exitCode) { + DWORD code = 0; + if (GetExitCodeProcess(pi.hProcess, &code)) *exitCode = static_cast(code); + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + if (hWrite != NULL) CloseHandle(hWrite); + if (hNul != INVALID_HANDLE_VALUE) CloseHandle(hNul); + CloseHandle(hRead); + return out; +#else + const std::string full = cmdLine + (mergeStderr ? " 2>&1" : " 2>/dev/null"); + std::string out; + FILE* f = popen(full.c_str(), "r"); + if (!f) return out; + char buf[512]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + const int st = pclose(f); + if (exitCode) *exitCode = st; // raw status (matches prior pclose-based rc checks) + return out; +#endif +} + int Platform::getGpuUtilization() { #ifdef _WIN32 @@ -877,23 +941,16 @@ int Platform::getGpuUtilization() static bool s_has_nvidia = false; if (!s_tried_nvidia) { s_tried_nvidia = true; - FILE* f = _popen("where nvidia-smi 2>nul", "r"); - if (f) { - char buf[256]; - s_has_nvidia = (fgets(buf, sizeof(buf), f) != nullptr); - _pclose(f); - } + // Windowless (runHiddenCapture) so GPU-aware idle detection never flashes a cmd.exe console. + const std::string w = runHiddenCapture("where nvidia-smi"); + s_has_nvidia = (w.find_first_not_of(" \t\r\n") != std::string::npos); } if (s_has_nvidia) { - FILE* f = _popen("nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>nul", "r"); - if (f) { - char buf[64]; - int util = -1; - if (fgets(buf, sizeof(buf), f)) { - util = atoi(buf); - if (util < 0 || util > 100) util = -1; - } - _pclose(f); + const std::string o = runHiddenCapture( + "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits"); + if (!o.empty()) { + int util = atoi(o.c_str()); + if (util < 0 || util > 100) util = -1; return util; } } diff --git a/src/util/platform.h b/src/util/platform.h index f5fbc79..baad2ce 100644 --- a/src/util/platform.h +++ b/src/util/platform.h @@ -179,6 +179,15 @@ public: * @return GPU busy percent, or -1 if unavailable. */ static int getGpuUtilization(); + + // Run a command line and capture its stdout WITHOUT ever popping a console window: Windows uses + // CreateProcess + CREATE_NO_WINDOW (a plain popen()/_popen() flashes a cmd.exe console), POSIX uses + // popen(). Use this instead of _popen for anything run while the GUI is up. `mergeStderr` folds the + // child's stderr into the result (like "2>&1"); otherwise stderr is discarded. `exitCode`, if given, + // receives the child's exit status (raw pclose() status on POSIX, GetExitCodeProcess on Windows; -1 + // if the process could not be launched). + static std::string runHiddenCapture(const std::string& cmdLine, bool mergeStderr = false, + int* exitCode = nullptr); }; /** -- 2.34.1