Files
leaf_widgets/AGENTS.md
T

121 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — leaf_widgets
## Overview
Reusable Flutter UI widgets with **zero external dependencies** (pure `flutter`). Extracted from the Kemono Browser app (`S:\G_drive_backup\.Projects\SoyDev\Flutter\kemono_browser_project`) — that repo's AGENTS.md has the app-side context, and its `docs/photo-view-evaluation.md` is the **design bible** for the viewer: findings 1–12 from a photo_view feel-test, locked decisions in §6, and the package-extraction rationale in §6.1. Read it before touching the viewer.
Current contents:
| Widget | File | Notes |
|--------|------|-------|
| `LeafViewer` | `lib/src/viewer/leaf_viewer.dart` | Fullscreen pager: per-page zoom, edge-release, dismiss, desktop controls |
| `LeafZoomable` | `lib/src/viewer/leaf_zoomable.dart` | Per-page opt-in zoom wrapper (pages without it: paging + dismiss only) |
| `LeafZoomController` | `lib/src/viewer/leaf_zoom_controller.dart` | Pure math + animation state, `ChangeNotifier` |
| `LeafPanZoomRecognizer` | `lib/src/viewer/leaf_pan_zoom_recognizer.dart` | Arena pan/zoom recognizer (photo_view port) |
| `LeafViewerScope`/`LeafPageIndexScope`/`LeafViewerRegistry` | `lib/src/viewer/leaf_viewer_internal.dart` | Internal plumbing, **not** exported from the barrel |
| `LeafContextMenu` | `lib/src/context_menu/leaf_context_menu.dart` | Right-click / long-press menu |
| `LeafPopover` | `lib/src/popover/leaf_popover.dart` | Anchored overlay popover |
| `LeafImage` | `lib/src/image/leaf_image.dart` | Image widget: reveal animations, skeleton, retry |
| `AnimatedFavoriteButton` / `AnimatedStar` | `lib/src/misc/` | Async-aware heart toggle / spinning star |
## Commands
```bash
flutter analyze # package must stay at zero issues
flutter test # full suite
flutter test test/viewer # viewer subset (controller + widget tests)
cd example && flutter run -d windows # feel-test harness (gallery + 3-image viewer demo)
cd example && flutter test
```
Convention: analyze + test on every change to the package (unlike the app, the package is small and the loop is cheap). No commit unless asked.
## LeafViewer API
```dart
LeafViewer(
pageCount, pageBuilder,
pageController?, initialPage, scrollDirection,
onPageChanged, // external page-change notification
dismissEnabled, onDismissed, // onDismissed == null → Navigator.maybePop(context)
onTapOutside, // quick tap outside the active image at 1×
backgroundBuilder, // (context, dismissProgress) → backdrop layer
controlsBuilder, // media_kit-style chrome slot
enableSwipeGestures, // null = platform default; true = phone emulation anywhere
)
```
`controlsBuilder(context, LeafViewerControls)` receives `pageCount`, `currentIndex`, `canGoPrevious/Next`, `goToPage(index)` (animated). When null: a default control set (prev/next chevrons, close, counter) renders **on desktop only**.
`LeafZoomable(child, childSize, originalSize?, maxScale, enabled)` — `childSize` is the contained (fit-to-screen) size, `originalSize` enables the 1:1 double-tap step. `enabled: false` renders content without zoom (until an image loads).
## Gesture architecture (how it works and why)
**The arbitration lives in the gesture arena — never in ScrollPhysics.** `Scrollable.setCanDrag` **removes the drag recognizer entirely** when physics is `NeverScrollableScrollPhysics` (`scrollable.dart`, `_gestureRecognizers = {}`), so a PageView with locked physics never tracks the in-flight pointer. Mid-drag physics unlocking is therefore impossible. Do not reintroduce physics-locking for paging control.
The flow:
1. Each `LeafZoomable` registers a `LeafPanZoomRecognizer` (extends `ScaleGestureRecognizer`, ported from photo_view's `PhotoViewGestureRecognizer`). On the **first non-zero move** it resolves immediately:
- `controller.shouldClaim(fingerDelta)` (`shouldMoveX || shouldMoveY` — the LoliSnatcher `Axis.values` port) → `acceptGesture` → pan/pinch callbacks drive the controller
- otherwise → `resolve(rejected)` → the arena hands the gesture to the PageView's drag (horizontal) or the viewer's dismiss drag (perpendicular) — **this is the edge-release behaviour**: a drag starting at a fully clamped position pages/dismisses instead of panning
2. `shouldClaim` is false at 1× (the child fits → `HitCorners(true,true)` → blocked), so at 1× every drag goes to the pager/dismiss.
3. Dismiss is viewer-level (LoliSnatcher pattern): the whole PageView translates + scales, the backdrop fades with progress. Thresholds: velocity > 800 px/s **or** offset > 25% of the perpendicular extent → fly-off to `dir * extent * 1.5` (200 ms easeIn) → `onDismissed ?? Navigator.maybePop`; otherwise spring back (`SpringDescription(mass 1, stiffness 600, damping 50)`). Progress = `|offset| / (extent * 0.5)`.
4. Wheel zooms from **any** scale (user decision: no wheel-paging), cursor-anchored: `scale * (1 - scrollDy * 0.001)` clamped `[1, maxScale]`.
5. Once a pan is claimed it stays claimed (sticky) — mid-drag clamp→page transitions do **not** exist (same as photo_view; they never existed in the app either).
### Desktop mode
Windows/Linux/macOS (auto-detected, `leafIsDesktopPlatform()`): no mouse drag paging, no drag dismiss, no double-tap — the wheel zooms, the default controls page, the close button closes. Mouse-drag **panning when zoomed stays** (recognizer-based, device-agnostic). `enableSwipeGestures: true` forces the full touch set anywhere (mouse paging + dismiss + double-tap) for phone emulation.
## Ported math (photo_view 0.15.0, MIT)
Source-verified against the installed package in the pub cache (`photo_view-0.15.0/lib/...`):
| Piece | Where | Notes |
|-------|-------|-------|
| Corners/clamp | `photo_view_controller_delegate.dart` `cornersX/Y`/`clampPosition` | Our model: scale relative to contained (1.0 = resting); position in screen px. Property-tested against photo_view's formulas |
| HitCornersDetector | `photo_view_hit_corners.dart` | Ported verbatim (private `_HitCorners`/`_shouldMoveAxis`) |
| Double-tap cycle | `photo_view.dart` `defaultScaleStateCycle` + `nextScaleState` | 1× → covered → 1:1 → 1×, with the no-op-skip loop; zoom-in targets the **tap point**, zoom-out animates to `(1.0, zero)` |
| Contained/covered | `src/utils/photo_view_utils.dart` | `min`/`max` of viewport/child ratios |
**Deliberately NOT ported** (evaluation findings 2/3/9/11): the friction-tail animations (`AnimationController.fling(velocity: 0.4)`), the fixed +100 px pan glide, center-targeted double-tap. Replaced by: our velocity-proportional fling (Ticker, friction 0.92/frame, `v/60` per frame, stop < 5, trigger > 300 px/s, tap-to-stop), 200 ms easeOut Matrix4Tween zoom animation, tap-point anchoring, and cancel-and-snap-to-1× on gesture interrupt. Rotation not ported (YAGNI).
## Critical gotchas (hard-won — read before changing anything)
1. **Never physics-lock the PageView** — see above. The arena recognizer is load-bearing.
2. **The recognizer must resolve on the first non-zero move** — otherwise `ScaleGestureRecognizer._advanceStateMachine` auto-accepts at `kPanSlop` (36 px) and can win the arena tie-break against the PageView's 18 px accept on large per-event deltas (fast flicks, tests), swallowing the drag.
3. **After `resolve(rejected)`, skip `super.handleEvent`** — the base asserts `_state != ready` on subsequent events.
4. **Never `setState` the viewer on page changes** — `PageView.builder` re-invokes `itemBuilder` on rebuild → new `ImageProvider` instances → `ImageCache` identity-key misses → re-downloads → **black flash**. The page index lives in a `ValueNotifier` (`_indexNotifier`); only the controls listen. There is a regression test asserting pageBuilder is not re-invoked for existing pages.
5. **`allowImplicitScrolling: true` is required** — default cache extent 0 disposes neighbor pages, destroying per-page zoom state on swipe.
6. **`shouldClaim` sign convention** — public API takes raw finger delta; negated internally to photo_view's `move = initialFocalPoint - currentFocalPoint` convention. The corner-block matrix tests depend on this.
7. **`onDismissed` defaults to `Navigator.maybePop`** — standalone dismissals close the route; the app passes its own for custom cleanup.
8. **`_indexNotifier` + `_currentIndex`**: keep both in sync (`_onPageIndexChanged`). The registry's `activeController` reads `_currentIndex` (event-time); the notifier drives UI only.
## Testing conventions
- Controller math: plain `test()` with `TestVSync()` (no binding needed for pure math). Animation/fling tests: `testWidgets`.
- **Ticker pattern**: `pump()` (zero duration — starts the ticker, elapsed 0) *then* `pump(duration)`. A single timed pump only starts the ticker.
- **Fling tests**: pump a frame loop (16 ms steps) until it stops, and never end a test with a running fling/ticker.
- **`debugDefaultTargetPlatformOverride` must be reset inside the test body** (last line) — the binding's invariant check runs before any `tearDown`/`addTearDown`.
- Taps start the `DoubleTapGestureRecognizer`'s 40 ms countdown — flush with `pump(350 ms)` or the test fails on pending timers.
- `Offset.toString()` rounds to 1 decimal — use `closeTo`, not exact equality, for float-derived values.
- `pumpAndSettle` never settles with perpetual animations on screen (the example gallery's `AnimatedStar`); use fixed pumps there.
- Widget tests drive gestures with `tester.startGesture` / `moveBy` / `timedDrag`; the slow-drag helper (~8 px steps, 200 ms pumps) keeps per-event deltas realistic (see gotcha 2).
## References & inspirations
- **Design bible**: `kemono_browser_project/docs/photo-view-evaluation.md` — findings 1–12 (zoom feel, double-tap, fling, single-axis scope flaw, wheel, mouse dragDevices, phone findings, lag), §5 LoliSnatcher architecture, §6 locked decisions, §6.1 package layout
- **photo_view** (upstream, MIT): `https://github.com/bluefireteam/photo_view`, installed at `C:\Users\timka\AppData\Local\Pub\Cache\hosted\pub.dev\photo_view-0.15.0\lib\` — `photo_view_gallery.dart`, `src/core/photo_view_gesture_detector.dart`, `src/core/photo_view_hit_corners.dart`, `src/core/photo_view_core.dart`, `src/controller/photo_view_controller_delegate.dart`, `src/utils/photo_view_utils.dart`, `src/photo_view_scale_state.dart`
- **LoliSnatcher** (architecture): `https://github.com/NO-ob/LoliSnatcher_Droid` — `lib/src/pages/gallery_view_page.dart` (local copy: `C:\Users\timka\AppData\Local\Temp\loli_gallery_view_page.dart`): viewer-level dismiss, `dismissProgress` mapping, videos as dumb children. Their photo_view fork (`github.com/NANI-SORE/photo_view` @ `410b4c2`) is the `Axis.values` both-axes validation — our `shouldClaim` is that rule in recognizer form
- **Flutter internals** (this SDK, `Z:\.Programs\flutter`): `scrollable.dart` `setCanDrag` (drag recognizer removal), `gestures/scale.dart` `_advanceStateMachine` (36 px auto-accept), `painting/image_stream.dart` `reportError` (error routing to listeners), `widgets/image.dart` listener wiring, `scroll_configuration.dart` `_kTouchLikeDeviceTypes` (mouse removed from default dragDevices in 3.47)
- **media_kit**: `controlsBuilder` inspiration for `LeafViewer.controlsBuilder`
- **The app**: `kemono_browser_project/lib/src/screens/fullscreen_image_viewer_screen.dart` — the `_ZoomableMixin` this viewer replaces (parameters ported: slop 12 px, fling 300, dismiss 800/25%, spring params); integration is still pending there
## Status & known future work
- Package 0.0.1, `publish_to: none`, git repo initialized — no commits made by the agent
- **App integration pending**: replace `_ZoomableMixin` + the 4 page types' gesture code with `LeafViewer`, delete `_ZoomableMixin`/`_ZoomReporter`/video dismiss copies, remove `photo_view` dep + demo screen
- Decode-size cap for huge images (51 MP, evaluation finding 12) — test on phone after integration
- Trackpad pinch (PointerPanZoomEvent) works through the base recognizer but is untested on hardware
- Example's `_HeaderImageProvider` has value equality so recreated pages reuse the cache; the app's `KemonoImageProvider` must keep stable instances across rebuilds (create once in `initState`, like `_FullscreenPage` does)