nandi/vflutter_ffipublic Fork 0
0ed90f39e1f4dc334e577e5b38316674a53fc88d
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 0ed90f3 · on 0ed90f39e1f4dc334e577e5b38316674a53fc88d · nandithebull · 10h ago
main.dart · 518 lines · 15.8 KBDart Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
// A Mandelbrot explorer whose pixels are computed in V.
//
// What this is actually demonstrating:
//   * bulk data across the FFI boundary — a full RGBA frame per render,
//     into a buffer Dart allocates and V fills in place (nothing to vf_free)
//   * band parallelism — the image is split into horizontal strips rendered
//     on separate isolates, which is safe because the library is -gc none
//   * the same algorithm written twice, in V and in Dart, so the cost of the
//     bridge is a number on screen rather than a claim
import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:vflutter_ffi/vflutter_ffi.dart' as v;

import 'mandelbrot_dart.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'vflutter_ffi',
        debugShowCheckedModeBanner: false,
        theme: ThemeData.dark(useMaterial3: true).copyWith(
          scaffoldBackgroundColor: const Color(0xFF12141A),
        ),
        home: const ExplorerPage(),
      );
}

class ExplorerPage extends StatefulWidget {
  const ExplorerPage({super.key});

  @override
  State<ExplorerPage> createState() => _ExplorerPageState();
}

/// Identifies the interactive canvas, so tests can drive gestures on it
/// without depending on whether a frame has finished rendering yet.
const Key canvasKey = Key('fractal-canvas');

class _ExplorerPageState extends State<ExplorerPage> {
  static const int _renderWidth = 720;
  static const int _renderHeight = 540;

  v.FractalView _view = const v.FractalView(
    width: _renderWidth,
    height: _renderHeight,
    maxIter: 400,
  );

  ui.Image? _image;
  bool _rendering = false;
  bool _renderQueued = false;
  int _lastMs = 0;
  int _tiles = 16;
  _Benchmark? _benchmark;

  @override
  void initState() {
    super.initState();
    v.ensureInitialized();
    _render();
  }

  @override
  void dispose() {
    _image?.dispose();
    super.dispose();
  }

  /// Renders the current view, coalescing requests that arrive mid-frame.
  ///
  /// A wheel gesture emits far more events than we can render. Dropping them
  /// outright would leave the view mutated but never drawn, so instead the
  /// latest request is remembered and serviced once the in-flight frame lands.
  Future<void> _render() async {
    if (_rendering) {
      _renderQueued = true;
      return;
    }
    setState(() => _rendering = true);

    do {
      _renderQueued = false;
      final view = _view;

      final watch = Stopwatch()..start();
      final pixels = _tiles > 1
          ? await v.renderParallel(view, tiles: _tiles)
          : v.render(view);
      watch.stop();

      final image = await _decode(pixels, view.width, view.height);
      if (!mounted) {
        image.dispose();
        return;
      }
      setState(() {
        _image?.dispose();
        _image = image;
        _lastMs = watch.elapsedMilliseconds;
      });
    } while (_renderQueued);

    if (!mounted) return;
    setState(() => _rendering = false);
  }

