nandi/vflutter_ffipublic Fork 0
0ed90f3
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add drag-to-pan

FractalView.pannedBy takes the drag as a fraction of the viewport, so the
shift scales with the zoom level — the same drag covers less ground the deeper
you are, which is what keeps deep zooms navigable. Tests pin the direction
(the image follows the finger, so the centre moves the other way), the
magnitude, and that panning leaves the zoom alone.

The render coalescing added for wheel zoom is what makes this usable: a drag
emits far more updates than we can render, and they collapse onto the latest.

Zoom moved from onTapDown to onTapUp. Worth being clear that this is not what
makes panning work — the gesture arena already withholds the tap once a pan
claims the pointer, and the drag test passes either way. It only means the
zoom commits on a completed tap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-18T16:38:08-07:00 Browse files
0ed90f3 parent: 59a731a
modified example/README.md +3 -2
@@ -1,7 +1,8 @@
11 # vflutter_ffi example
22
3-A Mandelbrot explorer whose pixels are computed in V. Scroll to zoom smoothly,
4-tap to zoom in, right-click to zoom out — all anchored at the cursor. Sliders
3+A Mandelbrot explorer whose pixels are computed in V. Drag to pan, scroll to
4+zoom smoothly, tap to zoom in, right-click to zoom out — zoom is anchored at
5+the cursor, and a drag covers less ground the deeper you are. Sliders
56 control the iteration cap and how many isolates the frame is split across;
67 "Reset view" restores the framing without disturbing either.
78
@@ -1,7 +1,8 @@
1 # vflutter_ffi example1 # vflutter_ffi example
2 2
3-A Mandelbrot explorer whose pixels are computed in V. Scroll to zoom smoothly,3+A Mandelbrot explorer whose pixels are computed in V. Drag to pan, scroll to
4-tap to zoom in, right-click to zoom out — all anchored at the cursor. Sliders4+zoom smoothly, tap to zoom in, right-click to zoom out — zoom is anchored at
5+the cursor, and a drag covers less ground the deeper you are. Sliders
5 control the iteration cap and how many isolates the frame is split across;6 control the iteration cap and how many isolates the frame is split across;
6 "Reset view" restores the framing without disturbing either.7 "Reset view" restores the framing without disturbing either.
7 8
modified example/lib/main.dart +25 -3
@@ -41,6 +41,10 @@ class ExplorerPage extends StatefulWidget {
4141 State<ExplorerPage> createState() => _ExplorerPageState();
4242 }
4343
44+/// Identifies the interactive canvas, so tests can drive gestures on it
45+/// without depending on whether a frame has finished rendering yet.
46+const Key canvasKey = Key('fractal-canvas');
47+
4448 class _ExplorerPageState extends State<ExplorerPage> {
4549 static const int _renderWidth = 720;
4650 static const int _renderHeight = 540;
@@ -116,6 +120,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
116120 return completer.future;
117121 }
118122
123+ /// Drags the view by a pixel delta, so the image follows the cursor.
124+ void _panBy(Offset delta, Size widgetSize) {
125+ if (widgetSize.isEmpty || delta == Offset.zero) return;
126+ setState(() {
127+ _view = _view.pannedBy(
128+ delta.dx / widgetSize.width,
129+ delta.dy / widgetSize.height,
130+ );
131+ });
132+ _render();
133+ }
134+
119135 /// Zooms about a point, keeping that point under the cursor.
120136 void _zoomAt(Offset local, Size widgetSize, double factor) {
121137 if (widgetSize.isEmpty) return;
@@ -236,6 +252,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
236252 final size = Size(constraints.maxWidth, constraints.maxHeight);
237253 _fitToCanvas(size);
238254 return Listener(
255+ key: canvasKey,
239256 // Wheel zoom, anchored at the cursor like the tap zoom. One notch is
240257 // a small step so a scroll feels continuous rather than octave-wise;
241258 // the render loop coalesces whatever it cannot keep up with.
@@ -246,8 +263,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
246263 _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());
247264 },
248265 child: GestureDetector(
249- onTapDown: (d) => _zoomAt(d.localPosition, size, 0.5),
250- onSecondaryTapDown: (d) => _zoomAt(d.localPosition, size, 2.0),
266+ // Zoom on tap up rather than tap down. The gesture arena already
267+ // keeps onTapDown from firing once a pan claims the pointer, so
268+ // this is not what makes drag-to-pan work — it just means the
269+ // zoom commits on a completed tap.
270+ onTapUp: (d) => _zoomAt(d.localPosition, size, 0.5),
271+ onSecondaryTapUp: (d) => _zoomAt(d.localPosition, size, 2.0),
272+ onPanUpdate: (d) => _panBy(d.delta, size),
251273 child: Stack(
252274 fit: StackFit.expand,
253275 children: [
@@ -297,7 +319,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
297319 Text('Mandelbrot in V', style: theme.textTheme.titleLarge),
298320 const SizedBox(height: 4),
299321 Text(
300- 'Tap to zoom in, right-click to zoom out. '
322+ 'Scroll or tap to zoom in, right-click to zoom out, drag to pan. '
301323 'Every pixel is computed by vf_mandelbrot in src/vflutter.v.',
302324 style: theme.textTheme.bodySmall,
303325 ),
@@ -41,6 +41,10 @@ class ExplorerPage extends StatefulWidget {
41 State<ExplorerPage> createState() => _ExplorerPageState();41 State<ExplorerPage> createState() => _ExplorerPageState();
42 }42 }
43 43
44+/// Identifies the interactive canvas, so tests can drive gestures on it
45+/// without depending on whether a frame has finished rendering yet.
46+const Key canvasKey = Key('fractal-canvas');
47+
44 class _ExplorerPageState extends State<ExplorerPage> {48 class _ExplorerPageState extends State<ExplorerPage> {
45 static const int _renderWidth = 720;49 static const int _renderWidth = 720;
46 static const int _renderHeight = 540;50 static const int _renderHeight = 540;
@@ -116,6 +120,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
116 return completer.future;120 return completer.future;
117 }121 }
118 122
123+ /// Drags the view by a pixel delta, so the image follows the cursor.
124+ void _panBy(Offset delta, Size widgetSize) {
125+ if (widgetSize.isEmpty || delta == Offset.zero) return;
126+ setState(() {
127+ _view = _view.pannedBy(
128+ delta.dx / widgetSize.width,
129+ delta.dy / widgetSize.height,
130+ );
131+ });
132+ _render();
133+ }
134+
119 /// Zooms about a point, keeping that point under the cursor.135 /// Zooms about a point, keeping that point under the cursor.
120 void _zoomAt(Offset local, Size widgetSize, double factor) {136 void _zoomAt(Offset local, Size widgetSize, double factor) {
121 if (widgetSize.isEmpty) return;137 if (widgetSize.isEmpty) return;
@@ -236,6 +252,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
236 final size = Size(constraints.maxWidth, constraints.maxHeight);252 final size = Size(constraints.maxWidth, constraints.maxHeight);
237 _fitToCanvas(size);253 _fitToCanvas(size);
238 return Listener(254 return Listener(
255+ key: canvasKey,
239 // Wheel zoom, anchored at the cursor like the tap zoom. One notch is256 // Wheel zoom, anchored at the cursor like the tap zoom. One notch is
240 // a small step so a scroll feels continuous rather than octave-wise;257 // a small step so a scroll feels continuous rather than octave-wise;
241 // the render loop coalesces whatever it cannot keep up with.258 // the render loop coalesces whatever it cannot keep up with.
@@ -246,8 +263,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
246 _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());263 _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());
247 },264 },
248 child: GestureDetector(265 child: GestureDetector(
249- onTapDown: (d) => _zoomAt(d.localPosition, size, 0.5),266+ // Zoom on tap up rather than tap down. The gesture arena already
250- onSecondaryTapDown: (d) => _zoomAt(d.localPosition, size, 2.0),267+ // keeps onTapDown from firing once a pan claims the pointer, so
268+ // this is not what makes drag-to-pan work — it just means the
269+ // zoom commits on a completed tap.
270+ onTapUp: (d) => _zoomAt(d.localPosition, size, 0.5),
271+ onSecondaryTapUp: (d) => _zoomAt(d.localPosition, size, 2.0),
272+ onPanUpdate: (d) => _panBy(d.delta, size),
251 child: Stack(273 child: Stack(
252 fit: StackFit.expand,274 fit: StackFit.expand,
253 children: [275 children: [
@@ -297,7 +319,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
297 Text('Mandelbrot in V', style: theme.textTheme.titleLarge),319 Text('Mandelbrot in V', style: theme.textTheme.titleLarge),
298 const SizedBox(height: 4),320 const SizedBox(height: 4),
299 Text(321 Text(
300- 'Tap to zoom in, right-click to zoom out. '322+ 'Scroll or tap to zoom in, right-click to zoom out, drag to pan. '
301 'Every pixel is computed by vf_mandelbrot in src/vflutter.v.',323 'Every pixel is computed by vf_mandelbrot in src/vflutter.v.',
302 style: theme.textTheme.bodySmall,324 style: theme.textTheme.bodySmall,
303 ),325 ),
modified example/test/explorer_test.dart +30 -0
@@ -55,6 +55,36 @@ void main() {
5555 expect(find.textContaining('zoom 1.0x'), findsOneWidget);
5656 });
5757
58+ testWidgets('dragging pans without also zooming', (tester) async {
59+ await pumpWide(tester);
60+ String zoomLabel() => (tester
61+ .widget<Text>(find.textContaining('zoom ').first)
62+ .data)!;
63+ final before = zoomLabel();
64+
65+ // Guards the gesture arena's behaviour: a pan must claim the pointer so
66+ // the tap recognizer never fires. (This passes with the zoom on either
67+ // onTapDown or onTapUp — the arena, not the callback choice, is what
68+ // separates them.)
69+ await tester.drag(find.byKey(canvasKey), const Offset(-120, -60));
70+ await settle(tester);
71+
72+ expect(zoomLabel(), before, reason: 'a drag must not change the zoom');
73+ });
74+
75+ testWidgets('a tap still zooms', (tester) async {
76+ await pumpWide(tester);
77+ String zoomLabel() => (tester
78+ .widget<Text>(find.textContaining('zoom ').first)
79+ .data)!;
80+ final before = zoomLabel();
81+
82+ await tester.tap(find.byKey(canvasKey));
83+ await settle(tester);
84+
85+ expect(zoomLabel(), isNot(before));
86+ });
87+
5888 testWidgets('narrow layout lays out without overflowing', (tester) async {
5989 // Regression guard: the controls used to be a ListView nested inside the
6090 // page's ListView, which threw "Vertical viewport was given unbounded
@@ -55,6 +55,36 @@ void main() {
55 expect(find.textContaining('zoom 1.0x'), findsOneWidget);55 expect(find.textContaining('zoom 1.0x'), findsOneWidget);
56 });56 });
57 57
58+ testWidgets('dragging pans without also zooming', (tester) async {
59+ await pumpWide(tester);
60+ String zoomLabel() => (tester
61+ .widget<Text>(find.textContaining('zoom ').first)
62+ .data)!;
63+ final before = zoomLabel();
64+
65+ // Guards the gesture arena's behaviour: a pan must claim the pointer so
66+ // the tap recognizer never fires. (This passes with the zoom on either
67+ // onTapDown or onTapUp — the arena, not the callback choice, is what
68+ // separates them.)
69+ await tester.drag(find.byKey(canvasKey), const Offset(-120, -60));
70+ await settle(tester);
71+
72+ expect(zoomLabel(), before, reason: 'a drag must not change the zoom');
73+ });
74+
75+ testWidgets('a tap still zooms', (tester) async {
76+ await pumpWide(tester);
77+ String zoomLabel() => (tester
78+ .widget<Text>(find.textContaining('zoom ').first)
79+ .data)!;
80+ final before = zoomLabel();
81+
82+ await tester.tap(find.byKey(canvasKey));
83+ await settle(tester);
84+
85+ expect(zoomLabel(), isNot(before));
86+ });
87+
58 testWidgets('narrow layout lays out without overflowing', (tester) async {88 testWidgets('narrow layout lays out without overflowing', (tester) async {
59 // Regression guard: the controls used to be a ListView nested inside the89 // Regression guard: the controls used to be a ListView nested inside the
60 // page's ListView, which threw "Vertical viewport was given unbounded90 // page's ListView, which threw "Vertical viewport was given unbounded
modified example/test/widget_test.dart +38 -0
@@ -73,6 +73,44 @@ void _zoomTests() {
7373 );
7474 });
7575
76+ test('panning moves the plane under the viewport, not the other way', () {
77+ // Dragging right must reveal what was to the LEFT, so the centre moves
78+ // left. Getting this backwards is the classic panning bug.
79+ final right = view.pannedBy(0.5, 0.0);
80+ expect(right.centerX, lessThan(view.centerX));
81+ expect(right.centerY, view.centerY);
82+
83+ final down = view.pannedBy(0.0, 0.5);
84+ expect(down.centerY, lessThan(view.centerY));
85+ expect(down.centerX, view.centerX);
86+ });
87+
88+ test('a full-width drag moves exactly one viewport', () {
89+ final panned = view.pannedBy(1.0, 0.0);
90+ final aspect = view.width / view.height;
91+ expect(panned.centerX,
92+ closeTo(view.centerX - view.scale * aspect, 1e-12));
93+ });
94+
95+ test('panning does not change the zoom level', () {
96+ expect(view.pannedBy(0.3, -0.7).scale, view.scale);
97+ });
98+
99+ test('panning back and forth returns to the starting view', () {
100+ final round = view.pannedBy(0.4, 0.25).pannedBy(-0.4, -0.25);
101+ expect(round.centerX, closeTo(view.centerX, 1e-12));
102+ expect(round.centerY, closeTo(view.centerY, 1e-12));
103+ });
104+
105+ test('pan distance scales with zoom depth', () {
106+ // The same drag must cover less ground when zoomed in, or deep zooms
107+ // become unnavigable.
108+ final deep = view.copyWith(scale: view.scale / 1000);
109+ final shallowShift = (view.pannedBy(0.5, 0).centerX - view.centerX).abs();
110+ final deepShift = (deep.pannedBy(0.5, 0).centerX - deep.centerX).abs();
111+ expect(deepShift, lessThan(shallowShift / 100));
112+ });
113+
76114 test('zooming in then out returns to the starting view', () {
77115 final round = view.zoomedAt(0.3, 0.8, 0.5).zoomedAt(0.3, 0.8, 2.0);
78116 expect(round.scale, closeTo(view.scale, 1e-12));
@@ -73,6 +73,44 @@ void _zoomTests() {
73 );73 );
74 });74 });
75 75
76+ test('panning moves the plane under the viewport, not the other way', () {
77+ // Dragging right must reveal what was to the LEFT, so the centre moves
78+ // left. Getting this backwards is the classic panning bug.
79+ final right = view.pannedBy(0.5, 0.0);
80+ expect(right.centerX, lessThan(view.centerX));
81+ expect(right.centerY, view.centerY);
82+
83+ final down = view.pannedBy(0.0, 0.5);
84+ expect(down.centerY, lessThan(view.centerY));
85+ expect(down.centerX, view.centerX);
86+ });
87+
88+ test('a full-width drag moves exactly one viewport', () {
89+ final panned = view.pannedBy(1.0, 0.0);
90+ final aspect = view.width / view.height;
91+ expect(panned.centerX,
92+ closeTo(view.centerX - view.scale * aspect, 1e-12));
93+ });
94+
95+ test('panning does not change the zoom level', () {
96+ expect(view.pannedBy(0.3, -0.7).scale, view.scale);
97+ });
98+
99+ test('panning back and forth returns to the starting view', () {
100+ final round = view.pannedBy(0.4, 0.25).pannedBy(-0.4, -0.25);
101+ expect(round.centerX, closeTo(view.centerX, 1e-12));
102+ expect(round.centerY, closeTo(view.centerY, 1e-12));
103+ });
104+
105+ test('pan distance scales with zoom depth', () {
106+ // The same drag must cover less ground when zoomed in, or deep zooms
107+ // become unnavigable.
108+ final deep = view.copyWith(scale: view.scale / 1000);
109+ final shallowShift = (view.pannedBy(0.5, 0).centerX - view.centerX).abs();
110+ final deepShift = (deep.pannedBy(0.5, 0).centerX - deep.centerX).abs();
111+ expect(deepShift, lessThan(shallowShift / 100));
112+ });
113+
76 test('zooming in then out returns to the starting view', () {114 test('zooming in then out returns to the starting view', () {
77 final round = view.zoomedAt(0.3, 0.8, 0.5).zoomedAt(0.3, 0.8, 2.0);115 final round = view.zoomedAt(0.3, 0.8, 0.5).zoomedAt(0.3, 0.8, 2.0);
78 expect(round.scale, closeTo(view.scale, 1e-12));116 expect(round.scale, closeTo(view.scale, 1e-12));
modified lib/vflutter_ffi.dart +12 -0
@@ -146,6 +146,18 @@ class FractalView {
146146 );
147147 }
148148
149+ /// Returns this view dragged by a delta given as a *fraction* of the
150+ /// viewport — dragging right by half the width is `fdx = 0.5`.
151+ ///
152+ /// The image follows the finger, so the centre moves the opposite way.
153+ FractalView pannedBy(double fdx, double fdy) {
154+ final aspect = width / height;
155+ return copyWith(
156+ centerX: centerX - fdx * scale * aspect,
157+ centerY: centerY - fdy * scale,
158+ );
159+ }
160+
149161 FractalView copyWith({
150162 int? width,
151163 int? height,
@@ -146,6 +146,18 @@ class FractalView {
146 );146 );
147 }147 }
148 148
149+ /// Returns this view dragged by a delta given as a *fraction* of the
150+ /// viewport — dragging right by half the width is `fdx = 0.5`.
151+ ///
152+ /// The image follows the finger, so the centre moves the opposite way.
153+ FractalView pannedBy(double fdx, double fdy) {
154+ final aspect = width / height;
155+ return copyWith(
156+ centerX: centerX - fdx * scale * aspect,
157+ centerY: centerY - fdy * scale,
158+ );
159+ }
160+
149 FractalView copyWith({161 FractalView copyWith({
150 int? width,162 int? width,
151 int? height,163 int? height,