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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user