// 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 pageHeights, this.gap = 0.0}) : assert(gap >= 0), _heights = List.unmodifiable(pageHeights), _tops = _cumulativeTops(pageHeights, gap); final List _heights; /// Top edge of each page in content coordinates (length == pageCount). final List _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 _cumulativeTops(List heights, double gap) { final tops = List.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.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); } }