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) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 21:27:25 -05:00
parent 4473e7e00a
commit b20e7efb16
4 changed files with 289 additions and 122 deletions

113
src/ui/widgets/copy_field.h Normal file
View File

@@ -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 <string>
#include <cstring>
#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

View File

@@ -4,6 +4,8 @@
#include "key_export_dialog.h"
#include "../../app.h"
#include "../../wallet/lite_wallet_controller.h"
#include <nlohmann/json.hpp>
#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<std::string>();
} 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

View File

@@ -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);

View File

@@ -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();