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

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