From 1c38f6878162680192e7ea00fbe05dbe544a4fb3 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 23:00:02 -0500 Subject: [PATCH] fix(market): HiDPI-scale hand-drawn geometry + dedup row/column helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/windows/market_tab.cpp | 87 +++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 2adfdf9..f6dc62b 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -31,6 +31,7 @@ #include #include #include +#include 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 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