feat(p0.5): continuous-single page windowing math (step 10, automatable slice)
Some checks failed
CI / Windows build (push) Has been cancelled

PageStackMetrics + PageWindow: the pure geometry that decides which pages are
mounted for a scroll position (windowed lazy hosting → 60fps on a 300-page doc,
R1). Pages stack vertically with cumulative tops (O(log n) binary-search
visibleRange); a viewport [scroll, scroll+extent) grown by cacheExtent on each
side selects the inclusive intersecting page band, half-open at page boundaries,
clamped to valid indices, empty for empty/over-scrolled-past documents, and
gap-aware (a scroll resting inside an inter-page gap shows no page).

Widget-free + pdfrx-free by design: the page-mounting widget and zoom-settle DPI
refresh are device-gated; only the windowing math is automatable, and it is here
with exhaustive unit coverage (boundaries, cache band, clamping, gaps, empty).

flutter analyze lib/editor clean; 115/115 tests (+13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:08:03 +08:00
parent 07b543f3e1
commit 1a3d1065f9
2 changed files with 260 additions and 0 deletions

View 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);
}
}

View 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));
});
});
}