feat(settings): send-tab-style slider for the rescan start height

The optional "scan from block height" control in the Import Private/Viewing
Key dialog now leads with a slider styled like the send-tab amount bar
(rounded track, accent fill, glass thumb) bounded by the chain tip, with the
numeric field beside it for exact entry and the current chain height
right-aligned above. When the node's height is unknown (state_.sync.blocks == 0,
e.g. not yet synced) the slider is omitted and the field spans the full row —
which also fixes the previous cramped 140px field that left the row half empty.

- New static helpers in app.cpp: formatIntWithCommas() and RenderScanHeightControl()
  (mirrors RenderAmountBar in send_tab.cpp). The height decimal string is the single
  source of truth shared by slider + field; the slider reserves/queries its
  InvisibleButton before drawing so a drag has no one-frame lag; the bar height is
  ImGui::GetFrameHeight() so it aligns with the numeric field.
- Import handler clamps the parsed start height to the chain tip.
- i18n: added import_scan_tip ("current height") in en + 8 langs.

Reviewed via an adversarial multi-agent pass (slider-math / state-sync /
dpi-layout / consistency); the one confirmed finding (bar/field height mismatch)
is fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 15:17:15 -05:00
parent 1fc3e03e6f
commit 0c55ed0fe4
10 changed files with 105 additions and 9 deletions

View File

@@ -446,6 +446,7 @@
"import_private_key": "Privaten Schlüssel importieren...", "import_private_key": "Privaten Schlüssel importieren...",
"import_scan_hint": "0 = von Anfang an neu scannen", "import_scan_hint": "0 = von Anfang an neu scannen",
"import_scan_label": "Ab Blockhöhe scannen (optional)", "import_scan_label": "Ab Blockhöhe scannen (optional)",
"import_scan_tip": "aktuelle Höhe",
"import_scan_transparent": "Transparente Schlüssel scannen immer vollständig neu", "import_scan_transparent": "Transparente Schlüssel scannen immer vollständig neu",
"import_viewkey_field": "Anzeigeschlüssel", "import_viewkey_field": "Anzeigeschlüssel",
"import_viewkey_note": "Nur Lesen: Ein Anzeigeschlüssel zeigt Guthaben und Transaktionen einer Adresse, kann deren Mittel aber nicht ausgeben.", "import_viewkey_note": "Nur Lesen: Ein Anzeigeschlüssel zeigt Guthaben und Transaktionen einer Adresse, kann deren Mittel aber nicht ausgeben.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "Importar Clave Privada...", "import_private_key": "Importar Clave Privada...",
"import_scan_hint": "0 = volver a escanear desde el inicio", "import_scan_hint": "0 = volver a escanear desde el inicio",
"import_scan_label": "Escanear desde la altura de bloque (opcional)", "import_scan_label": "Escanear desde la altura de bloque (opcional)",
"import_scan_tip": "altura actual",
"import_scan_transparent": "Las claves transparentes siempre se reescanean por completo", "import_scan_transparent": "Las claves transparentes siempre se reescanean por completo",
"import_viewkey_field": "Clave de visualización", "import_viewkey_field": "Clave de visualización",
"import_viewkey_note": "Solo lectura: una clave de visualización revela el saldo y las transacciones de una dirección, pero no puede gastar sus fondos.", "import_viewkey_note": "Solo lectura: una clave de visualización revela el saldo y las transacciones de una dirección, pero no puede gastar sus fondos.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "Importer une clé privée...", "import_private_key": "Importer une clé privée...",
"import_scan_hint": "0 = réanalyser depuis le début", "import_scan_hint": "0 = réanalyser depuis le début",
"import_scan_label": "Analyser à partir de la hauteur de bloc (facultatif)", "import_scan_label": "Analyser à partir de la hauteur de bloc (facultatif)",
"import_scan_tip": "hauteur actuelle",
"import_scan_transparent": "Les clés transparentes réanalysent toujours entièrement", "import_scan_transparent": "Les clés transparentes réanalysent toujours entièrement",
"import_viewkey_field": "Clé de visualisation", "import_viewkey_field": "Clé de visualisation",
"import_viewkey_note": "Lecture seule : une clé de visualisation révèle le solde et les transactions d'une adresse, mais ne peut pas dépenser ses fonds.", "import_viewkey_note": "Lecture seule : une clé de visualisation révèle le solde et les transactions d'une adresse, mais ne peut pas dépenser ses fonds.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "秘密鍵をインポート...", "import_private_key": "秘密鍵をインポート...",
"import_scan_hint": "0 = 最初から再スキャン", "import_scan_hint": "0 = 最初から再スキャン",
"import_scan_label": "スキャン開始ブロック高(任意)", "import_scan_label": "スキャン開始ブロック高(任意)",
"import_scan_tip": "現在の高さ",
"import_scan_transparent": "透明鍵は常に全体を再スキャンします", "import_scan_transparent": "透明鍵は常に全体を再スキャンします",
"import_viewkey_field": "閲覧鍵", "import_viewkey_field": "閲覧鍵",
"import_viewkey_note": "監視のみ:閲覧鍵はアドレスの残高と取引を表示できますが、資金を送金することはできません。", "import_viewkey_note": "監視のみ:閲覧鍵はアドレスの残高と取引を表示できますが、資金を送金することはできません。",

