diff --git a/lib/editor/layout/page_viewport.dart b/lib/editor/layout/page_viewport.dart new file mode 100644 index 0000000..d8c0c28 --- /dev/null +++ b/lib/editor/layout/page_viewport.dart @@ -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 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); + } +} diff --git a/test/page_viewport_test.dart b/test/page_viewport_test.dart new file mode 100644 index 0000000..1027572 --- /dev/null +++ b/test/page_viewport_test.dart @@ -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)); + }); + }); +}