Compare commits
2 Commits
eca5141372
...
1a3d1065f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a3d1065f9 | |||
| 07b543f3e1 |
153
lib/editor/layout/page_viewport.dart
Normal file
153
lib/editor/layout/page_viewport.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
// lib/editor/layout/page_viewport.dart
|
||||
//
|
||||
// Continuous-single layout geometry: the PURE windowing math that decides which
|
||||
// pages are mounted for a given scroll position (P0.5 step 10). Pages stack
|
||||
// vertically; only the pages intersecting the viewport ± a cache band are
|
||||
// mounted (windowed lazy hosting → 60fps on a 300-page doc, R1).
|
||||
//
|
||||
// This file is intentionally widget-free and pdfrx-free: the actual page
|
||||
// mounting (a re-laid-out PdfPageView + the AnnotationLayer per page) and the
|
||||
// zoom-settle DPI refresh are device-gated and live in the viewport WIDGET +
|
||||
// pdf/page_tile.dart. The geometry is separated so it is unit-testable without
|
||||
// a GPU or a real document.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// An inclusive range of page indices to mount. [isEmpty] when nothing
|
||||
/// intersects (e.g. an empty document or a scroll position past the end with no
|
||||
/// cache band reaching back).
|
||||
class PageWindow {
|
||||
const PageWindow(this.first, this.last);
|
||||
|
||||
/// Sentinel empty window.
|
||||
static const PageWindow empty = PageWindow(0, -1);
|
||||
|
||||
final int first;
|
||||
final int last;
|
||||
|
||||
bool get isEmpty => last < first;
|
||||
int get count => isEmpty ? 0 : (last - first + 1);
|
||||
|
||||
bool contains(int index) => !isEmpty && index >= first && index <= last;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PageWindow && other.first == first && other.last == last;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(first, last);
|
||||
|
||||
@override
|
||||
String toString() => isEmpty ? 'PageWindow.empty' : 'PageWindow($first..$last)';
|
||||
}
|
||||
|
||||
/// Vertical stacking metrics for continuous-single layout.
|
||||
///
|
||||
/// Page `i` occupies the half-open band `[offsetOf(i), offsetOf(i) + heightOf(i))`
|
||||
/// in content (scale-1) coordinates, with [gap] inserted between consecutive
|
||||
/// pages. Cumulative tops are precomputed so [visibleRange] is O(log n) per
|
||||
/// scroll frame.
|
||||
class PageStackMetrics {
|
||||
PageStackMetrics({required List<double> pageHeights, this.gap = 0.0})
|
||||
: assert(gap >= 0),
|
||||
_heights = List<double>.unmodifiable(pageHeights),
|
||||
_tops = _cumulativeTops(pageHeights, gap);
|
||||
|
||||
final List<double> _heights;
|
||||
|
||||
/// Top edge of each page in content coordinates (length == pageCount).
|
||||
final List<double> _tops;
|
||||
|
||||
/// Gap between consecutive pages in content units.
|
||||
final double gap;
|
||||
|
||||
int get pageCount => _heights.length;
|
||||
|
||||
/// Total scrollable content height (0 when there are no pages).
|
||||
double get totalExtent {
|
||||
if (_heights.isEmpty) return 0;
|
||||
return _tops.last + _heights.last;
|
||||
}
|
||||
|
||||
double heightOf(int index) => _heights[index];
|
||||
|
||||
/// Top edge (content coordinate) of page [index].
|
||||
double offsetOf(int index) => _tops[index];
|
||||
|
||||
static List<double> _cumulativeTops(List<double> heights, double gap) {
|
||||
final tops = List<double>.filled(heights.length, 0);
|
||||
var acc = 0.0;
|
||||
for (var i = 0; i < heights.length; i++) {
|
||||
tops[i] = acc;
|
||||
acc += heights[i] + gap;
|
||||
}
|
||||
return List<double>.unmodifiable(tops);
|
||||
}
|
||||
|
||||
/// The inclusive page range intersecting the viewport
|
||||
/// `[scrollOffset, scrollOffset + viewportExtent)` grown by [cacheExtent] on
|
||||
/// each side. Pages whose band overlaps the grown window (even partially) are
|
||||
/// included; the result is clamped to `[0, pageCount-1]`.
|
||||
///
|
||||
/// Returns [PageWindow.empty] for an empty document or a window that does not
|
||||
/// reach any page.
|
||||
PageWindow visibleRange(
|
||||
double scrollOffset,
|
||||
double viewportExtent, {
|
||||
double cacheExtent = 0.0,
|
||||
}) {
|
||||
if (_heights.isEmpty) return PageWindow.empty;
|
||||
assert(viewportExtent >= 0);
|
||||
assert(cacheExtent >= 0);
|
||||
|
||||
final double windowTop = scrollOffset - cacheExtent;
|
||||
final double windowBottom = scrollOffset + viewportExtent + cacheExtent;
|
||||
|
||||
// No overlap with the content at all.
|
||||
if (windowBottom <= 0 || windowTop >= totalExtent) {
|
||||
return PageWindow.empty;
|
||||
}
|
||||
|
||||
final int first = _firstIntersecting(windowTop);
|
||||
final int last = _lastIntersecting(windowBottom);
|
||||
if (last < first) return PageWindow.empty;
|
||||
return PageWindow(first, last);
|
||||
}
|
||||
|
||||
/// Lowest index whose band bottom is strictly after [y] (i.e. the first page
|
||||
/// that the window's top edge does not sit fully below).
|
||||
int _firstIntersecting(double y) {
|
||||
// Find the first page whose bottom edge (top + height) > y.
|
||||
var lo = 0;
|
||||
var hi = _heights.length - 1;
|
||||
var result = _heights.length - 1;
|
||||
while (lo <= hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
final bottom = _tops[mid] + _heights[mid];
|
||||
if (bottom > y) {
|
||||
result = mid;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return math.max(0, result);
|
||||
}
|
||||
|
||||
/// Highest index whose top edge is strictly before [y].
|
||||
int _lastIntersecting(double y) {
|
||||
var lo = 0;
|
||||
var hi = _heights.length - 1;
|
||||
var result = 0;
|
||||
while (lo <= hi) {
|
||||
final mid = (lo + hi) >> 1;
|
||||
if (_tops[mid] < y) {
|
||||
result = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return math.min(_heights.length - 1, result);
|
||||
}
|
||||
}
|
||||
137
lib/editor/pdf/page_tile_cache.dart
Normal file
137
lib/editor/pdf/page_tile_cache.dart
Normal file
@@ -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<TileKey, ui.Image> _cache =
|
||||
LinkedHashMap<TileKey, ui.Image>();
|
||||
|
||||
/// Number of tiles currently retained.
|
||||
int get length => _cache.length;
|
||||
|
||||
/// The keys currently retained, most-recently-used LAST.
|
||||
Iterable<TileKey> 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<String> 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<ui.Image>.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;
|
||||
}
|
||||
150
test/page_tile_cache_test.dart
Normal file
150
test/page_tile_cache_test.dart
Normal file
@@ -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<ui.Image> _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);
|
||||
});
|
||||
});
|
||||
}
|
||||
107
test/page_viewport_test.dart
Normal file
107
test/page_viewport_test.dart
Normal file
@@ -0,0 +1,107 @@
|
||||
// Unit tests for continuous-single windowing math (P0.5 step 10, automatable
|
||||
// slice). Pure geometry — no widgets, no pdfrx.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/layout/page_viewport.dart';
|
||||
|
||||
void main() {
|
||||
group('PageStackMetrics geometry', () {
|
||||
test('cumulative tops and total extent (uniform pages + gap)', () {
|
||||
final m = PageStackMetrics(pageHeights: [100, 100, 100], gap: 10);
|
||||
expect(m.pageCount, 3);
|
||||
expect(m.offsetOf(0), 0);
|
||||
expect(m.offsetOf(1), 110); // 100 + 10 gap
|
||||
expect(m.offsetOf(2), 220);
|
||||
expect(m.totalExtent, 320); // 220 + last height 100, no trailing gap
|
||||
});
|
||||
|
||||
test('variable page heights', () {
|
||||
final m = PageStackMetrics(pageHeights: [50, 200, 75]);
|
||||
expect(m.offsetOf(0), 0);
|
||||
expect(m.offsetOf(1), 50);
|
||||
expect(m.offsetOf(2), 250);
|
||||
expect(m.totalExtent, 325);
|
||||
});
|
||||
|
||||
test('empty document', () {
|
||||
final m = PageStackMetrics(pageHeights: []);
|
||||
expect(m.pageCount, 0);
|
||||
expect(m.totalExtent, 0);
|
||||
expect(m.visibleRange(0, 800), PageWindow.empty);
|
||||
});
|
||||
});
|
||||
|
||||
group('PageStackMetrics.visibleRange', () {
|
||||
final m = PageStackMetrics(pageHeights: List.filled(10, 100)); // 0..1000
|
||||
|
||||
test('top of document shows the first pages', () {
|
||||
// viewport [0,250) → pages 0,1,2 (page 2 is [200,300), overlaps top 250)
|
||||
expect(m.visibleRange(0, 250), const PageWindow(0, 2));
|
||||
});
|
||||
|
||||
test('mid scroll shows the intersecting band', () {
|
||||
// viewport [420,720) → pages 4 [400,500) .. 7 [700,800)
|
||||
expect(m.visibleRange(420, 300), const PageWindow(4, 7));
|
||||
});
|
||||
|
||||
test('cacheExtent grows the window on both sides', () {
|
||||
// base [420,720) → 4..7; +150 cache → [270,870) → pages 2..8
|
||||
expect(
|
||||
m.visibleRange(420, 300, cacheExtent: 150),
|
||||
const PageWindow(2, 8),
|
||||
);
|
||||
});
|
||||
|
||||
test('exact page boundary is half-open (no spurious extra page)', () {
|
||||
// viewport [0,200): page 0 [0,100), page 1 [100,200). Page 2 starts at
|
||||
// 200 which is the exclusive bottom → NOT included.
|
||||
expect(m.visibleRange(0, 200), const PageWindow(0, 1));
|
||||
});
|
||||
|
||||
test('last page at the bottom', () {
|
||||
expect(m.visibleRange(950, 50), const PageWindow(9, 9));
|
||||
});
|
||||
|
||||
test('clamps to valid indices at the ends', () {
|
||||
// Overscroll below 0 (cache reaches negative) still starts at page 0.
|
||||
expect(m.visibleRange(0, 100, cacheExtent: 500).first, 0);
|
||||
// Overscroll past the end still ends at the last page.
|
||||
expect(m.visibleRange(900, 200, cacheExtent: 500).last, 9);
|
||||
});
|
||||
|
||||
test('window entirely above content is empty', () {
|
||||
// Scrolled far past the end with no cache reaching back.
|
||||
expect(m.visibleRange(5000, 300), PageWindow.empty);
|
||||
});
|
||||
|
||||
test('window entirely below content (negative, no overlap) is empty', () {
|
||||
// viewport sitting above page 0 with bottom <= 0.
|
||||
expect(m.visibleRange(-1000, 500), PageWindow.empty);
|
||||
});
|
||||
|
||||
test('PageWindow helpers', () {
|
||||
const w = PageWindow(2, 5);
|
||||
expect(w.count, 4);
|
||||
expect(w.contains(2), isTrue);
|
||||
expect(w.contains(5), isTrue);
|
||||
expect(w.contains(6), isFalse);
|
||||
expect(PageWindow.empty.isEmpty, isTrue);
|
||||
expect(PageWindow.empty.count, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('visibleRange with gaps', () {
|
||||
test('gap bands are not page bands (a scroll inside a gap shows neighbors)',
|
||||
() {
|
||||
final m = PageStackMetrics(pageHeights: [100, 100, 100], gap: 20);
|
||||
// page0 [0,100), gap [100,120), page1 [120,220), gap [220,240), page2 [240,340)
|
||||
// viewport [105,115) sits inside the first gap → nearest pages 0 and 1
|
||||
// both within a tiny window? window [105,115): page0 bottom 100 < 105 so
|
||||
// page0 excluded; page1 top 120 > 115 so excluded → empty band in gap.
|
||||
expect(m.visibleRange(105, 10), PageWindow.empty);
|
||||
// A slightly taller viewport spanning the gap catches both.
|
||||
expect(m.visibleRange(90, 40), const PageWindow(0, 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user