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 2936b743fc..bef9d21975 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -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 { + 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. @@ -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 = 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)); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 461ae954c2..355f29e6b2 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -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 { + 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(); + // 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(); 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:?}"); + } +}