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..167c6bb533f9 --- /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/Window/WindowManagerInterop.wasm.cs b/src/Uno.UI/UI/Xaml/Window/WindowManagerInterop.wasm.cs index cea518b461c9..78d021d19cb5 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 { @@ -442,6 +443,27 @@ internal static void SetShapeStylesFast2( #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); + + #endregion + #region RemoveView internal static void RemoveView(IntPtr htmlId, IntPtr childId) { @@ -761,7 +783,10 @@ 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"; @@ -911,6 +936,24 @@ 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 93e1c36ecbad..cecdbeb67371 100644 --- a/src/Uno.UI/ts/WindowManager.ts +++ b/src/Uno.UI/ts/WindowManager.ts @@ -1,4 +1,4 @@ -declare const config: any; +declare const config: any; // eslint-disable-next-line @typescript-eslint/no-namespace namespace Uno.UI { @@ -1650,6 +1650,69 @@ namespace Uno.UI { 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.setAttribute('cx', cx.toString()); + e.setAttribute('cy', cy.toString()); + e.setAttribute('rx', rx.toString()); + e.setAttribute('ry', ry.toString()); + } + } + + public setSvgLineAttributes(htmlId: number, x1: number, x2: number, y1: number, y2: number): void { + const e = this.getView(htmlId); + if (e instanceof SVGLineElement) { + e.setAttribute('x1', x1.toString()); + e.setAttribute('x2', x2.toString()); + e.setAttribute('y1', y1.toString()); + e.setAttribute('y2', y2.toString()); + } + } + + 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.setAttribute('rx', rx.toString()); + e.setAttribute('ry', ry.toString()); + } + } } if (typeof define === "function") {