mypaint_ffi 0.0.3
mypaint_ffi: ^0.0.3 copied to clipboard
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)],
),
),
);
}
}