From c31bfd3445c42681cd23e73c31bf2ec294631320 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:16:20 +0800 Subject: [PATCH] feat(p0.5): continuous-single navigation math (current-page + scroll clamp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends PageStackMetrics with the navigation geometry continuous-single needs: maxScrollExtent (last page bottom rests at viewport bottom, never negative), clampScroll, and dominantPageAt — the page covering most of the viewport, which drives the page-number indicator + thumbnail-grid highlight + jump-to-page (F4). Pure; clamps past both ends; 0 for empty documents. flutter analyze lib/editor clean; 129/129 tests (+6). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/layout/page_viewport.dart | 43 ++++++++++++++++++++++++++++ test/page_viewport_test.dart | 41 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/lib/editor/layout/page_viewport.dart b/lib/editor/layout/page_viewport.dart index d8c0c28..e3380f9 100644 --- a/lib/editor/layout/page_viewport.dart +++ b/lib/editor/layout/page_viewport.dart @@ -114,6 +114,49 @@ class PageStackMetrics { 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) { diff --git a/test/page_viewport_test.dart b/test/page_viewport_test.dart index 1027572..53abc1c 100644 --- a/test/page_viewport_test.dart +++ b/test/page_viewport_test.dart @@ -91,6 +91,47 @@ void main() { }); }); + group('navigation math', () { + final m = PageStackMetrics(pageHeights: List.filled(10, 100)); // 0..1000 + + test('maxScrollExtent leaves the last page bottom at the viewport bottom', + () { + expect(m.maxScrollExtent(300), 700); // 1000 - 300 + }); + + test('maxScrollExtent is 0 when content is shorter than the viewport', () { + final short = PageStackMetrics(pageHeights: [100]); + expect(short.maxScrollExtent(800), 0); + }); + + test('clampScroll keeps offset within [0, maxScrollExtent]', () { + expect(m.clampScroll(-50, 300), 0); + expect(m.clampScroll(5000, 300), 700); + expect(m.clampScroll(420, 300), 420); + }); + + test('dominantPageAt picks the page covering most of the viewport', () { + // viewport [0,100) fully on page 0. + expect(m.dominantPageAt(0, 100), 0); + // viewport [180,280): page1 [100,200) covers 20, page2 [200,300) covers + // 80 → page 2 dominates. + expect(m.dominantPageAt(180, 100), 2); + // viewport [150,250): page1 covers [150,200)=50, page2 [200,250)=50 → + // tie resolves to the first (page1) since overlap must strictly exceed. + expect(m.dominantPageAt(150, 100), 1); + }); + + test('dominantPageAt clamps past the ends', () { + expect(m.dominantPageAt(-500, 100), 0); + expect(m.dominantPageAt(99999, 100), 9); + }); + + test('dominantPageAt is 0 for an empty document', () { + final empty = PageStackMetrics(pageHeights: []); + expect(empty.dominantPageAt(0, 800), 0); + }); + }); + group('visibleRange with gaps', () { test('gap bands are not page bands (a scroll inside a gap shows neighbors)', () {