fix(market): harden portfolio group editor + HiDPI scaling

Portfolio audit fixes:
- Guard the BTC value line behind price_btc > 0 so an empty BTC price no
  longer renders a misleading "≈ 0.00000000 BTC".
- Validate the working group before commit/save (pfWorkingValid: needs a
  label, at least one address, and a positive manual price when the manual
  price basis is selected); the Save button disables to match, so a
  half-filled editor no longer persists a broken group on close/switch.
- Default an empty manual currency to "USD" instead of an empty string.
- Reload the editor after clamping a stale selection index so it can't
  point past the end of the group list.
- Scale hand-drawn geometry by dpiScale(): address-row rounding, chip
  y-offset, preview border, chart toggle buttons, and the shielded/
  transparent ratio-bar corner radii now render correctly on HiDPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:14:45 -05:00
parent 82da4af857
commit 97a54f0e0c

View File

@@ -298,7 +298,8 @@ static config::Settings::PortfolioEntry pfBuildWorking(const config::Settings::P
config::Settings::PortfolioEntry e = base; config::Settings::PortfolioEntry e = base;
e.label = s_pfEdit.label; e.addresses = s_pfEdit.addrs; e.icon = s_pfEdit.icon; e.label = s_pfEdit.label; e.addresses = s_pfEdit.addrs; e.icon = s_pfEdit.icon;
e.color = s_pfEdit.color; e.outlineOpacity = s_pfEdit.outlineOpacity; e.color = s_pfEdit.color; e.outlineOpacity = s_pfEdit.outlineOpacity;
e.priceBasis = s_pfEdit.priceBasis; e.manualPrice = s_pfEdit.manualPrice; e.manualCurrency = s_pfEdit.manualCcy; e.priceBasis = s_pfEdit.priceBasis; e.manualPrice = s_pfEdit.manualPrice;
e.manualCurrency = (s_pfEdit.manualCcy[0] != '\0') ? std::string(s_pfEdit.manualCcy) : std::string("USD");
e.showDrgx = s_pfEdit.showDrgx; e.showValue = s_pfEdit.showValue; e.show24h = s_pfEdit.show24h; e.showDrgx = s_pfEdit.showDrgx; e.showValue = s_pfEdit.showValue; e.show24h = s_pfEdit.show24h;
e.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval; e.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval;
return e; return e;
@@ -315,6 +316,16 @@ static bool pfWorkingMatches(const config::Settings::PortfolioEntry& e)
&& e.showSparkline == s_pfEdit.showSparkline && e.sparklineInterval == s_pfEdit.sparkInterval; && e.showSparkline == s_pfEdit.showSparkline && e.sparklineInterval == s_pfEdit.sparkInterval;
} }
// A group is only worth persisting when it has a name, at least one address (else it's always $0), and
// — on the Manual price basis — a positive price (0 would render as "unavailable"). Gates both the Save
// button and the commit path (Close/switch commit too, so validating only the button wouldn't be enough).
static bool pfWorkingValid()
{
return s_pfEdit.label[0] != '\0'
&& !s_pfEdit.addrs.empty()
&& !(s_pfEdit.priceBasis == 3 && s_pfEdit.manualPrice <= 0.0);
}
static void pfPersist(config::Settings* settings, const std::vector<config::Settings::PortfolioEntry>& entries) static void pfPersist(config::Settings* settings, const std::vector<config::Settings::PortfolioEntry>& entries)
{ {
settings->setPortfolioEntries(entries); settings->setPortfolioEntries(entries);
@@ -327,7 +338,7 @@ static void pfPersist(config::Settings* settings, const std::vector<config::Sett
static void pfCommitIfNeeded(App* app) static void pfCommitIfNeeded(App* app)
{ {
config::Settings* settings = app->settings(); config::Settings* settings = app->settings();
if (s_pfEdit.label[0] == '\0') return; if (!pfWorkingValid()) return; // drop invalid drafts (unnamed / no addresses / manual price <= 0)
auto entries = settings->getPortfolioEntries(); auto entries = settings->getPortfolioEntries();
bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size()); bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size());
config::Settings::PortfolioEntry base; config::Settings::PortfolioEntry base;
@@ -635,8 +646,8 @@ static void pfDrawAddressSection(App* app)
ImVec2 rmn = ImGui::GetCursorScreenPos(); ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH); ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx); bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f); if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f * dp);
else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f); else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f * dp);
float rcy = rmn.y + rowH * 0.5f; float rcy = rmn.y + rowH * 0.5f;
float rx = rmn.x + Layout::spacingSm(); float rx = rmn.x + Layout::spacingSm();
float cb = 15.0f * dp; float cb = 15.0f * dp;
@@ -654,7 +665,7 @@ static void pfDrawAddressSection(App* app)
bool isZ = (a.type == "shielded"); bool isZ = (a.type == "shielded");
const char* chip = isZ ? "Z" : "T"; const char* chip = isZ ? "Z" : "T";
ImU32 chipCol = isZ ? Success() : Warning(); ImU32 chipCol = isZ ? Success() : Warning();
ImVec2 chipPos(rx, rcy - capF->LegacySize * 0.5f - 2.0f); ImVec2 chipPos(rx, rcy - capF->LegacySize * 0.5f - 2.0f * dp);
float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol, float chipW = material::DrawPill(ldl, chipPos, chip, capF, chipCol,
WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f), 4.0f * dp).x; WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f), 4.0f * dp).x;
rx += chipW + Layout::spacingSm(); rx += chipW + Layout::spacingSm();
@@ -719,9 +730,14 @@ static void RenderPortfolioEditor(App* app)
if (!material::BeginOverlayDialog(ov)) return; if (!material::BeginOverlayDialog(ov)) return;
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_pfEdit.open = false; if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_pfEdit.open = false;
{ // clamp/validate the selection against the current entry list { // Clamp/validate the selection. The in-editor delete already reloads the working state, but if the
// list shrank via any other path, reload after clamping so a later commit can't overlay stale
// fields onto the clamped entry.
const auto& entriesRO = settings->getPortfolioEntries(); const auto& entriesRO = settings->getPortfolioEntries();
if (s_pfEdit.sel >= (int)entriesRO.size()) s_pfEdit.sel = entriesRO.empty() ? -1 : 0; if (s_pfEdit.sel >= (int)entriesRO.size()) {
s_pfEdit.sel = entriesRO.empty() ? -1 : 0;
PortfolioBeginEdit(app, s_pfEdit.sel);
}
} }
float footerH = 56.0f * dp; // room for the 40px buttons + separator so they aren't clipped float footerH = 56.0f * dp; // room for the 40px buttons + separator so they aren't clipped
@@ -869,7 +885,7 @@ static void RenderPortfolioEditor(App* app)
DrawGlassPanel(pdl, pMin, pMax, g); DrawGlassPanel(pdl, pMin, pMax, g);
if (accent) { if (accent) {
int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f); int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f * dp, 0, 2.0f * dp);
} }
if (s_pfEdit.showSparkline && (s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)) { if (s_pfEdit.showSparkline && (s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1)) {
std::vector<double> h = data::sparklineSeries(state.market, s_pfEdit.sparkInterval); std::vector<double> h = data::sparklineSeries(state.market, s_pfEdit.sparkInterval);
@@ -956,7 +972,7 @@ static void RenderPortfolioEditor(App* app)
if (material::TactileButton(TR("portfolio_revert"), ImVec2(bw, addH))) if (material::TactileButton(TR("portfolio_revert"), ImVec2(bw, addH)))
PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry
ImGui::SameLine(0, sp); ImGui::SameLine(0, sp);
ImGui::BeginDisabled(s_pfEdit.label[0] == '\0'); ImGui::BeginDisabled(!pfWorkingValid());
if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app); if (material::TactileButton(TR("portfolio_save"), ImVec2(bw, addH))) pfCommitIfNeeded(app);
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::EndDisabled(); ImGui::EndDisabled();
@@ -1406,7 +1422,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
bool bhov = material::IsRectHovered(bmn, bmx); bool bhov = material::IsRectHovered(bmn, bmx);
ImU32 bg = sel ? WithAlpha(Primary(), 200) ImU32 bg = sel ? WithAlpha(Primary(), 200)
: (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18)); : (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18));
dl->AddRectFilled(bmn, bmx, bg, 4.0f); dl->AddRectFilled(bmn, bmx, bg, 4.0f * mktDp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY), dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY),
sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl); sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl);
ImGui::SetCursorScreenPos(bmn); ImGui::SetCursorScreenPos(bmn);
@@ -1429,7 +1445,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
const char* styleIcon = isCandle ? ICON_MD_CANDLESTICK_CHART : ICON_MD_SHOW_CHART; const char* styleIcon = isCandle ? ICON_MD_CANDLESTICK_CHART : ICON_MD_SHOW_CHART;
ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH); ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH);
bool thov = material::IsRectHovered(tmn, tmx); bool thov = material::IsRectHovered(tmn, tmx);
if (thov) { dl->AddRectFilled(tmn, tmx, IM_COL32(255, 255, 255, 20), 4.0f); if (thov) { dl->AddRectFilled(tmn, tmx, IM_COL32(255, 255, 255, 20), 4.0f * mktDp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon); ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon);
dl->AddText(icoF, icoF->LegacySize, dl->AddText(icoF, icoF->LegacySize,
@@ -1450,7 +1466,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImFont* iconSmall = material::Typography::instance().iconSmall(); ImFont* iconSmall = material::Typography::instance().iconSmall();
ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH); ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH);
bool refreshHov = material::IsRectHovered(rbMin, rbMax); bool refreshHov = material::IsRectHovered(rbMin, rbMax);
if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f); if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f * mktDp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH); ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
dl->AddText(iconSmall, iconSmall->LegacySize, dl->AddText(iconSmall, iconSmall->LegacySize,
@@ -1797,12 +1813,16 @@ static void mktDrawPortfolio(const MktCtx& cx)
chgCol, chg); chgCol, chg);
} }
double portfolio_btc = total_balance * market.price_btc; // Only show the BTC equivalent when a BTC price is actually available — otherwise it would read a
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC" // misleading "≈ 0.00000000 BTC" (price_usd can be present while price_btc is still 0).
float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x; if (market.price_btc > 0) {
dl->AddText(capFont, capFont->LegacySize, double portfolio_btc = total_balance * market.price_btc;
ImVec2(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)), snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
OnSurfaceMedium(), buf); float btcW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(rightEdge - btcW, cy + (sub1->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
}
} else { } else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx0, cy), OnSurfaceDisabled(), TR("market_no_price")); dl->AddText(sub1, sub1->LegacySize, ImVec2(cx0, cy), OnSurfaceDisabled(), TR("market_no_price"));
} }
@@ -1832,16 +1852,16 @@ static void mktDrawPortfolio(const MktCtx& cx)
const bool barLight = IsLightTheme(); const bool barLight = IsLightTheme();
const int fillA = barLight ? 205 : 165; const int fillA = barLight ? 205 : 165;
dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH), dl->AddRectFilled(barStart, ImVec2(barStart.x + barW, barStart.y + ratioBarH),
barLight ? IM_COL32(0, 0, 0, 20) : IM_COL32(255, 255, 255, 10), 3.0f); barLight ? IM_COL32(0, 0, 0, 20) : IM_COL32(255, 255, 255, 10), 3.0f * mktDp);
if (shieldedW > 0.5f) if (shieldedW > 0.5f)
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH), dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
WithAlpha(Success(), fillA), WithAlpha(Success(), fillA),
transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f); transpW > 0.5f ? ImDrawFlags_RoundCornersLeft : ImDrawFlags_RoundCornersAll, 3.0f * mktDp);
if (transpW > 0.5f) if (transpW > 0.5f)
dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y), dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
ImVec2(barStart.x + barW, barStart.y + ratioBarH), ImVec2(barStart.x + barW, barStart.y + ratioBarH),
WithAlpha(Warning(), fillA), WithAlpha(Warning(), fillA),
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f); shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f * mktDp);
// market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB). // market_pct_shielded is "%.0f%% Shielded" — pass a double (an int to %f is UB).
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0); snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);