From b20e7efb16e20972e6addb16b68033c95e9e8219 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 27 Jun 2026 21:27:25 -0500 Subject: [PATCH] feat(lite): network-tab polish, reworked key-export/QR dialogs Network tab: glow only the active node, drop the left accent bar. Key-export dialog: fix the lite-wallet "Not connected" failure by exporting the key locally via the SDXL backend when there's no daemon; rework the layout to wrapping click-to-copy fields with a side QR (empty placeholder when hidden), 85% modal width, HRP-preserving key chunking, and a centered, emphasized warning. QR popup matched to the same sizing and click-to-copy address. Shared field rendering extracted to widgets/copy_field.h so both dialogs stay in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/widgets/copy_field.h | 113 +++++++++++++ src/ui/windows/key_export_dialog.cpp | 230 +++++++++++++++++---------- src/ui/windows/network_tab.cpp | 9 +- src/ui/windows/qr_popup_dialog.cpp | 59 ++++--- 4 files changed, 289 insertions(+), 122 deletions(-) create mode 100644 src/ui/widgets/copy_field.h diff --git a/src/ui/widgets/copy_field.h b/src/ui/widgets/copy_field.h new file mode 100644 index 0000000..e052a14 --- /dev/null +++ b/src/ui/widgets/copy_field.h @@ -0,0 +1,113 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Shared "click-to-copy field" widgets used by the key-export and QR-popup dialogs, so an address +// renders identically in both. A field is a bordered box of wrapping text whose whole area copies +// to the clipboard on click; long secrets/addresses are grouped into space-separated blocks. + +#pragma once + +#include +#include + +#include "imgui.h" +#include "../material/colors.h" +#include "../material/tooltip_style.h" +#include "../../util/i18n.h" + +namespace dragonx { +namespace ui { +namespace widgets { + +// Draw `text` wrapped within wrapW with every visual line horizontally centered (returns the height +// used). ImGui has no center+wrap, so we walk the wrap points the way it would. +inline float DrawCenteredWrapped(ImDrawList* dl, ImFont* font, float fs, ImVec2 origin, + float wrapW, ImU32 col, const char* text) +{ + const float scale = fs / font->LegacySize; + const float lineH = ImGui::GetTextLineHeight(); + const char* s = text; + const char* e = text + std::strlen(text); + float y = origin.y; + while (s < e) { + const char* wrap = font->CalcWordWrapPositionA(scale, s, e, wrapW); + if (wrap <= s) wrap = s + 1; + const ImVec2 lw = font->CalcTextSizeA(fs, 1.0e30f, 0.0f, s, wrap); + float lx = origin.x + (wrapW - lw.x) * 0.5f; + if (lx < origin.x) lx = origin.x; + dl->AddText(font, fs, ImVec2(lx, y), col, s, wrap); + y += lineH; + s = wrap; + while (s < e && (*s == ' ' || *s == '\n')) s++; // ImGui consumes the break char + } + return y - origin.y; +} + +// Group a long string into space-separated blocks so it reads in scannable chunks and wraps on +// block boundaries. Callers copy the ORIGINAL (un-grouped) string. +inline std::string ChunkString(const std::string& s, size_t group) +{ + if (group == 0 || s.size() <= group) return s; + std::string out; + out.reserve(s.size() + s.size() / group + 1); + for (size_t i = 0; i < s.size(); ++i) { + if (i && (i % group) == 0) out.push_back(' '); + out.push_back(s[i]); + } + return out; +} + +// A wrapping, click-to-copy "field": draws `display` in a bordered box (the whole box is the copy +// affordance) and returns true when clicked. `center` centers each wrapped line. The caller is +// responsible for the actual clipboard write (so secrets can use an auto-clearing clipboard). +inline bool CopyField(const char* id, const std::string& display, float width, float minH, bool center) +{ + namespace m = material; + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImGuiStyle& st = ImGui::GetStyle(); + ImFont* font = ImGui::GetFont(); + const float fs = ImGui::GetFontSize(); + const float padX = st.FramePadding.x + 4.0f; + const float padY = st.FramePadding.y + 4.0f; + const float wrapW = (width - padX * 2.0f) > 1.0f ? (width - padX * 2.0f) : 1.0f; + const ImVec2 ts = ImGui::CalcTextSize(display.c_str(), nullptr, false, wrapW); + float h = ts.y + padY * 2.0f; + if (h < minH) h = minH; + const ImVec2 p0 = ImGui::GetCursorScreenPos(); + const ImVec2 p1 = ImVec2(p0.x + width, p0.y + h); + ImGui::InvisibleButton(id, ImVec2(width, h)); + const bool hovered = ImGui::IsItemHovered(); + const bool clicked = ImGui::IsItemClicked(); + dl->AddRectFilled(p0, p1, m::WithAlpha(m::OnSurface(), hovered ? 18 : 8), 6.0f); + dl->AddRect(p0, p1, m::WithAlpha(m::OnSurface(), hovered ? 70 : 30), 6.0f, 0, 1.0f); + const float textY = p0.y + (h - ts.y) * 0.5f; // vertical center + if (center) + DrawCenteredWrapped(dl, font, fs, ImVec2(p0.x + padX, textY), wrapW, m::OnSurface(), display.c_str()); + else + dl->AddText(font, fs, ImVec2(p0.x + padX, textY), m::OnSurface(), display.c_str(), nullptr, wrapW); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + material::Tooltip("%s", TR("copy")); + } + return clicked; +} + +// An address field: chunked by 4 for readability, width clamped to its text, box centered in the +// available region. Copies the raw address to the clipboard on click; returns true when clicked. +inline bool AddressCopyField(const char* id, const std::string& address) +{ + const std::string at = ChunkString(address, 4); + const float avail = ImGui::GetContentRegionAvail().x; + const float padX = ImGui::GetStyle().FramePadding.x + 4.0f; + float boxW = ImGui::CalcTextSize(at.c_str()).x + padX * 2.0f + 2.0f; // clamp to text width + if (boxW > avail) boxW = avail; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail > boxW ? (avail - boxW) * 0.5f : 0.0f)); + const bool clicked = CopyField(id, at, boxW, 0.0f, /*center=*/true); + if (clicked) ImGui::SetClipboardText(address.c_str()); + return clicked; +} + +} // namespace widgets +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/key_export_dialog.cpp b/src/ui/windows/key_export_dialog.cpp index 88aabe8..d90193e 100644 --- a/src/ui/windows/key_export_dialog.cpp +++ b/src/ui/windows/key_export_dialog.cpp @@ -4,6 +4,8 @@ #include "key_export_dialog.h" #include "../../app.h" +#include "../../wallet/lite_wallet_controller.h" +#include #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" #include "../../util/i18n.h" @@ -11,7 +13,10 @@ #include "../material/draw_helpers.h" #include "../material/type.h" #include "../material/colors.h" +#include "../layout.h" #include "../widgets/qr_code.h" +#include "../widgets/copy_field.h" +#include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" namespace dragonx { @@ -57,19 +62,24 @@ void KeyExportDialog::render(App* app) { if (!s_open) return; - const char* title = (s_key_type == KeyType::Private) ? + const char* title = (s_key_type == KeyType::Private) ? TR("export_private_key") : TR("export_viewing_key"); auto& S = schema::UI(); - auto win = S.window("dialogs.key-export"); auto warningBox = S.drawElement("dialogs.key-export", "warning-box"); auto addrInput = S.input("dialogs.key-export", "address-input"); auto revealBtn = S.button("dialogs.key-export", "reveal-button"); auto keyDisplay = S.drawElement("dialogs.key-export", "key-display"); auto copyBtn = S.button("dialogs.key-export", "copy-button"); auto closeBtn = S.button("dialogs.key-export", "close-button"); + (void)addrInput; + (void)keyDisplay; + (void)copyBtn; - if (material::BeginOverlayDialog(title, &s_open, win.width, 0.94f)) { + // Modal scales to 85% of the window width. BeginOverlayDialog multiplies the width by + // Layout::dpiScale(); divide it out here so the final card is exactly 85% at any font scale. + const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale(); + if (material::BeginOverlayDialog(title, &s_open, cardW, 0.94f)) { ImGui::Spacing(); // Warning section with colored background @@ -78,16 +88,40 @@ void KeyExportDialog::render(App* app) (void)warningBox; ImGui::BeginChild("WarningBox", ImVec2(-1, 0), ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_Borders); - - ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), " %s", TR("warning_upper")); - ImGui::Spacing(); - - if (s_key_type == KeyType::Private) { - ImGui::TextWrapped(" %s", TR("key_export_private_warning")); - } else { - ImGui::TextWrapped(" %s", TR("key_export_viewing_warning")); + + // Heading — warning icon (drawn with the icon font, not the text font, so it isn't tofu) + + // larger h6 text, centered, for emphasis. + { + ImFont* iconF = material::Type().iconMed(); + ImFont* textF = material::Type().h6(); + const char* icon = ICON_MD_WARNING; + const char* txt = TR("warning_upper"); + ImGui::PushFont(iconF); const float iw = ImGui::CalcTextSize(icon).x; ImGui::PopFont(); + ImGui::PushFont(textF); const float tw = ImGui::CalcTextSize(txt).x; ImGui::PopFont(); + const float sp = ImGui::GetStyle().ItemSpacing.x; + const float wa = ImGui::GetContentRegionAvail().x; + const float total = iw + sp + tw; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (wa > total ? (wa - total) * 0.5f : 0.0f)); + const ImVec4 red(1.0f, 0.42f, 0.42f, 1.0f); + ImGui::PushFont(iconF); ImGui::TextColored(red, "%s", icon); ImGui::PopFont(); + ImGui::SameLine(0, sp); + ImGui::PushFont(textF); ImGui::TextColored(red, "%s", txt); ImGui::PopFont(); } - + ImGui::Spacing(); + + // Body — centered wrapped text. + { + const char* body = (s_key_type == KeyType::Private) + ? TR("key_export_private_warning") : TR("key_export_viewing_warning"); + ImDrawList* wdl = ImGui::GetWindowDrawList(); + ImFont* wf = ImGui::GetFont(); + const float wfs = ImGui::GetFontSize(); + const float wa = ImGui::GetContentRegionAvail().x; + const ImVec2 wp = ImGui::GetCursorScreenPos(); + const float bh = widgets::DrawCenteredWrapped(wdl, wf, wfs, wp, wa, material::OnSurface(), body); + ImGui::Dummy(ImVec2(wa, bh)); + } + ImGui::EndChild(); ImGui::PopStyleColor(); @@ -95,32 +129,9 @@ void KeyExportDialog::render(App* app) ImGui::Separator(); ImGui::Spacing(); - // Address display + // Address — click-to-copy field (chunked, width-clamped, centered). ImGui::Text("%s", TR("address_label")); - - // Determine if it's a z-address (longer) or t-address - bool is_zaddr = s_address.length() > 50; - - if (is_zaddr) { - // Use multiline for z-addresses - char addr_buf[512]; - strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1); - addr_buf[sizeof(addr_buf) - 1] = '\0'; - // Fit the field to the wrapped address (no excess empty space below it). - (void)addrInput; - const float addrFieldH = - ImGui::CalcTextSize(addr_buf, nullptr, false, - ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x * 2.0f).y - + ImGui::GetStyle().FramePadding.y * 2.0f + 4.0f; - ImGui::InputTextMultiline("##Address", addr_buf, sizeof(addr_buf), - ImVec2(-1, addrFieldH), ImGuiInputTextFlags_ReadOnly); - } else { - char addr_buf[128]; - strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1); - addr_buf[sizeof(addr_buf) - 1] = '\0'; - ImGui::SetNextItemWidth(-1); - ImGui::InputText("##Address", addr_buf, sizeof(addr_buf), ImGuiInputTextFlags_ReadOnly); - } + widgets::AddressCopyField("##addrcopy", s_address); ImGui::Spacing(); @@ -136,11 +147,48 @@ void KeyExportDialog::render(App* app) // Show button to fetch key if (material::StyledButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) { s_fetching = true; - + // Check if z-address or t-address bool is_zaddress = (s_address.length() > 50 || s_address[0] == 'z'); - - if (s_key_type == KeyType::Private) { + + if (auto* lite = app->liteWallet()) { + // Lite wallet: there is no daemon RPC — export the key locally via the SDXL + // backend (a local, network-free op) instead of the z_exportkey/dumpprivkey + // RPCs, which fail with "Not connected" when no full node is present. + const bool wantViewing = (s_key_type != KeyType::Private); + if (wantViewing && !is_zaddress) { + s_error = TR("key_export_viewing_keys_zonly"); + s_fetching = false; + } else { + auto r = lite->exportPrivateKeys(s_address); + std::string found; + if (r.ok) { + try { + auto j = nlohmann::json::parse(r.privateKeysJson); + const nlohmann::json* entry = nullptr; + if (j.is_array()) { + for (auto& e : j) + if (e.contains("address") && e["address"] == s_address) { entry = &e; break; } + if (!entry && !j.empty()) entry = &j.front(); + } else if (j.is_object()) { + entry = &j; + } + const char* field = wantViewing ? "viewing_key" : "private_key"; + if (entry && entry->contains(field) && (*entry)[field].is_string()) + found = (*entry)[field].get(); + } catch (...) {} + wallet::secureWipeLiteSecret(r.privateKeysJson); + } + if (!found.empty()) { + s_key = found; + s_show_key = wantViewing; // viewing keys are less sensitive + } else { + s_error = r.ok ? std::string("Key not available for this address") : r.error; + } + wallet::secureWipeLiteSecret(found); + s_fetching = false; + } + } else if (s_key_type == KeyType::Private) { // Export private key std::string addr = s_address; std::string method = is_zaddress ? "z_exportkey" : "dumpprivkey"; @@ -201,51 +249,52 @@ void KeyExportDialog::render(App* app) ImGui::SameLine(); ImGui::TextDisabled("%s", TR("key_export_click_retrieve")); } else { - // Key has been fetched - display it - - // Fit the field to the wrapped key (same length whether shown or masked). - (void)keyDisplay; - char key_buf[1024]; - if (s_show_key) { - strncpy(key_buf, s_key.c_str(), sizeof(key_buf) - 1); - } else { - std::string masked(s_key.length(), '*'); - strncpy(key_buf, masked.c_str(), sizeof(key_buf) - 1); - } - key_buf[sizeof(key_buf) - 1] = '\0'; - const float keyFieldH = - ImGui::CalcTextSize(key_buf, nullptr, false, - ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x * 2.0f).y - + ImGui::GetStyle().FramePadding.y * 2.0f + 4.0f; - ImGui::InputTextMultiline("##Key", key_buf, sizeof(key_buf), - ImVec2(-1, keyFieldH), ImGuiInputTextFlags_ReadOnly); + // Key fetched. Layout: [ key text (click-to-copy) + Show/Hide below ] [ QR | square ]. + const float gap = 12.0f; + const float avail = ImGui::GetContentRegionAvail().x; + // Larger, responsive QR: ~30% of the content width, clamped to a comfortable range. + float qrSize = avail * 0.30f; + if (qrSize < 200.0f) qrSize = 200.0f; + if (qrSize > 340.0f) qrSize = 340.0f; + float keyW = avail - qrSize - gap; + const bool sideBySide = keyW >= 200.0f; // too narrow -> stack the QR under the key + if (!sideBySide) keyW = avail; - // Action row: Show/Hide · Copy · QR. Auto-width buttons (size 0) so the label text never - // clips at the user's font scale, and ONE shared font so they all match. - ImGui::Spacing(); - ImFont* actionFont = S.resolveFont(copyBtn.font); - - if (material::StyledButton(s_show_key ? TR("hide") : TR("show"), ImVec2(0, 0), actionFont)) { - s_show_key = !s_show_key; - if (!s_show_key) s_show_qr = false; // hiding the key also hides its QR - } - - ImGui::SameLine(); - - if (material::StyledButton(TR("copy_to_clipboard"), ImVec2(0, 0), actionFont)) { - // Auto-clearing clipboard: the key (as sensitive as the seed) is wiped after ~45s. - app->copySecretToClipboard(s_key); - } - - // QR (only once revealed) — for scanning the key into another wallet. - if (s_show_key) { - ImGui::SameLine(); - if (material::StyledButton(s_show_qr ? TR("hide_qr") : TR("show_qr"), ImVec2(0, 0), actionFont)) { - s_show_qr = !s_show_qr; + // Chunk for readability, but keep a Bech32 HRP (e.g. "secret-extended-key-main") intact + // rather than slicing it into 4s — only the part from the '1' separator on is chunked. + auto chunkKey = [](const std::string& s) -> std::string { + const size_t one = s.find('1'); + if (one != std::string::npos && one > 0) { + bool isHrp = true; + for (size_t i = 0; i < one; ++i) { + const char c = s[i]; + if (!((c >= 'a' && c <= 'z') || c == '-')) { isHrp = false; break; } + } + if (isHrp) return s.substr(0, one) + " " + widgets::ChunkString(s.substr(one), 4); } - } + return widgets::ChunkString(s, 4); + }; + const std::string shown = s_show_key ? s_key : std::string(s_key.size(), '*'); + const std::string keyText = chunkKey(shown); - if (s_show_qr && s_show_key && !s_key.empty()) { + // Left column: key text (click-to-copy), with the Show/Hide button just below it. + ImGui::BeginGroup(); + if (widgets::CopyField("##keycopy", keyText, keyW, 0.0f, /*center=*/true)) { + app->copySecretToClipboard(s_key); // auto-clearing clipboard + } + ImGui::Spacing(); + if (material::StyledButton(s_show_key ? TR("hide") : TR("show"), ImVec2(0, 0), + material::Type().subtitle1())) { + s_show_key = !s_show_key; + } + ImGui::EndGroup(); + + if (sideBySide) ImGui::SameLine(0, gap); + else ImGui::Spacing(); + + // QR to the right of the key when revealed; an empty placeholder square while hidden. + const ImVec2 sq0 = ImGui::GetCursorScreenPos(); + if (s_show_key && !s_key.empty()) { if (s_qr_cached != s_key) { // (re)generate the QR texture when the key changes if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; } int qw = 0, qh = 0; @@ -253,11 +302,20 @@ void KeyExportDialog::render(App* app) s_qr_cached = s_key; } if (s_qr_tex) { - ImGui::Spacing(); - const float qrSize = 180.0f; - ImGui::SetCursorPosX((ImGui::GetWindowWidth() - qrSize) * 0.5f); + // White quiet-zone backing so the code stays scannable on dark themes. + ImGui::GetWindowDrawList()->AddRectFilled( + sq0, ImVec2(sq0.x + qrSize, sq0.y + qrSize), IM_COL32_WHITE, 4.0f); RenderQRCode(s_qr_tex, qrSize); + } else { + ImGui::Dummy(ImVec2(qrSize, qrSize)); } + } else { + // Empty placeholder square — same footprint the QR will occupy once revealed. + ImGui::Dummy(ImVec2(qrSize, qrSize)); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const ImVec2 sq1(sq0.x + qrSize, sq0.y + qrSize); + dl->AddRectFilled(sq0, sq1, material::WithAlpha(material::OnSurface(), 8), 6.0f); + dl->AddRect(sq0, sq1, material::WithAlpha(material::OnSurface(), 45), 6.0f, 0, 1.0f); } } @@ -269,7 +327,7 @@ void KeyExportDialog::render(App* app) float button_width = closeBtn.width; float avail_width = ImGui::GetContentRegionAvail().x; ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail_width - button_width) / 2.0f); - + if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) { s_open = false; // Clear sensitive data diff --git a/src/ui/windows/network_tab.cpp b/src/ui/windows/network_tab.cpp index 4cda491..0eae5ac 100644 --- a/src/ui/windows/network_tab.cpp +++ b/src/ui/windows/network_tab.cpp @@ -230,17 +230,16 @@ void RenderLiteNetworkTab(App* app) spec.rounding = rnd; spec.fillAlpha = selected ? 34 : 18; DrawGlassPanel(dl, cardMin, cardMax, spec); - if (official) { + // Glow only the ACTIVE (selected/in-use) node — officials are distinguished by their pill. + if (selected) { auto& fx = effects::ThemeEffects::instance(); if (fx.isEnabled()) { fx.drawGlowPulse(dl, cardMin, cardMax, rnd); } - // Always-visible static outline so officials are distinguishable even without effects. + // Always-visible pulsing outline so the active node stands out even without effects. float pulse = 0.75f + 0.25f * (float)std::sin(ImGui::GetTime() * 2.0); dl->AddRect(cardMin, cardMax, WithAlpha(Primary(), (int)(150 * pulse)), rnd, 0, 1.6f * dp); } - if (selected) - dl->AddRectFilled(cardMin, ImVec2(cardMin.x + 3.0f * dp, cardMax.y), Primary(), rnd); // Main click area selects the server (left of the hide strip). ImGui::SetCursorScreenPos(cardMin); @@ -302,7 +301,7 @@ void RenderLiteNetworkTab(App* app) bool hideHov = ImGui::IsItemHovered(); if (hideHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - ImGui::SetTooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide")); + material::Tooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide")); } if (ImGui::IsItemClicked()) { if (hiddenList) st->unhideLiteServer(sv.url); diff --git a/src/ui/windows/qr_popup_dialog.cpp b/src/ui/windows/qr_popup_dialog.cpp index 1d4573a..1b61633 100644 --- a/src/ui/windows/qr_popup_dialog.cpp +++ b/src/ui/windows/qr_popup_dialog.cpp @@ -6,8 +6,10 @@ #include "../../app.h" #include "../../util/i18n.h" #include "../widgets/qr_code.h" +#include "../widgets/copy_field.h" #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" +#include "../layout.h" #include "../theme.h" #include "imgui.h" @@ -59,12 +61,13 @@ void QRPopupDialog::render(App* app) if (!s_open) return; auto& S = schema::UI(); - auto win = S.window("dialogs.qr-popup"); auto qr = S.drawElement("dialogs.qr-popup", "qr-code"); - auto addrInput = S.input("dialogs.qr-popup", "address-input"); auto actionBtn = S.button("dialogs.qr-popup", "action-button"); - if (material::BeginOverlayDialog(TR("qr_title"), &s_open, win.width, 0.94f)) { + // Match the key-export modal: 85% of the window width (divide out the dpiScale that + // BeginOverlayDialog re-applies, so the final card is exactly 85% at any font scale). + const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale(); + if (material::BeginOverlayDialog(TR("qr_title"), &s_open, cardW, 0.94f)) { // Label if present if (!s_label.empty()) { @@ -74,10 +77,14 @@ void QRPopupDialog::render(App* app) ImGui::Spacing(); } - // Center the QR code - float qr_size = qr.size > 0 ? qr.size : 280; + // Center the QR code (responsive — larger, to suit the wider modal). float window_width = ImGui::GetWindowWidth(); + float qr_size = qr.size > 0 ? (float)qr.size : 280.0f; + const float responsive = window_width * 0.5f; + if (responsive > qr_size) qr_size = responsive; + if (qr_size > 420.0f) qr_size = 420.0f; float padding = (window_width - qr_size) / 2.0f; + if (padding < 0.0f) padding = 0.0f; ImGui::SetCursorPosX(padding); @@ -95,39 +102,29 @@ void QRPopupDialog::render(App* app) ImGui::Separator(); ImGui::Spacing(); - // Address display + // Address — click-to-copy field (same formatting as the key-export dialog). ImGui::Text("%s", TR("address_label")); - - // Use multiline for z-addresses - if (s_address.length() > 50) { - char addr_buf[512]; - strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1); - addr_buf[sizeof(addr_buf) - 1] = '\0'; - ImGui::InputTextMultiline("##QRAddress", addr_buf, sizeof(addr_buf), - ImVec2(-1, addrInput.height > 0 ? addrInput.height : 60), ImGuiInputTextFlags_ReadOnly); - } else { - char addr_buf[128]; - strncpy(addr_buf, s_address.c_str(), sizeof(addr_buf) - 1); - addr_buf[sizeof(addr_buf) - 1] = '\0'; - ImGui::SetNextItemWidth(-1); - ImGui::InputText("##QRAddress", addr_buf, sizeof(addr_buf), ImGuiInputTextFlags_ReadOnly); - } + widgets::AddressCopyField("##QRAddress", s_address); ImGui::Spacing(); - // Buttons - float button_width = actionBtn.width; - float total_width = button_width * 2 + ImGui::GetStyle().ItemSpacing.x; - float start_x = (window_width - total_width) / 2.0f; - ImGui::SetCursorPosX(start_x); - - if (material::StyledButton(TR("copy_address"), ImVec2(button_width, 0), S.resolveFont(actionBtn.font))) { + // Buttons — size each to its label (so "Copy address" never clips), then center the pair. + ImFont* btnFont = S.resolveFont(actionBtn.font); + ImGui::PushFont(btnFont); + const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f; + float w_copy = ImGui::CalcTextSize(TR("copy_address")).x + btnPad; + float w_close = ImGui::CalcTextSize(TR("close")).x + btnPad; + ImGui::PopFont(); + if (w_copy < actionBtn.width) w_copy = actionBtn.width; + if (w_close < actionBtn.width) w_close = actionBtn.width; + const float total_width = w_copy + w_close + ImGui::GetStyle().ItemSpacing.x; + ImGui::SetCursorPosX((window_width - total_width) / 2.0f); + + if (material::StyledButton(TR("copy_address"), ImVec2(w_copy, 0), btnFont)) { ImGui::SetClipboardText(s_address.c_str()); } - ImGui::SameLine(); - - if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(actionBtn.font))) { + if (material::StyledButton(TR("close"), ImVec2(w_close, 0), btnFont)) { close(); } material::EndOverlayDialog();