diff --git a/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media/Given_GeometryData.cs b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media/Given_GeometryData.cs new file mode 100644 index 000000000000..39c8fdf38f11 --- /dev/null +++ b/src/Uno.UI.RuntimeTests/Tests/Windows_UI_Xaml_Media/Given_GeometryData.cs @@ -0,0 +1,43 @@ +#if __WASM__ + +using System; +using Microsoft.UI.Xaml.Media; +using Uno.Xaml; + +namespace Uno.UI.RuntimeTests.Tests.Windows_UI_Xaml_Media; + +[TestClass] +[RunsOnUIThread] +public class Given_GeometryData +{ + [DataTestMethod] + [DataRow("", FillRule.EvenOdd, "")] + [DataRow("F0", FillRule.EvenOdd, "")] + [DataRow("F1", FillRule.Nonzero, "")] + [DataRow(" F1", FillRule.Nonzero, "")] + [DataRow(" F 1", FillRule.Nonzero, "")] + [DataRow("F1 M0 0", FillRule.Nonzero, " M0 0")] + [DataRow(" F1 M0 0", FillRule.Nonzero, " M0 0")] + [DataRow(" F 1 M0 0", FillRule.Nonzero, " M0 0")] + public void When_GeometryData_ParseData_Valid(string rawdata, FillRule rule, string data) + { + var result = GeometryData.ParseData(rawdata); + + Assert.AreEqual(rule, result.FillRule); + Assert.AreEqual(data, result.Data); + } + + [DataTestMethod] + [DataRow("F")] + [DataRow("F2")] + [DataRow("FF")] + [DataRow("F 2")] + [DataRow("F M0 0")] + public void When_GeometryData_ParseData_Invalid(string rawdata) + { + Assert.ThrowsException(() => + GeometryData.ParseData(rawdata) + ); + } +} +#endif diff --git a/src/Uno.UI/UI/Xaml/Media/GeometryData.wasm.cs b/src/Uno.UI/UI/Xaml/Media/GeometryData.wasm.cs index f731f433d76a..a30ed26bbbff 100644 --- a/src/Uno.UI/UI/Xaml/Media/GeometryData.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Media/GeometryData.wasm.cs @@ -1,5 +1,7 @@ using System; using Microsoft.UI.Xaml.Wasm; +using Uno.UI.Xaml; +using Uno.Xaml; namespace Microsoft.UI.Xaml.Media { @@ -14,11 +16,13 @@ public class GeometryData : Geometry // // + private const FillRule DefaultFillRule = FillRule.EvenOdd; + private readonly SvgElement _svgElement = new SvgElement("path"); public string Data { get; } - public FillRule FillRule { get; } = FillRule.EvenOdd; + public FillRule FillRule { get; } = DefaultFillRule; public GeometryData() { @@ -26,26 +30,73 @@ public GeometryData() public GeometryData(string data) { - if ((data.StartsWith('F') || data.StartsWith('f')) && data.Length > 2) + (FillRule, Data) = ParseData(data); + + WindowManagerInterop.SetSvgPathAttributes(_svgElement.HtmlId, FillRule == FillRule.Nonzero, Data); + } + + internal static (FillRule FillRule, string Data) ParseData(string data) + { + if (data == "F") { - // TODO: support spaces between the F and the 0/1 + // uncompleted fill-rule block: missing value (just 'F' without 0/1 after) + throw new XamlParseException($"Failed to create a 'Data' from the text '{data}'."); + } - FillRule = data[1] == '1' ? FillRule.Nonzero : FillRule.EvenOdd; - Data = data.Substring(2); + if (data.Length >= 2 && TryExtractFillRule(data) is { } result) + { + return (result.Value, data[result.CurrentPosition..]); } else { - Data = data; + return (DefaultFillRule, data); } + } + private static (FillRule Value, int CurrentPosition)? TryExtractFillRule(string data) + { + // XamlParseException: 'Failed to create a 'Data' from the text 'F2'.' Line number '1' and line position '7'. + // "F1" just fill-rule without data is okay - _svgElement.SetAttribute("d", Data); - var rule = FillRule switch + // syntax: [fillRule] moveCommand drawCommand [drawCommand*] [closeCommand] + // Fill rule: + // There are two possible values for the optional fill rule: F0 or F1. (The F is always uppercase.) + // F0 is the default value; it produces EvenOdd fill behavior, so you don't typically specify it. + // Use F1 to get the Nonzero fill behavior. These fill values align with the values of the FillRule enumeration. + // -- https://learn.microsoft.com/en-us/windows/uwp/xaml-platform/move-draw-commands-syntax#the-basic-syntax + + // remark: despite explicitly stated: "The F is always uppercase", WinAppSDK is happily to accept lowercase 'f'. + // remark: you can use any number of whitespaces before/inbetween/after fill-rule/commands/command-parameters. + + var inFillRule = false; + for (int i = 0; i < data.Length; i++) + { + var c = data[i]; + + if (char.IsWhiteSpace(c)) continue; + if (inFillRule) + { + if (c is '1') return (FillRule.Nonzero, i + 1); + if (c is '0') // legacy uno behavior would be to use an `else` instead here + return (FillRule.EvenOdd, i + 1); + + throw new XamlParseException($"Failed to create a 'Data' from the text '{data}'."); + } + else if (c is 'F' or 'f') + { + inFillRule = true; + } + else + { + return null; + } + } + + if (inFillRule) { - FillRule.EvenOdd => "evenodd", - FillRule.Nonzero => "nonzero", - _ => "evenodd" - }; - _svgElement.SetAttribute("fill-rule", rule); + // uncompleted fill-rule block: missing value (just 'F' without 0/1 after) + throw new XamlParseException($"Failed to create a 'Data' from the text '{data}'."); + } + return null; } internal override SvgElement GetSvgElement() => _svgElement; diff --git a/src/Uno.UI/UI/Xaml/Media/GeometryGroup.wasm.cs b/src/Uno.UI/UI/Xaml/Media/GeometryGroup.wasm.cs index 75d5494a3da7..42869f4856bf 100644 --- a/src/Uno.UI/UI/Xaml/Media/GeometryGroup.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Media/GeometryGroup.wasm.cs @@ -5,6 +5,7 @@ using Uno.UI.DataBinding; using Windows.Foundation.Collections; using Microsoft.UI.Xaml.Wasm; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Media { @@ -28,13 +29,7 @@ private void OnPropertyChanged(ManagedWeakReference? instance, DependencyPropert if (property == FillRuleProperty) { - var rule = FillRule switch - { - FillRule.EvenOdd => "evenodd", - FillRule.Nonzero => "nonzero", - _ => "evenodd" - }; - _svgElement.SetAttribute("fill-rule", rule); + WindowManagerInterop.SetSvgFillRule(_svgElement.HtmlId, FillRule == FillRule.Nonzero); } else if (property == ChildrenProperty) { diff --git a/src/Uno.UI/UI/Xaml/Media/PointCollection.wasm.cs b/src/Uno.UI/UI/Xaml/Media/PointCollection.wasm.cs index 8b8d7fd7a410..36c7cac6e549 100644 --- a/src/Uno.UI/UI/Xaml/Media/PointCollection.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Media/PointCollection.wasm.cs @@ -11,17 +11,21 @@ namespace Microsoft.UI.Xaml.Media { public partial class PointCollection : IEnumerable, IList { - internal string ToCssString() + internal double[] Flatten() { - var sb = new StringBuilder(); - foreach (var p in _points) + if (_points.Count == 0) { - sb.Append(p.X.ToStringInvariant()); - sb.Append(','); - sb.Append(p.Y.ToStringInvariant()); - sb.Append(' '); // We will have an extra space at the end ... which is going to be ignored by browsers! + return Array.Empty(); } - return sb.ToString(); + + var buffer = new double[_points.Count * 2]; + for (int i = 0; i < _points.Count; i++) + { + buffer[i * 2] = _points[i].X; + buffer[i * 2 + 1] = _points[i].Y; + } + + return buffer; } } } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs index eb62e09c0619..16a3930d0167 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Ellipse.wasm.cs @@ -2,6 +2,7 @@ using Uno.Extensions; using Windows.Foundation; using Microsoft.UI.Xaml.Wasm; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { @@ -18,14 +19,9 @@ protected override Size ArrangeOverride(Size finalSize) var cx = shapeSize.Width / 2; var cy = shapeSize.Height / 2; - var halfStrokeThickness = ActualStrokeThickness / 2; - _mainSvgElement.SetAttribute( - ("cx", cx.ToStringInvariant()), - ("cy", cy.ToStringInvariant()), - ("rx", (cx - halfStrokeThickness).ToStringInvariant()), - ("ry", (cy - halfStrokeThickness).ToStringInvariant())); + WindowManagerInterop.SetSvgEllipseAttributes(_mainSvgElement.HtmlId, cx, cy, cx - halfStrokeThickness, cy - halfStrokeThickness); return finalSize; } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs index 770b6dae9988..da60adf64097 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Line.wasm.cs @@ -2,6 +2,7 @@ using Uno.Extensions; using Windows.Foundation; using Microsoft.UI.Xaml.Wasm; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { @@ -13,12 +14,8 @@ public Line() : base("line") protected override Size MeasureOverride(Size availableSize) { - _mainSvgElement.SetAttribute( - ("x1", X1.ToStringInvariant()), - ("x2", X2.ToStringInvariant()), - ("y1", Y1.ToStringInvariant()), - ("y2", Y2.ToStringInvariant()) - ); + WindowManagerInterop.SetSvgLineAttributes(_mainSvgElement.HtmlId, X1, X2, Y1, Y2); + return MeasureAbsoluteShape(availableSize, this); } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs index 320b146a982d..c466681d9ab1 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Polygon.wasm.cs @@ -2,6 +2,7 @@ using Windows.Foundation; using Microsoft.UI.Xaml.Wasm; using Uno.Extensions; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { @@ -9,15 +10,7 @@ partial class Polygon { protected override Size MeasureOverride(Size availableSize) { - var points = Points; - if (points == null) - { - _mainSvgElement.RemoveAttribute("points"); - } - else - { - _mainSvgElement.SetAttribute("points", points.ToCssString()); - } + WindowManagerInterop.SetSvgPolyPoints(_mainSvgElement.HtmlId, Points?.Flatten()); return MeasureAbsoluteShape(availableSize, this); } @@ -42,7 +35,7 @@ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs arg private protected override string GetBBoxCacheKeyImpl() => Points is { } points - ? ("polygone:" + points.ToCssString()) + ? ("polygone:" + string.Join(',', points.Flatten())) : null; } } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs index ea61ed2f2b47..94997aa0fce0 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Polyline.wasm.cs @@ -6,6 +6,7 @@ using Uno.Extensions; using Microsoft.UI.Xaml.Media; using System.Collections.Generic; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { @@ -13,15 +14,7 @@ partial class Polyline { protected override Size MeasureOverride(Size availableSize) { - var points = Points; - if (points == null) - { - _mainSvgElement.RemoveAttribute("points"); - } - else - { - _mainSvgElement.SetAttribute("points", points.ToCssString()); - } + WindowManagerInterop.SetSvgPolyPoints(_mainSvgElement.HtmlId, Points?.Flatten()); return MeasureAbsoluteShape(availableSize, this); } @@ -46,7 +39,7 @@ internal override void OnPropertyChanged2(DependencyPropertyChangedEventArgs arg private protected override string GetBBoxCacheKeyImpl() => Points is { } points - ? ("polygone:" + points.ToCssString()) + ? ("polyline:" + string.Join(',', points.Flatten())) : null; } } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs index aa9c80302013..cada67ab85b9 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Rectangle.wasm.cs @@ -4,6 +4,7 @@ using Windows.Foundation; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Wasm; +using Uno.UI.Xaml; namespace Microsoft.UI.Xaml.Shapes { @@ -16,15 +17,18 @@ public Rectangle() : base("rect") protected override Size ArrangeOverride(Size finalSize) { UpdateRender(); - _mainSvgElement.SetAttribute( - ("rx", RadiusX.ToStringInvariant()), - ("ry", RadiusY.ToStringInvariant()) - ); var (shapeSize, renderingArea) = ArrangeRelativeShape(finalSize); - Uno.UI.Xaml.WindowManagerInterop.SetSvgElementRect(_mainSvgElement.HtmlId, renderingArea); + WindowManagerInterop.SetSvgRectangleAttributes( + _mainSvgElement.HtmlId, + renderingArea.X, renderingArea.Y, renderingArea.Width, renderingArea.Height, + RadiusX, RadiusY + ); - _mainSvgElement.Clip = new RectangleGeometry() { Rect = new Rect(0, 0, finalSize.Width, finalSize.Height) }; + _mainSvgElement.Clip = new RectangleGeometry() + { + Rect = new Rect(0, 0, finalSize.Width, finalSize.Height) + }; return finalSize; } diff --git a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs index 104723dd3dd8..895a4d55b86d 100644 --- a/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Shapes/Shape.wasm.cs @@ -1,11 +1,15 @@ -using System; +#nullable enable + +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Numerics; using Windows.Foundation; +using Windows.UI; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Wasm; using Uno; using Uno.Collections; @@ -15,6 +19,7 @@ using Uno.UI.Xaml; using RadialGradientBrush = Microsoft/* UWP don't rename */.UI.Xaml.Media.RadialGradientBrush; +using BrushDef = (Microsoft.UI.Xaml.UIElement Def, System.IDisposable? InnerSubscription); namespace Microsoft.UI.Xaml.Shapes { @@ -27,19 +32,23 @@ internal static int BBoxCacheSize get => _bboxCache.Capacity; set => _bboxCache.Capacity = value; } + + private record UpdateRenderPropertiesHashes(int? Fill, int? Stroke, int? StrokeWidth, int? StrokeDashArray); } partial class Shape { - private protected string _bboxCacheKey; + private protected string? _bboxCacheKey; private readonly SerialDisposable _fillBrushSubscription = new SerialDisposable(); private readonly SerialDisposable _strokeBrushSubscription = new SerialDisposable(); - private DefsSvgElement _defs; + private DefsSvgElement? _defs; private protected readonly SvgElement _mainSvgElement; private protected bool _shouldUpdateNative = !FeatureConfiguration.Shape.WasmDelayUpdateUntilFirstArrange; + private UpdateRenderPropertiesHashes _lastRenderHashes = new(null, null, StrokeWidth: 0d.GetHashCode(), null); + protected Shape() : base("svg", isSvg: true) { // This constructor shouldn't be used. It exists to match WinUI API surface. @@ -68,161 +77,140 @@ private protected void UpdateRender() // StrokeThickness can alter getBBox on Ellipse and Rectangle, but we dont use getBBox in these two. - OnFillBrushChanged(); // fill - OnStrokeBrushChanged(); // stroke - UpdateStrokeThickness(); // stroke-width - UpdateStrokeDashArray(); // stroke-dasharray - } + // nested subscriptions of SolidColorBrush::{ Color, Opacity } for Fill and Stroke + // are done in OnFillChanged/OnStrokeChanged which in turns calls OnFillBrushChanged/OnStrokeBrushChanged + // on brush changes and on nested properties changes. + + var hashes = new UpdateRenderPropertiesHashes + ( + Fill: GetHashOfInterestFor(GetActualFill()), + Stroke: GetHashOfInterestFor(Stroke), + StrokeWidth: ActualStrokeThickness.GetHashCode(), + StrokeDashArray: GetHashOfInterestFor(StrokeDashArray) + ); + + switch ( + _lastRenderHashes.Fill != hashes.Fill, + _lastRenderHashes.Stroke != hashes.Stroke, + _lastRenderHashes.StrokeWidth != hashes.StrokeWidth, + _lastRenderHashes.StrokeDashArray != hashes.StrokeDashArray + ) + { + case (true, false, false, false): UpdateSvgFill(); break; + case (false, true, false, false): UpdateSvgStroke(); break; + case (false, false, true, false): UpdateSvgStrokeWidth(); break; + case (false, false, false, true): UpdateSvgStrokeDashArray(); break; + case (true, true, false, false): UpdateSvgFillAndStroke(); break; + + case (false, false, false, false): return; + default: UpdateSvgEverything(); break; + }; + _lastRenderHashes = hashes; + + // todo@xy: we need to ensure dp-of-interests guarantees an arrange call if changed + } private void OnFillBrushChanged() { if (!_shouldUpdateNative) return; - // We don't request an update of the HitTest (UpdateHitTest()) since this element is never expected to be hit testable. - // Note: We also enforce that the default hit test == false is not altered in the OnHitTestVisibilityChanged. - - // Instead we explicitly set the IsHitTestVisible on each child SvgElement - var fill = Fill; - - // Known issue: The hit test is only linked to the Fill, but should also take in consideration the Stroke and the StrokeThickness. - // Note: _mainSvgElement and _defs are internal elements, so it's legit to alter the IsHitTestVisible here. - _mainSvgElement.IsHitTestVisible = fill != null; - if (_defs is not null) + var hash = GetHashOfInterestFor(GetActualFill()); + if (hash != _lastRenderHashes.Fill) { - _defs.IsHitTestVisible = fill != null; + UpdateSvgFill(); + _lastRenderHashes = _lastRenderHashes with { Fill = hash }; } + } + private void OnStrokeBrushChanged() + { + if (!_shouldUpdateNative) return; - var svgElement = _mainSvgElement; - switch (fill) + var hash = GetHashOfInterestFor(Stroke); + if (hash != _lastRenderHashes.Stroke) { - case SolidColorBrush scb: - Uno.UI.Xaml.WindowManagerInterop.SetElementFill(svgElement.HtmlId, scb.ColorWithOpacity); - _fillBrushSubscription.Disposable = null; - break; - case ImageBrush ib: - var (imageFill, subscription) = ib.ToSvgElement(this); - var imageFillId = imageFill.HtmlId; - GetDefs().Add(imageFill); - svgElement.SetStyle("fill", $"url(#{imageFillId})"); - var removeDef = new DisposableAction(() => GetDefs().Remove(imageFill)); - _fillBrushSubscription.Disposable = new CompositeDisposable(removeDef, subscription); - break; - case GradientBrush gb: - var gradient = gb.ToSvgElement(); - var gradientId = gradient.HtmlId; - GetDefs().Add(gradient); - svgElement.SetStyle("fill", $"url(#{gradientId})"); - _fillBrushSubscription.Disposable = new DisposableAction( - () => GetDefs().Remove(gradient) - ); - break; - case RadialGradientBrush rgb: - var radialGradient = rgb.ToSvgElement(); - var radialGradientId = radialGradient.HtmlId; - GetDefs().Add(radialGradient); - svgElement.SetStyle("fill", $"url(#{radialGradientId})"); - _fillBrushSubscription.Disposable = new DisposableAction( - () => GetDefs().Remove(radialGradient) - ); - break; - case AcrylicBrush ab: - svgElement.SetStyle("fill", ab.FallbackColorWithOpacity.ToHexString()); - _fillBrushSubscription.Disposable = null; - break; - case null: - // The default is black if the style is not set in Web's' SVG. So if the Fill property is not set, - // we explicitly set the style to transparent in order to match the UWP behavior. - svgElement.SetStyle("fill", "transparent"); - _fillBrushSubscription.Disposable = null; - break; - default: - svgElement.ResetStyle("fill"); - _fillBrushSubscription.Disposable = null; - break; + UpdateSvgStroke(); + _lastRenderHashes = _lastRenderHashes with { Stroke = hash }; } } - private void OnStrokeBrushChanged() + private void UpdateSvgFill() { if (!_shouldUpdateNative) return; - var svgElement = _mainSvgElement; - var stroke = Stroke; + UpdateHitTestVisibility(); - switch (stroke) - { - case SolidColorBrush scb: - svgElement.SetStyle("stroke", scb.ColorWithOpacity.ToHexString()); - _strokeBrushSubscription.Disposable = null; - break; - case ImageBrush ib: - var (imageFill, subscription) = ib.ToSvgElement(this); - var imageFillId = imageFill.HtmlId; - GetDefs().Add(imageFill); - svgElement.SetStyle("stroke", $"url(#{imageFillId})"); - var removeDef = new DisposableAction(() => GetDefs().Remove(imageFill)); - _fillBrushSubscription.Disposable = new CompositeDisposable(removeDef, subscription); - break; - case GradientBrush gb: - var gradient = gb.ToSvgElement(); - var gradientId = gradient.HtmlId; - GetDefs().Add(gradient); - svgElement.SetStyle("stroke", $"url(#{gradientId})"); - _strokeBrushSubscription.Disposable = new DisposableAction( - () => GetDefs().Remove(gradient) - ); - break; - case RadialGradientBrush rgb: - var radialGradient = rgb.ToSvgElement(); - var radialGradientId = radialGradient.HtmlId; - GetDefs().Add(radialGradient); - svgElement.SetStyle("stroke", $"url(#{radialGradientId})"); - _strokeBrushSubscription.Disposable = new DisposableAction( - () => GetDefs().Remove(radialGradient) - ); - break; - case AcrylicBrush ab: - svgElement.SetStyle("stroke", ab.FallbackColorWithOpacity.ToHexString()); - _strokeBrushSubscription.Disposable = null; - break; - default: - svgElement.ResetStyle("stroke"); - _strokeBrushSubscription.Disposable = null; - break; - } + var (color, def) = GetBrushImpl(GetActualFill()); + + _fillBrushSubscription.Disposable = TryAppendBrushDef(def); + WindowManagerInterop.SetShapeFillStyle(_mainSvgElement.HtmlId, color?.ToCssIntegerAsInt(), def?.Def.HtmlId); } + private void UpdateSvgStroke() + { + if (!_shouldUpdateNative) return; + + var (color, def) = GetBrushImpl(Stroke); - private void UpdateStrokeThickness() + _strokeBrushSubscription.Disposable = TryAppendBrushDef(def); + WindowManagerInterop.SetShapeStrokeStyle(_mainSvgElement.HtmlId, color?.ToCssIntegerAsInt(), def?.Def.HtmlId); + } + private void UpdateSvgStrokeWidth() { if (!_shouldUpdateNative) return; - var svgElement = _mainSvgElement; - var strokeThickness = ActualStrokeThickness; + WindowManagerInterop.SetShapeStrokeWidthStyle(_mainSvgElement.HtmlId, ActualStrokeThickness); + } + private void UpdateSvgStrokeDashArray() + { + if (!_shouldUpdateNative) return; - if (strokeThickness != 1.0d) - { - svgElement.SetStyle("stroke-width", $"{strokeThickness}px"); - } - else - { - svgElement.ResetStyle("stroke-width"); - } + WindowManagerInterop.SetShapeStrokeDashArrayStyle(_mainSvgElement.HtmlId, StrokeDashArray?.ToArray() ?? Array.Empty()); } + private void UpdateSvgFillAndStroke() + { + if (!_shouldUpdateNative) return; + + var fillImpl = GetBrushImpl(GetActualFill()); + var strokeImpl = GetBrushImpl(Stroke); - private void UpdateStrokeDashArray() + _fillBrushSubscription.Disposable = TryAppendBrushDef(fillImpl.Def); + _strokeBrushSubscription.Disposable = TryAppendBrushDef(strokeImpl.Def); + WindowManagerInterop.SetShapeStylesFast1( + _mainSvgElement.HtmlId, + fillImpl.Color?.ToCssIntegerAsInt(), fillImpl.Def?.Def.HtmlId, + strokeImpl.Color?.ToCssIntegerAsInt(), strokeImpl.Def?.Def.HtmlId + ); + } + private void UpdateSvgEverything() { if (!_shouldUpdateNative) return; - var svgElement = _mainSvgElement; + var fillImpl = GetBrushImpl(GetActualFill()); + var strokeImpl = GetBrushImpl(Stroke); - if (StrokeDashArray is not { } strokeDashArray) - { - svgElement.ResetStyle("stroke-dasharray"); - } - else + _fillBrushSubscription.Disposable = TryAppendBrushDef(fillImpl.Def); + _strokeBrushSubscription.Disposable = TryAppendBrushDef(strokeImpl.Def); + WindowManagerInterop.SetShapeStylesFast2( + _mainSvgElement.HtmlId, + fillImpl.Color?.ToCssIntegerAsInt(), fillImpl.Def?.Def.HtmlId, + strokeImpl.Color?.ToCssIntegerAsInt(), strokeImpl.Def?.Def.HtmlId, ActualStrokeThickness, StrokeDashArray?.ToArray() ?? Array.Empty() + ); + } + + + private void UpdateHitTestVisibility() + { + // We don't request an update of the HitTest (UpdateHitTest()) since this element is never expected to be hit testable. + // Note: We also enforce that the default hit test == false is not altered in the OnHitTestVisibilityChanged. + + // Instead we explicitly set the IsHitTestVisible on each child SvgElement + var fill = Fill; + + // Known issue: The hit test is only linked to the Fill, but should also take in consideration the Stroke and the StrokeThickness. + // Note: _mainSvgElement and _defs are internal elements, so it's legit to alter the IsHitTestVisible here. + _mainSvgElement.IsHitTestVisible = fill != null; + if (_defs is not null) { - var str = string.Join(",", strokeDashArray.Select(d => $"{d.ToStringInvariant()}px")); - svgElement.SetStyle("stroke-dasharray", str); + _defs.IsHitTestVisible = fill != null; } } @@ -260,7 +248,7 @@ private static Rect GetPathBoundingBox(Shape shape) return result; } - private protected void Render(Shape shape, Size? size = null, double scaleX = 1d, double scaleY = 1d, double renderOriginX = 0d, double renderOriginY = 0d) + private protected void Render(Shape? shape, Size? size = null, double scaleX = 1d, double scaleY = 1d, double renderOriginX = 0d, double renderOriginY = 0d) { Debug.Assert(shape == this); var scale = Matrix3x2.CreateScale((float)scaleX, (float)scaleY); @@ -281,9 +269,106 @@ internal override bool HitTest(Point relativePosition) } // lazy impl, and _cacheKey can be invalidated by setting to null - private string GetBBoxCacheKey() => _bboxCacheKey ?? (_bboxCacheKey = GetBBoxCacheKeyImpl()); + private string? GetBBoxCacheKey() => _bboxCacheKey ?? (_bboxCacheKey = GetBBoxCacheKeyImpl()); // note: perf is of concern here. avoid $"string interpolation" and current-culture .ToString, and use string.concat and ToStringInvariant - private protected abstract string GetBBoxCacheKeyImpl(); + private protected abstract string? GetBBoxCacheKeyImpl(); + + private Brush GetActualFill() + { + // The default is black if the style is not set in Web's' SVG. So if the Fill property is not set, + // we explicitly set the style to transparent in order to match the UWP behavior. + + return Fill ?? SolidColorBrushHelper.Transparent; + } + + private (Color? Color, BrushDef? Def) GetBrushImpl(Brush brush) => brush switch // todo@xy: fix the name... + { + SolidColorBrush scb => (scb.ColorWithOpacity, null), + ImageBrush ib => (null, ib.ToSvgElement(this)), + AcrylicBrush ab => (ab.FallbackColorWithOpacity, null), + LinearGradientBrush lgb => (null, (lgb.ToSvgElement(), null)), + RadialGradientBrush rgb => (null, (rgb.ToSvgElement(), null)), + // The default is black if the style is not set in Web's' SVG. So if the Fill property is not set, + // we explicitly set the style to transparent in order to match the UWP behavior. + null => (null, null), + + _ => default, + }; + private IDisposable? TryAppendBrushDef(BrushDef? def) + { + if (def is not { } d) return null; + + GetDefs().Add(d.Def); + return new DisposableAction(() => + { + GetDefs().Remove(d.Def); + d.InnerSubscription?.Dispose(); + }); + } + + private static int? GetHashOfInterestFor(Brush brush) + { + int GetLGBHash(LinearGradientBrush lgb) + { + var hash = new HashCode(); + hash.Add(lgb.StartPoint); + hash.Add(lgb.EndPoint); + if (lgb.GradientStops is { Count: > 0 }) + { + foreach (var stop in lgb.GradientStops) + { + hash.Add(stop); + } + } + + return hash.ToHashCode(); + } + int GetRGBHash(RadialGradientBrush rgb) + { + var hash = new HashCode(); + hash.Add(rgb.Center); + hash.Add(rgb.RadiusX); + hash.Add(rgb.RadiusX); + if (rgb.GradientStops is { Count: > 0 }) + { + foreach (var stop in rgb.GradientStops) + { + hash.Add(stop); + } + } + + return hash.ToHashCode(); + } + + return brush switch + { + SolidColorBrush scb => scb.ColorWithOpacity.GetHashCode(), + // We don't care about the nested properties of ImageBrush, + // because their changes will be updated through ImageBrush::ToSvgElement subscriptions. + // So an object's reference hash is good here. + ImageBrush ib => ib.GetHashCode(), + LinearGradientBrush lgb => GetLGBHash(lgb), + RadialGradientBrush rgb => GetRGBHash(rgb), + AcrylicBrush ab => ab.FallbackColorWithOpacity.GetHashCode(), + + _ => null, + }; + } + private static int? GetHashOfInterestFor(DoubleCollection doubles) + { + if (doubles is not { Count: > 0 }) + { + return null; + } + + var hash = new HashCode(); + foreach (var item in doubles) + { + hash.Add(item); + } + + return hash.ToHashCode(); + } } } diff --git a/src/Uno.UI/UI/Xaml/Window/WindowManagerInterop.wasm.cs b/src/Uno.UI/UI/Xaml/Window/WindowManagerInterop.wasm.cs index 1de561f6ef4a..f8afa662c1f7 100644 --- a/src/Uno.UI/UI/Xaml/Window/WindowManagerInterop.wasm.cs +++ b/src/Uno.UI/UI/Xaml/Window/WindowManagerInterop.wasm.cs @@ -16,6 +16,7 @@ using System.Runtime.InteropServices.JavaScript; using Microsoft.UI.Xaml.Controls; using System.Xml.Linq; +using Microsoft.UI.Composition.Interactions; namespace Uno.UI.Xaml { @@ -140,36 +141,6 @@ internal static void SetStyleString(IntPtr htmlId, string name, string value) #endregion - #region SetRectanglePosition - - internal static void SetSvgElementRect(IntPtr htmlId, Rect rect) - { - var parms = new WindowManagerSetSvgElementRectParams - { - HtmlId = htmlId, - X = rect.X, - Y = rect.Y, - Width = rect.Width, - Height = rect.Height, - }; - - TSInteropMarshaller.InvokeJS("Uno:setSvgElementRect", parms); - } - - [TSInteropMessage] - [StructLayout(LayoutKind.Sequential, Pack = 8)] - private struct WindowManagerSetSvgElementRectParams - { - public double X; - public double Y; - public double Width; - public double Height; - - public IntPtr HtmlId; - } - - #endregion - #region SetStyles internal static void SetStyles(IntPtr htmlId, (string name, string value)[] styles) @@ -418,29 +389,49 @@ private struct WindowManagerSetSelectionHighlightParams #endregion - #region SetElementFill + #region SetShapeStyles... - internal static void SetElementFill(IntPtr htmlId, Color color) - { - var colorAsInteger = color.ToCssInteger(); + // error SYSLIB1072: Type uint is not supported by source-generated JavaScript interop. + // ^ we can't use uint here for color, so int will do. - var parms = new WindowManagerSetElementFillParams() - { - HtmlId = htmlId, - Color = colorAsInteger, - }; + internal static void SetShapeFillStyle(IntPtr htmlId, int? color, IntPtr? paintRef) => NativeMethods.SetShapeFillStyle(htmlId, color, paintRef); - TSInteropMarshaller.InvokeJS("Uno:setElementFillNative", parms); - } + internal static void SetShapeStrokeStyle(IntPtr htmlId, int? color, IntPtr? paintRef) => NativeMethods.SetShapeStrokeStyle(htmlId, color, paintRef); - [TSInteropMessage] - [StructLayout(LayoutKind.Sequential, Pack = 4)] - private struct WindowManagerSetElementFillParams - { - public IntPtr HtmlId; + internal static void SetShapeStrokeWidthStyle(IntPtr htmlId, double strokeWidth) => NativeMethods.SetShapeStrokeWidthStyle(htmlId, strokeWidth); + + internal static void SetShapeStrokeDashArrayStyle(IntPtr htmlId, double[] strokeDashArray) => NativeMethods.SetShapeStrokeDashArrayStyle(htmlId, strokeDashArray); + + internal static void SetShapeStylesFast1(IntPtr htmlId, int? fillColor, IntPtr? fillPaintRef, int? strokeColor, IntPtr? strokePaintRef) => + NativeMethods.SetShapeStylesFast1(htmlId, fillColor, fillPaintRef, strokeColor, strokePaintRef); + + internal static void SetShapeStylesFast2( + IntPtr htmlId, + int? fillColor, IntPtr? fillPaintRef, + int? strokeColor, IntPtr? strokePaintRef, double strokeWidth, double[] strokeDashArray) => + NativeMethods.SetShapeStylesFast2(htmlId, fillColor, fillPaintRef, strokeColor, strokePaintRef, strokeWidth, strokeDashArray); + + #endregion + + #region SetSvgProperties... + internal static void SetSvgFillRule(IntPtr htmlId, bool nonzero) => + NativeMethods.SetSvgFillRule(htmlId, nonzero); + + internal static void SetSvgEllipseAttributes(IntPtr htmlId, double cx, double cy, double rx, double ry) => + NativeMethods.SetSvgEllipseAttributes(htmlId, cx, cy, rx, ry); + + internal static void SetSvgLineAttributes(IntPtr htmlId, double x1, double x2, double y1, double y2) => + NativeMethods.SetSvgLineAttributes(htmlId, x1, x2, y1, y2); + + internal static void SetSvgPathAttributes(IntPtr htmlId, bool nonzero, string data) => + NativeMethods.SetSvgPathAttributes(htmlId, nonzero, data); + + internal static void SetSvgPolyPoints(IntPtr htmlId, double[] points) => + NativeMethods.SetSvgPolyPoints(htmlId, points); + + internal static void SetSvgRectangleAttributes(IntPtr htmlId, double x, double y, double width, double height, double rx, double ry) => + NativeMethods.SetSvgRectangleAttributes(htmlId, x, y, width, height, rx, ry); - public uint Color; - } #endregion #region RemoveView @@ -762,9 +753,15 @@ internal static UIElement TryGetElementInCoordinate(Point point) var htmlId = NativeMethods.GetElementInCoordinate(point.X, point.Y); return UIElement.GetElementFromHandle(htmlId); } + } + partial class WindowManagerInterop + { internal static partial class NativeMethods { + private const string StaticThis = "globalThis.Uno.UI.WindowManager"; + private const string InstancedThis = "globalThis.Uno.UI.WindowManager.current"; + [JSImport("globalThis.Uno.UI.WindowManager.current.arrangeElementNativeFast")] internal static partial void ArrangeElement( IntPtr htmlId, @@ -888,6 +885,45 @@ internal static partial void ArrangeElement( [JSImport("globalThis.Uno.UI.WindowManager.current.getBBox")] internal static partial double[] GetBBox(IntPtr htmlId); + + [JSImport($"{InstancedThis}.setShapeFillStyle")] + internal static partial void SetShapeFillStyle(IntPtr htmlId, int? color, IntPtr? paintRef); + + [JSImport($"{InstancedThis}.setShapeStrokeStyle")] + internal static partial void SetShapeStrokeStyle(IntPtr htmlId, int? color, IntPtr? paintRef); + + [JSImport($"{InstancedThis}.setShapeStrokeWidthStyle")] + internal static partial void SetShapeStrokeWidthStyle(IntPtr htmlId, double strokeWidth); + + [JSImport($"{InstancedThis}.setShapeStrokeDashArrayStyle")] + internal static partial void SetShapeStrokeDashArrayStyle(IntPtr htmlId, double[] strokeDashArray); + + [JSImport($"{InstancedThis}.setShapeStylesFast1")] + internal static partial void SetShapeStylesFast1(IntPtr htmlId, int? fillColor, IntPtr? fillPaintRef, int? strokeColor, IntPtr? strokePaintRef); + + [JSImport($"{InstancedThis}.setShapeStylesFast2")] + internal static partial void SetShapeStylesFast2( + IntPtr htmlId, + int? fillColor, IntPtr? fillPaintRef, + int? strokeColor, IntPtr? strokePaintRef, double strokeWidth, double[] strokeDashArray); + + [JSImport($"{InstancedThis}.setSvgFillRule")] + internal static partial void SetSvgFillRule(IntPtr htmlId, bool nonzero); + + [JSImport($"{InstancedThis}.setSvgEllipseAttributes")] + internal static partial void SetSvgEllipseAttributes(IntPtr htmlId, double cx, double cy, double rx, double ry); + + [JSImport($"{InstancedThis}.setSvgLineAttributes")] + internal static partial void SetSvgLineAttributes(IntPtr htmlId, double x1, double x2, double y1, double y2); + + [JSImport($"{InstancedThis}.setSvgPathAttributes")] + internal static partial void SetSvgPathAttributes(IntPtr htmlId, bool nonzero, System.String data); + + [JSImport($"{InstancedThis}.setSvgPolyPoints")] + internal static partial void SetSvgPolyPoints(IntPtr htmlId, double[] points); + + [JSImport($"{InstancedThis}.setSvgRectangleAttributes")] + internal static partial void SetSvgRectangleAttributes(IntPtr htmlId, double x, double y, double width, double height, double rx, double ry); } } } diff --git a/src/Uno.UI/ts/WindowManager.ts b/src/Uno.UI/ts/WindowManager.ts index c39687471958..e30ed70c4534 100644 --- a/src/Uno.UI/ts/WindowManager.ts +++ b/src/Uno.UI/ts/WindowManager.ts @@ -629,21 +629,6 @@ namespace Uno.UI { return this.setSelectionHighlight(params.HtmlId, params.BackgroundColor, params.ForegroundColor); } - /** - * Sets the fill property of the specified element - */ - public setElementFillNative(pParam: number): boolean { - const params = WindowManagerSetElementFillParams.unmarshal(pParam); - this.setElementFillInternal(params.HtmlId, params.Color); - return true; - } - - private setElementFillInternal(elementId: number, color: number): void { - const element = this.getView(elementId); - - element.style.setProperty("fill", this.numberToCssColor(color)); - } - /** * Sets the background color property of the specified element */ @@ -1024,19 +1009,6 @@ namespace Uno.UI { } } - public setSvgElementRect(pParams: number): boolean { - const params = WindowManagerSetSvgElementRectParams.unmarshal(pParams); - - const element = this.getView(params.HtmlId) as any; - - element.x.baseVal.value = params.X; - element.y.baseVal.value = params.Y; - element.width.baseVal.value = params.Width; - element.height.baseVal.value = params.Height; - - return true; - } - /** * Use the Html engine to measure the element using specified constraints. * @@ -1598,6 +1570,132 @@ namespace Uno.UI { private onBodyKeyUp(event: KeyboardEvent) { WindowManager.keyTrackingMethod(event.key, false); } + + private getCssColorOrUrlRef(color: number, paintRef: number): string { + if (paintRef != null) { + return `url(#${paintRef})`; + } + else if (color != null) { + // JSInvoke doesnt allow passing of uint, so we had to deal with int's "sign-ness" here + // (-1 >>> 0) is a quick hack to turn signed negative into "unsigned" positive + // padded to 8-digits 'RRGGBBAA', so the value doesnt get processed as 'RRGGBB' or 'RGB'. + return `#${(color >>> 0).toString(16).padStart(8, '0')}`; + } + else { + return ''; + } + } + + public setShapeFillStyle(elementId: number, color: number, paintRef: number): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + e.style.fill = this.getCssColorOrUrlRef(color, paintRef); + } + } + + public setShapeStrokeStyle(elementId: number, color: number, paintRef: number): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + e.style.stroke = this.getCssColorOrUrlRef(color, paintRef); + } + } + + public setShapeStrokeWidthStyle(elementId: number, strokeWidth: number): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + e.style.strokeWidth = `${strokeWidth}px`; + } + } + + public setShapeStrokeDashArrayStyle(elementId: number, strokeDashArray: number[]): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + + e.style.strokeDasharray = strokeDashArray.join(','); + } + } + + public setShapeStylesFast1(elementId: number, fillColor: number, fillPaintRef: number, strokeColor: number, strokePaintRef: number): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + + e.style.fill = this.getCssColorOrUrlRef(fillColor, fillPaintRef); + e.style.stroke = this.getCssColorOrUrlRef(strokeColor, strokePaintRef); + } + } + + public setShapeStylesFast2(elementId: number, fillColor: number, fillPaintRef: number, strokeColor: number, strokePaintRef: number, strokeWidth: number, strokeDashArray: any[]): void { + const e = this.getView(elementId); + if (e instanceof SVGElement) { + + e.style.fill = this.getCssColorOrUrlRef(fillColor, fillPaintRef); + e.style.stroke = this.getCssColorOrUrlRef(strokeColor, strokePaintRef); + e.style.strokeWidth = `${strokeWidth}px`; + e.style.strokeDasharray = strokeDashArray.join(','); + } + } + + public setSvgFillRule(htmlId: number, nonzero: boolean): void { + const e = this.getView(htmlId); + if (e instanceof SVGPathElement) { + e.setAttribute('fill-rule', nonzero ? 'nonzero' : 'evenodd'); + } + } + + public setSvgEllipseAttributes(htmlId: number, cx: number, cy: number, rx: number, ry: number): void { + const e = this.getView(htmlId); + if (e instanceof SVGEllipseElement) { + e.cx.baseVal.value = cx; + e.cy.baseVal.value = cy; + e.rx.baseVal.value = rx; + e.ry.baseVal.value = ry; + } + } + + public setSvgLineAttributes(htmlId: number, x1: number, x2: number, y1: number, y2: number): void { + const e = this.getView(htmlId); + if (e instanceof SVGLineElement) { + e.x1.baseVal.value = x1; + e.x2.baseVal.value = x2; + e.y1.baseVal.value = y1; + e.y2.baseVal.value = y2; + } + } + + public setSvgPathAttributes(htmlId: number, nonzero: boolean, data: string): void { + const e = this.getView(htmlId); + if (e instanceof SVGPathElement) { + e.setAttribute('fill-rule', nonzero ? 'nonzero' : 'evenodd'); + e.setAttribute('d', data); + } + } + + public setSvgPolyPoints(htmlId: number, points: number[]): void { + const e = this.getView(htmlId); + if (e instanceof SVGPolygonElement || e instanceof SVGPolylineElement) { + if (points != null) { + const delimiters = [' ', ',']; + // interwave to produce: x0,y0 x1,y1 ... + // i start at 1 + e.setAttribute('points', points.reduce((acc, x, i) => acc + delimiters[i % delimiters.length] + x, '')); + } + else { + e.removeAttribute('points'); + } + } + } + + public setSvgRectangleAttributes(htmlId: number, x: number, y: number, width: number, height: number, rx: number, ry: number): void { + const e = this.getView(htmlId); + if (e instanceof SVGRectElement) { + e.x.baseVal.value = x; + e.y.baseVal.value = y; + e.width.baseVal.value = width; + e.height.baseVal.value = height; + e.rx.baseVal.value = rx; + e.ry.baseVal.value = ry; + } + } } if (typeof define === "function") { diff --git a/src/Uno.UWP/UI/Color.cs b/src/Uno.UWP/UI/Color.cs index f18f100218bc..74e9d2d309ad 100644 --- a/src/Uno.UWP/UI/Color.cs +++ b/src/Uno.UWP/UI/Color.cs @@ -40,7 +40,9 @@ public partial struct Color : IFormattable internal Color(byte a, byte r, byte g, byte b) { - _color = 0; // Required for field initialization rules in C# + // Required for field initialization rules in C# + _color = 0; + _b = b; _g = g; _r = r; @@ -54,6 +56,7 @@ internal Color(uint color) _g = 0; _r = 0; _a = 0; + _color = color; } diff --git a/src/Uno.UWP/UI/Color.wasm.cs b/src/Uno.UWP/UI/Color.wasm.cs index a092578c023b..ade9d04b2323 100644 --- a/src/Uno.UWP/UI/Color.wasm.cs +++ b/src/Uno.UWP/UI/Color.wasm.cs @@ -18,14 +18,28 @@ internal string ToCssString() => + ")"; /// - /// Get color value in CSS format "rgba(r, g, b, a)" + /// Get color value in "rrggbbaa" as integer value /// + /// + /// IMPORTANT: We MUST NOT just naively prefix this with # in js without appropriate padding, + /// because of 6-digits (00GGBBAA as RRGGBB) and 3-digits (00000BAA as RGB) color notations. + /// internal uint ToCssInteger() => - (uint)(R << 24) - | (uint)(G << 16) - | (uint)(B << 8) - | A; + // = AARRGGBB << 8 | AA + // = RRGGBB00 | AA + // = RRGGBBAA + AsUInt32() << 8 | A; + + // [JSImport] doesnt allow for `uint` params, so we pass an `int` instead + internal int ToCssIntegerAsInt() => unchecked((int)ToCssInteger()); + /// + /// Get color value in "#rrggbb" or "#rrggbbaa" notation + /// + /// + /// The #rrggbbaa hex color notation requires modern browsers. It is not available in older versions of Internet Explorer. + /// See also: https://caniuse.com/css-rrggbbaa + /// internal string ToHexString() { var builder = new StringBuilder(10);