From 95735d239b83c71e1c854827f1911e4aeccccd9f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 21 Apr 2020 16:17:52 +0200 Subject: [PATCH 01/25] Measure Window to MaxAutoSizeHint. - Renamed `MaxClientSize` to `MaxAutoSizeHint` - On Windows get its value from `WM_GETMINMAXINFO` message - Remove `ILayoutRoot.MaxClientSize` as it's not used any more --- src/Avalonia.Controls/Platform/IWindowBaseImpl.cs | 5 ++--- .../Presenters/ItemVirtualizerSimple.cs | 2 +- src/Avalonia.Controls/TopLevel.cs | 3 --- src/Avalonia.Controls/Window.cs | 10 ++++------ .../Remote/PreviewerWindowImpl.cs | 2 +- src/Avalonia.DesignerSupport/Remote/Stubs.cs | 2 +- src/Avalonia.Layout/ILayoutRoot.cs | 5 ----- src/Avalonia.Native/WindowImplBase.cs | 2 +- src/Avalonia.X11/X11Window.cs | 2 +- src/Windows/Avalonia.Win32/WindowImpl.WndProc.cs | 2 ++ src/Windows/Avalonia.Win32/WindowImpl.cs | 12 ++---------- tests/Avalonia.Controls.UnitTests/WindowTests.cs | 13 ++++++++----- tests/Avalonia.UnitTests/MockWindowingPlatform.cs | 1 + 13 files changed, 24 insertions(+), 37 deletions(-) diff --git a/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs b/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs index 8c99dffc28..b190c4f2e7 100644 --- a/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowBaseImpl.cs @@ -1,5 +1,4 @@ using System; -using Avalonia.Controls; namespace Avalonia.Platform { @@ -46,9 +45,9 @@ namespace Avalonia.Platform IPlatformHandle Handle { get; } /// - /// Gets the maximum size of a window on the system. + /// Gets a maximum client size hint for an auto-sizing window, in device-independent pixels. /// - Size MaxClientSize { get; } + Size MaxAutoSizeHint { get; } /// /// Sets whether this window appears on top of all other windows diff --git a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs index b8d338741a..58fa8124db 100644 --- a/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs +++ b/src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs @@ -113,7 +113,7 @@ namespace Avalonia.Controls.Presenters { var scrollable = (ILogicalScrollable)Owner; var visualRoot = Owner.GetVisualRoot(); - var maxAvailableSize = (visualRoot as WindowBase)?.PlatformImpl?.MaxClientSize + var maxAvailableSize = (visualRoot as WindowBase)?.PlatformImpl?.MaxAutoSizeHint ?? (visualRoot as TopLevel)?.ClientSize; // If infinity is passed as the available size and we're virtualized then we need to diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index d17f3b0423..0be051d65b 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -212,9 +212,6 @@ namespace Avalonia.Controls set { SetValue(AccessText.ShowAccessKeyProperty, value); } } - /// - Size ILayoutRoot.MaxClientSize => Size.Infinity; - /// double ILayoutRoot.LayoutScaling => PlatformImpl?.Scaling ?? 1; diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index ee596432f7..f985a52467 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -184,7 +184,7 @@ namespace Avalonia.Controls { impl.Closing = HandleClosing; impl.WindowStateChanged = HandleWindowStateChanged; - _maxPlatformClientSize = PlatformImpl?.MaxClientSize ?? default(Size); + _maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size); this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => PlatformImpl?.Resize(x)); PlatformImpl?.ShowTaskbarIcon(ShowInTaskbar); @@ -314,9 +314,6 @@ namespace Avalonia.Controls /// public void BeginResizeDrag(WindowEdge edge, PointerPressedEventArgs e) => PlatformImpl?.BeginResizeDrag(edge, e); - /// - Size ILayoutRoot.MaxClientSize => _maxPlatformClientSize; - /// Type IStyleable.StyleKey => typeof(Window); @@ -572,15 +569,16 @@ namespace Avalonia.Controls var sizeToContent = SizeToContent; var clientSize = ClientSize; var constraint = clientSize; + var maxAutoSize = PlatformImpl?.MaxAutoSizeHint ?? Size.Infinity; if (sizeToContent.HasFlagCustom(SizeToContent.Width)) { - constraint = constraint.WithWidth(double.PositiveInfinity); + constraint = constraint.WithWidth(maxAutoSize.Width); } if (sizeToContent.HasFlagCustom(SizeToContent.Height)) { - constraint = constraint.WithHeight(double.PositiveInfinity); + constraint = constraint.WithHeight(maxAutoSize.Height); } var result = base.MeasureOverride(constraint); diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index 7480b3519c..57fcb785e0 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -44,7 +44,7 @@ namespace Avalonia.DesignerSupport.Remote public IPlatformHandle Handle { get; } public WindowState WindowState { get; set; } public Action WindowStateChanged { get; set; } - public Size MaxClientSize { get; } = new Size(4096, 4096); + public Size MaxAutoSizeHint { get; } = new Size(4096, 4096); public event Action LostFocus { add {} diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index 7bf1d236bd..6d77d0dee5 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -19,7 +19,7 @@ namespace Avalonia.DesignerSupport.Remote public Action Deactivated { get; set; } public Action Activated { get; set; } public IPlatformHandle Handle { get; } - public Size MaxClientSize { get; } + public Size MaxAutoSizeHint { get; } public Size ClientSize { get; } public double Scaling { get; } = 1.0; public IEnumerable Surfaces { get; } diff --git a/src/Avalonia.Layout/ILayoutRoot.cs b/src/Avalonia.Layout/ILayoutRoot.cs index 56aca75871..e2f16b338a 100644 --- a/src/Avalonia.Layout/ILayoutRoot.cs +++ b/src/Avalonia.Layout/ILayoutRoot.cs @@ -10,11 +10,6 @@ namespace Avalonia.Layout /// Size ClientSize { get; } - /// - /// The maximum client size available. - /// - Size MaxClientSize { get; } - /// /// The scaling factor to use in layout. /// diff --git a/src/Avalonia.Native/WindowImplBase.cs b/src/Avalonia.Native/WindowImplBase.cs index c4ededd197..c97a3ebccb 100644 --- a/src/Avalonia.Native/WindowImplBase.cs +++ b/src/Avalonia.Native/WindowImplBase.cs @@ -309,7 +309,7 @@ namespace Avalonia.Native _native.BeginMoveDrag(); } - public Size MaxClientSize => Screen.AllScreens.Select(s => s.Bounds.Size.ToSize(s.PixelDensity)) + public Size MaxAutoSizeHint => Screen.AllScreens.Select(s => s.Bounds.Size.ToSize(s.PixelDensity)) .OrderByDescending(x => x.Width + x.Height).FirstOrDefault(); public void SetTopmost(bool value) diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 3a919e2bc4..340cad7842 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -915,7 +915,7 @@ namespace Avalonia.X11 public IScreenImpl Screen => _platform.Screens; - public Size MaxClientSize => _platform.X11Screens.Screens.Select(s => s.Bounds.Size.ToSize(s.PixelDensity)) + public Size MaxAutoSizeHint => _platform.X11Screens.Screens.Select(s => s.Bounds.Size.ToSize(s.PixelDensity)) .OrderByDescending(x => x.Width + x.Height).FirstOrDefault(); diff --git a/src/Windows/Avalonia.Win32/WindowImpl.WndProc.cs b/src/Windows/Avalonia.Win32/WindowImpl.WndProc.cs index 3ea8c1e48f..6c4ac3da2e 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.WndProc.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.WndProc.cs @@ -399,6 +399,8 @@ namespace Avalonia.Win32 case WindowsMessage.WM_GETMINMAXINFO: { MINMAXINFO mmi = Marshal.PtrToStructure(lParam); + + _maxTrackSize = mmi.ptMaxTrackSize; if (_minSize.Width > 0) { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index e193c72ef7..aa61b0d48f 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -62,6 +62,7 @@ namespace Avalonia.Win32 private OleDropTarget _dropTarget; private Size _minSize; private Size _maxSize; + private POINT _maxTrackSize; private WindowImpl _parent; public WindowImpl() @@ -164,16 +165,7 @@ namespace Avalonia.Win32 public IPlatformHandle Handle { get; private set; } - public Size MaxClientSize - { - get - { - return (new Size( - GetSystemMetrics(SystemMetric.SM_CXMAXTRACK), - GetSystemMetrics(SystemMetric.SM_CYMAXTRACK)) - - BorderThickness) / Scaling; - } - } + public Size MaxAutoSizeHint => new Size(_maxTrackSize.X / Scaling, _maxTrackSize.Y / Scaling); public IMouseDevice MouseDevice => _mouseDevice; diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index cf2920998a..d6634778ef 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -297,12 +297,12 @@ namespace Avalonia.Controls.UnitTests { var parentWindowImpl = MockWindowingPlatform.CreateWindowMock(); parentWindowImpl.Setup(x => x.ClientSize).Returns(new Size(800, 480)); - parentWindowImpl.Setup(x => x.MaxClientSize).Returns(new Size(1920, 1080)); + parentWindowImpl.Setup(x => x.MaxAutoSizeHint).Returns(new Size(1920, 1080)); parentWindowImpl.Setup(x => x.Scaling).Returns(1); var windowImpl = MockWindowingPlatform.CreateWindowMock(); windowImpl.Setup(x => x.ClientSize).Returns(new Size(320, 200)); - windowImpl.Setup(x => x.MaxClientSize).Returns(new Size(1920, 1080)); + windowImpl.Setup(x => x.MaxAutoSizeHint).Returns(new Size(1920, 1080)); windowImpl.Setup(x => x.Scaling).Returns(1); var parentWindowServices = TestServices.StyledWindow.With( @@ -380,12 +380,15 @@ namespace Avalonia.Controls.UnitTests } [Fact] - public void Child_Should_Be_Measured_With_Infinity_If_SizeToContent_Is_WidthAndHeight() + public void Child_Should_Be_Measured_With_MaxAutoSizeHint_If_SizeToContent_Is_WidthAndHeight() { using (UnitTestApplication.Start(TestServices.StyledWindow)) { + var windowImpl = MockWindowingPlatform.CreateWindowMock(); + windowImpl.Setup(x => x.MaxAutoSizeHint).Returns(new Size(1200, 1000)); + var child = new ChildControl(); - var target = new Window + var target = new Window(windowImpl.Object) { Width = 100, Height = 50, @@ -396,7 +399,7 @@ namespace Avalonia.Controls.UnitTests target.Show(); Assert.Equal(1, child.MeasureSizes.Count); - Assert.Equal(Size.Infinity, child.MeasureSizes[0]); + Assert.Equal(new Size(1200, 1000), child.MeasureSizes[0]); } } diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index b3e4b4edbc..105a028120 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -28,6 +28,7 @@ namespace Avalonia.UnitTests windowImpl.SetupAllProperties(); windowImpl.Setup(x => x.ClientSize).Returns(() => clientSize); + windowImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize); windowImpl.Setup(x => x.Scaling).Returns(1); windowImpl.Setup(x => x.Screen).Returns(CreateScreenMock().Object); windowImpl.Setup(x => x.Position).Returns(() => position); From 9391ddbab5285d0d555f714692bd00628c07db9b Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Fri, 22 May 2020 10:33:58 +0200 Subject: [PATCH 02/25] Add failing tests for shorthand color parsing. --- .../Media/ColorTests.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs index e17fd47ff8..f8bd15593a 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs @@ -17,6 +17,17 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0xff, result.A); } + [Fact] + public void Parse_Parses_RGB_Hash_Shorthand_Color() + { + var result = Color.Parse("#f84"); + + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0xff, result.A); + } + [Fact] public void Parse_Parses_ARGB_Hash_Color() { @@ -28,6 +39,17 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0x40, result.A); } + [Fact] + public void Parse_Parses_ARGB_Hash_Shorthand_Color() + { + var result = Color.Parse("#4f84"); + + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0x44, result.A); + } + [Fact] public void Parse_Parses_Named_Color_Lowercase() { From 34fa98c013cdb7b2c5b7b5b12078cee166bb593e Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Fri, 22 May 2020 11:58:18 +0200 Subject: [PATCH 03/25] Unify color parsing and expose ROS and string based variants properly. --- src/Avalonia.Visuals/Media/Color.cs | 126 +++++++++++++++++++++------- 1 file changed, 97 insertions(+), 29 deletions(-) diff --git a/src/Avalonia.Visuals/Media/Color.cs b/src/Avalonia.Visuals/Media/Color.cs index 2e06d2578f..d52642ff9c 100644 --- a/src/Avalonia.Visuals/Media/Color.cs +++ b/src/Avalonia.Visuals/Media/Color.cs @@ -89,33 +89,62 @@ namespace Avalonia.Media /// The . public static Color Parse(string s) { - if (s == null) throw new ArgumentNullException(nameof(s)); - if (s.Length == 0) throw new FormatException(); + if (TryParse(s, out Color color)) + { + return color; + } - if (s[0] == '#') + throw new FormatException($"Invalid color string: '{s}'."); + } + + /// + /// Parses a color string. + /// + /// The color string. + /// The . + public static Color Parse(ReadOnlySpan s) + { + if (TryParse(s, out Color color)) { - var or = 0u; + return color; + } - if (s.Length == 7) - { - or = 0xff000000; - } - else if (s.Length != 9) - { - throw new FormatException($"Invalid color string: '{s}'."); - } + throw new FormatException($"Invalid color string: '{s.ToString()}'."); + } + + /// + /// Parses a color string. + /// + /// The color string. + /// The parsed color + /// The status of the operation. + public static bool TryParse(string s, out Color color) + { + if (s == null) + { + throw new ArgumentNullException(nameof(s)); + } + + if (s.Length == 0) + { + throw new FormatException(); + } - return FromUInt32(uint.Parse(s.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture) | or); + if (TryParseInternal(s.AsSpan(), out color)) + { + return true; } var knownColor = KnownColors.GetKnownColor(s); if (knownColor != KnownColor.None) { - return knownColor.ToColor(); + color = knownColor.ToColor(); + + return true; } - throw new FormatException($"Invalid color string: '{s}'."); + return false; } /// @@ -126,40 +155,79 @@ namespace Avalonia.Media /// The status of the operation. public static bool TryParse(ReadOnlySpan s, out Color color) { - color = default; - if (s == null) - return false; if (s.Length == 0) + { + color = default; + return false; + } if (s[0] == '#') { - var or = 0u; + return TryParseInternal(s, out color); + } + + var knownColor = KnownColors.GetKnownColor(s.ToString()); + + if (knownColor != KnownColor.None) + { + color = knownColor.ToColor(); + + return true; + } + + color = default; - if (s.Length == 7) + return false; + } + + private static bool TryParseInternal(ReadOnlySpan s, out Color color) + { + static bool TryParseCore(ReadOnlySpan input, ref Color color) + { + var alphaComponent = 0u; + + if (input.Length == 6) { - or = 0xff000000; + alphaComponent = 0xff000000; } - else if (s.Length != 9) + else if (input.Length != 8) { return false; } - if(!uint.TryParse(s.Slice(1).ToString(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + // TODO: (netstandard 2.1) Can use allocation free parsing. + if (!uint.TryParse(input.ToString(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, + out var parsed)) + { return false; - color = FromUInt32(parsed| or); + } + + color = FromUInt32(parsed | alphaComponent); + return true; } - var knownColor = KnownColors.GetKnownColor(s.ToString()); + color = default; - if (knownColor != KnownColor.None) + ReadOnlySpan input = s.Slice(1); + + // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB) + if (input.Length == 3 || input.Length == 4) { - color = knownColor.ToColor(); - return true; + var extendedLength = 2 * input.Length; + Span extended = stackalloc char[extendedLength]; + + for (int i = 0; i < input.Length; i++) + { + extended[2 * i + 0] = input[i]; + extended[2 * i + 1] = input[i]; + } + + return TryParseCore(extended, ref color); } - return false; + return TryParseCore(input, ref color); } /// From 9c4bcbdea987b8a8b0a819cd78d2ba924973e91a Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Fri, 22 May 2020 11:58:35 +0200 Subject: [PATCH 04/25] Add more coverage for TryParse --- .../Media/ColorTests.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs index f8bd15593a..f3f3c9a4ca 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/ColorTests.cs @@ -17,6 +17,18 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0xff, result.A); } + [Fact] + public void Try_Parse_Parses_RGB_Hash_Color() + { + var success = Color.TryParse("#ff8844", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0xff, result.A); + } + [Fact] public void Parse_Parses_RGB_Hash_Shorthand_Color() { @@ -28,6 +40,18 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0xff, result.A); } + [Fact] + public void Try_Parse_Parses_RGB_Hash_Shorthand_Color() + { + var success = Color.TryParse("#f84", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0xff, result.A); + } + [Fact] public void Parse_Parses_ARGB_Hash_Color() { @@ -39,6 +63,18 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0x40, result.A); } + [Fact] + public void Try_Parse_Parses_ARGB_Hash_Color() + { + var success = Color.TryParse("#40ff8844", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0x40, result.A); + } + [Fact] public void Parse_Parses_ARGB_Hash_Shorthand_Color() { @@ -50,6 +86,18 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0x44, result.A); } + [Fact] + public void Try_Parse_Parses_ARGB_Hash_Shorthand_Color() + { + var success = Color.TryParse("#4f84", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x88, result.G); + Assert.Equal(0x44, result.B); + Assert.Equal(0x44, result.A); + } + [Fact] public void Parse_Parses_Named_Color_Lowercase() { @@ -61,6 +109,18 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0xff, result.A); } + [Fact] + public void TryParse_Parses_Named_Color_Lowercase() + { + var success = Color.TryParse("red", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x00, result.G); + Assert.Equal(0x00, result.B); + Assert.Equal(0xff, result.A); + } + [Fact] public void Parse_Parses_Named_Color_Uppercase() { @@ -72,22 +132,52 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(0xff, result.A); } + [Fact] + public void TryParse_Parses_Named_Color_Uppercase() + { + var success = Color.TryParse("RED", out Color result); + + Assert.True(success); + Assert.Equal(0xff, result.R); + Assert.Equal(0x00, result.G); + Assert.Equal(0x00, result.B); + Assert.Equal(0xff, result.A); + } + [Fact] public void Parse_Hex_Value_Doesnt_Accept_Too_Few_Chars() { Assert.Throws(() => Color.Parse("#ff")); } + [Fact] + public void TryParse_Hex_Value_Doesnt_Accept_Too_Few_Chars() + { + Assert.False(Color.TryParse("#ff", out _)); + } + [Fact] public void Parse_Hex_Value_Doesnt_Accept_Too_Many_Chars() { Assert.Throws(() => Color.Parse("#ff5555555")); } + [Fact] + public void TryParse_Hex_Value_Doesnt_Accept_Too_Many_Chars() + { + Assert.False(Color.TryParse("#ff5555555", out _)); + } + [Fact] public void Parse_Hex_Value_Doesnt_Accept_Invalid_Number() { Assert.Throws(() => Color.Parse("#ff808g80")); } + + [Fact] + public void TryParse_Hex_Value_Doesnt_Accept_Invalid_Number() + { + Assert.False(Color.TryParse("#ff808g80", out _)); + } } } From cdce7d96348f27f15c758a299a521d522184719f Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Fri, 22 May 2020 13:24:47 +0200 Subject: [PATCH 05/25] Avoid redundant parsing of named colors in TryParse. --- src/Avalonia.Visuals/Media/Color.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Visuals/Media/Color.cs b/src/Avalonia.Visuals/Media/Color.cs index d52642ff9c..052ee5e1b7 100644 --- a/src/Avalonia.Visuals/Media/Color.cs +++ b/src/Avalonia.Visuals/Media/Color.cs @@ -130,7 +130,7 @@ namespace Avalonia.Media throw new FormatException(); } - if (TryParseInternal(s.AsSpan(), out color)) + if (s[0] == '#' && TryParseInternal(s.AsSpan(), out color)) { return true; } @@ -144,6 +144,8 @@ namespace Avalonia.Media return true; } + color = default; + return false; } @@ -212,7 +214,7 @@ namespace Avalonia.Media ReadOnlySpan input = s.Slice(1); - // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB) + // Handle shorthand cases like #FFF (RGB) or #FFFF (ARGB). if (input.Length == 3 || input.Length == 4) { var extendedLength = 2 * input.Length; From d69718ab61d75c5542449a436bca5202b33e8b3e Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sat, 23 May 2020 21:00:41 +0200 Subject: [PATCH 06/25] Reimplement new child window support. --- samples/ControlCatalog/Pages/DialogsPage.xaml | 2 + .../ControlCatalog/Pages/DialogsPage.xaml.cs | 15 ++++ src/Avalonia.Controls/Window.cs | 84 ++++++++++++++++--- 3 files changed, 88 insertions(+), 13 deletions(-) diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml b/samples/ControlCatalog/Pages/DialogsPage.xaml index 0834e829d8..a0e82663bf 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml @@ -11,5 +11,7 @@ + + diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs index dcb94a89e7..cf6c771e34 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs @@ -93,6 +93,21 @@ namespace ControlCatalog.Pages window.ShowInTaskbar = false; window.ShowDialog(GetWindow()); }; + this.FindControl public class Window : WindowBase, IStyleable, IFocusScope, ILayoutRoot { - private List _children = new List(); + private readonly List<(Window child, bool isDialog)> _children = new List<(Window, bool)>(); /// /// Defines the property. @@ -376,7 +376,7 @@ namespace Avalonia.Controls private void CloseInternal() { - foreach (var child in _children.ToList()) + foreach (var (child, _) in _children.ToList()) { // if we HandleClosing() before then there will be no children. child.CloseInternal(); @@ -399,7 +399,7 @@ namespace Avalonia.Controls { bool canClose = true; - foreach (var child in _children.ToList()) + foreach (var (child, _) in _children.ToList()) { if (!child.HandleClosing()) { @@ -472,6 +472,27 @@ namespace Avalonia.Controls /// The window has already been closed. /// public override void Show() + { + ShowCore(null); + } + + /// + /// Shows the window as a child of . + /// + /// + /// The window has already been closed. + /// + public void Show(Window parent) + { + if (parent is null) + { + throw new ArgumentNullException(nameof(parent), "Showing a child window requires valid parent."); + } + + ShowCore(parent); + } + + private void ShowCore(Window parent) { if (PlatformImpl == null) { @@ -483,7 +504,7 @@ namespace Avalonia.Controls return; } - this.RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); + RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); EnsureInitialized(); IsVisible = true; @@ -504,6 +525,14 @@ namespace Avalonia.Controls using (BeginAutoSizing()) { + if (parent != null) + { + PlatformImpl?.SetParent(parent.PlatformImpl); + } + + Owner = parent; + parent?.AddChild(this, false); + PlatformImpl?.Show(); Renderer?.Start(); } @@ -571,9 +600,9 @@ namespace Avalonia.Controls using (BeginAutoSizing()) { - PlatformImpl.SetParent(owner.PlatformImpl); + PlatformImpl?.SetParent(owner.PlatformImpl); Owner = owner; - owner.AddChild(this); + owner.AddChild(this, true); PlatformImpl?.Show(); Renderer?.Start(); @@ -598,28 +627,57 @@ namespace Avalonia.Controls private void UpdateEnabled() { - PlatformImpl.SetEnabled(_children.Count == 0); + bool isEnabled = true; + + foreach (var (_, isDialog) in _children) + { + if (isDialog) + { + isEnabled = false; + break; + } + } + + PlatformImpl.SetEnabled(isEnabled); } - private void AddChild(Window window) + private void AddChild(Window window, bool isDialog) { - _children.Add(window); + _children.Add((window, isDialog)); UpdateEnabled(); } private void RemoveChild(Window window) { - _children.Remove(window); + for (int i = _children.Count - 1; i >= 0; i--) + { + var (child, _) = _children[i]; + + if (ReferenceEquals(child, window)) + { + _children.RemoveAt(i); + } + } + UpdateEnabled(); } private void OnGotInputWhenDisabled() { - var firstChild = _children.FirstOrDefault(); + Window firstDialogChild = null; + + foreach (var (child, isDialog) in _children) + { + if (isDialog) + { + firstDialogChild = child; + break; + } + } - if (firstChild != null) + if (firstDialogChild != null) { - firstChild.OnGotInputWhenDisabled(); + firstDialogChild.OnGotInputWhenDisabled(); } else { From a04e187fa09fd3255024e77183b43f484e101f40 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sat, 23 May 2020 21:02:43 +0200 Subject: [PATCH 07/25] Extra parameter doc. --- src/Avalonia.Controls/Window.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 24e0e90417..782dc69b39 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -479,6 +479,7 @@ namespace Avalonia.Controls /// /// Shows the window as a child of . /// + /// Window that will be a parent of the shown window. /// /// The window has already been closed. /// From fd30ecc903d1fc98553b199d564493830c6c5b0c Mon Sep 17 00:00:00 2001 From: usUyGBx <64971385+usUyGBx@users.noreply.github.com> Date: Sun, 31 May 2020 21:40:32 +0300 Subject: [PATCH 08/25] Set property value before using any Interfaces --- samples/ControlCatalog/Models/Person.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/ControlCatalog/Models/Person.cs b/samples/ControlCatalog/Models/Person.cs index 4248cb8056..a0abcfe8f4 100644 --- a/samples/ControlCatalog/Models/Person.cs +++ b/samples/ControlCatalog/Models/Person.cs @@ -21,12 +21,12 @@ namespace ControlCatalog.Models get => _firstName; set { + _firstName = value; if (string.IsNullOrWhiteSpace(value)) SetError(nameof(FirstName), "First Name Required"); else SetError(nameof(FirstName), null); - _firstName = value; OnPropertyChanged(nameof(FirstName)); } @@ -37,12 +37,12 @@ namespace ControlCatalog.Models get => _lastName; set { + _lastName = value; if (string.IsNullOrWhiteSpace(value)) SetError(nameof(LastName), "Last Name Required"); else SetError(nameof(LastName), null); - _lastName = value; OnPropertyChanged(nameof(LastName)); } } @@ -95,4 +95,4 @@ namespace ControlCatalog.Models return null; } } -} \ No newline at end of file +} From d4215a56ecfe37b6a827fe355c4cc396e0190c49 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 9 Jun 2020 23:18:15 +0200 Subject: [PATCH 09/25] Restyle `ScrollViewer` in menus. Make it appear as an up and down button at the top and bottom of the menu when needed. This follows WPF's menu scrolling behavior. --- .../MenuScrollingVisibilityConverter.cs | 64 ++++++++ src/Avalonia.Controls/ScrollViewer.cs | 32 ++++ src/Avalonia.Themes.Default/ContextMenu.xaml | 2 +- src/Avalonia.Themes.Default/MenuItem.xaml | 2 +- src/Avalonia.Themes.Default/ScrollViewer.xaml | 150 ++++++++++++------ 5 files changed, 202 insertions(+), 48 deletions(-) create mode 100644 src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs diff --git a/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs b/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs new file mode 100644 index 0000000000..e6420fe342 --- /dev/null +++ b/src/Avalonia.Controls/Converters/MenuScrollingVisibilityConverter.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Controls.Primitives; +using Avalonia.Data.Converters; +using Avalonia.Utilities; + +namespace Avalonia.Controls.Converters +{ + public class MenuScrollingVisibilityConverter : IMultiValueConverter + { + public static readonly MenuScrollingVisibilityConverter Instance = new MenuScrollingVisibilityConverter(); + + public object Convert(IList values, Type targetType, object parameter, CultureInfo culture) + { + if (parameter == null || + values == null || + values.Count != 4 || + !(values[0] is ScrollBarVisibility visiblity) || + !(values[1] is double offset) || + !(values[2] is double extent) || + !(values[3] is double viewport)) + { + return AvaloniaProperty.UnsetValue; + } + + if (visiblity == ScrollBarVisibility.Auto) + { + if (extent == viewport) + { + return false; + } + + double target; + + if (parameter is double d) + { + target = d; + } + else if (parameter is string s) + { + target = double.Parse(s, NumberFormatInfo.InvariantInfo); + } + else + { + return AvaloniaProperty.UnsetValue; + } + + // Calculate the percent so that we can see if we are near the edge of the range + double percent = MathUtilities.Clamp(offset * 100.0 / (extent - viewport), 0, 100); + + if (MathUtilities.AreClose(percent, target)) + { + // We are at the end of the range, so no need for this button to be shown + return false; + } + + return true; + } + + return false; + } + } +} diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index c3f0dc0056..813d6967c3 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -387,6 +387,38 @@ namespace Avalonia.Controls /// IControl IScrollAnchorProvider.CurrentAnchor => null; // TODO: Implement + /// + /// Scrolls the content up one line. + /// + public void LineUp() + { + Offset -= new Vector(0, _smallChange.Height); + } + + /// + /// Scrolls the content down one line. + /// + public void LineDown() + { + Offset += new Vector(0, _smallChange.Height); + } + + /// + /// Scrolls the content left one line. + /// + public void LineLeft() + { + Offset -= new Vector(_smallChange.Width, 0); + } + + /// + /// Scrolls the content right one line. + /// + public void LineRight() + { + Offset += new Vector(_smallChange.Width, 0); + } + /// /// Scrolls to the top-left corner of the content. /// diff --git a/src/Avalonia.Themes.Default/ContextMenu.xaml b/src/Avalonia.Themes.Default/ContextMenu.xaml index 75f8f7c23d..9b84253c8a 100644 --- a/src/Avalonia.Themes.Default/ContextMenu.xaml +++ b/src/Avalonia.Themes.Default/ContextMenu.xaml @@ -10,7 +10,7 @@ BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Padding="{TemplateBinding Padding}"> - + - + - - - - - - - + + + + + + + + + + + + + + + + From fc711e7c86648a1c01b79977636b118c2cbdcfd6 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 10 Jun 2020 05:48:07 +0200 Subject: [PATCH 10/25] Measure PopupRoot to MaxAutoSizeHint. On win32 popup's don't receive `WM_GETMINMAXINFO` so instead query the monitor info for the max work area, --- src/Avalonia.Controls/Primitives/PopupRoot.cs | 15 ++++++++++- src/Windows/Avalonia.Win32/PopupImpl.cs | 26 +++++++++++++++++++ src/Windows/Avalonia.Win32/WindowImpl.cs | 4 ++- .../Primitives/PopupRootTests.cs | 20 +++++++++----- .../MockWindowingPlatform.cs | 1 + 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index aab7a68795..6a363d2a20 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -119,7 +119,20 @@ namespace Avalonia.Controls.Primitives protected override Size MeasureOverride(Size availableSize) { - var measured = base.MeasureOverride(availableSize); + var maxAutoSize = PlatformImpl?.MaxAutoSizeHint ?? Size.Infinity; + var constraint = availableSize; + + if (double.IsInfinity(constraint.Width)) + { + constraint = constraint.WithWidth(maxAutoSize.Width); + } + + if (double.IsInfinity(constraint.Height)) + { + constraint = constraint.WithHeight(maxAutoSize.Height); + } + + var measured = base.MeasureOverride(constraint); var width = measured.Width; var height = measured.Height; var widthCache = Width; diff --git a/src/Windows/Avalonia.Win32/PopupImpl.cs b/src/Windows/Avalonia.Win32/PopupImpl.cs index efcf1ea674..7f27a9e841 100644 --- a/src/Windows/Avalonia.Win32/PopupImpl.cs +++ b/src/Windows/Avalonia.Win32/PopupImpl.cs @@ -8,12 +8,35 @@ namespace Avalonia.Win32 class PopupImpl : WindowImpl, IPopupImpl { private bool _dropShadowHint = true; + private Size? _maxAutoSize; public override void Show() { UnmanagedMethods.ShowWindow(Handle.Handle, UnmanagedMethods.ShowWindowCommand.ShowNoActivate); } + public override Size MaxAutoSizeHint + { + get + { + if (_maxAutoSize is null) + { + var monitor = UnmanagedMethods.MonitorFromWindow( + Hwnd, + UnmanagedMethods.MONITOR.MONITOR_DEFAULTTONEAREST); + + if (monitor != IntPtr.Zero) + { + var info = UnmanagedMethods.MONITORINFO.Create(); + UnmanagedMethods.GetMonitorInfo(monitor, ref info); + _maxAutoSize = info.rcWork.ToPixelRect().ToRect(Scaling).Size; + } + } + + return _maxAutoSize ?? Size.Infinity; + } + } + protected override IntPtr CreateWindowOverride(ushort atom) { UnmanagedMethods.WindowStyles style = @@ -47,6 +70,9 @@ namespace Avalonia.Win32 { switch ((UnmanagedMethods.WindowsMessage)msg) { + case UnmanagedMethods.WindowsMessage.WM_DISPLAYCHANGE: + _maxAutoSize = null; + goto default; case UnmanagedMethods.WindowsMessage.WM_MOUSEACTIVATE: return (IntPtr)UnmanagedMethods.MouseActivate.MA_NOACTIVATE; default: diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index e7eb623a5d..3afc962a76 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -169,7 +169,7 @@ namespace Avalonia.Win32 public IPlatformHandle Handle { get; private set; } - public Size MaxAutoSizeHint => new Size(_maxTrackSize.X / Scaling, _maxTrackSize.Y / Scaling); + public virtual Size MaxAutoSizeHint => new Size(_maxTrackSize.X / Scaling, _maxTrackSize.Y / Scaling); public IMouseDevice MouseDevice => _mouseDevice; @@ -203,6 +203,8 @@ namespace Avalonia.Win32 public WindowTransparencyLevel TransparencyLevel { get; private set; } + protected IntPtr Hwnd => _hwnd; + public void SetTransparencyLevelHint (WindowTransparencyLevel transparencyLevel) { TransparencyLevel = EnableBlur(transparencyLevel); diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs index 08b9c75dbc..f27ff3928c 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; @@ -181,18 +182,21 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void Child_Should_Be_Measured_With_Infinity() + public void Child_Should_Be_Measured_With_MaxAutoSizeHint() { using (UnitTestApplication.Start(TestServices.StyledWindow)) { var child = new ChildControl(); var window = new Window(); - var target = CreateTarget(window); + var popupImpl = MockWindowingPlatform.CreatePopupMock(window.PlatformImpl); + popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(new Size(1200, 1000)); + var target = CreateTarget(window, popupImpl.Object); target.Content = child; target.Show(); - Assert.Equal(Size.Infinity, child.MeasureSize); + Assert.Equal(1, child.MeasureSizes.Count); + Assert.Equal(new Size(1200, 1000), child.MeasureSizes[0]); } } @@ -210,7 +214,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Content = child; target.Show(); - Assert.Equal(new Size(500, 600), child.MeasureSize); + Assert.Equal(1, child.MeasureSizes.Count); + Assert.Equal(new Size(500, 600), child.MeasureSizes[0]); } } @@ -228,7 +233,8 @@ namespace Avalonia.Controls.UnitTests.Primitives target.Content = child; target.Show(); - Assert.Equal(new Size(500, 600), child.MeasureSize); + Assert.Equal(1, child.MeasureSizes.Count); + Assert.Equal(new Size(500, 600), child.MeasureSizes[0]); } } @@ -365,11 +371,11 @@ namespace Avalonia.Controls.UnitTests.Primitives private class ChildControl : Control { - public Size MeasureSize { get; private set; } + public List MeasureSizes { get; } = new List(); protected override Size MeasureOverride(Size availableSize) { - MeasureSize = availableSize; + MeasureSizes.Add(availableSize); return base.MeasureOverride(availableSize); } } diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index 105a028120..ee45433089 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -80,6 +80,7 @@ namespace Avalonia.UnitTests popupImpl.SetupAllProperties(); popupImpl.Setup(x => x.ClientSize).Returns(() => clientSize); + popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize); popupImpl.Setup(x => x.Scaling).Returns(1); popupImpl.Setup(x => x.PopupPositioner).Returns(positioner); From 8c5b22c8cca7efa4f3631bf9e954bc9731ef5874 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Mon, 8 Jun 2020 23:01:54 +0200 Subject: [PATCH 11/25] Initial CSS like transform support. --- src/Avalonia.Base/Utilities/MathUtilities.cs | 30 ++ .../LayoutTransformControl.cs | 6 +- .../Animation/Animators/TransformAnimator.cs | 10 +- .../Animators/TransformOperationsAnimator.cs | 35 ++ .../TransformOperationsTransition.cs | 25 + src/Avalonia.Visuals/Matrix.cs | 65 +++ .../Media/IMutableTransform.cs | 12 + src/Avalonia.Visuals/Media/ITransform.cs | 10 + src/Avalonia.Visuals/Media/Transform.cs | 5 +- .../Media/TransformConverter.cs | 23 + .../Transformation/InterpolationUtilities.cs | 40 ++ .../Transformation/TransformOperation.cs | 203 ++++++++ .../Transformation/TransformOperations.cs | 252 ++++++++++ .../Media/Transformation/TransformParser.cs | 463 ++++++++++++++++++ .../Properties/AssemblyInfo.cs | 1 + src/Avalonia.Visuals/Visual.cs | 14 +- src/Avalonia.Visuals/VisualTree/IVisual.cs | 2 +- .../Media/MatrixTests.cs | 44 +- .../Media/TransformOperationsTests.cs | 128 +++++ 19 files changed, 1353 insertions(+), 15 deletions(-) create mode 100644 src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs create mode 100644 src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs create mode 100644 src/Avalonia.Visuals/Media/IMutableTransform.cs create mode 100644 src/Avalonia.Visuals/Media/ITransform.cs create mode 100644 src/Avalonia.Visuals/Media/TransformConverter.cs create mode 100644 src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs create mode 100644 src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs create mode 100644 src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs create mode 100644 src/Avalonia.Visuals/Media/Transformation/TransformParser.cs create mode 100644 tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs index 7949a62949..fa5ab2d45b 100644 --- a/src/Avalonia.Base/Utilities/MathUtilities.cs +++ b/src/Avalonia.Base/Utilities/MathUtilities.cs @@ -262,5 +262,35 @@ namespace Avalonia.Utilities return val; } } + + /// + /// Converts an angle in degrees to radians. + /// + /// The angle in degrees. + /// The angle in radians. + public static double Deg2Rad(double angle) + { + return angle * (Math.PI / 180d); + } + + /// + /// Converts an angle in gradians to radians. + /// + /// The angle in gradians. + /// The angle in radians. + public static double Grad2Rad(double angle) + { + return angle * (Math.PI / 200d); + } + + /// + /// Converts an angle in turns to radians. + /// + /// The angle in turns. + /// The angle in radians. + public static double Turn2Rad(double angle) + { + return angle * 2 * Math.PI; + } } } diff --git a/src/Avalonia.Controls/LayoutTransformControl.cs b/src/Avalonia.Controls/LayoutTransformControl.cs index 8d48f6646d..83ad2b3638 100644 --- a/src/Avalonia.Controls/LayoutTransformControl.cs +++ b/src/Avalonia.Controls/LayoutTransformControl.cs @@ -14,8 +14,8 @@ namespace Avalonia.Controls /// public class LayoutTransformControl : Decorator { - public static readonly StyledProperty LayoutTransformProperty = - AvaloniaProperty.Register(nameof(LayoutTransform)); + public static readonly StyledProperty LayoutTransformProperty = + AvaloniaProperty.Register(nameof(LayoutTransform)); public static readonly StyledProperty UseRenderTransformProperty = AvaloniaProperty.Register(nameof(LayoutTransform)); @@ -37,7 +37,7 @@ namespace Avalonia.Controls /// /// Gets or sets a graphics transformation that should apply to this element when layout is performed. /// - public Transform LayoutTransform + public ITransform LayoutTransform { get { return GetValue(LayoutTransformProperty); } set { SetValue(LayoutTransformProperty, value); } diff --git a/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs index 1f1590bdcd..bb1c0da902 100644 --- a/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs +++ b/src/Avalonia.Visuals/Animation/Animators/TransformAnimator.cs @@ -1,6 +1,8 @@ using System; +using System.Reactive.Disposables; using Avalonia.Logging; using Avalonia.Media; +using Avalonia.Media.Transformation; namespace Avalonia.Animation.Animators { @@ -19,6 +21,12 @@ namespace Avalonia.Animation.Animators // Check if the Target Property is Transform derived. if (typeof(Transform).IsAssignableFrom(Property.OwnerType)) { + if (ctrl.RenderTransform is TransformOperations) + { + // HACK: This animator cannot reasonably animate CSS transforms at the moment. + return Disposable.Empty; + } + if (ctrl.RenderTransform == null) { var normalTransform = new TransformGroup(); @@ -51,7 +59,7 @@ namespace Avalonia.Animation.Animators // It's a transform object so let's target that. if (renderTransformType == Property.OwnerType) { - return _doubleAnimator.Apply(animation, ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete); + return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock, obsMatch, onComplete); } // It's a TransformGroup and try finding the target there. else if (renderTransformType == typeof(TransformGroup)) diff --git a/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs new file mode 100644 index 0000000000..f45338122f --- /dev/null +++ b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs @@ -0,0 +1,35 @@ +using System; +using Avalonia.Media; +using Avalonia.Media.Transformation; + +namespace Avalonia.Animation.Animators +{ + public class TransformOperationsAnimator : Animator + { + public TransformOperationsAnimator() + { + Validate = ValidateTransform; + } + + private void ValidateTransform(AnimatorKeyFrame kf) + { + if (!(kf.Value is TransformOperations)) + { + throw new InvalidOperationException($"All keyframes must be of type {typeof(TransformOperations)}."); + } + } + + public override ITransform Interpolate(double progress, ITransform oldValue, ITransform newValue) + { + var oldTransform = Cast(oldValue); + var newTransform = Cast(newValue); + + return TransformOperations.Interpolate(oldTransform, newTransform, progress); + } + + private static TransformOperations Cast(ITransform value) + { + return value as TransformOperations ?? TransformOperations.Identity; + } + } +} diff --git a/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs new file mode 100644 index 0000000000..4911b34d91 --- /dev/null +++ b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs @@ -0,0 +1,25 @@ +using System; +using System.Reactive.Linq; +using Avalonia.Animation.Animators; +using Avalonia.Media; + +namespace Avalonia.Animation +{ + public class TransformOperationsTransition : Transition + { + private static readonly TransformOperationsAnimator _operationsAnimator = new TransformOperationsAnimator(); + + public override IObservable DoTransition(IObservable progress, + ITransform oldValue, + ITransform newValue) + { + return progress + .Select(p => + { + var f = Easing.Ease(p); + + return _operationsAnimator.Interpolate(f, oldValue, newValue); + }); + } + } +} diff --git a/src/Avalonia.Visuals/Matrix.cs b/src/Avalonia.Visuals/Matrix.cs index 898c6027a5..e18140aa8d 100644 --- a/src/Avalonia.Visuals/Matrix.cs +++ b/src/Avalonia.Visuals/Matrix.cs @@ -319,5 +319,70 @@ namespace Avalonia ); } } + + public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed) + { + decomposed = default; + + var determinant = matrix.GetDeterminant(); + + if (determinant == 0) + { + return false; + } + + var m11 = matrix.M11; + var m21 = matrix.M21; + var m12 = matrix.M12; + var m22 = matrix.M22; + + // Translation. + decomposed.Translate = new Vector(matrix.M31, matrix.M32); + + // Scale sign. + var scaleX = 1d; + var scaleY = 1d; + + if (determinant < 0) + { + if (m11 < m22) + { + scaleX *= -1d; + } + else + { + scaleY *= -1d; + } + } + + // X Scale. + scaleX *= Math.Sqrt(m11 * m11 + m12 * m12); + + m11 /= scaleX; + m12 /= scaleX; + + // XY Shear. + double scaledShear = m11 * m21 + m12 * m22; + + m21 -= m11 * scaledShear; + m22 -= m12 * scaledShear; + + // Y Scale. + scaleY *= Math.Sqrt(m21 * m21 + m22 * m22); + + decomposed.Scale = new Vector(scaleX, scaleY); + decomposed.Skew = new Vector(scaledShear / scaleY, 0d); + decomposed.Angle = Math.Atan2(m12, m11); + + return true; + } + + public struct Decomposed + { + public Vector Translate; + public Vector Scale; + public Vector Skew; + public double Angle; + } } } diff --git a/src/Avalonia.Visuals/Media/IMutableTransform.cs b/src/Avalonia.Visuals/Media/IMutableTransform.cs new file mode 100644 index 0000000000..2033c434c0 --- /dev/null +++ b/src/Avalonia.Visuals/Media/IMutableTransform.cs @@ -0,0 +1,12 @@ +using System; + +namespace Avalonia.Media +{ + public interface IMutableTransform : ITransform + { + /// + /// Raised when the transform changes. + /// + event EventHandler Changed; + } +} diff --git a/src/Avalonia.Visuals/Media/ITransform.cs b/src/Avalonia.Visuals/Media/ITransform.cs new file mode 100644 index 0000000000..91577fe38e --- /dev/null +++ b/src/Avalonia.Visuals/Media/ITransform.cs @@ -0,0 +1,10 @@ +using System.ComponentModel; + +namespace Avalonia.Media +{ + [TypeConverter(typeof(TransformConverter))] + public interface ITransform + { + Matrix Value { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/Transform.cs b/src/Avalonia.Visuals/Media/Transform.cs index 70ef1eaaf4..7cf1b35ada 100644 --- a/src/Avalonia.Visuals/Media/Transform.cs +++ b/src/Avalonia.Visuals/Media/Transform.cs @@ -8,11 +8,12 @@ namespace Avalonia.Media /// /// Represents a transform on an . /// - public abstract class Transform : Animatable + public abstract class Transform : Animatable, IMutableTransform { static Transform() { - Animation.Animation.RegisterAnimator(prop => typeof(Transform).IsAssignableFrom(prop.OwnerType)); + Animation.Animation.RegisterAnimator(prop => + typeof(ITransform).IsAssignableFrom(prop.OwnerType)); } /// diff --git a/src/Avalonia.Visuals/Media/TransformConverter.cs b/src/Avalonia.Visuals/Media/TransformConverter.cs new file mode 100644 index 0000000000..e79c0b8b7b --- /dev/null +++ b/src/Avalonia.Visuals/Media/TransformConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using Avalonia.Media.Transformation; + +namespace Avalonia.Media +{ + /// + /// Creates an from a string representation. + /// + public class TransformConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return TransformOperations.Parse((string)value); + } + } +} diff --git a/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs b/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs new file mode 100644 index 0000000000..1e80eabfc8 --- /dev/null +++ b/src/Avalonia.Visuals/Media/Transformation/InterpolationUtilities.cs @@ -0,0 +1,40 @@ +namespace Avalonia.Media.Transformation +{ + internal static class InterpolationUtilities + { + public static double InterpolateScalars(double from, double to, double progress) + { + return from * (1d - progress) + to * progress; + } + + public static Vector InterpolateVectors(Vector from, Vector to, double progress) + { + var x = InterpolateScalars(from.X, to.X, progress); + var y = InterpolateScalars(from.Y, to.Y, progress); + + return new Vector(x, y); + } + + public static Matrix ComposeTransform(Matrix.Decomposed decomposed) + { + // According to https://www.w3.org/TR/css-transforms-1/#recomposing-to-a-2d-matrix + + return Matrix.CreateTranslation(decomposed.Translate) * + Matrix.CreateRotation(decomposed.Angle) * + Matrix.CreateSkew(decomposed.Skew.X, decomposed.Skew.Y) * + Matrix.CreateScale(decomposed.Scale); + } + + public static Matrix.Decomposed InterpolateDecomposedTransforms(ref Matrix.Decomposed from, ref Matrix.Decomposed to, double progres) + { + Matrix.Decomposed result = default; + + result.Translate = InterpolateVectors(from.Translate, to.Translate, progres); + result.Scale = InterpolateVectors(from.Scale, to.Scale, progres); + result.Skew = InterpolateVectors(from.Skew, to.Skew, progres); + result.Angle = InterpolateScalars(from.Angle, to.Angle, progres); + + return result; + } + } +} diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs new file mode 100644 index 0000000000..cdf31f8e5b --- /dev/null +++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs @@ -0,0 +1,203 @@ +using System.Runtime.InteropServices; + +namespace Avalonia.Media.Transformation +{ + public struct TransformOperation + { + public OperationType Type; + public Matrix Matrix; + public DataLayout Data; + + public enum OperationType + { + Translate, + Rotate, + Scale, + Skew, + Matrix, + Identity + } + + public bool IsIdentity => Matrix.IsIdentity; + + public void Bake() + { + Matrix = Matrix.Identity; + + switch (Type) + { + case OperationType.Translate: + { + Matrix = Matrix.CreateTranslation(Data.Translate.X, Data.Translate.Y); + + break; + } + case OperationType.Rotate: + { + Matrix = Matrix.CreateRotation(Data.Rotate.Angle); + + break; + } + case OperationType.Scale: + { + Matrix = Matrix.CreateScale(Data.Scale.X, Data.Scale.Y); + + break; + } + case OperationType.Skew: + { + Matrix = Matrix.CreateSkew(Data.Skew.X, Data.Skew.Y); + + break; + } + } + } + + public static bool IsOperationIdentity(ref TransformOperation? operation) + { + return !operation.HasValue || operation.Value.IsIdentity; + } + + public static bool TryInterpolate(TransformOperation? from, TransformOperation? to, double progress, + ref TransformOperation result) + { + bool fromIdentity = IsOperationIdentity(ref from); + bool toIdentity = IsOperationIdentity(ref to); + + if (fromIdentity && toIdentity) + { + return true; + } + + TransformOperation fromValue = fromIdentity ? default : from.Value; + TransformOperation toValue = toIdentity ? default : to.Value; + + var interpolationType = toIdentity ? fromValue.Type : toValue.Type; + + result.Type = interpolationType; + + switch (interpolationType) + { + case OperationType.Translate: + { + double fromX = fromIdentity ? 0 : fromValue.Data.Translate.X; + double fromY = fromIdentity ? 0 : fromValue.Data.Translate.Y; + + double toX = toIdentity ? 0 : toValue.Data.Translate.X; + double toY = toIdentity ? 0 : toValue.Data.Translate.Y; + + result.Data.Translate.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress); + result.Data.Translate.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress); + + result.Bake(); + + break; + } + case OperationType.Rotate: + { + double fromAngle = fromIdentity ? 0 : fromValue.Data.Rotate.Angle; + + double toAngle = toIdentity ? 0 : toValue.Data.Rotate.Angle; + + result.Data.Rotate.Angle = InterpolationUtilities.InterpolateScalars(fromAngle, toAngle, progress); + + result.Bake(); + + break; + } + case OperationType.Scale: + { + double fromX = fromIdentity ? 1 : fromValue.Data.Scale.X; + double fromY = fromIdentity ? 1 : fromValue.Data.Scale.Y; + + double toX = toIdentity ? 1 : toValue.Data.Scale.X; + double toY = toIdentity ? 1 : toValue.Data.Scale.Y; + + result.Data.Scale.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress); + result.Data.Scale.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress); + + result.Bake(); + + break; + } + case OperationType.Skew: + { + double fromX = fromIdentity ? 0 : fromValue.Data.Skew.X; + double fromY = fromIdentity ? 0 : fromValue.Data.Skew.Y; + + double toX = toIdentity ? 0 : toValue.Data.Skew.X; + double toY = toIdentity ? 0 : toValue.Data.Skew.Y; + + result.Data.Skew.X = InterpolationUtilities.InterpolateScalars(fromX, toX, progress); + result.Data.Skew.Y = InterpolationUtilities.InterpolateScalars(fromY, toY, progress); + + result.Bake(); + + break; + } + case OperationType.Matrix: + { + var fromMatrix = fromIdentity ? Matrix.Identity : fromValue.Matrix; + var toMatrix = toIdentity ? Matrix.Identity : toValue.Matrix; + + if (!Matrix.TryDecomposeTransform(fromMatrix, out Matrix.Decomposed fromDecomposed) || + !Matrix.TryDecomposeTransform(toMatrix, out Matrix.Decomposed toDecomposed)) + { + return false; + } + + var interpolated = + InterpolationUtilities.InterpolateDecomposedTransforms( + ref fromDecomposed, ref toDecomposed, + progress); + + result.Matrix = InterpolationUtilities.ComposeTransform(interpolated); + + break; + } + case OperationType.Identity: + { + // Do nothing. + break; + } + } + + return true; + } + + [StructLayout(LayoutKind.Explicit)] + public struct DataLayout + { + [FieldOffset(0)] public SkewLayout Skew; + + [FieldOffset(0)] public ScaleLayout Scale; + + [FieldOffset(0)] public TranslateLayout Translate; + + [FieldOffset(0)] public RotateLayout Rotate; + + public struct SkewLayout + { + public double X; + public double Y; + } + + public struct ScaleLayout + { + public double X; + public double Y; + } + + public struct TranslateLayout + { + public double X; + public double Y; + } + + public struct RotateLayout + { + public double Angle; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs new file mode 100644 index 0000000000..9f711a2d63 --- /dev/null +++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace Avalonia.Media.Transformation +{ + public sealed class TransformOperations : ITransform + { + public static TransformOperations Identity { get; } = new TransformOperations(new List()); + + private readonly List _operations; + + private TransformOperations(List operations) + { + _operations = operations ?? throw new ArgumentNullException(nameof(operations)); + + IsIdentity = CheckIsIdentity(); + + Value = ApplyTransforms(); + } + + public bool IsIdentity { get; } + + public IReadOnlyList Operations => _operations; + + public Matrix Value { get; } + + public static TransformOperations Parse(string s) + { + return TransformParser.Parse(s); + } + + public static Builder CreateBuilder(int capacity) + { + return new Builder(capacity); + } + + public static TransformOperations Interpolate(TransformOperations from, TransformOperations to, double progress) + { + TransformOperations result = Identity; + + if (!TryInterpolate(from, to, progress, ref result)) + { + // If the matrices cannot be interpolated, fallback to discrete animation logic. + // See https://drafts.csswg.org/css-transforms/#matrix-interpolation + result = progress < 0.5 ? from : to; + } + + return result; + } + + private Matrix ApplyTransforms(int startOffset = 0) + { + Matrix matrix = Matrix.Identity; + + for (var i = startOffset; i < _operations.Count; i++) + { + TransformOperation operation = _operations[i]; + matrix *= operation.Matrix; + } + + return matrix; + } + + private bool CheckIsIdentity() + { + foreach (TransformOperation operation in _operations) + { + if (!operation.IsIdentity) + { + return false; + } + } + + return true; + } + + private static bool TryInterpolate(TransformOperations from, TransformOperations to, double progress, ref TransformOperations result) + { + bool fromIdentity = from.IsIdentity; + bool toIdentity = to.IsIdentity; + + if (fromIdentity && toIdentity) + { + return true; + } + + int matchingPrefixLength = ComputeMatchingPrefixLength(from, to); + int fromSize = fromIdentity ? 0 : from._operations.Count; + int toSize = toIdentity ? 0 : to._operations.Count; + int numOperations = Math.Max(fromSize, toSize); + + var builder = new Builder(matchingPrefixLength); + + for (int i = 0; i < matchingPrefixLength; i++) + { + TransformOperation interpolated = new TransformOperation + { + Type = TransformOperation.OperationType.Identity + }; + + if (!TransformOperation.TryInterpolate( + i >= fromSize ? default(TransformOperation?) : from._operations[i], + i >= toSize ? default(TransformOperation?) : to._operations[i], + progress, + ref interpolated)) + { + return false; + } + + builder.Append(interpolated); + } + + if (matchingPrefixLength < numOperations) + { + if (!ComputeDecomposedTransform(from, matchingPrefixLength, out Matrix.Decomposed fromDecomposed) || + !ComputeDecomposedTransform(to, matchingPrefixLength, out Matrix.Decomposed toDecomposed)) + { + return false; + } + + var transform = InterpolationUtilities.InterpolateDecomposedTransforms(ref fromDecomposed, ref toDecomposed, progress); + + builder.AppendMatrix(InterpolationUtilities.ComposeTransform(transform)); + } + + result = builder.Build(); + + return true; + } + + private static bool ComputeDecomposedTransform(TransformOperations operations, int startOffset, out Matrix.Decomposed decomposed) + { + Matrix transform = operations.ApplyTransforms(startOffset); + + if (!Matrix.TryDecomposeTransform(transform, out decomposed)) + { + return false; + } + + return true; + } + + private static int ComputeMatchingPrefixLength(TransformOperations from, TransformOperations to) + { + int numOperations = Math.Min(from._operations.Count, to._operations.Count); + + for (int i = 0; i < numOperations; i++) + { + if (from._operations[i].Type != to._operations[i].Type) + { + return i; + } + } + + // If the operations match to the length of the shorter list, then pad its + // length with the matching identity operations. + // https://drafts.csswg.org/css-transforms/#transform-function-lists + return Math.Max(from._operations.Count, to._operations.Count); + } + + public readonly struct Builder + { + private readonly List _operations; + + public Builder(int capacity) + { + _operations = new List(capacity); + } + + public void AppendTranslate(double x, double y) + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Translate; + toAdd.Data.Translate.X = x; + toAdd.Data.Translate.Y = y; + + toAdd.Bake(); + + _operations.Add(toAdd); + } + + public void AppendRotate(double angle) + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Rotate; + toAdd.Data.Rotate.Angle = angle; + + toAdd.Bake(); + + _operations.Add(toAdd); + } + + public void AppendScale(double x, double y) + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Scale; + toAdd.Data.Scale.X = x; + toAdd.Data.Scale.Y = y; + + toAdd.Bake(); + + _operations.Add(toAdd); + } + + public void AppendSkew(double x, double y) + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Skew; + toAdd.Data.Skew.X = x; + toAdd.Data.Skew.Y = y; + + toAdd.Bake(); + + _operations.Add(toAdd); + } + + public void AppendMatrix(Matrix matrix) + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Matrix; + toAdd.Matrix = matrix; + + _operations.Add(toAdd); + } + + public void AppendIdentity() + { + var toAdd = new TransformOperation(); + + toAdd.Type = TransformOperation.OperationType.Identity; + + _operations.Add(toAdd); + } + + public void Append(TransformOperation toAdd) + { + _operations.Add(toAdd); + } + + public TransformOperations Build() + { + return new TransformOperations(_operations); + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs new file mode 100644 index 0000000000..2a3912832b --- /dev/null +++ b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs @@ -0,0 +1,463 @@ +using System; +using System.Globalization; +using Avalonia.Utilities; + +namespace Avalonia.Media.Transformation +{ + public static class TransformParser + { + private static readonly (string, TransformFunction)[] s_functionMapping = + { + ("translate", TransformFunction.Translate), + ("translateX", TransformFunction.TranslateX), + ("translateY", TransformFunction.TranslateY), + ("scale", TransformFunction.Scale), + ("scaleX", TransformFunction.ScaleX), + ("scaleY", TransformFunction.ScaleY), + ("skew", TransformFunction.Skew), + ("skewX", TransformFunction.SkewX), + ("skewY", TransformFunction.SkewY), + ("rotate", TransformFunction.Rotate), + ("matrix", TransformFunction.Matrix) + }; + + private static readonly (string, Unit)[] s_unitMapping = + { + ("deg", Unit.Degree), + ("grad", Unit.Gradian), + ("rad", Unit.Radian), + ("turn", Unit.Turn), + ("px", Unit.Pixel) + }; + + public static TransformOperations Parse(string s) + { + void ThrowInvalidFormat() + { + throw new FormatException($"Invalid transform string: '{s}'."); + } + + if (string.IsNullOrEmpty(s)) + { + throw new ArgumentException(nameof(s)); + } + + var span = s.AsSpan().Trim(); + + if (span.Equals("none".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return TransformOperations.Identity; + } + + var builder = TransformOperations.CreateBuilder(0); + + while (true) + { + var beginIndex = span.IndexOf('('); + var endIndex = span.IndexOf(')'); + + if (beginIndex == -1 || endIndex == -1) + { + ThrowInvalidFormat(); + } + + var namePart = span.Slice(0, beginIndex).Trim(); + + var function = ParseTransformFunction(in namePart); + + if (function == TransformFunction.Invalid) + { + ThrowInvalidFormat(); + } + + var valuePart = span.Slice(beginIndex + 1, endIndex - beginIndex - 1).Trim(); + + ParseFunction(in valuePart, function, in builder); + + span = span.Slice(endIndex + 1); + + if (span.IsWhiteSpace()) + { + break; + } + } + + return builder.Build(); + } + + private static void ParseFunction( + in ReadOnlySpan functionPart, + TransformFunction function, + in TransformOperations.Builder builder) + { + static UnitValue ParseValue(ReadOnlySpan part) + { + int unitIndex = -1; + + for (int i = 0; i < part.Length; i++) + { + char c = part[i]; + + if (char.IsDigit(c) || c == '-' || c == '.') + { + continue; + } + + unitIndex = i; + break; + } + + Unit unit = Unit.None; + + if (unitIndex != -1) + { + var unitPart = part.Slice(unitIndex, part.Length - unitIndex); + + unit = ParseUnit(unitPart); + + part = part.Slice(0, unitIndex); + } + + var value = double.Parse(part.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture); + + return new UnitValue(unit, value); + } + + static int ParseValuePair( + in ReadOnlySpan part, + ref UnitValue leftValue, + ref UnitValue rightValue) + { + var commaIndex = part.IndexOf(','); + + if (commaIndex != -1) + { + var leftPart = part.Slice(0, commaIndex).Trim(); + var rightPart = part.Slice(commaIndex + 1, part.Length - commaIndex - 1).Trim(); + + leftValue = ParseValue(leftPart); + rightValue = ParseValue(rightPart); + + return 2; + } + + leftValue = ParseValue(part); + + return 1; + } + + static int ParseCommaDelimitedValues(ReadOnlySpan part, in Span outValues) + { + int valueIndex = 0; + + while (true) + { + if (valueIndex >= outValues.Length) + { + throw new FormatException("Too many provided values."); + } + + var commaIndex = part.IndexOf(','); + + if (commaIndex == -1) + { + if (!part.IsWhiteSpace()) + { + outValues[valueIndex++] = ParseValue(part); + } + + break; + } + + var valuePart = part.Slice(0, commaIndex).Trim(); + + outValues[valueIndex++] = ParseValue(valuePart); + + part = part.Slice(commaIndex + 1, part.Length - commaIndex - 1); + } + + return valueIndex; + } + + switch (function) + { + case TransformFunction.Scale: + case TransformFunction.ScaleX: + case TransformFunction.ScaleY: + { + var scaleX = UnitValue.One; + var scaleY = UnitValue.One; + + int count = ParseValuePair(functionPart, ref scaleX, ref scaleY); + + if (count != 1 && (function == TransformFunction.ScaleX || function == TransformFunction.ScaleY)) + { + ThrowFormatInvalidValueCount(function, 1); + } + + VerifyZeroOrUnit(function, in scaleX, Unit.None); + VerifyZeroOrUnit(function, in scaleY, Unit.None); + + if (function == TransformFunction.ScaleX) + { + scaleY = UnitValue.Zero; + } + else if (function == TransformFunction.ScaleY) + { + scaleY = scaleX; + scaleX = UnitValue.Zero; + } + else if (count == 1) + { + scaleY = scaleX; + } + + builder.AppendScale(scaleX.Value, scaleY.Value); + + break; + } + case TransformFunction.Skew: + case TransformFunction.SkewX: + case TransformFunction.SkewY: + { + var skewX = UnitValue.Zero; + var skewY = UnitValue.Zero; + + int count = ParseValuePair(functionPart, ref skewX, ref skewY); + + if (count != 1 && (function == TransformFunction.SkewX || function == TransformFunction.SkewY)) + { + ThrowFormatInvalidValueCount(function, 1); + } + + VerifyZeroOrAngle(function, in skewX); + VerifyZeroOrAngle(function, in skewY); + + if (function == TransformFunction.SkewX) + { + skewY = UnitValue.Zero; + } + else if (function == TransformFunction.SkewY) + { + skewY = skewX; + skewX = UnitValue.Zero; + } + else if (count == 1) + { + skewY = skewX; + } + + builder.AppendSkew(ToRadians(in skewX), ToRadians(in skewY)); + + break; + } + case TransformFunction.Rotate: + { + var angle = UnitValue.Zero; + UnitValue _ = default; + + int count = ParseValuePair(functionPart, ref angle, ref _); + + if (count != 1) + { + ThrowFormatInvalidValueCount(function, 1); + } + + VerifyZeroOrAngle(function, in angle); + + builder.AppendRotate(ToRadians(in angle)); + + break; + } + case TransformFunction.Translate: + case TransformFunction.TranslateX: + case TransformFunction.TranslateY: + { + var translateX = UnitValue.Zero; + var translateY = UnitValue.Zero; + + int count = ParseValuePair(functionPart, ref translateX, ref translateY); + + if (count != 1 && (function == TransformFunction.TranslateX || function == TransformFunction.TranslateY)) + { + ThrowFormatInvalidValueCount(function, 1); + } + + VerifyZeroOrUnit(function, in translateX, Unit.Pixel); + VerifyZeroOrUnit(function, in translateY, Unit.Pixel); + + if (function == TransformFunction.TranslateX) + { + translateY = UnitValue.Zero; + } + else if (function == TransformFunction.TranslateY) + { + translateY = translateX; + translateX = UnitValue.Zero; + } + else if (count == 1) + { + translateY = translateX; + } + + builder.AppendTranslate(translateX.Value, translateY.Value); + + break; + } + case TransformFunction.Matrix: + { + Span values = stackalloc UnitValue[6]; + + int count = ParseCommaDelimitedValues(functionPart, in values); + + if (count != 6) + { + ThrowFormatInvalidValueCount(function, 6); + } + + foreach (UnitValue value in values) + { + VerifyZeroOrUnit(function, value, Unit.None); + } + + var matrix = new Matrix( + values[0].Value, + values[1].Value, + values[2].Value, + values[3].Value, + values[4].Value, + values[5].Value); + + builder.AppendMatrix(matrix); + + break; + } + } + } + + private static void VerifyZeroOrUnit(TransformFunction function, in UnitValue value, Unit unit) + { + bool isZero = value.Unit == Unit.None && value.Value == 0d; + + if (!isZero && value.Unit != unit) + { + ThrowFormatInvalidValue(function, in value); + } + } + + private static void VerifyZeroOrAngle(TransformFunction function, in UnitValue value) + { + if (value.Value != 0d && !IsAngleUnit(value.Unit)) + { + ThrowFormatInvalidValue(function, in value); + } + } + + private static bool IsAngleUnit(Unit unit) + { + switch (unit) + { + case Unit.Radian: + case Unit.Degree: + case Unit.Turn: + { + return true; + } + } + + return false; + } + + private static void ThrowFormatInvalidValue(TransformFunction function, in UnitValue value) + { + var unitString = value.Unit == Unit.None ? string.Empty : value.Unit.ToString(); + + throw new FormatException($"Invalid value {value.Value} {unitString} for {function}"); + } + + private static void ThrowFormatInvalidValueCount(TransformFunction function, int count) + { + throw new FormatException($"Invalid format. {function} expects {count} value(s)."); + } + + private static Unit ParseUnit(in ReadOnlySpan part) + { + foreach (var (name, unit) in s_unitMapping) + { + if (part.Equals(name.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return unit; + } + } + + throw new FormatException($"Invalid unit: {part.ToString()}"); + } + + private static TransformFunction ParseTransformFunction(in ReadOnlySpan part) + { + foreach (var (name, transformFunction) in s_functionMapping) + { + if (part.Equals(name.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return transformFunction; + } + } + + return TransformFunction.Invalid; + } + + private static double ToRadians(in UnitValue value) + { + return value.Unit switch + { + Unit.Radian => value.Value, + Unit.Gradian => MathUtilities.Grad2Rad(value.Value), + Unit.Degree => MathUtilities.Deg2Rad(value.Value), + Unit.Turn => MathUtilities.Turn2Rad(value.Value), + _ => value.Value + }; + } + + private enum Unit + { + None, + Pixel, + Radian, + Gradian, + Degree, + Turn + } + + private readonly struct UnitValue + { + public readonly Unit Unit; + public readonly double Value; + + public UnitValue(Unit unit, double value) + { + Unit = unit; + Value = value; + } + + public static UnitValue Zero => new UnitValue(Unit.None, 0); + + public static UnitValue One => new UnitValue(Unit.None, 1); + } + + private enum TransformFunction + { + Invalid, + Translate, + TranslateX, + TranslateY, + Scale, + ScaleX, + ScaleY, + Skew, + SkewX, + SkewY, + Rotate, + Matrix + } + } +} diff --git a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs index 6cd6442095..5d802c27b9 100644 --- a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs @@ -6,6 +6,7 @@ using Avalonia.Metadata; [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Animation")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media.Imaging")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media.Transformation")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")] [assembly: InternalsVisibleTo("Avalonia.Direct2D1.RenderTests")] diff --git a/src/Avalonia.Visuals/Visual.cs b/src/Avalonia.Visuals/Visual.cs index bb9a4cf208..cd6e5bb075 100644 --- a/src/Avalonia.Visuals/Visual.cs +++ b/src/Avalonia.Visuals/Visual.cs @@ -68,8 +68,8 @@ namespace Avalonia /// /// Defines the property. /// - public static readonly StyledProperty RenderTransformProperty = - AvaloniaProperty.Register(nameof(RenderTransform)); + public static readonly StyledProperty RenderTransformProperty = + AvaloniaProperty.Register(nameof(RenderTransform)); /// /// Defines the property. @@ -219,7 +219,7 @@ namespace Avalonia /// /// Gets the render transform of the control. /// - public Transform RenderTransform + public ITransform RenderTransform { get { return GetValue(RenderTransformProperty); } set { SetValue(RenderTransformProperty, value); } @@ -391,9 +391,9 @@ namespace Avalonia _visualRoot = e.Root; - if (RenderTransform != null) + if (RenderTransform is IMutableTransform mutableTransform) { - RenderTransform.Changed += RenderTransformChanged; + mutableTransform.Changed += RenderTransformChanged; } EnableTransitions(); @@ -428,9 +428,9 @@ namespace Avalonia _visualRoot = null; - if (RenderTransform != null) + if (RenderTransform is IMutableTransform mutableTransform) { - RenderTransform.Changed -= RenderTransformChanged; + mutableTransform.Changed -= RenderTransformChanged; } DisableTransitions(); diff --git a/src/Avalonia.Visuals/VisualTree/IVisual.cs b/src/Avalonia.Visuals/VisualTree/IVisual.cs index 6f905cc269..50787655d9 100644 --- a/src/Avalonia.Visuals/VisualTree/IVisual.cs +++ b/src/Avalonia.Visuals/VisualTree/IVisual.cs @@ -76,7 +76,7 @@ namespace Avalonia.VisualTree /// /// Gets or sets the render transform of the control. /// - Transform RenderTransform { get; set; } + ITransform RenderTransform { get; set; } /// /// Gets or sets the render transform origin of the control. diff --git a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs index ff1d17164e..44e2e8663b 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Avalonia.Utilities; using Xunit; namespace Avalonia.Visuals.UnitTests.Media @@ -12,5 +13,46 @@ namespace Avalonia.Visuals.UnitTests.Media var expected = new Matrix(1, 2, 3, -4, 5, 6); Assert.Equal(expected, matrix); } + + [Fact] + public void Can_Decompose_Translation() + { + var matrix = Matrix.CreateTranslation(5, 10); + + var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed); + + Assert.Equal(true, result); + Assert.Equal(5, decomposed.Translate.X); + Assert.Equal(10, decomposed.Translate.Y); + } + + [Fact] + public void Can_Decompose_Angle() + { + var angleRad = MathUtilities.Deg2Rad(30); + + var matrix = Matrix.CreateRotation(angleRad); + + var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed); + + Assert.Equal(true, result); + Assert.Equal(angleRad, decomposed.Angle); + } + + [Theory] + [InlineData(1d, 1d)] + [InlineData(-1d, 1d)] + [InlineData(1d, -1d)] + [InlineData(5d, 10d)] + public void Can_Decompose_Scale(double x, double y) + { + var matrix = Matrix.CreateScale(x, y); + + var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed); + + Assert.Equal(true, result); + Assert.Equal(x, decomposed.Scale.X); + Assert.Equal(y, decomposed.Scale.Y); + } } -} \ No newline at end of file +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs new file mode 100644 index 0000000000..8e0520a71d --- /dev/null +++ b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs @@ -0,0 +1,128 @@ +using Avalonia.Media.Transformation; +using Avalonia.Utilities; +using Xunit; + +namespace Avalonia.Visuals.UnitTests.Media +{ + public class TransformOperationsTests + { + [Fact] + public void Can_Parse_Compound_Operations() + { + var data = "scale(1,2) translate(3px,4px) rotate(5deg) skew(6deg,7deg)"; + + var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Equal(TransformOperation.OperationType.Scale, operations[0].Type); + Assert.Equal(1, operations[0].Data.Scale.X); + Assert.Equal(2, operations[0].Data.Scale.Y); + + Assert.Equal(TransformOperation.OperationType.Translate, operations[1].Type); + Assert.Equal(3, operations[1].Data.Translate.X); + Assert.Equal(4, operations[1].Data.Translate.Y); + + Assert.Equal(TransformOperation.OperationType.Rotate, operations[2].Type); + Assert.Equal(MathUtilities.Deg2Rad(5), operations[2].Data.Rotate.Angle); + + Assert.Equal(TransformOperation.OperationType.Skew, operations[3].Type); + Assert.Equal(MathUtilities.Deg2Rad(6), operations[3].Data.Skew.X); + Assert.Equal(MathUtilities.Deg2Rad(7), operations[3].Data.Skew.Y); + } + + [Fact] + public void Can_Parse_Matrix_Operation() + { + var data = "matrix(1,2,3,4,5,6)"; + + var transform = TransformOperations.Parse(data); + } + + [Theory] + [InlineData(0d, 10d, 0d)] + [InlineData(0.5d, 5d, 10d)] + [InlineData(1d, 0d, 20d)] + public void Can_Interpolate_Translation(double progress, double x, double y) + { + var from = TransformOperations.Parse("translateX(10px)"); + var to = TransformOperations.Parse("translateY(20px)"); + + var interpolated = TransformOperations.Interpolate(from, to, progress); + + var operations = interpolated.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Translate, operations[0].Type); + Assert.Equal(x, operations[0].Data.Translate.X); + Assert.Equal(y, operations[0].Data.Translate.Y); + } + + [Theory] + [InlineData(0d, 10d, 0d)] + [InlineData(0.5d, 5d, 10d)] + [InlineData(1d, 0d, 20d)] + public void Can_Interpolate_Scale(double progress, double x, double y) + { + var from = TransformOperations.Parse("scaleX(10)"); + var to = TransformOperations.Parse("scaleY(20)"); + + var interpolated = TransformOperations.Interpolate(from, to, progress); + + var operations = interpolated.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Scale, operations[0].Type); + Assert.Equal(x, operations[0].Data.Scale.X); + Assert.Equal(y, operations[0].Data.Scale.Y); + } + + [Theory] + [InlineData(0d, 10d, 0d)] + [InlineData(0.5d, 5d, 10d)] + [InlineData(1d, 0d, 20d)] + public void Can_Interpolate_Skew(double progress, double x, double y) + { + var from = TransformOperations.Parse("skewX(10deg)"); + var to = TransformOperations.Parse("skewY(20deg)"); + + var interpolated = TransformOperations.Interpolate(from, to, progress); + + var operations = interpolated.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Skew, operations[0].Type); + Assert.Equal(MathUtilities.Deg2Rad(x), operations[0].Data.Skew.X); + Assert.Equal(MathUtilities.Deg2Rad(y), operations[0].Data.Skew.Y); + } + + [Theory] + [InlineData(0d, 10d)] + [InlineData(0.5d, 15d)] + [InlineData(1d,20d)] + public void Can_Interpolate_Rotation(double progress, double angle) + { + var from = TransformOperations.Parse("rotate(10deg)"); + var to = TransformOperations.Parse("rotate(20deg)"); + + var interpolated = TransformOperations.Interpolate(from, to, progress); + + var operations = interpolated.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Rotate, operations[0].Type); + Assert.Equal(MathUtilities.Deg2Rad(angle), operations[0].Data.Rotate.Angle); + } + + [Fact] + public void Can_Interpolate_Matrix() + { + double progress = 0.5d; + + var from = TransformOperations.Parse("rotate(45deg)"); + var to = TransformOperations.Parse("translate(100px, 100px) rotate(1215deg)"); + + var interpolated = TransformOperations.Interpolate(from, to, progress); + } + } +} From 179fdd21b33e57f7fa70fc1a8c6b12aa1928a653 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Tue, 9 Jun 2020 12:45:42 +0200 Subject: [PATCH 12/25] Use epsilon when checking for singular matrices. Add benchmark for decomposing a Matrix. Add more tests for Matrix struct. --- src/Avalonia.Base/Utilities/MathUtilities.cs | 1 - src/Avalonia.Visuals/Matrix.cs | 11 ++-- .../Visuals/MatrixBenchmarks.cs | 16 ++++++ .../Media/MatrixTests.cs | 53 ++++++++++++++++--- 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs diff --git a/src/Avalonia.Base/Utilities/MathUtilities.cs b/src/Avalonia.Base/Utilities/MathUtilities.cs index fa5ab2d45b..3115065528 100644 --- a/src/Avalonia.Base/Utilities/MathUtilities.cs +++ b/src/Avalonia.Base/Utilities/MathUtilities.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.InteropServices; namespace Avalonia.Utilities { diff --git a/src/Avalonia.Visuals/Matrix.cs b/src/Avalonia.Visuals/Matrix.cs index e18140aa8d..3c8e5e39f2 100644 --- a/src/Avalonia.Visuals/Matrix.cs +++ b/src/Avalonia.Visuals/Matrix.cs @@ -9,6 +9,8 @@ namespace Avalonia /// public readonly struct Matrix : IEquatable { + private const float DecomposeEpsilon = 0.0001f; + private readonly double _m11; private readonly double _m12; private readonly double _m21; @@ -54,7 +56,7 @@ namespace Avalonia /// /// HasInverse Property - returns true if this matrix is invertible, false otherwise. /// - public bool HasInverse => GetDeterminant() != 0; + public bool HasInverse => Math.Abs(GetDeterminant()) >= double.Epsilon; /// /// The first element of the first row @@ -286,7 +288,7 @@ namespace Avalonia { double d = GetDeterminant(); - if (d == 0) + if (Math.Abs(d) < double.Epsilon) { throw new InvalidOperationException("Transform is not invertible."); } @@ -325,8 +327,9 @@ namespace Avalonia decomposed = default; var determinant = matrix.GetDeterminant(); - - if (determinant == 0) + + // Based upon constant in System.Numerics.Matrix4x4. + if (Math.Abs(determinant) < DecomposeEpsilon) { return false; } diff --git a/tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs b/tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs new file mode 100644 index 0000000000..17e2237eb0 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Visuals/MatrixBenchmarks.cs @@ -0,0 +1,16 @@ +using BenchmarkDotNet.Attributes; + +namespace Avalonia.Benchmarks.Visuals +{ + [MemoryDiagnoser, InProcess] + public class MatrixBenchmarks + { + private static readonly Matrix s_data = Matrix.Identity; + + [Benchmark(Baseline = true)] + public bool Decompose() + { + return Matrix.TryDecomposeTransform(s_data, out Matrix.Decomposed decomposed); + } + } +} diff --git a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs index 44e2e8663b..6ef48b6161 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/MatrixTests.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System; using Avalonia.Utilities; using Xunit; @@ -7,13 +7,29 @@ namespace Avalonia.Visuals.UnitTests.Media public class MatrixTests { [Fact] - public void Parse_Parses() + public void Can_Parse() { var matrix = Matrix.Parse("1,2,3,-4,5 6"); var expected = new Matrix(1, 2, 3, -4, 5, 6); Assert.Equal(expected, matrix); } + [Fact] + public void Singular_Has_No_Inverse() + { + var matrix = new Matrix(0, 0, 0, 0, 0, 0); + + Assert.False(matrix.HasInverse); + } + + [Fact] + public void Identity_Has_Inverse() + { + var matrix = Matrix.Identity; + + Assert.True(matrix.HasInverse); + } + [Fact] public void Can_Decompose_Translation() { @@ -26,17 +42,25 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(10, decomposed.Translate.Y); } - [Fact] - public void Can_Decompose_Angle() + [Theory] + [InlineData(30d)] + [InlineData(0d)] + [InlineData(90d)] + [InlineData(270d)] + public void Can_Decompose_Angle(double angleDeg) { - var angleRad = MathUtilities.Deg2Rad(30); + var angleRad = MathUtilities.Deg2Rad(angleDeg); var matrix = Matrix.CreateRotation(angleRad); var result = Matrix.TryDecomposeTransform(matrix, out Matrix.Decomposed decomposed); Assert.Equal(true, result); - Assert.Equal(angleRad, decomposed.Angle); + + var expected = NormalizeAngle(angleRad); + var actual = NormalizeAngle(decomposed.Angle); + + Assert.Equal(expected, actual, 4); } [Theory] @@ -54,5 +78,22 @@ namespace Avalonia.Visuals.UnitTests.Media Assert.Equal(x, decomposed.Scale.X); Assert.Equal(y, decomposed.Scale.Y); } + + private static double NormalizeAngle(double rad) + { + double twoPi = 2 * Math.PI; + + while (rad < 0) + { + rad += twoPi; + } + + while (rad > twoPi) + { + rad -= twoPi; + } + + return rad; + } } } From 4b0e88f0eccafe3aaad6e758f8c95236781b4fec Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Tue, 9 Jun 2020 12:51:06 +0200 Subject: [PATCH 13/25] Implement remainder of matrix interpolation fallback test. --- .../Media/TransformOperationsTests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs index 8e0520a71d..8b4ccba57d 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs @@ -115,7 +115,7 @@ namespace Avalonia.Visuals.UnitTests.Media } [Fact] - public void Can_Interpolate_Matrix() + public void Interpolation_Fallback_To_Matrix() { double progress = 0.5d; @@ -123,6 +123,11 @@ namespace Avalonia.Visuals.UnitTests.Media var to = TransformOperations.Parse("translate(100px, 100px) rotate(1215deg)"); var interpolated = TransformOperations.Interpolate(from, to, progress); + + var operations = interpolated.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Matrix, operations[0].Type); } } } From 0f6bf8d9bae23f7419d48e012d9821a27a017143 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 14 Jun 2020 14:18:51 +0200 Subject: [PATCH 14/25] Cleanup and add comments. --- src/Avalonia.Visuals/Matrix.cs | 11 +++-- .../Transformation/TransformOperation.cs | 43 +++++++++++++++---- .../Transformation/TransformOperations.cs | 8 +++- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/Avalonia.Visuals/Matrix.cs b/src/Avalonia.Visuals/Matrix.cs index 3c8e5e39f2..24ec343106 100644 --- a/src/Avalonia.Visuals/Matrix.cs +++ b/src/Avalonia.Visuals/Matrix.cs @@ -9,8 +9,6 @@ namespace Avalonia /// public readonly struct Matrix : IEquatable { - private const float DecomposeEpsilon = 0.0001f; - private readonly double _m11; private readonly double _m12; private readonly double _m21; @@ -322,14 +320,19 @@ namespace Avalonia } } + /// + /// Decomposes given matrix into transform operations. + /// + /// Matrix to decompose. + /// Decomposed matrix. + /// The status of the operation. public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed) { decomposed = default; var determinant = matrix.GetDeterminant(); - // Based upon constant in System.Numerics.Matrix4x4. - if (Math.Abs(determinant) < DecomposeEpsilon) + if (MathUtilities.IsZero(determinant)) { return false; } diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs index cdf31f8e5b..36f5dd98f1 100644 --- a/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs +++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperation.cs @@ -2,6 +2,9 @@ using System.Runtime.InteropServices; namespace Avalonia.Media.Transformation { + /// + /// Represents a single primitive transform (like translation, rotation, scale, etc.). + /// public struct TransformOperation { public OperationType Type; @@ -18,8 +21,14 @@ namespace Avalonia.Media.Transformation Identity } + /// + /// Returns whether operation produces the identity matrix. + /// public bool IsIdentity => Matrix.IsIdentity; + /// + /// Bakes this operation to a transform matrix. + /// public void Bake() { Matrix = Matrix.Identity; @@ -53,11 +62,22 @@ namespace Avalonia.Media.Transformation } } - public static bool IsOperationIdentity(ref TransformOperation? operation) - { - return !operation.HasValue || operation.Value.IsIdentity; - } - + /// + /// Returns new identity transform operation. + /// + public static TransformOperation Identity => + new TransformOperation { Matrix = Matrix.Identity, Type = OperationType.Identity }; + + /// + /// Attempts to interpolate between two transform operations. + /// + /// Source operation. + /// Target operation. + /// Interpolation progress. + /// Interpolation result that will be filled in when operation was successful. + /// + /// Based upon https://www.w3.org/TR/css-transforms-1/#interpolation-of-transform-functions. + /// public static bool TryInterpolate(TransformOperation? from, TransformOperation? to, double progress, ref TransformOperation result) { @@ -69,8 +89,10 @@ namespace Avalonia.Media.Transformation return true; } - TransformOperation fromValue = fromIdentity ? default : from.Value; - TransformOperation toValue = toIdentity ? default : to.Value; + // ReSharper disable PossibleInvalidOperationException + TransformOperation fromValue = fromIdentity ? Identity : from.Value; + TransformOperation toValue = toIdentity ? Identity : to.Value; + // ReSharper restore PossibleInvalidOperationException var interpolationType = toIdentity ? fromValue.Type : toValue.Type; @@ -139,7 +161,7 @@ namespace Avalonia.Media.Transformation { var fromMatrix = fromIdentity ? Matrix.Identity : fromValue.Matrix; var toMatrix = toIdentity ? Matrix.Identity : toValue.Matrix; - + if (!Matrix.TryDecomposeTransform(fromMatrix, out Matrix.Decomposed fromDecomposed) || !Matrix.TryDecomposeTransform(toMatrix, out Matrix.Decomposed toDecomposed)) { @@ -165,6 +187,11 @@ namespace Avalonia.Media.Transformation return true; } + private static bool IsOperationIdentity(ref TransformOperation? operation) + { + return !operation.HasValue || operation.Value.IsIdentity; + } + [StructLayout(LayoutKind.Explicit)] public struct DataLayout { diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs index 9f711a2d63..334bb93562 100644 --- a/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs +++ b/src/Avalonia.Visuals/Media/Transformation/TransformOperations.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; -using JetBrains.Annotations; namespace Avalonia.Media.Transformation { + /// + /// Contains a list of that represent primitive transforms that will be + /// applied in declared order. + /// public sealed class TransformOperations : ITransform { public static TransformOperations Identity { get; } = new TransformOperations(new List()); @@ -19,6 +22,9 @@ namespace Avalonia.Media.Transformation Value = ApplyTransforms(); } + /// + /// Returns whether all operations combined together produce the identity matrix. + /// public bool IsIdentity { get; } public IReadOnlyList Operations => _operations; From 2b503357277c5e9f62c68f60b8dd68eea0767b04 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 14 Jun 2020 15:08:36 +0200 Subject: [PATCH 15/25] Increase coverage of transform parsing and fix found issues. --- .../Media/Transformation/TransformParser.cs | 31 ++---- .../Media/TransformOperationsTests.cs | 102 +++++++++++++++++- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs index 2a3912832b..85f4f5fec1 100644 --- a/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs +++ b/src/Avalonia.Visuals/Media/Transformation/TransformParser.cs @@ -198,16 +198,12 @@ namespace Avalonia.Media.Transformation VerifyZeroOrUnit(function, in scaleX, Unit.None); VerifyZeroOrUnit(function, in scaleY, Unit.None); - if (function == TransformFunction.ScaleX) - { - scaleY = UnitValue.Zero; - } - else if (function == TransformFunction.ScaleY) + if (function == TransformFunction.ScaleY) { scaleY = scaleX; - scaleX = UnitValue.Zero; + scaleX = UnitValue.One; } - else if (count == 1) + else if (function == TransformFunction.Scale && count == 1) { scaleY = scaleX; } @@ -233,19 +229,11 @@ namespace Avalonia.Media.Transformation VerifyZeroOrAngle(function, in skewX); VerifyZeroOrAngle(function, in skewY); - if (function == TransformFunction.SkewX) - { - skewY = UnitValue.Zero; - } - else if (function == TransformFunction.SkewY) + if (function == TransformFunction.SkewY) { skewY = skewX; skewX = UnitValue.Zero; } - else if (count == 1) - { - skewY = skewX; - } builder.AppendSkew(ToRadians(in skewX), ToRadians(in skewY)); @@ -286,19 +274,11 @@ namespace Avalonia.Media.Transformation VerifyZeroOrUnit(function, in translateX, Unit.Pixel); VerifyZeroOrUnit(function, in translateY, Unit.Pixel); - if (function == TransformFunction.TranslateX) - { - translateY = UnitValue.Zero; - } - else if (function == TransformFunction.TranslateY) + if (function == TransformFunction.TranslateY) { translateY = translateX; translateX = UnitValue.Zero; } - else if (count == 1) - { - translateY = translateX; - } builder.AppendTranslate(translateX.Value, translateY.Value); @@ -358,6 +338,7 @@ namespace Avalonia.Media.Transformation switch (unit) { case Unit.Radian: + case Unit.Gradian: case Unit.Degree: case Unit.Turn: { diff --git a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs index 8b4ccba57d..856b4615a5 100644 --- a/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs +++ b/tests/Avalonia.Visuals.UnitTests/Media/TransformOperationsTests.cs @@ -6,6 +6,93 @@ namespace Avalonia.Visuals.UnitTests.Media { public class TransformOperationsTests { + [Theory] + [InlineData("translate(10px)", 10d, 0d)] + [InlineData("translate(10px, 10px)", 10d, 10d)] + [InlineData("translate(0px, 10px)", 0d, 10d)] + [InlineData("translate(10px, 0px)", 10d, 0d)] + [InlineData("translateX(10px)", 10d, 0d)] + [InlineData("translateY(10px)", 0d, 10d)] + public void Can_Parse_Translation(string data, double x, double y) + { + var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Translate, operations[0].Type); + Assert.Equal(x, operations[0].Data.Translate.X); + Assert.Equal(y, operations[0].Data.Translate.Y); + } + + [Theory] + [InlineData("rotate(90deg)", 90d)] + [InlineData("rotate(0.5turn)", 180d)] + [InlineData("rotate(200grad)", 180d)] + [InlineData("rotate(3.14159265rad)", 180d)] + public void Can_Parse_Rotation(string data, double angleDeg) + { + var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Rotate, operations[0].Type); + Assert.Equal(MathUtilities.Deg2Rad(angleDeg), operations[0].Data.Rotate.Angle, 4); + } + + [Theory] + [InlineData("scale(10)", 10d, 10d)] + [InlineData("scale(10, 10)", 10d, 10d)] + [InlineData("scale(0, 10)", 0d, 10d)] + [InlineData("scale(10, 0)", 10d, 0d)] + [InlineData("scaleX(10)", 10d, 1d)] + [InlineData("scaleY(10)", 1d, 10d)] + public void Can_Parse_Scale(string data, double x, double y) + { + var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Scale, operations[0].Type); + Assert.Equal(x, operations[0].Data.Scale.X); + Assert.Equal(y, operations[0].Data.Scale.Y); + } + + [Theory] + [InlineData("skew(90deg)", 90d, 0d)] + [InlineData("skew(0.5turn)", 180d, 0d)] + [InlineData("skew(200grad)", 180d, 0d)] + [InlineData("skew(3.14159265rad)", 180d, 0d)] + [InlineData("skewX(90deg)", 90d, 0d)] + [InlineData("skewX(0.5turn)", 180d, 0d)] + [InlineData("skewX(200grad)", 180d, 0d)] + [InlineData("skewX(3.14159265rad)", 180d, 0d)] + [InlineData("skew(0, 90deg)", 0d, 90d)] + [InlineData("skew(0, 0.5turn)", 0d, 180d)] + [InlineData("skew(0, 200grad)", 0d, 180d)] + [InlineData("skew(0, 3.14159265rad)", 0d, 180d)] + [InlineData("skewY(90deg)", 0d, 90d)] + [InlineData("skewY(0.5turn)", 0d, 180d)] + [InlineData("skewY(200grad)", 0d, 180d)] + [InlineData("skewY(3.14159265rad)", 0d, 180d)] + [InlineData("skew(90deg, 90deg)", 90d, 90d)] + [InlineData("skew(0.5turn, 0.5turn)", 180d, 180d)] + [InlineData("skew(200grad, 200grad)", 180d, 180d)] + [InlineData("skew(3.14159265rad, 3.14159265rad)", 180d, 180d)] + public void Can_Parse_Skew(string data, double x, double y) + { + var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Skew, operations[0].Type); + Assert.Equal(MathUtilities.Deg2Rad(x), operations[0].Data.Skew.X, 4); + Assert.Equal(MathUtilities.Deg2Rad(y), operations[0].Data.Skew.Y, 4); + } + [Fact] public void Can_Parse_Compound_Operations() { @@ -37,6 +124,15 @@ namespace Avalonia.Visuals.UnitTests.Media var data = "matrix(1,2,3,4,5,6)"; var transform = TransformOperations.Parse(data); + + var operations = transform.Operations; + + Assert.Single(operations); + Assert.Equal(TransformOperation.OperationType.Matrix, operations[0].Type); + + var expectedMatrix = new Matrix(1, 2, 3, 4, 5, 6); + + Assert.Equal(expectedMatrix, operations[0].Matrix); } [Theory] @@ -59,9 +155,9 @@ namespace Avalonia.Visuals.UnitTests.Media } [Theory] - [InlineData(0d, 10d, 0d)] - [InlineData(0.5d, 5d, 10d)] - [InlineData(1d, 0d, 20d)] + [InlineData(0d, 10d, 1d)] + [InlineData(0.5d, 5.5d, 10.5d)] + [InlineData(1d, 1d, 20d)] public void Can_Interpolate_Scale(double progress, double x, double y) { var from = TransformOperations.Parse("scaleX(10)"); From 86062c40ae2c991bb7b327e246d494432232d466 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 14 Jun 2020 16:03:01 +0200 Subject: [PATCH 16/25] Cleanup transform animator. --- .../Animators/TransformOperationsAnimator.cs | 26 +++++++++---------- .../TransformOperationsTransition.cs | 5 +++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs index f45338122f..8e9d20eb8f 100644 --- a/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs +++ b/src/Avalonia.Visuals/Animation/Animators/TransformOperationsAnimator.cs @@ -4,32 +4,32 @@ using Avalonia.Media.Transformation; namespace Avalonia.Animation.Animators { - public class TransformOperationsAnimator : Animator + public class TransformOperationsAnimator : Animator { public TransformOperationsAnimator() { Validate = ValidateTransform; } - private void ValidateTransform(AnimatorKeyFrame kf) - { - if (!(kf.Value is TransformOperations)) - { - throw new InvalidOperationException($"All keyframes must be of type {typeof(TransformOperations)}."); - } - } - - public override ITransform Interpolate(double progress, ITransform oldValue, ITransform newValue) + public override TransformOperations Interpolate(double progress, TransformOperations oldValue, TransformOperations newValue) { - var oldTransform = Cast(oldValue); - var newTransform = Cast(newValue); + var oldTransform = EnsureOperations(oldValue); + var newTransform = EnsureOperations(newValue); return TransformOperations.Interpolate(oldTransform, newTransform, progress); } - private static TransformOperations Cast(ITransform value) + internal static TransformOperations EnsureOperations(ITransform value) { return value as TransformOperations ?? TransformOperations.Identity; } + + private void ValidateTransform(AnimatorKeyFrame kf) + { + if (!(kf.Value is TransformOperations)) + { + throw new InvalidOperationException($"All keyframes must be of type {typeof(TransformOperations)}."); + } + } } } diff --git a/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs index 4911b34d91..104acb71ad 100644 --- a/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs +++ b/src/Avalonia.Visuals/Animation/Transitions/TransformOperationsTransition.cs @@ -13,12 +13,15 @@ namespace Avalonia.Animation ITransform oldValue, ITransform newValue) { + var oldTransform = TransformOperationsAnimator.EnsureOperations(oldValue); + var newTransform = TransformOperationsAnimator.EnsureOperations(newValue); + return progress .Select(p => { var f = Easing.Ease(p); - return _operationsAnimator.Interpolate(f, oldValue, newValue); + return _operationsAnimator.Interpolate(f, oldTransform, newTransform); }); } } From 3b8cd5dae282c4168e6de8bbc9b5f83f1f51e223 Mon Sep 17 00:00:00 2001 From: Dariusz Komosinski Date: Sun, 14 Jun 2020 16:03:10 +0200 Subject: [PATCH 17/25] Add transitions example. --- samples/RenderDemo/MainWindow.xaml | 3 + samples/RenderDemo/Pages/TransitionsPage.xaml | 101 ++++++++++++++++++ .../RenderDemo/Pages/TransitionsPage.xaml.cs | 37 +++++++ 3 files changed, 141 insertions(+) create mode 100644 samples/RenderDemo/Pages/TransitionsPage.xaml create mode 100644 samples/RenderDemo/Pages/TransitionsPage.xaml.cs diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml index c098ef411e..14ccc82043 100644 --- a/samples/RenderDemo/MainWindow.xaml +++ b/samples/RenderDemo/MainWindow.xaml @@ -29,6 +29,9 @@ + + + diff --git a/samples/RenderDemo/Pages/TransitionsPage.xaml b/samples/RenderDemo/Pages/TransitionsPage.xaml new file mode 100644 index 0000000000..df7130a925 --- /dev/null +++ b/samples/RenderDemo/Pages/TransitionsPage.xaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hover to activate Transform Keyframe Animations. +