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;
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.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.showSparkline = s_pfEdit.showSparkline; e.sparklineInterval = s_pfEdit.sparkInterval;
return e;
@@ -315,6 +316,16 @@ static bool pfWorkingMatches(const config::Settings::PortfolioEntry& e)
&& 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)
{
settings->setPortfolioEntries(entries);
@@ -327,7 +338,7 @@ static void pfPersist(config::Settings* settings, const std::vector<config::Sett
static void pfCommitIfNeeded(App* app)
{
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();
bool existing = (s_pfEdit.sel >= 0 && s_pfEdit.sel < (int)entries.size());
config::Settings::PortfolioEntry base;
@@ -635,8 +646,8 @@ static void pfDrawAddressSection(App* app)
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f);
else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 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 * dp);
float rcy = rmn.y + rowH * 0.5f;
float rx = rmn.x + Layout::spacingSm();
float cb = 15.0f * dp;
@@ -654,7 +665,7 @@ static void pfDrawAddressSection(App* app)
bool isZ = (a.type == "shielded");
const char* chip = isZ ? "Z" : "T";
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,
WithAlpha(chipCol, 40), 0, ImVec2(4.0f * dp, 2.0f), 4.0f * dp).x;
rx += chipW + Layout::spacingSm();
@@ -719,9 +730,14 @@ static void RenderPortfolioEditor(App* app)
if (!material::BeginOverlayDialog(ov)) return;
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();
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
@@ -869,7 +885,7 @@ static void RenderPortfolioEditor(App* app)
DrawGlassPanel(pdl, pMin, pMax, g);
if (accent) {
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)) {
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)))
PortfolioBeginEdit(app, s_pfEdit.sel); // reload the working set from the stored entry
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);
ImGui::EndDisabled();
ImGui::EndDisabled();
@@ -1406,7 +1422,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
bool bhov = material::IsRectHovered(bmn, bmx);
ImU32 bg = sel ? WithAlpha(Primary(), 200)
: (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),
sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl);
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;
ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH);
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); }
ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon);
dl->AddText(icoF, icoF->LegacySize,
@@ -1450,7 +1466,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImFont* iconSmall = material::Typography::instance().iconSmall();
ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH);
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); }
ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
dl->AddText(iconSmall, iconSmall->LegacySize,
@@ -1797,12 +1813,16 @@ static void mktDrawPortfolio(const MktCtx& cx)
chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
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);
// Only show the BTC equivalent when a BTC price is actually available — otherwise it would read a
// misleading "≈ 0.00000000 BTC" (price_usd can be present while price_btc is still 0).
if (market.price_btc > 0) {
double portfolio_btc = total_balance * market.price_btc;
snprintf(buf, sizeof(buf), "\xE2\x89\x88 %.8f BTC", portfolio_btc); // "≈ <n> BTC"
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 {
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 int fillA = barLight ? 205 : 165;
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)
dl->AddRectFilled(barStart, ImVec2(barStart.x + shieldedW, barStart.y + ratioBarH),
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)
dl->AddRectFilled(ImVec2(barStart.x + shieldedW, barStart.y),
ImVec2(barStart.x + barW, barStart.y + ratioBarH),
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).
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), static_cast<double>(shieldedRatio) * 100.0);