fix(market): HiDPI-scale hand-drawn geometry + dedup row/column helpers

Market-tab audit follow-up (all 9 confirmed findings):
- Scale the unscaled corner radii on the Cards/Spotlight row rects, the address-preview glass panel, the chart Y-axis labels, and both chart hover tooltips by dpiScale() — they mismatched the coincident already-scaled rects on HiDPI (the CLAUDE.md hand-drawn-geometry rule).
- Share the portfolio row-height/gap formula (pfRowHeight/pfRowGapFor) and the TABLE column widths (kPfValColW/kPfDrgxColW) between the draw loop and the scroll-region height budget so they can't drift and clip.
- Derive the portfolio total from the shielded/transparent split (one address scan instead of two in Cards).
- Build the address-picker selected-set once (unordered_set) instead of a linear PortfolioEntryContains per sort-compare and per row.
No correctness/security/threading defects were found in the audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:00:02 -05:00
parent 818c29fca7
commit 1c38f68781

View File

@@ -31,6 +31,7 @@
#include <algorithm>
#include <cmath>
#include <ctime>
#include <unordered_set>
namespace dragonx {
namespace ui {
@@ -668,14 +669,18 @@ static void pfDrawAddressSection(App* app)
}
filtered.push_back(&a);
}
// Build the selected-address set once — the sort comparator and the per-row membership test below
// both consulted it via a linear PortfolioEntryContains scan (O(n) each) every frame.
std::unordered_set<std::string> selSet(s_pfEdit.addrs.begin(), s_pfEdit.addrs.end());
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address);
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address);
bool sx = selSet.count(x->address) != 0;
bool sy = selSet.count(y->address) != 0;
if (sx != sy) return sx;
return x->balance > y->balance;
});
if (selAllClicked)
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address);
for (const AddressInfo* a : filtered)
if (data::PortfolioEntryAdd(s_pfEdit.addrs, a->address)) selSet.insert(a->address);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade,
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
@@ -685,7 +690,7 @@ static void pfDrawAddressSection(App* app)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address);
bool inSet = selSet.count(a.address) != 0;
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
@@ -734,8 +739,8 @@ static void pfDrawAddressSection(App* app)
ImGui::PushID(a.address.c_str());
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
if (ImGui::IsItemClicked()) {
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address);
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);
if (inSet) { data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); selSet.erase(a.address); }
else { data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); selSet.insert(a.address); }
}
ImGui::PopID();
}
@@ -957,7 +962,7 @@ static void RenderPortfolioEditor(App* app)
ImVec2 pMin = ImGui::GetCursorScreenPos();
ImVec2 pMax(pMin.x + pw, pMin.y + ph);
ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0;
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40;
GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
DrawGlassPanel(pdl, pMin, pMax, g);
if (accent) {
int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
@@ -1083,25 +1088,39 @@ bool PortfolioEditorActive() { return s_pfEdit.open; }
// muted em-dash so a row never looks broken. Pure drawing into dl.
// TABLE-style (portfolio_style 0) right-edge column anchors — shared by pfDrawRow's row body and the
// column-header strip so the header labels sit exactly over the values they name.
// TABLE-style (style 0) column widths (logical px) — shared by pfTableCols (which turns them into
// right-edge anchors) and pfDrawRow (which feeds valW/drgxW to fit() truncation), so truncated text
// always lines up with the column edges. Change here and both stay in sync.
static constexpr float kPfChgColW = 62.0f;
static constexpr float kPfValColW = 108.0f;
static constexpr float kPfDrgxColW = 118.0f;
struct PfTableCols { float labelR, drgxR, valR, chgR; };
static PfTableCols pfTableCols(float right, float dp)
{
const float colGap = Layout::spacingMd();
const float chgW = 62.0f*dp, valW = 108.0f*dp, drgxW = 118.0f*dp;
PfTableCols c;
c.chgR = right;
c.valR = c.chgR - chgW - colGap;
c.drgxR = c.valR - valW - colGap;
c.labelR = c.drgxR - drgxW - colGap;
c.valR = c.chgR - kPfChgColW * dp - colGap;
c.drgxR = c.valR - kPfValColW * dp - colGap;
c.labelR = c.drgxR - kPfDrgxColW * dp - colGap;
return c;
}
// Portfolio row height + inter-row gap by style — one source shared by the row drawing
// (mktDrawPortfolio) and the scroll-region height budget (RenderMarketTab) so they can't drift and clip.
static float pfRowHeight(int style, float dp) { return (style == 0 ? 40.0f : style == 1 ? 68.0f : 80.0f) * dp; }
static float pfRowGapFor(int style, float dp) { return style == 0 ? 2.0f * dp : Layout::spacingSm(); }
static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
const config::Settings::PortfolioEntry& e, const WalletState& state,
const MarketInfo& market, int style, float dp,
ImFont* sub1, ImFont* capFont, bool hov)
{
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
// One pass over the wallet's addresses: the shielded/transparent split (used by the Cards Z/T bar)
// also yields the total, so we never scan twice.
const data::PortfolioSplit split = data::SumPortfolioSplit(e.addresses, state.addresses);
const double bal = split.shielded + split.transparent;
ImU32 accent = e.color ? (ImU32)e.color : 0;
const bool zeroBal = (bal <= 0.0); // empty groups render dimmed (content only, not the container)
@@ -1118,23 +1137,23 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
dl->AddRectFilled(ImVec2(rowMin.x, rowMin.y + 5.0f * dp), ImVec2(rowMin.x + 3.0f * dp, rowMax.y - 5.0f * dp),
accent ? accent : WithAlpha(OnSurface(), 55), 1.5f * dp);
} else if (style == 1) {
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
DrawGlassPanel(dl, rowMin, rowMax, g);
if (accent) {
dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round);
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + 3.0f * dp, rowMax.y), WithAlpha(accent, 205),
round, ImDrawFlags_RoundCornersLeft);
dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), 10.0f, 0, hov ? 1.8f : 1.2f);
dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), round, 0, hov ? 1.8f : 1.2f);
} else if (hov) {
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), 10.0f, 0, 1.2f);
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), round, 0, 1.2f);
}
} else {
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
DrawGlassPanel(dl, rowMin, rowMax, g);
dl->AddRectFilled(rowMin, ImVec2(rowMin.x + 4.0f * dp, rowMax.y),
WithAlpha(accent ? accent : Primary(), 220), round, ImDrawFlags_RoundCornersLeft);
if (accent) dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), 10.0f, 0, hov ? 1.8f : 1.2f);
else if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.2f);
if (accent) dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), round, 0, hov ? 1.8f : 1.2f);
else if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), round, 0, 1.2f);
}
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
@@ -1178,7 +1197,7 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
// No sparkline — the trend rides in a small coloured % pill, keeping this a scannable grid.
const PfTableCols cols = pfTableCols(right, dp);
const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR;
const float valW = 108.0f*dp, drgxW = 118.0f*dp; // fit() widths for the value / DRGX columns
const float valW = kPfValColW*dp, drgxW = kPfDrgxColW*dp; // fit() widths (shared with pfTableCols)
float iconSz = capFont->LegacySize, x = left;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
@@ -1217,9 +1236,9 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
// DRGX (bottom-left) + 24h (bottom-right)
if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(left, botY), OnSurfaceMedium(), drgxStr.c_str());
if (wantChange) { std::string s = hasChange?chgStr:kDash; dl->AddText(capFont, capFont->LegacySize, ImVec2(right - tw(capFont,s), botY), hasChange?chgCol:OnSurfaceDisabled(), s.c_str()); }
// shielded/transparent split bar (Cards-only signature; read-only balance data)
// shielded/transparent split bar (Cards-only signature; reuses the split computed above)
{
const data::PortfolioSplit sp = data::SumPortfolioSplit(e.addresses, state.addresses);
const data::PortfolioSplit& sp = split;
const double tot = sp.shielded + sp.transparent;
const float barY = rowMax.y - padY - zbarH;
const bool barLight = IsLightTheme();
@@ -1703,7 +1722,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
// Keep the axis label inside the card even on narrow windows (min-padding may be
// smaller than the label width) so it never spills onto the tab background.
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6);
float lblX = std::max(chartMin.x + 3.0f * mktDp, plotLeft - labelSz.x - 6.0f * mktDp);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lblX, gy - labelSz.y * 0.5f),
OnSurfaceDisabled(), buf);
@@ -1766,8 +1785,8 @@ static void mktDrawPriceChart(const MktCtx& cx)
tipX = std::max(plotLeft, tipX);
float tipY = plotTop + 4.0f * mktDp;
ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f);
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f * mktDp);
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
ImU32 cCol = (hc.close >= hc.open) ? Success() : Error();
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad), OnSurface(), when);
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2);
@@ -1870,16 +1889,16 @@ static void mktDrawPriceChart(const MktCtx& cx)
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;
float tipY = py - tipSz.y - tipPad * 2 - 4;
float tipX = px + 10.0f * mktDp;
float tipY = py - tipSz.y - tipPad * 2 - 4.0f * mktDp;
if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10;
if (tipY < plotTop) tipY = py + 10;
tipX = px - tipSz.x - tipPad * 2 - 10.0f * mktDp;
if (tipY < plotTop) tipY = py + 10.0f * mktDp;
ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f * mktDp);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
@@ -2071,9 +2090,9 @@ static void mktDrawPortfolio(const MktCtx& cx)
}
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
float rowH = (style == 0 ? 40.0f : style == 1 ? 68.0f : 80.0f) * mktDp;
float rowH = pfRowHeight(style, mktDp);
// Table rows abut like a ledger (thin gap); Cards/Spotlight breathe.
float rowGap = (style == 0 ? 2.0f * mktDp : Layout::spacingSm());
float rowGap = pfRowGapFor(style, mktDp);
float rowsH = std::max(rowH, portfolioH - pfSummaryH);
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
@@ -2319,8 +2338,8 @@ void RenderMarketTab(App* app)
for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = (pfStyle == 0 ? 40.0f : pfStyle == 1 ? 68.0f : 80.0f) * mktDp;
float pfRowGap = (pfStyle == 0 ? 2.0f * mktDp : Layout::spacingSm()); // Table rows abut
float pfRowH = pfRowHeight(pfStyle, mktDp);
float pfRowGap = pfRowGapFor(pfStyle, mktDp); // Table rows abut
float pfHeaderH = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f; // Table column strip
const int pfMaxVisibleRows = 4; // rows shown before the list scrolls
// Height for up to pfMaxVisibleRows rows. ItemSpacing is zeroed inside the row child, so the