Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/core/lib/src/core_widget_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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,
);

Expand Down
1 change: 1 addition & 0 deletions packages/core/lib/src/internal/core_parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
177 changes: 175 additions & 2 deletions packages/core/lib/src/internal/ops/style_background.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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,
);
},
Expand Down Expand Up @@ -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,
Expand All @@ -168,13 +177,15 @@ class _StyleBackgroundData {
_StyleBackgroundData copyWith({
AlignmentGeometry? alignment,
CssColor? color,
CssGradient? gradient,
String? imageUrl,
ImageRepeat? repeat,
BoxFit? size,
}) =>
_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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 <angle>` 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<Color>, List<double>?) _expandRepeatingStops(
List<Color> colors,
List<double>? 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 = <Color>[];
final outStops = <double>[];

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 <angle>`
/// 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);
}
}
Loading
Loading