Merge pull request #6063 from dotCooCoo/fix/continuous-scroll-prefetch

fix(ui): continuous view renders the pages ahead of the scroll
This commit is contained in:
FredApps authored and GitHub committed 2026-11-04 08:56:30 +01:00
commit 9bbcde4699
5 files changed
+327 -13

No files matched your search

+97 -12
View File
@@ -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<usize>,
/// 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<usize>,
/// Thumbnails that are out of date (still shown until their replacement arrives).
stale_thumbs: HashSet<usize>,
/// 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<Vec2>,
/// 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<usize> {
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<Tile>) {
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<Tile>)], 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<T>(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput<T>, 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<T>(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 {
+15 -1
View File
@@ -158,6 +158,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<usize> {
let mut pages: Vec<usize> = 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.
@@ -418,6 +425,7 @@ impl DocView {
// current page first, so turning back a page shows it at once instead of rendering.
let mut undemanded: Vec<usize> = 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));
@@ -671,7 +679,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));
}
+135
View File
@@ -1273,6 +1273,141 @@ 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<RenderRequest> {
let mut wanted: Vec<(usize, f32, u64, Option<Tile>)> = 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::<usize>::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<usize> = 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<usize> = 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<usize> = scroll_demand(&v, &shown).iter().map(|r| r.page).collect();
v.scroll_dir = 1;
let down: Vec<usize> = 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::<Vec<_>>(), [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<usize> = (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<usize> = 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();
// 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, 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, 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");
}
#[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();
+1
View File
@@ -72,6 +72,7 @@ mod recovery;
mod redact;
mod reload;
mod script_noop;
mod scroll_prefetch;
mod side_panels;
mod signing;
mod stamps;
@@ -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<u8> {
let kids: Vec<String> = (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<usize> {
(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:?}");
}
}