feat(settings): polish the import-key dialog + paste verify preview

Design pass on the Import Private/Viewing Key dialog:

- Group the optional scan-from-height section (label, slider, field, hint) in a
  subtle glass panel via a draw-list splitter so it reads as one distinct,
  clearly-optional block.
- Replace the boxed reveal / Paste / Clear buttons with frameless Material icon
  buttons: the eye toggle sits flush at the Key field's right edge (rounded to
  match the input frame), Paste / Clear are faint icon pills with tooltips.
- Add a centered %-of-chain label to the slider (mirrors the send-tab amount bar).

New: hovering Paste peeks at the clipboard and verifies it before pasting —
a tooltip classifies the clipboard the same way the field indicator does
(✓ valid type for this dialog / ⚠ a key for the other button / ✗ not a key),
with a preview line masked with '*' unless the key is currently revealed (then a
truncated peek). Empty clipboard shows "Clipboard is empty".

i18n: added paste_clip_empty (en + 8 langs); the preview reuses the field
indicator's type / wrong-type keys.

Reviewed via three adversarial multi-agent passes (splitter/panel, icon buttons,
slider math, and the paste preview's correctness/security + i18n); the confirmed
findings (eye hover-pill rounding, unrecognized-state color) are addressed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 16:38:36 -05:00
parent c25da0d26d
commit 2a96c41e07
10 changed files with 155 additions and 30 deletions

View File

@@ -785,6 +785,7 @@
"output_filename": "Ausgabedateiname:", "output_filename": "Ausgabedateiname:",
"overview": "Übersicht", "overview": "Übersicht",
"paste": "Einfügen", "paste": "Einfügen",
"paste_clip_empty": "Zwischenablage ist leer",
"paste_from_clipboard": "Aus Zwischenablage einfügen", "paste_from_clipboard": "Aus Zwischenablage einfügen",
"pay_from": "Zahlen von", "pay_from": "Zahlen von",
"payment_request": "ZAHLUNGSANFRAGE", "payment_request": "ZAHLUNGSANFRAGE",

View File

@@ -785,6 +785,7 @@
"output_filename": "Nombre del archivo:", "output_filename": "Nombre del archivo:",
"overview": "Resumen", "overview": "Resumen",
"paste": "Pegar", "paste": "Pegar",
"paste_clip_empty": "El portapapeles está vacío",
"paste_from_clipboard": "Pegar del Portapapeles", "paste_from_clipboard": "Pegar del Portapapeles",
"pay_from": "Pagar desde", "pay_from": "Pagar desde",
"payment_request": "SOLICITUD DE PAGO", "payment_request": "SOLICITUD DE PAGO",

View File

@@ -785,6 +785,7 @@
"output_filename": "Nom du fichier de sortie :", "output_filename": "Nom du fichier de sortie :",
"overview": "Aperçu", "overview": "Aperçu",
"paste": "Coller", "paste": "Coller",
"paste_clip_empty": "Le presse-papiers est vide",
"paste_from_clipboard": "Coller depuis le presse-papiers", "paste_from_clipboard": "Coller depuis le presse-papiers",
"pay_from": "Payer depuis", "pay_from": "Payer depuis",
"payment_request": "DEMANDE DE PAIEMENT", "payment_request": "DEMANDE DE PAIEMENT",

View File

@@ -785,6 +785,7 @@
"output_filename": "出力ファイル名:", "output_filename": "出力ファイル名:",
"overview": "概要", "overview": "概要",
"paste": "貼り付け", "paste": "貼り付け",
"paste_clip_empty": "クリップボードは空です",
"paste_from_clipboard": "クリップボードから貼り付け", "paste_from_clipboard": "クリップボードから貼り付け",
"pay_from": "支払い元", "pay_from": "支払い元",
"payment_request": "支払い請求", "payment_request": "支払い請求",

View File

@@ -785,6 +785,7 @@
"output_filename": "출력 파일명:", "output_filename": "출력 파일명:",
"overview": "개요", "overview": "개요",
"paste": "붙여넣기", "paste": "붙여넣기",
"paste_clip_empty": "클립보드가 비어 있습니다",
"paste_from_clipboard": "클립보드에서 붙여넣기", "paste_from_clipboard": "클립보드에서 붙여넣기",
"pay_from": "보낼 곳", "pay_from": "보낼 곳",
"payment_request": "결제 요청", "payment_request": "결제 요청",

View File

@@ -785,6 +785,7 @@
"output_filename": "Nome do arquivo de saída:", "output_filename": "Nome do arquivo de saída:",
"overview": "Resumo", "overview": "Resumo",
"paste": "Colar", "paste": "Colar",
"paste_clip_empty": "A área de transferência está vazia",
"paste_from_clipboard": "Colar da Área de Transferência", "paste_from_clipboard": "Colar da Área de Transferência",
"pay_from": "Pagar de", "pay_from": "Pagar de",
"payment_request": "SOLICITAÇÃO DE PAGAMENTO", "payment_request": "SOLICITAÇÃO DE PAGAMENTO",

View File

@@ -785,6 +785,7 @@
"output_filename": "Имя выходного файла:", "output_filename": "Имя выходного файла:",
"overview": "Обзор", "overview": "Обзор",
"paste": "Вставить", "paste": "Вставить",
"paste_clip_empty": "Буфер обмена пуст",
"paste_from_clipboard": "Вставить из буфера обмена", "paste_from_clipboard": "Вставить из буфера обмена",
"pay_from": "Оплатить с", "pay_from": "Оплатить с",
"payment_request": "ЗАПРОС НА ОПЛАТУ", "payment_request": "ЗАПРОС НА ОПЛАТУ",

View File

@@ -785,6 +785,7 @@
"output_filename": "输出文件名:", "output_filename": "输出文件名:",
"overview": "概览", "overview": "概览",
"paste": "粘贴", "paste": "粘贴",
"paste_clip_empty": "剪贴板为空",
"paste_from_clipboard": "从剪贴板粘贴", "paste_from_clipboard": "从剪贴板粘贴",
"pay_from": "付款来源", "pay_from": "付款来源",
"payment_request": "付款请求", "payment_request": "付款请求",

View File

@@ -2761,12 +2761,13 @@ static std::string formatIntWithCommas(long long v)
return s; return s;
} }
// A send-tab-style slider (mirrors RenderAmountBar in send_tab.cpp: rounded track, accent fill, glass // A send-tab-style slider (mirrors RenderAmountBar in send_tab.cpp: rounded track, accent fill,
// thumb) for the optional rescan start height, stacked above a full-width numeric field for exact // centered % label, glass thumb) for the optional rescan start height, stacked above a full-width
// entry. Both share `buf` — the height as a decimal string — so it's the single source of truth. // numeric field for exact entry. Both share `buf` — the height as a decimal string — so it's the
// `tipHeight` is the chain tip = the slider's max; when <= 0 (height unknown) the slider is omitted // single source of truth. `tipHeight` is the chain tip = the slider's max; when <= 0 (height unknown)
// and only the field shows. // the slider is omitted and only the field shows. `availW` is the interior width (bar + field span it).
static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bool enabled, float dp) static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bool enabled,
float dp, float availW)
{ {
namespace m = ui::material; namespace m = ui::material;
int cur = buf[0] ? std::atoi(buf) : 0; int cur = buf[0] ? std::atoi(buf) : 0;
@@ -2775,8 +2776,8 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
ImGui::BeginDisabled(!enabled); ImGui::BeginDisabled(!enabled);
if (tipHeight > 0) { if (tipHeight > 0) {
const float barW = ImGui::GetContentRegionAvail().x; // slider spans the full row, stacked above the field const float barW = availW; // slider spans the panel interior, stacked above the field
const float barH = ImGui::GetFrameHeight(); // match the numeric field's height (already dp-scaled) const float barH = ImGui::GetFrameHeight(); // match the numeric field's height (already dp-scaled)
const float barRound = barH * 0.5f; const float barRound = barH * 0.5f;
const float thumbR = barH * 0.5f; const float thumbR = barH * 0.5f;
@@ -2804,7 +2805,18 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y), dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y),
m::WithAlpha(m::Primary(), enabled ? 150 : 55), barRound); m::WithAlpha(m::Primary(), enabled ? 150 : 55), barRound);
// Glass thumb. // Percentage-of-chain label centered on the bar (mirrors the send-tab amount bar).
{
ImFont* cap = m::Type().caption();
char pctBuf[16];
snprintf(pctBuf, sizeof(pctBuf), "%.0f%%", pct * 100.0f);
const ImVec2 tsz = cap->CalcTextSizeA(cap->LegacySize, 10000.0f, 0.0f, pctBuf);
dl->AddText(cap, cap->LegacySize,
ImVec2(barMin.x + (barW - tsz.x) * 0.5f, barMin.y + (barH - tsz.y) * 0.5f),
IM_COL32(255, 255, 255, enabled ? 190 : 80), pctBuf);
}
// Glass thumb (drawn last so it sits above the fill + label).
const float thumbY = barMin.y + barH * 0.5f; const float thumbY = barMin.y + barH * 0.5f;
dl->AddCircleFilled(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 205 : 90), 24); dl->AddCircleFilled(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 205 : 90), 24);
dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 120 : 50), 24, 1.4f * dp); dl->AddCircle(ImVec2(thumbCenterX, thumbY), thumbR, IM_COL32(255, 255, 255, enabled ? 120 : 50), 24, 1.4f * dp);
@@ -2816,13 +2828,76 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
} }
} }
// Exact numeric entry — full-width row beneath the slider. // Exact numeric entry — full-width beneath the slider.
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::SetNextItemWidth(availW);
ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal); ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal);
ImGui::EndDisabled(); ImGui::EndDisabled();
} }
// Hover preview for the Paste button: peek at the clipboard, classify it in the dialog's current
// key-type mode, and show a validity line + a preview masked with '*' unless the key is currently
// revealed — so the user can verify the clipboard holds a valid key before pasting it.
static void RenderPasteHoverPreview(bool viewMode, bool reveal, float dp)
{
namespace m = ui::material;
const char* clipRaw = ImGui::GetClipboardText();
std::string key(clipRaw ? clipRaw : "");
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
ImGui::BeginTooltip();
ImGui::TextUnformatted(TR("paste"));
if (key.empty()) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("paste_clip_empty"));
ImGui::EndTooltip();
return;
}
const bool isView = services::WalletSecurityController::isViewingKey(key);
const bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(key);
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const bool recognized = viewMode ? isView : isSpend; // valid for THIS dialog's mode
const bool wrongType = viewMode ? isSpend : isView; // a key, but for the other button
// valid / wrong-type / not-a-key. The field indicator softens the last case to amber+HELP, but this
// is a verify-before-paste check, so a truly-unrecognized clipboard gets a deliberately stronger
// red ✗ "don't paste" signal (amber ⚠ stays reserved for a real key aimed at the other button).
const ImVec4 amber(0.8f, 0.6f, 0.0f, 1.0f); // matches the field indicator's wrong-type amber
const ImVec4 red = m::ErrorVec4();
ImGui::PushFont(m::Type().iconSmall());
if (recognized) ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
else if (wrongType) ImGui::TextColored(amber, ICON_MD_WARNING);
else ImGui::TextColored(red, ICON_MD_CANCEL);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f * dp);
if (recognized) {
const char* label = viewMode ? TR("import_key_type_zview")
: shielded ? TR("import_key_type_zspend")
: TR("import_key_type_tkey");
ImGui::TextColored(m::SuccessVec4(), "%s", label);
} else if (wrongType) {
ImGui::TextColored(amber, "%s", viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"));
} else {
ImGui::TextColored(red, "%s", TR("import_key_type_unknown"));
}
// Preview line — masked with '*' unless the key is currently revealed (then a truncated peek).
std::string preview;
if (reveal) {
preview = key.size() > 22 ? key.substr(0, 14) + "..." + key.substr(key.size() - 6) : key;
} else {
const size_t n = std::min(key.size(), (size_t)32);
preview.assign(n, '*');
if (key.size() > n) preview += "...";
}
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", preview.c_str());
ImGui::EndTooltip();
}
void App::renderImportKeyDialog() void App::renderImportKeyDialog()
{ {
namespace m = ui::material; namespace m = ui::material;
@@ -2869,19 +2944,31 @@ void App::renderImportKeyDialog()
ImGui::Spacing(); ImGui::Spacing();
} }
// Masked key field with a reveal (eye) toggle + Paste/Clear. // Masked key field; the reveal (eye) toggle sits frameless flush at its right edge, with frameless
// Paste / Clear icon actions beneath — lighter and less cluttered than three boxed buttons.
ImGui::TextUnformatted(viewMode ? TR("import_viewkey_field") : TR("import_key_field")); ImGui::TextUnformatted(viewMode ? TR("import_viewkey_field") : TR("import_key_field"));
float eyeW = ImGui::GetFrameHeight(); const float fieldH = ImGui::GetFrameHeight();
ImGui::SetNextItemWidth(-(eyeW + ui::Layout::spacingSm())); const ImVec2 iconSz(fieldH, fieldH);
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - fieldH);
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password; ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags); ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
ImGui::SameLine(0, ui::Layout::spacingSm()); ImGui::SameLine(0, 0);
if (m::TactileButton(import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY, m::IconButtonStyle eyeSt;
ImVec2(eyeW, eyeW), m::Type().iconSmall())) eyeSt.hoverBg = m::WithAlpha(m::OnSurface(), 30);
eyeSt.bgRounding = 4.0f * dp; // match the input frame's corners (it sits flush at the field's edge)
eyeSt.tooltip = TR("import_key_reveal_tip");
if (m::IconButton("##revealkey", import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY,
m::Type().iconSmall(), iconSz, eyeSt))
import_key_reveal_ = !import_key_reveal_; import_key_reveal_ = !import_key_reveal_;
if (ImGui::IsItemHovered()) m::Tooltip("%s", TR("import_key_reveal_tip"));
if (m::TactileButton(TR("paste"))) { m::IconButtonStyle actSt;
actSt.restBg = m::WithAlpha(m::OnSurface(), 14); // faint pill so it still reads as a button
actSt.hoverBg = m::WithAlpha(m::OnSurface(), 34);
// Paste has no static tooltip — hovering shows a live clipboard preview that classifies the key
// (masked with '*' when the key isn't revealed) so the user can verify it before pasting.
const bool pasteClicked = m::IconButton("##pastekey", ICON_MD_CONTENT_PASTE, m::Type().iconSmall(), iconSz, actSt);
if (ImGui::IsItemHovered()) RenderPasteHoverPreview(viewMode, import_key_reveal_, dp);
if (pasteClicked) {
const char* clipboard = ImGui::GetClipboardText(); const char* clipboard = ImGui::GetClipboardText();
if (clipboard) { if (clipboard) {
std::string trimmed(clipboard); std::string trimmed(clipboard);
@@ -2890,8 +2977,10 @@ void App::renderImportKeyDialog()
snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str()); snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
} }
} }
ImGui::SameLine(); ImGui::SameLine(0, ui::Layout::spacingSm());
if (m::TactileButton(TR("clear"))) sodium_memzero(import_key_input_, sizeof(import_key_input_)); actSt.tooltip = TR("clear");
if (m::IconButton("##clearkey", ICON_MD_CLEAR, m::Type().iconSmall(), iconSz, actSt))
sodium_memzero(import_key_input_, sizeof(import_key_input_));
// Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey. // Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey.
std::string keyTrim(import_key_input_); std::string keyTrim(import_key_input_);
@@ -2931,36 +3020,63 @@ void App::renderImportKeyDialog()
} }
ImGui::Spacing(); ImGui::Spacing();
// Optional scan-from height. z_importkey / z_importviewingkey accept a start block, so a shielded // Optional scan-from-height, grouped in a subtle glass panel so it reads as one distinct,
// import of a recent key needn't rescan the whole chain. A send-tab-style slider (bounded by the // clearly-optional block. z_importkey / z_importviewingkey accept a start block, so a shielded
// chain tip) plus a numeric field set it. Transparent WIF (importprivkey) has no start-height // import of a recent key needn't rescan the whole chain. Transparent WIF (importprivkey) has no
// parameter, so the control is disabled + noted once a transparent key is recognized. // start-height parameter, so the control is disabled + noted once a transparent key is recognized.
ImGui::Dummy(ImVec2(0, 2.0f * dp));
{ {
const bool transparentKey = !viewMode && isSpend && !shielded; // recognized transparent WIF const bool transparentKey = !viewMode && isSpend && !shielded; // recognized transparent WIF
if (transparentKey && import_key_scan_height_[0]) if (transparentKey && import_key_scan_height_[0])
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_)); // no stale height sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_)); // no stale height
const int tip = state_.sync.blocks; // chain tip = slider max; 0 when unknown const int tip = state_.sync.blocks; // chain tip = slider max; 0 when unknown
// Label, with the current chain height right-aligned when known. // Render the content on an upper draw channel, then paint the glass panel behind it once the
// block's height is known (immediate-mode auto-sized panel via a draw-list splitter).
ImDrawList* dl = ImGui::GetWindowDrawList();
ImDrawListSplitter splitter;
splitter.Split(dl, 2);
splitter.SetCurrentChannel(dl, 1);
const float padX = 12.0f * dp, padY = 10.0f * dp;
const float panelW = ImGui::GetContentRegionAvail().x;
const ImVec2 panelMin = ImGui::GetCursorScreenPos();
ImGui::Dummy(ImVec2(0, padY));
ImGui::Indent(padX);
const float availW = ImGui::GetContentRegionAvail().x - padX; // interior width (both side paddings)
// Label + current chain height, right-aligned within the interior.
const float rowStartX = ImGui::GetCursorPosX(); const float rowStartX = ImGui::GetCursorPosX();
const float rowW = ImGui::GetContentRegionAvail().x;
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_scan_label")); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_scan_label"));
if (tip > 0) { if (tip > 0) {
char tipBuf[64]; char tipBuf[64];
snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str()); snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str());
const float targetX = rowStartX + rowW - ImGui::CalcTextSize(tipBuf).x; const float targetX = rowStartX + availW - ImGui::CalcTextSize(tipBuf).x;
ImGui::SameLine(); ImGui::SameLine();
if (targetX > ImGui::GetCursorPosX()) ImGui::SetCursorPosX(targetX); if (targetX > ImGui::GetCursorPosX()) ImGui::SetCursorPosX(targetX);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", tipBuf); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", tipBuf);
} }
RenderScanHeightControl(import_key_scan_height_, sizeof(import_key_scan_height_), RenderScanHeightControl(import_key_scan_height_, sizeof(import_key_scan_height_),
tip, !transparentKey, dp); tip, !transparentKey, dp, availW);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s",
transparentKey ? TR("import_scan_transparent") : TR("import_scan_hint")); transparentKey ? TR("import_scan_transparent") : TR("import_scan_hint"));
ImGui::Spacing();
ImGui::Unindent(padX);
ImGui::Dummy(ImVec2(0, padY));
const ImVec2 panelMax(panelMin.x + panelW, ImGui::GetCursorScreenPos().y);
splitter.SetCurrentChannel(dl, 0);
m::GlassPanelSpec spec;
spec.rounding = 8.0f * dp;
spec.fillAlpha = 20;
spec.borderAlpha = 30;
m::DrawGlassPanel(dl, panelMin, panelMax, spec);
splitter.Merge(dl);
} }
ImGui::Dummy(ImVec2(0, 2.0f * dp));
// Progress / result / error. // Progress / result / error.
if (import_in_progress_) { if (import_in_progress_) {

View File

@@ -1370,6 +1370,7 @@ void I18n::loadBuiltinEnglish()
strings_["import_scan_hint"] = "0 = rescan from the start"; strings_["import_scan_hint"] = "0 = rescan from the start";
strings_["import_scan_transparent"] = "Transparent keys always rescan fully"; strings_["import_scan_transparent"] = "Transparent keys always rescan fully";
strings_["import_scan_tip"] = "current height"; strings_["import_scan_tip"] = "current height";
strings_["paste_clip_empty"] = "Clipboard is empty";
// --- Key Export Dialog --- // --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet..."; strings_["key_export_fetching"] = "Fetching key from wallet...";