// lib/editor/pdf/page_tile_cache.dart // // Bounded LRU cache of rasterized PAGE tiles (ui.Image), DPI-bucketed. // // This is the "heavy" cache (a single A4 page at 3× DPI is ~18 MB) and is // DELIBERATELY SEPARATE from the resolution-independent ink Picture cache // (render/ink_picture_cache.dart): ink is vector and valid at any zoom, but a // page bitmap is only crisp at the DPI it was rasterized for, so its key // carries a DPI bucket (R11 / MF2). On zoom-settle the page_tile renderer // re-rasterizes at the new bucket and put()s it here; matrix-upscale of a lower // bucket is the accepted transient until the new tile lands. // // Tiles are rendered ASYNCHRONOUSLY (pdfrx PdfPage.render / a re-laid-out // PdfPageView), so the cache is get()/put() — NOT getOrBuild — and the caller // owns the async render. Evicted images are disposed via a post-frame callback // so Flutter's raster thread is never asked to free a ui.Image it may still be // sampling this frame. import 'dart:collection'; import 'dart:ui' as ui; import 'package:flutter/widgets.dart'; /// Identity of a cached page tile: which host (page) and which DPI bucket. /// /// The DPI bucket (an integer, e.g. round(scale × base-DPI) snapped to a step) /// keeps the key space small so a smooth pinch doesn't spawn a distinct tile /// per frame — only per bucket. @immutable class TileKey { const TileKey(this.hostId, this.dpiBucket); final String hostId; final int dpiBucket; @override bool operator ==(Object other) => other is TileKey && other.hostId == hostId && other.dpiBucket == dpiBucket; @override int get hashCode => Object.hash(hostId, dpiBucket); @override String toString() => 'TileKey($hostId @dpi$dpiBucket)'; } /// Bounded LRU cache of page-tile [ui.Image]s keyed by [TileKey]. /// /// Capacity is a TILE COUNT (not bytes); size the window to the device memory /// budget — full-DPI tiles for visible ±1 pages, off-window pages downgraded to /// a 1× tier elsewhere (see the plan's R10 resolution). Eviction disposes the /// image post-frame. class PageTileCache { PageTileCache({int maxTiles = 6}) : assert(maxTiles > 0), _maxTiles = maxTiles; final int _maxTiles; // Insertion-ordered; accessed entries are moved to the back so the front is // always the least-recently-used. final LinkedHashMap _cache = LinkedHashMap(); /// Number of tiles currently retained. int get length => _cache.length; /// The keys currently retained, most-recently-used LAST. Iterable get keys => _cache.keys; /// Returns the cached image for [key] (promoting it to most-recently-used), /// or null on a miss. The caller renders + [put]s on a miss. ui.Image? get(TileKey key) { final image = _cache.remove(key); if (image == null) return null; _cache[key] = image; // promote to MRU return image; } /// Inserts [image] for [key], evicting the least-recently-used tiles beyond /// the cap. If a DIFFERENT image was already stored for [key], the old one is /// disposed (post-frame). Re-putting the identical image is a no-op promote. void put(TileKey key, ui.Image image) { final existing = _cache.remove(key); if (existing != null && !identical(existing, image)) { _disposeDeferred(existing); } _cache[key] = image; while (_cache.length > _maxTiles) { final lruKey = _cache.keys.first; _disposeDeferred(_cache.remove(lruKey)!); } } /// Evicts every tile whose host is NOT in [liveHostIds] (e.g. pages that /// scrolled out of the mounted window). Disposed post-frame. void evictHostsExcept(Set liveHostIds) { final doomed = _cache.keys .where((k) => !liveHostIds.contains(k.hostId)) .toList(growable: false); for (final key in doomed) { _disposeDeferred(_cache.remove(key)!); } } /// Disposes all retained tiles (post-frame). Call from the owner's dispose. void dispose() { final images = List.from(_cache.values); _cache.clear(); for (final image in images) { _disposeDeferred(image); } } static void _disposeDeferred(ui.Image image) { // Defer to after the current frame so the raster thread is done with it. // If no binding/frame is scheduled (e.g. a unit test that never pumps), // fall back to disposing on the next microtask so images aren't leaked. final binding = WidgetsBinding.instance; binding.addPostFrameCallback((_) => image.dispose()); binding.scheduleFrame(); } } /// Snaps a continuous render scale to a coarse DPI bucket so a smooth pinch /// re-uses tiles instead of spawning one per frame. [step] is the bucket /// granularity in the same units as [scale] (e.g. 0.5). The result is capped at /// [maxBucket] to bound retained-tile memory (the plan's ~3× cap, R11). int dpiBucketFor(double scale, {double step = 0.5, int maxBucket = 6}) { if (!scale.isFinite || scale <= 0) return 1; final bucket = (scale / step).ceil(); if (bucket < 1) return 1; return bucket > maxBucket ? maxBucket : bucket; }