View File

@@ -446,6 +446,7 @@
"import_private_key": "개인 키 가져오기...", "import_private_key": "개인 키 가져오기...",
"import_scan_hint": "0 = 처음부터 다시 스캔", "import_scan_hint": "0 = 처음부터 다시 스캔",
"import_scan_label": "스캔 시작 블록 높이(선택 사항)", "import_scan_label": "스캔 시작 블록 높이(선택 사항)",
"import_scan_tip": "현재 높이",
"import_scan_transparent": "투명 키는 항상 전체를 다시 스캔합니다", "import_scan_transparent": "투명 키는 항상 전체를 다시 스캔합니다",
"import_viewkey_field": "조회 키", "import_viewkey_field": "조회 키",
"import_viewkey_note": "읽기 전용: 조회 키는 주소의 잔액과 거래를 보여주지만 자금을 사용할 수는 없습니다.", "import_viewkey_note": "읽기 전용: 조회 키는 주소의 잔액과 거래를 보여주지만 자금을 사용할 수는 없습니다.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "Importar Chave Privada...", "import_private_key": "Importar Chave Privada...",
"import_scan_hint": "0 = reescanear desde o início", "import_scan_hint": "0 = reescanear desde o início",
"import_scan_label": "Escanear a partir da altura do bloco (opcional)", "import_scan_label": "Escanear a partir da altura do bloco (opcional)",
"import_scan_tip": "altura atual",
"import_scan_transparent": "Chaves transparentes sempre reescaneiam totalmente", "import_scan_transparent": "Chaves transparentes sempre reescaneiam totalmente",
"import_viewkey_field": "Chave de visualização", "import_viewkey_field": "Chave de visualização",
"import_viewkey_note": "Somente leitura: uma chave de visualização revela o saldo e as transações de um endereço, mas não pode gastar seus fundos.", "import_viewkey_note": "Somente leitura: uma chave de visualização revela o saldo e as transações de um endereço, mas não pode gastar seus fundos.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "Импорт приватного ключа...", "import_private_key": "Импорт приватного ключа...",
"import_scan_hint": "0 = повторное сканирование с начала", "import_scan_hint": "0 = повторное сканирование с начала",
"import_scan_label": "Сканировать с высоты блока (необязательно)", "import_scan_label": "Сканировать с высоты блока (необязательно)",
"import_scan_tip": "текущая высота",
"import_scan_transparent": "Прозрачные ключи всегда пересканируются полностью", "import_scan_transparent": "Прозрачные ключи всегда пересканируются полностью",
"import_viewkey_field": "Ключ просмотра", "import_viewkey_field": "Ключ просмотра",
"import_viewkey_note": "Только чтение: ключ просмотра показывает баланс и транзакции адреса, но не может тратить его средства.", "import_viewkey_note": "Только чтение: ключ просмотра показывает баланс и транзакции адреса, но не может тратить его средства.",

View File

@@ -446,6 +446,7 @@
"import_private_key": "导入私钥...", "import_private_key": "导入私钥...",
"import_scan_hint": "0 = 从头重新扫描", "import_scan_hint": "0 = 从头重新扫描",
"import_scan_label": "从区块高度开始扫描(可选)", "import_scan_label": "从区块高度开始扫描(可选)",
"import_scan_tip": "当前高度",
"import_scan_transparent": "透明密钥始终完整重新扫描", "import_scan_transparent": "透明密钥始终完整重新扫描",
"import_viewkey_field": "查看密钥", "import_viewkey_field": "查看密钥",
"import_viewkey_note": "仅查看:查看密钥可显示地址的余额和交易,但无法花费其资金。", "import_viewkey_note": "仅查看:查看密钥可显示地址的余额和交易,但无法花费其资金。",

View File

