From 0c55ed0fe4972db1bb42d1d1d130887f7bcae96d Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 13 Jul 2026 15:17:15 -0500 Subject: [PATCH] feat(settings): send-tab-style slider for the rescan start height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- res/lang/de.json | 1 + res/lang/es.json | 1 + res/lang/fr.json | 1 + res/lang/ja.json | 1 + res/lang/ko.json | 1 + res/lang/pt.json | 1 + res/lang/ru.json | 1 + res/lang/zh.json | 1 + src/app.cpp | 105 ++++++++++++++++++++++++++++++++++++++++++---- src/util/i18n.cpp | 1 + 10 files changed, 105 insertions(+), 9 deletions(-) diff --git a/res/lang/de.json b/res/lang/de.json index 400cd5f..98dfd6c 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -446,6 +446,7 @@ "import_private_key": "Privaten Schlüssel importieren...", "import_scan_hint": "0 = von Anfang an neu scannen", "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_viewkey_field": "Anzeigeschlüssel", "import_viewkey_note": "Nur Lesen: Ein Anzeigeschlüssel zeigt Guthaben und Transaktionen einer Adresse, kann deren Mittel aber nicht ausgeben.", diff --git a/res/lang/es.json b/res/lang/es.json index 3f628a6..1ab4be4 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -446,6 +446,7 @@ "import_private_key": "Importar Clave Privada...", "import_scan_hint": "0 = volver a escanear desde el inicio", "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_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.", diff --git a/res/lang/fr.json b/res/lang/fr.json index 43e05be..2a12a21 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -446,6 +446,7 @@ "import_private_key": "Importer une clé privée...", "import_scan_hint": "0 = réanalyser depuis le début", "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_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.", diff --git a/res/lang/ja.json b/res/lang/ja.json index 06ebe16..dca7c60 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -446,6 +446,7 @@ "import_private_key": "秘密鍵をインポート...", "import_scan_hint": "0 = 最初から再スキャン", "import_scan_label": "スキャン開始ブロック高(任意)", + "import_scan_tip": "現在の高さ", "import_scan_transparent": "透明鍵は常に全体を再スキャンします", "import_viewkey_field": "閲覧鍵", "import_viewkey_note": "監視のみ:閲覧鍵はアドレスの残高と取引を表示できますが、資金を送金することはできません。", diff --git a/res/lang/ko.json b/res/lang/ko.json index 0a3e160..d1d1fee 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -446,6 +446,7 @@ "import_private_key": "개인 키 가져오기...", "import_scan_hint": "0 = 처음부터 다시 스캔", "import_scan_label": "스캔 시작 블록 높이(선택 사항)", + "import_scan_tip": "현재 높이", "import_scan_transparent": "투명 키는 항상 전체를 다시 스캔합니다", "import_viewkey_field": "조회 키", "import_viewkey_note": "읽기 전용: 조회 키는 주소의 잔액과 거래를 보여주지만 자금을 사용할 수는 없습니다.", diff --git a/res/lang/pt.json b/res/lang/pt.json index 2792e13..d66e818 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -446,6 +446,7 @@ "import_private_key": "Importar Chave Privada...", "import_scan_hint": "0 = reescanear desde o início", "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_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.", diff --git a/res/lang/ru.json b/res/lang/ru.json index 8112d73..b7fcab2 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -446,6 +446,7 @@ "import_private_key": "Импорт приватного ключа...", "import_scan_hint": "0 = повторное сканирование с начала", "import_scan_label": "Сканировать с высоты блока (необязательно)", + "import_scan_tip": "текущая высота", "import_scan_transparent": "Прозрачные ключи всегда пересканируются полностью", "import_viewkey_field": "Ключ просмотра", "import_viewkey_note": "Только чтение: ключ просмотра показывает баланс и транзакции адреса, но не может тратить его средства.", diff --git a/res/lang/zh.json b/res/lang/zh.json index 31d2b4b..44fac30 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -446,6 +446,7 @@ "import_private_key": "导入私钥...", "import_scan_hint": "0 = 从头重新扫描", "import_scan_label": "从区块高度开始扫描(可选)", + "import_scan_tip": "当前高度", "import_scan_transparent": "透明密钥始终完整重新扫描", "import_viewkey_field": "查看密钥", "import_viewkey_note": "仅查看:查看密钥可显示地址的余额和交易,但无法花费其资金。", diff --git a/src/app.cpp b/src/app.cpp index 935ae0e..62a4a1b 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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() { @@ -2863,19 +2936,31 @@ void App::renderImportKeyDialog() ImGui::Spacing(); // 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 - // start-height parameter, so the field is disabled + noted once a transparent key is recognized. + // import of a recent key needn't rescan the whole chain. A send-tab-style slider (bounded by the + // 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 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::BeginDisabled(transparentKey); - ImGui::SetNextItemWidth(140.0f * dp); - ImGui::InputText("##importscanheight", import_key_scan_height_, sizeof(import_key_scan_height_), - ImGuiInputTextFlags_CharsDecimal); - ImGui::EndDisabled(); - ImGui::SameLine(0, ui::Layout::spacingSm()); + if (tip > 0) { + char tipBuf[64]; + snprintf(tipBuf, sizeof(tipBuf), "%s %s", TR("import_scan_tip"), formatIntWithCommas(tip).c_str()); + const float targetX = rowStartX + rowW - ImGui::CalcTextSize(tipBuf).x; + ImGui::SameLine(); + 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", transparentKey ? TR("import_scan_transparent") : TR("import_scan_hint")); ImGui::Spacing(); @@ -2918,6 +3003,8 @@ void App::renderImportKeyDialog() if (import_key_scan_height_[0]) { // it; importPrivateKey ignores it for transparent WIF. startHeight = std::atoi(import_key_scan_height_); 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, [this](bool ok, const std::string& err, const std::string& addr) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 92f8480..572c2fa 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1369,6 +1369,7 @@ void I18n::loadBuiltinEnglish() strings_["import_scan_label"] = "Scan from block height (optional)"; strings_["import_scan_hint"] = "0 = rescan from the start"; strings_["import_scan_transparent"] = "Transparent keys always rescan fully"; + strings_["import_scan_tip"] = "current height"; // --- Key Export Dialog --- strings_["key_export_fetching"] = "Fetching key from wallet...";