diff --git a/bridge/foundation/ui_command_ring_buffer.cc b/bridge/foundation/ui_command_ring_buffer.cc index 5b9715ad84..9fcdf1ec82 100644 --- a/bridge/foundation/ui_command_ring_buffer.cc +++ b/bridge/foundation/ui_command_ring_buffer.cc @@ -9,6 +9,7 @@ #include "ui_command_ring_buffer.h" #include #include +#include #include "bindings/qjs/native_string_utils.h" #include "core/executing_context.h" #include "foundation/logging.h" @@ -201,9 +202,6 @@ void UICommandPackage::AddCommand(const UICommandItem& item) { } bool UICommandPackage::ShouldSplit(UICommand next_command) const { - // Split package based on command type strategy - UICommandKind next_kind = GetKindFromUICommand(next_command); - // Always split on certain commands switch (next_command) { case UICommand::kStartRecordingCommand: @@ -214,13 +212,9 @@ bool UICommandPackage::ShouldSplit(UICommand next_command) const { break; } - // Split if mixing incompatible command types - if ((kind_mask & static_cast(UICommandKind::kNodeCreation)) && - (static_cast(next_kind) & static_cast(UICommandKind::kNodeMutation))) { - return true; - } - - // Split if package is getting too large + // Keep alternating create/insert DOM command streams together. Splitting on + // every node mutation turns large mounts into thousands of tiny packages and + // overwhelms the package ring buffer before Dart can drain it. if (commands.size() >= 1000) { return true; } @@ -330,8 +324,17 @@ void UICommandPackageRingBuffer::PushPackage(std::unique_ptr p if (next_write_idx == read_index_.load(std::memory_order_acquire)) { // Buffer full, use overflow std::lock_guard lock(overflow_mutex_); - WEBF_LOG(WARN) << "[UICommandPackageRingBuffer] PUSH PACKAGE TO OVERFLOW " << package.get(); overflow_packages_.push_back(std::move(package)); + + uint64_t overflow_count = overflow_push_count_.fetch_add(1, std::memory_order_relaxed) + 1; + if (overflow_count == 1 || overflow_count % 64 == 0) { + size_t ring_count = (write_idx - read_index_.load(std::memory_order_acquire)) & capacity_mask_; + const auto* newest_package = overflow_packages_.back().get(); + size_t newest_size = newest_package ? newest_package->commands.size() : 0; + WEBF_LOG(WARN) << "[UICommandPackageRingBuffer] overflow packages=" << overflow_packages_.size() + << " total_overflows=" << overflow_count << " ring_backlog=" << ring_count + << " newest_package_commands=" << newest_size << " capacity=" << capacity_; + } return; } @@ -344,21 +347,25 @@ void UICommandPackageRingBuffer::PushPackage(std::unique_ptr p } std::unique_ptr UICommandPackageRingBuffer::PopPackage() { - // First check overflow + size_t read_idx = read_index_.load(std::memory_order_relaxed); + bool ring_ready = packages_[read_idx].ready.load(std::memory_order_acquire); + uint64_t ring_sequence = ring_ready && packages_[read_idx].package + ? packages_[read_idx].package->sequence_number + : std::numeric_limits::max(); + { std::lock_guard lock(overflow_mutex_); if (!overflow_packages_.empty()) { - auto package = std::move(overflow_packages_.front()); - WEBF_LOG(VERBOSE) << " POP OVERFLOW PACKAGE " << package.get(); - overflow_packages_.erase(overflow_packages_.begin()); - return package; + uint64_t overflow_sequence = overflow_packages_.front()->sequence_number; + if (!ring_ready || overflow_sequence < ring_sequence) { + auto package = std::move(overflow_packages_.front()); + overflow_packages_.erase(overflow_packages_.begin()); + return package; + } } } - // Then check ring buffer - size_t read_idx = read_index_.load(std::memory_order_relaxed); - - if (!packages_[read_idx].ready.load(std::memory_order_acquire)) { + if (!ring_ready) { return nullptr; // No package available } @@ -432,6 +439,8 @@ void UICommandPackageRingBuffer::Clear() { std::lock_guard overflow_lock(overflow_mutex_); overflow_packages_.clear(); } + + overflow_push_count_.store(0, std::memory_order_relaxed); } bool UICommandPackageRingBuffer::ShouldCreateNewPackage(UICommand command) const { diff --git a/bridge/foundation/ui_command_ring_buffer.h b/bridge/foundation/ui_command_ring_buffer.h index 03e13bd713..05eac2fa2f 100644 --- a/bridge/foundation/ui_command_ring_buffer.h +++ b/bridge/foundation/ui_command_ring_buffer.h @@ -150,6 +150,7 @@ class UICommandPackageRingBuffer { // Overflow handling std::mutex overflow_mutex_; std::vector> overflow_packages_; + std::atomic overflow_push_count_{0}; // Helper methods bool ShouldCreateNewPackage(UICommand command) const; diff --git a/bridge/foundation/ui_command_ring_buffer_test.cc b/bridge/foundation/ui_command_ring_buffer_test.cc index 0f5d729848..e769d1975a 100644 --- a/bridge/foundation/ui_command_ring_buffer_test.cc +++ b/bridge/foundation/ui_command_ring_buffer_test.cc @@ -176,8 +176,8 @@ TEST_F(UICommandRingBufferTest, CommandBatchingStrategy) { package.AddCommand(UICommandItem(static_cast(UICommand::kCreateTextNode), nullptr, nullptr, nullptr)); EXPECT_FALSE(package.ShouldSplit(UICommand::kCreateComment)); - // Test split on node mutation after creation - EXPECT_TRUE(package.ShouldSplit(UICommand::kInsertAdjacentNode)); + // Keep the common create/insert sequence in the same package. + EXPECT_FALSE(package.ShouldSplit(UICommand::kInsertAdjacentNode)); // Test split on special commands package.Clear(); @@ -187,6 +187,24 @@ TEST_F(UICommandRingBufferTest, CommandBatchingStrategy) { EXPECT_TRUE(package.ShouldSplit(UICommand::kAsyncCaller)); } +TEST_F(UICommandRingBufferTest, PackageOverflowPreservesSequenceOrder) { + UICommandPackageRingBuffer buffer(nullptr, 4); + + for (intptr_t i = 1; i <= 5; ++i) { + buffer.AddCommand(UICommand::kCreateElement, nullptr, reinterpret_cast(i), nullptr); + buffer.FlushCurrentPackage(); + } + + for (intptr_t expected = 1; expected <= 5; ++expected) { + auto package = buffer.PopPackage(); + ASSERT_NE(package, nullptr); + ASSERT_EQ(package->commands.size(), 1u); + EXPECT_EQ(package->commands[0].nativePtr, expected); + } + + EXPECT_EQ(buffer.PopPackage(), nullptr); +} + // Test SharedUICommandRingBuffer integration TEST_F(UICommandRingBufferTest, SharedUICommandIntegration) { // Note: This test is disabled as it requires a proper ExecutingContext @@ -234,4 +252,4 @@ TEST_F(UICommandRingBufferTest, StressTestHighVolume) { EXPECT_TRUE(buffer.Empty()); } -} // namespace webf \ No newline at end of file +} // namespace webf diff --git a/webf/lib/src/css/background.dart b/webf/lib/src/css/background.dart index 716d8109d7..439cd4e22a 100644 --- a/webf/lib/src/css/background.dart +++ b/webf/lib/src/css/background.dart @@ -19,7 +19,8 @@ import 'package:webf/html.dart'; import 'package:webf/css.dart'; import 'package:webf/launcher.dart'; -int _colorByte(double channel) => (channel * 255.0).round().clamp(0, 255).toInt(); +int _colorByte(double channel) => + (channel * 255.0).round().clamp(0, 255).toInt(); String _rgbaString(Color c) => 'rgba(${_colorByte(c.r)},${_colorByte(c.g)},${_colorByte(c.b)},${c.a.toStringAsFixed(3)})'; @@ -148,8 +149,10 @@ enum CSSBackgroundImageType { /// The [CSSBackgroundMixin] mixin used to handle background shorthand and compute /// to single value of background. mixin CSSBackgroundMixin on RenderStyle { - static final CSSBackgroundPosition defaultBackgroundPosition = CSSBackgroundPosition(percentage: -1); - static final CSSBackgroundSize defaultBackgroundSize = CSSBackgroundSize(fit: BoxFit.none); + static final CSSBackgroundPosition defaultBackgroundPosition = + CSSBackgroundPosition(percentage: -1); + static final CSSBackgroundSize defaultBackgroundSize = + CSSBackgroundSize(fit: BoxFit.none); /// Background-clip @override @@ -162,8 +165,8 @@ mixin CSSBackgroundMixin on RenderStyle { _backgroundClip = value; // `background-clip:text` affects how glyphs are built/painted (paragraph cache), // so toggling to/from it must rebuild text layout instead of paint-only. - final bool togglesClipText = - oldValue == CSSBackgroundBoundary.text || value == CSSBackgroundBoundary.text; + final bool togglesClipText = oldValue == CSSBackgroundBoundary.text || + value == CSSBackgroundBoundary.text; if (togglesClipText) { markNeedsLayout(); } else { @@ -207,13 +210,16 @@ mixin CSSBackgroundMixin on RenderStyle { } _backgroundImage = value; + _backgroundImage?.warmUpImage(); if (DebugFlags.enableBackgroundLogs) { try { final el = target; final id = (el.id != null && el.id!.isNotEmpty) ? '#${el.id}' : ''; final cls = (el.className.isNotEmpty) ? '.${el.className}' : ''; - final names = value?.functions.map((f) => f.name).toList() ?? const []; - renderingLogger.finer('[Background] set BACKGROUND_IMAGE on <${el.tagName.toLowerCase()}$id$cls> -> $names'); + final names = + value?.functions.map((f) => f.name).toList() ?? const []; + renderingLogger.finer( + '[Background] set BACKGROUND_IMAGE on <${el.tagName.toLowerCase()}$id$cls> -> $names'); } catch (_) {} } markNeedsPaint(); @@ -222,7 +228,8 @@ mixin CSSBackgroundMixin on RenderStyle { /// Background-position-x @override - CSSBackgroundPosition get backgroundPositionX => _backgroundPositionX ?? defaultBackgroundPosition; + CSSBackgroundPosition get backgroundPositionX => + _backgroundPositionX ?? defaultBackgroundPosition; CSSBackgroundPosition? _backgroundPositionX; set backgroundPositionX(CSSBackgroundPosition? value) { @@ -234,7 +241,8 @@ mixin CSSBackgroundMixin on RenderStyle { /// Background-position-y @override - CSSBackgroundPosition get backgroundPositionY => _backgroundPositionY ?? defaultBackgroundPosition; + CSSBackgroundPosition get backgroundPositionY => + _backgroundPositionY ?? defaultBackgroundPosition; CSSBackgroundPosition? _backgroundPositionY; set backgroundPositionY(CSSBackgroundPosition? value) { @@ -246,7 +254,8 @@ mixin CSSBackgroundMixin on RenderStyle { /// Background-size @override - CSSBackgroundSize get backgroundSize => _backgroundSize ?? defaultBackgroundSize; + CSSBackgroundSize get backgroundSize => + _backgroundSize ?? defaultBackgroundSize; CSSBackgroundSize? _backgroundSize; set backgroundSize(CSSBackgroundSize? value) { @@ -258,7 +267,8 @@ mixin CSSBackgroundMixin on RenderStyle { /// Background-attachment @override - CSSBackgroundAttachmentType? get backgroundAttachment => _backgroundAttachment; + CSSBackgroundAttachmentType? get backgroundAttachment => + _backgroundAttachment; CSSBackgroundAttachmentType? _backgroundAttachment; set backgroundAttachment(CSSBackgroundAttachmentType? value) { @@ -269,7 +279,8 @@ mixin CSSBackgroundMixin on RenderStyle { final el = target; final id = (el.id != null && el.id!.isNotEmpty) ? '#${el.id}' : ''; final cls = (el.className.isNotEmpty) ? '.${el.className}' : ''; - renderingLogger.finer('[Background] set BACKGROUND_ATTACHMENT on <${el.tagName.toLowerCase()}$id$cls> -> ' + renderingLogger.finer( + '[Background] set BACKGROUND_ATTACHMENT on <${el.tagName.toLowerCase()}$id$cls> -> ' '${_backgroundAttachment?.cssText() ?? 'null'}'); } catch (_) {} } @@ -279,7 +290,8 @@ mixin CSSBackgroundMixin on RenderStyle { /// Background-repeat @override - CSSBackgroundRepeatType get backgroundRepeat => _backgroundRepeat ?? CSSBackgroundRepeatType.repeat; + CSSBackgroundRepeatType get backgroundRepeat => + _backgroundRepeat ?? CSSBackgroundRepeatType.repeat; CSSBackgroundRepeatType? _backgroundRepeat; set backgroundRepeat(CSSBackgroundRepeatType? value) { @@ -298,6 +310,8 @@ class CSSColorStop { } class CSSBackgroundImage { + static const double _defaultDevicePixelRatio = 2.0; + List functions; RenderStyle renderStyle; WebFController controller; @@ -305,16 +319,21 @@ class CSSBackgroundImage { // Optional per-layer length hint to normalize px stops, provided by painter. final double? gradientLengthHint; - CSSBackgroundImage(this.functions, this.renderStyle, this.controller, {this.baseHref, this.gradientLengthHint}); + CSSBackgroundImage(this.functions, this.renderStyle, this.controller, + {this.baseHref, this.gradientLengthHint}); ImageProvider? _image; + ImageStream? _prewarmImageStream; + ImageStreamListener? _prewarmImageListener; - static Future _obtainImage(Element element, Uri url) async { + static Future _obtainImage( + Element element, Uri url) async { ImageRequest request = ImageRequest.fromUri(url); // Increment count when request. element.ownerDocument.controller.view.document.incrementRequestCount(); - ImageLoadResponse data = await request.obtainImage(element.ownerDocument.controller); + ImageLoadResponse data = + await request.obtainImage(element.ownerDocument.controller); // Decrement count when response. element.ownerDocument.controller.view.document.decrementRequestCount(); @@ -347,16 +366,18 @@ class CSSBackgroundImage { final String base = baseHref ?? controller.url; uri = controller.uriParser!.resolve(Uri.parse(base), uri); - FlutterView ownerFlutterView = controller.ownerFlutterView!; + final FlutterView? ownerFlutterView = controller.ownerFlutterView; + final double devicePixelRatio = + ownerFlutterView?.devicePixelRatio ?? _defaultDevicePixelRatio; return _image = BoxFitImage( - boxFit: renderStyle.backgroundSize.fit, - url: uri, - contextId: controller.view.contextId, - targetElementPtr: renderStyle.target.pointer!, - loadImage: _obtainImage, - onImageLoad: _handleBitFitImageLoad, - devicePixelRatio: ownerFlutterView.devicePixelRatio); + boxFit: renderStyle.backgroundSize.fit, + url: uri, + contextId: controller.view.contextId, + targetElementPtr: renderStyle.target.pointer!, + loadImage: _obtainImage, + onImageLoad: _handleBitFitImageLoad, + devicePixelRatio: devicePixelRatio); } } } @@ -364,6 +385,33 @@ class CSSBackgroundImage { return null; } + void warmUpImage() { + if (_prewarmImageStream != null) return; + final ImageProvider? provider = image; + if (provider == null) return; + + final ImageStream stream = provider.resolve(ImageConfiguration.empty); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (ImageInfo imageInfo, bool synchronousCall) { + try { + renderStyle.markNeedsPaint(); + } catch (_) {} + _prewarmImageStream?.removeListener(listener); + _prewarmImageStream = null; + _prewarmImageListener = null; + }, + onError: (_, __) { + _prewarmImageStream?.removeListener(listener); + _prewarmImageStream = null; + _prewarmImageListener = null; + }, + ); + _prewarmImageStream = stream; + _prewarmImageListener = listener; + stream.addListener(listener); + } + Gradient? _gradient; Gradient? get gradient { if (_gradient != null) return _gradient; @@ -380,10 +428,12 @@ class CSSBackgroundImage { String arg0 = method.args[0].trim(); double? gradientLength = gradientLengthHint; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] parse ${method.name}: rawArgs=${method.args}'); + renderingLogger.finer( + '[Background] parse ${method.name}: rawArgs=${method.args}'); } if (arg0.startsWith('to ')) { - final List parts = splitByAsciiWhitespacePreservingGroups(arg0); + final List parts = + splitByAsciiWhitespacePreservingGroups(arg0); if (parts.length >= 2) { switch (parts[1]) { case LEFT: @@ -459,13 +509,21 @@ class CSSBackgroundImage { // 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; + 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); + (renderStyle + .target.ownerDocument.viewport?.viewportSize.width ?? + 0.0); final double fbH = renderStyle.paddingBoxHeight ?? - (renderStyle.target.ownerDocument.viewport?.viewportSize.height ?? 0.0); + (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. @@ -477,9 +535,11 @@ class CSSBackgroundImage { } 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) && + bool isVertical = (begin == Alignment.topCenter || + begin == Alignment.bottomCenter) && (end == Alignment.topCenter || end == Alignment.bottomCenter); - bool isHorizontal = (begin == Alignment.centerLeft || begin == Alignment.centerRight) && + bool isHorizontal = (begin == Alignment.centerLeft || + begin == Alignment.centerRight) && (end == Alignment.centerLeft || end == Alignment.centerRight); if (isVertical) { gradientLength = bsH ?? fbH; @@ -493,15 +553,18 @@ class CSSBackgroundImage { } } if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] linear-gradient choose gradientLength = ' + 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)}'); + renderingLogger.finer( + '[Background] linear-gradient using painter length hint = ${gradientLengthHint!.toStringAsFixed(2)}'); } - _applyColorAndStops(start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE, gradientLength); + _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. @@ -512,7 +575,8 @@ class CSSBackgroundImage { if (DebugFlags.enableBackgroundLogs) { final double gl = gradientLength; final double periodPx = (range > 0) ? (range * gl) : -1; - renderingLogger.finer('[Background] repeating-linear normalize: first=${first.toStringAsFixed(4)} last=${last.toStringAsFixed(4)} ' + renderingLogger.finer( + '[Background] repeating-linear normalize: first=${first.toStringAsFixed(4)} last=${last.toStringAsFixed(4)} ' 'range=${range.toStringAsFixed(4)} periodPx=${periodPx >= 0 ? periodPx.toStringAsFixed(2) : ''}'); } if (range <= 0) { @@ -522,26 +586,27 @@ class CSSBackgroundImage { // Capture period in device pixels for shader scaling. repeatPeriodPx = range * gradientLength; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] repeating-linear periodPx=${repeatPeriodPx.toStringAsFixed(2)}'); + 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()}'); + renderingLogger.finer( + '[Background] repeating-linear normalized stops=${stops.map((s) => s.toStringAsFixed(4)).toList()}'); } } } if (DebugFlags.enableBackgroundLogs) { - final cs = colors - .map(_rgbaString) - .toList(); + final cs = colors.map(_rgbaString).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); - renderingLogger.finer('[Background] ${method.name} colors=$cs stops=$st $dir gradientLength=$len'); + renderingLogger.finer( + '[Background] ${method.name} colors=$cs stops=$st $dir gradientLength=$len'); } if (colors.length >= 2) { _gradient = CSSLinearGradient( @@ -551,11 +616,14 @@ class CSSBackgroundImage { repeatPeriod: repeatPeriodPx, colors: colors, stops: stops, - tileMode: method.name == 'linear-gradient' ? TileMode.clamp : TileMode.repeated); + tileMode: method.name == 'linear-gradient' + ? TileMode.clamp + : TileMode.repeated); return _gradient; } if (DebugFlags.enableBackgroundLogs) { - renderingLogger.warning('[Background] ${method.name} dropped: need >=2 colors, got ${colors.length}. ' + renderingLogger.warning( + '[Background] ${method.name} dropped: need >=2 colors, got ${colors.length}. ' 'args=${method.args}'); } break; @@ -567,14 +635,16 @@ class CSSBackgroundImage { case 'repeating-radial-gradient': double? atX = 0.5; double? atY = 0.5; - double radius = 0.5; // normalized factor; 0.5 -> farthest-corner in CSSRadialGradient + 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 tokens = splitByAsciiWhitespacePreservingGroups(prelude); + final List tokens = + splitByAsciiWhitespacePreservingGroups(prelude); // Detect ellipse/circle keywords isEllipse = tokens.contains('ellipse'); @@ -588,14 +658,17 @@ class CSSBackgroundImage { 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)!; + 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)!; + if (CSSPercentage.isPercentage(s)) + return CSSPercentage.parsePercentage(s)!; return 0.5; } @@ -622,25 +695,28 @@ class CSSBackgroundImage { // 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('); + 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)) - ); + 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); + _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); @@ -659,8 +735,11 @@ class CSSBackgroundImage { 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)} ' + 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) : ''}'); } if (range > 0) { @@ -671,26 +750,29 @@ class CSSBackgroundImage { 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()}'); + renderingLogger.finer( + '[Background] repeating-radial normalized stops=${stops.map((s) => s.toStringAsFixed(4)).toList()}'); } } } if (DebugFlags.enableBackgroundLogs) { - final cs = colors - .map(_rgbaString) - .toList(); - renderingLogger.finer('[Background] ${method.name} colors=$cs stops=${stops.map((s)=>s.toStringAsFixed(4)).toList()} ' + final cs = colors.map(_rgbaString).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; + 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, + tileMode: method.name == 'radial-gradient' + ? TileMode.clamp + : TileMode.repeated, transform: xf, repeatPeriod: repeatPeriodPx, ); @@ -701,8 +783,11 @@ class CSSBackgroundImage { 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 tokens = splitByAsciiWhitespacePreservingGroups(method.args[0].trim()); + if (method.args.isNotEmpty && + (method.args[0].contains('from ') || + method.args[0].contains('at '))) { + final List tokens = + splitByAsciiWhitespacePreservingGroups(method.args[0].trim()); final int fromIndex = tokens.indexOf('from'); final int atIndex = tokens.indexOf('at'); if (fromIndex != -1 && fromIndex + 1 < tokens.length) { @@ -713,16 +798,20 @@ class CSSBackgroundImage { 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)!; + 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)!; + if (CSSPercentage.isPercentage(s)) + return CSSPercentage.parsePercentage(s)!; return 0.5; } + final List pos = tokens.sublist(atIndex + 1); if (pos.isNotEmpty) { if (pos.length == 1) { @@ -742,13 +831,13 @@ class CSSBackgroundImage { } start = 1; } - _applyColorAndStops(start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE); + _applyColorAndStops( + start, method.args, colors, stops, renderStyle, BACKGROUND_IMAGE); if (DebugFlags.enableBackgroundLogs) { - final cs = colors - .map(_rgbaString) - .toList(); + final cs = colors.map(_rgbaString).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()}'); + renderingLogger.finer( + '[Background] ${method.name} from=${fromDeg}deg colors=$cs stops=${stops.map((s) => s.toStringAsFixed(4)).toList()}'); } if (colors.length >= 2) { _gradient = CSSConicGradient( @@ -791,6 +880,12 @@ class CSSBackgroundImage { } void dispose() { + final ImageStreamListener? listener = _prewarmImageListener; + if (listener != null) { + _prewarmImageStream?.removeListener(listener); + } + _prewarmImageStream = null; + _prewarmImageListener = null; _image = null; } } @@ -852,7 +947,8 @@ class CSSBackgroundSize { CSSLengthValue? height; @override - String toString() => 'CSSBackgroundSize(fit: $fit, width: $width, height: $height)'; + String toString() => + 'CSSBackgroundSize(fit: $fit, width: $width, height: $height)'; String cssText() { if (fit == BoxFit.contain) { @@ -875,7 +971,10 @@ class CSSBackgroundSize { class CSSBackground { static bool isValidBackgroundRepeatValue(String value) { - return value == REPEAT || value == NO_REPEAT || value == REPEAT_X || value == REPEAT_Y; + return value == REPEAT || + value == NO_REPEAT || + value == REPEAT_X || + value == REPEAT_Y; } static bool isValidBackgroundSizeValue(String value) { @@ -930,7 +1029,8 @@ class CSSBackground { } } - static CSSBackgroundSize resolveBackgroundSize(String value, RenderStyle renderStyle, String propertyName) { + static CSSBackgroundSize resolveBackgroundSize( + String value, RenderStyle renderStyle, String propertyName) { switch (value) { case CONTAIN: return CSSBackgroundSize(fit: BoxFit.contain); @@ -939,17 +1039,21 @@ class CSSBackground { case AUTO: return CSSBackgroundSize(fit: BoxFit.none); default: - final List values = splitByAsciiWhitespacePreservingGroups(value); + final List values = + splitByAsciiWhitespacePreservingGroups(value); if (values.length == 1 && values[0].isNotEmpty) { - CSSLengthValue width = CSSLength.parseLength(values[0], renderStyle, propertyName, Axis.horizontal); + CSSLengthValue width = CSSLength.parseLength( + values[0], renderStyle, propertyName, Axis.horizontal); return CSSBackgroundSize( fit: BoxFit.none, width: width, ); } else if (values.length == 2) { - CSSLengthValue width = CSSLength.parseLength(values[0], renderStyle, propertyName, Axis.horizontal); - CSSLengthValue height = CSSLength.parseLength(values[1], renderStyle, propertyName, Axis.vertical); + CSSLengthValue width = CSSLength.parseLength( + values[0], renderStyle, propertyName, Axis.horizontal); + CSSLengthValue height = CSSLength.parseLength( + values[1], renderStyle, propertyName, Axis.vertical); // Value which is neither length/percentage/auto is considered to be invalid. return CSSBackgroundSize( fit: BoxFit.none, @@ -961,8 +1065,8 @@ class CSSBackground { } } - static resolveBackgroundImage( - String present, RenderStyle renderStyle, String property, WebFController controller, String? baseHref) { + static resolveBackgroundImage(String present, RenderStyle renderStyle, + String property, WebFController controller, String? baseHref) { // Expand CSS variables inside the background-image string so that // values like linear-gradient(..., var(--tw-gradient-stops)) work. // Tailwind sets --tw-gradient-stops to a comma-separated list @@ -977,11 +1081,14 @@ class CSSBackground { if (functions.isNotEmpty) { final List filtered = []; for (final f in functions) { - if (f.name == 'linear-gradient' || f.name == 'repeating-linear-gradient') { - final bool ok = _isValidLinearGradientArgs(f.args, renderStyle, property); + if (f.name == 'linear-gradient' || + f.name == 'repeating-linear-gradient') { + final bool ok = + _isValidLinearGradientArgs(f.args, renderStyle, property); if (!ok) { if (DebugFlags.enableBackgroundLogs) { - renderingLogger.warning('[Background] drop invalid ${f.name} args=${f.args} present="$present"'); + renderingLogger.warning( + '[Background] drop invalid ${f.name} args=${f.args} present="$present"'); } continue; } @@ -991,24 +1098,31 @@ class CSSBackground { functions = filtered; } if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] resolveBackgroundImage present="$present" expanded="$expanded" ' + renderingLogger.finer( + '[Background] resolveBackgroundImage present="$present" expanded="$expanded" ' 'fnCount=${functions.length}'); for (final f in functions) { if (f.name == 'url') { final raw = f.args.isNotEmpty ? f.args[0] : ''; - renderingLogger.finer('[Background] resolve image url raw=$raw baseHref=${baseHref ?? controller.url}'); + renderingLogger.finer( + '[Background] resolve image url raw=$raw baseHref=${baseHref ?? controller.url}'); } else if (f.name.contains('gradient')) { - renderingLogger.finer('[Background] resolve gradient ${f.name} args=${f.args.length} rawArgs=${f.args}'); + renderingLogger.finer( + '[Background] resolve gradient ${f.name} args=${f.args.length} rawArgs=${f.args}'); } } } - return CSSBackgroundImage(functions, renderStyle, controller, baseHref: baseHref); + return CSSBackgroundImage(functions, renderStyle, controller, + baseHref: baseHref); } static List _tokenizeGradientStop(String src) { if (src.isEmpty) return const []; // rgb[a]()/hsl[a]() may contain spaces; treat the whole function as one token. - if (src.startsWith('rgba(') || src.startsWith('rgb(') || src.startsWith('hsl(') || src.startsWith('hsla(')) { + if (src.startsWith('rgba(') || + src.startsWith('rgb(') || + src.startsWith('hsl(') || + src.startsWith('hsla(')) { final int indexOfEnd = src.lastIndexOf(')'); if (indexOfEnd != -1) { final List out = [src.substring(0, indexOfEnd + 1)]; @@ -1021,14 +1135,17 @@ class CSSBackground { return out; } } - return splitByAsciiWhitespacePreservingGroups(src).where((s) => s != ';').toList(); + return splitByAsciiWhitespacePreservingGroups(src) + .where((s) => s != ';') + .toList(); } static bool _looksLikeColorToken(String token) { return CSSColor.isColor(token); } - static bool _isValidLinearGradientArgs(List args, RenderStyle renderStyle, String propertyName) { + static bool _isValidLinearGradientArgs( + List args, RenderStyle renderStyle, String propertyName) { if (args.isEmpty) return false; int start = 0; final String arg0 = args[0].trim(); @@ -1042,7 +1159,9 @@ class CSSBackground { if (raw.isEmpty) return false; // A stop token may itself be a var() that expands to multiple tokens // (e.g., Tailwind: var(--tw-gradient-from) -> "rgb(...) var(--pos)"). - final String expandedStop = raw.contains('var(') ? _expandBackgroundVars(raw, renderStyle).trim() : raw; + final String expandedStop = raw.contains('var(') + ? _expandBackgroundVars(raw, renderStyle).trim() + : raw; if (expandedStop.isEmpty) return false; final List tokens = _tokenizeGradientStop(expandedStop); if (tokens.isEmpty) return false; @@ -1050,7 +1169,8 @@ class CSSBackground { // First token must resolve to a color. final String colorToken = _stripTrailingSemicolons(tokens.first.trim()); if (colorToken.isEmpty) return false; - final CSSColor? resolved = CSSColor.resolveColor(colorToken, renderStyle, propertyName); + final CSSColor? resolved = + CSSColor.resolveColor(colorToken, renderStyle, propertyName); if (resolved == null) return false; // Remaining tokens are optional stop positions (0-2). Any additional @@ -1061,14 +1181,17 @@ class CSSBackground { if (t0 == ';') continue; // var() may represent a position token (Tailwind uses var(--tw-gradient-*-position)). // Resolve var() here; if it resolves to a color, treat as missing-comma (invalid). - final String t = _stripTrailingSemicolons( - t0.contains('var(') ? _expandBackgroundVars(t0, renderStyle).trim() : t0.trim()); + final String t = _stripTrailingSemicolons(t0.contains('var(') + ? _expandBackgroundVars(t0, renderStyle).trim() + : t0.trim()); if (t.isEmpty) { // An empty var() is equivalent to no token; ignore. continue; } if (_looksLikeColorToken(t)) return false; - if (CSSPercentage.isPercentage(t) || CSSLength.isLength(t) || CSSAngle.isAngle(t)) { + if (CSSPercentage.isPercentage(t) || + CSSLength.isLength(t) || + CSSAngle.isAngle(t)) { positionCount++; continue; } @@ -1086,37 +1209,43 @@ class CSSBackground { static String _expandBackgroundVars(String input, RenderStyle renderStyle) { if (!input.contains('var(')) return input; String result = input; - final bool trace = DebugFlags.enableBackgroundLogs && input.contains('gradient'); + final bool trace = + DebugFlags.enableBackgroundLogs && input.contains('gradient'); // Limit to avoid infinite loops on pathological input. int guard = 0; while (result.contains('var(') && guard++ < 8) { final original = result; result = replaceCssVarFunctions(result, (String varString) { // Parse the var() expression to get identifier and (optional) fallback. - final CSSVariable? variable = CSSVariable.tryParse(renderStyle, varString); + final CSSVariable? variable = + CSSVariable.tryParse(renderStyle, varString); if (variable == null) { if (trace) { - renderingLogger.finer('[Background] var expand parse-failed var="$varString" input="$input"'); + renderingLogger.finer( + '[Background] var expand parse-failed var="$varString" input="$input"'); } return ''; } // Track dependency on this variable for backgroundImage recomputation. final depKey = '${BACKGROUND_IMAGE}_$input'; - final dynamic raw = renderStyle.getCSSVariable(variable.identifier, depKey); + final dynamic raw = + renderStyle.getCSSVariable(variable.identifier, depKey); if (raw == null || raw == INITIAL) { // Use fallback defined in var(--x, ) if provided. final fallback = variable.defaultValue; if (trace) { - renderingLogger.finer('[Background] var expand id=${variable.identifier} -> fallback="${fallback?.toString() ?? ''}"'); + renderingLogger.finer( + '[Background] var expand id=${variable.identifier} -> fallback="${fallback?.toString() ?? ''}"'); } return fallback?.toString() ?? ''; } final String rawText = raw.toString(); final String stripped = _stripTrailingSemicolons(rawText); if (trace) { - final suffix = (rawText != stripped) ? ' (stripped trailing ;)': ''; - renderingLogger.finer('[Background] var expand id=${variable.identifier} -> "$rawText"$suffix'); + final suffix = (rawText != stripped) ? ' (stripped trailing ;)' : ''; + renderingLogger.finer( + '[Background] var expand id=${variable.identifier} -> "$rawText"$suffix'); } return stripped; }); @@ -1167,19 +1296,20 @@ class CSSBackground { } } -void _applyColorAndStops( - int start, List args, List colors, List stops, RenderStyle renderStyle, String propertyName, +void _applyColorAndStops(int start, List args, List colors, + List stops, RenderStyle renderStyle, String propertyName, [double? gradientLength]) { // colors should more than one, otherwise invalid if (args.length - start - 1 > 0) { double grow = 1.0 / (args.length - start - 1); if (DebugFlags.enableBackgroundLogs) { final subset = args.sublist(start); - renderingLogger.finer('[Background] applyColorStops start=$start args=$subset gradientLength=${gradientLength?.toStringAsFixed(2) ?? ''}'); + renderingLogger.finer( + '[Background] applyColorStops start=$start args=$subset gradientLength=${gradientLength?.toStringAsFixed(2) ?? ''}'); } for (int i = start; i < args.length; i++) { - List colorGradients = - _parseColorAndStop(args[i].trim(), renderStyle, propertyName, (i - start) * grow, gradientLength); + List colorGradients = _parseColorAndStop(args[i].trim(), + renderStyle, propertyName, (i - start) * grow, gradientLength); for (var colorStop in colorGradients) { if (colorStop.color != null) { @@ -1191,7 +1321,8 @@ void _applyColorAndStops( } } -List _parseColorAndStop(String src, RenderStyle renderStyle, String propertyName, +List _parseColorAndStop( + String src, RenderStyle renderStyle, String propertyName, [double? defaultStop, double? gradientLength]) { final List colorGradients = []; final String original = src.trim(); @@ -1199,10 +1330,12 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin // A stop token may be a var() that expands to "color " (Tailwind), // so expand the whole stop string before tokenizing. if (expanded.contains('var(')) { - expanded = CSSBackground._expandBackgroundVars(expanded, renderStyle).trim(); + expanded = + CSSBackground._expandBackgroundVars(expanded, renderStyle).trim(); } if (DebugFlags.enableBackgroundLogs && expanded != original) { - renderingLogger.finer('[Background] stop expand src="$original" -> "$expanded"'); + renderingLogger + .finer('[Background] stop expand src="$original" -> "$expanded"'); } final List tokens = CSSBackground._tokenizeGradientStop(expanded); if (tokens.isEmpty) return colorGradients; @@ -1210,10 +1343,12 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin final String colorToken = _stripTrailingSemicolons(tokens.first.trim()); if (colorToken.isEmpty) return colorGradients; - final CSSColor? color = CSSColor.resolveColor(colorToken, renderStyle, propertyName); + final CSSColor? color = + CSSColor.resolveColor(colorToken, renderStyle, propertyName); if (color == null) { if (DebugFlags.enableBackgroundLogs) { - renderingLogger.warning('[Background] stop color parse failed: token="$colorToken" src="$expanded"'); + renderingLogger.warning( + '[Background] stop color parse failed: token="$colorToken" src="$expanded"'); } return colorGradients; } @@ -1238,7 +1373,8 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin if (stop < 0) stop = 0; parsedStops.add(stop); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] stop token="$t" unit=% -> ${stop.toStringAsFixed(4)} ' + renderingLogger.finer( + '[Background] stop token="$t" unit=% -> ${stop.toStringAsFixed(4)} ' 'color=${_rgbaString(color.value)} src="$src"'); } } @@ -1248,16 +1384,21 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin final double stop = radians / (math.pi * 2); parsedStops.add(stop); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] stop token="$t" unit=angle -> ${stop.toStringAsFixed(4)} ' + renderingLogger.finer( + '[Background] stop token="$t" unit=angle -> ${stop.toStringAsFixed(4)} ' 'color=${_rgbaString(color.value)} src="$src"'); } } } else if (CSSLength.isLength(t)) { if (gradientLength != null && gradientLength > 0) { - final double stop = CSSLength.parseLength(t, renderStyle, propertyName).computedValue / gradientLength; + final double stop = + CSSLength.parseLength(t, renderStyle, propertyName) + .computedValue / + gradientLength; parsedStops.add(stop); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] stop token="$t" unit=length -> ${stop.toStringAsFixed(4)} ' + renderingLogger.finer( + '[Background] stop token="$t" unit=length -> ${stop.toStringAsFixed(4)} ' '(gradLen=${gradientLength.toStringAsFixed(2)}) ' 'color=${_rgbaString(color.value)} src="$src"'); } @@ -1272,7 +1413,8 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin } } catch (e, st) { if (DebugFlags.enableBackgroundLogs) { - renderingLogger.warning('[Background] Failed to parse color stop "$src"', e, st); + renderingLogger.warning( + '[Background] Failed to parse color stop "$src"', e, st); } return const []; } @@ -1280,7 +1422,8 @@ List _parseColorAndStop(String src, RenderStyle renderStyle, Strin if (parsedStops.isEmpty) { colorGradients.add(CSSColorStop(color.value, defaultStop)); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] stop default -> ${defaultStop?.toStringAsFixed(4) ?? ''} ' + renderingLogger.finer( + '[Background] stop default -> ${defaultStop?.toStringAsFixed(4) ?? ''} ' 'color=${_rgbaString(color.value)} src="$src"'); } return colorGradients; diff --git a/webf/lib/src/css/cascade.dart b/webf/lib/src/css/cascade.dart index 9dc2b02493..7f3ef8b89f 100644 --- a/webf/lib/src/css/cascade.dart +++ b/webf/lib/src/css/cascade.dart @@ -3,6 +3,7 @@ * Licensed under GNU GPL with Enterprise exception. */ +import 'package:quiver/collection.dart'; import 'package:webf/css.dart'; /// Internal segment appended to layered rules that are directly inside a layer @@ -14,6 +15,57 @@ import 'package:webf/css.dart'; const String kWebFImplicitLayerSegment = '__webf__implicit_layer__'; const int _kImplicitLayerSiblingIndex = 1 << 30; +const int _kCascadeDeclarationCacheSize = 512; + +final LinkedLruHashMap<_CascadeCacheKey, CSSStyleDeclaration> + _cascadeDeclarationCache = LinkedLruHashMap<_CascadeCacheKey, + CSSStyleDeclaration>(maximumSize: _kCascadeDeclarationCacheSize); + +class _CascadeCacheKey { + final int version; + final List rules; + final int _hashCode; + + _CascadeCacheKey._(this.version, this.rules, this._hashCode); + + factory _CascadeCacheKey.lookup(int version, List rules) { + return _CascadeCacheKey._(version, rules, _computeHash(version, rules)); + } + + factory _CascadeCacheKey.stored(int version, List rules) { + return _CascadeCacheKey._(version, + List.of(rules, growable: false), _computeHash(version, rules)); + } + + static int _computeHash(int version, List rules) { + int hash = 0x1fffffff & (version + rules.length); + for (final CSSStyleRule rule in rules) { + hash = 0x1fffffff & (hash + identityHashCode(rule)); + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + hash ^= (hash >> 6); + } + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + hash ^= (hash >> 11); + hash = 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + return hash; + } + + @override + int get hashCode => _hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! _CascadeCacheKey) return false; + if (version != other.version || rules.length != other.rules.length) { + return false; + } + for (int i = 0; i < rules.length; i++) { + if (!identical(rules[i], other.rules[i])) return false; + } + return true; + } +} class CascadeLayerTree { final _LayerNode _root = _LayerNode(name: '', siblingIndex: -1); @@ -107,21 +159,57 @@ int compareStyleRulesForCascade(CSSStyleRule a, CSSStyleRule b, return a.position.compareTo(b.position); } -CSSStyleDeclaration cascadeMatchedStyleRules(List rules) { +CSSStyleDeclaration cascadeMatchedStyleRules(List rules, + {int? cacheVersion, bool copyResult = false}) { final declaration = CSSStyleDeclaration(); if (rules.isEmpty) return declaration; + if (rules.length == 1) { + declaration.union(rules.first.declaration); + return copyResult ? declaration.cloneEffective() : declaration; + } + + if (cacheVersion != null) { + final _CascadeCacheKey lookupKey = + _CascadeCacheKey.lookup(cacheVersion, rules); + final CSSStyleDeclaration? cached = _cascadeDeclarationCache[lookupKey]; + if (cached != null) { + return copyResult ? cached.cloneEffective() : cached; + } + } final normalOrder = List.from(rules) ..sort((a, b) => compareStyleRulesForCascade(a, b, important: false)); + + final bool hasImportantDeclarations = + rules.any((rule) => rule.declaration.hasImportantDeclarations); + if (!hasImportantDeclarations) { + for (final r in normalOrder) { + declaration.union(r.declaration); + } + if (cacheVersion != null) { + _cascadeDeclarationCache[ + _CascadeCacheKey.stored(cacheVersion, rules)] = declaration; + } + return copyResult ? declaration.cloneEffective() : declaration; + } + for (final r in normalOrder) { declaration.unionByImportance(r.declaration, important: false); } - final importantOrder = List.from(rules) - ..sort((a, b) => compareStyleRulesForCascade(a, b, important: true)); + final bool hasLayeredRules = rules.any((rule) => rule.layerOrderKey != null); + final List importantOrder = hasLayeredRules + ? (List.from(rules) + ..sort((a, b) => compareStyleRulesForCascade(a, b, important: true))) + : normalOrder; for (final r in importantOrder) { declaration.unionByImportance(r.declaration, important: true); } - return declaration; + if (cacheVersion != null) { + _cascadeDeclarationCache[ + _CascadeCacheKey.stored(cacheVersion, rules)] = declaration; + } + + return copyResult ? declaration.cloneEffective() : declaration; } diff --git a/webf/lib/src/css/css_rule.dart b/webf/lib/src/css/css_rule.dart index 143de0f98e..57cab95e83 100644 --- a/webf/lib/src/css/css_rule.dart +++ b/webf/lib/src/css/css_rule.dart @@ -7,15 +7,212 @@ * Copyright (C) 2022-2024 The WebF authors. All rights reserved. */ -import 'package:quiver/core.dart'; -import 'package:collection/collection.dart'; import 'package:webf/css.dart'; import 'package:webf/src/foundation/logger.dart'; +bool cssRuleListsStructurallyEqual(List left, List right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (int index = 0; index < left.length; index++) { + if (!cssRulesStructurallyEqual(left[index], right[index])) { + return false; + } + } + return true; +} + +int cssRuleListStructuralHash(Iterable rules) { + return Object.hashAll(rules.map(cssRuleStructuralHash)); +} + +bool cssRulesStructurallyEqual(CSSRule? left, CSSRule? right) { + if (identical(left, right)) return true; + if (left == null || right == null) return left == right; + if (left.runtimeType != right.runtimeType) return false; + + if (left is CSSStyleRule && right is CSSStyleRule) { + return left.selectorGroup.structurallyEquals(right.selectorGroup) && + left.layerPath.length == right.layerPath.length && + _stringListsEqual(left.layerPath, right.layerPath) && + left.declaration.structurallyEquals(right.declaration); + } + + if (left is CSSLayerStatementRule && right is CSSLayerStatementRule) { + return _layerNamePathsEqual(left.layerNamePaths, right.layerNamePaths); + } + + if (left is CSSLayerBlockRule && right is CSSLayerBlockRule) { + return left.name == right.name && + _stringListsEqual(left.layerNamePath, right.layerNamePath) && + cssRuleListsStructurallyEqual(left.cssRules, right.cssRules); + } + + if (left is CSSImportRule && right is CSSImportRule) { + return left.href == right.href && left.media == right.media; + } + + if (left is CSSKeyframesRule && right is CSSKeyframesRule) { + return left.name == right.name && + _keyframesEqual(left.keyframes, right.keyframes); + } + + if (left is CSSFontFaceRule && right is CSSFontFaceRule) { + return left.declarations.structurallyEquals(right.declarations); + } + + if (left is CSSMediaDirective && right is CSSMediaDirective) { + return _mediaQueriesEqual(left.cssMediaQuery, right.cssMediaQuery) && + cssRuleListsStructurallyEqual( + left.rules ?? const [], right.rules ?? const []); + } + + return left.cssText == right.cssText; +} + +int cssRuleStructuralHash(CSSRule rule) { + if (rule is CSSStyleRule) { + return Object.hash( + rule.type, + rule.selectorGroup.structuralHashCode, + Object.hashAll(rule.layerPath), + rule.declaration.structuralHashCode, + ); + } + + if (rule is CSSLayerStatementRule) { + return Object.hash( + rule.type, + Object.hashAll(rule.layerNamePaths.map((path) => Object.hashAll(path))), + ); + } + + if (rule is CSSLayerBlockRule) { + return Object.hash( + rule.type, + rule.name, + Object.hashAll(rule.layerNamePath), + cssRuleListStructuralHash(rule.cssRules), + ); + } + + if (rule is CSSImportRule) { + return Object.hash(rule.type, rule.href, rule.media); + } + + if (rule is CSSKeyframesRule) { + return Object.hash( + rule.type, + rule.name, + Object.hashAll(rule.keyframes.map((keyframe) => Object.hash( + keyframe.property, + keyframe.value, + keyframe.offset, + keyframe.easing, + ))), + ); + } + + if (rule is CSSFontFaceRule) { + return Object.hash(rule.type, rule.declarations.structuralHashCode); + } + + if (rule is CSSMediaDirective) { + return Object.hash( + rule.type, + _mediaQueryStructuralHash(rule.cssMediaQuery), + cssRuleListStructuralHash(rule.rules ?? const []), + ); + } + + return Object.hash(rule.type, rule.cssText); +} + +bool _layerNamePathsEqual(List> left, List> right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (int index = 0; index < left.length; index++) { + if (!_stringListsEqual(left[index], right[index])) return false; + } + return true; +} + +bool _stringListsEqual(List left, List right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (int index = 0; index < left.length; index++) { + if (left[index] != right[index]) return false; + } + return true; +} + +bool _keyframesEqual(List left, List right) { + if (identical(left, right)) return true; + if (left.length != right.length) return false; + for (int index = 0; index < left.length; index++) { + final Keyframe leftKeyframe = left[index]; + final Keyframe rightKeyframe = right[index]; + if (leftKeyframe.property != rightKeyframe.property || + leftKeyframe.value != rightKeyframe.value || + leftKeyframe.offset != rightKeyframe.offset || + leftKeyframe.easing != rightKeyframe.easing) { + return false; + } + } + return true; +} + +bool _mediaQueriesEqual(CSSMediaQuery? left, CSSMediaQuery? right) { + if (identical(left, right)) return true; + if (left == null || right == null) return left == right; + if (left._mediaUnary != right._mediaUnary) return false; + if (left._mediaType?.name != right._mediaType?.name) return false; + if (left.expressions.length != right.expressions.length) return false; + for (int index = 0; index < left.expressions.length; index++) { + final CSSMediaExpression leftExpression = left.expressions[index]; + final CSSMediaExpression rightExpression = right.expressions[index]; + if (leftExpression.op != rightExpression.op) return false; + final Map? leftStyle = leftExpression.mediaStyle; + final Map? rightStyle = rightExpression.mediaStyle; + if (leftStyle == null || rightStyle == null) { + if (leftStyle != rightStyle) return false; + continue; + } + if (leftStyle.length != rightStyle.length) return false; + final List keys = leftStyle.keys.toList(growable: false)..sort(); + final List otherKeys = rightStyle.keys.toList(growable: false) + ..sort(); + if (!_stringListsEqual(keys, otherKeys)) return false; + for (final String key in keys) { + if (leftStyle[key] != rightStyle[key]) return false; + } + } + return true; +} + +int _mediaQueryStructuralHash(CSSMediaQuery? mediaQuery) { + if (mediaQuery == null) return 0; + return Object.hash( + mediaQuery._mediaUnary, + mediaQuery._mediaType?.name, + Object.hashAll(mediaQuery.expressions.map((expression) { + final Map? mediaStyle = expression.mediaStyle; + if (mediaStyle == null) { + return Object.hash(expression.op, null); + } + final List keys = mediaStyle.keys.toList(growable: false)..sort(); + return Object.hash( + expression.op, + Object.hashAll(keys.map((key) => Object.hash(key, mediaStyle[key]))), + ); + })), + ); +} + /// https://drafts.csswg.org/cssom/#the-cssstylerule-interface class CSSStyleRule extends CSSRule { @override - String get cssText => '${selectorGroup.selectorText} {${declaration.cssText}}'; + String get cssText => + '${selectorGroup.selectorText} {${declaration.cssText}}'; @override int get type => CSSRule.STYLE_RULE; @@ -25,12 +222,10 @@ class CSSStyleRule extends CSSRule { CSSStyleRule(this.selectorGroup, this.declaration) : super(); - @override - int get hashCode => hash2(selectorGroup, declaration); + int get structuralHashCode => cssRuleStructuralHash(this); - @override - bool operator ==(Object other) { - return hashCode == other.hashCode; + bool structurallyEquals(CSSStyleRule other) { + return cssRulesStructurallyEqual(this, other); } } @@ -58,29 +253,6 @@ class CSSLayerStatementRule extends CSSRule { @override int get type => CSSRule.LAYER_STATEMENT_RULE; - - static bool _deepEquals(List> a, List> b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - final ai = a[i]; - final bi = b[i]; - if (ai.length != bi.length) return false; - for (var j = 0; j < ai.length; j++) { - if (ai[j] != bi[j]) return false; - } - } - return true; - } - - @override - int get hashCode => hashObjects(layerNamePaths.map((p) => hashObjects(p))); - - @override - bool operator ==(Object other) { - return other is CSSLayerStatementRule && - _deepEquals(layerNamePaths, other.layerNamePaths); - } } /// Represents a CSS Cascade Layer block: `@layer name { ... }`. @@ -107,30 +279,6 @@ class CSSLayerBlockRule extends CSSRule { @override int get type => CSSRule.LAYER_BLOCK_RULE; - @override - int get hashCode => hashObjects([ - name, - hashObjects(layerNamePath), - hashObjects(cssRules), - ]); - - static bool _listEquals(List a, List b) { - if (identical(a, b)) return true; - if (a.length != b.length) return false; - for (int i = 0; i < a.length; i++) { - if (a[i] != b[i]) return false; - } - return true; - } - - @override - bool operator ==(Object other) { - return other is CSSLayerBlockRule && - other.name == name && - _listEquals(other.layerNamePath, layerNamePath) && - const ListEquality().equals(other.cssRules, cssRules); - } - int insertRule(CSSRule rule, int index) { if (index < 0 || index > cssRules.length) { throw RangeError.index(index, cssRules, 'index'); diff --git a/webf/lib/src/css/element_rule_collector.dart b/webf/lib/src/css/element_rule_collector.dart index 4778340460..bdc814bb80 100644 --- a/webf/lib/src/css/element_rule_collector.dart +++ b/webf/lib/src/css/element_rule_collector.dart @@ -6,6 +6,8 @@ * Copyright (C) 2022-2024 The WebF authors. All rights reserved. */ +import 'dart:collection'; + import 'package:webf/css.dart'; import 'package:webf/dom.dart'; import 'package:webf/src/foundation/debug_flags.dart'; @@ -14,7 +16,73 @@ import 'package:webf/src/css/query_selector.dart'; bool kShowUnavailableCSSProperties = false; +class SelectorAncestorTokenSet { + final Element? _element; + Set? _ids; + Set? _classes; + Set? _tags; + + SelectorAncestorTokenSet._( + this._element, this._ids, this._classes, this._tags); + + factory SelectorAncestorTokenSet.lazy(Element element) { + return SelectorAncestorTokenSet._(element, null, null, null); + } + + factory SelectorAncestorTokenSet.eager( + {required Set ids, + required Set classes, + required Set tags}) { + return SelectorAncestorTokenSet._(null, ids, classes, tags); + } + + Set get ids { + _ensureBuilt(); + return _ids!; + } + + Set get classes { + _ensureBuilt(); + return _classes!; + } + + Set get tags { + _ensureBuilt(); + return _tags!; + } + + void _ensureBuilt() { + if (_ids != null && _classes != null && _tags != null) { + return; + } + + final Set ids = {}; + final Set classes = {}; + final Set tags = {}; + Element? cursor = _element?.parentElement; + while (cursor != null) { + final String? cursorId = cursor.id; + if (cursorId != null && cursorId.isNotEmpty) { + ids.add(cursorId); + } + if (cursor.classList.isNotEmpty) { + classes.addAll(cursor.classList); + } + tags.add(cursor.tagName.toUpperCase()); + cursor = cursor.parentElement; + } + + _ids = ids; + _classes = classes; + _tags = tags; + } +} + class ElementRuleCollector { + SelectorAncestorTokenSet buildAncestorTokens(Element element) { + return _buildAncestorTokens(element); + } + bool matchedAnyRule(RuleSet ruleSet, Element element) { return matchedRules(ruleSet, element).isNotEmpty; } @@ -71,100 +139,119 @@ class ElementRuleCollector { matchRules(ruleSet.pseudoRules); } - List matchedPseudoRules(RuleSet ruleSet, Element element) { - final SelectorEvaluator evaluator = SelectorEvaluator(); + List matchedPseudoRules(RuleSet ruleSet, Element element, + {SelectorAncestorTokenSet? ancestorTokens, + SelectorEvaluator? evaluator}) { + final SelectorEvaluator resolvedEvaluator = evaluator ?? SelectorEvaluator(); + final SelectorAncestorTokenSet? resolvedAncestorTokens = ancestorTokens ?? + (DebugFlags.enableCssAncestryFastPath + ? _buildAncestorTokens(element) + : null); // Collect candidates from all indexed buckets because many pseudo-element // selectors (e.g., ".foo div::before") are indexed under tag/class/id // buckets for matching efficiency. - final List candidates = []; + final List candidates = []; // #id String? id = element.id; if (id != null) { - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.idRules[id], element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); } // .class for (final String className in element.classList) { - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.classRules[className], element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); } // [attr] for (final String attribute in element.attributes.keys) { - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.attributeRules[attribute.toUpperCase()], element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); } // tag final String tagLookup = element.tagName.toUpperCase(); - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.tagRules[tagLookup], element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); // universal - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.universalRules, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); // legacy pseudo bucket (for selectors without a better rightmost key) - candidates.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.pseudoRules, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, includePseudo: true, - enableAncestryFastPath: false, - )); + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: resolvedAncestorTokens, + matchedRules: candidates, + ); // Deduplicate while preserving order. final List list = []; - final Set seen = {}; - for (final CSSRule r in candidates) { - if (r is CSSStyleRule && !seen.contains(r)) { - seen.add(r); - list.add(r); + final Set seen = LinkedHashSet.identity(); + for (final CSSStyleRule rule in candidates) { + if (seen.add(rule)) { + list.add(rule); } } return list; } - List matchedRules(RuleSet ruleSet, Element element) { - List matchedRules = []; + List matchedRules(RuleSet ruleSet, Element element, + {SelectorAncestorTokenSet? ancestorTokens, + SelectorEvaluator? evaluator}) { + final List matchedRules = []; // Reuse a single evaluator per matchedRules() to avoid repeated allocations. - final SelectorEvaluator evaluator = SelectorEvaluator(); - // Build ancestor token sets once per call if fast-path is enabled, to avoid - // repeatedly walking the ancestor chain for each candidate rule. - final _AncestorTokenSet? ancestorTokens = - DebugFlags.enableCssAncestryFastPath + final SelectorEvaluator resolvedEvaluator = evaluator ?? SelectorEvaluator(); + // Share one lazily materialized ancestor token set across all candidate + // checks in this match pass so we only walk the ancestor chain on demand. + final SelectorAncestorTokenSet? resolvedAncestorTokens = ancestorTokens ?? + (DebugFlags.enableCssAncestryFastPath ? _buildAncestorTokens(element) - : null; + : null); if (ruleSet.isEmpty) { return matchedRules; @@ -174,58 +261,63 @@ class ElementRuleCollector { String? id = element.id; if (id != null) { final list = ruleSet.idRules[id]; - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( list, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, - ancestorTokens: ancestorTokens, - )); + ancestorTokens: resolvedAncestorTokens, + matchedRules: matchedRules, + ); } // .class for (String className in element.classList) { final list = ruleSet.classRules[className]; - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( list, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, - ancestorTokens: ancestorTokens, - )); + ancestorTokens: resolvedAncestorTokens, + matchedRules: matchedRules, + ); } // attribute selector for (String attribute in element.attributes.keys) { final list = ruleSet.attributeRules[attribute.toUpperCase()]; - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( list, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, - ancestorTokens: ancestorTokens, - )); + ancestorTokens: resolvedAncestorTokens, + matchedRules: matchedRules, + ); } // tag selectors are stored uppercase; normalize element tag for lookup. final String tagLookup = element.tagName.toUpperCase(); final listTag = ruleSet.tagRules[tagLookup]; - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( listTag, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, - ancestorTokens: ancestorTokens, - )); + ancestorTokens: resolvedAncestorTokens, + matchedRules: matchedRules, + ); // universal - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.universalRules, element, - evaluator: evaluator, + evaluator: resolvedEvaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, - ancestorTokens: ancestorTokens, - )); + ancestorTokens: resolvedAncestorTokens, + matchedRules: matchedRules, + ); return matchedRules; } @@ -234,10 +326,10 @@ class ElementRuleCollector { // categories (e.g., universal/tag) and capping universal evaluations to help // diagnose hotspots when many rules change at once. List matchedRulesForInvalidate(RuleSet ruleSet, Element element) { - List matchedRules = []; + final List matchedRules = []; // Reuse a single evaluator per call. final SelectorEvaluator evaluator = SelectorEvaluator(); - final _AncestorTokenSet? ancestorTokens = + final SelectorAncestorTokenSet? ancestorTokens = DebugFlags.enableCssAncestryFastPath ? _buildAncestorTokens(element) : null; @@ -251,13 +343,14 @@ class ElementRuleCollector { if (!DebugFlags.enableCssInvalidateSkipTag) { final String tagLookup = element.tagName.toUpperCase(); final listTag = ruleSet.tagRules[tagLookup]; - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( listTag, element, evaluator: evaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, ancestorTokens: ancestorTokens, - )); + matchedRules: matchedRules, + ); if (matchedRules.isNotEmpty) gotoReturn(matchedRules); } @@ -269,105 +362,97 @@ class ElementRuleCollector { if (!skipUniversal) { final int cap = DebugFlags.cssInvalidateUniversalCap; if (cap > 0 && ruleSet.universalRules.length > cap) { - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.universalRules.take(cap).toList(), element, evaluator: evaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, ancestorTokens: ancestorTokens, - )); + matchedRules: matchedRules, + ); } else { - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.universalRules, element, evaluator: evaluator, enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, ancestorTokens: ancestorTokens, - )); + matchedRules: matchedRules, + ); } if (matchedRules.isNotEmpty) gotoReturn(matchedRules); } // Legacy pseudo rules (e.g., ::before/::after) if (ruleSet.pseudoRules.isNotEmpty) { - matchedRules.addAll(_collectMatchingRulesForList( + _collectMatchingRulesForList( ruleSet.pseudoRules, element, evaluator: evaluator, - enableAncestryFastPath: false, - ancestorTokens: null, + enableAncestryFastPath: DebugFlags.enableCssAncestryFastPath, + ancestorTokens: ancestorTokens, includePseudo: true, - )); + matchedRules: matchedRules, + ); if (matchedRules.isNotEmpty) gotoReturn(matchedRules); } - return matchedRules; + return matchedRules.cast(); } - void gotoReturn(List matchedRules) {} + void gotoReturn(List matchedRules) {} - CSSStyleDeclaration collectionFromRuleSet(RuleSet ruleSet, Element element) { - final rules = matchedRules(ruleSet, element); - final styleRules = rules.whereType().toList(); - return cascadeMatchedStyleRules(styleRules); + CSSStyleDeclaration collectionFromRuleSet(RuleSet ruleSet, Element element, + {SelectorAncestorTokenSet? ancestorTokens, SelectorEvaluator? evaluator}) { + return cascadeMatchedStyleRules( + matchedRules(ruleSet, element, + ancestorTokens: ancestorTokens, evaluator: evaluator), + cacheVersion: ruleSet.ownerDocument.ruleSetVersion); } - List _collectMatchingRulesForList( + void _collectMatchingRulesForList( List? rules, Element element, { required SelectorEvaluator evaluator, bool enableAncestryFastPath = true, - _AncestorTokenSet? ancestorTokens, + SelectorAncestorTokenSet? ancestorTokens, bool includePseudo = false, + required List matchedRules, }) { if (rules == null || rules.isEmpty) { - return []; + return; } - List matchedRules = []; for (CSSRule rule in rules) { if (rule is! CSSStyleRule) { continue; } + final SelectorGroup selectorGroup = rule.selectorGroup; + final List normalSelectors = + selectorGroup.selectorsWithoutPseudoElement; + if (!includePseudo && normalSelectors.isEmpty) { + continue; + } // Cheap ancestry key precheck for descendant combinators: if a selector // requires an ancestor ID/class/tag that's not present in the chain, skip // the expensive evaluator entirely. if (enableAncestryFastPath) { - final _AncestorHints hints = - _collectDescendantAncestorHints(rule.selectorGroup); - if (!hints.isEmpty) { - if (!_ancestorChainSatisfiesHints(element, hints, - tokens: ancestorTokens)) { - continue; - } + final Iterable selectorsForHintCheck = + includePseudo ? selectorGroup.selectors : normalSelectors; + if (!_selectorsMayMatchAncestorHints(selectorsForHintCheck, element, + tokens: ancestorTokens)) { + continue; } } try { - if (evaluator.matchSelector(rule.selectorGroup, element)) { - final bool hasPseudo = - _selectorGroupHasPseudoElement(rule.selectorGroup); - final bool hasNonPseudo = - _selectorGroupHasNonPseudoElement(rule.selectorGroup); + if (evaluator.matchSelector(selectorGroup, element)) { if (includePseudo) { - if (hasPseudo) matchedRules.add(rule); - } else { - // For normal elements, only include the rule if there exists at least - // one non-pseudo selector in the group that matches the element on its - // own. This avoids accidentally including pseudo-element selectors like - // ".angle::before" for the base element when the evaluator treats legacy - // pseudos as matching. - bool matchedByNonPseudo = false; - if (hasNonPseudo) { - for (final Selector sel in rule.selectorGroup.selectors) { - final bool selHasPseudo = _selectorHasPseudoElement(sel); - if (!selHasPseudo) { - final SelectorGroup single = SelectorGroup([sel]); - if (evaluator.matchSelector(single, element)) { - matchedByNonPseudo = true; - break; - } - } - } + if (selectorGroup.hasPseudoElement) { + matchedRules.add(rule); } + } else { + final bool matchedByNonPseudo = !selectorGroup.hasPseudoElement || + _matchesAnySelectorWithoutSpecificity( + evaluator, normalSelectors, element); if (matchedByNonPseudo) { matchedRules.add(rule); } else { @@ -385,136 +470,49 @@ class ElementRuleCollector { } } } - return matchedRules; } - bool _selectorGroupHasPseudoElement(SelectorGroup selectorGroup) { - for (final Selector selector in selectorGroup.selectors) { - for (final SimpleSelectorSequence seq - in selector.simpleSelectorSequences) { - final simple = seq.simpleSelector; - if (simple is PseudoElementSelector || - simple is PseudoElementFunctionSelector) { - return true; - } + bool _matchesAnySelectorWithoutSpecificity( + SelectorEvaluator evaluator, List selectors, Element element) { + for (final Selector selector in selectors) { + if (evaluator.matchSingleSelectorWithoutSpecificity(selector, element)) { + return true; } } return false; } - bool _selectorGroupHasNonPseudoElement(SelectorGroup selectorGroup) { - for (final Selector selector in selectorGroup.selectors) { - for (final SimpleSelectorSequence seq - in selector.simpleSelectorSequences) { - final simple = seq.simpleSelector; - // Any non-pseudo simple selector (including universal '*', tag, class, id, attribute) - // indicates the group targets normal elements as well. - if (simple is! PseudoElementSelector && - simple is! PseudoElementFunctionSelector) { - return true; - } + bool _selectorsMayMatchAncestorHints( + Iterable selectors, Element element, + {SelectorAncestorTokenSet? tokens}) { + SelectorAncestorTokenSet? localTokens = tokens; + for (final Selector selector in selectors) { + final SelectorAncestorHints hints = selector.descendantAncestorHints; + if (hints.isEmpty) { + return true; } - } - return false; - } - - bool _selectorHasPseudoElement(Selector selector) { - for (final SimpleSelectorSequence seq in selector.simpleSelectorSequences) { - final simple = seq.simpleSelector; - if (simple is PseudoElementSelector || - simple is PseudoElementFunctionSelector) { + localTokens ??= _buildAncestorTokens(element); + if (_ancestorChainSatisfiesHints(localTokens, hints)) { return true; } } return false; } - // A minimal hint collector that gathers ancestor ID/class/tag tokens from - // groups that are connected via DESCENDANT combinators. Used for an early - // presence check along the ancestor chain. - _AncestorHints _collectDescendantAncestorHints(SelectorGroup selectorGroup) { - final hints = _AncestorHints(); - for (final Selector selector in selectorGroup.selectors) { - // Build right-to-left groups as in SelectorEvaluator. - final List> groups = >[]; - final List groupCombinators = []; - List current = []; - for (final seq in selector.simpleSelectorSequences.reversed) { - current.add(seq.simpleSelector); - if (seq.combinator != TokenKind.COMBINATOR_NONE) { - groups.add(current); - groupCombinators.add(seq.combinator); - current = []; - } - } - if (current.isNotEmpty) { - groups.add(current); - groupCombinators.add(TokenKind.COMBINATOR_NONE); - } - - // Walk combinators; when it’s a descendant combinator from the rightmost - // to the next group, collect simple tokens from that ancestor group. - for (int gi = 0; gi < groups.length - 1; gi++) { - final int combinator = groupCombinators[gi]; - if (combinator == TokenKind.COMBINATOR_DESCENDANT) { - final List ancestorGroup = groups[gi + 1]; - for (final SimpleSelector s in ancestorGroup) { - if (s is IdSelector) { - hints.ids.add(s.name); - } else if (s is ClassSelector) { - hints.classes.add(s.name); - } else if (s is ElementSelector && !s.isWildcard) { - hints.tags.add(s.name.toUpperCase()); - } - } - } - } - } - return hints; - } - - bool _ancestorChainSatisfiesHints(Element element, _AncestorHints hints, - {_AncestorTokenSet? tokens}) { + bool _ancestorChainSatisfiesHints( + SelectorAncestorTokenSet tokens, SelectorAncestorHints hints) { if (hints.isEmpty) return true; - final _AncestorTokenSet localTokens = - tokens ?? _buildAncestorTokens(element); // All required tokens must be present somewhere in the chain. - if (hints.ids.isNotEmpty && !localTokens.ids.containsAll(hints.ids)) + if (hints.ids.isNotEmpty && !tokens.ids.containsAll(hints.ids)) + return false; + if (hints.classes.isNotEmpty && !tokens.classes.containsAll(hints.classes)) return false; - if (hints.classes.isNotEmpty && - !localTokens.classes.containsAll(hints.classes)) return false; - if (hints.tags.isNotEmpty && !localTokens.tags.containsAll(hints.tags)) + if (hints.tags.isNotEmpty && !tokens.tags.containsAll(hints.tags)) return false; return true; } - _AncestorTokenSet _buildAncestorTokens(Element element) { - final Set ids = {}; - final Set classes = {}; - final Set tags = {}; - Element? cursor = element.parentElement; - while (cursor != null) { - if (cursor.id != null && cursor.id!.isNotEmpty) ids.add(cursor.id!); - if (cursor.classList.isNotEmpty) classes.addAll(cursor.classList); - tags.add(cursor.tagName.toUpperCase()); - cursor = cursor.parentElement; - } - return _AncestorTokenSet(ids: ids, classes: classes, tags: tags); + SelectorAncestorTokenSet _buildAncestorTokens(Element element) { + return SelectorAncestorTokenSet.lazy(element); } } - -class _AncestorHints { - final Set ids = {}; - final Set classes = {}; - final Set tags = {}; - - bool get isEmpty => ids.isEmpty && classes.isEmpty && tags.isEmpty; -} - -class _AncestorTokenSet { - final Set ids; - final Set classes; - final Set tags; - _AncestorTokenSet( - {required this.ids, required this.classes, required this.tags}); -} diff --git a/webf/lib/src/css/parser/selector.dart b/webf/lib/src/css/parser/selector.dart index 36d46fa063..70360e99c7 100644 --- a/webf/lib/src/css/parser/selector.dart +++ b/webf/lib/src/css/parser/selector.dart @@ -33,18 +33,26 @@ part of 'parser.dart'; const kIdSpecificity = 0x010000; const kClassLikeSpecificity = 0x000100; const kTagSpecificity = 0x000001; +const kPseudoElementMaskBefore = 1 << 0; +const kPseudoElementMaskAfter = 1 << 1; +const kPseudoElementMaskFirstLetter = 1 << 2; +const kPseudoElementMaskFirstLine = 1 << 3; // https://drafts.csswg.org/cssom/#parse-a-group-of-selectors class SelectorGroup extends TreeNode { final SelectorTextVisitor _selectorTextVisitor = SelectorTextVisitor(); final List selectors; + List? _selectorsWithoutPseudoElement; + bool? _hasPseudoElement; + int? _pseudoElementMask; + int? _structuralHashCode; int _matchSpecificity = -1; int get matchSpecificity => _matchSpecificity; set matchSpecificity(int specificity) { - if (specificity > _matchSpecificity || specificity == -1 ) { + if (specificity > _matchSpecificity || specificity == -1) { _matchSpecificity = specificity; } } @@ -61,12 +69,36 @@ class SelectorGroup extends TreeNode { SelectorGroup(this.selectors) : super(); + bool get hasPseudoElement { + return _hasPseudoElement ??= + selectors.any((selector) => selector.hasPseudoElement); + } + + List get selectorsWithoutPseudoElement { + return _selectorsWithoutPseudoElement ??= selectors + .where((selector) => !selector.hasPseudoElement) + .toList(growable: false); + } + + int get pseudoElementMask { + return _pseudoElementMask ??= selectors.fold( + 0, (mask, selector) => mask | selector.pseudoElementMask); + } + + int get structuralHashCode => _structuralHashCode ??= selectorText.hashCode; + + bool structurallyEquals(SelectorGroup other) { + if (identical(this, other)) return true; + return selectorText == other.selectorText; + } + @override dynamic visit(Visitor visitor) => visitor.visitSelectorGroup(this); } class Selector extends TreeNode { final List simpleSelectorSequences; + _SelectorMatchPlan? _matchPlan; Selector(this.simpleSelectorSequences) : super(); @@ -86,10 +118,26 @@ class Selector extends TreeNode { return _specificity; } + bool get hasPseudoElement => _ensureMatchPlan().hasPseudoElement; + + int get pseudoElementMask => _ensureMatchPlan().pseudoElementMask; + + SelectorAncestorHints get descendantAncestorHints => + _ensureMatchPlan().descendantAncestorHints; + + List> get matchGroups => _ensureMatchPlan().groups; + + List get matchGroupCombinators => _ensureMatchPlan().groupCombinators; + + _SelectorMatchPlan _ensureMatchPlan() { + return _matchPlan ??= _SelectorMatchPlan.compile(this); + } + int _calcSpecificity() { int specificity = 0; for (final simpleSelectorSequence in simpleSelectorSequences) { - specificity += _specificityForSimpleSelector(simpleSelectorSequence.simpleSelector); + specificity += + _specificityForSimpleSelector(simpleSelectorSequence.simpleSelector); } return specificity; } @@ -134,7 +182,104 @@ class Selector extends TreeNode { } // True if any component simple selector is invalid. - bool get hasInvalid => simpleSelectorSequences.any((s) => s.simpleSelector is InvalidSelector); + bool get hasInvalid => + simpleSelectorSequences.any((s) => s.simpleSelector is InvalidSelector); +} + +class SelectorAncestorHints { + final Set ids = {}; + final Set classes = {}; + final Set tags = {}; + + bool get isEmpty => ids.isEmpty && classes.isEmpty && tags.isEmpty; +} + +class _SelectorMatchPlan { + final List> groups; + final List groupCombinators; + final SelectorAncestorHints descendantAncestorHints; + final bool hasPseudoElement; + final int pseudoElementMask; + + _SelectorMatchPlan._({ + required this.groups, + required this.groupCombinators, + required this.descendantAncestorHints, + required this.hasPseudoElement, + required this.pseudoElementMask, + }); + + factory _SelectorMatchPlan.compile(Selector selector) { + final List> groups = >[]; + final List groupCombinators = []; + final SelectorAncestorHints descendantAncestorHints = + SelectorAncestorHints(); + List current = []; + bool hasPseudoElement = false; + int pseudoElementMask = 0; + + for (final SimpleSelectorSequence seq + in selector.simpleSelectorSequences.reversed) { + final SimpleSelector simpleSelector = seq.simpleSelector; + current.add(simpleSelector); + if (simpleSelector is PseudoElementSelector || + simpleSelector is PseudoElementFunctionSelector) { + hasPseudoElement = true; + pseudoElementMask |= _pseudoElementBit(simpleSelector.name); + } + if (seq.combinator != TokenKind.COMBINATOR_NONE) { + groups.add(current); + groupCombinators.add(seq.combinator); + current = []; + } + } + + if (current.isNotEmpty) { + groups.add(current); + groupCombinators.add(TokenKind.COMBINATOR_NONE); + } + + for (int groupIndex = 0; groupIndex < groups.length - 1; groupIndex++) { + if (groupCombinators[groupIndex] != TokenKind.COMBINATOR_DESCENDANT) { + continue; + } + + final List ancestorGroup = groups[groupIndex + 1]; + for (final SimpleSelector simpleSelector in ancestorGroup) { + if (simpleSelector is IdSelector) { + descendantAncestorHints.ids.add(simpleSelector.name); + } else if (simpleSelector is ClassSelector) { + descendantAncestorHints.classes.add(simpleSelector.name); + } else if (simpleSelector is ElementSelector && + !simpleSelector.isWildcard) { + descendantAncestorHints.tags.add(simpleSelector.name.toUpperCase()); + } + } + } + + return _SelectorMatchPlan._( + groups: groups, + groupCombinators: groupCombinators, + descendantAncestorHints: descendantAncestorHints, + hasPseudoElement: hasPseudoElement, + pseudoElementMask: pseudoElementMask, + ); + } + + static int _pseudoElementBit(String name) { + switch (name) { + case 'before': + return kPseudoElementMaskBefore; + case 'after': + return kPseudoElementMaskAfter; + case 'first-letter': + return kPseudoElementMaskFirstLetter; + case 'first-line': + return kPseudoElementMaskFirstLine; + default: + return 0; + } + } } class SimpleSelectorSequence extends TreeNode { @@ -142,13 +287,16 @@ class SimpleSelectorSequence extends TreeNode { int combinator; final SimpleSelector simpleSelector; - SimpleSelectorSequence(this.simpleSelector, [this.combinator = TokenKind.COMBINATOR_NONE]) : super(); + SimpleSelectorSequence(this.simpleSelector, + [this.combinator = TokenKind.COMBINATOR_NONE]) + : super(); bool get isCombinatorNone => combinator == TokenKind.COMBINATOR_NONE; bool get isCombinatorPlus => combinator == TokenKind.COMBINATOR_PLUS; bool get isCombinatorGreater => combinator == TokenKind.COMBINATOR_GREATER; bool get isCombinatorTilde => combinator == TokenKind.COMBINATOR_TILDE; - bool get isCombinatorDescendant => combinator == TokenKind.COMBINATOR_DESCENDANT; + bool get isCombinatorDescendant => + combinator == TokenKind.COMBINATOR_DESCENDANT; String get combinatorToString { switch (combinator) { @@ -171,13 +319,15 @@ class SimpleSelectorSequence extends TreeNode { @override String toString() => simpleSelector.name; } + final Set selectorKeySet = {}; + // All other selectors (element, #id, .class, attribute, pseudo, negation, // namespace, *) are derived from this selector. abstract class SimpleSelector extends TreeNode { final dynamic _name; // ThisOperator, Identifier, Negation, others? - SimpleSelector(this._name) : super(){ + SimpleSelector(this._name) : super() { selectorKeySet.add(_name.name); } @@ -340,7 +490,8 @@ class PseudoClassFunctionSelector extends PseudoClassSelector { List get expression => argument as List; @override - dynamic visit(Visitor visitor) => visitor.visitPseudoClassFunctionSelector(this); + dynamic visit(Visitor visitor) => + visitor.visitPseudoClassFunctionSelector(this); } // ::pseudoElementFunction(expression) @@ -350,7 +501,8 @@ class PseudoElementFunctionSelector extends PseudoElementSelector { PseudoElementFunctionSelector(super.name, this.expression); @override - dynamic visit(Visitor visitor) => visitor.visitPseudoElementFunctionSelector(this); + dynamic visit(Visitor visitor) => + visitor.visitPseudoElementFunctionSelector(this); } // :NOT(negation_arg) @@ -383,8 +535,10 @@ List mergeNestedSelector( // Substitue the & with the parent selector and only use a combinator // descendant if & is prefix by a sequence with an empty name e.g., // "... + &", "&", "... ~ &", etc. - var hasPrefix = newSequence.isNotEmpty && newSequence.last.simpleSelector.name.isNotEmpty; - newSequence.addAll(hasPrefix ? _convertToDescendentSequence(parent) : parent); + var hasPrefix = newSequence.isNotEmpty && + newSequence.last.simpleSelector.name.isNotEmpty; + newSequence + .addAll(hasPrefix ? _convertToDescendentSequence(parent) : parent); } else { newSequence.add(sequence); } @@ -398,12 +552,14 @@ List mergeNestedSelector( /// descendant. Used for nested selectors when the parent selector needs to /// be prefixed to a nested selector or to substitute the this (&) with the /// parent selector. -List _convertToDescendentSequence(List sequences) { +List _convertToDescendentSequence( + List sequences) { if (sequences.isEmpty) return sequences; var newSequences = []; var first = sequences.first; - newSequences.add(SimpleSelectorSequence(first.simpleSelector, TokenKind.COMBINATOR_DESCENDANT)); + newSequences.add(SimpleSelectorSequence( + first.simpleSelector, TokenKind.COMBINATOR_DESCENDANT)); newSequences.addAll(sequences.skip(1)); return newSequences; diff --git a/webf/lib/src/css/query_selector.dart b/webf/lib/src/css/query_selector.dart index 68455c0469..3eb2d9ab0a 100644 --- a/webf/lib/src/css/query_selector.dart +++ b/webf/lib/src/css/query_selector.dart @@ -229,6 +229,11 @@ class SelectorEvaluator extends SelectorVisitor { return visitSelectorGroup(selectorGroup); } + bool matchSingleSelectorWithoutSpecificity( + Selector selector, Element element) { + return _matchesSelectorWithoutSpecificity(selector, element); + } + bool matchSelectorWithForcedPseudoClass( SelectorGroup? selectorGroup, Element? element, { @@ -285,36 +290,21 @@ class SelectorEvaluator extends SelectorVisitor { } @override - bool visitSelectorGroup(SelectorGroup node) => - node.selectors.any(visitSelector); + bool visitSelectorGroup(SelectorGroup node) { + final List selectors = node.selectors; + for (int index = 0; index < selectors.length; index++) { + if (visitSelector(selectors[index])) { + return true; + } + } + return false; + } @override bool visitSelector(Selector node) { final old = _element; - - // Build right-to-left groups of compound selectors (simple selectors joined - // with COMBINATOR_NONE), and the combinator that connects each group to the - // next group on the left. - final List> groups = >[]; - final List groupCombinators = - []; // combinator from this group to the next (left) group - - { - List current = []; - for (final seq in node.simpleSelectorSequences.reversed) { - current.add(seq.simpleSelector); - if (seq.combinator != TokenKind.COMBINATOR_NONE) { - groups.add(current); - groupCombinators.add(seq.combinator); - current = []; - } - } - if (current.isNotEmpty) { - groups.add(current); - // No combinator to the left of the leftmost group - groupCombinators.add(TokenKind.COMBINATOR_NONE); - } - } + final List> groups = node.matchGroups; + final List groupCombinators = node.matchGroupCombinators; bool matchesCompound(Element? element, List compound) { if (element == null) return false; diff --git a/webf/lib/src/css/render_style.dart b/webf/lib/src/css/render_style.dart index 65343b7f00..e35c595141 100644 --- a/webf/lib/src/css/render_style.dart +++ b/webf/lib/src/css/render_style.dart @@ -15,6 +15,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart' as flutter; +import 'package:quiver/collection.dart'; import 'package:webf/css.dart'; import 'package:webf/dom.dart'; import 'package:webf/html.dart'; @@ -26,6 +27,345 @@ import 'package:webf/src/css/css_animation.dart'; typedef RenderStyleVisitor = void Function(T renderObject); +const int _kParsedStyleValueCacheSize = 2048; + +final LinkedLruHashMap _parsedStyleValueCache = + LinkedLruHashMap( + maximumSize: _kParsedStyleValueCacheSize); + +abstract class _ParsedStyleValue { + const _ParsedStyleValue(); + + dynamic resolve(CSSRenderStyle renderStyle, String propertyName, + {String? baseHref}); +} + +class _StaticParsedStyleValue extends _ParsedStyleValue { + final dynamic value; + + const _StaticParsedStyleValue(this.value); + + @override + dynamic resolve(CSSRenderStyle renderStyle, String propertyName, + {String? baseHref}) { + return value; + } +} + +class _LengthParsedStyleValue extends _ParsedStyleValue { + final CSSParsedLengthValue value; + + const _LengthParsedStyleValue(this.value); + + @override + dynamic resolve(CSSRenderStyle renderStyle, String propertyName, + {String? baseHref}) { + return value.resolve(renderStyle, propertyName); + } +} + +class _ScaledEmParsedStyleValue extends _ParsedStyleValue { + final double factor; + + const _ScaledEmParsedStyleValue(this.factor); + + @override + dynamic resolve(CSSRenderStyle renderStyle, String propertyName, + {String? baseHref}) { + return CSSLengthValue(factor, CSSLengthType.EM, renderStyle, propertyName); + } +} + +_ParsedStyleValue? _parseLengthStyleValue(String propertyValue) { + final CSSParsedLengthValue? parsed = CSSParsedLengthValue.tryParse(propertyValue); + if (parsed == null) { + return null; + } + return _LengthParsedStyleValue(parsed); +} + +_ParsedStyleValue? _parseFontSizeStyleValue(String propertyValue) { + switch (propertyValue) { + case 'xx-small': + return _StaticParsedStyleValue(CSSLengthValue(3 / 5 * 16, CSSLengthType.PX)); + case 'x-small': + return _StaticParsedStyleValue(CSSLengthValue(3 / 4 * 16, CSSLengthType.PX)); + case 'small': + return _StaticParsedStyleValue(CSSLengthValue(8 / 9 * 16, CSSLengthType.PX)); + case 'medium': + return _StaticParsedStyleValue(CSSLengthValue(16, CSSLengthType.PX)); + case 'large': + return _StaticParsedStyleValue(CSSLengthValue(6 / 5 * 16, CSSLengthType.PX)); + case 'x-large': + return _StaticParsedStyleValue(CSSLengthValue(3 / 2 * 16, CSSLengthType.PX)); + case 'xx-large': + return _StaticParsedStyleValue(CSSLengthValue(2 / 1 * 16, CSSLengthType.PX)); + case 'xxx-large': + return _StaticParsedStyleValue(CSSLengthValue(3 / 1 * 16, CSSLengthType.PX)); + case 'smaller': + return const _ScaledEmParsedStyleValue(5 / 6); + case 'larger': + return const _ScaledEmParsedStyleValue(6 / 5); + default: + return _parseLengthStyleValue(propertyValue); + } +} + +_ParsedStyleValue? _parseLineHeightStyleValue(String propertyValue) { + if (propertyValue == NORMAL) { + return _StaticParsedStyleValue(CSSLengthValue.normal); + } + if (CSSNumber.isNumber(propertyValue)) { + final double? multiplier = CSSNumber.parseNumber(propertyValue); + if (multiplier == null) { + return null; + } + return _ScaledEmParsedStyleValue(multiplier); + } + return _parseLengthStyleValue(propertyValue); +} + +_ParsedStyleValue? _parseCachedStyleValue( + String propertyName, String propertyValue) { + final String cacheKey = '$propertyName\u0000$propertyValue'; + final _ParsedStyleValue? cached = _parsedStyleValueCache[cacheKey]; + if (cached != null) { + return cached; + } + + final _ParsedStyleValue? parsed = + _parseStyleValue(propertyName, propertyValue); + if (parsed != null) { + _parsedStyleValueCache[cacheKey] = parsed; + } + return parsed; +} + +_ParsedStyleValue? _parseStyleValue(String propertyName, String propertyValue) { + if (propertyValue == INHERIT) { + return null; + } + + switch (propertyName) { + case DISPLAY: + return _StaticParsedStyleValue( + CSSDisplayMixin.resolveDisplay(propertyValue)); + case OVERFLOW_X: + case OVERFLOW_Y: + return _StaticParsedStyleValue( + CSSOverflowMixin.resolveOverflowType(propertyValue)); + case POSITION: + return _StaticParsedStyleValue( + CSSPositionMixin.resolvePositionType(propertyValue)); + case Z_INDEX: + return _StaticParsedStyleValue(int.tryParse(propertyValue)); + case TOP: + case LEFT: + case BOTTOM: + case RIGHT: + case INSET_INLINE_START: + case INSET_INLINE_END: + case FLEX_BASIS: + case WIDTH: + case MIN_WIDTH: + case MAX_WIDTH: + case HEIGHT: + case MIN_HEIGHT: + case MAX_HEIGHT: + case X: + case Y: + case RX: + case RY: + case CX: + case CY: + case R: + case X1: + case X2: + case Y1: + case Y2: + case STROKE_WIDTH: + case TEXT_INDENT: + case PADDING_TOP: + case MARGIN_TOP: + case MARGIN_RIGHT: + case PADDING_RIGHT: + case PADDING_BOTTOM: + case MARGIN_BOTTOM: + case PADDING_LEFT: + case MARGIN_LEFT: + case PADDING_INLINE_START: + case PADDING_INLINE_END: + case MARGIN_INLINE_START: + case MARGIN_INLINE_END: + case LETTER_SPACING: + case WORD_SPACING: + return _parseLengthStyleValue(propertyValue); + case ASPECT_RATIO: + return _StaticParsedStyleValue( + CSSSizingMixin.resolveAspectRatio(propertyValue)); + case GAP: + case ROW_GAP: + case COLUMN_GAP: + if (propertyValue == NORMAL) { + return _StaticParsedStyleValue(CSSLengthValue.normal); + } + return _parseLengthStyleValue(propertyValue); + case FLEX_DIRECTION: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveFlexDirection(propertyValue)); + case FLEX_WRAP: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveFlexWrap(propertyValue)); + case ALIGN_CONTENT: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveAlignContent(propertyValue)); + case ALIGN_ITEMS: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveAlignItems(propertyValue)); + case JUSTIFY_CONTENT: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveJustifyContent(propertyValue)); + case JUSTIFY_ITEMS: + return _StaticParsedStyleValue( + CSSGridParser.parseAxisAlignment(propertyValue, allowAuto: false)); + case JUSTIFY_SELF: + return _StaticParsedStyleValue( + CSSGridParser.parseAxisAlignment(propertyValue, allowAuto: true)); + case ALIGN_SELF: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveAlignSelf(propertyValue)); + case FLEX_GROW: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveFlexGrow(propertyValue)); + case FLEX_SHRINK: + return _StaticParsedStyleValue( + CSSFlexboxMixin.resolveFlexShrink(propertyValue)); + case ORDER: + return _StaticParsedStyleValue(CSSOrderMixin.resolveOrder(propertyValue)); + case SLIVER_DIRECTION: + return _StaticParsedStyleValue(CSSSliverMixin.resolveAxis(propertyValue)); + case TEXT_ALIGN: + return _StaticParsedStyleValue( + CSSTextMixin.resolveTextAlign(propertyValue)); + case DIRECTION: + return _StaticParsedStyleValue( + CSSTextMixin.resolveDirection(propertyValue)); + case WRITING_MODE: + return _StaticParsedStyleValue( + CSSWritingModeMixin.resolveWritingMode(propertyValue)); + case BACKGROUND_ATTACHMENT: + return _StaticParsedStyleValue( + CSSBackground.resolveBackgroundAttachment(propertyValue)); + case BACKGROUND_REPEAT: + return _StaticParsedStyleValue( + CSSBackground.resolveBackgroundRepeat(propertyValue)); + case BACKGROUND_CLIP: + return _StaticParsedStyleValue( + CSSBackground.resolveBackgroundClip(propertyValue)); + case BACKGROUND_ORIGIN: + return _StaticParsedStyleValue( + CSSBackground.resolveBackgroundOrigin(propertyValue)); + case BORDER_LEFT_WIDTH: + case BORDER_TOP_WIDTH: + case BORDER_RIGHT_WIDTH: + case BORDER_BOTTOM_WIDTH: + if (propertyValue.contains(' ')) { + return null; + } + return _parseLengthStyleValue(propertyValue); + case BORDER_LEFT_STYLE: + case BORDER_TOP_STYLE: + case BORDER_RIGHT_STYLE: + case BORDER_BOTTOM_STYLE: + if (propertyValue.contains(' ')) { + return null; + } + return _StaticParsedStyleValue( + CSSBorderSide.resolveBorderStyle(propertyValue)); + case OPACITY: + return _StaticParsedStyleValue( + CSSOpacityMixin.resolveOpacity(propertyValue)); + case VISIBILITY: + return _StaticParsedStyleValue( + CSSVisibilityMixin.resolveVisibility(propertyValue)); + case CONTENT_VISIBILITY: + return _StaticParsedStyleValue( + CSSContentVisibilityMixin.resolveContentVisibility(propertyValue)); + case TRANSFORM: + final List? transform = + CSSTransformMixin.resolveTransform(propertyValue); + return _StaticParsedStyleValue(transform == null + ? null + : List.unmodifiable(transform)); + case FILTER: + final List functions = + CSSFunction.parseFunction(propertyValue); + return _StaticParsedStyleValue( + List.unmodifiable(functions)); + case OBJECT_FIT: + return _StaticParsedStyleValue( + CSSObjectFitMixin.resolveBoxFit(propertyValue)); + case OBJECT_POSITION: + return _StaticParsedStyleValue( + CSSObjectPositionMixin.resolveObjectPosition(propertyValue)); + case TEXT_DECORATION_LINE: + return _StaticParsedStyleValue( + CSSText.resolveTextDecorationLine(propertyValue)); + case TEXT_DECORATION_STYLE: + return _StaticParsedStyleValue( + CSSText.resolveTextDecorationStyle(propertyValue)); + case FONT_WEIGHT: + return _StaticParsedStyleValue(CSSText.resolveFontWeight(propertyValue)); + case FONT_SIZE: + return _parseFontSizeStyleValue(propertyValue); + case FONT_STYLE: + return _StaticParsedStyleValue(CSSText.resolveFontStyle(propertyValue)); + case FONT_VARIANT: + return _StaticParsedStyleValue( + CSSText.resolveFontVariant(propertyValue)); + case FONT_FAMILY: + return _StaticParsedStyleValue(List.unmodifiable( + CSSText.resolveFontFamilyFallback(propertyValue))); + case LINE_HEIGHT: + return _parseLineHeightStyleValue(propertyValue); + case WHITE_SPACE: + return _StaticParsedStyleValue(CSSText.resolveWhiteSpace(propertyValue)); + case TEXT_OVERFLOW: + return _StaticParsedStyleValue( + CSSText.resolveTextOverflow(propertyValue)); + case WORD_BREAK: + return _StaticParsedStyleValue(CSSText.resolveWordBreak(propertyValue)); + case TEXT_TRANSFORM: + return _StaticParsedStyleValue( + CSSText.resolveTextTransform(propertyValue)); + case LINE_CLAMP: + return _StaticParsedStyleValue(CSSText.parseLineClamp(propertyValue)); + case TAB_SIZE: + return _StaticParsedStyleValue(CSSNumber.parseNumber(propertyValue)); + case VERTICAL_ALIGN: + return _StaticParsedStyleValue( + CSSInlineMixin.resolveVerticalAlign(propertyValue)); + case TRANSITION_DELAY: + case TRANSITION_DURATION: + case TRANSITION_TIMING_FUNCTION: + case TRANSITION_PROPERTY: + case ANIMATION_DELAY: + case ANIMATION_DIRECTION: + case ANIMATION_DURATION: + case ANIMATION_FILL_MODE: + case ANIMATION_ITERATION_COUNT: + case ANIMATION_NAME: + case ANIMATION_PLAY_STATE: + case ANIMATION_TIMING_FUNCTION: + final List? values = + CSSStyleProperty.getMultipleValues(propertyValue); + return _StaticParsedStyleValue( + values == null ? null : List.unmodifiable(values)); + } + + return null; +} + class AdapterUpdateReason {} class WebFInitReason extends AdapterUpdateReason {} @@ -2289,6 +2629,12 @@ class CSSRenderStyle extends RenderStyle propertyValue = CSSWritingModeMixin.expandInlineVars(propertyValue, renderStyle, propertyName); } + final _ParsedStyleValue? parsedStyleValue = + _parseCachedStyleValue(propertyName, propertyValue); + if (parsedStyleValue != null) { + return parsedStyleValue.resolve(this, propertyName, baseHref: baseHref); + } + dynamic value; switch (propertyName) { case DISPLAY: @@ -3728,6 +4074,7 @@ class CSSRenderStyle extends RenderStyle enum CSSWritingMode { horizontalTb, verticalRl, verticalLr } mixin CSSWritingModeMixin on RenderStyle { + @override CSSWritingMode get writingMode => _writingMode ?? CSSWritingMode.horizontalTb; CSSWritingMode? _writingMode; set writingMode(CSSWritingMode value) { diff --git a/webf/lib/src/css/style_declaration.dart b/webf/lib/src/css/style_declaration.dart index 0acf24bb1d..b56c3583b3 100644 --- a/webf/lib/src/css/style_declaration.dart +++ b/webf/lib/src/css/style_declaration.dart @@ -77,6 +77,13 @@ List _propertyOrders = [ HEIGHT ]; +final List _propertyFlushPriorityOrder = + List.unmodifiable(_propertyOrders.reversed); +final Map _propertyFlushPriorityRanks = { + for (int i = 0; i < _propertyFlushPriorityOrder.length; i++) + _propertyFlushPriorityOrder[i]: i, +}; + final LinkedLruHashMap> _cachedExpandedShorthand = LinkedLruHashMap(maximumSize: 500); @@ -122,6 +129,7 @@ class CSSStyleDeclaration extends DynamicBindingObject _pseudoBeforeStyle = newStyle; target?.markBeforePseudoElementNeedsUpdate(); } + CSSStyleDeclaration? get resolvedPseudoBeforeStyle => _resolvePseudoStyle(_pseudoBeforeStyle, _inlinePseudoBeforeStyle); @@ -132,6 +140,7 @@ class CSSStyleDeclaration extends DynamicBindingObject _pseudoAfterStyle = newStyle; target?.markAfterPseudoElementNeedsUpdate(); } + CSSStyleDeclaration? get resolvedPseudoAfterStyle => _resolvePseudoStyle(_pseudoAfterStyle, _inlinePseudoAfterStyle); @@ -144,6 +153,7 @@ class CSSStyleDeclaration extends DynamicBindingObject // Trigger a layout rebuild so IFC can re-shape text for first-letter styling target?.markFirstLetterPseudoNeedsUpdate(); } + CSSStyleDeclaration? get resolvedPseudoFirstLetterStyle => _resolvePseudoStyle( _pseudoFirstLetterStyle, _inlinePseudoFirstLetterStyle); @@ -156,6 +166,7 @@ class CSSStyleDeclaration extends DynamicBindingObject _pseudoFirstLineStyle = newStyle; target?.markFirstLinePseudoNeedsUpdate(); } + CSSStyleDeclaration? get resolvedPseudoFirstLineStyle => _resolvePseudoStyle(_pseudoFirstLineStyle, _inlinePseudoFirstLineStyle); @@ -207,9 +218,13 @@ class CSSStyleDeclaration extends DynamicBindingObject /// Exposed for components (e.g., CSS variable resolver) that need to /// preserve importance when updating dependent properties. bool isImportant(String propertyName) { - return _importants[propertyName] == true; + return _importants[_normalizePropertyName(propertyName)] == true; } + bool get hasImportantDeclarations => _importants.isNotEmpty; + + bool get hasPendingProperties => _pendingProperties.isNotEmpty; + bool get hasInheritedPendingProperty { return _pendingProperties.keys .any((key) => isInheritedPropertyString(_kebabize(key))); @@ -230,6 +245,11 @@ class CSSStyleDeclaration extends DynamicBindingObject /// value is a String containing the value of the property. /// If not set, returns the empty string. String getPropertyValue(String propertyName) { + propertyName = _normalizePropertyName(propertyName); + return _getPropertyValueByNormalizedName(propertyName); + } + + String _getPropertyValueByNormalizedName(String propertyName) { // Get the latest pending value first. return _pendingProperties[propertyName]?.value ?? _properties[propertyName]?.value ?? @@ -238,12 +258,185 @@ class CSSStyleDeclaration extends DynamicBindingObject /// Returns the baseHref associated with a property value if available. String? getPropertyBaseHref(String propertyName) { + propertyName = _normalizePropertyName(propertyName); + return _getPropertyBaseHrefByNormalizedName(propertyName); + } + + String? _getPropertyBaseHrefByNormalizedName(String propertyName) { return _pendingProperties[propertyName]?.baseHref ?? _properties[propertyName]?.baseHref; } + CSSPropertyValue? _effectiveProperty(String propertyName) { + return _pendingProperties[propertyName] ?? _properties[propertyName]; + } + + bool _hasEffectiveProperty(String propertyName) { + final CSSPropertyValue? value = _effectiveProperty(propertyName); + return value != null && value.value.isNotEmpty; + } + + String _removedPropertyFallbackValue(String propertyName, + [bool? isImportant]) { + String present = EMPTY_STRING; + if (isImportant == true) { + _importants.remove(propertyName); + final String? value = _sheetStyle[propertyName]; + if (!isNullOrEmptyValue(value)) { + present = value!; + } + } else if (isImportant == false) { + _sheetStyle.remove(propertyName); + } + + if (isNullOrEmptyValue(present) && + defaultStyle != null && + defaultStyle!.containsKey(propertyName)) { + present = defaultStyle![propertyName]; + } + + if (isNullOrEmptyValue(present) && + cssInitialValues.containsKey(propertyName)) { + final String kebabName = _kebabize(propertyName); + final bool isInherited = isInheritedPropertyString(kebabName); + if (!isInherited) { + present = cssInitialValues[propertyName]; + } + } + + return present; + } + + bool _queueMergedPropertyValue(String propertyName, CSSPropertyValue value, + {required bool important}) { + if (!important) { + _sheetStyle[propertyName] = value.value; + } + + if (!important && _importants[propertyName] == true) { + return false; + } + + if (important) { + _importants[propertyName] = true; + } + + _pendingProperties[propertyName] = value; + return true; + } + + bool get _isEffectivelyEmpty => + _properties.isEmpty && _pendingProperties.isEmpty && _importants.isEmpty; + + void _adoptEffectivePropertiesFrom(CSSStyleDeclaration declaration) { + if (declaration._properties.isEmpty) { + if (declaration._pendingProperties.isNotEmpty) { + _pendingProperties = + Map.of(declaration._pendingProperties); + } + if (declaration._importants.isNotEmpty) { + _importants.addAll(declaration._importants); + } + return; + } + + final CSSStyleDeclaration cloned = declaration.cloneEffective(); + if (cloned._pendingProperties.isNotEmpty) { + _pendingProperties = cloned._pendingProperties; + } + if (cloned._importants.isNotEmpty) { + _importants.addAll(cloned._importants); + } + } + + CSSStyleDeclaration cloneEffective() { + final CSSStyleDeclaration cloned = CSSStyleDeclaration(); + + if (_properties.isEmpty) { + if (_pendingProperties.isNotEmpty) { + cloned._pendingProperties = + Map.of(_pendingProperties); + } + if (_importants.isNotEmpty) { + cloned._importants.addAll(_importants); + } + return cloned; + } + + for (final String propertyName in _properties.keys) { + if (_pendingProperties.containsKey(propertyName)) continue; + final CSSPropertyValue? value = _properties[propertyName]; + if (value == null || value.value.isEmpty) continue; + cloned._pendingProperties[propertyName] = value; + if (_importants[propertyName] == true) { + cloned._importants[propertyName] = true; + } + } + + for (final String propertyName in _pendingProperties.keys) { + final CSSPropertyValue value = _pendingProperties[propertyName]!; + if (value.value.isEmpty) continue; + cloned._pendingProperties[propertyName] = value; + if (_importants[propertyName] == true) { + cloned._importants[propertyName] = true; + } + } + + return cloned; + } + + List _structuralPropertyNames() { + final Set propertyNames = {} + ..addAll(_properties.keys) + ..addAll(_pendingProperties.keys) + ..addAll(_importants.keys); + propertyNames + .removeWhere((propertyName) => getPropertyValue(propertyName).isEmpty); + final List sorted = propertyNames.toList(growable: false); + sorted.sort(); + return sorted; + } + + int get structuralHashCode { + final List propertyNames = _structuralPropertyNames(); + return Object.hashAll(propertyNames.map((propertyName) => Object.hash( + propertyName, + _getPropertyValueByNormalizedName(propertyName), + _getPropertyBaseHrefByNormalizedName(propertyName), + _importants[propertyName] == true, + ))); + } + + bool structurallyEquals(CSSStyleDeclaration other) { + if (identical(this, other)) return true; + + final List propertyNames = _structuralPropertyNames(); + final List otherPropertyNames = other._structuralPropertyNames(); + if (propertyNames.length != otherPropertyNames.length) return false; + + for (int index = 0; index < propertyNames.length; index++) { + final String propertyName = propertyNames[index]; + if (propertyName != otherPropertyNames[index]) return false; + if (_getPropertyValueByNormalizedName(propertyName) != + other._getPropertyValueByNormalizedName(propertyName)) { + return false; + } + if (_getPropertyBaseHrefByNormalizedName(propertyName) != + other._getPropertyBaseHrefByNormalizedName(propertyName)) { + return false; + } + if ((_importants[propertyName] == true) != + (other._importants[propertyName] == true)) { + return false; + } + } + + return true; + } + /// Removes a property from the CSS declaration. void removeProperty(String propertyName, [bool? isImportant]) { + propertyName = _normalizePropertyName(propertyName); switch (propertyName) { case PADDING: return CSSStyleProperty.removeShorthandPadding(this, isImportant); @@ -567,14 +760,16 @@ class CSSStyleDeclaration extends DynamicBindingObject // Validate value. switch (propertyName) { - case GAP: { - final List tokens = splitByAsciiWhitespacePreservingGroups(normalizedValue); - if (tokens.isEmpty || tokens.length > 2) return false; - for (final token in tokens) { - if (!CSSGap.isValidGapValue(token)) return false; + case GAP: + { + final List tokens = + splitByAsciiWhitespacePreservingGroups(normalizedValue); + if (tokens.isEmpty || tokens.length > 2) return false; + for (final token in tokens) { + if (!CSSGap.isValidGapValue(token)) return false; + } + break; } - break; - } case ROW_GAP: case COLUMN_GAP: if (!CSSGap.isValidGapValue(normalizedValue)) return false; @@ -706,7 +901,7 @@ class CSSStyleDeclaration extends DynamicBindingObject String? baseHref, bool validate = true, }) { - propertyName = propertyName.trim(); + propertyName = _normalizePropertyName(propertyName); // Null or empty value means should be removed. if (isNullOrEmptyValue(value)) { @@ -789,39 +984,98 @@ class CSSStyleDeclaration extends DynamicBindingObject // Reset first avoid set property in flush stage. _pendingProperties = {}; - List propertyNames = pendingProperties.keys.toList(); - for (String propertyName in _propertyOrders) { - int index = propertyNames.indexOf(propertyName); - if (index > -1) { - propertyNames.removeAt(index); - propertyNames.insert(0, propertyName); - } + if (pendingProperties.length == 1) { + final MapEntry entry = + pendingProperties.entries.first; + final String propertyName = entry.key; + final CSSPropertyValue currentValue = entry.value; + final CSSPropertyValue? prevValue = _properties[propertyName]; + _properties[propertyName] = currentValue; + _emitPropertyChanged(propertyName, prevValue?.value, currentValue.value, + baseHref: currentValue.baseHref); + onStyleFlushed?.call([propertyName]); + return; } - Map prevValues = {}; - for (String propertyName in propertyNames) { - // Update the prevValue to currentValue. - prevValues[propertyName] = _properties[propertyName]; - _properties[propertyName] = pendingProperties[propertyName]!; - } + final List variablePropertyNames = []; + final List variablePrevValues = + []; + final List prioritizedPropertyNames = + List.filled(_propertyFlushPriorityOrder.length, null); + final List prioritizedPrevValues = + List.filled(_propertyFlushPriorityOrder.length, null); + final List regularPropertyNames = []; + final List regularPrevValues = []; + + for (final MapEntry entry + in pendingProperties.entries) { + final String propertyName = entry.key; + final CSSPropertyValue currentValue = entry.value; + final CSSPropertyValue? prevValue = _properties[propertyName]; + _properties[propertyName] = currentValue; + + if (CSSVariable.isCSSSVariableProperty(propertyName)) { + variablePropertyNames.add(propertyName); + variablePrevValues.add(prevValue); + continue; + } - propertyNames.sort((left, right) { - final isVariableLeft = CSSVariable.isCSSSVariableProperty(left) ? 1 : 0; - final isVariableRight = CSSVariable.isCSSSVariableProperty(right) ? 1 : 0; - if (isVariableLeft == 1 || isVariableRight == 1) { - return isVariableRight - isVariableLeft; + final int? priorityRank = _propertyFlushPriorityRanks[propertyName]; + if (priorityRank != null) { + prioritizedPropertyNames[priorityRank] = propertyName; + prioritizedPrevValues[priorityRank] = prevValue; + continue; } - return 0; - }); - for (String propertyName in propertyNames) { - CSSPropertyValue? prevValue = prevValues[propertyName]; - CSSPropertyValue currentValue = pendingProperties[propertyName]!; + regularPropertyNames.add(propertyName); + regularPrevValues.add(prevValue); + } + + final StyleFlushedListener? styleFlushed = onStyleFlushed; + final List? flushedPropertyNames = + styleFlushed == null ? null : []; + + _flushOrderedPendingProperties(variablePropertyNames, variablePrevValues, + pendingProperties, flushedPropertyNames); + _flushPrioritizedPendingProperties(prioritizedPropertyNames, + prioritizedPrevValues, pendingProperties, flushedPropertyNames); + _flushOrderedPendingProperties(regularPropertyNames, regularPrevValues, + pendingProperties, flushedPropertyNames); + + if (flushedPropertyNames != null) { + styleFlushed!(flushedPropertyNames); + } + } + + void _flushOrderedPendingProperties( + List propertyNames, + List prevValues, + Map pendingProperties, + List? flushedPropertyNames) { + for (int i = 0; i < propertyNames.length; i++) { + final String propertyName = propertyNames[i]; + final CSSPropertyValue? prevValue = prevValues[i]; + final CSSPropertyValue currentValue = pendingProperties[propertyName]!; _emitPropertyChanged(propertyName, prevValue?.value, currentValue.value, baseHref: currentValue.baseHref); + flushedPropertyNames?.add(propertyName); } + } - onStyleFlushed?.call(propertyNames); + void _flushPrioritizedPendingProperties( + List propertyNames, + List prevValues, + Map pendingProperties, + List? flushedPropertyNames) { + for (int i = 0; i < propertyNames.length; i++) { + final String? propertyName = propertyNames[i]; + if (propertyName == null) continue; + final CSSPropertyValue? prevValue = prevValues[i]; + final CSSPropertyValue currentValue = pendingProperties[propertyName]!; + _emitPropertyChanged(propertyName, prevValue?.value, currentValue.value, + baseHref: currentValue.baseHref); + flushedPropertyNames?.add(propertyName); + } } // Set a style property on a pseudo element (before/after/first-letter/first-line) for this element. @@ -911,24 +1165,35 @@ class CSSStyleDeclaration extends DynamicBindingObject // Inserts the style of the given Declaration into the current Declaration. void union(CSSStyleDeclaration declaration) { - Map properties = {} - ..addAll(_properties) - ..addAll(_pendingProperties); - - for (String propertyName in declaration._pendingProperties.keys) { - bool currentIsImportant = _importants[propertyName] ?? false; - bool otherIsImportant = declaration._importants[propertyName] ?? false; - CSSPropertyValue? currentValue = properties[propertyName]; - CSSPropertyValue? otherValue = - declaration._pendingProperties[propertyName]; + final Map incomingPending = + declaration._pendingProperties; + if (incomingPending.isEmpty && declaration._properties.isEmpty) { + return; + } + + if (_isEffectivelyEmpty) { + _adoptEffectivePropertiesFrom(declaration); + return; + } + + if (_properties.isEmpty && + _importants.isEmpty && + declaration._properties.isEmpty && + declaration._importants.isEmpty) { + _pendingProperties.addAll(incomingPending); + return; + } + + for (final MapEntry entry in incomingPending.entries) { + final String propertyName = entry.key; + final bool currentIsImportant = _importants[propertyName] ?? false; + final bool otherIsImportant = declaration._importants[propertyName] ?? false; + final CSSPropertyValue? currentValue = + _pendingProperties[propertyName] ?? _properties[propertyName]; + final CSSPropertyValue otherValue = entry.value; if ((otherIsImportant || !currentIsImportant) && currentValue != otherValue) { - // Add property. - if (otherValue != null) { - _pendingProperties[propertyName] = otherValue; - } else { - _pendingProperties.remove(propertyName); - } + _pendingProperties[propertyName] = otherValue; if (otherIsImportant) { _importants[propertyName] = true; } @@ -941,27 +1206,25 @@ class CSSStyleDeclaration extends DynamicBindingObject /// This is used by cascade layers where `!important` reverses layer order. void unionByImportance(CSSStyleDeclaration declaration, {required bool important}) { - Map properties = {} - ..addAll(_properties) - ..addAll(_pendingProperties); + final Map incomingPending = + declaration._pendingProperties; + if (incomingPending.isEmpty) { + return; + } - for (String propertyName in declaration._pendingProperties.keys) { - final bool otherIsImportant = - declaration._importants[propertyName] ?? false; + for (final MapEntry entry in incomingPending.entries) { + final String propertyName = entry.key; + final bool otherIsImportant = declaration._importants[propertyName] ?? false; if (otherIsImportant != important) continue; final bool currentIsImportant = _importants[propertyName] ?? false; - final CSSPropertyValue? currentValue = properties[propertyName]; - final CSSPropertyValue? otherValue = - declaration._pendingProperties[propertyName]; + final CSSPropertyValue? currentValue = + _pendingProperties[propertyName] ?? _properties[propertyName]; + final CSSPropertyValue otherValue = entry.value; if ((otherIsImportant || !currentIsImportant) && currentValue != otherValue) { - if (otherValue != null) { - _pendingProperties[propertyName] = otherValue; - } else { - _pendingProperties.remove(propertyName); - } + _pendingProperties[propertyName] = otherValue; if (otherIsImportant) { _importants[propertyName] = true; } @@ -991,152 +1254,244 @@ class CSSStyleDeclaration extends DynamicBindingObject return; } - List beforeRules = []; - List afterRules = []; - List firstLetterRules = []; - List firstLineRules = []; + List? beforeRules; + List? afterRules; + List? firstLetterRules; + List? firstLineRules; for (CSSStyleRule style in rules) { - for (Selector selector in style.selectorGroup.selectors) { - for (SimpleSelectorSequence sequence - in selector.simpleSelectorSequences) { - if (sequence.simpleSelector is PseudoElementSelector) { - if (sequence.simpleSelector.name == 'before') { - beforeRules.add(style); - } else if (sequence.simpleSelector.name == 'after') { - afterRules.add(style); - } else if (sequence.simpleSelector.name == 'first-letter') { - firstLetterRules.add(style); - } else if (sequence.simpleSelector.name == 'first-line') { - firstLineRules.add(style); - } - } - } + final int pseudoElementMask = style.selectorGroup.pseudoElementMask; + if ((pseudoElementMask & kPseudoElementMaskBefore) != 0) { + (beforeRules ??= []).add(style); + } + if ((pseudoElementMask & kPseudoElementMaskAfter) != 0) { + (afterRules ??= []).add(style); + } + if ((pseudoElementMask & kPseudoElementMaskFirstLetter) != 0) { + (firstLetterRules ??= []).add(style); + } + if ((pseudoElementMask & kPseudoElementMaskFirstLine) != 0) { + (firstLineRules ??= []).add(style); } } - if (beforeRules.isNotEmpty) { - pseudoBeforeStyle = cascadeMatchedStyleRules(beforeRules); - parentElement.markBeforePseudoElementNeedsUpdate(); - } else if (beforeRules.isEmpty && pseudoBeforeStyle != null) { + if (beforeRules != null) { + pseudoBeforeStyle = cascadeMatchedStyleRules(beforeRules, + cacheVersion: parentElement.ownerDocument.ruleSetVersion, + copyResult: true); + } else if (pseudoBeforeStyle != null) { pseudoBeforeStyle = null; - parentElement.markBeforePseudoElementNeedsUpdate(); } - if (afterRules.isNotEmpty) { - pseudoAfterStyle = cascadeMatchedStyleRules(afterRules); - parentElement.markAfterPseudoElementNeedsUpdate(); - } else if (afterRules.isEmpty && pseudoAfterStyle != null) { + if (afterRules != null) { + pseudoAfterStyle = cascadeMatchedStyleRules(afterRules, + cacheVersion: parentElement.ownerDocument.ruleSetVersion, + copyResult: true); + } else if (pseudoAfterStyle != null) { pseudoAfterStyle = null; - parentElement.markAfterPseudoElementNeedsUpdate(); } - if (firstLetterRules.isNotEmpty) { - pseudoFirstLetterStyle = cascadeMatchedStyleRules(firstLetterRules); - parentElement.markFirstLetterPseudoNeedsUpdate(); - } else if (firstLetterRules.isEmpty && pseudoFirstLetterStyle != null) { + if (firstLetterRules != null) { + pseudoFirstLetterStyle = cascadeMatchedStyleRules(firstLetterRules, + cacheVersion: parentElement.ownerDocument.ruleSetVersion, + copyResult: true); + } else if (pseudoFirstLetterStyle != null) { pseudoFirstLetterStyle = null; - parentElement.markFirstLetterPseudoNeedsUpdate(); } - if (firstLineRules.isNotEmpty) { - pseudoFirstLineStyle = cascadeMatchedStyleRules(firstLineRules); - parentElement.markFirstLinePseudoNeedsUpdate(); - } else if (firstLineRules.isEmpty && pseudoFirstLineStyle != null) { + if (firstLineRules != null) { + pseudoFirstLineStyle = cascadeMatchedStyleRules(firstLineRules, + cacheVersion: parentElement.ownerDocument.ruleSetVersion, + copyResult: true); + } else if (pseudoFirstLineStyle != null) { pseudoFirstLineStyle = null; - parentElement.markFirstLinePseudoNeedsUpdate(); } } // Merge the difference between the declarations and return the updated status bool merge(CSSStyleDeclaration other) { - Map properties = {} - ..addAll(_properties) - ..addAll(_pendingProperties); bool updateStatus = false; - for (String propertyName in properties.keys) { - CSSPropertyValue? prevValue = properties[propertyName]; - CSSPropertyValue? currentValue = other._pendingProperties[propertyName]; - bool currentImportant = other._importants[propertyName] ?? false; - - if (isNullOrEmptyValue(prevValue) && isNullOrEmptyValue(currentValue)) { - continue; - } else if (!isNullOrEmptyValue(prevValue) && - isNullOrEmptyValue(currentValue)) { - // Remove property. - removeProperty(propertyName, currentImportant); - updateStatus = true; - } else if (prevValue != currentValue) { - // Update property. - setProperty(propertyName, currentValue?.value, - isImportant: currentImportant, baseHref: currentValue?.baseHref); - updateStatus = true; + final Map otherPendingProperties = + other._pendingProperties; + final Map otherProperties = other._properties; + final Map otherImportants = other._importants; + + void mergePseudoStyle({ + required CSSStyleDeclaration? currentStyle, + required CSSStyleDeclaration? incomingStyle, + required void Function(CSSStyleDeclaration? value) assign, + required VoidCallback markNeedsUpdate, + required bool clearWhenMissing, + }) { + if (incomingStyle != null) { + if (currentStyle == null) { + assign(incomingStyle.cloneEffective()); + } else if (currentStyle.merge(incomingStyle)) { + markNeedsUpdate(); + } + } else if (clearWhenMissing && currentStyle != null) { + assign(null); } } - for (String propertyName in other._pendingProperties.keys) { - CSSPropertyValue? prevValue = properties[propertyName]; - CSSPropertyValue? currentValue = other._pendingProperties[propertyName]; - bool currentImportant = other._importants[propertyName] ?? false; + void mergeProperty(String propertyName, + {CSSPropertyValue? prevValue, + CSSPropertyValue? currentValue, + required bool currentImportant}) { + prevValue ??= _effectiveProperty(propertyName); + currentValue ??= + otherPendingProperties[propertyName] ?? otherProperties[propertyName]; + + if ((prevValue == null || prevValue.value.isEmpty) && + (currentValue == null || currentValue.value.isEmpty)) { + return; + } - if (isNullOrEmptyValue(prevValue) && !isNullOrEmptyValue(currentValue)) { - // Add property. - setProperty(propertyName, currentValue?.value, - isImportant: currentImportant, baseHref: currentValue?.baseHref); + if (prevValue != null && + prevValue.value.isNotEmpty && + (currentValue == null || currentValue.value.isEmpty)) { + // Remove property. + _pendingProperties[propertyName] = + CSSPropertyValue(_removedPropertyFallbackValue( + propertyName, + currentImportant, + )); updateStatus = true; + return; } - } - // Merge pseudo-element styles. Ensure target side is initialized so rules from - // 'other' are not dropped when this side is null. When pseudo rules were - // processed on the other side, clear stale pseudo styles if no rule matches. - if (other._didProcessPseudoRules) { - if (other.pseudoBeforeStyle != null) { - pseudoBeforeStyle ??= CSSStyleDeclaration(); - pseudoBeforeStyle!.merge(other.pseudoBeforeStyle!); - } else if (pseudoBeforeStyle != null) { - pseudoBeforeStyle = null; + if (currentValue == null || currentValue.value.isEmpty) { + return; } - if (other.pseudoAfterStyle != null) { - pseudoAfterStyle ??= CSSStyleDeclaration(); - pseudoAfterStyle!.merge(other.pseudoAfterStyle!); - } else if (pseudoAfterStyle != null) { - pseudoAfterStyle = null; + final bool sameSerializedValue = + prevValue != null && prevValue.value == currentValue.value; + if (sameSerializedValue && + !CSSVariable.isCSSVariableValue(currentValue.value)) { + return; } - if (other.pseudoFirstLetterStyle != null) { - pseudoFirstLetterStyle ??= CSSStyleDeclaration(); - pseudoFirstLetterStyle!.merge(other.pseudoFirstLetterStyle!); - } else if (pseudoFirstLetterStyle != null) { - pseudoFirstLetterStyle = null; + if (_queueMergedPropertyValue(propertyName, currentValue, + important: currentImportant)) { + // Update property. + updateStatus = true; } + } - if (other.pseudoFirstLineStyle != null) { - pseudoFirstLineStyle ??= CSSStyleDeclaration(); - pseudoFirstLineStyle!.merge(other.pseudoFirstLineStyle!); - } else if (pseudoFirstLineStyle != null) { - pseudoFirstLineStyle = null; + if (otherProperties.isEmpty) { + for (final String propertyName in _pendingProperties.keys.toList()) { + mergeProperty(propertyName, + prevValue: _pendingProperties[propertyName], + currentValue: otherPendingProperties[propertyName], + currentImportant: otherImportants[propertyName] == true); + } + for (final MapEntry entry in _properties.entries) { + final String propertyName = entry.key; + if (_pendingProperties.containsKey(propertyName)) continue; + mergeProperty(propertyName, + prevValue: entry.value, + currentValue: otherPendingProperties[propertyName], + currentImportant: otherImportants[propertyName] == true); + } + for (final MapEntry entry + in otherPendingProperties.entries) { + final String propertyName = entry.key; + if (_pendingProperties.containsKey(propertyName) || + _properties.containsKey(propertyName)) { + continue; + } + mergeProperty(propertyName, + currentValue: entry.value, + currentImportant: otherImportants[propertyName] == true); } } else { - if (other.pseudoBeforeStyle != null) { - pseudoBeforeStyle ??= CSSStyleDeclaration(); - pseudoBeforeStyle!.merge(other.pseudoBeforeStyle!); + for (final String propertyName in _pendingProperties.keys.toList()) { + mergeProperty(propertyName, + currentImportant: otherImportants[propertyName] == true); } - if (other.pseudoAfterStyle != null) { - pseudoAfterStyle ??= CSSStyleDeclaration(); - pseudoAfterStyle!.merge(other.pseudoAfterStyle!); + for (final String propertyName in _properties.keys) { + if (_pendingProperties.containsKey(propertyName)) continue; + mergeProperty(propertyName, + currentImportant: otherImportants[propertyName] == true); } - if (other.pseudoFirstLetterStyle != null) { - pseudoFirstLetterStyle ??= CSSStyleDeclaration(); - pseudoFirstLetterStyle!.merge(other.pseudoFirstLetterStyle!); + for (final String propertyName in otherPendingProperties.keys) { + if (_hasEffectiveProperty(propertyName)) continue; + mergeProperty(propertyName, + currentImportant: otherImportants[propertyName] == true); } - if (other.pseudoFirstLineStyle != null) { - pseudoFirstLineStyle ??= CSSStyleDeclaration(); - pseudoFirstLineStyle!.merge(other.pseudoFirstLineStyle!); + for (final String propertyName in otherProperties.keys) { + if (otherPendingProperties.containsKey(propertyName) || + _hasEffectiveProperty(propertyName)) { + continue; + } + mergeProperty(propertyName, + currentImportant: otherImportants[propertyName] == true); } } + // Merge pseudo-element styles. Ensure target side is initialized so rules from + // 'other' are not dropped when this side is null. When pseudo rules were + // processed on the other side, clear stale pseudo styles if no rule matches. + if (other._didProcessPseudoRules) { + mergePseudoStyle( + currentStyle: pseudoBeforeStyle, + incomingStyle: other.pseudoBeforeStyle, + assign: (value) => pseudoBeforeStyle = value, + markNeedsUpdate: () => target?.markBeforePseudoElementNeedsUpdate(), + clearWhenMissing: true, + ); + mergePseudoStyle( + currentStyle: pseudoAfterStyle, + incomingStyle: other.pseudoAfterStyle, + assign: (value) => pseudoAfterStyle = value, + markNeedsUpdate: () => target?.markAfterPseudoElementNeedsUpdate(), + clearWhenMissing: true, + ); + mergePseudoStyle( + currentStyle: pseudoFirstLetterStyle, + incomingStyle: other.pseudoFirstLetterStyle, + assign: (value) => pseudoFirstLetterStyle = value, + markNeedsUpdate: () => target?.markFirstLetterPseudoNeedsUpdate(), + clearWhenMissing: true, + ); + mergePseudoStyle( + currentStyle: pseudoFirstLineStyle, + incomingStyle: other.pseudoFirstLineStyle, + assign: (value) => pseudoFirstLineStyle = value, + markNeedsUpdate: () => target?.markFirstLinePseudoNeedsUpdate(), + clearWhenMissing: true, + ); + } else { + mergePseudoStyle( + currentStyle: pseudoBeforeStyle, + incomingStyle: other.pseudoBeforeStyle, + assign: (value) => pseudoBeforeStyle = value, + markNeedsUpdate: () => target?.markBeforePseudoElementNeedsUpdate(), + clearWhenMissing: false, + ); + mergePseudoStyle( + currentStyle: pseudoAfterStyle, + incomingStyle: other.pseudoAfterStyle, + assign: (value) => pseudoAfterStyle = value, + markNeedsUpdate: () => target?.markAfterPseudoElementNeedsUpdate(), + clearWhenMissing: false, + ); + mergePseudoStyle( + currentStyle: pseudoFirstLetterStyle, + incomingStyle: other.pseudoFirstLetterStyle, + assign: (value) => pseudoFirstLetterStyle = value, + markNeedsUpdate: () => target?.markFirstLetterPseudoNeedsUpdate(), + clearWhenMissing: false, + ); + mergePseudoStyle( + currentStyle: pseudoFirstLineStyle, + incomingStyle: other.pseudoFirstLineStyle, + assign: (value) => pseudoFirstLineStyle = value, + markNeedsUpdate: () => target?.markFirstLinePseudoNeedsUpdate(), + clearWhenMissing: false, + ); + } + return updateStatus; } @@ -1209,14 +1564,6 @@ class CSSStyleDeclaration extends DynamicBindingObject String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => 'CSSStyleDeclaration($cssText)'; - @override - int get hashCode => cssText.hashCode; - - @override - bool operator ==(Object other) { - return hashCode == other.hashCode; - } - @override Iterator> get iterator { return _properties.entries.followedBy(_pendingProperties.entries).iterator; @@ -1227,3 +1574,14 @@ class CSSStyleDeclaration extends DynamicBindingObject String _kebabize(String str) { return kebabizeCamelCase(str); } + +String _normalizePropertyName(String propertyName) { + final String trimmed = propertyName.trim(); + if (trimmed.isEmpty || trimmed.startsWith('--')) { + return trimmed; + } + if (trimmed.contains('-')) { + return camelize(trimmed.toLowerCase()); + } + return trimmed; +} diff --git a/webf/lib/src/css/style_sheet.dart b/webf/lib/src/css/style_sheet.dart index 2ced8e86b9..067a7fb0f7 100644 --- a/webf/lib/src/css/style_sheet.dart +++ b/webf/lib/src/css/style_sheet.dart @@ -7,7 +7,6 @@ * Copyright (C) 2022-2024 The WebF authors. All rights reserved. */ import 'package:webf/css.dart'; -import 'package:quiver/core.dart'; abstract class StyleSheet {} @@ -36,8 +35,10 @@ class CSSStyleSheet implements StyleSheet, Comparable { }) { // Parse with this stylesheet's href so relative URLs in inserted rules // resolve against the stylesheet URL, not the document URL. - List rules = CSSParser(text, href: href) - .parseRules(windowWidth: windowWidth, windowHeight: windowHeight, isDarkMode: isDarkMode); + List rules = CSSParser(text, href: href).parseRules( + windowWidth: windowWidth, + windowHeight: windowHeight, + isDarkMode: isDarkMode); if (index < 0 || index > cssRules.length) { throw RangeError.index(index, cssRules, 'index'); } @@ -54,28 +55,41 @@ class CSSStyleSheet implements StyleSheet, Comparable { } /// Synchronously replaces the content of the stylesheet with the content passed into it. - replaceSync(String text, {required double windowWidth, required double windowHeight, required bool? isDarkMode}) { + replaceSync(String text, + {required double windowWidth, + required double windowHeight, + required bool? isDarkMode}) { cssRules.clear(); // Preserve href so relative URLs continue to resolve correctly after replace. - List rules = CSSParser(text, href: href) - .parseRules(windowWidth: windowWidth, windowHeight: windowHeight, isDarkMode: isDarkMode); + List rules = CSSParser(text, href: href).parseRules( + windowWidth: windowWidth, + windowHeight: windowHeight, + isDarkMode: isDarkMode); cssRules.addAll(rules); for (final rule in rules) { _assignParentStyleSheetRecursive(rule, this); } } - @override - bool operator ==(Object other) { - return hashCode == other.hashCode; - } + int get structuralHashCode => Object.hash( + type, + disabled, + href, + cssRuleListStructuralHash(cssRules), + ); - @override - int get hashCode => hashObjects(cssRules); + bool structurallyEquals(CSSStyleSheet other) { + if (identical(this, other)) return true; + return type == other.type && + disabled == other.disabled && + href == other.href && + cssRuleListsStructurallyEqual(cssRules, other.cssRules); + } CSSStyleSheet clone() { final clonedRules = cssRules.map(_cloneRuleForDiff).toList(growable: false); - CSSStyleSheet sheet = CSSStyleSheet(List.from(clonedRules), disabled: disabled, href: href); + CSSStyleSheet sheet = + CSSStyleSheet(List.from(clonedRules), disabled: disabled, href: href); for (final rule in sheet.cssRules) { _assignParentStyleSheetRecursive(rule, sheet); } @@ -87,10 +101,11 @@ class CSSStyleSheet implements StyleSheet, Comparable { if (other is! CSSStyleSheet) { return 0; } - return hashCode.compareTo(other.hashCode); + return structuralHashCode.compareTo(other.structuralHashCode); } - static void _assignParentStyleSheetRecursive(CSSRule rule, CSSStyleSheet sheet) { + static void _assignParentStyleSheetRecursive( + CSSRule rule, CSSStyleSheet sheet) { rule.parentStyleSheet = sheet; if (rule is CSSLayerBlockRule) { for (final child in rule.cssRules) { @@ -112,7 +127,9 @@ class CSSStyleSheet implements StyleSheet, Comparable { } if (rule is CSSLayerStatementRule) { return CSSLayerStatementRule( - rule.layerNamePaths.map((p) => List.from(p)).toList(growable: false), + rule.layerNamePaths + .map((p) => List.from(p)) + .toList(growable: false), ); } return rule; diff --git a/webf/lib/src/css/transition.dart b/webf/lib/src/css/transition.dart index 211e0d07a0..1e1d80f3db 100644 --- a/webf/lib/src/css/transition.dart +++ b/webf/lib/src/css/transition.dart @@ -637,6 +637,7 @@ mixin CSSTransitionMixin on RenderStyle { set transitionProperty(List? value) { _transitionProperty = value; _effectiveTransitions = null; + _hasRunnableTransitions = null; // https://github.com/WebKit/webkit/blob/master/Source/WebCore/animation/AnimationTimeline.cpp#L257 // Any animation found in previousAnimations but not found in newAnimations is not longer current and should be canceled. // @HACK: There are no way to get animationList from styles(Webkit will create an new Style object when style changes, but Kraken not). @@ -662,6 +663,7 @@ mixin CSSTransitionMixin on RenderStyle { set transitionDuration(List? value) { _transitionDuration = value; _effectiveTransitions = null; + _hasRunnableTransitions = null; } @override @@ -682,6 +684,7 @@ mixin CSSTransitionMixin on RenderStyle { set transitionTimingFunction(List? value) { _transitionTimingFunction = value; _effectiveTransitions = null; + _hasRunnableTransitions = null; } @override @@ -702,12 +705,14 @@ mixin CSSTransitionMixin on RenderStyle { set transitionDelay(List? value) { _transitionDelay = value; _effectiveTransitions = null; + _hasRunnableTransitions = null; } @override List get transitionDelay => _transitionDelay ?? const [_zeroSeconds]; Map? _effectiveTransitions; + bool? _hasRunnableTransitions; Map get effectiveTransitions { if (_effectiveTransitions != null) return _effectiveTransitions!; @@ -724,6 +729,31 @@ mixin CSSTransitionMixin on RenderStyle { return _effectiveTransitions = transitions; } + bool get hasRunnableTransitions { + final bool? cached = _hasRunnableTransitions; + if (cached != null) return cached; + + for (final List transitionOptions in effectiveTransitions.values) { + final int? duration = CSSTime.parseTime(transitionOptions[0]); + if (duration != null && duration != 0) { + return _hasRunnableTransitions = true; + } + } + return _hasRunnableTransitions = false; + } + + bool mayTransitionProperty(String property) { + if (!_didFlushStyleWhileConnected) return false; + if (CSSVariable.isCSSSVariableProperty(property)) return false; + if (!hasRunnableTransitions) return false; + if (!hasRenderBox() || !isBoxModelHaveSize()) return false; + if (cssTransitionHandlers[property] == null) return false; + + final String key = _canonicalTransitionKey(property); + final Map transitions = effectiveTransitions; + return transitions.containsKey(key) || transitions.containsKey(ALL); + } + bool shouldTransition(String property, String? prevValue, String nextValue) { if (DebugFlags.shouldLogTransitionForProp(property)) { cssLogger.info('[transition][check] property=$property prev=${prevValue ?? 'null'} next=$nextValue'); @@ -782,7 +812,9 @@ mixin CSSTransitionMixin on RenderStyle { } final String key = _canonicalTransitionKey(property); - final bool configured = effectiveTransitions.containsKey(key) || effectiveTransitions.containsKey(ALL); + final Map transitions = effectiveTransitions; + final bool configured = + transitions.containsKey(key) || transitions.containsKey(ALL); if (!configured) { if (DebugFlags.shouldLogTransitionForProp(property)) { cssLogger @@ -791,18 +823,11 @@ mixin CSSTransitionMixin on RenderStyle { return false; } if (DebugFlags.shouldLogTransitionForProp(property)) { - final List? opts = effectiveTransitions[key] ?? effectiveTransitions[ALL]; + final List? opts = transitions[key] ?? transitions[ALL]; cssLogger.info( '[transition][check] property=$property key=$key opts=${opts != null ? '{duration: ${opts[0]}, easing: ${opts[1]}, delay: ${opts[2]}}' : 'null'}'); } - bool shouldTransition = false; - // Transition will be disabled when all transition has transitionDuration as 0. - effectiveTransitions.forEach((String transitionKey, List transitionOptions) { - int? duration = CSSTime.parseTime(transitionOptions[0]); - if (duration != null && duration != 0) { - shouldTransition = true; - } - }); + final bool shouldTransition = hasRunnableTransitions; if (DebugFlags.shouldLogTransitionForProp(property)) { cssLogger.info( '[transition][check] property=$property configured key=$key result=$shouldTransition'); diff --git a/webf/lib/src/css/values/length.dart b/webf/lib/src/css/values/length.dart index fb8cd07126..cd50bae4e5 100644 --- a/webf/lib/src/css/values/length.dart +++ b/webf/lib/src/css/values/length.dart @@ -291,7 +291,7 @@ class CSSLengthValue { final bool parentIsFlexContainer = flexParent != null && (flexParent.effectiveDisplay == CSSDisplay.flex || flexParent.effectiveDisplay == CSSDisplay.inlineFlex); if (parentIsFlexContainer && rs.width.isAuto && rs.flexGrow == 0 && rs.contentBoxLogicalWidth == null) { - final FlexDirection dir = flexParent!.flexDirection; + final FlexDirection dir = flexParent.flexDirection; final bool parentIsColumn = dir == FlexDirection.column || dir == FlexDirection.columnReverse; if (parentIsColumn) { final AlignSelf self = rs.alignSelf; @@ -1067,6 +1067,185 @@ class CSSLengthValue { 'CSSLengthValue(value: $value, unit: $type, computedValue: $computedValue, calcValue: $calcValue)'; } +class CSSParsedLengthValue { + final double? value; + final CSSLengthType type; + + const CSSParsedLengthValue(this.value, this.type); + + static const CSSParsedLengthValue zero = + CSSParsedLengthValue(0, CSSLengthType.PX); + static const CSSParsedLengthValue auto = + CSSParsedLengthValue(null, CSSLengthType.AUTO); + static const CSSParsedLengthValue initial = + CSSParsedLengthValue(null, CSSLengthType.INITIAL); + static const CSSParsedLengthValue normal = + CSSParsedLengthValue(null, CSSLengthType.NORMAL); + static const CSSParsedLengthValue none = + CSSParsedLengthValue(null, CSSLengthType.NONE); + static const CSSParsedLengthValue content = + CSSParsedLengthValue(null, CSSLengthType.CONTENT); + static const CSSParsedLengthValue minContent = + CSSParsedLengthValue(null, CSSLengthType.MIN_CONTENT); + static const CSSParsedLengthValue maxContent = + CSSParsedLengthValue(null, CSSLengthType.MAX_CONTENT); + static const CSSParsedLengthValue fitContent = + CSSParsedLengthValue(null, CSSLengthType.FIT_CONTENT); + + static CSSParsedLengthValue? tryParse(String text) { + double? parsedValue; + CSSLengthType unit = CSSLengthType.PX; + if (text == ZERO) { + return zero; + } else if (text == INITIAL) { + return initial; + } else if (text == INHERIT) { + return null; + } else if (text == AUTO) { + return auto; + } else if (text == NONE) { + return none; + } else if (text == NORMAL) { + return normal; + } else if (text.toLowerCase() == 'content') { + return content; + } else if (text.toLowerCase() == 'min-content') { + return minContent; + } else if (text.toLowerCase() == 'max-content') { + return maxContent; + } else if (text.toLowerCase() == 'fit-content') { + return fitContent; + } else if (text.endsWith(REM)) { + parsedValue = double.tryParse(text.split(REM)[0]); + unit = CSSLengthType.REM; + } else if (text.endsWith(EM)) { + parsedValue = double.tryParse(text.split(EM)[0]); + unit = CSSLengthType.EM; + } else if (text.endsWith(EX)) { + parsedValue = double.tryParse(text.split(EX)[0]); + unit = CSSLengthType.EX; + } else if (text.endsWith(CH)) { + parsedValue = double.tryParse(text.split(CH)[0]); + unit = CSSLengthType.CH; + } else if (text.endsWith(RPX)) { + parsedValue = double.tryParse(text.split(RPX)[0]); + unit = CSSLengthType.RPX; + } else if (text.endsWith(PX)) { + parsedValue = double.tryParse(text.split(PX)[0]); + } else if (text.endsWith(VW)) { + parsedValue = double.tryParse(text.split(VW)[0]); + if (parsedValue != null) { + parsedValue = parsedValue / 100; + } + unit = CSSLengthType.VW; + } else if (text.endsWith(VH)) { + parsedValue = double.tryParse(text.split(VH)[0]); + if (parsedValue != null) { + parsedValue = parsedValue / 100; + } + unit = CSSLengthType.VH; + } else if (text.endsWith(CM)) { + parsedValue = double.tryParse(text.split(CM)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1cm; + } + } else if (text.endsWith(MM)) { + parsedValue = double.tryParse(text.split(MM)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1mm; + } + } else if (text.endsWith(PC)) { + parsedValue = double.tryParse(text.split(PC)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1pc; + } + } else if (text.endsWith(PT)) { + parsedValue = double.tryParse(text.split(PT)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1pt; + } + } else if (text.endsWith(VMIN)) { + parsedValue = double.tryParse(text.split(VMIN)[0]); + if (parsedValue != null) { + parsedValue = parsedValue / 100; + } + unit = CSSLengthType.VMIN; + } else if (text.endsWith(VMAX)) { + parsedValue = double.tryParse(text.split(VMAX)[0]); + if (parsedValue != null) { + parsedValue = parsedValue / 100; + } + unit = CSSLengthType.VMAX; + } else if (text.endsWith(IN)) { + parsedValue = double.tryParse(text.split(IN)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1in; + } + } else if (text.endsWith(Q)) { + parsedValue = double.tryParse(text.split(Q)[0]); + if (parsedValue != null) { + parsedValue = parsedValue * _1Q; + } + } else if (text.endsWith(PERCENTAGE)) { + parsedValue = double.tryParse(text.split(PERCENTAGE)[0]); + if (parsedValue != null) { + parsedValue = parsedValue / 100; + } + unit = CSSLengthType.PERCENTAGE; + } else if (CSSFunction.isFunction(text)) { + return null; + } else { + parsedValue = double.tryParse(text); + } + + if (parsedValue == 0 && unit != CSSLengthType.PERCENTAGE) { + return zero; + } + if (parsedValue == null) { + return null; + } + + return CSSParsedLengthValue(parsedValue, unit); + } + + CSSLengthValue resolve(RenderStyle renderStyle, String propertyName, + [Axis? axisType]) { + switch (type) { + case CSSLengthType.PX: + if (value == 0) { + return CSSLengthValue.zero; + } + return CSSLengthValue(value, CSSLengthType.PX); + case CSSLengthType.AUTO: + return CSSLengthValue.auto; + case CSSLengthType.NONE: + return CSSLengthValue.none; + case CSSLengthType.NORMAL: + return CSSLengthValue.normal; + case CSSLengthType.INITIAL: + return CSSLengthValue.initial; + case CSSLengthType.CONTENT: + case CSSLengthType.MIN_CONTENT: + case CSSLengthType.MAX_CONTENT: + case CSSLengthType.FIT_CONTENT: + return CSSLengthValue(value, type, renderStyle, propertyName, axisType); + case CSSLengthType.UNKNOWN: + return CSSLengthValue.unknown; + case CSSLengthType.RPX: + case CSSLengthType.EM: + case CSSLengthType.EX: + case CSSLengthType.CH: + case CSSLengthType.REM: + case CSSLengthType.VH: + case CSSLengthType.VW: + case CSSLengthType.VMIN: + case CSSLengthType.VMAX: + case CSSLengthType.PERCENTAGE: + return CSSLengthValue(value, type, renderStyle, propertyName, axisType); + } + } +} + // Cache computed length value during perform layout. // format: { hashCode: { renderStyleKey: renderStyleValue } } final LinkedLruHashMap> _cachedComputedValue = LinkedLruHashMap(maximumSize: 500); diff --git a/webf/lib/src/css/values/position.dart b/webf/lib/src/css/values/position.dart index 0139cb002d..cead138283 100644 --- a/webf/lib/src/css/values/position.dart +++ b/webf/lib/src/css/values/position.dart @@ -69,7 +69,6 @@ class CSSPosition { if (_isVertKW(a) && _isHorizKW(b)) { final List swapped = [b, a]; _cachedParsedPosition[input] = swapped; - cssLogger.finer('[CSSPosition] Parsed background-position "$input" => x="${swapped[0]}", y="${swapped[1]}"'); return swapped; } } @@ -77,14 +76,12 @@ class CSSPosition { if (_isHorizKW(a) && _looksNumeric(b)) { final List pair = [a, b]; _cachedParsedPosition[input] = pair; - cssLogger.finer('[CSSPosition] Parsed background-position "$input" => x="${pair[0]}", y="${pair[1]}"'); return pair; } // Numeric followed by vertical keyword => x=numeric, y=keyword. if (_looksNumeric(a) && _isVertKW(b)) { final List pair = [a, b]; _cachedParsedPosition[input] = pair; - cssLogger.finer('[CSSPosition] Parsed background-position "$input" => x="${pair[0]}", y="${pair[1]}"'); return pair; } } @@ -179,7 +176,6 @@ class CSSPosition { final List result = [xValue, yValue]; _cachedParsedPosition[input] = result; - cssLogger.finer('[CSSPosition] Parsed background-position "$input" => x="$xValue", y="$yValue"'); return result; } diff --git a/webf/lib/src/dom/document.dart b/webf/lib/src/dom/document.dart index 386304bb8b..1278b94364 100644 --- a/webf/lib/src/dom/document.dart +++ b/webf/lib/src/dom/document.dart @@ -62,6 +62,52 @@ class _PendingInteractivePseudoUpdate { } } +@visibleForTesting +List> pruneNestedDirtyStyleElements( + Iterable> dirtyElements) { + final List> dirtyList = dirtyElements.toList(); + if (dirtyList.length <= 1) { + return dirtyList; + } + + final Set rebuildNestedAddresses = {}; + for (final dirty in dirtyList) { + if (!dirty.value) { + continue; + } + final Pointer? pointer = dirty.key.pointer; + if (pointer != null) { + rebuildNestedAddresses.add(pointer.address); + } + } + + if (rebuildNestedAddresses.isEmpty) { + return dirtyList; + } + + final List> effectiveDirty = + >[]; + for (final dirty in dirtyList) { + bool coveredByAncestorSubtreeRecalc = false; + Element? ancestor = dirty.key.parentElement; + while (ancestor != null) { + final Pointer? ancestorPtr = ancestor.pointer; + if (ancestorPtr != null && + rebuildNestedAddresses.contains(ancestorPtr.address)) { + coveredByAncestorSubtreeRecalc = true; + break; + } + ancestor = ancestor.parentElement; + } + + if (!coveredByAncestorSubtreeRecalc) { + effectiveDirty.add(dirty); + } + } + + return effectiveDirty; +} + class Document extends ContainerNode { final WebFController controller; late AnimationTimeline animationTimeline; @@ -588,8 +634,9 @@ class Document extends ContainerNode { } dynamic querySelector(List args) { - if (args[0].runtimeType == String && (args[0] as String).isEmpty) + if (args[0].runtimeType == String && (args[0] as String).isEmpty) { return null; + } return query_selector.querySelector(this, args.first); } @@ -624,8 +671,9 @@ class Document extends ContainerNode { } dynamic getElementById(List args) { - if (args[0].runtimeType == String && (args[0] as String).isEmpty) + if (args[0].runtimeType == String && (args[0] as String).isEmpty) { return null; + } final elements = elementsByID[args.first]; if (elements == null || elements.isEmpty) { return null; @@ -907,12 +955,28 @@ class Document extends ContainerNode { if (recalcFromRoot) { documentElement?.recalculateStyle(rebuildNested: true); } else { + final List> resolvedDirty = + >[]; for (int address in _styleDirtyElements) { Element? element = ownerView .getBindingObject(Pointer.fromAddress(address)) as Element?; + if (element == null) { + continue; + } final bool rebuildNested = _styleDirtyElementsRebuildNested.contains(address); - element?.recalculateStyle(rebuildNested: rebuildNested); + resolvedDirty.add(MapEntry(element, rebuildNested)); + } + + // Child-list batching can mark both an ancestor and many descendants + // dirty in the same microtask. If an ancestor is already going to + // rebuild its subtree, separately recalc-ing descendants repeats the + // same recursive walk and dominates large popup mounts. + final List> effectiveDirty = + pruneNestedDirtyStyleElements(resolvedDirty); + + for (final dirty in effectiveDirty) { + dirty.key.recalculateStyle(rebuildNested: dirty.value); } } _styleDirtyElements.clear(); diff --git a/webf/lib/src/dom/element.dart b/webf/lib/src/dom/element.dart index 676fb826c6..974887d2bd 100644 --- a/webf/lib/src/dom/element.dart +++ b/webf/lib/src/dom/element.dart @@ -1800,6 +1800,7 @@ abstract class Element extends ContainerNode void _markPseudoStateDirty() { _matchedRulesLRU = null; + _matchedRulesLRUVersion = -1; ownerDocument.markElementStyleDirty(this, reason: 'pseudo-state'); _markHasSelectorsDirty(); } @@ -1942,9 +1943,14 @@ abstract class Element extends ContainerNode cssLogger.info( '[style][apply] $tagName.$property present="$present" baseHref=${baseHref ?? 'null'}'); } - dynamic value = present.isEmpty - ? null - : renderStyle.resolveValue(property, present, baseHref: baseHref); + dynamic value; + if (present.isEmpty) { + value = null; + } else if (CSSVariable.isCSSSVariableProperty(property)) { + value = present; + } else { + value = renderStyle.resolveValue(property, present, baseHref: baseHref); + } setRenderStyleProperty(property, value); } @@ -2010,62 +2016,60 @@ abstract class Element extends ContainerNode } } - void _applySheetStyle(CSSStyleDeclaration style) { - CSSStyleDeclaration matchRule = _collectMatchedRulesWithCache(); + void _applySheetStyle(CSSStyleDeclaration style, + {SelectorAncestorTokenSet? ancestorTokens, + query_selector.SelectorEvaluator? evaluator}) { + CSSStyleDeclaration matchRule = + _collectMatchedRulesWithCache( + ancestorTokens: ancestorTokens, evaluator: evaluator); style.union(matchRule); } // Lightweight memoization for matched rules (per-element LRU cache). // Guarded by DebugFlags.enableCssMemoization. // Capacity kept intentionally tiny to bound memory (default via DebugFlags). - LinkedHashMap<_MatchFingerprint, _MatchedRulesCacheEntry>? _matchedRulesLRU; + LinkedHashMap<_MatchFingerprint, CSSStyleDeclaration>? _matchedRulesLRU; + int _matchedRulesLRUVersion = -1; - CSSStyleDeclaration _collectMatchedRulesWithCache() { + CSSStyleDeclaration _collectMatchedRulesWithCache( + {SelectorAncestorTokenSet? ancestorTokens, + query_selector.SelectorEvaluator? evaluator}) { final RuleSet ruleSet = ownerDocument.ruleSet; if (!DebugFlags.enableCssMemoization) { _matchedRulesLRU = null; - return _elementRuleCollector.collectionFromRuleSet(ruleSet, this); + _matchedRulesLRUVersion = -1; + return _elementRuleCollector.collectionFromRuleSet(ruleSet, this, + ancestorTokens: ancestorTokens, evaluator: evaluator); } final int version = ownerDocument.ruleSetVersion; final _MatchFingerprint fingerprint = _computeMatchFingerprint(ruleSet); - final LinkedHashMap<_MatchFingerprint, _MatchedRulesCacheEntry> cache = + final LinkedHashMap<_MatchFingerprint, CSSStyleDeclaration> cache = _matchedRulesLRU ??= - LinkedHashMap<_MatchFingerprint, _MatchedRulesCacheEntry>(); - - // Prune stale entries from previous RuleSet versions (capacity is tiny). - if (cache.isNotEmpty) { - final List<_MatchFingerprint> toRemove = <_MatchFingerprint>[]; - cache.forEach((fp, entry) { - if (entry.version != version) toRemove.add(fp); - }); - for (final fp in toRemove) { - cache.remove(fp); - } + LinkedHashMap<_MatchFingerprint, CSSStyleDeclaration>(); + if (_matchedRulesLRUVersion != version) { + cache.clear(); + _matchedRulesLRUVersion = version; } - final _MatchedRulesCacheEntry? hitEntry = cache[fingerprint]; - if (hitEntry != null && hitEntry.version == version) { + final CSSStyleDeclaration? hitEntry = cache.remove(fingerprint); + if (hitEntry != null) { // LRU refresh: move to most-recent by reinserting. - cache.remove(fingerprint); cache[fingerprint] = hitEntry; - return hitEntry.style; + return hitEntry; } // Cache miss: compute and insert, enforce capacity with LRU eviction. - final CSSStyleDeclaration computed = - _elementRuleCollector.collectionFromRuleSet(ruleSet, this); final int capRaw = DebugFlags.cssMatchedRulesCacheCapacity; final int capacity = capRaw <= 0 ? 1 : capRaw; + final CSSStyleDeclaration computed = + _elementRuleCollector.collectionFromRuleSet(ruleSet, this, + ancestorTokens: ancestorTokens, evaluator: evaluator); if (cache.length >= capacity) { final _MatchFingerprint oldest = cache.keys.first; cache.remove(oldest); } - cache[fingerprint] = _MatchedRulesCacheEntry( - version: version, - fingerprint: fingerprint, - style: computed, - ); + cache[fingerprint] = computed; return computed; } @@ -2235,6 +2239,14 @@ abstract class Element extends ContainerNode String propertyName, String? prevValue, String currentValue, {String? baseHref}) { final String property = _normalizeStylePropertyName(propertyName); + final bool pending = _pendingTransitionProps.contains(property); + final bool running = renderStyle.isTransitionRunning(property); + if (!pending && + !running && + !renderStyle.mayTransitionProperty(property)) { + setRenderStyle(property, currentValue, baseHref: baseHref); + return; + } // Identify color-bearing properties up front so we can normalize // both the previous and current values to concrete colors for // transition decisions, independent of any var(...) indirection. @@ -2292,8 +2304,6 @@ abstract class Element extends ContainerNode // running, avoid applying the immediate setRenderStyle which would clobber // the animation-driven value. The scheduled/active transition will drive // updates. - final bool pending = _pendingTransitionProps.contains(property); - final bool running = renderStyle.isTransitionRunning(property); if (DebugFlags.shouldLogTransitionForProp(property)) { cssLogger.info( '[style][route] $tagName.$property pending=$pending running=$running'); @@ -2439,22 +2449,33 @@ abstract class Element extends ContainerNode style.clearPseudoStyle(type); } - void _applyPseudoStyle(CSSStyleDeclaration style) { - List pseudoRules = - _elementRuleCollector.matchedPseudoRules(ownerDocument.ruleSet, this); + void _applyPseudoStyle(CSSStyleDeclaration style, + {SelectorAncestorTokenSet? ancestorTokens, + query_selector.SelectorEvaluator? evaluator}) { + List pseudoRules = _elementRuleCollector.matchedPseudoRules( + ownerDocument.ruleSet, this, + ancestorTokens: ancestorTokens, evaluator: evaluator); style.handlePseudoRules(this, pseudoRules); } void applyStyle(CSSStyleDeclaration style) { + final SelectorAncestorTokenSet? ancestorTokens = + DebugFlags.enableCssAncestryFastPath + ? _elementRuleCollector.buildAncestorTokens(this) + : null; + final query_selector.SelectorEvaluator selectorEvaluator = + query_selector.SelectorEvaluator(); // Apply default style. applyDefaultStyle(style); // Init display from style directly cause renderStyle is not flushed yet. renderStyle.initDisplay(style); applyAttributeStyle(style); - _applySheetStyle(style); + _applySheetStyle(style, + ancestorTokens: ancestorTokens, evaluator: selectorEvaluator); applyInlineStyle(style); - _applyPseudoStyle(style); + _applyPseudoStyle(style, + ancestorTokens: ancestorTokens, evaluator: selectorEvaluator); } void applyAttributeStyle(CSSStyleDeclaration style) { @@ -2495,14 +2516,16 @@ abstract class Element extends ContainerNode // callers request a nested rebuild. if (rebuildNested) { _matchedRulesLRU = null; + _matchedRulesLRUVersion = -1; } // Diff style. CSSStyleDeclaration newStyle = CSSStyleDeclaration(); applyStyle(newStyle); bool hasInheritedPendingProperty = false; + final bool hadPendingProperties = style.hasPendingProperties; final bool merged = style.merge(newStyle); - if (merged) { + if (merged || hadPendingProperties) { hasInheritedPendingProperty = style.hasInheritedPendingProperty; style.flushPendingProperties(); } @@ -3016,22 +3039,6 @@ abstract class Element extends ContainerNode } } -class _MatchedRulesCacheEntry { - final int version; - final _MatchFingerprint fingerprint; - final CSSStyleDeclaration style; - - _MatchedRulesCacheEntry({ - required this.version, - required this.fingerprint, - required this.style, - }); - - bool matches({required int version, required _MatchFingerprint fingerprint}) { - return this.version == version && this.fingerprint == fingerprint; - } -} - class _MatchFingerprint { final String tagName; final String id; diff --git a/webf/lib/src/dom/style_node_manager.dart b/webf/lib/src/dom/style_node_manager.dart index 6a7d7f7ac8..e549d298f5 100644 --- a/webf/lib/src/dom/style_node_manager.dart +++ b/webf/lib/src/dom/style_node_manager.dart @@ -9,7 +9,6 @@ import 'dart:math' as math; import 'package:webf/src/foundation/debug_flags.dart'; import 'package:webf/src/foundation/logger.dart'; -import 'package:collection/collection.dart'; import 'package:webf/css.dart'; import 'package:webf/dom.dart'; @@ -31,7 +30,8 @@ class StyleNodeManager { bool get hasPendingStyleSheet => _pendingStyleSheets.isNotEmpty; int get pendingStyleSheetCount => _pendingStyleSheets.length; bool _isStyleSheetCandidateNodeChanged = false; - bool get isStyleSheetCandidateNodeChanged => _isStyleSheetCandidateNodeChanged; + bool get isStyleSheetCandidateNodeChanged => + _isStyleSheetCandidateNodeChanged; final Document document; @@ -58,7 +58,8 @@ class StyleNodeManager { // Determine an appropriate insertion point. for (int i = _styleSheetCandidateNodes.length - 1; i >= 0; i--) { - DocumentPosition position = _styleSheetCandidateNodes[i].compareDocumentPosition(node); + DocumentPosition position = + _styleSheetCandidateNodes[i].compareDocumentPosition(node); if (position == DocumentPosition.FOLLOWING) { _styleSheetCandidateNodes.insert(i + 1, node); _isStyleSheetCandidateNodeChanged = true; @@ -81,26 +82,34 @@ class StyleNodeManager { } _pendingStyleSheets.add(styleSheet); if (DebugFlags.enableCssMultiStyleTrace) { - cssLogger.info('[trace][multi-style][add] pending=${_pendingStyleSheets.length} candidates=${_styleSheetCandidateNodes.length} ' 'hash=${styleSheet.hashCode}'); + cssLogger.info( + '[trace][multi-style][add] pending=${_pendingStyleSheets.length} candidates=${_styleSheetCandidateNodes.length} ' + 'hash=${styleSheet.hashCode}'); } } void removePendingStyleSheet(CSSStyleSheet styleSheet) { - _pendingStyleSheets.removeWhere((element) => element == styleSheet); - + _pendingStyleSheets.remove(styleSheet); } // TODO(jiangzhou): cache stylesheet bool updateActiveStyleSheets({bool rebuild = false}) { List newSheets = _collectActiveStyleSheets(); if (DebugFlags.enableCssMultiStyleTrace) { - cssLogger.info('[trace][multi-style][update] pending=${_pendingStyleSheets.length} candidates=${_styleSheetCandidateNodes.length} rebuild=$rebuild'); + cssLogger.info( + '[trace][multi-style][update] pending=${_pendingStyleSheets.length} candidates=${_styleSheetCandidateNodes.length} rebuild=$rebuild'); } - newSheets = newSheets.where((element) => element.cssRules.isNotEmpty).toList(); + newSheets = + newSheets.where((element) => element.cssRules.isNotEmpty).toList(); if (rebuild == false) { - RuleSet changedRuleSet = analyzeStyleSheetChangeRuleSet(document.styleSheets, newSheets); - final bool shouldForceHtml = !changedRuleSet.tagRules.containsKey('HTML') && _sheetsContainTagDifference(document.styleSheets, newSheets, 'HTML'); - final bool shouldForceBody = !changedRuleSet.tagRules.containsKey('BODY') && _sheetsContainTagDifference(document.styleSheets, newSheets, 'BODY'); + RuleSet changedRuleSet = + analyzeStyleSheetChangeRuleSet(document.styleSheets, newSheets); + final bool shouldForceHtml = !changedRuleSet.tagRules + .containsKey('HTML') && + _sheetsContainTagDifference(document.styleSheets, newSheets, 'HTML'); + final bool shouldForceBody = !changedRuleSet.tagRules + .containsKey('BODY') && + _sheetsContainTagDifference(document.styleSheets, newSheets, 'BODY'); if (changedRuleSet.isEmpty) { _pendingStyleSheets.clear(); _isStyleSheetCandidateNodeChanged = false; @@ -193,7 +202,8 @@ class StyleNodeManager { final Node node = stack.removeLast(); if (node is Element) { fallbackVisited++; - final rules = collector.matchedRulesForInvalidate(changedRuleSet, node); + final rules = + collector.matchedRulesForInvalidate(changedRuleSet, node); if (rules.isNotEmpty) { addReason(node, 'fallback'); } @@ -207,7 +217,8 @@ class StyleNodeManager { // Ensure document root elements pick up tag-based changes immediately. if (hasTagRules) { final HTMLElement? root = document.documentElement; - if (root != null && changedRuleSet.tagRules.containsKey(root.tagName.toUpperCase())) { + if (root != null && + changedRuleSet.tagRules.containsKey(root.tagName.toUpperCase())) { addReason(root, 'tag:${root.tagName}'); } final BodyElement? body = document.bodyElement; @@ -240,7 +251,10 @@ class StyleNodeManager { // replaced (e.g. integration test resets). Ignore disconnected nodes to // avoid leaking stale stylesheets across document lifecycles. if (!node.isConnected) continue; - if (node is LinkElement && !node.disabled && !node.loading && node.styleSheet != null) { + if (node is LinkElement && + !node.disabled && + !node.loading && + node.styleSheet != null) { final sheet = node.styleSheet!; if (!sheet.disabled) { styleSheetsForStyleSheetsList.add(sheet); @@ -255,8 +269,8 @@ class StyleNodeManager { return styleSheetsForStyleSheetsList; } - - RuleSet analyzeStyleSheetChangeRuleSet(List oldSheets, List newSheets) { + RuleSet analyzeStyleSheetChangeRuleSet( + List oldSheets, List newSheets) { RuleSet ruleSet = RuleSet(document); final oldSheetsCount = oldSheets.length; @@ -264,63 +278,82 @@ class StyleNodeManager { final minCount = math.min(oldSheetsCount, newSheetsCount); - Function equals = ListEquality().equals; - int index = 0; - for (; index < minCount && oldSheets[index] == newSheets[index]; index++) { - if (equals(oldSheets[index].cssRules, newSheets[index].cssRules)) { + for (; index < minCount; index++) { + final CSSStyleSheet oldSheet = oldSheets[index]; + final CSSStyleSheet newSheet = newSheets[index]; + if (identical(oldSheet, newSheet) || + oldSheet.structurallyEquals(newSheet)) { continue; } - ruleSet.addRules(newSheets[index].cssRules, baseHref: newSheets[index].href); - ruleSet.addRules(oldSheets[index].cssRules, baseHref: oldSheets[index].href); + ruleSet.addRules(newSheet.cssRules, baseHref: newSheet.href); + ruleSet.addRules(oldSheet.cssRules, baseHref: oldSheet.href); + index++; + break; } if (index == oldSheetsCount) { for (; index < newSheetsCount; index++) { - ruleSet.addRules(newSheets[index].cssRules, baseHref: newSheets[index].href); + ruleSet.addRules(newSheets[index].cssRules, + baseHref: newSheets[index].href); } return ruleSet; } if (index == newSheetsCount) { for (; index < oldSheetsCount; index++) { - ruleSet.addRules(oldSheets[index].cssRules, baseHref: oldSheets[index].href); + ruleSet.addRules(oldSheets[index].cssRules, + baseHref: oldSheets[index].href); } return ruleSet; } - List mergeSorted = []; - mergeSorted.addAll(oldSheets.sublist(index)); - mergeSorted.addAll(newSheets.sublist(index)); - mergeSorted.sort(); + final Map> unmatchedOldByHash = + >{}; + for (int oldIndex = index; oldIndex < oldSheetsCount; oldIndex++) { + final CSSStyleSheet sheet = oldSheets[oldIndex]; + unmatchedOldByHash + .putIfAbsent(sheet.structuralHashCode, () => []) + .add(sheet); + } - for (int index = 0; index < mergeSorted.length; index++) { - CSSStyleSheet sheet = mergeSorted[index]; - if (index + 1 < mergeSorted.length) { - ++index; + final List unmatchedNew = []; + for (int newIndex = index; newIndex < newSheetsCount; newIndex++) { + final CSSStyleSheet sheet = newSheets[newIndex]; + final List? bucket = + unmatchedOldByHash[sheet.structuralHashCode]; + if (bucket == null) { + unmatchedNew.add(sheet); + continue; } - CSSStyleSheet sheet1 = mergeSorted[index]; - if (index == mergeSorted.length - 1 || sheet != sheet1) { - ruleSet.addRules(sheet1.cssRules, baseHref: sheet1.href); + + final int matchedIndex = bucket.indexWhere(sheet.structurallyEquals); + if (matchedIndex == -1) { + unmatchedNew.add(sheet); continue; } - if (index + 1 < mergeSorted.length) { - ++index; + bucket.removeAt(matchedIndex); + if (bucket.isEmpty) { + unmatchedOldByHash.remove(sheet.structuralHashCode); } - CSSStyleSheet sheet2 = mergeSorted[index]; - if (equals(sheet1.cssRules, sheet2.cssRules)) { - continue; + } + + for (final List bucket in unmatchedOldByHash.values) { + for (final CSSStyleSheet sheet in bucket) { + ruleSet.addRules(sheet.cssRules, baseHref: sheet.href); } + } - ruleSet.addRules(sheet1.cssRules, baseHref: sheet1.href); - ruleSet.addRules(sheet2.cssRules, baseHref: sheet2.href); + for (final CSSStyleSheet sheet in unmatchedNew) { + ruleSet.addRules(sheet.cssRules, baseHref: sheet.href); } return ruleSet; } } -bool _sheetsContainTagDifference(List oldSheets, List newSheets, String tag) { +bool _sheetsContainTagDifference( + List oldSheets, List newSheets, String tag) { final bool before = _sheetsContainTagSelector(oldSheets, tag); final bool after = _sheetsContainTagSelector(newSheets, tag); return before != after; @@ -333,7 +366,8 @@ bool _sheetsContainTagSelector(List sheets, String tag) { for (final CSSRule rule in sheet.cssRules) { if (rule is! CSSStyleRule) continue; for (final Selector selector in rule.selectorGroup.selectors) { - for (final SimpleSelectorSequence sequence in selector.simpleSelectorSequences) { + for (final SimpleSelectorSequence sequence + in selector.simpleSelectorSequences) { final SimpleSelector simple = sequence.simpleSelector; if (simple is ElementSelector && !simple.isWildcard) { if (simple.name.toUpperCase() == target) { diff --git a/webf/lib/src/rendering/box_decoration_painter.dart b/webf/lib/src/rendering/box_decoration_painter.dart index 7594a5414b..1e1b57015c 100644 --- a/webf/lib/src/rendering/box_decoration_painter.dart +++ b/webf/lib/src/rendering/box_decoration_painter.dart @@ -18,7 +18,8 @@ import 'package:webf/foundation.dart'; import 'package:webf/html.dart'; import 'package:webf/rendering.dart'; -int _colorByte(double channel) => (channel * 255.0).round().clamp(0, 255).toInt(); +int _colorByte(double channel) => + (channel * 255.0).round().clamp(0, 255).toInt(); String _rgbaString(Color c) => 'rgba(${_colorByte(c.r)},${_colorByte(c.g)},${_colorByte(c.b)},${c.a.toStringAsFixed(3)})'; @@ -50,7 +51,8 @@ const double _kDashedBorderMinGapWidthRatio = 0.5; /// An object that paints a [BoxDecoration] into a canvas. class BoxDecorationPainter extends BoxPainter { - BoxDecorationPainter(this.padding, this.renderStyle, VoidCallback onChanged) : super(onChanged); + BoxDecorationPainter(this.padding, this.renderStyle, VoidCallback onChanged) + : super(onChanged); EdgeInsets? padding; CSSRenderStyle renderStyle; @@ -90,7 +92,8 @@ class BoxDecorationPainter extends BoxPainter { bool _hasImageLayers() { final img = renderStyle.backgroundImage; if (img == null) return false; - return img.functions.any((f) => f.name == 'url') && _decoration.image != null; + return img.functions.any((f) => f.name == 'url') && + _decoration.image != null; } // Report the destination background-image size after applying background-size. @@ -142,7 +145,8 @@ class BoxDecorationPainter extends BoxPainter { backgroundHeight != null && !backgroundHeight.isAuto && backgroundHeight.computedValue > 0) { - return Size(backgroundWidth.computedValue, backgroundHeight.computedValue); + return Size( + backgroundWidth.computedValue, backgroundHeight.computedValue); } // For keyword values (auto/contain/cover) we cannot compute without the @@ -155,17 +159,21 @@ class BoxDecorationPainter extends BoxPainter { Gradient? _cachedGradient; Paint? _getBackgroundPaint(Rect rect, TextDirection? textDirection) { - assert(_decoration.gradient != null || _rectForCachedBackgroundPaint == null); + assert( + _decoration.gradient != null || _rectForCachedBackgroundPaint == null); if (_cachedBackgroundPaint == null || _decoration.color != null || (_decoration.gradient != null && - (_rectForCachedBackgroundPaint != rect || _cachedGradient != _decoration.gradient))) { + (_rectForCachedBackgroundPaint != rect || + _cachedGradient != _decoration.gradient))) { final Paint paint = Paint(); - if (_decoration.backgroundBlendMode != null) paint.blendMode = _decoration.backgroundBlendMode!; + if (_decoration.backgroundBlendMode != null) + paint.blendMode = _decoration.backgroundBlendMode!; if (_decoration.color != null) paint.color = _decoration.color!; if (_decoration.gradient != null) { - paint.shader = _decoration.gradient!.createShader(rect, textDirection: textDirection); + paint.shader = _decoration.gradient! + .createShader(rect, textDirection: textDirection); _rectForCachedBackgroundPaint = rect; _cachedGradient = _decoration.gradient; } @@ -175,7 +183,8 @@ class BoxDecorationPainter extends BoxPainter { return _cachedBackgroundPaint; } - void _paintBox(Canvas canvas, Rect rect, Paint? paint, TextDirection? textDirection) { + void _paintBox( + Canvas canvas, Rect rect, Paint? paint, TextDirection? textDirection) { switch (_decoration.shape) { case BoxShape.circle: assert(!_decoration.hasBorderRadius); @@ -191,7 +200,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint box <${el.tagName.toLowerCase()}> rect=${rect.size} ' + renderingLogger.finer( + '[BorderRadius] paint box <${el.tagName.toLowerCase()}> rect=${rect.size} ' 'rrect: tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)}), ' 'tr=(${rrect.trRadiusX.toStringAsFixed(2)},${rrect.trRadiusY.toStringAsFixed(2)}), ' 'br=(${rrect.brRadiusX.toStringAsFixed(2)},${rrect.brRadiusY.toStringAsFixed(2)}), ' @@ -222,7 +232,8 @@ class BoxDecorationPainter extends BoxPainter { } } - void _paintDashedBorder(Canvas canvas, Rect rect, TextDirection? textDirection) { + void _paintDashedBorder( + Canvas canvas, Rect rect, TextDirection? textDirection) { if (_decoration.border == null) return; // Get the border instance @@ -234,7 +245,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed: isUniform=$isUniform on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed: isUniform=$isUniform on <${el.tagName.toLowerCase()}>'); } catch (_) {} } @@ -244,7 +256,8 @@ class BoxDecorationPainter extends BoxPainter { // (natural ~45° appearance at extreme points). Otherwise, paint per side // to preserve crisp "L" corners on rectangular borders. final ExtendedBorderSide side = border.top as ExtendedBorderSide; - if (side.extendBorderStyle != CSSBorderStyleType.dashed || side.width == 0.0) return; + if (side.extendBorderStyle != CSSBorderStyleType.dashed || + side.width == 0.0) return; if (_decoration.hasBorderRadius && _decoration.borderRadius != null) { final Paint paint = Paint() @@ -258,13 +271,15 @@ class BoxDecorationPainter extends BoxPainter { final RRect rr = _decoration.borderRadius!.toRRect(rect).deflate(inset); final Path borderPath = Path()..addRRect(rr); - final double baseDash = (side.width * _kDashedBorderAvgUnitRatio).clamp(side.width, double.infinity); + final double baseDash = (side.width * _kDashedBorderAvgUnitRatio) + .clamp(side.width, double.infinity); final dashArray = CircularIntervalList([baseDash, baseDash]); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint dashed(uniform+rrect) <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] paint dashed(uniform+rrect) <${el.tagName.toLowerCase()}> ' 'w=${side.width.toStringAsFixed(2)} tl=(${rr.tlRadiusX.toStringAsFixed(2)},${rr.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -275,37 +290,49 @@ class BoxDecorationPainter extends BoxPainter { ); } else { // No border radius: per-side painting for sharp corners. - _paintDashedBorderSide(canvas, rect, null, border.top as ExtendedBorderSide, _BorderDirection.top); - _paintDashedBorderSide(canvas, rect, null, border.right as ExtendedBorderSide, _BorderDirection.right); - _paintDashedBorderSide(canvas, rect, null, border.bottom as ExtendedBorderSide, _BorderDirection.bottom); - _paintDashedBorderSide(canvas, rect, null, border.left as ExtendedBorderSide, _BorderDirection.left); + _paintDashedBorderSide(canvas, rect, null, + border.top as ExtendedBorderSide, _BorderDirection.top); + _paintDashedBorderSide(canvas, rect, null, + border.right as ExtendedBorderSide, _BorderDirection.right); + _paintDashedBorderSide(canvas, rect, null, + border.bottom as ExtendedBorderSide, _BorderDirection.bottom); + _paintDashedBorderSide(canvas, rect, null, + border.left as ExtendedBorderSide, _BorderDirection.left); } } else { // Handle non-uniform borders - draw each side individually if it's dashed // Check which sides have dashed borders bool hasTopDashedBorder = border.top is ExtendedBorderSide && - (border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed && + (border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed && border.top.width > 0; bool hasRightDashedBorder = border.right is ExtendedBorderSide && - (border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed && + (border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed && border.right.width > 0; bool hasBottomDashedBorder = border.bottom is ExtendedBorderSide && - (border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed && + (border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed && border.bottom.width > 0; bool hasLeftDashedBorder = border.left is ExtendedBorderSide && - (border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed && + (border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed && border.left.width > 0; // Return early if no dashed borders - if (!hasTopDashedBorder && !hasRightDashedBorder && !hasBottomDashedBorder && !hasLeftDashedBorder) return; + if (!hasTopDashedBorder && + !hasRightDashedBorder && + !hasBottomDashedBorder && + !hasLeftDashedBorder) return; if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed(non-uniform) sides on <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] dashed(non-uniform) sides on <${el.tagName.toLowerCase()}> ' 'top=$hasTopDashedBorder right=$hasRightDashedBorder bottom=$hasBottomDashedBorder left=$hasLeftDashedBorder'); } catch (_) {} } @@ -318,19 +345,23 @@ class BoxDecorationPainter extends BoxPainter { // Draw each dashed border side individually if (hasTopDashedBorder) { - _paintDashedBorderSide(canvas, rect, rrect, border.top as ExtendedBorderSide, _BorderDirection.top); + _paintDashedBorderSide(canvas, rect, rrect, + border.top as ExtendedBorderSide, _BorderDirection.top); } if (hasRightDashedBorder) { - _paintDashedBorderSide(canvas, rect, rrect, border.right as ExtendedBorderSide, _BorderDirection.right); + _paintDashedBorderSide(canvas, rect, rrect, + border.right as ExtendedBorderSide, _BorderDirection.right); } if (hasBottomDashedBorder) { - _paintDashedBorderSide(canvas, rect, rrect, border.bottom as ExtendedBorderSide, _BorderDirection.bottom); + _paintDashedBorderSide(canvas, rect, rrect, + border.bottom as ExtendedBorderSide, _BorderDirection.bottom); } if (hasLeftDashedBorder) { - _paintDashedBorderSide(canvas, rect, rrect, border.left as ExtendedBorderSide, _BorderDirection.left); + _paintDashedBorderSide(canvas, rect, rrect, + border.left as ExtendedBorderSide, _BorderDirection.left); } } } @@ -358,12 +389,16 @@ class BoxDecorationPainter extends BoxPainter { } // Check if all sides have the same width - if (topSide.width != rightSide.width || topSide.width != bottomSide.width || topSide.width != leftSide.width) { + if (topSide.width != rightSide.width || + topSide.width != bottomSide.width || + topSide.width != leftSide.width) { return false; } // Check if all sides have the same color - if (topSide.color != rightSide.color || topSide.color != bottomSide.color || topSide.color != leftSide.color) { + if (topSide.color != rightSide.color || + topSide.color != bottomSide.color || + topSide.color != leftSide.color) { return false; } @@ -371,8 +406,8 @@ class BoxDecorationPainter extends BoxPainter { } // Helper method to paint a dashed border on a specific side - void _paintDashedBorderSide( - Canvas canvas, Rect rect, RRect? rrect, ExtendedBorderSide side, _BorderDirection direction) { + void _paintDashedBorderSide(Canvas canvas, Rect rect, RRect? rrect, + ExtendedBorderSide side, _BorderDirection direction) { // Create a paint object for the border final Paint paint = Paint() ..color = side.color @@ -386,7 +421,8 @@ class BoxDecorationPainter extends BoxPainter { // producing a dashed stroked path is numerically unstable (negative/zero // path length). Browsers effectively fill the side area with the border // color. Emulate that by drawing a filled rectangle for the side. - final double sideExtent = (direction == _BorderDirection.top || direction == _BorderDirection.bottom) + final double sideExtent = (direction == _BorderDirection.top || + direction == _BorderDirection.bottom) ? rect.width : rect.height; if (side.width >= sideExtent) { @@ -395,16 +431,25 @@ class BoxDecorationPainter extends BoxPainter { ..style = PaintingStyle.fill; switch (direction) { case _BorderDirection.top: - canvas.drawRect(Rect.fromLTWH(rect.left, rect.top, rect.width, side.width), fill); + canvas.drawRect( + Rect.fromLTWH(rect.left, rect.top, rect.width, side.width), fill); break; case _BorderDirection.bottom: - canvas.drawRect(Rect.fromLTWH(rect.left, rect.bottom - side.width, rect.width, side.width), fill); + canvas.drawRect( + Rect.fromLTWH( + rect.left, rect.bottom - side.width, rect.width, side.width), + fill); break; case _BorderDirection.left: - canvas.drawRect(Rect.fromLTWH(rect.left, rect.top, side.width, rect.height), fill); + canvas.drawRect( + Rect.fromLTWH(rect.left, rect.top, side.width, rect.height), + fill); break; case _BorderDirection.right: - canvas.drawRect(Rect.fromLTWH(rect.right - side.width, rect.top, side.width, rect.height), fill); + canvas.drawRect( + Rect.fromLTWH( + rect.right - side.width, rect.top, side.width, rect.height), + fill); break; } return; @@ -417,7 +462,8 @@ class BoxDecorationPainter extends BoxPainter { if (rrect == null) { // Non-rounded corners: compute dash/gap to start and end with a dash. final double sideLength; - if (direction == _BorderDirection.top || direction == _BorderDirection.bottom) { + if (direction == _BorderDirection.top || + direction == _BorderDirection.bottom) { // Path goes from left+sw/2 to right-sw/2 sideLength = rect.width - side.width; } else { @@ -447,14 +493,19 @@ class BoxDecorationPainter extends BoxPainter { bool drewArcFallback = false; const double eps = 0.01; - if (direction == _BorderDirection.top || direction == _BorderDirection.bottom) { - final double leftRadiusX = direction == _BorderDirection.top ? rr.tlRadiusX : rr.blRadiusX; - final double rightRadiusX = direction == _BorderDirection.top ? rr.trRadiusX : rr.brRadiusX; - final double segLen = (rr.outerRect.width) - (leftRadiusX + rightRadiusX); + if (direction == _BorderDirection.top || + direction == _BorderDirection.bottom) { + final double leftRadiusX = + direction == _BorderDirection.top ? rr.tlRadiusX : rr.blRadiusX; + final double rightRadiusX = + direction == _BorderDirection.top ? rr.trRadiusX : rr.brRadiusX; + final double segLen = + (rr.outerRect.width) - (leftRadiusX + rightRadiusX); if (DebugFlags.enableBorderRadiusLogs) { try { - renderingLogger.finer('[BorderRadius] dashed side(${direction.toString().split('.').last}) rrect ' + renderingLogger.finer( + '[BorderRadius] dashed side(${direction.toString().split('.').last}) rrect ' 'w=${rr.outerRect.width.toStringAsFixed(2)} leftRx=${leftRadiusX.toStringAsFixed(2)} ' 'rightRx=${rightRadiusX.toStringAsFixed(2)} segLen=${segLen.toStringAsFixed(2)}'); } catch (_) {} @@ -464,26 +515,29 @@ class BoxDecorationPainter extends BoxPainter { // Fallback when straight segment collapses. drewArcFallback = true; // Prefer a centered 90° arc when the rrect is effectively a circle. - final bool isCircle = (rr.outerRect.width - rr.outerRect.height).abs() < eps && - (rr.tlRadiusX - rr.trRadiusX).abs() < eps && - (rr.tlRadiusX - rr.brRadiusX).abs() < eps && - (rr.tlRadiusX - rr.blRadiusX).abs() < eps && - (rr.tlRadiusY - rr.trRadiusY).abs() < eps && - (rr.tlRadiusY - rr.brRadiusY).abs() < eps && - (rr.tlRadiusY - rr.blRadiusY).abs() < eps && - (rr.outerRect.width - (2.0 * rr.tlRadiusX)).abs() < eps && - (rr.outerRect.height - (2.0 * rr.tlRadiusY)).abs() < eps; + final bool isCircle = + (rr.outerRect.width - rr.outerRect.height).abs() < eps && + (rr.tlRadiusX - rr.trRadiusX).abs() < eps && + (rr.tlRadiusX - rr.brRadiusX).abs() < eps && + (rr.tlRadiusX - rr.blRadiusX).abs() < eps && + (rr.tlRadiusY - rr.trRadiusY).abs() < eps && + (rr.tlRadiusY - rr.brRadiusY).abs() < eps && + (rr.tlRadiusY - rr.blRadiusY).abs() < eps && + (rr.outerRect.width - (2.0 * rr.tlRadiusX)).abs() < eps && + (rr.outerRect.height - (2.0 * rr.tlRadiusY)).abs() < eps; if (isCircle) { final Rect circleOval = rr.outerRect; switch (direction) { case _BorderDirection.top: // Centered on top: 225° -> 315° (5π/4 -> 7π/4) - borderPath.addArc(circleOval, 5.0 * math.pi / 4.0, math.pi / 2.0); + borderPath.addArc( + circleOval, 5.0 * math.pi / 4.0, math.pi / 2.0); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=top angles=225°..315° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=top angles=225°..315° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; @@ -493,7 +547,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=bottom angles=45°..135° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=bottom angles=45°..135° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; @@ -503,17 +558,20 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=right angles=-45°..45° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=right angles=-45°..45° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; case _BorderDirection.left: // Centered on left: 135° -> 225° (3π/4 -> 5π/4) - borderPath.addArc(circleOval, 3.0 * math.pi / 4.0, math.pi / 2.0); + borderPath.addArc( + circleOval, 3.0 * math.pi / 4.0, math.pi / 2.0); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=left angles=135°..225° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=left angles=135°..225° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; @@ -522,22 +580,31 @@ class BoxDecorationPainter extends BoxPainter { // Non-circle: use half-corner arcs so a single side covers ≤ 90° total. if (direction == _BorderDirection.top) { if (rr.tlRadiusX > 0 && rr.tlRadiusY > 0) { - final Rect tlOval = Rect.fromLTWH(rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); + final Rect tlOval = Rect.fromLTWH( + rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); borderPath.arcTo(tlOval, math.pi, math.pi / 4.0, true); } if (rr.trRadiusX > 0 && rr.trRadiusY > 0) { - final Rect trOval = Rect.fromLTWH(rr.right - rr.trRadiusX * 2.0, rr.top, rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); + final Rect trOval = Rect.fromLTWH(rr.right - rr.trRadiusX * 2.0, + rr.top, rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); borderPath.arcTo(trOval, 1.75 * math.pi, math.pi / 4.0, true); } } else { if (direction == _BorderDirection.bottom) { if (rr.brRadiusX > 0 && rr.brRadiusY > 0) { - final Rect brOval = Rect.fromLTWH(rr.right - rr.brRadiusX * 2.0, rr.bottom - rr.brRadiusY * 2.0, - rr.brRadiusX * 2.0, rr.brRadiusY * 2.0); + final Rect brOval = Rect.fromLTWH( + rr.right - rr.brRadiusX * 2.0, + rr.bottom - rr.brRadiusY * 2.0, + rr.brRadiusX * 2.0, + rr.brRadiusY * 2.0); borderPath.arcTo(brOval, math.pi / 4.0, math.pi / 4.0, true); } if (rr.blRadiusX > 0 && rr.blRadiusY > 0) { - final Rect blOval = Rect.fromLTWH(rr.left, rr.bottom - rr.blRadiusY * 2.0, rr.blRadiusX * 2.0, rr.blRadiusY * 2.0); + final Rect blOval = Rect.fromLTWH( + rr.left, + rr.bottom - rr.blRadiusY * 2.0, + rr.blRadiusX * 2.0, + rr.blRadiusY * 2.0); borderPath.arcTo(blOval, math.pi / 2.0, math.pi / 4.0, true); } } @@ -547,19 +614,25 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed side(${direction.toString().split('.').last}) ' + renderingLogger.finer( + '[BorderRadius] dashed side(${direction.toString().split('.').last}) ' 'fallback arcs drawn for <${el.tagName.toLowerCase()}>'); } catch (_) {} } } - } else if (direction == _BorderDirection.left || direction == _BorderDirection.right) { - final double topRadiusY = direction == _BorderDirection.right ? rr.trRadiusY : rr.tlRadiusY; - final double bottomRadiusY = direction == _BorderDirection.right ? rr.brRadiusY : rr.blRadiusY; - final double segLen = (rr.outerRect.height) - (topRadiusY + bottomRadiusY); + } else if (direction == _BorderDirection.left || + direction == _BorderDirection.right) { + final double topRadiusY = + direction == _BorderDirection.right ? rr.trRadiusY : rr.tlRadiusY; + final double bottomRadiusY = + direction == _BorderDirection.right ? rr.brRadiusY : rr.blRadiusY; + final double segLen = + (rr.outerRect.height) - (topRadiusY + bottomRadiusY); if (DebugFlags.enableBorderRadiusLogs) { try { - renderingLogger.finer('[BorderRadius] dashed side(${direction.toString().split('.').last}) rrect ' + renderingLogger.finer( + '[BorderRadius] dashed side(${direction.toString().split('.').last}) rrect ' 'h=${rr.outerRect.height.toStringAsFixed(2)} topRy=${topRadiusY.toStringAsFixed(2)} ' 'bottomRy=${bottomRadiusY.toStringAsFixed(2)} segLen=${segLen.toStringAsFixed(2)}'); } catch (_) {} @@ -567,15 +640,16 @@ class BoxDecorationPainter extends BoxPainter { if (segLen <= eps) { drewArcFallback = true; - final bool isCircle = (rr.outerRect.width - rr.outerRect.height).abs() < eps && - (rr.tlRadiusX - rr.trRadiusX).abs() < eps && - (rr.tlRadiusX - rr.brRadiusX).abs() < eps && - (rr.tlRadiusX - rr.blRadiusX).abs() < eps && - (rr.tlRadiusY - rr.trRadiusY).abs() < eps && - (rr.tlRadiusY - rr.brRadiusY).abs() < eps && - (rr.tlRadiusY - rr.blRadiusY).abs() < eps && - (rr.outerRect.width - (2.0 * rr.tlRadiusX)).abs() < eps && - (rr.outerRect.height - (2.0 * rr.tlRadiusY)).abs() < eps; + final bool isCircle = + (rr.outerRect.width - rr.outerRect.height).abs() < eps && + (rr.tlRadiusX - rr.trRadiusX).abs() < eps && + (rr.tlRadiusX - rr.brRadiusX).abs() < eps && + (rr.tlRadiusX - rr.blRadiusX).abs() < eps && + (rr.tlRadiusY - rr.trRadiusY).abs() < eps && + (rr.tlRadiusY - rr.brRadiusY).abs() < eps && + (rr.tlRadiusY - rr.blRadiusY).abs() < eps && + (rr.outerRect.width - (2.0 * rr.tlRadiusX)).abs() < eps && + (rr.outerRect.height - (2.0 * rr.tlRadiusY)).abs() < eps; if (isCircle) { final Rect circleOval = rr.outerRect; @@ -586,27 +660,32 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=right angles=-45°..45° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=right angles=-45°..45° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; case _BorderDirection.left: // 135° -> 225° - borderPath.addArc(circleOval, 3.0 * math.pi / 4.0, math.pi / 2.0); + borderPath.addArc( + circleOval, 3.0 * math.pi / 4.0, math.pi / 2.0); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=left angles=135°..225° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=left angles=135°..225° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; case _BorderDirection.top: // Already handled in previous branch; keep for completeness. - borderPath.addArc(circleOval, 5.0 * math.pi / 4.0, math.pi / 2.0); + borderPath.addArc( + circleOval, 5.0 * math.pi / 4.0, math.pi / 2.0); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=top angles=225°..315° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=top angles=225°..315° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; @@ -615,7 +694,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed circle-arc side=bottom angles=45°..135° for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] dashed circle-arc side=bottom angles=45°..135° for <${el.tagName.toLowerCase()}>'); } catch (_) {} } break; @@ -623,24 +703,32 @@ class BoxDecorationPainter extends BoxPainter { } else { if (direction == _BorderDirection.right) { if (rr.trRadiusX > 0 && rr.trRadiusY > 0) { - final Rect trOval = Rect.fromLTWH( - rr.right - rr.trRadiusX * 2.0, rr.top, rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); + final Rect trOval = Rect.fromLTWH(rr.right - rr.trRadiusX * 2.0, + rr.top, rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); borderPath.arcTo(trOval, 1.5 * math.pi, math.pi / 4.0, true); } if (rr.brRadiusX > 0 && rr.brRadiusY > 0) { final Rect brOval = Rect.fromLTWH( - rr.right - rr.brRadiusX * 2.0, rr.bottom - rr.brRadiusY * 2.0, rr.brRadiusX * 2.0, rr.brRadiusY * 2.0); + rr.right - rr.brRadiusX * 2.0, + rr.bottom - rr.brRadiusY * 2.0, + rr.brRadiusX * 2.0, + rr.brRadiusY * 2.0); borderPath.arcTo(brOval, 0.0, math.pi / 4.0, true); } } else { // left if (rr.blRadiusX > 0 && rr.blRadiusY > 0) { final Rect blOval = Rect.fromLTWH( - rr.left, rr.bottom - rr.blRadiusY * 2.0, rr.blRadiusX * 2.0, rr.blRadiusY * 2.0); - borderPath.arcTo(blOval, 3.0 * math.pi / 4.0, math.pi / 4.0, true); + rr.left, + rr.bottom - rr.blRadiusY * 2.0, + rr.blRadiusX * 2.0, + rr.blRadiusY * 2.0); + borderPath.arcTo( + blOval, 3.0 * math.pi / 4.0, math.pi / 4.0, true); } if (rr.tlRadiusX > 0 && rr.tlRadiusY > 0) { - final Rect tlOval = Rect.fromLTWH(rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); + final Rect tlOval = Rect.fromLTWH( + rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); borderPath.arcTo(tlOval, 1.25 * math.pi, math.pi / 4.0, true); } } @@ -649,7 +737,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed side(${direction.toString().split('.').last}) ' + renderingLogger.finer( + '[BorderRadius] dashed side(${direction.toString().split('.').last}) ' 'fallback arcs drawn for <${el.tagName.toLowerCase()}>'); } catch (_) {} } @@ -683,20 +772,28 @@ class BoxDecorationPainter extends BoxPainter { // Handle non-rounded corners switch (direction) { case _BorderDirection.top: - borderPath.moveTo(rect.left + side.width / 2.0, rect.top + side.width / 2.0); - borderPath.lineTo(rect.right - side.width / 2.0, rect.top + side.width / 2.0); + borderPath.moveTo( + rect.left + side.width / 2.0, rect.top + side.width / 2.0); + borderPath.lineTo( + rect.right - side.width / 2.0, rect.top + side.width / 2.0); break; case _BorderDirection.right: - borderPath.moveTo(rect.right - side.width / 2.0, rect.top + side.width / 2.0); - borderPath.lineTo(rect.right - side.width / 2.0, rect.bottom - side.width / 2.0); + borderPath.moveTo( + rect.right - side.width / 2.0, rect.top + side.width / 2.0); + borderPath.lineTo( + rect.right - side.width / 2.0, rect.bottom - side.width / 2.0); break; case _BorderDirection.bottom: - borderPath.moveTo(rect.right - side.width / 2.0, rect.bottom - side.width / 2.0); - borderPath.lineTo(rect.left + side.width / 2.0, rect.bottom - side.width / 2.0); + borderPath.moveTo( + rect.right - side.width / 2.0, rect.bottom - side.width / 2.0); + borderPath.lineTo( + rect.left + side.width / 2.0, rect.bottom - side.width / 2.0); break; case _BorderDirection.left: - borderPath.moveTo(rect.left + side.width / 2.0, rect.bottom - side.width / 2.0); - borderPath.lineTo(rect.left + side.width / 2.0, rect.top + side.width / 2.0); + borderPath.moveTo( + rect.left + side.width / 2.0, rect.bottom - side.width / 2.0); + borderPath.lineTo( + rect.left + side.width / 2.0, rect.top + side.width / 2.0); break; } } @@ -705,7 +802,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] dashed drawPath side=${direction.toString().split('.').last} ' + renderingLogger.finer( + '[BorderRadius] dashed drawPath side=${direction.toString().split('.').last} ' 'w=${side.width.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); } catch (_) {} } @@ -799,19 +897,24 @@ class BoxDecorationPainter extends BoxPainter { /// An outer box-shadow casts a shadow as if the border-box of the element were opaque. /// It is clipped inside the border-box of the element. - void _paintBoxShadow(Canvas canvas, Rect rect, TextDirection? textDirection, BoxShadow boxShadow) { + void _paintBoxShadow(Canvas canvas, Rect rect, TextDirection? textDirection, + BoxShadow boxShadow) { final Paint paint = Paint() ..color = boxShadow.color // Following W3C spec, blur sigma is exactly half the blur radius // which is different from the value of Flutter: // https://www.w3.org/TR/css-backgrounds-3/#shadow-blur // https://html.spec.whatwg.org/C/#when-shadows-are-drawn - ..maskFilter = MaskFilter.blur(BlurStyle.normal, boxShadow.blurRadius / 2); + ..maskFilter = + MaskFilter.blur(BlurStyle.normal, boxShadow.blurRadius / 2); // Rect of box shadow not including blur radius - final Rect shadowRect = rect.shift(boxShadow.offset).inflate(boxShadow.spreadRadius); + final Rect shadowRect = + rect.shift(boxShadow.offset).inflate(boxShadow.spreadRadius); // Rect of box shadow including blur radius, add 1 pixel to avoid the fill bleed in (due to antialiasing) - final Rect shadowBlurRect = rect.shift(boxShadow.offset).inflate(boxShadow.spreadRadius + boxShadow.blurRadius + 1); + final Rect shadowBlurRect = rect + .shift(boxShadow.offset) + .inflate(boxShadow.spreadRadius + boxShadow.blurRadius + 1); // Path of border rect Path borderPath; // Path of box shadow rect @@ -826,19 +929,27 @@ class BoxDecorationPainter extends BoxPainter { } else { final rrect = _decoration.borderRadius!.toRRect(rect); borderPath = Path()..addRRect(rrect); - shadowPath = Path()..addRRect(_decoration.borderRadius!.resolve(textDirection).toRRect(shadowRect)); - shadowBlurPath = Path()..addRRect(_decoration.borderRadius!.resolve(textDirection).toRRect(shadowBlurRect)); + shadowPath = Path() + ..addRRect(_decoration.borderRadius! + .resolve(textDirection) + .toRRect(shadowRect)); + shadowBlurPath = Path() + ..addRRect(_decoration.borderRadius! + .resolve(textDirection) + .toRRect(shadowBlurRect)); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] box-shadow clip <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] box-shadow clip <${el.tagName.toLowerCase()}> ' 'tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } } // Path of shadow blur rect subtract border rect of which the box shadow should paint - final Path clippedPath = Path.combine(PathOperation.difference, shadowBlurPath, borderPath); + final Path clippedPath = + Path.combine(PathOperation.difference, shadowBlurPath, borderPath); canvas.save(); canvas.clipPath(clippedPath); canvas.drawPath(shadowPath, paint); @@ -847,14 +958,16 @@ class BoxDecorationPainter extends BoxPainter { /// An inner box-shadow casts a shadow as if everything outside the padding edge were opaque. /// It is clipped outside the padding box of the element. - void _paintInsetBoxShadow(Canvas canvas, Rect rect, TextDirection? textDirection, BoxShadow boxShadow) { + void _paintInsetBoxShadow(Canvas canvas, Rect rect, + TextDirection? textDirection, BoxShadow boxShadow) { final Paint paint = Paint() ..color = boxShadow.color // Following W3C spec, blur sigma is exactly half the blur radius // which is different from the value of Flutter: // https://www.w3.org/TR/css-backgrounds-3/#shadow-blur // https://html.spec.whatwg.org/C/#when-shadows-are-drawn - ..maskFilter = MaskFilter.blur(BlurStyle.normal, boxShadow.blurRadius / 2); + ..maskFilter = + MaskFilter.blur(BlurStyle.normal, boxShadow.blurRadius / 2); // The normal box-shadow is drawn outside the border box edge while // the inset box-shadow is drawn inside the padding box edge. @@ -872,13 +985,15 @@ class BoxDecorationPainter extends BoxPainter { RRect borderBoxRRect = _decoration.borderRadius!.toRRect(rect); // A borderRadius can only be given for a uniform Border in Flutter. // https://github.com/flutter/flutter/issues/12583 - double uniformBorderWidth = renderStyle.effectiveBorderTopWidth.computedValue; + double uniformBorderWidth = + renderStyle.effectiveBorderTopWidth.computedValue; RRect paddingBoxRRect = borderBoxRRect.deflate(uniformBorderWidth); paddingBoxPath = Path()..addRRect(paddingBoxRRect); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] inset-shadow padding clip <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] inset-shadow padding clip <${el.tagName.toLowerCase()}> ' 'deflate=${uniformBorderWidth.toStringAsFixed(2)} ' 'tl=(${borderBoxRRect.tlRadiusX.toStringAsFixed(2)},${borderBoxRRect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} @@ -887,24 +1002,27 @@ class BoxDecorationPainter extends BoxPainter { // 1. Create a shadow rect shifted by boxShadow and spread radius and get the // difference path subtracted from the padding box path. - Rect shadowOffsetRect = - paddingBoxRect.shift(Offset(boxShadow.offset.dx, boxShadow.offset.dy)).deflate(boxShadow.spreadRadius); + Rect shadowOffsetRect = paddingBoxRect + .shift(Offset(boxShadow.offset.dx, boxShadow.offset.dy)) + .deflate(boxShadow.spreadRadius); Path shadowOffsetPath = _decoration.hasBorderRadius - ? (Path()..addRRect(_decoration.borderRadius!.toRRect(shadowOffsetRect))) + ? (Path() + ..addRRect(_decoration.borderRadius!.toRRect(shadowOffsetRect))) : (Path()..addRect(shadowOffsetRect)); - Path innerShadowPath = Path.combine(PathOperation.difference, paddingBoxPath, shadowOffsetPath); + Path innerShadowPath = Path.combine( + PathOperation.difference, paddingBoxPath, shadowOffsetPath); // 2. Create shadow rect in four directions and get the difference path // subtracted from the padding box path. - Path topRectPath = _getOuterPaddingBoxPathByDirection( - paddingBoxPath, paddingBoxRect, textDirection, boxShadow, _BorderDirection.top); - Path bottomRectPath = _getOuterPaddingBoxPathByDirection( - paddingBoxPath, paddingBoxRect, textDirection, boxShadow, _BorderDirection.bottom); - Path leftRectPath = _getOuterPaddingBoxPathByDirection( - paddingBoxPath, paddingBoxRect, textDirection, boxShadow, _BorderDirection.left); - Path rightRectPath = _getOuterPaddingBoxPathByDirection( - paddingBoxPath, paddingBoxRect, textDirection, boxShadow, _BorderDirection.right); + Path topRectPath = _getOuterPaddingBoxPathByDirection(paddingBoxPath, + paddingBoxRect, textDirection, boxShadow, _BorderDirection.top); + Path bottomRectPath = _getOuterPaddingBoxPathByDirection(paddingBoxPath, + paddingBoxRect, textDirection, boxShadow, _BorderDirection.bottom); + Path leftRectPath = _getOuterPaddingBoxPathByDirection(paddingBoxPath, + paddingBoxRect, textDirection, boxShadow, _BorderDirection.left); + Path rightRectPath = _getOuterPaddingBoxPathByDirection(paddingBoxPath, + paddingBoxRect, textDirection, boxShadow, _BorderDirection.right); // 3. Combine all the paths in step 1 and step 2 as the final shadow path. List paintPaths = [ @@ -935,23 +1053,32 @@ class BoxDecorationPainter extends BoxPainter { Size paddingBoxSize = paddingBoxRect.size; if (direction == _BorderDirection.left) { - offsetRect = paddingBoxRect - .shift(Offset(-paddingBoxSize.width + boxShadow.offset.dx + boxShadow.spreadRadius, boxShadow.offset.dy)); + offsetRect = paddingBoxRect.shift(Offset( + -paddingBoxSize.width + boxShadow.offset.dx + boxShadow.spreadRadius, + boxShadow.offset.dy)); } else if (direction == _BorderDirection.right) { - offsetRect = paddingBoxRect - .shift(Offset(paddingBoxSize.width + boxShadow.offset.dx - boxShadow.spreadRadius, boxShadow.offset.dy)); + offsetRect = paddingBoxRect.shift(Offset( + paddingBoxSize.width + boxShadow.offset.dx - boxShadow.spreadRadius, + boxShadow.offset.dy)); } else if (direction == _BorderDirection.top) { - offsetRect = paddingBoxRect - .shift(Offset(boxShadow.offset.dx, -paddingBoxSize.height + boxShadow.offset.dy + boxShadow.spreadRadius)); + offsetRect = paddingBoxRect.shift(Offset( + boxShadow.offset.dx, + -paddingBoxSize.height + + boxShadow.offset.dy + + boxShadow.spreadRadius)); } else { - offsetRect = paddingBoxRect - .shift(Offset(boxShadow.offset.dx, paddingBoxSize.height + boxShadow.offset.dy - boxShadow.spreadRadius)); + offsetRect = paddingBoxRect.shift(Offset( + boxShadow.offset.dx, + paddingBoxSize.height + + boxShadow.offset.dy - + boxShadow.spreadRadius)); } Path offsetRectPath = _decoration.hasBorderRadius ? (Path()..addRRect(_decoration.borderRadius!.toRRect(offsetRect))) : (Path()..addRect(offsetRect)); - Path outerBorderPath = Path.combine(PathOperation.difference, offsetRectPath, paddingBoxPath); + Path outerBorderPath = + Path.combine(PathOperation.difference, offsetRectPath, paddingBoxPath); return outerBorderPath; } @@ -968,7 +1095,8 @@ class BoxDecorationPainter extends BoxPainter { return finalPath; } - void _paintBackgroundColor(Canvas canvas, Rect rect, TextDirection? textDirection) { + void _paintBackgroundColor( + Canvas canvas, Rect rect, TextDirection? textDirection) { // Special handling: CSS gradients respect background-size/position/repeat per layer. // When background-image uses gradient functions, Flutter's BoxDecoration.gradient // paints full-rect and ignores background-size/position. To emulate CSS, @@ -982,8 +1110,10 @@ class BoxDecorationPainter extends BoxPainter { final el = renderStyle.target; final id = (el.id != null && el.id!.isNotEmpty) ? '#${el.id}' : ''; final cls = (el.className.isNotEmpty) ? '.${el.className}' : ''; - final rawAttach = el.style.getPropertyValue(BACKGROUND_ATTACHMENT); - renderingLogger.finer('[Background] gradient-only path for <${el.tagName.toLowerCase()}$id$cls> rect=$rect raw-attachment="$rawAttach"'); + final rawAttach = + _getLayeredBackgroundPropertyValue(BACKGROUND_ATTACHMENT); + renderingLogger.finer( + '[Background] gradient-only path for <${el.tagName.toLowerCase()}$id$cls> rect=$rect raw-attachment="$rawAttach"'); } catch (_) {} } _paintLayeredGradients(canvas, rect, textDirection); @@ -993,7 +1123,8 @@ class BoxDecorationPainter extends BoxPainter { } if (_decoration.color != null || _decoration.gradient != null) { - _paintBox(canvas, rect, _getBackgroundPaint(rect, textDirection), textDirection); + _paintBox(canvas, rect, _getBackgroundPaint(rect, textDirection), + textDirection); // Report FP when non-default background color is painted // Check if this is a non-default background (not transparent or white) @@ -1026,23 +1157,36 @@ class BoxDecorationPainter extends BoxPainter { return out.where((s) => s.isNotEmpty).toList(); } + String _getLayeredBackgroundPropertyValue(String propertyName) { + final dynamic inlineValue = renderStyle.target.inlineStyle[propertyName]; + if (inlineValue is String && inlineValue.isNotEmpty) { + return inlineValue; + } + return renderStyle.target.style.getPropertyValue(propertyName); + } + // Parse background-position for the full layer list and map to gradient layers by index. List<(CSSBackgroundPosition, CSSBackgroundPosition)> _parsePositionsMapped( List gradientIndices, int fullCount, ) { - final String raw = renderStyle.target.style.getPropertyValue(BACKGROUND_POSITION); - final List tokens = raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; + final String raw = _getLayeredBackgroundPropertyValue(BACKGROUND_POSITION); + final List tokens = + raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] parse positions raw="$raw" tokens=${tokens.isNotEmpty ? tokens : ['']} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); + renderingLogger.finer( + '[Background] parse positions raw="$raw" tokens=${tokens.isNotEmpty ? tokens : [ + '' + ]} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); } // Prefer computed longhands when a transition is actively running for // background-position or its axes, so that animation-driven values take // effect even if the shorthand string was authored in stylesheet. - final bool animatingPos = renderStyle.isTransitionRunning(BACKGROUND_POSITION) || - renderStyle.isTransitionRunning(BACKGROUND_POSITION_X) || - renderStyle.isTransitionRunning(BACKGROUND_POSITION_Y); + final bool animatingPos = + renderStyle.isTransitionRunning(BACKGROUND_POSITION) || + renderStyle.isTransitionRunning(BACKGROUND_POSITION_X) || + renderStyle.isTransitionRunning(BACKGROUND_POSITION_Y); // Build full list first final List<(CSSBackgroundPosition, CSSBackgroundPosition)> full = []; @@ -1051,12 +1195,15 @@ class BoxDecorationPainter extends BoxPainter { // Cycle provided list across images. final String token = tokens[j % tokens.length]; final List pair = CSSPosition.parsePositionShorthand(token); - final x = CSSPosition.resolveBackgroundPosition(pair[0], renderStyle, BACKGROUND_POSITION_X, true); - final y = CSSPosition.resolveBackgroundPosition(pair[1], renderStyle, BACKGROUND_POSITION_Y, false); + final x = CSSPosition.resolveBackgroundPosition( + pair[0], renderStyle, BACKGROUND_POSITION_X, true); + final y = CSSPosition.resolveBackgroundPosition( + pair[1], renderStyle, BACKGROUND_POSITION_Y, false); full.add((x, y)); } else { // Use computed longhands (animated or computed) and apply to all layers. - full.add((renderStyle.backgroundPositionX, renderStyle.backgroundPositionY)); + full.add( + (renderStyle.backgroundPositionX, renderStyle.backgroundPositionY)); } } // Map to gradient-only order @@ -1066,11 +1213,16 @@ class BoxDecorationPainter extends BoxPainter { } // Parse background-size for the full layer list and map to gradient layers by index. - List _parseSizesMapped(List gradientIndices, int fullCount) { - final String raw = renderStyle.target.style.getPropertyValue(BACKGROUND_SIZE); - final List tokens = raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; + List _parseSizesMapped( + List gradientIndices, int fullCount) { + final String raw = _getLayeredBackgroundPropertyValue(BACKGROUND_SIZE); + final List tokens = + raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] parse sizes raw="$raw" tokens=${tokens.isNotEmpty ? tokens : ['']} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); + renderingLogger.finer( + '[Background] parse sizes raw="$raw" tokens=${tokens.isNotEmpty ? tokens : [ + '' + ]} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); } final bool animatingSize = renderStyle.isTransitionRunning(BACKGROUND_SIZE); final List full = []; @@ -1078,22 +1230,29 @@ class BoxDecorationPainter extends BoxPainter { if (!animatingSize && tokens.isNotEmpty) { // Repeat list cyclically. final String token = tokens[j % tokens.length]; - full.add(CSSBackground.resolveBackgroundSize(token, renderStyle, BACKGROUND_SIZE)); + full.add(CSSBackground.resolveBackgroundSize( + token, renderStyle, BACKGROUND_SIZE)); } else { // Use computed single background-size and apply to all layers. full.add(renderStyle.backgroundSize); } } - final List mapped = gradientIndices.map((idx) => full[idx]).toList(growable: false); + final List mapped = + gradientIndices.map((idx) => full[idx]).toList(growable: false); return mapped; } // Parse background-repeat for the full layer list and map to gradient layers by index. - List _parseRepeatsMapped(List gradientIndices, int fullCount) { - final String raw = renderStyle.target.style.getPropertyValue(BACKGROUND_REPEAT); - final List tokens = raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; + List _parseRepeatsMapped( + List gradientIndices, int fullCount) { + final String raw = _getLayeredBackgroundPropertyValue(BACKGROUND_REPEAT); + final List tokens = + raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] parse repeats raw="$raw" tokens=${tokens.isNotEmpty ? tokens : ['']} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); + renderingLogger.finer( + '[Background] parse repeats raw="$raw" tokens=${tokens.isNotEmpty ? tokens : [ + '' + ]} fullCount=$fullCount mapIdx=${gradientIndices.toString()}'); } final List full = []; for (int j = 0; j < fullCount; j++) { @@ -1105,17 +1264,24 @@ class BoxDecorationPainter extends BoxPainter { full.add(renderStyle.backgroundRepeat.imageRepeat()); } } - final List mapped = gradientIndices.map((idx) => full[idx]).toList(growable: false); + final List mapped = + gradientIndices.map((idx) => full[idx]).toList(growable: false); return mapped; } // Parse background-attachment for the full layer list and map by index. // Supports comma-separated list. If fewer tokens than layers, values repeat cyclically. - List _parseAttachmentsMapped(List mapIndices, int fullCount) { - final String raw = renderStyle.target.style.getPropertyValue(BACKGROUND_ATTACHMENT); - final List tokens = raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; + List _parseAttachmentsMapped( + List mapIndices, int fullCount) { + final String raw = + _getLayeredBackgroundPropertyValue(BACKGROUND_ATTACHMENT); + final List tokens = + raw.isNotEmpty ? _splitByTopLevelCommas(raw) : []; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] parse attachments raw="$raw" tokens=${tokens.isNotEmpty ? tokens : ['']} fullCount=$fullCount mapIdx=${mapIndices.toString()}'); + renderingLogger.finer( + '[Background] parse attachments raw="$raw" tokens=${tokens.isNotEmpty ? tokens : [ + '' + ]} fullCount=$fullCount mapIdx=${mapIndices.toString()}'); } final List full = []; for (int j = 0; j < fullCount; j++) { @@ -1127,7 +1293,8 @@ class BoxDecorationPainter extends BoxPainter { full.add(CSSBackgroundAttachmentType.scroll); } } - final List mapped = mapIndices.map((idx) => full[idx]).toList(growable: false); + final List mapped = + mapIndices.map((idx) => full[idx]).toList(growable: false); return mapped; } @@ -1160,7 +1327,8 @@ class BoxDecorationPainter extends BoxPainter { backgroundHeight != null && !backgroundHeight.isAuto && backgroundHeight.computedValue > 0) { - destinationSize = Size(backgroundWidth.computedValue, backgroundHeight.computedValue); + destinationSize = + Size(backgroundWidth.computedValue, backgroundHeight.computedValue); } else { // contain/cover/auto: for gradients, treat as no scaling (cover full rect for cover/auto). // contain behaves similar to cover for gradients as there's no intrinsic size. @@ -1176,7 +1344,8 @@ class BoxDecorationPainter extends BoxPainter { } // Compute destination rect from position and size, similar to _paintImage logic. - Rect _computeDestinationRect(Rect rect, Size destSize, CSSBackgroundPosition posX, CSSBackgroundPosition posY) { + Rect _computeDestinationRect(Rect rect, Size destSize, + CSSBackgroundPosition posX, CSSBackgroundPosition posY) { final Size outputSize = rect.size; final double halfWidthDelta = (outputSize.width - destSize.width) / 2.0; final double halfHeightDelta = (outputSize.height - destSize.height) / 2.0; @@ -1195,13 +1364,15 @@ class BoxDecorationPainter extends BoxPainter { return (rect.topLeft.translate(dx, dy)) & destSize; } - void _paintLayeredGradients(Canvas canvas, Rect rect, TextDirection? textDirection) { + void _paintLayeredGradients( + Canvas canvas, Rect rect, TextDirection? textDirection) { final img = renderStyle.backgroundImage; if (img == null) return; // Extract gradient functions (each represents a layer) final List fullFns = img.functions; - final List fns = fullFns.where((f) => f.name.contains('gradient')).toList(); + final List fns = + fullFns.where((f) => f.name.contains('gradient')).toList(); // Map each gradient to its index in the full background list final List gIndices = []; for (int i = 0; i < fullFns.length; i++) { @@ -1221,20 +1392,25 @@ class BoxDecorationPainter extends BoxPainter { final fn = fns[i]; // Build a temporary CSSBackgroundImage for this single function to reuse parsing logic. // Compute destination size and rect first to derive a length hint for px stops. - final (CSSBackgroundPosition px, CSSBackgroundPosition py) = positionsGrad[i]; + final (CSSBackgroundPosition px, CSSBackgroundPosition py) = + positionsGrad[i]; final CSSBackgroundSize size = sizesGrad[i]; final ImageRepeat repeat = repeatsGrad[i]; final CSSBackgroundAttachmentType attach = attachmentsGrad[i]; final bool useViewport = attach == CSSBackgroundAttachmentType.fixed; - final Rect viewportRect = Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? rect.size); - final bool propagateToViewport = useViewport && _isRootBackgroundTarget(renderStyle.target); + final Rect viewportRect = Offset.zero & + (renderStyle.target.ownerDocument.viewport?.viewportSize ?? + rect.size); + final bool propagateToViewport = + useViewport && _isRootBackgroundTarget(renderStyle.target); // When fixed, anchor to viewport for positioning; otherwise use the element clip rect. final Rect positioningRect = useViewport ? viewportRect : rect; // For root background propagation with fixed, expand clip to viewport; otherwise clip to element rect. final Rect layerClipRect = propagateToViewport ? viewportRect : rect; - final Size destSize = _computeGradientDestinationSize(positioningRect, size); + final Size destSize = + _computeGradientDestinationSize(positioningRect, size); double? lengthHint; if (fn.name.contains('linear-gradient')) { lengthHint = _linearGradientLengthHint(fn, destSize); @@ -1242,32 +1418,30 @@ class BoxDecorationPainter extends BoxPainter { lengthHint = _radialGradientLengthHint(fn, positioningRect); } - final single = CSSBackgroundImage([fn], renderStyle, renderStyle.target.ownerDocument.controller, - baseHref: renderStyle.target.style.getPropertyBaseHref(BACKGROUND_IMAGE), gradientLengthHint: lengthHint); + final single = CSSBackgroundImage( + [fn], renderStyle, renderStyle.target.ownerDocument.controller, + baseHref: + renderStyle.target.style.getPropertyBaseHref(BACKGROUND_IMAGE), + gradientLengthHint: lengthHint); final Gradient? gradient = single.gradient; if (gradient == null) continue; // Mapping this gradient layer's positioning/size/repeat from precomputed lists. - Rect destRect = _computeDestinationRect(positioningRect, destSize, px, py); + Rect destRect = + _computeDestinationRect(positioningRect, destSize, px, py); if (DebugFlags.enableBackgroundLogs) { // Extract a compact view of colors/stops if available List cs = const []; List? st; if (gradient is LinearGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); + cs = gradient.colors.map(_rgbaString).toList(); st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); } else if (gradient is RadialGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); + cs = gradient.colors.map(_rgbaString).toList(); st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); } else if (gradient is SweepGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); + cs = gradient.colors.map(_rgbaString).toList(); st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); } final tag = () { @@ -1276,24 +1450,31 @@ class BoxDecorationPainter extends BoxPainter { final id = (el.id != null && el.id!.isNotEmpty) ? '#${el.id}' : ''; final cls = (el.className.isNotEmpty) ? '.${el.className}' : ''; return '<${el.tagName.toLowerCase()}$id$cls>'; - } catch (_) { return ''; } + } catch (_) { + return ''; + } }(); - renderingLogger.finer('[Background] layer(gradient) i=$i target=$tag fn=${fn.name} attach=${attach.cssText()} useViewport=$useViewport ' + renderingLogger.finer( + '[Background] layer(gradient) i=$i target=$tag fn=${fn.name} attach=${attach.cssText()} useViewport=$useViewport ' 'clip=$layerClipRect positionRect=$positioningRect viewport=$viewportRect pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} repeat=$repeat'); - renderingLogger.finer('[Background] dest=$destRect colors=$cs stops=${st ?? const []}'); + renderingLogger.finer( + '[Background] dest=$destRect colors=$cs stops=${st ?? const []}'); } // Clip to background painting area. Respect border-radius when present to // avoid leaking color outside rounded corners (matches CSS background-clip). canvas.save(); - if (_decoration.hasBorderRadius && _decoration.borderRadius != null && !propagateToViewport) { + if (_decoration.hasBorderRadius && + _decoration.borderRadius != null && + !propagateToViewport) { final rrect = _decoration.borderRadius!.toRRect(layerClipRect); final Path rounded = Path()..addRRect(rrect); canvas.clipPath(rounded); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] background clip layer <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] background clip layer <${el.tagName.toLowerCase()}> ' 'clipRect=${layerClipRect.size} tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -1304,22 +1485,28 @@ class BoxDecorationPainter extends BoxPainter { if (destRect.isEmpty) { // Nothing to paint for empty destination. } else if (repeat == ImageRepeat.noRepeat) { - final paint = Paint()..shader = gradient.createShader(destRect, textDirection: textDirection); + final paint = Paint() + ..shader = + gradient.createShader(destRect, textDirection: textDirection); canvas.drawRect(destRect, paint); } else { // Tile the gradient rect similar to image tiling. // Important: per CSS, each tile's image space restarts at the tile origin. // Create a shader per tile so the gradient aligns with the tile's rect. int tCount = 0; - for (final Rect tile in _generateImageTileRects(layerClipRect, destRect, repeat)) { - final paint = Paint()..shader = gradient.createShader(tile, textDirection: textDirection); + for (final Rect tile + in _generateImageTileRects(layerClipRect, destRect, repeat)) { + final paint = Paint() + ..shader = + gradient.createShader(tile, textDirection: textDirection); canvas.drawRect(tile, paint); if (DebugFlags.enableBackgroundLogs) { tCount++; } } if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] tiled $tCount rects for gradient layer'); + renderingLogger + .finer('[Background] tiled $tCount rects for gradient layer'); } } @@ -1345,7 +1532,8 @@ class BoxDecorationPainter extends BoxPainter { bool toH = parts.contains(LEFT) || parts.contains(RIGHT); bool toV = parts.contains(TOP) || parts.contains(BOTTOM); if (toH && toV) { - return math.sqrt(destSize.width * destSize.width + destSize.height * destSize.height); + return math.sqrt(destSize.width * destSize.width + + destSize.height * destSize.height); } if (toH) return destSize.width; if (toV) return destSize.height; @@ -1357,14 +1545,16 @@ class BoxDecorationPainter extends BoxPainter { // Compute a radial-gradient length hint (device px) for px stops normalization. // Approximates the shader's effective radius (farthest-corner with radius=0.5) // based on the positioning rect and optional "at " prelude. - double _radialGradientLengthHint(CSSFunctionalNotation fn, Rect positioningRect) { + double _radialGradientLengthHint( + CSSFunctionalNotation fn, Rect positioningRect) { double atX = 0.5; double atY = 0.5; bool isEllipse = false; if (fn.args.isNotEmpty) { final String prelude = fn.args[0].trim(); if (prelude.isNotEmpty) { - final List tokens = splitByAsciiWhitespacePreservingGroups(prelude); + final List tokens = + splitByAsciiWhitespacePreservingGroups(prelude); isEllipse = tokens.contains('ellipse'); final int atIndex = tokens.indexOf('at'); if (atIndex != -1) { @@ -1373,16 +1563,20 @@ class BoxDecorationPainter extends BoxPainter { 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)!; + 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)!; + if (CSSPercentage.isPercentage(s)) + return CSSPercentage.parsePercentage(s)!; return 0.5; } + if (pos.isNotEmpty) { if (pos.length == 1) { final String v = pos.first; @@ -1404,11 +1598,14 @@ class BoxDecorationPainter extends BoxPainter { final double cx = positioningRect.left + atX * positioningRect.width; final double cy = positioningRect.top + atY * positioningRect.height; - final double rx = math.max((cx - positioningRect.left).abs(), (positioningRect.right - cx).abs()); - final double ry = math.max((cy - positioningRect.top).abs(), (positioningRect.bottom - cy).abs()); + final double rx = math.max( + (cx - positioningRect.left).abs(), (positioningRect.right - cx).abs()); + final double ry = math.max( + (cy - positioningRect.top).abs(), (positioningRect.bottom - cy).abs()); final double hint = isEllipse ? rx : math.sqrt(rx * rx + ry * ry); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] radial length hint: ellipse=$isEllipse at=(${atX.toStringAsFixed(3)},${atY.toStringAsFixed(3)}) ' + renderingLogger.finer( + '[Background] radial length hint: ellipse=$isEllipse at=(${atX.toStringAsFixed(3)},${atY.toStringAsFixed(3)}) ' 'rx=${rx.toStringAsFixed(2)} ry=${ry.toStringAsFixed(2)} hint=${hint.toStringAsFixed(2)}'); } return hint; @@ -1416,8 +1613,10 @@ class BoxDecorationPainter extends BoxPainter { void _paintLayeredMixedBackgrounds( Canvas canvas, - Rect clipRect, - Rect originRect, + Rect stationaryClipRect, + Rect stationaryOriginRect, + Rect localClipRect, + Rect localOriginRect, ImageConfiguration configuration, TextDirection? textDirection, ) { @@ -1428,7 +1627,8 @@ class BoxDecorationPainter extends BoxPainter { if (count == 0) return; if (DebugFlags.enableBackgroundLogs) { final names = fullFns.map((f) => f.name).toList(); - renderingLogger.finer('[Background] layered begin count=$count layers=$names'); + renderingLogger + .finer('[Background] layered begin count=$count layers=$names'); } // If there are image layers, resolve the stream once now. If unresolved, @@ -1439,10 +1639,12 @@ class BoxDecorationPainter extends BoxPainter { if (_decoration.image == null) { canPaintUrl = false; } else { - _imagePainter ??= BoxDecorationImagePainter._(_decoration.image!, renderStyle, onChanged!); + _imagePainter ??= BoxDecorationImagePainter._( + _decoration.image!, renderStyle, onChanged!); _imagePainter!.image = _decoration.image!; if (_imagePainter!._image == null) { - final ImageStream newImageStream = _imagePainter!._details.image.resolve(configuration); + final ImageStream newImageStream = + _imagePainter!._details.image.resolve(configuration); if (newImageStream.key != _imagePainter!._imageStream?.key) { final ImageStreamListener listener = ImageStreamListener( _imagePainter!._handleImage, @@ -1453,7 +1655,8 @@ class BoxDecorationPainter extends BoxPainter { } canPaintUrl = _imagePainter!._image != null; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] layered pre-resolve: image ${canPaintUrl ? 'resolved synchronously' : 'unresolved; painting only non-url layers'}'); + renderingLogger.finer( + '[Background] layered pre-resolve: image ${canPaintUrl ? 'resolved synchronously' : 'unresolved; painting only non-url layers'}'); } } } @@ -1472,23 +1675,27 @@ class BoxDecorationPainter extends BoxPainter { final Paint p = Paint()..color = bgColor; switch (_decoration.shape) { case BoxShape.circle: - final Offset center = clipRect.center; - final double radius = clipRect.shortestSide / 2.0; + final Offset center = stationaryClipRect.center; + final double radius = stationaryClipRect.shortestSide / 2.0; canvas.drawCircle(center, radius, p); break; case BoxShape.rectangle: if (_decoration.hasBorderRadius) { - canvas.drawRRect(_decoration.borderRadius!.toRRect(clipRect), p); + canvas.drawRRect( + _decoration.borderRadius!.toRRect(stationaryClipRect), p); } else { - canvas.drawRect(clipRect, p); + canvas.drawRect(stationaryClipRect, p); } break; } } if (DebugFlags.enableBackgroundLogs) { - final order = List.generate(count, (i) => fullFns[i].name).reversed.toList(); - renderingLogger.finer('[Background] mixed layering count=$count paint order bottom->top=${order.join(' -> ')}'); + final order = List.generate(count, (i) => fullFns[i].name) + .reversed + .toList(); + renderingLogger.finer( + '[Background] mixed layering count=$count paint order bottom->top=${order.join(' -> ')}'); } // Paint from bottom-most (last) to top-most (first) per CSS layering rules. @@ -1504,7 +1711,8 @@ class BoxDecorationPainter extends BoxPainter { if (!canPaintUrl) continue; // Stream should already be resolved by the pre-check above; paint all url layers now. final ui.Image img = _imagePainter!._image!.image; - final double scale = _decoration.image!.scale * _imagePainter!._image!.scale; + final double scale = + _decoration.image!.scale * _imagePainter!._image!.scale; bool flipHorizontally = false; if (_decoration.image!.matchTextDirection) { if (configuration.textDirection == null) { @@ -1519,19 +1727,28 @@ class BoxDecorationPainter extends BoxPainter { // the clip to the viewport so the row at top is visible (matches UA behavior). final CSSBackgroundAttachmentType attach = attachments[i]; final bool useViewport = attach == CSSBackgroundAttachmentType.fixed; - final Rect viewportRect = Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? configuration.size!); - final bool propagateToViewport = useViewport && _isRootBackgroundTarget(renderStyle.target); - final Rect layerClipRect = propagateToViewport ? viewportRect : clipRect; + final bool useLocalScroll = attach == CSSBackgroundAttachmentType.local; + final Rect viewportRect = Offset.zero & + (renderStyle.target.ownerDocument.viewport?.viewportSize ?? + configuration.size!); + final bool propagateToViewport = + useViewport && _isRootBackgroundTarget(renderStyle.target); + final Rect layerClipRect = propagateToViewport + ? viewportRect + : (useLocalScroll ? localClipRect : stationaryClipRect); canvas.save(); - if (_decoration.hasBorderRadius && _decoration.borderRadius != null && !propagateToViewport) { + if (_decoration.hasBorderRadius && + _decoration.borderRadius != null && + !propagateToViewport) { final rrect = _decoration.borderRadius!.toRRect(layerClipRect); final Path rounded = Path()..addRRect(rrect); canvas.clipPath(rounded); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] background(url) layer clip <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] background(url) layer clip <${el.tagName.toLowerCase()}> ' 'clipRect=${layerClipRect.size} tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -1542,8 +1759,8 @@ class BoxDecorationPainter extends BoxPainter { // For attachment: fixed, position relative to the viewport (initial containing block), // while still clipping to the element's background painting area. final Rect positioningRect = useViewport - ? (Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? configuration.size!)) - : originRect; + ? viewportRect + : (useLocalScroll ? localOriginRect : stationaryOriginRect); // Paint the image with per-layer overrides. _paintImage( @@ -1566,8 +1783,10 @@ class BoxDecorationPainter extends BoxPainter { canvas.restore(); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] layer(url) i=$i pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} ' - 'repeat=$repeat originRect=$originRect clipRect=$layerClipRect attachRect=$positioningRect attach=${attach.cssText()}'); + renderingLogger.finer( + '[Background] layer(url) i=$i pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} ' + 'repeat=$repeat originRect=${useLocalScroll ? localOriginRect : stationaryOriginRect} ' + 'clipRect=$layerClipRect attachRect=$positioningRect attach=${attach.cssText()}'); } continue; } @@ -1578,15 +1797,20 @@ class BoxDecorationPainter extends BoxPainter { // the clip to the viewport so the top row is visible. final CSSBackgroundAttachmentType attach = attachments[i]; final bool useViewport = attach == CSSBackgroundAttachmentType.fixed; - final Rect viewportRect = Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? configuration.size!); - final bool propagateToViewport = useViewport && _isRootBackgroundTarget(renderStyle.target); + final bool useLocalScroll = attach == CSSBackgroundAttachmentType.local; + final Rect viewportRect = Offset.zero & + (renderStyle.target.ownerDocument.viewport?.viewportSize ?? + configuration.size!); + final bool propagateToViewport = + useViewport && _isRootBackgroundTarget(renderStyle.target); final Rect positioningRect = useViewport - ? (Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? configuration.size!)) - : clipRect; + ? viewportRect + : (useLocalScroll ? localClipRect : stationaryClipRect); // Build gradient for this layer and paint. final CSSFunctionalNotation fn = fullFns[i]; // Compute destination size now to derive a per-layer length hint for px stops. - final Size destSize = _computeGradientDestinationSize(positioningRect, size); + final Size destSize = + _computeGradientDestinationSize(positioningRect, size); double? lengthHint; if (fn.name.contains('linear-gradient')) { lengthHint = _linearGradientLengthHint(fn, destSize); @@ -1594,46 +1818,50 @@ class BoxDecorationPainter extends BoxPainter { lengthHint = _radialGradientLengthHint(fn, positioningRect); } - final single = CSSBackgroundImage([fn], renderStyle, renderStyle.target.ownerDocument.controller, - baseHref: renderStyle.target.style.getPropertyBaseHref(BACKGROUND_IMAGE), gradientLengthHint: lengthHint); + final single = CSSBackgroundImage( + [fn], renderStyle, renderStyle.target.ownerDocument.controller, + baseHref: + renderStyle.target.style.getPropertyBaseHref(BACKGROUND_IMAGE), + gradientLengthHint: lengthHint); final Gradient? gradient = single.gradient; if (gradient == null) continue; - Rect destRect = _computeDestinationRect(positioningRect, destSize, px, py); + Rect destRect = + _computeDestinationRect(positioningRect, destSize, px, py); if (DebugFlags.enableBackgroundLogs) { List cs = const []; List? st; - if (gradient is LinearGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); - st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); - } else if (gradient is RadialGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); - st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); - } else if (gradient is SweepGradient) { - cs = gradient.colors - .map(_rgbaString) - .toList(); - st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); - } - renderingLogger.finer('[Background] layer(gradient) i=$i fn=${fullFns[i].name} rect=${positioningRect.size} ' + if (gradient is LinearGradient) { + cs = gradient.colors.map(_rgbaString).toList(); + st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); + } else if (gradient is RadialGradient) { + cs = gradient.colors.map(_rgbaString).toList(); + st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); + } else if (gradient is SweepGradient) { + cs = gradient.colors.map(_rgbaString).toList(); + st = gradient.stops?.map((v) => v.toStringAsFixed(4)).toList(); + } + renderingLogger.finer( + '[Background] layer(gradient) i=$i fn=${fullFns[i].name} rect=${positioningRect.size} ' 'destRect=${destRect.size} pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} repeat=$repeat ' 'colors=$cs stops=${st ?? const []}'); } canvas.save(); - final Rect layerClipRect = propagateToViewport ? viewportRect : clipRect; - if (_decoration.hasBorderRadius && _decoration.borderRadius != null && !propagateToViewport) { + final Rect layerClipRect = propagateToViewport + ? viewportRect + : (useLocalScroll ? localClipRect : stationaryClipRect); + if (_decoration.hasBorderRadius && + _decoration.borderRadius != null && + !propagateToViewport) { final rrect = _decoration.borderRadius!.toRRect(layerClipRect); final Path rounded = Path()..addRRect(rrect); canvas.clipPath(rounded); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] background(gradient) layer clip <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] background(gradient) layer clip <${el.tagName.toLowerCase()}> ' 'clipRect=${layerClipRect.size} tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -1644,23 +1872,30 @@ class BoxDecorationPainter extends BoxPainter { if (destRect.isEmpty) { // nothing } else if (repeat == ImageRepeat.noRepeat) { - final paint = Paint()..shader = gradient.createShader(destRect, textDirection: textDirection); + final paint = Paint() + ..shader = + gradient.createShader(destRect, textDirection: textDirection); canvas.drawRect(destRect, paint); } else { int tCount = 0; - for (final Rect tile in _generateImageTileRects(clipRect, destRect, repeat)) { - final paint = Paint()..shader = gradient.createShader(tile, textDirection: textDirection); + for (final Rect tile + in _generateImageTileRects(layerClipRect, destRect, repeat)) { + final paint = Paint() + ..shader = + gradient.createShader(tile, textDirection: textDirection); canvas.drawRect(tile, paint); if (DebugFlags.enableBackgroundLogs) tCount++; } if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] tiled $tCount rects for gradient layer'); + renderingLogger + .finer('[Background] tiled $tCount rects for gradient layer'); } } canvas.restore(); if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] layer(gradient) i=$i pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} repeat=$repeat rect=$destRect attachRect=$positioningRect attach=${attach.cssText()}'); + renderingLogger.finer( + '[Background] layer(gradient) i=$i pos=(${px.cssText()}, ${py.cssText()}) size=${size.cssText()} repeat=$repeat rect=$destRect attachRect=$positioningRect attach=${attach.cssText()}'); } } } @@ -1668,21 +1903,25 @@ class BoxDecorationPainter extends BoxPainter { BoxDecorationImagePainter? _imagePainter; - void _paintBackgroundImage(Canvas canvas, Rect clipRect, Rect originRect, ImageConfiguration configuration) { + void _paintBackgroundImage(Canvas canvas, Rect clipRect, Rect originRect, + ImageConfiguration configuration) { if (_decoration.image == null) return; if (_imagePainter == null) { - _imagePainter = BoxDecorationImagePainter._(_decoration.image!, renderStyle, onChanged!); + _imagePainter = BoxDecorationImagePainter._( + _decoration.image!, renderStyle, onChanged!); } else { _imagePainter!.image = _decoration.image!; } if (DebugFlags.enableBackgroundLogs) { final px = renderStyle.backgroundPositionX; final py = renderStyle.backgroundPositionY; - renderingLogger.finer('[Background] before painter: posX=${px.cssText()} (len=${px.length != null} pct=${px.percentage != null} calc=${px.calcValue != null}) ' + renderingLogger.finer( + '[Background] before painter: posX=${px.cssText()} (len=${px.length != null} pct=${px.percentage != null} calc=${px.calcValue != null}) ' 'posY=${py.cssText()} (len=${py.length != null} pct=${py.percentage != null} calc=${py.calcValue != null}) ' 'originRect=$originRect clipRect=$clipRect attach=${renderStyle.backgroundAttachment?.cssText() ?? 'scroll'}'); } - _imagePainter ??= BoxDecorationImagePainter._(_decoration.image!, renderStyle, onChanged!); + _imagePainter ??= BoxDecorationImagePainter._( + _decoration.image!, renderStyle, onChanged!); Path? clipPath; switch (_decoration.shape) { case BoxShape.circle: @@ -1695,7 +1934,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] background(url) clipPath <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] background(url) clipPath <${el.tagName.toLowerCase()}> ' 'clipRect=${clipRect.size} tl=(${rrect.tlRadiusX.toStringAsFixed(2)},${rrect.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -1707,9 +1947,12 @@ class BoxDecorationPainter extends BoxPainter { } // For attachment: fixed, use the viewport as the positioning rect so that // background-position is relative to the viewport (initial containing block). - final bool useViewport = renderStyle.backgroundAttachment == CSSBackgroundAttachmentType.fixed; + final bool useViewport = + renderStyle.backgroundAttachment == CSSBackgroundAttachmentType.fixed; final Rect positioningRect = useViewport - ? (Offset.zero & (renderStyle.target.ownerDocument.viewport?.viewportSize ?? configuration.size!)) + ? (Offset.zero & + (renderStyle.target.ownerDocument.viewport?.viewportSize ?? + configuration.size!)) : originRect; _imagePainter!.paint(canvas, positioningRect, clipPath, configuration); @@ -1724,7 +1967,8 @@ class BoxDecorationPainter extends BoxPainter { // Calculate the visible area of the background image double visibleArea = originRect.width * originRect.height; if (visibleArea > 0) { - renderStyle.target.ownerDocument.controller.reportLCPCandidate(renderStyle.target, visibleArea); + renderStyle.target.ownerDocument.controller + .reportLCPCandidate(renderStyle.target, visibleArea); } } } @@ -1737,19 +1981,24 @@ class BoxDecorationPainter extends BoxPainter { bool _hasLocalBackgroundImage() { if (renderStyle.backgroundImage == null) return false; - final String raw = renderStyle.target.style.getPropertyValue(BACKGROUND_ATTACHMENT); - if (raw.isEmpty) return renderStyle.backgroundAttachment == CSSBackgroundAttachmentType.local; + final String raw = + _getLayeredBackgroundPropertyValue(BACKGROUND_ATTACHMENT); + if (raw.isEmpty) + return renderStyle.backgroundAttachment == + CSSBackgroundAttachmentType.local; // Check comma-separated list for any 'local'. final List tokens = _splitByTopLevelCommas(raw); for (final t in tokens) { - if (CSSBackground.resolveBackgroundAttachment(t.trim()) == CSSBackgroundAttachmentType.local) { + if (CSSBackground.resolveBackgroundAttachment(t.trim()) == + CSSBackgroundAttachmentType.local) { return true; } } return false; } - void paintBackground(Canvas canvas, Offset offset, ImageConfiguration configuration) { + void paintBackground( + Canvas canvas, Offset offset, ImageConfiguration configuration) { assert(configuration.size != null); Offset baseOffset = Offset.zero; @@ -1764,19 +2013,31 @@ class BoxDecorationPainter extends BoxPainter { } // Rects for color and image - Rect backgroundColorRect = _getBackgroundClipRect(baseOffset, configuration); + Rect backgroundColorRect = + _getBackgroundClipRect(baseOffset, configuration); + Rect stationaryBackgroundClipRect = backgroundColorRect; + Rect stationaryBackgroundOriginRect = + _getBackgroundOriginRect(baseOffset, configuration); + Rect localBackgroundClipRect = + _getBackgroundClipRect(offset, configuration); + Rect localBackgroundOriginRect = + _getBackgroundOriginRect(offset, configuration); // Background image of background-attachment local scroll with content Offset backgroundImageOffset = hasLocalAttachment ? offset : baseOffset; // Rect of background image - Rect backgroundClipRect = _getBackgroundClipRect(backgroundImageOffset, configuration); - Rect backgroundOriginRect = _getBackgroundOriginRect(backgroundImageOffset, configuration); - Rect backgroundImageRect = backgroundClipRect.intersect(backgroundOriginRect); + Rect backgroundClipRect = + _getBackgroundClipRect(backgroundImageOffset, configuration); + Rect backgroundOriginRect = + _getBackgroundOriginRect(backgroundImageOffset, configuration); + Rect backgroundImageRect = + backgroundClipRect.intersect(backgroundOriginRect); if (DebugFlags.enableBackgroundLogs) { final clip = renderStyle.backgroundClip; final origin = renderStyle.backgroundOrigin; final rep = renderStyle.backgroundRepeat; - renderingLogger.finer('[Background] container=${configuration.size} offset=$offset ' + renderingLogger.finer( + '[Background] container=${configuration.size} offset=$offset ' 'clipRect=$backgroundClipRect originRect=$backgroundOriginRect imageRect=$backgroundImageRect ' 'clip=${clip ?? CSSBackgroundBoundary.borderBox} origin=${origin ?? CSSBackgroundBoundary.paddingBox} ' 'repeat=${rep.cssText()}'); @@ -1785,16 +2046,26 @@ class BoxDecorationPainter extends BoxPainter { final bool hasGradients = _hasGradientLayers(); final bool hasImages = _hasImageLayers(); if (hasGradients && hasImages) { - _paintLayeredMixedBackgrounds(canvas, backgroundClipRect, backgroundOriginRect, configuration, textDirection); + _paintLayeredMixedBackgrounds( + canvas, + stationaryBackgroundClipRect, + stationaryBackgroundOriginRect, + localBackgroundClipRect, + localBackgroundOriginRect, + configuration, + textDirection, + ); } else if (hasGradients) { _paintBackgroundColor(canvas, backgroundColorRect, textDirection); } else { _paintBackgroundColor(canvas, backgroundColorRect, textDirection); - _paintBackgroundImage(canvas, backgroundClipRect, backgroundOriginRect, configuration); + _paintBackgroundImage( + canvas, backgroundClipRect, backgroundOriginRect, configuration); } } - Rect _getBackgroundOriginRect(Offset offset, ImageConfiguration configuration) { + Rect _getBackgroundOriginRect( + Offset offset, ImageConfiguration configuration) { Size? size = configuration.size; EdgeInsets borderEdge = renderStyle.border; @@ -1815,7 +2086,9 @@ class BoxDecorationPainter extends BoxPainter { backgroundOriginRect = offset & size!; break; case CSSBackgroundBoundary.contentBox: - backgroundOriginRect = offset.translate(borderLeft + paddingLeft, borderTop + paddingTop) & size!; + backgroundOriginRect = + offset.translate(borderLeft + paddingLeft, borderTop + paddingTop) & + size!; break; default: backgroundOriginRect = offset.translate(borderLeft, borderTop) & size!; @@ -1853,11 +2126,20 @@ class BoxDecorationPainter extends BoxPainter { ); break; case CSSBackgroundBoundary.contentBox: - backgroundClipRect = offset.translate(borderLeft + paddingLeft, borderTop + paddingTop) & - Size( - size!.width - borderRight - borderLeft - paddingRight - paddingLeft, - size.height - borderBottom - borderTop - paddingBottom - paddingTop, - ); + backgroundClipRect = + offset.translate(borderLeft + paddingLeft, borderTop + paddingTop) & + Size( + size!.width - + borderRight - + borderLeft - + paddingRight - + paddingLeft, + size.height - + borderBottom - + borderTop - + paddingBottom - + paddingTop, + ); break; default: backgroundClipRect = offset & size!; @@ -1902,11 +2184,20 @@ class BoxDecorationPainter extends BoxPainter { final bool hasGradients = _hasGradientLayers(); final bool hasImages = _hasImageLayers(); Rect backgroundClipRect = _getBackgroundClipRect(offset, configuration); - Rect backgroundOriginRect = _getBackgroundOriginRect(offset, configuration); + Rect backgroundOriginRect = + _getBackgroundOriginRect(offset, configuration); if (hasImages) { // Paint layered images (and gradients if present) in proper order. - _paintLayeredMixedBackgrounds(canvas, backgroundClipRect, backgroundOriginRect, configuration, textDirection); + _paintLayeredMixedBackgrounds( + canvas, + backgroundClipRect, + backgroundOriginRect, + backgroundClipRect, + backgroundOriginRect, + configuration, + textDirection, + ); } else if (hasGradients) { _paintBackgroundColor(canvas, backgroundClipRect, textDirection); } else { @@ -1923,24 +2214,32 @@ class BoxDecorationPainter extends BoxPainter { final Border border = _decoration.border as Border; bool topDashed = border.top is ExtendedBorderSide && - (border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed; + (border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed; bool rightDashed = border.right is ExtendedBorderSide && - (border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed; + (border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed; bool bottomDashed = border.bottom is ExtendedBorderSide && - (border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed; + (border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed; bool leftDashed = border.left is ExtendedBorderSide && - (border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.dashed; + (border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.dashed; hasDashedBorder = topDashed || rightDashed || bottomDashed || leftDashed; bool topDouble = border.top is ExtendedBorderSide && - (border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double; + (border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double; bool rightDouble = border.right is ExtendedBorderSide && - (border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double; + (border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double; bool bottomDouble = border.bottom is ExtendedBorderSide && - (border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double; + (border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double; bool leftDouble = border.left is ExtendedBorderSide && - (border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double; + (border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double; hasDoubleBorder = topDouble || rightDouble || bottomDouble || leftDouble; } @@ -1962,18 +2261,27 @@ class BoxDecorationPainter extends BoxPainter { b.right is ExtendedBorderSide && b.bottom is ExtendedBorderSide && b.left is ExtendedBorderSide && - (b.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && - (b.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && - (b.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && - (b.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid; - final bool sameColor = b.top.color == b.right.color && b.top.color == b.bottom.color && b.top.color == b.left.color; + (b.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + (b.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + (b.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + (b.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid; + final bool sameColor = b.top.color == b.right.color && + b.top.color == b.bottom.color && + b.top.color == b.left.color; final bool nonUniformWidth = !(b.isUniform); - final bool uniformWidthCheck = b.top.width == b.right.width && b.top.width == b.bottom.width && b.top.width == b.left.width; + final bool uniformWidthCheck = b.top.width == b.right.width && + b.top.width == b.bottom.width && + b.top.width == b.left.width; if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] border solid/uniform checks on <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] border solid/uniform checks on <${el.tagName.toLowerCase()}> ' 'allSolid=$allSolid sameColor=$sameColor b.isUniform=${b.isUniform} uniformWidth=$uniformWidthCheck ' 'w=[${b.left.width},${b.top.width},${b.right.width},${b.bottom.width}]'); } catch (_) {} @@ -1990,12 +2298,18 @@ class BoxDecorationPainter extends BoxPainter { if (allSolid && uniformWidthCheck && b.top.width > 0.0) { // Treat as a circle when the box is square and all corner radii are // at least half of the side (handles "rounded-full" like 9999px). - bool isCircleByBorderRadius(BorderRadius br, Rect r, {double tol = 0.5}) { + bool isCircleByBorderRadius(BorderRadius br, Rect r, + {double tol = 0.5}) { final double w = r.width; final double h = r.height; if ((w - h).abs() > tol) return false; final double half = w / 2.0; - final List corners = [br.topLeft, br.topRight, br.bottomRight, br.bottomLeft]; + final List corners = [ + br.topLeft, + br.topRight, + br.bottomRight, + br.bottomLeft + ]; for (final c in corners) { if (c.x + tol < half || c.y + tol < half) return false; } @@ -2008,8 +2322,12 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - final r0 = br.topLeft; final r1 = br.topRight; final r2 = br.bottomRight; final r3 = br.bottomLeft; - renderingLogger.finer('[BorderRadius] circle detect(solid) <${el.tagName.toLowerCase()}> ' + final r0 = br.topLeft; + final r1 = br.topRight; + final r2 = br.bottomRight; + final r3 = br.bottomLeft; + renderingLogger.finer( + '[BorderRadius] circle detect(solid) <${el.tagName.toLowerCase()}> ' 'w=${rect.width.toStringAsFixed(2)} h=${rect.height.toStringAsFixed(2)} ' 'tl=(${r0.x.toStringAsFixed(2)},${r0.y.toStringAsFixed(2)}) ' 'tr=(${r1.x.toStringAsFixed(2)},${r1.y.toStringAsFixed(2)}) ' @@ -2022,7 +2340,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] solid per-side circle border painter for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] solid per-side circle border painter for <${el.tagName.toLowerCase()}>'); } catch (_) {} } _paintSolidPerSideCircleBorder(canvas, rect, b); @@ -2032,7 +2351,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] solid per-side rounded-rect painter for <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] solid per-side rounded-rect painter for <${el.tagName.toLowerCase()}>'); } catch (_) {} } _paintSolidPerSideRoundedRect(canvas, rect, b); @@ -2043,11 +2363,15 @@ class BoxDecorationPainter extends BoxPainter { } // Fallback: use Flutter's Border.paint. Only pass radius when border is uniform. - final BorderRadius? borderRadiusForPaint = b.isUniform ? _decoration.borderRadius : null; - if (!b.isUniform && _decoration.borderRadius != null && DebugFlags.enableBorderRadiusLogs) { + final BorderRadius? borderRadiusForPaint = + b.isUniform ? _decoration.borderRadius : null; + if (!b.isUniform && + _decoration.borderRadius != null && + DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] skip passing radius to border painter for <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] skip passing radius to border painter for <${el.tagName.toLowerCase()}> ' 'due to non-uniform border'); } catch (_) {} } @@ -2055,7 +2379,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] call Flutter Border.paint for <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] call Flutter Border.paint for <${el.tagName.toLowerCase()}> ' 'uniform=${b.isUniform} passRadius=${borderRadiusForPaint != null}'); } catch (_) {} } @@ -2074,7 +2399,8 @@ class BoxDecorationPainter extends BoxPainter { // Paint solid, non-uniform border widths with rounded corners by stroking // the band between an outer RRect (border box) and an inner RRect (content box). - void _paintSolidNonUniformBorderWithRadius(Canvas canvas, Rect rect, Border border) { + void _paintSolidNonUniformBorderWithRadius( + Canvas canvas, Rect rect, Border border) { final RRect outer = _decoration.borderRadius!.toRRect(rect); final double t = border.top.width; final double r = border.right.width; @@ -2086,12 +2412,16 @@ class BoxDecorationPainter extends BoxPainter { _cachedNonUniformRingRect == rect && _cachedNonUniformRingOuter != null && _cachedNonUniformRingOuter!.toString() == outer.toString() && - _cachedWTop == t && _cachedWRight == r && _cachedWBottom == b && _cachedWLeft == l) { + _cachedWTop == t && + _cachedWRight == r && + _cachedWBottom == b && + _cachedWLeft == l) { canReuse = true; } if (!canReuse) { - final Rect innerRect = Rect.fromLTRB(rect.left + l, rect.top + t, rect.right - r, rect.bottom - b); + final Rect innerRect = Rect.fromLTRB( + rect.left + l, rect.top + t, rect.right - r, rect.bottom - b); // Compute inner corner radii per CSS by subtracting corresponding side widths. final RRect inner = RRect.fromLTRBAndCorners( @@ -2132,14 +2462,17 @@ class BoxDecorationPainter extends BoxPainter { } final Color color = border.top.color; // uniform color validated by caller - final Paint p = Paint()..style = PaintingStyle.fill..color = color; + final Paint p = Paint() + ..style = PaintingStyle.fill + ..color = color; if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; final double innerTlX = math.max(outer.tlRadiusX - l, 0); final double innerTlY = math.max(outer.tlRadiusY - t, 0); - renderingLogger.finer('[BorderRadius] paint solid non-uniform+radius <${el.tagName.toLowerCase()}> ' + renderingLogger.finer( + '[BorderRadius] paint solid non-uniform+radius <${el.tagName.toLowerCase()}> ' 'w=[${l.toStringAsFixed(1)},${t.toStringAsFixed(1)},${r.toStringAsFixed(1)},${b.toStringAsFixed(1)}] ' 'outer.tl=(${outer.tlRadiusX.toStringAsFixed(1)},${outer.tlRadiusY.toStringAsFixed(1)}) ' 'inner.tl=(${innerTlX.toStringAsFixed(1)},${innerTlY.toStringAsFixed(1)})'); @@ -2167,49 +2500,61 @@ class BoxDecorationPainter extends BoxPainter { ..strokeJoin = StrokeJoin.miter; // Top: 225°..315° - if ((border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.top.width > 0) { + if ((border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.top.width > 0) { final p = p0(border.top.color); canvas.drawArc(circleOval, 5.0 * math.pi / 4.0, math.pi / 2.0, false, p); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint circle side=top w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] paint circle side=top w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); } catch (_) {} } } // Right: -45°..45° - if ((border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.right.width > 0) { + if ((border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.right.width > 0) { final p = p0(border.right.color); canvas.drawArc(circleOval, -math.pi / 4.0, math.pi / 2.0, false, p); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint circle side=right w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] paint circle side=right w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); } catch (_) {} } } // Bottom: 45°..135° - if ((border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.bottom.width > 0) { + if ((border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.bottom.width > 0) { final p = p0(border.bottom.color); canvas.drawArc(circleOval, math.pi / 4.0, math.pi / 2.0, false, p); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint circle side=bottom w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] paint circle side=bottom w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); } catch (_) {} } } // Left: 135°..225° - if ((border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.left.width > 0) { + if ((border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.left.width > 0) { final p = p0(border.left.color); canvas.drawArc(circleOval, 3.0 * math.pi / 4.0, math.pi / 2.0, false, p); if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint circle side=left w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] paint circle side=left w=${w.toStringAsFixed(2)} on <${el.tagName.toLowerCase()}>'); } catch (_) {} } } @@ -2232,16 +2577,23 @@ class BoxDecorationPainter extends BoxPainter { ..strokeJoin = StrokeJoin.miter; // Convenience ovals for corner arcs. - Rect tlOval = Rect.fromLTWH(rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); - Rect trOval = Rect.fromLTWH(rr.right - rr.trRadiusX * 2.0, rr.top, rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); - Rect brOval = Rect.fromLTWH(rr.right - rr.brRadiusX * 2.0, rr.bottom - rr.brRadiusY * 2.0, rr.brRadiusX * 2.0, rr.brRadiusY * 2.0); - Rect blOval = Rect.fromLTWH(rr.left, rr.bottom - rr.blRadiusY * 2.0, rr.blRadiusX * 2.0, rr.blRadiusY * 2.0); + Rect tlOval = + Rect.fromLTWH(rr.left, rr.top, rr.tlRadiusX * 2.0, rr.tlRadiusY * 2.0); + Rect trOval = Rect.fromLTWH(rr.right - rr.trRadiusX * 2.0, rr.top, + rr.trRadiusX * 2.0, rr.trRadiusY * 2.0); + Rect brOval = Rect.fromLTWH(rr.right - rr.brRadiusX * 2.0, + rr.bottom - rr.brRadiusY * 2.0, rr.brRadiusX * 2.0, rr.brRadiusY * 2.0); + Rect blOval = Rect.fromLTWH(rr.left, rr.bottom - rr.blRadiusY * 2.0, + rr.blRadiusX * 2.0, rr.blRadiusY * 2.0); // Top side: TL half-arc (225°->270°), straight top, TR half-arc (270°->315°) - if ((border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.top.width > 0) { + if ((border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.top.width > 0) { final Path topPath = Path(); if (rr.tlRadiusX > 0 && rr.tlRadiusY > 0) { - topPath.addArc(tlOval, 5.0 * math.pi / 4.0, math.pi / 4.0); // 225 -> 270 + topPath.addArc( + tlOval, 5.0 * math.pi / 4.0, math.pi / 4.0); // 225 -> 270 } else { topPath.moveTo(rr.left, rr.top); } @@ -2253,7 +2605,9 @@ class BoxDecorationPainter extends BoxPainter { } // Right side: TR half-arc (315°->360°), straight right, BR half-arc (0°->45°) - if ((border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.right.width > 0) { + if ((border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.right.width > 0) { final Path rightPath = Path(); if (rr.trRadiusX > 0 && rr.trRadiusY > 0) { rightPath.addArc(trOval, 1.75 * math.pi, math.pi / 4.0); // 315 -> 360 @@ -2268,7 +2622,9 @@ class BoxDecorationPainter extends BoxPainter { } // Bottom side: BR half-arc (45°->90°), straight bottom, BL half-arc (90°->135°) - if ((border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.bottom.width > 0) { + if ((border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.bottom.width > 0) { final Path bottomPath = Path(); if (rr.brRadiusX > 0 && rr.brRadiusY > 0) { bottomPath.addArc(brOval, math.pi / 4.0, math.pi / 4.0); // 45 -> 90 @@ -2283,10 +2639,13 @@ class BoxDecorationPainter extends BoxPainter { } // Left side: BL half-arc (135°->180°), straight left, TL half-arc (180°->225°) - if ((border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.solid && border.left.width > 0) { + if ((border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.solid && + border.left.width > 0) { final Path leftPath = Path(); if (rr.blRadiusX > 0 && rr.blRadiusY > 0) { - leftPath.addArc(blOval, 3.0 * math.pi / 4.0, math.pi / 4.0); // 135 -> 180 + leftPath.addArc( + blOval, 3.0 * math.pi / 4.0, math.pi / 4.0); // 135 -> 180 } else { leftPath.moveTo(rr.left, rr.bottom); } @@ -2300,14 +2659,16 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint per-side rounded-rect (uniform width) on <${el.tagName.toLowerCase()}>'); + renderingLogger.finer( + '[BorderRadius] paint per-side rounded-rect (uniform width) on <${el.tagName.toLowerCase()}>'); } catch (_) {} } } // Paint CSS double borders. Two parallel bands per side inside the border area. // For small widths (< 3), fall back to a single solid band for readability. - void _paintDoubleBorder(Canvas canvas, Rect rect, TextDirection? textDirection) { + void _paintDoubleBorder( + Canvas canvas, Rect rect, TextDirection? textDirection) { if (_decoration.border == null) return; final Border border = _decoration.border as Border; @@ -2315,7 +2676,9 @@ class BoxDecorationPainter extends BoxPainter { void drawHorizontalDoubleBands(double top, double bottom, Color color) { final double w = (bottom - top).abs(); if (w <= 0) return; - final Paint p = Paint()..style = PaintingStyle.fill..color = color; + final Paint p = Paint() + ..style = PaintingStyle.fill + ..color = color; if (w < 3.0) { // Fallback solid band canvas.drawRect(Rect.fromLTWH(rect.left, top, rect.width, w), p); @@ -2326,14 +2689,17 @@ class BoxDecorationPainter extends BoxPainter { // Upper band (closer to content for bottom side; for top side this sits at the top edge) canvas.drawRect(Rect.fromLTWH(rect.left, top, rect.width, band), p); // Lower band (outer edge) - canvas.drawRect(Rect.fromLTWH(rect.left, top + band + gap, rect.width, band), p); + canvas.drawRect( + Rect.fromLTWH(rect.left, top + band + gap, rect.width, band), p); } // Helper: draw vertical double bands within [left, right] region of the rect. void drawVerticalDoubleBands(double left, double right, Color color) { final double w = (right - left).abs(); if (w <= 0) return; - final Paint p = Paint()..style = PaintingStyle.fill..color = color; + final Paint p = Paint() + ..style = PaintingStyle.fill + ..color = color; if (w < 3.0) { canvas.drawRect(Rect.fromLTWH(left, rect.top, w, rect.height), p); return; @@ -2343,12 +2709,16 @@ class BoxDecorationPainter extends BoxPainter { // Left inner band canvas.drawRect(Rect.fromLTWH(left, rect.top, band, rect.height), p); // Right outer band - canvas.drawRect(Rect.fromLTWH(left + band + gap, rect.top, band, rect.height), p); + canvas.drawRect( + Rect.fromLTWH(left + band + gap, rect.top, band, rect.height), p); } // Detect uniform double border to support border-radius by stroking rrect twice. bool isUniformDouble(Border b) { - if (b.top is! ExtendedBorderSide || b.right is! ExtendedBorderSide || b.bottom is! ExtendedBorderSide || b.left is! ExtendedBorderSide) { + if (b.top is! ExtendedBorderSide || + b.right is! ExtendedBorderSide || + b.bottom is! ExtendedBorderSide || + b.left is! ExtendedBorderSide) { return false; } final t = b.top as ExtendedBorderSide; @@ -2359,13 +2729,17 @@ class BoxDecorationPainter extends BoxPainter { t.extendBorderStyle == r.extendBorderStyle && t.extendBorderStyle == btm.extendBorderStyle && t.extendBorderStyle == l.extendBorderStyle; - final sameWidth = t.width == r.width && t.width == btm.width && t.width == l.width; - final sameColor = t.color == r.color && t.color == btm.color && t.color == l.color; + final sameWidth = + t.width == r.width && t.width == btm.width && t.width == l.width; + final sameColor = + t.color == r.color && t.color == btm.color && t.color == l.color; return sameStyle && sameWidth && sameColor; } // Uniform double with border radius: draw two RRect strokes - if (isUniformDouble(border) && _decoration.hasBorderRadius && _decoration.borderRadius != null) { + if (isUniformDouble(border) && + _decoration.hasBorderRadius && + _decoration.borderRadius != null) { final side = border.top as ExtendedBorderSide; // all equal final double w = side.width; final Color color = side.color; @@ -2381,7 +2755,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint double border(rrect) <${el.tagName.toLowerCase()}> w=${w.toStringAsFixed(2)} ' + renderingLogger.finer( + '[BorderRadius] paint double border(rrect) <${el.tagName.toLowerCase()}> w=${w.toStringAsFixed(2)} ' 'tl=(${r.tlRadiusX.toStringAsFixed(2)},${r.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -2396,7 +2771,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint double border outer <${el.tagName.toLowerCase()}> band=${band.toStringAsFixed(2)} ' + renderingLogger.finer( + '[BorderRadius] paint double border outer <${el.tagName.toLowerCase()}> band=${band.toStringAsFixed(2)} ' 'tl=(${rOuter.tlRadiusX.toStringAsFixed(2)},${rOuter.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -2406,7 +2782,8 @@ class BoxDecorationPainter extends BoxPainter { if (DebugFlags.enableBorderRadiusLogs) { try { final el = renderStyle.target; - renderingLogger.finer('[BorderRadius] paint double border inner <${el.tagName.toLowerCase()}> band=${band.toStringAsFixed(2)} ' + renderingLogger.finer( + '[BorderRadius] paint double border inner <${el.tagName.toLowerCase()}> band=${band.toStringAsFixed(2)} ' 'tl=(${rInner.tlRadiusX.toStringAsFixed(2)},${rInner.tlRadiusY.toStringAsFixed(2)})'); } catch (_) {} } @@ -2419,7 +2796,8 @@ class BoxDecorationPainter extends BoxPainter { // however it ensures visibility rather than painting nothing. // Extract sides as ExtendedBorderSide when double if (border.top is ExtendedBorderSide && - (border.top as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double && + (border.top as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double && border.top.width > 0) { final s = border.top as ExtendedBorderSide; final double w = s.width; @@ -2427,7 +2805,8 @@ class BoxDecorationPainter extends BoxPainter { } if (border.right is ExtendedBorderSide && - (border.right as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double && + (border.right as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double && border.right.width > 0) { final s = border.right as ExtendedBorderSide; final double w = s.width; @@ -2435,7 +2814,8 @@ class BoxDecorationPainter extends BoxPainter { } if (border.bottom is ExtendedBorderSide && - (border.bottom as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double && + (border.bottom as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double && border.bottom.width > 0) { final s = border.bottom as ExtendedBorderSide; final double w = s.width; @@ -2443,7 +2823,8 @@ class BoxDecorationPainter extends BoxPainter { } if (border.left is ExtendedBorderSide && - (border.left as ExtendedBorderSide).extendBorderStyle == CSSBorderStyleType.double && + (border.left as ExtendedBorderSide).extendBorderStyle == + CSSBorderStyleType.double && border.left.width > 0) { final s = border.left as ExtendedBorderSide; final double w = s.width; @@ -2458,7 +2839,8 @@ class BoxDecorationPainter extends BoxPainter { /// Forked from flutter of [DecorationImagePainter] Class. /// https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/painting/decoration_image.dart#L208 class BoxDecorationImagePainter { - BoxDecorationImagePainter._(this._details, this._renderStyle, this._onChanged); + BoxDecorationImagePainter._( + this._details, this._renderStyle, this._onChanged); final CSSRenderStyle _renderStyle; DecorationImage _details; @@ -2486,7 +2868,8 @@ class BoxDecorationImagePainter { /// Forked from flutter with parameter customization of _paintImage method: /// https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/painting/decoration_image.dart#L231 - void paint(Canvas canvas, Rect rect, Path? clipPath, ImageConfiguration configuration) { + void paint(Canvas canvas, Rect rect, Path? clipPath, + ImageConfiguration configuration) { bool flipHorizontally = false; if (_details.matchTextDirection) { assert(() { @@ -2494,20 +2877,24 @@ class BoxDecorationImagePainter { // when the image is ready. if (configuration.textDirection == null) { throw FlutterError.fromParts([ - ErrorSummary('DecorationImage.matchTextDirection can only be used when a TextDirection is available.'), + ErrorSummary( + 'DecorationImage.matchTextDirection can only be used when a TextDirection is available.'), ErrorDescription( 'When BoxDecorationImagePainter.paint() was called, there was no text direction provided ' 'in the ImageConfiguration object to match.', ), - DiagnosticsProperty('The DecorationImage was', _details, + DiagnosticsProperty( + 'The DecorationImage was', _details, style: DiagnosticsTreeStyle.errorProperty), - DiagnosticsProperty('The ImageConfiguration was', configuration, + DiagnosticsProperty( + 'The ImageConfiguration was', configuration, style: DiagnosticsTreeStyle.errorProperty), ]); } return true; }()); - if (configuration.textDirection == TextDirection.rtl) flipHorizontally = true; + if (configuration.textDirection == TextDirection.rtl) + flipHorizontally = true; } final ImageStream newImageStream = _details.image.resolve(configuration); @@ -2522,7 +2909,8 @@ class BoxDecorationImagePainter { } if (_image == null) { if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] awaiting image load stream=${newImageStream.key} rect=$rect'); + renderingLogger.finer( + '[Background] awaiting image load stream=${newImageStream.key} rect=$rect'); } return; } @@ -2538,7 +2926,11 @@ class BoxDecorationImagePainter { CSSBackgroundPosition py = _backgroundPositionY; bool isDefault(CSSBackgroundPosition p) => p.length == null && p.calcValue == null && (p.percentage ?? -1) == -1; - final String rawPos = _renderStyle.target.style.getPropertyValue(BACKGROUND_POSITION); + final dynamic inlineRawPos = + _renderStyle.target.inlineStyle[BACKGROUND_POSITION]; + final String rawPos = inlineRawPos is String && inlineRawPos.isNotEmpty + ? inlineRawPos + : _renderStyle.target.style.getPropertyValue(BACKGROUND_POSITION); if (rawPos.isNotEmpty && (isDefault(px) || isDefault(py))) { try { final List pair = CSSPosition.parsePositionShorthand(rawPos); @@ -2549,7 +2941,8 @@ class BoxDecorationImagePainter { if (isDefault(px)) px = ax; if (isDefault(py)) py = ay; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] fallback axes from shorthand: raw="$rawPos" -> ' + renderingLogger.finer( + '[Background] fallback axes from shorthand: raw="$rawPos" -> ' 'x=${ax.cssText()} y=${ay.cssText()}'); } } catch (_) {} @@ -2584,7 +2977,8 @@ class BoxDecorationImagePainter { _image?.dispose(); _image = value; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] image stream delivered (sync=$synchronousCall) size=${value.image.width}x${value.image.height}'); + renderingLogger.finer( + '[Background] image stream delivered (sync=$synchronousCall) size=${value.image.width}x${value.image.height}'); } if (!synchronousCall) _onChanged(); } @@ -2700,10 +3094,14 @@ void _paintImage({ } else { // Default background-size: auto (no scaling). When fit is BoxFit.none and // both width/height are null, use the intrinsic image size as destination. - if (fit == BoxFit.none && backgroundWidth == null && backgroundHeight == null) { - destinationSize = inputSize; // draw at intrinsic size; clipping handled by clip rect + if (fit == BoxFit.none && + backgroundWidth == null && + backgroundHeight == null) { + destinationSize = + inputSize; // draw at intrinsic size; clipping handled by clip rect } else { - final FittedSizes fittedSizes = applyBoxFit(fit, inputSize / scale, outputSize); + final FittedSizes fittedSizes = + applyBoxFit(fit, inputSize / scale, outputSize); sourceSize = fittedSizes.source * scale; destinationSize = fittedSizes.destination; } @@ -2727,8 +3125,10 @@ void _paintImage({ if (colorFilter != null) paint.colorFilter = colorFilter; paint.filterQuality = filterQuality; paint.invertColors = invertColors; - final double halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0; - final double halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0; + final double halfWidthDelta = + (outputSize.width - destinationSize.width) / 2.0; + final double halfHeightDelta = + (outputSize.height - destinationSize.height) / 2.0; // Provide layer destination size for percentage resolution inside calc(). if (painterRef != null) { @@ -2740,7 +3140,11 @@ void _paintImage({ ? positionX.calcValue!.computedValue(BACKGROUND_POSITION_X) ?? 0 : positionX.length != null ? positionX.length!.computedValue - : halfWidthDelta + (flipHorizontally ? -positionX.percentage! : positionX.percentage!) * halfWidthDelta; + : halfWidthDelta + + (flipHorizontally + ? -positionX.percentage! + : positionX.percentage!) * + halfWidthDelta; final double dy = positionY.calcValue != null ? positionY.calcValue!.computedValue(BACKGROUND_POSITION_Y) ?? 0 : positionY.length != null @@ -2750,7 +3154,8 @@ void _paintImage({ final Offset destinationPosition = rect.topLeft.translate(dx, dy); final Rect destinationRect = destinationPosition & destinationSize; if (DebugFlags.enableBackgroundLogs) { - renderingLogger.finer('[Background] paintImage rect=$rect srcSize=$inputSize dstSize=$destinationSize ' + renderingLogger.finer( + '[Background] paintImage rect=$rect srcSize=$inputSize dstSize=$destinationSize ' 'dx=${dx.toStringAsFixed(2)} dy=${dy.toStringAsFixed(2)} destRect=$destinationRect ' 'posX=${positionX.cssText()} posY=${positionY.cssText()} fit=$backgroundSize'); } @@ -2765,14 +3170,17 @@ void _paintImage({ if (!kReleaseMode) { final ImageSizeInfo sizeInfo = ImageSizeInfo( // Some ImageProvider implementations may not have given this. - source: debugImageLabel ?? '', + source: + debugImageLabel ?? '', imageSize: Size(image.width.toDouble(), image.height.toDouble()), displaySize: outputSize, ); assert(() { if (debugInvertOversizedImages && - sizeInfo.decodedSizeInBytes > sizeInfo.displaySizeInBytes + debugImageOverheadAllowance) { - final int overheadInKilobytes = (sizeInfo.decodedSizeInBytes - sizeInfo.displaySizeInBytes) ~/ 1024; + sizeInfo.decodedSizeInBytes > + sizeInfo.displaySizeInBytes + debugImageOverheadAllowance) { + final int overheadInKilobytes = + (sizeInfo.decodedSizeInBytes - sizeInfo.displaySizeInBytes) ~/ 1024; final int outputWidth = outputSize.width.toInt(); final int outputHeight = outputSize.height.toInt(); FlutterError.reportError(FlutterErrorDetails( @@ -2824,8 +3232,10 @@ void _paintImage({ }()); // Avoid emitting events that are the same as those emitted in the last frame. if (!_lastFrameImageSizeInfo.contains(sizeInfo)) { - final ImageSizeInfo? existingSizeInfo = _pendingImageSizeInfo[sizeInfo.source]; - if (existingSizeInfo == null || existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) { + final ImageSizeInfo? existingSizeInfo = + _pendingImageSizeInfo[sizeInfo.source]; + if (existingSizeInfo == null || + existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) { _pendingImageSizeInfo[sizeInfo.source!] = sizeInfo; } debugOnPaintImage?.call(sizeInfo); @@ -2873,17 +3283,21 @@ void _paintImage({ if (repeat == ImageRepeat.noRepeat) { canvas.drawImageRect(image, sourceRect, destinationRect, paint); } else { - for (final Rect tileRect in _generateImageTileRects(rect, destinationRect, repeat)) { + for (final Rect tileRect + in _generateImageTileRects(rect, destinationRect, repeat)) { canvas.drawImageRect(image, sourceRect, tileRect, paint); } } } else { canvas.scale(1 / scale); if (repeat == ImageRepeat.noRepeat) { - canvas.drawImageNine(image, _scaleRect(centerSlice, scale), _scaleRect(destinationRect, scale), paint); + canvas.drawImageNine(image, _scaleRect(centerSlice, scale), + _scaleRect(destinationRect, scale), paint); } else { - for (final Rect tileRect in _generateImageTileRects(rect, destinationRect, repeat)) { - canvas.drawImageNine(image, _scaleRect(centerSlice, scale), _scaleRect(tileRect, scale), paint); + for (final Rect tileRect + in _generateImageTileRects(rect, destinationRect, repeat)) { + canvas.drawImageNine(image, _scaleRect(centerSlice, scale), + _scaleRect(tileRect, scale), paint); } } } @@ -2897,7 +3311,8 @@ void _paintImage({ // Forked from flutter with no modification: // https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/painting/decoration_image.dart#L597 -Iterable _generateImageTileRects(Rect outputRect, Rect fundamentalRect, ImageRepeat repeat) sync* { +Iterable _generateImageTileRects( + Rect outputRect, Rect fundamentalRect, ImageRepeat repeat) sync* { int startX = 0; int startY = 0; int stopX = 0; @@ -2924,5 +3339,5 @@ Iterable _generateImageTileRects(Rect outputRect, Rect fundamentalRect, Im // Forked from flutter with no modification: // https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/painting/decoration_image.dart#L621 -Rect _scaleRect(Rect rect, double scale) => - Rect.fromLTRB(rect.left * scale, rect.top * scale, rect.right * scale, rect.bottom * scale); +Rect _scaleRect(Rect rect, double scale) => Rect.fromLTRB(rect.left * scale, + rect.top * scale, rect.right * scale, rect.bottom * scale); diff --git a/webf/lib/src/rendering/box_model.dart b/webf/lib/src/rendering/box_model.dart index 5caa1100cf..48c6373728 100644 --- a/webf/lib/src/rendering/box_model.dart +++ b/webf/lib/src/rendering/box_model.dart @@ -469,7 +469,10 @@ abstract class RenderBoxModel extends RenderBox double? parentBoxContentConstraintsWidth; if (renderStyle.isParentRenderBoxModel() && - renderStyle.getAttachedRenderParentRenderStyle()?.attachedRenderBoxModel != null && + renderStyle + .getAttachedRenderParentRenderStyle() + ?.attachedRenderBoxModel != + null && (renderStyle.isSelfRenderLayoutBox() || renderStyle.isSelfRenderWidget())) { RenderBoxModel parentRenderBoxModel = (renderStyle @@ -677,21 +680,25 @@ abstract class RenderBoxModel extends RenderBox renderStyle.right.isNotAuto && renderStyle.width.isAuto)) { // Use the parent's available inline size if it's definite; otherwise fall back to infinity. - final double available = - (parentBoxContentConstraintsWidth != null && parentBoxContentConstraintsWidth.isFinite) - ? parentBoxContentConstraintsWidth - : (maxConstraintWidth.isFinite ? maxConstraintWidth : double.infinity); + final double available = (parentBoxContentConstraintsWidth != null && + parentBoxContentConstraintsWidth.isFinite) + ? parentBoxContentConstraintsWidth + : (maxConstraintWidth.isFinite + ? maxConstraintWidth + : double.infinity); double minIntrinsic = getMinIntrinsicWidth(double.infinity); double maxIntrinsic = getMaxIntrinsicWidth(double.infinity); // Respect nowrap/pre: min-content equals max-content for unbreakable inline content. - if (renderStyle.whiteSpace == WhiteSpace.nowrap || renderStyle.whiteSpace == WhiteSpace.pre) { + if (renderStyle.whiteSpace == WhiteSpace.nowrap || + renderStyle.whiteSpace == WhiteSpace.pre) { minIntrinsic = maxIntrinsic; } if (!minIntrinsic.isFinite || minIntrinsic < 0) minIntrinsic = 0; - if (!maxIntrinsic.isFinite || maxIntrinsic < minIntrinsic) maxIntrinsic = minIntrinsic; + if (!maxIntrinsic.isFinite || maxIntrinsic < minIntrinsic) + maxIntrinsic = minIntrinsic; double used; switch (renderStyle.width.type) { @@ -885,7 +892,9 @@ abstract class RenderBoxModel extends RenderBox // Base layout methods to compute content constraints before content box layout. // Call this method before content box layout. void beforeLayout() { - final RenderObject? effectiveParent = parent is RenderEventListener ? (parent as RenderEventListener).parent : parent; + final RenderObject? effectiveParent = parent is RenderEventListener + ? (parent as RenderEventListener).parent + : parent; // In WebF's render tree, CSS boxes can be wrapped by Flutter proxy render objects // (e.g., semantics/gesture/scroll adapters). Those wrappers can break the direct // "parent is RenderBoxModel" check, even though the incoming constraints are @@ -900,8 +909,11 @@ abstract class RenderBoxModel extends RenderBox // because that can unintentionally clamp shrink-to-fit widget elements like // `` and make them expand to maxWidth. final bool shouldUseIncomingConstraints = - effectiveParent is RenderBoxModel || constraints.hasTightWidth || constraints.hasTightHeight; - BoxConstraints contentConstraints = shouldUseIncomingConstraints ? constraints : getConstraints(); + effectiveParent is RenderBoxModel || + constraints.hasTightWidth || + constraints.hasTightHeight; + BoxConstraints contentConstraints = + shouldUseIncomingConstraints ? constraints : getConstraints(); // When a parent enforces a definite border-box size (e.g., grid/flex cell), // treat that as the used size for resolving this element's "auto" sizing. @@ -919,7 +931,8 @@ abstract class RenderBoxModel extends RenderBox !renderStyle.isParentRenderFlexLayout() && renderStyle.position != CSSPositionType.absolute && renderStyle.position != CSSPositionType.fixed) { - double contentW = renderStyle.deflatePaddingBorderWidth(constraints.maxWidth); + double contentW = + renderStyle.deflatePaddingBorderWidth(constraints.maxWidth); if (contentW.isFinite && contentW < 0) contentW = 0; renderStyle.contentBoxLogicalWidth = contentW; hasOverrideContentLogicalWidth = true; @@ -932,7 +945,8 @@ abstract class RenderBoxModel extends RenderBox !renderStyle.isParentRenderFlexLayout() && renderStyle.position != CSSPositionType.absolute && renderStyle.position != CSSPositionType.fixed) { - double contentH = renderStyle.deflatePaddingBorderHeight(constraints.maxHeight); + double contentH = + renderStyle.deflatePaddingBorderHeight(constraints.maxHeight); if (contentH.isFinite && contentH < 0) contentH = 0; renderStyle.contentBoxLogicalHeight = contentH; hasOverrideContentLogicalHeight = true; @@ -1394,7 +1408,8 @@ abstract class RenderBoxModel extends RenderBox // - others: any runtime paint adjustments. final Offset add = renderStyle.position == CSSPositionType.fixed ? getFixedScrollCompensation() - : Offset(additionalPaintOffsetX ?? 0.0, additionalPaintOffsetY ?? 0.0); + : Offset( + additionalPaintOffsetX ?? 0.0, additionalPaintOffsetY ?? 0.0); if (add.dx != 0.0 || add.dy != 0.0) { offset = offset.translate(add.dx, add.dy); } @@ -1589,8 +1604,11 @@ abstract class RenderBoxModel extends RenderBox // Prefer checking raw comma-separated list so that any layer with 'local' // opts this element into the overflow painting path that scrolls backgrounds // with content. Falls back to single computed value when raw is absent. - final String raw = - renderStyle.target.style.getPropertyValue(BACKGROUND_ATTACHMENT); + final dynamic inlineValue = + renderStyle.target.inlineStyle[BACKGROUND_ATTACHMENT]; + final String raw = inlineValue is String && inlineValue.isNotEmpty + ? inlineValue + : renderStyle.target.style.getPropertyValue(BACKGROUND_ATTACHMENT); if (raw.isEmpty) { return renderStyle.backgroundAttachment == CSSBackgroundAttachmentType.local; diff --git a/webf/test/src/css/css_structural_equality_test.dart b/webf/test/src/css/css_structural_equality_test.dart new file mode 100644 index 0000000000..5d4f9907b4 --- /dev/null +++ b/webf/test/src/css/css_structural_equality_test.dart @@ -0,0 +1,99 @@ +import 'package:test/test.dart'; +import 'package:webf/css.dart'; + +void main() { + group('CSS structural equality', () { + test( + 'declaration structural equality includes property value, importance and baseHref', + () { + final CSSStyleDeclaration left = CSSStyleDeclaration() + ..setProperty( + 'backgroundImage', + 'url(foo.png)', + baseHref: 'https://a.test/a.css', + ) + ..setProperty('color', 'red', isImportant: true); + final CSSStyleDeclaration right = CSSStyleDeclaration() + ..setProperty( + 'backgroundImage', + 'url(foo.png)', + baseHref: 'https://a.test/a.css', + ) + ..setProperty('color', 'red', isImportant: true); + final CSSStyleDeclaration differentBaseHref = CSSStyleDeclaration() + ..setProperty( + 'backgroundImage', + 'url(foo.png)', + baseHref: 'https://b.test/b.css', + ) + ..setProperty('color', 'red', isImportant: true); + + expect(left == right, isFalse); + expect(left.structurallyEquals(right), isTrue); + expect(left.structuralHashCode, right.structuralHashCode); + expect(left.structurallyEquals(differentBaseHref), isFalse); + }); + + test( + 'style rules from reparsed CSS compare structurally but not by identity', + () { + final CSSStyleRule left = + CSSParser('@layer ui { .foo::before { color: red; } }') + .parse() + .cssRules + .whereType() + .single + .cssRules + .whereType() + .single; + final CSSStyleRule right = + CSSParser('@layer ui { .foo::before { color: red; } }') + .parse() + .cssRules + .whereType() + .single + .cssRules + .whereType() + .single; + + expect(left == right, isFalse); + expect(left.structurallyEquals(right), isTrue); + expect(left.structuralHashCode, right.structuralHashCode); + }); + + test('stylesheet structural equality survives reparsing and respects href', + () { + final CSSStyleSheet left = CSSParser( + '.foo { background-image: url(foo.png); }', + href: 'https://a.test/a.css', + ).parse(); + final CSSStyleSheet same = CSSParser( + '.foo { background-image: url(foo.png); }', + href: 'https://a.test/a.css', + ).parse(); + final CSSStyleSheet differentHref = CSSParser( + '.foo { background-image: url(foo.png); }', + href: 'https://b.test/b.css', + ).parse(); + + expect(left == same, isFalse); + expect(left.structurallyEquals(same), isTrue); + expect(left.structuralHashCode, same.structuralHashCode); + expect(left.structurallyEquals(differentHref), isFalse); + }); + + test('rule list structural equality handles nested layer blocks', () { + final List left = + CSSParser('@layer ui { @layer chrome { .foo { color: red; } } }') + .parse() + .cssRules; + final List right = + CSSParser('@layer ui { @layer chrome { .foo { color: red; } } }') + .parse() + .cssRules; + + expect(cssRuleListsStructurallyEqual(left, right), isTrue); + expect(cssRuleListStructuralHash(left), cssRuleListStructuralHash(right)); + }); + }); +} diff --git a/webf/test/src/css/layer_cascade_test.dart b/webf/test/src/css/layer_cascade_test.dart index 24ef7ec07d..07e13f5561 100644 --- a/webf/test/src/css/layer_cascade_test.dart +++ b/webf/test/src/css/layer_cascade_test.dart @@ -53,8 +53,64 @@ String? _cascadeColor(String css) { return decl.getPropertyValue('color'); } +CSSStyleDeclaration _cascadeDeclaration(String css) { + final rules = _prepareMatchedStyleRules(css); + return cascadeMatchedStyleRules(rules); +} + void main() { group('@layer cascade', () { + test('single matched rule keeps normal and important declarations', () { + final decl = _cascadeDeclaration(''' + .x { + color: red !important; + background-color: blue; + } + '''); + + expect(decl.getPropertyValue('color'), 'red'); + expect(decl.getPropertyValue('background-color'), 'blue'); + }); + + test('cascade cache invalidates when ruleSet version changes', () { + final rules = _prepareMatchedStyleRules(''' + .x { color: red; } + .y { background-color: blue; } + '''); + + final first = cascadeMatchedStyleRules(rules, cacheVersion: 1); + expect(first.getPropertyValue('color'), 'red'); + + rules.first.declaration.setProperty('color', 'green'); + + final second = cascadeMatchedStyleRules(rules, cacheVersion: 2); + expect(second.getPropertyValue('color'), 'green'); + }); + + test('copyResult isolates cached declarations from caller mutation', () { + final rules = _prepareMatchedStyleRules(''' + .x::before { color: red; } + .y::before { background-color: blue; } + '''); + + final first = + cascadeMatchedStyleRules(rules, cacheVersion: 3, copyResult: true); + first.setProperty('color', 'green'); + + final second = + cascadeMatchedStyleRules(rules, cacheVersion: 3, copyResult: true); + expect(second.getPropertyValue('color'), 'red'); + }); + + test( + 'important cascade without layers follows specificity and source order', + () { + expect(_cascadeColor(''' + div { color: blue !important; } + .x { color: red !important; } + '''), 'red'); + }); + test('unlayered overrides layered', () { expect(_cascadeColor(''' @layer a { .x { color: red; } } diff --git a/webf/test/src/css/render_style_parsed_value_cache_test.dart b/webf/test/src/css/render_style_parsed_value_cache_test.dart new file mode 100644 index 0000000000..cbc69f7195 --- /dev/null +++ b/webf/test/src/css/render_style_parsed_value_cache_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:webf/css.dart'; +import 'package:webf/html.dart'; + +import '../../setup.dart'; + +void main() { + setUpAll(() { + setupTest(); + }); + + group('CSSRenderStyle parsed value cache', () { + test('reuses parse-time objects for context-free values', () { + final CSSRenderStyle firstStyle = CSSRenderStyle(target: HTMLElement(null)); + final CSSRenderStyle secondStyle = + CSSRenderStyle(target: HTMLElement(null)); + + final List firstValue = firstStyle.resolveValue( + TRANSITION_PROPERTY, 'opacity, transform') as List; + final List secondValue = secondStyle.resolveValue( + TRANSITION_PROPERTY, 'opacity, transform') as List; + + expect(identical(firstValue, secondValue), isTrue); + expect(firstValue, ['opacity', 'transform']); + }); + + test('resolves relative lengths per render style', () { + final CSSRenderStyle firstStyle = CSSRenderStyle(target: HTMLElement(null)); + final CSSRenderStyle secondStyle = + CSSRenderStyle(target: HTMLElement(null)); + + firstStyle.fontSize = CSSLengthValue(10, CSSLengthType.PX); + secondStyle.fontSize = CSSLengthValue(20, CSSLengthType.PX); + + final CSSLengthValue firstValue = + firstStyle.resolveValue(WIDTH, '2em') as CSSLengthValue; + final CSSLengthValue secondValue = + secondStyle.resolveValue(WIDTH, '2em') as CSSLengthValue; + + expect(identical(firstValue, secondValue), isFalse); + expect(firstValue.computedValue, 20); + expect(secondValue.computedValue, 40); + }); + }); +} diff --git a/webf/test/src/css/style_declaration_merge_test.dart b/webf/test/src/css/style_declaration_merge_test.dart new file mode 100644 index 0000000000..4407b4d94e --- /dev/null +++ b/webf/test/src/css/style_declaration_merge_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:webf/css.dart'; +import 'package:webf/html.dart'; + +import '../../setup.dart'; + +void main() { + setUpAll(() { + setupTest(); + }); + + group('CSSStyleDeclaration CSSOM property names', () { + test('accepts kebab-case property names', () { + final CSSStyleDeclaration style = CSSStyleDeclaration(); + + style.setProperty('background-color', 'blue', isImportant: true); + + expect(style.getPropertyValue('background-color'), 'blue'); + expect(style.getPropertyValue(BACKGROUND_COLOR), 'blue'); + expect(style.isImportant('background-color'), isTrue); + expect(style.isImportant(BACKGROUND_COLOR), isTrue); + }); + }); + + group('CSSStyleDeclaration.union', () { + test('adopts important declarations into an empty receiver', () { + final CSSStyleDeclaration current = CSSStyleDeclaration(); + final CSSStyleDeclaration incoming = CSSStyleDeclaration(); + + incoming.setProperty(COLOR, 'red', isImportant: true); + incoming.setProperty(WIDTH, '10px'); + + current.union(incoming); + + expect(current.getPropertyValue(COLOR), 'red'); + expect(current.isImportant(COLOR), isTrue); + expect(current.getPropertyValue(WIDTH), '10px'); + }); + + test('overlays non-important pending declarations directly', () { + final CSSStyleDeclaration current = CSSStyleDeclaration(); + final CSSStyleDeclaration first = CSSStyleDeclaration(); + final CSSStyleDeclaration second = CSSStyleDeclaration(); + + first.setProperty(COLOR, 'red'); + second.setProperty(COLOR, 'blue'); + second.setProperty(WIDTH, '10px'); + + current.union(first); + current.union(second); + + expect(current.getPropertyValue(COLOR), 'blue'); + expect(current.getPropertyValue(WIDTH), '10px'); + expect(current.isImportant(COLOR), isFalse); + }); + }); + + group('CSSStyleDeclaration.merge', () { + test('removes missing non-inherited properties to their initial value', () { + final CSSStyleDeclaration current = CSSStyleDeclaration(); + final CSSStyleDeclaration next = CSSStyleDeclaration(); + + current.setProperty(WIDTH, '10px'); + + expect(current.merge(next), isTrue); + expect(current.getPropertyValue(WIDTH), 'auto'); + }); + + test('preserves non-important fallback values for later removals', () { + final CSSStyleDeclaration current = CSSStyleDeclaration(); + final CSSStyleDeclaration next = CSSStyleDeclaration(); + + next.setProperty(COLOR, 'red'); + + expect(current.merge(next), isTrue); + current.removeProperty(COLOR, true); + + expect(current.getPropertyValue(COLOR), 'red'); + }); + }); + + group('CSSStyleDeclaration.flushPendingProperties', () { + CSSStyleDeclaration createStyle(List flushedProperties) { + final CSSStyleDeclaration style = CSSStyleDeclaration(); + style.target = HTMLElement(null); + style.onStyleFlushed = + (List properties) => flushedProperties.addAll(properties); + return style; + } + + test('flushes custom properties before dependent properties', () { + final List flushedProperties = []; + final CSSStyleDeclaration style = createStyle(flushedProperties); + + style.setProperty(COLOR, 'var(--tone)'); + style.setProperty('--tone', 'red'); + style.flushPendingProperties(); + + expect(flushedProperties, ['--tone', COLOR]); + }); + + test('preserves priority property flush order', () { + final List flushedProperties = []; + final CSSStyleDeclaration style = createStyle(flushedProperties); + + style.setProperty(FONT_SIZE, '16px'); + style.setProperty(COLOR, 'red'); + style.setProperty(WIDTH, '10px'); + style.setProperty(HEIGHT, '20px'); + style.flushPendingProperties(); + + expect(flushedProperties, [HEIGHT, WIDTH, COLOR, FONT_SIZE]); + }); + }); +} diff --git a/webf/test/src/dom/node_children_changed_style_batching_test.dart b/webf/test/src/dom/node_children_changed_style_batching_test.dart index b6a8b88967..3ae39d1240 100644 --- a/webf/test/src/dom/node_children_changed_style_batching_test.dart +++ b/webf/test/src/dom/node_children_changed_style_batching_test.dart @@ -2,6 +2,8 @@ * Copyright (C) 2024-present The WebF authors. All rights reserved. */ +import 'dart:ffi'; + import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -9,7 +11,14 @@ import 'package:webf/dom.dart'; class MockDocument extends Mock implements Document { @override - String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => 'MockDocument'; + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + 'MockDocument'; +} + +class MockElement extends Mock implements Element { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) => + 'MockElement'; } class TestNode extends Node { @@ -68,4 +77,55 @@ void main() { verifyNever(doc.updateStyleIfNeeded()); }); }); + + group('pruneNestedDirtyStyleElements', () { + test('drops dirty descendants covered by an ancestor subtree recalc', () { + final outer = MockElement(); + final middle = MockElement(); + final inner = MockElement(); + + when(outer.pointer).thenReturn(Pointer.fromAddress(1)); + when(outer.parentElement).thenReturn(null); + + when(middle.pointer).thenReturn(Pointer.fromAddress(2)); + when(middle.parentElement).thenReturn(outer); + + when(inner.pointer).thenReturn(Pointer.fromAddress(3)); + when(inner.parentElement).thenReturn(middle); + + final effectiveDirty = + pruneNestedDirtyStyleElements(>[ + MapEntry(outer, true), + MapEntry(middle, true), + MapEntry(inner, false), + ]); + + expect(effectiveDirty.map((entry) => entry.key), + orderedEquals([outer])); + expect(effectiveDirty.single.value, isTrue); + }); + + test('keeps dirty elements when no ancestor subtree rebuild covers them', + () { + final outer = MockElement(); + final middle = MockElement(); + + when(outer.pointer).thenReturn(Pointer.fromAddress(11)); + when(outer.parentElement).thenReturn(null); + + when(middle.pointer).thenReturn(Pointer.fromAddress(12)); + when(middle.parentElement).thenReturn(outer); + + final effectiveDirty = + pruneNestedDirtyStyleElements(>[ + MapEntry(outer, false), + MapEntry(middle, true), + ]); + + expect( + effectiveDirty.map((entry) => entry.key), + orderedEquals([outer, middle]), + ); + }); + }); } diff --git a/webf/test/src/rendering/css_selectors_pseudo_test.dart b/webf/test/src/rendering/css_selectors_pseudo_test.dart index 0d56b85065..af04a48126 100644 --- a/webf/test/src/rendering/css_selectors_pseudo_test.dart +++ b/webf/test/src/rendering/css_selectors_pseudo_test.dart @@ -42,10 +42,12 @@ void main() { }); group('CSS Pseudo Elements', () { - testWidgets('::before pseudo element creates PseudoElement child', (WidgetTester tester) async { + testWidgets('::before pseudo element creates PseudoElement child', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'before-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'before-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -88,10 +90,12 @@ void main() { expect(beforeElement.offsetHeight, equals(30.0)); }); - testWidgets('::after pseudo element creates PseudoElement child', (WidgetTester tester) async { + testWidgets('::after pseudo element creates PseudoElement child', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'after-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'after-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -134,10 +138,12 @@ void main() { expect(afterElement.offsetHeight, equals(20.0)); }); - testWidgets('both ::before and ::after pseudo elements', (WidgetTester tester) async { + testWidgets('both ::before and ::after pseudo elements', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'both-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'both-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -172,7 +178,8 @@ void main() { expect(afterElement, isNotNull); // Check content - expect((beforeElement!.firstChild as dom.TextNode).data, equals('Before')); + expect( + (beforeElement!.firstChild as dom.TextNode).data, equals('Before')); expect((afterElement!.firstChild as dom.TextNode).data, equals('After')); // Check colors @@ -189,11 +196,13 @@ void main() { expect(children.last, equals(afterElement)); }); - testWidgets('dynamic pseudo element creation/removal', skip: true, (WidgetTester tester) async { + testWidgets('dynamic pseudo element creation/removal', skip: true, + (WidgetTester tester) async { // TODO: This test is flaky due to timing issues with WebF's style recalculation final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'dynamic-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'dynamic-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -225,7 +234,8 @@ void main() { // Should have pseudo element now final beforeElement = findPseudoElement(div, PseudoKind.kPseudoBefore); expect(beforeElement, isNotNull); - expect((beforeElement!.firstChild as dom.TextNode).data, equals('Dynamic Before')); + expect((beforeElement!.firstChild as dom.TextNode).data, + equals('Dynamic Before')); // Remove class to remove pseudo element div.className = ''; @@ -238,10 +248,129 @@ void main() { expect(findPseudoElement(div, PseudoKind.kPseudoBefore), isNull); }); - testWidgets('pseudo element with display:none', (WidgetTester tester) async { + testWidgets('pseudo updates when className and id change', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'hidden-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'pseudo-toggle-id-class-${DateTime.now().millisecondsSinceEpoch}', + html: ''' + + + + + +
Main Content
+ + + ''', + ); + + final div = prepared.getElementById('pro'); + + await tester.pump(const Duration(milliseconds: 50)); + + final beforeInitial = findPseudoElement(div, PseudoKind.kPseudoBefore); + final afterInitial = findPseudoElement(div, PseudoKind.kPseudoAfter); + + expect(beforeInitial, isNotNull); + expect(afterInitial, isNotNull); + expect((beforeInitial!.firstChild as dom.TextNode).data, + equals('ID BEFORE')); + expect( + (afterInitial!.firstChild as dom.TextNode).data, equals('ID AFTER')); + expect( + beforeInitial.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); + expect( + afterInitial.renderStyle.color.value.toARGB32(), equals(0xFFFFFF00)); + + div.className = 'div1'; + div.id = ''; + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(); + + final beforeUpdated = findPseudoElement(div, PseudoKind.kPseudoBefore); + final afterUpdated = findPseudoElement(div, PseudoKind.kPseudoAfter); + + expect(beforeUpdated, isNotNull); + expect(afterUpdated, isNotNull); + expect((beforeUpdated!.firstChild as dom.TextNode).data, + equals('CLASS BEFORE')); + expect((afterUpdated!.firstChild as dom.TextNode).data, + equals('CLASS AFTER')); + expect( + beforeUpdated.renderStyle.color.value.toARGB32(), equals(0xFFFF0000)); + expect( + afterUpdated.renderStyle.color.value.toARGB32(), equals(0xFF00FF00)); + }); + + testWidgets( + 'pseudo descendant selector lists still match with ancestry fast path', + (WidgetTester tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: + 'pseudo-descendant-selector-list-${DateTime.now().millisecondsSinceEpoch}', + html: ''' + + + + + +
+
Main Content
+
+ + + ''', + ); + + final div = prepared.getElementById('target'); + + await tester.pump(const Duration(milliseconds: 50)); + + final beforeElement = findPseudoElement(div, PseudoKind.kPseudoBefore); + expect(beforeElement, isNotNull); + expect((beforeElement!.firstChild as dom.TextNode).data, equals('DESC')); + expect( + beforeElement.renderStyle.color.value.toARGB32(), equals(0xFF123456)); + }); + + testWidgets('pseudo element with display:none', + (WidgetTester tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: + 'hidden-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -274,10 +403,12 @@ void main() { expect(div.renderStyle.display, isNot(equals(CSSDisplay.none))); }); - testWidgets('pseudo element positioning and layout', (WidgetTester tester) async { + testWidgets('pseudo element positioning and layout', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'layout-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'layout-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -328,9 +459,10 @@ void main() { expect(afterElement, isNotNull); // Check positioning - expect(beforeElement!.renderStyle.position, equals(CSSPositionType.absolute)); - expect(afterElement!.renderStyle.position, equals(CSSPositionType.absolute)); - + expect(beforeElement!.renderStyle.position, + equals(CSSPositionType.absolute)); + expect( + afterElement!.renderStyle.position, equals(CSSPositionType.absolute)); // Check dimensions expect(beforeElement.offsetWidth, equals(30.0)); @@ -339,10 +471,12 @@ void main() { expect(afterElement.offsetHeight, equals(40.0)); }); - testWidgets('pseudo element behavior with empty content vs no content', (WidgetTester tester) async { + testWidgets('pseudo element behavior with empty content vs no content', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'empty-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'empty-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' diff --git a/webf/test/src/rendering/css_selectors_test.dart b/webf/test/src/rendering/css_selectors_test.dart index c789e6a52f..7ea8c1695b 100644 --- a/webf/test/src/rendering/css_selectors_test.dart +++ b/webf/test/src/rendering/css_selectors_test.dart @@ -42,7 +42,8 @@ void main() { testWidgets('basic id selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'id-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'id-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -60,13 +61,15 @@ void main() { final div = prepared.getElementById('div1'); // Check that a color is applied (not default black) expect(div.renderStyle.color, isNotNull); - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFF000000))); // not black + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFF000000))); // not black }); testWidgets('id selector with hyphen', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'id-selector-hyphen-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'id-selector-hyphen-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -85,14 +88,17 @@ void main() { final div = prepared.getElementById('-div1'); // Check that a color is applied (not default black or red) expect(div.renderStyle.color, isNotNull); - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFF000000))); // not black - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFFFF0000))); // not red + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFF000000))); // not black + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFFFF0000))); // not red }); testWidgets('id selector specificity', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'id-selector-specificity-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'id-selector-specificity-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -111,8 +117,10 @@ void main() { final div = prepared.getElementById('div1'); // ID selector should win, applying green color expect(div.renderStyle.color, isNotNull); - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFF000000))); // not black - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFFFF0000))); // not red + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFF000000))); // not black + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFFFF0000))); // not red }); }); @@ -120,7 +128,8 @@ void main() { testWidgets('basic class selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'class-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'class-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -143,7 +152,8 @@ void main() { testWidgets('element with class selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'element-class-selector-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'element-class-selector-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -166,12 +176,13 @@ void main() { // Span should not have the color since selector is div.div1 final spanColor = span.renderStyle.color; expect(spanColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + }); testWidgets('multiple class selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'multiple-class-selector-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'multiple-class-selector-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -193,13 +204,16 @@ void main() { expect(divWithAll.renderStyle.color, isNotNull); // has color // Missing class should have default color final missingColor = divMissing.renderStyle.color; - expect(missingColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + expect( + missingColor.value.toARGB32(), equals(0xFF000000)); // default black + }); - testWidgets('class selector specificity order', (WidgetTester tester) async { + testWidgets('class selector specificity order', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'class-specificity-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'class-specificity-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -220,13 +234,15 @@ void main() { expect(p.renderStyle.backgroundColor, isNotNull); expect(p.renderStyle.color, isNotNull); // Both styles should be applied from rule2 - expect(p.renderStyle.backgroundColor!.value, isNot(equals(p.renderStyle.color.value))); + expect(p.renderStyle.backgroundColor!.value, + isNot(equals(p.renderStyle.color.value))); }); testWidgets('dynamic class addition', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'dynamic-class-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'dynamic-class-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -245,7 +261,8 @@ void main() { final div = prepared.getElementById('test'); // Initially default color (WebF sets black as default) - expect(div.renderStyle.color.value.toARGB32(), equals(0xFF000000)); // black + expect( + div.renderStyle.color.value.toARGB32(), equals(0xFF000000)); // black // Add red class div.className = 'red'; @@ -261,8 +278,10 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); final blueColor = div.renderStyle.color; expect(blueColor, isNotNull); - expect(blueColor.value.toARGB32(), isNot(equals(0xFF000000))); // not black - expect(blueColor.value.toARGB32(), isNot(equals(redColor.value.toARGB32()))); // different from red + expect( + blueColor.value.toARGB32(), isNot(equals(0xFF000000))); // not black + expect(blueColor.value.toARGB32(), + isNot(equals(redColor.value.toARGB32()))); // different from red }); }); @@ -270,7 +289,8 @@ void main() { testWidgets('basic tag selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'tag-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'tag-selector-basic-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -292,13 +312,15 @@ void main() { expect(div.renderStyle.color, isNotNull); // red applied expect(span.renderStyle.color, isNotNull); // blue applied - expect(areColorsDifferent(div.renderStyle.color, span.renderStyle.color), isTrue); + expect(areColorsDifferent(div.renderStyle.color, span.renderStyle.color), + isTrue); }); testWidgets('universal selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'universal-selector-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'universal-selector-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -324,18 +346,20 @@ void main() { // Only div has red color expect(div.renderStyle.color, isNotNull); - expect(div.renderStyle.color.value.toARGB32(), isNot(equals(0xFF000000))); // not default + expect(div.renderStyle.color.value.toARGB32(), + isNot(equals(0xFF000000))); // not default // Span should have default color final spanColor = span.renderStyle.color; expect(spanColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + }); }); group('Pseudo Selectors', () { testWidgets('::before pseudo element content', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'before-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'before-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -367,7 +391,8 @@ void main() { testWidgets('::after pseudo element content', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'after-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'after-pseudo-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -392,10 +417,12 @@ void main() { expect(div.renderStyle, isNotNull); }); - testWidgets('pseudo element with display none', (WidgetTester tester) async { + testWidgets('pseudo element with display none', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'pseudo-display-none-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'pseudo-display-none-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -419,10 +446,12 @@ void main() { expect(div.renderStyle.display, isNot(equals(CSSDisplay.none))); }); - testWidgets(':root of-type pseudos on descendants', (WidgetTester tester) async { + testWidgets(':root of-type pseudos on descendants', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'root-of-type-descendant-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'root-of-type-descendant-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -462,7 +491,8 @@ void main() { testWidgets(':is() empty matches nothing', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'is-empty-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'is-empty-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -483,7 +513,8 @@ void main() { ); // Should not throw; should match nothing. - final List results = prepared.document.querySelectorAll([':is()']) as List; + final List results = + prepared.document.querySelectorAll([':is()']) as List; expect(results, isEmpty); // The invalid :is() rule should be ignored; red rule should apply. @@ -491,10 +522,12 @@ void main() { expect(a.renderStyle.color.value.toARGB32(), equals(0xFFFF0000)); }); - testWidgets(':is() selector list matches and has id specificity', (WidgetTester tester) async { + testWidgets(':is() selector list matches and has id specificity', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'is-basic-specificity-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'is-basic-specificity-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -512,7 +545,8 @@ void main() { ''', ); - final List results = prepared.document.querySelectorAll([':is(#a, #b)']) as List; + final List results = + prepared.document.querySelectorAll([':is(#a, #b)']) as List; final ids = results.map((e) => (e as dom.Element).id).toList()..sort(); expect(ids, equals(['a', 'b'])); @@ -532,7 +566,8 @@ void main() { testWidgets('basic descendant selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'descendant-selector-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'descendant-selector-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -557,12 +592,13 @@ void main() { // Top span should have default color final topColor = topSpan.renderStyle.color; expect(topColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + }); testWidgets('multiple level descendant', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'multi-descendant-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'multi-descendant-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -588,14 +624,44 @@ void main() { expect(deepSpan.renderStyle.color, isNotNull); // color applied // Shallow span should have default color final shallowColor = shallowSpan.renderStyle.color; - expect(shallowColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + expect( + shallowColor.value.toARGB32(), equals(0xFF000000)); // default black + }); + + testWidgets('selector list descendant fast path matches any branch', + (WidgetTester tester) async { + final prepared = await WebFWidgetTestUtils.prepareWidgetTest( + tester: tester, + controllerName: + 'selector-list-descendant-fast-path-${DateTime.now().millisecondsSinceEpoch}', + html: ''' + + + + + +
+ Matched by .foo branch +
+ + + ''', + ); + final target = prepared.getElementById('target'); + expect(target.renderStyle.color, isNotNull); + expect( + target.renderStyle.color.value.toARGB32(), isNot(equals(0xFF000000))); + }); - testWidgets('descendant with compound ancestor (attribute + class)', (WidgetTester tester) async { + testWidgets('descendant with compound ancestor (attribute + class)', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'descendant-compound-ancestor-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'descendant-compound-ancestor-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -626,7 +692,8 @@ void main() { testWidgets('direct child selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'child-selector-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'child-selector-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -653,14 +720,15 @@ void main() { // Nested span should have default color final nestedColor = nestedSpan.renderStyle.color; expect(nestedColor.value.toARGB32(), equals(0xFF000000)); // default black - }); + }); }); group('Sibling Selectors', () { testWidgets('adjacent sibling selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'adjacent-sibling-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'adjacent-sibling-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -684,12 +752,13 @@ void main() { // p2 should have default color final p2Color = p2.renderStyle.color; expect(p2Color.value.toARGB32(), equals(0xFF000000)); // default black - }); + }); testWidgets('general sibling selector', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'general-sibling-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'general-sibling-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -714,10 +783,12 @@ void main() { expect(p2.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); // blue }); - testWidgets('general sibling with child + :not()', (WidgetTester tester) async { + testWidgets('general sibling with child + :not()', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'general-sibling-child-not-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'general-sibling-child-not-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -740,15 +811,18 @@ void main() { final p2 = prepared.getElementById('p2'); final p3 = prepared.getElementById('p3'); - expect(p1.renderStyle.color.value.toARGB32(), equals(0xFF000000)); // default black + expect(p1.renderStyle.color.value.toARGB32(), + equals(0xFF000000)); // default black expect(p2.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); // blue expect(p3.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); // blue }); - testWidgets('general sibling updates after insertBefore', (WidgetTester tester) async { + testWidgets('general sibling updates after insertBefore', + (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'general-sibling-insertbefore-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'general-sibling-insertbefore-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -771,7 +845,8 @@ void main() { final p3 = prepared.getElementById('p3'); // Initial: only p3 matches because it has a previous sibling. - expect(p2.renderStyle.color.value.toARGB32(), equals(0xFF000000)); // default black + expect(p2.renderStyle.color.value.toARGB32(), + equals(0xFF000000)); // default black expect(p3.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); // blue // Insert p1 before p2; this should cause p2 to start matching the "~" selector. @@ -793,7 +868,8 @@ void main() { await tester.pump(const Duration(milliseconds: 100)); final p2After = prepared.getElementById('p2'); - expect(p2After.renderStyle.color.value.toARGB32(), equals(0xFF0000FF)); // blue + expect(p2After.renderStyle.color.value.toARGB32(), + equals(0xFF0000FF)); // blue }); }); @@ -801,7 +877,8 @@ void main() { testWidgets('multiple combinators', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'multiple-combinators-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'multiple-combinators-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -830,7 +907,8 @@ void main() { expect(li.renderStyle.color, isNotNull); // color applied expect(p.renderStyle.color, isNotNull); // color applied // They should have different colors - expect(areColorsDifferent(li.renderStyle.color, p.renderStyle.color), isTrue); + expect(areColorsDifferent(li.renderStyle.color, p.renderStyle.color), + isTrue); }); }); @@ -839,7 +917,8 @@ void main() { // TODO: WebF doesn't properly update styles when style elements are removed from DOM final prepared = await WebFWidgetTestUtils.prepareWidgetTest( tester: tester, - controllerName: 'style-removal-test-${DateTime.now().millisecondsSinceEpoch}', + controllerName: + 'style-removal-test-${DateTime.now().millisecondsSinceEpoch}', html: ''' @@ -860,7 +939,8 @@ void main() { // Initially has color final initialColor = div.renderStyle.color; expect(initialColor, isNotNull); - expect(initialColor.value.toARGB32(), isNot(equals(0xFF000000))); // not default + expect(initialColor.value.toARGB32(), + isNot(equals(0xFF000000))); // not default // Remove style style.parentNode?.removeChild(style); @@ -871,11 +951,12 @@ void main() { final afterColor = div.renderStyle.color; // If there's still a color, it should either be default black or different from initial expect( - afterColor.value.toARGB32() == 0xFF000000 || afterColor.value != initialColor.value, - isTrue, - reason: 'Color should either be default black or different from initial color' - ); - }); + afterColor.value.toARGB32() == 0xFF000000 || + afterColor.value != initialColor.value, + isTrue, + reason: + 'Color should either be default black or different from initial color'); + }); testWidgets('multiple styles cascade', (WidgetTester tester) async { final prepared = await WebFWidgetTestUtils.prepareWidgetTest(