mypaint_ffi 0.0.4 copy "mypaint_ffi: ^0.0.4" to clipboard
mypaint_ffi: ^0.0.4 copied to clipboard

PlatformAndroid

Android-only Flutter FFI plugin wrapping libmypaint to paint MyPaint brush strokes with pressure and tilt onto a pixel surface.

example/lib/main.dart

import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show AssetManifest, rootBundle;
import 'package:mypaint_ffi/mypaint_ffi.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'libmypaint demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      home: const PaintScreen(),
    );
  }
}

/// The brush selected when the app starts, before the catalog finishes loading.
const String _defaultBrush = 'assets/brushes/classic/brush.myb';

/// A single brush bundled with the example app: its `.myb` settings asset and
/// optional preview thumbnail.
@immutable
class BrushAsset {
  const BrushAsset({
    required this.path,
    required this.preview,
    required this.name,
    required this.group,
  });

  /// Asset key of the `.myb` file, e.g. `assets/brushes/classic/brush.myb`.
  final String path;

  /// Asset key of the `_prev.png` thumbnail, or null when none was bundled.
  final String? preview;

  /// Display name (file name without the `.myb` extension).
  final String name;

  /// Brush set the brush belongs to (its containing folder).
  final String group;
}

/// Loads every bundled brush, grouped by set, by reading the asset manifest.
///
/// Discovering brushes from the manifest means new `.myb` files dropped into
/// `assets/brushes/<set>/` appear automatically without editing this list.
Future<Map<String, List<BrushAsset>>> _loadBrushCatalog() async {
  final AssetManifest manifest =
      await AssetManifest.loadFromAssetBundle(rootBundle);
  final List<String> keys = manifest.listAssets();
  final Set<String> previews =
      keys.where((k) => k.endsWith('_prev.png')).toSet();

  final Map<String, List<BrushAsset>> groups = {};
  for (final String key in keys) {
    if (!key.endsWith('.myb') || !key.startsWith('assets/brushes/')) continue;
    final List<String> parts = key.split('/');
    final String group = parts[parts.length - 2];
    final String name = parts.last.replaceAll('.myb', '');
    final String prev = key.replaceAll('.myb', '_prev.png');
    groups.putIfAbsent(group, () => []).add(
          BrushAsset(
            path: key,
            preview: previews.contains(prev) ? prev : null,
            name: name,
            group: group,
          ),
        );
  }

  for (final List<BrushAsset> list in groups.values) {
    list.sort((a, b) => a.name.compareTo(b.name));
  }
  return groups;
}

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

  @override
  State<PaintScreen> createState() => _PaintScreenState();
}

class _PaintScreenState extends State<PaintScreen> {
  BrushEngine? _engine;
  ui.Image? _image;
  Size? _surfaceSize;

  Map<String, List<BrushAsset>> _catalog = {};
  String _currentBrush = _defaultBrush;
  Color _color = Colors.black;
  Duration? _lastEventTime;
  int? _activePointer;
  bool _rendering = false;
  bool _renderQueued = false;

  @override
  void initState() {
    super.initState();
    _loadBrushCatalog().then((catalog) {
      if (mounted) setState(() => _catalog = catalog);
    });
  }

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

  Future<void> _ensureEngine(Size size) async {
    final int w = size.width.floor();
    final int h = size.height.floor();
    if (w <= 0 || h <= 0) return;
    if (_engine != null && _surfaceSize == size) return;

    _engine?.dispose();
    final engine = BrushEngine(surface: MyPaintSurface(w, h), brush: Brush());
    _surfaceSize = size;
    _engine = engine;
    await _applyBrush(_currentBrush);
    await _render();
  }

  Future<void> _applyBrush(String asset) async {
    final engine = _engine;
    if (engine == null) return;
    final String json = await rootBundle.loadString(asset);
    engine.brush.loadFromString(json);
    engine.brush.setColorFrom(_color);
  }

  /// Begins a fresh, disconnected stroke at the event's position.
  ///
  /// Discards the previous stroke's position and primes the brush at this point
  /// with a large dtime so libmypaint teleports there (its 5-second threshold)
  /// instead of drawing a connecting line from where the last stroke ended.
  void _beginStroke(PointerEvent event, BrushEngine engine) {
    engine.brush.reset();
    engine.brush.newStroke();
    _activePointer = event.pointer;
    _lastEventTime = event.timeStamp;
    engine.surface.beginAtomic();
    engine.strokeTo(
      event.localPosition.dx,
      event.localPosition.dy,
      pressure: 0.0,
      dtime: 10.0,
    );
    engine.surface.endAtomic();
  }

