diff --git a/.editorconfig b/.editorconfig
index cb589a5ce1..30edee1633 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -21,7 +21,7 @@ csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
-trim_trailing_whitespace = true
+# trim_trailing_whitespace = true
# Indentation preferences
csharp_indent_block_contents = true
diff --git a/.ncrunch/ControlCatalog.v3.ncrunchproject b/.ncrunch/ControlCatalog.net6.0.v3.ncrunchproject
similarity index 100%
rename from .ncrunch/ControlCatalog.v3.ncrunchproject
rename to .ncrunch/ControlCatalog.net6.0.v3.ncrunchproject
diff --git a/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject b/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject
new file mode 100644
index 0000000000..319cd523ce
--- /dev/null
+++ b/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject
@@ -0,0 +1,5 @@
+
+
+ True
+
+
\ No newline at end of file
diff --git a/Avalonia.sln b/Avalonia.sln
index 25c7daf080..35b6b2108a 100644
--- a/Avalonia.sln
+++ b/Avalonia.sln
@@ -13,7 +13,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Direct2D1", "src\W
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls", "src\Avalonia.Controls\Avalonia.Controls.csproj", "{D2221C82-4A25-4583-9B43-D791E3F6820C}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Themes.Default", "src\Avalonia.Themes.Default\Avalonia.Themes.Default.csproj", "{3E10A5FA-E8DA-48B1-AD44-6A5B6CB7750F}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Themes.Simple", "src\Avalonia.Themes.Simple\Avalonia.Themes.Simple.csproj", "{3E10A5FA-E8DA-48B1-AD44-6A5B6CB7750F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Diagnostics", "src\Avalonia.Diagnostics\Avalonia.Diagnostics.csproj", "{7062AE20-5DCC-4442-9645-8195BDECE63E}"
EndProject
@@ -38,6 +38,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{A689DEF5-D50F-4975-8B72-124C9EB54066}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
+ src\Shared\IsExternalInit.cs = src\Shared\IsExternalInit.cs
src\Shared\ModuleInitializer.cs = src\Shared\ModuleInitializer.cs
src\Shared\SourceGeneratorAttributes.cs = src\Shared\SourceGeneratorAttributes.cs
EndProjectSection
@@ -205,14 +206,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlSamples", "samples\S
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlCatalog.iOS", "samples\ControlCatalog.iOS\ControlCatalog.iOS.csproj", "{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.SourceGenerator", "src\Avalonia.SourceGenerator\Avalonia.SourceGenerator.csproj", "{CA932DF3-2616-4BF6-8F28-1AD0EC40F1FF}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DevAnalyzers", "src\tools\DevAnalyzers\DevAnalyzers.csproj", "{2B390431-288C-435C-BB6B-A374033BD8D1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Controls.ColorPicker", "src\Avalonia.Controls.ColorPicker\Avalonia.Controls.ColorPicker.csproj", "{7BF6C69D-FC14-43EB-9ED0-782C16F3D5D9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.DesignerSupport.Tests", "tests\Avalonia.DesignerSupport.Tests\Avalonia.DesignerSupport.Tests.csproj", "{EABE2161-989B-42BF-BD8D-1E34B20C21F1}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevGenerators", "src\tools\DevGenerators\DevGenerators.csproj", "{1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -485,10 +486,6 @@ Global
{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B}.Release|Any CPU.Build.0 = Release|Any CPU
- {CA932DF3-2616-4BF6-8F28-1AD0EC40F1FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CA932DF3-2616-4BF6-8F28-1AD0EC40F1FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {CA932DF3-2616-4BF6-8F28-1AD0EC40F1FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CA932DF3-2616-4BF6-8F28-1AD0EC40F1FF}.Release|Any CPU.Build.0 = Release|Any CPU
{2B390431-288C-435C-BB6B-A374033BD8D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2B390431-288C-435C-BB6B-A374033BD8D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2B390431-288C-435C-BB6B-A374033BD8D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -501,6 +498,10 @@ Global
{EABE2161-989B-42BF-BD8D-1E34B20C21F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EABE2161-989B-42BF-BD8D-1E34B20C21F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EABE2161-989B-42BF-BD8D-1E34B20C21F1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -557,6 +558,8 @@ Global
{70B9F5CC-E2F9-4314-9514-EDE762ACCC4B} = {9B9E3891-2366-4253-A952-D08BCEB71098}
{2B390431-288C-435C-BB6B-A374033BD8D1} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637}
{EABE2161-989B-42BF-BD8D-1E34B20C21F1} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B}
+ {1BBFAD42-B99E-47E0-B00A-A4BC6B6BB4BB} = {4ED8B739-6F4E-4CD4-B993-545E6B5CE637}
+ {4D36CEC8-53F2-40A5-9A37-79AAE356E2DA} = {86C53C40-57AA-45B8-AD42-FAE0EFDF0F2B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A}
diff --git a/azure-pipelines-integrationtests.yml b/azure-pipelines-integrationtests.yml
new file mode 100644
index 0000000000..0b79758c76
--- /dev/null
+++ b/azure-pipelines-integrationtests.yml
@@ -0,0 +1,67 @@
+# Starter pipeline
+# Start with a minimal pipeline that you can customize to build and deploy your code.
+# Add steps that build, run tests, deploy, and more:
+# https://aka.ms/yaml
+
+trigger:
+- master
+
+jobs:
+- job: Mac
+ pool:
+ name: 'AvaloniaMacPool'
+
+ steps:
+ - script: system_profiler SPDisplaysDataType |grep Resolution
+
+ - script: |
+ pkill node
+ appium &
+ pkill IntegrationTestApp
+ ./build.sh CompileNative
+ rm -rf $(osascript -e "POSIX path of (path to application id \"net.avaloniaui.avalonia.integrationtestapp\")")
+ pkill IntegrationTestApp
+ ./samples/IntegrationTestApp/bundle.sh
+ open -n ./samples/IntegrationTestApp/bin/Debug/net6.0/osx-arm64/publish/IntegrationTestApp.app
+ pkill IntegrationTestApp
+
+ - task: DotNetCoreCLI@2
+ inputs:
+ command: 'test'
+ projects: 'tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj'
+
+ - script: |
+ pkill IntegrationTestApp
+ pkill node
+
+
+- job: Windows
+ pool:
+ vmImage: 'windows-2022'
+
+ steps:
+ - task: UseDotNet@2
+ displayName: 'Use .NET Core SDK 6.0.202'
+ inputs:
+ version: 6.0.202
+
+ - task: Windows Application Driver@0
+ inputs:
+ OperationType: 'Start'
+ AgentResolution: '4K'
+ displayName: 'Start WinAppDriver'
+
+ - task: DotNetCoreCLI@2
+ inputs:
+ command: 'build'
+ projects: 'samples/IntegrationTestApp/IntegrationTestApp.csproj'
+
+ - task: DotNetCoreCLI@2
+ inputs:
+ command: 'test'
+ projects: 'tests/Avalonia.IntegrationTests.Appium/Avalonia.IntegrationTests.Appium.csproj'
+
+ - task: Windows Application Driver@0
+ inputs:
+ OperationType: 'Stop'
+ displayName: 'Stop WinAppDriver'
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index edf3c3d819..52fc8db53c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -59,7 +59,7 @@ jobs:
variables:
SolutionDir: '$(Build.SourcesDirectory)'
pool:
- vmImage: 'macOS-10.15'
+ vmImage: 'macos-12'
steps:
- task: UseDotNet@2
displayName: 'Use .NET Core SDK 3.1.418'
@@ -91,10 +91,10 @@ jobs:
inputs:
actions: 'build'
scheme: ''
- sdk: 'macosx11.1'
+ sdk: 'macosx12.3'
configuration: 'Release'
xcWorkspacePath: '**/*.xcodeproj/project.xcworkspace'
- xcodeVersion: '12' # Options: 8, 9, default, specifyPath
+ xcodeVersion: '13' # Options: 8, 9, default, specifyPath
args: '-derivedDataPath ./'
- task: CmdLine@2
diff --git a/build/SourceGenerators.props b/build/SourceGenerators.props
index d000af1bf6..4929578b60 100644
--- a/build/SourceGenerators.props
+++ b/build/SourceGenerators.props
@@ -1,7 +1,7 @@
diff --git a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
index 6fc3977d4e..ace4a71a56 100644
--- a/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
+++ b/native/Avalonia.Native/src/OSX/Avalonia.Native.OSX.xcodeproj/project.pbxproj
@@ -49,6 +49,7 @@
AB8F7D6B21482D7F0057DBA5 /* platformthreading.mm in Sources */ = {isa = PBXBuildFile; fileRef = AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */; };
BC11A5BE2608D58F0017BAD0 /* automation.h in Headers */ = {isa = PBXBuildFile; fileRef = BC11A5BC2608D58F0017BAD0 /* automation.h */; };
BC11A5BF2608D58F0017BAD0 /* automation.mm in Sources */ = {isa = PBXBuildFile; fileRef = BC11A5BD2608D58F0017BAD0 /* automation.mm */; };
+ ED3791C42862E1F40080BD62 /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -101,6 +102,7 @@
AB8F7D6A21482D7F0057DBA5 /* platformthreading.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = platformthreading.mm; sourceTree = ""; };
BC11A5BC2608D58F0017BAD0 /* automation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = automation.h; sourceTree = ""; };
BC11A5BD2608D58F0017BAD0 /* automation.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = automation.mm; sourceTree = ""; };
+ ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -108,6 +110,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
+ ED3791C42862E1F40080BD62 /* UniformTypeIdentifiers.framework in Frameworks */,
1A3E5EB023E9FE8300EDE661 /* QuartzCore.framework in Frameworks */,
1A3E5EAA23E9F26C00EDE661 /* IOSurface.framework in Frameworks */,
AB1E522C217613570091CD71 /* OpenGL.framework in Frameworks */,
@@ -122,6 +125,7 @@
AB661C1C2148230E00291242 /* Frameworks */ = {
isa = PBXGroup;
children = (
+ ED3791C32862E1F40080BD62 /* UniformTypeIdentifiers.framework */,
522D5958258159C1006F7F7A /* Carbon.framework */,
1A3E5EAF23E9FE8300EDE661 /* QuartzCore.framework */,
1A3E5EA923E9F26C00EDE661 /* IOSurface.framework */,
diff --git a/native/Avalonia.Native/src/OSX/AvnView.mm b/native/Avalonia.Native/src/OSX/AvnView.mm
index 01725ace03..4ae6ad5a00 100644
--- a/native/Avalonia.Native/src/OSX/AvnView.mm
+++ b/native/Avalonia.Native/src/OSX/AvnView.mm
@@ -127,7 +127,11 @@
[self updateRenderTarget];
auto reason = [self inLiveResize] ? ResizeUser : _resizeReason;
- _parent->BaseEvents->Resized(AvnSize{newSize.width, newSize.height}, reason);
+
+ if(_parent->IsShown())
+ {
+ _parent->BaseEvents->Resized(AvnSize{newSize.width, newSize.height}, reason);
+ }
}
}
diff --git a/native/Avalonia.Native/src/OSX/SystemDialogs.mm b/native/Avalonia.Native/src/OSX/SystemDialogs.mm
index 535b6c3b66..e133a5d31f 100644
--- a/native/Avalonia.Native/src/OSX/SystemDialogs.mm
+++ b/native/Avalonia.Native/src/OSX/SystemDialogs.mm
@@ -1,5 +1,6 @@
#include "common.h"
#include "INSWindowHolder.h"
+#import
class SystemDialogs : public ComSingleObject
{
@@ -7,6 +8,7 @@ public:
FORWARD_IUNKNOWN()
virtual void SelectFolderDialog (IAvnWindow* parentWindowHandle,
IAvnSystemDialogEvents* events,
+ bool allowMultiple,
const char* title,
const char* initialDirectory) override
{
@@ -14,6 +16,7 @@ public:
{
auto panel = [NSOpenPanel openPanel];
+ panel.allowsMultipleSelection = allowMultiple;
panel.canChooseDirectories = true;
panel.canCreateDirectories = true;
panel.canChooseFiles = false;
@@ -118,7 +121,15 @@ public:
{
auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
- panel.allowedFileTypes = allowedTypes;
+ // Prefer allowedContentTypes if available
+ if (@available(macOS 11.0, *))
+ {
+ panel.allowedContentTypes = ConvertToUTType(allowedTypes);
+ }
+ else
+ {
+ panel.allowedFileTypes = allowedTypes;
+ }
}
}
@@ -207,7 +218,18 @@ public:
{
auto allowedTypes = [filtersString componentsSeparatedByString:@";"];
- panel.allowedFileTypes = allowedTypes;
+ // Prefer allowedContentTypes if available
+ if (@available(macOS 11.0, *))
+ {
+ panel.allowedContentTypes = ConvertToUTType(allowedTypes);
+ }
+ else
+ {
+ panel.allowedFileTypes = allowedTypes;
+ }
+
+ panel.allowsOtherFileTypes = false;
+ panel.extensionHidden = false;
}
}
@@ -250,6 +272,32 @@ public:
}
}
}
+
+private:
+ NSMutableArray* ConvertToUTType(NSArray* allowedTypes)
+ {
+ auto originalCount = [allowedTypes count];
+ auto mapped = [[NSMutableArray alloc] init];
+
+ if (@available(macOS 11.0, *))
+ {
+ for (int i = 0; i < originalCount; i++)
+ {
+ auto utTypeStr = allowedTypes[i];
+ auto utType = [UTType typeWithIdentifier:utTypeStr];
+ if (utType == nil)
+ {
+ utType = [UTType typeWithMIMEType:utTypeStr];
+ }
+ if (utType != nil)
+ {
+ [mapped addObject:utType];
+ }
+ }
+ }
+
+ return mapped;
+ }
};
diff --git a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
index 45d78b3926..77f53332cd 100644
--- a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
+++ b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
@@ -48,7 +48,6 @@ WindowBaseImpl::WindowBaseImpl(IAvnWindowBaseEvents *events, IAvnGlContext *gl,
[Window setContentMaxSize:lastMaxSize];
[Window setOpaque:false];
- [Window setHasShadow:true];
}
HRESULT WindowBaseImpl::ObtainNSViewHandle(void **ret) {
diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.mm b/native/Avalonia.Native/src/OSX/WindowImpl.mm
index 85a89955f4..95f61422cb 100644
--- a/native/Avalonia.Native/src/OSX/WindowImpl.mm
+++ b/native/Avalonia.Native/src/OSX/WindowImpl.mm
@@ -24,6 +24,8 @@ WindowImpl::WindowImpl(IAvnWindowEvents *events, IAvnGlContext *gl) : WindowBase
_lastTitle = @"";
_parent = nullptr;
WindowEvents = events;
+
+ [Window setHasShadow:true];
OnInitialiseNSWindow();
}
diff --git a/native/Avalonia.Native/src/OSX/rendertarget.mm b/native/Avalonia.Native/src/OSX/rendertarget.mm
index 266d0345d1..2075cc85ab 100644
--- a/native/Avalonia.Native/src/OSX/rendertarget.mm
+++ b/native/Avalonia.Native/src/OSX/rendertarget.mm
@@ -13,6 +13,7 @@
{
@public IOSurfaceRef surface;
@public AvnPixelSize size;
+ @public bool hasContent;
@public float scale;
ComPtr _context;
GLuint _framebuffer, _texture, _renderbuffer;
@@ -41,6 +42,7 @@
self->scale = scale;
self->size = size;
self->_context = context;
+ self->hasContent = false;
return self;
}
@@ -92,6 +94,7 @@
_context->MakeCurrent(release.getPPV());
glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, 0);
glFlush();
+ self->hasContent = true;
}
-(void) dealloc
@@ -170,6 +173,8 @@ static IAvnGlSurfaceRenderTarget* CreateGlRenderTarget(IOSurfaceRenderTarget* ta
@synchronized (lock) {
if(_layer == nil)
return;
+ if(!surface->hasContent)
+ return;
[CATransaction begin];
[_layer setContents: nil];
if(surface != nil)
@@ -213,6 +218,7 @@ static IAvnGlSurfaceRenderTarget* CreateGlRenderTarget(IOSurfaceRenderTarget* ta
memcpy(pSurface + y*sstride, pFb + y*fstride, wbytes);
}
IOSurfaceUnlock(surf, 0, nil);
+ surface->hasContent = true;
[self updateLayer];
return S_OK;
}
diff --git a/samples/BindingDemo/App.xaml b/samples/BindingDemo/App.xaml
index 9260dd280f..175e838616 100644
--- a/samples/BindingDemo/App.xaml
+++ b/samples/BindingDemo/App.xaml
@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="BindingDemo.App">
-
-
+
+
-
\ No newline at end of file
+
diff --git a/samples/BindingDemo/BindingDemo.csproj b/samples/BindingDemo/BindingDemo.csproj
index bd6054327f..056d3bf552 100644
--- a/samples/BindingDemo/BindingDemo.csproj
+++ b/samples/BindingDemo/BindingDemo.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
index e52430f50b..a43ea4539a 100644
--- a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
+++ b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
@@ -9,7 +9,6 @@
1.0
apk
true
- android-arm64;android-x64
@@ -21,12 +20,12 @@
True
-
+
True
diff --git a/samples/ControlCatalog.NetCore/NativeControls/Gtk/EmbedSample.Gtk.cs b/samples/ControlCatalog.NetCore/NativeControls/Gtk/EmbedSample.Gtk.cs
index 521d3674eb..81a5ba536f 100644
--- a/samples/ControlCatalog.NetCore/NativeControls/Gtk/EmbedSample.Gtk.cs
+++ b/samples/ControlCatalog.NetCore/NativeControls/Gtk/EmbedSample.Gtk.cs
@@ -22,8 +22,7 @@ public class EmbedSampleGtk : INativeDemoControl
var control = createDefault();
var nodes = Path.GetFullPath(Path.Combine(typeof(EmbedSample).Assembly.GetModules()[0].FullyQualifiedName,
- "..",
- "nodes.mp4"));
+ "..", "NativeControls", "Gtk", "nodes.mp4"));
_mplayer = Process.Start(new ProcessStartInfo("mplayer",
$"-vo x11 -zoom -loop 0 -wid {control.Handle.ToInt64()} \"{nodes}\"")
{
diff --git a/samples/ControlCatalog.NetCore/NativeControls/Gtk/GtkHelper.cs b/samples/ControlCatalog.NetCore/NativeControls/Gtk/GtkHelper.cs
index 456f77a44d..b1fef7c013 100644
--- a/samples/ControlCatalog.NetCore/NativeControls/Gtk/GtkHelper.cs
+++ b/samples/ControlCatalog.NetCore/NativeControls/Gtk/GtkHelper.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading.Tasks;
using Avalonia.Controls.Platform;
using Avalonia.Platform.Interop;
+using Avalonia.X11.Interop;
using Avalonia.X11.NativeDialogs;
using static Avalonia.X11.NativeDialogs.Gtk;
using static Avalonia.X11.NativeDialogs.Glib;
@@ -10,8 +11,6 @@ namespace ControlCatalog.NetCore;
internal class GtkHelper
{
- private static Task s_gtkTask;
-
class FileChooser : INativeControlHostDestroyableControlHandle
{
private readonly IntPtr _widget;
@@ -38,11 +37,7 @@ internal class GtkHelper
public static INativeControlHostDestroyableControlHandle CreateGtkFileChooser(IntPtr parentXid)
{
- if (s_gtkTask == null)
- s_gtkTask = StartGtk();
- if (!s_gtkTask.Result)
- return null;
- return RunOnGlibThread(() =>
+ return GtkInteropHelper.RunOnGlibThread(() =>
{
using (var title = new Utf8Buffer("Embedded"))
{
diff --git a/samples/ControlCatalog.NetCore/Program.cs b/samples/ControlCatalog.NetCore/Program.cs
index fd080cfc5b..d98a068d84 100644
--- a/samples/ControlCatalog.NetCore/Program.cs
+++ b/samples/ControlCatalog.NetCore/Program.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
@@ -53,7 +53,11 @@ namespace ControlCatalog.NetCore
else if (args.Contains("--full-headless"))
{
return builder
- .UseHeadless(true)
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions
+ {
+ UseHeadlessDrawing = true,
+ UseCompositor = true
+ })
.AfterSetup(_ =>
{
DispatcherTimer.RunOnce(async () =>
@@ -63,12 +67,11 @@ namespace ControlCatalog.NetCore
var tc = window.GetLogicalDescendants().OfType().First();
foreach (var page in tc.Items.Cast().ToList())
{
- // Skip DatePicker because of some layout bug in grid
- if (page.Header.ToString() == "DatePicker")
+ if (page.Header.ToString() == "DatePicker" || page.Header.ToString() == "TreeView")
continue;
Console.WriteLine("Selecting " + page.Header);
tc.SelectedItem = page;
- await Task.Delay(500);
+ await Task.Delay(50);
}
Console.WriteLine("Selecting the first page");
tc.SelectedItem = tc.Items.OfType
-
+
diff --git a/samples/RenderDemo/App.xaml b/samples/RenderDemo/App.xaml
index 66d97a6444..8ea792de19 100644
--- a/samples/RenderDemo/App.xaml
+++ b/samples/RenderDemo/App.xaml
@@ -3,6 +3,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
+
+
+
+
+
+
+
diff --git a/samples/RenderDemo/App.xaml.cs b/samples/RenderDemo/App.xaml.cs
index 8054b06964..8f4e02df01 100644
--- a/samples/RenderDemo/App.xaml.cs
+++ b/samples/RenderDemo/App.xaml.cs
@@ -29,6 +29,10 @@ namespace RenderDemo
.With(new Win32PlatformOptions
{
OverlayPopups = true,
+ })
+ .With(new X11PlatformOptions
+ {
+ UseCompositor = true
})
.UsePlatformDetect()
.LogToTrace();
diff --git a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml
index 1d58c465a0..fbc51414e3 100644
--- a/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml
+++ b/samples/SampleControls/HamburgerMenu/HamburgerMenu.xaml
@@ -1,6 +1,6 @@
-
+
@@ -20,25 +20,136 @@
-
- 40
- 220
- 36
- 36
- 32
- 12,0,0,0
- 52,0,0,0
- 212,0,0,0
- 1 1 1 1 #2000, 0 0 1 1 #2fff
- 0 0 1 1 #2000
-
+ 40
+ 220
+ 36
+ 36
+ 32
+ 12,0,0,0
+ 52,0,0,0
+ 212,0,0,0
+ 1 1 1 1 #2000, 0 0 1 1 #2fff
+ 0 0 1 1 #2000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
diff --git a/samples/VirtualizationDemo/App.xaml b/samples/VirtualizationDemo/App.xaml
index 3ad1dce794..eb5f0e4dca 100644
--- a/samples/VirtualizationDemo/App.xaml
+++ b/samples/VirtualizationDemo/App.xaml
@@ -1,9 +1,7 @@
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
diff --git a/samples/VirtualizationDemo/VirtualizationDemo.csproj b/samples/VirtualizationDemo/VirtualizationDemo.csproj
index bd6054327f..b27cfe77e8 100644
--- a/samples/VirtualizationDemo/VirtualizationDemo.csproj
+++ b/samples/VirtualizationDemo/VirtualizationDemo.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/samples/interop/Direct3DInteropSample/App.paml b/samples/interop/Direct3DInteropSample/App.paml
index d9630eef58..e6d77dfaf4 100644
--- a/samples/interop/Direct3DInteropSample/App.paml
+++ b/samples/interop/Direct3DInteropSample/App.paml
@@ -1,6 +1,5 @@
-
-
+
-
\ No newline at end of file
+
diff --git a/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj b/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj
index 81b94b4d09..f9ef4693d5 100644
--- a/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj
+++ b/samples/interop/Direct3DInteropSample/Direct3DInteropSample.csproj
@@ -22,7 +22,7 @@
-
+
diff --git a/src/Android/Avalonia.Android/AndroidPlatform.cs b/src/Android/Avalonia.Android/AndroidPlatform.cs
index c7dd896cb8..0c72c389dc 100644
--- a/src/Android/Avalonia.Android/AndroidPlatform.cs
+++ b/src/Android/Avalonia.Android/AndroidPlatform.cs
@@ -1,10 +1,8 @@
using System;
using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Android;
using Avalonia.Android.Platform;
using Avalonia.Android.Platform.Input;
-using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.OpenGL.Egl;
@@ -55,7 +53,6 @@ namespace Avalonia.Android
.Bind().ToSingleton()
.Bind().ToConstant(Instance)
.Bind().ToConstant(new AndroidThreadingInterface())
- .Bind().ToTransient()
.Bind().ToSingleton()
.Bind().ToConstant(new ChoreographerTimer())
.Bind().ToConstant(new RenderLoop())
diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs
index f3692d33d7..4ee4bc1375 100644
--- a/src/Android/Avalonia.Android/AvaloniaActivity.cs
+++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs
@@ -4,10 +4,14 @@ using Android.Content.Res;
using AndroidX.Lifecycle;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls;
+using Android.Runtime;
+using Android.App;
+using Android.Content;
+using System;
namespace Avalonia.Android
{
- public abstract class AvaloniaActivity : AppCompatActivity where TApp : Application, new()
+ public abstract class AvaloniaActivity : AppCompatActivity
{
internal class SingleViewLifetime : ISingleViewApplicationLifetime
{
@@ -20,16 +24,15 @@ namespace Avalonia.Android
}
}
+ internal Action ActivityResult;
internal AvaloniaView View;
internal AvaloniaViewModel _viewModel;
- protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid();
+ protected abstract AppBuilder CreateAppBuilder();
protected override void OnCreate(Bundle savedInstanceState)
{
- var builder = AppBuilder.Configure();
-
- CustomizeAppBuilder(builder);
+ var builder = CreateAppBuilder();
var lifetime = new SingleViewLifetime();
@@ -79,5 +82,24 @@ namespace Avalonia.Android
base.OnDestroy();
}
+
+ protected override void OnActivityResult(int requestCode, [GeneratedEnum] Result resultCode, Intent data)
+ {
+ base.OnActivityResult(requestCode, resultCode, data);
+
+ ActivityResult?.Invoke(requestCode, resultCode, data);
+ }
+ }
+
+ public abstract class AvaloniaActivity : AvaloniaActivity where TApp : Application, new()
+ {
+ protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder.UseAndroid();
+
+ protected override AppBuilder CreateAppBuilder()
+ {
+ var builder = AppBuilder.Configure();
+
+ return CustomizeAppBuilder(builder);
+ }
}
}
diff --git a/src/Android/Avalonia.Android/ChoreographerTimer.cs b/src/Android/Avalonia.Android/ChoreographerTimer.cs
index 1d898261a3..19dc7b4ab6 100644
--- a/src/Android/Avalonia.Android/ChoreographerTimer.cs
+++ b/src/Android/Avalonia.Android/ChoreographerTimer.cs
@@ -29,6 +29,9 @@ namespace Avalonia.Android
_thread = new Thread(Loop);
_thread.Start();
}
+
+
+ public bool RunsInBackground => true;
public event Action Tick
{
diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
index 10f98609bf..fd97e293f9 100644
--- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
+++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
@@ -7,6 +7,7 @@ using Android.Views.InputMethods;
using Avalonia.Android.OpenGL;
using Avalonia.Android.Platform.Specific;
using Avalonia.Android.Platform.Specific.Helpers;
+using Avalonia.Android.Platform.Storage;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Controls.Platform.Surfaces;
@@ -16,11 +17,13 @@ using Avalonia.Input.TextInput;
using Avalonia.OpenGL.Egl;
using Avalonia.OpenGL.Surfaces;
using Avalonia.Platform;
+using Avalonia.Platform.Storage;
using Avalonia.Rendering;
namespace Avalonia.Android.Platform.SkiaPlatform
{
- class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo, ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost
+ class TopLevelImpl : IAndroidView, ITopLevelImpl, EglGlPlatformSurfaceBase.IEglWindowGlPlatformSurfaceInfo,
+ ITopLevelImplWithTextInputMethod, ITopLevelImplWithNativeControlHost, ITopLevelImplWithStorageProvider
{
private readonly IGlPlatformSurface _gl;
private readonly IFramebufferPlatformSurface _framebuffer;
@@ -46,6 +49,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
_view.Resources.DisplayMetrics.HeightPixels).ToSize(RenderScaling);
NativeControlHost = new AndroidNativeControlHostImpl(avaloniaView);
+ StorageProvider = new AndroidStorageProvider((AvaloniaActivity)avaloniaView.Context);
}
public virtual Point GetAvaloniaPointFromEvent(MotionEvent e, int pointerIndex) =>
@@ -225,6 +229,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform
public ITextInputMethodImpl TextInputMethod => _textInputMethod;
public INativeControlHostImpl NativeControlHost { get; }
+
+ public IStorageProvider StorageProvider { get; }
public void SetTransparencyLevelHint(WindowTransparencyLevel transparencyLevel)
{
diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
new file mode 100644
index 0000000000..a9b2e16d43
--- /dev/null
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
@@ -0,0 +1,269 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Android.Content;
+using Android.Provider;
+using Avalonia.Logging;
+using Avalonia.Platform.Storage;
+using Java.Lang;
+using AndroidUri = Android.Net.Uri;
+using Exception = System.Exception;
+using JavaFile = Java.IO.File;
+
+namespace Avalonia.Android.Platform.Storage;
+
+internal abstract class AndroidStorageItem : IStorageBookmarkItem
+{
+ private Context? _context;
+
+ protected AndroidStorageItem(Context context, AndroidUri uri)
+ {
+ _context = context;
+ Uri = uri;
+ }
+
+ internal AndroidUri Uri { get; }
+
+ protected Context Context => _context ?? throw new ObjectDisposedException(nameof(AndroidStorageItem));
+
+ public string Name => GetColumnValue(Context, Uri, MediaStore.IMediaColumns.DisplayName)
+ ?? Uri.PathSegments?.LastOrDefault() ?? string.Empty;
+
+ public bool CanBookmark => true;
+
+ public Task SaveBookmarkAsync()
+ {
+ Context.ContentResolver?.TakePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
+ return Task.FromResult(Uri.ToString());
+ }
+
+ public Task ReleaseBookmarkAsync()
+ {
+ Context.ContentResolver?.ReleasePersistableUriPermission(Uri, ActivityFlags.GrantWriteUriPermission | ActivityFlags.GrantReadUriPermission);
+ return Task.CompletedTask;
+ }
+
+ public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
+ {
+ uri = new Uri(Uri.ToString()!);
+ return true;
+ }
+
+ public abstract Task GetBasicPropertiesAsync();
+
+ protected string? GetColumnValue(Context context, AndroidUri contentUri, string column, string? selection = null, string[]? selectionArgs = null)
+ {
+ try
+ {
+ var projection = new[] { column };
+ using var cursor = context.ContentResolver!.Query(contentUri, projection, selection, selectionArgs, null);
+ if (cursor?.MoveToFirst() == true)
+ {
+ var columnIndex = cursor.GetColumnIndex(column);
+ if (columnIndex != -1)
+ return cursor.GetString(columnIndex);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "File metadata reader failed: '{Exception}'", ex);
+ }
+
+ return null;
+ }
+
+ public Task GetParentAsync()
+ {
+ using var javaFile = new JavaFile(Uri.Path!);
+
+ // Java file represents files AND directories. Don't be confused.
+ if (javaFile.ParentFile is {} parentFile
+ && AndroidUri.FromFile(parentFile) is {} androidUri)
+ {
+ return Task.FromResult(new AndroidStorageFolder(Context, androidUri));
+ }
+
+ return Task.FromResult(null);
+ }
+
+ public void Dispose()
+ {
+ _context = null;
+ }
+}
+
+internal sealed class AndroidStorageFolder : AndroidStorageItem, IStorageBookmarkFolder
+{
+ public AndroidStorageFolder(Context context, AndroidUri uri) : base(context, uri)
+ {
+ }
+
+ public override Task GetBasicPropertiesAsync()
+ {
+ return Task.FromResult(new StorageItemProperties());
+ }
+
+ public async Task> GetItemsAsync()
+ {
+ using var javaFile = new JavaFile(Uri.Path!);
+
+ // Java file represents files AND directories. Don't be confused.
+ var files = await javaFile.ListFilesAsync().ConfigureAwait(false);
+ if (files is null)
+ {
+ return Array.Empty();
+ }
+
+ return files
+ .Select(f => (file: f, uri: AndroidUri.FromFile(f)))
+ .Where(t => t.uri is not null)
+ .Select(t => t.file switch
+ {
+ { IsFile: true } => (IStorageItem)new AndroidStorageFile(Context, t.uri!),
+ { IsDirectory: true } => new AndroidStorageFolder(Context, t.uri!),
+ _ => null
+ })
+ .Where(i => i is not null)
+ .ToArray()!;
+ }
+}
+
+internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkFile
+{
+ public AndroidStorageFile(Context context, AndroidUri uri) : base(context, uri)
+ {
+ }
+
+ public bool CanOpenRead => true;
+
+ public bool CanOpenWrite => true;
+
+ public Task OpenReadAsync() => Task.FromResult(OpenContentStream(Context, Uri, false)
+ ?? throw new InvalidOperationException("Failed to open content stream"));
+
+ public Task OpenWriteAsync() => Task.FromResult(OpenContentStream(Context, Uri, true)
+ ?? throw new InvalidOperationException("Failed to open content stream"));
+
+ private Stream? OpenContentStream(Context context, AndroidUri uri, bool isOutput)
+ {
+ var isVirtual = IsVirtualFile(context, uri);
+ if (isVirtual)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?.Log(this, "Content URI was virtual: '{Uri}'", uri);
+ return GetVirtualFileStream(context, uri, isOutput);
+ }
+
+ return isOutput
+ ? context.ContentResolver?.OpenOutputStream(uri)
+ : context.ContentResolver?.OpenInputStream(uri);
+ }
+
+ private bool IsVirtualFile(Context context, AndroidUri uri)
+ {
+ if (!DocumentsContract.IsDocumentUri(context, uri))
+ return false;
+
+ var value = GetColumnValue(context, uri, DocumentsContract.Document.ColumnFlags);
+ if (!string.IsNullOrEmpty(value) && int.TryParse(value, out var flagsInt))
+ {
+ var flags = (DocumentContractFlags)flagsInt;
+ return flags.HasFlag(DocumentContractFlags.VirtualDocument);
+ }
+
+ return false;
+ }
+
+ private Stream? GetVirtualFileStream(Context context, AndroidUri uri, bool isOutput)
+ {
+ var mimeTypes = context.ContentResolver?.GetStreamTypes(uri, FilePickerFileTypes.All.MimeTypes![0]);
+ if (mimeTypes?.Length >= 1)
+ {
+ var mimeType = mimeTypes[0];
+ var asset = context.ContentResolver!
+ .OpenTypedAssetFileDescriptor(uri, mimeType, null);
+
+ var stream = isOutput
+ ? asset?.CreateOutputStream()
+ : asset?.CreateInputStream();
+
+ return stream;
+ }
+
+ return null;
+ }
+
+ public override Task GetBasicPropertiesAsync()
+ {
+ ulong? size = null;
+ DateTimeOffset? itemDate = null;
+ DateTimeOffset? dateModified = null;
+
+ try
+ {
+ var projection = new[]
+ {
+ MediaStore.IMediaColumns.Size, MediaStore.IMediaColumns.DateAdded,
+ MediaStore.IMediaColumns.DateModified
+ };
+ using var cursor = Context.ContentResolver!.Query(Uri, projection, null, null, null);
+
+ if (cursor?.MoveToFirst() == true)
+ {
+ try
+ {
+ var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.Size);
+ if (columnIndex != -1)
+ {
+ size = (ulong)cursor.GetLong(columnIndex);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "File Size metadata reader failed: '{Exception}'", ex);
+ }
+
+ try
+ {
+ var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateAdded);
+ if (columnIndex != -1)
+ {
+ var longValue = cursor.GetLong(columnIndex);
+ itemDate = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
+ }
+
+ try
+ {
+ var columnIndex = cursor.GetColumnIndex(MediaStore.IMediaColumns.DateModified);
+ if (columnIndex != -1)
+ {
+ var longValue = cursor.GetLong(columnIndex);
+ dateModified = longValue > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(longValue) : null;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.TryGet(LogEventLevel.Verbose, LogArea.AndroidPlatform)?
+ .Log(this, "File DateAdded metadata reader failed: '{Exception}'", ex);
+ }
+ }
+ }
+ catch (UnsupportedOperationException)
+ {
+ // It's not possible to get parameters of some files/folders.
+ }
+
+ return Task.FromResult(new StorageItemProperties(size, itemDate, dateModified));
+ }
+}
diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
new file mode 100644
index 0000000000..653f450ec8
--- /dev/null
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
@@ -0,0 +1,177 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Android.App;
+using Android.Content;
+using Android.Provider;
+using Avalonia.Platform.Storage;
+using AndroidUri = Android.Net.Uri;
+
+namespace Avalonia.Android.Platform.Storage;
+
+internal class AndroidStorageProvider : IStorageProvider
+{
+ private readonly AvaloniaActivity _activity;
+ private int _lastRequestCode = 20000;
+
+ public AndroidStorageProvider(AvaloniaActivity activity)
+ {
+ _activity = activity;
+ }
+
+ public bool CanOpen => OperatingSystem.IsAndroidVersionAtLeast(19);
+
+ public bool CanSave => OperatingSystem.IsAndroidVersionAtLeast(19);
+
+ public bool CanPickFolder => OperatingSystem.IsAndroidVersionAtLeast(21);
+
+ public Task OpenFolderBookmarkAsync(string bookmark)
+ {
+ var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
+ return Task.FromResult(new AndroidStorageFolder(_activity, uri));
+ }
+
+ public Task OpenFileBookmarkAsync(string bookmark)
+ {
+ var uri = AndroidUri.Parse(bookmark) ?? throw new ArgumentException("Couldn't parse Bookmark value", nameof(bookmark));
+ return Task.FromResult(new AndroidStorageFile(_activity, uri));
+ }
+
+ public async Task> OpenFilePickerAsync(FilePickerOpenOptions options)
+ {
+ var mimeTypes = options.FileTypeFilter?.Where(t => t != FilePickerFileTypes.All)
+ .SelectMany(f => f.MimeTypes ?? Array.Empty()).Distinct().ToArray() ?? Array.Empty();
+
+ var intent = new Intent(Intent.ActionOpenDocument)
+ .AddCategory(Intent.CategoryOpenable)
+ .PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple)
+ .SetType(FilePickerFileTypes.All.MimeTypes![0]);
+ if (mimeTypes.Length > 0)
+ {
+ intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
+ }
+
+ if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+ {
+ intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+ }
+
+ var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select file");
+
+ var uris = await StartActivity(pickerIntent, false);
+ return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray();
+ }
+
+ public async Task SaveFilePickerAsync(FilePickerSaveOptions options)
+ {
+ var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All)
+ .SelectMany(f => f.MimeTypes ?? Array.Empty()).Distinct().ToArray() ?? Array.Empty();
+
+ var intent = new Intent(Intent.ActionCreateDocument)
+ .AddCategory(Intent.CategoryOpenable)
+ .SetType(FilePickerFileTypes.All.MimeTypes![0]);
+ if (mimeTypes.Length > 0)
+ {
+ intent = intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes);
+ }
+
+ if (options.SuggestedFileName is { } fileName)
+ {
+ if (options.DefaultExtension is { } ext)
+ {
+ fileName += ext.StartsWith('.') ? ext : "." + ext;
+ }
+ intent = intent.PutExtra(Intent.ExtraTitle, fileName);
+ }
+
+ if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+ {
+ intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+ }
+
+ var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Save file");
+
+ var uris = await StartActivity(pickerIntent, true);
+ return uris.Select(u => new AndroidStorageFile(_activity, u)).FirstOrDefault();
+ }
+
+ public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options)
+ {
+ var intent = new Intent(Intent.ActionOpenDocumentTree)
+ .PutExtra(Intent.ExtraAllowMultiple, options.AllowMultiple);
+ if (TryGetInitialUri(options.SuggestedStartLocation) is { } initialUri)
+ {
+ intent = intent.PutExtra(DocumentsContract.ExtraInitialUri, initialUri);
+ }
+
+ var pickerIntent = Intent.CreateChooser(intent, options.Title ?? "Select folder");
+
+ var uris = await StartActivity(pickerIntent, false);
+ return uris.Select(u => new AndroidStorageFolder(_activity, u)).ToArray();
+ }
+
+ private async Task> StartActivity(Intent? pickerIntent, bool singleResult)
+ {
+ var resultList = new List(1);
+ var tcs = new TaskCompletionSource();
+ var currentRequestCode = _lastRequestCode++;
+
+ _activity.ActivityResult += OnActivityResult;
+ _activity.StartActivityForResult(pickerIntent, currentRequestCode);
+
+ var result = await tcs.Task;
+
+ if (result != null)
+ {
+ // ClipData first to avoid issue with multiple files selection.
+ if (!singleResult && result.ClipData is { } clipData)
+ {
+ for (var i = 0; i < clipData.ItemCount; i++)
+ {
+ var uri = clipData.GetItemAt(i)?.Uri;
+ if (uri != null)
+ {
+ resultList.Add(uri);
+ }
+ }
+ }
+ else if (result.Data is { } uri)
+ {
+ resultList.Add(uri);
+ }
+ }
+
+ if (result?.HasExtra("error") == true)
+ {
+ throw new Exception(result.GetStringExtra("error"));
+ }
+
+ return resultList;
+
+ void OnActivityResult(int requestCode, Result resultCode, Intent data)
+ {
+ if (currentRequestCode != requestCode)
+ {
+ return;
+ }
+
+ _activity.ActivityResult -= OnActivityResult;
+
+ _ = tcs.TrySetResult(resultCode == Result.Ok ? data : null);
+ }
+ }
+
+ private static AndroidUri? TryGetInitialUri(IStorageFolder? folder)
+ {
+ if (OperatingSystem.IsAndroidVersionAtLeast(26)
+ && (folder as AndroidStorageItem)?.Uri is { } uri)
+ {
+ return uri;
+ }
+
+ return null;
+ }
+}
diff --git a/src/Android/Avalonia.Android/SystemDialogImpl.cs b/src/Android/Avalonia.Android/SystemDialogImpl.cs
deleted file mode 100644
index 1ed1f688b1..0000000000
--- a/src/Android/Avalonia.Android/SystemDialogImpl.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using Avalonia.Controls;
-using Avalonia.Controls.Platform;
-
-namespace Avalonia.Android
-{
- internal class SystemDialogImpl : ISystemDialogImpl
- {
- public Task ShowFileDialogAsync(FileDialog dialog, Window parent)
- {
- throw new NotImplementedException();
- }
-
- public Task ShowFolderDialogAsync(OpenFolderDialog dialog, Window parent)
- {
- throw new NotImplementedException();
- }
- }
-}
diff --git a/src/Avalonia.Base/Animation/Easings/CubicBezier.cs b/src/Avalonia.Base/Animation/Easings/CubicBezier.cs
new file mode 100644
index 0000000000..5c2487a516
--- /dev/null
+++ b/src/Avalonia.Base/Animation/Easings/CubicBezier.cs
@@ -0,0 +1,306 @@
+// ReSharper disable InconsistentNaming
+// Ported from Chromium project https://github.com/chromium/chromium/blob/374d31b7704475fa59f7b2cb836b3b68afdc3d79/ui/gfx/geometry/cubic_bezier.cc
+
+using System;
+using Avalonia.Utilities;
+
+// ReSharper disable CompareOfFloatsByEqualityOperator
+// ReSharper disable CommentTypo
+// ReSharper disable MemberCanBePrivate.Global
+// ReSharper disable TooWideLocalVariableScope
+// ReSharper disable UnusedMember.Global
+#pragma warning disable 649
+
+namespace Avalonia.Animation.Easings
+{
+ ///
+ /// Represents a cubic bezier curve and can compute Y coordinate for a given X
+ ///
+ internal unsafe struct CubicBezier
+ {
+ const int CUBIC_BEZIER_SPLINE_SAMPLES = 11;
+ double ax_;
+ double bx_;
+ double cx_;
+
+ double ay_;
+ double by_;
+ double cy_;
+
+ double start_gradient_;
+ double end_gradient_;
+
+ double range_min_;
+ double range_max_;
+ private bool monotonically_increasing_;
+
+ fixed double spline_samples_[CUBIC_BEZIER_SPLINE_SAMPLES];
+
+ public CubicBezier(double p1x, double p1y, double p2x, double p2y) : this()
+ {
+ InitCoefficients(p1x, p1y, p2x, p2y);
+ InitGradients(p1x, p1y, p2x, p2y);
+ InitRange(p1y, p2y);
+ InitSpline();
+ }
+
+ public readonly double SampleCurveX(double t)
+ {
+ // `ax t^3 + bx t^2 + cx t' expanded using Horner's rule.
+ return ((ax_ * t + bx_) * t + cx_) * t;
+ }
+
+ readonly double SampleCurveY(double t)
+ {
+ return ((ay_ * t + by_) * t + cy_) * t;
+ }
+
+ readonly double SampleCurveDerivativeX(double t)
+ {
+ return (3.0 * ax_ * t + 2.0 * bx_) * t + cx_;
+ }
+
+ readonly double SampleCurveDerivativeY(double t)
+ {
+ return (3.0 * ay_ * t + 2.0 * by_) * t + cy_;
+ }
+
+ public readonly double SolveWithEpsilon(double x, double epsilon)
+ {
+ if (x < 0.0)
+ return 0.0 + start_gradient_ * x;
+ if (x > 1.0)
+ return 1.0 + end_gradient_ * (x - 1.0);
+ return SampleCurveY(SolveCurveX(x, epsilon));
+ }
+
+ void InitCoefficients(double p1x,
+ double p1y,
+ double p2x,
+ double p2y)
+ {
+ // Calculate the polynomial coefficients, implicit first and last control
+ // points are (0,0) and (1,1).
+ cx_ = 3.0 * p1x;
+ bx_ = 3.0 * (p2x - p1x) - cx_;
+ ax_ = 1.0 - cx_ - bx_;
+
+ cy_ = 3.0 * p1y;
+ by_ = 3.0 * (p2y - p1y) - cy_;
+ ay_ = 1.0 - cy_ - by_;
+
+#if DEBUG
+ // Bezier curves with x-coordinates outside the range [0,1] for internal
+ // control points may have multiple values for t for a given value of x.
+ // In this case, calls to SolveCurveX may produce ambiguous results.
+ monotonically_increasing_ = p1x >= 0 && p1x <= 1 && p2x >= 0 && p2x <= 1;
+#endif
+ }
+
+ void InitGradients(double p1x,
+ double p1y,
+ double p2x,
+ double p2y)
+ {
+ // End-point gradients are used to calculate timing function results
+ // outside the range [0, 1].
+ //
+ // There are four possibilities for the gradient at each end:
+ // (1) the closest control point is not horizontally coincident with regard to
+ // (0, 0) or (1, 1). In this case the line between the end point and
+ // the control point is tangent to the bezier at the end point.
+ // (2) the closest control point is coincident with the end point. In
+ // this case the line between the end point and the far control
+ // point is tangent to the bezier at the end point.
+ // (3) both internal control points are coincident with an endpoint. There
+ // are two special case that fall into this category:
+ // CubicBezier(0, 0, 0, 0) and CubicBezier(1, 1, 1, 1). Both are
+ // equivalent to linear.
+ // (4) the closest control point is horizontally coincident with the end
+ // point, but vertically distinct. In this case the gradient at the
+ // end point is Infinite. However, this causes issues when
+ // interpolating. As a result, we break down to a simple case of
+ // 0 gradient under these conditions.
+
+ if (p1x > 0)
+ start_gradient_ = p1y / p1x;
+ else if (p1y == 0 && p2x > 0)
+ start_gradient_ = p2y / p2x;
+ else if (p1y == 0 && p2y == 0)
+ start_gradient_ = 1;
+ else
+ start_gradient_ = 0;
+
+ if (p2x < 1)
+ end_gradient_ = (p2y - 1) / (p2x - 1);
+ else if (p2y == 1 && p1x < 1)
+ end_gradient_ = (p1y - 1) / (p1x - 1);
+ else if (p2y == 1 && p1y == 1)
+ end_gradient_ = 1;
+ else
+ end_gradient_ = 0;
+ }
+
+ const double kBezierEpsilon = 1e-7;
+
+ void InitRange(double p1y, double p2y)
+ {
+ range_min_ = 0;
+ range_max_ = 1;
+ if (0 <= p1y && p1y < 1 && 0 <= p2y && p2y <= 1)
+ return;
+
+ double epsilon = kBezierEpsilon;
+
+ // Represent the function's derivative in the form at^2 + bt + c
+ // as in sampleCurveDerivativeY.
+ // (Technically this is (dy/dt)*(1/3), which is suitable for finding zeros
+ // but does not actually give the slope of the curve.)
+ double a = 3.0 * ay_;
+ double b = 2.0 * by_;
+ double c = cy_;
+
+ // Check if the derivative is constant.
+ if (Math.Abs(a) < epsilon && Math.Abs(b) < epsilon)
+ return;
+
+ // Zeros of the function's derivative.
+ double t1;
+ double t2 = 0;
+
+ if (Math.Abs(a) < epsilon)
+ {
+ // The function's derivative is linear.
+ t1 = -c / b;
+ }
+ else
+ {
+ // The function's derivative is a quadratic. We find the zeros of this
+ // quadratic using the quadratic formula.
+ double discriminant = b * b - 4 * a * c;
+ if (discriminant < 0)
+ return;
+ double discriminant_sqrt = Math.Sqrt(discriminant);
+ t1 = (-b + discriminant_sqrt) / (2 * a);
+ t2 = (-b - discriminant_sqrt) / (2 * a);
+ }
+
+ double sol1 = 0;
+ double sol2 = 0;
+
+ // If the solution is in the range [0,1] then we include it, otherwise we
+ // ignore it.
+
+ // An interesting fact about these beziers is that they are only
+ // actually evaluated in [0,1]. After that we take the tangent at that point
+ // and linearly project it out.
+ if (0 < t1 && t1 < 1)
+ sol1 = SampleCurveY(t1);
+
+ if (0 < t2 && t2 < 1)
+ sol2 = SampleCurveY(t2);
+
+ range_min_ = Math.Min(Math.Min(range_min_, sol1), sol2);
+ range_max_ = Math.Max(Math.Max(range_max_, sol1), sol2);
+ }
+
+ void InitSpline()
+ {
+ double delta_t = 1.0 / (CUBIC_BEZIER_SPLINE_SAMPLES - 1);
+ for (int i = 0; i < CUBIC_BEZIER_SPLINE_SAMPLES; i++)
+ {
+ spline_samples_[i] = SampleCurveX(i * delta_t);
+ }
+ }
+
+ const int kMaxNewtonIterations = 4;
+
+
+ public readonly double SolveCurveX(double x, double epsilon)
+ {
+ if (x < 0 || x > 1)
+ throw new ArgumentException();
+
+ double t0 = 0;
+ double t1 = 0;
+ double t2 = x;
+ double x2 = 0;
+ double d2;
+ int i;
+
+#if DEBUG
+ if (!monotonically_increasing_)
+ throw new InvalidOperationException();
+#endif
+
+ // Linear interpolation of spline curve for initial guess.
+ double delta_t = 1.0 / (CUBIC_BEZIER_SPLINE_SAMPLES - 1);
+ for (i = 1; i < CUBIC_BEZIER_SPLINE_SAMPLES; i++)
+ {
+ if (x <= spline_samples_[i])
+ {
+ t1 = delta_t * i;
+ t0 = t1 - delta_t;
+ t2 = t0 + (t1 - t0) * (x - spline_samples_[i - 1]) /
+ (spline_samples_[i] - spline_samples_[i - 1]);
+ break;
+ }
+ }
+
+ // Perform a few iterations of Newton's method -- normally very fast.
+ // See https://en.wikipedia.org/wiki/Newton%27s_method.
+ double newton_epsilon = Math.Min(kBezierEpsilon, epsilon);
+ for (i = 0; i < kMaxNewtonIterations; i++)
+ {
+ x2 = SampleCurveX(t2) - x;
+ if (Math.Abs(x2) < newton_epsilon)
+ return t2;
+ d2 = SampleCurveDerivativeX(t2);
+ if (Math.Abs(d2) < kBezierEpsilon)
+ break;
+ t2 = t2 - x2 / d2;
+ }
+
+ if (Math.Abs(x2) < epsilon)
+ return t2;
+
+ // Fall back to the bisection method for reliability.
+ while (t0 < t1)
+ {
+ x2 = SampleCurveX(t2);
+ if (Math.Abs(x2 - x) < epsilon)
+ return t2;
+ if (x > x2)
+ t0 = t2;
+ else
+ t1 = t2;
+ t2 = (t1 + t0) * .5;
+ }
+
+ // Failure.
+ return t2;
+ }
+
+ public readonly double Solve(double x)
+ {
+ return SolveWithEpsilon(x, kBezierEpsilon);
+ }
+
+ public readonly double SlopeWithEpsilon(double x, double epsilon)
+ {
+ x = MathUtilities.Clamp(x, 0.0, 1.0);
+ double t = SolveCurveX(x, epsilon);
+ double dx = SampleCurveDerivativeX(t);
+ double dy = SampleCurveDerivativeY(t);
+ return dy / dx;
+ }
+
+ public readonly double Slope(double x)
+ {
+ return SlopeWithEpsilon(x, kBezierEpsilon);
+ }
+
+ public readonly double RangeMin => range_min_;
+ public readonly double RangeMax => range_max_;
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Animation/Easings/CubicBezierEasing.cs b/src/Avalonia.Base/Animation/Easings/CubicBezierEasing.cs
new file mode 100644
index 0000000000..71582fa448
--- /dev/null
+++ b/src/Avalonia.Base/Animation/Easings/CubicBezierEasing.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Avalonia.Animation.Easings;
+
+public class CubicBezierEasing : IEasing
+{
+ private CubicBezier _bezier;
+ //cubic-bezier(0.25, 0.1, 0.25, 1.0)
+ internal CubicBezierEasing(Point controlPoint1, Point controlPoint2)
+ {
+ ControlPoint1 = controlPoint1;
+ ControlPoint2 = controlPoint2;
+ if (controlPoint1.X < 0 || controlPoint1.X > 1 || controlPoint2.X < 0 || controlPoint2.X > 1)
+ throw new ArgumentException();
+ _bezier = new CubicBezier(controlPoint1.X, controlPoint1.Y, controlPoint2.X, controlPoint2.Y);
+ }
+
+ public Point ControlPoint2 { get; set; }
+ public Point ControlPoint1 { get; set; }
+
+ internal static IEasing Ease { get; } = new CubicBezierEasing(new Point(0.25, 0.1), new Point(0.25, 1));
+
+ double IEasing.Ease(double progress)
+ {
+ return _bezier.Solve(progress);
+ }
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj
index a07e0e3667..15feed388b 100644
--- a/src/Avalonia.Base/Avalonia.Base.csproj
+++ b/src/Avalonia.Base/Avalonia.Base.csproj
@@ -3,10 +3,13 @@
net6.0;netstandard2.0
Avalonia.Base
Avalonia
- True
+ True
+ true
+ $(BaseIntermediateOutputPath)\GeneratedFiles
+
@@ -32,6 +35,11 @@
-
+
+
+
+
+
+
diff --git a/src/Avalonia.Base/Collections/IAvaloniaReadOnlyList.cs b/src/Avalonia.Base/Collections/IAvaloniaReadOnlyList.cs
index a6a5953827..cefbf642be 100644
--- a/src/Avalonia.Base/Collections/IAvaloniaReadOnlyList.cs
+++ b/src/Avalonia.Base/Collections/IAvaloniaReadOnlyList.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
diff --git a/src/Avalonia.Base/Collections/Pooled/PooledList.cs b/src/Avalonia.Base/Collections/Pooled/PooledList.cs
index 803b8d60dc..267c403ab7 100644
--- a/src/Avalonia.Base/Collections/Pooled/PooledList.cs
+++ b/src/Avalonia.Base/Collections/Pooled/PooledList.cs
@@ -1434,7 +1434,7 @@ namespace Avalonia.Collections.Pooled
///
/// Returns the internal buffers to the ArrayPool.
///
- public void Dispose()
+ public virtual void Dispose()
{
ReturnArray();
_size = 0;
diff --git a/src/Avalonia.Base/Controls/Classes.cs b/src/Avalonia.Base/Controls/Classes.cs
index 50605661fa..c3d3fbca46 100644
--- a/src/Avalonia.Base/Controls/Classes.cs
+++ b/src/Avalonia.Base/Controls/Classes.cs
@@ -1,8 +1,7 @@
using System;
using System.Collections.Generic;
using Avalonia.Collections;
-
-#nullable enable
+using Avalonia.Utilities;
namespace Avalonia.Controls
{
@@ -14,6 +13,8 @@ namespace Avalonia.Controls
///
public class Classes : AvaloniaList, IPseudoClasses
{
+ private SafeEnumerableList? _listeners;
+
///
/// Initializes a new instance of the class.
///
@@ -39,6 +40,11 @@ namespace Avalonia.Controls
{
}
+ ///
+ /// Gets the number of listeners subscribed to this collection for unit testing purposes.
+ ///
+ internal int ListenerCount => _listeners?.Count ?? 0;
+
///
/// Parses a classes string.
///
@@ -62,6 +68,7 @@ namespace Avalonia.Controls
if (!Contains(name))
{
base.Add(name);
+ NotifyChanged();
}
}
@@ -89,6 +96,7 @@ namespace Avalonia.Controls
}
base.AddRange(c);
+ NotifyChanged();
}
///
@@ -103,6 +111,8 @@ namespace Avalonia.Controls
RemoveAt(i);
}
}
+
+ NotifyChanged();
}
///
@@ -122,6 +132,7 @@ namespace Avalonia.Controls
if (!Contains(name))
{
base.Insert(index, name);
+ NotifyChanged();
}
}
@@ -154,6 +165,7 @@ namespace Avalonia.Controls
if (toInsert != null)
{
base.InsertRange(index, toInsert);
+ NotifyChanged();
}
}
@@ -169,7 +181,14 @@ namespace Avalonia.Controls
public override bool Remove(string name)
{
ThrowIfPseudoclass(name, "removed");
- return base.Remove(name);
+
+ if (base.Remove(name))
+ {
+ NotifyChanged();
+ return true;
+ }
+
+ return false;
}
///
@@ -197,6 +216,7 @@ namespace Avalonia.Controls
if (toRemove != null)
{
base.RemoveAll(toRemove);
+ NotifyChanged();
}
}
@@ -214,6 +234,7 @@ namespace Avalonia.Controls
var name = this[index];
ThrowIfPseudoclass(name, "removed");
base.RemoveAt(index);
+ NotifyChanged();
}
///
@@ -224,6 +245,7 @@ namespace Avalonia.Controls
public override void RemoveRange(int index, int count)
{
base.RemoveRange(index, count);
+ NotifyChanged();
}
///
@@ -255,6 +277,7 @@ namespace Avalonia.Controls
}
base.AddRange(source);
+ NotifyChanged();
}
///
@@ -263,13 +286,38 @@ namespace Avalonia.Controls
if (!Contains(name))
{
base.Add(name);
+ NotifyChanged();
}
}
///
bool IPseudoClasses.Remove(string name)
{
- return base.Remove(name);
+ if (base.Remove(name))
+ {
+ NotifyChanged();
+ return true;
+ }
+
+ return false;
+ }
+
+ internal void AddListener(IClassesChangedListener listener)
+ {
+ (_listeners ??= new()).Add(listener);
+ }
+
+ internal void RemoveListener(IClassesChangedListener listener)
+ {
+ _listeners?.Remove(listener);
+ }
+
+ private void NotifyChanged()
+ {
+ if (_listeners is null)
+ return;
+ foreach (var listener in _listeners)
+ listener.Changed();
}
private void ThrowIfPseudoclass(string name, string operation)
diff --git a/src/Avalonia.Base/Controls/IClassesChangedListener.cs b/src/Avalonia.Base/Controls/IClassesChangedListener.cs
new file mode 100644
index 0000000000..b4de893c97
--- /dev/null
+++ b/src/Avalonia.Base/Controls/IClassesChangedListener.cs
@@ -0,0 +1,14 @@
+namespace Avalonia.Controls
+{
+ ///
+ /// Internal interface for listening to changes in in a more
+ /// performant manner than subscribing to CollectionChanged.
+ ///
+ internal interface IClassesChangedListener
+ {
+ ///
+ /// Notifies the listener that the collection has changed.
+ ///
+ void Changed();
+ }
+}
diff --git a/src/Avalonia.Base/Controls/IPseudoClasses.cs b/src/Avalonia.Base/Controls/IPseudoClasses.cs
index eda521727f..438b05a8cf 100644
--- a/src/Avalonia.Base/Controls/IPseudoClasses.cs
+++ b/src/Avalonia.Base/Controls/IPseudoClasses.cs
@@ -19,5 +19,12 @@ namespace Avalonia.Controls
///
/// The pseudoclass name.
bool Remove(string name);
+
+ ///
+ /// Returns whether a pseudoclass is present in the collection.
+ ///
+ /// The pseudoclass name.
+ /// Whether the pseudoclass is present.
+ bool Contains(string name);
}
}
diff --git a/src/Avalonia.Base/Data/Core/Plugins/InpcPropertyAccessorPlugin.cs b/src/Avalonia.Base/Data/Core/Plugins/InpcPropertyAccessorPlugin.cs
index b93bf87fdf..91d69b5d3d 100644
--- a/src/Avalonia.Base/Data/Core/Plugins/InpcPropertyAccessorPlugin.cs
+++ b/src/Avalonia.Base/Data/Core/Plugins/InpcPropertyAccessorPlugin.cs
@@ -55,7 +55,7 @@ namespace Avalonia.Data.Core.Plugins
private PropertyInfo? GetFirstPropertyWithName(object instance, string propertyName)
{
- if (instance is IReflectableType reflectableType)
+ if (instance is IReflectableType reflectableType && instance is not Type)
return reflectableType.GetTypeInfo().GetProperty(propertyName, PropertyBindingFlags);
var type = instance.GetType();
diff --git a/src/Avalonia.Base/Input/Cursor.cs b/src/Avalonia.Base/Input/Cursor.cs
index 122838f682..98c4258a90 100644
--- a/src/Avalonia.Base/Input/Cursor.cs
+++ b/src/Avalonia.Base/Input/Cursor.cs
@@ -32,10 +32,7 @@ namespace Avalonia.Input
DragCopy,
DragLink,
None,
-
- [Obsolete("Use BottomSide")]
- BottomSize = BottomSide
-
+
// Not available in GTK directly, see http://www.pixelbeat.org/programming/x_cursors/
// We might enable them later, preferably, by loading pixmax directly from theme with fallback image
// SizeNorthWestSouthEast,
diff --git a/src/Avalonia.Base/Input/DragEventArgs.cs b/src/Avalonia.Base/Input/DragEventArgs.cs
index 22ca8358ff..0e613c0f21 100644
--- a/src/Avalonia.Base/Input/DragEventArgs.cs
+++ b/src/Avalonia.Base/Input/DragEventArgs.cs
@@ -13,9 +13,6 @@ namespace Avalonia.Input
public IDataObject Data { get; private set; }
- [Obsolete("Use KeyModifiers")]
- public InputModifiers Modifiers { get; private set; }
-
public KeyModifiers KeyModifiers { get; private set; }
public Point GetPosition(IVisual relativeTo)
@@ -35,17 +32,6 @@ namespace Avalonia.Input
return point;
}
- [Obsolete("Use constructor taking KeyModifiers")]
- public DragEventArgs(RoutedEvent routedEvent, IDataObject data, Interactive target, Point targetLocation, InputModifiers modifiers)
- : base(routedEvent)
- {
- Data = data;
- _target = target;
- _targetLocation = targetLocation;
- Modifiers = modifiers;
- KeyModifiers = (KeyModifiers)(((int)modifiers) & 0xF);
- }
-
public DragEventArgs(RoutedEvent routedEvent, IDataObject data, Interactive target, Point targetLocation, KeyModifiers keyModifiers)
: base(routedEvent)
{
@@ -53,10 +39,6 @@ namespace Avalonia.Input
_target = target;
_targetLocation = targetLocation;
KeyModifiers = keyModifiers;
-#pragma warning disable CS0618 // Type or member is obsolete
- Modifiers = (InputModifiers)keyModifiers;
-#pragma warning restore CS0618 // Type or member is obsolete
}
-
}
}
diff --git a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
index f8cedb636f..889b7e3b82 100644
--- a/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
+++ b/src/Avalonia.Base/Input/GestureRecognizers/ScrollGestureRecognizer.cs
@@ -66,7 +66,8 @@ namespace Avalonia.Input.GestureRecognizers
public void PointerPressed(PointerPressedEventArgs e)
{
- if (e.Pointer.IsPrimary && e.Pointer.Type == PointerType.Touch)
+ if (e.Pointer.IsPrimary &&
+ (e.Pointer.Type == PointerType.Touch || e.Pointer.Type == PointerType.Pen))
{
EndGesture();
_tracking = e.Pointer;
@@ -101,7 +102,7 @@ namespace Avalonia.Input.GestureRecognizers
if (_scrolling)
{
var vector = _trackedRootPoint - rootPoint;
- var elapsed = _lastMoveTimestamp.HasValue ?
+ var elapsed = _lastMoveTimestamp.HasValue && _lastMoveTimestamp < e.Timestamp ?
TimeSpan.FromMilliseconds(e.Timestamp - _lastMoveTimestamp.Value) :
TimeSpan.Zero;
diff --git a/src/Avalonia.Base/Input/GotFocusEventArgs.cs b/src/Avalonia.Base/Input/GotFocusEventArgs.cs
index 9d958823fe..5cce138ee0 100644
--- a/src/Avalonia.Base/Input/GotFocusEventArgs.cs
+++ b/src/Avalonia.Base/Input/GotFocusEventArgs.cs
@@ -1,4 +1,3 @@
-using System;
using Avalonia.Interactivity;
namespace Avalonia.Input
@@ -13,16 +12,6 @@ namespace Avalonia.Input
///
public NavigationMethod NavigationMethod { get; set; }
- ///
- /// Gets or sets any input modifiers active at the time of focus.
- ///
- [Obsolete("Use KeyModifiers")]
- public InputModifiers InputModifiers
- {
- get => (InputModifiers)KeyModifiers;
- set => KeyModifiers = (KeyModifiers)((int)value & 0xF);
- }
-
///
/// Gets or sets any key modifiers active at the time of focus.
///
diff --git a/src/Avalonia.Base/Input/IInputRoot.cs b/src/Avalonia.Base/Input/IInputRoot.cs
index 7edc69df52..344a4eefd7 100644
--- a/src/Avalonia.Base/Input/IInputRoot.cs
+++ b/src/Avalonia.Base/Input/IInputRoot.cs
@@ -27,10 +27,5 @@ namespace Avalonia.Input
/// Gets or sets a value indicating whether access keys are shown in the window.
///
bool ShowAccessKeys { get; set; }
-
- ///
- /// Gets associated mouse device
- ///
- IMouseDevice? MouseDevice { get; }
}
}
diff --git a/src/Avalonia.Base/Input/IKeyboardDevice.cs b/src/Avalonia.Base/Input/IKeyboardDevice.cs
index c8db8bf16f..0b7b5aaecc 100644
--- a/src/Avalonia.Base/Input/IKeyboardDevice.cs
+++ b/src/Avalonia.Base/Input/IKeyboardDevice.cs
@@ -4,19 +4,6 @@ using Avalonia.Metadata;
namespace Avalonia.Input
{
- [Flags, Obsolete("Use KeyModifiers and PointerPointProperties")]
- public enum InputModifiers
- {
- None = 0,
- Alt = 1,
- Control = 2,
- Shift = 4,
- Windows = 8,
- LeftMouseButton = 16,
- RightMouseButton = 32,
- MiddleMouseButton = 64
- }
-
[Flags]
public enum KeyModifiers
{
@@ -43,12 +30,17 @@ namespace Avalonia.Input
Control = 2,
Shift = 4,
Meta = 8,
+
LeftMouseButton = 16,
RightMouseButton = 32,
MiddleMouseButton = 64,
XButton1MouseButton = 128,
XButton2MouseButton = 256,
- KeyboardMask = Alt | Control | Shift | Meta
+ KeyboardMask = Alt | Control | Shift | Meta,
+
+ PenInverted = 512,
+ PenEraser = 1024,
+ PenBarrelButton = 2048
}
[NotClientImplementable]
diff --git a/src/Avalonia.Base/Input/IMouseDevice.cs b/src/Avalonia.Base/Input/IMouseDevice.cs
index 2d66397d63..00c436bf21 100644
--- a/src/Avalonia.Base/Input/IMouseDevice.cs
+++ b/src/Avalonia.Base/Input/IMouseDevice.cs
@@ -1,4 +1,3 @@
-using System;
using Avalonia.Metadata;
namespace Avalonia.Input
@@ -9,16 +8,5 @@ namespace Avalonia.Input
[NotClientImplementable]
public interface IMouseDevice : IPointerDevice
{
- ///
- /// Gets the mouse position, in screen coordinates.
- ///
- [Obsolete("Use PointerEventArgs.GetPosition")]
- PixelPoint Position { get; }
-
- [Obsolete]
- void TopLevelClosed(IInputRoot root);
-
- [Obsolete]
- void SceneInvalidated(IInputRoot root, Rect rect);
}
}
diff --git a/src/Avalonia.Base/Input/IPenDevice.cs b/src/Avalonia.Base/Input/IPenDevice.cs
new file mode 100644
index 0000000000..1cc0fcf76d
--- /dev/null
+++ b/src/Avalonia.Base/Input/IPenDevice.cs
@@ -0,0 +1,10 @@
+namespace Avalonia.Input
+{
+ ///
+ /// Represents a pen/stylus device.
+ ///
+ public interface IPenDevice : IPointerDevice
+ {
+
+ }
+}
diff --git a/src/Avalonia.Base/Input/IPointer.cs b/src/Avalonia.Base/Input/IPointer.cs
index 66aeacadc9..52605bb6ae 100644
--- a/src/Avalonia.Base/Input/IPointer.cs
+++ b/src/Avalonia.Base/Input/IPointer.cs
@@ -2,20 +2,59 @@ using Avalonia.Metadata;
namespace Avalonia.Input
{
+ ///
+ /// Identifies specific pointer generated by input device.
+ ///
+ ///
+ /// Some devices, for instance, touchscreen might generate a pointer on each physical contact.
+ ///
[NotClientImplementable]
public interface IPointer
{
+ ///
+ /// Gets a unique identifier for the input pointer.
+ ///
int Id { get; }
+
+ ///
+ /// Captures pointer input to the specified control.
+ ///
+ /// The control.
+ ///
+ /// When an element captures the pointer, it receives pointer input whether the cursor is
+ /// within the control's bounds or not. The current pointer capture control is exposed
+ /// by the property.
+ ///
void Capture(IInputElement? control);
+
+ ///
+ /// Gets the control that is currently capturing by the pointer, if any.
+ ///
+ ///
+ /// When an element captures the pointer, it receives pointer input whether the cursor is
+ /// within the control's bounds or not. To set the pointer capture, call the
+ /// method.
+ ///
IInputElement? Captured { get; }
+
+ ///
+ /// Gets the pointer device type.
+ ///
PointerType Type { get; }
+
+ ///
+ /// Gets a value that indicates whether the input is from the primary pointer when multiple pointers are registered.
+ ///
bool IsPrimary { get; }
-
}
+ ///
+ /// Enumerates pointer device types.
+ ///
public enum PointerType
{
Mouse,
- Touch
+ Touch,
+ Pen
}
}
diff --git a/src/Avalonia.Base/Input/IPointerDevice.cs b/src/Avalonia.Base/Input/IPointerDevice.cs
index 0993835feb..e0aebda9c5 100644
--- a/src/Avalonia.Base/Input/IPointerDevice.cs
+++ b/src/Avalonia.Base/Input/IPointerDevice.cs
@@ -1,5 +1,3 @@
-using System;
-using Avalonia.VisualTree;
using Avalonia.Input.Raw;
using Avalonia.Metadata;
@@ -8,18 +6,6 @@ namespace Avalonia.Input
[NotClientImplementable]
public interface IPointerDevice : IInputDevice
{
- ///
- [Obsolete("Use IPointer")]
- IInputElement? Captured { get; }
-
- ///
- [Obsolete("Use IPointer")]
- void Capture(IInputElement? control);
-
- ///
- [Obsolete("Use PointerEventArgs.GetPosition")]
- Point GetPosition(IVisual relativeTo);
-
///
/// Gets a pointer for specific event args.
///
diff --git a/src/Avalonia.Base/Input/KeyEventArgs.cs b/src/Avalonia.Base/Input/KeyEventArgs.cs
index 67cd5a520a..b8291e9096 100644
--- a/src/Avalonia.Base/Input/KeyEventArgs.cs
+++ b/src/Avalonia.Base/Input/KeyEventArgs.cs
@@ -9,8 +9,6 @@ namespace Avalonia.Input
public Key Key { get; set; }
- [Obsolete("Use KeyModifiers")]
- public InputModifiers Modifiers => (InputModifiers)KeyModifiers;
public KeyModifiers KeyModifiers { get; set; }
}
}
diff --git a/src/Avalonia.Base/Input/KeyGesture.cs b/src/Avalonia.Base/Input/KeyGesture.cs
index 3b7a828b86..e79e9341a9 100644
--- a/src/Avalonia.Base/Input/KeyGesture.cs
+++ b/src/Avalonia.Base/Input/KeyGesture.cs
@@ -15,13 +15,6 @@ namespace Avalonia.Input
{ "+", Key.OemPlus }, { "-", Key.OemMinus }, { ".", Key.OemPeriod }, { ",", Key.OemComma }
};
- [Obsolete("Use constructor taking KeyModifiers")]
- public KeyGesture(Key key, InputModifiers modifiers)
- {
- Key = key;
- KeyModifiers = (KeyModifiers)(((int)modifiers) & 0xf);
- }
-
public KeyGesture(Key key, KeyModifiers modifiers = KeyModifiers.None)
{
Key = key;
@@ -63,10 +56,7 @@ namespace Avalonia.Input
}
public Key Key { get; }
-
- [Obsolete("Use KeyModifiers")]
- public InputModifiers Modifiers => (InputModifiers)KeyModifiers;
-
+
public KeyModifiers KeyModifiers { get; }
public static KeyGesture Parse(string gesture)
diff --git a/src/Avalonia.Base/Input/MouseDevice.cs b/src/Avalonia.Base/Input/MouseDevice.cs
index 5f8ab24b79..055c9cf1fd 100644
--- a/src/Avalonia.Base/Input/MouseDevice.cs
+++ b/src/Avalonia.Base/Input/MouseDevice.cs
@@ -21,7 +21,6 @@ namespace Avalonia.Input
private readonly Pointer _pointer;
private bool _disposed;
- private PixelPoint? _position;
private MouseButton _lastMouseDownButton;
public MouseDevice(Pointer? pointer = null)
@@ -29,43 +28,6 @@ namespace Avalonia.Input
_pointer = pointer ?? new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true);
}
- [Obsolete("Use IPointer instead")]
- public IInputElement? Captured => _pointer.Captured;
-
- [Obsolete("Use events instead")]
- public PixelPoint Position
- {
- get => _position ?? new PixelPoint(-1, -1);
- protected set => _position = value;
- }
-
- [Obsolete("Use IPointer instead")]
- public void Capture(IInputElement? control)
- {
- _pointer.Capture(control);
- }
-
- ///
- /// Gets the mouse position relative to a control.
- ///
- /// The control.
- /// The mouse position in the control's coordinates.
- public Point GetPosition(IVisual relativeTo)
- {
- relativeTo = relativeTo ?? throw new ArgumentNullException(nameof(relativeTo));
-
- if (relativeTo.VisualRoot == null)
- {
- throw new InvalidOperationException("Control is not attached to visual tree.");
- }
-
-#pragma warning disable CS0618 // Type or member is obsolete
- var rootPoint = relativeTo.VisualRoot.PointToClient(Position);
-#pragma warning restore CS0618 // Type or member is obsolete
- var transform = relativeTo.VisualRoot.TransformToVisual(relativeTo);
- return rootPoint * transform!.Value;
- }
-
public void ProcessRawEvent(RawInputEventArgs e)
{
if (!e.Handled && e is RawPointerEventArgs margs)
@@ -96,7 +58,6 @@ namespace Avalonia.Input
if(mouse._disposed)
return;
- _position = e.Root.PointToScreen(e.Position);
var props = CreateProperties(e);
var keyModifiers = e.InputModifiers.ToKeyModifiers();
switch (e.Type)
@@ -145,7 +106,6 @@ namespace Avalonia.Input
private void LeaveWindow()
{
- _position = null;
}
PointerPointProperties CreateProperties(RawPointerEventArgs args)
@@ -324,19 +284,7 @@ namespace Avalonia.Input
_disposed = true;
_pointer?.Dispose();
}
-
- [Obsolete]
- public void TopLevelClosed(IInputRoot root)
- {
- // no-op
- }
-
- [Obsolete]
- public void SceneInvalidated(IInputRoot root, Rect rect)
- {
- // no-op
- }
-
+
public IPointer? TryGetPointer(RawPointerEventArgs ev)
{
return _pointer;
diff --git a/src/Avalonia.Base/Input/PenDevice.cs b/src/Avalonia.Base/Input/PenDevice.cs
new file mode 100644
index 0000000000..f5f0e90a45
--- /dev/null
+++ b/src/Avalonia.Base/Input/PenDevice.cs
@@ -0,0 +1,157 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Input.Raw;
+using Avalonia.Platform;
+
+namespace Avalonia.Input
+{
+ ///
+ /// Represents a pen/stylus device.
+ ///
+ public class PenDevice : IPenDevice, IDisposable
+ {
+ private readonly Dictionary _pointers = new();
+ private int _clickCount;
+ private Rect _lastClickRect;
+ private ulong _lastClickTime;
+ private MouseButton _lastMouseDownButton;
+
+ private bool _disposed;
+
+ public void ProcessRawEvent(RawInputEventArgs e)
+ {
+ if (!e.Handled && e is RawPointerEventArgs margs)
+ ProcessRawEvent(margs);
+ }
+
+ private void ProcessRawEvent(RawPointerEventArgs e)
+ {
+ e = e ?? throw new ArgumentNullException(nameof(e));
+
+ if (!_pointers.TryGetValue(e.RawPointerId, out var pointer))
+ {
+ if (e.Type == RawPointerEventType.LeftButtonUp
+ || e.Type == RawPointerEventType.TouchEnd)
+ return;
+
+ _pointers[e.RawPointerId] = pointer = new Pointer(Pointer.GetNextFreeId(),
+ PointerType.Pen, _pointers.Count == 0);
+ }
+
+ var props = new PointerPointProperties(e.InputModifiers, e.Type.ToUpdateKind(),
+ e.Point.Twist, e.Point.Pressure, e.Point.XTilt, e.Point.YTilt);
+ var keyModifiers = e.InputModifiers.ToKeyModifiers();
+
+ bool shouldReleasePointer = false;
+ switch (e.Type)
+ {
+ case RawPointerEventType.LeaveWindow:
+ shouldReleasePointer = true;
+ break;
+ case RawPointerEventType.LeftButtonDown:
+ e.Handled = PenDown(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
+ break;
+ case RawPointerEventType.LeftButtonUp:
+ e.Handled = PenUp(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult);
+ break;
+ case RawPointerEventType.Move:
+ e.Handled = PenMove(pointer, e.Timestamp, e.Root, e.Position, props, keyModifiers, e.InputHitTestResult, e.IntermediatePoints);
+ break;
+ }
+
+ if (shouldReleasePointer)
+ {
+ pointer.Dispose();
+ _pointers.Remove(e.RawPointerId);
+ }
+ }
+
+ private bool PenDown(Pointer pointer, ulong timestamp,
+ IInputElement root, Point p, PointerPointProperties properties,
+ KeyModifiers inputModifiers, IInputElement? hitTest)
+ {
+ var source = pointer.Captured ?? hitTest;
+
+ if (source != null)
+ {
+ pointer.Capture(source);
+ var settings = AvaloniaLocator.Current.GetService();
+ var doubleClickTime = settings?.DoubleClickTime.TotalMilliseconds ?? 500;
+ var doubleClickSize = settings?.DoubleClickSize ?? new Size(4, 4);
+
+ if (!_lastClickRect.Contains(p) || timestamp - _lastClickTime > doubleClickTime)
+ {
+ _clickCount = 0;
+ }
+
+ ++_clickCount;
+ _lastClickTime = timestamp;
+ _lastClickRect = new Rect(p, new Size())
+ .Inflate(new Thickness(doubleClickSize.Width / 2, doubleClickSize.Height / 2));
+ _lastMouseDownButton = properties.PointerUpdateKind.GetMouseButton();
+ var e = new PointerPressedEventArgs(source, pointer, root, p, timestamp, properties, inputModifiers, _clickCount);
+ source.RaiseEvent(e);
+ return e.Handled;
+ }
+
+ return false;
+ }
+
+ private bool PenMove(Pointer pointer, ulong timestamp,
+ IInputRoot root, Point p, PointerPointProperties properties,
+ KeyModifiers inputModifiers, IInputElement? hitTest,
+ Lazy?>? intermediatePoints)
+ {
+ var source = pointer.Captured ?? hitTest;
+
+ if (source is not null)
+ {
+ var e = new PointerEventArgs(InputElement.PointerMovedEvent, source, pointer, root,
+ p, timestamp, properties, inputModifiers, intermediatePoints);
+
+ source.RaiseEvent(e);
+ return e.Handled;
+ }
+
+ return false;
+ }
+
+ private bool PenUp(Pointer pointer, ulong timestamp,
+ IInputElement root, Point p, PointerPointProperties properties,
+ KeyModifiers inputModifiers, IInputElement? hitTest)
+ {
+ var source = pointer.Captured ?? hitTest;
+
+ if (source is not null)
+ {
+ var e = new PointerReleasedEventArgs(source, pointer, root, p, timestamp, properties, inputModifiers,
+ _lastMouseDownButton);
+
+ source?.RaiseEvent(e);
+ pointer.Capture(null);
+ return e.Handled;
+ }
+
+ return false;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ var values = _pointers.Values.ToList();
+ _pointers.Clear();
+ _disposed = true;
+ foreach (var p in values)
+ p.Dispose();
+ }
+
+ public IPointer? TryGetPointer(RawPointerEventArgs ev)
+ {
+ return _pointers.TryGetValue(ev.RawPointerId, out var pointer)
+ ? pointer
+ : null;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Input/PointerEventArgs.cs b/src/Avalonia.Base/Input/PointerEventArgs.cs
index 5495802920..1f3c726e7b 100644
--- a/src/Avalonia.Base/Input/PointerEventArgs.cs
+++ b/src/Avalonia.Base/Input/PointerEventArgs.cs
@@ -11,7 +11,7 @@ namespace Avalonia.Input
private readonly IVisual? _rootVisual;
private readonly Point _rootVisualPosition;
private readonly PointerPointProperties _properties;
- private Lazy?>? _previousPoints;
+ private readonly Lazy?>? _previousPoints;
public PointerEventArgs(RoutedEvent routedEvent,
IInteractive? source,
@@ -43,55 +43,20 @@ namespace Avalonia.Input
{
_previousPoints = previousPoints;
}
-
-
- class EmulatedDevice : IPointerDevice
- {
- private readonly PointerEventArgs _ev;
-
- public EmulatedDevice(PointerEventArgs ev)
- {
- _ev = ev;
- }
-
- public void ProcessRawEvent(RawInputEventArgs ev) => throw new NotSupportedException();
-
- public IInputElement? Captured => _ev.Pointer.Captured;
- public void Capture(IInputElement? control)
- {
- _ev.Pointer.Capture(control);
- }
-
- public Point GetPosition(IVisual relativeTo) => _ev.GetPosition(relativeTo);
-
- public IPointer? TryGetPointer(RawPointerEventArgs ev) => _ev.Pointer;
- }
+ ///
+ /// Gets specific pointer generated by input device.
+ ///
public IPointer Pointer { get; }
- public ulong Timestamp { get; }
- private IPointerDevice? _device;
-
- [Obsolete("Use Pointer to get pointer-specific information")]
- public IPointerDevice Device => _device ?? (_device = new EmulatedDevice(this));
+ ///
+ /// Gets the time when the input occurred.
+ ///
+ public ulong Timestamp { get; }
- [Obsolete("Use KeyModifiers and PointerPointProperties")]
- public InputModifiers InputModifiers
- {
- get
- {
- var mods = (InputModifiers)KeyModifiers;
- if (_properties.IsLeftButtonPressed)
- mods |= InputModifiers.LeftMouseButton;
- if (_properties.IsMiddleButtonPressed)
- mods |= InputModifiers.MiddleMouseButton;
- if (_properties.IsRightButtonPressed)
- mods |= InputModifiers.RightMouseButton;
-
- return mods;
- }
- }
-
+ ///
+ /// Gets a value that indicates which key modifiers were active at the time that the pointer event was initiated.
+ ///
public KeyModifiers KeyModifiers { get; }
private Point GetPosition(Point pt, IVisual? relativeTo)
@@ -102,12 +67,14 @@ namespace Avalonia.Input
return pt;
return pt * _rootVisual.TransformToVisual(relativeTo) ?? default;
}
-
+
+ ///
+ /// Gets the pointer position relative to a control.
+ ///
+ /// The control.
+ /// The pointer position in the control's coordinates.
public Point GetPosition(IVisual? relativeTo) => GetPosition(_rootVisualPosition, relativeTo);
- [Obsolete("Use GetCurrentPoint")]
- public PointerPoint GetPointerPoint(IVisual? relativeTo) => GetCurrentPoint(relativeTo);
-
///
/// Returns the PointerPoint associated with the current event
///
@@ -130,7 +97,8 @@ namespace Avalonia.Input
for (var c = 0; c < previousPoints.Count; c++)
{
var pt = previousPoints[c];
- points[c] = new PointerPoint(Pointer, GetPosition(pt.Position, relativeTo), _properties);
+ var pointProperties = new PointerPointProperties(_properties, pt);
+ points[c] = new PointerPoint(Pointer, GetPosition(pt.Position, relativeTo), pointProperties);
}
points[points.Length - 1] = GetCurrentPoint(relativeTo);
@@ -155,8 +123,6 @@ namespace Avalonia.Input
public class PointerPressedEventArgs : PointerEventArgs
{
- private readonly int _clickCount;
-
public PointerPressedEventArgs(
IInteractive source,
IPointer pointer,
@@ -168,13 +134,10 @@ namespace Avalonia.Input
: base(InputElement.PointerPressedEvent, source, pointer, rootVisual, rootVisualPosition,
timestamp, properties, modifiers)
{
- _clickCount = clickCount;
+ ClickCount = clickCount;
}
- public int ClickCount => _clickCount;
-
- [Obsolete("Use PointerPressedEventArgs.GetCurrentPoint(this).Properties")]
- public MouseButton MouseButton => Properties.PointerUpdateKind.GetMouseButton();
+ public int ClickCount { get; }
}
public class PointerReleasedEventArgs : PointerEventArgs
@@ -194,9 +157,6 @@ namespace Avalonia.Input
/// Gets the mouse button that triggered the corresponding PointerPressed event
///
public MouseButton InitialPressMouseButton { get; }
-
- [Obsolete("Use InitialPressMouseButton")]
- public MouseButton MouseButton => InitialPressMouseButton;
}
public class PointerCaptureLostEventArgs : RoutedEventArgs
diff --git a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
index 67d1eea7e3..a95d66346a 100644
--- a/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
+++ b/src/Avalonia.Base/Input/PointerOverPreProcessor.cs
@@ -15,6 +15,8 @@ namespace Avalonia.Input
_inputRoot = inputRoot ?? throw new ArgumentNullException(nameof(inputRoot));
}
+ public PixelPoint? LastPosition => _lastPointer?.position;
+
public void OnCompleted()
{
ClearPointerOver();
diff --git a/src/Avalonia.Base/Input/PointerPoint.cs b/src/Avalonia.Base/Input/PointerPoint.cs
index 9f8285a8e1..71145b5cb0 100644
--- a/src/Avalonia.Base/Input/PointerPoint.cs
+++ b/src/Avalonia.Base/Input/PointerPoint.cs
@@ -1,5 +1,10 @@
+using Avalonia.Input.Raw;
+
namespace Avalonia.Input
{
+ ///
+ /// Provides basic properties for the input pointer associated with a single mouse, pen/stylus, or touch contact.
+ ///
public sealed class PointerPoint
{
public PointerPoint(IPointer pointer, Point position, PointerPointProperties properties)
@@ -8,25 +13,109 @@ namespace Avalonia.Input
Position = position;
Properties = properties;
}
+
+ ///
+ /// Gets specific pointer generated by input device.
+ ///
public IPointer Pointer { get; }
+
+ ///
+ /// Gets extended information about the input pointer.
+ ///
public PointerPointProperties Properties { get; }
+
+ ///
+ /// Gets the location of the pointer input in client coordinates.
+ ///
public Point Position { get; }
}
+ ///
+ /// Provides extended properties for a PointerPoint object.
+ ///
public sealed class PointerPointProperties
{
+ ///
+ /// Gets a value that indicates whether the pointer input was triggered by the primary action mode of an input device.
+ ///
public bool IsLeftButtonPressed { get; }
+
+ ///
+ /// Gets a value that indicates whether the pointer input was triggered by the tertiary action mode of an input device.
+ ///
public bool IsMiddleButtonPressed { get; }
+
+ ///
+ /// Gets a value that indicates whether the pointer input was triggered by the secondary action mode (if supported) of an input device.
+ ///
public bool IsRightButtonPressed { get; }
+
+ ///
+ /// Gets a value that indicates whether the pointer input was triggered by the first extended mouse button (XButton1).
+ ///
public bool IsXButton1Pressed { get; }
+
+ ///
+ /// Gets a value that indicates whether the pointer input was triggered by the second extended mouse button (XButton2).
+ ///
public bool IsXButton2Pressed { get; }
+ ///
+ /// Gets a value that indicates whether the barrel button of the pen/stylus device is pressed.
+ ///
+ public bool IsBarrelButtonPressed { get; }
+
+ ///
+ /// Gets a value that indicates whether the input is from a pen eraser.
+ ///
+ public bool IsEraser { get; }
+
+ ///
+ /// Gets a value that indicates whether the digitizer pen is inverted.
+ ///
+ public bool IsInverted { get; }
+
+ ///
+ /// Gets the clockwise rotation in degrees of a pen device around its own major axis (such as when the user spins the pen in their fingers).
+ ///
+ ///
+ /// A value between 0.0 and 359.0 in degrees of rotation. The default value is 0.0.
+ ///
+ public float Twist { get; }
+
+ ///
+ /// Gets a value that indicates the force that the pointer device (typically a pen/stylus) exerts on the surface of the digitizer.
+ ///
+ ///
+ /// A value from 0 to 1.0. The default value is 0.5.
+ ///
+ public float Pressure { get; } = 0.5f;
+
+ ///
+ /// Gets the plane angle between the Y-Z plane and the plane that contains the Y axis and the axis of the input device (typically a pen/stylus).
+ ///
+ ///
+ /// The value is 0.0 when the finger or pen is perpendicular to the digitizer surface, between 0.0 and 90.0 when tilted to the right of perpendicular, and between 0.0 and -90.0 when tilted to the left of perpendicular. The default value is 0.0.
+ ///
+ public float XTilt { get; }
+
+ ///
+ /// Gets the plane angle between the X-Z plane and the plane that contains the X axis and the axis of the input device (typically a pen/stylus).
+ ///
+ ///
+ /// The value is 0.0 when the finger or pen is perpendicular to the digitizer surface, between 0.0 and 90.0 when tilted towards the user, and between 0.0 and -90.0 when tilted away from the user. The default value is 0.0.
+ ///
+ public float YTilt { get; }
+
+ ///
+ /// Gets the kind of pointer state change.
+ ///
public PointerUpdateKind PointerUpdateKind { get; }
private PointerPointProperties()
- {
+ {
}
-
+
public PointerPointProperties(RawInputModifiers modifiers, PointerUpdateKind kind)
{
PointerUpdateKind = kind;
@@ -36,10 +125,13 @@ namespace Avalonia.Input
IsRightButtonPressed = modifiers.HasAllFlags(RawInputModifiers.RightMouseButton);
IsXButton1Pressed = modifiers.HasAllFlags(RawInputModifiers.XButton1MouseButton);
IsXButton2Pressed = modifiers.HasAllFlags(RawInputModifiers.XButton2MouseButton);
+ IsInverted = modifiers.HasAllFlags(RawInputModifiers.PenInverted);
+ IsEraser = modifiers.HasAllFlags(RawInputModifiers.PenEraser);
+ IsBarrelButtonPressed = modifiers.HasAllFlags(RawInputModifiers.PenBarrelButton);
// The underlying input source might be reporting the previous state,
// so make sure that we reflect the current state
-
+
if (kind == PointerUpdateKind.LeftButtonPressed)
IsLeftButtonPressed = true;
if (kind == PointerUpdateKind.LeftButtonReleased)
@@ -62,6 +154,33 @@ namespace Avalonia.Input
IsXButton2Pressed = false;
}
+ public PointerPointProperties(RawInputModifiers modifiers, PointerUpdateKind kind,
+ float twist, float pressure, float xTilt, float yTilt
+ ) : this (modifiers, kind)
+ {
+ Twist = twist;
+ Pressure = pressure;
+ XTilt = xTilt;
+ YTilt = yTilt;
+ }
+
+ internal PointerPointProperties(PointerPointProperties basedOn, RawPointerPoint rawPoint)
+ {
+ IsLeftButtonPressed = basedOn.IsLeftButtonPressed;
+ IsMiddleButtonPressed = basedOn.IsMiddleButtonPressed;
+ IsRightButtonPressed = basedOn.IsRightButtonPressed;
+ IsXButton1Pressed = basedOn.IsXButton1Pressed;
+ IsXButton2Pressed = basedOn.IsXButton2Pressed;
+ IsInverted = basedOn.IsInverted;
+ IsEraser = basedOn.IsEraser;
+ IsBarrelButtonPressed = basedOn.IsBarrelButtonPressed;
+
+ Twist = rawPoint.Twist;
+ Pressure = rawPoint.Pressure;
+ XTilt = rawPoint.XTilt;
+ YTilt = rawPoint.YTilt;
+ }
+
public static PointerPointProperties None { get; } = new PointerPointProperties();
}
diff --git a/src/Avalonia.Base/Input/Raw/RawDragEvent.cs b/src/Avalonia.Base/Input/Raw/RawDragEvent.cs
index 652bad7115..c903aad684 100644
--- a/src/Avalonia.Base/Input/Raw/RawDragEvent.cs
+++ b/src/Avalonia.Base/Input/Raw/RawDragEvent.cs
@@ -8,8 +8,6 @@ namespace Avalonia.Input.Raw
public IDataObject Data { get; }
public DragDropEffects Effects { get; set; }
public RawDragEventType Type { get; }
- [Obsolete("Use KeyModifiers")]
- public InputModifiers Modifiers { get; }
public KeyModifiers KeyModifiers { get; }
public RawDragEvent(IDragDropDevice inputDevice, RawDragEventType type,
@@ -21,9 +19,6 @@ namespace Avalonia.Input.Raw
Data = data;
Effects = effects;
KeyModifiers = modifiers.ToKeyModifiers();
-#pragma warning disable CS0618 // Type or member is obsolete
- Modifiers = (InputModifiers)modifiers;
-#pragma warning restore CS0618 // Type or member is obsolete
}
}
}
diff --git a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
index 8b9d7c161d..0e4e0ed3e2 100644
--- a/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
+++ b/src/Avalonia.Base/Input/Raw/RawPointerEventArgs.cs
@@ -56,11 +56,12 @@ namespace Avalonia.Input.Raw
Contract.Requires(device != null);
Contract.Requires(root != null);
+ Point = new RawPointerPoint();
Position = position;
Type = type;
InputModifiers = inputModifiers;
}
-
+
///
/// Initializes a new instance of the class.
///
@@ -87,6 +88,11 @@ namespace Avalonia.Input.Raw
InputModifiers = inputModifiers;
}
+ ///
+ /// Gets the raw pointer identifier.
+ ///
+ public long RawPointerId { get; set; }
+
///
/// Gets the pointer properties and position, in client DIPs.
///
@@ -130,10 +136,17 @@ namespace Avalonia.Input.Raw
/// Pointer position, in client DIPs.
///
public Point Position { get; set; }
-
+
+ public float Twist { get; set; }
+ public float Pressure { get; set; }
+ public float XTilt { get; set; }
+ public float YTilt { get; set; }
+
+
public RawPointerPoint()
{
- Position = default;
+ this = default;
+ Pressure = 0.5f;
}
}
}
diff --git a/src/Avalonia.Base/Input/Raw/RawTouchEventArgs.cs b/src/Avalonia.Base/Input/Raw/RawTouchEventArgs.cs
index 020b40e55b..3e842e6e3f 100644
--- a/src/Avalonia.Base/Input/Raw/RawTouchEventArgs.cs
+++ b/src/Avalonia.Base/Input/Raw/RawTouchEventArgs.cs
@@ -1,15 +1,23 @@
+using System;
+
namespace Avalonia.Input.Raw
{
public class RawTouchEventArgs : RawPointerEventArgs
{
public RawTouchEventArgs(IInputDevice device, ulong timestamp, IInputRoot root,
RawPointerEventType type, Point position, RawInputModifiers inputModifiers,
- long touchPointId)
+ long rawPointerId)
: base(device, timestamp, root, type, position, inputModifiers)
{
- TouchPointId = touchPointId;
+ RawPointerId = rawPointerId;
}
- public long TouchPointId { get; set; }
+ public RawTouchEventArgs(IInputDevice device, ulong timestamp, IInputRoot root,
+ RawPointerEventType type, RawPointerPoint point, RawInputModifiers inputModifiers,
+ long rawPointerId)
+ : base(device, timestamp, root, type, point, inputModifiers)
+ {
+ RawPointerId = rawPointerId;
+ }
}
}
diff --git a/src/Avalonia.Base/Input/TouchDevice.cs b/src/Avalonia.Base/Input/TouchDevice.cs
index 54dcc4051e..1d5b1d6bbf 100644
--- a/src/Avalonia.Base/Input/TouchDevice.cs
+++ b/src/Avalonia.Base/Input/TouchDevice.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia.Input.Raw;
using Avalonia.Platform;
-using Avalonia.VisualTree;
namespace Avalonia.Input
{
@@ -20,9 +19,6 @@ namespace Avalonia.Input
private int _clickCount;
private Rect _lastClickRect;
private ulong _lastClickTime;
- private Pointer? _lastPointer;
-
- IInputElement? IPointerDevice.Captured => _lastPointer?.Captured;
RawInputModifiers GetModifiers(RawInputModifiers modifiers, bool isLeftButtonDown)
{
@@ -32,26 +28,21 @@ namespace Avalonia.Input
return rv;
}
- void IPointerDevice.Capture(IInputElement? control) => _lastPointer?.Capture(control);
-
- Point IPointerDevice.GetPosition(IVisual relativeTo) => default;
-
public void ProcessRawEvent(RawInputEventArgs ev)
{
if (ev.Handled || _disposed)
return;
- var args = (RawTouchEventArgs)ev;
- if (!_pointers.TryGetValue(args.TouchPointId, out var pointer))
+ var args = (RawPointerEventArgs)ev;
+ if (!_pointers.TryGetValue(args.RawPointerId, out var pointer))
{
if (args.Type == RawPointerEventType.TouchEnd)
return;
var hit = args.InputHitTestResult;
- _pointers[args.TouchPointId] = pointer = new Pointer(Pointer.GetNextFreeId(),
+ _pointers[args.RawPointerId] = pointer = new Pointer(Pointer.GetNextFreeId(),
PointerType.Touch, _pointers.Count == 0);
pointer.Capture(hit);
}
- _lastPointer = pointer;
var target = pointer.Captured ?? args.Root;
var updateKind = args.Type.ToUpdateKind();
@@ -88,7 +79,7 @@ namespace Avalonia.Input
if (args.Type == RawPointerEventType.TouchEnd)
{
- _pointers.Remove(args.TouchPointId);
+ _pointers.Remove(args.RawPointerId);
using (pointer)
{
target.RaiseEvent(new PointerReleasedEventArgs(target, pointer,
@@ -96,15 +87,13 @@ namespace Avalonia.Input
new PointerPointProperties(GetModifiers(args.InputModifiers, false), updateKind),
keyModifier, MouseButton.Left));
}
- _lastPointer = null;
}
if (args.Type == RawPointerEventType.TouchCancel)
{
- _pointers.Remove(args.TouchPointId);
+ _pointers.Remove(args.RawPointerId);
using (pointer)
pointer.Capture(null);
- _lastPointer = null;
}
if (args.Type == RawPointerEventType.TouchUpdate)
@@ -129,8 +118,7 @@ namespace Avalonia.Input
public IPointer? TryGetPointer(RawPointerEventArgs ev)
{
- return ev is RawTouchEventArgs args
- && _pointers.TryGetValue(args.TouchPointId, out var pointer)
+ return _pointers.TryGetValue(ev.RawPointerId, out var pointer)
? pointer
: null;
}
diff --git a/src/Avalonia.Base/Layout/LayoutManager.cs b/src/Avalonia.Base/Layout/LayoutManager.cs
index 446f135c83..b9ca6bfbd7 100644
--- a/src/Avalonia.Base/Layout/LayoutManager.cs
+++ b/src/Avalonia.Base/Layout/LayoutManager.cs
@@ -350,7 +350,7 @@ namespace Avalonia.Layout
{
for (var i = 0; i < count; ++i)
{
- var l = _effectiveViewportChangedListeners[i];
+ var l = listeners[i];
if (!l.Listener.IsAttachedToVisualTree)
{
@@ -362,7 +362,7 @@ namespace Avalonia.Layout
if (viewport != l.Viewport)
{
l.Listener.EffectiveViewportChanged(new EffectiveViewportChangedEventArgs(viewport));
- _effectiveViewportChangedListeners[i] = new EffectiveViewportChangedListener(l.Listener, viewport);
+ l.Viewport = viewport;
}
}
}
@@ -414,7 +414,7 @@ namespace Avalonia.Layout
}
}
- private readonly struct EffectiveViewportChangedListener
+ private class EffectiveViewportChangedListener
{
public EffectiveViewportChangedListener(ILayoutable listener, Rect viewport)
{
@@ -423,7 +423,7 @@ namespace Avalonia.Layout
}
public ILayoutable Listener { get; }
- public Rect Viewport { get; }
+ public Rect Viewport { get; set; }
}
}
}
diff --git a/src/Avalonia.Base/Logging/LogArea.cs b/src/Avalonia.Base/Logging/LogArea.cs
index c049f9e763..98ef6d2530 100644
--- a/src/Avalonia.Base/Logging/LogArea.cs
+++ b/src/Avalonia.Base/Logging/LogArea.cs
@@ -44,5 +44,15 @@ namespace Avalonia.Logging
/// The log event comes from X11Platform.
///
public const string X11Platform = nameof(X11Platform);
+
+ ///
+ /// The log event comes from AndroidPlatform.
+ ///
+ public const string AndroidPlatform = nameof(AndroidPlatform);
+
+ ///
+ /// The log event comes from IOSPlatform.
+ ///
+ public const string IOSPlatform = nameof(IOSPlatform);
}
}
diff --git a/src/Avalonia.Base/Media/GlyphRun.cs b/src/Avalonia.Base/Media/GlyphRun.cs
index ac87d521a5..cae7a8fe75 100644
--- a/src/Avalonia.Base/Media/GlyphRun.cs
+++ b/src/Avalonia.Base/Media/GlyphRun.cs
@@ -265,7 +265,7 @@ namespace Avalonia.Media
//RightToLeft
var glyphIndex = FindGlyphIndex(characterIndex);
- if (GlyphClusters != null)
+ if (GlyphClusters != null && GlyphClusters.Count > 0)
{
if (characterIndex > GlyphClusters[0])
{
@@ -445,7 +445,7 @@ namespace Avalonia.Media
///
public int FindGlyphIndex(int characterIndex)
{
- if (GlyphClusters == null)
+ if (GlyphClusters == null || GlyphClusters.Count == 0)
{
return characterIndex;
}
@@ -614,17 +614,29 @@ namespace Avalonia.Media
private GlyphRunMetrics CreateGlyphRunMetrics()
{
+ var firstCluster = 0;
+ var lastCluster = Characters.Length - 1;
+
+ if (!IsLeftToRight)
+ {
+ var cluster = firstCluster;
+ firstCluster = lastCluster;
+ lastCluster = cluster;
+ }
+
if (GlyphClusters != null && GlyphClusters.Count > 0)
{
- var firstCluster = GlyphClusters[0];
+ firstCluster = GlyphClusters[0];
+ lastCluster = GlyphClusters[GlyphClusters.Count - 1];
_offsetToFirstCharacter = Math.Max(0, Characters.Start - firstCluster);
}
+ var isReversed = firstCluster > lastCluster;
var height = (GlyphTypeface.Descent - GlyphTypeface.Ascent + GlyphTypeface.LineGap) * Scale;
var widthIncludingTrailingWhitespace = 0d;
- var trailingWhitespaceLength = GetTrailingWhitespaceLength(out var newLineLength, out var glyphCount);
+ var trailingWhitespaceLength = GetTrailingWhitespaceLength(isReversed, out var newLineLength, out var glyphCount);
for (var index = 0; index < GlyphIndices.Count; index++)
{
@@ -635,16 +647,16 @@ namespace Avalonia.Media
var width = widthIncludingTrailingWhitespace;
- if (IsLeftToRight)
+ if (isReversed)
{
- for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
+ for (var index = 0; index < glyphCount; index++)
{
width -= GetGlyphAdvance(index, out _);
}
}
else
{
- for (var index = 0; index < glyphCount; index++)
+ for (var index = GlyphIndices.Count - glyphCount; index < GlyphIndices.Count; index++)
{
width -= GetGlyphAdvance(index, out _);
}
@@ -654,16 +666,15 @@ namespace Avalonia.Media
height);
}
- private int GetTrailingWhitespaceLength(out int newLineLength, out int glyphCount)
- {
- glyphCount = 0;
- newLineLength = 0;
-
- if (Characters.IsEmpty)
+ private int GetTrailingWhitespaceLength(bool isReversed, out int newLineLength, out int glyphCount)
+ {
+ if (isReversed)
{
- return 0;
+ return GetTralingWhitespaceLengthRightToLeft(out newLineLength, out glyphCount);
}
+ glyphCount = 0;
+ newLineLength = 0;
var trailingWhitespaceLength = 0;
if (GlyphClusters == null)
@@ -732,6 +743,78 @@ namespace Avalonia.Media
return trailingWhitespaceLength;
}
+ private int GetTralingWhitespaceLengthRightToLeft(out int newLineLength, out int glyphCount)
+ {
+ glyphCount = 0;
+ newLineLength = 0;
+ var trailingWhitespaceLength = 0;
+
+ if (GlyphClusters == null)
+ {
+ for (var i = 0; i < Characters.Length;)
+ {
+ var codepoint = Codepoint.ReadAt(_characters, i, out var count);
+
+ if (!codepoint.IsWhiteSpace)
+ {
+ break;
+ }
+
+ if (codepoint.IsBreakChar)
+ {
+ newLineLength++;
+ }
+
+ trailingWhitespaceLength++;
+
+ i += count;
+ glyphCount++;
+ }
+ }
+ else
+ {
+ for (var i = 0; i < GlyphClusters.Count; i++)
+ {
+ var currentCluster = GlyphClusters[i];
+ var characterIndex = Math.Max(0, currentCluster - _characters.BufferOffset);
+ var codepoint = Codepoint.ReadAt(_characters, characterIndex, out _);
+
+ if (!codepoint.IsWhiteSpace)
+ {
+ break;
+ }
+
+ var clusterLength = 1;
+
+ while (i - 1 >= 0)
+ {
+ var nextCluster = GlyphClusters[i - 1];
+
+ if (currentCluster == nextCluster)
+ {
+ clusterLength++;
+ i--;
+
+ continue;
+ }
+
+ break;
+ }
+
+ if (codepoint.IsBreakChar)
+ {
+ newLineLength += clusterLength;
+ }
+
+ trailingWhitespaceLength += clusterLength;
+
+ glyphCount++;
+ }
+ }
+
+ return trailingWhitespaceLength;
+ }
+
private void Set(ref T field, T value)
{
_glyphRunImpl?.Dispose();
diff --git a/src/Avalonia.Base/Media/TextDecoration.cs b/src/Avalonia.Base/Media/TextDecoration.cs
index 8eeb86c555..4c9764af96 100644
--- a/src/Avalonia.Base/Media/TextDecoration.cs
+++ b/src/Avalonia.Base/Media/TextDecoration.cs
@@ -209,7 +209,7 @@ namespace Avalonia.Media
var pen = new Pen(Stroke ?? defaultBrush, thickness,
new DashStyle(StrokeDashArray, StrokeDashOffset), StrokeLineCap);
- drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Size.Width, 0));
+ drawingContext.DrawLine(pen, origin, origin + new Point(glyphRun.Metrics.Width, 0));
}
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs
index 97df87d3d9..7ab67ea34d 100644
--- a/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/FormattedTextSource.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Utilities;
namespace Avalonia.Media.TextFormatting
@@ -116,7 +117,30 @@ namespace Avalonia.Media.TextFormatting
length = text.Length;
}
+ length = CoerceLength(text, length);
+
return new ValueSpan(firstTextSourceIndex, length, currentProperties);
}
+
+ private static int CoerceLength(ReadOnlySlice text, int length)
+ {
+ var finalLength = 0;
+
+ var graphemeEnumerator = new GraphemeEnumerator(text);
+
+ while (graphemeEnumerator.MoveNext())
+ {
+ var grapheme = graphemeEnumerator.Current;
+
+ finalLength += grapheme.Text.Length;
+
+ if (finalLength >= length)
+ {
+ return finalLength;
+ }
+ }
+
+ return length;
+ }
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
index df83ada34a..a49e4ef13b 100644
--- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
@@ -15,6 +15,13 @@ namespace Avalonia.Media.TextFormatting
public override void Justify(TextLine textLine)
{
+ var lineImpl = textLine as TextLineImpl;
+
+ if(lineImpl is null)
+ {
+ return;
+ }
+
var paragraphWidth = Width;
if (double.IsInfinity(paragraphWidth))
@@ -22,12 +29,12 @@ namespace Avalonia.Media.TextFormatting
return;
}
- if (textLine.NewLineLength > 0)
+ if (lineImpl.NewLineLength > 0)
{
return;
}
- var textLineBreak = textLine.TextLineBreak;
+ var textLineBreak = lineImpl.TextLineBreak;
if (textLineBreak is not null && textLineBreak.TextEndOfLine is not null)
{
@@ -39,7 +46,7 @@ namespace Avalonia.Media.TextFormatting
var breakOportunities = new Queue();
- foreach (var textRun in textLine.TextRuns)
+ foreach (var textRun in lineImpl.TextRuns)
{
var text = textRun.Text;
@@ -68,10 +75,10 @@ namespace Avalonia.Media.TextFormatting
return;
}
- var remainingSpace = Math.Max(0, paragraphWidth - textLine.WidthIncludingTrailingWhitespace);
+ var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.WidthIncludingTrailingWhitespace);
var spacing = remainingSpace / breakOportunities.Count;
- foreach (var textRun in textLine.TextRuns)
+ foreach (var textRun in lineImpl.TextRuns)
{
var text = textRun.Text;
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
index ab72601c3e..42a9e61c36 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
@@ -38,7 +38,7 @@ namespace Avalonia.Media.TextFormatting
/// Gets a list of .
///
/// The shapeable text characters.
- internal IReadOnlyList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel,
+ internal IReadOnlyList GetShapeableCharacters(ReadOnlySlice runText, sbyte biDiLevel,
ref TextRunProperties? previousProperties)
{
var shapeableCharacters = new List(2);
@@ -65,7 +65,7 @@ namespace Avalonia.Media.TextFormatting
/// The bidi level of the run.
///
/// A list of shapeable text runs.
- private static ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text,
+ private static ShapeableTextCharacters CreateShapeableRun(ReadOnlySlice text,
TextRunProperties defaultProperties, sbyte biDiLevel, ref TextRunProperties? previousProperties)
{
var defaultTypeface = defaultProperties.Typeface;
@@ -76,7 +76,7 @@ namespace Avalonia.Media.TextFormatting
{
if (script == Script.Common && previousTypeface is not null)
{
- if(TryGetShapeableLength(text, previousTypeface.Value, defaultTypeface, out var fallbackCount, out _))
+ if (TryGetShapeableLength(text, previousTypeface.Value, defaultTypeface, out var fallbackCount, out _))
{
return new ShapeableTextCharacters(text.Take(fallbackCount),
defaultProperties.WithTypeface(previousTypeface.Value), biDiLevel);
@@ -86,10 +86,10 @@ namespace Avalonia.Media.TextFormatting
return new ShapeableTextCharacters(text.Take(count), defaultProperties.WithTypeface(currentTypeface),
biDiLevel);
}
-
+
if (previousTypeface is not null)
{
- if(TryGetShapeableLength(text, previousTypeface.Value, defaultTypeface, out count, out _))
+ if (TryGetShapeableLength(text, previousTypeface.Value, defaultTypeface, out count, out _))
{
return new ShapeableTextCharacters(text.Take(count),
defaultProperties.WithTypeface(previousTypeface.Value), biDiLevel);
@@ -106,12 +106,12 @@ namespace Avalonia.Media.TextFormatting
{
continue;
}
-
+
codepoint = codepointEnumerator.Current;
-
+
break;
}
-
+
//ToDo: Fix FontFamily fallback
var matchFound =
FontManager.Current.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight,
@@ -157,14 +157,14 @@ namespace Avalonia.Media.TextFormatting
///
///
protected static bool TryGetShapeableLength(
- ReadOnlySlice text,
- Typeface typeface,
+ ReadOnlySlice text,
+ Typeface typeface,
Typeface? defaultTypeface,
out int length,
out Script script)
{
length = 0;
- script = Script.Unknown;
+ script = Script.Unknown;
if (text.Length == 0)
{
@@ -182,7 +182,7 @@ namespace Avalonia.Media.TextFormatting
var currentScript = currentGrapheme.FirstCodepoint.Script;
- if (currentScript != Script.Common && defaultFont != null && defaultFont.TryGetGlyph(currentGrapheme.FirstCodepoint, out _))
+ if (!currentGrapheme.FirstCodepoint.IsWhiteSpace && defaultFont != null && defaultFont.TryGetGlyph(currentGrapheme.FirstCodepoint, out _))
{
break;
}
@@ -192,7 +192,7 @@ namespace Avalonia.Media.TextFormatting
{
break;
}
-
+
if (currentScript != script)
{
if (script is Script.Unknown || currentScript != Script.Common &&
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
index f3af240c58..0828b6518a 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
@@ -63,7 +63,7 @@ namespace Avalonia.Media.TextFormatting
MaxHeight = maxHeight;
- MaxLines = maxLines;
+ MaxLines = maxLines;
TextLines = CreateTextLines();
}
@@ -80,7 +80,7 @@ namespace Avalonia.Media.TextFormatting
/// The maximum number of text lines.
public TextLayout(
ITextSource textSource,
- TextParagraphProperties paragraphProperties,
+ TextParagraphProperties paragraphProperties,
TextTrimming? textTrimming = null,
double maxWidth = double.PositiveInfinity,
double maxHeight = double.PositiveInfinity,
@@ -178,24 +178,18 @@ namespace Avalonia.Media.TextFormatting
return new Rect();
}
- if (textPosition < 0 || textPosition >= _textSourceLength)
+ if (textPosition < 0)
{
- var lastLine = TextLines[TextLines.Count - 1];
-
- var lineX = lastLine.Width;
-
- var lineY = Bounds.Bottom - lastLine.Height;
-
- return new Rect(lineX, lineY, 0, lastLine.Height);
+ textPosition = _textSourceLength;
}
var currentY = 0.0;
foreach (var textLine in TextLines)
{
- var end = textLine.FirstTextSourceIndex + textLine.Length - 1;
+ var end = textLine.FirstTextSourceIndex + textLine.Length;
- if (end < textPosition)
+ if (end <= textPosition && end < _textSourceLength)
{
currentY += textLine.Height;
@@ -224,7 +218,7 @@ namespace Avalonia.Media.TextFormatting
}
var result = new List(TextLines.Count);
-
+
var currentY = 0d;
foreach (var textLine in TextLines)
@@ -239,7 +233,7 @@ namespace Avalonia.Media.TextFormatting
var textBounds = textLine.GetTextBounds(start, length);
- if(textBounds.Count > 0)
+ if (textBounds.Count > 0)
{
foreach (var bounds in textBounds)
{
@@ -262,7 +256,7 @@ namespace Avalonia.Media.TextFormatting
}
}
- if(textLine.FirstTextSourceIndex + textLine.Length >= start + length)
+ if (textLine.FirstTextSourceIndex + textLine.Length >= start + length)
{
break;
}
@@ -305,7 +299,7 @@ namespace Avalonia.Media.TextFormatting
return GetHitTestResult(currentLine, characterHit, point);
}
-
+
public int GetLineIndexFromCharacterIndex(int charIndex, bool trailingEdge)
{
if (charIndex < 0)
@@ -327,7 +321,7 @@ namespace Avalonia.Media.TextFormatting
continue;
}
- if (charIndex >= textLine.FirstTextSourceIndex &&
+ if (charIndex >= textLine.FirstTextSourceIndex &&
charIndex <= textLine.FirstTextSourceIndex + textLine.Length - (trailingEdge ? 0 : 1))
{
return index;
@@ -398,7 +392,7 @@ namespace Avalonia.Media.TextFormatting
/// The current left.
/// The current width.
/// The current height.
- private static void UpdateBounds(TextLine textLine,ref double left, ref double width, ref double height)
+ private static void UpdateBounds(TextLine textLine, ref double left, ref double width, ref double height)
{
var lineWidth = textLine.WidthIncludingTrailingWhitespace;
@@ -421,7 +415,7 @@ namespace Avalonia.Media.TextFormatting
{
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, double.PositiveInfinity, _paragraphProperties);
- Bounds = new Rect(0,0,0, textLine.Height);
+ Bounds = new Rect(0, 0, 0, textLine.Height);
return new List { textLine };
}
@@ -439,9 +433,9 @@ namespace Avalonia.Media.TextFormatting
var textLine = TextFormatter.Current.FormatLine(_textSource, _textSourceLength, MaxWidth,
_paragraphProperties, previousLine?.TextLineBreak);
- if(textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph)
+ if (textLine == null || textLine.Length == 0 || textLine.TextRuns.Count == 0 && textLine.TextLineBreak?.TextEndOfLine is TextEndOfParagraph)
{
- if(previousLine != null && previousLine.NewLineLength > 0)
+ if (previousLine != null && previousLine.NewLineLength > 0)
{
var emptyTextLine = TextFormatterImpl.CreateEmptyTextLine(_textSourceLength, MaxWidth, _paragraphProperties);
@@ -454,7 +448,7 @@ namespace Avalonia.Media.TextFormatting
}
_textSourceLength += textLine.Length;
-
+
//Fulfill max height constraint
if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight) && height + textLine.Height > MaxHeight)
{
@@ -485,12 +479,17 @@ namespace Avalonia.Media.TextFormatting
//Fulfill max lines constraint
if (MaxLines > 0 && textLines.Count >= MaxLines)
{
+ if(textLine.TextLineBreak is TextLineBreak lineBreak && lineBreak.RemainingRuns != null)
+ {
+ textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(width));
+ }
+
break;
}
}
//Make sure the TextLayout always contains at least on empty line
- if(textLines.Count == 0)
+ if (textLines.Count == 0)
{
var textLine = TextFormatterImpl.CreateEmptyTextLine(0, MaxWidth, _paragraphProperties);
@@ -501,7 +500,7 @@ namespace Avalonia.Media.TextFormatting
Bounds = new Rect(left, 0, width, height);
- if(_paragraphProperties.TextAlignment == TextAlignment.Justify)
+ if (_paragraphProperties.TextAlignment == TextAlignment.Justify)
{
var whitespaceWidth = 0d;
@@ -509,7 +508,7 @@ namespace Avalonia.Media.TextFormatting
{
var lineWhitespaceWidth = line.Width - line.WidthIncludingTrailingWhitespace;
- if(lineWhitespaceWidth > whitespaceWidth)
+ if (lineWhitespaceWidth > whitespaceWidth)
{
whitespaceWidth = lineWhitespaceWidth;
}
@@ -517,7 +516,7 @@ namespace Avalonia.Media.TextFormatting
var justificationWidth = width - whitespaceWidth;
- if(justificationWidth > 0)
+ if (justificationWidth > 0)
{
var justificationProperties = new InterWordJustification(justificationWidth);
@@ -538,8 +537,13 @@ namespace Avalonia.Media.TextFormatting
///
/// The collapsing width.
/// The .
- private TextCollapsingProperties GetCollapsingProperties(double width)
+ private TextCollapsingProperties? GetCollapsingProperties(double width)
{
+ if(_textTrimming == TextTrimming.None)
+ {
+ return null;
+ }
+
return _textTrimming.CreateCollapsingProperties(new TextCollapsingCreateInfo(width, _paragraphProperties.DefaultTextRunProperties));
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs
index c8a23097db..61b24dc8c5 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLine.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLine.cs
@@ -153,7 +153,7 @@ namespace Avalonia.Media.TextFormatting
///
/// A value that represents a collapsed line that can be displayed.
///
- public abstract TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList);
+ public abstract TextLine Collapse(params TextCollapsingProperties?[] collapsingPropertiesList);
///
/// Create a justified line based on justification text properties.
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
index 7c686358e2..fa1ab6fd29 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
@@ -119,7 +119,7 @@ namespace Avalonia.Media.TextFormatting
}
///
- public override TextLine Collapse(params TextCollapsingProperties[] collapsingPropertiesList)
+ public override TextLine Collapse(params TextCollapsingProperties?[] collapsingPropertiesList)
{
if (collapsingPropertiesList.Length == 0)
{
@@ -128,6 +128,11 @@ namespace Avalonia.Media.TextFormatting
var collapsingProperties = collapsingPropertiesList[0];
+ if(collapsingProperties is null)
+ {
+ return this;
+ }
+
var collapsedRuns = collapsingProperties.Collapse(this);
if (collapsedRuns is null)
@@ -166,58 +171,122 @@ namespace Avalonia.Media.TextFormatting
if (distance <= 0)
{
- // hit happens before the line, return the first position
var firstRun = _textRuns[0];
- if (firstRun is ShapedTextCharacters shapedTextCharacters)
- {
- return shapedTextCharacters.GlyphRun.GetCharacterHitFromDistance(distance, out _);
- }
+ return GetRunCharacterHit(firstRun, FirstTextSourceIndex, 0);
+ }
- return _resolvedFlowDirection == FlowDirection.LeftToRight ?
- new CharacterHit(FirstTextSourceIndex) :
- new CharacterHit(FirstTextSourceIndex + Length);
+ if (distance >= WidthIncludingTrailingWhitespace)
+ {
+ var lastRun = _textRuns[_textRuns.Count - 1];
+
+ return GetRunCharacterHit(lastRun, FirstTextSourceIndex + Length - lastRun.TextSourceLength, lastRun.Size.Width);
}
// process hit that happens within the line
var characterHit = new CharacterHit();
var currentPosition = FirstTextSourceIndex;
+ var currentDistance = 0.0;
- foreach (var currentRun in _textRuns)
+ for (var i = 0; i < _textRuns.Count; i++)
{
- switch (currentRun)
+ var currentRun = _textRuns[i];
+
+ if(currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight)
{
- case ShapedTextCharacters shapedRun:
+ var rightToLeftIndex = i;
+ currentPosition += currentRun.TextSourceLength;
+
+ while (rightToLeftIndex + 1 <= _textRuns.Count - 1)
+ {
+ var nextShaped = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters;
+
+ if (nextShaped == null || nextShaped.ShapedBuffer.IsLeftToRight)
{
- characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
+ break;
+ }
- var offset = Math.Max(0, currentPosition - shapedRun.Text.Start);
+ currentPosition += nextShaped.TextSourceLength;
- characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength);
+ rightToLeftIndex++;
+ }
+ for (var j = i; i <= rightToLeftIndex; j++)
+ {
+ if(j > _textRuns.Count - 1)
+ {
break;
}
- default:
+
+ currentRun = _textRuns[j];
+
+ if(currentDistance + currentRun.Size.Width <= distance)
{
- if (distance < currentRun.Size.Width / 2)
- {
- characterHit = new CharacterHit(currentPosition);
- }
- else
- {
- characterHit = new CharacterHit(currentPosition, currentRun.TextSourceLength);
- }
- break;
+ currentDistance += currentRun.Size.Width;
+ currentPosition -= currentRun.TextSourceLength;
+
+ continue;
}
+
+ characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance);
+
+ break;
+ }
}
- if (distance <= currentRun.Size.Width)
+ if (currentDistance + currentRun.Size.Width < distance)
{
- break;
+ currentDistance += currentRun.Size.Width;
+ currentPosition += currentRun.TextSourceLength;
+
+ continue;
}
- distance -= currentRun.Size.Width;
- currentPosition += currentRun.TextSourceLength;
+ characterHit = GetRunCharacterHit(currentRun, currentPosition, distance - currentDistance);
+
+ break;
+ }
+
+ return characterHit;
+ }
+
+ private static CharacterHit GetRunCharacterHit(DrawableTextRun run, int currentPosition, double distance)
+ {
+ CharacterHit characterHit;
+
+ switch (run)
+ {
+ case ShapedTextCharacters shapedRun:
+ {
+ characterHit = shapedRun.GlyphRun.GetCharacterHitFromDistance(distance, out _);
+
+ var offset = 0;
+
+ if (shapedRun.GlyphRun.IsLeftToRight)
+ {
+ offset = Math.Max(0, currentPosition - shapedRun.Text.Start);
+ }
+ //else
+ //{
+ // offset = Math.Max(0, currentPosition - shapedRun.Text.Start + shapedRun.Text.Length);
+ //}
+
+ characterHit = new CharacterHit(characterHit.FirstCharacterIndex + offset, characterHit.TrailingLength);
+
+ break;
+ }
+ default:
+ {
+ if (distance < run.Size.Width / 2)
+ {
+ characterHit = new CharacterHit(currentPosition);
+ }
+ else
+ {
+ characterHit = new CharacterHit(currentPosition, run.TextSourceLength);
+ }
+ break;
+ }
}
return characterHit;
@@ -226,136 +295,168 @@ namespace Avalonia.Media.TextFormatting
///
public override double GetDistanceFromCharacterHit(CharacterHit characterHit)
{
- var isTrailingHit = characterHit.TrailingLength > 0;
+ var flowDirection = _paragraphProperties.FlowDirection;
var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
- var currentDistance = Start;
var currentPosition = FirstTextSourceIndex;
var remainingLength = characterIndex - FirstTextSourceIndex;
- GlyphRun? lastRun = null;
+ var currentDistance = Start;
- for (var index = 0; index < _textRuns.Count; index++)
+ if (flowDirection == FlowDirection.LeftToRight)
{
- var textRun = _textRuns[index];
-
- switch (textRun)
+ for (var index = 0; index < _textRuns.Count; index++)
{
- case ShapedTextCharacters shapedTextCharacters:
- {
- var currentRun = shapedTextCharacters.GlyphRun;
+ var currentRun = _textRuns[index];
- if (lastRun != null)
- {
- if (!lastRun.IsLeftToRight && currentRun.IsLeftToRight &&
- currentRun.Characters.Start == characterHit.FirstCharacterIndex &&
- characterHit.TrailingLength == 0)
- {
- return currentDistance;
- }
- }
+ if (currentRun is ShapedTextCharacters shapedRun && !shapedRun.ShapedBuffer.IsLeftToRight)
+ {
+ var i = index;
+
+ var rightToLeftWidth = currentRun.Size.Width;
- //Look for a hit in within the current run
- if (currentPosition + remainingLength <= currentPosition + textRun.Text.Length)
+ while (i + 1 <= _textRuns.Count - 1)
+ {
+ var nextRun = _textRuns[i + 1];
+
+ if (nextRun is ShapedTextCharacters nextShapedRun && !nextShapedRun.ShapedBuffer.IsLeftToRight)
{
- characterHit = new CharacterHit(textRun.Text.Start + remainingLength);
+ i++;
- var distance = currentRun.GetDistanceFromCharacterHit(characterHit);
+ rightToLeftWidth += nextRun.Size.Width;
- return currentDistance + distance;
+ continue;
}
+
+ break;
+ }
- //Look at the left and right edge of the current run
- if (currentRun.IsLeftToRight)
+ if(i > index)
+ {
+ while (i >= index)
{
- if (_resolvedFlowDirection == FlowDirection.LeftToRight && (lastRun == null || lastRun.IsLeftToRight))
- {
- if (characterIndex <= currentPosition)
- {
- return currentDistance;
- }
- }
- else
- {
- if (characterIndex == currentPosition)
- {
- return currentDistance;
- }
- }
+ currentRun = _textRuns[i];
- if (characterIndex == currentPosition + textRun.Text.Length && isTrailingHit)
- {
- return currentDistance + currentRun.Size.Width;
- }
- }
- else
- {
- if (characterIndex == currentPosition)
+ rightToLeftWidth -= currentRun.Size.Width;
+
+ if (currentPosition + currentRun.TextSourceLength >= characterIndex)
{
- return currentDistance + currentRun.Size.Width;
+ break;
}
- var nextRun = index + 1 < _textRuns.Count ?
- _textRuns[index + 1] as ShapedTextCharacters :
- null;
+ currentPosition += currentRun.TextSourceLength;
- if (nextRun != null)
- {
- if (nextRun.ShapedBuffer.IsLeftToRight)
- {
- if (characterIndex == currentPosition + textRun.Text.Length)
- {
- return currentDistance;
- }
- }
- else
- {
- if (currentPosition + nextRun.Text.Length == characterIndex)
- {
- return currentDistance;
- }
- }
- }
- else
- {
- if (characterIndex > currentPosition + textRun.Text.Length)
- {
- return currentDistance;
- }
- }
+ remainingLength -= currentRun.TextSourceLength;
+
+ i--;
}
- lastRun = currentRun;
+ currentDistance += rightToLeftWidth;
+ }
+ }
+
+ if (currentPosition + currentRun.TextSourceLength >= characterIndex &&
+ TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength, flowDirection, out var distance, out _))
+ {
+ return Math.Max(0, currentDistance + distance);
+ }
- break;
+ //No hit hit found so we add the full width
+ currentDistance += currentRun.Size.Width;
+ currentPosition += currentRun.TextSourceLength;
+ remainingLength -= currentRun.TextSourceLength;
+ }
+ }
+ else
+ {
+ currentDistance += WidthIncludingTrailingWhitespace;
+
+ for (var index = _textRuns.Count - 1; index >= 0; index--)
+ {
+ var currentRun = _textRuns[index];
+
+ if (TryGetDistanceFromCharacterHit(currentRun, characterHit, currentPosition, remainingLength,
+ flowDirection, out var distance, out var currentGlyphRun))
+ {
+ if (currentGlyphRun != null)
+ {
+ distance = currentGlyphRun.Size.Width - distance;
}
- default:
+
+ return Math.Max(0, currentDistance - distance);
+ }
+
+ //No hit hit found so we add the full width
+ currentDistance -= currentRun.Size.Width;
+ currentPosition += currentRun.TextSourceLength;
+ remainingLength -= currentRun.TextSourceLength;
+ }
+ }
+
+ return Math.Max(0, currentDistance);
+ }
+
+ private static bool TryGetDistanceFromCharacterHit(
+ DrawableTextRun currentRun,
+ CharacterHit characterHit,
+ int currentPosition,
+ int remainingLength,
+ FlowDirection flowDirection,
+ out double distance,
+ out GlyphRun? currentGlyphRun)
+ {
+ var characterIndex = characterHit.FirstCharacterIndex + characterHit.TrailingLength;
+ var isTrailingHit = characterHit.TrailingLength > 0;
+
+ distance = 0;
+ currentGlyphRun = null;
+
+ switch (currentRun)
+ {
+ case ShapedTextCharacters shapedTextCharacters:
+ {
+ currentGlyphRun = shapedTextCharacters.GlyphRun;
+
+ if (currentPosition + remainingLength <= currentPosition + currentRun.Text.Length)
{
- if (characterIndex == currentPosition)
- {
- return currentDistance;
- }
+ characterHit = new CharacterHit(currentRun.Text.Start + remainingLength);
+
+ distance = currentGlyphRun.GetDistanceFromCharacterHit(characterHit);
+
+ return true;
+ }
- if (characterIndex == currentPosition + textRun.TextSourceLength)
+ if (currentPosition + remainingLength == currentPosition + currentRun.Text.Length && isTrailingHit)
+ {
+ if (currentGlyphRun.IsLeftToRight || flowDirection == FlowDirection.RightToLeft)
{
- return currentDistance + textRun.Size.Width;
+ distance = currentGlyphRun.Size.Width;
}
- break;
+ return true;
}
- }
- //No hit hit found so we add the full width
- currentDistance += textRun.Size.Width;
- currentPosition += textRun.TextSourceLength;
- remainingLength -= textRun.TextSourceLength;
+ break;
+ }
+ default:
+ {
+ if (characterIndex == currentPosition)
+ {
+ return true;
+ }
- if (remainingLength <= 0)
- {
- break;
- }
+ if (characterIndex == currentPosition + currentRun.TextSourceLength)
+ {
+ distance = currentRun.Size.Width;
+
+ return true;
+
+ }
+
+ break;
+ }
}
- return currentDistance;
+ return false;
}
///
@@ -440,121 +541,168 @@ namespace Avalonia.Media.TextFormatting
continue;
}
- if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
- {
- startX += currentRun.Size.Width;
-
- currentPosition += currentRun.TextSourceLength;
-
- continue;
- }
-
var characterLength = 0;
var endX = startX;
+ var runWidth = 0.0;
+ TextRunBounds? currentRunBounds = null;
- if (currentRun is ShapedTextCharacters currentShapedRun)
- {
- var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
-
- currentPosition += offset;
+ var currentShapedRun = currentRun as ShapedTextCharacters;
- var startIndex = currentRun.Text.Start + offset;
+ if (currentShapedRun != null && !currentShapedRun.ShapedBuffer.IsLeftToRight)
+ {
+ var rightToLeftIndex = index;
+ startX += currentShapedRun.Size.Width;
- var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
- currentShapedRun.ShapedBuffer.IsLeftToRight ?
- new CharacterHit(startIndex + remainingLength) :
- new CharacterHit(startIndex));
+ while (rightToLeftIndex + 1 <= _textRuns.Count - 1)
+ {
+ var nextShapedRun = _textRuns[rightToLeftIndex + 1] as ShapedTextCharacters;
- endX += endOffset;
+ if (nextShapedRun == null || nextShapedRun.ShapedBuffer.IsLeftToRight)
+ {
+ break;
+ }
- var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
- currentShapedRun.ShapedBuffer.IsLeftToRight ?
- new CharacterHit(startIndex) :
- new CharacterHit(startIndex + remainingLength));
+ startX += nextShapedRun.Size.Width;
- startX += startOffset;
+ rightToLeftIndex++;
+ }
- var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
- var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
+ if (TryGetTextRunBoundsRightToLeft(startX, firstTextSourceIndex, characterIndex, rightToLeftIndex, ref currentPosition, ref remainingLength, out currentRunBounds))
+ {
+ startX = currentRunBounds!.Rectangle.Left;
+ endX = currentRunBounds.Rectangle.Right;
- characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength);
+ runWidth = currentRunBounds.Rectangle.Width;
+ }
- currentDirection = currentShapedRun.ShapedBuffer.IsLeftToRight ?
- FlowDirection.LeftToRight :
- FlowDirection.RightToLeft;
+ currentDirection = FlowDirection.RightToLeft;
}
else
{
- if (currentPosition < firstTextSourceIndex)
+ if (currentShapedRun != null)
{
- startX += currentRun.Size.Width;
- }
+ if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
+ {
+ startX += currentRun.Size.Width;
- if (currentPosition + currentRun.TextSourceLength <= characterIndex)
- {
- endX += currentRun.Size.Width;
+ currentPosition += currentRun.TextSourceLength;
- characterLength = currentRun.TextSourceLength;
+ continue;
+ }
+
+ var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
+
+ currentPosition += offset;
+
+ var startIndex = currentRun.Text.Start + offset;
+
+ double startOffset;
+ double endOffset;
+
+ if (currentShapedRun.ShapedBuffer.IsLeftToRight)
+ {
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
+
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
+ }
+ else
+ {
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
+
+ if (currentPosition < startIndex)
+ {
+ startOffset = endOffset;
+ }
+ else
+ {
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
+ }
+ }
+
+ startX += startOffset;
+
+ endX += endOffset;
+
+ var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
+ var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
+
+ characterLength = Math.Abs(endHit.FirstCharacterIndex + endHit.TrailingLength - startHit.FirstCharacterIndex - startHit.TrailingLength);
+
+ currentDirection = FlowDirection.LeftToRight;
}
- }
+ else
+ {
+ if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
+ {
+ startX += currentRun.Size.Width;
- if (endX < startX)
- {
- (endX, startX) = (startX, endX);
- }
+ currentPosition += currentRun.TextSourceLength;
- //Lines that only contain a linebreak need to be covered here
- if(characterLength == 0)
- {
- characterLength = NewLineLength;
- }
+ continue;
+ }
- var runwidth = endX - startX;
- var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runwidth, Height), currentPosition, characterLength, currentRun);
+ if (currentPosition < firstTextSourceIndex)
+ {
+ startX += currentRun.Size.Width;
+ }
- if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
- {
- currentRect = currentRect.WithWidth(currentWidth + runwidth);
+ if (currentPosition + currentRun.TextSourceLength <= characterIndex)
+ {
+ endX += currentRun.Size.Width;
- var textBounds = result[result.Count - 1];
+ characterLength = currentRun.TextSourceLength;
+ }
+ }
- textBounds.Rectangle = currentRect;
+ if (endX < startX)
+ {
+ (endX, startX) = (startX, endX);
+ }
- textBounds.TextRunBounds.Add(currentRunBounds);
- }
- else
- {
- currentRect = currentRunBounds.Rectangle;
+ //Lines that only contain a linebreak need to be covered here
+ if (characterLength == 0)
+ {
+ characterLength = NewLineLength;
+ }
- result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds }));
- }
+ runWidth = endX - startX;
+ currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
- currentWidth += runwidth;
- currentPosition += characterLength;
+ currentPosition += characterLength;
+
+ remainingLength -= characterLength;
+ }
- if (currentDirection == FlowDirection.LeftToRight)
+ if (currentRunBounds != null && !MathUtilities.IsZero(runWidth) || NewLineLength > 0)
{
- if (currentPosition > characterIndex)
+ if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
{
- break;
+ currentRect = currentRect.WithWidth(currentWidth + runWidth);
+
+ var textBounds = result[result.Count - 1];
+
+ textBounds.Rectangle = currentRect;
+
+ textBounds.TextRunBounds.Add(currentRunBounds!);
}
- }
- else
- {
- if (currentPosition <= firstTextSourceIndex)
+ else
{
- break;
+ currentRect = currentRunBounds!.Rectangle;
+
+ result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds }));
}
}
- startX = endX;
- lastDirection = currentDirection;
- remainingLength -= characterLength;
+ currentWidth += runWidth;
+
- if (remainingLength <= 0)
+ if (remainingLength <= 0 || currentPosition >= characterIndex)
{
break;
}
+
+ startX = endX;
+ lastDirection = currentDirection;
}
return result;
@@ -571,7 +719,7 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = FirstTextSourceIndex;
var remainingLength = textLength;
- var startX = Start + WidthIncludingTrailingWhitespace;
+ var startX = WidthIncludingTrailingWhitespace;
double currentWidth = 0;
var currentRect = Rect.Empty;
@@ -582,7 +730,7 @@ namespace Avalonia.Media.TextFormatting
continue;
}
- if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
+ if (currentPosition + currentRun.TextSourceLength < firstTextSourceIndex)
{
startX -= currentRun.Size.Width;
@@ -601,20 +749,31 @@ namespace Avalonia.Media.TextFormatting
currentPosition += offset;
var startIndex = currentRun.Text.Start + offset;
+ double startOffset;
+ double endOffset;
- var endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
- currentShapedRun.ShapedBuffer.IsLeftToRight ?
- new CharacterHit(startIndex + remainingLength) :
- new CharacterHit(startIndex));
+ if (currentShapedRun.ShapedBuffer.IsLeftToRight)
+ {
+ if (currentPosition < startIndex)
+ {
+ startOffset = endOffset = 0;
+ }
+ else
+ {
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
- endX += endOffset - currentShapedRun.Size.Width;
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
+ }
+ }
+ else
+ {
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
- var startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(
- currentShapedRun.ShapedBuffer.IsLeftToRight ?
- new CharacterHit(startIndex) :
- new CharacterHit(startIndex + remainingLength));
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
+ }
- startX += startOffset - currentShapedRun.Size.Width;
+ startX -= currentRun.Size.Width - startOffset;
+ endX -= currentRun.Size.Width - endOffset;
var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
@@ -652,53 +811,150 @@ namespace Avalonia.Media.TextFormatting
}
var runWidth = endX - startX;
- var currentRunBounds = new TextRunBounds(new Rect(startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
- if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, startX))
+ var currentRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
+
+ if (!MathUtilities.IsZero(runWidth) || NewLineLength > 0)
{
- currentRect = currentRect.WithWidth(currentWidth + runWidth);
+ if (lastDirection == currentDirection && result.Count > 0 && MathUtilities.AreClose(currentRect.Right, Start + startX))
+ {
+ currentRect = currentRect.WithWidth(currentWidth + runWidth);
- var textBounds = result[result.Count - 1];
+ var textBounds = result[result.Count - 1];
- textBounds.Rectangle = currentRect;
+ textBounds.Rectangle = currentRect;
- textBounds.TextRunBounds.Add(currentRunBounds);
- }
- else
- {
- currentRect = currentRunBounds.Rectangle;
+ textBounds.TextRunBounds.Add(currentRunBounds);
+ }
+ else
+ {
+ currentRect = currentRunBounds.Rectangle;
- result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds }));
+ result.Add(new TextBounds(currentRect, currentDirection, new List { currentRunBounds }));
+ }
}
currentWidth += runWidth;
currentPosition += characterLength;
- if (currentDirection == FlowDirection.LeftToRight)
+ if (currentPosition > characterIndex)
+ {
+ break;
+ }
+
+ lastDirection = currentDirection;
+ remainingLength -= characterLength;
+
+ if (remainingLength <= 0)
+ {
+ break;
+ }
+ }
+
+ result.Reverse();
+
+ return result;
+ }
+
+ private bool TryGetTextRunBoundsRightToLeft(double startX, int firstTextSourceIndex, int characterIndex, int runIndex, ref int currentPosition, ref int remainingLength, out TextRunBounds? textRunBounds)
+ {
+ textRunBounds = null;
+
+ for (var index = runIndex; index >= 0; index--)
+ {
+ if (TextRuns[index] is not DrawableTextRun currentRun)
+ {
+ continue;
+ }
+
+ if (currentPosition + currentRun.TextSourceLength <= firstTextSourceIndex)
{
- if (currentPosition > characterIndex)
+ startX -= currentRun.Size.Width;
+
+ currentPosition += currentRun.TextSourceLength;
+
+ continue;
+ }
+
+ var characterLength = 0;
+ var endX = startX;
+
+ if (currentRun is ShapedTextCharacters currentShapedRun)
+ {
+ var offset = Math.Max(0, firstTextSourceIndex - currentPosition);
+
+ currentPosition += offset;
+
+ var startIndex = currentRun.Text.Start + offset;
+ double startOffset;
+ double endOffset;
+
+ if (currentShapedRun.ShapedBuffer.IsLeftToRight)
{
- break;
+ if (currentPosition < startIndex)
+ {
+ startOffset = endOffset = 0;
+ }
+ else
+ {
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
+
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
+ }
}
+ else
+ {
+ endOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex));
+
+ startOffset = currentShapedRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(startIndex + remainingLength));
+ }
+
+ startX -= currentRun.Size.Width - startOffset;
+ endX -= currentRun.Size.Width - endOffset;
+
+ var endHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(endOffset, out _);
+ var startHit = currentShapedRun.GlyphRun.GetCharacterHitFromDistance(startOffset, out _);
+
+ characterLength = Math.Abs(startHit.FirstCharacterIndex + startHit.TrailingLength - endHit.FirstCharacterIndex - endHit.TrailingLength);
}
else
{
- if (currentPosition <= firstTextSourceIndex)
+ if (currentPosition + currentRun.TextSourceLength <= characterIndex)
{
- break;
+ endX -= currentRun.Size.Width;
+ }
+
+ if (currentPosition < firstTextSourceIndex)
+ {
+ startX -= currentRun.Size.Width;
+
+ characterLength = currentRun.TextSourceLength;
}
}
- lastDirection = currentDirection;
- remainingLength -= characterLength;
+ if (endX < startX)
+ {
+ (endX, startX) = (startX, endX);
+ }
- if (remainingLength <= 0)
+ //Lines that only contain a linebreak need to be covered here
+ if (characterLength == 0)
{
- break;
+ characterLength = NewLineLength;
}
+
+ var runWidth = endX - startX;
+
+ remainingLength -= characterLength;
+
+ currentPosition += characterLength;
+
+ textRunBounds = new TextRunBounds(new Rect(Start + startX, 0, runWidth, Height), currentPosition, characterLength, currentRun);
+
+ return true;
}
- return result;
+ return false;
}
public override IReadOnlyList GetTextBounds(int firstTextSourceIndex, int textLength)
@@ -1280,6 +1536,11 @@ namespace Avalonia.Media.TextFormatting
var textAlignment = _paragraphProperties.TextAlignment;
var paragraphFlowDirection = _paragraphProperties.FlowDirection;
+ if(textAlignment == TextAlignment.Justify)
+ {
+ textAlignment = TextAlignment.Start;
+ }
+
switch (textAlignment)
{
case TextAlignment.Start:
@@ -1302,8 +1563,14 @@ namespace Avalonia.Media.TextFormatting
switch (textAlignment)
{
case TextAlignment.Center:
- return Math.Max(0, (_paragraphWidth - width) / 2);
+ var start = (_paragraphWidth - width) / 2;
+
+ if (paragraphFlowDirection == FlowDirection.RightToLeft)
+ {
+ start -= (widthIncludingTrailingWhitespace - width);
+ }
+ return Math.Max(0, start);
case TextAlignment.Right:
return Math.Max(0, _paragraphWidth - widthIncludingTrailingWhitespace);
diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs
index 56a90f31ea..ab17263806 100644
--- a/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/Codepoint.cs
@@ -224,7 +224,7 @@ namespace Avalonia.Media.TextFormatting.Unicode
}
///
- /// Returns if is between
+ /// Returns if is between
/// and , inclusive.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/src/Avalonia.Base/Metadata/IAddChild.cs b/src/Avalonia.Base/Metadata/IAddChild.cs
new file mode 100644
index 0000000000..8ef02912a6
--- /dev/null
+++ b/src/Avalonia.Base/Metadata/IAddChild.cs
@@ -0,0 +1,12 @@
+namespace Avalonia.Metadata
+{
+ public interface IAddChild
+ {
+ void AddChild(object child);
+ }
+
+ public interface IAddChild
+ {
+ void AddChild(T child);
+ }
+}
diff --git a/src/Avalonia.Base/Platform/IGeometryImpl.cs b/src/Avalonia.Base/Platform/IGeometryImpl.cs
index c80f8923ef..5826cfb2ff 100644
--- a/src/Avalonia.Base/Platform/IGeometryImpl.cs
+++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs
@@ -38,8 +38,8 @@ namespace Avalonia.Platform
/// Intersects the geometry with another geometry.
///
/// The other geometry.
- /// A new representing the intersection.
- IGeometryImpl Intersect(IGeometryImpl geometry);
+ /// A new representing the intersection or null when the operation failed.
+ IGeometryImpl? Intersect(IGeometryImpl geometry);
///
/// Indicates whether the geometry's stroke contains the specified point.
diff --git a/src/Avalonia.Base/Platform/IPlatformGpu.cs b/src/Avalonia.Base/Platform/IPlatformGpu.cs
new file mode 100644
index 0000000000..0507dea1d7
--- /dev/null
+++ b/src/Avalonia.Base/Platform/IPlatformGpu.cs
@@ -0,0 +1,16 @@
+using System;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform;
+
+[Unstable]
+public interface IPlatformGpu
+{
+ IPlatformGpuContext PrimaryContext { get; }
+}
+
+[Unstable]
+public interface IPlatformGpuContext : IDisposable
+{
+ IDisposable EnsureCurrent();
+}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
new file mode 100644
index 0000000000..cf21e9b8b5
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Security;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage.FileIO;
+
+[Unstable]
+public class BclStorageFile : IStorageBookmarkFile
+{
+ private readonly FileInfo _fileInfo;
+
+ public BclStorageFile(FileInfo fileInfo)
+ {
+ _fileInfo = fileInfo ?? throw new ArgumentNullException(nameof(fileInfo));
+ }
+
+ public bool CanOpenRead => true;
+
+ public bool CanOpenWrite => true;
+
+ public string Name => _fileInfo.Name;
+
+ public virtual bool CanBookmark => true;
+
+ public Task GetBasicPropertiesAsync()
+ {
+ var props = new StorageItemProperties();
+ if (_fileInfo.Exists)
+ {
+ props = new StorageItemProperties(
+ (ulong)_fileInfo.Length,
+ _fileInfo.CreationTimeUtc,
+ _fileInfo.LastAccessTimeUtc);
+ }
+ return Task.FromResult(props);
+ }
+
+ public Task GetParentAsync()
+ {
+ if (_fileInfo.Directory is { } directory)
+ {
+ return Task.FromResult(new BclStorageFolder(directory));
+ }
+ return Task.FromResult(null);
+ }
+
+ public Task OpenReadAsync()
+ {
+ return Task.FromResult(_fileInfo.OpenRead());
+ }
+
+ public Task OpenWriteAsync()
+ {
+ return Task.FromResult(_fileInfo.OpenWrite());
+ }
+
+ public virtual Task SaveBookmarkAsync()
+ {
+ return Task.FromResult(_fileInfo.FullName);
+ }
+
+ public Task ReleaseBookmarkAsync()
+ {
+ // No-op
+ return Task.CompletedTask;
+ }
+
+ public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
+ {
+ try
+ {
+ if (_fileInfo.Directory is not null)
+ {
+ uri = Path.IsPathRooted(_fileInfo.FullName) ?
+ new Uri(new Uri("file://"), _fileInfo.FullName) :
+ new Uri(_fileInfo.FullName, UriKind.Relative);
+ return true;
+ }
+
+ uri = null;
+ return false;
+ }
+ catch (SecurityException)
+ {
+ uri = null;
+ return false;
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+
+ ~BclStorageFile()
+ {
+ Dispose(disposing: false);
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
new file mode 100644
index 0000000000..cd6c8be1ae
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFolder.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Security;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage.FileIO;
+
+[Unstable]
+public class BclStorageFolder : IStorageBookmarkFolder
+{
+ private readonly DirectoryInfo _directoryInfo;
+
+ public BclStorageFolder(DirectoryInfo directoryInfo)
+ {
+ _directoryInfo = directoryInfo ?? throw new ArgumentNullException(nameof(directoryInfo));
+ if (!_directoryInfo.Exists)
+ {
+ throw new ArgumentException("Directory must exist", nameof(directoryInfo));
+ }
+ }
+
+ public string Name => _directoryInfo.Name;
+
+ public bool CanBookmark => true;
+
+ public Task GetBasicPropertiesAsync()
+ {
+ var props = new StorageItemProperties(
+ null,
+ _directoryInfo.CreationTimeUtc,
+ _directoryInfo.LastAccessTimeUtc);
+ return Task.FromResult(props);
+ }
+
+ public Task GetParentAsync()
+ {
+ if (_directoryInfo.Parent is { } directory)
+ {
+ return Task.FromResult(new BclStorageFolder(directory));
+ }
+ return Task.FromResult(null);
+ }
+
+ public Task> GetItemsAsync()
+ {
+ var items = _directoryInfo.GetDirectories()
+ .Select(d => (IStorageItem)new BclStorageFolder(d))
+ .Concat(_directoryInfo.GetFiles().Select(f => new BclStorageFile(f)))
+ .ToArray();
+
+ return Task.FromResult>(items);
+ }
+
+ public virtual Task SaveBookmarkAsync()
+ {
+ return Task.FromResult(_directoryInfo.FullName);
+ }
+
+ public Task ReleaseBookmarkAsync()
+ {
+ // No-op
+ return Task.CompletedTask;
+ }
+
+ public bool TryGetUri([NotNullWhen(true)] out Uri? uri)
+ {
+ try
+ {
+ uri = Path.IsPathRooted(_directoryInfo.FullName) ?
+ new Uri(new Uri("file://"), _directoryInfo.FullName) :
+ new Uri(_directoryInfo.FullName, UriKind.Relative);
+
+ return true;
+ }
+ catch (SecurityException)
+ {
+ uri = null;
+ return false;
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+
+ ~BclStorageFolder()
+ {
+ Dispose(disposing: false);
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
new file mode 100644
index 0000000000..469388021e
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage.FileIO;
+
+[Unstable]
+public abstract class BclStorageProvider : IStorageProvider
+{
+ public abstract bool CanOpen { get; }
+ public abstract Task> OpenFilePickerAsync(FilePickerOpenOptions options);
+
+ public abstract bool CanSave { get; }
+ public abstract Task SaveFilePickerAsync(FilePickerSaveOptions options);
+
+ public abstract bool CanPickFolder { get; }
+ public abstract Task> OpenFolderPickerAsync(FolderPickerOpenOptions options);
+
+ public virtual Task OpenFileBookmarkAsync(string bookmark)
+ {
+ var file = new FileInfo(bookmark);
+ return file.Exists
+ ? Task.FromResult(new BclStorageFile(file))
+ : Task.FromResult(null);
+ }
+
+ public virtual Task OpenFolderBookmarkAsync(string bookmark)
+ {
+ var folder = new DirectoryInfo(bookmark);
+ return folder.Exists
+ ? Task.FromResult(new BclStorageFolder(folder))
+ : Task.FromResult(null);
+ }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
new file mode 100644
index 0000000000..f90d0a5a2f
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageProviderHelpers.cs
@@ -0,0 +1,40 @@
+using System;
+using System.IO;
+using System.Linq;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage.FileIO;
+
+[Unstable]
+public static class StorageProviderHelpers
+{
+ public static string NameWithExtension(string path, string? defaultExtension, FilePickerFileType? filter)
+ {
+ var name = Path.GetFileName(path);
+ if (name != null && !Path.HasExtension(name))
+ {
+ if (filter?.Patterns?.Count > 0)
+ {
+ if (defaultExtension != null
+ && filter.Patterns.Contains(defaultExtension))
+ {
+ return Path.ChangeExtension(path, defaultExtension.TrimStart('.'));
+ }
+
+ var ext = filter.Patterns.FirstOrDefault(x => x != "*.*");
+ ext = ext?.Split(new[] { "*." }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
+ if (ext != null)
+ {
+ return Path.ChangeExtension(path, ext);
+ }
+ }
+
+ if (defaultExtension != null)
+ {
+ return Path.ChangeExtension(path, defaultExtension);
+ }
+ }
+
+ return path;
+ }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
new file mode 100644
index 0000000000..75076e2bb8
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Represents a name mapped to the associated file types (extensions).
+///
+public sealed class FilePickerFileType
+{
+ public FilePickerFileType(string name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// File type name.
+ ///
+ public string Name { get; }
+
+ ///
+ /// List of extensions in GLOB format. I.e. "*.png" or "*.*".
+ ///
+ ///
+ /// Used on Windows and Linux systems.
+ ///
+ public IReadOnlyList? Patterns { get; set; }
+
+ ///
+ /// List of extensions in MIME format.
+ ///
+ ///
+ /// Used on Android, Browser and Linux systems.
+ ///
+ public IReadOnlyList? MimeTypes { get; set; }
+
+ ///
+ /// List of extensions in Apple uniform format.
+ ///
+ ///
+ /// Used only on Apple devices.
+ /// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers.
+ ///
+ public IReadOnlyList? AppleUniformTypeIdentifiers { get; set; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs b/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
new file mode 100644
index 0000000000..5da037999a
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FilePickerFileTypes.cs
@@ -0,0 +1,48 @@
+namespace Avalonia.Platform.Storage;
+
+///
+/// Dictionary of well known file types.
+///
+public static class FilePickerFileTypes
+{
+ public static FilePickerFileType All { get; } = new("All")
+ {
+ Patterns = new[] { "*.*" },
+ MimeTypes = new[] { "*/*" }
+ };
+
+ public static FilePickerFileType TextPlain { get; } = new("Plain Text")
+ {
+ Patterns = new[] { "*.txt" },
+ AppleUniformTypeIdentifiers = new[] { "public.plain-text" },
+ MimeTypes = new[] { "text/plain" }
+ };
+
+ public static FilePickerFileType ImageAll { get; } = new("All Images")
+ {
+ Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.bmp" },
+ AppleUniformTypeIdentifiers = new[] { "public.image" },
+ MimeTypes = new[] { "image/*" }
+ };
+
+ public static FilePickerFileType ImageJpg { get; } = new("JPEG image")
+ {
+ Patterns = new[] { "*.jpg", "*.jpeg" },
+ AppleUniformTypeIdentifiers = new[] { "public.jpeg" },
+ MimeTypes = new[] { "image/jpeg" }
+ };
+
+ public static FilePickerFileType ImagePng { get; } = new("PNG image")
+ {
+ Patterns = new[] { "*.png" },
+ AppleUniformTypeIdentifiers = new[] { "public.png" },
+ MimeTypes = new[] { "image/png" }
+ };
+
+ public static FilePickerFileType Pdf { get; } = new("PDF document")
+ {
+ Patterns = new[] { "*.pdf" },
+ AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
+ MimeTypes = new[] { "application/pdf" }
+ };
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs b/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs
new file mode 100644
index 0000000000..1674ec11c8
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FilePickerOpenOptions.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Options class for method.
+///
+public class FilePickerOpenOptions : PickerOptions
+{
+ ///
+ /// Gets or sets an option indicating whether open picker allows users to select multiple files.
+ ///
+ public bool AllowMultiple { get; set; }
+
+ ///
+ /// Gets or sets the collection of file types that the file open picker displays.
+ ///
+ public IReadOnlyList? FileTypeFilter { get; set; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs b/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs
new file mode 100644
index 0000000000..fa4fccd47a
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FilePickerSaveOptions.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Options class for method.
+///
+public class FilePickerSaveOptions : PickerOptions
+{
+ ///
+ /// Gets or sets the file name that the file save picker suggests to the user.
+ ///
+ public string? SuggestedFileName { get; set; }
+
+ ///
+ /// Gets or sets the default extension to be used to save the file.
+ ///
+ public string? DefaultExtension { get; set; }
+
+ ///
+ /// Gets or sets the collection of valid file types that the user can choose to assign to a file.
+ ///
+ public IReadOnlyList? FileTypeChoices { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether file open picker displays a warning if the user specifies the name of a file that already exists.
+ ///
+ public bool? ShowOverwritePrompt { get; set; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs b/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs
new file mode 100644
index 0000000000..df9fa8fd35
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/FolderPickerOpenOptions.cs
@@ -0,0 +1,12 @@
+namespace Avalonia.Platform.Storage;
+
+///
+/// Options class for method.
+///
+public class FolderPickerOpenOptions : PickerOptions
+{
+ ///
+ /// Gets or sets an option indicating whether open picker allows users to select multiple folders.
+ ///
+ public bool AllowMultiple { get; set; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs
new file mode 100644
index 0000000000..40f2720ee8
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/IStorageBookmarkItem.cs
@@ -0,0 +1,20 @@
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage;
+
+[NotClientImplementable]
+public interface IStorageBookmarkItem : IStorageItem
+{
+ Task ReleaseBookmarkAsync();
+}
+
+[NotClientImplementable]
+public interface IStorageBookmarkFile : IStorageFile, IStorageBookmarkItem
+{
+}
+
+[NotClientImplementable]
+public interface IStorageBookmarkFolder : IStorageFolder, IStorageBookmarkItem
+{
+}
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs
new file mode 100644
index 0000000000..46aa6efa72
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs
@@ -0,0 +1,32 @@
+using System.IO;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Represents a file. Provides information about the file and its contents, and ways to manipulate them.
+///
+[NotClientImplementable]
+public interface IStorageFile : IStorageItem
+{
+ ///
+ /// Returns true, if file is readable.
+ ///
+ bool CanOpenRead { get; }
+
+ ///
+ /// Opens a stream for read access.
+ ///
+ Task OpenReadAsync();
+
+ ///
+ /// Returns true, if file is writeable.
+ ///
+ bool CanOpenWrite { get; }
+
+ ///
+ /// Opens stream for writing to the file.
+ ///
+ Task OpenWriteAsync();
+}
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs
new file mode 100644
index 0000000000..0ffb9f41c6
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/IStorageFolder.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Manipulates folders and their contents, and provides information about them.
+///
+[NotClientImplementable]
+public interface IStorageFolder : IStorageItem
+{
+ ///
+ /// Gets the files and subfolders in the current folder.
+ ///
+ ///
+ /// When this method completes successfully, it returns a list of the files and folders in the current folder. Each item in the list is represented by an implementation object.
+ ///
+ Task> GetItemsAsync();
+}
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageItem.cs b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs
new file mode 100644
index 0000000000..f5469d31c9
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/IStorageItem.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Manipulates storage items (files and folders) and their contents, and provides information about them
+///
+///
+/// This interface inherits . It's recommended to dispose when it's not used anymore.
+///
+[NotClientImplementable]
+public interface IStorageItem : IDisposable
+{
+ ///
+ /// Gets the name of the item including the file name extension if there is one.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the full file-system path of the item, if the item has a path.
+ ///
+ ///
+ /// Android backend might return file path with "content:" scheme.
+ /// Browser and iOS backends might return relative uris.
+ ///
+ bool TryGetUri([NotNullWhen(true)] out Uri? uri);
+
+ ///
+ /// Gets the basic properties of the current item.
+ ///
+ Task GetBasicPropertiesAsync();
+
+ ///
+ /// Returns true is item can be bookmarked and reused later.
+ ///
+ bool CanBookmark { get; }
+
+ ///
+ /// Saves items to a bookmark.
+ ///
+ ///
+ /// Returns identifier of a bookmark. Can be null if OS denied request.
+ ///
+ Task SaveBookmarkAsync();
+
+ ///
+ /// Gets the parent folder of the current storage item.
+ ///
+ Task GetParentAsync();
+}
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
new file mode 100644
index 0000000000..0f5cf931d9
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
@@ -0,0 +1,56 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Avalonia.Metadata;
+
+namespace Avalonia.Platform.Storage;
+
+[NotClientImplementable]
+public interface IStorageProvider
+{
+ ///
+ /// Returns true if it's possible to open file picker on the current platform.
+ ///
+ bool CanOpen { get; }
+
+ ///
+ /// Opens file picker dialog.
+ ///
+ /// Array of selected or empty collection if user canceled the dialog.
+ Task> OpenFilePickerAsync(FilePickerOpenOptions options);
+
+ ///
+ /// Returns true if it's possible to open save file picker on the current platform.
+ ///
+ bool CanSave { get; }
+
+ ///
+ /// Opens save file picker dialog.
+ ///
+ /// Saved or null if user canceled the dialog.
+ Task SaveFilePickerAsync(FilePickerSaveOptions options);
+
+ ///
+ /// Returns true if it's possible to open folder picker on the current platform.
+ ///
+ bool CanPickFolder { get; }
+
+ ///
+ /// Opens folder picker dialog.
+ ///
+ /// Array of selected or empty collection if user canceled the dialog.
+ Task> OpenFolderPickerAsync(FolderPickerOpenOptions options);
+
+ ///
+ /// Open from the bookmark ID.
+ ///
+ /// Bookmark ID.
+ /// Bookmarked file or null if OS denied request.
+ Task OpenFileBookmarkAsync(string bookmark);
+
+ ///
+ /// Open from the bookmark ID.
+ ///
+ /// Bookmark ID.
+ /// Bookmarked folder or null if OS denied request.
+ Task OpenFolderBookmarkAsync(string bookmark);
+}
diff --git a/src/Avalonia.Base/Platform/Storage/PickerOptions.cs b/src/Avalonia.Base/Platform/Storage/PickerOptions.cs
new file mode 100644
index 0000000000..6f97916a26
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/PickerOptions.cs
@@ -0,0 +1,17 @@
+namespace Avalonia.Platform.Storage;
+
+///
+/// Common options for , and methods.
+///
+public class PickerOptions
+{
+ ///
+ /// Gets or sets the text that appears in the title bar of a folder dialog.
+ ///
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets the initial location where the file open picker looks for files to present to the user.
+ ///
+ public IStorageFolder? SuggestedStartLocation { get; set; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs b/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs
new file mode 100644
index 0000000000..a63973ccf8
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/StorageItemProperties.cs
@@ -0,0 +1,43 @@
+using System;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Provides access to the content-related properties of an item (like a file or folder).
+///
+public class StorageItemProperties
+{
+ public StorageItemProperties(
+ ulong? size = null,
+ DateTimeOffset? dateCreated = null,
+ DateTimeOffset? dateModified = null)
+ {
+ Size = size;
+ DateCreated = dateCreated;
+ DateModified = dateModified;
+ }
+
+ ///
+ /// Gets the size of the file in bytes.
+ ///
+ ///
+ /// Can be null if property is not available.
+ ///
+ public ulong? Size { get; }
+
+ ///
+ /// Gets the date and time that the current folder was created.
+ ///
+ ///
+ /// Can be null if property is not available.
+ ///
+ public DateTimeOffset? DateCreated { get; }
+
+ ///
+ /// Gets the date and time of the last time the file was modified.
+ ///
+ ///
+ /// Can be null if property is not available.
+ ///
+ public DateTimeOffset? DateModified { get; }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/AnimationInstanceBase.cs b/src/Avalonia.Base/Rendering/Composition/Animations/AnimationInstanceBase.cs
new file mode 100644
index 0000000000..889c1d34c8
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/AnimationInstanceBase.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations;
+
+///
+/// The base class for both key-frame and expression animation instances
+/// Is responsible for activation tracking and for subscribing to properties used in dependencies
+///
+internal abstract class AnimationInstanceBase : IAnimationInstance
+{
+ private List<(ServerObject obj, CompositionProperty member)>? _trackedObjects;
+ protected PropertySetSnapshot Parameters { get; }
+ public ServerObject TargetObject { get; }
+ protected CompositionProperty Property { get; private set; } = null!;
+ private bool _invalidated;
+
+ public AnimationInstanceBase(ServerObject target, PropertySetSnapshot parameters)
+ {
+ Parameters = parameters;
+ TargetObject = target;
+ }
+
+ protected void Initialize(CompositionProperty property, HashSet<(string name, string member)> trackedObjects)
+ {
+ if (trackedObjects.Count > 0)
+ {
+ _trackedObjects = new ();
+ foreach (var t in trackedObjects)
+ {
+ var obj = Parameters.GetObjectParameter(t.name);
+ if (obj is ServerObject tracked)
+ {
+ var off = tracked.GetCompositionProperty(t.member);
+ if (off == null)
+#if DEBUG
+ throw new InvalidCastException("Attempting to subscribe to unknown field");
+#else
+ continue;
+#endif
+ _trackedObjects.Add((tracked, off));
+ }
+ }
+ }
+
+ Property = property;
+ }
+
+ public abstract void Initialize(TimeSpan startedAt, ExpressionVariant startingValue, CompositionProperty property);
+ protected abstract ExpressionVariant EvaluateCore(TimeSpan now, ExpressionVariant currentValue);
+
+ public ExpressionVariant Evaluate(TimeSpan now, ExpressionVariant currentValue)
+ {
+ _invalidated = false;
+ return EvaluateCore(now, currentValue);
+ }
+
+ public virtual void Activate()
+ {
+ if (_trackedObjects != null)
+ foreach (var tracked in _trackedObjects)
+ tracked.obj.SubscribeToInvalidation(tracked.member, this);
+ }
+
+ public virtual void Deactivate()
+ {
+ if (_trackedObjects != null)
+ foreach (var tracked in _trackedObjects)
+ tracked.obj.UnsubscribeFromInvalidation(tracked.member, this);
+ }
+
+ public void Invalidate()
+ {
+ if (_invalidated)
+ return;
+ _invalidated = true;
+ TargetObject.NotifyAnimatedValueChanged(Property);
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimation.cs b/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimation.cs
new file mode 100644
index 0000000000..a6db4330a3
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimation.cs
@@ -0,0 +1,74 @@
+// ReSharper disable InconsistentNaming
+// ReSharper disable CheckNamespace
+
+using System.Numerics;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// This is the base class for ExpressionAnimation and KeyFrameAnimation.
+ ///
+ ///
+ /// Use the method to start the animation.
+ /// Value parameters (as opposed to reference parameters which are set using )
+ /// are copied and "embedded" into an expression at the time CompositionObject.StartAnimation is called.
+ /// Changing the value of the variable after is called will not affect
+ /// the value of the ExpressionAnimation.
+ /// See the remarks section of ExpressionAnimation for additional information.
+ ///
+ public abstract class CompositionAnimation : CompositionObject, ICompositionAnimationBase
+ {
+ private readonly CompositionPropertySet _propertySet;
+ internal CompositionAnimation(Compositor compositor) : base(compositor, null!)
+ {
+ _propertySet = new CompositionPropertySet(compositor);
+ }
+
+ ///
+ /// Clears all of the parameters of the animation.
+ ///
+ public void ClearAllParameters() => _propertySet.ClearAll();
+
+ ///
+ /// Clears a parameter from the animation.
+ ///
+ public void ClearParameter(string key) => _propertySet.Clear(key);
+
+ void SetVariant(string key, ExpressionVariant value) => _propertySet.Set(key, value);
+
+ public void SetColorParameter(string key, Media.Color value) => SetVariant(key, value);
+
+ public void SetMatrix3x2Parameter(string key, Matrix3x2 value) => SetVariant(key, value);
+
+ public void SetMatrix4x4Parameter(string key, Matrix4x4 value) => SetVariant(key, value);
+
+ public void SetQuaternionParameter(string key, Quaternion value) => SetVariant(key, value);
+
+ public void SetReferenceParameter(string key, CompositionObject compositionObject) =>
+ _propertySet.Set(key, compositionObject);
+
+ public void SetScalarParameter(string key, float value) => SetVariant(key, value);
+
+ public void SetVector2Parameter(string key, Vector2 value) => SetVariant(key, value);
+
+ public void SetVector3Parameter(string key, Vector3 value) => SetVariant(key, value);
+
+ public void SetVector4Parameter(string key, Vector4 value) => SetVariant(key, value);
+
+ public string? Target { get; set; }
+
+ internal abstract IAnimationInstance CreateInstance(ServerObject targetObject,
+ ExpressionVariant? finalValue);
+
+ internal PropertySetSnapshot CreateSnapshot() => _propertySet.Snapshot();
+
+ void ICompositionAnimationBase.InternalOnly()
+ {
+
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimationGroup.cs b/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimationGroup.cs
new file mode 100644
index 0000000000..bad3991f43
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/CompositionAnimationGroup.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Rendering.Composition.Transport;
+
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ public class CompositionAnimationGroup : CompositionObject, ICompositionAnimationBase
+ {
+ internal List Animations { get; } = new List();
+ void ICompositionAnimationBase.InternalOnly()
+ {
+
+ }
+
+ public void Add(CompositionAnimation value) => Animations.Add(value);
+ public void Remove(CompositionAnimation value) => Animations.Remove(value);
+ public void RemoveAll() => Animations.Clear();
+
+ public CompositionAnimationGroup(Compositor compositor) : base(compositor, null!)
+ {
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimation.cs b/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimation.cs
new file mode 100644
index 0000000000..ec2972044e
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimation.cs
@@ -0,0 +1,55 @@
+// ReSharper disable CheckNamespace
+using System;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// A Composition Animation that uses a mathematical equation to calculate the value for an animating property every frame.
+ ///
+ ///
+ /// The core of ExpressionAnimations allows a developer to define a mathematical equation that can be used to calculate the value
+ /// of a targeted animating property each frame.
+ /// This contrasts s, which use an interpolator to define how the animating
+ /// property changes over time. The mathematical equation can be defined using references to properties
+ /// of Composition objects, mathematical functions and operators and Input.
+ /// Use the method to start the animation.
+ ///
+ public class ExpressionAnimation : CompositionAnimation
+ {
+ private string? _expression;
+ private Expression? _parsedExpression;
+
+ internal ExpressionAnimation(Compositor compositor) : base(compositor)
+ {
+ }
+
+ ///
+ /// The mathematical equation specifying how the animated value is calculated each frame.
+ /// The Expression is the core of an and represents the equation
+ /// the system will use to calculate the value of the animation property each frame.
+ /// The equation is set on this property in the form of a string.
+ /// Although expressions can be defined by simple mathematical equations such as "2+2",
+ /// the real power lies in creating mathematical relationships where the input values can change frame over frame.
+ ///
+ public string? Expression
+ {
+ get => _expression;
+ set
+ {
+ _expression = value;
+ _parsedExpression = null;
+ }
+ }
+
+ private Expression ParsedExpression => _parsedExpression ??= ExpressionParser.Parse(_expression.AsSpan());
+
+ internal override IAnimationInstance CreateInstance(
+ ServerObject targetObject, ExpressionVariant? finalValue)
+ => new ExpressionAnimationInstance(ParsedExpression,
+ targetObject, finalValue, CreateSnapshot());
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimationInstance.cs b/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimationInstance.cs
new file mode 100644
index 0000000000..6c7c556456
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/ExpressionAnimationInstance.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+
+ ///
+ /// Server-side counterpart of with values baked-in.
+ ///
+ internal class ExpressionAnimationInstance : AnimationInstanceBase, IAnimationInstance
+ {
+ private readonly Expression _expression;
+ private ExpressionVariant _startingValue;
+ private readonly ExpressionVariant? _finalValue;
+
+ protected override ExpressionVariant EvaluateCore(TimeSpan now, ExpressionVariant currentValue)
+ {
+ var ctx = new ExpressionEvaluationContext
+ {
+ Parameters = Parameters,
+ Target = TargetObject,
+ ForeignFunctionInterface = BuiltInExpressionFfi.Instance,
+ StartingValue = _startingValue,
+ FinalValue = _finalValue ?? _startingValue,
+ CurrentValue = currentValue
+ };
+ return _expression.Evaluate(ref ctx);
+ }
+
+ public override void Initialize(TimeSpan startedAt, ExpressionVariant startingValue, CompositionProperty property)
+ {
+ _startingValue = startingValue;
+ var hs = new HashSet<(string, string)>();
+ _expression.CollectReferences(hs);
+ base.Initialize(property, hs);
+ }
+
+ public ExpressionAnimationInstance(Expression expression,
+ ServerObject target,
+ ExpressionVariant? finalValue,
+ PropertySetSnapshot parameters) : base(target, parameters)
+ {
+ _expression = expression;
+ _finalValue = finalValue;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/IAnimationInstance.cs b/src/Avalonia.Base/Rendering/Composition/Animations/IAnimationInstance.cs
new file mode 100644
index 0000000000..8ec4ec19bc
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/IAnimationInstance.cs
@@ -0,0 +1,18 @@
+using System;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ internal interface IAnimationInstance
+ {
+ ServerObject TargetObject { get; }
+ ExpressionVariant Evaluate(TimeSpan now, ExpressionVariant currentValue);
+ void Initialize(TimeSpan startedAt, ExpressionVariant startingValue, CompositionProperty property);
+ void Activate();
+ void Deactivate();
+ void Invalidate();
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/ICompositionAnimationBase.cs b/src/Avalonia.Base/Rendering/Composition/Animations/ICompositionAnimationBase.cs
new file mode 100644
index 0000000000..91b37a81af
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/ICompositionAnimationBase.cs
@@ -0,0 +1,17 @@
+// ReSharper disable CheckNamespace
+
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// Base class for composition animations.
+ ///
+ public interface ICompositionAnimationBase
+ {
+ internal void InternalOnly();
+ }
+
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/ImplicitAnimationCollection.cs b/src/Avalonia.Base/Rendering/Composition/Animations/ImplicitAnimationCollection.cs
new file mode 100644
index 0000000000..72be4edd07
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/ImplicitAnimationCollection.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Rendering.Composition.Transport;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// A collection of animations triggered when a condition is met.
+ ///
+ ///
+ /// Implicit animations let you drive animations by specifying trigger conditions rather than requiring the manual definition of animation behavior.
+ /// They help decouple animation start logic from core app logic. You define animations and the events that should trigger these animations.
+ /// Currently the only available trigger is animated property change.
+ ///
+ /// When expression is used in ImplicitAnimationCollection a special keyword `this.FinalValue` will represent
+ /// the final value of the animated property that was changed
+ ///
+ public class ImplicitAnimationCollection : CompositionObject, IDictionary
+ {
+ private Dictionary _inner = new Dictionary();
+ private IDictionary _innerface;
+ internal ImplicitAnimationCollection(Compositor compositor) : base(compositor, null!)
+ {
+ _innerface = _inner;
+ }
+
+ public IEnumerator> GetEnumerator() => _inner.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable) _inner).GetEnumerator();
+
+ void ICollection>.Add(KeyValuePair item) => _innerface.Add(item);
+
+ public void Clear() => _inner.Clear();
+
+ bool ICollection>.Contains(KeyValuePair item) => _innerface.Contains(item);
+
+ void ICollection>.CopyTo(KeyValuePair[] array, int arrayIndex) => _innerface.CopyTo(array, arrayIndex);
+
+ bool ICollection>.Remove(KeyValuePair item) => _innerface.Remove(item);
+
+ public int Count => _inner.Count;
+
+ bool ICollection>.IsReadOnly => _innerface.IsReadOnly;
+
+ public void Add(string key, ICompositionAnimationBase value) => _inner.Add(key, value);
+
+ public bool ContainsKey(string key) => _inner.ContainsKey(key);
+
+ public bool Remove(string key) => _inner.Remove(key);
+
+ public bool TryGetValue(string key, [MaybeNullWhen(false)] out ICompositionAnimationBase value) =>
+ _inner.TryGetValue(key, out value);
+
+ public ICompositionAnimationBase this[string key]
+ {
+ get => _inner[key];
+ set => _inner[key] = value;
+ }
+
+ ICollection IDictionary.Keys => _innerface.Keys;
+
+ ICollection IDictionary.Values =>
+ _innerface.Values;
+
+ // UWP compat
+ public uint Size => (uint) Count;
+
+ public IReadOnlyDictionary GetView() =>
+ new Dictionary(this);
+
+ public bool HasKey(string key) => ContainsKey(key);
+ public void Insert(string key, ICompositionAnimationBase animation) => Add(key, animation);
+
+ public ICompositionAnimationBase? Lookup(string key)
+ {
+ _inner.TryGetValue(key, out var rv);
+ return rv;
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs b/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs
new file mode 100644
index 0000000000..e19c0ff098
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Numerics;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// An interface to define interpolation logic for a particular type
+ ///
+ internal interface IInterpolator
+ {
+ T Interpolate(T from, T to, float progress);
+ }
+
+ class ScalarInterpolator : IInterpolator
+ {
+ public float Interpolate(float @from, float to, float progress) => @from + (to - @from) * progress;
+
+ public static ScalarInterpolator Instance { get; } = new ScalarInterpolator();
+ }
+
+ class Vector2Interpolator : IInterpolator
+ {
+ public Vector2 Interpolate(Vector2 @from, Vector2 to, float progress)
+ => Vector2.Lerp(@from, to, progress);
+
+ public static Vector2Interpolator Instance { get; } = new Vector2Interpolator();
+ }
+
+ class Vector3Interpolator : IInterpolator
+ {
+ public Vector3 Interpolate(Vector3 @from, Vector3 to, float progress)
+ => Vector3.Lerp(@from, to, progress);
+
+ public static Vector3Interpolator Instance { get; } = new Vector3Interpolator();
+ }
+
+ class Vector4Interpolator : IInterpolator
+ {
+ public Vector4 Interpolate(Vector4 @from, Vector4 to, float progress)
+ => Vector4.Lerp(@from, to, progress);
+
+ public static Vector4Interpolator Instance { get; } = new Vector4Interpolator();
+ }
+
+ class QuaternionInterpolator : IInterpolator
+ {
+ public Quaternion Interpolate(Quaternion @from, Quaternion to, float progress)
+ => Quaternion.Lerp(@from, to, progress);
+
+ public static QuaternionInterpolator Instance { get; } = new QuaternionInterpolator();
+ }
+
+ class ColorInterpolator : IInterpolator
+ {
+ static byte Lerp(float a, float b, float p) => (byte) Math.Max(0, Math.Min(255, (p * (b - a) + a)));
+
+ public static Avalonia.Media.Color
+ LerpRGB(Avalonia.Media.Color to, Avalonia.Media.Color from, float progress) =>
+ new Avalonia.Media.Color(Lerp(to.A, @from.A, progress),
+ Lerp(to.R, @from.R, progress),
+ Lerp(to.G, @from.G, progress),
+ Lerp(to.B, @from.B, progress));
+
+ public Avalonia.Media.Color Interpolate(Avalonia.Media.Color @from, Avalonia.Media.Color to, float progress)
+ => LerpRGB(@from, to, progress);
+
+ public static ColorInterpolator Instance { get; } = new ColorInterpolator();
+ }
+
+ class BooleanInterpolator : IInterpolator
+ {
+ public bool Interpolate(bool @from, bool to, float progress) => progress >= 1 ? to : @from;
+
+ public static BooleanInterpolator Instance { get; } = new BooleanInterpolator();
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimation.cs b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimation.cs
new file mode 100644
index 0000000000..d21a4d06e3
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimation.cs
@@ -0,0 +1,136 @@
+using System;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+
+ ///
+ /// A time-based animation with one or more key frames.
+ /// These frames are markers, allowing developers to specify values at specific times for the animating property.
+ /// KeyFrame animations can be further customized by specifying how the animation interpolates between keyframes.
+ ///
+ public abstract class KeyFrameAnimation : CompositionAnimation
+ {
+ private TimeSpan _duration = TimeSpan.FromMilliseconds(1);
+
+ internal KeyFrameAnimation(Compositor compositor) : base(compositor)
+ {
+ }
+
+ ///
+ /// The delay behavior of the key frame animation.
+ ///
+ public AnimationDelayBehavior DelayBehavior { get; set; }
+
+ ///
+ /// Delay before the animation starts after is called.
+ ///
+ public System.TimeSpan DelayTime { get; set; }
+
+ ///
+ /// The direction the animation is playing.
+ /// The Direction property allows you to drive your animation from start to end or end to start or alternate
+ /// between start and end or end to start if animation has an greater than one.
+ /// This gives an easy way for customizing animation definitions.
+ ///
+ public PlaybackDirection Direction { get; set; }
+
+ ///
+ /// The duration of the animation.
+ /// Minimum allowed value is 1ms and maximum allowed value is 24 days.
+ ///
+ public TimeSpan Duration
+ {
+ get => _duration;
+ set
+ {
+ if (_duration < TimeSpan.FromMilliseconds(1) || _duration > TimeSpan.FromDays(1))
+ throw new ArgumentException("Minimum allowed value is 1ms and maximum allowed value is 24 days.");
+ _duration = value;
+ }
+ }
+
+ ///
+ /// The iteration behavior for the key frame animation.
+ ///
+ public AnimationIterationBehavior IterationBehavior { get; set; }
+
+ ///
+ /// The number of times to repeat the key frame animation.
+ ///
+ public int IterationCount { get; set; } = 1;
+
+ ///
+ /// Specifies how to set the property value when animation is stopped
+ ///
+ public AnimationStopBehavior StopBehavior { get; set; }
+
+ private protected abstract IKeyFrames KeyFrames { get; }
+
+ ///
+ /// Inserts an expression keyframe.
+ ///
+ ///
+ /// The time the key frame should occur at, expressed as a percentage of the animation Duration. Allowed value is from 0.0 to 1.0.
+ ///
+ /// The expression used to calculate the value of the key frame.
+ /// The easing function to use when interpolating between frames.
+ public void InsertExpressionKeyFrame(float normalizedProgressKey, string value,
+ Easing? easingFunction = null) =>
+ KeyFrames.InsertExpressionKeyFrame(normalizedProgressKey, value, easingFunction ?? Compositor.DefaultEasing);
+ }
+
+ ///
+ /// Specifies the animation delay behavior.
+ ///
+ public enum AnimationDelayBehavior
+ {
+ ///
+ /// If a DelayTime is specified, it delays starting the animation according to delay time and after delay
+ /// has expired it applies animation to the object property.
+ ///
+ SetInitialValueAfterDelay,
+ ///
+ /// Applies the initial value of the animation (i.e. the value at Keyframe 0) to the object before the delay time
+ /// is elapsed (when there is a DelayTime specified), it then delays starting the animation according to the DelayTime.
+ ///
+ SetInitialValueBeforeDelay
+ }
+
+ ///
+ /// Specifies if the animation should loop.
+ ///
+ public enum AnimationIterationBehavior
+ {
+ ///
+ /// The animation should loop the specified number of times.
+ ///
+ Count,
+ ///
+ /// The animation should loop forever.
+ ///
+ Forever
+ }
+
+ ///
+ /// Specifies the behavior of an animation when it stops.
+ ///
+ public enum AnimationStopBehavior
+ {
+ ///
+ /// Leave the animation at its current value.
+ ///
+ LeaveCurrentValue,
+ ///
+ /// Reset the animation to its initial value.
+ ///
+ SetToInitialValue,
+ ///
+ /// Set the animation to its final value.
+ ///
+ SetToFinalValue
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs
new file mode 100644
index 0000000000..e20a4a9ad8
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrameAnimationInstance.cs
@@ -0,0 +1,180 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Animation;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// Server-side counterpart of KeyFrameAnimation with values baked-in
+ ///
+ class KeyFrameAnimationInstance : AnimationInstanceBase, IAnimationInstance where T : struct
+ {
+ private readonly IInterpolator _interpolator;
+ private readonly ServerKeyFrame[] _keyFrames;
+ private readonly ExpressionVariant? _finalValue;
+ private readonly AnimationDelayBehavior _delayBehavior;
+ private readonly TimeSpan _delayTime;
+ private readonly PlaybackDirection _direction;
+ private readonly TimeSpan _duration;
+ private readonly AnimationIterationBehavior _iterationBehavior;
+ private readonly int _iterationCount;
+ private readonly AnimationStopBehavior _stopBehavior;
+ private TimeSpan _startedAt;
+ private T _startingValue;
+ private readonly TimeSpan _totalDuration;
+ private bool _finished;
+
+ public KeyFrameAnimationInstance(
+ IInterpolator interpolator, ServerKeyFrame[] keyFrames,
+ PropertySetSnapshot snapshot, ExpressionVariant? finalValue,
+ ServerObject target,
+ AnimationDelayBehavior delayBehavior, TimeSpan delayTime,
+ PlaybackDirection direction, TimeSpan duration,
+ AnimationIterationBehavior iterationBehavior,
+ int iterationCount, AnimationStopBehavior stopBehavior) : base(target, snapshot)
+ {
+ _interpolator = interpolator;
+ _keyFrames = keyFrames;
+ _finalValue = finalValue;
+ _delayBehavior = delayBehavior;
+ _delayTime = delayTime;
+ _direction = direction;
+ _duration = duration;
+ _iterationBehavior = iterationBehavior;
+ _iterationCount = iterationCount;
+ _stopBehavior = stopBehavior;
+ if (_iterationBehavior == AnimationIterationBehavior.Count)
+ _totalDuration = delayTime.Add(TimeSpan.FromTicks(iterationCount * _duration.Ticks));
+ if (_keyFrames.Length == 0)
+ throw new InvalidOperationException("Animation has no key frames");
+ if(_duration.Ticks <= 0)
+ throw new InvalidOperationException("Invalid animation duration");
+ }
+
+
+ protected override ExpressionVariant EvaluateCore(TimeSpan now, ExpressionVariant currentValue)
+ {
+ var starting = ExpressionVariant.Create(_startingValue);
+ var ctx = new ExpressionEvaluationContext
+ {
+ Parameters = Parameters,
+ Target = TargetObject,
+ CurrentValue = currentValue,
+ FinalValue = _finalValue ?? starting,
+ StartingValue = starting,
+ ForeignFunctionInterface = BuiltInExpressionFfi.Instance
+ };
+ var elapsed = now - _startedAt;
+ var res = EvaluateImpl(elapsed, currentValue, ref ctx);
+
+ if (_iterationBehavior == AnimationIterationBehavior.Count
+ && !_finished
+ && elapsed > _totalDuration)
+ {
+ // Active check?
+ TargetObject.Compositor.RemoveFromClock(this);
+ _finished = true;
+ }
+ return res;
+ }
+
+ private ExpressionVariant EvaluateImpl(TimeSpan elapsed, ExpressionVariant currentValue, ref ExpressionEvaluationContext ctx)
+ {
+ if (elapsed < _delayTime)
+ {
+ if (_delayBehavior == AnimationDelayBehavior.SetInitialValueBeforeDelay)
+ return ExpressionVariant.Create(GetKeyFrame(ref ctx, _keyFrames[0]));
+ return currentValue;
+ }
+
+ elapsed -= _delayTime;
+ var iterationNumber = elapsed.Ticks / _duration.Ticks;
+ if (_iterationBehavior == AnimationIterationBehavior.Count
+ && iterationNumber >= _iterationCount)
+ return ExpressionVariant.Create(GetKeyFrame(ref ctx, _keyFrames[_keyFrames.Length - 1]));
+
+
+ var evenIterationNumber = iterationNumber % 2 == 0;
+ elapsed = TimeSpan.FromTicks(elapsed.Ticks % _duration.Ticks);
+
+ var reverse =
+ _direction == PlaybackDirection.Alternate
+ ? !evenIterationNumber
+ : _direction == PlaybackDirection.AlternateReverse
+ ? evenIterationNumber
+ : _direction == PlaybackDirection.Reverse;
+
+ var iterationProgress = elapsed.TotalSeconds / _duration.TotalSeconds;
+ if (reverse)
+ iterationProgress = 1 - iterationProgress;
+
+ var left = new ServerKeyFrame
+ {
+ Value = _startingValue
+ };
+ var right = _keyFrames[_keyFrames.Length - 1];
+ for (var c = 0; c < _keyFrames.Length; c++)
+ {
+ var kf = _keyFrames[c];
+ if (kf.Key < iterationProgress)
+ {
+ // this is the last frame
+ if (c == _keyFrames.Length - 1)
+ return ExpressionVariant.Create(GetKeyFrame(ref ctx, kf));
+
+ left = kf;
+ right = _keyFrames[c + 1];
+ break;
+ }
+ }
+
+ var keyProgress = Math.Max(0, Math.Min(1, (iterationProgress - left.Key) / (right.Key - left.Key)));
+
+ var easedKeyProgress = (float)right.EasingFunction.Ease(keyProgress);
+ if (float.IsNaN(easedKeyProgress) || float.IsInfinity(easedKeyProgress))
+ return currentValue;
+
+ return ExpressionVariant.Create(_interpolator.Interpolate(
+ GetKeyFrame(ref ctx, left),
+ GetKeyFrame(ref ctx, right),
+ easedKeyProgress
+ ));
+ }
+
+ T GetKeyFrame(ref ExpressionEvaluationContext ctx, ServerKeyFrame f)
+ {
+ if (f.Expression != null)
+ return f.Expression.Evaluate(ref ctx).CastOrDefault();
+ else
+ return f.Value;
+ }
+
+ public override void Initialize(TimeSpan startedAt, ExpressionVariant startingValue, CompositionProperty property)
+ {
+ _startedAt = startedAt;
+ _startingValue = startingValue.CastOrDefault();
+ var hs = new HashSet<(string, string)>();
+
+ // TODO: Update subscriptions based on the current keyframe rather than keeping subscriptions to all of them
+ foreach (var frame in _keyFrames)
+ frame.Expression?.CollectReferences(hs);
+ Initialize(property, hs);
+ }
+
+ public override void Activate()
+ {
+ TargetObject.Compositor.AddToClock(this);
+ base.Activate();
+ }
+
+ public override void Deactivate()
+ {
+ TargetObject.Compositor.RemoveFromClock(this);
+ base.Deactivate();
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrames.cs b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrames.cs
new file mode 100644
index 0000000000..79045ccff8
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/KeyFrames.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Animation.Easings;
+using Avalonia.Rendering.Composition.Expressions;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+
+ ///
+ /// Collection of composition animation key frames
+ ///
+ ///
+ class KeyFrames : List>, IKeyFrames
+ {
+ void Validate(float key)
+ {
+ if (key < 0 || key > 1)
+ throw new ArgumentException("Key frame key");
+ if (Count > 0 && this[Count - 1].NormalizedProgressKey > key)
+ throw new ArgumentException("Key frame key " + key + " is less than the previous one");
+ }
+
+ public void InsertExpressionKeyFrame(float normalizedProgressKey, string value, IEasing easingFunction)
+ {
+ Validate(normalizedProgressKey);
+ Add(new KeyFrame
+ {
+ NormalizedProgressKey = normalizedProgressKey,
+ Expression = Expression.Parse(value),
+ EasingFunction = easingFunction
+ });
+ }
+
+ public void Insert(float normalizedProgressKey, T value, IEasing easingFunction)
+ {
+ Validate(normalizedProgressKey);
+ Add(new KeyFrame
+ {
+ NormalizedProgressKey = normalizedProgressKey,
+ Value = value,
+ EasingFunction = easingFunction
+ });
+ }
+
+ public ServerKeyFrame[] Snapshot()
+ {
+ var frames = new ServerKeyFrame[Count];
+ for (var c = 0; c < Count; c++)
+ {
+ var f = this[c];
+ frames[c] = new ServerKeyFrame
+ {
+ Expression = f.Expression,
+ Value = f.Value,
+ EasingFunction = f.EasingFunction,
+ Key = f.NormalizedProgressKey
+ };
+ }
+ return frames;
+ }
+ }
+
+ ///
+ /// Composition animation key frame
+ ///
+ struct KeyFrame
+ {
+ public float NormalizedProgressKey;
+ public T Value;
+ public Expression Expression;
+ public IEasing EasingFunction;
+ }
+
+ ///
+ /// Server-side composition animation key frame
+ ///
+ struct ServerKeyFrame
+ {
+ public T Value;
+ public Expression? Expression;
+ public IEasing EasingFunction;
+ public float Key;
+ }
+
+ interface IKeyFrames
+ {
+ public void InsertExpressionKeyFrame(float normalizedProgressKey, string value, IEasing easingFunction);
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/PropertySetSnapshot.cs b/src/Avalonia.Base/Rendering/Composition/Animations/PropertySetSnapshot.cs
new file mode 100644
index 0000000000..af3c344999
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/PropertySetSnapshot.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using Avalonia.Rendering.Composition.Expressions;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition.Animations
+{
+ ///
+ /// A snapshot of properties used by an animation
+ ///
+ internal class PropertySetSnapshot : IExpressionParameterCollection, IExpressionObject
+ {
+ private readonly Dictionary _dic;
+
+ public struct Value
+ {
+ public ExpressionVariant Variant;
+ public IExpressionObject Object;
+
+ public Value(IExpressionObject o)
+ {
+ Object = o;
+ Variant = default;
+ }
+
+ public static implicit operator Value(ExpressionVariant v) => new Value
+ {
+ Variant = v
+ };
+ }
+
+ public PropertySetSnapshot(Dictionary dic)
+ {
+ _dic = dic;
+ }
+
+ public ExpressionVariant GetParameter(string name)
+ {
+ _dic.TryGetValue(name, out var v);
+ return v.Variant;
+ }
+
+ public IExpressionObject GetObjectParameter(string name)
+ {
+ _dic.TryGetValue(name, out var v);
+ return v.Object;
+ }
+
+ public ExpressionVariant GetProperty(string name) => GetParameter(name);
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
new file mode 100644
index 0000000000..db773bc43c
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
@@ -0,0 +1,280 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Runtime.InteropServices;
+using Avalonia.Collections;
+using Avalonia.Collections.Pooled;
+using Avalonia.Media;
+using Avalonia.Rendering.Composition.Drawing;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition;
+
+///
+/// A renderer that utilizes to render the visual tree
+///
+public class CompositingRenderer : IRendererWithCompositor
+{
+ private readonly IRenderRoot _root;
+ private readonly Compositor _compositor;
+ CompositionDrawingContext _recorder = new();
+ DrawingContext _recordingContext;
+ private HashSet _dirty = new();
+ private HashSet _recalculateChildren = new();
+ private bool _queuedUpdate;
+ private Action _update;
+ private Action _invalidateScene;
+
+ internal CompositionTarget CompositionTarget;
+
+ ///
+ /// Asks the renderer to only draw frames on the render thread. Makes Paint to wait until frame is rendered.
+ ///
+ public bool RenderOnlyOnRenderThread { get; set; } = true;
+
+ public CompositingRenderer(IRenderRoot root,
+ Compositor compositor)
+ {
+ _root = root;
+ _compositor = compositor;
+ _recordingContext = new DrawingContext(_recorder);
+ CompositionTarget = compositor.CreateCompositionTarget(root.CreateRenderTarget);
+ CompositionTarget.Root = ((Visual)root!.VisualRoot!).AttachToCompositor(compositor);
+ _update = Update;
+ _invalidateScene = InvalidateScene;
+ }
+
+ ///
+ public bool DrawFps
+ {
+ get => CompositionTarget.DrawFps;
+ set => CompositionTarget.DrawFps = value;
+ }
+
+ ///
+ public bool DrawDirtyRects
+ {
+ get => CompositionTarget.DrawDirtyRects;
+ set => CompositionTarget.DrawDirtyRects = value;
+ }
+
+ ///
+ public event EventHandler? SceneInvalidated;
+
+ void QueueUpdate()
+ {
+ if(_queuedUpdate)
+ return;
+ _queuedUpdate = true;
+ Dispatcher.UIThread.Post(_update, DispatcherPriority.Composition);
+ }
+
+ ///
+ public void AddDirty(IVisual visual)
+ {
+ _dirty.Add((Visual)visual);
+ QueueUpdate();
+ }
+
+ ///
+ public IEnumerable HitTest(Point p, IVisual root, Func? filter)
+ {
+ var res = CompositionTarget.TryHitTest(p, filter);
+ if(res == null)
+ yield break;
+ foreach(var v in res)
+ {
+ if (v is CompositionDrawListVisual dv)
+ {
+ if (filter == null || filter(dv.Visual))
+ yield return dv.Visual;
+ }
+ }
+ }
+
+ ///
+ public IVisual? HitTestFirst(Point p, IVisual root, Func? filter)
+ {
+ // TODO: Optimize
+ return HitTest(p, root, filter).FirstOrDefault();
+ }
+
+ ///
+ public void RecalculateChildren(IVisual visual)
+ {
+ _recalculateChildren.Add((Visual)visual);
+ QueueUpdate();
+ }
+
+ private void SyncChildren(Visual v)
+ {
+ //TODO: Optimize by moving that logic to Visual itself
+ if(v.CompositionVisual == null)
+ return;
+ var compositionChildren = v.CompositionVisual.Children;
+ var visualChildren = (AvaloniaList)v.GetVisualChildren();
+
+ PooledList<(IVisual visual, int index)>? sortedChildren = null;
+ if (v.HasNonUniformZIndexChildren && visualChildren.Count > 1)
+ {
+ sortedChildren = new (visualChildren.Count);
+ for (var c = 0; c < visualChildren.Count; c++)
+ sortedChildren.Add((visualChildren[c], c));
+
+ // Regular Array.Sort is unstable, we need to provide indices as well to avoid reshuffling elements.
+ sortedChildren.Sort(static (lhs, rhs) =>
+ {
+ var result = lhs.visual.ZIndex.CompareTo(rhs.visual.ZIndex);
+ return result == 0 ? lhs.index.CompareTo(rhs.index) : result;
+ });
+ }
+
+ if (compositionChildren.Count == visualChildren.Count)
+ {
+ bool mismatch = false;
+ if (v.HasNonUniformZIndexChildren)
+ {
+
+
+ }
+
+ if (sortedChildren != null)
+ for (var c = 0; c < visualChildren.Count; c++)
+ {
+ if (!ReferenceEquals(compositionChildren[c], ((Visual)sortedChildren[c].visual).CompositionVisual))
+ {
+ mismatch = true;
+ break;
+ }
+ }
+ else
+ for (var c = 0; c < visualChildren.Count; c++)
+ if (!ReferenceEquals(compositionChildren[c], ((Visual)visualChildren[c]).CompositionVisual))
+ {
+ mismatch = true;
+ break;
+ }
+
+
+ if (!mismatch)
+ {
+ sortedChildren?.Dispose();
+ return;
+ }
+ }
+
+ compositionChildren.Clear();
+ if (sortedChildren != null)
+ {
+ foreach (var ch in sortedChildren)
+ {
+ var compositionChild = ((Visual)ch.visual).CompositionVisual;
+ if (compositionChild != null)
+ compositionChildren.Add(compositionChild);
+ }
+ sortedChildren.Dispose();
+ }
+ else
+ foreach (var ch in v.GetVisualChildren())
+ {
+ var compositionChild = ((Visual)ch).CompositionVisual;
+ if (compositionChild != null)
+ compositionChildren.Add(compositionChild);
+ }
+ }
+
+ private void InvalidateScene() =>
+ SceneInvalidated?.Invoke(this, new SceneInvalidatedEventArgs(_root, new Rect(_root.ClientSize)));
+
+ private void Update()
+ {
+ _queuedUpdate = false;
+ foreach (var visual in _dirty)
+ {
+ var comp = visual.CompositionVisual;
+ if(comp == null)
+ continue;
+
+ // TODO: Optimize all of that by moving to the Visual itself, so we won't have to recalculate every time
+ comp.Offset = new Vector3((float)visual.Bounds.Left, (float)visual.Bounds.Top, 0);
+ comp.Size = new Vector2((float)visual.Bounds.Width, (float)visual.Bounds.Height);
+ comp.Visible = visual.IsVisible;
+ comp.Opacity = (float)visual.Opacity;
+ comp.ClipToBounds = visual.ClipToBounds;
+ comp.Clip = visual.Clip?.PlatformImpl;
+ comp.OpacityMask = visual.OpacityMask;
+
+ var renderTransform = Matrix.Identity;
+
+ if (visual.HasMirrorTransform)
+ renderTransform = new Matrix(-1.0, 0.0, 0.0, 1.0, visual.Bounds.Width, 0);
+
+ if (visual.RenderTransform != null)
+ {
+ var origin = visual.RenderTransformOrigin.ToPixels(new Size(visual.Bounds.Width, visual.Bounds.Height));
+ var offset = Matrix.CreateTranslation(origin);
+ renderTransform *= (-offset) * visual.RenderTransform.Value * (offset);
+ }
+
+
+
+ comp.TransformMatrix = MatrixUtils.ToMatrix4x4(renderTransform);
+
+ _recorder.BeginUpdate(comp.DrawList);
+ visual.Render(_recordingContext);
+ comp.DrawList = _recorder.EndUpdate();
+
+ SyncChildren(visual);
+ }
+ foreach(var v in _recalculateChildren)
+ if (!_dirty.Contains(v))
+ SyncChildren(v);
+ _dirty.Clear();
+ _recalculateChildren.Clear();
+ CompositionTarget.Size = _root.ClientSize;
+ CompositionTarget.Scaling = _root.RenderScaling;
+ Compositor.InvokeOnNextCommit(_invalidateScene);
+ }
+
+ public void Resized(Size size)
+ {
+ }
+
+ public void Paint(Rect rect)
+ {
+ Update();
+ CompositionTarget.RequestRedraw();
+ if(RenderOnlyOnRenderThread && Compositor.Loop.RunsInBackground)
+ Compositor.RequestCommitAsync().Wait();
+ else
+ CompositionTarget.ImmediateUIThreadRender();
+ }
+
+ public void Start() => CompositionTarget.IsEnabled = true;
+
+ public void Stop()
+ {
+ CompositionTarget.IsEnabled = false;
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ CompositionTarget.Dispose();
+
+ // Wait for the composition batch to be applied and rendered to guarantee that
+ // render target is not used anymore and can be safely disposed
+ if (Compositor.Loop.RunsInBackground)
+ _compositor.RequestCommitAsync().Wait();
+ }
+
+ ///
+ /// The associated object
+ ///
+ public Compositor Compositor => _compositor;
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
new file mode 100644
index 0000000000..77b392eee5
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Numerics;
+using Avalonia.Rendering.Composition.Drawing;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Rendering.Composition.Transport;
+using Avalonia.VisualTree;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition;
+
+
+///
+/// A composition visual that holds a list of drawing commands issued by
+///
+internal class CompositionDrawListVisual : CompositionContainerVisual
+{
+ ///
+ /// The associated
+ ///
+ public Visual Visual { get; }
+
+ private bool _drawListChanged;
+ private CompositionDrawList? _drawList;
+
+ ///
+ /// The list of drawing commands
+ ///
+ public CompositionDrawList? DrawList
+ {
+ get => _drawList;
+ set
+ {
+ _drawList?.Dispose();
+ _drawList = value;
+ _drawListChanged = true;
+ RegisterForSerialization();
+ }
+ }
+
+ private protected override void SerializeChangesCore(BatchStreamWriter writer)
+ {
+ writer.Write((byte)(_drawListChanged ? 1 : 0));
+ if (_drawListChanged)
+ {
+ writer.WriteObject(DrawList?.Clone());
+ _drawListChanged = false;
+ }
+ base.SerializeChangesCore(writer);
+ }
+
+ internal CompositionDrawListVisual(Compositor compositor, ServerCompositionDrawListVisual server, Visual visual) : base(compositor, server)
+ {
+ Visual = visual;
+ }
+
+ internal override bool HitTest(Point pt, Func? filter)
+ {
+ var custom = Visual as ICustomHitTest;
+ if (DrawList == null && custom == null)
+ return false;
+ if (filter != null && !filter(Visual))
+ return false;
+ if (custom != null)
+ {
+ // Simulate the old behavior
+ // TODO: Change behavior once legacy renderers are removed
+ pt += new Point(Offset.X, Offset.Y);
+ return custom.HitTest(pt);
+ }
+
+ foreach (var op in DrawList!)
+ if (op.Item.HitTest(pt))
+ return true;
+ return false;
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionObject.cs b/src/Avalonia.Base/Rendering/Composition/CompositionObject.cs
new file mode 100644
index 0000000000..50332926ad
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionObject.cs
@@ -0,0 +1,143 @@
+using System;
+using Avalonia.Rendering.Composition.Animations;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Rendering.Composition.Transport;
+using Avalonia.Utilities;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition
+{
+ ///
+ /// Base class of the composition API representing a node in the visual tree structure.
+ /// Composition objects are the visual tree structure on which all other features of the composition API use and build on.
+ /// The API allows developers to define and create one or many objects each representing a single node in a Visual tree.
+ ///
+ public abstract class CompositionObject : IDisposable
+ {
+ ///
+ /// The collection of implicit animations attached to this object.
+ ///
+ public ImplicitAnimationCollection? ImplicitAnimations { get; set; }
+
+ private protected InlineDictionary PendingAnimations;
+ internal CompositionObject(Compositor compositor, ServerObject server)
+ {
+ Compositor = compositor;
+ Server = server;
+ }
+
+ ///
+ /// The associated Compositor
+ ///
+ public Compositor Compositor { get; }
+ internal ServerObject Server { get; }
+ public bool IsDisposed { get; private set; }
+ private bool _registeredForSerialization;
+
+ private static void ThrowInvalidOperation() =>
+ throw new InvalidOperationException("There is no server-side counterpart for this object");
+
+ public void Dispose()
+ {
+ RegisterForSerialization();
+ IsDisposed = true;
+ }
+
+ ///
+ /// Connects an animation with the specified property of the object and starts the animation.
+ ///
+ public void StartAnimation(string propertyName, CompositionAnimation animation)
+ => StartAnimation(propertyName, animation, null);
+
+ internal virtual void StartAnimation(string propertyName, CompositionAnimation animation, ExpressionVariant? finalValue)
+ {
+ throw new ArgumentException("Unknown property " + propertyName);
+ }
+
+ ///
+ /// Starts an animation group.
+ /// The StartAnimationGroup method on CompositionObject lets you start CompositionAnimationGroup.
+ /// All the animations in the group will be started at the same time on the object.
+ ///
+ public void StartAnimationGroup(ICompositionAnimationBase grp)
+ {
+ if (grp is CompositionAnimation animation)
+ {
+ if(animation.Target == null)
+ throw new ArgumentException("Animation Target can't be null");
+ StartAnimation(animation.Target, animation);
+ }
+ else if (grp is CompositionAnimationGroup group)
+ {
+ foreach (var a in group.Animations)
+ {
+ if (a.Target == null)
+ throw new ArgumentException("Animation Target can't be null");
+ StartAnimation(a.Target, a);
+ }
+ }
+ }
+
+ bool StartAnimationGroupPart(CompositionAnimation animation, string target, ExpressionVariant finalValue)
+ {
+ if(animation.Target == null)
+ throw new ArgumentException("Animation Target can't be null");
+ if (animation.Target == target)
+ {
+ StartAnimation(animation.Target, animation, finalValue);
+ return true;
+ }
+ else
+ {
+ StartAnimation(animation.Target, animation);
+ return false;
+ }
+ }
+
+ internal bool StartAnimationGroup(ICompositionAnimationBase grp, string target, ExpressionVariant finalValue)
+ {
+ if (grp is CompositionAnimation animation)
+ return StartAnimationGroupPart(animation, target, finalValue);
+ if (grp is CompositionAnimationGroup group)
+ {
+ var matched = false;
+ foreach (var a in group.Animations)
+ {
+ if (a.Target == null)
+ throw new ArgumentException("Animation Target can't be null");
+ if (StartAnimationGroupPart(a, target, finalValue))
+ matched = true;
+ }
+
+ return matched;
+ }
+
+ throw new ArgumentException();
+ }
+
+ protected void RegisterForSerialization()
+ {
+ if (Server == null)
+ throw new InvalidOperationException("The object doesn't have an associated server counterpart");
+
+ if(_registeredForSerialization)
+ return;
+ _registeredForSerialization = true;
+ Compositor.RegisterForSerialization(this);
+ }
+
+ internal void SerializeChanges(BatchStreamWriter writer)
+ {
+ _registeredForSerialization = false;
+ SerializeChangesCore(writer);
+ }
+
+ private protected virtual void SerializeChangesCore(BatchStreamWriter writer)
+ {
+ if (Server is IDisposable)
+ writer.Write((byte)(IsDisposed ? 1 : 0));
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionPropertySet.cs b/src/Avalonia.Base/Rendering/Composition/CompositionPropertySet.cs
new file mode 100644
index 0000000000..7d794af9a2
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionPropertySet.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Avalonia.Rendering.Composition.Animations;
+using Avalonia.Rendering.Composition.Expressions;
+using Avalonia.Rendering.Composition.Transport;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition
+{
+ ///
+ /// s are s that allow storage of key values pairs
+ /// that can be shared across the application and are not tied to the lifetime of another composition object.
+ /// s are most commonly used with animations, where they maintain key-value pairs
+ /// that are referenced to drive portions of composition animations. s
+ /// provide the ability to insert key-value pairs or retrieve a value for a given key.
+ /// does not support a delete function – ensure you use
+ /// to store values that will be shared across the application.
+ ///
+ public class CompositionPropertySet : CompositionObject
+ {
+ private readonly Dictionary _variants = new Dictionary();
+ private readonly Dictionary _objects = new Dictionary();
+
+ internal CompositionPropertySet(Compositor compositor) : base(compositor, null!)
+ {
+ }
+
+ internal void Set(string key, ExpressionVariant value)
+ {
+ _objects.Remove(key);
+ _variants[key] = value;
+ }
+
+ /*
+ For INTERNAL USE by CompositionAnimation ONLY, we DON'T support expression
+ paths like SomeParam.SomePropertyObject.SomeValue
+ */
+ internal void Set(string key, CompositionObject obj)
+ {
+ _objects[key] = obj ?? throw new ArgumentNullException(nameof(obj));
+ _variants.Remove(key);
+ }
+
+ public void InsertColor(string propertyName, Avalonia.Media.Color value) => Set(propertyName, value);
+
+ public void InsertMatrix3x2(string propertyName, Matrix3x2 value) => Set(propertyName, value);
+
+ public void InsertMatrix4x4(string propertyName, Matrix4x4 value) => Set(propertyName, value);
+
+ public void InsertQuaternion(string propertyName, Quaternion value) => Set(propertyName, value);
+
+ public void InsertScalar(string propertyName, float value) => Set(propertyName, value);
+ public void InsertVector2(string propertyName, Vector2 value) => Set(propertyName, value);
+
+ public void InsertVector3(string propertyName, Vector3 value) => Set(propertyName, value);
+
+ public void InsertVector4(string propertyName, Vector4 value) => Set(propertyName, value);
+
+
+ CompositionGetValueStatus TryGetVariant(string key, out T value) where T : struct
+ {
+ value = default;
+ if (!_variants.TryGetValue(key, out var v))
+ return _objects.ContainsKey(key)
+ ? CompositionGetValueStatus.TypeMismatch
+ : CompositionGetValueStatus.NotFound;
+
+ return v.TryCast(out value) ? CompositionGetValueStatus.Succeeded : CompositionGetValueStatus.TypeMismatch;
+ }
+
+ public CompositionGetValueStatus TryGetColor(string propertyName, out Avalonia.Media.Color value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetMatrix3x2(string propertyName, out Matrix3x2 value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetMatrix4x4(string propertyName, out Matrix4x4 value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetQuaternion(string propertyName, out Quaternion value)
+ => TryGetVariant(propertyName, out value);
+
+
+ public CompositionGetValueStatus TryGetScalar(string propertyName, out float value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetVector2(string propertyName, out Vector2 value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetVector3(string propertyName, out Vector3 value)
+ => TryGetVariant(propertyName, out value);
+
+ public CompositionGetValueStatus TryGetVector4(string propertyName, out Vector4 value)
+ => TryGetVariant(propertyName, out value);
+
+
+ public void InsertBoolean(string propertyName, bool value) => Set(propertyName, value);
+
+ public CompositionGetValueStatus TryGetBoolean(string propertyName, out bool value)
+ => TryGetVariant(propertyName, out value);
+
+ internal void ClearAll()
+ {
+ _objects.Clear();
+ _variants.Clear();
+ }
+
+ internal void Clear(string key)
+ {
+ _objects.Remove(key);
+ _variants.Remove(key);
+ }
+
+ internal PropertySetSnapshot Snapshot() =>
+ SnapshotCore(1);
+
+ private PropertySetSnapshot SnapshotCore(int allowedNestingLevel)
+ {
+ var dic = new Dictionary(_objects.Count + _variants.Count);
+ foreach (var o in _objects)
+ {
+ if (o.Value is CompositionPropertySet ps)
+ {
+ if (allowedNestingLevel <= 0)
+ throw new InvalidOperationException("PropertySet depth limit reached");
+ dic[o.Key] = new PropertySetSnapshot.Value(ps.SnapshotCore(allowedNestingLevel - 1));
+ }
+ else if (o.Value.Server == null)
+ throw new InvalidOperationException($"Object of type {o.Value.GetType()} is not allowed");
+ else
+ dic[o.Key] = new PropertySetSnapshot.Value(o.Value.Server);
+ }
+
+ foreach (var v in _variants)
+ dic[v.Key] = v.Value;
+
+ return new PropertySetSnapshot(dic);
+ }
+ }
+
+ public enum CompositionGetValueStatus
+ {
+ Succeeded,
+ TypeMismatch,
+ NotFound
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
new file mode 100644
index 0000000000..4e53e163ec
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Avalonia.Collections.Pooled;
+using Avalonia.VisualTree;
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition
+{
+ ///
+ /// Represents the composition output (e. g. a window, embedded control, entire screen)
+ ///
+ public partial class CompositionTarget
+ {
+ partial void OnRootChanged()
+ {
+ if (Root != null)
+ Root.Root = this;
+ }
+
+ partial void OnRootChanging()
+ {
+ if (Root != null)
+ Root.Root = null;
+ }
+
+ ///
+ /// Attempts to perform a hit-tst
+ ///
+ ///
+ ///
+ ///
+ public PooledList? TryHitTest(Point point, Func? filter)
+ {
+ Server.Readback.NextRead();
+ if (Root == null)
+ return null;
+ var res = new PooledList();
+ HitTestCore(Root, point, res, filter);
+ return res;
+ }
+
+ ///
+ /// Attempts to transform a point to a particular CompositionVisual coordinate space
+ ///
+ ///
+ public Point? TryTransformToVisual(CompositionVisual visual, Point point)
+ {
+ if (visual.Root != this)
+ return null;
+ var v = visual;
+ var m = Matrix.Identity;
+ while (v != null)
+ {
+ if (!TryGetInvertedTransform(v, out var cm))
+ return null;
+ m = m * cm;
+ v = v.Parent;
+ }
+
+ return point * m;
+ }
+
+ bool TryGetInvertedTransform(CompositionVisual visual, out Matrix matrix)
+ {
+ var m = visual.TryGetServerGlobalTransform();
+ if (m == null)
+ {
+ matrix = default;
+ return false;
+ }
+
+ var m33 = MatrixUtils.ToMatrix(m.Value);
+ return m33.TryInvert(out matrix);
+ }
+
+ bool TryTransformTo(CompositionVisual visual, Point globalPoint, out Point v)
+ {
+ v = default;
+ if (TryGetInvertedTransform(visual, out var m))
+ {
+ v = globalPoint * m;
+ return true;
+ }
+
+ return false;
+ }
+
+ void HitTestCore(CompositionVisual visual, Point globalPoint, PooledList result,
+ Func? filter)
+ {
+ if (visual.Visible == false)
+ return;
+ if (!TryTransformTo(visual, globalPoint, out var point))
+ return;
+
+ if (visual.ClipToBounds
+ && (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y))
+ return;
+
+ if (visual.Clip?.FillContains(point) == false)
+ return;
+
+ // Inspect children
+ if (visual is CompositionContainerVisual cv)
+ for (var c = cv.Children.Count - 1; c >= 0; c--)
+ {
+ var ch = cv.Children[c];
+ HitTestCore(ch, globalPoint, result, filter);
+ }
+
+ // Hit-test the current node
+ if (visual.HitTest(point, filter))
+ result.Add(visual);
+ }
+
+ ///
+ /// Registers the composition target for explicit redraw
+ ///
+ public void RequestRedraw() => RegisterForSerialization();
+
+ ///
+ /// Performs composition directly on the UI thread
+ ///
+ internal void ImmediateUIThreadRender()
+ {
+ Compositor.RequestCommitAsync();
+ Compositor.Server.Render();
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Compositor.cs b/src/Avalonia.Base/Rendering/Composition/Compositor.cs
new file mode 100644
index 0000000000..45212d0f36
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Compositor.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using System.Threading.Tasks;
+using Avalonia.Animation.Easings;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Rendering.Composition.Animations;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Rendering.Composition.Transport;
+using Avalonia.Threading;
+
+
+// Special license applies License.md
+
+namespace Avalonia.Rendering.Composition
+{
+ ///
+ /// The Compositor class manages communication between UI-thread and render-thread parts of the composition engine.
+ /// It also serves as a factory to create UI-thread parts of various composition objects
+ ///
+ public partial class Compositor
+ {
+ internal IRenderLoop Loop { get; }
+ private ServerCompositor _server;
+ private bool _implicitBatchCommitQueued;
+ private Action _implicitBatchCommit;
+ private BatchStreamObjectPool