diff --git a/lib/editor/pdf/page_tile_cache.dart b/lib/editor/pdf/page_tile_cache.dart new file mode 100644 index 0000000..e17a1e0 --- /dev/null +++ b/lib/editor/pdf/page_tile_cache.dart @@ -0,0 +1,137 @@ +// 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; +} diff --git a/test/page_tile_cache_test.dart b/test/page_tile_cache_test.dart new file mode 100644 index 0000000..b392b75 --- /dev/null +++ b/test/page_tile_cache_test.dart @@ -0,0 +1,150 @@ +// Unit tests for the DPI-bucketed page-tile cache (P0.5 step 10, automatable +// slice). Covers LRU eviction, MRU promotion, per-key replacement, host +// eviction, dpiBucketFor snapping, and post-frame disposal of evicted images. +// +// The pdfrx tile RENDERING (page_tile.dart) is device-gated and not tested +// here; this is the pure cache data structure. + +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/pdf/page_tile_cache.dart'; + +Future _img() async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 2, 2), + Paint()..color = const Color(0xFF000000), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(2, 2); + picture.dispose(); + return image; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('dpiBucketFor', () { + test('snaps continuous scale to a coarse bucket (ceil by step)', () { + expect(dpiBucketFor(1.0, step: 0.5), 2); // 1.0/0.5 = 2 + expect(dpiBucketFor(1.1, step: 0.5), 3); // ceil(2.2) + expect(dpiBucketFor(0.4, step: 0.5), 1); // ceil(0.8) = 1 + }); + + test('is monotonic non-decreasing in scale', () { + var prev = 0; + for (final s in [0.3, 0.6, 1.0, 1.6, 2.0, 2.9]) { + final b = dpiBucketFor(s, step: 0.5, maxBucket: 100); + expect(b, greaterThanOrEqualTo(prev)); + prev = b; + } + }); + + test('caps at maxBucket (bounds retained-DPI memory)', () { + expect(dpiBucketFor(99.0, step: 0.5, maxBucket: 6), 6); + }); + + test('guards invalid scale', () { + expect(dpiBucketFor(0), 1); + expect(dpiBucketFor(-3), 1); + expect(dpiBucketFor(double.nan), 1); + }); + }); + + group('PageTileCache LRU', () { + test('get returns null on miss, the image on hit', () async { + final cache = PageTileCache(maxTiles: 4); + const key = TileKey('p0', 2); + expect(cache.get(key), isNull); + final img = await _img(); + cache.put(key, img); + expect(identical(cache.get(key), img), isTrue); + cache.dispose(); + }); + + test('TileKey equality is by (hostId, dpiBucket)', () { + expect(const TileKey('p0', 2), const TileKey('p0', 2)); + expect(const TileKey('p0', 2), isNot(const TileKey('p0', 3))); + expect(const TileKey('p0', 2), isNot(const TileKey('p1', 2))); + expect(const TileKey('p0', 2).hashCode, const TileKey('p0', 2).hashCode); + }); + + test('evicts the least-recently-used beyond the cap', () async { + final cache = PageTileCache(maxTiles: 2); + cache.put(const TileKey('a', 1), await _img()); + cache.put(const TileKey('b', 1), await _img()); + cache.put(const TileKey('c', 1), await _img()); // evicts 'a' + expect(cache.length, 2); + expect(cache.keys, isNot(contains(const TileKey('a', 1)))); + expect(cache.get(const TileKey('a', 1)), isNull); + expect(cache.get(const TileKey('b', 1)), isNotNull); + cache.dispose(); + }); + + test('get promotes MRU so the OTHER entry is evicted next', () async { + final cache = PageTileCache(maxTiles: 2); + cache.put(const TileKey('a', 1), await _img()); + cache.put(const TileKey('b', 1), await _img()); + cache.get(const TileKey('a', 1)); // 'a' now MRU → 'b' is LRU + cache.put(const TileKey('c', 1), await _img()); // evicts 'b' + expect(cache.get(const TileKey('a', 1)), isNotNull); + expect(cache.get(const TileKey('b', 1)), isNull); + cache.dispose(); + }); + + test('evictHostsExcept drops other hosts, keeps live ones', () async { + final cache = PageTileCache(maxTiles: 8); + cache.put(const TileKey('p0', 1), await _img()); + cache.put(const TileKey('p0', 2), await _img()); + cache.put(const TileKey('p1', 1), await _img()); + cache.put(const TileKey('p2', 1), await _img()); + cache.evictHostsExcept({'p0', 'p1'}); + expect(cache.keys.map((k) => k.hostId).toSet(), {'p0', 'p1'}); + expect(cache.length, 3); + cache.dispose(); + }); + }); + + group('PageTileCache disposal (post-frame)', () { + testWidgets('an evicted tile is disposed after the frame', (tester) async { + final cache = PageTileCache(maxTiles: 1); + final first = await _img(); + cache.put(const TileKey('a', 1), first); + cache.put(const TileKey('b', 1), await _img()); // evicts 'a' → defers + expect(first.debugDisposed, isFalse, reason: 'deferred, not yet'); + await tester.pump(); // run the post-frame callback + expect(first.debugDisposed, isTrue); + cache.dispose(); + await tester.pump(); + }); + + testWidgets('re-putting a different image for a key disposes the old one', + (tester) async { + final cache = PageTileCache(maxTiles: 4); + final old = await _img(); + cache.put(const TileKey('a', 1), old); + cache.put(const TileKey('a', 1), await _img()); + await tester.pump(); + expect(old.debugDisposed, isTrue); + expect(cache.length, 1); + cache.dispose(); + await tester.pump(); + }); + + testWidgets('dispose() frees all retained tiles', (tester) async { + final cache = PageTileCache(maxTiles: 8); + final a = await _img(); + final b = await _img(); + cache.put(const TileKey('a', 1), a); + cache.put(const TileKey('b', 1), b); + cache.dispose(); + await tester.pump(); + expect(a.debugDisposed, isTrue); + expect(b.debugDisposed, isTrue); + expect(cache.length, 0); + }); + }); +}