diff --git a/packages/core/lib/src/core_widget_factory.dart b/packages/core/lib/src/core_widget_factory.dart index 2c60b6c79..15b004713 100644 --- a/packages/core/lib/src/core_widget_factory.dart +++ b/packages/core/lib/src/core_widget_factory.dart @@ -34,6 +34,7 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { BuildOp? _styleBackground; BuildOp? _styleBorder; + BuildOp? _styleClipPath; BuildOp? _styleDisplayFlex; BuildOp? _styleMargin; BuildOp? _stylePadding; @@ -168,27 +169,70 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { ); var clipBehavior = Clip.none; + Border? nonUniformBorder; + final baseRadius = baseDeco.borderRadius; final effectiveRadius = borderRadius ?? (baseRadius is BorderRadius ? baseRadius : null); if (effectiveRadius != null) { - final borderIsUniform = decoration.border?.isUniform ?? true; + final effectiveBorder = decoration.border; + final borderIsUniform = effectiveBorder?.isUniform ?? true; if (borderIsUniform) { - // TODO: add support for non-uniform border - // https://github.com/flutter/flutter/commit/5054b6e - // https://pub.dev/packages/non_uniform_border decoration = decoration.copyWith(borderRadius: effectiveRadius); clipBehavior = Clip.hardEdge; + } else if (effectiveBorder is Border && effectiveRadius != BorderRadius.zero) { + // Non-uniform border + radius: paint each border side along the + // rounded-rect outline using a CustomPainter (the same canvas/Paint + // mechanism used in html_list_marker.dart). The border is removed + // from the BoxDecoration to avoid Flutter's isUniform assertion; + // borderRadius is kept so the Container clips content correctly. + nonUniformBorder = effectiveBorder; + decoration = BoxDecoration( + color: decoration.color, + image: decoration.image, + boxShadow: decoration.boxShadow, + gradient: decoration.gradient, + backgroundBlendMode: decoration.backgroundBlendMode, + shape: decoration.shape, + borderRadius: effectiveRadius, + ); + clipBehavior = Clip.hardEdge; } } - return Container( + Widget built = Container( decoration: decoration, clipBehavior: clipBehavior, child: grandChild ?? child, ); + + if (nonUniformBorder != null) { + built = CustomPaint( + foregroundPainter: _NonUniformBorderPainter( + border: nonUniformBorder, + borderRadius: effectiveRadius!, + ), + child: built, + ); + } + + return built; } + /// Builds [ClipPath]. + Widget? buildClipPath( + BuildTree tree, + Widget child, + CustomClipper clipper, + ) => + ClipPath(clipper: clipper, child: child); + + /// Parses an SVG path data string (e.g. from `clip-path: path("...")`) into + /// a Flutter [Path], or returns `null` if not supported. + /// + /// Override in a mixin (e.g. `SvgFactory`) to provide support. + Path? buildClipPathFromSvgData(String pathData) => null; + /// Builds decoration image from [url] DecorationImage? buildDecorationImage( BuildTree tree, @@ -1092,6 +1136,10 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { tree.register(_styleBorder ??= StyleBorder(this).buildOp); } + if (key == kCssClipPath) { + tree.register(_styleClipPath ??= StyleClipPath(this).buildOp); + } + if (key.startsWith(kCssMargin)) { tree.register(_styleMargin ??= StyleMargin(this).buildOp); } @@ -1308,6 +1356,28 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { }; } +// Paints a non-uniform [Border] along a rounded-rect outline by delegating to +// [Border.paint] with a [borderRadius] argument — the same method used by +// the table cell render object in [HtmlTableCell]. +class _NonUniformBorderPainter extends CustomPainter { + final Border border; + final BorderRadius borderRadius; + + _NonUniformBorderPainter({ + required this.border, + required this.borderRadius, + }); + + @override + void paint(Canvas canvas, Size size) { + border.paint(canvas, Offset.zero & size, borderRadius: borderRadius); + } + + @override + bool shouldRepaint(_NonUniformBorderPainter old) => + old.border != border || old.borderRadius != borderRadius; +} + /// A factory to build widgets. class WidgetFactoryResetter { /// Called when the [HtmlWidget]'s state is disposed. diff --git a/packages/core/lib/src/internal/core_ops.dart b/packages/core/lib/src/internal/core_ops.dart index 5259f2f92..2e1134001 100644 --- a/packages/core/lib/src/internal/core_ops.dart +++ b/packages/core/lib/src/internal/core_ops.dart @@ -25,6 +25,7 @@ part 'ops/column.dart'; part 'ops/priorities.dart'; part 'ops/style_background.dart'; part 'ops/style_border.dart'; +part 'ops/style_clip_path.dart'; part 'ops/style_display.dart'; part 'ops/style_display_flex.dart'; part 'ops/style_ellipsis.dart'; diff --git a/packages/core/lib/src/internal/ops/priorities.dart b/packages/core/lib/src/internal/ops/priorities.dart index 77952a45e..c0c0b8de9 100644 --- a/packages/core/lib/src/internal/ops/priorities.dart +++ b/packages/core/lib/src/internal/ops/priorities.dart @@ -94,7 +94,8 @@ class BoxModel { static const padding = verticalAlign + _step; static const border = padding + _step; static const background = border + _step; - static const margin = background + _step; + static const clipPath = background + _step; + static const margin = clipPath + _step; static const sizingMinWidthZero = margin + _step; } diff --git a/packages/core/lib/src/internal/ops/style_clip_path.dart b/packages/core/lib/src/internal/ops/style_clip_path.dart new file mode 100644 index 000000000..bc322ed46 --- /dev/null +++ b/packages/core/lib/src/internal/ops/style_clip_path.dart @@ -0,0 +1,715 @@ +// Public shape classes intentionally reference private helper types +// (_CssClipPathPoint, _CssClipPathTrbl, _CssClipPathRadius) that live in the +// same library via `part`. They are implementation details and not reachable +// by consumers of the public API. +// ignore_for_file: library_private_types_in_public_api +part of '../core_ops.dart'; + +const kCssClipPath = 'clip-path'; +const kCssClipPathCircle = 'circle'; +const kCssClipPathEllipse = 'ellipse'; +const kCssClipPathInset = 'inset'; +const kCssClipPathNone = 'none'; +const kCssClipPathPath = 'path'; +const kCssClipPathPolygon = 'polygon'; +const kCssClipPathRect = 'rect'; +const kCssClipPathXywh = 'xywh'; + +class StyleClipPath { + final WidgetFactory wf; + + StyleClipPath(this.wf); + + BuildOp get buildOp => BuildOp( + alwaysRenderBlock: false, + debugLabel: kCssClipPath, + onRenderBlock: (tree, placeholder) { + final shape = tree.clipPathData.shape; + if (shape == null) { + return placeholder; + } + + if (shape is CssClipPathSvgPath) { + return placeholder.wrapWith( + (_, child) { + final path = wf.buildClipPathFromSvgData(shape.pathData); + if (path == null) { + return child; + } + return wf.buildClipPath(tree, child, _FixedPathClipper(path)) ?? + child; + }, + ); + } + + return placeholder.wrapWith( + (_, child) => + wf.buildClipPath(tree, child, CssClipPathClipper(shape)), + ); + }, + priority: BoxModel.clipPath, + ); +} + +@immutable +class _FixedPathClipper extends CustomClipper { + final Path _path; + + const _FixedPathClipper(this._path); + + @override + Path getClip(Size size) => _path; + + @override + bool shouldReclip(covariant _FixedPathClipper oldClipper) => + oldClipper._path != _path; +} + +@immutable +class CssClipPathClipper extends CustomClipper { + final CssClipPathShape shape; + + const CssClipPathClipper(this.shape); + + @override + Path getClip(Size size) => shape.toPath(size); + + @override + bool shouldReclip(covariant CssClipPathClipper oldClipper) => + oldClipper.shape != shape; +} + +@immutable +abstract class CssClipPathShape { + const CssClipPathShape(); + + Path toPath(Size size); +} + +/// Holds a raw SVG path data string for `clip-path: path("...")`. +/// The actual [Path] is computed at render time by [WidgetFactory.buildClipPathFromSvgData]. +/// [toPath] must never be called directly on this shape — the render path +/// in [StyleClipPath.buildOp] handles it via the WF hook before reaching +/// [CssClipPathClipper]. +@immutable +class CssClipPathSvgPath extends CssClipPathShape { + final String pathData; + + const CssClipPathSvgPath(this.pathData); + + @override + Path toPath(Size size) => throw UnsupportedError( + 'CssClipPathSvgPath.toPath() must not be called directly; ' + 'use WidgetFactory.buildClipPathFromSvgData() instead.', + ); +} + +@immutable +class CssClipPathPolygon extends CssClipPathShape { + final List<_CssClipPathPoint> points; + + const CssClipPathPolygon(this.points); + + @override + Path toPath(Size size) { + final path = Path(); + if (points.isEmpty) { + return path; + } + + path.moveTo( + points.first.x.resolve(size.width), + points.first.y.resolve(size.height), + ); + + for (var i = 1; i < points.length; i++) { + path.lineTo( + points[i].x.resolve(size.width), + points[i].y.resolve(size.height), + ); + } + + path.close(); + return path; + } +} + +@immutable +class CssClipPathCircle extends CssClipPathShape { + final CssLength radius; + final CssLength x; + final CssLength y; + + const CssClipPathCircle({ + required this.radius, + required this.x, + required this.y, + }); + + @override + Path toPath(Size size) { + final center = Offset(x.resolve(size.width), y.resolve(size.height)); + // Per CSS Shapes spec, percentage radii on circle() resolve against + // sqrt(width² + height²) / sqrt(2) — the "normalised diagonal". + final diagonal = + sqrt(size.width * size.width + size.height * size.height) / sqrt2; + final r = radius.resolve(diagonal); + return Path()..addOval(Rect.fromCircle(center: center, radius: r)); + } +} + +@immutable +class CssClipPathEllipse extends CssClipPathShape { + final CssLength radiusX; + final CssLength radiusY; + final CssLength x; + final CssLength y; + + const CssClipPathEllipse({ + required this.radiusX, + required this.radiusY, + required this.x, + required this.y, + }); + + @override + Path toPath(Size size) { + return Path() + ..addOval( + Rect.fromCenter( + center: Offset(x.resolve(size.width), y.resolve(size.height)), + width: radiusX.resolve(size.width) * 2, + height: radiusY.resolve(size.height) * 2, + ), + ); + } +} + +@immutable +class CssClipPathInset extends CssClipPathShape { + final _CssClipPathTrbl cutout; + final _CssClipPathRadius? radius; + + const CssClipPathInset({required this.cutout, this.radius}); + + @override + Path toPath(Size size) { + final left = cutout.left.resolve(size.width); + final top = cutout.top.resolve(size.height); + final right = cutout.right.resolve(size.width); + final bottom = cutout.bottom.resolve(size.height); + + final rect = Rect.fromLTWH( + left, + top, + max(0, size.width - left - right), + max(0, size.height - top - bottom), + ); + + final path = Path(); + final parsedRadius = radius; + if (parsedRadius == null) { + path.addRect(rect); + } else { + path.addRRect(parsedRadius.toRRect(rect)); + } + return path; + } +} + +@immutable +class CssClipPathRect extends CssClipPathShape { + final CssLength x; + final CssLength y; + final CssLength width; + final CssLength height; + final _CssClipPathRadius? radius; + + const CssClipPathRect({ + required this.x, + required this.y, + required this.width, + required this.height, + this.radius, + }); + + @override + Path toPath(Size size) { + final rect = Rect.fromLTWH( + x.resolve(size.width), + y.resolve(size.height), + width.resolve(size.width), + height.resolve(size.height), + ); + final parsedRadius = radius; + if (parsedRadius == null) { + return Path()..addRect(rect); + } + return Path()..addRRect(parsedRadius.toRRect(rect)); + } +} + +// CSS rect(top right bottom left [round ]?): +// Each value is an absolute edge coordinate measured from the left/top edge of +// the reference box +@immutable +class CssClipPathRectLtrb extends CssClipPathShape { + final CssLength top; + final CssLength right; + final CssLength bottom; + final CssLength left; + final _CssClipPathRadius? radius; + + const CssClipPathRectLtrb({ + required this.top, + required this.right, + required this.bottom, + required this.left, + this.radius, + }); + + @override + Path toPath(Size size) { + final rect = Rect.fromLTRB( + left.resolve(size.width), + top.resolve(size.height), + right.resolve(size.width), + bottom.resolve(size.height), + ); + final parsedRadius = radius; + if (parsedRadius == null) { + return Path()..addRect(rect); + } + return Path()..addRRect(parsedRadius.toRRect(rect)); + } +} + +extension on BuildTree { + _StyleClipPathData get clipPathData => + getNonInherited<_StyleClipPathData>() ?? + setNonInherited<_StyleClipPathData>(_parseClipPathData()); + + _StyleClipPathData _parseClipPathData() { + var data = const _StyleClipPathData(); + for (final style in styles) { + if (style.property != kCssClipPath) { + continue; + } + + final value = style.value; + final term = value is css.LiteralTerm ? value.valueAsString : null; + if (term == kCssClipPathNone) { + data = const _StyleClipPathData(); + continue; + } + + final shape = tryParseCssClipPath(value); + if (shape != null) { + data = _StyleClipPathData(shape: shape); + } + } + + return data; + } +} + +@immutable +class _StyleClipPathData { + final CssClipPathShape? shape; + + const _StyleClipPathData({this.shape}); +} + +CssClipPathShape? tryParseCssClipPath(css.Expression? expression) { + if (expression is! css.FunctionTerm) { + return null; + } + + switch (expression.text) { + case kCssClipPathPolygon: + return _tryParseCssClipPathPolygon(expression); + case kCssClipPathCircle: + return _tryParseCssClipPathCircle(expression); + case kCssClipPathEllipse: + return _tryParseCssClipPathEllipse(expression); + case kCssClipPathInset: + return _tryParseCssClipPathInset(expression); + case kCssClipPathPath: + return _tryParseCssClipPathPath(expression); + case kCssClipPathRect: + return _tryParseCssClipPathRect(expression); + case kCssClipPathXywh: + return _tryParseCssClipPathXywh(expression); + } + + return null; +} + +CssClipPathShape? _tryParseCssClipPathPath(css.FunctionTerm expression) { + final params = expression.params; + if (params.isEmpty) { + return null; + } + + final first = params.first; + // csslib stores quoted CSS strings as LiteralTerm where .value includes the + // surrounding quote characters. The valueAsString extension strips them. + if (first is! css.LiteralTerm) { + return null; + } + + final pathData = first.valueAsString; + if (pathData.isEmpty) { + return null; + } + + return CssClipPathSvgPath(pathData); +} + +CssClipPathShape? _tryParseCssClipPathPolygon(css.FunctionTerm expression) { + final params = expression.params; + var startAt = 0; + if (params.isNotEmpty && + params.first is css.LiteralTerm && + ((params.first as css.LiteralTerm).valueAsString == 'evenodd' || + (params.first as css.LiteralTerm).valueAsString == 'nonzero')) { + startAt = 1; + } + + final points = <_CssClipPathPoint>[]; + for (var i = startAt; i + 1 < params.length; i += 2) { + final x = _tryParseCssLength(params[i]); + final y = _tryParseCssLength(params[i + 1]); + if (x == null || y == null) { + return null; + } + + points.add(_CssClipPathPoint(x, y)); + } + + return points.length >= 2 ? CssClipPathPolygon(points) : null; +} + +CssClipPathShape? _tryParseCssClipPathCircle(css.FunctionTerm expression) { + final params = expression.params; + final at = _findParamLiteral(params, 'at'); + + final radius = at == 0 + ? null + : _tryParseCssLength(at > 0 ? params.first : null) ?? + const CssLength(50, CssLengthUnit.percentage); + + if (radius == null) { + return null; + } + + var x = const CssLength(50, CssLengthUnit.percentage); + var y = const CssLength(50, CssLengthUnit.percentage); + if (at >= 0) { + if (params.length - at < 3) { + return null; + } + + final parsedX = _tryParseCssLength(params[at + 1]); + final parsedY = _tryParseCssLength(params[at + 2]); + if (parsedX == null || parsedY == null) { + return null; + } + x = parsedX; + y = parsedY; + } + + return CssClipPathCircle(radius: radius, x: x, y: y); +} + +CssClipPathShape? _tryParseCssClipPathEllipse(css.FunctionTerm expression) { + final params = expression.params; + final at = _findParamLiteral(params, 'at'); + final hasAt = at >= 0; + + CssLength radiusX = const CssLength(50, CssLengthUnit.percentage); + CssLength radiusY = const CssLength(50, CssLengthUnit.percentage); + if ((!hasAt && params.length >= 2) || at >= 2) { + final parsedRadiusX = _tryParseCssLength(params[0]); + final parsedRadiusY = _tryParseCssLength(params[1]); + if (parsedRadiusX == null || parsedRadiusY == null) { + return null; + } + radiusX = parsedRadiusX; + radiusY = parsedRadiusY; + } + + var x = const CssLength(50, CssLengthUnit.percentage); + var y = const CssLength(50, CssLengthUnit.percentage); + if (hasAt) { + if (params.length - at < 3) { + return null; + } + + final parsedX = _tryParseCssLength(params[at + 1]); + final parsedY = _tryParseCssLength(params[at + 2]); + if (parsedX == null || parsedY == null) { + return null; + } + x = parsedX; + y = parsedY; + } + + return CssClipPathEllipse(radiusX: radiusX, radiusY: radiusY, x: x, y: y); +} + +CssClipPathShape? _tryParseCssClipPathInset(css.FunctionTerm expression) { + final params = expression.params; + if (params.isEmpty) { + return null; + } + + final roundAt = _findParamLiteral(params, 'round'); + final cutoutExpressions = roundAt > -1 + ? params.sublist(0, roundAt) + : params.toList(growable: false); + final cutout = _tryParseCssClipPathTrbl(cutoutExpressions); + if (cutout == null) { + return null; + } + + _CssClipPathRadius? radius; + if (roundAt > -1 && roundAt + 1 < params.length) { + radius = _tryParseCssClipPathRadius(params.sublist(roundAt + 1)); + if (radius == null) { + return null; + } + } + + return CssClipPathInset(cutout: cutout, radius: radius); +} + +CssClipPathShape? _tryParseCssClipPathRect(css.FunctionTerm expression) { + final params = expression.params; + if (params.length < 4) { + return null; + } + + final roundAt = _findParamLiteral(params, 'round'); + final edgeExpressions = + roundAt > -1 ? params.sublist(0, roundAt) : params.sublist(0, 4); + if (edgeExpressions.length != 4) { + return null; + } + + final top = _tryParseCssLength(edgeExpressions[0]); + final right = _tryParseCssLength(edgeExpressions[1]); + final bottom = _tryParseCssLength(edgeExpressions[2]); + final left = _tryParseCssLength(edgeExpressions[3]); + if (top == null || right == null || bottom == null || left == null) { + return null; + } + + _CssClipPathRadius? radius; + if (roundAt > -1 && roundAt + 1 < params.length) { + radius = _tryParseCssClipPathRadius(params.sublist(roundAt + 1)); + if (radius == null) { + return null; + } + } + + return CssClipPathRectLtrb( + top: top, + right: right, + bottom: bottom, + left: left, + radius: radius, + ); +} + +CssClipPathShape? _tryParseCssClipPathXywh(css.FunctionTerm expression) { + final params = expression.params; + if (params.length < 4) { + return null; + } + + final roundAt = _findParamLiteral(params, 'round'); + final x = _tryParseCssLength(params[0]); + final y = _tryParseCssLength(params[1]); + final width = _tryParseCssLength(params[2]); + final height = _tryParseCssLength(params[3]); + if (x == null || y == null || width == null || height == null) { + return null; + } + + _CssClipPathRadius? radius; + if (roundAt > -1 && roundAt + 1 < params.length) { + radius = _tryParseCssClipPathRadius(params.sublist(roundAt + 1)); + if (radius == null) { + return null; + } + } + + return CssClipPathRect( + x: x, y: y, width: width, height: height, radius: radius); +} + +int _findParamLiteral(List params, String literal) { + for (var i = 0; i < params.length; i++) { + final param = params[i]; + if (param is css.LiteralTerm && param.valueAsString == literal) { + return i; + } + } + + return -1; +} + +CssLength? _tryParseCssLength(css.Expression? expression) { + if (expression == null) { + return null; + } + + if (expression is css.NumberTerm) { + return CssLength(expression.number.toDouble()); + } + + return tryParseCssLength(expression); +} + +_CssClipPathTrbl? _tryParseCssClipPathTrbl(List params) { + final lengths = params.map(_tryParseCssLength).toList(growable: false); + if (lengths.any((x) => x == null)) { + return null; + } + + switch (lengths.length) { + case 1: + final all = lengths[0]!; + return _CssClipPathTrbl(top: all, right: all, bottom: all, left: all); + case 2: + final vertical = lengths[0]!; + final horizontal = lengths[1]!; + return _CssClipPathTrbl( + top: vertical, + right: horizontal, + bottom: vertical, + left: horizontal, + ); + case 3: + return _CssClipPathTrbl( + top: lengths[0]!, + right: lengths[1]!, + bottom: lengths[2]!, + left: lengths[1]!, + ); + case 4: + return _CssClipPathTrbl( + top: lengths[0]!, + right: lengths[1]!, + bottom: lengths[2]!, + left: lengths[3]!, + ); + } + + return null; +} + +_CssClipPathRadius? _tryParseCssClipPathRadius(List params) { + final lengths = params.map(_tryParseCssLength).toList(growable: false); + if (lengths.isEmpty || lengths.any((x) => x == null)) { + return null; + } + + switch (lengths.length) { + case 1: + final all = lengths[0]!; + return _CssClipPathRadius( + topLeft: all, + topRight: all, + bottomRight: all, + bottomLeft: all, + ); + case 2: + return _CssClipPathRadius( + topLeft: lengths[0]!, + topRight: lengths[1]!, + bottomRight: lengths[0]!, + bottomLeft: lengths[1]!, + ); + case 3: + return _CssClipPathRadius( + topLeft: lengths[0]!, + topRight: lengths[1]!, + bottomRight: lengths[2]!, + bottomLeft: lengths[1]!, + ); + default: + return _CssClipPathRadius( + topLeft: lengths[0]!, + topRight: lengths[1]!, + bottomRight: lengths[2]!, + bottomLeft: lengths[3]!, + ); + } +} + +@immutable +class _CssClipPathPoint { + final CssLength x; + final CssLength y; + + const _CssClipPathPoint(this.x, this.y); +} + +@immutable +class _CssClipPathTrbl { + final CssLength top; + final CssLength right; + final CssLength bottom; + final CssLength left; + + const _CssClipPathTrbl({ + required this.top, + required this.right, + required this.bottom, + required this.left, + }); +} + +@immutable +class _CssClipPathRadius { + final CssLength topLeft; + final CssLength topRight; + final CssLength bottomRight; + final CssLength bottomLeft; + + const _CssClipPathRadius({ + required this.topLeft, + required this.topRight, + required this.bottomRight, + required this.bottomLeft, + }); + + RRect toRRect(Rect rect) { + final base = min(rect.width, rect.height); + return RRect.fromRectAndCorners( + rect, + topLeft: Radius.circular(topLeft.resolve(base)), + topRight: Radius.circular(topRight.resolve(base)), + bottomRight: Radius.circular(bottomRight.resolve(base)), + bottomLeft: Radius.circular(bottomLeft.resolve(base)), + ); + } +} + +extension on CssLength { + double resolve(double baseValue) { + switch (unit) { + case CssLengthUnit.auto: + return 0; + case CssLengthUnit.percentage: + return baseValue * number / 100; + case CssLengthUnit.pt: + return number * 96 / 72; + case CssLengthUnit.em: + case CssLengthUnit.px: + return number; + } + } +} diff --git a/packages/core/lib/src/internal/ops/style_sizing.dart b/packages/core/lib/src/internal/ops/style_sizing.dart index 23b0f81d4..fa83ab915 100644 --- a/packages/core/lib/src/internal/ops/style_sizing.dart +++ b/packages/core/lib/src/internal/ops/style_sizing.dart @@ -98,12 +98,25 @@ class StyleSizing { } static Widget _sizingBlock(BuildTree tree, WidgetPlaceholder placeholder) { - if (placeholder.isEmpty) { + final input = tree.sizingInput; + if (input == null) { return placeholder; } - final input = tree.sizingInput; - if (input == null) { + // Allow empty elements through when they have an explicit preferred + // width (non-100%, non-auto) or height (non-auto), so that e.g.: + //
+ // still gets a CssSizing wrapper and renders at the correct size. + // `auto` (e.g. from default styles) and pure 100%-width block + // behaviour do not count as explicit sizes. + final pw = input.preferredWidth; + final ph = input.preferredHeight; + final hasExplicitPreferredSize = + (pw != null && + pw != k100percent && + pw.unit != CssLengthUnit.auto) || + (ph != null && ph.unit != CssLengthUnit.auto); + if (placeholder.isEmpty && !hasExplicitPreferredSize) { return placeholder; } diff --git a/packages/core/test/style_border_test.dart b/packages/core/test/style_border_test.dart index 3e9ee9d61..cdaf5391d 100644 --- a/packages/core/test/style_border_test.dart +++ b/packages/core/test/style_border_test.dart @@ -800,8 +800,11 @@ void main() { expect(container.clipBehavior, equals(Clip.hardEdge)); }); - testWidgets('ignore radius if border is not uniform', (t) async { + testWidgets('ignore radius if border is not uniform and radius is zero', + (t) async { // https://github.com/daohoangson/flutter_widget_from_html/issues/909 + // border-bottom-right-radius: 0px is non-zero in object identity but + // effectively zero — no CustomPaint overhead should be added. const html = '
Foo
'; final explained = await explain(t, html); @@ -813,6 +816,20 @@ void main() { ), ); }); + + testWidgets('applies radius when border is not uniform', (t) async { + const html = '
Foo
'; + final explained = await explain(t, html); + expect( + explained, + equals( + '[CustomPaint:child=' + '[Container:radius=[10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0],child=' + '[CssBlock:child=[RichText:(:Foo)]]]]', + ), + ); + }); }); group('overwriting', () { diff --git a/packages/core/test/style_clip_path_test.dart b/packages/core/test/style_clip_path_test.dart new file mode 100644 index 000000000..ad5bb1671 --- /dev/null +++ b/packages/core/test/style_clip_path_test.dart @@ -0,0 +1,224 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:flutter_widget_from_html_core/src/internal/core_ops.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; + +import '_.dart'; + +Future main() async { + await loadAppFonts(); + Future getClipper( + WidgetTester tester, String style) async { + final html = + '
Foo
'; + await explain(tester, html); + + final clipPath = tester.widget(find.byType(ClipPath)); + final clipper = clipPath.clipper; + expect(clipper, isA()); + + return clipper! as CssClipPathClipper; + } + + testWidgets('polygon', (WidgetTester tester) async { + final clipper = await getClipper( + tester, + 'polygon(0% 0%, 100% 0%, 50% 100%)', + ); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 20)), isTrue); + expect(path.contains(const Offset(20, 90)), isFalse); + }); + + testWidgets('circle', (WidgetTester tester) async { + final clipper = await getClipper(tester, 'circle(25% at 50% 50%)'); + + // On a 200x100 box the spec resolves % against sqrt(w²+h²)/sqrt(2): + // sqrt(200²+100²)/sqrt(2) * 25% ≈ 39.5px + // A point 35px from the centre must be inside (visible with correct radius, + // would be outside with the wrong min(w,h)=100 * 25% = 25px radius). + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 50)), isTrue); + expect(path.contains(const Offset(135, 50)), isTrue); // 35px from centre, within ~39.5px radius + expect(path.contains(const Offset(160, 50)), isFalse); // 60px from centre, outside + }); + + testWidgets('ellipse', (WidgetTester tester) async { + final clipper = await getClipper(tester, 'ellipse(25% 40% at 50% 50%)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 50)), isTrue); + expect(path.contains(const Offset(10, 50)), isFalse); + }); + + testWidgets('inset', (WidgetTester tester) async { + final clipper = await getClipper(tester, 'inset(10% 20% 30% 40%)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 40)), isTrue); + expect(path.contains(const Offset(40, 10)), isFalse); + }); + + testWidgets('inset round', (WidgetTester tester) async { + final clipper = + await getClipper(tester, 'inset(10% 20% 30% 40% round 20px)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 40)), isTrue); + expect(path.contains(const Offset(81, 11)), isFalse); + }); + + testWidgets('rect', (WidgetTester tester) async { + // rect(top right bottom left): absolute edge coordinates (not insets). + // rect(10% 80% 90% 20%) on 200x100 → visible rect x:[40,160], y:[10,90]. + final clipper = await getClipper(tester, 'rect(10% 80% 90% 20%)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 50)), isTrue); + expect(path.contains(const Offset(30, 5)), isFalse); + }); + + testWidgets('rect round', (WidgetTester tester) async { + final clipper = + await getClipper(tester, 'rect(10% 80% 90% 20% round 10px)'); + + // Same visible area as plain rect, points near the corner are cut by the + // rounded corner. + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(100, 50)), isTrue); + expect(path.contains(const Offset(41, 11)), isFalse); // just inside corner, clipped by radius + }); + + testWidgets('xywh', (WidgetTester tester) async { + final clipper = await getClipper(tester, 'xywh(10% 20% 50% 40%)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(60, 40)), isTrue); + expect(path.contains(const Offset(10, 10)), isFalse); + }); + + testWidgets('xywh round', (WidgetTester tester) async { + // xywh(10% 20% 50% 40% round 10px): origin (20,20), size (100,40). + // Corner at (20,20); a point just inside the corner box is cut by radius. + final clipper = + await getClipper(tester, 'xywh(10% 20% 50% 40% round 10px)'); + + final path = clipper.getClip(const Size(200, 100)); + expect(path.contains(const Offset(70, 40)), isTrue); + expect(path.contains(const Offset(21, 21)), isFalse); // clipped by radius + }); + + testWidgets('none', (WidgetTester tester) async { + const html = '
Foo
'; + await explain(tester, html); + + expect(find.byType(ClipPath), findsNothing); + }); + + // path("...") requires SvgFactory — without it the widget is not clipped. + testWidgets('path() no-op without SvgFactory', (WidgetTester tester) async { + const html = + '
Foo
'; + await explain(tester, html); + + expect(find.byType(ClipPath), findsNothing); + }); + + group('empty element', () { + // Regression: empty divs with explicit size + clip-path must render a + // ClipPath. Previously _sizingBlock bailed on isEmpty, leaving the element + // at 0x0 and invisible. + testWidgets('circle on empty div', (WidgetTester tester) async { + const html = + '
'; + await explain(tester, html); + + expect(find.byType(ClipPath), findsOneWidget); + final clipPath = tester.widget(find.byType(ClipPath)); + expect(clipPath.clipper, isA()); + }); + + testWidgets('polygon on empty div', (WidgetTester tester) async { + const html = + '
'; + await explain(tester, html); + + expect(find.byType(ClipPath), findsOneWidget); + }); + + testWidgets('empty div without clip-path collapses', (WidgetTester tester) async { + const html = '
'; + await explain(tester, html); + + expect(find.byType(ClipPath), findsNothing); + }); + }); + + final goldenSkipEnvVar = Platform.environment['GOLDEN_SKIP']; + final goldenSkip = goldenSkipEnvVar == null + ? Platform.isLinux + ? null + : 'Linux only' + : 'GOLDEN_SKIP=$goldenSkipEnvVar'; + + GoldenToolkit.runWithConfiguration( + () { + group( + 'goldens', + () { + const testCases = { + 'polygon': '
', + 'circle': '
', + 'ellipse': '
', + 'inset': '
', + 'rect': '
', + 'rect_round': '
', + 'xywh': '
', + 'xywh_round': '
', + 'none': '
', + // path() requires SvgFactory; without it the element renders unclipped. + 'path': '
', + }; + + for (final testCase in testCases.entries) { + testGoldens( + testCase.key, + (tester) async { + await tester.pumpWidgetBuilder( + _Golden(testCase.value), + wrapper: materialAppWrapper(theme: ThemeData.light()), + surfaceSize: const Size(116, 116), + ); + + await screenMatchesGolden(tester, testCase.key); + }, + skip: goldenSkip != null, + ); + } + }, + skip: goldenSkip, + ); + }, + config: GoldenToolkitConfiguration( + fileNameFactory: (name) => '$kGoldenFilePrefix/clip_path/$name.png', + ), + ); +} + +class _Golden extends StatelessWidget { + final String html; + + const _Golden(this.html); + + @override + Widget build(BuildContext context) => Scaffold( + body: Padding( + padding: const EdgeInsets.all(8.0), + child: HtmlWidget(html), + ), + ); +} diff --git a/packages/core/test/style_sizing_test.dart b/packages/core/test/style_sizing_test.dart index 26c8916d8..7dece3c36 100644 --- a/packages/core/test/style_sizing_test.dart +++ b/packages/core/test/style_sizing_test.dart @@ -444,6 +444,75 @@ Future main() async { ); }); + group('empty element', () { + testWidgets('renders with explicit width and height', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect( + explained, + equals('[CssSizing:height=50.0,width=100.0,child=[widget0]]'), + ); + }); + + testWidgets('renders with explicit width only', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect( + explained, + equals('[CssSizing:width=100.0,child=[widget0]]'), + ); + }); + + testWidgets('renders with explicit height only', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect( + explained, + equals('[CssSizing:height=50.0,width=100.0%,child=[widget0]]'), + ); + }); + + testWidgets('collapses without explicit size', (tester) async { + // A block with only display:block (100% width) and no content should + // still collapse — the isEmpty guard must keep the optimization. + const html = '
'; + final explained = await explain(tester, html); + expect(explained, equals('[widget0]')); + }); + + testWidgets('collapses with only width 100%', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect(explained, equals('[CssSizing:width=100.0%,child=[widget0]]')); + }); + + testWidgets('collapses with only min-height', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect(explained, equals('[widget0]')); + }); + + testWidgets('collapses with only min-width', (tester) async { + const html = '
'; + final explained = await explain(tester, html); + expect(explained, equals('[widget0]')); + }); + + testWidgets('renders with background and explicit size', (tester) async { + // Regression: empty div with background + size must be visible. + const html = + '
'; + final explained = await explain(tester, html); + expect( + explained, + equals( + '[Container:color=#FFFF0000,child=' + '[CssSizing:height=50.0,width=100.0,child=[widget0]]]', + ), + ); + }); + }); + group('block', () { testWidgets('renders block within block', (WidgetTester tester) async { const html = diff --git a/packages/fwfh_svg/lib/src/svg_factory.dart b/packages/fwfh_svg/lib/src/svg_factory.dart index cf68a07f0..23dc77610 100644 --- a/packages/fwfh_svg/lib/src/svg_factory.dart +++ b/packages/fwfh_svg/lib/src/svg_factory.dart @@ -1,9 +1,9 @@ -// TODO: remove ignore for file when our minimum core version >= 1.0 // ignore_for_file: deprecated_member_use import 'package:flutter/widgets.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:path_parsing/path_parsing.dart'; import 'internal/platform_specific/fallback.dart' if (dart.library.io) 'internal/platform_specific/io.dart'; @@ -129,6 +129,13 @@ mixin SvgFactory on WidgetFactory { return super.parse(meta); } + @override + Path? buildClipPathFromSvgData(String pathData) { + final path = Path(); + writeSvgPathDataToPath(pathData, _PathProxy(path)); + return path; + } + Widget _buildSvgPicture( BuildMetadata meta, ImageSource src, @@ -160,3 +167,29 @@ mixin SvgFactory on WidgetFactory { ); } } + +class _PathProxy implements PathProxy { + final Path path; + + _PathProxy(this.path); + + @override + void close() => path.close(); + + @override + void cubicTo( + double x1, + double y1, + double x2, + double y2, + double x3, + double y3, + ) => + path.cubicTo(x1, y1, x2, y2, x3, y3); + + @override + void lineTo(double x, double y) => path.lineTo(x, y); + + @override + void moveTo(double x, double y) => path.moveTo(x, y); +} diff --git a/packages/fwfh_svg/pubspec.yaml b/packages/fwfh_svg/pubspec.yaml index 48e91d1d6..47dd4c612 100644 --- a/packages/fwfh_svg/pubspec.yaml +++ b/packages/fwfh_svg/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: sdk: flutter flutter_svg: ^2.0.0 flutter_widget_from_html_core: ">=0.8.0 <0.18.0" + path_parsing: ">=1.0.0 <2.0.0" dependency_overrides: flutter_widget_from_html_core: diff --git a/packages/fwfh_svg/test/svg_factory_test.dart b/packages/fwfh_svg/test/svg_factory_test.dart index 539a714e0..c51de4d1e 100644 --- a/packages/fwfh_svg/test/svg_factory_test.dart +++ b/packages/fwfh_svg/test/svg_factory_test.dart @@ -364,6 +364,31 @@ Bar.'''; fileNameFactory: (name) => '${core.kGoldenFilePrefix}/svg/$name.png', ), ); + + group('clip-path: path()', () { + testWidgets('clips when SvgFactory is loaded', (WidgetTester tester) async { + const pathData = 'M 0 0 L 200 0 L 100 100 Z'; + const html = + '
Foo
'; + await core.explain( + tester, + null, + hw: HtmlWidget(html, factoryBuilder: () => _ClipPathSvgFactory()), + ); + + final clipPath = tester.widget(find.byType(ClipPath)); + expect(clipPath.clipper, isNotNull); + }); + + testWidgets('does nothing without SvgFactory', (WidgetTester tester) async { + const pathData = 'M 0 0 L 200 0 L 100 100 Z'; + const html = + '
Foo
'; + await core.explain(tester, html); + + expect(find.byType(ClipPath), findsNothing); + }); + }); } class _Golden extends StatelessWidget { @@ -485,3 +510,5 @@ class _NullLoadingFactory extends WidgetFactory with SvgFactory { ]) => null; } + +class _ClipPathSvgFactory extends WidgetFactory with SvgFactory {}