perf: desktop row virtualization, phase 2

Render List/Card/Grid through ScrollArea::show_rows so only the visible
row range is laid out per frame. Fixed-size cells (allocate_ui_with_layout
+ set_min_size) enforce a uniform lattice; titles truncate to one line with
the full title on hover. List separators are painted hlines inside the
cell; Grid is a manual horizontal run of fixed cells per lattice row with
cols computed from available width before show_rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-09 02:52:04 -07:00
parent d4bed019b2
commit 23ed80a3b7
No known key found for this signature in database
2 changed files with 221 additions and 67 deletions

View file

@ -1,7 +1,51 @@
# Session handoff — Catacomb
> Working notes for continuing this work in another session/agent (Copilot, Codex, etc.).
> Updated 2026-06-19. Delete or update freely; this is not a tracked spec.
> Updated 2026-07-08. Delete or update freely; this is not a tracked spec.
## Current work (2026-07-08): desktop row virtualization — DONE, ready to commit
- `main` is at `d4bed01` (perf pass phase 1: WAL pragmas, prepare_cached,
batched scan writes, sort_by_cached_key, panic=abort) — committed + pushed.
- **`src/app.rs`: desktop row virtualization** (the follow-up the perf spec
deferred; spec at
`docs/superpowers/specs/2026-07-07-runtime-performance-pass-design.md`):
- List/Card/Grid all render through `ScrollArea::show_rows` — only the
visible row range is laid out per frame.
- Row heights are enforced by fixed-size cells
(`allocate_ui_with_layout` + `set_min_size`), built from
`row_text_block_height(ui)` + density-scaled thumb height. Titles
truncate to one line (`wrap_mode = Truncate`, full title on hover) —
required for the fixed lattice.
- List separator is a painted hline inside the cell (the old
`ui.separator()` added unpredictable height). Grid is a manual
horizontal run of `cols` fixed cells per lattice row (egui::Grid can't
be windowed); `cols` is computed before `show_rows` from available width.
- **Verified (live GUI, screenshots in session scratchpad):**
- List @ 2000 — windowed rendering, uniform lattice, both-direction scroll,
stable-sort interleave, selection + details pane; @ 24 — exact bottom
termination, single-line ellipsis truncation.
- Card — uniform fixed-height cards, single-line truncation, hover ring.
- Grid — 2 cols @1400px window, 3 cols @1500px, uniform lattice, no clip,
truncated titles, full button rows. `cols` computed to fit (conservative;
never overcommits/clips off-screen).
- `cargo build --release` clean; `cargo test --release` = 134 unit + 12
integration, 0 failures.
- **Known pre-existing issue (NOT this change):** at the ~1000px window
minimum width, the central panel's content origin shifts left and underlaps
the ~290px sidebar (column 1 / card left edge clipped), because the top
header/toolbar row overflows and shoves the layout. Confirmed **identical on
the pre-virtualization binary** (git-stash test), so it's not a regression.
Root cause is the non-wrapping header `ui.horizontal`; a real fix would make
that row wrap or clip. Left as a separate follow-up.
- **GUI screenshot recipe on this box (Wayland/KWin):** winit defaults to a
native Wayland surface that xdotool/`import` can't see. Launch with
`env -u WAYLAND_DISPLAY WINIT_UNIX_BACKEND=x11 DISPLAY=:0` to force XWayland,
then `xdotool search --pid`, `windowsize`, and `import -window <wid> out.png`
all work. The window has a hard min size ~1000px (xdotool can't shrink
below it). Foreground `sleep` is blocked by the harness Bash tool — put
launch+settle+capture in a script file and run that. `spectacle -b -a` was
flaky here; `import -window <wid>` is reliable.
## TL;DR — where things are

View file

