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;
}