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. + + + + + + + + + + + + +
Overlapping radial, linear, and sweep gradientsAn organic blob drawn with LayerPathBuilder cubic and quadratic curvesAn abstract Bauhaus-style geometric composition
A parsed SVG document placed as a scattered patternA dashed-stroke constellation/network diagramAn editorial card with wrapped text, a clipped image badge, and a gradient background
+ ## 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,