feat(settings): labelled Paste/Clear + in-field clipboard preview

Restore text labels on the import dialog's Paste and Clear buttons (the reveal
eye stays a compact icon flush in the field), and replace the paste-hover tooltip
with an in-field preview like the send tab: while Paste is hovered and the Key
field is empty, the trimmed clipboard is drawn as a transparent ghost overlay
inside the field — masked with '*' unless the key is revealed — so the user can
verify it before pasting.

The type indicator now reflects the hovered clipboard (indKey/iRecognized/…),
while the Import/Sweep button guard and the scan panel keep using the actual
field content only, so a hover can never enable or trigger the action. Removes
the now-unused RenderPasteHoverPreview tooltip helper and the dead wrongType local.

Reviewed via an adversarial pass (overlay masking/security, the indicator-vs-action
classification split, button regressions); the one finding (dead wrongType) is fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:43:17 -05:00
parent 828018de2b
commit 0b47134112

View File

@@ -2835,69 +2835,6 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
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()
{
namespace m = ui::material;
@@ -2959,14 +2896,16 @@ void App::renderImportKeyDialog()
ImGui::Spacing();
}
// 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.
// Masked key field; the reveal (eye) toggle sits frameless flush at its right edge, with labelled
// Paste / Clear buttons beneath. Hovering Paste previews the clipboard INSIDE the field (masked
// with '*' unless the key is revealed) — like the send tab — so the user can verify before pasting.
ImGui::TextUnformatted(viewMode ? TR("import_viewkey_field") : TR("import_key_field"));
const float fieldH = ImGui::GetFrameHeight();
const ImVec2 iconSz(fieldH, fieldH);
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - fieldH);
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
const ImVec2 fieldMin = ImGui::GetItemRectMin(); // anchor for the paste-preview overlay
ImGui::SameLine(0, 0);
m::IconButtonStyle eyeSt;
eyeSt.hoverBg = m::WithAlpha(m::OnSurface(), 30);
@@ -2976,13 +2915,9 @@ void App::renderImportKeyDialog()
m::Type().iconSmall(), iconSz, eyeSt))
import_key_reveal_ = !import_key_reveal_;
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);
// Paste / Clear — labelled buttons.
const bool pasteClicked = m::TactileButton(TR("paste"));
const bool pasteHovered = ImGui::IsItemHovered();
if (pasteClicked) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) {
@@ -2992,12 +2927,34 @@ void App::renderImportKeyDialog()
snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
}
}
ImGui::SameLine(0, ui::Layout::spacingSm());
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_));
ImGui::SameLine();
if (m::TactileButton(TR("clear"))) sodium_memzero(import_key_input_, sizeof(import_key_input_));
// Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey.
// While Paste is hovered and the field is empty, preview the trimmed clipboard inside the field,
// masked with '*' unless the key is revealed. pastePreview also drives the type indicator (below)
// so the user sees how the clipboard would classify before pasting — but NOT the action button.
std::string pastePreview;
if (pasteHovered && import_key_input_[0] == '\0') {
const char* clip = ImGui::GetClipboardText();
if (clip && clip[0]) {
std::string t(clip);
while (!t.empty() && (t.front()==' '||t.front()=='\t'||t.front()=='\n'||t.front()=='\r')) t.erase(t.begin());
while (!t.empty() && (t.back()==' '||t.back()=='\t'||t.back()=='\n'||t.back()=='\r')) t.pop_back();
if (!t.empty()) {
pastePreview = t;
std::string shown = import_key_reveal_ ? t : std::string(std::min(t.size(), (size_t)56), '*');
if (shown.size() > 48) shown.resize(48); // fits the field before the flush eye button
const ImVec2 tp(fieldMin.x + ImGui::GetStyle().FramePadding.x,
fieldMin.y + ImGui::GetStyle().FramePadding.y);
ImVec4 pc = ImGui::GetStyleColorVec4(ImGuiCol_Text);
pc.w = 0.45f; // transparent ghost, like the send tab
ImGui::GetWindowDrawList()->AddText(tp, ImGui::ColorConvertFloat4ToU32(pc), shown.c_str());
}
}
}
// Trimmed field content drives the Import/Sweep guard + scan panel; the paste preview (only while
// hovering an empty field) drives just the type indicator, so a hover can never enable the button.
std::string keyTrim(import_key_input_);
while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
@@ -3005,15 +2962,23 @@ void App::renderImportKeyDialog()
bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
// Each button accepts only its own key type; a key valid for the OTHER button is flagged with a
// redirect hint rather than lumped in with truly unrecognized input.
// Each button accepts only its own key type; the Import/Sweep guard uses `recognized` (wrong-type
// redirect handling lives in the indicator's iWrongType path below).
bool recognized = viewMode ? isView : isSpend;
bool wrongType = viewMode ? isSpend : isView;
if (!keyTrim.empty()) {
if (recognized) {
// Indicator key = the paste preview while hovering, else the field content.
const std::string& indKey = pastePreview.empty() ? keyTrim : pastePreview;
const bool iShielded = services::WalletSecurityController::classifyPrivateKey(indKey)
== services::WalletSecurityController::KeyKind::Shielded;
const bool iRecognized = viewMode ? services::WalletSecurityController::isViewingKey(indKey)
: services::WalletSecurityController::isRecognizedPrivateKey(indKey);
const bool iWrongType = viewMode ? services::WalletSecurityController::isRecognizedPrivateKey(indKey)
: services::WalletSecurityController::isViewingKey(indKey);
if (!indKey.empty()) {
if (iRecognized) {
const char* label = viewMode ? TR("import_key_type_zview")
: shielded ? TR("import_key_type_zspend")
: iShielded ? TR("import_key_type_zspend")
: TR("import_key_type_tkey");
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
@@ -3021,11 +2986,11 @@ void App::renderImportKeyDialog()
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", label);
} else {
const char* msg = wrongType
const char* msg = iWrongType
? (viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"))
: TR("import_key_type_unknown");
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), wrongType ? ICON_MD_WARNING : ICON_MD_HELP);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), iWrongType ? ICON_MD_WARNING : ICON_MD_HELP);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f * dp);
ImGui::PushTextWrapPos(0.0f);