diff --git a/ROADMAP.md b/ROADMAP.md index f5daa2d3..4268eec9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | +| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | @@ -242,6 +243,7 @@ app's JSON control channel (`deckcraft --control PORT`). |---|---|---| | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | +| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | diff --git a/crates/ui-egui/src/huge_deck_tests.rs b/crates/ui-egui/src/huge_deck_tests.rs index b22cb400..2ff81bdf 100644 --- a/crates/ui-egui/src/huge_deck_tests.rs +++ b/crates/ui-egui/src/huge_deck_tests.rs @@ -37,3 +37,40 @@ fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() { assert!(h.query_all_by_role(Role::TextInput).count() < 200); assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500); } + +/// The thumbnail pane of an 800-slide deck renders only the thumbnails in view. When every row +/// asked for one, a deck bigger than the 600-entry thumbnail cache evicted its own thumbnails before +/// the pane was done, so the pane kept rendering and flashing white forever (#969). +#[test] +fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() { + let mut h = harness(800); + h.run_steps(60); + let settled = h.state().textures.thumb_count(); + assert!(settled > 3 && settled < 60, "{settled} thumbnails rendered"); + // The visible thumbnails are done: more frames render nothing new. + h.run_steps(10); + assert_eq!(h.state().textures.thumb_count(), settled); + // Scrolled far down, the slides now in view get their thumbnails, and only those. + h.hover_at(egui::pos2(100.0, 450.0)); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40_000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(60); + let scrolled = h.state().textures.thumb_count(); + assert!(scrolled > settled && scrolled < 2 * settled + 10, "{scrolled} thumbnails after scrolling (was {settled})"); +} + +/// The same in the Slide Sorter: only the cells in view render a thumbnail (#969). +#[test] +fn the_sorter_of_a_huge_deck_renders_only_the_slides_in_view() { + let mut h = harness(800); + h.state_mut().ui.view = crate::ViewMode::Sorter; + h.run_steps(60); + let settled = h.state().textures.thumb_count(); + assert!(settled > 3 && settled < 80, "{settled} thumbnails rendered"); + h.run_steps(10); + assert_eq!(h.state().textures.thumb_count(), settled); +} diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 4c97605c..3c840860 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -203,13 +203,17 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp); rects.push((i, cell)); let p = ui.painter(); - match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) { + // Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger + // than the thumbnail cache does not evict its own thumbnails forever (#969). + let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell); + let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None }; + match tex { Some(tex) => { p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); - need_more = true; + need_more |= near; } } let id = g.doc.slides.get(i).map(|s| s.id); diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index d6c9c02c..eb26ab19 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -115,7 +115,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint); } let wpx = (img.width() * ppp).round() as u32; - match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) { + // Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a + // deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969). + let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row); + let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None }; + match tex { Some(tex) => { p.image( tex.id(), @@ -126,7 +130,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { } None => { p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE); - need_more = true; + need_more |= near; } } if selected {