  static Future<ui.Image> _decode(Uint8List rgba, int w, int h) {
    final completer = Completer<ui.Image>();
    ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888,
        completer.complete);
    return completer.future;
  }

  /// Drags the view by a pixel delta, so the image follows the cursor.
  void _panBy(Offset delta, Size widgetSize) {
    if (widgetSize.isEmpty || delta == Offset.zero) return;
    setState(() {
      _view = _view.pannedBy(
        delta.dx / widgetSize.width,
        delta.dy / widgetSize.height,
      );
    });
    _render();
  }

  /// Zooms about a point, keeping that point under the cursor.
  void _zoomAt(Offset local, Size widgetSize, double factor) {
    if (widgetSize.isEmpty) return;
    setState(() {
      _view = _view.zoomedAt(
        local.dx / widgetSize.width,
        local.dy / widgetSize.height,
        factor,
      );
    });
    _render();
  }

  /// Restores the starting framing.
  ///
  /// Only the pan/zoom is reset. Width and height belong to the canvas (see
  /// _fitToCanvas) and maxIter belongs to its slider — clobbering those made
  /// "Reset view" silently undo settings the button does not own, and briefly
  /// render at the wrong aspect ratio before the canvas corrected it.
  void _reset() {
    const home = v.FractalView(width: 1, height: 1);
    setState(() {
      _view = _view.copyWith(
        centerX: home.centerX,
        centerY: home.centerY,
        scale: home.scale,
      );
    });
    _render();
  }

  /// Renders the identical frame in V and in Dart on this isolate, so the
  /// comparison is like-for-like rather than V-plus-parallelism vs Dart.
  Future<void> _runBenchmark() async {
    setState(() {
      _rendering = true;
      _benchmark = null;
    });

    final swV = Stopwatch()..start();
    v.render(_view);
    swV.stop();

    final swP = Stopwatch()..start();
    await v.renderParallel(_view, tiles: _tiles);
    swP.stop();

    final swD = Stopwatch()..start();
    renderDart(_view);
    swD.stop();

    if (!mounted) return;
    setState(() {
      _rendering = false;
      _benchmark = _Benchmark(
        vSerialMs: swV.elapsedMilliseconds,
        vParallelMs: swP.elapsedMilliseconds,
        dartMs: swD.elapsedMilliseconds,
        tiles: _tiles,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final wide = constraints.maxWidth > 900;
            final canvas = _buildCanvas();
            final controls = _buildControls();
            return wide
                ? Row(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Expanded(child: canvas),
                      SizedBox(
                        width: 340,
                        child: SingleChildScrollView(child: controls),
                      ),
                    ],
                  )
                : ListView(
                    children: [
                      AspectRatio(
                        aspectRatio: _renderWidth / _renderHeight,
                        child: canvas,
                      ),
                      controls,
                    ],
                  );
          },
        ),
      ),
    );
  }

  /// Keeps the rendered buffer at the canvas's aspect ratio.
  ///
  /// Without this the image would be letterboxed or cropped, and the
  /// click-to-zoom maths — which maps widget coordinates straight into the
  /// complex plane — would point at the wrong place.
  void _fitToCanvas(Size size) {
    if (size.isEmpty) return;
    final height = (_renderWidth * size.height / size.width).round();
    if ((height - _view.height).abs() <= 2) return;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      setState(() => _view = _view.copyWith(height: height));
      _render();
    });
  }

  Widget _buildCanvas() {
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = Size(constraints.maxWidth, constraints.maxHeight);
        _fitToCanvas(size);
        return Listener(
          key: canvasKey,
          // Wheel zoom, anchored at the cursor like the tap zoom. One notch is
          // a small step so a scroll feels continuous rather than octave-wise;
          // the render loop coalesces whatever it cannot keep up with.
          onPointerSignal: (event) {
            if (event is! PointerScrollEvent) return;
            final dy = event.scrollDelta.dy;
            if (dy == 0) return;
            _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());
          },
          child: GestureDetector(
            // Zoom on tap up rather than tap down. The gesture arena already
            // keeps onTapDown from firing once a pan claims the pointer, so
            // this is not what makes drag-to-pan work — it just means the
            // zoom commits on a completed tap.
            onTapUp: (d) => _zoomAt(d.localPosition, size, 0.5),
            onSecondaryTapUp: (d) => _zoomAt(d.localPosition, size, 2.0),
            onPanUpdate: (d) => _panBy(d.delta, size),
            child: Stack(
            fit: StackFit.expand,
            children: [
              if (_image != null)
                RawImage(image: _image, fit: BoxFit.fill)
              else
                const Center(child: CircularProgressIndicator()),
              Positioned(
                left: 12,
                bottom: 12,
                child: _Chip(
                  'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x  ·  '
                  '$_lastMs ms  ·  '
                  '${_tiles > 1 ? "$_tiles isolates" : "1 isolate"}',
                ),
              ),
              if (_rendering)
                const Positioned(
                  right: 12,
                  top: 12,
                  child: SizedBox(
                    width: 18,
                    height: 18,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                ),
              ],
            ),
          ),
        );
      },
    );
  }

  /// Returns a non-scrolling column.
  ///
  /// The wide layout wraps this in its own scroll view; the narrow layout
  /// embeds it in the page's ListView. Making this a ListView itself would
  /// nest two scrollables and give the inner one unbounded height.
  Widget _buildControls() {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
        Text('Mandelbrot in V', style: theme.textTheme.titleLarge),
        const SizedBox(height: 4),
        Text(
          'Scroll or tap to zoom in, right-click to zoom out, drag to pan. '
          'Every pixel is computed by vf_mandelbrot in src/vflutter.v.',
          style: theme.textTheme.bodySmall,
        ),
        const SizedBox(height: 20),
        _Labelled(
          label: 'iterations',
          value: '${_view.maxIter}',
          child: Slider(
            value: _view.maxIter.toDouble(),
            min: 50,
            max: 2000,
            divisions: 39,
            onChanged: (n) =>
                setState(() => _view = _view.copyWith(maxIter: n.round())),
            onChangeEnd: (_) => _render(),
          ),
        ),
        _Labelled(
          label: 'isolates',
          value: '$_tiles',
          child: Slider(
            value: _tiles.toDouble(),
            min: 1,
            // Deliberately well past the core count. Bands cost wildly
            // different amounts — rows through the set's interior run the full
            // iteration cap, rows in open space escape immediately — so the
            // frame waits on its slowest band. Over-decomposing lets a free
            // core pick up the next small band instead of idling. Measured on
            // an 8-thread machine: 8 tiles 44 ms, 24 tiles 29 ms.
            max: 32,
            divisions: 31,
            onChanged: (n) => setState(() => _tiles = n.round()),
            onChangeEnd: (_) => _render(),
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            Expanded(
              child: FilledButton.tonal(
                onPressed: _rendering ? null : _reset,
                child: const Text('Reset view'),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: FilledButton(
                onPressed: _rendering ? null : _runBenchmark,
                child: const Text('Benchmark'),
              ),
            ),
          ],
        ),
        const SizedBox(height: 20),
        if (_benchmark != null) _BenchmarkCard(result: _benchmark!),
        const SizedBox(height: 12),
        Card(
          color: theme.colorScheme.surfaceContainerHighest,
          child: Padding(
            padding: const EdgeInsets.all(14),
            child: Text(
              'The V kernel fills a buffer Dart allocates, so no V memory '
              'crosses the boundary and there is nothing to free. Bands are '
              'independent, which is what makes the isolate slider safe: '
              '-gc none means no stop-the-world phase to serialise them.',
              style: theme.textTheme.bodySmall,
            ),
          ),
        ),
        ],
      ),
    );
  }
}

