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>
0ed90f3 parent: 59a731a modified
example/README.md +3 -2 | @@ -1,7 +1,8 @@ | ||
| 1 | 1 | # vflutter_ffi example |
| 2 | 2 | |
| 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 | |
| 5 | 6 | control the iteration cap and how many isolates the frame is split across; |
| 6 | 7 | "Reset view" restores the framing without disturbing either. |
| 7 | 8 | |
| @@ -1,7 +1,8 @@ | |||
| 1 | # vflutter_ffi example | 1 | # 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. Sliders | 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 | ||
| 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 { | ||
| 41 | 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 | 48 | class _ExplorerPageState extends State<ExplorerPage> { |
| 45 | 49 | static const int _renderWidth = 720; |
| 46 | 50 | static const int _renderHeight = 540; |
| @@ -116,6 +120,18 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 116 | 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 | 135 | /// Zooms about a point, keeping that point under the cursor. |
| 120 | 136 | void _zoomAt(Offset local, Size widgetSize, double factor) { |
| 121 | 137 | if (widgetSize.isEmpty) return; |
| @@ -236,6 +252,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 236 | 252 | final size = Size(constraints.maxWidth, constraints.maxHeight); |
| 237 | 253 | _fitToCanvas(size); |
| 238 | 254 | return Listener( |
| 255 | + key: canvasKey, | |
| 239 | 256 | // Wheel zoom, anchored at the cursor like the tap zoom. One notch is |
| 240 | 257 | // a small step so a scroll feels continuous rather than octave-wise; |
| 241 | 258 | // the render loop coalesces whatever it cannot keep up with. |
| @@ -246,8 +263,13 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 246 | 263 | _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble()); |
| 247 | 264 | }, |
| 248 | 265 | 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), | |
| 251 | 273 | child: Stack( |
| 252 | 274 | fit: StackFit.expand, |
| 253 | 275 | children: [ |
| @@ -297,7 +319,7 @@ class _ExplorerPageState extends State<ExplorerPage> { | ||
| 297 | 319 | Text('Mandelbrot in V', style: theme.textTheme.titleLarge), |
| 298 | 320 | const SizedBox(height: 4), |
| 299 | 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 | 323 | 'Every pixel is computed by vf_mandelbrot in src/vflutter.v.', |
| 302 | 324 | style: theme.textTheme.bodySmall, |
| 303 | 325 | ), |
| @@ -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 is | 256 | // 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() { | ||
| 55 | 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 | 88 | testWidgets('narrow layout lays out without overflowing', (tester) async { |
| 59 | 89 | // Regression guard: the controls used to be a ListView nested inside the |
| 60 | 90 | // 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 the | 89 | // Regression guard: the controls used to be a ListView nested inside the |
| 60 | // page's ListView, which threw "Vertical viewport was given unbounded | 90 | // page's ListView, which threw "Vertical viewport was given unbounded |
modified
example/test/widget_test.dart +38 -0 | @@ -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 | 114 | test('zooming in then out returns to the starting view', () { |
| 77 | 115 | final round = view.zoomedAt(0.3, 0.8, 0.5).zoomedAt(0.3, 0.8, 2.0); |
| 78 | 116 | 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 { | ||
| 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 | 161 | FractalView copyWith({ |
| 150 | 162 | int? width, |
| 151 | 163 | 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, |