@ -4308,27 +4308,96 @@ impl App {
let density = self.card_density;
let view_mode = self.view_mode_for(&self.sidebar_view);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
// Virtualized rendering: every mode goes through `show_rows`, which
// lays out only the visible slice of a fixed-height row lattice, so
// frame cost tracks the viewport, not the library size. Row heights
// are enforced by allocating fixed-size cells (titles truncate to a
// single line inside them — see render_row_body), never measured
// from content, so the scrollbar geometry stays exact.
let thumb_h = (99.0 * density).round();
let text_h = Self::row_text_block_height(ui);
match view_mode {
ViewMode::List | ViewMode::Card => {
self.render_video_rows(ui, ctx, &cards, density, show_channel, view_mode == ViewMode::Card);
let as_card = view_mode == ViewMode::Card;
// Card mode wraps the row in a Frame: 8px inner margin and
// 3px outer margin top+bottom. List draws a bottom hairline.
let row_h = if as_card {
thumb_h.max(text_h) + 2.0 * 8.0 + 2.0 * 3.0
} else {
thumb_h.max(text_h) + 6.0
};
egui::ScrollArea::vertical().auto_shrink([false, false]).show_rows(
ui,
row_h,
cards.len(),
|ui, range| {
self.render_video_rows(
ui, ctx, &cards, range, row_h, density, show_channel, as_card,
);
},
);
}
ViewMode::Grid => {
self.render_video_grid(ui, ctx, &cards, density, show_channel);
let thumb_w = (176.0 * density).round();
// A grid cell is as wide as its widest line, and that is the
// action-button row (Play/Details/flags ≈ 300px at default
// fonts), not the thumbnail, whenever density is small. Size
// the lattice to the real footprint or the columns drift
// past the computed count and the last one clips off-screen.
let cell_w = (thumb_w + 8.0).max(310.0);
let gap_x = ui.spacing().item_spacing.x;
// Column count must be fixed before show_rows (it defines
// the row lattice), so compute it from the width available
// here rather than inside the scroll closure.
let avail = ui.available_width();
let cols = (((avail + gap_x) / (cell_w + gap_x)).floor() as usize).max(1);
let grid_rows = cards.len().div_ceil(cols);
let cell_h = thumb_h + text_h;
egui::ScrollArea::vertical().auto_shrink([false, false]).show_rows(
ui,
cell_h,
grid_rows,
|ui, range| {
self.render_video_grid(
ui, ctx, &cards, range, cols, cell_w, cell_h, density, show_channel,
);
},
);
}
}
});
self.cards_cache = cards;
}
/// Fixed height of the text block beside/under a thumbnail: truncated
/// title line + meta line + button row + the item spacing between them.
/// Derived from live style metrics so theme/font changes stay correct.
/// Every virtualized cell height is built from this — the content is
/// then laid out inside a cell of exactly that size, which is what lets
/// `show_rows` trust the row lattice.
fn row_text_block_height(ui: &egui::Ui) -> f32 {
let title_h = ui.fonts(|f| f.row_height(&egui::FontId::proportional(14.0)));
let body_h = ui.text_style_height(&egui::TextStyle::Body);
let button_h = body_h + 2.0 * ui.spacing().button_padding.y;
let gap = ui.spacing().item_spacing.y;
// title (selectable_label pads like a button) + meta labels + buttons
(title_h + 2.0 * ui.spacing().button_padding.y) + body_h + button_h + 2.0 * gap
}
/// Row-based render path shared by List and Card modes. When `as_card`
/// is true, each row is wrapped in a rounded faint-bg card with a hover
/// accent ring; otherwise the row is rendered flat (legacy List style).
///
/// Virtualized: called by `show_rows` with the visible `range` only, and
/// each row is allocated exactly `row_h` tall regardless of content so
/// the scroll geometry the lattice promised holds.
#[allow(clippy::too_many_arguments)]
fn render_video_rows(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
cards: &[Card],
range: std::ops::Range<usize>,
row_h: f32,
density: f32,
show_channel: bool,
as_card: bool,
@ -4337,7 +4406,7 @@ impl App {
let thumb_h = (99.0 * density).round();
let thumb_size = egui::vec2(thumb_w, thumb_h);
for card in cards {
for card in &cards[range] {
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
let bulk_checked = self.bulk_selected.contains(&card.id);
@ -4348,9 +4417,17 @@ impl App {
// mutate `self.flags` + DB without fighting the borrow checker.
let mut toggle_flag_card: Option<&'static str> = None;
// Wrap each row in a Frame when rendering as a Card.
// Fixed-size cell: content lays out inside exactly `row_h`, so
// the parent advances by the same height show_rows promised.
let cell_w = ui.available_width();
let mut card_rect: Option<egui::Rect> = None;
let cell = ui.allocate_ui_with_layout(
egui::vec2(cell_w, row_h),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_min_size(egui::vec2(cell_w, row_h));
if as_card {
// Wrap the row in a rounded faint-bg card.
let frame = egui::Frame::default()
.fill(ui.visuals().faint_bg_color)
.stroke(egui::Stroke::new(
@ -4361,6 +4438,7 @@ impl App {
.inner_margin(egui::Margin::same(8.0))
.outer_margin(egui::Margin::symmetric(0.0, 3.0));
let resp = frame.show(ui, |ui| {
ui.set_min_width(cell_w - 2.0 * 8.0);
ui.horizontal(|ui| {
self.render_row_body(
ui, ctx, card, density, thumb_size, show_channel,
@ -4383,6 +4461,8 @@ impl App {
);
});
}
},
);
if let Some(rect) = card_rect {
ui.painter().rect_stroke(
@ -4391,36 +4471,48 @@ impl App {
egui::Stroke::new(1.5, self.theme_accents.accent),
);
}
if !as_card {
// The old per-row ui.separator() would add unpredictable
// height; paint the divider inside the fixed cell instead.
let r = cell.response.rect;
ui.painter().hline(
r.x_range(),
r.bottom() - 1.0,
ui.visuals().widgets.noninteractive.bg_stroke,
);
}
self.apply_card_actions(
card, clicked_card, play_card, toggle_watched_card, toggle_flag_card,
);
ui.separator();
}
}
/// Grid mode: YouTube/Plex-style vertical cards in a responsive grid.
///
/// Virtualized: `show_rows` hands us the visible *grid-row* range; each
/// grid row is a horizontal run of `cols` fixed-size cells. (egui::Grid
/// can't be windowed, so the lattice is laid out manually.)
#[allow(clippy::too_many_arguments)]
fn render_video_grid(
&mut self,
ui: &mut egui::Ui,
ctx: &egui::Context,
cards: &[Card],
range: std::ops::Range<usize>,
cols: usize,
cell_w: f32,
cell_h: f32,
density: f32,
show_channel: bool,
) {
let thumb_w = (176.0 * density).round();
let thumb_h = (99.0 * density).round();
let card_w = thumb_w + 8.0;
let avail = ui.available_width();
let cols = ((avail / card_w).floor() as usize).max(1);
// Grid mode renders one cell at a time but still uses the deferred-
// flags pattern: actions are applied per-card right after its cell.
egui::Grid::new("video_grid")
.num_columns(cols)
.spacing([8.0, 8.0])
.show(ui, |ui| {
for card in cards {
for grid_row in range {
ui.horizontal(|ui| {
let start = grid_row * cols;
for card in cards.iter().skip(start).take(cols) {
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
let bulk_checked = self.bulk_selected.contains(&card.id);
@ -4429,24 +4521,38 @@ impl App {
let mut toggle_watched_card = false;
let mut toggle_flag_card: Option<&'static str> = None;
ui.vertical(|ui| {
// `render_row_body` owns the thumbnail + rings + clicks,
// so the grid just lays out title/meta below it.
// Hard-allocate the cell rect so the parent advances by
// exactly cell_w whatever the content lays out to (a
// soft allocate_ui grows with the widest child — button
// rows and truncating labels then drift the lattice
// wider than the column count assumed). The child ui is
// clipped to the cell so nothing bleeds into a neighbor.
let (cell_rect, _) = ui.allocate_exact_size(
egui::vec2(cell_w, cell_h),
egui::Sense::hover(),
);
let mut cell_ui = ui.new_child(
egui::UiBuilder::new()
.max_rect(cell_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
cell_ui.set_clip_rect(cell_rect.intersect(ui.clip_rect()));
// `render_row_body` owns the thumbnail + rings + clicks;
// the grid just stacks title/meta below.
self.render_row_body(
ui, ctx, card, density, egui::vec2(thumb_w, thumb_h), show_channel,
&mut cell_ui, ctx, card, density, egui::vec2(thumb_w, thumb_h), show_channel,
selected, is_playing, bulk_checked,
&mut clicked_card, &mut play_card,
&mut toggle_watched_card, &mut toggle_flag_card,
);
});
self.apply_card_actions(
card, clicked_card, play_card, toggle_watched_card, toggle_flag_card,
);
ui.end_row();
}
});
}
}
/// The thumbnail + metadata + flag-button body shared by List/Card/Grid.
/// Owns the thumbnail paint, selection/playing/bulk rings, watched
@ -4528,6 +4634,9 @@ impl App {
}
ui.vertical(|ui| {
// Truncate, never wrap: the virtualized cells are fixed-height,
// so a long title must clamp to one line. Full title on hover.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
let title_color = if card.watched {
ui.visuals().weak_text_color()
} else {
@ -4541,6 +4650,7 @@ impl App {
.size(14.0)
.color(title_color),
)
.on_hover_text(&card.title)
.clicked()
{
*clicked_card = true;