class _Benchmark {
  const _Benchmark({
    required this.vSerialMs,
    required this.vParallelMs,
    required this.dartMs,
    required this.tiles,
  });

  final int vSerialMs;
  final int vParallelMs;
  final int dartMs;
  final int tiles;
}

class _BenchmarkCard extends StatelessWidget {
  const _BenchmarkCard({required this.result});

  final _Benchmark result;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final ratio = result.dartMs / result.vSerialMs;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('same frame, three ways',
                style: theme.textTheme.titleSmall),
            const SizedBox(height: 10),
            _Row(label: 'V, 1 isolate', value: '${result.vSerialMs} ms'),
            _Row(
                label: 'V, ${result.tiles} isolates',
                value: '${result.vParallelMs} ms'),
            _Row(label: 'Dart, 1 isolate', value: '${result.dartMs} ms'),
            const Divider(height: 20),
            Text(
              'V is ${ratio.toStringAsFixed(2)}x Dart on one isolate. For a '
              'scalar FP loop the two are close — the real win here is the '
              'parallel split, which Dart could also do. The bridge is what '
              'this demonstrates, not a speed claim.',
              style: theme.textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }
}

class _Row extends StatelessWidget {
  const _Row({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 2),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(label, style: Theme.of(context).textTheme.bodyMedium),
            Text(value,
                style: Theme.of(context)
                    .textTheme
                    .bodyMedium
                    ?.copyWith(fontFeatures: const [ui.FontFeature.tabularFigures()])),
          ],
        ),
      );
}

class _Labelled extends StatelessWidget {
  const _Labelled({
    required this.label,
    required this.value,
    required this.child,
  });

  final String label;
  final String value;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(label, style: theme.textTheme.labelLarge),
            Text(value, style: theme.textTheme.labelLarge),
          ],
        ),
        child,
      ],
    );
  }
}

class _Chip extends StatelessWidget {
  const _Chip(this.text);

  final String text;

  @override
  Widget build(BuildContext context) => Container(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        decoration: BoxDecoration(
          color: Colors.black.withValues(alpha: 0.55),
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(text,
            style: const TextStyle(color: Colors.white, fontSize: 12)),
      );
}