From bc2410552bba926c896bec3b4fc3e7ec26b78598 Mon Sep 17 00:00:00 2001 From: Rehmat Singh Gill Date: Wed, 2 Sep 2026 11:59:38 -0700 Subject: [PATCH] fix(painting): build the ring without a path-ops difference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Path.combine(PathOperation.difference, ...)` is unreliable on Flutter web (CanvasKit): it silently returns the first operand. `ring` and `halo` were built with it, so on web both came back as the filled shape instead of a ring, and every ring-confined layer — stroke, bloom, the pulse glows — washed across the whole child instead of hugging the border. The effect was plainly visible on the Pages gallery: card interiors flooded with colour where iOS and the goldens show a thin edge glow. For the built-in shapes `inner` is always simple and nested inside `outer`, so the even-odd fill of the two contours in one path describes exactly the same region with no path-ops pass, and renders identically on every backend — every golden passes unchanged. A custom `BeamPathContour` can be self-intersecting (the star contour is), where even-odd and non-zero disagree and the shortcut would punch a hole of its own, so those keep the path-ops route. Custom contours therefore still render wrong on web; that needs an inverse clip and costs a `saveLayer`, so it is left alone here. Verified by rebuilding `example` for web and comparing against the same gallery rendered through the Skia test renderer at identical size. Co-Authored-By: Claude Opus 5 (1M context) --- lib/src/painting/ring_geometry.dart | 31 +++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/src/painting/ring_geometry.dart b/lib/src/painting/ring_geometry.dart index 6f65340..24136c1 100644 --- a/lib/src/painting/ring_geometry.dart +++ b/lib/src/painting/ring_geometry.dart @@ -394,9 +394,29 @@ class BeamRingGeometry { ); /// The border ring: [outer] minus [inner]. - late final Path ring = rect.isEmpty - ? Path() - : Path.combine(PathOperation.difference, outer, inner); + late final Path ring = rect.isEmpty ? Path() : _subtract(outer, inner); + + // Subtracts the nested [hole] from [shell]. + // + // `Path.combine(PathOperation.difference, ...)` is unreliable on Flutter web + // (CanvasKit): it silently returns the first operand, so the ring collapses + // back into the filled shape and every ring-confined layer washes across the + // whole child. For the built-in shapes both contours are simple and nested, + // so the even-odd fill of the two in one path describes the same region + // without a path-ops pass, and renders identically on every backend. + // + // A custom [contour] can be self-intersecting (a star is), where even-odd + // and non-zero disagree and the shortcut would punch a hole of its own — so + // those keep the path-ops route. + Path _subtract(Path shell, Path hole) { + if (contour != null) { + return Path.combine(PathOperation.difference, shell, hole); + } + return Path() + ..fillType = PathFillType.evenOdd + ..addPath(shell, Offset.zero) + ..addPath(hole, Offset.zero); + } Path _shapePath(Rect r, BorderRadius cornerRadii) { // A rect with no area (or an inverted one, from deflating past the @@ -433,9 +453,8 @@ class BeamRingGeometry { /// The halo a comet bloom fills: the shape grown by [reach], minus the /// content box, so the glow hugs the border and spills outward instead of /// washing across the child. - Path halo(double reach) => rect.isEmpty - ? Path() - : Path.combine(PathOperation.difference, grown(reach), inner); + Path halo(double reach) => + rect.isEmpty ? Path() : _subtract(grown(reach), inner); /// [outer] grown outward by [reach], in the shape's own family. Path grown(double reach) => contour != null