perf(overview): clip the address list to the visible rows

The Overview address list built all N rows on every frame (icons,
labels, balances, buttons, an InvisibleButton and a context-menu
check per address) and allocated a vector of N row positions, however
few rows fit on screen; the edge mask then walked all their vertices.

An ImGuiListClipper now submits only the rows that intersect the
list's clip rect, plus:
- one guard row on each side, so dividers and anti-aliased edges at
  the clip boundary are drawn as before;
- the row being dragged and the row whose context menu is open, so
  drag-to-reorder and the menu keep working when they scroll away;
- the rows keyboard navigation targets (kept by the clipper itself).

Before each row the cursor is put back exactly where the unclipped
loop left it, so the density-scaled spacing, which depends on the
cursor position, and every row's geometry are unchanged. Row tops are
computed from the index, so the per-frame vector is gone, and the
scroll extent is the same. The row model (sort, filter) is still
rebuilt over all addresses each frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent bb973a1007
commit c2c2a8325b
4 changed files with 100 additions and 11 deletions
+22
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
namespace dragonx {
@@ -106,5 +107,26 @@ std::string FormatAddressUsdValue(double balance, double priceUsd)
return buffer;
}
AddressRowWindow ComputeAddressRowWindow(float clipMinY, float clipMaxY, float startY, float rowH, int rowCount)
{
AddressRowWindow w;
if (rowCount <= 0) return w;
w.end = rowCount; // fail open (every row) on degenerate input
if (!(rowH > 0.0f)) return w;
const double first = std::floor(((double)clipMinY - startY) / rowH) - 1.0;
const double last = std::ceil(((double)clipMaxY - startY) / rowH) + 1.0; // exclusive
if (!std::isfinite(first) || !std::isfinite(last)) return w;
w.begin = (int)std::clamp(first, 0.0, (double)rowCount);
w.end = (int)std::clamp(last, (double)w.begin, (double)rowCount);
return w;
}
float AddressRowEntryCursorY(int i, float startY, float rowH, float itemSpacingY)
{
if (i <= 0) return startY;
const float prevTop = startY + (i - 1) * rowH; // the row loop's row-top expression
return (float)(int)(prevTop + rowH + itemSpacingY); // ImGui::ItemSize: IM_TRUNC(line_y1 + line_height + ItemSpacing.y)
}
} // namespace ui
} // namespace dragonx
+13
View File
@@ -63,5 +63,18 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
float spacingXs);
std::string FormatAddressUsdValue(double balance, double priceUsd);
// ---- Overview address-list clipping (fixed row pitch rowH from startY) ----
// [begin, end) = the rows intersecting [clipMinY, clipMaxY) plus ONE guard row on each side (a row draws its
// divider / AA fringe just past its own slot), clamped to [0, rowCount). Non-finite input -> every row.
struct AddressRowWindow {
int begin = 0;
int end = 0;
};
AddressRowWindow ComputeAddressRowWindow(float clipMinY, float clipMaxY, float startY, float rowH, int rowCount);
// Cursor y the unclipped row loop had when row i's body ran: row 0 = startY; row i > 0 = where ImGui::ItemSize
// (imgui 1.92.6) leaves it after row i-1's InvisibleButton(h = rowH) placed at startY + (i-1)*rowH, i.e.
// IM_TRUNC(top + rowH + ItemSpacing.y). Replaying it keeps the cursor-dependent Layout::spacing*() exact.
float AddressRowEntryCursorY(int i, float startY, float rowH, float itemSpacingY);
} // namespace ui
} // namespace dragonx
+37 -11
View File
@@ -142,6 +142,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// Copy feedback
static int s_copiedRow = -1;
static float s_copiedTime = 0.0f;
static int s_ctxRow = -1; // row whose context popup is open: always submitted (clipper must not drop it)
// ---- Build and filter address rows ----
std::vector<AddressListInput> rowInputs;
@@ -296,11 +297,11 @@ void RenderSharedAddressList(App* app, float listH, float availW,
ImU32 rowSelectCol = S.resolveColor("var(--row-select)", IM_COL32(255, 255, 255, 20));
ImU32 dividerCol = S.resolveColor("var(--sidebar-divider)", IM_COL32(255, 255, 255, 15));
// Compute Y positions for all rows (pass 1)
std::vector<float> rowY(rows.size());
float cursorStartY = ImGui::GetCursorScreenPos().y;
for (int i = 0; i < (int)rows.size(); ++i)
rowY[i] = cursorStartY + i * rowH;
// Row i's top is a pure function of its index (fixed pitch rowH): no per-frame table.
const float cursorStartY = ImGui::GetCursorScreenPos().y;
const float rowLineX = ImGui::GetCursorScreenPos().x; // every row's x (line start)
const float rowItemSpacingY = ImGui::GetStyle().ItemSpacing.y;
auto rowTop = [cursorStartY, rowH](int i) { return cursorStartY + i * rowH; };
// Drag logic — detect drag start, compute drag position
ImVec2 mousePos = ImGui::GetMousePos();
@@ -344,7 +345,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// Reorder — compute insert position from mouseY.
int insertIdx = 0;
for (int i = 0; i < (int)rows.size(); ++i) {
if (mousePos.y > rowY[i] + rowH * 0.5f) insertIdx = i + 1;
if (mousePos.y > rowTop(i) + rowH * 0.5f) insertIdx = i + 1;
}
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1 &&
s_dragIdx < (int)rows.size()) {
@@ -369,14 +370,35 @@ void RenderSharedAddressList(App* app, float listH, float availW,
s_dropMode = 0;
}
// ---- PASS 2: Render rows ----
for (int row_idx = 0; row_idx < (int)rows.size(); row_idx++) {
// ---- PASS 2: Render rows (visible rows only) ----
// ImGuiListClipper submits only rows that intersect the child's clip rect (and, itself, the rows of an
// ImGui nav request / the nav-focused row, so keyboard nav keeps working). Always submitted on top:
// - one guard row on each side of the visible window (a row draws its divider on the next row's top);
// - the row being dragged (its elevation + tooltip follow the mouse; its drag threshold is tested in
// its own body) and the row whose context popup is open (the popup exists only while submitted).
const int rowCount = (int)rows.size();
ImGuiListClipper rowClipper;
rowClipper.Begin(rowCount, rowH);
{
const AddressRowWindow guard = ComputeAddressRowWindow(dl->GetClipRectMin().y, dl->GetClipRectMax().y,
cursorStartY, rowH, rowCount);
rowClipper.IncludeItemsByIndex(guard.begin, guard.end);
}
if (s_dragIdx >= 0 && s_dragIdx < rowCount) rowClipper.IncludeItemByIndex(s_dragIdx);
if (s_ctxRow >= rowCount) s_ctxRow = -1;
if (s_ctxRow >= 0) rowClipper.IncludeItemByIndex(s_ctxRow);
while (rowClipper.Step()) {
for (int row_idx = rowClipper.DisplayStart; row_idx < rowClipper.DisplayEnd; row_idx++) {
// Replay the cursor the unclipped loop had here: the Layout::spacing*() tokens in this body read
// GetContentRegionAvail().y (densityScale), so every row keeps exactly its old geometry.
ImGui::SetCursorScreenPos(ImVec2(rowLineX,
AddressRowEntryCursorY(row_idx, cursorStartY, rowH, rowItemSpacingY)));
const auto& row = rows[row_idx];
const auto& addr = *row.info;
bool isDragged = (s_dragIdx == row_idx && s_dragActive);
// For dragged row, offset Y to follow mouse
float drawY = rowY[row_idx];
float drawY = rowTop(row_idx);
if (isDragged) {
drawY = mousePos.y - s_dragOffsetY;
}
@@ -648,7 +670,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// Advance cursor for interaction area
ImGui::PushID(row_idx);
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowY[row_idx]));
ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowTop(row_idx)));
ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH));
if (ImGui::IsItemHovered() && !s_dragActive) {
if (row.isZ) material::Tooltip("%s", addr.address.c_str());
@@ -658,6 +680,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
// Context menu
const auto& acrTheme = GetCurrentAcrylicTheme();
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("AddressContext", 0, acrTheme.menu)) {
s_ctxRow = row_idx;
if (ImGui::MenuItem(TR("copy_address"))) {
ImGui::SetClipboardText(addr.address.c_str());
s_copiedRow = row_idx;
@@ -729,18 +752,21 @@ void RenderSharedAddressList(App* app, float listH, float availW,
ImGui::EndMenu();
}
effects::ImGuiAcrylic::EndAcrylicPopup();
} else if (s_ctxRow == row_idx) {
s_ctxRow = -1; // its popup closed
}
ImGui::PopID();
// Separator between rows
if (row_idx < (int)rows.size() - 1 && selected_row != row_idx) {
float sepY = rowY[row_idx] + rowH;
float sepY = rowTop(row_idx) + rowH;
dl->AddLine(
ImVec2(rowPos.x + rowPadLeft + rowIconSz * 2.0f, sepY),
ImVec2(rowPos.x + innerW - Layout::spacingLg(), sepY),
dividerCol);
}
}
} // while (rowClipper.Step())
// Advance cursor past all rows
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
+28
View File
@@ -6452,6 +6452,33 @@ void testScrollEdgeFade()
}
}
void testAddressRowWindow()
{
using namespace dragonx::ui;
auto w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 10);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 5); // rows 0-3 visible + guard 4 (its top is the clip edge)
w = ComputeAddressRowWindow(175.0f, 260.0f, 100.0f, 50.0f, 10);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 5); // rows 1-3 visible + guards 0 and 4
w = ComputeAddressRowWindow(420.0f, 520.0f, 100.0f, 50.0f, 20);
EXPECT_EQ(w.begin, 5); EXPECT_EQ(w.end, 10); // rows 6-8 visible + guards 5 and 9
w = ComputeAddressRowWindow(90.0f, 200.0f, 100.0f, 50.0f, 10);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 3); // list starts below the clip top
w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 3);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 3); // clamped to the row count
w = ComputeAddressRowWindow(900.0f, 1000.0f, 100.0f, 50.0f, 5);
EXPECT_EQ(w.begin, 5); EXPECT_EQ(w.end, 5); // nothing visible: empty (IncludeItemsByIndex no-op)
w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 0);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 0);
w = ComputeAddressRowWindow(std::numeric_limits<float>::quiet_NaN(), 300.0f, 100.0f, 50.0f, 7);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 7); // non-finite: fail open (every row)
w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 0.0f, 4);
EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 4); // degenerate pitch: every row
// Row entry cursor = where ImGui::ItemSize left it after the previous row's InvisibleButton.
EXPECT_NEAR(AddressRowEntryCursorY(0, 100.25f, 60.5f, 4.0f), 100.25, 0.0);
EXPECT_NEAR(AddressRowEntryCursorY(1, 100.25f, 60.5f, 4.0f), 164.0, 0.0); // trunc(100.25+60.5+4)
EXPECT_NEAR(AddressRowEntryCursorY(3, 100.25f, 60.5f, 4.0f), 285.0, 0.0); // trunc(221.25+60.5+4)
}
void testMiningBenchmarkModel()
{
dragonx::ui::ThreadBenchmark benchmark;
@@ -14783,6 +14810,7 @@ int main()
testMiningEarningsCache();
testMiningEarningsMidnightRollover();
testScrollEdgeFade();
testAddressRowWindow();
testBalanceAddressListModel();
testExplorerBlockCache();
testTransactionHistoryCache();