diff --git a/packages/core/lib/src/core_widget_factory.dart b/packages/core/lib/src/core_widget_factory.dart index 2c60b6c79..6b83be775 100644 --- a/packages/core/lib/src/core_widget_factory.dart +++ b/packages/core/lib/src/core_widget_factory.dart @@ -147,11 +147,15 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { BoxBorder? border, BorderRadius? borderRadius, Color? color, + Gradient? gradient, DecorationImage? image, }) { + // BoxDecoration asserts that color and gradient cannot both be non-null. + final effectiveColor = gradient != null ? null : color; if (border == null && borderRadius == null && - color == null && + effectiveColor == null && + gradient == null && image == null) { return child; } @@ -163,7 +167,8 @@ class WidgetFactory extends WidgetFactoryResetter with AnchorWidgetFactory { prevDeco is BoxDecoration ? prevDeco : const BoxDecoration(); var decoration = baseDeco.copyWith( border: border, - color: color, + color: effectiveColor, + gradient: gradient, image: image, ); diff --git a/packages/core/lib/src/internal/core_parser.dart b/packages/core/lib/src/internal/core_parser.dart index ec7522897..b9a21d2a4 100644 --- a/packages/core/lib/src/internal/core_parser.dart +++ b/packages/core/lib/src/internal/core_parser.dart @@ -9,6 +9,7 @@ import '../core_helpers.dart'; part 'parser/border.dart'; part 'parser/color.dart'; +part 'parser/gradient.dart'; part 'parser/length.dart'; const kCssLengthAuto = 'auto'; diff --git a/packages/core/lib/src/internal/ops/style_background.dart b/packages/core/lib/src/internal/ops/style_background.dart index 168ef5cd2..659b04461 100644 --- a/packages/core/lib/src/internal/ops/style_background.dart +++ b/packages/core/lib/src/internal/ops/style_background.dart @@ -33,9 +33,10 @@ class StyleBackground { onRenderBlock: (tree, placeholder) { final data = tree.backgroundData; final color = data.color; + final gradient = data.gradient; final imageUrl = data.imageUrl; - if (color == null && imageUrl == null) { + if (color == null && gradient == null && imageUrl == null) { return placeholder; } @@ -50,11 +51,17 @@ class StyleBackground { return placeholder.wrapWith( (context, child) { final resolved = tree.inheritanceResolvers.resolve(context); - final resolvedColor = color?.getValue(resolved); + // gradient takes precedence over background-color (BoxDecoration + // disallows both simultaneously; gradient renders on top anyway). + final resolvedColor = + gradient == null ? color?.getValue(resolved) : null; return wf.buildDecoration( tree, child, color: resolvedColor, + gradient: gradient != null + ? _cssGradientToFlutter(gradient) + : null, image: image, ); }, @@ -154,12 +161,14 @@ extension on css.Expression { class _StyleBackgroundData { final AlignmentGeometry alignment; final CssColor? color; + final CssGradient? gradient; final String? imageUrl; final ImageRepeat repeat; final BoxFit size; const _StyleBackgroundData({ this.alignment = Alignment.topLeft, this.color, + this.gradient, this.imageUrl, this.repeat = ImageRepeat.noRepeat, this.size = BoxFit.scaleDown, @@ -168,6 +177,7 @@ class _StyleBackgroundData { _StyleBackgroundData copyWith({ AlignmentGeometry? alignment, CssColor? color, + CssGradient? gradient, String? imageUrl, ImageRepeat? repeat, BoxFit? size, @@ -175,6 +185,7 @@ class _StyleBackgroundData { _StyleBackgroundData( alignment: alignment ?? this.alignment, color: color ?? this.color, + gradient: gradient ?? this.gradient, imageUrl: imageUrl ?? this.imageUrl, repeat: repeat ?? this.repeat, size: size ?? this.size, @@ -192,6 +203,15 @@ class _StyleBackgroundData { _StyleBackgroundData copyWithImageUrl(_StyleBackgroundDeclaration style) { final value = style.value; + + // Gradient functions (linear-gradient, radial-gradient, conic-gradient…) + // are parsed first; they shadow background-color per the CSS spec. + final cssGradient = tryParseGradient(value); + if (cssGradient != null) { + style.increaseIndex(); + return copyWith(gradient: cssGradient); + } + final imageUrl = value is css.UriTerm ? value.text : null; if (imageUrl == null) { return this; @@ -351,3 +371,156 @@ enum _StyleBackgroundPosition { right, top, } + +// ────────────────────────────────────────────────────────────────────────────── +// CSS gradient → Flutter Gradient conversion +// ────────────────────────────────────────────────────────────────────────────── + +Gradient _cssGradientToFlutter(CssGradient g) { + final colors = g.stops.map((s) => s.color).toList(growable: false); + final allHavePositions = g.stops.every((s) => s.position != null); + final rawStops = allHavePositions + ? g.stops.map((s) => s.position!).toList(growable: false) + : null; + + // Flutter's TileMode.repeated tiles outside the [begin,end] vector but not + // within it, so it has no effect when the vector spans the whole box. + // Manually expand the stop pattern to cover [0,1] instead. + final (effectiveColors, effectiveStops) = + g.repeating ? _expandRepeatingStops(colors, rawStops) : (colors, rawStops); + + return switch (g) { + CssLinearGradient(:final begin, :final end) => LinearGradient( + begin: begin, + end: end, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + ), + CssRadialGradient(:final center, :final isCircle) => RadialGradient( + center: center, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + transform: _RadialFarthestCornerTransform(center, isCircle: isCircle), + ), + CssConicGradient(:final center, :final startAngle) => SweepGradient( + center: center, + // Always sweep the full 0→2π so stop positions map correctly and + // TileMode.clamp doesn't produce a clamped wedge before startAngle. + startAngle: 0, + endAngle: 2 * pi, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + // Rotate around the gradient's own center: + // • -π/2 maps CSS 12 o'clock → Flutter 3 o'clock origin + // • + startAngle applies the CSS `from ` offset + transform: _ConicAlignTransform(center, startAngle), + ), + }; +} + +/// Expands a repeating gradient's stop pattern to cover [0, 1]. +/// +/// Flutter's [TileMode.repeated] tiles outside the gradient vector but not +/// within [0, 1], so it produces no visible repetition when the vector spans +/// the full bounding box. Manually repeating the tile fixes this. +(List, List?) _expandRepeatingStops( + List colors, + List? stops, +) { + if (stops == null || stops.length < 2) return (colors, stops); + final period = stops.last - stops.first; + if (period <= 0 || stops.last >= 1.0) return (colors, stops); + + final outColors = []; + final outStops = []; + + var offset = 0.0; + outer: + while (true) { + for (var i = 0; i < stops.length; i++) { + final s = offset + (stops[i] - stops.first); + if (s >= 1.0) { + outStops.add(1.0); + outColors.add(colors[i]); + break outer; + } + outStops.add(s); + outColors.add(colors[i]); + } + offset += period; + } + + return (outColors, outStops); +} + +/// Applies CSS [farthest-corner] sizing to a Flutter [RadialGradient]. +/// +/// Flutter's [RadialGradient] defaults to `radius: 0.5` (half the shortest +/// side). CSS defaults to farthest-corner, meaning the last stop should reach +/// the furthest corner of the bounding box from the gradient centre. +/// +/// * **Circle** – radius = Euclidean distance from centre to farthest corner. +/// * **Ellipse** – each axis scaled independently (CSS spec §4.2.1). +class _RadialFarthestCornerTransform implements GradientTransform { + final Alignment center; + final bool isCircle; + + const _RadialFarthestCornerTransform(this.center, {required this.isCircle}); + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + final baseR = bounds.shortestSide / 2.0; + if (baseR == 0) return null; + + final cx = bounds.left + bounds.width * (center.x + 1) / 2; + final cy = bounds.top + bounds.height * (center.y + 1) / 2; + + final maxDx = max(cx - bounds.left, bounds.right - cx); + final maxDy = max(cy - bounds.top, bounds.bottom - cy); + + final double scaleX; + final double scaleY; + if (isCircle) { + final farDist = sqrt(maxDx * maxDx + maxDy * maxDy); + if (farDist == 0) return null; + scaleX = farDist / baseR; + scaleY = scaleX; + } else { + if (maxDx == 0 || maxDy == 0) return null; + scaleX = maxDx / baseR; + scaleY = maxDy / baseR; + } + + return Matrix4.identity() + ..translate(cx, cy) + ..scale(scaleX, scaleY, 1.0) + ..translate(-cx, -cy); + } +} + +/// Rotates a conic gradient around [center] (not necessarily Alignment.center) +/// by [startAngle] − π/2. +/// +/// Flutter's SweepGradient starts at 3 o'clock; CSS starts at 12 o'clock. +/// The extra -π/2 corrects that, and [startAngle] adds the CSS `from ` +/// offset on top. +class _ConicAlignTransform implements GradientTransform { + final Alignment center; + final double startAngle; + + const _ConicAlignTransform(this.center, this.startAngle); + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + final cx = bounds.left + bounds.width * (center.x + 1) / 2; + final cy = bounds.top + bounds.height * (center.y + 1) / 2; + final angle = startAngle - pi / 2; + return Matrix4.identity() + ..translate(cx, cy) + ..rotateZ(angle) + ..translate(-cx, -cy); + } +} diff --git a/packages/core/lib/src/internal/parser/gradient.dart b/packages/core/lib/src/internal/parser/gradient.dart new file mode 100644 index 000000000..d08d27177 --- /dev/null +++ b/packages/core/lib/src/internal/parser/gradient.dart @@ -0,0 +1,630 @@ +part of '../core_parser.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// Public data types +// ───────────────────────────────────────────────────────────────────────────── + +/// A single color stop inside a CSS gradient. +/// +/// [color] is the resolved ARGB color value. +/// [position] is the normalised position in [0.0, 1.0], or `null` when the +/// browser should distribute the stop automatically. +@immutable +class CssGradientStop { + final Color color; + final double? position; + + const CssGradientStop({required this.color, this.position}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CssGradientStop && + other.color == color && + other.position == position; + + @override + int get hashCode => Object.hash(color, position); + + @override + String toString() => 'CssGradientStop(color: $color, position: $position)'; +} + +/// Base sealed class for all parsed CSS gradient types. +/// +/// Subtypes: +/// - [CssLinearGradient] → `linear-gradient` / `repeating-linear-gradient` +/// - [CssRadialGradient] → `radial-gradient` / `repeating-radial-gradient` +/// - [CssConicGradient] → `conic-gradient` / `repeating-conic-gradient` +@immutable +sealed class CssGradient { + final List stops; + + /// Whether this is a `repeating-*` variant, which maps to + /// Flutter's `TileMode.repeated`. + final bool repeating; + + const CssGradient({required this.stops, required this.repeating}); +} + +/// Parsed `linear-gradient` or `repeating-linear-gradient`. +/// +/// [begin] and [end] are `Alignment` values in Flutter's coordinate system. +/// They map directly to `LinearGradient.begin` and `LinearGradient.end`. +@immutable +final class CssLinearGradient extends CssGradient { + /// Gradient start point. Defaults to `Alignment.topCenter` (CSS `0deg`). + final Alignment begin; + + /// Gradient end point. Defaults to `Alignment.bottomCenter` (CSS `0deg`). + final Alignment end; + + const CssLinearGradient({ + required super.stops, + required super.repeating, + this.begin = Alignment.topCenter, + this.end = Alignment.bottomCenter, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CssLinearGradient && + other.begin == begin && + other.end == end && + other.stops == stops && + other.repeating == repeating; + + @override + int get hashCode => Object.hash(begin, end, stops, repeating); + + @override + String toString() => 'CssLinearGradient(begin: $begin, end: $end, ' + 'stops: $stops, repeating: $repeating)'; +} + +/// Parsed `radial-gradient` or `repeating-radial-gradient`. +/// +/// [center] maps to `RadialGradient.center`. +/// [isCircle] distinguishes the CSS `circle` keyword from the default ellipse. +/// Flutter's `RadialGradient` is always circular; ellipse support requires a +/// custom transform (handled downstream in the rendering layer). +@immutable +final class CssRadialGradient extends CssGradient { + final Alignment center; + + /// `true` when the CSS `circle` keyword was present; `false` for the + /// default ellipse behaviour. + final bool isCircle; + + const CssRadialGradient({ + required super.stops, + required super.repeating, + this.center = Alignment.center, + this.isCircle = false, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CssRadialGradient && + other.center == center && + other.isCircle == isCircle && + other.stops == stops && + other.repeating == repeating; + + @override + int get hashCode => Object.hash(center, isCircle, stops, repeating); + + @override + String toString() => + 'CssRadialGradient(center: $center, isCircle: $isCircle, ' + 'stops: $stops, repeating: $repeating)'; +} + +/// Parsed `conic-gradient` or `repeating-conic-gradient`. +/// +/// [center] maps to `SweepGradient.center`. +/// [startAngle] is in **radians** and maps to `SweepGradient.startAngle`. +/// CSS `from 0deg` = `startAngle: 0.0` (sweep starts at 3 o'clock in Flutter's +/// coordinate system, which aligns with CSS conic-gradient's 0-degree start). +@immutable +final class CssConicGradient extends CssGradient { + final Alignment center; + + /// Starting angle in radians. Maps to `SweepGradient.startAngle`. + final double startAngle; + + const CssConicGradient({ + required super.stops, + required super.repeating, + this.center = Alignment.center, + this.startAngle = 0.0, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CssConicGradient && + other.center == center && + other.startAngle == startAngle && + other.stops == stops && + other.repeating == repeating; + + @override + int get hashCode => Object.hash(center, startAngle, stops, repeating); + + @override + String toString() => + 'CssConicGradient(center: $center, startAngle: $startAngle, ' + 'stops: $stops, repeating: $repeating)'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Entry point +// ───────────────────────────────────────────────────────────────────────────── + +/// Attempts to parse a CSS gradient from a csslib [expression]. +/// +/// Returns a typed [CssGradient] on success, or `null` when [expression] is +/// not a recognised gradient function or the content is malformed. +/// +/// Recognises: +/// * `linear-gradient` / `repeating-linear-gradient` +/// * `radial-gradient` / `repeating-radial-gradient` +/// * `conic-gradient` / `repeating-conic-gradient` +CssGradient? tryParseGradient(css.Expression? expression) { + if (expression is! css.FunctionTerm) { + return null; + } + + final kind = _cssGradientKind(expression.text); + if (kind == null) { + return null; + } + + // Split the function's params on top-level commas, preserving structure + // inside nested function calls such as rgb(…) or hsl(…). + final groups = _splitGradientArgs(expression); + if (groups.isEmpty) { + return null; + } + + return switch (kind) { + _CssGradientKind.linear || + _CssGradientKind.repeatingLinear => + _parseLinearGradient(groups, kind.isRepeating), + _CssGradientKind.radial || + _CssGradientKind.repeatingRadial => + _parseRadialGradient(groups, kind.isRepeating), + _CssGradientKind.conic || + _CssGradientKind.repeatingConic => + _parseConicGradient(groups, kind.isRepeating), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: gradient-kind enum +// ───────────────────────────────────────────────────────────────────────────── + +enum _CssGradientKind { + linear, + repeatingLinear, + radial, + repeatingRadial, + conic, + repeatingConic; + + bool get isRepeating => + this == repeatingLinear || + this == repeatingRadial || + this == repeatingConic; +} + +_CssGradientKind? _cssGradientKind(String funcName) { + return switch (funcName.toLowerCase()) { + 'linear-gradient' => _CssGradientKind.linear, + 'repeating-linear-gradient' => _CssGradientKind.repeatingLinear, + 'radial-gradient' => _CssGradientKind.radial, + 'repeating-radial-gradient' => _CssGradientKind.repeatingRadial, + 'conic-gradient' => _CssGradientKind.conic, + 'repeating-conic-gradient' => _CssGradientKind.repeatingConic, + _ => null, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: param splitting +// ───────────────────────────────────────────────────────────────────────────── + +/// Splits the direct params of [term] into comma-separated groups. +/// +/// Uses a single-pass [css.Visitor] that captures the top-level [Expressions] +/// node of the function (which includes [css.OperatorComma] items) without +/// recursing into nested function calls such as `rgb(…)`. +/// +/// Example: `linear-gradient(to right, rgb(255,0,0), blue)` → +/// ```dart +/// [ [LiteralTerm('to'), LiteralTerm('right')], +/// [FunctionTerm('rgb', …)], +/// [HexColorTerm('0000ff')] ] +/// ``` +List> _splitGradientArgs(css.FunctionTerm term) { + final rawParams = []; + term.visit(_GradientParamsCollector(rawParams)); + + final groups = >[[]]; + for (final expr in rawParams) { + if (expr is css.OperatorComma) { + groups.add([]); + } else { + groups.last.add(expr); + } + } + + // Drop any empty trailing group caused by a trailing comma. + while (groups.isNotEmpty && groups.last.isEmpty) { + groups.removeLast(); + } + + return groups; +} + +/// A [css.Visitor] that captures the direct params of a [css.FunctionTerm] +/// (with [css.OperatorComma] preserved) without recursing into nested calls. +class _GradientParamsCollector extends css.Visitor { + final List _target; + + _GradientParamsCollector(this._target); + + @override + void visitExpressions(css.Expressions node) { + _target.addAll(node.expressions); + // Intentionally NOT calling super — prevents recursion into nested + // function params (e.g. the comma-separated args inside rgb(…)). + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: linear-gradient parser +// ───────────────────────────────────────────────────────────────────────────── + +CssLinearGradient? _parseLinearGradient( + List> groups, + bool repeating, +) { + var begin = Alignment.topCenter; + var end = Alignment.bottomCenter; + var stopStart = 0; + + // Detect optional direction in the first group. + if (groups.isNotEmpty) { + final first = groups[0]; + if (first.isNotEmpty) { + final head = first[0]; + if (head is css.AngleTerm) { + // e.g. `linear-gradient(45deg, …)` + final degrees = _angleTermToDegrees(head); + if (degrees != null) { + (begin, end) = _degreesToAlignment(degrees); + stopStart = 1; + } + } else if (head is css.LiteralTerm && + head.valueAsString == 'to' && + first.length > 1) { + // e.g. `linear-gradient(to right, …)` or `to bottom right` + final dir = _parseToDirection(first.skip(1).toList()); + if (dir != null) { + (begin, end) = dir; + stopStart = 1; + } + } + } + } + + final stops = _parseColorStops(groups, from: stopStart); + if (stops.length < 2) { + return null; + } + + return CssLinearGradient( + begin: begin, + end: end, + stops: stops, + repeating: repeating, + ); +} + +/// Parses a `to []` CSS direction into a (begin, end) pair. +(Alignment, Alignment)? _parseToDirection(List keywords) { + if (keywords.isEmpty) { + return null; + } + + String? keyword(int i) => + i < keywords.length && keywords[i] is css.LiteralTerm + ? (keywords[i] as css.LiteralTerm).valueAsString + : null; + + final k1 = keyword(0); + final k2 = keyword(1); + + if (k2 == null) { + return switch (k1) { + 'top' => (Alignment.bottomCenter, Alignment.topCenter), + 'bottom' => (Alignment.topCenter, Alignment.bottomCenter), + 'left' => (Alignment.centerRight, Alignment.centerLeft), + 'right' => (Alignment.centerLeft, Alignment.centerRight), + _ => null, + }; + } + + // Corner: order-insensitive; CSS allows `top right` or `right top`. + final corner = {k1, k2}; + if (corner.containsAll(['top', 'right'])) { + return (Alignment.bottomLeft, Alignment.topRight); + } + if (corner.containsAll(['top', 'left'])) { + return (Alignment.bottomRight, Alignment.topLeft); + } + if (corner.containsAll(['bottom', 'right'])) { + return (Alignment.topLeft, Alignment.bottomRight); + } + if (corner.containsAll(['bottom', 'left'])) { + return (Alignment.topRight, Alignment.bottomLeft); + } + return null; +} + +/// Converts a CSS angle term to degrees. +double? _angleTermToDegrees(css.AngleTerm term) { + final v = term.angle.toDouble(); + return switch (term.unit) { + css.TokenKind.UNIT_ANGLE_DEG => v, + css.TokenKind.UNIT_ANGLE_RAD => v * (180.0 / pi), + css.TokenKind.UNIT_ANGLE_GRAD => v * 0.9, // 400grad = 360deg + css.TokenKind.UNIT_ANGLE_TURN => v * 360.0, + _ => null, + }; +} + +/// Converts a CSS **angle in degrees** to a Flutter `(begin, end)` alignment +/// pair for use in [LinearGradient]. +/// +/// CSS `0deg` points upward (`to top`): +/// ```dart +/// 0deg → begin=bottomCenter, end=topCenter +/// 90deg → begin=centerLeft, end=centerRight +/// 180deg → begin=topCenter, end=bottomCenter +/// 270deg → begin=centerRight, end=centerLeft +/// ``` +/// +/// The direction vector (begin→end) = (sin θ, −cos θ) in normalised space. +(Alignment, Alignment) _degreesToAlignment(double degrees) { + final rad = degrees * pi / 180.0; + // Unit vector pointing in the gradient direction (CSS convention). + final dx = sin(rad); // positive = rightward + final dy = -cos(rad); // positive = downward (Flutter y increases downward) + return (Alignment(-dx, -dy), Alignment(dx, dy)); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: radial-gradient parser +// ───────────────────────────────────────────────────────────────────────────── + +CssRadialGradient? _parseRadialGradient( + List> groups, + bool repeating, +) { + var center = Alignment.center; + var isCircle = false; + var stopStart = 0; + + // The first group is a shape/size/position hint iff its first token is NOT + // parseable as a color. Position keywords, shape keywords (`circle`, + // `ellipse`), and size keywords (`closest-side`, etc.) all fail color + // parsing, while valid color names always succeed. + if (groups.isNotEmpty && + groups[0].isNotEmpty && + tryParseColor(groups[0][0]) == null) { + final hint = groups[0]; + stopStart = 1; + + for (final expr in hint) { + if (expr is! css.LiteralTerm) { + continue; + } + switch (expr.valueAsString) { + case 'circle': + isCircle = true; + case 'ellipse': + isCircle = false; + } + } + + // Look for `at ` within the hint group. + final atIdx = hint.indexWhere( + (e) => e is css.LiteralTerm && e.valueAsString == 'at', + ); + if (atIdx >= 0) { + center = _parsePositionKeywords(hint.sublist(atIdx + 1)) ?? center; + } + } + + final stops = _parseColorStops(groups, from: stopStart); + if (stops.length < 2) { + return null; + } + + return CssRadialGradient( + center: center, + isCircle: isCircle, + stops: stops, + repeating: repeating, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: conic-gradient parser +// ───────────────────────────────────────────────────────────────────────────── + +CssConicGradient? _parseConicGradient( + List> groups, + bool repeating, +) { + var center = Alignment.center; + var startAngle = 0.0; // radians + var stopStart = 0; + + // Same heuristic as radial: first group is a hint iff its first token is + // not a valid color. + if (groups.isNotEmpty && + groups[0].isNotEmpty && + tryParseColor(groups[0][0]) == null) { + final hint = groups[0]; + stopStart = 1; + + // Scan for `from `. + for (var i = 0; i < hint.length - 1; i++) { + if (hint[i] is css.LiteralTerm && + (hint[i] as css.LiteralTerm).valueAsString == 'from' && + hint[i + 1] is css.AngleTerm) { + final deg = _angleTermToDegrees(hint[i + 1] as css.AngleTerm); + if (deg != null) { + startAngle = deg * pi / 180.0; + } + break; + } + } + + // Scan for `at `. + final atIdx = hint.indexWhere( + (e) => e is css.LiteralTerm && e.valueAsString == 'at', + ); + if (atIdx >= 0) { + center = _parsePositionKeywords(hint.sublist(atIdx + 1)) ?? center; + } + } + + final stops = _parseColorStops(groups, from: stopStart); + if (stops.length < 2) { + return null; + } + + return CssConicGradient( + center: center, + startAngle: startAngle, + stops: stops, + repeating: repeating, + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: shared position-keyword parser +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolves a list of CSS position keywords to an [Alignment]. +/// +/// Handles single keywords (`center`, `top`, `left`, …) and two-keyword +/// combinations (`top right`, `bottom left`, …). Percentage / length tokens +/// are currently ignored (not yet supported; falls back to `null`). +Alignment? _parsePositionKeywords(List exprs) { + final keywords = exprs + .whereType() + .map((e) => e.valueAsString) + .where((s) => s.isNotEmpty) + .toList(growable: false); + + if (keywords.isEmpty) { + return null; + } + + double? x; + double? y; + + for (final kw in keywords) { + switch (kw) { + case 'left': + x = -1.0; + case 'right': + x = 1.0; + case 'top': + y = -1.0; + case 'bottom': + y = 1.0; + case 'center': + // Assign to whichever axis hasn't been set yet. On a second `center` + // both axes default to 0. + x ??= 0.0; + y ??= 0.0; + } + } + + if (x == null && y == null) { + return null; + } + return Alignment(x ?? 0.0, y ?? 0.0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Private: color-stop parser +// ───────────────────────────────────────────────────────────────────────────── + +/// Parses color stops from [groups] starting at index [from]. +/// +/// Each group corresponds to one comma-separated argument in the original CSS, +/// e.g. `["#f00", "20%"]` or `["blue"]`. +List _parseColorStops( + List> groups, { + required int from, +}) { + final stops = []; + for (var i = from; i < groups.length; i++) { + final stop = _parseColorStop(groups[i]); + if (stop != null) { + stops.add(stop); + } + } + return stops; +} + +/// Parses a single color-stop group such as `[HexColorTerm, PercentageTerm]` +/// or `[FunctionTerm('rgb'), PercentageTerm]`. +CssGradientStop? _parseColorStop(List group) { + if (group.isEmpty) { + return null; + } + + final cssColor = tryParseColor(group[0]); + final raw = cssColor?.rawValue; + if (raw == null) { + // also skips currentcolor + return null; + } + + double? position; + if (group.length >= 2) { + position = _parseStopPosition(group[1]); + } + + return CssGradientStop(color: raw, position: position); +} + +/// Resolves a stop-position expression to a normalised [0.0, 1.0] fraction. +/// +/// * `PercentageTerm` → value / 100. +/// * `AngleTerm` → degrees / 360 (used by conic-gradient). +/// * Other terms → `null`. +double? _parseStopPosition(css.Expression expr) { + if (expr is css.PercentageTerm) { + return expr.valueAsDouble.clamp(0.0, 1.0); + } + if (expr is css.AngleTerm) { + final deg = _angleTermToDegrees(expr); + if (deg != null) { + // Do NOT use modulo: 360deg must map to 1.0 (end of sweep), not 0.0. + return (deg / 360.0).clamp(0.0, 1.0); + } + } + return null; +} diff --git a/packages/core/test/gradient_test.dart b/packages/core/test/gradient_test.dart new file mode 100644 index 000000000..e85f75ba5 --- /dev/null +++ b/packages/core/test/gradient_test.dart @@ -0,0 +1,695 @@ +import 'dart:math'; + +import 'package:csslib/parser.dart' as css_parser; +import 'package:csslib/visitor.dart' as css; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_widget_from_html_core/src/core_helpers.dart'; +import 'package:flutter_widget_from_html_core/src/internal/core_parser.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; + +import '_constants.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Parses [gradientCss] as a `background-image` value and returns the first +/// CSS expression, which for a gradient will be a [css.FunctionTerm]. +css.Expression? _expr(String gradientCss) { + final sheet = css_parser.parse('a{background-image:$gradientCss}'); + final decls = sheet.collectDeclarations(); + if (decls.isEmpty) { + return null; + } + final values = decls.first.values; + if (values.isEmpty) { + return null; + } + return values.first; +} + +/// Parses and asserts a gradient, then casts it to [T]. +T _parseAs(String gradientCss) { + final result = tryParseGradient(_expr(gradientCss)); + expect(result, isNotNull, reason: 'Expected "$gradientCss" to parse'); + expect(result, isA(), reason: 'Expected result to be $T'); + return result! as T; +} + +void main() async { + await loadAppFonts(); + // ─────────────────────────────────────────────────────────────────────────── + // tryParseGradient — null / unrecognised inputs + // ─────────────────────────────────────────────────────────────────────────── + + group('tryParseGradient: edge cases', () { + test('returns null for null input', () { + expect(tryParseGradient(null), isNull); + }); + + test('returns null for non-function expression', () { + final sheet = css_parser.parse('a{color:#f00}'); + final decl = sheet.collectDeclarations().first; + expect(tryParseGradient(decl.values.first), isNull); + }); + + test('returns null for unrecognised function', () { + expect(tryParseGradient(_expr('drop-shadow(#f00, #00f)')), isNull); + }); + + test('returns null with fewer than 2 color stops', () { + expect(tryParseGradient(_expr('linear-gradient(red)')), isNull); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // linear-gradient: direction + // ─────────────────────────────────────────────────────────────────────────── + + group('CssLinearGradient: direction', () { + test('default direction (no hint) is top→bottom (CSS 180deg)', () { + final g = _parseAs('linear-gradient(red, blue)'); + expect(g.begin, equals(Alignment.topCenter)); + expect(g.end, equals(Alignment.bottomCenter)); + }); + + test('to top → bottom→top', () { + final g = + _parseAs('linear-gradient(to top, red, blue)'); + expect(g.begin, equals(Alignment.bottomCenter)); + expect(g.end, equals(Alignment.topCenter)); + }); + + test('to bottom → top→bottom', () { + final g = + _parseAs('linear-gradient(to bottom, red, blue)'); + expect(g.begin, equals(Alignment.topCenter)); + expect(g.end, equals(Alignment.bottomCenter)); + }); + + test('to left → right→left', () { + final g = + _parseAs('linear-gradient(to left, red, blue)'); + expect(g.begin, equals(Alignment.centerRight)); + expect(g.end, equals(Alignment.centerLeft)); + }); + + test('to right → left→right', () { + final g = + _parseAs('linear-gradient(to right, red, blue)'); + expect(g.begin, equals(Alignment.centerLeft)); + expect(g.end, equals(Alignment.centerRight)); + }); + + test('to top right → bottomLeft→topRight', () { + final g = _parseAs( + 'linear-gradient(to top right, red, blue)'); + expect(g.begin, equals(Alignment.bottomLeft)); + expect(g.end, equals(Alignment.topRight)); + }); + + test('to bottom left → topRight→bottomLeft', () { + final g = _parseAs( + 'linear-gradient(to bottom left, red, blue)'); + expect(g.begin, equals(Alignment.topRight)); + expect(g.end, equals(Alignment.bottomLeft)); + }); + + test('corner keywords are order-insensitive (right top == top right)', () { + final a = _parseAs( + 'linear-gradient(to top right, red, blue)'); + final b = _parseAs( + 'linear-gradient(to right top, red, blue)'); + expect(a.begin, equals(b.begin)); + expect(a.end, equals(b.end)); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // linear-gradient: angle-to-alignment math + // ─────────────────────────────────────────────────────────────────────────── + + group('CssLinearGradient: angle directions', () { + test('0deg → bottomCenter→topCenter (upward)', () { + final g = _parseAs('linear-gradient(0deg, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.bottomCenter); + _expectAlignmentApprox(g.end, Alignment.topCenter); + }); + + test('90deg → centerLeft→centerRight (rightward)', () { + final g = + _parseAs('linear-gradient(90deg, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.centerLeft); + _expectAlignmentApprox(g.end, Alignment.centerRight); + }); + + test('180deg → topCenter→bottomCenter (downward)', () { + final g = + _parseAs('linear-gradient(180deg, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.topCenter); + _expectAlignmentApprox(g.end, Alignment.bottomCenter); + }); + + test('270deg → centerRight→centerLeft (leftward)', () { + final g = + _parseAs('linear-gradient(270deg, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.centerRight); + _expectAlignmentApprox(g.end, Alignment.centerLeft); + }); + + test('45deg → bottomLeft quadrant → topRight quadrant', () { + final g = + _parseAs('linear-gradient(45deg, red, blue)'); + // begin should be in bottom-left (-x, +y) quadrant + expect(g.begin.x, lessThan(0)); + expect(g.begin.y, greaterThan(0)); + // end should be in top-right (+x, -y) quadrant + expect(g.end.x, greaterThan(0)); + expect(g.end.y, lessThan(0)); + }); + + test('0.5turn == 180deg (downward)', () { + final g = + _parseAs('linear-gradient(0.5turn, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.topCenter); + _expectAlignmentApprox(g.end, Alignment.bottomCenter); + }); + + test('200grad == 180deg (downward)', () { + final g = + _parseAs('linear-gradient(200grad, red, blue)'); + _expectAlignmentApprox(g.begin, Alignment.topCenter); + _expectAlignmentApprox(g.end, Alignment.bottomCenter); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // linear-gradient: color stops + // ─────────────────────────────────────────────────────────────────────────── + + group('CssLinearGradient: color stops', () { + test('two named stops, no positions', () { + final g = _parseAs('linear-gradient(red, blue)'); + expect(g.stops.length, 2); + expect(g.stops[0].color, equals(const Color(0xFFFF0000))); + expect(g.stops[0].position, isNull); + expect(g.stops[1].color, equals(const Color(0xFF0000FF))); + expect(g.stops[1].position, isNull); + }); + + test('hex stops with explicit percentage positions', () { + final g = _parseAs( + 'linear-gradient(#ff0000 0%, #0000ff 100%)'); + expect(g.stops[0].color, equals(const Color(0xFFFF0000))); + expect(g.stops[0].position, closeTo(0.0, 1e-6)); + expect(g.stops[1].color, equals(const Color(0xFF0000FF))); + expect(g.stops[1].position, closeTo(1.0, 1e-6)); + }); + + test('three stops with mid-point position', () { + final g = _parseAs( + 'linear-gradient(to right, red 0%, green 50%, blue 100%)'); + expect(g.stops.length, 3); + expect(g.stops[1].color, equals(const Color(0xFF008000))); + expect(g.stops[1].position, closeTo(0.5, 1e-6)); + }); + + test('rgb() function stop is parsed correctly', () { + final g = _parseAs( + 'linear-gradient(rgb(255, 0, 0), rgb(0, 0, 255))'); + expect(g.stops[0].color, equals(const Color(0xFFFF0000))); + expect(g.stops[1].color, equals(const Color(0xFF0000FF))); + }); + + test('hsl() function stop is parsed correctly', () { + final g = _parseAs( + 'linear-gradient(hsl(0, 100%, 50%), hsl(240, 100%, 50%))'); + expect(g.stops[0].color, equals(const Color(0xFFFF0000))); + expect(g.stops[1].color, equals(const Color(0xFF0000FF))); + }); + + test('transparent stop has rawValue = Color(0x00000000)', () { + final g = + _parseAs('linear-gradient(transparent, red)'); + expect(g.stops.length, 2); + expect(g.stops[0].color, equals(const Color(0x00000000))); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // repeating-linear-gradient + // ─────────────────────────────────────────────────────────────────────────── + + group('CssLinearGradient: repeating flag', () { + test('linear-gradient sets repeating=false', () { + final g = _parseAs('linear-gradient(red, blue)'); + expect(g.repeating, isFalse); + }); + + test('repeating-linear-gradient sets repeating=true', () { + final g = _parseAs( + 'repeating-linear-gradient(to right, red 0%, blue 20%)'); + expect(g.repeating, isTrue); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // radial-gradient + // ─────────────────────────────────────────────────────────────────────────── + + group('CssRadialGradient: basics', () { + test('two stops, no hint → defaults', () { + final g = _parseAs('radial-gradient(red, blue)'); + expect(g.center, equals(Alignment.center)); + expect(g.isCircle, isFalse); + expect(g.stops.length, 2); + expect(g.repeating, isFalse); + }); + + test('circle keyword sets isCircle=true', () { + final g = + _parseAs('radial-gradient(circle, red, blue)'); + expect(g.isCircle, isTrue); + }); + + test('ellipse keyword sets isCircle=false', () { + final g = + _parseAs('radial-gradient(ellipse, red, blue)'); + expect(g.isCircle, isFalse); + }); + + test('circle at top right → center=topRight', () { + final g = _parseAs( + 'radial-gradient(circle at top right, red, blue)'); + expect(g.isCircle, isTrue); + expect(g.center, equals(Alignment.topRight)); + }); + + test('at center → center=Alignment.center', () { + final g = + _parseAs('radial-gradient(at center, red, blue)'); + expect(g.center, equals(Alignment.center)); + }); + + test('at bottom left → center=bottomLeft', () { + final g = _parseAs( + 'radial-gradient(at bottom left, red, blue)'); + expect(g.center, equals(Alignment.bottomLeft)); + }); + + test('repeating-radial-gradient sets repeating=true', () { + final g = _parseAs( + 'repeating-radial-gradient(circle, red 0%, blue 20%)'); + expect(g.repeating, isTrue); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // conic-gradient + // ─────────────────────────────────────────────────────────────────────────── + + group('CssConicGradient: basics', () { + test('two stops, no hint → defaults', () { + final g = _parseAs('conic-gradient(red, blue)'); + expect(g.center, equals(Alignment.center)); + expect(g.startAngle, closeTo(0.0, 1e-6)); + expect(g.stops.length, 2); + expect(g.repeating, isFalse); + }); + + test('from 90deg → startAngle = π/2', () { + final g = + _parseAs('conic-gradient(from 90deg, red, blue)'); + expect(g.startAngle, closeTo(pi / 2, 1e-6)); + }); + + test('from 180deg → startAngle = π', () { + final g = + _parseAs('conic-gradient(from 180deg, red, blue)'); + expect(g.startAngle, closeTo(pi, 1e-6)); + }); + + test('from 0.25turn → startAngle = π/2', () { + final g = _parseAs( + 'conic-gradient(from 0.25turn, red, blue)'); + expect(g.startAngle, closeTo(pi / 2, 1e-6)); + }); + + test('at center → center=Alignment.center', () { + final g = _parseAs( + 'conic-gradient(from 90deg at center, red, blue)'); + expect(g.center, equals(Alignment.center)); + expect(g.startAngle, closeTo(pi / 2, 1e-6)); + }); + + test('at top left → center=topLeft', () { + final g = + _parseAs('conic-gradient(at top left, red, blue)'); + expect(g.center, equals(Alignment.topLeft)); + }); + + test('repeating-conic-gradient sets repeating=true', () { + final g = _parseAs( + 'repeating-conic-gradient(from 0deg, red 0%, blue 25%)'); + expect(g.repeating, isTrue); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // conic-gradient: angle-position stops + // ─────────────────────────────────────────────────────────────────────────── + + group('CssConicGradient: angle stop positions', () { + test('0deg stop position → 0.0', () { + final g = + _parseAs('conic-gradient(red 0deg, blue 360deg)'); + expect(g.stops[0].position, closeTo(0.0, 1e-6)); + }); + + test('360deg stop position → 1.0 (not 0.0 via modulo)', () { + final g = + _parseAs('conic-gradient(red 0deg, blue 360deg)'); + expect(g.stops[1].position, closeTo(1.0, 1e-6)); + }); + + test('180deg stop position → 0.5', () { + final g = _parseAs( + 'conic-gradient(red 0deg, green 180deg, blue 360deg)'); + expect(g.stops[1].position, closeTo(0.5, 1e-6)); + }); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // Golden tests + // + // Linux-only check is intentionally bypassed so goldens can be generated and + // verified on any platform. Re-enable the check before merging to main: + // final goldenSkip = Platform.isLinux ? null : 'Linux only'; + // ─────────────────────────────────────────────────────────────────────────── + + // null = never skip (bypassed). Restore before merging: + // Platform.isLinux ? null : 'Linux only' + // ignore: avoid_redundant_argument_values + // ignore: prefer_const_declarations + final String? goldenSkip = null; + + GoldenToolkit.runWithConfiguration( + () { + group( + 'gradient', + () { + const size = Size(200, 100); + + // ── linear-gradient ───────────────────────────────────────────── + const linearCases = { + 'linear/to_bottom': + 'linear-gradient(to bottom, #e74c3c, #3498db)', + 'linear/to_right': + 'linear-gradient(to right, #e74c3c, #3498db)', + 'linear/45deg': + 'linear-gradient(45deg, #e74c3c, #f39c12, #3498db)', + 'linear/to_bottom_right': + 'linear-gradient(to bottom right, #e74c3c, #3498db)', + 'linear/explicit_stops': + 'linear-gradient(to right, #e74c3c 0%, #f39c12 50%, #3498db 100%)', + 'linear/transparent': + 'linear-gradient(to right, transparent, #3498db)', + 'repeating-linear': + 'repeating-linear-gradient(to right, #e74c3c 0%, #3498db 20%)', + }; + + for (final entry in linearCases.entries) { + testGoldens( + entry.key, + (tester) async { + final g = _parseAs(entry.value); + await tester.pumpWidgetBuilder( + _GradientBox(gradient: _toFlutterGradient(g), size: size), + wrapper: materialAppWrapper(theme: ThemeData.light()), + surfaceSize: size, + ); + await screenMatchesGolden(tester, entry.key); + }, + skip: goldenSkip != null, + ); + } + + // ── radial-gradient ───────────────────────────────────────────── + const radialCases = { + 'radial/default_ellipse': + 'radial-gradient(#e74c3c, #3498db)', + 'radial/circle': + 'radial-gradient(circle, #e74c3c, #3498db)', + 'radial/circle_at_top_right': + 'radial-gradient(circle at top right, #e74c3c, #3498db)', + 'radial/at_bottom_left': + 'radial-gradient(at bottom left, #e74c3c, #3498db)', + 'repeating-radial': + 'repeating-radial-gradient(circle, #e74c3c 0%, #3498db 20%)', + }; + + for (final entry in radialCases.entries) { + testGoldens( + entry.key, + (tester) async { + final g = _parseAs(entry.value); + await tester.pumpWidgetBuilder( + _GradientBox(gradient: _toFlutterGradient(g), size: size), + wrapper: materialAppWrapper(theme: ThemeData.light()), + surfaceSize: size, + ); + await screenMatchesGolden(tester, entry.key); + }, + skip: goldenSkip != null, + ); + } + + // ── conic-gradient ─────────────────────────────────────────────── + const conicCases = { + 'conic/default': + 'conic-gradient(#e74c3c, #f39c12, #2ecc71, #3498db)', + 'conic/from_45deg': + 'conic-gradient(from 45deg, #e74c3c, #3498db)', + 'conic/at_top_left': + 'conic-gradient(at top left, #e74c3c, #3498db)', + 'conic/explicit_stops': + 'conic-gradient(#e74c3c 0deg, #f39c12 90deg, #2ecc71 180deg, #3498db 360deg)', + 'repeating-conic': + 'repeating-conic-gradient(from 0deg, #e74c3c 0%, #3498db 25%)', + }; + + for (final entry in conicCases.entries) { + testGoldens( + entry.key, + (tester) async { + final g = _parseAs(entry.value); + await tester.pumpWidgetBuilder( + _GradientBox(gradient: _toFlutterGradient(g), size: size), + wrapper: materialAppWrapper(theme: ThemeData.light()), + surfaceSize: size, + ); + await screenMatchesGolden(tester, entry.key); + }, + skip: goldenSkip != null, + ); + } + }, + skip: goldenSkip, + ); + }, + config: GoldenToolkitConfiguration( + fileNameFactory: (name) => '$kGoldenFilePrefix/background/gradient/$name.png', + ), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const _eps = 1e-4; + +/// Asserts that [a] and [b] are within [_eps] of each other in both axes. +void _expectAlignmentApprox(Alignment a, Alignment b) { + expect(a.x, closeTo(b.x, _eps), reason: 'x: $a vs $b'); + expect(a.y, closeTo(b.y, _eps), reason: 'y: $a vs $b'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Golden helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Converts a parsed [CssGradient] to the corresponding Flutter [Gradient]. +/// +/// Stop positions: +/// - If **all** stops carry explicit positions they are forwarded as-is. +/// - Otherwise, Flutter automatically spaces them evenly (`stops: null`). +Gradient _toFlutterGradient(CssGradient g) { + final colors = g.stops.map((s) => s.color).toList(growable: false); + final allHavePositions = g.stops.every((s) => s.position != null); + final rawStops = allHavePositions + ? g.stops.map((s) => s.position!).toList(growable: false) + : null; + + // Flutter's TileMode.repeated tiles outside the [begin,end] vector but not + // within it, so it has no effect when the vector spans the whole box. + // Manually expand the stop pattern to cover [0,1] instead. + final (effectiveColors, effectiveStops) = + g.repeating ? _expandRepeatingStops(colors, rawStops) : (colors, rawStops); + + return switch (g) { + CssLinearGradient(:final begin, :final end) => LinearGradient( + begin: begin, + end: end, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + ), + CssRadialGradient(:final center, :final isCircle) => RadialGradient( + center: center, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + transform: _RadialFarthestCornerTransform( + center, + isCircle: isCircle, + ), + ), + CssConicGradient(:final center, :final startAngle) => SweepGradient( + center: center, + startAngle: 0, + endAngle: 2 * pi, + colors: effectiveColors, + stops: effectiveStops, + tileMode: TileMode.clamp, + transform: _ConicAlignTransform(center, startAngle), + ), + }; +} + +/// Expands a repeating gradient's stop pattern to cover [0, 1]. +/// +/// Flutter's [TileMode.repeated] tiles outside the gradient vector but not +/// within [0, 1], so it produces no visible repetition when the vector spans +/// the full bounding box. Manually repeating the tile fixes this. +(List, List?) _expandRepeatingStops( + List colors, + List? stops, +) { + if (stops == null || stops.length < 2) return (colors, stops); + final period = stops.last - stops.first; + if (period <= 0 || stops.last >= 1.0) return (colors, stops); + + final outColors = []; + final outStops = []; + + var offset = 0.0; + outer: + while (true) { + for (var i = 0; i < stops.length; i++) { + final s = offset + (stops[i] - stops.first); + if (s >= 1.0) { + outStops.add(1.0); + outColors.add(colors[i]); + break outer; + } + outStops.add(s); + outColors.add(colors[i]); + } + offset += period; + } + + return (outColors, outStops); +} + +/// Applies CSS [farthest-corner] sizing to a Flutter [RadialGradient]. +/// +/// Flutter's [RadialGradient] defaults to `radius: 0.5` (half the shortest +/// side). CSS defaults to `farthest-corner`, meaning the last stop should +/// reach the furthest extent of the bounding box from the gradient centre. +/// +/// * **Circle** – radius = Euclidean distance from centre to farthest corner. +/// * **Ellipse** – each axis scaled independently to the farthest distance +/// in that dimension (CSS spec §4.2.1). +class _RadialFarthestCornerTransform implements GradientTransform { + final Alignment center; + final bool isCircle; + + const _RadialFarthestCornerTransform( + this.center, { + required this.isCircle, + }); + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + // Flutter's RadialGradient pixel radius = shortestSide × 0.5. + final baseR = bounds.shortestSide / 2.0; + if (baseR == 0) return null; + + // Gradient centre in absolute pixel coordinates. + final cx = bounds.left + bounds.width * (center.x + 1) / 2; + final cy = bounds.top + bounds.height * (center.y + 1) / 2; + + final maxDx = max(cx - bounds.left, bounds.right - cx); + final maxDy = max(cy - bounds.top, bounds.bottom - cy); + + final double scaleX; + final double scaleY; + if (isCircle) { + // Euclidean distance to the farthest corner. + final farDist = sqrt(maxDx * maxDx + maxDy * maxDy); + if (farDist == 0) return null; + scaleX = farDist / baseR; + scaleY = scaleX; + } else { + if (maxDx == 0 || maxDy == 0) return null; + scaleX = maxDx / baseR; + scaleY = maxDy / baseR; + } + + // M maps gradient space → screen space: + // screen = S(scaleX, scaleY) · (gradient − centre) + centre + return Matrix4.identity() + ..translate(cx, cy) + ..scale(scaleX, scaleY, 1.0) + ..translate(-cx, -cy); + } +} + +class _ConicAlignTransform implements GradientTransform { + final Alignment center; + final double startAngle; + + const _ConicAlignTransform(this.center, this.startAngle); + + @override + Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { + final cx = bounds.left + bounds.width * (center.x + 1) / 2; + final cy = bounds.top + bounds.height * (center.y + 1) / 2; + final angle = startAngle - pi / 2; + return Matrix4.identity() + ..translate(cx, cy) + ..rotateZ(angle) + ..translate(-cx, -cy); + } +} + +/// A simple fixed-size box filled with [gradient], used as the golden subject. +class _GradientBox extends StatelessWidget { + final Gradient gradient; + final Size size; + + const _GradientBox({required this.gradient, required this.size}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size.width, + height: size.height, + child: DecoratedBox( + decoration: BoxDecoration(gradient: gradient), + ), + ); + } +}