// A Mandelbrot explorer whose pixels are computed in Nim. // // What this is actually demonstrating: // * bulk data across the FFI boundary — a full RGBA frame per render, // into a buffer Dart allocates and Nim fills in place (nothing to nf_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 Nim 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:nimflutter_ffi/nimflutter_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: 'nimflutter_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 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 { 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; String? _bootError; /// Bands run on isolates natively and sequentially on web, so the word the /// UI uses for them has to follow the backend. String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band'; @override void initState() { super.initState(); _boot(); } /// Brings the bridge up and draws the first frame. Future _boot() => _render(); /// Waits for the bridge, returning false if it could not be brought up. /// /// On native this resolves immediately. On web it is where the wasm module /// is fetched and instantiated, which cannot be synchronous. /// /// Every path that asks for a frame goes through here, not just startup: /// the first layout pass calls _fitToCanvas -> _render before initState's /// future has resolved, so gating only the boot path would still let the /// very first render race the module load. initialize() is idempotent and /// hands back the in-flight future, so the extra calls just wait. Future _ensureReady() async { if (v.isInitialized) return true; try { await v.initialize(); return true; } catch (e) { if (mounted) setState(() => _bootError = '$e'); return false; } } @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 _render() async { if (!await _ensureReady()) return; 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 _decode(Uint8List rgba, int w, int h) { final completer = Completer(); 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 Nim and in Dart on this isolate, so the /// comparison is like-for-like rather than Nim-plus-parallelism vs Dart. Future _runBenchmark() async { if (!await _ensureReady()) return; 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( unit: _unit, parallel: v.supportsIsolateParallelism, 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 (_bootError != null) // Most likely on web: the wasm glue did not load, so there is // no Nim to call. Say so rather than spinning forever. Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( 'Could not start the Nim bridge.\n\n$_bootError', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), ) else 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 ${_unit}s" : "1 $_unit"}', ), ), 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 Nim', 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 nf_mandelbrot in src/nimflutter.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: '${_unit}s', 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( v.supportsIsolateParallelism ? 'The Nim kernel fills a buffer Dart allocates, so no Nim ' '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.' : 'The same Nim kernel, compiled to WebAssembly and reached ' 'over dart:js_interop instead of dart:ffi — it fills a ' 'buffer in the wasm heap, so nothing needs freeing on ' 'the Dart side. The module is single-threaded, so the ' 'bands here run in sequence; they still produce the ' 'identical frame, byte for byte, as the native build.', style: theme.textTheme.bodySmall, ), ), ), ], ), ); } } class _Benchmark { const _Benchmark({ required this.vSerialMs, required this.vParallelMs, required this.dartMs, required this.tiles, required this.unit, required this.parallel, }); final int vSerialMs; final int vParallelMs; final int dartMs; final int tiles; /// 'isolate' natively, 'band' on web. final String unit; /// Whether the split actually ran concurrently. final bool parallel; } 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: 'Nim, 1 ${result.unit}', value: '${result.vSerialMs} ms'), _Row( label: 'Nim, ${result.tiles} ${result.unit}s', value: '${result.vParallelMs} ms'), _Row(label: 'Dart, 1 ${result.unit}', value: '${result.dartMs} ms'), const Divider(height: 20), Text( result.parallel ? 'Nim 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.' : 'Nim is ${ratio.toStringAsFixed(2)}x Dart on one thread. ' 'The wasm module is single-threaded, so the ' '${result.tiles}-band row above is the same work done ' 'in sequence, not in parallel — it is here to show the ' 'split produces identical pixels either way.', 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)), ); }