  void _onPointerDown(PointerDownEvent event) {
    final engine = _engine;
    if (engine == null) return;
    _beginStroke(event, engine);
  }

  void _onPointerMove(PointerMoveEvent event) {
    final engine = _engine;
    if (engine == null) return;

    // Self-heal: if we never saw the matching pointer-down, start the stroke
    // here so it doesn't connect to the previous stroke's end point.
    if (_activePointer != event.pointer) {
      _beginStroke(event, engine);
      _scheduleRender();
      return;
    }

    final double dt = _lastEventTime == null
        ? 1.0 / 60.0
        : (event.timeStamp - _lastEventTime!).inMicroseconds / 1e6;
    _lastEventTime = event.timeStamp;

    final double pressure = event.pressureMax > event.pressureMin
        ? event.pressure
        : 1.0;

    engine.surface.beginAtomic();
    engine.strokeTo(
      event.localPosition.dx,
      event.localPosition.dy,
      pressure: pressure.clamp(0.0, 1.0),
      dtime: dt <= 0 ? 1.0 / 60.0 : dt,
    );
    engine.surface.endAtomic();
    _scheduleRender();
  }

  void _onPointerUp(PointerEvent event) {
    _activePointer = null;
    _lastEventTime = null;
  }

  /// Coalesces render requests so we never run more than one decode at a time.
  void _scheduleRender() {
    if (_rendering) {
      _renderQueued = true;
      return;
    }
    _render();
  }

  Future<void> _render() async {
    final engine = _engine;
    if (engine == null) return;
    _rendering = true;
    final ui.Image image = await engine.surface.toImage();
    if (!mounted) {
      image.dispose();
      return;
    }
    setState(() {
      _image?.dispose();
      _image = image;
    });
    _rendering = false;
    if (_renderQueued) {
      _renderQueued = false;
      _scheduleRender();
    }
  }

  void _clear() {
    final engine = _engine;
    if (engine == null) return;
    engine.surface.clear();
    _render();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('libmypaint'),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_outline),
            onPressed: _clear,
            tooltip: 'Clear',
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            child: LayoutBuilder(
              builder: (context, constraints) {
                final size = constraints.biggest;
                _ensureEngine(size);
                return RepaintBoundary(
                  child: Listener(
                    behavior: HitTestBehavior.opaque,
                    onPointerDown: _onPointerDown,
                    onPointerMove: _onPointerMove,
                    onPointerUp: _onPointerUp,
                    onPointerCancel: _onPointerUp,
                    child: CustomPaint(
                      painter: _CanvasPainter(_image),
                      size: Size.infinite,
                    ),
                  ),
                );
              },
            ),
          ),
          _Toolbar(
            catalog: _catalog,
            currentBrush: _currentBrush,
            color: _color,
            onBrush: (asset) {
              setState(() => _currentBrush = asset);
              _applyBrush(asset);
            },
            onColor: (color) {
              setState(() => _color = color);
              _engine?.brush.setColorFrom(color);
            },
          ),
        ],
      ),
    );
  }
}

class _CanvasPainter extends CustomPainter {
  _CanvasPainter(this.image);

  final ui.Image? image;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawColor(Colors.white, BlendMode.src);
    final img = image;
    if (img != null) {
      canvas.drawImage(img, Offset.zero, Paint());
    }
  }

  @override
  bool shouldRepaint(_CanvasPainter oldDelegate) => oldDelegate.image != image;
}

class _Toolbar extends StatelessWidget {
  const _Toolbar({
    required this.catalog,
    required this.currentBrush,
    required this.color,
    required this.onBrush,
    required this.onColor,
  });

  final Map<String, List<BrushAsset>> catalog;
  final String currentBrush;
  final Color color;
  final ValueChanged<String> onBrush;
  final ValueChanged<Color> onColor;

  static const List<Color> _palette = [
    Colors.black,
    Colors.red,
    Colors.green,
    Colors.blue,
    Colors.orange,
    Colors.purple,
  ];

