From 95735d239b83c71e1c854827f1911e4aeccccd9f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 21 Apr 2020 16:17:52 +0200 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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 a2a135206524e0c53def447ac426c5f9328e6e96 Mon Sep 17 00:00:00 2001 From: Jumar Macato Date: Sun, 31 May 2020 19:36:02 +0800 Subject: [PATCH 08/55] Implement transition delay --- src/Avalonia.Animation/TransitionInstance.cs | 36 +++++++++++++++++--- src/Avalonia.Animation/Transition`1.cs | 9 +++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Animation/TransitionInstance.cs b/src/Avalonia.Animation/TransitionInstance.cs index ad2001d621..5184341324 100644 --- a/src/Avalonia.Animation/TransitionInstance.cs +++ b/src/Avalonia.Animation/TransitionInstance.cs @@ -4,6 +4,7 @@ using System.Reactive.Linq; using Avalonia.Animation.Easings; using Avalonia.Animation.Utils; using Avalonia.Reactive; +using Avalonia.Utilities; namespace Avalonia.Animation { @@ -13,31 +14,56 @@ namespace Avalonia.Animation internal class TransitionInstance : SingleSubscriberObservableBase { private IDisposable _timerSubscription; + private TimeSpan _delay; private TimeSpan _duration; private readonly IClock _baseClock; private IClock _clock; - public TransitionInstance(IClock clock, TimeSpan Duration) + public TransitionInstance(IClock clock, TimeSpan delay, TimeSpan duration) { clock = clock ?? throw new ArgumentNullException(nameof(clock)); - _duration = Duration; + _delay = delay; + _duration = duration; _baseClock = clock; } private void TimerTick(TimeSpan t) { - var interpVal = _duration.Ticks == 0 ? 1d : (double)t.Ticks / _duration.Ticks; + + // [<------------- normalizedTotalDur ------------------>] + // [<---- Delay ---->][<---------- Duration ------------>] + // ^- normalizedDelayEnd + // [<---- normalizedInterpVal --->] + + var normalizedInterpVal = 1d; + + if (!MathUtilities.AreClose(_duration.TotalSeconds, 0d)) + { + var normalizedTotalDur = _delay + _duration; + var normalizedDelayEnd = _delay.TotalSeconds / normalizedTotalDur.TotalSeconds; + var normalizedPresentationTime = t.TotalSeconds / normalizedTotalDur.TotalSeconds; + + if (normalizedPresentationTime < normalizedDelayEnd + || MathUtilities.AreClose(normalizedPresentationTime, normalizedDelayEnd)) + { + normalizedInterpVal = 0d; + } + else + { + normalizedInterpVal = (t.TotalSeconds - _delay.TotalSeconds) / _duration.TotalSeconds; + } + } // Clamp interpolation value. - if (interpVal >= 1d | interpVal < 0d) + if (normalizedInterpVal >= 1d || normalizedInterpVal < 0d) { PublishNext(1d); PublishCompleted(); } else { - PublishNext(interpVal); + PublishNext(normalizedInterpVal); } } diff --git a/src/Avalonia.Animation/Transition`1.cs b/src/Avalonia.Animation/Transition`1.cs index 138131acb9..4542a137e5 100644 --- a/src/Avalonia.Animation/Transition`1.cs +++ b/src/Avalonia.Animation/Transition`1.cs @@ -13,10 +13,15 @@ namespace Avalonia.Animation private AvaloniaProperty _prop; /// - /// Gets the duration of the animation. + /// Gets or sets the duration of the transition. /// public TimeSpan Duration { get; set; } + /// + /// Gets or sets delay before starting the transition. + /// + public TimeSpan Delay { get; set; } = TimeSpan.Zero; + /// /// Gets the easing class to be used. /// @@ -47,7 +52,7 @@ namespace Avalonia.Animation /// public virtual IDisposable Apply(Animatable control, IClock clock, object oldValue, object newValue) { - var transition = DoTransition(new TransitionInstance(clock, Duration), (T)oldValue, (T)newValue); + var transition = DoTransition(new TransitionInstance(clock, Delay, Duration), (T)oldValue, (T)newValue); return control.Bind((AvaloniaProperty)Property, transition, Data.BindingPriority.Animation); } } From a7536aee6789bd97eb68fc61f392a53b71420b91 Mon Sep 17 00:00:00 2001 From: Jumar Macato Date: Sun, 31 May 2020 20:34:27 +0800 Subject: [PATCH 09/55] add unit test --- .../Avalonia.Animation.UnitTests/TestClock.cs | 10 ++- .../TransitionsTests.cs | 88 ++++++++++++------- 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/tests/Avalonia.Animation.UnitTests/TestClock.cs b/tests/Avalonia.Animation.UnitTests/TestClock.cs index a1c4ff9277..4812880c03 100644 --- a/tests/Avalonia.Animation.UnitTests/TestClock.cs +++ b/tests/Avalonia.Animation.UnitTests/TestClock.cs @@ -5,10 +5,12 @@ namespace Avalonia.Animation.UnitTests { internal class TestClock : IClock, IDisposable { + private TimeSpan _curTime; + private IObserver _observer; public PlayState PlayState { get; set; } = PlayState.Run; - + public void Dispose() { _observer?.OnCompleted(); @@ -19,6 +21,12 @@ namespace Avalonia.Animation.UnitTests _observer?.OnNext(time); } + public void Pulse(TimeSpan time) + { + _curTime += time; + _observer?.OnNext(_curTime); + } + public IDisposable Subscribe(IObserver observer) { _observer = observer; diff --git a/tests/Avalonia.Animation.UnitTests/TransitionsTests.cs b/tests/Avalonia.Animation.UnitTests/TransitionsTests.cs index 70ffd781a1..640013dedd 100644 --- a/tests/Avalonia.Animation.UnitTests/TransitionsTests.cs +++ b/tests/Avalonia.Animation.UnitTests/TransitionsTests.cs @@ -10,13 +10,11 @@ namespace Avalonia.Animation.UnitTests [Fact] public void Check_Transitions_Interpolation_Negative_Bounds_Clamp() { - var clock = new MockGlobalClock(); + var clock = new TestClock(); - using (UnitTestApplication.Start(new TestServices(globalClock: clock))) + var border = new Border { - var border = new Border - { - Transitions = new Transitions + Transitions = new Transitions { new DoubleTransition { @@ -24,27 +22,25 @@ namespace Avalonia.Animation.UnitTests Property = Border.OpacityProperty, } } - }; + }; - border.Opacity = 0; + border.Opacity = 0; - clock.Pulse(TimeSpan.FromSeconds(0)); - clock.Pulse(TimeSpan.FromSeconds(-0.5)); + clock.Pulse(TimeSpan.FromSeconds(0)); + clock.Pulse(TimeSpan.FromSeconds(-0.5)); + + Assert.Equal(0, border.Opacity); - Assert.Equal(0, border.Opacity); - } } [Fact] public void Check_Transitions_Interpolation_Positive_Bounds_Clamp() { - var clock = new MockGlobalClock(); + var clock = new TestClock(); - using (UnitTestApplication.Start(new TestServices(globalClock: clock))) + var border = new Border { - var border = new Border - { - Transitions = new Transitions + Transitions = new Transitions { new DoubleTransition { @@ -52,34 +48,62 @@ namespace Avalonia.Animation.UnitTests Property = Border.OpacityProperty, } } - }; + }; - border.Opacity = 0; + border.Opacity = 0; - clock.Pulse(TimeSpan.FromSeconds(0)); - clock.Pulse(TimeSpan.FromMilliseconds(1001)); + clock.Pulse(TimeSpan.FromSeconds(0)); + clock.Pulse(TimeSpan.FromMilliseconds(1001)); + + Assert.Equal(0, border.Opacity); - Assert.Equal(0, border.Opacity); - } } [Fact] public void TransitionInstance_With_Zero_Duration_Is_Completed_On_First_Tick() { - var clock = new MockGlobalClock(); + var clock = new TestClock(); - using (UnitTestApplication.Start(new TestServices(globalClock: clock))) + int i = 0; + var inst = new TransitionInstance(clock, TimeSpan.Zero, TimeSpan.Zero).Subscribe(nextValue => { - int i = 0; - var inst = new TransitionInstance(clock, TimeSpan.Zero).Subscribe(nextValue => + switch (i++) { - switch (i++) - { - case 0: Assert.Equal(0, nextValue); break; - case 1: Assert.Equal(1d, nextValue); break; - } - }); + case 0: Assert.Equal(0, nextValue); break; + case 1: Assert.Equal(1d, nextValue); break; + } + }); + + clock.Pulse(TimeSpan.FromMilliseconds(10)); + } + + [Fact] + public void TransitionInstance_Properly_Calculates_Delay_And_Duration_Values() + { + var clock = new TestClock(); + + int i = -1; + var inst = new TransitionInstance(clock, TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(70)).Subscribe(nextValue => + { + switch (i++) + { + case 0: Assert.Equal(0, nextValue); break; + case 1: Assert.Equal(0, nextValue); break; + case 2: Assert.Equal(0, nextValue); break; + case 3: Assert.Equal(0, nextValue); break; + case 4: Assert.Equal(Math.Round(10d / 70d, 4), Math.Round(nextValue, 4)); break; + case 5: Assert.Equal(Math.Round(20d / 70d, 4), Math.Round(nextValue, 4)); break; + case 6: Assert.Equal(Math.Round(30d / 70d, 4), Math.Round(nextValue, 4)); break; + case 7: Assert.Equal(Math.Round(40d / 70d, 4), Math.Round(nextValue, 4)); break; + case 8: Assert.Equal(Math.Round(50d / 70d, 4), Math.Round(nextValue, 4)); break; + case 9: Assert.Equal(Math.Round(60d / 70d, 4), Math.Round(nextValue, 4)); break; + case 10: Assert.Equal(1d, nextValue); break; + } + }); + + for (int z = 0; z <= 10; z++) + { clock.Pulse(TimeSpan.FromMilliseconds(10)); } } 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 10/55] 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 7b1a9fbb84e14124a67d54748571344f6db74bc4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 4 Jun 2020 15:26:10 +0200 Subject: [PATCH 11/55] Fix caret not displaying when focused via Tab key. Also set caret position to end when selecting all, as textboxes when selected should scroll to display the end of the text. --- src/Avalonia.Controls/TextBox.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index 42e16e6979..c2f496287c 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -347,7 +347,7 @@ namespace Avalonia.Controls if (IsFocused) { - DecideCaretVisibility(); + _presenter?.ShowCaret(); } } @@ -364,14 +364,7 @@ namespace Avalonia.Controls { SelectAll(); } - else - { - DecideCaretVisibility(); - } - } - private void DecideCaretVisibility() - { _presenter?.ShowCaret(); } @@ -975,6 +968,7 @@ namespace Avalonia.Controls { SelectionStart = 0; SelectionEnd = Text?.Length ?? 0; + CaretIndex = SelectionEnd; } private bool DeleteSelection() From c2ad34a4ce072ead90aa23323b76544a6fd91a9b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 4 Jun 2020 17:14:43 +0200 Subject: [PATCH 12/55] Fix moving caret left/right when selection present. When a selection is present, pressing the left key should move the caret to the start of the selection and pressing the right key should move the caret to the end of the selection. --- src/Avalonia.Controls/TextBox.cs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index c2f496287c..394699ce64 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -573,15 +573,15 @@ namespace Avalonia.Controls switch (e.Key) { case Key.Left: - MoveHorizontal(-1, hasWholeWordModifiers); - movement = true; selection = DetectSelection(); + MoveHorizontal(-1, hasWholeWordModifiers, selection); + movement = true; break; case Key.Right: - MoveHorizontal(1, hasWholeWordModifiers); - movement = true; selection = DetectSelection(); + MoveHorizontal(1, hasWholeWordModifiers, selection); + movement = true; break; case Key.Up: @@ -826,13 +826,21 @@ namespace Avalonia.Controls return result; } - private void MoveHorizontal(int direction, bool wholeWord) + private void MoveHorizontal(int direction, bool wholeWord, bool isSelecting) { var text = Text ?? string.Empty; var caretIndex = CaretIndex; if (!wholeWord) { + if (SelectionStart != SelectionEnd && !isSelecting) + { + var start = Math.Min(SelectionStart, SelectionEnd); + var end = Math.Max(SelectionStart, SelectionEnd); + CaretIndex = direction < 0 ? start : end; + return; + } + var index = caretIndex + direction; if (index < 0 || index > text.Length) @@ -1049,14 +1057,14 @@ namespace Avalonia.Controls private void SetSelectionForControlBackspace() { SelectionStart = CaretIndex; - MoveHorizontal(-1, true); + MoveHorizontal(-1, true, false); SelectionEnd = CaretIndex; } private void SetSelectionForControlDelete() { SelectionStart = CaretIndex; - MoveHorizontal(1, true); + MoveHorizontal(1, true, false); SelectionEnd = CaretIndex; } From e20930793187c95d2e5392484e0782a494e758fd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 5 Jun 2020 13:54:09 +0200 Subject: [PATCH 13/55] Added nullable annotations. --- src/Avalonia.Layout/ILayoutManager.cs | 3 +++ src/Avalonia.Layout/ILayoutable.cs | 2 ++ src/Avalonia.Layout/LayoutManager.cs | 10 ++++++---- src/Avalonia.Layout/Layoutable.cs | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Avalonia.Layout/ILayoutManager.cs b/src/Avalonia.Layout/ILayoutManager.cs index c3675c18a2..16814f2e1f 100644 --- a/src/Avalonia.Layout/ILayoutManager.cs +++ b/src/Avalonia.Layout/ILayoutManager.cs @@ -1,3 +1,6 @@ +using System; + +#nullable enable namespace Avalonia.Layout { diff --git a/src/Avalonia.Layout/ILayoutable.cs b/src/Avalonia.Layout/ILayoutable.cs index 5c785613a9..316a017f1d 100644 --- a/src/Avalonia.Layout/ILayoutable.cs +++ b/src/Avalonia.Layout/ILayoutable.cs @@ -1,5 +1,7 @@ using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Layout { /// diff --git a/src/Avalonia.Layout/LayoutManager.cs b/src/Avalonia.Layout/LayoutManager.cs index e8cb937997..92506fee9a 100644 --- a/src/Avalonia.Layout/LayoutManager.cs +++ b/src/Avalonia.Layout/LayoutManager.cs @@ -3,6 +3,8 @@ using System.Diagnostics; using Avalonia.Logging; using Avalonia.Threading; +#nullable enable + namespace Avalonia.Layout { /// @@ -24,7 +26,7 @@ namespace Avalonia.Layout /// public void InvalidateMeasure(ILayoutable control) { - Contract.Requires(control != null); + control = control ?? throw new ArgumentNullException(nameof(control)); Dispatcher.UIThread.VerifyAccess(); if (!control.IsAttachedToVisualTree) @@ -45,7 +47,7 @@ namespace Avalonia.Layout /// public void InvalidateArrange(ILayoutable control) { - Contract.Requires(control != null); + control = control ?? throw new ArgumentNullException(nameof(control)); Dispatcher.UIThread.VerifyAccess(); if (!control.IsAttachedToVisualTree) @@ -73,7 +75,7 @@ namespace Avalonia.Layout { _running = true; - Stopwatch stopwatch = null; + Stopwatch? stopwatch = null; const LogEventLevel timingLogLevel = LogEventLevel.Information; bool captureTiming = Logger.IsEnabled(timingLogLevel); @@ -117,7 +119,7 @@ namespace Avalonia.Layout if (captureTiming) { - stopwatch.Stop(); + stopwatch!.Stop(); Logger.TryGet(timingLogLevel)?.Log(LogArea.Layout, this, "Layout pass finished in {Time}", stopwatch.Elapsed); } diff --git a/src/Avalonia.Layout/Layoutable.cs b/src/Avalonia.Layout/Layoutable.cs index ce5200f4a4..3b13647681 100644 --- a/src/Avalonia.Layout/Layoutable.cs +++ b/src/Avalonia.Layout/Layoutable.cs @@ -1,8 +1,9 @@ using System; using Avalonia.Logging; -using Avalonia.Utilities; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Layout { /// @@ -153,7 +154,7 @@ namespace Avalonia.Layout /// /// Occurs when a layout pass completes for the control. /// - public event EventHandler LayoutUpdated; + public event EventHandler? LayoutUpdated; /// /// Gets or sets the width of the element. From d46265233dbd0ee77020e9ef6ed8d27f8f2e75b5 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 5 Jun 2020 14:29:20 +0200 Subject: [PATCH 14/55] Raise LayoutUpdated at the end of layout pass. This attempts to match the behavior of `LayoutUpdated` in WPF and UWP. --- build/Moq.props | 2 +- src/Avalonia.Layout/ILayoutManager.cs | 5 + src/Avalonia.Layout/LayoutManager.cs | 3 + src/Avalonia.Layout/Layoutable.cs | 54 +++++++- .../Avalonia.Controls.UnitTests/GridTests.cs | 18 ++- .../LayoutableTests.cs | 121 ++++++++++++++++++ 6 files changed, 192 insertions(+), 11 deletions(-) diff --git a/build/Moq.props b/build/Moq.props index 7de9b6b6ba..9e2fd1db5d 100644 --- a/build/Moq.props +++ b/build/Moq.props @@ -1,5 +1,5 @@  - + diff --git a/src/Avalonia.Layout/ILayoutManager.cs b/src/Avalonia.Layout/ILayoutManager.cs index 16814f2e1f..6e63d3edbb 100644 --- a/src/Avalonia.Layout/ILayoutManager.cs +++ b/src/Avalonia.Layout/ILayoutManager.cs @@ -9,6 +9,11 @@ namespace Avalonia.Layout /// public interface ILayoutManager { + /// + /// Raised when the layout manager completes a layout pass. + /// + event EventHandler LayoutUpdated; + /// /// Notifies the layout manager that a control requires a measure. /// diff --git a/src/Avalonia.Layout/LayoutManager.cs b/src/Avalonia.Layout/LayoutManager.cs index 92506fee9a..908758045a 100644 --- a/src/Avalonia.Layout/LayoutManager.cs +++ b/src/Avalonia.Layout/LayoutManager.cs @@ -23,6 +23,8 @@ namespace Avalonia.Layout _executeLayoutPass = ExecuteLayoutPass; } + public event EventHandler? LayoutUpdated; + /// public void InvalidateMeasure(ILayoutable control) { @@ -126,6 +128,7 @@ namespace Avalonia.Layout } _queued = false; + LayoutUpdated?.Invoke(this, EventArgs.Empty); } /// diff --git a/src/Avalonia.Layout/Layoutable.cs b/src/Avalonia.Layout/Layoutable.cs index 3b13647681..513d3d540e 100644 --- a/src/Avalonia.Layout/Layoutable.cs +++ b/src/Avalonia.Layout/Layoutable.cs @@ -132,6 +132,7 @@ namespace Avalonia.Layout private bool _measuring; private Size? _previousMeasure; private Rect? _previousArrange; + private EventHandler? _layoutUpdated; /// /// Initializes static members of the class. @@ -154,7 +155,28 @@ namespace Avalonia.Layout /// /// Occurs when a layout pass completes for the control. /// - public event EventHandler? LayoutUpdated; + public event EventHandler? LayoutUpdated + { + add + { + if (_layoutUpdated is null && VisualRoot is ILayoutRoot r) + { + r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated; + } + + _layoutUpdated += value; + } + + remove + { + _layoutUpdated -= value; + + if (_layoutUpdated is null && VisualRoot is ILayoutRoot r) + { + r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated; + } + } + } /// /// Gets or sets the width of the element. @@ -359,12 +381,9 @@ namespace Avalonia.Layout IsArrangeValid = true; ArrangeCore(rect); _previousArrange = rect; - - LayoutUpdated?.Invoke(this, EventArgs.Empty); } } - /// /// Called by InvalidateMeasure /// @@ -694,6 +713,26 @@ namespace Avalonia.Layout InvalidateMeasure(); } + protected override void OnAttachedToVisualTreeCore(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTreeCore(e); + + if (_layoutUpdated is object && e.Root is ILayoutRoot r) + { + r.LayoutManager.LayoutUpdated += LayoutManagedLayoutUpdated; + } + } + + protected override void OnDetachedFromVisualTreeCore(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTreeCore(e); + + if (_layoutUpdated is object && e.Root is ILayoutRoot r) + { + r.LayoutManager.LayoutUpdated -= LayoutManagedLayoutUpdated; + } + } + /// protected sealed override void OnVisualParentChanged(IVisual oldParent, IVisual newParent) { @@ -702,6 +741,13 @@ namespace Avalonia.Layout base.OnVisualParentChanged(oldParent, newParent); } + /// + /// Called when the layout manager raises a LayoutUpdated event. + /// + /// The sender. + /// The event args. + private void LayoutManagedLayoutUpdated(object sender, EventArgs e) => _layoutUpdated?.Invoke(this, e); + /// /// Tests whether any of a 's properties include negative values, /// a NaN or Infinity. diff --git a/tests/Avalonia.Controls.UnitTests/GridTests.cs b/tests/Avalonia.Controls.UnitTests/GridTests.cs index 353bb9c98d..b3882c534b 100644 --- a/tests/Avalonia.Controls.UnitTests/GridTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Avalonia.UnitTests; using Xunit; using Xunit.Abstractions; @@ -1182,13 +1183,18 @@ namespace Avalonia.Controls.UnitTests foreach (var xgrids in grids) scope.Children.Add(xgrids); - var root = new Grid(); - root.UseLayoutRounding = false; - root.SetValue(Grid.IsSharedSizeScopeProperty, true); - root.Children.Add(scope); + var rootGrid = new Grid(); + rootGrid.UseLayoutRounding = false; + rootGrid.SetValue(Grid.IsSharedSizeScopeProperty, true); + rootGrid.Children.Add(scope); - root.Measure(new Size(50, 50)); - root.Arrange(new Rect(new Point(), new Point(50, 50))); + var root = new TestRoot(rootGrid) + { + Width = 50, + Height = 50, + }; + + root.LayoutManager.ExecuteInitialLayoutPass(root); PrintColumnDefinitions(grids[0]); Assert.Equal(5, grids[0].ColumnDefinitions[0].ActualWidth); diff --git a/tests/Avalonia.Layout.UnitTests/LayoutableTests.cs b/tests/Avalonia.Layout.UnitTests/LayoutableTests.cs index a1c1e62f58..7013e93a43 100644 --- a/tests/Avalonia.Layout.UnitTests/LayoutableTests.cs +++ b/tests/Avalonia.Layout.UnitTests/LayoutableTests.cs @@ -203,6 +203,127 @@ namespace Avalonia.Layout.UnitTests Assert.Equal(new Rect(expectedX, 0, childWidth, 100), target.Bounds); } + [Fact] + public void LayoutUpdated_Is_Called_At_End_Of_Layout_Pass() + { + Border border1; + Border border2; + var layoutManager = new LayoutManager(); + var root = new TestRoot + { + Child = border1 = new Border + { + Child = border2 = new Border + { + } + }, + LayoutManager = layoutManager, + }; + var raised = 0; + + void ValidateBounds(object sender, EventArgs e) + { + Assert.Equal(new Rect(0, 0, 100, 100), border1.Bounds); + Assert.Equal(new Rect(0, 0, 100, 100), border2.Bounds); + ++raised; + } + + root.LayoutUpdated += ValidateBounds; + border1.LayoutUpdated += ValidateBounds; + border2.LayoutUpdated += ValidateBounds; + + root.Measure(new Size(100, 100)); + root.Arrange(new Rect(0, 0, 100, 100)); + + layoutManager.ExecuteLayoutPass(); + + Assert.Equal(3, raised); + Assert.Equal(new Rect(0, 0, 100, 100), border1.Bounds); + Assert.Equal(new Rect(0, 0, 100, 100), border2.Bounds); + } + + [Fact] + public void LayoutUpdated_Subscribes_To_LayoutManager() + { + Border target; + var layoutManager = new Mock(); + layoutManager.SetupAdd(m => m.LayoutUpdated += (sender, args) => { }); + + var root = new TestRoot + { + Child = new Border + { + Child = target = new Border(), + }, + LayoutManager = layoutManager.Object, + }; + + void Handler(object sender, EventArgs e) {} + + layoutManager.Invocations.Clear(); + target.LayoutUpdated += Handler; + + layoutManager.VerifyAdd( + x => x.LayoutUpdated += It.IsAny(), + Times.Once); + + layoutManager.Invocations.Clear(); + target.LayoutUpdated -= Handler; + + layoutManager.VerifyRemove( + x => x.LayoutUpdated -= It.IsAny(), + Times.Once); + } + + [Fact] + public void LayoutManager_LayoutUpdated_Is_Subscribed_When_Attached_To_Tree() + { + Border border1; + var layoutManager = new Mock(); + layoutManager.SetupAdd(m => m.LayoutUpdated += (sender, args) => { }); + + var root = new TestRoot + { + Child = border1 = new Border(), + LayoutManager = layoutManager.Object, + }; + + var border2 = new Border(); + border2.LayoutUpdated += (s, e) => { }; + + layoutManager.Invocations.Clear(); + border1.Child = border2; + + layoutManager.VerifyAdd( + x => x.LayoutUpdated += It.IsAny(), + Times.Once); + } + + [Fact] + public void LayoutManager_LayoutUpdated_Is_Unsubscribed_When_Detached_From_Tree() + { + Border border1; + var layoutManager = new Mock(); + layoutManager.SetupAdd(m => m.LayoutUpdated += (sender, args) => { }); + + var root = new TestRoot + { + Child = border1 = new Border(), + LayoutManager = layoutManager.Object, + }; + + var border2 = new Border(); + border2.LayoutUpdated += (s, e) => { }; + border1.Child = border2; + + layoutManager.Invocations.Clear(); + border1.Child = null; + + layoutManager.VerifyRemove( + x => x.LayoutUpdated -= It.IsAny(), + Times.Once); + } + private class TestLayoutable : Layoutable { public Size ArrangeSize { get; private set; } From c3a5c48d6fcc11903339c95597b9137c7a40b69f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 5 Jun 2020 14:52:50 +0200 Subject: [PATCH 15/55] Raise ScrollChanged when layout updated. To be consistent with WPF/UWP `ScrollChanged` should be raised when layout finishes updating, rather than when the individual properties are changed. --- src/Avalonia.Controls/ScrollViewer.cs | 65 +++++++++++-------- .../ScrollViewerTests.cs | 25 ++++++- 2 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/Avalonia.Controls/ScrollViewer.cs b/src/Avalonia.Controls/ScrollViewer.cs index c3f0dc0056..058c11c9ee 100644 --- a/src/Avalonia.Controls/ScrollViewer.cs +++ b/src/Avalonia.Controls/ScrollViewer.cs @@ -181,6 +181,9 @@ namespace Avalonia.Controls private Size _extent; private Vector _offset; private Size _viewport; + private Size _oldExtent; + private Vector _oldOffset; + private Size _oldViewport; private Size _largeChange; private Size _smallChange = new Size(DefaultSmallChange, DefaultSmallChange); @@ -198,6 +201,7 @@ namespace Avalonia.Controls /// public ScrollViewer() { + LayoutUpdated += OnLayoutUpdated; } /// @@ -221,11 +225,9 @@ namespace Avalonia.Controls private set { - var old = _extent; - if (SetAndRaise(ExtentProperty, ref _extent, value)) { - CalculatedPropertiesChanged(extentDelta: value - old); + CalculatedPropertiesChanged(); } } } @@ -242,13 +244,11 @@ namespace Avalonia.Controls set { - var old = _offset; - value = ValidateOffset(this, value); if (SetAndRaise(OffsetProperty, ref _offset, value)) { - CalculatedPropertiesChanged(offsetDelta: value - old); + CalculatedPropertiesChanged(); } } } @@ -265,11 +265,9 @@ namespace Avalonia.Controls private set { - var old = _viewport; - if (SetAndRaise(ViewportProperty, ref _viewport, value)) { - CalculatedPropertiesChanged(viewportDelta: value - old); + CalculatedPropertiesChanged(); } } } @@ -549,10 +547,7 @@ namespace Avalonia.Controls } } - private void CalculatedPropertiesChanged( - Size extentDelta = default, - Vector offsetDelta = default, - Size viewportDelta = default) + private void CalculatedPropertiesChanged() { // Pass old values of 0 here because we don't have the old values at this point, // and it shouldn't matter as only the template uses these properies. @@ -573,20 +568,6 @@ namespace Avalonia.Controls SetAndRaise(SmallChangeProperty, ref _smallChange, new Size(DefaultSmallChange, DefaultSmallChange)); SetAndRaise(LargeChangeProperty, ref _largeChange, Viewport); } - - if (extentDelta != default || offsetDelta != default || viewportDelta != default) - { - using var route = BuildEventRoute(ScrollChangedEvent); - - if (route.HasHandlers) - { - var e = new ScrollChangedEventArgs( - new Vector(extentDelta.Width, extentDelta.Height), - offsetDelta, - new Vector(viewportDelta.Width, viewportDelta.Height)); - route.RaiseEvent(this, e); - } - } } protected override void OnKeyDown(KeyEventArgs e) @@ -602,5 +583,35 @@ namespace Avalonia.Controls e.Handled = true; } } + + /// + /// Called when a change in scrolling state is detected, such as a change in scroll + /// position, extent, or viewport size. + /// + /// The event args. + /// + /// If you override this method, call `base.OnScrollChanged(ScrollChangedEventArgs)` to + /// ensure that this event is raised. + /// + protected virtual void OnScrollChanged(ScrollChangedEventArgs e) + { + RaiseEvent(e); + } + + private void OnLayoutUpdated(object sender, EventArgs e) => RaiseScrollChanged(); + + private void RaiseScrollChanged() + { + var e = new ScrollChangedEventArgs( + new Vector(Extent.Width - _oldExtent.Width, Extent.Height - _oldExtent.Height), + Offset - _oldOffset, + new Vector(Viewport.Width - _oldViewport.Width, Viewport.Height - _oldViewport.Height)); + + OnScrollChanged(e); + + _oldExtent = Extent; + _oldOffset = Offset; + _oldViewport = Viewport; + } } } diff --git a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs index 8da1e26f0d..deca3cfb75 100644 --- a/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ScrollViewerTests.cs @@ -4,6 +4,7 @@ using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Layout; +using Avalonia.UnitTests; using Moq; using Xunit; @@ -150,12 +151,15 @@ namespace Avalonia.Controls.UnitTests public void Changing_Extent_Should_Raise_ScrollChanged() { var target = new ScrollViewer(); + var root = new TestRoot(target); var raised = 0; target.SetValue(ScrollViewer.ExtentProperty, new Size(100, 100)); target.SetValue(ScrollViewer.ViewportProperty, new Size(50, 50)); target.Offset = new Vector(10, 10); + root.LayoutManager.ExecuteInitialLayoutPass(root); + target.ScrollChanged += (s, e) => { Assert.Equal(new Vector(11, 12), e.ExtentDelta); @@ -166,20 +170,26 @@ namespace Avalonia.Controls.UnitTests target.SetValue(ScrollViewer.ExtentProperty, new Size(111, 112)); - Assert.Equal(1, raised); + Assert.Equal(0, raised); + + root.LayoutManager.ExecuteLayoutPass(); + Assert.Equal(1, raised); } [Fact] public void Changing_Offset_Should_Raise_ScrollChanged() { var target = new ScrollViewer(); + var root = new TestRoot(target); var raised = 0; target.SetValue(ScrollViewer.ExtentProperty, new Size(100, 100)); target.SetValue(ScrollViewer.ViewportProperty, new Size(50, 50)); target.Offset = new Vector(10, 10); + root.LayoutManager.ExecuteInitialLayoutPass(root); + target.ScrollChanged += (s, e) => { Assert.Equal(default, e.ExtentDelta); @@ -190,20 +200,26 @@ namespace Avalonia.Controls.UnitTests target.Offset = new Vector(22, 24); - Assert.Equal(1, raised); + Assert.Equal(0, raised); + root.LayoutManager.ExecuteLayoutPass(); + + Assert.Equal(1, raised); } [Fact] public void Changing_Viewport_Should_Raise_ScrollChanged() { var target = new ScrollViewer(); + var root = new TestRoot(target); var raised = 0; target.SetValue(ScrollViewer.ExtentProperty, new Size(100, 100)); target.SetValue(ScrollViewer.ViewportProperty, new Size(50, 50)); target.Offset = new Vector(10, 10); + root.LayoutManager.ExecuteInitialLayoutPass(root); + target.ScrollChanged += (s, e) => { Assert.Equal(default, e.ExtentDelta); @@ -214,8 +230,11 @@ namespace Avalonia.Controls.UnitTests target.SetValue(ScrollViewer.ViewportProperty, new Size(56, 58)); - Assert.Equal(1, raised); + Assert.Equal(0, raised); + + root.LayoutManager.ExecuteLayoutPass(); + Assert.Equal(1, raised); } private Control CreateTemplate(ScrollViewer control, INameScope scope) From b51cc92ba56a2c111efa588e15c25a9fbc602ba5 Mon Sep 17 00:00:00 2001 From: Dan Walmsley Date: Fri, 5 Jun 2020 12:36:34 -0300 Subject: [PATCH 16/55] Add rounded rect clip to drawing context. --- src/Avalonia.Visuals/Media/DrawingContext.cs | 6 ++++++ .../Platform/IDrawingContextImpl.cs | 6 ++++++ .../Rendering/SceneGraph/IVisualNode.cs | 5 +++++ .../Rendering/SceneGraph/VisualNode.cs | 3 +++ src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 6 ++++++ src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs | 15 +++++++++++++++ 6 files changed, 41 insertions(+) diff --git a/src/Avalonia.Visuals/Media/DrawingContext.cs b/src/Avalonia.Visuals/Media/DrawingContext.cs index 6fdcd9631b..190675fe48 100644 --- a/src/Avalonia.Visuals/Media/DrawingContext.cs +++ b/src/Avalonia.Visuals/Media/DrawingContext.cs @@ -282,6 +282,12 @@ namespace Avalonia.Media } + public PushedState PushClip(RoundedRect clip) + { + PlatformImpl.PushClip(clip); + return new PushedState(this, PushedState.PushedStateType.Clip); + } + /// /// Pushes a clip rectangle. /// diff --git a/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs b/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs index 660d10c088..c87946b3ea 100644 --- a/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs +++ b/src/Avalonia.Visuals/Platform/IDrawingContextImpl.cs @@ -107,6 +107,12 @@ namespace Avalonia.Platform /// The clip rectangle. void PushClip(Rect clip); + /// + /// Pushes a clip rounded rectangle. + /// + /// The clip rounded rectangle + void PushClip(RoundedRect clip); + /// /// Pops the latest pushed clip rectangle. /// diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/IVisualNode.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/IVisualNode.cs index 8b77c37c1f..679cf3daa0 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/IVisualNode.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/IVisualNode.cs @@ -26,6 +26,11 @@ namespace Avalonia.Rendering.SceneGraph /// Matrix Transform { get; } + /// + /// Gets the corner radius of visual. Contents are clipped to this radius. + /// + CornerRadius CornerRadius { get; } + /// /// Gets the bounds of the node's geometry in global coordinates. /// diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs index 6f566ff6d6..6276d5c7c3 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs @@ -46,6 +46,9 @@ namespace Avalonia.Rendering.SceneGraph /// public IVisualNode Parent { get; } + /// + public CornerRadius CornerRadius { get; } + /// public Matrix Transform { get; set; } diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 26fdb08a4b..ae756f4eab 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -389,6 +389,12 @@ namespace Avalonia.Skia Canvas.ClipRect(clip.ToSKRect()); } + public void PushClip(RoundedRect clip) + { + Canvas.Save(); + Canvas.ClipRoundRect(clip.ToSKRoundRect()); + } + /// public void PopClip() { diff --git a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs index 6375f74c59..ec7e0a67ed 100644 --- a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs +++ b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs @@ -40,6 +40,21 @@ namespace Avalonia.Skia return new SKRect((float)r.X, (float)r.Y, (float)r.Right, (float)r.Bottom); } + public static SKRoundRect ToSKRoundRect(this RoundedRect r) + { + var rc = r.Rect.ToSKRect(); + var result = new SKRoundRect(); + + result.SetRectRadii(rc, + new[] + { + r.RadiiTopLeft.ToSKPoint(), r.RadiiTopRight.ToSKPoint(), + r.RadiiBottomRight.ToSKPoint(), r.RadiiBottomLeft.ToSKPoint(), + }); + + return result; + } + public static Rect ToAvaloniaRect(this SKRect r) { return new Rect(r.Left, r.Top, r.Right - r.Left, r.Bottom - r.Top); From d4215a56ecfe37b6a827fe355c4cc396e0190c49 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 9 Jun 2020 23:18:15 +0200 Subject: [PATCH 17/55] 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 18/55] 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 19/55] 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 20/55] 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 21/55] 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 22/55] 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 23/55] 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 24/55] 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 25/55] 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. +