diff --git a/.pubignore b/.pubignore
index 7f6280c..0ae7e76 100644
--- a/.pubignore
+++ b/.pubignore
@@ -5,4 +5,5 @@
/doc/hero.png
/build/
.git/
-.github/
\ No newline at end of file
+.github/
+/doc/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae5e826..9c24cd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,59 @@
+## 0.1.0
+
+Targets `layer_canvas: ^0.1.0` (was `^0.1.0-beta.4`) and surfaces everything the
+core gained since — still with only Flutter types.
+
+* **Stroke cap/join/miter/dash** — `Layers.rectangle`/`Layers.path` gain
+ `strokeCap`/`strokeJoin`/`strokeMiterLimit`, using `dart:ui`'s own
+ `StrokeCap`/`StrokeJoin`; `Layers.path` additionally accepts
+ `dashArray`/`dashOffset` (scaled by `pixelRatio` like every other length),
+ since dashing only applies to a `PathLayer`'s stroke in the core.
+* **`clipBehavior`** — `Layers.rectangle`/`text`/`image`/`path` accept a
+ `Clip clipBehavior` (any value but `Clip.none` clips the layer to its
+ `size`), mapping to the core's `Layer.clipToBounds`. Not on `Layers.group`/
+ `Layers.svg`: the core expands a `Group` into its concrete descendants
+ before rendering, leaving no single surface to clip.
+* **`scale`/`alignment`** — every `Layers` factory now accepts `scale`
+ (uniform, via the core's `LayerTransform.scale`) and `alignment` (via the
+ existing `AlignmentGeometryX.toFractionalPoint2D()`, so `rotation`/`scale`
+ can pivot around a corner instead of the center), alongside `position`/
+ `rotation`.
+* **Word-wrap** — `Layers.text` word-wraps into a `size` with a width set
+ (greedily, only at spaces), vertically centered within `size`'s height —
+ no new parameter, this was already reachable, just newly documented.
+* **Exact `FontWeight` mapping** — `FontWeightX.toTextWeight()` now uses the
+ core's `TextWeight.fromValue` (added in `layer_canvas` 0.1.0-beta.6) for an
+ exact 1:1 conversion, instead of snapping to the nearest of 7 named
+ constants.
+* **`AssetImageSource`** — a `LayerImageSource` that lazily loads a Flutter
+ asset by key (optionally from a `package`) instead of eagerly reading bytes
+ like `ImageSources.asset`, so it's cheap to build ahead of time and
+ compact to serialize (a short string instead of a base64 blob). Every
+ widget in this package (and the new `Scenes.encode`/`saveToFile`) resolves
+ it against the ambient `AssetBundle` before rendering; registers its own
+ `LayerRegistry` decoder automatically, so `Scene.fromJson` reconstructs it
+ too.
+* **Scene persistence** — `Scene.toJson()`/`Scene.fromJson()` (added in
+ `layer_canvas` 0.1.0-beta.6) work as-is through this package's re-exported
+ `Scene`; only documented here, no new API.
+* **Export** — `Scenes.encode(scene, {format})` and
+ `Scenes.saveToFile(scene, path, {format})` rasterize a `Scene` to
+ `png`/`bmp`/`qoi` bytes or a file (via the re-exported `OutputFormat`),
+ resolving any `AssetImageSource` first — for exporting a scene instead of
+ displaying it, which every widget still does as PNG.
+* Re-exports `OutputFormat` and `LayerRegistry` (+ its `LayerFromJson`/
+ `ImageSourceFromJson` typedefs) alongside the previously re-exported core
+ types.
+* **Rendering never blocks the UI isolate** — `LayerCanvas`, `SceneWidget`,
+ `SvgLayer`, and `Scenes.encode`/`saveToFile` now run the native render
+ call on a background isolate internally (`renderOffMainIsolate`), instead
+ of on the calling isolate. The core `layer_canvas` package's own
+ `Renderer.render` stays synchronous-under-the-hood on purpose — it's
+ plain Dart with no UI thread to protect, so a CLI/batch consumer isn't
+ charged isolate-spawn overhead for a benefit only a Flutter app needs;
+ this package is where that trade makes sense, so it's where the offload
+ lives.
+
## 0.1.0-beta.1
Initial release.
diff --git a/README.md b/README.md
index 0b9052b..9acd9cf 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,25 @@ other `Image` — no `Color32`, no `Point2D`, nothing from the core package
imported directly. See [Usage](#usage) below for gradients, custom shapes,
SVG, and tap handling.
+## Gallery
+
+A few scenes built entirely from `Layers`/`Scenes.of`/`LayerPathBuilder` —
+gradients, hand-drawn vector paths, SVG, dashed strokes, and wrapped text —
+to give a sense of what a `LayerCanvas` can render.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Features
* **`Layers`** — static factories (`rectangle`, `text`, `image`, `path`,
@@ -51,6 +70,13 @@ SVG, and tap handling.
* **Gradients** — pass a Flutter `LinearGradient`/`RadialGradient`/
`SweepGradient` as `Layers.rectangle`/`Layers.path`'s `gradient:`, no core
gradient types involved.
+* **Stroke cap/join/miter/dash** — `strokeCap`/`strokeJoin` (`dart:ui`'s own
+ enums), `strokeMiterLimit`, and (on `Layers.path`) `dashArray`/
+ `dashOffset` for a dashed stroke.
+* **`clipBehavior`** — clips a sized layer to its own box, same idea as
+ `Container`'s `clipBehavior`.
+* **`scale`/`alignment`** — every factory's `rotation` gets a uniform
+ `scale` and an `alignment` to pivot around, instead of always the center.
* **`LayerPathBuilder`** — draws a `Layers.path` shape with the same method
names as `dart:ui`'s own `Path` (`moveTo`, `lineTo`, `cubicTo`,
`arcToPoint`, `close`...), so it reads like drawing on a `Canvas`.
@@ -60,6 +86,12 @@ SVG, and tap handling.
widget; a different rendering path entirely).
* **`Scenes.of`** — builds a `Scene` from a `children` list instead of the
core's mutate-after-construction `Scene(...)..add(...)..add(...)`.
+* **`AssetImageSource`** — an image layer/background source that lazily
+ loads a Flutter asset by key, resolved automatically before every render.
+* **Scene persistence** — `Scene.toJson()`/`fromJson()` save and restore a
+ whole scene, `AssetImageSource` included.
+* **`Scenes.encode`/`Scenes.saveToFile`** — export a `Scene` to
+ `png`/`bmp`/`qoi` bytes or a file, instead of displaying it.
* **`LayerCanvas`** — a widget that renders a `Scene` (fixed, or built from
the widget's measured size and device pixel ratio) as an `Image`, with
render caching, a placeholder while it's rendering, an error builder, and
@@ -82,7 +114,7 @@ cover the whole surface you need from Flutter code.
```yaml
dependencies:
- layer_canvas_flutter: ^0.1.0-beta.1
+ layer_canvas_flutter: ^0.1.0
```
`layer_canvas` embeds a default font (Roboto) in its native library so text
@@ -181,6 +213,66 @@ Layers.rectangle(
)
```
+### Strokes: cap, join, miter, dash
+
+`strokeCap`/`strokeJoin` take `dart:ui`'s own enums — the same ones a
+`Paint` would — and `strokeMiterLimit` controls how far a `StrokeJoin.miter`
+corner may extend before it's clamped to a bevel. `Layers.path` additionally
+takes `dashArray`/`dashOffset` for a dashed stroke (only `PathLayer`s dash —
+a `RectangleLayer` has no path geometry of its own to dash):
+
+```dart
+Layers.path(
+ path: LayerPathBuilder()
+ ..moveTo(const Offset(0, 50))
+ ..lineTo(const Offset(300, 50)),
+ color: const Color(0xFF4C6EF5),
+ style: PaintingStyle.stroke,
+ strokeWidth: 4,
+ strokeCap: StrokeCap.round,
+ dashArray: const [12, 8],
+ pixelRatio: pixelRatio,
+)
+```
+
+### Clipping with clipBehavior
+
+`clipBehavior` (any value but the default `Clip.none`) clips a sized layer
+to its own box — same idea as `Container`'s `clipBehavior`. The natural
+case is cropping a `cover`-fit image, exactly like `Image` inside a clipped
+box:
+
+```dart
+Layers.image(
+ source: MemoryImageSource(bytes),
+ size: const Size(200, 120),
+ fit: BoxFit.cover,
+ clipBehavior: Clip.hardEdge,
+ pixelRatio: pixelRatio,
+)
+```
+
+Not available on `Layers.group`/`Layers.svg`: the core expands a `Group`
+into its concrete descendants before rendering, leaving no single surface
+to clip — clip an individual child via its own factory instead.
+
+### scale and alignment
+
+Every factory's `rotation` gets two companions: `scale` (uniform) and
+`alignment` (where `rotation`/`scale` pivot from — `Alignment.center` by
+default, same as the core):
+
+```dart
+Layers.rectangle(
+ size: const Size(80, 80),
+ color: const Color(0xFFFF6B6B),
+ rotation: 0.3,
+ scale: 1.2,
+ alignment: Alignment.topLeft, // pivot from the corner, not the center
+ pixelRatio: pixelRatio,
+)
+```
+
### Custom shapes with LayerPathBuilder
`LayerPathBuilder` mirrors `dart:ui`'s `Path` — the same method names, in
@@ -248,6 +340,25 @@ the document at its own natural size and lets Flutter's ordinary layout
scale/position that result, the same way it would any other
fixed-aspect-ratio child.
+### Word-wrap
+
+`Layers.text` word-wraps into a `size` with a width set — greedily,
+breaking only at spaces (a single word wider than the box overflows on its
+own line rather than being split mid-word) — and the wrapped block is
+vertically centered within `size`'s height:
+
+```dart
+Layers.text(
+ text: 'A longer caption that should wrap across a few lines.',
+ size: const Size(220, 80),
+ fontSize: 16,
+ pixelRatio: pixelRatio,
+)
+```
+
+Leave `size` unset (or give it no width) for a single, possibly overflowing
+line — the same as before this existed.
+
### Tap handling with onLayerTap
`LayerCanvas.onLayerTap` reports which `Layer` (if any) was under a tap,
@@ -310,6 +421,45 @@ one in place, pass a changing `rebuildKey` to force a re-render:
LayerCanvas(scene: scene, rebuildKey: generation)
```
+### Saving and loading a Scene
+
+`Scene` round-trips through JSON as-is — `toJson()`/`fromJson` recurse
+through every layer, paint, gradient, transform, and image source:
+
+```dart
+final json = jsonEncode(scene.toJson());
+// ...later, or on another device:
+final restored = Scene.fromJson(jsonDecode(json) as Map);
+```
+
+For an image that should serialize as a short asset key instead of a
+base64 blob, use `AssetImageSource` instead of `ImageSources.asset`
+(which reads the bytes immediately):
+
+```dart
+Layers.image(
+ source: AssetImageSource('assets/logo.png'),
+ size: const Size(120, 40),
+)
+```
+
+Every widget in this package resolves an `AssetImageSource` against the
+ambient `AssetBundle` right before rendering, and its `LayerRegistry`
+decoder is registered automatically the first time one is built or
+deserialized — no setup call needed.
+
+### Exporting a Scene
+
+Every widget in this package always displays a `Scene` as PNG. To export
+one instead — to a file, or as bytes in another format — use `Scenes`:
+
+```dart
+final pngBytes = await Scenes.encode(scene); // png by default
+await Scenes.saveToFile(scene, '/path/to/export.qoi', format: OutputFormat.qoi);
+```
+
+Both resolve any `AssetImageSource` in `scene` first, same as `LayerCanvas`.
+
## Additional information
This package only depends on `layer_canvas` and re-exports only the pieces
@@ -317,14 +467,17 @@ of its API that this package's own public API surfaces as parameters or
return types: `Scene`, `Layer` and its subclasses (`RectangleLayer`,
`TextLayer`, `ImageLayer`, `PathLayer`, `Group`), `LayerImageSource` (with
`FileImageSource`/`MemoryImageSource`), `SvgDocument`/`SvgParseException`,
-`Renderer`/`RenderException`, and `FontRegistry`/`FontRegistrationException`.
-Value types the core exposes that `Layers` and the adapters exist
-specifically to shield you from (`Color32`, `Point2D`/`Size2D`,
-`TextWeight`, `TextAlignment`, `ImageFit`, `LayerPaint`, `LayerTransform`,
-`FillRule`, and the core's own `Gradient`/`LinearGradient`/
+`Renderer`/`RenderException`, `OutputFormat`,
+`FontRegistry`/`FontRegistrationException`, and `LayerRegistry` (with its
+`LayerFromJson`/`ImageSourceFromJson` typedefs, for registering a custom
+`Layer`/`LayerImageSource` subclass of your own). Value types the core
+exposes that `Layers` and the adapters exist specifically to shield you
+from (`Color32`, `Point2D`/`Size2D`, `TextWeight`, `TextAlignment`,
+`ImageFit`, `LayerPaint`, `LayerTransform`, `FillRule`, the core's own
+`StrokeCap`/`StrokeJoin`, and its `Gradient`/`LinearGradient`/
`RadialGradient`/`ConicGradient`, which would otherwise collide with
-Flutter's own same-named gradient types) are intentionally not re-exported
-— building UI with this package should never require importing
+Flutter's own same-named types) are intentionally not re-exported —
+building UI with this package should never require importing
`package:layer_canvas` directly.
See [`layer_canvas`](https://pub.dev/packages/layer_canvas) and its
diff --git a/doc/gallery/bauhaus-grid.png b/doc/gallery/bauhaus-grid.png
new file mode 100644
index 0000000..4ce79c3
Binary files /dev/null and b/doc/gallery/bauhaus-grid.png differ
diff --git a/doc/gallery/constellation.png b/doc/gallery/constellation.png
new file mode 100644
index 0000000..4603e37
Binary files /dev/null and b/doc/gallery/constellation.png differ
diff --git a/doc/gallery/editorial-card.png b/doc/gallery/editorial-card.png
new file mode 100644
index 0000000..ddb635e
Binary files /dev/null and b/doc/gallery/editorial-card.png differ
diff --git a/doc/gallery/gradient-burst.png b/doc/gallery/gradient-burst.png
new file mode 100644
index 0000000..28a21f7
Binary files /dev/null and b/doc/gallery/gradient-burst.png differ
diff --git a/doc/gallery/svg-pattern.png b/doc/gallery/svg-pattern.png
new file mode 100644
index 0000000..459ff06
Binary files /dev/null and b/doc/gallery/svg-pattern.png differ
diff --git a/doc/gallery/vector-blob.png b/doc/gallery/vector-blob.png
new file mode 100644
index 0000000..6ea6326
Binary files /dev/null and b/doc/gallery/vector-blob.png differ
diff --git a/example/assets/images/logo.png b/example/assets/images/logo.png
new file mode 100644
index 0000000..bdb5722
Binary files /dev/null and b/example/assets/images/logo.png differ
diff --git a/example/lib/gallery_main.dart b/example/lib/gallery_main.dart
new file mode 100644
index 0000000..803fa74
--- /dev/null
+++ b/example/lib/gallery_main.dart
@@ -0,0 +1,421 @@
+// Throwaway screenshot generator for the README gallery — not part of the
+// shipped example. Run with:
+// flutter run -d -t lib/gallery_main.dart
+// Tap anywhere to advance to the next scene.
+import 'package:flutter/material.dart';
+import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
+
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await LayerCanvasFonts.ensureInitialized(
+ asDefault: 'Roboto',
+ families: {'Roboto'},
+ );
+ runApp(const GalleryApp());
+}
+
+class GalleryApp extends StatelessWidget {
+ const GalleryApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const MaterialApp(
+ debugShowCheckedModeBanner: false,
+ home: GalleryPage(),
+ );
+ }
+}
+
+typedef _SceneBuilder = Scene Function(Size logicalSize, double pixelRatio);
+
+const _gallerySvg = '''
+
+''';
+
+class GalleryPage extends StatefulWidget {
+ const GalleryPage({super.key});
+
+ @override
+ State createState() => _GalleryPageState();
+}
+
+class _GalleryPageState extends State {
+ static final _svg = SvgDocument.parse(_gallerySvg);
+
+ late final List<_SceneBuilder> _scenes = [
+ _gradientBurst,
+ _vectorBlob,
+ _bauhausGrid,
+ _svgPattern,
+ _constellation,
+ _editorialCard,
+ ];
+
+ int _index = 0;
+
+ Scene _gradientBurst(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFF0B0B14)),
+ // Gradients are fractional relative to the layer's own `size` — an
+ // explicit `size` centered on the circle (path centered at
+ // (radius, radius), so the box exactly bounds it) is what makes the
+ // gradient actually fade across the shape instead of collapsing to
+ // a solid fill.
+ _gradientCircle(
+ center: const Offset(150, 220),
+ radius: 170,
+ gradient: const RadialGradient(
+ colors: [Color(0xFFFFD93D), Color(0x00FFD93D)],
+ ),
+ opacity: 0.9,
+ pixelRatio: pixelRatio,
+ ),
+ _gradientCircle(
+ center: const Offset(260, 480),
+ radius: 190,
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFF4C6EF5), Color(0xFF63E6BE)],
+ ),
+ opacity: 0.85,
+ pixelRatio: pixelRatio,
+ ),
+ _gradientCircle(
+ center: const Offset(190, 740),
+ radius: 210,
+ gradient: const SweepGradient(
+ colors: [
+ Color(0xFFFF6B6B),
+ Color(0xFF4C6EF5),
+ Color(0xFFFFD93D),
+ Color(0xFFFF6B6B),
+ ],
+ ),
+ opacity: 0.85,
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ /// A filled circle sized/positioned so its `Layers.path` `size` box exactly
+ /// bounds it — see the comment in [_gradientBurst] for why that matters
+ /// for [gradient] to render as an actual fade instead of a solid fill.
+ PathLayer _gradientCircle({
+ required Offset center,
+ required double radius,
+ required Gradient gradient,
+ required double pixelRatio,
+ double opacity = 1,
+ }) {
+ return Layers.path(
+ path: LayerPathBuilder.circle(Offset(radius, radius), radius),
+ position: Offset(center.dx - radius, center.dy - radius),
+ size: Size(radius * 2, radius * 2),
+ gradient: gradient,
+ opacity: opacity,
+ pixelRatio: pixelRatio,
+ );
+ }
+
+ /// A smooth, clearly-asymmetric organic blob through [vertices] — the
+ /// "rounded polygon" trick: draw quadratic curves between each edge's
+ /// midpoint, using the shared vertex as the curve's control point, so the
+ /// silhouette bulges through every vertex instead of having sharp corners.
+ static LayerPathBuilder _blob(List vertices) {
+ Offset mid(Offset a, Offset b) =>
+ Offset((a.dx + b.dx) / 2, (a.dy + b.dy) / 2);
+
+ final builder = LayerPathBuilder()..moveTo(mid(vertices.last, vertices[0]));
+ for (var i = 0; i < vertices.length; i++) {
+ final next = vertices[(i + 1) % vertices.length];
+ builder.quadraticBezierTo(vertices[i], mid(vertices[i], next));
+ }
+ return builder..close();
+ }
+
+ Scene _vectorBlob(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ // 8 vertices around center (170,170) at 45° steps, radius sequence
+ // 155/95/145/110/150/90/135/120 — deliberately irregular (no repeating
+ // big/small alternation) so the quadratic "rounded polygon" trick (see
+ // [_blob]) reads as an organic blob instead of a symmetric rounded
+ // square/diamond (which a strict alternating radius produces).
+ final blob = _blob(const [
+ Offset(325, 170),
+ Offset(237, 237),
+ Offset(170, 315),
+ Offset(92, 248),
+ Offset(20, 170),
+ Offset(106, 106),
+ Offset(170, 35),
+ Offset(255, 85),
+ ]);
+
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFF14141F)),
+ Layers.path(
+ path: blob,
+ position: const Offset(30, 220),
+ // The blob's own local coordinates span roughly (20,35)-(325,315)
+ // — size it to that bounding box so the gradient (fractional
+ // relative to `size`) actually fades across the shape instead of
+ // collapsing to a solid fill (Layer.size defaults to 0x0 when
+ // unset).
+ size: const Size(305, 280),
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFF1098AD), Color(0xFF63E6BE)],
+ ),
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(const Offset(300, 180), 16),
+ color: const Color(0xFFFFD93D),
+ position: const Offset(30, 220),
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(const Offset(60, 620), 26),
+ color: const Color(0xFFFF6B6B),
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(const Offset(280, 700), 10),
+ color: const Color(0xFFFFFFFF),
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ Scene _bauhausGrid(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFFF5F0E6)),
+ Layers.rectangle(
+ size: const Size(220, 220),
+ position: Offset(logicalSize.width / 2 - 110, 90),
+ color: const Color(0xFFE03131),
+ rotation: 0.12,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(Offset.zero, 90),
+ position: Offset(logicalSize.width - 130, 260),
+ color: const Color(0xFF1864AB),
+ pixelRatio: pixelRatio,
+ ),
+ Layers.rectangle(
+ size: const Size(260, 90),
+ position: const Offset(30, 470),
+ color: const Color(0xFFFFD43B),
+ rotation: -0.06,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.rectangle(
+ size: const Size(70, 320),
+ position: Offset(logicalSize.width / 2 - 35, 560),
+ color: const Color(0xFF1A1A1A),
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(Offset.zero, 40),
+ position: const Offset(70, 700),
+ color: const Color(0xFFE03131),
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ Scene _svgPattern(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(
+ size: physicalSize,
+ gradient: const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Color(0xFF1E1E2E), Color(0xFF2B2B45)],
+ ),
+ ),
+ // `size:` is Group metadata (anchor/gradient reference box), not a
+ // rescale — the artwork's own viewBox (0 0 100 100) is its true
+ // visual size, so growing/shrinking it needs `scale:` instead.
+ // `size: Size(100,100)` keeps the default center anchor pivoting
+ // rotation around the artwork's actual middle. With the default
+ // center anchor, `position` places where that anchor ends up — i.e.
+ // the artwork's *center*, not its top-left corner — so each is
+ // placed with enough margin for its scaled radius to stay on
+ // screen.
+ Layers.svg(
+ _svg,
+ position: const Offset(150, 190),
+ size: const Size(100, 100),
+ scale: 4.0,
+ rotation: -0.15,
+ opacity: 0.5,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.svg(
+ _svg,
+ position: const Offset(280, 400),
+ size: const Size(100, 100),
+ scale: 5.0,
+ rotation: 0.1,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.svg(
+ _svg,
+ position: const Offset(160, 750),
+ size: const Size(100, 100),
+ scale: 3.0,
+ rotation: 0.3,
+ opacity: 0.75,
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ Scene _constellation(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ const nodes = [
+ Offset(60, 120),
+ Offset(220, 90),
+ Offset(300, 260),
+ Offset(150, 320),
+ Offset(90, 480),
+ Offset(260, 520),
+ Offset(180, 650),
+ Offset(300, 760),
+ ];
+ final links = [
+ (0, 1),
+ (1, 2),
+ (0, 3),
+ (2, 3),
+ (3, 4),
+ (4, 5),
+ (3, 6),
+ (5, 6),
+ (6, 7),
+ ];
+
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFF0B0F1F)),
+ for (final (a, b) in links)
+ Layers.path(
+ path: LayerPathBuilder()
+ ..moveTo(nodes[a])
+ ..lineTo(nodes[b]),
+ color: const Color(0x8863E6BE),
+ style: PaintingStyle.stroke,
+ strokeWidth: 2,
+ strokeCap: StrokeCap.round,
+ dashArray: const [10, 8],
+ pixelRatio: pixelRatio,
+ ),
+ for (final node in nodes)
+ Layers.path(
+ path: LayerPathBuilder.circle(Offset.zero, 8),
+ position: node,
+ color: const Color(0xFFFFD93D),
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ Scene _editorialCard(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(
+ size: physicalSize,
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [Color(0xFF2B2B45), Color(0xFF4C6EF5)],
+ ),
+ ),
+ Layers.image(
+ source: AssetImageSource('assets/images/logo.png'),
+ position: const Offset(24, 24),
+ size: const Size(56, 56),
+ fit: BoxFit.cover,
+ clipBehavior: Clip.hardEdge,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.text(
+ text: 'Native 2D rendering,\nbuilt for Flutter.',
+ position: const Offset(24, 110),
+ size: const Size(300, 220),
+ color: const Color(0xFFFFFFFF),
+ fontSize: 30,
+ fontWeight: FontWeight.w700,
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder()
+ ..moveTo(const Offset(24, 0))
+ ..lineTo(const Offset(140, 0)),
+ position: const Offset(0, 380),
+ color: const Color(0xFFFFD93D),
+ style: PaintingStyle.stroke,
+ strokeWidth: 4,
+ strokeCap: StrokeCap.round,
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ void _next() {
+ setState(() => _index = (_index + 1) % _scenes.length);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: _next,
+ child: Scaffold(
+ backgroundColor: Colors.black,
+ body: SizedBox.expand(
+ child: LayerCanvas(sceneBuilder: _scenes[_index]),
+ ),
+ ),
+ );
+ }
+}
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 78a4c6d..0de249a 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,3 +1,6 @@
+import 'dart:convert';
+import 'dart:io';
+
import 'package:flutter/material.dart';
import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
@@ -40,14 +43,25 @@ const _demoLogoSvg = '''
''';
-class DemoPage extends StatelessWidget {
+class DemoPage extends StatefulWidget {
const DemoPage({super.key});
+ @override
+ State createState() => _DemoPageState();
+}
+
+class _DemoPageState extends State {
// Parsed once (real XML work) and reused by every rebuild — see the
// "SVG" section of the README for why this isn't parsed inside a
// sceneBuilder, which runs on every build/resize.
static final _logo = SvgDocument.parse(_demoLogoSvg);
+ // Populated by the "Save"/"Load"/"Export" buttons in the persistence demo
+ // near the bottom of the page.
+ String? _savedJson;
+ Scene? _restoredScene;
+ String? _exportStatus;
+
Scene _buildScene(Size logicalSize, double pixelRatio, {String? fontFamily}) {
final physicalSize = logicalSize * pixelRatio;
return Scenes.of(
@@ -170,6 +184,63 @@ class DemoPage extends StatelessWidget {
);
}
+ Scene _buildDashedStrokeScene(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFF1E1E2E)),
+ // strokeCap/strokeJoin are dart:ui's own enums; dashArray/dashOffset
+ // only take effect on a Layers.path stroke (a rectangle has no path
+ // geometry of its own to dash).
+ Layers.path(
+ path: LayerPathBuilder()
+ ..moveTo(const Offset(20, 50))
+ ..lineTo(const Offset(280, 50)),
+ color: const Color(0xFFFFD93D),
+ style: PaintingStyle.stroke,
+ strokeWidth: 6,
+ strokeCap: StrokeCap.round,
+ dashArray: const [16, 10],
+ pixelRatio: pixelRatio,
+ ),
+ Layers.path(
+ path: LayerPathBuilder.circle(const Offset(150, 110), 40),
+ color: const Color(0xFF63E6BE),
+ style: PaintingStyle.stroke,
+ strokeWidth: 5,
+ strokeJoin: StrokeJoin.round,
+ dashArray: const [10, 6],
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
+ Scene _buildClippedImageScene(Size logicalSize, double pixelRatio) {
+ final physicalSize = logicalSize * pixelRatio;
+ return Scenes.of(
+ width: physicalSize.width,
+ height: physicalSize.height,
+ children: [
+ Layers.rectangle(size: physicalSize, color: const Color(0xFF1E1E2E)),
+ // AssetImageSource loads assets/images/logo.png lazily, resolved by
+ // LayerCanvas right before rendering. fit: cover overflows a
+ // non-square box by design; clipBehavior crops that overflow away,
+ // the same way an Image inside a ClipRRect would.
+ Layers.image(
+ source: AssetImageSource('assets/images/logo.png'),
+ position: const Offset(20, 20),
+ size: const Size(260, 120),
+ fit: BoxFit.cover,
+ clipBehavior: Clip.hardEdge,
+ pixelRatio: pixelRatio,
+ ),
+ ],
+ );
+ }
+
Scene _buildSvgScene(Size logicalSize, double pixelRatio) {
final physicalSize = logicalSize * pixelRatio;
return Scenes.of(
@@ -187,6 +258,61 @@ class DemoPage extends StatelessWidget {
);
}
+ /// Built once by the "Save"/"Export" buttons below — includes an
+ /// `AssetImageSource`, so saving proves it serializes as a short asset
+ /// key rather than a base64 blob, and loading proves `Scene.fromJson`
+ /// reconstructs it (via the `LayerRegistry` decoder it registers itself).
+ Scene _buildPersistenceScene() {
+ return Scenes.of(
+ width: 300,
+ height: 160,
+ children: [
+ Layers.rectangle(
+ size: const Size(300, 160),
+ color: const Color(0xFF1E1E2E),
+ ),
+ Layers.image(
+ source: AssetImageSource('assets/images/logo.png'),
+ position: const Offset(16, 16),
+ size: const Size(64, 64),
+ fit: BoxFit.cover,
+ ),
+ Layers.text(
+ text: 'Saved & restored',
+ position: const Offset(96, 40),
+ color: const Color(0xFFFFFFFF),
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ ),
+ ],
+ );
+ }
+
+ void _saveScene() {
+ final json = jsonEncode(_buildPersistenceScene().toJson());
+ setState(() {
+ _savedJson = json;
+ _restoredScene = null;
+ });
+ }
+
+ void _loadScene() {
+ final json = _savedJson;
+ if (json == null) return;
+ final decoded = Scene.fromJson(jsonDecode(json) as Map);
+ setState(() => _restoredScene = decoded);
+ }
+
+ Future _exportScene() async {
+ final path = '${Directory.systemTemp.path}/layer_canvas_export.qoi';
+ await Scenes.saveToFile(
+ _buildPersistenceScene(),
+ path,
+ format: OutputFormat.qoi,
+ );
+ setState(() => _exportStatus = 'Exported to $path');
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -256,6 +382,26 @@ class DemoPage extends StatelessWidget {
child: LayerCanvas(sceneBuilder: _buildPathScene),
),
const SizedBox(height: 24),
+ const Text(
+ 'Strokes: strokeCap/strokeJoin (dart:ui\'s own enums) and a '
+ 'dashArray, only on Layers.path:',
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 160,
+ child: LayerCanvas(sceneBuilder: _buildDashedStrokeScene),
+ ),
+ const SizedBox(height: 24),
+ const Text(
+ 'clipBehavior: a BoxFit.cover image cropped to its box, like '
+ 'an Image inside a ClipRRect:',
+ ),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 160,
+ child: LayerCanvas(sceneBuilder: _buildClippedImageScene),
+ ),
+ const SizedBox(height: 24),
const Text(
'Layers.svg: an SvgDocument parsed once (above, as a static '
'field) and placed here like any other layer:',
@@ -297,6 +443,45 @@ class DemoPage extends StatelessWidget {
],
),
),
+ const SizedBox(height: 24),
+ const Text(
+ 'Persistence: Scene.toJson()/fromJson() (AssetImageSource '
+ 'included) and Scenes.saveToFile for exporting instead of '
+ 'displaying:',
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ FilledButton(
+ onPressed: _saveScene,
+ child: const Text('Save scene to JSON'),
+ ),
+ FilledButton(
+ onPressed: _savedJson == null ? null : _loadScene,
+ child: const Text('Load from JSON'),
+ ),
+ FilledButton(
+ onPressed: _exportScene,
+ child: const Text('Export to file (QOI)'),
+ ),
+ ],
+ ),
+ if (_savedJson != null) ...[
+ const SizedBox(height: 8),
+ Text('Saved JSON: ${_savedJson!.length} bytes'),
+ ],
+ if (_exportStatus != null) ...[
+ const SizedBox(height: 4),
+ Text(_exportStatus!),
+ ],
+ if (_restoredScene != null) ...[
+ const SizedBox(height: 8),
+ const Text('Restored from JSON:'),
+ const SizedBox(height: 8),
+ SizedBox(height: 160, child: LayerCanvas(scene: _restoredScene)),
+ ],
],
),
),
diff --git a/example/pubspec.lock b/example/pubspec.lock
index f07e4a2..4516537 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -127,10 +127,10 @@ packages:
dependency: transitive
description:
name: layer_canvas
- sha256: d062a3f8e163c3d3667eaa5c3c3096642c8b5359402603d18e56b149bb443aa5
+ sha256: "21295d01d5a01856fa3d74c52947968adb5d79699f761e68af84dfc1eb01a003"
url: "https://pub.dev"
source: hosted
- version: "0.1.0-beta.4"
+ version: "0.1.0"
layer_canvas_flutter:
dependency: "direct main"
description:
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index af10ef5..a9d1168 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -69,6 +69,9 @@ flutter:
# the material Icons class.
uses-material-design: true
+ assets:
+ - assets/images/logo.png
+
fonts:
- family: Roboto
fonts:
diff --git a/lib/layer_canvas_flutter.dart b/lib/layer_canvas_flutter.dart
index c872271..294dfe1 100644
--- a/lib/layer_canvas_flutter.dart
+++ b/lib/layer_canvas_flutter.dart
@@ -2,14 +2,15 @@
///
/// Re-exports only the `layer_canvas` core types this package's own public
/// API surfaces as parameters or return types (`Scene`, the `Layer`
-/// subclasses, `SvgDocument`, `Renderer`...), plus this package's
-/// Flutter-typed adapters, layer factories, font loader and render widget.
-/// Value types that `Layers` and the adapters exist specifically to shield
-/// callers from (`Color32`, `Point2D`/`Size2D`, `TextWeight`,
-/// `TextAlignment`, `ImageFit`, `LayerPaint`, `LayerTransform`, `FillRule`,
-/// and the core's own `Gradient`/`LinearGradient`/`RadialGradient`/
-/// `ConicGradient`, which would otherwise collide with Flutter's own
-/// same-named gradient types) are intentionally *not* re-exported here —
+/// subclasses, `SvgDocument`, `Renderer`, `OutputFormat`, `LayerRegistry`...),
+/// plus this package's Flutter-typed adapters, layer factories, font loader,
+/// `AssetImageSource`, and render widgets. Value types that `Layers` and the
+/// adapters exist specifically to shield callers from (`Color32`,
+/// `Point2D`/`Size2D`, `TextWeight`, `TextAlignment`, `ImageFit`,
+/// `LayerPaint`, `LayerTransform`, `FillRule`, the core's own
+/// `StrokeCap`/`StrokeJoin`, and its `Gradient`/`LinearGradient`/
+/// `RadialGradient`/`ConicGradient`, which would otherwise collide with
+/// Flutter's own same-named types) are intentionally *not* re-exported here —
/// building UI with this package should never require importing
/// `package:layer_canvas` directly.
library;
@@ -28,15 +29,21 @@ export 'package:layer_canvas/layer_canvas.dart'
MemoryImageSource,
Renderer,
RenderException,
+ OutputFormat,
FontRegistry,
FontRegistrationException,
+ LayerRegistry,
+ LayerFromJson,
+ ImageSourceFromJson,
SvgDocument,
SvgParseException;
+export 'src/adapters/asset_image_source.dart';
export 'src/adapters/color_adapter.dart';
export 'src/adapters/geometry_adapter.dart';
export 'src/adapters/gradient_adapter.dart';
export 'src/adapters/image_adapter.dart';
+export 'src/adapters/paint_adapter.dart';
export 'src/adapters/path_adapter.dart';
export 'src/adapters/text_adapter.dart';
export 'src/fonts/layer_canvas_fonts.dart';
diff --git a/lib/src/adapters/asset_image_source.dart b/lib/src/adapters/asset_image_source.dart
new file mode 100644
index 0000000..7f65f62
--- /dev/null
+++ b/lib/src/adapters/asset_image_source.dart
@@ -0,0 +1,78 @@
+import 'package:layer_canvas/layer_canvas.dart';
+
+/// An image loaded from a Flutter asset bundle, by asset key.
+///
+/// Unlike [ImageSources.asset] (in `image_adapter.dart`), which loads the
+/// asset's bytes immediately into a [MemoryImageSource], this is a *lazy*
+/// descriptor: it stores [assetKey] (and [package]) rather than bytes, so
+/// it's cheap to build ahead of time, compact to serialize via
+/// [Scene.toJson] (a short string instead of a base64 blob), and portable
+/// across app installs where the same asset key still resolves. It only
+/// becomes bytes when a [LayerCanvas] actually renders it — see
+/// `resolveSceneAssetSources` in `scene_asset_resolver.dart`, which every
+/// widget in this package runs a scene through first, since the native
+/// renderer itself only understands `file`/`memory` sources.
+///
+/// Registers its own [LayerRegistry] decoder (so `Scene.fromJson` can
+/// reconstruct one) the first time one is built or deserialized — no setup
+/// call needed.
+class AssetImageSource extends LayerImageSource {
+ /// The asset key, as declared in `pubspec.yaml`'s `flutter: assets:` (or
+ /// `flutter: fonts:`-style) section — the same string `Image.asset` or
+ /// `AssetBundle.load` would take, without a `packages//` prefix
+ /// even when [package] is set (see [bundleKey]).
+ final String assetKey;
+
+ /// The package this asset ships from, when it isn't part of the app's own
+ /// bundle — same meaning as `Image.asset`'s `package` argument.
+ final String? package;
+
+ /// Creates a source that lazily loads [assetKey] (optionally from
+ /// [package]) when a scene containing it is rendered.
+ AssetImageSource(this.assetKey, {this.package}) {
+ _ensureRegistered();
+ }
+
+ /// The key to actually hand an [AssetBundle], with the `packages//`
+ /// prefix `Image.asset`/`AssetImage` add for a package asset — applied
+ /// unless [assetKey] already carries it.
+ String get bundleKey {
+ final pkg = package;
+ if (pkg == null || assetKey.startsWith('packages/$pkg/')) {
+ return assetKey;
+ }
+ return 'packages/$pkg/$assetKey';
+ }
+
+ @override
+ String toString() =>
+ 'AssetImageSource($assetKey'
+ '${package == null ? '' : ', package: $package'})';
+
+ @override
+ Map toJson() => {
+ 'type': 'asset',
+ 'key': assetKey,
+ if (package != null) 'package': package,
+ };
+
+ /// Reconstructs an [AssetImageSource] from [toJson]'s output.
+ factory AssetImageSource.fromJson(Map json) {
+ return AssetImageSource(
+ json['key'] as String,
+ package: json['package'] as String?,
+ );
+ }
+
+ static bool _registered = false;
+
+ /// Registers [AssetImageSource]'s decoder with [LayerRegistry] exactly
+ /// once, however many instances get built — so `Scene.fromJson` can
+ /// reconstruct an `'asset'`-typed source without the caller having to
+ /// remember a separate setup step.
+ static void _ensureRegistered() {
+ if (_registered) return;
+ _registered = true;
+ LayerRegistry.registerImageSource('asset', AssetImageSource.fromJson);
+ }
+}
diff --git a/lib/src/adapters/paint_adapter.dart b/lib/src/adapters/paint_adapter.dart
new file mode 100644
index 0000000..698f614
--- /dev/null
+++ b/lib/src/adapters/paint_adapter.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/widgets.dart';
+import 'package:layer_canvas/layer_canvas.dart' as lc;
+
+/// Converts a Flutter [StrokeCap] (as used by `dart:ui`'s own [Paint]) to
+/// the core's [lc.StrokeCap].
+///
+/// Both packages name this enum (and its three values) identically, so —
+/// like [Gradient]/`lc.Gradient` in `gradient_adapter.dart` — this file is
+/// the one place that imports `layer_canvas` with an `lc.` prefix instead of
+/// hiding the core's copy; callers keep using Flutter's own [StrokeCap].
+extension StrokeCapX on StrokeCap {
+ lc.StrokeCap toLayerStrokeCap() => switch (this) {
+ StrokeCap.butt => lc.StrokeCap.butt,
+ StrokeCap.round => lc.StrokeCap.round,
+ StrokeCap.square => lc.StrokeCap.square,
+ };
+}
+
+/// Converts a Flutter [StrokeJoin] to the core's [lc.StrokeJoin] — see
+/// [StrokeCapX] for why this needs the `lc.` prefix.
+extension StrokeJoinX on StrokeJoin {
+ lc.StrokeJoin toLayerStrokeJoin() => switch (this) {
+ StrokeJoin.miter => lc.StrokeJoin.miter,
+ StrokeJoin.round => lc.StrokeJoin.round,
+ StrokeJoin.bevel => lc.StrokeJoin.bevel,
+ };
+}
diff --git a/lib/src/adapters/text_adapter.dart b/lib/src/adapters/text_adapter.dart
index 90ff3d0..2152a08 100644
--- a/lib/src/adapters/text_adapter.dart
+++ b/lib/src/adapters/text_adapter.dart
@@ -1,34 +1,15 @@
import 'package:flutter/widgets.dart';
import 'package:layer_canvas/layer_canvas.dart';
-const _textWeights = [
- TextWeight.thin,
- TextWeight.light,
- TextWeight.normal,
- TextWeight.medium,
- TextWeight.semiBold,
- TextWeight.bold,
- TextWeight.black,
-];
-
/// Converts a Flutter [FontWeight] to the core's [TextWeight].
///
-/// [TextWeight] only exposes 7 static values (thin/light/normal/medium/
-/// semiBold/bold/black) with a private constructor, so [FontWeight.value]
-/// is mapped to the closest one rather than a 1:1 conversion.
+/// Exact: [TextWeight.fromValue] accepts any 100..900 weight, and
+/// [FontWeight.value] is already one of those nine steps, so no
+/// nearest-match rounding is needed (unlike [TextWeightX.toFontWeight]'s
+/// reverse direction being exact for a different reason — every named
+/// [TextWeight] constant already lands on one of Flutter's nine steps).
extension FontWeightX on FontWeight {
- TextWeight toTextWeight() {
- var closest = _textWeights.first;
- var closestDiff = (value - closest.value).abs();
- for (final candidate in _textWeights.skip(1)) {
- final diff = (value - candidate.value).abs();
- if (diff < closestDiff) {
- closest = candidate;
- closestDiff = diff;
- }
- }
- return closest;
- }
+ TextWeight toTextWeight() => TextWeight.fromValue(value);
}
/// Converts a core [TextWeight] to a Flutter [FontWeight].
diff --git a/lib/src/layers/layers.dart b/lib/src/layers/layers.dart
index 2e4856d..17ab6a8 100644
--- a/lib/src/layers/layers.dart
+++ b/lib/src/layers/layers.dart
@@ -1,17 +1,20 @@
import 'package:flutter/widgets.dart';
-// `Gradient` (and its Linear/Radial/Conic subtypes) hidden: this file's own
-// factories take Flutter's own `Gradient` as a parameter type (see
-// `rectangle`/`path` below) and convert it via `GradientX.toLayerGradient`
-// — the core's same-named type is never referenced by name here, only
-// produced by that adapter, so importing both unprefixed would be a real
-// ambiguous-import error, not just a style nitpick.
+// `Gradient` (and its Linear/Radial/Conic subtypes) and `StrokeCap`/
+// `StrokeJoin` hidden: this file's own factories take Flutter's own
+// same-named types as parameters (see `rectangle`/`path` below) and convert
+// them via `GradientX.toLayerGradient`/`StrokeCapX.toLayerStrokeCap`/
+// `StrokeJoinX.toLayerStrokeJoin` — the core's copies are never referenced
+// by name here, only produced by those adapters, so importing both
+// unprefixed would be a real ambiguous-import error, not just a style
+// nitpick.
import 'package:layer_canvas/layer_canvas.dart'
- hide Gradient, LinearGradient, RadialGradient;
+ hide Gradient, LinearGradient, RadialGradient, StrokeCap, StrokeJoin;
import '../adapters/color_adapter.dart';
import '../adapters/geometry_adapter.dart';
import '../adapters/gradient_adapter.dart';
import '../adapters/image_adapter.dart';
+import '../adapters/paint_adapter.dart';
import '../adapters/path_adapter.dart';
import '../adapters/text_adapter.dart';
import '../fonts/layer_canvas_fonts.dart';
@@ -31,6 +34,20 @@ abstract final class Layers {
/// [gradient] — a Flutter `LinearGradient`/`RadialGradient`/
/// `SweepGradient` — paints over [color] when given.
///
+ /// [strokeCap]/[strokeJoin]/[strokeMiterLimit] shape a stroke's open ends
+ /// and corners exactly like `dart:ui`'s own [Paint] fields of the same
+ /// name; there's no `dashArray` here since a rectangle has no path
+ /// geometry of its own to dash (see [path]).
+ ///
+ /// [clipBehavior] set to anything but [Clip.none] clips this layer's own
+ /// paint to its [size] box — the rectangle's fill/stroke already stay
+ /// within [size], so this mainly matters once [cornerRadius] or a
+ /// gradient/stroke could otherwise bleed past a rotated/scaled edge.
+ ///
+ /// [scale] and [alignment] extend [rotation]: [scale] grows/shrinks the
+ /// layer in place, and [alignment] moves the pivot [rotation]/[scale] turn
+ /// around away from the box's center (the default).
+ ///
/// [pixelRatio] scales [size], [position], [cornerRadius] and
/// [strokeWidth] together — pass the value a [LayerCanvas.sceneBuilder]
/// received so layers built in logical units land at physical-pixel
@@ -42,9 +59,15 @@ abstract final class Layers {
Gradient? gradient,
PaintingStyle? style,
double strokeWidth = 1.0,
+ StrokeCap strokeCap = StrokeCap.butt,
+ StrokeJoin strokeJoin = StrokeJoin.miter,
+ double strokeMiterLimit = 4.0,
bool fillAndStroke = false,
double cornerRadius = 0,
+ Clip clipBehavior = Clip.none,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -59,12 +82,18 @@ abstract final class Layers {
gradient: gradient,
style: style,
strokeWidth: strokeWidth * pixelRatio,
+ strokeCap: strokeCap,
+ strokeJoin: strokeJoin,
+ miterLimit: strokeMiterLimit,
fillAndStroke: fillAndStroke,
),
cornerRadius: cornerRadius * pixelRatio,
+ clipToBounds: clipBehavior != Clip.none,
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
opacity: opacity,
zIndex: zIndex,
@@ -76,13 +105,25 @@ abstract final class Layers {
/// — see its doc comment for how to draw one (it mirrors `dart:ui`'s
/// `Path`). [gradient] paints over [color] when given.
///
+ /// [strokeCap]/[strokeJoin]/[strokeMiterLimit] shape a stroke's open ends
+ /// and corners exactly like `dart:ui`'s own [Paint] fields of the same
+ /// name. [dashArray] (alternating on/off lengths, e.g. `[4, 2]`) and
+ /// [dashOffset] divide the stroke into dashes instead of a solid line —
+ /// an odd-length [dashArray] repeats, matching SVG/CSS (`[4]` behaves like
+ /// `[4, 4]`); both are ignored when [dashArray] is empty (the default).
+ ///
+ /// [clipBehavior] set to anything but [Clip.none] clips this layer's own
+ /// paint to its [size] box (required for clipping to apply) — [size]
+ /// otherwise still never scales the drawn geometry, as with the core's
+ /// own `PathLayer`; it only places the [rotation]/[scale] pivot when that
+ /// needs to turn around the shape's visual center rather than its local
+ /// origin.
+ ///
+ /// [scale] and [alignment] extend [rotation] — see [rectangle] for how.
+ ///
/// [pixelRatio] scales [path]'s own coordinates (via
- /// [LayerPathBuilder.build]) along with [position], [size] and
- /// [strokeWidth] — see [rectangle] for why. Unlike [rectangle], [size] is
- /// never used to scale or clip the drawn geometry — as with the core's
- /// own `PathLayer`, it only places the rotation/scale pivot when
- /// [rotation] (or a caller-managed scale) needs to pivot around the
- /// shape's visual center rather than its local origin.
+ /// [LayerPathBuilder.build]) along with [position], [size], [strokeWidth]
+ /// and [dashArray]/[dashOffset] — see [rectangle] for why.
static PathLayer path({
required LayerPathBuilder path,
Offset position = Offset.zero,
@@ -91,9 +132,17 @@ abstract final class Layers {
Gradient? gradient,
PaintingStyle? style,
double strokeWidth = 1.0,
+ StrokeCap strokeCap = StrokeCap.butt,
+ StrokeJoin strokeJoin = StrokeJoin.miter,
+ double strokeMiterLimit = 4.0,
+ List dashArray = const [],
+ double dashOffset = 0,
bool fillAndStroke = false,
PathFillType fillType = PathFillType.nonZero,
+ Clip clipBehavior = Clip.none,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -108,12 +157,20 @@ abstract final class Layers {
gradient: gradient,
style: style,
strokeWidth: strokeWidth * pixelRatio,
+ strokeCap: strokeCap,
+ strokeJoin: strokeJoin,
+ miterLimit: strokeMiterLimit,
+ dashArray: [for (final length in dashArray) length * pixelRatio],
+ dashOffset: dashOffset * pixelRatio,
fillAndStroke: fillAndStroke,
),
fillRule: fillType.toFillRule(),
+ clipToBounds: clipBehavior != Clip.none,
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
size: size == null ? null : (size * pixelRatio).toSize2D(),
opacity: opacity,
@@ -124,6 +181,18 @@ abstract final class Layers {
/// Builds a run of styled text.
///
+ /// Text always breaks on an explicit `\n`; passing a [size] with a width
+ /// additionally word-wraps to fit it — greedily, only at spaces (a single
+ /// word wider than [size]'s width overflows on its own line rather than
+ /// being split mid-word) — and the wrapped block is vertically centered
+ /// within [size]'s height.
+ ///
+ /// [clipBehavior] set to anything but [Clip.none] clips overflowing text
+ /// to [size] (required for clipping to apply) instead of letting it
+ /// overflow past the box.
+ ///
+ /// [scale] and [alignment] extend [rotation] — see [rectangle] for how.
+ ///
/// [pixelRatio] scales [size], [position] and [fontSize] together — see
/// [rectangle] for why.
static TextLayer text({
@@ -135,7 +204,10 @@ abstract final class Layers {
FontWeight fontWeight = FontWeight.w400,
TextAlign align = TextAlign.left,
String? fontFamily,
+ Clip clipBehavior = Clip.none,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -150,9 +222,12 @@ abstract final class Layers {
color: color.toColor32(),
align: align.toTextAlignment(),
fontWeight: fontWeight.toTextWeight(),
+ clipToBounds: clipBehavior != Clip.none,
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
size: size == null ? null : (size * pixelRatio).toSize2D(),
opacity: opacity,
@@ -163,6 +238,14 @@ abstract final class Layers {
/// Builds an image layer.
///
+ /// [clipBehavior] set to anything but [Clip.none] clips this layer's own
+ /// paint to its [size] box (required for clipping to apply) — the natural
+ /// case is `fit: BoxFit.cover` together with `clipBehavior:
+ /// Clip.hardEdge`, so the overflow a cover fit produces is cropped away
+ /// exactly like an `Image` inside a clipped box.
+ ///
+ /// [scale] and [alignment] extend [rotation] — see [rectangle] for how.
+ ///
/// [pixelRatio] scales [size] and [position] together — see [rectangle]
/// for why.
static ImageLayer image({
@@ -170,7 +253,10 @@ abstract final class Layers {
Offset position = Offset.zero,
Size? size,
BoxFit fit = BoxFit.contain,
+ Clip clipBehavior = Clip.none,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -181,9 +267,12 @@ abstract final class Layers {
id: id,
source: source,
fit: fit.toImageFit(),
+ clipToBounds: clipBehavior != Clip.none,
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
size: size == null ? null : (size * pixelRatio).toSize2D(),
opacity: opacity,
@@ -200,12 +289,20 @@ abstract final class Layers {
/// (e.g. into a `final` field) and pass the result here each time,
/// instead of parsing it again per layer/frame.
///
+ /// No `clipBehavior` here: like [group], this places a [Group], and the
+ /// core expands groups into their concrete descendants before rendering,
+ /// leaving no single composited surface to clip.
+ ///
+ /// [scale] and [alignment] extend [rotation] — see [rectangle] for how.
+ ///
/// [pixelRatio] scales [position] and [size] — see [rectangle] for why.
static Group svg(
SvgDocument document, {
Offset position = Offset.zero,
Size? size,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -217,6 +314,8 @@ abstract final class Layers {
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
size: size == null ? null : (size * pixelRatio).toSize2D(),
opacity: opacity,
@@ -227,14 +326,23 @@ abstract final class Layers {
/// Groups [children] under a shared transform/opacity.
///
- /// [pixelRatio] scales [position] — scale each child individually (via its
- /// own factory's `pixelRatio`) rather than relying on this to also scale
- /// their sizes, since a group's transform composes geometrically and does
- /// not re-rasterize its children at a different resolution.
+ /// No `clipBehavior` here — the core expands a [Group] into its concrete
+ /// descendants before rendering, leaving no single composited surface to
+ /// clip; clip an individual child via its own factory instead.
+ ///
+ /// [scale] and [alignment] extend [rotation] — see [rectangle] for how.
+ /// Prefer scaling each child individually (via its own factory's
+ /// `pixelRatio`) over relying on this [scale] for resolution changes,
+ /// since a group's transform composes geometrically and does not
+ /// re-rasterize its children at a different resolution.
+ ///
+ /// [pixelRatio] scales [position] — see [rectangle] for why.
static Group group({
required List children,
Offset position = Offset.zero,
double rotation = 0,
+ double scale = 1.0,
+ AlignmentGeometry alignment = Alignment.center,
double opacity = 1,
double pixelRatio = 1.0,
String? id,
@@ -247,6 +355,8 @@ abstract final class Layers {
transform: LayerTransform(
position: (position * pixelRatio).toPoint2D(),
rotation: rotation,
+ scale: Point2D(scale, scale),
+ anchor: alignment.toFractionalPoint2D(),
),
opacity: opacity,
zIndex: zIndex,
@@ -265,6 +375,11 @@ LayerPaint _paintFrom({
required double strokeWidth,
required bool fillAndStroke,
Gradient? gradient,
+ StrokeCap strokeCap = StrokeCap.butt,
+ StrokeJoin strokeJoin = StrokeJoin.miter,
+ double miterLimit = 4.0,
+ List dashArray = const [],
+ double dashOffset = 0.0,
}) {
return LayerPaint(
color: color.toColor32(),
@@ -276,5 +391,10 @@ LayerPaint _paintFrom({
PaintingStyle.fill || null => LayerPaintStyle.fill,
},
strokeWidth: strokeWidth,
+ strokeCap: strokeCap.toLayerStrokeCap(),
+ strokeJoin: strokeJoin.toLayerStrokeJoin(),
+ miterLimit: miterLimit,
+ dashArray: dashArray,
+ dashOffset: dashOffset,
);
}
diff --git a/lib/src/rendering/isolate_render.dart b/lib/src/rendering/isolate_render.dart
new file mode 100644
index 0000000..88ef4f8
--- /dev/null
+++ b/lib/src/rendering/isolate_render.dart
@@ -0,0 +1,42 @@
+import 'dart:isolate';
+import 'dart:typed_data';
+
+import 'package:layer_canvas/layer_canvas.dart';
+
+/// Runs [renderer].render(scene, format:) on a fresh background isolate via
+/// [Isolate.run], instead of on the calling isolate.
+///
+/// The core `layer_canvas` package's own `Renderer.render` runs the native
+/// call synchronously under the hood — it's plain Dart with no opinion on
+/// whether its caller has a UI thread to protect, so it doesn't pay
+/// isolate-spawn overhead on every call for a benefit only some callers
+/// need. Every widget/helper in this package that renders calls this
+/// instead, so Flutter's UI isolate stays free to keep building/laying
+/// out/painting frames while a (possibly large) scene rasterizes, instead
+/// of blocking on it like any other synchronous, CPU-bound Dart call would.
+///
+/// Safe to run off-isolate because `@Native` symbol resolution is
+/// isolate-group-wide (no per-isolate library re-init needed) and the
+/// native backend keeps no rendering state shared across calls — the one
+/// piece of process-global native state, the font registry, is already
+/// guarded by a mutex on both the write path
+/// (`FontRegistry.register`/`unregister`) and the read path a render
+/// exercises internally.
+Future renderOffMainIsolate(
+ Renderer renderer,
+ Scene scene, {
+ OutputFormat format = OutputFormat.png,
+}) {
+ return Isolate.run(() => renderer.render(scene, format: format));
+}
+
+/// Like [renderOffMainIsolate], but writes straight to [path] — see
+/// [Renderer.renderToFile].
+Future renderToFileOffMainIsolate(
+ Renderer renderer,
+ Scene scene,
+ String path, {
+ OutputFormat format = OutputFormat.png,
+}) {
+ return Isolate.run(() => renderer.renderToFile(scene, path, format: format));
+}
diff --git a/lib/src/scenes/scene_asset_resolver.dart b/lib/src/scenes/scene_asset_resolver.dart
new file mode 100644
index 0000000..72c260e
--- /dev/null
+++ b/lib/src/scenes/scene_asset_resolver.dart
@@ -0,0 +1,76 @@
+import 'package:flutter/services.dart';
+import 'package:layer_canvas/layer_canvas.dart';
+
+import '../adapters/asset_image_source.dart';
+
+/// Resolves every [AssetImageSource] reachable from [scene] — its own
+/// [Scene.background], any [ImageLayer.source], recursing into
+/// [Group.children] — into a [MemoryImageSource] loaded from [bundle].
+///
+/// The native renderer only understands `file`/`memory` sources, and
+/// loading an asset is inherently async, so this has to run before a
+/// [Scene] reaches `Renderer.render`. Every widget/helper in this package
+/// that renders or encodes a [Scene] runs it through this first.
+///
+/// Returns [scene] itself, unchanged, when it contains no
+/// [AssetImageSource] — the common case for scenes built entirely from
+/// [FileImageSource]/[MemoryImageSource] — so [LayerCanvas]'s render cache,
+/// keyed on [Scene] identity, isn't defeated by a pointless rebuild.
+Future resolveSceneAssetSources(Scene scene, AssetBundle bundle) async {
+ var changed = false;
+
+ Future resolveSource(LayerImageSource source) async {
+ if (source is! AssetImageSource) return source;
+ changed = true;
+ final data = await bundle.load(source.bundleKey);
+ return MemoryImageSource(
+ data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
+ );
+ }
+
+ Future resolveLayer(Layer layer) async {
+ if (layer is ImageLayer) {
+ return ImageLayer(
+ id: layer.id,
+ source: await resolveSource(layer.source),
+ fit: layer.fit,
+ transform: layer.transform,
+ size: layer.size,
+ opacity: layer.opacity,
+ zIndex: layer.zIndex,
+ visible: layer.visible,
+ clipToBounds: layer.clipToBounds,
+ );
+ }
+ if (layer is Group) {
+ return Group(
+ id: layer.id,
+ children: await Future.wait(layer.children.map(resolveLayer)),
+ transform: layer.transform,
+ size: layer.size,
+ opacity: layer.opacity,
+ zIndex: layer.zIndex,
+ visible: layer.visible,
+ );
+ }
+ return layer;
+ }
+
+ final resolvedBackground = scene.background == null
+ ? null
+ : await resolveSource(scene.background!);
+ final resolvedLayers = await Future.wait(scene.layers.map(resolveLayer));
+
+ // Every ImageLayer/Group above gets rebuilt regardless of whether it (or
+ // a descendant) actually held an AssetImageSource — cheap relative to the
+ // render this scene is about to go through, and simpler than threading an
+ // extra "did this subtree change" return value through the recursion.
+ // `changed` is what decides whether that rebuilt tree is used at all.
+ if (!changed) return scene;
+
+ return Scene(
+ width: scene.width,
+ height: scene.height,
+ background: resolvedBackground,
+ )..addAll(resolvedLayers);
+}
diff --git a/lib/src/scenes/scenes.dart b/lib/src/scenes/scenes.dart
index cbc25d0..bbcbfc4 100644
--- a/lib/src/scenes/scenes.dart
+++ b/lib/src/scenes/scenes.dart
@@ -1,5 +1,9 @@
+import 'package:flutter/services.dart';
import 'package:layer_canvas/layer_canvas.dart';
+import '../rendering/isolate_render.dart';
+import 'scene_asset_resolver.dart';
+
/// Builds a `layer_canvas` [Scene] with its layers passed as a list, instead
/// of the core's mutate-after-construction `Scene(...)..add(...)..add(...)`.
///
@@ -27,4 +31,39 @@ abstract final class Scenes {
background: background,
)..addAll(children);
}
+
+ /// Rasterizes [scene] to encoded image bytes, [format] (`png` by default,
+ /// or `bmp`/`qoi`) instead of the PNG [LayerCanvas] always shows on
+ /// screen — for exporting a scene rather than displaying it. Resolves any
+ /// `AssetImageSource` in [scene] against [bundle] (or `rootBundle`) first,
+ /// same as every widget in this package, before delegating to [renderer].
+ static Future encode(
+ Scene scene, {
+ OutputFormat format = OutputFormat.png,
+ Renderer renderer = const Renderer(),
+ AssetBundle? bundle,
+ }) async {
+ final resolved = await resolveSceneAssetSources(
+ scene,
+ bundle ?? rootBundle,
+ );
+ return renderOffMainIsolate(renderer, resolved, format: format);
+ }
+
+ /// Rasterizes [scene] and writes it to [path] — see [encode] for
+ /// [format]/[bundle]; use this instead of [encode] to write straight to
+ /// disk rather than holding the encoded bytes in memory.
+ static Future saveToFile(
+ Scene scene,
+ String path, {
+ OutputFormat format = OutputFormat.png,
+ Renderer renderer = const Renderer(),
+ AssetBundle? bundle,
+ }) async {
+ final resolved = await resolveSceneAssetSources(
+ scene,
+ bundle ?? rootBundle,
+ );
+ await renderToFileOffMainIsolate(renderer, resolved, path, format: format);
+ }
}
diff --git a/lib/src/widgets/layer_canvas_widget.dart b/lib/src/widgets/layer_canvas_widget.dart
index cd7761b..90309d6 100644
--- a/lib/src/widgets/layer_canvas_widget.dart
+++ b/lib/src/widgets/layer_canvas_widget.dart
@@ -4,6 +4,8 @@ import 'package:flutter/widgets.dart';
import 'package:layer_canvas/layer_canvas.dart';
import '../adapters/geometry_adapter.dart';
+import '../rendering/isolate_render.dart';
+import '../scenes/scene_asset_resolver.dart';
/// Builds a [Scene] sized to [logicalSize] (the widget's measured box, in
/// logical pixels) and [pixelRatio] (the device pixel ratio). Build the
@@ -24,6 +26,14 @@ typedef SceneBuilder = Scene Function(Size logicalSize, double pixelRatio);
/// because the object identity the widget is keying off of hasn't changed.
/// Build a new [Scene] whenever its contents change; if you must mutate one
/// in place, pass a changing [rebuildKey] to force a re-render.
+///
+/// Before rendering, any `AssetImageSource` reachable from the scene is
+/// resolved against `DefaultAssetBundle.of(context)` (see
+/// `resolveSceneAssetSources`) — a scene built entirely from
+/// `FileImageSource`/`MemoryImageSource` skips this step untouched. The
+/// actual native render then runs on a background isolate (see
+/// `renderOffMainIsolate`), so a large scene rasterizing never blocks this
+/// app's UI isolate.
class LayerCanvas extends StatefulWidget {
const LayerCanvas({
super.key,
@@ -114,7 +124,11 @@ class _LayerCanvasState extends State {
final cacheKey = (scene, logicalSize, pixelRatio, widget.rebuildKey);
if (_cacheKey != cacheKey) {
_cacheKey = cacheKey;
- _future = widget.renderer.render(scene);
+ final bundle = DefaultAssetBundle.of(context);
+ _future = resolveSceneAssetSources(
+ scene,
+ bundle,
+ ).then((resolved) => renderOffMainIsolate(widget.renderer, resolved));
}
Widget content = SizedBox(
diff --git a/pubspec.yaml b/pubspec.yaml
index f2ebf85..38e13be 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: layer_canvas_flutter
description: "Flutter widgets and adapters for the layer_canvas 2D compositor."
-version: 0.1.0-beta.1
+version: 0.1.0
homepage: https://github.com/code3743/layer_canvas_flutter
repository: https://github.com/code3743/layer_canvas_flutter
issue_tracker: https://github.com/code3743/layer_canvas_flutter/issues
@@ -12,7 +12,7 @@ environment:
dependencies:
flutter:
sdk: flutter
- layer_canvas: ^0.1.0-beta.4
+ layer_canvas: ^0.1.0
dev_dependencies:
flutter_test:
diff --git a/test/adapters_test.dart b/test/adapters_test.dart
index cea7f3b..0d20ded 100644
--- a/test/adapters_test.dart
+++ b/test/adapters_test.dart
@@ -1,18 +1,21 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
// Adapter tests verify the bridge between the two type vocabularies, so
// unlike application code they legitimately need the raw core types too.
-// `Gradient`/`LinearGradient`/`RadialGradient` collide with Flutter's own —
-// this file needs both sides at once (build a Flutter gradient, assert on
-// the core one it converts to), so those three are only reachable via the
-// `lc.` prefix below; everything else in the core package (`Color32`,
-// `GradientStop`, `ConicGradient`, `FillRule`...) doesn't collide and stays
-// unprefixed.
+// `Gradient`/`LinearGradient`/`RadialGradient` and `StrokeCap`/`StrokeJoin`
+// collide with Flutter's own — this file needs both sides at once (build a
+// Flutter value, assert on the core one it converts to), so those five are
+// only reachable via the `lc.` prefix below; everything else in the core
+// package (`Color32`, `GradientStop`, `ConicGradient`, `FillRule`...)
+// doesn't collide and stays unprefixed.
import 'package:layer_canvas/layer_canvas.dart'
- hide Gradient, LinearGradient, RadialGradient;
+ hide Gradient, LinearGradient, RadialGradient, StrokeCap, StrokeJoin;
import 'package:layer_canvas/layer_canvas.dart'
as lc
- show LinearGradient, RadialGradient;
+ show LinearGradient, RadialGradient, StrokeCap, StrokeJoin;
import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
void main() {
@@ -48,19 +51,22 @@ void main() {
});
group('text_adapter', () {
- test('exact FontWeight maps to matching TextWeight', () {
- expect(FontWeight.w700.toTextWeight(), TextWeight.bold);
- expect(FontWeight.w400.toTextWeight(), TextWeight.normal);
- expect(FontWeight.w100.toTextWeight(), TextWeight.thin);
- expect(FontWeight.w900.toTextWeight(), TextWeight.black);
+ test('named FontWeight maps to the matching TextWeight value', () {
+ // TextWeight has no value equality (unlike LayerTransform/Point2D/
+ // Size2D/Color32), so compare `.value` rather than instance equality.
+ expect(FontWeight.w700.toTextWeight().value, TextWeight.bold.value);
+ expect(FontWeight.w400.toTextWeight().value, TextWeight.normal.value);
+ expect(FontWeight.w100.toTextWeight().value, TextWeight.thin.value);
+ expect(FontWeight.w900.toTextWeight().value, TextWeight.black.value);
});
- test('intermediate FontWeight maps to closest TextWeight', () {
- // w200 (value 200) is closer to thin (100) than to light (300)? No:
- // |200-100|=100, |200-300|=100 -> tie, first candidate (thin) wins.
- expect(FontWeight.w200.toTextWeight(), TextWeight.thin);
- // w800 (value 800) is closer to bold (700) than black (900).
- expect(FontWeight.w800.toTextWeight(), TextWeight.bold);
+ test('intermediate FontWeight maps exactly, not to the closest named '
+ 'TextWeight', () {
+ // TextWeight.fromValue is exact (layer_canvas 0.1.0-beta.6+), so a
+ // FontWeight with no matching named constant round-trips to its own
+ // raw value instead of snapping to the nearest named one.
+ expect(FontWeight.w200.toTextWeight().value, 200);
+ expect(FontWeight.w800.toTextWeight().value, 800);
});
test('TextAlign maps to TextAlignment', () {
@@ -135,6 +141,20 @@ void main() {
});
});
+ group('paint_adapter', () {
+ test('StrokeCap maps 1:1 to the core StrokeCap', () {
+ expect(StrokeCap.butt.toLayerStrokeCap(), lc.StrokeCap.butt);
+ expect(StrokeCap.round.toLayerStrokeCap(), lc.StrokeCap.round);
+ expect(StrokeCap.square.toLayerStrokeCap(), lc.StrokeCap.square);
+ });
+
+ test('StrokeJoin maps 1:1 to the core StrokeJoin', () {
+ expect(StrokeJoin.miter.toLayerStrokeJoin(), lc.StrokeJoin.miter);
+ expect(StrokeJoin.round.toLayerStrokeJoin(), lc.StrokeJoin.round);
+ expect(StrokeJoin.bevel.toLayerStrokeJoin(), lc.StrokeJoin.bevel);
+ });
+ });
+
group('Layers', () {
test('rectangle converts Flutter types', () {
final layer = Layers.rectangle(
@@ -453,8 +473,171 @@ void main() {
expect(group.children, hasLength(1));
});
});
+
+ group('Layers stroke cap/join/miter/dash', () {
+ test('rectangle passes strokeCap/strokeJoin/strokeMiterLimit through', () {
+ final layer = Layers.rectangle(
+ size: const Size(10, 10),
+ style: PaintingStyle.stroke,
+ strokeCap: StrokeCap.round,
+ strokeJoin: StrokeJoin.bevel,
+ strokeMiterLimit: 2.5,
+ );
+ expect(layer.paint.strokeCap, lc.StrokeCap.round);
+ expect(layer.paint.strokeJoin, lc.StrokeJoin.bevel);
+ expect(layer.paint.miterLimit, 2.5);
+ });
+
+ test('rectangle defaults match dart:ui Paint defaults', () {
+ final layer = Layers.rectangle(size: const Size(10, 10));
+ expect(layer.paint.strokeCap, lc.StrokeCap.butt);
+ expect(layer.paint.strokeJoin, lc.StrokeJoin.miter);
+ expect(layer.paint.miterLimit, 4.0);
+ expect(layer.paint.dashArray, isEmpty);
+ });
+
+ test('path scales dashArray/dashOffset by pixelRatio', () {
+ final layer = Layers.path(
+ path: LayerPathBuilder.circle(const Offset(5, 5), 5),
+ style: PaintingStyle.stroke,
+ dashArray: const [4, 2],
+ dashOffset: 1,
+ pixelRatio: 2.0,
+ );
+ expect(layer.paint.dashArray, [8, 4]);
+ expect(layer.paint.dashOffset, 2);
+ });
+ });
+
+ group('Layers clipBehavior', () {
+ test('Clip.none leaves clipToBounds false (the default)', () {
+ final layer = Layers.rectangle(size: const Size(10, 10));
+ expect(layer.clipToBounds, isFalse);
+ });
+
+ test('any non-none Clip sets clipToBounds true', () {
+ expect(
+ Layers.rectangle(
+ size: const Size(10, 10),
+ clipBehavior: Clip.hardEdge,
+ ).clipToBounds,
+ isTrue,
+ );
+ expect(
+ Layers.image(
+ source: MemoryImageSource(Uint8List.fromList([])),
+ size: const Size(10, 10),
+ clipBehavior: Clip.antiAlias,
+ ).clipToBounds,
+ isTrue,
+ );
+ expect(
+ Layers.text(
+ text: 'hi',
+ size: const Size(10, 10),
+ clipBehavior: Clip.hardEdge,
+ ).clipToBounds,
+ isTrue,
+ );
+ expect(
+ Layers.path(
+ path: LayerPathBuilder.circle(const Offset(5, 5), 5),
+ clipBehavior: Clip.hardEdge,
+ ).clipToBounds,
+ isTrue,
+ );
+ });
+ });
+
+ group('Layers scale/alignment', () {
+ test('scale becomes a uniform LayerTransform.scale', () {
+ final layer = Layers.rectangle(size: const Size(10, 10), scale: 1.5);
+ expect(layer.transform.scale, const Point2D(1.5, 1.5));
+ });
+
+ test('default alignment matches the core default anchor', () {
+ final layer = Layers.rectangle(size: const Size(10, 10));
+ expect(layer.transform.anchor, const Point2D(0.5, 0.5));
+ });
+
+ test('alignment maps like gradient_adapter\'s AlignmentGeometryX', () {
+ final layer = Layers.rectangle(
+ size: const Size(10, 10),
+ alignment: Alignment.topLeft,
+ );
+ expect(layer.transform.anchor, const Point2D(0, 0));
+ });
+
+ test('group/svg also accept scale and alignment', () {
+ final group = Layers.group(
+ children: [Layers.rectangle(size: const Size(10, 10))],
+ scale: 2.0,
+ alignment: Alignment.bottomRight,
+ );
+ expect(group.transform.scale, const Point2D(2.0, 2.0));
+ expect(group.transform.anchor, const Point2D(1, 1));
+ });
+ });
+
+ group('AssetImageSource', () {
+ test('toJson/fromJson round trip, without a package', () {
+ final source = AssetImageSource('images/logo.png');
+ final json = source.toJson();
+ expect(json, {'type': 'asset', 'key': 'images/logo.png'});
+
+ final decoded = AssetImageSource.fromJson(json);
+ expect(decoded.assetKey, 'images/logo.png');
+ expect(decoded.package, isNull);
+ expect(decoded.bundleKey, 'images/logo.png');
+ });
+
+ test('toJson/fromJson round trip, with a package', () {
+ final source = AssetImageSource('images/logo.png', package: 'brand_kit');
+ final json = source.toJson();
+ expect(json, {
+ 'type': 'asset',
+ 'key': 'images/logo.png',
+ 'package': 'brand_kit',
+ });
+
+ final decoded = AssetImageSource.fromJson(json);
+ expect(decoded.package, 'brand_kit');
+ expect(decoded.bundleKey, 'packages/brand_kit/images/logo.png');
+ });
+
+ test(
+ 'registers itself with LayerRegistry so Scene.fromJson decodes it',
+ () {
+ final scene = Scene(width: 10, height: 10)
+ ..add(
+ Layers.image(
+ source: AssetImageSource('images/logo.png'),
+ size: const Size(10, 10),
+ ),
+ );
+
+ final decodedScene = Scene.fromJson(
+ jsonDecodeRoundTrip(scene.toJson()),
+ );
+ final decodedLayer = decodedScene.layers.single as ImageLayer;
+ expect(decodedLayer.source, isA());
+ expect(
+ (decodedLayer.source as AssetImageSource).assetKey,
+ 'images/logo.png',
+ );
+ },
+ );
+ });
}
+/// Round-trips a `toJson()` map through `jsonEncode`/`jsonDecode` — the same
+/// path a real save/load would take — rather than passing the `Map` object
+/// straight to `fromJson`, so this also exercises that every value in the
+/// map is actually JSON-safe (no stray `Color32`/enum instances left
+/// un-encoded).
+Map jsonDecodeRoundTrip(Map json) =>
+ jsonDecode(jsonEncode(json)) as Map;
+
/// A minimal [Gradient] subtype that isn't [LinearGradient], [RadialGradient],
/// or [SweepGradient] — exercises [GradientX.toLayerGradient]'s fallback,
/// which none of Flutter's own gradient types can reach.
diff --git a/test/isolate_render_test.dart b/test/isolate_render_test.dart
new file mode 100644
index 0000000..4597bc9
--- /dev/null
+++ b/test/isolate_render_test.dart
@@ -0,0 +1,60 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
+import 'package:layer_canvas_flutter/src/rendering/isolate_render.dart';
+
+void main() {
+ test(
+ 'renderOffMainIsolate does not block the calling isolate\'s event loop',
+ () async {
+ // Deliberately heavy (large canvas, many gradient-filled shapes) so
+ // the native render reliably takes tens of milliseconds — long
+ // enough for a concurrently-ticking Timer to prove the calling
+ // isolate's event loop kept running while it was in flight. A plain
+ // `renderer.render(scene)` call would occupy the isolate's single
+ // thread for the whole render, so this Timer could not fire even
+ // once until it returned; renderOffMainIsolate runs the native call
+ // on a background isolate instead, so the caller's event loop stays
+ // free — this is exactly what every widget/helper in this package
+ // relies on to avoid blocking Flutter's UI isolate.
+ final scene = Scene(width: 3000, height: 3000);
+ for (var i = 0; i < 300; i++) {
+ scene.add(
+ Layers.path(
+ path: LayerPathBuilder.circle(
+ Offset((i % 20) * 150.0 + 75, (i ~/ 20) * 200.0 + 100),
+ 70,
+ ),
+ gradient: RadialGradient(
+ colors: [
+ Color.fromARGB(255, i % 255, 0, 255 - i % 255),
+ Color.fromARGB(255, 0, i % 255, i % 255),
+ ],
+ ),
+ ),
+ );
+ }
+
+ var ticks = 0;
+ final ticker = Timer.periodic(
+ const Duration(milliseconds: 1),
+ (_) => ticks++,
+ );
+
+ await renderOffMainIsolate(const Renderer(), scene);
+ ticker.cancel();
+
+ expect(
+ ticks,
+ greaterThan(0),
+ reason:
+ 'expected the 1ms Timer to have fired at least once while '
+ 'renderOffMainIsolate() was in flight — 0 ticks means the '
+ 'calling isolate was blocked for the whole render, not just '
+ 'slow test timing',
+ );
+ },
+ );
+}
diff --git a/test/layer_canvas_widget_test.dart b/test/layer_canvas_widget_test.dart
index 9800f68..86a7cd8 100644
--- a/test/layer_canvas_widget_test.dart
+++ b/test/layer_canvas_widget_test.dart
@@ -4,20 +4,14 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
-class _CountingRenderer extends Renderer {
- int calls = 0;
-
- @override
- Future render(Scene scene) {
- calls++;
- return super.render(scene);
- }
-}
+import 'test_utils.dart';
class _ThrowingRenderer extends Renderer {
@override
- Future render(Scene scene) =>
- Future.error(RenderException('boom'), StackTrace.current);
+ Future render(
+ Scene scene, {
+ OutputFormat format = OutputFormat.png,
+ }) => Future.error(RenderException('boom'), StackTrace.current);
}
Widget _wrap(Widget child) {
@@ -42,12 +36,15 @@ void main() {
),
);
- await tester.pumpWidget(_wrap(LayerCanvas(scene: scene)));
+ // See pumpUntilImageRenders's doc comment for why this needs runAsync.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(_wrap(LayerCanvas(scene: scene)));
- // Renderer.render is async; nothing decoded yet on the first frame.
- expect(find.byType(Image), findsNothing);
+ // Renderer.render is async; nothing decoded yet on the first frame.
+ expect(find.byType(Image), findsNothing);
- await tester.pumpAndSettle();
+ await pumpUntilImageRenders(tester);
+ });
expect(find.byType(Image), findsOneWidget);
});
@@ -58,57 +55,73 @@ void main() {
Size? seenSize;
double? seenPixelRatio;
- await tester.pumpWidget(
- _wrap(
- LayerCanvas(
- sceneBuilder: (logicalSize, pixelRatio) {
- seenSize = logicalSize;
- seenPixelRatio = pixelRatio;
- return Scene(width: 100, height: 100)
- ..add(Layers.rectangle(size: const Size(100, 100)));
- },
+ // See the previous test's comment on runAsync — needed here too since
+ // this asserts an Image eventually renders.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ _wrap(
+ LayerCanvas(
+ sceneBuilder: (logicalSize, pixelRatio) {
+ seenSize = logicalSize;
+ seenPixelRatio = pixelRatio;
+ return Scene(width: 100, height: 100)
+ ..add(Layers.rectangle(size: const Size(100, 100)));
+ },
+ ),
),
- ),
- );
+ );
- expect(seenSize, const Size(100, 100));
- expect(seenPixelRatio, 1.0);
+ expect(seenSize, const Size(100, 100));
+ expect(seenPixelRatio, 1.0);
- await tester.pumpAndSettle();
+ await pumpUntilImageRenders(tester);
+ });
expect(find.byType(Image), findsOneWidget);
});
testWidgets('rebuildKey forces a re-render for an in-place scene mutation', (
tester,
) async {
- final renderer = _CountingRenderer();
final scene = Scene(width: 100, height: 100)
..add(Layers.rectangle(size: const Size(100, 100)));
- Widget build(int rebuildKey) => _wrap(
- LayerCanvas(scene: scene, renderer: renderer, rebuildKey: rebuildKey),
- );
+ Widget build(int rebuildKey) =>
+ _wrap(LayerCanvas(scene: scene, rebuildKey: rebuildKey));
- await tester.pumpWidget(build(0));
- await tester.pumpAndSettle();
- expect(renderer.calls, 1);
+ late Uint8List firstBytes;
+ await tester.runAsync(() async {
+ await tester.pumpWidget(build(0));
+ await pumpUntilImageRenders(tester);
+ firstBytes = renderedImageBytes(tester)!;
+ });
// Mutate the same Scene instance in place: identity is unchanged, so
- // re-passing the same rebuildKey must NOT trigger a re-render.
+ // re-passing the same rebuildKey must NOT trigger a re-render — the
+ // rendered bytes stay the (stale) first render's, ignoring the added
+ // blue rectangle.
scene.add(
Layers.rectangle(
size: const Size(50, 50),
color: const Color(0xFF0000FF),
),
);
- await tester.pumpWidget(build(0));
- await tester.pumpAndSettle();
- expect(renderer.calls, 1);
-
- // Bumping rebuildKey is the documented escape hatch to force a refresh.
- await tester.pumpWidget(build(1));
- await tester.pumpAndSettle();
- expect(renderer.calls, 2);
+ await tester.runAsync(() async {
+ await tester.pumpWidget(build(0));
+ await tester.pump();
+ await tester.pump();
+ });
+ expect(bytesEqual(renderedImageBytes(tester)!, firstBytes), isTrue);
+
+ // Bumping rebuildKey is the documented escape hatch to force a refresh
+ // — this time the bytes must actually change once it completes.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(build(1));
+ await pumpUntil(
+ tester,
+ () => !bytesEqual(renderedImageBytes(tester)!, firstBytes),
+ );
+ });
+ expect(bytesEqual(renderedImageBytes(tester)!, firstBytes), isFalse);
});
testWidgets('errorBuilder receives the error and its stack trace', (
@@ -120,20 +133,25 @@ void main() {
final scene = Scene(width: 10, height: 10)
..add(Layers.rectangle(size: const Size(10, 10)));
- await tester.pumpWidget(
- _wrap(
- LayerCanvas(
- scene: scene,
- renderer: _ThrowingRenderer(),
- errorBuilder: (context, error, stackTrace) {
- seenError = error;
- seenStackTrace = stackTrace;
- return const SizedBox.shrink();
- },
+ // See pumpUntilImageRenders's doc comment on why this needs runAsync —
+ // even a Renderer that fails immediately still goes through
+ // renderOffMainIsolate's real background isolate.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ _wrap(
+ LayerCanvas(
+ scene: scene,
+ renderer: _ThrowingRenderer(),
+ errorBuilder: (context, error, stackTrace) {
+ seenError = error;
+ seenStackTrace = stackTrace;
+ return const SizedBox.shrink();
+ },
+ ),
),
- ),
- );
- await tester.pumpAndSettle();
+ );
+ await pumpUntil(tester, () => seenError != null);
+ });
expect(seenError, isA());
expect(seenStackTrace, isNotNull);
diff --git a/test/scene_widget_test.dart b/test/scene_widget_test.dart
index 512cd0c..747d22a 100644
--- a/test/scene_widget_test.dart
+++ b/test/scene_widget_test.dart
@@ -4,15 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
-class _CountingRenderer extends Renderer {
- int calls = 0;
-
- @override
- Future render(Scene scene) {
- calls++;
- return super.render(scene);
- }
-}
+import 'test_utils.dart';
Widget _wrap(Widget child) {
return Directionality(
@@ -28,47 +20,59 @@ void main() {
testWidgets('renders an Image from its children, like a Stack', (
tester,
) async {
- await tester.pumpWidget(
- _wrap(
- SceneWidget(
- width: 100,
- height: 100,
- children: [
- Layers.rectangle(size: const Size(100, 100)),
- Layers.text(text: 'hi'),
- ],
+ // See pumpUntilImageRenders's doc comment for why this needs runAsync.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(
+ _wrap(
+ SceneWidget(
+ width: 100,
+ height: 100,
+ children: [
+ Layers.rectangle(size: const Size(100, 100)),
+ Layers.text(text: 'hi'),
+ ],
+ ),
),
- ),
- );
+ );
- expect(find.byType(Image), findsNothing);
- await tester.pumpAndSettle();
+ expect(find.byType(Image), findsNothing);
+ await pumpUntilImageRenders(tester);
+ });
expect(find.byType(Image), findsOneWidget);
});
testWidgets(
're-renders on every rebuild, unlike a stable LayerCanvas scene',
(tester) async {
- final renderer = _CountingRenderer();
-
- Widget build() => _wrap(
+ Widget build(Color color) => _wrap(
SceneWidget(
width: 100,
height: 100,
- renderer: renderer,
- children: [Layers.rectangle(size: const Size(100, 100))],
+ children: [
+ Layers.rectangle(size: const Size(100, 100), color: color),
+ ],
),
);
- await tester.pumpWidget(build());
- await tester.pumpAndSettle();
- expect(renderer.calls, 1);
+ late Uint8List firstBytes;
+ await tester.runAsync(() async {
+ await tester.pumpWidget(build(const Color(0xFFFF0000)));
+ await pumpUntilImageRenders(tester);
+ firstBytes = renderedImageBytes(tester)!;
+ });
- // Same width/height/content, but a freshly-built Scene each time — no
- // rebuildKey needed to force this, unlike a fixed LayerCanvas(scene:).
- await tester.pumpWidget(build());
- await tester.pumpAndSettle();
- expect(renderer.calls, 2);
+ // A freshly-built Scene each build (no rebuildKey needed, unlike a
+ // fixed LayerCanvas(scene:)) — changing just the color between builds
+ // proves each build actually re-renders instead of reusing a cached
+ // image, since a plain Scene has no value equality to cache against.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(build(const Color(0xFF0000FF)));
+ await pumpUntil(
+ tester,
+ () => !bytesEqual(renderedImageBytes(tester)!, firstBytes),
+ );
+ });
+ expect(bytesEqual(renderedImageBytes(tester)!, firstBytes), isFalse);
},
);
}
diff --git a/test/serialization_test.dart b/test/serialization_test.dart
new file mode 100644
index 0000000..b885148
--- /dev/null
+++ b/test/serialization_test.dart
@@ -0,0 +1,167 @@
+import 'dart:convert';
+
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+// `ImageFit`/`Point2D`/`Size2D` are core value types the public barrel
+// intentionally doesn't re-export (see layer_canvas_flutter.dart) — tests
+// legitimately need them to assert on a resolved/decoded Layer's raw
+// fields. Every symbol below also exists in the barrel and refers to the
+// exact same declaration, so importing both isn't ambiguous.
+import 'package:layer_canvas/layer_canvas.dart';
+import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
+import 'package:layer_canvas_flutter/src/scenes/scene_asset_resolver.dart';
+
+/// A fake [AssetBundle] that hands back the same fixed bytes for every key,
+/// recording which keys were asked for — enough to exercise
+/// `resolveSceneAssetSources` without needing a real decodable image, since
+/// the resolver only swaps `LayerImageSource` types and never decodes them
+/// itself (decoding happens natively, inside `Renderer.render`).
+class _FakeAssetBundle extends AssetBundle {
+ _FakeAssetBundle(this.bytes);
+
+ final Uint8List bytes;
+ final List loadedKeys = [];
+
+ @override
+ Future load(String key) async {
+ loadedKeys.add(key);
+ return ByteData.sublistView(bytes);
+ }
+}
+
+void main() {
+ group('Scene JSON round trip', () {
+ test('round-trips width/height/background/layers', () {
+ final scene =
+ Scene(
+ width: 200,
+ height: 100,
+ background: MemoryImageSource(Uint8List.fromList([1, 2, 3])),
+ )..addAll([
+ Layers.rectangle(
+ size: const Size(50, 50),
+ color: const Color(0xFFFF0000),
+ ),
+ Layers.text(text: 'hello', fontWeight: FontWeight.w600),
+ Layers.group(
+ children: [Layers.rectangle(size: const Size(10, 10))],
+ ),
+ ]);
+
+ final decoded = Scene.fromJson(
+ jsonDecode(jsonEncode(scene.toJson())) as Map,
+ );
+
+ expect(decoded.width, 200);
+ expect(decoded.height, 100);
+ expect(decoded.background, isA());
+ expect(decoded.layers, hasLength(3));
+ expect(decoded.layers[0], isA());
+ expect(decoded.layers[1], isA());
+ expect(decoded.layers[2], isA());
+ });
+ });
+
+ group('resolveSceneAssetSources', () {
+ test(
+ 'returns the same Scene instance when no AssetImageSource exists',
+ () async {
+ final scene = Scene(width: 10, height: 10)
+ ..add(
+ Layers.image(
+ source: MemoryImageSource(Uint8List.fromList([1])),
+ size: const Size(10, 10),
+ ),
+ );
+ final bundle = _FakeAssetBundle(Uint8List.fromList([9, 9, 9]));
+
+ final resolved = await resolveSceneAssetSources(scene, bundle);
+
+ expect(identical(resolved, scene), isTrue);
+ expect(bundle.loadedKeys, isEmpty);
+ },
+ );
+
+ test(
+ 'resolves an AssetImageSource background into MemoryImageSource',
+ () async {
+ final scene = Scene(
+ width: 10,
+ height: 10,
+ background: AssetImageSource('images/bg.png'),
+ );
+ final bundle = _FakeAssetBundle(Uint8List.fromList([1, 2, 3]));
+
+ final resolved = await resolveSceneAssetSources(scene, bundle);
+
+ expect(resolved.background, isA());
+ expect((resolved.background as MemoryImageSource).bytes, [1, 2, 3]);
+ expect(bundle.loadedKeys, ['images/bg.png']);
+ },
+ );
+
+ test(
+ 'resolves an AssetImageSource on an ImageLayer, preserving fields',
+ () async {
+ final scene = Scene(width: 10, height: 10)
+ ..add(
+ Layers.image(
+ source: AssetImageSource('images/logo.png'),
+ size: const Size(10, 10),
+ fit: BoxFit.cover,
+ position: const Offset(1, 2),
+ ),
+ );
+ final bundle = _FakeAssetBundle(Uint8List.fromList([4, 5, 6]));
+
+ final resolved = await resolveSceneAssetSources(scene, bundle);
+
+ final layer = resolved.layers.single as ImageLayer;
+ expect(layer.source, isA());
+ expect(layer.fit, ImageFit.cover);
+ expect(layer.transform.position, const Point2D(1, 2));
+ expect(layer.size, const Size2D(10, 10));
+ },
+ );
+
+ test('recurses into Group children', () async {
+ final scene = Scene(width: 10, height: 10)
+ ..add(
+ Layers.group(
+ children: [
+ Layers.image(
+ source: AssetImageSource('images/nested.png'),
+ size: const Size(5, 5),
+ ),
+ Layers.rectangle(size: const Size(5, 5)),
+ ],
+ ),
+ );
+ final bundle = _FakeAssetBundle(Uint8List.fromList([7]));
+
+ final resolved = await resolveSceneAssetSources(scene, bundle);
+
+ final group = resolved.layers.single as Group;
+ final nested = group.children[0] as ImageLayer;
+ expect(nested.source, isA());
+ expect(group.children[1], isA());
+ expect(bundle.loadedKeys, ['images/nested.png']);
+ });
+
+ test('uses the packages// prefix for a package asset', () async {
+ final scene = Scene(width: 10, height: 10)
+ ..add(
+ Layers.image(
+ source: AssetImageSource('images/logo.png', package: 'brand_kit'),
+ size: const Size(10, 10),
+ ),
+ );
+ final bundle = _FakeAssetBundle(Uint8List.fromList([1]));
+
+ await resolveSceneAssetSources(scene, bundle);
+
+ expect(bundle.loadedKeys, ['packages/brand_kit/images/logo.png']);
+ });
+ });
+}
diff --git a/test/svg_layer_test.dart b/test/svg_layer_test.dart
index 9c214d8..44f5b82 100644
--- a/test/svg_layer_test.dart
+++ b/test/svg_layer_test.dart
@@ -2,6 +2,8 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:layer_canvas_flutter/layer_canvas_flutter.dart';
+import 'test_utils.dart';
+
Widget _wrap(Widget child) {
return Directionality(
textDirection: TextDirection.ltr,
@@ -20,8 +22,11 @@ void main() {
'',
);
- await tester.pumpWidget(_wrap(SvgLayer(document)));
- await tester.pumpAndSettle();
+ // See pumpUntilImageRenders's doc comment for why this needs runAsync.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(_wrap(SvgLayer(document)));
+ await pumpUntilImageRenders(tester);
+ });
expect(find.byType(Image), findsOneWidget);
expect(tester.getSize(find.byType(SvgLayer)), const Size(100, 50));
@@ -47,8 +52,11 @@ void main() {
'',
);
- await tester.pumpWidget(_wrap(SvgLayer(document, width: 40, height: 30)));
- await tester.pumpAndSettle();
+ // See pumpUntilImageRenders's doc comment for why this needs runAsync.
+ await tester.runAsync(() async {
+ await tester.pumpWidget(_wrap(SvgLayer(document, width: 40, height: 30)));
+ await pumpUntilImageRenders(tester);
+ });
expect(find.byType(Image), findsOneWidget);
expect(tester.getSize(find.byType(SvgLayer)), const Size(40, 30));
diff --git a/test/test_utils.dart b/test/test_utils.dart
new file mode 100644
index 0000000..9e91fed
--- /dev/null
+++ b/test/test_utils.dart
@@ -0,0 +1,62 @@
+import 'dart:typed_data';
+
+import 'package:flutter/widgets.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// Pumps [tester] with real waits until [predicate] is true (or [timeout]
+/// elapses) — call this from inside a `tester.runAsync(...)` block.
+///
+/// Rendering in this package runs the native call on a background isolate
+/// (see `renderOffMainIsolate`), so a render's `Future` only completes via a
+/// genuine cross-isolate message — something FakeAsync (what `pump()`/
+/// `pumpAndSettle()` run under by default) can't fast-forward.
+/// `tester.runAsync()` steps outside FakeAsync for real async work, but
+/// isolate spawn latency varies (especially under a test sandbox), so this
+/// polls with real waits instead of a single fixed delay, which would be
+/// flaky either way (too short: fails before the isolate finishes; too
+/// long: slows every test down for no reason).
+Future pumpUntil(
+ WidgetTester tester,
+ bool Function() predicate, {
+ Duration timeout = const Duration(seconds: 10),
+ Duration step = const Duration(milliseconds: 100),
+}) async {
+ final deadline = DateTime.now().add(timeout);
+ while (!predicate() && DateTime.now().isBefore(deadline)) {
+ await Future.delayed(step);
+ await tester.pump();
+ }
+}
+
+/// [pumpUntil] specialized for "at least one [Image] widget appears".
+Future pumpUntilImageRenders(
+ WidgetTester tester, {
+ Duration timeout = const Duration(seconds: 10),
+ Duration step = const Duration(milliseconds: 100),
+}) {
+ return pumpUntil(
+ tester,
+ () => find.byType(Image).evaluate().isNotEmpty,
+ timeout: timeout,
+ step: step,
+ );
+}
+
+/// The raw bytes of the sole [Image] widget currently in the tree, which
+/// every widget in this package renders via `Image.memory`. `null` if none
+/// is mounted yet.
+Uint8List? renderedImageBytes(WidgetTester tester) {
+ final finder = find.byType(Image);
+ if (finder.evaluate().isEmpty) return null;
+ final provider = tester.widget(finder).image;
+ return (provider as MemoryImage).bytes;
+}
+
+/// Byte-for-byte equality, since [Uint8List] doesn't override `==`.
+bool bytesEqual(Uint8List a, Uint8List b) {
+ if (a.length != b.length) return false;
+ for (var i = 0; i < a.length; i++) {
+ if (a[i] != b[i]) return false;
+ }
+ return true;
+}
diff --git a/tool/generate_readme_assets.dart b/tool/generate_readme_assets.dart
index a28d14b..ae9c8c1 100644
--- a/tool/generate_readme_assets.dart
+++ b/tool/generate_readme_assets.dart
@@ -92,7 +92,9 @@ Future main() async {
);
scene.add(
RectangleLayer(
- transform: const LayerTransform(position: Point2D(cardX + 16, cardY + 16)),
+ transform: const LayerTransform(
+ position: Point2D(cardX + 16, cardY + 16),
+ ),
size: const Size2D(cardW - 32, cardH - 48),
paint: LayerPaint(
gradient: LinearGradient.colors(
@@ -107,7 +109,9 @@ Future main() async {
scene.add(
TextLayer(
text: 'Hello, layer_canvas!',
- transform: const LayerTransform(position: Point2D(cardX + 26, cardY + 30)),
+ transform: const LayerTransform(
+ position: Point2D(cardX + 26, cardY + 30),
+ ),
size: const Size2D(cardW - 52, 26),
fontSize: 14,
fontWeight: TextWeight.semiBold,