  Future<void> _openPicker(BuildContext context) async {
    final int total =
        catalog.values.fold(0, (sum, list) => sum + list.length);
    final String? picked = await showModalBottomSheet<String>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (context) => _BrushPicker(
        catalog: catalog,
        currentBrush: currentBrush,
        total: total,
      ),
    );
    if (picked != null) onBrush(picked);
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 8,
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.all(8),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              SizedBox(
                height: 40,
                child: ListView(
                  scrollDirection: Axis.horizontal,
                  children: [
                    for (final color in _palette)
                      _ColorDot(
                        color: color,
                        selected: color == this.color,
                        onTap: () => onColor(color),
                      ),
                  ],
                ),
              ),
              const SizedBox(height: 8),
              SizedBox(
                width: double.infinity,
                child: OutlinedButton.icon(
                  icon: const Icon(Icons.brush),
                  label: Text('Brush: ${_brushName(currentBrush)}'),
                  onPressed:
                      catalog.isEmpty ? null : () => _openPicker(context),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  static String _brushName(String asset) =>
      asset.split('/').last.replaceAll('.myb', '');
}

/// A modal sheet listing every bundled brush, grouped by set, with preview
/// thumbnails. Pops the selected brush's asset path, or null if dismissed.
class _BrushPicker extends StatelessWidget {
  const _BrushPicker({
    required this.catalog,
    required this.currentBrush,
    required this.total,
  });

  final Map<String, List<BrushAsset>> catalog;
  final String currentBrush;
  final int total;

  @override
  Widget build(BuildContext context) {
    final List<String> groups = catalog.keys.toList()..sort();
    return DraggableScrollableSheet(
      expand: false,
      initialChildSize: 0.7,
      maxChildSize: 0.95,
      builder: (context, scrollController) {
        return CustomScrollView(
          controller: scrollController,
          slivers: [
            SliverPadding(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
              sliver: SliverToBoxAdapter(
                child: Text(
                  '$total brushes',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ),
            ),
            for (final String group in groups) ...[
              SliverPadding(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
                sliver: SliverToBoxAdapter(
                  child: Text(
                    group,
                    style: Theme.of(context).textTheme.titleSmall,
                  ),
                ),
              ),
              SliverPadding(
                padding: const EdgeInsets.symmetric(horizontal: 8),
                sliver: SliverGrid(
                  gridDelegate:
                      const SliverGridDelegateWithMaxCrossAxisExtent(
                    maxCrossAxisExtent: 96,
                    mainAxisExtent: 108,
                    crossAxisSpacing: 4,
                    mainAxisSpacing: 4,
                  ),
                  delegate: SliverChildBuilderDelegate(
                    (context, index) {
                      final BrushAsset brush = catalog[group]![index];
                      return _BrushTile(
                        brush: brush,
                        selected: brush.path == currentBrush,
                        onTap: () => Navigator.pop(context, brush.path),
                      );
                    },
                    childCount: catalog[group]!.length,
                  ),
                ),
              ),
            ],
            const SliverToBoxAdapter(child: SizedBox(height: 16)),
          ],
        );
      },
    );
  }
}

class _BrushTile extends StatelessWidget {
  const _BrushTile({
    required this.brush,
    required this.selected,
    required this.onTap,
  });

  final BrushAsset brush;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final ColorScheme scheme = Theme.of(context).colorScheme;
    return InkWell(
      onTap: onTap,
      borderRadius: BorderRadius.circular(8),
      child: Column(
        children: [
          Container(
            width: 72,
            height: 72,
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(8),
              border: Border.all(
                color: selected ? scheme.primary : scheme.outlineVariant,
                width: selected ? 3 : 1,
              ),
            ),
            clipBehavior: Clip.antiAlias,
            child: brush.preview == null
                ? const Icon(Icons.brush, color: Colors.black38)
                : Image.asset(
                    brush.preview!,
                    fit: BoxFit.cover,
                    errorBuilder: (context, error, stackTrace) =>
                        const Icon(Icons.brush, color: Colors.black38),
                  ),
          ),
          const SizedBox(height: 4),
          Expanded(
            child: Text(
              brush.name,
              style: Theme.of(context).textTheme.labelSmall,
              textAlign: TextAlign.center,
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
          ),
        ],
      ),
    );
  }
}

class _ColorDot extends StatelessWidget {
  const _ColorDot({
    required this.color,
    required this.selected,
    required this.onTap,
  });

  final Color color;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        width: 40,
        height: 40,
        margin: const EdgeInsets.symmetric(horizontal: 4),
        decoration: BoxDecoration(
          color: color,
          shape: BoxShape.circle,
          border: Border.all(
            color: selected ? Colors.white : Colors.transparent,
            width: 3,
          ),
          boxShadow: const [BoxShadow(blurRadius: 2, color: Colors.black26)],
        ),
      ),
    );
  }
}
1
likes
155
points
186
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Android-only Flutter FFI plugin wrapping libmypaint to paint MyPaint brush strokes with pressure and tilt onto a pixel surface.

Homepage
Repository (GitHub)
View/report issues

Topics

#painting #drawing #brush #graphics #ffi

License

MIT (license)

Dependencies

ffi, flutter, plugin_platform_interface

More

Packages that depend on mypaint_ffi

Packages that implement mypaint_ffi