@@ -2753,6 +2753,79 @@ void App::renderLiteUnlockPrompt()
} }
} }
// Group an integer height with thousands separators, e.g. 1234567 -> "1,234,567".
static std::string formatIntWithCommas(long long v)
{
std::string s = std::to_string(v);
for (int pos = (int)s.length() - 3; pos > 0; pos -= 3) s.insert(pos, ",");
return s;
}
// A send-tab-style slider (mirrors RenderAmountBar in send_tab.cpp: rounded track, accent fill, glass
// thumb) for the optional rescan start height, paired with a numeric field for exact entry. Both share
// `buf` — the height as a decimal string — so it's the single source of truth. `tipHeight` is the chain
// tip = the slider's max; when <= 0 (height unknown) the slider is omitted and the field spans the row.
static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bool enabled, float dp)
{
namespace m = ui::material;
int cur = buf[0] ? std::atoi(buf) : 0;
if (cur < 0) cur = 0;
ImGui::BeginDisabled(!enabled);
const float inputW = 148.0f * dp;
if (tipHeight > 0) {
const float gap = ui::Layout::spacingSm();
float barW = ImGui::GetContentRegionAvail().x - inputW - gap;
const float minBar = 120.0f * dp;
if (barW < minBar) barW = minBar;
const float barH = ImGui::GetFrameHeight(); // match the numeric field's height so the two align (already dp-scaled)
const float barRound = barH * 0.5f;
const float thumbR = barH * 0.5f;
const ImVec2 barMin = ImGui::GetCursorScreenPos();
const ImVec2 barMax(barMin.x + barW, barMin.y + barH);
ImDrawList* dl = ImGui::GetWindowDrawList();
// Reserve + interact first so the fill/thumb drawn below reflect the current drag (no lag).
ImGui::InvisibleButton("##scanbar", ImVec2(barW, barH));
const bool active = enabled && ImGui::IsItemActive();
const bool hovered = ImGui::IsItemHovered();
if (active) {
const float clickPct = std::clamp((ImGui::GetIO().MousePos.x - barMin.x) / barW, 0.0f, 1.0f);
cur = (int)lroundf(clickPct * (float)tipHeight);
snprintf(buf, bufSize, "%d", cur);
}
const float pct = std::clamp((float)cur / (float)tipHeight, 0.0f, 1.0f);
// Track + accent fill up to the thumb (fill's rounded right edge matches the thumb circle).
dl->AddRectFilled(barMin, barMax, IM_COL32(255, 255, 255, enabled ? 26 : 12), barRound);
const float usableW = barW - thumbR * 2.0f;
const float thumbCenterX = barMin.x + thumbR + usableW * pct;
const float fillRight = thumbCenterX + thumbR;
if (fillRight > barMin.x + barRound * 2.0f)
dl->AddRectFilled(barMin, ImVec2(fillRight, barMax.y),
m::WithAlpha(m::Primary(), enabled ? 150 : 55), barRound);
// Glass thumb.
const float thumbY = barMin.y + barH * 0.5f;
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);
if (hovered && enabled) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
m::Tooltip("%s / %s (%.0f%%)", formatIntWithCommas(cur).c_str(),
formatIntWithCommas(tipHeight).c_str(), pct * 100.0f);
}
ImGui::SameLine(0, gap);
}
// Exact numeric entry — fills the remaining row (or the whole row when there's no slider).
ImGui::SetNextItemWidth(tipHeight > 0 ? inputW : ImGui::GetContentRegionAvail().x);
ImGui::InputText("##importscanheight", buf, bufSize, ImGuiInputTextFlags_CharsDecimal);
ImGui::EndDisabled();
}
void App::renderImportKeyDialog() void App::renderImportKeyDialog()
{ {
@@ -2863,19 +2936,31 @@ 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. z_importkey / z_importviewingkey accept a start block, so a shielded
// import of a recent key needn't rescan the whole chain. Transparent WIF (importprivkey) has no // import of a recent key needn't rescan the whole chain. A send-tab-style slider (bounded by the
// start-height parameter, so the field is disabled + noted once a transparent key is recognized. // chain tip) plus a numeric field set it. Transparent WIF (importprivkey) has no start-height
// parameter, so the control is disabled + noted once a transparent key is recognized.
{ {
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 in the disabled box 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
// Label, with the current chain height right-aligned when known.
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"));
ImGui::BeginDisabled(transparentKey); if (tip > 0) {
ImGui::SetNextItemWidth(140.0f * dp); char tipBuf[64];
ImGui::InputText("##importscanheight", import_key_scan_height_, sizeof(import_key_scan_height_), snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str());
ImGuiInputTextFlags_CharsDecimal); const float targetX = rowStartX + rowW - ImGui::CalcTextSize(tipBuf).x;
ImGui::EndDisabled(); ImGui::SameLine();
ImGui::SameLine(0, ui::Layout::spacingSm()); if (targetX > ImGui::GetCursorPosX()) ImGui::SetCursorPosX(targetX);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", tipBuf);
}
RenderScanHeightControl(import_key_scan_height_, sizeof(import_key_scan_height_),
tip, !transparentKey, dp);
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::Spacing();
@@ -2918,6 +3003,8 @@ void App::renderImportKeyDialog()
if (import_key_scan_height_[0]) { // it; importPrivateKey ignores it for transparent WIF. if (import_key_scan_height_[0]) { // it; importPrivateKey ignores it for transparent WIF.
startHeight = std::atoi(import_key_scan_height_); startHeight = std::atoi(import_key_scan_height_);
if (startHeight < 0) startHeight = 0; if (startHeight < 0) startHeight = 0;
if (state_.sync.blocks > 0 && startHeight > state_.sync.blocks)
startHeight = state_.sync.blocks; // never past the chain tip
} }
importPrivateKey(std::string(import_key_input_), startHeight, importPrivateKey(std::string(import_key_input_), startHeight,
[this](bool ok, const std::string& err, const std::string& addr) { [this](bool ok, const std::string& err, const std::string& addr) {

View File

@@ -1369,6 +1369,7 @@ void I18n::loadBuiltinEnglish()
strings_["import_scan_label"] = "Scan from block height (optional)"; strings_["import_scan_label"] = "Scan from block height (optional)";
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";
// --- Key Export Dialog --- // --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet..."; strings_["key_export_fetching"] = "Fetching key from wallet...";