// 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); } /// Maximum scroll offset so the last page bottom rests at the viewport /// bottom (never negative — a document shorter than the viewport can't /// scroll). double maxScrollExtent(double viewportExtent) { final max = totalExtent - viewportExtent; return max > 0 ? max : 0.0; } /// Clamps [scrollOffset] into the legal `[0, maxScrollExtent]` range. double clampScroll(double scrollOffset, double viewportExtent) { final max = maxScrollExtent(viewportExtent); if (scrollOffset < 0) return 0.0; return scrollOffset > max ? max : scrollOffset; } /// The "current" page for a scroll position: the page covering the LARGEST /// portion of the viewport `[scrollOffset, scrollOffset + viewportExtent)`. /// Drives the page-number indicator + thumbnail-grid highlight (F4). Returns /// 0 for an empty document. int dominantPageAt(double scrollOffset, double viewportExtent) { if (_heights.isEmpty) return 0; final window = visibleRange(scrollOffset, viewportExtent); if (window.isEmpty) { // Past the end / before the start → clamp to nearest real page. return scrollOffset <= 0 ? 0 : pageCount - 1; } final viewTop = scrollOffset; final viewBottom = scrollOffset + viewportExtent; var best = window.first; var bestOverlap = -1.0; for (var i = window.first; i <= window.last; i++) { final top = _tops[i]; final bottom = top + _heights[i]; final overlap = math.min(bottom, viewBottom) - math.max(top, viewTop); if (overlap > bestOverlap) { bestOverlap = overlap; best = i; } } return best; } /// 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); } }