From a262171999a937c95335b2928fb1a1bba7aa9850 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 12:54:13 -0800 Subject: [PATCH 1/2] fix(ui): continuous view renders the pages ahead of the scroll In continuous view the only demand off screen was the pages near the window. A fast wheel spin crosses them faster than a heavy page renders, so pages came into view blank. The view now keeps the direction it last moved and also asks for the next two rows that way (four pages in two-up). A zoom or a layout change clears the direction. Scrolling the other way drops those pages from the queue, which cancels their renders. Pages on screen stay at the head of the queue in their usual order, and the byte and count limits bound the prefetch. The web build renders on the UI thread and does not prefetch. A raster that arrives over the allowance now evicts the cached pages the frame does not ask for first, then those behind the scroll, so it no longer evicts a prefetched page that the next frame asks for again. A prefetched page that is tiled at the current zoom keeps a cached whole-page raster sharper than its backdrop, as a tiled page on screen does. This also applies to the prefetch of single-page and two-page view. --- crates/ui-egui/src/canvas.rs | 109 +++++++++++++++-- crates/ui-egui/src/canvas/cache.rs | 16 ++- crates/ui-egui/src/canvas/tests.rs | 134 +++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/scroll_prefetch.rs | 79 ++++++++++++ 5 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/it/scroll_prefetch.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49d2c44b03..03bfe5030a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -521,6 +521,9 @@ pub struct DocView { /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport /// bounds them, and the byte and count limits apply to prefetch only. frame_visible: HashSet, + /// Pages whose whole-page raster this frame asks for, on screen or prefetched. An arriving + /// raster over the allowance evicts the other cached pages first. + frame_demanded: HashSet, /// Thumbnails that are out of date (still shown until their replacement arrives). stale_thumbs: HashSet, /// Tiles that are out of date (still shown until their replacement arrives). @@ -712,6 +715,13 @@ pub struct DocView { /// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay /// stretched meanwhile; fresh tiles go out once the pan settles. pub(crate) last_scroll: Vec2, + /// The way the view last moved vertically: 1 down, -1 up, 0 before any move and after the + /// content's size changes (a zoom or a layout change). A jump to a page or a search result + /// counts as a move. It stays set while the view is still, so the pages prefetched ahead stay + /// in the render queue until they arrive. + scroll_dir: i8, + /// The content's size when `scroll_dir` was last updated. + scroll_content: Vec2, /// Side-by-side compare: scroll to here this frame, following the other half. pub(crate) sync_scroll: Option, /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. @@ -929,6 +939,7 @@ impl DocView { frame_queue: Vec::new(), frame_geometry_changed: false, frame_visible: HashSet::new(), + frame_demanded: HashSet::new(), stale_thumbs: HashSet::new(), stale_tiles: HashSet::new(), current_scale_tag: 0, @@ -996,6 +1007,8 @@ impl DocView { last_scale: 0.0, sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, + scroll_dir: 0, + scroll_content: Vec2::ZERO, sync_scroll: None, page_widget: None, pan_hold_until: 0.0, @@ -2917,6 +2930,56 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } } +/// Rows of pages that continuous view prefetches past the window in the direction it last +/// moved. +const PREFETCH_ROWS: usize = 2; + +/// The pages continuous view prefetches past the window: the next [`PREFETCH_ROWS`] rows the way +/// it last moved, nearest first, and none before it moves. The web build renders on the UI +/// thread, so it prefetches none. +fn scroll_prefetch(view: &DocView, visible_now: &[usize]) -> Vec { + if !view.continuous || cfg!(target_arch = "wasm32") { + return Vec::new(); + } + let n = PREFETCH_ROWS * if view.layout == PageLayout::Single { 1 } else { 2 }; + match (view.scroll_dir, visible_now.iter().min(), visible_now.iter().max()) { + (1, _, Some(&last)) => (last.saturating_add(1)..=last.saturating_add(n)).collect(), + (-1, Some(&first), _) => (first.saturating_sub(n)..first).rev().collect(), + _ => Vec::new(), + } +} + +/// The whole-page raster a prefetch asks for: the page at this scale, or the backdrop of a page +/// that is tiled at this scale. A cached raster sharper than the backdrop stays demanded as it +/// is, as `paint_page_surface` demands it under tiles. +fn prefetch_demand(view: &DocView, p: &pdfcraft_render::PageInfo, i: usize, scale: f32, tag: u64, max_side: f32) -> (usize, f32, u64, Option) { + let side = p.width.max(p.height).max(1.0); + if side * scale <= TILE_THRESHOLD.min(max_side) { + return (i, scale, tag, None); + } + let bs = BASE_SIDE.min(max_side) / side; + match view.pages.get(&i).filter(|c| c.tag != STALE_TAG && tag_scale(c.tag) >= bs) { + Some(c) => (i, tag_scale(c.tag), c.tag, None), + None => (i, bs, scale_tag(bs), None), + } +} + +/// Whether `page` lies behind the way continuous view last moved: above the current page while +/// scrolling down, below it while scrolling up. `false` outside continuous view. +fn behind_scroll(continuous: bool, scroll_dir: i8, current: usize, page: usize) -> bool { + continuous && ((scroll_dir > 0 && page < current) || (scroll_dir < 0 && page > current)) +} + +/// Orders a frame's demand: pages on screen first, then off-screen pages ahead of the way the +/// view last moved, then those behind it, each nearest the current page first. +fn order_demand(view: &DocView, wanted: &mut [(usize, f32, u64, Option)], visible_now: &[usize]) { + let cur = view.current; + wanted.sort_by_key(|w| { + let shown = visible_now.contains(&w.0); + (!shown, !shown && behind_scroll(view.continuous, view.scroll_dir, cur, w.0), w.0.abs_diff(cur), w.3.is_some()) + }); +} + /// What decides which renders a frame asks for. #[derive(Clone, Copy)] struct RenderPlan<'a> { @@ -2929,7 +2992,8 @@ struct RenderPlan<'a> { } /// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the -/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. +/// rows either side (in continuous view, the rows ahead in the scroll direction), frees tiles of far +/// pages and old zooms, and asks for the text layers. fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { let (mut wanted, visible_now) = renders; let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; @@ -2944,17 +3008,20 @@ fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: Page for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; // As drawn: the whole page at this scale, or the backdrop of a tiled page. - let side = p.width.max(p.height).max(1.0); - wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { - let bs = BASE_SIDE.min(max_side) / side; - (i, bs, scale_tag(bs), None) - } else { - (i, scale, tag, None) - }); + wanted.push(prefetch_demand(view, p, i, scale, tag, max_side)); } } + // Continuous view also asks for the rows ahead of the way it last moved. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in scroll_prefetch(view, &visible_now) { + if view.errors.contains_key(&i) || wanted.iter().any(|w| w.0 == i) { + continue; + } + let Some(p) = info.pages.get(i) else { continue }; + wanted.push(prefetch_demand(view, p, i, scale, tag, max_side)); + } let cur = view.current; - wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); + order_demand(view, &mut wanted, &visible_now); // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page // that is on screen, whether or not the page is tiled at this zoom (#2196): a zoom out below // the tiling threshold used to drop the tiles of the zoom just left, so coming back to it @@ -3187,9 +3254,10 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry scroll } -/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, -/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether -/// a pan is still settling. +/// Notes how far and which way the page view scrolled this frame: reader-owned scrolling ends +/// search following, a pan holds back fresh tile renders until it settles, a glide keeps going or +/// stops, and the vertical direction is kept for continuous view's prefetch. Whether a pan is +/// still settling. fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { let scroll_offset = out.state.clamped_offset(); // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas @@ -3213,10 +3281,27 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc if glide != Vec2::ZERO { view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); } + view.scroll_dir = scroll_direction(view.scroll_dir, scroll_offset.y - view.last_scroll.y, out.content_size != view.scroll_content); + view.scroll_content = out.content_size; view.last_scroll = scroll_offset; now_settle < view.pan_hold_until } +/// The way the view moves after a frame whose offset changed by `dy` points: down (1) or up (-1) +/// for a move of more than half a point, `dir` otherwise, and 0 when the content's size changed +/// (a zoom or a layout change). +fn scroll_direction(dir: i8, dy: f32, resized: bool) -> i8 { + if resized { + 0 + } else if dy > 0.5 { + 1 + } else if dy < -0.5 { + -1 + } else { + dir + } +} + /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index 4b6f6c2e42..551674c9d6 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -137,6 +137,13 @@ impl DocView { k } + /// The pages with a cached whole-page raster, in page order (diagnostics and tests). + pub fn cached_pages_debug(&self) -> Vec { + let mut pages: Vec = self.pages.keys().copied().collect(); + pages.sort_unstable(); + pages + } + /// Re-render every page and text layer (the document's appearance changed, e.g. a layer /// was toggled). Out-of-date rasters stay on screen until their replacements arrive, so the /// view never flashes blank. @@ -386,6 +393,7 @@ impl DocView { // current page first, so turning back a page shows it at once instead of rendering. let mut undemanded: Vec = self.pages.keys().copied().filter(|p| !pages.contains(p)).collect(); undemanded.sort_by_key(|p| p.abs_diff(self.current)); + self.frame_demanded = pages.clone(); let mut kept = pages.len(); for page in undemanded { let bytes = self.pages.get(&page).map_or(0, |p| texture_bytes(&p.tex)); @@ -638,7 +646,13 @@ impl DocView { // demand estimates. Evict those first, then let the current demand refill them. let on_screen = |p: &usize| !thumb && self.frame_visible.contains(p); while retained.saturating_add(bytes) > limit { - let Some(page) = cache.keys().copied().filter(|p| !on_screen(p)).max_by_key(|p| p.abs_diff(self.current)) else { break }; + // Pages this frame does not ask for go first, then those behind the scroll, the + // farthest from the current page first. + let evict_order = |p: &usize| { + let demanded = !thumb && self.frame_demanded.contains(p); + (!demanded, !thumb && super::behind_scroll(self.continuous, self.scroll_dir, self.current, *p), p.abs_diff(self.current)) + }; + let Some(page) = cache.keys().copied().filter(|p| !on_screen(p)).max_by_key(evict_order) else { break }; if let Some(old) = cache.remove(&page) { retained = retained.saturating_sub(texture_bytes(&old.tex)); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4761584735..c5503be2aa 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1273,6 +1273,140 @@ fn page_and_tile_admission_accounts_for_bytes_before_count_limits() { assert!(!v.admit_texture(RenderRequest::default(), PAGE_BYTES + 1)); } +/// The frame demand of a continuous view showing `shown`, with its prefetch, in queue order. +fn scroll_demand(v: &DocView, shown: &[usize]) -> Vec { + let mut wanted: Vec<(usize, f32, u64, Option)> = shown.iter().map(|&p| (p, 1.0, 1000, None)).collect(); + wanted.extend(scroll_prefetch(v, shown).into_iter().map(|p| (p, 1.0, 1000, None))); + order_demand(v, &mut wanted, shown); + wanted.into_iter().map(|(page, scale, tag, tile)| RenderRequest { page, scale, tag, tile, ..Default::default() }).collect() +} + +#[test] +fn continuous_view_prefetches_the_rows_ahead_of_the_way_it_moved() { + let mut v = view(20, PageLayout::Single, true); + v.current = 10; + assert!(scroll_prefetch(&v, &[9, 10]).is_empty(), "nothing before the view moves"); + v.scroll_dir = 1; + assert_eq!(scroll_prefetch(&v, &[9, 10]), [11, 12]); + v.scroll_dir = -1; + assert_eq!(scroll_prefetch(&v, &[9, 10]), [8, 7]); + assert_eq!(scroll_prefetch(&v, &[0, 1]), Vec::::new(), "nothing above the first page"); + let mut two = view(20, PageLayout::TwoUp, true); + two.scroll_dir = 1; + assert_eq!(scroll_prefetch(&two, &[8, 9]), [10, 11, 12, 13], "two rows of two pages"); + let mut single = view(20, PageLayout::Single, false); + single.scroll_dir = 1; + assert!(scroll_prefetch(&single, &[9]).is_empty(), "single-page view keeps its own prefetch"); +} + +#[test] +fn scrolling_the_other_way_drops_the_prefetch_from_the_queue() { + let info = DocInfo { + pages: (0..20) + .map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { continuous: true, ..Default::default() }); + v.current = 10; + v.scroll_dir = 1; + v.frame_visible = [9, 10].into(); + v.frame_queue = scroll_demand(&v, &[9, 10]); + let down: Vec = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect(); + assert_eq!(down, [10, 9, 11, 12], "on-screen pages first, then the pages below"); + v.scroll_dir = -1; + v.frame_queue = scroll_demand(&v, &[9, 10]); + let up: Vec = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect(); + assert_eq!(up, [10, 9, 8, 7], "the pages below leave the queue, which cancels their renders"); +} + +#[test] +fn the_way_the_view_moved_does_not_reorder_the_pages_on_screen() { + let mut v = view(20, PageLayout::Single, true); + v.current = 10; + let shown = [8, 9, 10, 11, 12]; + let still: Vec = scroll_demand(&v, &shown).iter().map(|r| r.page).collect(); + v.scroll_dir = 1; + let down: Vec = scroll_demand(&v, &shown).iter().map(|r| r.page).collect(); + assert_eq!(down[..5], still[..5], "on-screen pages keep their order"); + assert_eq!(down[5..], [13, 14]); + // Off-screen pages ahead of the move come before nearer ones behind it. + let mut wanted = vec![(9, 1.0, 1000, None), (13, 1.0, 1000, None), (10, 1.0, 1000, None)]; + order_demand(&v, &mut wanted, &[10]); + assert_eq!(wanted.iter().map(|w| w.0).collect::>(), [10, 13, 9]); +} + +#[test] +fn the_prefetch_ahead_stays_within_the_page_allowance() { + let mut info = thumbnail_info(20); + for p in &mut info.pages { + p.width = 2000.0; + p.height = 2000.0; + } + let mut v = DocView::new(DocId(1), &info, ViewDefaults { continuous: true, ..Default::default() }); + v.current = 3; + v.scroll_dir = 1; + // Eight 16 MB pages on screen leave no room in the 128 MiB allowance for another. + let shown: Vec = (0..8).collect(); + v.frame_visible = shown.iter().copied().collect(); + v.frame_queue = scroll_demand(&v, &shown); + assert!(v.frame_queue.iter().any(|r| r.page == 8), "the prefetch asks for the next page"); + let pages: Vec = v.prepare_render_queue(&info, 1.0).iter().map(|r| r.page).collect(); + assert_eq!(pages.iter().copied().max(), Some(7), "no prefetch past the allowance: {pages:?}"); +} + +#[test] +fn an_arriving_raster_evicts_pages_nobody_asks_for_and_then_pages_behind_the_scroll() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 4096); + let mut v = view(20, PageLayout::Single, true); + let texture = |name| ctx.load_texture(name, egui::ColorImage::filled([4000, 4000], Color32::WHITE), TextureOptions::LINEAR); + v.current = 10; + v.scroll_dir = 1; + v.frame_visible = [10].into(); + let page = RenderRequest { page: 10, scale: 1.0, tag: 1000, ..Default::default() }; + let bytes = rgba_bytes(4000, 4000); + // Page 7 is behind the scroll and no longer asked for; page 14 is the prefetch ahead. + v.frame_demanded = [10, 14].into(); + v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") }); + v.pages.insert(14, PageTex { tag: 1000, tex: texture("ahead") }); + assert!(v.admit_texture(page, bytes)); + assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page nobody asks for goes first"); + // Both asked for: the page behind the scroll goes, though it is nearer. + v.frame_demanded = [7, 10, 14].into(); + v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") }); + assert!(v.admit_texture(page, bytes)); + assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page behind the scroll goes first"); +} + +#[test] +fn a_tiled_page_ahead_keeps_its_sharper_raster() { + let ctx = egui::Context::default(); + let mut v = view(20, PageLayout::Single, true); + let p = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let (scale, max_side) = (12.0, 16384.0); + let backdrop = BASE_SIDE / 400.0; + // At 12× the page is 4800 pixels tall: tiled, with a 2048-pixel backdrop. + assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None)); + assert_eq!(prefetch_demand(&v, &p, 3, 2.0, scale_tag(2.0), max_side), (3, 2.0, scale_tag(2.0), None), "not tiled: the page as drawn"); + let texture = |name| ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR); + v.pages.insert(3, PageTex { tag: scale_tag(8.0), tex: texture("sharper") }); + assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, 8.0, scale_tag(8.0), None), "the sharper raster is kept"); + v.pages.insert(3, PageTex { tag: scale_tag(2.0), tex: texture("softer") }); + assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None)); + v.pages.insert(3, PageTex { tag: STALE_TAG, tex: texture("stale") }); + assert_eq!(prefetch_demand(&v, &p, 3, scale, scale_tag(scale), max_side), (3, backdrop, scale_tag(backdrop), None)); +} + +#[test] +fn a_zoom_or_layout_change_clears_the_scroll_direction() { + assert_eq!(scroll_direction(0, 40.0, false), 1); + assert_eq!(scroll_direction(1, -40.0, false), -1); + assert_eq!(scroll_direction(-1, 0.2, false), -1, "a still view keeps its direction"); + assert_eq!(scroll_direction(1, -300.0, true), 0, "a zoom out moves the offset up without a scroll"); + assert_eq!(scroll_direction(-1, 300.0, true), 0); +} + #[test] fn on_screen_pages_and_tiles_are_admitted_beyond_the_prefetch_allowance() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index bce7c4d069..20243ea86d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -72,6 +72,7 @@ mod recovery; mod redact; mod reload; mod script_noop; +mod scroll_prefetch; mod side_panels; mod signing; mod stamps; diff --git a/crates/ui-egui/tests/it/scroll_prefetch.rs b/crates/ui-egui/tests/it/scroll_prefetch.rs new file mode 100644 index 0000000000..3dbc01ba35 --- /dev/null +++ b/crates/ui-egui/tests/it/scroll_prefetch.rs @@ -0,0 +1,79 @@ +//! Continuous view renders the pages ahead of the scroll before they come into view. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +const PAGES: usize = 20; + +/// Twenty empty 300×400 pt pages. +fn pages_pdf() -> Vec { + let kids: Vec = (0..PAGES).map(|i| format!("{} 0 R", 3 + i)).collect(); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{}] /Count {PAGES} /MediaBox [0 0 300 400] >> endobj\n", + kids.join(" ") + ); + for i in 0..PAGES { + pdf.push_str(&format!("{} 0 obj << /Type /Page /Parent 2 0 R >> endobj\n", 3 + i)); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + pdf.into_bytes() +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pages.pdf", None, pages_pdf()).expect("opens"); + for (k, v) in [("left", "closed"), ("panel", "none"), ("layout", "continuous"), ("zoom", "100")] { + app.set_option(k, v).unwrap(); + } + app + }); + settle(&mut h); + h +} + +/// Runs frames until no render is queued. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + h.run_steps(4); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while h.state().render_pending() { + assert!(std::time::Instant::now() < deadline, "renders settle"); + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); +} + +/// Goes to a page (1-based, as the page box takes it) and waits for its renders. +fn go_to(h: &mut Harness<'static, PdfCraftApp>, page: usize) { + h.state_mut().set_option("page", &page.to_string()).unwrap(); + settle(h); +} + +fn shown(h: &Harness<'static, PdfCraftApp>) -> Vec { + (0..PAGES).filter(|&p| h.state().views[0].page_screen_rect(p).is_some()).collect() +} + +#[test] +fn continuous_view_prefetches_the_pages_ahead_of_the_scroll() { + let mut h = harness(); + go_to(&mut h, 14); + let shown_down = shown(&h); + let last = *shown_down.last().expect("pages on screen"); + let cached = h.state().views[0].cached_pages_debug(); + // The page just past the window is within the 400 pt band every frame asks for; the one + // after it is past the band, and only the prefetch asks for it. + for page in last + 1..=last + 2 { + assert!(cached.contains(&page), "page {page} renders ahead of a scroll down: shown {shown_down:?}, cached {cached:?}"); + } + + // Scrolling back up prefetches above the window instead. + go_to(&mut h, 9); + let shown_up = shown(&h); + let first = *shown_up.first().expect("pages on screen"); + let cached = h.state().views[0].cached_pages_debug(); + for page in first.saturating_sub(2)..first { + assert!(cached.contains(&page), "page {page} renders ahead of a scroll up: shown {shown_up:?}, cached {cached:?}"); + } +} From d21abe98815ed6daee933b3318e9ccf8bced8522 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:42:59 +0100 Subject: [PATCH 2/2] test(ui): keep the eviction-order test over the window-sized allowance --- crates/ui-egui/src/canvas/tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 70da6dd6ed..355f29e6b2 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1364,16 +1364,17 @@ fn an_arriving_raster_evicts_pages_nobody_asks_for_and_then_pages_behind_the_scr v.current = 10; v.scroll_dir = 1; v.frame_visible = [10].into(); - let page = RenderRequest { page: 10, scale: 1.0, tag: 1000, ..Default::default() }; + // A prefetch off screen: a raster arriving on screen would widen the allowance to hold it. + let page = RenderRequest { page: 11, scale: 1.0, tag: 1000, ..Default::default() }; let bytes = rgba_bytes(4000, 4000); // Page 7 is behind the scroll and no longer asked for; page 14 is the prefetch ahead. - v.frame_demanded = [10, 14].into(); + v.frame_demanded = [10, 11, 14].into(); v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") }); v.pages.insert(14, PageTex { tag: 1000, tex: texture("ahead") }); assert!(v.admit_texture(page, bytes)); assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page nobody asks for goes first"); // Both asked for: the page behind the scroll goes, though it is nearer. - v.frame_demanded = [7, 10, 14].into(); + v.frame_demanded = [7, 10, 11, 14].into(); v.pages.insert(7, PageTex { tag: 1000, tex: texture("behind") }); assert!(v.admit_texture(page, bytes)); assert!(v.pages.contains_key(&14) && !v.pages.contains_key(&7), "the page behind the scroll goes first");