gradient property
Gradient?
get
gradient
Implementation
Gradient? get gradient {
if (_gradient != null) return _gradient;
List<Color> colors = [];
List<double> stops = [];
int start = 0;
for (CSSFunctionalNotation method in functions) {
switch (method.name) {
case 'linear-gradient':
case 'repeating-linear-gradient':
double? linearAngle;
Alignment begin = Alignment.topCenter;
Alignment end = Alignment.bottomCenter;
String arg0 = method.args[0].trim();
double? gradientLength = gradientLengthHint;
if (DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] parse ${method.name}: rawArgs=${method.args}');
}
if (arg0.startsWith('to ')) {
List<String> parts = arg0.split(splitRegExp);
if (parts.length >= 2) {
switch (parts[1]) {
case LEFT:
if (parts.length == 3) {
if (parts[2] == TOP) {
begin = Alignment.bottomRight;
end = Alignment.topLeft;
} else if (parts[2] == BOTTOM) {
begin = Alignment.topRight;
end = Alignment.bottomLeft;
}
} else {
begin = Alignment.centerRight;
end = Alignment.centerLeft;
}
gradientLength = renderStyle.paddingBoxWidth;
break;
case TOP:
if (parts.length == 3) {
if (parts[2] == LEFT) {
begin = Alignment.bottomRight;
end = Alignment.topLeft;
} else if (parts[2] == RIGHT) {
begin = Alignment.bottomLeft;
end = Alignment.topRight;
}
} else {
begin = Alignment.bottomCenter;
end = Alignment.topCenter;
}
gradientLength = renderStyle.paddingBoxHeight;
break;
case RIGHT:
if (parts.length == 3) {
if (parts[2] == TOP) {
begin = Alignment.bottomLeft;
end = Alignment.topRight;
} else if (parts[2] == BOTTOM) {
begin = Alignment.topLeft;
end = Alignment.bottomRight;
}
} else {
begin = Alignment.centerLeft;
end = Alignment.centerRight;
}
gradientLength = renderStyle.paddingBoxWidth;
break;
case BOTTOM:
if (parts.length == 3) {
if (parts[2] == LEFT) {
begin = Alignment.topRight;
end = Alignment.bottomLeft;
} else if (parts[2] == RIGHT) {
begin = Alignment.topLeft;
end = Alignment.bottomRight;
}
} else {
begin = Alignment.topCenter;
end = Alignment.bottomCenter;
}
gradientLength = renderStyle.paddingBoxHeight;
break;
}
}
linearAngle = null;
start = 1;
} else if (CSSAngle.isAngle(arg0)) {
linearAngle = CSSAngle.parseAngle(arg0);
start = 1;
}
// If no explicit gradientLength was resolved from painter hint or direction keywords,
// try to derive it from background-size so px color-stops normalize
// against the actual tile dimension instead of the element box.
if (gradientLength == null) {
final CSSBackgroundSize bs = renderStyle.backgroundSize;
double? bsW = (bs.width != null && !bs.width!.isAuto) ? bs.width!.computedValue : null;
double? bsH = (bs.height != null && !bs.height!.isAuto) ? bs.height!.computedValue : null;
// Fallbacks when background-size is auto or layout not finalized yet.
final double fbW = renderStyle.paddingBoxWidth ??
(renderStyle.target.ownerDocument.viewport?.viewportSize.width ?? 0.0);
final double fbH = renderStyle.paddingBoxHeight ??
(renderStyle.target.ownerDocument.viewport?.viewportSize.height ?? 0.0);
if (linearAngle != null) {
// For angle-based gradients, approximate the gradient line length
// using the tile size and the same projection used at shader time.
final double sin = math.sin(linearAngle);
final double cos = math.cos(linearAngle);
final double w = bsW ?? fbW;
final double h = bsH ?? fbH;
gradientLength = (sin.abs() * w) + (cos.abs() * h);
} else {
// No angle provided: infer axis from begin/end and use the
// background-size along that axis when available, else fall back to box/viewport.
bool isVertical = (begin == Alignment.topCenter || begin == Alignment.bottomCenter) &&
(end == Alignment.topCenter || end == Alignment.bottomCenter);
bool isHorizontal = (begin == Alignment.centerLeft || begin == Alignment.centerRight) &&
(end == Alignment.centerLeft || end == Alignment.centerRight);
if (isVertical) {
gradientLength = bsH ?? fbH;
} else if (isHorizontal) {
gradientLength = bsW ?? fbW;
} else {
// Diagonal without an explicit angle; use diagonal of available size.
final double w = bsW ?? fbW;
final double h = bsH ?? fbH;
gradientLength = math.sqrt(w * w + h * h);
}
}
if (DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] linear-gradient choose gradientLength = '
'${gradientLength?.toStringAsFixed(2)} (bg-size: w=${bs.width?.computedValue.toStringAsFixed(2) ?? 'auto'}, '
'h=${bs.height?.computedValue.toStringAsFixed(2) ?? 'auto'}; fb: w=${fbW.toStringAsFixed(2)}, h=${fbH.toStringAsFixed(2)})');
}
}
if (gradientLengthHint != null && DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] linear-gradient using painter length hint = ${gradientLengthHint!.toStringAsFixed(2)}');
}
_applyColorAndStops(start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE, gradientLength);
double? repeatPeriodPx;
// For repeating-linear-gradient, normalize the stop range to one cycle [0..1]
// so Flutter's TileMode.repeated repeats the intended segment length.
if (method.name == 'repeating-linear-gradient' && stops.isNotEmpty) {
final double first = stops.first;
final double last = stops.last;
double range = last - first;
if (DebugFlags.enableBackgroundLogs) {
final double? gl = gradientLength;
final double periodPx = (gl != null && range > 0) ? (range * gl) : -1;
renderingLogger.finer('[Background] repeating-linear normalize: first=${first.toStringAsFixed(4)} last=${last.toStringAsFixed(4)} '
'range=${range.toStringAsFixed(4)} periodPx=${periodPx >= 0 ? periodPx.toStringAsFixed(2) : '<unknown>'}');
}
if (range <= 0) {
// Guard: invalid or zero-length cycle; fall back to full [0..1]
// Keep stops as-is to avoid division by zero.
} else {
// Capture period in device pixels for shader scaling.
if (gradientLength != null) {
repeatPeriodPx = range * gradientLength!;
if (DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] repeating-linear periodPx=${repeatPeriodPx!.toStringAsFixed(2)}');
}
}
for (int i = 0; i < stops.length; i++) {
stops[i] = ((stops[i] - first) / range).clamp(0.0, 1.0);
}
if (DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] repeating-linear normalized stops=${stops.map((s)=>s.toStringAsFixed(4)).toList()}');
}
}
}
if (DebugFlags.enableBackgroundLogs) {
final cs = colors
.map((c) => 'rgba(${c.red},${c.green},${c.blue},${c.opacity.toStringAsFixed(3)})')
.toList();
final st = stops.map((s) => s.toStringAsFixed(4)).toList();
final dir = linearAngle != null
? 'angle=' + (linearAngle * 180 / math.pi).toStringAsFixed(1) + 'deg'
: 'begin=$begin end=$end';
final len = gradientLength?.toStringAsFixed(2) ?? '<none>';
renderingLogger.finer('[Background] ${method.name} colors=$cs stops=$st $dir gradientLength=$len');
}
if (colors.length >= 2) {
_gradient = CSSLinearGradient(
begin: begin,
end: end,
angle: linearAngle,
repeatPeriod: repeatPeriodPx,
colors: colors,
stops: stops,
tileMode: method.name == 'linear-gradient' ? TileMode.clamp : TileMode.repeated);
return _gradient;
}
break;
// Radial gradients: support "[<shape> || <size>] [at <position>]" prelude.
// Current implementation treats shape as circle and size as farthest-corner by default,
// but we do parse the optional "at <position>" correctly, including single-value forms
// like "at 100%" meaning x=100%, y=center.
case 'radial-gradient':
case 'repeating-radial-gradient':
double? atX = 0.5;
double? atY = 0.5;
double radius = 0.5; // normalized factor; 0.5 -> farthest-corner in CSSRadialGradient
bool isEllipse = false;
if (method.args.isNotEmpty) {
final String prelude = method.args[0].trim();
if (prelude.isNotEmpty) {
// Split by whitespace while collapsing multiple spaces.
final List<String> tokens = prelude.split(splitRegExp).where((s) => s.isNotEmpty).toList();
// Detect ellipse/circle keywords
isEllipse = tokens.contains('ellipse');
// Detect and parse "at <position>" anywhere in prelude.
final int atIndex = tokens.indexOf('at');
if (atIndex != -1) {
// Position tokens follow 'at'. They can be 1 or 2 tokens.
final List<String> pos = tokens.sublist(atIndex + 1);
if (pos.isNotEmpty) {
double parseX(String s) {
if (s == LEFT) return 0.0;
if (s == CENTER) return 0.5;
if (s == RIGHT) return 1.0;
if (CSSPercentage.isPercentage(s)) return CSSPercentage.parsePercentage(s)!;
return 0.5;
}
double parseY(String s) {
if (s == TOP) return 0.0;
if (s == CENTER) return 0.5;
if (s == BOTTOM) return 1.0;
if (CSSPercentage.isPercentage(s)) return CSSPercentage.parsePercentage(s)!;
return 0.5;
}
if (pos.length == 1) {
// Single-value position: percentage or a keyword on one axis.
final String v = pos.first;
if (v == TOP || v == BOTTOM) {
atY = parseY(v);
atX = 0.5;
} else {
atX = parseX(v);
atY = 0.5;
}
} else {
// Two-value position: x y.
atX = parseX(pos[0]);
atY = parseY(pos[1]);
}
}
}
// Only treat arg[0] as a radial prelude when it does NOT start with a color token.
// Previously, the presence of a percentage (e.g., "black 50%") caused arg[0]
// to be misclassified as a prelude and skipped. Guard against that by checking
// whether the first token looks like a color (named/hex/rgb[a]/hsl[a]/var()).
final String firstToken = tokens.isNotEmpty ? tokens.first : '';
final bool firstLooksLikeColor = CSSColor.isColor(firstToken) || firstToken.startsWith('var(');
// Recognize common prelude markers when the first token is not a color.
final bool hasPrelude = !firstLooksLikeColor && (
tokens.contains('circle') ||
tokens.contains('ellipse') ||
tokens.contains('closest-side') ||
tokens.contains('closest-corner') ||
tokens.contains('farthest-side') ||
tokens.contains('farthest-corner') ||
atIndex != -1 ||
// Allow explicit numeric size in prelude only if arg[0] doesn't start with a color.
tokens.any((t) => CSSPercentage.isPercentage(t) || CSSLength.isLength(t))
);
if (hasPrelude) start = 1;
}
}
// Normalize px stops using painter-provided length hint when available.
_applyColorAndStops(start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE, gradientLengthHint);
// Ensure non-decreasing stops per CSS Images spec when explicit positions are out of order.
if (stops.isNotEmpty) {
double last = stops[0].clamp(0.0, 1.0);
stops[0] = last;
for (int i = 1; i < stops.length; i++) {
double s = stops[i].clamp(0.0, 1.0);
if (s < last) s = last;
stops[i] = s;
last = s;
}
}
// For repeating-radial-gradient, normalize to one cycle [0..1] for tile repetition.
double? repeatPeriodPx;
if (method.name == 'repeating-radial-gradient' && stops.isNotEmpty) {
final double first = stops.first;
final double last = stops.last;
double range = last - first;
if (DebugFlags.enableBackgroundLogs) {
final double periodPx = (gradientLengthHint != null && range > 0) ? (range * gradientLengthHint!) : -1;
renderingLogger.finer('[Background] repeating-radial normalize: first=${first.toStringAsFixed(4)} last=${last.toStringAsFixed(4)} '
'range=${range.toStringAsFixed(4)} periodPx=${periodPx >= 0 ? periodPx.toStringAsFixed(2) : '<unknown>'}');
}
if (range > 0) {
if (gradientLengthHint != null) {
repeatPeriodPx = range * gradientLengthHint!;
}
for (int i = 0; i < stops.length; i++) {
stops[i] = ((stops[i] - first) / range).clamp(0.0, 1.0);
}
if (DebugFlags.enableBackgroundLogs) {
renderingLogger.finer('[Background] repeating-radial normalized stops=${stops.map((s)=>s.toStringAsFixed(4)).toList()}');
}
}
}
if (DebugFlags.enableBackgroundLogs) {
final cs = colors
.map((c) => 'rgba(${c.red},${c.green},${c.blue},${c.opacity.toStringAsFixed(3)})')
.toList();
renderingLogger.finer('[Background] ${method.name} colors=$cs stops=${stops.map((s)=>s.toStringAsFixed(4)).toList()} '
'center=(${atX!.toStringAsFixed(3)},${atY!.toStringAsFixed(3)}) radius=$radius');
}
if (colors.length >= 2) {
// Apply an ellipse transform when requested.
final GradientTransform? xf = isEllipse ? CSSGradientEllipseTransform(atX!, atY!) : null;
_gradient = CSSRadialGradient(
center: FractionalOffset(atX!, atY!),
radius: radius,
colors: colors,
stops: stops,
tileMode: method.name == 'radial-gradient' ? TileMode.clamp : TileMode.repeated,
transform: xf,
repeatPeriod: repeatPeriodPx,
);
return _gradient;
}
break;
case 'conic-gradient':
double? from = 0.0;
double? atX = 0.5;
double? atY = 0.5;
if (method.args.isNotEmpty && (method.args[0].contains('from ') || method.args[0].contains('at '))) {
final List<String> tokens = method.args[0].trim().split(splitRegExp).where((s) => s.isNotEmpty).toList();
final int fromIndex = tokens.indexOf('from');
final int atIndex = tokens.indexOf('at');
if (fromIndex != -1 && fromIndex + 1 < tokens.length) {
from = CSSAngle.parseAngle(tokens[fromIndex + 1]);
}
if (atIndex != -1) {
double parseX(String s) {
if (s == LEFT) return 0.0;
if (s == CENTER) return 0.5;
if (s == RIGHT) return 1.0;
if (CSSPercentage.isPercentage(s)) return CSSPercentage.parsePercentage(s)!;
return 0.5;
}
double parseY(String s) {
if (s == TOP) return 0.0;
if (s == CENTER) return 0.5;
if (s == BOTTOM) return 1.0;
if (CSSPercentage.isPercentage(s)) return CSSPercentage.parsePercentage(s)!;
return 0.5;
}
final List<String> pos = tokens.sublist(atIndex + 1);
if (pos.isNotEmpty) {
if (pos.length == 1) {
final String v = pos.first;
if (v == TOP || v == BOTTOM) {
atY = parseY(v);
atX = 0.5;
} else {
atX = parseX(v);
atY = 0.5;
}
} else {
atX = parseX(pos[0]);
atY = parseY(pos[1]);
}
}
}
start = 1;
}
_applyColorAndStops(start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE);
if (DebugFlags.enableBackgroundLogs) {
final cs = colors
.map((c) => 'rgba(${c.red},${c.green},${c.blue},${c.opacity.toStringAsFixed(3)})')
.toList();
final fromDeg = ((from ?? 0) * 180 / math.pi).toStringAsFixed(1);
renderingLogger.finer('[Background] ${method.name} from=${fromDeg}deg colors=$cs stops=${stops.map((s)=>s.toStringAsFixed(4)).toList()}');
}
if (colors.length >= 2) {
_gradient = CSSConicGradient(
center: FractionalOffset(atX!, atY!),
colors: colors,
stops: stops,
transform: GradientRotation(-math.pi / 2 + from!));
return _gradient;
}
break;
}
}
return null;
}