diff --git a/.ncrunch/BindingDemo.v3.ncrunchproject b/.ncrunch/BindingDemo.v3.ncrunchproject new file mode 100644 index 0000000000..319cd523ce --- /dev/null +++ b/.ncrunch/BindingDemo.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/RenderDemo.v3.ncrunchproject b/.ncrunch/RenderDemo.v3.ncrunchproject new file mode 100644 index 0000000000..319cd523ce --- /dev/null +++ b/.ncrunch/RenderDemo.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/.ncrunch/VirtualizationDemo.v3.ncrunchproject b/.ncrunch/VirtualizationDemo.v3.ncrunchproject new file mode 100644 index 0000000000..319cd523ce --- /dev/null +++ b/.ncrunch/VirtualizationDemo.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index accad63faa..b70e0bf77f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -14,7 +14,7 @@ jobs: displayName: 'Install Nuke' inputs: script: | - dotnet tool install --global Nuke.GlobalTool --version 0.12.3 + dotnet tool install --global Nuke.GlobalTool --version 0.24.0 - task: CmdLine@2 displayName: 'Run Nuke' inputs: @@ -35,16 +35,16 @@ jobs: vmImage: 'macOS-10.14' steps: - task: UseDotNet@2 - displayName: 'Use .NET Core SDK 3.0.x' + displayName: 'Use .NET Core SDK 3.1.101' inputs: packageType: sdk - version: 3.0.x + version: 3.1.101 - task: UseDotNet@2 - displayName: 'Use .NET Core Runtime 2.1.x' + displayName: 'Use .NET Core Runtime 3.1.1' inputs: packageType: runtime - version: 2.1.x + version: 3.1.1 - task: CmdLine@2 displayName: 'Install Mono 5.18' @@ -74,7 +74,7 @@ jobs: displayName: 'Install Nuke' inputs: script: | - dotnet tool install --global Nuke.GlobalTool --version 0.12.3 + dotnet tool install --global Nuke.GlobalTool --version 0.24.0 - task: CmdLine@2 displayName: 'Run Nuke' @@ -116,7 +116,7 @@ jobs: displayName: 'Install Nuke' inputs: script: | - dotnet tool install --global Nuke.GlobalTool --version 0.12.3 + dotnet tool install --global Nuke.GlobalTool --version 0.24.0 - task: CmdLine@2 displayName: 'Run Nuke' diff --git a/build/CoreLibraries.props b/build/CoreLibraries.props index 3923bdeeda..d61268173d 100644 --- a/build/CoreLibraries.props +++ b/build/CoreLibraries.props @@ -4,7 +4,6 @@ - diff --git a/global.json b/global.json index 1e599211d4..a3fdca9bed 100644 --- a/global.json +++ b/global.json @@ -1,4 +1,7 @@ { + "sdk": { + "version": "3.1.101" + }, "msbuild-sdks": { "Microsoft.Build.Traversal": "1.0.43", "MSBuild.Sdk.Extras": "2.0.46", diff --git a/native/Avalonia.Native/inc/avalonia-native.h b/native/Avalonia.Native/inc/avalonia-native.h index 4a960d47a1..ee57f54e59 100644 --- a/native/Avalonia.Native/inc/avalonia-native.h +++ b/native/Avalonia.Native/inc/avalonia-native.h @@ -25,6 +25,12 @@ struct IAvnGlSurfaceRenderingSession; struct IAvnAppMenu; struct IAvnAppMenuItem; +enum SystemDecorations { + SystemDecorationsNone = 0, + SystemDecorationsBorderOnly = 1, + SystemDecorationsFull = 2, +}; + struct AvnSize { double Width, Height; @@ -236,7 +242,7 @@ AVNCOM(IAvnWindow, 04) : virtual IAvnWindowBase { virtual HRESULT ShowDialog (IAvnWindow* parent) = 0; virtual HRESULT SetCanResize(bool value) = 0; - virtual HRESULT SetHasDecorations(bool value) = 0; + virtual HRESULT SetHasDecorations(SystemDecorations value) = 0; virtual HRESULT SetTitle (void* utf8Title) = 0; virtual HRESULT SetTitleBarColor (AvnColor color) = 0; virtual HRESULT SetWindowState(AvnWindowState state) = 0; diff --git a/native/Avalonia.Native/src/OSX/platformthreading.mm b/native/Avalonia.Native/src/OSX/platformthreading.mm index e7abedae51..ed00f8c5e1 100644 --- a/native/Avalonia.Native/src/OSX/platformthreading.mm +++ b/native/Avalonia.Native/src/OSX/platformthreading.mm @@ -157,11 +157,14 @@ NSArray* _modes; -(void) perform { + ComPtr cb; @synchronized (self) { _signaled = false; - if(_parent != NULL && _parent->SignaledCallback != NULL) - _parent->SignaledCallback->Signaled(0, false); + if(_parent != NULL) + cb = _parent->SignaledCallback; } + if(cb != nullptr) + cb->Signaled(0, false); } -(void) setParent:(PlatformThreadingInterface *)parent diff --git a/native/Avalonia.Native/src/OSX/window.mm b/native/Avalonia.Native/src/OSX/window.mm index b6ce172ffa..2c03407732 100644 --- a/native/Avalonia.Native/src/OSX/window.mm +++ b/native/Avalonia.Native/src/OSX/window.mm @@ -115,7 +115,6 @@ public: [NSApp activateIgnoringOtherApps:YES]; [Window setTitle:_lastTitle]; - [Window setTitleVisibility:NSWindowTitleVisible]; return S_OK; } @@ -411,7 +410,7 @@ class WindowImpl : public virtual WindowBaseImpl, public virtual IAvnWindow, pub { private: bool _canResize = true; - bool _hasDecorations = true; + SystemDecorations _hasDecorations = SystemDecorationsFull; CGRect _lastUndecoratedFrame; AvnWindowState _lastWindowState; @@ -476,23 +475,26 @@ private: bool IsZoomed () { - return _hasDecorations ? [Window isZoomed] : UndecoratedIsMaximized(); + return _hasDecorations != SystemDecorationsNone ? [Window isZoomed] : UndecoratedIsMaximized(); } void DoZoom() { - if (_hasDecorations) + switch (_hasDecorations) { - [Window performZoom:Window]; - } - else - { - if (!UndecoratedIsMaximized()) - { - _lastUndecoratedFrame = [Window frame]; - } - - [Window zoom:Window]; + case SystemDecorationsNone: + if (!UndecoratedIsMaximized()) + { + _lastUndecoratedFrame = [Window frame]; + } + + [Window zoom:Window]; + break; + + case SystemDecorationsBorderOnly: + case SystemDecorationsFull: + [Window performZoom:Window]; + break; } } @@ -506,13 +508,35 @@ private: } } - virtual HRESULT SetHasDecorations(bool value) override + virtual HRESULT SetHasDecorations(SystemDecorations value) override { @autoreleasepool { _hasDecorations = value; UpdateStyle(); - + + switch (_hasDecorations) + { + case SystemDecorationsNone: + [Window setHasShadow:NO]; + [Window setTitleVisibility:NSWindowTitleHidden]; + [Window setTitlebarAppearsTransparent:YES]; + break; + + case SystemDecorationsBorderOnly: + [Window setHasShadow:YES]; + [Window setTitleVisibility:NSWindowTitleHidden]; + [Window setTitlebarAppearsTransparent:YES]; + break; + + case SystemDecorationsFull: + [Window setHasShadow:YES]; + [Window setTitleVisibility:NSWindowTitleVisible]; + [Window setTitlebarAppearsTransparent:NO]; + [Window setTitle:_lastTitle]; + break; + } + return S_OK; } } @@ -523,7 +547,6 @@ private: { _lastTitle = [NSString stringWithUTF8String:(const char*)utf8title]; [Window setTitle:_lastTitle]; - [Window setTitleVisibility:NSWindowTitleVisible]; return S_OK; } @@ -645,10 +668,26 @@ protected: virtual NSWindowStyleMask GetStyle() override { unsigned long s = NSWindowStyleMaskBorderless; - if(_hasDecorations) - s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable; - if(_canResize) - s = s | NSWindowStyleMaskResizable; + + switch (_hasDecorations) + { + case SystemDecorationsNone: + break; + + case SystemDecorationsBorderOnly: + s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView; + break; + + case SystemDecorationsFull: + s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskBorderless; + if(_canResize) + { + s = s | NSWindowStyleMaskResizable; + } + + break; + } + return s; } }; diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs index 7b3b8465ce..358846a14e 100644 --- a/nukebuild/Build.cs +++ b/nukebuild/Build.cs @@ -13,6 +13,7 @@ using Nuke.Common.Tooling; using Nuke.Common.Tools.DotNet; using Nuke.Common.Tools.MSBuild; using Nuke.Common.Utilities; +using Nuke.Common.Utilities.Collections; using static Nuke.Common.EnvironmentInfo; using static Nuke.Common.IO.FileSystemTasks; using static Nuke.Common.IO.PathConstruction; @@ -26,11 +27,13 @@ using static Nuke.Common.Tools.VSWhere.VSWhereTasks; running and debugging a particular target (optionally without deps) would be way easier ReSharper/Rider - https://plugins.jetbrains.com/plugin/10803-nuke-support VSCode - https://marketplace.visualstudio.com/items?itemName=nuke.support - + */ partial class Build : NukeBuild { + [Solution("Avalonia.sln")] readonly Solution Solution; + static Lazy MsBuildExe = new Lazy(() => { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -54,7 +57,7 @@ partial class Build : NukeBuild protected override void OnBuildInitialized() { Parameters = new BuildParameters(this); - Information("Building version {0} of Avalonia ({1}) using version {2} of Nuke.", + Information("Building version {0} of Avalonia ({1}) using version {2} of Nuke.", Parameters.Version, Parameters.Configuration, typeof(NukeBuild).Assembly.GetName().Version.ToString()); @@ -93,29 +96,24 @@ partial class Build : NukeBuild string projectFile, Configure configurator = null) { - return MSBuild(projectFile, c => - { + return MSBuild(c => c + .SetProjectFile(projectFile) // This is required for VS2019 image on Azure Pipelines - if (Parameters.IsRunningOnWindows && Parameters.IsRunningOnAzure) - { - var javaSdk = Environment.GetEnvironmentVariable("JAVA_HOME_8_X64"); - if (javaSdk != null) - c = c.AddProperty("JavaSdkDirectory", javaSdk); - } - - c = c.AddProperty("PackageVersion", Parameters.Version) - .AddProperty("iOSRoslynPathHackRequired", "true") - .SetToolPath(MsBuildExe.Value) - .SetConfiguration(Parameters.Configuration) - .SetVerbosity(MSBuildVerbosity.Minimal); - c = configurator?.Invoke(c) ?? c; - return c; - }); + .When(Parameters.IsRunningOnWindows && + Parameters.IsRunningOnAzure, c => c + .AddProperty("JavaSdkDirectory", GetVariable("JAVA_HOME_8_X64"))) + .AddProperty("PackageVersion", Parameters.Version) + .AddProperty("iOSRoslynPathHackRequired", true) + .SetToolPath(MsBuildExe.Value) + .SetConfiguration(Parameters.Configuration) + .SetVerbosity(MSBuildVerbosity.Minimal) + .Apply(configurator)); } + Target Clean => _ => _.Executes(() => { - DeleteDirectories(Parameters.BuildDirs); - EnsureCleanDirectories(Parameters.BuildDirs); + Parameters.BuildDirs.ForEach(DeleteDirectory); + Parameters.BuildDirs.ForEach(EnsureCleanDirectory); EnsureCleanDirectory(Parameters.ArtifactsDir); EnsureCleanDirectory(Parameters.NugetIntermediateRoot); EnsureCleanDirectory(Parameters.NugetRoot); @@ -134,97 +132,84 @@ partial class Build : NukeBuild ); else - DotNetBuild(Parameters.MSBuildSolution, c => c + DotNetBuild(c => c + .SetProjectFile(Parameters.MSBuildSolution) .AddProperty("PackageVersion", Parameters.Version) .SetConfiguration(Parameters.Configuration) ); }); - - void RunCoreTest(string project) + + void RunCoreTest(string projectName) { - if(!project.EndsWith(".csproj")) - project = System.IO.Path.Combine(project, System.IO.Path.GetFileName(project)+".csproj"); - Information("Running tests from " + project); - XDocument xdoc; - using (var s = File.OpenRead(project)) - xdoc = XDocument.Load(s); - - List frameworks = null; - var targets = xdoc.Root.Descendants("TargetFrameworks").FirstOrDefault(); - if (targets != null) - frameworks = targets.Value.Split(';').Where(f => !string.IsNullOrWhiteSpace(f)).ToList(); - else - frameworks = new List {xdoc.Root.Descendants("TargetFramework").First().Value}; - - foreach(var fw in frameworks) + Information($"Running tests from {projectName}"); + var project = Solution.GetProject(projectName).NotNull("project != null"); + + foreach (var fw in project.GetTargetFrameworks()) { if (fw.StartsWith("net4") - && RuntimeInformation.IsOSPlatform(OSPlatform.Linux) + && RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && Environment.GetEnvironmentVariable("FORCE_LINUX_TESTS") != "1") { - Information($"Skipping {fw} tests on Linux - https://github.com/mono/mono/issues/13969"); + Information($"Skipping {projectName} ({fw}) tests on Linux - https://github.com/mono/mono/issues/13969"); continue; } - Information("Running for " + fw); - DotNetTest(c => - { - c = c - .SetProjectFile(project) - .SetConfiguration(Parameters.Configuration) - .SetFramework(fw) - .EnableNoBuild() - .EnableNoRestore(); - // NOTE: I can see that we could maybe add another extension method "Switch" or "If" to make this more convenient - if (Parameters.PublishTestResults) - c = c.SetLogger("trx").SetResultsDirectory(Parameters.TestResultsRoot); - return c; - }); + Information($"Running for {projectName} ({fw}) ..."); + + DotNetTest(c => c + .SetProjectFile(project) + .SetConfiguration(Parameters.Configuration) + .SetFramework(fw) + .EnableNoBuild() + .EnableNoRestore() + .When(Parameters.PublishTestResults, c => c + .SetLogger("trx") + .SetResultsDirectory(Parameters.TestResultsRoot))); } } Target RunCoreLibsTests => _ => _ - .OnlyWhen(() => !Parameters.SkipTests) + .OnlyWhenStatic(() => !Parameters.SkipTests) .DependsOn(Compile) .Executes(() => { - RunCoreTest("./tests/Avalonia.Animation.UnitTests"); - RunCoreTest("./tests/Avalonia.Base.UnitTests"); - RunCoreTest("./tests/Avalonia.Controls.UnitTests"); - RunCoreTest("./tests/Avalonia.Controls.DataGrid.UnitTests"); - RunCoreTest("./tests/Avalonia.Input.UnitTests"); - RunCoreTest("./tests/Avalonia.Interactivity.UnitTests"); - RunCoreTest("./tests/Avalonia.Layout.UnitTests"); - RunCoreTest("./tests/Avalonia.Markup.UnitTests"); - RunCoreTest("./tests/Avalonia.Markup.Xaml.UnitTests"); - RunCoreTest("./tests/Avalonia.Styling.UnitTests"); - RunCoreTest("./tests/Avalonia.Visuals.UnitTests"); - RunCoreTest("./tests/Avalonia.Skia.UnitTests"); - RunCoreTest("./tests/Avalonia.ReactiveUI.UnitTests"); + RunCoreTest("Avalonia.Animation.UnitTests"); + RunCoreTest("Avalonia.Base.UnitTests"); + RunCoreTest("Avalonia.Controls.UnitTests"); + RunCoreTest("Avalonia.Controls.DataGrid.UnitTests"); + RunCoreTest("Avalonia.Input.UnitTests"); + RunCoreTest("Avalonia.Interactivity.UnitTests"); + RunCoreTest("Avalonia.Layout.UnitTests"); + RunCoreTest("Avalonia.Markup.UnitTests"); + RunCoreTest("Avalonia.Markup.Xaml.UnitTests"); + RunCoreTest("Avalonia.Styling.UnitTests"); + RunCoreTest("Avalonia.Visuals.UnitTests"); + RunCoreTest("Avalonia.Skia.UnitTests"); + RunCoreTest("Avalonia.ReactiveUI.UnitTests"); }); Target RunRenderTests => _ => _ - .OnlyWhen(() => !Parameters.SkipTests) + .OnlyWhenStatic(() => !Parameters.SkipTests) .DependsOn(Compile) .Executes(() => { - RunCoreTest("./tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj"); + RunCoreTest("Avalonia.Skia.RenderTests"); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - RunCoreTest("./tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj"); + RunCoreTest("Avalonia.Direct2D1.RenderTests"); }); - + Target RunDesignerTests => _ => _ - .OnlyWhen(() => !Parameters.SkipTests && Parameters.IsRunningOnWindows) + .OnlyWhenStatic(() => !Parameters.SkipTests && Parameters.IsRunningOnWindows) .DependsOn(Compile) .Executes(() => { - RunCoreTest("./tests/Avalonia.DesignerSupport.Tests"); + RunCoreTest("Avalonia.DesignerSupport.Tests"); }); [PackageExecutable("JetBrains.dotMemoryUnit", "dotMemoryUnit.exe")] readonly Tool DotMemoryUnit; Target RunLeakTests => _ => _ - .OnlyWhen(() => !Parameters.SkipTests && Parameters.IsRunningOnWindows) + .OnlyWhenStatic(() => !Parameters.SkipTests && Parameters.IsRunningOnWindows) .DependsOn(Compile) .Executes(() => { @@ -235,7 +220,7 @@ partial class Build : NukeBuild }); Target ZipFiles => _ => _ - .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package) + .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package) .Executes(() => { var data = Parameters; @@ -259,9 +244,10 @@ partial class Build : NukeBuild MsBuildCommon(Parameters.MSBuildSolution, c => c .AddTargets("Pack")); else - DotNetPack(Parameters.MSBuildSolution, c => - c.SetConfiguration(Parameters.Configuration) - .AddProperty("PackageVersion", Parameters.Version)); + DotNetPack(c => c + .SetProject(Parameters.MSBuildSolution) + .SetConfiguration(Parameters.Configuration) + .AddProperty("PackageVersion", Parameters.Version)); }); Target CreateNugetPackages => _ => _ @@ -274,32 +260,40 @@ partial class Build : NukeBuild new NumergeNukeLogger())) throw new Exception("Package merge failed"); }); - + Target RunTests => _ => _ .DependsOn(RunCoreLibsTests) .DependsOn(RunRenderTests) .DependsOn(RunDesignerTests) .DependsOn(RunLeakTests); - + Target Package => _ => _ .DependsOn(RunTests) .DependsOn(CreateNugetPackages); - + Target CiAzureLinux => _ => _ .DependsOn(RunTests); - + Target CiAzureOSX => _ => _ .DependsOn(Package) .DependsOn(ZipFiles); - + Target CiAzureWindows => _ => _ .DependsOn(Package) .DependsOn(ZipFiles); - + public static int Main() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Execute(x => x.Package) : Execute(x => x.RunTests); } + +public static class ToolSettingsExtensions +{ + public static T Apply(this T settings, Configure configurator) + { + return configurator != null ? configurator(settings) : settings; + } +} diff --git a/nukebuild/BuildParameters.cs b/nukebuild/BuildParameters.cs index 65ba5e9756..149716b416 100644 --- a/nukebuild/BuildParameters.cs +++ b/nukebuild/BuildParameters.cs @@ -4,24 +4,21 @@ using System.Linq; using System.Runtime.InteropServices; using System.Xml.Linq; using Nuke.Common; -using Nuke.Common.BuildServers; -using Nuke.Common.Execution; +using Nuke.Common.CI.AzurePipelines; using Nuke.Common.IO; -using static Nuke.Common.IO.FileSystemTasks; using static Nuke.Common.IO.PathConstruction; -using static Nuke.Common.Tools.MSBuild.MSBuildTasks; public partial class Build { [Parameter("configuration")] public string Configuration { get; set; } - + [Parameter("skip-tests")] public bool SkipTests { get; set; } - + [Parameter("force-nuget-version")] public string ForceNugetVersion { get; set; } - + public class BuildParameters { public string Configuration { get; } @@ -79,15 +76,15 @@ public partial class Build IsRunningOnUnix = Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX; IsRunningOnWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - IsRunningOnAzure = Host == HostType.TeamServices || + IsRunningOnAzure = Host == HostType.AzurePipelines || Environment.GetEnvironmentVariable("LOGNAME") == "vsts"; if (IsRunningOnAzure) { - RepositoryName = TeamServices.Instance.RepositoryUri; - RepositoryBranch = TeamServices.Instance.SourceBranch; - IsPullRequest = TeamServices.Instance.PullRequestId.HasValue; - IsMainRepo = StringComparer.OrdinalIgnoreCase.Equals(MainRepo, TeamServices.Instance.RepositoryUri); + RepositoryName = AzurePipelines.Instance.RepositoryUri; + RepositoryBranch = AzurePipelines.Instance.SourceBranch; + IsPullRequest = AzurePipelines.Instance.PullRequestId.HasValue; + IsMainRepo = StringComparer.OrdinalIgnoreCase.Equals(MainRepo, AzurePipelines.Instance.RepositoryUri); } IsMainRepo = StringComparer.OrdinalIgnoreCase.Equals(MainRepo, diff --git a/nukebuild/Shims.cs b/nukebuild/Shims.cs index 461d617643..1ac14bf622 100644 --- a/nukebuild/Shims.cs +++ b/nukebuild/Shims.cs @@ -19,9 +19,9 @@ public partial class Build Logger.Info(info, args); } - private void Zip(PathConstruction.AbsolutePath target, params string[] paths) => Zip(target, paths.AsEnumerable()); + private void Zip(AbsolutePath target, params string[] paths) => Zip(target, paths.AsEnumerable()); - private void Zip(PathConstruction.AbsolutePath target, IEnumerable paths) + private void Zip(AbsolutePath target, IEnumerable paths) { var targetPath = target.ToString(); bool finished = false, atLeastOneFileAdded = false; @@ -38,7 +38,7 @@ public partial class Build fileStream.CopyTo(entryStream); atLeastOneFileAdded = true; } - + foreach (var path in paths) { if (Directory.Exists(path)) @@ -64,7 +64,7 @@ public partial class Build finished = true; } - finally + finally { try { diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj index 2a736e4653..584c36d033 100644 --- a/nukebuild/_build.csproj +++ b/nukebuild/_build.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp2.0 + netcoreapp3.1 false False @@ -10,7 +10,7 @@ - + @@ -20,11 +20,11 @@ - + - + diff --git a/packages/Avalonia/Avalonia.csproj b/packages/Avalonia/Avalonia.csproj index 2c5a09bee7..9f7593b702 100644 --- a/packages/Avalonia/Avalonia.csproj +++ b/packages/Avalonia/Avalonia.csproj @@ -1,6 +1,6 @@  - netstandard2.0;net461;netcoreapp2.0 + netstandard2.0;net461;netcoreapp3.1 Avalonia @@ -20,8 +20,8 @@ Platform=$(Platform)" /> - <_PackageFiles Include="$(DesignerHostAppPath)/Avalonia.Designer.HostApp/bin/$(Configuration)/netcoreapp2.0/Avalonia.Designer.HostApp.dll"> - tools/netcoreapp2.0/designer + <_PackageFiles Include="$(DesignerHostAppPath)/Avalonia.Designer.HostApp/bin/$(Configuration)/netcoreapp3.1/Avalonia.Designer.HostApp.dll"> + tools/netcoreapp3.1/designer false None diff --git a/packages/Avalonia/Avalonia.props b/packages/Avalonia/Avalonia.props index 6f21971d3d..99d53c1e03 100644 --- a/packages/Avalonia/Avalonia.props +++ b/packages/Avalonia/Avalonia.props @@ -1,6 +1,6 @@ - $(MSBuildThisFileDirectory)\..\tools\netcoreapp2.0\designer\Avalonia.Designer.HostApp.dll + $(MSBuildThisFileDirectory)\..\tools\netcoreapp3.1\designer\Avalonia.Designer.HostApp.dll $(MSBuildThisFileDirectory)\..\tools\net461\designer\Avalonia.Designer.HostApp.exe $(MSBuildThisFileDirectory)\..\tools\netstandard2.0\Avalonia.Build.Tasks.dll false diff --git a/samples/BindingDemo/BindingDemo.csproj b/samples/BindingDemo/BindingDemo.csproj index 3005fdbc51..ce33f42143 100644 --- a/samples/BindingDemo/BindingDemo.csproj +++ b/samples/BindingDemo/BindingDemo.csproj @@ -1,9 +1,10 @@  Exe - netcoreapp2.0;net461 + netcoreapp3.1 + diff --git a/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj b/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj index 7919c3ac5a..772af9eacd 100644 --- a/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj +++ b/samples/ControlCatalog.NetCore/ControlCatalog.NetCore.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp2.0 + netcoreapp3.1 true diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj index 4c2769bcc4..11c0a4fa68 100644 --- a/samples/ControlCatalog/ControlCatalog.csproj +++ b/samples/ControlCatalog/ControlCatalog.csproj @@ -21,6 +21,7 @@ + diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml index cbe2c62890..f3f70719e3 100644 --- a/samples/ControlCatalog/MainView.xaml +++ b/samples/ControlCatalog/MainView.xaml @@ -54,14 +54,22 @@ + - + + + No Decorations + Border Only + Full Decorations + + Light Dark - + + diff --git a/samples/ControlCatalog/MainView.xaml.cs b/samples/ControlCatalog/MainView.xaml.cs index acb9bc5bc6..5f71b2ecd9 100644 --- a/samples/ControlCatalog/MainView.xaml.cs +++ b/samples/ControlCatalog/MainView.xaml.cs @@ -56,6 +56,20 @@ namespace ControlCatalog } }; Styles.Add(light); + + var decorations = this.Find("Decorations"); + decorations.SelectionChanged += (sender, e) => + { + Window window = (Window)VisualRoot; + window.SystemDecorations = (SystemDecorations)decorations.SelectedIndex; + }; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + var decorations = this.Find("Decorations"); + decorations.SelectedIndex = (int)((Window)VisualRoot).SystemDecorations; } } } diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index 248f94082d..d25de9c1f5 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -14,7 +14,7 @@ - + @@ -22,7 +22,9 @@ - + diff --git a/samples/ControlCatalog/MainWindow.xaml.cs b/samples/ControlCatalog/MainWindow.xaml.cs index 38cbde9d92..b40fdb4a17 100644 --- a/samples/ControlCatalog/MainWindow.xaml.cs +++ b/samples/ControlCatalog/MainWindow.xaml.cs @@ -1,13 +1,11 @@ +using System; +using System.Runtime.InteropServices; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Notifications; -using Avalonia.Controls.Primitives; +using Avalonia.Input; using Avalonia.Markup.Xaml; -using Avalonia.Threading; using ControlCatalog.ViewModels; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; namespace ControlCatalog { @@ -35,6 +33,12 @@ namespace ControlCatalog mainMenu.AttachedToVisualTree += MenuAttached; } + public static string MenuQuitHeader => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "Quit Avalonia" : "E_xit"; + + public static KeyGesture MenuQuitGesture => RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? + new KeyGesture(Key.Q, KeyModifiers.Meta) : + new KeyGesture(Key.F4, KeyModifiers.Alt); + public void MenuAttached(object sender, VisualTreeAttachmentEventArgs e) { if (NativeMenu.GetIsNativeMenuExported(this) && sender is Menu mainMenu) diff --git a/samples/ControlCatalog/Pages/ImagePage.xaml b/samples/ControlCatalog/Pages/ImagePage.xaml index 9b8f8af765..f61931ed72 100644 --- a/samples/ControlCatalog/Pages/ImagePage.xaml +++ b/samples/ControlCatalog/Pages/ImagePage.xaml @@ -7,7 +7,7 @@ Displays an image - + Bitmap @@ -22,6 +22,23 @@ + Crop + + None + Center + TopLeft + TopRight + BottomLeft + BottomRight + + + + + + + + + Drawing None diff --git a/samples/ControlCatalog/Pages/ImagePage.xaml.cs b/samples/ControlCatalog/Pages/ImagePage.xaml.cs index bbe89d1dfd..d8f4d6d5a2 100644 --- a/samples/ControlCatalog/Pages/ImagePage.xaml.cs +++ b/samples/ControlCatalog/Pages/ImagePage.xaml.cs @@ -1,6 +1,10 @@ +using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; namespace ControlCatalog.Pages { @@ -8,12 +12,14 @@ namespace ControlCatalog.Pages { private readonly Image _bitmapImage; private readonly Image _drawingImage; + private readonly Image _croppedImage; public ImagePage() { InitializeComponent(); _bitmapImage = this.FindControl("bitmapImage"); _drawingImage = this.FindControl("drawingImage"); + _croppedImage = this.FindControl("croppedImage"); } private void InitializeComponent() @@ -38,5 +44,32 @@ namespace ControlCatalog.Pages _drawingImage.Stretch = (Stretch)comboxBox.SelectedIndex; } } + + public void BitmapCropChanged(object sender, SelectionChangedEventArgs e) + { + if (_croppedImage != null) + { + var comboxBox = (ComboBox)sender; + var croppedBitmap = _croppedImage.Source as CroppedBitmap; + croppedBitmap.SourceRect = GetCropRect(comboxBox.SelectedIndex); + } + } + + private PixelRect GetCropRect(int index) + { + var bitmapWidth = 640; + var bitmapHeight = 426; + var cropSize = new PixelSize(320, 240); + return index switch + { + 1 => new PixelRect(new PixelPoint((bitmapWidth - cropSize.Width) / 2, (bitmapHeight - cropSize.Width) / 2), cropSize), + 2 => new PixelRect(new PixelPoint(0, 0), cropSize), + 3 => new PixelRect(new PixelPoint(bitmapWidth - cropSize.Width, 0), cropSize), + 4 => new PixelRect(new PixelPoint(0, bitmapHeight - cropSize.Height), cropSize), + 5 => new PixelRect(new PixelPoint(bitmapWidth - cropSize.Width, bitmapHeight - cropSize.Height), cropSize), + _ => PixelRect.Empty + }; + + } } } diff --git a/samples/ControlCatalog/Pages/MenuPage.xaml b/samples/ControlCatalog/Pages/MenuPage.xaml index 868f0df6ad..de9ea34e80 100644 --- a/samples/ControlCatalog/Pages/MenuPage.xaml +++ b/samples/ControlCatalog/Pages/MenuPage.xaml @@ -16,13 +16,13 @@ Defined in XAML - + - + diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml b/samples/ControlCatalog/Pages/TextBlockPage.xaml new file mode 100644 index 0000000000..f73ef9b4fb --- /dev/null +++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml @@ -0,0 +1,134 @@ + + + TextBlock + A control that can display text + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml.cs b/samples/ControlCatalog/Pages/TextBlockPage.xaml.cs new file mode 100644 index 0000000000..49fecbe7c5 --- /dev/null +++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace ControlCatalog.Pages +{ + public class TextBlockPage : UserControl + { + public TextBlockPage() + { + this.InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/samples/PlatformSanityChecks/PlatformSanityChecks.csproj b/samples/PlatformSanityChecks/PlatformSanityChecks.csproj index 2adef5f64e..86d762a5bc 100644 --- a/samples/PlatformSanityChecks/PlatformSanityChecks.csproj +++ b/samples/PlatformSanityChecks/PlatformSanityChecks.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp2.0 + netcoreapp3.1 diff --git a/samples/Previewer/Previewer.csproj b/samples/Previewer/Previewer.csproj index 2cdde0c945..cd3daf61e1 100644 --- a/samples/Previewer/Previewer.csproj +++ b/samples/Previewer/Previewer.csproj @@ -1,7 +1,7 @@  Exe - netcoreapp2.0 + netcoreapp3.1 diff --git a/samples/RemoteDemo/RemoteDemo.csproj b/samples/RemoteDemo/RemoteDemo.csproj index 2d7699561a..530cad805f 100644 --- a/samples/RemoteDemo/RemoteDemo.csproj +++ b/samples/RemoteDemo/RemoteDemo.csproj @@ -1,7 +1,7 @@ Exe - netcoreapp2.0 + netcoreapp3.1 diff --git a/samples/RenderDemo/RenderDemo.csproj b/samples/RenderDemo/RenderDemo.csproj index 3005fdbc51..ce33f42143 100644 --- a/samples/RenderDemo/RenderDemo.csproj +++ b/samples/RenderDemo/RenderDemo.csproj @@ -1,9 +1,10 @@  Exe - netcoreapp2.0;net461 + netcoreapp3.1 + diff --git a/samples/VirtualizationDemo/VirtualizationDemo.csproj b/samples/VirtualizationDemo/VirtualizationDemo.csproj index 3005fdbc51..ce33f42143 100644 --- a/samples/VirtualizationDemo/VirtualizationDemo.csproj +++ b/samples/VirtualizationDemo/VirtualizationDemo.csproj @@ -1,9 +1,10 @@  Exe - netcoreapp2.0;net461 + netcoreapp3.1 + diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index ddc3d8d081..51062f1568 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -80,8 +80,12 @@ namespace Avalonia _inheritanceParent?.RemoveInheritanceChild(this); _inheritanceParent = value; - foreach (var property in AvaloniaPropertyRegistry.Instance.GetRegisteredInherited(GetType())) + var properties = AvaloniaPropertyRegistry.Instance.GetRegisteredInherited(GetType()); + var propertiesCount = properties.Count; + + for (var i = 0; i < propertiesCount; i++) { + var property = properties[i]; if (valuestore?.IsSet(property) == true) { // If local value set there can be no change. @@ -311,7 +315,10 @@ namespace Avalonia /// The property. /// The value. /// The priority of the value. - public void SetValue( + /// + /// An if setting the property can be undone, otherwise null. + /// + public IDisposable SetValue( StyledPropertyBase property, T value, BindingPriority priority = BindingPriority.LocalValue) @@ -335,8 +342,10 @@ namespace Avalonia } else if (!(value is DoNothingType)) { - Values.SetValue(property, value, priority); + return Values.SetValue(property, value, priority); } + + return null; } /// @@ -478,7 +487,13 @@ namespace Avalonia } } - void IValueSink.Completed(AvaloniaProperty property, IPriorityValueEntry entry) { } + void IValueSink.Completed( + StyledPropertyBase property, + IPriorityValueEntry entry, + Optional oldValue) + { + ((IValueSink)this).ValueChanged(property, BindingPriority.Unset, oldValue, default); + } /// /// Called for each inherited property when the changes. diff --git a/src/Avalonia.Base/AvaloniaObjectExtensions.cs b/src/Avalonia.Base/AvaloniaObjectExtensions.cs index a4c7fa95a5..0f82042dcd 100644 --- a/src/Avalonia.Base/AvaloniaObjectExtensions.cs +++ b/src/Avalonia.Base/AvaloniaObjectExtensions.cs @@ -458,7 +458,10 @@ namespace Avalonia /// The property. /// The value. /// The priority of the value. - public static void SetValue( + /// + /// An if setting the property can be undone, otherwise null. + /// + public static IDisposable SetValue( this IAvaloniaObject target, AvaloniaProperty property, object value, @@ -467,7 +470,7 @@ namespace Avalonia target = target ?? throw new ArgumentNullException(nameof(target)); property = property ?? throw new ArgumentNullException(nameof(property)); - property.RouteSetValue(target, value, priority); + return property.RouteSetValue(target, value, priority); } /// @@ -478,7 +481,10 @@ namespace Avalonia /// The property. /// The value. /// The priority of the value. - public static void SetValue( + /// + /// An if setting the property can be undone, otherwise null. + /// + public static IDisposable SetValue( this IAvaloniaObject target, AvaloniaProperty property, T value, @@ -490,11 +496,10 @@ namespace Avalonia switch (property) { case StyledPropertyBase styled: - target.SetValue(styled, value, priority); - break; + return target.SetValue(styled, value, priority); case DirectPropertyBase direct: target.SetValue(direct, value); - break; + return null; default: throw new NotSupportedException("Unsupported AvaloniaProperty type."); } diff --git a/src/Avalonia.Base/AvaloniaProperty.cs b/src/Avalonia.Base/AvaloniaProperty.cs index aa7a675764..394e22eac1 100644 --- a/src/Avalonia.Base/AvaloniaProperty.cs +++ b/src/Avalonia.Base/AvaloniaProperty.cs @@ -469,6 +469,15 @@ namespace Avalonia return Name; } + /// + /// Uses the visitor pattern to resolve an untyped property to a typed property. + /// + /// The type of user data passed. + /// The visitor which will accept the typed property. + /// The user data to pass. + public abstract void Accept(IAvaloniaPropertyVisitor vistor, ref TData data) + where TData : struct; + /// /// Notifies the observable. /// @@ -496,7 +505,10 @@ namespace Avalonia /// The object instance. /// The value. /// The priority. - internal abstract void RouteSetValue( + /// + /// An if setting the property can be undone, otherwise null. + /// + internal abstract IDisposable? RouteSetValue( IAvaloniaObject o, object value, BindingPriority priority); diff --git a/src/Avalonia.Base/AvaloniaPropertyRegistry.cs b/src/Avalonia.Base/AvaloniaPropertyRegistry.cs index 29ab10278b..7d3d5492b6 100644 --- a/src/Avalonia.Base/AvaloniaPropertyRegistry.cs +++ b/src/Avalonia.Base/AvaloniaPropertyRegistry.cs @@ -3,9 +3,7 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Runtime.CompilerServices; -using Avalonia.Data; namespace Avalonia { @@ -28,8 +26,6 @@ namespace Avalonia new Dictionary>(); private readonly Dictionary> _directCache = new Dictionary>(); - private readonly Dictionary> _initializedCache = - new Dictionary>(); private readonly Dictionary> _inheritedCache = new Dictionary>(); @@ -49,7 +45,7 @@ namespace Avalonia /// /// The type. /// A collection of definitions. - public IEnumerable GetRegistered(Type type) + public IReadOnlyList GetRegistered(Type type) { Contract.Requires(type != null); @@ -83,7 +79,7 @@ namespace Avalonia /// /// The type. /// A collection of definitions. - public IEnumerable GetRegisteredAttached(Type type) + public IReadOnlyList GetRegisteredAttached(Type type) { Contract.Requires(type != null); @@ -114,7 +110,7 @@ namespace Avalonia /// /// The type. /// A collection of definitions. - public IEnumerable GetRegisteredDirect(Type type) + public IReadOnlyList GetRegisteredDirect(Type type) { Contract.Requires(type != null); @@ -145,7 +141,7 @@ namespace Avalonia /// /// The type. /// A collection of definitions. - public IEnumerable GetRegisteredInherited(Type type) + public IReadOnlyList GetRegisteredInherited(Type type) { Contract.Requires(type != null); @@ -157,16 +153,27 @@ namespace Avalonia result = new List(); var visited = new HashSet(); - foreach (var property in GetRegistered(type)) + var registered = GetRegistered(type); + var registeredCount = registered.Count; + + for (var i = 0; i < registeredCount; i++) { + var property = registered[i]; + if (property.Inherits) { result.Add(property); visited.Add(property); } } - foreach (var property in GetRegisteredAttached(type)) + + var registeredAttached = GetRegisteredAttached(type); + var registeredAttachedCount = registeredAttached.Count; + + for (var i = 0; i < registeredAttachedCount; i++) { + var property = registeredAttached[i]; + if (property.Inherits) { if (!visited.Contains(property)) @@ -185,7 +192,7 @@ namespace Avalonia /// /// The object. /// A collection of definitions. - public IEnumerable GetRegistered(IAvaloniaObject o) + public IReadOnlyList GetRegistered(IAvaloniaObject o) { Contract.Requires(o != null); @@ -229,8 +236,13 @@ namespace Avalonia throw new InvalidOperationException("Attached properties not supported."); } - foreach (AvaloniaProperty x in GetRegistered(type)) + var registered = GetRegistered(type); + var registeredCount = registered.Count; + + for (var i = 0; i < registeredCount; i++) { + AvaloniaProperty x = registered[i]; + if (x.Name == name) { return x; @@ -276,8 +288,13 @@ namespace Avalonia return property; } - foreach (var p in GetRegisteredDirect(o.GetType())) + var registeredDirect = GetRegisteredDirect(o.GetType()); + var registeredDirectCount = registeredDirect.Count; + + for (var i = 0; i < registeredDirectCount; i++) { + var p = registeredDirect[i]; + if (p == property) { return (DirectPropertyBase)p; @@ -308,8 +325,23 @@ namespace Avalonia Contract.Requires(type != null); Contract.Requires(property != null); - return Instance.GetRegistered(type).Any(x => x == property) || - Instance.GetRegisteredAttached(type).Any(x => x == property); + static bool ContainsProperty(IReadOnlyList properties, AvaloniaProperty property) + { + var propertiesCount = properties.Count; + + for (var i = 0; i < propertiesCount; i++) + { + if (properties[i] == property) + { + return true; + } + } + + return false; + } + + return ContainsProperty(Instance.GetRegistered(type), property) || + ContainsProperty(Instance.GetRegisteredAttached(type), property); } /// @@ -374,7 +406,6 @@ namespace Avalonia } _registeredCache.Clear(); - _initializedCache.Clear(); _inheritedCache.Clear(); } @@ -411,32 +442,7 @@ namespace Avalonia } _attachedCache.Clear(); - _initializedCache.Clear(); _inheritedCache.Clear(); } - - private readonly struct PropertyInitializationData - { - public AvaloniaProperty Property { get; } - public object Value { get; } - public bool IsDirect { get; } - public IDirectPropertyAccessor DirectAccessor { get; } - - public PropertyInitializationData(AvaloniaProperty property, IDirectPropertyAccessor directAccessor) - { - Property = property; - Value = null; - IsDirect = true; - DirectAccessor = directAccessor; - } - - public PropertyInitializationData(AvaloniaProperty property, IStyledPropertyAccessor styledAccessor, Type type) - { - Property = property; - Value = styledAccessor.GetDefaultValue(type); - IsDirect = false; - DirectAccessor = null; - } - } } } diff --git a/src/Avalonia.Base/DirectPropertyBase.cs b/src/Avalonia.Base/DirectPropertyBase.cs index 39ed3b084f..d3b5277c53 100644 --- a/src/Avalonia.Base/DirectPropertyBase.cs +++ b/src/Avalonia.Base/DirectPropertyBase.cs @@ -1,6 +1,7 @@ using System; using Avalonia.Data; using Avalonia.Reactive; +using Avalonia.Utilities; #nullable enable @@ -101,6 +102,12 @@ namespace Avalonia return (DirectPropertyMetadata)base.GetMetadata(type); } + /// + public override void Accept(IAvaloniaPropertyVisitor vistor, ref TData data) + { + vistor.Visit(this, ref data); + } + /// internal override void RouteClearValue(IAvaloniaObject o) { @@ -114,7 +121,7 @@ namespace Avalonia } /// - internal override void RouteSetValue( + internal override IDisposable? RouteSetValue( IAvaloniaObject o, object value, BindingPriority priority) @@ -133,6 +140,8 @@ namespace Avalonia { throw v.Error!; } + + return null; } /// diff --git a/src/Avalonia.Base/IAvaloniaObject.cs b/src/Avalonia.Base/IAvaloniaObject.cs index fb85ae222c..81a212b087 100644 --- a/src/Avalonia.Base/IAvaloniaObject.cs +++ b/src/Avalonia.Base/IAvaloniaObject.cs @@ -65,7 +65,7 @@ namespace Avalonia /// The property. /// The value. /// The priority of the value. - void SetValue( + IDisposable SetValue( StyledPropertyBase property, T value, BindingPriority priority = BindingPriority.LocalValue); diff --git a/src/Avalonia.Base/PropertyStore/BindingEntry.cs b/src/Avalonia.Base/PropertyStore/BindingEntry.cs index 09a0f169df..3249b31d66 100644 --- a/src/Avalonia.Base/PropertyStore/BindingEntry.cs +++ b/src/Avalonia.Base/PropertyStore/BindingEntry.cs @@ -48,10 +48,10 @@ namespace Avalonia.PropertyStore { _subscription?.Dispose(); _subscription = null; - _sink.Completed(Property, this); + _sink.Completed(Property, this, Value); } - public void OnCompleted() => _sink.Completed(Property, this); + public void OnCompleted() => _sink.Completed(Property, this, Value); public void OnError(Exception error) { diff --git a/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs b/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs index f15f56e32b..aa054c46ff 100644 --- a/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs +++ b/src/Avalonia.Base/PropertyStore/ConstantValueEntry.cs @@ -10,16 +10,20 @@ namespace Avalonia.PropertyStore /// . /// /// The property type. - internal class ConstantValueEntry : IPriorityValueEntry + internal class ConstantValueEntry : IPriorityValueEntry, IDisposable { + private IValueSink _sink; + public ConstantValueEntry( StyledPropertyBase property, T value, - BindingPriority priority) + BindingPriority priority, + IValueSink sink) { Property = property; Value = value; Priority = priority; + _sink = sink; } public StyledPropertyBase Property { get; } @@ -28,6 +32,7 @@ namespace Avalonia.PropertyStore Optional IValue.Value => Value.ToObject(); BindingPriority IValue.ValuePriority => Priority; - public void Reparent(IValueSink sink) { } + public void Dispose() => _sink.Completed(Property, this, Value); + public void Reparent(IValueSink sink) => _sink = sink; } } diff --git a/src/Avalonia.Base/PropertyStore/IValueSink.cs b/src/Avalonia.Base/PropertyStore/IValueSink.cs index 223b0058c1..9012a985ac 100644 --- a/src/Avalonia.Base/PropertyStore/IValueSink.cs +++ b/src/Avalonia.Base/PropertyStore/IValueSink.cs @@ -15,6 +15,9 @@ namespace Avalonia.PropertyStore Optional oldValue, BindingValue newValue); - void Completed(AvaloniaProperty property, IPriorityValueEntry entry); + void Completed( + StyledPropertyBase property, + IPriorityValueEntry entry, + Optional oldValue); } } diff --git a/src/Avalonia.Base/PropertyStore/PriorityValue.cs b/src/Avalonia.Base/PropertyStore/PriorityValue.cs index 2785dc6840..affb20f334 100644 --- a/src/Avalonia.Base/PropertyStore/PriorityValue.cs +++ b/src/Avalonia.Base/PropertyStore/PriorityValue.cs @@ -78,8 +78,10 @@ namespace Avalonia.PropertyStore public void ClearLocalValue() => UpdateEffectiveValue(); - public void SetValue(T value, BindingPriority priority) + public IDisposable? SetValue(T value, BindingPriority priority) { + IDisposable? result = null; + if (priority == BindingPriority.LocalValue) { _localValue = value; @@ -87,10 +89,13 @@ namespace Avalonia.PropertyStore else { var insert = FindInsertPoint(priority); - _entries.Insert(insert, new ConstantValueEntry(Property, value, priority)); + var entry = new ConstantValueEntry(Property, value, priority, this); + _entries.Insert(insert, entry); + result = entry; } UpdateEffectiveValue(); + return result; } public BindingEntry AddBinding(IObservable> source, BindingPriority priority) @@ -117,7 +122,10 @@ namespace Avalonia.PropertyStore UpdateEffectiveValue(); } - void IValueSink.Completed(AvaloniaProperty property, IPriorityValueEntry entry) + void IValueSink.Completed( + StyledPropertyBase property, + IPriorityValueEntry entry, + Optional oldValue) { _entries.Remove((IPriorityValueEntry)entry); UpdateEffectiveValue(); diff --git a/src/Avalonia.Base/Reactive/SingleSubscriberObservableBase.cs b/src/Avalonia.Base/Reactive/SingleSubscriberObservableBase.cs index cd8ce2cd80..d3ac3dac8a 100644 --- a/src/Avalonia.Base/Reactive/SingleSubscriberObservableBase.cs +++ b/src/Avalonia.Base/Reactive/SingleSubscriberObservableBase.cs @@ -36,7 +36,7 @@ namespace Avalonia.Reactive return this; } - void IDisposable.Dispose() + public virtual void Dispose() { Unsubscribed(); _observer = null; diff --git a/src/Avalonia.Base/StyledPropertyBase.cs b/src/Avalonia.Base/StyledPropertyBase.cs index d1f961a567..fb07ef3f62 100644 --- a/src/Avalonia.Base/StyledPropertyBase.cs +++ b/src/Avalonia.Base/StyledPropertyBase.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using Avalonia.Data; using Avalonia.Reactive; +using Avalonia.Utilities; namespace Avalonia { @@ -169,6 +170,12 @@ namespace Avalonia base.OverrideMetadata(type, metadata); } + /// + public override void Accept(IAvaloniaPropertyVisitor vistor, ref TData data) + { + vistor.Visit(this, ref data); + } + /// /// Gets the string representation of the property. /// @@ -194,7 +201,7 @@ namespace Avalonia } /// - internal override void RouteSetValue( + internal override IDisposable RouteSetValue( IAvaloniaObject o, object value, BindingPriority priority) @@ -203,7 +210,7 @@ namespace Avalonia if (v.HasValue) { - o.SetValue(this, (TValue)v.Value, priority); + return o.SetValue(this, (TValue)v.Value, priority); } else if (v.Type == BindingValueType.UnsetValue) { @@ -213,6 +220,8 @@ namespace Avalonia { throw v.Error; } + + return null; } /// diff --git a/src/Avalonia.Base/Utilities/IAvaloniaPropertyVisitor.cs b/src/Avalonia.Base/Utilities/IAvaloniaPropertyVisitor.cs new file mode 100644 index 0000000000..4b889eb129 --- /dev/null +++ b/src/Avalonia.Base/Utilities/IAvaloniaPropertyVisitor.cs @@ -0,0 +1,34 @@ +#nullable enable + +namespace Avalonia.Utilities +{ + /// + /// A visitor to resolve an untyped to a typed property. + /// + /// The type of user data passed. + /// + /// Pass an instance that implements this interface to + /// + /// in order to resolve un untyped to a typed + /// or . + /// + public interface IAvaloniaPropertyVisitor + where TData : struct + { + /// + /// Called when the property is a styled property. + /// + /// The property value type. + /// The property. + /// The user data. + void Visit(StyledPropertyBase property, ref TData data); + + /// + /// Called when the property is a direct property. + /// + /// The property value type. + /// The property. + /// The user data. + void Visit(DirectPropertyBase property, ref TData data); + } +} diff --git a/src/Avalonia.Base/Utilities/TypeUtilities.cs b/src/Avalonia.Base/Utilities/TypeUtilities.cs index d1393a9c0d..8a25d29c3f 100644 --- a/src/Avalonia.Base/Utilities/TypeUtilities.cs +++ b/src/Avalonia.Base/Utilities/TypeUtilities.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.ComponentModel; using System.Globalization; using System.Linq; using System.Reflection; @@ -185,6 +186,14 @@ namespace Avalonia.Utilities } } + var typeConverter = TypeDescriptor.GetConverter(to); + + if (typeConverter.CanConvertFrom(from) == true) + { + result = typeConverter.ConvertFrom(null, culture, value); + return true; + } + var cast = FindTypeConversionOperatorMethod(from, to, OperatorType.Implicit | OperatorType.Explicit); if (cast != null) diff --git a/src/Avalonia.Base/ValueStore.cs b/src/Avalonia.Base/ValueStore.cs index 58ebc48652..104c06de0f 100644 --- a/src/Avalonia.Base/ValueStore.cs +++ b/src/Avalonia.Base/ValueStore.cs @@ -70,23 +70,25 @@ namespace Avalonia return false; } - public void SetValue(StyledPropertyBase property, T value, BindingPriority priority) + public IDisposable? SetValue(StyledPropertyBase property, T value, BindingPriority priority) { if (property.ValidateValue?.Invoke(value) == false) { throw new ArgumentException($"{value} is not a valid value for '{property.Name}."); } + IDisposable? result = null; + if (_values.TryGetValue(property, out var slot)) { - SetExisting(slot, property, value, priority); + result = SetExisting(slot, property, value, priority); } else if (property.HasCoercion) { // If the property has any coercion callbacks then always create a PriorityValue. var entry = new PriorityValue(_owner, property, this); _values.AddValue(property, entry); - entry.SetValue(value, priority); + result = entry.SetValue(value, priority); } else if (priority == BindingPriority.LocalValue) { @@ -95,10 +97,13 @@ namespace Avalonia } else { - var entry = new ConstantValueEntry(property, value, priority); + var entry = new ConstantValueEntry(property, value, priority, this); _values.AddValue(property, entry); _sink.ValueChanged(property, priority, default, value); + result = entry; } + + return result; } public IDisposable AddBinding( @@ -148,7 +153,7 @@ namespace Avalonia _values.Remove(property); _sink.ValueChanged( property, - BindingPriority.LocalValue, + BindingPriority.Unset, old, BindingValue.Unset); } @@ -173,7 +178,7 @@ namespace Avalonia { return new Diagnostics.AvaloniaPropertyValue( property, - slot.Value.HasValue ? (object)slot.Value : AvaloniaProperty.UnsetValue, + slot.Value.HasValue ? slot.Value.Value : AvaloniaProperty.UnsetValue, slot.ValuePriority, null); } @@ -190,32 +195,38 @@ namespace Avalonia _sink.ValueChanged(property, priority, oldValue, newValue); } - void IValueSink.Completed(AvaloniaProperty property, IPriorityValueEntry entry) + void IValueSink.Completed( + StyledPropertyBase property, + IPriorityValueEntry entry, + Optional oldValue) { if (_values.TryGetValue(property, out var slot)) { if (slot == entry) { _values.Remove(property); + _sink.Completed(property, entry, oldValue); } } } - private void SetExisting( + private IDisposable? SetExisting( object slot, StyledPropertyBase property, T value, BindingPriority priority) { + IDisposable? result = null; + if (slot is IPriorityValueEntry e) { var priorityValue = new PriorityValue(_owner, property, this, e); _values.SetValue(property, priorityValue); - priorityValue.SetValue(value, priority); + result = priorityValue.SetValue(value, priority); } else if (slot is PriorityValue p) { - p.SetValue(value, priority); + result = p.SetValue(value, priority); } else if (slot is LocalValueEntry l) { @@ -228,6 +239,7 @@ namespace Avalonia else { var priorityValue = new PriorityValue(_owner, property, this, l); + result = priorityValue.SetValue(value, priority); _values.SetValue(property, priorityValue); } } @@ -235,6 +247,8 @@ namespace Avalonia { throw new NotSupportedException("Unrecognised value store slot type."); } + + return result; } private IDisposable BindExisting( diff --git a/src/Avalonia.Controls.DataGrid/DataGrid.cs b/src/Avalonia.Controls.DataGrid/DataGrid.cs index 214132f03c..844316741a 100644 --- a/src/Avalonia.Controls.DataGrid/DataGrid.cs +++ b/src/Avalonia.Controls.DataGrid/DataGrid.cs @@ -65,7 +65,7 @@ namespace Avalonia.Controls private const double DATAGRID_minimumColumnHeaderHeight = 4; internal const double DATAGRID_maximumStarColumnWidth = 10000; internal const double DATAGRID_minimumStarColumnWidth = 0.001; - private const double DATAGRID_mouseWheelDelta = 48.0; + private const double DATAGRID_mouseWheelDelta = 72.0; private const double DATAGRID_maxHeadersThickness = 32768; private const double DATAGRID_defaultRowHeight = 22; diff --git a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs index bff2b500ae..31c77c595d 100644 --- a/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs +++ b/src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs @@ -67,7 +67,7 @@ namespace Avalonia.Controls static DataGridColumnHeader() { - AreSeparatorsVisibleProperty.Changed.AddClassHandler((x,e) => x.OnAreSeparatorsVisibleChanged(e)); + AreSeparatorsVisibleProperty.Changed.AddClassHandler((x, e) => x.OnAreSeparatorsVisibleChanged(e)); } /// @@ -103,7 +103,7 @@ namespace Avalonia.Controls { get; set; - } + } internal DataGrid OwningGrid => OwningColumn?.OwningGrid; internal int ColumnIndex @@ -116,19 +116,19 @@ namespace Avalonia.Controls } return OwningColumn.Index; } - } + } internal ListSortDirection? CurrentSortingState { get; private set; - } + } private bool IsMouseOver { get; set; - } + } private bool IsPressed { @@ -158,14 +158,14 @@ namespace Avalonia.Controls && OwningGrid.DataConnection.AllowSort) { var sort = OwningColumn.GetSortDescription(); - if(sort != null) + if (sort != null) { CurrentSortingState = sort.Descending ? ListSortDirection.Descending : ListSortDirection.Ascending; } } - PseudoClasses.Set(":sortascending", + PseudoClasses.Set(":sortascending", CurrentSortingState.HasValue && CurrentSortingState.Value == ListSortDirection.Ascending); - PseudoClasses.Set(":sortdescending", + PseudoClasses.Set(":sortdescending", CurrentSortingState.HasValue && CurrentSortingState.Value == ListSortDirection.Descending); } @@ -195,7 +195,7 @@ namespace Avalonia.Controls // completed a click without dragging, so we're sorting InvokeProcessSort(keyModifiers); handled = true; - } + } internal void InvokeProcessSort(KeyModifiers keyModifiers) { @@ -208,7 +208,7 @@ namespace Avalonia.Controls { Avalonia.Threading.Dispatcher.UIThread.Post(() => ProcessSort(keyModifiers)); } - } + } //TODO GroupSorting internal void ProcessSort(KeyModifiers keyModifiers) @@ -246,45 +246,49 @@ namespace Avalonia.Controls owningGrid.DataConnection.SortDescriptions.Clear(); } - if (sort != null) + // if ctrl is held down, we only clear the sort directions + if (!ctrl) { - newSort = sort.SwitchSortDirection(); - - // changing direction should not affect sort order, so we replace this column's - // sort description instead of just adding it to the end of the collection - int oldIndex = owningGrid.DataConnection.SortDescriptions.IndexOf(sort); - if (oldIndex >= 0) + if (sort != null) { - owningGrid.DataConnection.SortDescriptions.Remove(sort); - owningGrid.DataConnection.SortDescriptions.Insert(oldIndex, newSort); + newSort = sort.SwitchSortDirection(); + + // changing direction should not affect sort order, so we replace this column's + // sort description instead of just adding it to the end of the collection + int oldIndex = owningGrid.DataConnection.SortDescriptions.IndexOf(sort); + if (oldIndex >= 0) + { + owningGrid.DataConnection.SortDescriptions.Remove(sort); + owningGrid.DataConnection.SortDescriptions.Insert(oldIndex, newSort); + } + else + { + owningGrid.DataConnection.SortDescriptions.Add(newSort); + } } else { + string propertyName = OwningColumn.GetSortPropertyName(); + // no-opt if we couldn't find a property to sort on + if (string.IsNullOrEmpty(propertyName)) + { + return; + } + + newSort = DataGridSortDescription.FromPath(propertyName, culture: collectionView.Culture); owningGrid.DataConnection.SortDescriptions.Add(newSort); } } - else - { - string propertyName = OwningColumn.GetSortPropertyName(); - // no-opt if we couldn't find a property to sort on - if (string.IsNullOrEmpty(propertyName)) - { - return; - } - - newSort = DataGridSortDescription.FromPath(propertyName, culture: collectionView.Culture); - owningGrid.DataConnection.SortDescriptions.Add(newSort); - } } } - } + } private bool CanReorderColumn(DataGridColumn column) { - return OwningGrid.CanUserReorderColumns + return OwningGrid.CanUserReorderColumns && !(column is DataGridFillerColumn) && (column.CanUserReorderInternal.HasValue && column.CanUserReorderInternal.Value || !column.CanUserReorderInternal.HasValue); - } + } /// /// Determines whether a column can be resized by dragging the border of its header. If star sizing @@ -302,7 +306,7 @@ namespace Avalonia.Controls return false; } return column.ActualCanUserResize; - } + } private static bool TrySetResizeColumn(DataGridColumn column) { @@ -316,7 +320,7 @@ namespace Avalonia.Controls return true; } return false; - } + } //TODO DragDrop @@ -371,7 +375,7 @@ namespace Avalonia.Controls { if (_dragMode == DragMode.MouseDown) { - OnMouseLeftButtonUp_Click(args.KeyModifiers, ref handled); + OnMouseLeftButtonUp_Click(args.KeyModifiers, ref handled); } else if (_dragMode == DragMode.Reorder) { @@ -449,7 +453,7 @@ namespace Avalonia.Controls OnMouseLeave(); ApplyState(); - } + } private void DataGridColumnHeader_PointerPressed(object sender, PointerPressedEventArgs e) { @@ -577,7 +581,7 @@ namespace Avalonia.Controls { return OwningGrid.Columns.Count - 1; } - } + } /// /// Returns true if the mouse is @@ -723,7 +727,7 @@ namespace Avalonia.Controls Point targetPosition = new Point(0, 0); if (targetColumn == null || targetColumn == OwningGrid.ColumnsInternal.FillerColumn || targetColumn.IsFrozen != OwningColumn.IsFrozen) { - targetColumn = + targetColumn = OwningGrid.ColumnsInternal.GetLastColumn( isVisible: true, isFrozen: OwningColumn.IsFrozen, @@ -741,7 +745,7 @@ namespace Avalonia.Controls handled = true; } - } + } private void OnMouseMove_Resize(ref bool handled, Point mousePositionHeaders) { @@ -764,7 +768,7 @@ namespace Avalonia.Controls handled = true; } - } + } private void SetDragCursor(Point mousePosition) { diff --git a/src/Avalonia.Controls.DataGrid/Themes/Default.xaml b/src/Avalonia.Controls.DataGrid/Themes/Default.xaml index 0e039f01cb..17e7ecba43 100644 --- a/src/Avalonia.Controls.DataGrid/Themes/Default.xaml +++ b/src/Avalonia.Controls.DataGrid/Themes/Default.xaml @@ -202,7 +202,7 @@ @@ -215,11 +215,11 @@ - + - - + + /// Defines the property. @@ -160,7 +161,19 @@ namespace Avalonia /// /// Global styles apply to all windows in the application. /// - public Styles Styles => _styles ?? (_styles = new Styles()); + public Styles Styles + { + get + { + if (_styles == null) + { + _styles = new Styles(this); + _styles.ResourcesChanged += ThisResourcesChanged; + } + + return _styles; + } + } /// bool IDataTemplateHost.IsDataTemplatesInitialized => _dataTemplates != null; @@ -233,9 +246,29 @@ namespace Avalonia } + private void NotifyResourcesChanged(ResourcesChangedEventArgs e) + { + if (_notifyingResourcesChanged) + { + return; + } + + try + { + _notifyingResourcesChanged = true; + (_resources as ISetResourceParent)?.ParentResourcesChanged(e); + (_styles as ISetResourceParent)?.ParentResourcesChanged(e); + ResourcesChanged?.Invoke(this, new ResourcesChangedEventArgs()); + } + finally + { + _notifyingResourcesChanged = false; + } + } + private void ThisResourcesChanged(object sender, ResourcesChangedEventArgs e) { - ResourcesChanged?.Invoke(this, e); + NotifyResourcesChanged(e); } private string _name; diff --git a/src/Avalonia.Controls/Calendar/DatePicker.cs b/src/Avalonia.Controls/Calendar/DatePicker.cs index ea06bdb394..07e42c64e4 100644 --- a/src/Avalonia.Controls/Calendar/DatePicker.cs +++ b/src/Avalonia.Controls/Calendar/DatePicker.cs @@ -476,7 +476,7 @@ namespace Avalonia.Controls { _dropDownButton.Click += DropDownButton_Click; _buttonPointerPressedSubscription = - _dropDownButton.AddHandler(PointerPressedEvent, DropDownButton_PointerPressed, handledEventsToo: true); + _dropDownButton.AddDisposableHandler(PointerPressedEvent, DropDownButton_PointerPressed, handledEventsToo: true); } if (_textBox != null) diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 9d471a0fc0..d1f54da23a 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -9,6 +9,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Controls.Shapes; using Avalonia.Controls.Templates; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Media; using Avalonia.VisualTree; @@ -265,7 +266,7 @@ namespace Avalonia.Controls var toplevel = this.GetVisualRoot() as TopLevel; if (toplevel != null) { - _subscriptionsOnOpen = toplevel.AddHandler(PointerWheelChangedEvent, (s, ev) => + _subscriptionsOnOpen = toplevel.AddDisposableHandler(PointerWheelChangedEvent, (s, ev) => { //eat wheel scroll event outside dropdown popup while it's open if (IsDropDownOpen && (ev.Source as IVisual).GetVisualRoot() == toplevel) diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 5dfa5863f5..fc56e26add 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -104,8 +104,7 @@ namespace Avalonia.Controls { PlacementMode = PlacementMode.Pointer, PlacementTarget = control, - StaysOpen = false, - ObeyScreenEdges = true + StaysOpen = false }; _popup.Opened += PopupOpened; diff --git a/src/Avalonia.Controls/Converters/PlatformKeyGestureConverter.cs b/src/Avalonia.Controls/Converters/PlatformKeyGestureConverter.cs new file mode 100644 index 0000000000..603a470a93 --- /dev/null +++ b/src/Avalonia.Controls/Converters/PlatformKeyGestureConverter.cs @@ -0,0 +1,195 @@ +using System; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using Avalonia.Data.Converters; +using Avalonia.Input; + +namespace Avalonia.Controls.Converters +{ + /// + /// Converts a to a string, formatting it according to the current + /// platform's style guidelines. + /// + public class PlatformKeyGestureConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is null) + { + return null; + } + else if (value is KeyGesture gesture && targetType == typeof(string)) + { + return ToPlatformString(gesture); + } + else + { + throw new NotSupportedException(); + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + /// + /// Converts a to a string, formatting it according to the current + /// platform's style guidelines. + /// + /// The gesture. + /// The gesture formatted according to the current platform. + public static string ToPlatformString(KeyGesture gesture) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ToString(gesture, "Win"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return ToString(gesture, "Super"); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return ToOSXString(gesture); + } + else + { + return gesture.ToString(); + } + } + + private static string ToString(KeyGesture gesture, string meta) + { + var s = new StringBuilder(); + + static void Plus(StringBuilder s) + { + if (s.Length > 0) + { + s.Append("+"); + } + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Control)) + { + s.Append("Ctrl"); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Shift)) + { + Plus(s); + s.Append("Shift"); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Alt)) + { + Plus(s); + s.Append("Alt"); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Meta)) + { + Plus(s); + s.Append(meta); + } + + Plus(s); + s.Append(ToString(gesture.Key)); + + return s.ToString(); + } + + private static string ToOSXString(KeyGesture gesture) + { + var s = new StringBuilder(); + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Control)) + { + s.Append('⌃'); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Alt)) + { + s.Append('⌥'); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Shift)) + { + s.Append('⇧'); + } + + if (gesture.KeyModifiers.HasFlagCustom(KeyModifiers.Meta)) + { + s.Append('⌘'); + } + + s.Append(ToOSXString(gesture.Key)); + + return s.ToString(); + } + + private static string ToString(Key key) + { + return key switch + { + Key.Add => "+", + Key.Back => "Backspace", + Key.D0 => "0", + Key.D1 => "1", + Key.D2 => "2", + Key.D3 => "3", + Key.D4 => "4", + Key.D5 => "5", + Key.D6 => "6", + Key.D7 => "7", + Key.D8 => "8", + Key.D9 => "9", + Key.Decimal => ".", + Key.Divide => "/", + Key.Down => "Down Arrow", + Key.Left => "Left Arrow", + Key.Multiply => "*", + Key.OemBackslash => "\\", + Key.OemCloseBrackets => "]", + Key.OemComma => ",", + Key.OemMinus => "-", + Key.OemOpenBrackets => "[", + Key.OemPeriod=> ".", + Key.OemPipe => "|", + Key.OemPlus => "+", + Key.OemQuestion => "/", + Key.OemQuotes => "\"", + Key.OemSemicolon => ";", + Key.OemTilde => "`", + Key.Right => "Right Arrow", + Key.Separator => "/", + Key.Subtract => "-", + Key.Up => "Up Arrow", + _ => key.ToString(), + }; + } + + private static string ToOSXString(Key key) + { + return key switch + { + Key.Back => "⌫", + Key.Down => "↓", + Key.End => "↘", + Key.Escape => "⎋", + Key.Home => "↖", + Key.Left => "←", + Key.Return => "↩", + Key.PageDown => "⇞", + Key.PageUp => "⇟", + Key.Right => "→", + Key.Space => "␣", + Key.Tab => "⇥", + Key.Up => "↑", + _ => ToString(key), + }; + } + } +} diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index e0baa5e679..3164e56222 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -13,6 +13,7 @@ using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace Avalonia.Controls { @@ -48,6 +49,12 @@ namespace Avalonia.Controls public static readonly StyledProperty IconProperty = AvaloniaProperty.Register(nameof(Icon)); + /// + /// Defines the property. + /// + public static readonly StyledProperty InputGestureProperty = + AvaloniaProperty.Register(nameof(InputGesture)); + /// /// Defines the property. /// @@ -93,6 +100,7 @@ namespace Avalonia.Controls private ICommand _command; private bool _commandCanExecute = true; private Popup _popup; + private IDisposable _gridHack; /// /// Initializes static members of the class. @@ -194,6 +202,19 @@ namespace Avalonia.Controls set { SetValue(IconProperty, value); } } + /// + /// Gets or sets the input gesture that will be displayed in the menu item. + /// + /// + /// Setting this property does not cause the input gesture to be handled by the menu item, + /// it simply displays the gesture text in the menu. + /// + public KeyGesture InputGesture + { + get { return GetValue(InputGestureProperty); } + set { SetValue(InputGestureProperty, value); } + } + /// /// Gets or sets a value indicating whether the is currently selected. /// @@ -304,6 +325,32 @@ namespace Avalonia.Controls { Command.CanExecuteChanged -= CanExecuteChanged; } + + _gridHack?.Dispose(); + _gridHack = null; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + + if (this.GetVisualParent() is IControl parent) + { + // HACK: This nasty but it's all WPF's fault. Grid uses an inherited attached + // property to store SharedSizeGroup state, except property inheritance is done + // down the logical tree. In this case, the control which is setting + // Grid.IsSharedSizeScope="True" is not in the logical tree. Instead of fixing + // the way Grid stores shared size state, the developers of WPF just created a + // binding of the internal state of the visual parent to the menu item. We don't + // have much choice but to do the same for now unless we want to refactor Grid, + // which I honestly am not brave enough to do right now. Here's the same hack in + // the WPF codebase: + // + // https://github.com/dotnet/wpf/blob/89537909bdf36bc918e88b37751add46a8980bb0/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/MenuItem.cs#L2126-L2141 + _gridHack = Bind( + DefinitionBase.PrivateSharedSizeScopeProperty, + parent.GetBindingObservable(DefinitionBase.PrivateSharedSizeScopeProperty)); + } } /// diff --git a/src/Avalonia.Controls/Platform/IWindowImpl.cs b/src/Avalonia.Controls/Platform/IWindowImpl.cs index 91b895f38a..238070bbef 100644 --- a/src/Avalonia.Controls/Platform/IWindowImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowImpl.cs @@ -36,7 +36,7 @@ namespace Avalonia.Platform /// /// Enables or disables system window decorations (title bar, buttons, etc) /// - void SetSystemDecorations(bool enabled); + void SetSystemDecorations(SystemDecorations enabled); /// /// Sets the icon of this window. diff --git a/src/Avalonia.Controls/Presenters/TextPresenter.cs b/src/Avalonia.Controls/Presenters/TextPresenter.cs index 9084012619..a0f4c47b37 100644 --- a/src/Avalonia.Controls/Presenters/TextPresenter.cs +++ b/src/Avalonia.Controls/Presenters/TextPresenter.cs @@ -4,12 +4,13 @@ using System; using System.Reactive.Linq; using Avalonia.Media; +using Avalonia.Metadata; using Avalonia.Threading; using Avalonia.VisualTree; namespace Avalonia.Controls.Presenters { - public class TextPresenter : TextBlock + public class TextPresenter : Control { public static readonly DirectProperty CaretIndexProperty = TextBox.CaretIndexProperty.AddOwner( @@ -38,11 +39,41 @@ namespace Avalonia.Controls.Presenters o => o.SelectionEnd, (o, v) => o.SelectionEnd = v); + /// + /// Defines the property. + /// + public static readonly DirectProperty TextProperty = + AvaloniaProperty.RegisterDirect( + nameof(Text), + o => o.Text, + (o, v) => o.Text = v); + + /// + /// Defines the property. + /// + public static readonly StyledProperty TextAlignmentProperty = + TextBlock.TextAlignmentProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty TextWrappingProperty = + TextBlock.TextWrappingProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BackgroundProperty = + Border.BackgroundProperty.AddOwner(); + private readonly DispatcherTimer _caretTimer; private int _caretIndex; private int _selectionStart; private int _selectionEnd; private bool _caretBlink; + private string _text; + private FormattedText _formattedText; + private Size _constraint; static TextPresenter() { @@ -51,6 +82,7 @@ namespace Avalonia.Controls.Presenters SelectionStartProperty, SelectionEndProperty); Observable.Merge( + TextProperty.Changed, SelectionStartProperty.Changed, SelectionEndProperty.Changed, PasswordCharProperty.Changed @@ -61,11 +93,104 @@ namespace Avalonia.Controls.Presenters public TextPresenter() { - _caretTimer = new DispatcherTimer(); - _caretTimer.Interval = TimeSpan.FromMilliseconds(500); + _text = string.Empty; + _caretTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; _caretTimer.Tick += CaretTimerTick; } + /// + /// Gets or sets a brush used to paint the control's background. + /// + public IBrush Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + /// + /// Gets or sets the text. + /// + [Content] + public string Text + { + get => _text; + set => SetAndRaise(TextProperty, ref _text, value); + } + + /// + /// Gets or sets the font family. + /// + public FontFamily FontFamily + { + get => TextBlock.GetFontFamily(this); + set => TextBlock.SetFontFamily(this, value); + } + + /// + /// Gets or sets the font size. + /// + public double FontSize + { + get => TextBlock.GetFontSize(this); + set => TextBlock.SetFontSize(this, value); + } + + /// + /// Gets or sets the font style. + /// + public FontStyle FontStyle + { + get => TextBlock.GetFontStyle(this); + set => TextBlock.SetFontStyle(this, value); + } + + /// + /// Gets or sets the font weight. + /// + public FontWeight FontWeight + { + get => TextBlock.GetFontWeight(this); + set => TextBlock.SetFontWeight(this, value); + } + + /// + /// Gets or sets a brush used to paint the text. + /// + public IBrush Foreground + { + get => TextBlock.GetForeground(this); + set => TextBlock.SetForeground(this, value); + } + + /// + /// Gets or sets the control's text wrapping mode. + /// + public TextWrapping TextWrapping + { + get => GetValue(TextWrappingProperty); + set => SetValue(TextWrappingProperty, value); + } + + /// + /// Gets or sets the text alignment. + /// + public TextAlignment TextAlignment + { + get => GetValue(TextAlignmentProperty); + set => SetValue(TextAlignmentProperty, value); + } + + /// + /// Gets the used to render the text. + /// + public FormattedText FormattedText + { + get + { + return _formattedText ?? (_formattedText = CreateFormattedText(Bounds.Size, Text)); + } + } + public int CaretIndex { get @@ -138,6 +263,56 @@ namespace Avalonia.Controls.Presenters return hit.TextPosition + (hit.IsTrailing ? 1 : 0); } + /// + /// Creates the used to render the text. + /// + /// The constraint of the text. + /// The text to format. + /// A object. + private FormattedText CreateFormattedTextInternal(Size constraint, string text) + { + return new FormattedText + { + Constraint = constraint, + Typeface = FontManager.Current?.GetOrAddTypeface(FontFamily, FontWeight, FontStyle), + FontSize = FontSize, + Text = text ?? string.Empty, + TextAlignment = TextAlignment, + TextWrapping = TextWrapping, + }; + } + + /// + /// Invalidates . + /// + protected void InvalidateFormattedText() + { + if (_formattedText != null) + { + _constraint = _formattedText.Constraint; + _formattedText = null; + } + + InvalidateVisual(); + } + + /// + /// Renders the to a drawing context. + /// + /// The drawing context. + private void RenderInternal(DrawingContext context) + { + var background = Background; + + if (background != null) + { + context.FillRectangle(background, new Rect(Bounds.Size)); + } + + FormattedText.Constraint = Bounds.Size; + context.DrawText(Foreground, new Point(), FormattedText); + } + public override void Render(DrawingContext context) { var selectionStart = SelectionStart; @@ -150,7 +325,7 @@ namespace Avalonia.Controls.Presenters // issue #600: set constraint before any FormattedText manipulation // see base.Render(...) implementation - FormattedText.Constraint = Bounds.Size; + FormattedText.Constraint = _constraint; var rects = FormattedText.HitTestTextRange(start, length); @@ -160,7 +335,7 @@ namespace Avalonia.Controls.Presenters } } - base.Render(context); + RenderInternal(context); if (selectionStart == selectionEnd) { @@ -168,7 +343,7 @@ namespace Avalonia.Controls.Presenters if (caretBrush is null) { - var backgroundColor = (((Control)TemplatedParent).GetValue(BackgroundProperty) as SolidColorBrush)?.Color; + var backgroundColor = (Background as SolidColorBrush)?.Color; if (backgroundColor.HasValue) { byte red = (byte)~(backgroundColor.Value.R); @@ -255,17 +430,17 @@ namespace Avalonia.Controls.Presenters /// The constraint of the text. /// The text to generated the for. /// A object. - protected override FormattedText CreateFormattedText(Size constraint, string text) + protected virtual FormattedText CreateFormattedText(Size constraint, string text) { FormattedText result = null; if (PasswordChar != default(char)) { - result = base.CreateFormattedText(constraint, new string(PasswordChar, text?.Length ?? 0)); + result = CreateFormattedTextInternal(constraint, new string(PasswordChar, text?.Length ?? 0)); } else { - result = base.CreateFormattedText(constraint, text); + result = CreateFormattedTextInternal(constraint, text); } var selectionStart = SelectionStart; @@ -284,13 +459,37 @@ namespace Avalonia.Controls.Presenters return result; } + /// + /// Measures the control. + /// + /// The available size for the control. + /// The desired size. + private Size MeasureInternal(Size availableSize) + { + if (!string.IsNullOrEmpty(Text)) + { + if (TextWrapping == TextWrapping.Wrap) + { + FormattedText.Constraint = new Size(availableSize.Width, double.PositiveInfinity); + } + else + { + FormattedText.Constraint = Size.Infinity; + } + + return FormattedText.Bounds.Size; + } + + return new Size(); + } + protected override Size MeasureOverride(Size availableSize) { var text = Text; if (!string.IsNullOrEmpty(text)) { - return base.MeasureOverride(availableSize); + return MeasureInternal(availableSize); } else { diff --git a/src/Avalonia.Controls/Primitives/AccessText.cs b/src/Avalonia.Controls/Primitives/AccessText.cs index 5adc8d2448..bed5389cb3 100644 --- a/src/Avalonia.Controls/Primitives/AccessText.cs +++ b/src/Avalonia.Controls/Primitives/AccessText.cs @@ -4,6 +4,7 @@ using System; using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.TextFormatting; namespace Avalonia.Controls.Primitives { @@ -69,7 +70,7 @@ namespace Avalonia.Controls.Primitives if (underscore != -1 && ShowAccessKey) { - var rect = FormattedText.HitTestTextPosition(underscore); + var rect = HitTestTextPosition(underscore); var offset = new Vector(0, -0.5); context.DrawLine( new Pen(Foreground, 1), @@ -78,21 +79,85 @@ namespace Avalonia.Controls.Primitives } } - /// - protected override FormattedText CreateFormattedText(Size constraint, string text) + /// + /// Get the pixel location relative to the top-left of the layout box given the text position. + /// + /// The text position. + /// + private Rect HitTestTextPosition(int textPosition) { - return base.CreateFormattedText(constraint, StripAccessKey(text)); + if (TextLayout == null) + { + return new Rect(); + } + + if (TextLayout.TextLines.Count == 0) + { + return new Rect(); + } + + if (textPosition < 0 || textPosition >= Text.Length) + { + var lastLine = TextLayout.TextLines[TextLayout.TextLines.Count - 1]; + + var offsetX = lastLine.LineMetrics.BaselineOrigin.X; + + var lineX = offsetX + lastLine.LineMetrics.Size.Width; + + var lineY = Bounds.Height - lastLine.LineMetrics.Size.Height; + + return new Rect(lineX, lineY, 0, lastLine.LineMetrics.Size.Height); + } + + var currentY = 0.0; + + foreach (var textLine in TextLayout.TextLines) + { + if (textLine.Text.End < textPosition) + { + currentY += textLine.LineMetrics.Size.Height; + + continue; + } + + var currentX = textLine.LineMetrics.BaselineOrigin.X; + + foreach (var textRun in textLine.TextRuns) + { + if (!(textRun is ShapedTextRun shapedRun)) + { + continue; + } + + if (shapedRun.GlyphRun.Characters.End < textPosition) + { + currentX += shapedRun.GlyphRun.Bounds.Width; + + continue; + } + + var characterHit = shapedRun.GlyphRun.FindNearestCharacterHit(textPosition, out var width); + + var distance = shapedRun.GlyphRun.GetDistanceFromCharacterHit(characterHit); + + currentX += distance - width; + + if (characterHit.TrailingLength == 0) + { + width = 0.0; + } + + return new Rect(currentX, currentY, width, shapedRun.GlyphRun.Bounds.Height); + } + } + + return new Rect(); } - /// - /// Measures the control. - /// - /// The available size for the control. - /// The desired size. - protected override Size MeasureOverride(Size availableSize) + /// + protected override TextLayout CreateTextLayout(Size constraint, string text) { - var result = base.MeasureOverride(availableSize); - return result.WithHeight(result.Height + 1); + return base.CreateTextLayout(constraint, StripAccessKey(text)); } /// diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 9dd481cf40..1a12b53b9d 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -279,7 +279,7 @@ namespace Avalonia.Controls.Primitives } } - DeferCleanup(topLevel.AddHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel)); + DeferCleanup(topLevel.AddDisposableHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel)); DeferCleanup(InputManager.Instance?.Process.Subscribe(ListenForNonClientClick)); diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs index 8b8c7285be..0278360ba5 100644 --- a/src/Avalonia.Controls/TextBlock.cs +++ b/src/Avalonia.Controls/TextBlock.cs @@ -4,6 +4,7 @@ using System.Reactive.Linq; using Avalonia.LogicalTree; using Avalonia.Media; +using Avalonia.Media.TextFormatting; using Avalonia.Metadata; namespace Avalonia.Controls @@ -19,6 +20,12 @@ namespace Avalonia.Controls public static readonly StyledProperty BackgroundProperty = Border.BackgroundProperty.AddOwner(); + /// + /// Defines the property. + /// + public static readonly StyledProperty PaddingProperty = + Decorator.PaddingProperty.AddOwner(); + // TODO: Define these attached properties elsewhere (e.g. on a Text class) and AddOwner // them into TextBlock. @@ -28,7 +35,7 @@ namespace Avalonia.Controls public static readonly AttachedProperty FontFamilyProperty = AvaloniaProperty.RegisterAttached( nameof(FontFamily), - defaultValue: FontFamily.Default, + defaultValue: FontFamily.Default, inherits: true); /// @@ -87,8 +94,20 @@ namespace Avalonia.Controls public static readonly StyledProperty TextWrappingProperty = AvaloniaProperty.Register(nameof(TextWrapping)); + /// + /// Defines the property. + /// + public static readonly StyledProperty TextTrimmingProperty = + AvaloniaProperty.Register(nameof(TextTrimming)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty TextDecorationsProperty = + AvaloniaProperty.Register(nameof(TextDecorations)); + private string _text; - private FormattedText _formattedText; + private TextLayout _textLayout; private Size _constraint; /// @@ -97,20 +116,31 @@ namespace Avalonia.Controls static TextBlock() { ClipToBoundsProperty.OverrideDefaultValue(true); + AffectsRender( - BackgroundProperty, - ForegroundProperty, - FontWeightProperty, - FontSizeProperty, - FontStyleProperty); + BackgroundProperty, ForegroundProperty, FontSizeProperty, + FontWeightProperty, FontStyleProperty, TextWrappingProperty, + TextTrimmingProperty, TextAlignmentProperty, FontFamilyProperty, + TextDecorationsProperty, TextProperty, PaddingProperty); + + AffectsMeasure( + FontSizeProperty, FontWeightProperty, FontStyleProperty, + FontFamilyProperty, TextTrimmingProperty, TextProperty, + PaddingProperty); Observable.Merge( TextProperty.Changed, + ForegroundProperty.Changed, TextAlignmentProperty.Changed, + TextWrappingProperty.Changed, + TextTrimmingProperty.Changed, FontSizeProperty.Changed, FontStyleProperty.Changed, - FontWeightProperty.Changed - ).AddClassHandler((x,_) => x.OnTextPropertiesChanged()); + FontWeightProperty.Changed, + FontFamilyProperty.Changed, + TextDecorationsProperty.Changed, + PaddingProperty.Changed + ).AddClassHandler((x, _) => x.InvalidateTextLayout()); } /// @@ -121,6 +151,26 @@ namespace Avalonia.Controls _text = string.Empty; } + /// + /// Gets the used to render the text. + /// + public TextLayout TextLayout + { + get + { + return _textLayout ?? (_textLayout = CreateTextLayout(_constraint, Text)); + } + } + + /// + /// Gets or sets the padding to place around the . + /// + public Thickness Padding + { + get { return GetValue(PaddingProperty); } + set { SetValue(PaddingProperty, value); } + } + /// /// Gets or sets a brush used to paint the control's background. /// @@ -186,28 +236,21 @@ namespace Avalonia.Controls } /// - /// Gets the used to render the text. + /// Gets or sets the control's text wrapping mode. /// - public FormattedText FormattedText + public TextWrapping TextWrapping { - get - { - if (_formattedText == null) - { - _formattedText = CreateFormattedText(_constraint, Text); - } - - return _formattedText; - } + get { return GetValue(TextWrappingProperty); } + set { SetValue(TextWrappingProperty, value); } } /// - /// Gets or sets the control's text wrapping mode. + /// Gets or sets the control's text trimming mode. /// - public TextWrapping TextWrapping + public TextTrimming TextTrimming { - get { return GetValue(TextWrappingProperty); } - set { SetValue(TextWrappingProperty, value); } + get { return GetValue(TextTrimmingProperty); } + set { SetValue(TextTrimmingProperty, value); } } /// @@ -219,6 +262,15 @@ namespace Avalonia.Controls set { SetValue(TextAlignmentProperty, value); } } + /// + /// Gets or sets the text decorations. + /// + public TextDecorationCollection TextDecorations + { + get => GetValue(TextDecorationsProperty); + set => SetValue(TextDecorationsProperty, value); + } + /// /// Gets the value of the attached on a control. /// @@ -337,39 +389,43 @@ namespace Avalonia.Controls context.FillRectangle(background, new Rect(Bounds.Size)); } - FormattedText.Constraint = Bounds.Size; - context.DrawText(Foreground, new Point(), FormattedText); + var padding = Padding; + + TextLayout?.Draw(context.PlatformImpl, new Point(padding.Left, padding.Top)); } /// - /// Creates the used to render the text. + /// Creates the used to render the text. /// /// The constraint of the text. /// The text to format. - /// A object. - protected virtual FormattedText CreateFormattedText(Size constraint, string text) + /// A object. + protected virtual TextLayout CreateTextLayout(Size constraint, string text) { - return new FormattedText + if (constraint == Size.Empty) { - Constraint = constraint, - Typeface = FontManager.Current?.GetOrAddTypeface(FontFamily, FontWeight, FontStyle), - FontSize = FontSize, - Text = text ?? string.Empty, - TextAlignment = TextAlignment, - TextWrapping = TextWrapping, - }; + return null; + } + + return new TextLayout( + text ?? string.Empty, + FontManager.Current?.GetOrAddTypeface(FontFamily, FontWeight, FontStyle), + FontSize, + Foreground, + TextAlignment, + TextWrapping, + TextTrimming, + TextDecorations, + constraint.Width, + constraint.Height); } /// - /// Invalidates . + /// Invalidates . /// - protected void InvalidateFormattedText() + protected void InvalidateTextLayout() { - if (_formattedText != null) - { - _constraint = _formattedText.Constraint; - _formattedText = null; - } + _textLayout = null; } /// @@ -379,33 +435,33 @@ namespace Avalonia.Controls /// The desired size. protected override Size MeasureOverride(Size availableSize) { - if (!string.IsNullOrEmpty(Text)) + if (string.IsNullOrEmpty(Text)) + { + return new Size(); + } + + var padding = Padding; + + availableSize = availableSize.Deflate(padding); + + if (_constraint != availableSize) { - if (TextWrapping == TextWrapping.Wrap) - { - FormattedText.Constraint = new Size(availableSize.Width, double.PositiveInfinity); - } - else - { - FormattedText.Constraint = Size.Infinity; - } - - return FormattedText.Bounds.Size; + InvalidateTextLayout(); } - return new Size(); + _constraint = availableSize; + + var measuredSize = TextLayout?.Bounds.Size ?? Size.Empty; + + return measuredSize.Inflate(padding); } protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) { base.OnAttachedToLogicalTree(e); - InvalidateFormattedText(); - InvalidateMeasure(); - } - private void OnTextPropertiesChanged() - { - InvalidateFormattedText(); + InvalidateTextLayout(); + InvalidateMeasure(); } } diff --git a/src/Avalonia.Controls/TextBox.cs b/src/Avalonia.Controls/TextBox.cs index b561a3423f..4acfb75cb9 100644 --- a/src/Avalonia.Controls/TextBox.cs +++ b/src/Avalonia.Controls/TextBox.cs @@ -14,6 +14,7 @@ using Avalonia.Interactivity; using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Data; +using Avalonia.Layout; using Avalonia.Utilities; namespace Avalonia.Controls @@ -72,6 +73,18 @@ namespace Avalonia.Controls public static readonly StyledProperty TextAlignmentProperty = TextBlock.TextAlignmentProperty.AddOwner(); + /// + /// Defines the property. + /// + public static readonly StyledProperty HorizontalContentAlignmentProperty = + ContentControl.HorizontalContentAlignmentProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty VerticalContentAlignmentProperty = + ContentControl.VerticalContentAlignmentProperty.AddOwner(); + public static readonly StyledProperty TextWrappingProperty = TextBlock.TextWrappingProperty.AddOwner(); @@ -262,6 +275,40 @@ namespace Avalonia.Controls } } + public string SelectedText + { + get { return GetSelection(); } + set + { + if (value == null) + { + return; + } + + _undoRedoHelper.Snapshot(); + HandleTextInput(value); + _undoRedoHelper.Snapshot(); + } + } + + /// + /// Gets or sets the horizontal alignment of the content within the control. + /// + public HorizontalAlignment HorizontalContentAlignment + { + get { return GetValue(HorizontalContentAlignmentProperty); } + set { SetValue(HorizontalContentAlignmentProperty, value); } + } + + /// + /// Gets or sets the vertical alignment of the content within the control. + /// + public VerticalAlignment VerticalContentAlignment + { + get { return GetValue(VerticalContentAlignmentProperty); } + set { SetValue(VerticalContentAlignmentProperty, value); } + } + public TextAlignment TextAlignment { get { return GetValue(TextAlignmentProperty); } @@ -316,8 +363,7 @@ namespace Avalonia.Controls !AcceptsReturn && Text?.Length > 0) { - SelectionStart = 0; - SelectionEnd = Text.Length; + SelectAll(); } else { @@ -653,12 +699,12 @@ namespace Avalonia.Controls protected override void OnPointerPressed(PointerPressedEventArgs e) { - var point = e.GetPosition(_presenter); - var index = CaretIndex = _presenter.GetCaretIndex(point); var text = Text; - if (text != null && e.MouseButton == MouseButton.Left) + if (text != null && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { + var point = e.GetPosition(_presenter); + var index = CaretIndex = _presenter.GetCaretIndex(point); switch (e.ClickCount) { case 1: @@ -673,8 +719,7 @@ namespace Avalonia.Controls SelectionEnd = StringUtils.NextWord(text, index); break; case 3: - SelectionStart = 0; - SelectionEnd = text.Length; + SelectAll(); break; } } @@ -685,7 +730,8 @@ namespace Avalonia.Controls protected override void OnPointerMoved(PointerEventArgs e) { - if (_presenter != null && e.Pointer.Captured == _presenter) + // selection should not change during pointer move if the user right clicks + if (_presenter != null && e.Pointer.Captured == _presenter && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { var point = e.GetPosition(_presenter); @@ -698,6 +744,22 @@ namespace Avalonia.Controls { if (_presenter != null && e.Pointer.Captured == _presenter) { + if (e.InitialPressMouseButton == MouseButton.Right) + { + var point = e.GetPosition(_presenter); + var caretIndex = _presenter.GetCaretIndex(point); + + // see if mouse clicked inside current selection + // if it did not, we change the selection to where the user clicked + var firstSelection = Math.Min(SelectionStart, SelectionEnd); + var lastSelection = Math.Max(SelectionStart, SelectionEnd); + var didClickInSelection = SelectionStart != SelectionEnd && + caretIndex >= firstSelection && caretIndex <= lastSelection; + if (!didClickInSelection) + { + CaretIndex = SelectionEnd = SelectionStart = caretIndex; + } + } e.Pointer.Capture(null); } } @@ -896,7 +958,10 @@ namespace Avalonia.Controls CaretIndex = caretIndex; } - private void SelectAll() + /// + /// Select all text in the TextBox + /// + public void SelectAll() { SelectionStart = 0; SelectionEnd = Text?.Length ?? 0; diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 0c37858223..f4a3f05f03 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -2,19 +2,19 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using System.ComponentModel; +using System.Linq; using System.Reactive.Linq; using System.Threading.Tasks; using Avalonia.Controls.Platform; +using Avalonia.Data; using Avalonia.Input; +using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Styling; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; -using System.ComponentModel; -using Avalonia.Interactivity; namespace Avalonia.Controls { @@ -45,6 +45,27 @@ namespace Avalonia.Controls WidthAndHeight = 3, } + /// + /// Determines system decorations (title bar, border, etc) for a + /// + public enum SystemDecorations + { + /// + /// No decorations + /// + None = 0, + + /// + /// Window border without titlebar + /// + BorderOnly = 1, + + /// + /// Fully decorated (default) + /// + Full = 2 + } + /// /// A top-level window. /// @@ -59,8 +80,18 @@ namespace Avalonia.Controls /// /// Enables or disables system window decorations (title bar, buttons, etc) /// - public static readonly StyledProperty HasSystemDecorationsProperty = - AvaloniaProperty.Register(nameof(HasSystemDecorations), true); + [Obsolete("Use SystemDecorationsProperty instead")] + public static readonly DirectProperty HasSystemDecorationsProperty = + AvaloniaProperty.RegisterDirect( + nameof(HasSystemDecorations), + o => o.HasSystemDecorations, + (o, v) => o.HasSystemDecorations = v); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SystemDecorationsProperty = + AvaloniaProperty.Register(nameof(SystemDecorations), SystemDecorations.Full); /// /// Enables or disables the taskbar icon @@ -124,9 +155,6 @@ namespace Avalonia.Controls { BackgroundProperty.OverrideDefaultValue(typeof(Window), Brushes.White); TitleProperty.Changed.AddClassHandler((s, e) => s.PlatformImpl?.SetTitle((string)e.NewValue)); - HasSystemDecorationsProperty.Changed.AddClassHandler( - (s, e) => s.PlatformImpl?.SetSystemDecorations((bool)e.NewValue)); - ShowInTaskbarProperty.Changed.AddClassHandler((w, e) => w.PlatformImpl?.ShowTaskbarIcon((bool)e.NewValue)); IconProperty.Changed.AddClassHandler((s, e) => s.PlatformImpl?.SetIcon(((WindowIcon)e.NewValue)?.PlatformImpl)); @@ -135,12 +163,11 @@ namespace Avalonia.Controls WindowStateProperty.Changed.AddClassHandler( (w, e) => { if (w.PlatformImpl != null) w.PlatformImpl.WindowState = (WindowState)e.NewValue; }); - + MinWidthProperty.Changed.AddClassHandler((w, e) => w.PlatformImpl?.SetMinMaxSize(new Size((double)e.NewValue, w.MinHeight), new Size(w.MaxWidth, w.MaxHeight))); MinHeightProperty.Changed.AddClassHandler((w, e) => w.PlatformImpl?.SetMinMaxSize(new Size(w.MinWidth, (double)e.NewValue), new Size(w.MaxWidth, w.MaxHeight))); MaxWidthProperty.Changed.AddClassHandler((w, e) => w.PlatformImpl?.SetMinMaxSize(new Size(w.MinWidth, w.MinHeight), new Size((double)e.NewValue, w.MaxHeight))); MaxHeightProperty.Changed.AddClassHandler((w, e) => w.PlatformImpl?.SetMinMaxSize(new Size(w.MinWidth, w.MinHeight), new Size(w.MaxWidth, (double)e.NewValue))); - } /// @@ -191,11 +218,30 @@ namespace Avalonia.Controls /// /// Enables or disables system window decorations (title bar, buttons, etc) /// - /// + [Obsolete("Use SystemDecorations instead")] public bool HasSystemDecorations { - get { return GetValue(HasSystemDecorationsProperty); } - set { SetValue(HasSystemDecorationsProperty, value); } + get => SystemDecorations == SystemDecorations.Full; + set + { + var oldValue = HasSystemDecorations; + + if (oldValue != value) + { + SystemDecorations = value ? SystemDecorations.Full : SystemDecorations.None; + RaisePropertyChanged(HasSystemDecorationsProperty, oldValue, value); + } + } + } + + /// + /// Sets the system decorations (title bar, border, etc) + /// + /// + public SystemDecorations SystemDecorations + { + get { return GetValue(SystemDecorationsProperty); } + set { SetValue(SystemDecorationsProperty, value); } } /// @@ -529,7 +575,7 @@ namespace Avalonia.Controls { var sizeToContent = SizeToContent; var clientSize = ClientSize; - Size constraint = clientSize; + var constraint = availableSize; if ((sizeToContent & SizeToContent.Width) != 0) { @@ -584,5 +630,27 @@ namespace Avalonia.Controls /// event needs to be raised. /// protected virtual void OnClosing(CancelEventArgs e) => Closing?.Invoke(this, e); + + protected override void OnPropertyChanged( + AvaloniaProperty property, + Optional oldValue, + BindingValue newValue, + BindingPriority priority) + { + if (property == SystemDecorationsProperty) + { + var typedNewValue = newValue.GetValueOrDefault(); + + PlatformImpl?.SetSystemDecorations(typedNewValue); + + var o = oldValue.GetValueOrDefault() == SystemDecorations.Full; + var n = typedNewValue == SystemDecorations.Full; + + if (o != n) + { + RaisePropertyChanged(HasSystemDecorationsProperty, o, n); + } + } + } } } diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index 86e34ca6d4..7480b3519c 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -96,7 +96,7 @@ namespace Avalonia.DesignerSupport.Remote { } - public void SetSystemDecorations(bool enabled) + public void SetSystemDecorations(SystemDecorations enabled) { } diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index 4bba5ef41b..7bf1d236bd 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -110,7 +110,7 @@ namespace Avalonia.DesignerSupport.Remote { } - public void SetSystemDecorations(bool enabled) + public void SetSystemDecorations(SystemDecorations enabled) { } diff --git a/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj b/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj index c0e46416c1..3dcf7c9681 100644 --- a/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj +++ b/src/Avalonia.DesktopRuntime/Avalonia.DesktopRuntime.csproj @@ -1,7 +1,7 @@ - net461;netcoreapp2.0 + net461;netcoreapp3.1 diff --git a/src/Avalonia.Diagnostics/Assets/Fonts/SourceSansPro-Regular.ttf b/src/Avalonia.Diagnostics/Assets/Fonts/SourceSansPro-Regular.ttf new file mode 100644 index 0000000000..278ad8aa0a Binary files /dev/null and b/src/Avalonia.Diagnostics/Assets/Fonts/SourceSansPro-Regular.ttf differ diff --git a/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj b/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj index 7d0686a33f..5f49a46839 100644 --- a/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj +++ b/src/Avalonia.Diagnostics/Avalonia.Diagnostics.csproj @@ -1,8 +1,15 @@  netstandard2.0 + Avalonia + + %(Filename) + + + + @@ -14,7 +21,10 @@ - + + + + diff --git a/src/Avalonia.Diagnostics/DevTools.xaml b/src/Avalonia.Diagnostics/DevTools.xaml deleted file mode 100644 index 1df0f3a097..0000000000 --- a/src/Avalonia.Diagnostics/DevTools.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - Hold Ctrl+Shift over a control to inspect. - - Focused: - - - Pointer Over: - - - - diff --git a/src/Avalonia.Diagnostics/DevTools.xaml.cs b/src/Avalonia.Diagnostics/DevTools.xaml.cs deleted file mode 100644 index 037e80e372..0000000000 --- a/src/Avalonia.Diagnostics/DevTools.xaml.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive.Linq; -using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Diagnostics.ViewModels; -using Avalonia.Input; -using Avalonia.Input.Raw; -using Avalonia.Interactivity; -using Avalonia.Markup.Xaml; -using Avalonia.Rendering; -using Avalonia.VisualTree; - -namespace Avalonia -{ - public static class DevToolsExtensions - { - public static void AttachDevTools(this TopLevel control) - { - Diagnostics.DevTools.Attach(control, new KeyGesture(Key.F12)); - } - - public static void AttachDevTools(this TopLevel control, KeyGesture gesture) - { - Diagnostics.DevTools.Attach(control, gesture); - } - - public static void OpenDevTools(this TopLevel control) - { - Diagnostics.DevTools.OpenDevTools(control); - } - } -} - -namespace Avalonia.Diagnostics -{ - public class DevTools : UserControl - { - private static readonly Dictionary s_open = new Dictionary(); - private static readonly HashSet s_visualTreeRoots = new HashSet(); - private readonly IDisposable _keySubscription; - - public DevTools(IControl root) - { - InitializeComponent(); - Root = root; - DataContext = new DevToolsViewModel(root); - - _keySubscription = InputManager.Instance.Process - .OfType() - .Subscribe(RawKeyDown); - } - - // HACK: needed for XAMLIL, will fix that later - public DevTools() - { - } - - public IControl Root { get; } - - public static IDisposable Attach(TopLevel control, KeyGesture gesture) - { - void PreviewKeyDown(object sender, KeyEventArgs e) - { - if (gesture.Matches(e)) - { - OpenDevTools(control); - } - } - - return control.AddHandler( - KeyDownEvent, - PreviewKeyDown, - RoutingStrategies.Tunnel); - } - - internal static void OpenDevTools(TopLevel control) - { - if (s_open.TryGetValue(control, out var devToolsWindow)) - { - devToolsWindow.Activate(); - } - else - { - var devTools = new DevTools(control); - - devToolsWindow = new Window - { - Width = 1024, - Height = 512, - Content = devTools, - DataTemplates = { new ViewLocator() }, - Title = "Avalonia DevTools" - }; - - devToolsWindow.Closed += devTools.DevToolsClosed; - s_open.Add(control, devToolsWindow); - MarkAsDevTool(devToolsWindow); - devToolsWindow.Show(); - } - } - - private void DevToolsClosed(object sender, EventArgs e) - { - var devToolsWindow = (Window)sender; - var devTools = (DevTools)devToolsWindow.Content; - s_open.Remove((TopLevel)devTools.Root); - RemoveDevTool(devToolsWindow); - _keySubscription.Dispose(); - devToolsWindow.Closed -= DevToolsClosed; - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } - - private void RawKeyDown(RawKeyEventArgs e) - { - const RawInputModifiers modifiers = RawInputModifiers.Control | RawInputModifiers.Shift; - - if (e.Modifiers == modifiers) - { - var point = (Root.VisualRoot as IInputRoot)?.MouseDevice?.GetPosition(Root) ?? default(Point); - var control = Root.GetVisualsAt(point, x => (!(x is AdornerLayer) && x.IsVisible)) - .FirstOrDefault(); - - if (control != null) - { - var vm = (DevToolsViewModel)DataContext; - vm.SelectControl((IControl)control); - } - } - } - - /// - /// Marks a visual as part of the DevTools, so it can be excluded from event tracking. - /// - /// The visual whose root is to be marked. - public static void MarkAsDevTool(IVisual visual) - { - s_visualTreeRoots.Add(visual.GetVisualRoot()); - } - - public static void RemoveDevTool(IVisual visual) - { - s_visualTreeRoots.Remove(visual.GetVisualRoot()); - } - - public static bool BelongsToDevTool(IVisual visual) - { - return s_visualTreeRoots.Contains(visual.GetVisualRoot()); - } - } -} diff --git a/src/Avalonia.Diagnostics/DevToolsExtensions.cs b/src/Avalonia.Diagnostics/DevToolsExtensions.cs new file mode 100644 index 0000000000..4bc2ca313f --- /dev/null +++ b/src/Avalonia.Diagnostics/DevToolsExtensions.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Diagnostics; +using Avalonia.Input; + +namespace Avalonia +{ + /// + /// Extension methods for attaching DevTools.. + /// + public static class DevToolsExtensions + { + /// + /// Attaches DevTools to a window, to be opened with the F12 key. + /// + /// The window to attach DevTools to. + public static void AttachDevTools(this TopLevel root) + { + DevTools.Attach(root, new KeyGesture(Key.F12)); + } + + /// + /// Attaches DevTools to a window, to be opened with the specified key gesture. + /// + /// The window to attach DevTools to. + /// The key gesture to open DevTools. + public static void AttachDevTools(this TopLevel root, KeyGesture gesture) + { + DevTools.Attach(root, gesture); + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Converters/BoolToBrushConverter.cs b/src/Avalonia.Diagnostics/Diagnostics/Converters/BoolToBrushConverter.cs new file mode 100644 index 0000000000..37ba5155fd --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Converters/BoolToBrushConverter.cs @@ -0,0 +1,22 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace Avalonia.Diagnostics.Converters +{ + internal class BoolToBrushConverter : IValueConverter + { + public IBrush Brush { get; set; } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return (bool)value ? Brush : Brushes.Transparent; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/DevTools.cs b/src/Avalonia.Diagnostics/Diagnostics/DevTools.cs new file mode 100644 index 0000000000..b3a8f4745e --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/DevTools.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Disposables; +using Avalonia.Controls; +using Avalonia.Diagnostics.Views; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Avalonia.Diagnostics +{ + public static class DevTools + { + private static readonly Dictionary s_open = new Dictionary(); + + public static IDisposable Attach(TopLevel root, KeyGesture gesture) + { + void PreviewKeyDown(object sender, KeyEventArgs e) + { + if (gesture.Matches(e)) + { + Open(root); + } + } + + return root.AddDisposableHandler( + InputElement.KeyDownEvent, + PreviewKeyDown, + RoutingStrategies.Tunnel); + } + + public static IDisposable Open(TopLevel root) + { + if (s_open.TryGetValue(root, out var window)) + { + window.Activate(); + } + else + { + window = new MainWindow + { + Width = 1024, + Height = 512, + Root = root, + }; + + window.Closed += DevToolsClosed; + s_open.Add(root, window); + window.Show(); + } + + return Disposable.Create(() => window?.Close()); + } + + private static void DevToolsClosed(object sender, EventArgs e) + { + var window = (MainWindow)sender; + s_open.Remove(window.Root); + window.Closed -= DevToolsClosed; + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleContext.cs b/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleContext.cs new file mode 100644 index 0000000000..5927bd785e --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleContext.cs @@ -0,0 +1,36 @@ +#pragma warning disable IDE1006 // Naming Styles + +using Avalonia.Diagnostics.ViewModels; + +namespace Avalonia.Diagnostics.Models +{ + public class ConsoleContext + { + private readonly ConsoleViewModel _owner; + + internal ConsoleContext(ConsoleViewModel owner) => _owner = owner; + + public readonly string help = @"Welcome to Avalonia DevTools. Here you can execute arbitrary C# code using Roslyn scripting. + +The following variables are available: + +e: The control currently selected in the logical or visual tree view +root: The root of the visual tree + +The following commands are available: + +clear(): Clear the output history +"; + + public dynamic e { get; internal set; } + public dynamic root { get; internal set; } + + internal static object NoOutput { get; } = new object(); + + public object clear() + { + _owner.History.Clear(); + return NoOutput; + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleHistoryItem.cs b/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleHistoryItem.cs new file mode 100644 index 0000000000..86da18145a --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Models/ConsoleHistoryItem.cs @@ -0,0 +1,19 @@ +using System; +using Avalonia.Media; + +namespace Avalonia.Diagnostics.Models +{ + internal class ConsoleHistoryItem + { + public ConsoleHistoryItem(string input, object output) + { + Input = input; + Output = output; + Foreground = output is Exception ? Brushes.Red : Brushes.Green; + } + + public string Input { get; } + public object Output { get; } + public IBrush Foreground { get; } + } +} diff --git a/src/Avalonia.Diagnostics/Models/EventChainLink.cs b/src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs similarity index 100% rename from src/Avalonia.Diagnostics/Models/EventChainLink.cs rename to src/Avalonia.Diagnostics/Diagnostics/Models/EventChainLink.cs diff --git a/src/Avalonia.Diagnostics/ViewLocator.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewLocator.cs similarity index 70% rename from src/Avalonia.Diagnostics/ViewLocator.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewLocator.cs index a66703301d..c06fbec801 100644 --- a/src/Avalonia.Diagnostics/ViewLocator.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewLocator.cs @@ -1,13 +1,11 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - using System; using Avalonia.Controls; using Avalonia.Controls.Templates; +using Avalonia.Diagnostics.ViewModels; namespace Avalonia.Diagnostics { - internal class ViewLocator : IDataTemplate + internal class ViewLocator : IDataTemplate { public bool SupportsRecycling => false; @@ -28,7 +26,7 @@ namespace Avalonia.Diagnostics public bool Match(object data) { - return data is TViewModel; + return data is ViewModelBase; } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs new file mode 100644 index 0000000000..a9353eba8b --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs @@ -0,0 +1,95 @@ +using System.ComponentModel; +using Avalonia.Collections; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal class AvaloniaPropertyViewModel : PropertyViewModel + { + private readonly AvaloniaObject _target; + private string _type; + private object _value; + private string _priority; + private string _group; + + public AvaloniaPropertyViewModel(AvaloniaObject o, AvaloniaProperty property) + { + _target = o; + Property = property; + + Name = property.IsAttached ? + $"[{property.OwnerType.Name}.{property.Name}]" : + property.Name; + + if (property.IsDirect) + { + _group = "Properties"; + Priority = "Direct"; + } + + Update(); + } + + public AvaloniaProperty Property { get; } + public override object Key => Property; + public override string Name { get; } + public bool IsAttached => Property.IsAttached; + + public string Priority + { + get => _priority; + private set => RaiseAndSetIfChanged(ref _priority, value); + } + + public override string Type => _type; + + public override string Value + { + get => ConvertToString(_value); + set + { + try + { + var convertedValue = ConvertFromString(value, Property.PropertyType); + _target.SetValue(Property, convertedValue); + } + catch { } + } + } + + public override string Group + { + get => _group; + } + + public override void Update() + { + if (Property.IsDirect) + { + RaiseAndSetIfChanged(ref _value, _target.GetValue(Property), nameof(Value)); + RaiseAndSetIfChanged(ref _type, _value?.GetType().Name, nameof(Type)); + } + else + { + var val = _target.GetDiagnostic(Property); + + RaiseAndSetIfChanged(ref _value, val?.Value, nameof(Value)); + RaiseAndSetIfChanged(ref _type, _value?.GetType().Name, nameof(Type)); + + if (val != null) + { + SetGroup(IsAttached ? "Attached Properties" : "Properties"); + Priority = val.Priority.ToString(); + } + else + { + SetGroup(Priority = "Unset"); + } + } + } + + private void SetGroup(string group) + { + RaiseAndSetIfChanged(ref _group, group, nameof(Group)); + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs new file mode 100644 index 0000000000..af5e254204 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; +using System.Reflection; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal class ClrPropertyViewModel : PropertyViewModel + { + private readonly object _target; + private string _type; + private object _value; + + public ClrPropertyViewModel(object o, PropertyInfo property) + { + _target = o; + Property = property; + + if (!property.DeclaringType.IsInterface) + { + Name = property.Name; + } + else + { + Name = property.DeclaringType.Name + '.' + property.Name; + } + + Update(); + } + + public PropertyInfo Property { get; } + public override object Key => Name; + public override string Name { get; } + public override string Group => "CLR Properties"; + + public override string Type => _type; + + public override string Value + { + get => ConvertToString(_value); + set + { + try + { + var convertedValue = ConvertFromString(value, Property.PropertyType); + Property.SetValue(_target, convertedValue); + } + catch { } + } + } + + public override void Update() + { + var val = Property.GetValue(_target); + RaiseAndSetIfChanged(ref _value, val, nameof(Value)); + RaiseAndSetIfChanged(ref _type, _value?.GetType().Name, nameof(Type)); + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ConsoleViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ConsoleViewModel.cs new file mode 100644 index 0000000000..0e0c44ded8 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ConsoleViewModel.cs @@ -0,0 +1,112 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Collections; +using Avalonia.Diagnostics.Models; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal class ConsoleViewModel : ViewModelBase + { + private readonly ConsoleContext _context; + private readonly Action _updateContext; + private int _historyIndex = -1; + private string _input; + private bool _isVisible; + private ScriptState _state; + + public ConsoleViewModel(Action updateContext) + { + _context = new ConsoleContext(this); + _updateContext = updateContext; + } + + public string Input + { + get => _input; + set => RaiseAndSetIfChanged(ref _input, value); + } + + public bool IsVisible + { + get => _isVisible; + set => RaiseAndSetIfChanged(ref _isVisible, value); + } + + public AvaloniaList History { get; } = new AvaloniaList(); + + public async Task Execute() + { + if (string.IsNullOrWhiteSpace(Input)) + { + return; + } + + try + { + var options = ScriptOptions.Default + .AddReferences(Assembly.GetAssembly(typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo))); + + _updateContext(_context); + + if (_state == null) + { + _state = await CSharpScript.RunAsync(Input, options: options, globals: _context); + } + else + { + _state = await _state.ContinueWithAsync(Input); + } + + if (_state.ReturnValue != ConsoleContext.NoOutput) + { + History.Add(new ConsoleHistoryItem(Input, _state.ReturnValue ?? "(null)")); + } + } + catch (Exception ex) + { + History.Add(new ConsoleHistoryItem(Input, ex)); + } + + Input = string.Empty; + _historyIndex = -1; + } + + public void HistoryUp() + { + if (History.Count > 0) + { + if (_historyIndex == -1) + { + _historyIndex = History.Count - 1; + } + else if (_historyIndex > 0) + { + --_historyIndex; + } + + Input = History[_historyIndex].Input; + } + } + + public void HistoryDown() + { + if (History.Count > 0 && _historyIndex >= 0) + { + if (_historyIndex == History.Count - 1) + { + _historyIndex = -1; + Input = string.Empty; + } + else + { + Input = History[++_historyIndex].Input; + } + } + } + + public void ToggleVisibility() => IsVisible = !IsVisible; + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs new file mode 100644 index 0000000000..4e7129da41 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ControlDetailsViewModel.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Avalonia.Collections; +using Avalonia.VisualTree; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal class ControlDetailsViewModel : ViewModelBase, IDisposable + { + private readonly IVisual _control; + private readonly IDictionary> _propertyIndex; + private AvaloniaPropertyViewModel _selectedProperty; + private string _propertyFilter; + + public ControlDetailsViewModel(IVisual control, string propertyFilter) + { + _control = control; + + var properties = GetAvaloniaProperties(control) + .Concat(GetClrProperties(control)) + .OrderBy(x => x, PropertyComparer.Instance) + .ThenBy(x => x.Name) + .ToList(); + + _propertyIndex = properties.GroupBy(x => x.Key).ToDictionary(x => x.Key, x => x.ToList()); + _propertyFilter = propertyFilter; + + var view = new DataGridCollectionView(properties); + view.GroupDescriptions.Add(new DataGridPathGroupDescription(nameof(AvaloniaPropertyViewModel.Group))); + view.Filter = FilterProperty; + PropertiesView = view; + + if (control is INotifyPropertyChanged inpc) + { + inpc.PropertyChanged += ControlPropertyChanged; + } + + if (control is AvaloniaObject ao) + { + ao.PropertyChanged += ControlPropertyChanged; + } + } + + public DataGridCollectionView PropertiesView { get; } + + public string PropertyFilter + { + get => _propertyFilter; + set + { + if (RaiseAndSetIfChanged(ref _propertyFilter, value)) + { + PropertiesView.Refresh(); + } + } + } + + public AvaloniaPropertyViewModel SelectedProperty + { + get => _selectedProperty; + set => RaiseAndSetIfChanged(ref _selectedProperty, value); + } + + public void Dispose() + { + if (_control is INotifyPropertyChanged inpc) + { + inpc.PropertyChanged -= ControlPropertyChanged; + } + + if (_control is AvaloniaObject ao) + { + ao.PropertyChanged -= ControlPropertyChanged; + } + } + + private IEnumerable GetAvaloniaProperties(object o) + { + if (o is AvaloniaObject ao) + { + return AvaloniaPropertyRegistry.Instance.GetRegistered(ao) + .Concat(AvaloniaPropertyRegistry.Instance.GetRegisteredAttached(ao.GetType())) + .Select(x => new AvaloniaPropertyViewModel(ao, x)); + } + else + { + return Enumerable.Empty(); + } + } + + private IEnumerable GetClrProperties(object o) + { + foreach (var p in GetClrProperties(o, o.GetType())) + { + yield return p; + } + + foreach (var i in o.GetType().GetInterfaces()) + { + foreach (var p in GetClrProperties(o, i)) + { + yield return p; + } + } + } + + private IEnumerable GetClrProperties(object o, Type t) + { + return t.GetProperties() + .Where(x => x.GetIndexParameters().Length == 0) + .Select(x => new ClrPropertyViewModel(o, x)); + } + + private void ControlPropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (_propertyIndex.TryGetValue(e.Property, out var properties)) + { + foreach (var property in properties) + { + property.Update(); + } + } + } + + private void ControlPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_propertyIndex.TryGetValue(e.PropertyName, out var properties)) + { + foreach (var property in properties) + { + property.Update(); + } + } + } + + private bool FilterProperty(object arg) + { + if (!string.IsNullOrWhiteSpace(PropertyFilter) && arg is PropertyViewModel property) + { + return property.Name.IndexOf(PropertyFilter, StringComparison.OrdinalIgnoreCase) != -1; + } + + return true; + } + + private class PropertyComparer : IComparer + { + public static PropertyComparer Instance { get; } = new PropertyComparer(); + + public int Compare(PropertyViewModel x, PropertyViewModel y) + { + var groupX = GroupIndex(x.Group); + var groupY = GroupIndex(y.Group); + + if (groupX != groupY) + { + return groupX - groupY; + } + else + { + return string.CompareOrdinal(x.Name, y.Name); + } + } + + private int GroupIndex(string group) + { + switch (group) + { + case "Properties": return 0; + case "Attached Properties": return 1; + case "CLR Properties": return 2; + default: return 3; + } + } + } + } +} diff --git a/src/Avalonia.Diagnostics/ViewModels/EventOwnerTreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventOwnerTreeNode.cs similarity index 98% rename from src/Avalonia.Diagnostics/ViewModels/EventOwnerTreeNode.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventOwnerTreeNode.cs index 7e38749a6f..773140c446 100644 --- a/src/Avalonia.Diagnostics/ViewModels/EventOwnerTreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventOwnerTreeNode.cs @@ -19,7 +19,7 @@ namespace Avalonia.Diagnostics.ViewModels InputElement.PointerReleasedEvent, InputElement.PointerPressedEvent }; - public EventOwnerTreeNode(Type type, IEnumerable events, EventsViewModel vm) + public EventOwnerTreeNode(Type type, IEnumerable events, EventsPageViewModel vm) : base(null, type.Name) { Children = new AvaloniaList(events.OrderBy(e => e.Name) diff --git a/src/Avalonia.Diagnostics/ViewModels/EventTreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs similarity index 85% rename from src/Avalonia.Diagnostics/ViewModels/EventTreeNode.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs index 36f1904253..f017a50ea9 100644 --- a/src/Avalonia.Diagnostics/ViewModels/EventTreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNode.cs @@ -3,6 +3,7 @@ using System; using Avalonia.Diagnostics.Models; +using Avalonia.Diagnostics.Views; using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; @@ -12,11 +13,11 @@ namespace Avalonia.Diagnostics.ViewModels internal class EventTreeNode : EventTreeNodeBase { private readonly RoutedEvent _event; - private readonly EventsViewModel _parentViewModel; + private readonly EventsPageViewModel _parentViewModel; private bool _isRegistered; private FiredEvent _currentEvent; - public EventTreeNode(EventOwnerTreeNode parent, RoutedEvent @event, EventsViewModel vm) + public EventTreeNode(EventOwnerTreeNode parent, RoutedEvent @event, EventsPageViewModel vm) : base(parent, @event.Name) { Contract.Requires(@event != null); @@ -65,7 +66,7 @@ namespace Avalonia.Diagnostics.ViewModels { if (!_isRegistered || IsEnabled == false) return; - if (sender is IVisual v && DevTools.BelongsToDevTool(v)) + if (sender is IVisual v && BelongsToDevTool(v)) return; var s = sender; @@ -94,5 +95,20 @@ namespace Avalonia.Diagnostics.ViewModels else handler(); } + + private static bool BelongsToDevTool(IVisual v) + { + while (v != null) + { + if (v is MainView || v is MainWindow) + { + return true; + } + + v = v.VisualParent; + } + + return false; + } } } diff --git a/src/Avalonia.Diagnostics/ViewModels/EventTreeNodeBase.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNodeBase.cs similarity index 100% rename from src/Avalonia.Diagnostics/ViewModels/EventTreeNodeBase.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventTreeNodeBase.cs diff --git a/src/Avalonia.Diagnostics/ViewModels/EventsViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventsPageViewModel.cs similarity index 69% rename from src/Avalonia.Diagnostics/ViewModels/EventsViewModel.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventsPageViewModel.cs index 1c868148ce..0d58952687 100644 --- a/src/Avalonia.Diagnostics/ViewModels/EventsViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/EventsPageViewModel.cs @@ -12,12 +12,12 @@ using Avalonia.Media; namespace Avalonia.Diagnostics.ViewModels { - internal class EventsViewModel : ViewModelBase, IDevToolViewModel + internal class EventsPageViewModel : ViewModelBase { private readonly IControl _root; private FiredEvent _selectedEvent; - public EventsViewModel(IControl root) + public EventsPageViewModel(IControl root) { _root = root; @@ -45,17 +45,4 @@ namespace Avalonia.Diagnostics.ViewModels RecordedEvents.Clear(); } } - - internal class BoolToBrushConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return (bool)value ? Brushes.Green : Brushes.Transparent; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } } diff --git a/src/Avalonia.Diagnostics/ViewModels/FiredEvent.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs similarity index 100% rename from src/Avalonia.Diagnostics/ViewModels/FiredEvent.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/FiredEvent.cs diff --git a/src/Avalonia.Diagnostics/ViewModels/LogicalTreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/LogicalTreeNode.cs similarity index 65% rename from src/Avalonia.Diagnostics/ViewModels/LogicalTreeNode.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/LogicalTreeNode.cs index 0b9bd85b4f..a7c2997346 100644 --- a/src/Avalonia.Diagnostics/ViewModels/LogicalTreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/LogicalTreeNode.cs @@ -1,6 +1,3 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - using Avalonia.Collections; using Avalonia.Controls; using Avalonia.LogicalTree; @@ -17,7 +14,8 @@ namespace Avalonia.Diagnostics.ViewModels public static LogicalTreeNode[] Create(object control) { - return control is ILogical logical ? new[] { new LogicalTreeNode(logical, null) } : null; + var logical = control as ILogical; + return logical != null ? new[] { new LogicalTreeNode(logical, null) } : null; } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..7addaba977 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/MainViewModel.cs @@ -0,0 +1,126 @@ +using System; +using Avalonia.Controls; +using Avalonia.Diagnostics.Models; +using Avalonia.Input; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal class MainViewModel : ViewModelBase, IDisposable + { + private readonly IControl _root; + private readonly TreePageViewModel _logicalTree; + private readonly TreePageViewModel _visualTree; + private readonly EventsPageViewModel _events; + private ViewModelBase _content; + private int _selectedTab; + private string _focusedControl; + private string _pointerOverElement; + + public MainViewModel(IControl root) + { + _root = root; + _logicalTree = new TreePageViewModel(LogicalTreeNode.Create(root)); + _visualTree = new TreePageViewModel(VisualTreeNode.Create(root)); + _events = new EventsPageViewModel(root); + + UpdateFocusedControl(); + KeyboardDevice.Instance.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(KeyboardDevice.Instance.FocusedElement)) + { + UpdateFocusedControl(); + } + }; + + SelectedTab = 0; + root.GetObservable(TopLevel.PointerOverElementProperty) + .Subscribe(x => PointerOverElement = x?.GetType().Name); + Console = new ConsoleViewModel(UpdateConsoleContext); + } + + public ConsoleViewModel Console { get; } + + public ViewModelBase Content + { + get { return _content; } + private set + { + if (_content is TreePageViewModel oldTree && + value is TreePageViewModel newTree && + oldTree?.SelectedNode?.Visual is IControl control) + { + newTree.SelectControl(control); + } + + RaiseAndSetIfChanged(ref _content, value); + } + } + + public int SelectedTab + { + get { return _selectedTab; } + set + { + _selectedTab = value; + + switch (value) + { + case 0: + Content = _logicalTree; + break; + case 1: + Content = _visualTree; + break; + case 2: + Content = _events; + break; + } + + RaisePropertyChanged(); + } + } + + public string FocusedControl + { + get { return _focusedControl; } + private set { RaiseAndSetIfChanged(ref _focusedControl, value); } + } + + public string PointerOverElement + { + get { return _pointerOverElement; } + private set { RaiseAndSetIfChanged(ref _pointerOverElement, value); } + } + + private void UpdateConsoleContext(ConsoleContext context) + { + context.root = _root; + + if (Content is TreePageViewModel tree) + { + context.e = tree.SelectedNode?.Visual; + } + } + + public void SelectControl(IControl control) + { + var tree = Content as TreePageViewModel; + + if (tree != null) + { + tree.SelectControl(control); + } + } + + public void Dispose() + { + _logicalTree.Dispose(); + _visualTree.Dispose(); + } + + private void UpdateFocusedControl() + { + FocusedControl = KeyboardDevice.Instance.FocusedElement?.GetType().Name; + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs new file mode 100644 index 0000000000..ddbdae7ed9 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Reflection; + +namespace Avalonia.Diagnostics.ViewModels +{ + internal abstract class PropertyViewModel : ViewModelBase + { + private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; + private static readonly Type[] StringParameter = new[] { typeof(string) }; + private static readonly Type[] StringIFormatProviderParameters = new[] { typeof(string), typeof(IFormatProvider) }; + + public abstract object Key { get; } + public abstract string Name { get; } + public abstract string Group { get; } + public abstract string Type { get; } + public abstract string Value { get; set; } + public abstract void Update(); + + protected static string ConvertToString(object value) + { + if (value is null) + { + return "(null)"; + } + + var converter = TypeDescriptor.GetConverter(value); + return converter?.ConvertToString(value) ?? value.ToString(); + } + + protected static object ConvertFromString(string s, Type targetType) + { + var converter = TypeDescriptor.GetConverter(targetType); + + if (converter != null && converter.CanConvertFrom(typeof(string))) + { + return converter.ConvertFrom(null, CultureInfo.InvariantCulture, s); + } + else + { + var method = targetType.GetMethod("Parse", PublicStatic, null, StringIFormatProviderParameters, null); + + if (method != null) + { + return method.Invoke(null, new object[] { s, CultureInfo.InvariantCulture }); + } + + method = targetType.GetMethod("Parse", PublicStatic, null, StringParameter, null); + + if (method != null) + { + return method.Invoke(null, new object[] { s }); + } + } + + throw new InvalidCastException("Unable to convert value."); + } + } +} diff --git a/src/Avalonia.Diagnostics/ViewModels/TreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs similarity index 66% rename from src/Avalonia.Diagnostics/ViewModels/TreeNode.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs index 902eb81bd9..4eca8a3c25 100644 --- a/src/Avalonia.Diagnostics/ViewModels/TreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreeNode.cs @@ -6,6 +6,8 @@ using System.Collections.Specialized; using System.Reactive; using System.Reactive.Linq; using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.LogicalTree; using Avalonia.Styling; using Avalonia.VisualTree; @@ -22,22 +24,25 @@ namespace Avalonia.Diagnostics.ViewModels Type = visual.GetType().Name; Visual = visual; - if (visual is IStyleable styleable) + if (visual is IControl control) { + var removed = Observable.FromEventPattern( + x => control.DetachedFromLogicalTree += x, + x => control.DetachedFromLogicalTree -= x); var classesChanged = Observable.FromEventPattern< NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>( - x => styleable.Classes.CollectionChanged += x, - x => styleable.Classes.CollectionChanged -= x) - .TakeUntil(styleable.StyleDetach); + x => control.Classes.CollectionChanged += x, + x => control.Classes.CollectionChanged -= x) + .TakeUntil(removed); classesChanged.Select(_ => Unit.Default) .StartWith(Unit.Default) .Subscribe(_ => { - if (styleable.Classes.Count > 0) + if (control.Classes.Count > 0) { - Classes = "(" + string.Join(" ", styleable.Classes) + ")"; + Classes = "(" + string.Join(" ", control.Classes) + ")"; } else { @@ -55,8 +60,8 @@ namespace Avalonia.Diagnostics.ViewModels public string Classes { - get => _classes; - private set => RaiseAndSetIfChanged(ref _classes, value); + get { return _classes; } + private set { RaiseAndSetIfChanged(ref _classes, value); } } public IVisual Visual @@ -66,8 +71,8 @@ namespace Avalonia.Diagnostics.ViewModels public bool IsExpanded { - get => _isExpanded; - set => RaiseAndSetIfChanged(ref _isExpanded, value); + get { return _isExpanded; } + set { RaiseAndSetIfChanged(ref _isExpanded, value); } } public TreeNode Parent @@ -78,6 +83,7 @@ namespace Avalonia.Diagnostics.ViewModels public string Type { get; + private set; } } } diff --git a/src/Avalonia.Diagnostics/ViewModels/TreePageViewModel.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs similarity index 66% rename from src/Avalonia.Diagnostics/ViewModels/TreePageViewModel.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs index b2b1aaa723..26b5fe2524 100644 --- a/src/Avalonia.Diagnostics/ViewModels/TreePageViewModel.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/TreePageViewModel.cs @@ -1,24 +1,20 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - +using System; using Avalonia.Controls; using Avalonia.VisualTree; namespace Avalonia.Diagnostics.ViewModels { - internal class TreePageViewModel : ViewModelBase, IDevToolViewModel + internal class TreePageViewModel : ViewModelBase, IDisposable { private TreeNode _selected; private ControlDetailsViewModel _details; + private string _propertyFilter; - public TreePageViewModel(TreeNode[] nodes, string name) + public TreePageViewModel(TreeNode[] nodes) { Nodes = nodes; - Name = name; } - public string Name { get; } - public TreeNode[] Nodes { get; protected set; } public TreeNode SelectedNode @@ -26,9 +22,16 @@ namespace Avalonia.Diagnostics.ViewModels get => _selected; set { + if (Details != null) + { + _propertyFilter = Details.PropertyFilter; + } + if (RaiseAndSetIfChanged(ref _selected, value)) { - Details = value != null ? new ControlDetailsViewModel(value.Visual) : null; + Details = value != null ? + new ControlDetailsViewModel(value.Visual, _propertyFilter) : + null; } } } @@ -36,9 +39,19 @@ namespace Avalonia.Diagnostics.ViewModels public ControlDetailsViewModel Details { get => _details; - private set => RaiseAndSetIfChanged(ref _details, value); + private set + { + var oldValue = _details; + + if (RaiseAndSetIfChanged(ref _details, value)) + { + oldValue?.Dispose(); + } + } } + public void Dispose() => _details?.Dispose(); + public TreeNode FindNode(IControl control) { foreach (var node in Nodes) @@ -90,14 +103,16 @@ namespace Avalonia.Diagnostics.ViewModels { return node; } - - foreach (var child in node.Children) + else { - var result = FindNode(child, control); - - if (result != null) + foreach (var child in node.Children) { - return result; + var result = FindNode(child, control); + + if (result != null) + { + return result; + } } } diff --git a/src/Avalonia.Diagnostics/ViewModels/ViewModelBase.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs similarity index 54% rename from src/Avalonia.Diagnostics/ViewModels/ViewModelBase.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs index a6ff4dd853..66e9c34657 100644 --- a/src/Avalonia.Diagnostics/ViewModels/ViewModelBase.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/ViewModelBase.cs @@ -1,34 +1,42 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - +using System; using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; -using JetBrains.Annotations; namespace Avalonia.Diagnostics.ViewModels { internal class ViewModelBase : INotifyPropertyChanged { - public event PropertyChangedEventHandler PropertyChanged; + private PropertyChangedEventHandler _propertyChanged; + private List events = new List(); + + public event PropertyChangedEventHandler PropertyChanged + { + add { _propertyChanged += value; events.Add("added"); } + remove { _propertyChanged -= value; events.Add("removed"); } + } + + protected virtual void OnPropertyChanged(PropertyChangedEventArgs e) + { + } - [NotifyPropertyChangedInvocator] protected bool RaiseAndSetIfChanged(ref T field, T value, [CallerMemberName] string propertyName = null) { if (!EqualityComparer.Default.Equals(field, value)) { field = value; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + RaisePropertyChanged(propertyName); return true; } return false; } - [NotifyPropertyChangedInvocator] protected void RaisePropertyChanged([CallerMemberName] string propertyName = null) { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + var e = new PropertyChangedEventArgs(propertyName); + OnPropertyChanged(e); + _propertyChanged?.Invoke(this, e); } } } diff --git a/src/Avalonia.Diagnostics/ViewModels/VisualTreeNode.cs b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/VisualTreeNode.cs similarity index 85% rename from src/Avalonia.Diagnostics/ViewModels/VisualTreeNode.cs rename to src/Avalonia.Diagnostics/Diagnostics/ViewModels/VisualTreeNode.cs index 47ef91507a..8c070261d9 100644 --- a/src/Avalonia.Diagnostics/ViewModels/VisualTreeNode.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/ViewModels/VisualTreeNode.cs @@ -29,11 +29,12 @@ namespace Avalonia.Diagnostics.ViewModels } } - public bool IsInTemplate { get; } + public bool IsInTemplate { get; private set; } public static VisualTreeNode[] Create(object control) { - return control is IVisual visual ? new[] { new VisualTreeNode(visual, null) } : null; + var visual = control as IVisual; + return visual != null ? new[] { new VisualTreeNode(visual, null) } : null; } } } diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml new file mode 100644 index 0000000000..264a0de359 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + > + + + + + + + + + diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml.cs new file mode 100644 index 0000000000..ae70b59fde --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ConsoleView.xaml.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Diagnostics.ViewModels; +using Avalonia.Input; +using Avalonia.LogicalTree; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace Avalonia.Diagnostics.Views +{ + internal class ConsoleView : UserControl + { + private readonly ListBox _historyList; + private readonly TextBox _input; + + public ConsoleView() + { + this.InitializeComponent(); + _historyList = this.FindControl("historyList"); + ((ILogical)_historyList).LogicalChildren.CollectionChanged += HistoryChanged; + _input = this.FindControl("input"); + _input.KeyDown += InputKeyDown; + } + + public void FocusInput() => _input.Focus(); + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void HistoryChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems[0] is IControl control) + { + DispatcherTimer.RunOnce(control.BringIntoView, TimeSpan.Zero); + } + } + + private void InputKeyDown(object sender, KeyEventArgs e) + { + var vm = (ConsoleViewModel)DataContext; + + switch (e.Key) + { + case Key.Enter: + vm.Execute(); + e.Handled = true; + break; + case Key.Up: + vm.HistoryUp(); + _input.CaretIndex = _input.Text.Length; + e.Handled = true; + break; + case Key.Down: + vm.HistoryDown(); + _input.CaretIndex = _input.Text.Length; + e.Handled = true; + break; + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml new file mode 100644 index 0000000000..29ce26fcdf --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml.cs new file mode 100644 index 0000000000..c6bd5a18aa --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml.cs @@ -0,0 +1,18 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Avalonia.Diagnostics.Views +{ + internal class ControlDetailsView : UserControl + { + public ControlDetailsView() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/src/Avalonia.Diagnostics/Views/EventsView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/EventsPageView.xaml similarity index 92% rename from src/Avalonia.Diagnostics/Views/EventsView.xaml rename to src/Avalonia.Diagnostics/Diagnostics/Views/EventsPageView.xaml index 406dd433a2..b7f0860e70 100644 --- a/src/Avalonia.Diagnostics/Views/EventsView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/EventsPageView.xaml @@ -1,9 +1,10 @@  + xmlns:conv="clr-namespace:Avalonia.Diagnostics.Converters" + x:Class="Avalonia.Diagnostics.Views.EventsPageView"> - + ("events"); diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml new file mode 100644 index 0000000000..663722acba --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Hold Ctrl+Shift over a control to inspect. + + Focused: + + + Pointer Over: + + + + + diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs new file mode 100644 index 0000000000..783709e54b --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs @@ -0,0 +1,66 @@ +using Avalonia.Controls; +using Avalonia.Diagnostics.ViewModels; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace Avalonia.Diagnostics.Views +{ + internal class MainView : UserControl + { + private readonly ConsoleView _console; + private readonly GridSplitter _consoleSplitter; + private readonly Grid _rootGrid; + private readonly int _consoleRow; + private double _consoleHeight = -1; + + public MainView() + { + InitializeComponent(); + AddHandler(KeyDownEvent, PreviewKeyDown, RoutingStrategies.Tunnel); + _console = this.FindControl("console"); + _consoleSplitter = this.FindControl("consoleSplitter"); + _rootGrid = this.FindControl("rootGrid"); + _consoleRow = Grid.GetRow(_console); + } + + public void ToggleConsole() + { + var vm = (MainViewModel)DataContext; + + if (_consoleHeight == -1) + { + _consoleHeight = Bounds.Height / 3; + } + + vm.Console.ToggleVisibility(); + _consoleSplitter.IsVisible = vm.Console.IsVisible; + + if (vm.Console.IsVisible) + { + _rootGrid.RowDefinitions[_consoleRow].Height = new GridLength(_consoleHeight, GridUnitType.Pixel); + Dispatcher.UIThread.Post(() => _console.FocusInput(), DispatcherPriority.Background); + } + else + { + _consoleHeight = _rootGrid.RowDefinitions[_consoleRow].Height.Value; + _rootGrid.RowDefinitions[_consoleRow].Height = new GridLength(0, GridUnitType.Pixel); + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void PreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + ToggleConsole(); + e.Handled = true; + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml new file mode 100644 index 0000000000..3623e95597 --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs new file mode 100644 index 0000000000..3abdb5034a --- /dev/null +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Reactive.Linq; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Diagnostics.ViewModels; +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using Avalonia.VisualTree; + +namespace Avalonia.Diagnostics.Views +{ + internal class MainWindow : Window, IStyleHost + { + private TopLevel _root; + private IDisposable _keySubscription; + + public MainWindow() + { + InitializeComponent(); + + _keySubscription = InputManager.Instance.Process + .OfType() + .Subscribe(RawKeyDown); + } + + public TopLevel Root + { + get => _root; + set + { + if (_root != value) + { + _root = value; + DataContext = new MainViewModel(value); + } + } + } + + IStyleHost IStyleHost.StylingParent => null; + + protected override void OnClosed(EventArgs e) + { + base.OnClosed(e); + _keySubscription.Dispose(); + ((MainViewModel)DataContext)?.Dispose(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void RawKeyDown(RawKeyEventArgs e) + { + const RawInputModifiers modifiers = RawInputModifiers.Control | RawInputModifiers.Shift; + + if (e.Modifiers == modifiers) + { + var point = (Root as IInputRoot)?.MouseDevice?.GetPosition(Root) ?? default; + var control = Root.GetVisualsAt(point, x => (!(x is AdornerLayer) && x.IsVisible)) + .FirstOrDefault(); + + if (control != null) + { + var vm = (MainViewModel)DataContext; + vm.SelectControl((IControl)control); + } + } + } + } +} diff --git a/src/Avalonia.Diagnostics/Views/TreePageView.xaml b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml similarity index 60% rename from src/Avalonia.Diagnostics/Views/TreePageView.xaml rename to src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml index 2619fd744a..a1e6ca7d37 100644 --- a/src/Avalonia.Diagnostics/Views/TreePageView.xaml +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml @@ -1,26 +1,29 @@ - - + + - - + + - - + + diff --git a/src/Avalonia.Diagnostics/Views/TreePageView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs similarity index 90% rename from src/Avalonia.Diagnostics/Views/TreePageView.xaml.cs rename to src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs index 1326f718de..633d18ddd8 100644 --- a/src/Avalonia.Diagnostics/Views/TreePageView.xaml.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs @@ -1,6 +1,3 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - using Avalonia.Controls; using Avalonia.Controls.Generators; using Avalonia.Controls.Primitives; @@ -12,14 +9,14 @@ using Avalonia.Media; namespace Avalonia.Diagnostics.Views { - public class TreePageView : UserControl + internal class TreePageView : UserControl { private Control _adorner; private TreeView _tree; public TreePageView() { - InitializeComponent(); + this.InitializeComponent(); _tree.ItemContainerGenerator.Index.Materialized += TreeViewItemMaterialized; } @@ -39,7 +36,7 @@ namespace Avalonia.Diagnostics.Views _adorner = new Rectangle { Fill = new SolidColorBrush(0x80a0c5e8), - [AdornerLayer.AdornedElementProperty] = node.Visual + [AdornerLayer.AdornedElementProperty] = node.Visual, }; layer.Children.Add(_adorner); diff --git a/src/Avalonia.Diagnostics/VisualTreeDebug.cs b/src/Avalonia.Diagnostics/Diagnostics/VisualTreeDebug.cs similarity index 100% rename from src/Avalonia.Diagnostics/VisualTreeDebug.cs rename to src/Avalonia.Diagnostics/Diagnostics/VisualTreeDebug.cs diff --git a/src/Avalonia.Diagnostics/ViewModels/ControlDetailsViewModel.cs b/src/Avalonia.Diagnostics/ViewModels/ControlDetailsViewModel.cs deleted file mode 100644 index 4b832f7ce6..0000000000 --- a/src/Avalonia.Diagnostics/ViewModels/ControlDetailsViewModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System.Collections.Generic; -using System.Linq; -using Avalonia.VisualTree; - -namespace Avalonia.Diagnostics.ViewModels -{ - internal class ControlDetailsViewModel : ViewModelBase - { - public ControlDetailsViewModel(IVisual control) - { - if (control is AvaloniaObject avaloniaObject) - { - Properties = AvaloniaPropertyRegistry.Instance.GetRegistered(avaloniaObject) - .Select(x => new PropertyDetails(avaloniaObject, x)) - .OrderBy(x => x.IsAttached) - .ThenBy(x => x.Name); - } - } - - public IEnumerable Properties { get; } - } -} diff --git a/src/Avalonia.Diagnostics/ViewModels/DevToolsViewModel.cs b/src/Avalonia.Diagnostics/ViewModels/DevToolsViewModel.cs deleted file mode 100644 index 9f524a21eb..0000000000 --- a/src/Avalonia.Diagnostics/ViewModels/DevToolsViewModel.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.ObjectModel; -using System.Linq; -using Avalonia.Controls; -using Avalonia.Input; - -namespace Avalonia.Diagnostics.ViewModels -{ - internal class DevToolsViewModel : ViewModelBase - { - private IDevToolViewModel _selectedTool; - private string _focusedControl; - private string _pointerOverElement; - - public DevToolsViewModel(IControl root) - { - Tools = new ObservableCollection - { - new TreePageViewModel(LogicalTreeNode.Create(root), "Logical Tree"), - new TreePageViewModel(VisualTreeNode.Create(root), "Visual Tree"), - new EventsViewModel(root) - }; - - SelectedTool = Tools.First(); - - UpdateFocusedControl(); - - KeyboardDevice.Instance.PropertyChanged += (s, e) => - { - if (e.PropertyName == nameof(KeyboardDevice.Instance.FocusedElement)) - { - UpdateFocusedControl(); - } - }; - - root.GetObservable(TopLevel.PointerOverElementProperty) - .Subscribe(x => PointerOverElement = x?.GetType().Name); - } - - public IDevToolViewModel SelectedTool - { - get => _selectedTool; - set => RaiseAndSetIfChanged(ref _selectedTool, value); - } - - public ObservableCollection Tools { get; } - - public string FocusedControl - { - get => _focusedControl; - private set => RaiseAndSetIfChanged(ref _focusedControl, value); - } - - public string PointerOverElement - { - get => _pointerOverElement; - private set => RaiseAndSetIfChanged(ref _pointerOverElement, value); - } - - public void SelectControl(IControl control) - { - if (SelectedTool is TreePageViewModel tree) - { - tree.SelectControl(control); - } - } - - private void UpdateFocusedControl() - { - FocusedControl = KeyboardDevice.Instance.FocusedElement?.GetType().Name; - } - } -} diff --git a/src/Avalonia.Diagnostics/ViewModels/IDevToolViewModel.cs b/src/Avalonia.Diagnostics/ViewModels/IDevToolViewModel.cs deleted file mode 100644 index 0434230a63..0000000000 --- a/src/Avalonia.Diagnostics/ViewModels/IDevToolViewModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -namespace Avalonia.Diagnostics.ViewModels -{ - /// - /// View model interface for tool showing up in DevTools - /// - public interface IDevToolViewModel - { - /// - /// Name of a tool. - /// - string Name { get; } - } -} diff --git a/src/Avalonia.Diagnostics/ViewModels/PropertyDetails.cs b/src/Avalonia.Diagnostics/ViewModels/PropertyDetails.cs deleted file mode 100644 index 523be406c8..0000000000 --- a/src/Avalonia.Diagnostics/ViewModels/PropertyDetails.cs +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using Avalonia.Data; - -namespace Avalonia.Diagnostics.ViewModels -{ - internal class PropertyDetails : ViewModelBase - { - private object _value; - private string _priority; - private string _diagnostic; - - public PropertyDetails(AvaloniaObject o, AvaloniaProperty property) - { - Name = property.IsAttached ? - $"[{property.OwnerType.Name}.{property.Name}]" : - property.Name; - IsAttached = property.IsAttached; - - // TODO: Unsubscribe when view model is deactivated. - o.GetObservable(property).Subscribe(x => - { - var diagnostic = o.GetDiagnostic(property); - Value = diagnostic.Value ?? "(null)"; - Priority = (diagnostic.Priority != BindingPriority.Unset) ? - diagnostic.Priority.ToString() : - diagnostic.Property.Inherits ? - "Inherited" : - "Unset"; - Diagnostic = diagnostic.Diagnostic; - }); - } - - public string Name { get; } - - public bool IsAttached { get; } - - public string Priority - { - get => _priority; - private set => RaiseAndSetIfChanged(ref _priority, value); - } - - public string Diagnostic - { - get => _diagnostic; - private set => RaiseAndSetIfChanged(ref _diagnostic, value); - } - - public object Value - { - get => _value; - private set => RaiseAndSetIfChanged(ref _value, value); - } - } -} diff --git a/src/Avalonia.Diagnostics/Views/ControlDetailsView.cs b/src/Avalonia.Diagnostics/Views/ControlDetailsView.cs deleted file mode 100644 index fb867ab55e..0000000000 --- a/src/Avalonia.Diagnostics/Views/ControlDetailsView.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using Avalonia.Controls; -using Avalonia.Diagnostics.ViewModels; -using Avalonia.Media; - -namespace Avalonia.Diagnostics.Views -{ - internal class ControlDetailsView : UserControl - { - private static readonly StyledProperty ViewModelProperty = - AvaloniaProperty.Register(nameof(ViewModel)); - - private SimpleGrid _grid; - - public ControlDetailsView() - { - InitializeComponent(); - this.GetObservable(DataContextProperty) - .Subscribe(x => ViewModel = (ControlDetailsViewModel)x); - } - - public ControlDetailsViewModel ViewModel - { - get => GetValue(ViewModelProperty); - private set - { - SetValue(ViewModelProperty, value); - _grid[GridRepeater.ItemsProperty] = value?.Properties; - } - } - - private void InitializeComponent() - { - Func> pt = PropertyTemplate; - - Content = new ScrollViewer { Content = _grid = new SimpleGrid { [GridRepeater.TemplateProperty] = pt } }; - } - - private IEnumerable PropertyTemplate(object i) - { - var property = (PropertyDetails)i; - - var margin = new Thickness(2); - - yield return new TextBlock - { - Margin = margin, - Text = property.Name, - TextWrapping = TextWrapping.NoWrap, - [!ToolTip.TipProperty] = property.GetObservable(nameof(property.Diagnostic)).ToBinding() - }; - - yield return new TextBlock - { - Margin = margin, - TextWrapping = TextWrapping.NoWrap, - [!TextBlock.TextProperty] = property.GetObservable(nameof(property.Value)) - .Select(v => v?.ToString()) - .ToBinding() - }; - - yield return new TextBlock - { - Margin = margin, - TextWrapping = TextWrapping.NoWrap, - [!TextBlock.TextProperty] = property.GetObservable((nameof(property.Priority))).ToBinding() - }; - } - } -} diff --git a/src/Avalonia.Diagnostics/Views/GridRepeater.cs b/src/Avalonia.Diagnostics/Views/GridRepeater.cs deleted file mode 100644 index b0ff26c7b6..0000000000 --- a/src/Avalonia.Diagnostics/Views/GridRepeater.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using Avalonia.Controls; - -namespace Avalonia.Diagnostics.Views -{ - internal static class GridRepeater - { - public static readonly AttachedProperty ItemsProperty = - AvaloniaProperty.RegisterAttached("Items", typeof(GridRepeater)); - - public static readonly AttachedProperty>> TemplateProperty = - AvaloniaProperty.RegisterAttached>>("Template", - typeof(GridRepeater)); - - static GridRepeater() - { - ItemsProperty.Changed.Subscribe(ItemsChanged); - } - - private static void ItemsChanged(AvaloniaPropertyChangedEventArgs e) - { - var grid = (SimpleGrid)e.Sender; - var items = (IEnumerable)e.NewValue; - var template = grid.GetValue(TemplateProperty); - - grid.Children.Clear(); - - if (items != null) - { - int count = 0; - int cols = 3; - - foreach (var item in items) - { - foreach (var control in template(item)) - { - grid.Children.Add(control); - SimpleGrid.SetColumn(control, count % cols); - SimpleGrid.SetRow(control, count / cols); - ++count; - } - } - } - } - } -} diff --git a/src/Avalonia.Diagnostics/Views/PropertyChangedExtensions.cs b/src/Avalonia.Diagnostics/Views/PropertyChangedExtensions.cs deleted file mode 100644 index 24b2f29463..0000000000 --- a/src/Avalonia.Diagnostics/Views/PropertyChangedExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.ComponentModel; -using System.Reactive.Linq; -using System.Reflection; - -namespace Avalonia.Diagnostics.Views -{ - internal static class PropertyChangedExtensions - { - public static IObservable GetObservable(this INotifyPropertyChanged source, string propertyName) - { - Contract.Requires(source != null); - Contract.Requires(propertyName != null); - - var property = source.GetType().GetTypeInfo().GetDeclaredProperty(propertyName); - - if (property == null) - { - throw new ArgumentException($"Property '{propertyName}' not found on '{source}."); - } - - return Observable.FromEventPattern( - e => source.PropertyChanged += e, - e => source.PropertyChanged -= e) - .Where(e => e.EventArgs.PropertyName == propertyName) - .Select(_ => (T)property.GetValue(source)) - .StartWith((T)property.GetValue(source)); - } - } -} diff --git a/src/Avalonia.Diagnostics/Views/SimpleGrid.cs b/src/Avalonia.Diagnostics/Views/SimpleGrid.cs deleted file mode 100644 index 4fc77666e1..0000000000 --- a/src/Avalonia.Diagnostics/Views/SimpleGrid.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System.Collections.Generic; -using Avalonia.Controls; - -namespace Avalonia.Diagnostics.Views -{ - /// - /// A simple grid control that lays out columns with a equal width and rows to their desired - /// size. - /// - /// - /// This is used in the devtools because our performance sucks. - /// - public class SimpleGrid : Panel - { - private readonly List _columnWidths = new List(); - private readonly List _rowHeights = new List(); - private double _totalWidth; - private double _totalHeight; - - /// - /// Defines the Column attached property. - /// - public static readonly AttachedProperty ColumnProperty = - AvaloniaProperty.RegisterAttached("Column"); - - /// - /// Defines the Row attached property. - /// - public static readonly AttachedProperty RowProperty = - AvaloniaProperty.RegisterAttached("Row"); - - /// - /// Gets the value of the Column attached property for a control. - /// - /// The control. - /// The control's column. - public static int GetColumn(IControl control) - { - return control.GetValue(ColumnProperty); - } - - /// - /// Gets the value of the Row attached property for a control. - /// - /// The control. - /// The control's row. - public static int GetRow(IControl control) - { - return control.GetValue(RowProperty); - } - - /// - /// Sets the value of the Column attached property for a control. - /// - /// The control. - /// The column value. - public static void SetColumn(IControl control, int value) - { - control.SetValue(ColumnProperty, value); - } - - - /// - /// Sets the value of the Row attached property for a control. - /// - /// The control. - /// The row value. - public static void SetRow(IControl control, int value) - { - control.SetValue(RowProperty, value); - } - - protected override Size MeasureOverride(Size availableSize) - { - _columnWidths.Clear(); - _rowHeights.Clear(); - _totalWidth = 0; - _totalHeight = 0; - - foreach (var child in Children) - { - var column = GetColumn(child); - var row = GetRow(child); - - child.Measure(availableSize); - - var desired = child.DesiredSize; - UpdateCell(_columnWidths, column, desired.Width, ref _totalWidth); - UpdateCell(_rowHeights, row, desired.Height, ref _totalHeight); - } - - return new Size(_totalWidth, _totalHeight); - } - - protected override Size ArrangeOverride(Size finalSize) - { - var columnWidth = finalSize.Width / _columnWidths.Count; - - foreach (var child in Children) - { - var column = GetColumn(child); - var row = GetRow(child); - var rect = new Rect(column * columnWidth, GetRowTop(row), columnWidth, _rowHeights[row]); - child.Arrange(rect); - } - - return new Size(finalSize.Width, _totalHeight); - } - - private double UpdateCell(IList cells, int cell, double value, ref double total) - { - while (cells.Count < cell + 1) - { - cells.Add(0); - } - - var existing = cells[cell]; - - if (value > existing) - { - cells[cell] = value; - total += value - existing; - return value; - } - else - { - return existing; - } - } - - private double GetRowTop(int row) - { - var result = 0.0; - - for (var i = 0; i < row; ++i) - { - result += _rowHeights[i]; - } - - return result; - } - } -} diff --git a/src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs b/src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs index ef467aa5a3..3e52707c8e 100644 --- a/src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs +++ b/src/Avalonia.Dialogs/AboutAvaloniaDialog.xaml.cs @@ -29,7 +29,7 @@ namespace Avalonia.Dialogs using (Process process = Process.Start(new ProcessStartInfo { FileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? url : "open", - Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? $"-e {url}" : "", + Arguments = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? $"{url}" : "", CreateNoWindow = true, UseShellExecute = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) })); diff --git a/src/Avalonia.Dialogs/Avalonia.Dialogs.csproj b/src/Avalonia.Dialogs/Avalonia.Dialogs.csproj index cb86e271a6..f54ddf32be 100644 --- a/src/Avalonia.Dialogs/Avalonia.Dialogs.csproj +++ b/src/Avalonia.Dialogs/Avalonia.Dialogs.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Avalonia.Input/GestureRecognizers/GestureRecognizerCollection.cs b/src/Avalonia.Input/GestureRecognizers/GestureRecognizerCollection.cs index 91b224e65a..112abb1a4e 100644 --- a/src/Avalonia.Input/GestureRecognizers/GestureRecognizerCollection.cs +++ b/src/Avalonia.Input/GestureRecognizers/GestureRecognizerCollection.cs @@ -111,9 +111,7 @@ namespace Avalonia.Input.GestureRecognizers _pointerGrabs.Remove(e.Pointer); foreach (var r in _recognizers) { - if(e.Handled) - break; - r.PointerCaptureLost(e); + r.PointerCaptureLost(e.Pointer); } } @@ -121,6 +119,11 @@ namespace Avalonia.Input.GestureRecognizers { pointer.Capture(_inputElement); _pointerGrabs[pointer] = recognizer; + foreach (var r in _recognizers) + { + if (r != recognizer) + r.PointerCaptureLost(pointer); + } } } diff --git a/src/Avalonia.Input/GestureRecognizers/IGestureRecognizer.cs b/src/Avalonia.Input/GestureRecognizers/IGestureRecognizer.cs index b8ba9e529c..c1d9ae5304 100644 --- a/src/Avalonia.Input/GestureRecognizers/IGestureRecognizer.cs +++ b/src/Avalonia.Input/GestureRecognizers/IGestureRecognizer.cs @@ -6,7 +6,7 @@ namespace Avalonia.Input.GestureRecognizers void PointerPressed(PointerPressedEventArgs e); void PointerReleased(PointerReleasedEventArgs e); void PointerMoved(PointerEventArgs e); - void PointerCaptureLost(PointerCaptureLostEventArgs e); + void PointerCaptureLost(IPointer pointer); } public interface IGestureRecognizerActionsDispatcher diff --git a/src/Avalonia.Input/GestureRecognizers/ScrollGestureRecognizer.cs b/src/Avalonia.Input/GestureRecognizers/ScrollGestureRecognizer.cs index 4f3c7c0bba..e022401c8e 100644 --- a/src/Avalonia.Input/GestureRecognizers/ScrollGestureRecognizer.cs +++ b/src/Avalonia.Input/GestureRecognizers/ScrollGestureRecognizer.cs @@ -116,9 +116,9 @@ namespace Avalonia.Input.GestureRecognizers } } - public void PointerCaptureLost(PointerCaptureLostEventArgs e) + public void PointerCaptureLost(IPointer pointer) { - if (e.Pointer == _tracking) EndGesture(); + if (pointer == _tracking) EndGesture(); } void EndGesture() @@ -148,6 +148,7 @@ namespace Avalonia.Input.GestureRecognizers EndGesture(); else { + _tracking = null; var savedGestureId = _gestureId; var st = Stopwatch.StartNew(); var lastTime = TimeSpan.Zero; diff --git a/src/Avalonia.Input/IInputElement.cs b/src/Avalonia.Input/IInputElement.cs index 9247fb48a9..aa5e0dd8ec 100644 --- a/src/Avalonia.Input/IInputElement.cs +++ b/src/Avalonia.Input/IInputElement.cs @@ -63,7 +63,7 @@ namespace Avalonia.Input event EventHandler PointerReleased; /// - /// Occurs when the mouse wheen is scrolled over the control. + /// Occurs when the mouse wheel is scrolled over the control. /// event EventHandler PointerWheelChanged; diff --git a/src/Avalonia.Input/KeyGesture.cs b/src/Avalonia.Input/KeyGesture.cs index 490c31bef9..32236fe382 100644 --- a/src/Avalonia.Input/KeyGesture.cs +++ b/src/Avalonia.Input/KeyGesture.cs @@ -1,9 +1,11 @@ -// Copyright (c) The Avalonia Project. All rights reserved. +// Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.InteropServices; +using System.Text; namespace Avalonia.Input { @@ -108,19 +110,43 @@ namespace Avalonia.Input public override string ToString() { - var parts = new List(); + var s = new StringBuilder(); - foreach (var flag in Enum.GetValues(typeof(KeyModifiers)).Cast()) + static void Plus(StringBuilder s) { - if (KeyModifiers.HasFlag(flag) && flag != KeyModifiers.None) + if (s.Length > 0) { - parts.Add(flag.ToString()); + s.Append("+"); } } - parts.Add(Key.ToString()); + if (KeyModifiers.HasFlagCustom(KeyModifiers.Control)) + { + s.Append("Ctrl"); + } + + if (KeyModifiers.HasFlagCustom(KeyModifiers.Shift)) + { + Plus(s); + s.Append("Shift"); + } + + if (KeyModifiers.HasFlagCustom(KeyModifiers.Alt)) + { + Plus(s); + s.Append("Alt"); + } + + if (KeyModifiers.HasFlagCustom(KeyModifiers.Meta)) + { + Plus(s); + s.Append("Cmd"); + } + + Plus(s); + s.Append(Key); - return string.Join(" + ", parts); + return s.ToString(); } public bool Matches(KeyEventArgs keyEvent) => ResolveNumPadOperationKey(keyEvent.Key) == Key && keyEvent.KeyModifiers == KeyModifiers; @@ -141,7 +167,9 @@ namespace Avalonia.Input return KeyModifiers.Control; } - if (modifier.Equals("cmd".AsSpan(), StringComparison.OrdinalIgnoreCase)) + if (modifier.Equals("cmd".AsSpan(), StringComparison.OrdinalIgnoreCase) || + modifier.Equals("win".AsSpan(), StringComparison.OrdinalIgnoreCase) || + modifier.Equals("⌘".AsSpan(), StringComparison.OrdinalIgnoreCase)) { return KeyModifiers.Meta; } diff --git a/src/Avalonia.Input/NavigationDirection.cs b/src/Avalonia.Input/NavigationDirection.cs index 406890b767..0e7e991260 100644 --- a/src/Avalonia.Input/NavigationDirection.cs +++ b/src/Avalonia.Input/NavigationDirection.cs @@ -100,12 +100,12 @@ namespace Avalonia.Input /// public static NavigationDirection? ToNavigationDirection( this Key key, - InputModifiers modifiers = InputModifiers.None) + KeyModifiers modifiers = KeyModifiers.None) { switch (key) { case Key.Tab: - return (modifiers & InputModifiers.Shift) != 0 ? + return (modifiers & KeyModifiers.Shift) != 0 ? NavigationDirection.Next : NavigationDirection.Previous; case Key.Up: return NavigationDirection.Up; diff --git a/src/Avalonia.Interactivity/Avalonia.Interactivity.csproj b/src/Avalonia.Interactivity/Avalonia.Interactivity.csproj index 66f1e8cc26..730ca2bd6e 100644 --- a/src/Avalonia.Interactivity/Avalonia.Interactivity.csproj +++ b/src/Avalonia.Interactivity/Avalonia.Interactivity.csproj @@ -1,6 +1,8 @@  netstandard2.0 + Enable + CS8600;CS8602;CS8603 @@ -9,6 +11,4 @@ - - - + \ No newline at end of file diff --git a/src/Avalonia.Interactivity/EventRoute.cs b/src/Avalonia.Interactivity/EventRoute.cs new file mode 100644 index 0000000000..85ba33d7ba --- /dev/null +++ b/src/Avalonia.Interactivity/EventRoute.cs @@ -0,0 +1,200 @@ +using System; +using Avalonia.Collections.Pooled; + +namespace Avalonia.Interactivity +{ + /// + /// Holds the route for a routed event and supports raising an event on that route. + /// + public class EventRoute : IDisposable + { + private readonly RoutedEvent _event; + private PooledList? _route; + + /// + /// Initializes a new instance of the class. + /// + /// The routed event to be raised. + public EventRoute(RoutedEvent e) + { + e = e ?? throw new ArgumentNullException(nameof(e)); + + _event = e; + _route = null; + } + + /// + /// Gets a value indicating whether the route has any handlers. + /// + public bool HasHandlers => _route?.Count > 0; + + /// + /// Adds a handler to the route. + /// + /// The target on which the event should be raised. + /// The handler for the event. + /// The routing strategies to listen to. + /// + /// If true the handler will be raised even when the routed event is marked as handled. + /// + /// + /// An optional adapter which if supplied, will be called with + /// and the parameters for the event. This adapter can be used to avoid calling + /// `DynamicInvoke` on the handler. + /// + public void Add( + IInteractive target, + Delegate handler, + RoutingStrategies routes, + bool handledEventsToo = false, + Action? adapter = null) + { + target = target ?? throw new ArgumentNullException(nameof(target)); + handler = handler ?? throw new ArgumentNullException(nameof(handler)); + + _route ??= new PooledList(16); + _route.Add(new RouteItem(target, handler, adapter, routes, handledEventsToo)); + } + + /// + /// Adds a class handler to the route. + /// + /// The target on which the event should be raised. + public void AddClassHandler(IInteractive target) + { + target = target ?? throw new ArgumentNullException(nameof(target)); + + _route ??= new PooledList(16); + _route.Add(new RouteItem(target, null, null, 0, false)); + } + + /// + /// Raises an event along the route. + /// + /// The event source. + /// The event args. + public void RaiseEvent(IInteractive source, RoutedEventArgs e) + { + source = source ?? throw new ArgumentNullException(nameof(source)); + e = e ?? throw new ArgumentNullException(nameof(e)); + + e.Source = source; + + if (_event.RoutingStrategies == RoutingStrategies.Direct) + { + e.Route = RoutingStrategies.Direct; + RaiseEventImpl(e); + _event.InvokeRouteFinished(e); + } + else + { + if (_event.RoutingStrategies.HasFlagCustom(RoutingStrategies.Tunnel)) + { + e.Route = RoutingStrategies.Tunnel; + RaiseEventImpl(e); + _event.InvokeRouteFinished(e); + } + + if (_event.RoutingStrategies.HasFlagCustom(RoutingStrategies.Bubble)) + { + e.Route = RoutingStrategies.Bubble; + RaiseEventImpl(e); + _event.InvokeRouteFinished(e); + } + } + } + + /// + /// Disposes of the event route. + /// + public void Dispose() + { + _route?.Dispose(); + _route = null; + } + + private void RaiseEventImpl(RoutedEventArgs e) + { + if (_route is null) + { + return; + } + + if (e.Source is null) + { + throw new ArgumentException("Event source may not be null", nameof(e)); + } + + IInteractive? lastTarget = null; + var start = 0; + var end = _route.Count; + var step = 1; + + if (e.Route == RoutingStrategies.Tunnel) + { + start = end - 1; + step = end = -1; + } + + for (var i = start; i != end; i += step) + { + var entry = _route[i]; + + // If we've got to a new control then call any RoutedEvent.Raised listeners. + if (entry.Target != lastTarget) + { + if (!e.Handled) + { + _event.InvokeRaised(entry.Target, e); + } + + // If this is a direct event and we've already raised events then we're finished. + if (e.Route == RoutingStrategies.Direct && lastTarget is object) + { + return; + } + + lastTarget = entry.Target; + } + + // Raise the event handler. + if (entry.Handler is object && + entry.Routes.HasFlagCustom(e.Route) && + (!e.Handled || entry.HandledEventsToo)) + { + if (entry.Adapter is object) + { + entry.Adapter(entry.Handler, entry.Target, e); + } + else + { + entry.Handler.DynamicInvoke(entry.Target, e); + } + } + } + } + + private readonly struct RouteItem + { + public RouteItem( + IInteractive target, + Delegate? handler, + Action? adapter, + RoutingStrategies routes, + bool handledEventsToo) + { + Target = target; + Handler = handler; + Adapter = adapter; + Routes = routes; + HandledEventsToo = handledEventsToo; + } + + public IInteractive Target { get; } + public Delegate? Handler { get; } + public Action? Adapter { get; } + public RoutingStrategies Routes { get; } + public bool HandledEventsToo { get; } + } + } +} diff --git a/src/Avalonia.Interactivity/EventSubscription.cs b/src/Avalonia.Interactivity/EventSubscription.cs deleted file mode 100644 index e8fb1bfaf1..0000000000 --- a/src/Avalonia.Interactivity/EventSubscription.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; - -namespace Avalonia.Interactivity -{ - internal delegate void HandlerInvokeSignature(Delegate baseHandler, object sender, RoutedEventArgs args); - - internal class EventSubscription - { - public HandlerInvokeSignature InvokeAdapter { get; set; } - - public Delegate Handler { get; set; } - - public RoutingStrategies Routes { get; set; } - - public bool AlsoIfHandled { get; set; } - } -} diff --git a/src/Avalonia.Interactivity/IInteractive.cs b/src/Avalonia.Interactivity/IInteractive.cs index 47046b58e2..51aee78988 100644 --- a/src/Avalonia.Interactivity/IInteractive.cs +++ b/src/Avalonia.Interactivity/IInteractive.cs @@ -13,7 +13,7 @@ namespace Avalonia.Interactivity /// /// Gets the interactive parent of the object for bubbling and tunneling events. /// - IInteractive InteractiveParent { get; } + IInteractive? InteractiveParent { get; } /// /// Adds a handler for the specified routed event. @@ -23,7 +23,7 @@ namespace Avalonia.Interactivity /// The routing strategies to listen to. /// Whether handled events should also be listened for. /// A disposable that terminates the event subscription. - IDisposable AddHandler( + void AddHandler( RoutedEvent routedEvent, Delegate handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, @@ -38,7 +38,7 @@ namespace Avalonia.Interactivity /// The routing strategies to listen to. /// Whether handled events should also be listened for. /// A disposable that terminates the event subscription. - IDisposable AddHandler( + void AddHandler( RoutedEvent routedEvent, EventHandler handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, @@ -60,6 +60,13 @@ namespace Avalonia.Interactivity void RemoveHandler(RoutedEvent routedEvent, EventHandler handler) where TEventArgs : RoutedEventArgs; + /// + /// Adds the object's handlers for a routed event to an event route. + /// + /// The event. + /// The event route. + void AddToEventRoute(RoutedEvent routedEvent, EventRoute route); + /// /// Raises a routed event. /// diff --git a/src/Avalonia.Interactivity/Interactive.cs b/src/Avalonia.Interactivity/Interactive.cs index 27ece25183..321ecbc516 100644 --- a/src/Avalonia.Interactivity/Interactive.cs +++ b/src/Avalonia.Interactivity/Interactive.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; using Avalonia.Layout; using Avalonia.VisualTree; @@ -15,16 +13,12 @@ namespace Avalonia.Interactivity /// public class Interactive : Layoutable, IInteractive { - private Dictionary> _eventHandlers; - - private static readonly Dictionary s_invokeHandlerCache = new Dictionary(); + private Dictionary>? _eventHandlers; /// /// Gets the interactive parent of the object for bubbling and tunneling events. /// - IInteractive IInteractive.InteractiveParent => ((IVisual)this).VisualParent as IInteractive; - - private Dictionary> EventHandlers => _eventHandlers ?? (_eventHandlers = new Dictionary>()); + IInteractive? IInteractive.InteractiveParent => ((IVisual)this).VisualParent as IInteractive; /// /// Adds a handler for the specified routed event. @@ -33,24 +27,18 @@ namespace Avalonia.Interactivity /// The handler. /// The routing strategies to listen to. /// Whether handled events should also be listened for. - /// A disposable that terminates the event subscription. - public IDisposable AddHandler( + public void AddHandler( RoutedEvent routedEvent, Delegate handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, bool handledEventsToo = false) { - Contract.Requires(routedEvent != null); - Contract.Requires(handler != null); + routedEvent = routedEvent ?? throw new ArgumentNullException(nameof(routedEvent)); + handler = handler ?? throw new ArgumentNullException(nameof(handler)); - var subscription = new EventSubscription - { - Handler = handler, - Routes = routes, - AlsoIfHandled = handledEventsToo, - }; + var subscription = new EventSubscription(handler, routes, handledEventsToo); - return AddEventSubscription(routedEvent, subscription); + AddEventSubscription(routedEvent, subscription); } /// @@ -61,44 +49,26 @@ namespace Avalonia.Interactivity /// The handler. /// The routing strategies to listen to. /// Whether handled events should also be listened for. - /// A disposable that terminates the event subscription. - public IDisposable AddHandler( + public void AddHandler( RoutedEvent routedEvent, EventHandler handler, RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, bool handledEventsToo = false) where TEventArgs : RoutedEventArgs { - Contract.Requires(routedEvent != null); - Contract.Requires(handler != null); + routedEvent = routedEvent ?? throw new ArgumentNullException(nameof(routedEvent)); + handler = handler ?? throw new ArgumentNullException(nameof(handler)); - // EventHandler delegate is not covariant, this forces us to create small wrapper - // that will cast our type erased instance and invoke it. - Type eventArgsType = routedEvent.EventArgsType; - - if (!s_invokeHandlerCache.TryGetValue(eventArgsType, out var invokeAdapter)) + static void InvokeAdapter(Delegate baseHandler, object sender, RoutedEventArgs args) { - void InvokeAdapter(Delegate baseHandler, object sender, RoutedEventArgs args) - { - var typedHandler = (EventHandler)baseHandler; - var typedArgs = (TEventArgs)args; + var typedHandler = (EventHandler)baseHandler; + var typedArgs = (TEventArgs)args; - typedHandler(sender, typedArgs); - } - - invokeAdapter = InvokeAdapter; - - s_invokeHandlerCache.Add(eventArgsType, invokeAdapter); + typedHandler(sender, typedArgs); } - var subscription = new EventSubscription - { - InvokeAdapter = invokeAdapter, - Handler = handler, - Routes = routes, - AlsoIfHandled = handledEventsToo, - }; + var subscription = new EventSubscription(handler, routes, handledEventsToo, (baseHandler, sender, args) => InvokeAdapter(baseHandler, sender, args)); - return AddEventSubscription(routedEvent, subscription); + AddEventSubscription(routedEvent, subscription); } /// @@ -108,14 +78,19 @@ namespace Avalonia.Interactivity /// The handler. public void RemoveHandler(RoutedEvent routedEvent, Delegate handler) { - Contract.Requires(routedEvent != null); - Contract.Requires(handler != null); + routedEvent = routedEvent ?? throw new ArgumentNullException(nameof(routedEvent)); + handler = handler ?? throw new ArgumentNullException(nameof(handler)); - List subscriptions = null; - - if (_eventHandlers?.TryGetValue(routedEvent, out subscriptions) == true) + if (_eventHandlers is object && + _eventHandlers.TryGetValue(routedEvent, out var subscriptions)) { - subscriptions.RemoveAll(x => x.Handler == handler); + for (var i = subscriptions.Count - 1; i >= 0; i--) + { + if (subscriptions[i].Handler == handler) + { + subscriptions.RemoveAt(i); + } + } } } @@ -137,191 +112,115 @@ namespace Avalonia.Interactivity /// The event args. public void RaiseEvent(RoutedEventArgs e) { - Contract.Requires(e != null); - - e.Source = e.Source ?? this; - - if (e.RoutedEvent.RoutingStrategies == RoutingStrategies.Direct) - { - e.Route = RoutingStrategies.Direct; - RaiseEventImpl(e); - e.RoutedEvent.InvokeRouteFinished(e); - } + e = e ?? throw new ArgumentNullException(nameof(e)); - if ((e.RoutedEvent.RoutingStrategies & RoutingStrategies.Tunnel) != 0) + if (e.RoutedEvent == null) { - TunnelEvent(e); - e.RoutedEvent.InvokeRouteFinished(e); + throw new ArgumentException("Cannot raise an event whose RoutedEvent is null."); } - if ((e.RoutedEvent.RoutingStrategies & RoutingStrategies.Bubble) != 0) - { - BubbleEvent(e); - e.RoutedEvent.InvokeRouteFinished(e); - } + using var route = BuildEventRoute(e.RoutedEvent); + route.RaiseEvent(this, e); } - /// - /// Bubbles an event. - /// - /// The event args. - private void BubbleEvent(RoutedEventArgs e) - { - Contract.Requires(e != null); - - e.Route = RoutingStrategies.Bubble; - - var traverser = HierarchyTraverser.Create(e); - - traverser.Traverse(this); - } - - /// - /// Tunnels an event. - /// - /// The event args. - private void TunnelEvent(RoutedEventArgs e) + void IInteractive.AddToEventRoute(RoutedEvent routedEvent, EventRoute route) { - Contract.Requires(e != null); - - e.Route = RoutingStrategies.Tunnel; - - var traverser = HierarchyTraverser.Create(e); + routedEvent = routedEvent ?? throw new ArgumentNullException(nameof(routedEvent)); + route = route ?? throw new ArgumentNullException(nameof(route)); - traverser.Traverse(this); + if (_eventHandlers != null && + _eventHandlers.TryGetValue(routedEvent, out var subscriptions)) + { + foreach (var sub in subscriptions) + { + route.Add(this, sub.Handler, sub.Routes, sub.HandledEventsToo, sub.InvokeAdapter); + } + } } /// - /// Carries out the actual invocation of an event on this object. + /// Builds an event route for a routed event. /// - /// The event args. - private void RaiseEventImpl(RoutedEventArgs e) + /// The routed event. + /// An describing the route. + /// + /// Usually, calling is sufficent to raise a routed + /// event, however there are situations in which the construction of the event args is expensive + /// and should be avoided if there are no handlers for an event. In these cases you can call + /// this method to build the event route and check the + /// property to see if there are any handlers registered on the route. If there are, call + /// to raise the event. + /// + protected EventRoute BuildEventRoute(RoutedEvent e) { - Contract.Requires(e != null); + e = e ?? throw new ArgumentNullException(nameof(e)); - e.RoutedEvent.InvokeRaised(this, e); + var result = new EventRoute(e); + var hasClassHandlers = e.HasRaisedSubscriptions; - List subscriptions = null; - - if (_eventHandlers?.TryGetValue(e.RoutedEvent, out subscriptions) == true) + if (e.RoutingStrategies.HasFlagCustom(RoutingStrategies.Bubble) || + e.RoutingStrategies.HasFlagCustom(RoutingStrategies.Tunnel)) { - foreach (var sub in subscriptions.ToList()) - { - bool correctRoute = (e.Route & sub.Routes) != 0; - bool notFinished = !e.Handled || sub.AlsoIfHandled; + IInteractive? element = this; - if (correctRoute && notFinished) + while (element != null) + { + if (hasClassHandlers) { - if (sub.InvokeAdapter != null) - { - sub.InvokeAdapter(sub.Handler, this, e); - } - else - { - sub.Handler.DynamicInvoke(this, e); - } + result.AddClassHandler(element); } + + element.AddToEventRoute(e, result); + element = element.InteractiveParent; } } - } - - private List GetEventSubscriptions(RoutedEvent routedEvent) - { - if (!EventHandlers.TryGetValue(routedEvent, out var subscriptions)) + else { - subscriptions = new List(); - EventHandlers.Add(routedEvent, subscriptions); - } - - return subscriptions; - } - - private IDisposable AddEventSubscription(RoutedEvent routedEvent, EventSubscription subscription) - { - List subscriptions = GetEventSubscriptions(routedEvent); - - subscriptions.Add(subscription); - - return new UnsubscribeDisposable(subscriptions, subscription); - } - - private sealed class UnsubscribeDisposable : IDisposable - { - private readonly List _subscriptions; - private readonly EventSubscription _subscription; + if (hasClassHandlers) + { + result.AddClassHandler(this); + } - public UnsubscribeDisposable(List subscriptions, EventSubscription subscription) - { - _subscriptions = subscriptions; - _subscription = subscription; + ((IInteractive)this).AddToEventRoute(e, result); } - public void Dispose() - { - _subscriptions.Remove(_subscription); - } + return result; } - private interface ITraverse + private void AddEventSubscription(RoutedEvent routedEvent, EventSubscription subscription) { - void Execute(IInteractive target, RoutedEventArgs e); - } + _eventHandlers ??= new Dictionary>(); - private struct NopTraverse : ITraverse - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute(IInteractive target, RoutedEventArgs e) + if (!_eventHandlers.TryGetValue(routedEvent, out var subscriptions)) { + subscriptions = new List(); + _eventHandlers.Add(routedEvent, subscriptions); } - } - private struct RaiseEventTraverse : ITraverse - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Execute(IInteractive target, RoutedEventArgs e) - { - ((Interactive)target).RaiseEventImpl(e); - } + subscriptions.Add(subscription); } - /// - /// Traverses interactive hierarchy allowing for raising events. - /// - /// Called before parent is traversed. - /// Called after parent has been traversed. - private struct HierarchyTraverser - where TPreTraverse : struct, ITraverse - where TPostTraverse : struct, ITraverse + private readonly struct EventSubscription { - private TPreTraverse _preTraverse; - private TPostTraverse _postTraverse; - private readonly RoutedEventArgs _args; - - private HierarchyTraverser(TPreTraverse preTraverse, TPostTraverse postTraverse, RoutedEventArgs args) - { - _preTraverse = preTraverse; - _postTraverse = postTraverse; - _args = args; - } - - public static HierarchyTraverser Create(RoutedEventArgs args) - { - return new HierarchyTraverser(new TPreTraverse(), new TPostTraverse(), args); + public EventSubscription( + Delegate handler, + RoutingStrategies routes, + bool handledEventsToo, + Action? invokeAdapter = null) + { + Handler = handler; + Routes = routes; + HandledEventsToo = handledEventsToo; + InvokeAdapter = invokeAdapter; } - public void Traverse(IInteractive target) - { - _preTraverse.Execute(target, _args); + public Action? InvokeAdapter { get; } - IInteractive parent = target.InteractiveParent; + public Delegate Handler { get; } - if (parent != null) - { - Traverse(parent); - } + public RoutingStrategies Routes { get; } - _postTraverse.Execute(target, _args); - } + public bool HandledEventsToo { get; } } } } diff --git a/src/Avalonia.Interactivity/InteractiveExtensions.cs b/src/Avalonia.Interactivity/InteractiveExtensions.cs index 07e4029240..e6c93e26b2 100644 --- a/src/Avalonia.Interactivity/InteractiveExtensions.cs +++ b/src/Avalonia.Interactivity/InteractiveExtensions.cs @@ -2,8 +2,7 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections.Generic; -using System.Linq; +using System.Reactive.Disposables; using System.Reactive.Linq; namespace Avalonia.Interactivity @@ -13,6 +12,28 @@ namespace Avalonia.Interactivity /// public static class InteractiveExtensions { + /// + /// Adds a handler for the specified routed event and returns a disposable that can terminate the event subscription. + /// + /// The type of the event's args. + /// Target for adding given event handler. + /// The routed event. + /// The handler. + /// The routing strategies to listen to. + /// Whether handled events should also be listened for. + /// A disposable that terminates the event subscription. + public static IDisposable AddDisposableHandler(this IInteractive o, RoutedEvent routedEvent, + EventHandler handler, + RoutingStrategies routes = RoutingStrategies.Direct | RoutingStrategies.Bubble, + bool handledEventsToo = false) where TEventArgs : RoutedEventArgs + { + o.AddHandler(routedEvent, handler, routes, handledEventsToo); + + return Disposable.Create( + (instance: o, handler, routedEvent), + state => state.instance.RemoveHandler(state.routedEvent, state.handler)); + } + /// /// Gets an observable for a . /// @@ -30,7 +51,10 @@ namespace Avalonia.Interactivity bool handledEventsToo = false) where TEventArgs : RoutedEventArgs { - return Observable.Create(x => o.AddHandler( + o = o ?? throw new ArgumentNullException(nameof(o)); + routedEvent = routedEvent ?? throw new ArgumentNullException(nameof(routedEvent)); + + return Observable.Create(x => o.AddDisposableHandler( routedEvent, (_, e) => x.OnNext(e), routes, diff --git a/src/Avalonia.Interactivity/RoutedEvent.cs b/src/Avalonia.Interactivity/RoutedEvent.cs index 55d9e61d87..e515efd3b4 100644 --- a/src/Avalonia.Interactivity/RoutedEvent.cs +++ b/src/Avalonia.Interactivity/RoutedEvent.cs @@ -25,10 +25,14 @@ namespace Avalonia.Interactivity Type eventArgsType, Type ownerType) { - Contract.Requires(name != null); - Contract.Requires(eventArgsType != null); - Contract.Requires(ownerType != null); - Contract.Requires(typeof(RoutedEventArgs).IsAssignableFrom(eventArgsType)); + name = name ?? throw new ArgumentNullException(nameof(name)); + eventArgsType = eventArgsType ?? throw new ArgumentNullException(nameof(name)); + ownerType = ownerType ?? throw new ArgumentNullException(nameof(name)); + + if (!typeof(RoutedEventArgs).IsAssignableFrom(eventArgsType)) + { + throw new InvalidCastException("eventArgsType must be derived from RoutedEventArgs."); + } EventArgsType = eventArgsType; Name = name; @@ -44,6 +48,8 @@ namespace Avalonia.Interactivity public RoutingStrategies RoutingStrategies { get; } + public bool HasRaisedSubscriptions => _raised.HasObservers; + public IObservable<(object, RoutedEventArgs)> Raised => _raised; public IObservable RouteFinished => _routeFinished; @@ -52,7 +58,7 @@ namespace Avalonia.Interactivity RoutingStrategies routingStrategy) where TEventArgs : RoutedEventArgs { - Contract.Requires(name != null); + name = name ?? throw new ArgumentNullException(nameof(name)); var routedEvent = new RoutedEvent(name, routingStrategy, typeof(TOwner)); RoutedEventRegistry.Instance.Register(typeof(TOwner), routedEvent); @@ -65,7 +71,7 @@ namespace Avalonia.Interactivity Type ownerType) where TEventArgs : RoutedEventArgs { - Contract.Requires(name != null); + name = name ?? throw new ArgumentNullException(nameof(name)); var routedEvent = new RoutedEvent(name, routingStrategy, ownerType); RoutedEventRegistry.Instance.Register(ownerType, routedEvent); @@ -108,8 +114,6 @@ namespace Avalonia.Interactivity public RoutedEvent(string name, RoutingStrategies routingStrategies, Type ownerType) : base(name, routingStrategies, typeof(TEventArgs), ownerType) { - Contract.Requires(name != null); - Contract.Requires(ownerType != null); } [Obsolete("Use overload taking Action.")] diff --git a/src/Avalonia.Interactivity/RoutedEventArgs.cs b/src/Avalonia.Interactivity/RoutedEventArgs.cs index 05bbf7b6a3..e00393322d 100644 --- a/src/Avalonia.Interactivity/RoutedEventArgs.cs +++ b/src/Avalonia.Interactivity/RoutedEventArgs.cs @@ -11,12 +11,12 @@ namespace Avalonia.Interactivity { } - public RoutedEventArgs(RoutedEvent routedEvent) + public RoutedEventArgs(RoutedEvent? routedEvent) { RoutedEvent = routedEvent; } - public RoutedEventArgs(RoutedEvent routedEvent, IInteractive source) + public RoutedEventArgs(RoutedEvent? routedEvent, IInteractive? source) { RoutedEvent = routedEvent; Source = source; @@ -24,10 +24,10 @@ namespace Avalonia.Interactivity public bool Handled { get; set; } - public RoutedEvent RoutedEvent { get; set; } + public RoutedEvent? RoutedEvent { get; set; } public RoutingStrategies Route { get; set; } - public IInteractive Source { get; set; } + public IInteractive? Source { get; set; } } } diff --git a/src/Avalonia.Interactivity/RoutedEventRegistry.cs b/src/Avalonia.Interactivity/RoutedEventRegistry.cs index 34c970a806..0111b115e6 100644 --- a/src/Avalonia.Interactivity/RoutedEventRegistry.cs +++ b/src/Avalonia.Interactivity/RoutedEventRegistry.cs @@ -32,8 +32,8 @@ namespace Avalonia.Interactivity /// public void Register(Type type, RoutedEvent @event) { - Contract.Requires(type != null); - Contract.Requires(@event != null); + type = type ?? throw new ArgumentNullException(nameof(type)); + @event = @event ?? throw new ArgumentNullException(nameof(@event)); if (!_registeredRoutedEvents.TryGetValue(type, out var list)) { @@ -66,7 +66,7 @@ namespace Avalonia.Interactivity /// All routed events registered with the provided type. public IReadOnlyList GetRegistered(Type type) { - Contract.Requires(type != null); + type = type ?? throw new ArgumentNullException(nameof(type)); if (_registeredRoutedEvents.TryGetValue(type, out var events)) { diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index c757576017..73ec81ce57 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -68,9 +68,9 @@ namespace Avalonia.Native _native.CanResize = value; } - public void SetSystemDecorations(bool enabled) + public void SetSystemDecorations(Controls.SystemDecorations enabled) { - _native.HasDecorations = enabled; + _native.HasDecorations = (Interop.SystemDecorations)enabled; } public void SetTitleBarColor (Avalonia.Media.Color color) diff --git a/src/Avalonia.Native/WindowImplBase.cs b/src/Avalonia.Native/WindowImplBase.cs index 64cea1c430..331f083e8b 100644 --- a/src/Avalonia.Native/WindowImplBase.cs +++ b/src/Avalonia.Native/WindowImplBase.cs @@ -162,9 +162,12 @@ namespace Avalonia.Native void IAvnWindowBaseEvents.Resized(AvnSize size) { - var s = new Size(size.Width, size.Height); - _parent._savedLogicalSize = s; - _parent.Resized?.Invoke(s); + if (_parent._native != null) + { + var s = new Size(size.Width, size.Height); + _parent._savedLogicalSize = s; + _parent.Resized?.Invoke(s); + } } void IAvnWindowBaseEvents.PositionChanged(AvnPoint position) @@ -317,7 +320,7 @@ namespace Avalonia.Native _native.SetTopMost(value); } - public double Scaling => _native.GetScaling(); + public double Scaling => _native?.GetScaling() ?? 1; public Action Deactivated { get; set; } public Action Activated { get; set; } diff --git a/src/Avalonia.Styling/Avalonia.Styling.csproj b/src/Avalonia.Styling/Avalonia.Styling.csproj index a396cee35f..b4f6c2c942 100644 --- a/src/Avalonia.Styling/Avalonia.Styling.csproj +++ b/src/Avalonia.Styling/Avalonia.Styling.csproj @@ -8,5 +8,4 @@ - diff --git a/src/Avalonia.Styling/Controls/NameScopeLocator.cs b/src/Avalonia.Styling/Controls/NameScopeLocator.cs index 354ed33657..51f4c5c4eb 100644 --- a/src/Avalonia.Styling/Controls/NameScopeLocator.cs +++ b/src/Avalonia.Styling/Controls/NameScopeLocator.cs @@ -1,11 +1,5 @@ using System; -using System.Linq; using System.Reactive.Disposables; -using System.Reactive.Threading.Tasks; -using System.Reflection; -using System.Threading.Tasks; -using Avalonia.LogicalTree; -using Avalonia.Reactive; using Avalonia.Utilities; namespace Avalonia.Controls diff --git a/src/Avalonia.Styling/StyledElement.cs b/src/Avalonia.Styling/StyledElement.cs index 5768a25435..84df9d4b94 100644 --- a/src/Avalonia.Styling/StyledElement.cs +++ b/src/Avalonia.Styling/StyledElement.cs @@ -3,8 +3,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Subjects; using Avalonia.Animation; using Avalonia.Collections; using Avalonia.Controls; @@ -14,6 +12,8 @@ using Avalonia.Logging; using Avalonia.LogicalTree; using Avalonia.Styling; +#nullable enable + namespace Avalonia { /// @@ -29,8 +29,8 @@ namespace Avalonia /// /// Defines the property. /// - public static readonly StyledProperty DataContextProperty = - AvaloniaProperty.Register( + public static readonly StyledProperty DataContextProperty = + AvaloniaProperty.Register( nameof(DataContext), inherits: true, notifying: DataContextNotifying); @@ -38,35 +38,36 @@ namespace Avalonia /// /// Defines the property. /// - public static readonly DirectProperty NameProperty = - AvaloniaProperty.RegisterDirect(nameof(Name), o => o.Name, (o, v) => o.Name = v); + public static readonly DirectProperty NameProperty = + AvaloniaProperty.RegisterDirect(nameof(Name), o => o.Name, (o, v) => o.Name = v); /// /// Defines the property. /// - public static readonly DirectProperty ParentProperty = - AvaloniaProperty.RegisterDirect(nameof(Parent), o => o.Parent); + public static readonly DirectProperty ParentProperty = + AvaloniaProperty.RegisterDirect(nameof(Parent), o => o.Parent); /// /// Defines the property. /// - public static readonly DirectProperty TemplatedParentProperty = - AvaloniaProperty.RegisterDirect( + public static readonly DirectProperty TemplatedParentProperty = + AvaloniaProperty.RegisterDirect( nameof(TemplatedParent), o => o.TemplatedParent, (o ,v) => o.TemplatedParent = v); private int _initCount; - private string _name; + private string? _name; private readonly Classes _classes = new Classes(); - private ILogicalRoot _logicalRoot; - private IAvaloniaList _logicalChildren; - private IResourceDictionary _resources; - private Styles _styles; + private ILogicalRoot? _logicalRoot; + private IAvaloniaList? _logicalChildren; + private IResourceDictionary? _resources; + private Styles? _styles; private bool _styled; - private Subject _styleDetach = new Subject(); - private ITemplatedControl _templatedParent; + private List? _appliedStyles; + private ITemplatedControl? _templatedParent; private bool _dataContextUpdating; + private bool _notifyingResourcesChanged; /// /// Initializes static members of the class. @@ -87,12 +88,12 @@ namespace Avalonia /// /// Raised when the styled element is attached to a rooted logical tree. /// - public event EventHandler AttachedToLogicalTree; + public event EventHandler? AttachedToLogicalTree; /// /// Raised when the styled element is detached from a rooted logical tree. /// - public event EventHandler DetachedFromLogicalTree; + public event EventHandler? DetachedFromLogicalTree; /// /// Occurs when the property changes. @@ -101,7 +102,7 @@ namespace Avalonia /// This event will be raised when the property has changed and /// all subscribers to that change have been notified. /// - public event EventHandler DataContextChanged; + public event EventHandler? DataContextChanged; /// /// Occurs when the styled element has finished initialization. @@ -114,12 +115,12 @@ namespace Avalonia /// is not used, it is called when the styled element is attached /// to the visual tree. /// - public event EventHandler Initialized; + public event EventHandler? Initialized; /// /// Occurs when a resource in this styled element or a parent styled element has changed. /// - public event EventHandler ResourcesChanged; + public event EventHandler? ResourcesChanged; /// /// Gets or sets the name of the styled element. @@ -128,20 +129,12 @@ namespace Avalonia /// An element's name is used to uniquely identify an element within the element's name /// scope. Once the element is added to a logical tree, its name cannot be changed. /// - public string Name + public string? Name { - get - { - return _name; - } + get => _name; set { - if (String.IsNullOrWhiteSpace(value)) - { - throw new InvalidOperationException("Cannot set Name to null or empty string."); - } - if (_styled) { throw new InvalidOperationException("Cannot set Name : styled element already styled."); @@ -189,7 +182,7 @@ namespace Avalonia /// The data context is an inherited property that specifies the default object that will /// be used for data binding. /// - public object DataContext + public object? DataContext { get { return GetValue(DataContextProperty); } set { SetValue(DataContextProperty, value); } @@ -214,28 +207,15 @@ namespace Avalonia /// public Styles Styles { - get { return _styles ?? (Styles = new Styles()); } - set + get { - Contract.Requires(value != null); - - if (_styles != value) + if (_styles is null) { - if (_styles != null) - { - (_styles as ISetResourceParent)?.SetParent(null); - _styles.ResourcesChanged -= ThisResourcesChanged; - } - - _styles = value; - - if (value is ISetResourceParent setParent && setParent.ResourceParent == null) - { - setParent.SetParent(this); - } - + _styles = new Styles(this); _styles.ResourcesChanged += ThisResourcesChanged; } + + return _styles; } } @@ -247,12 +227,13 @@ namespace Avalonia get => _resources ?? (Resources = new ResourceDictionary()); set { - Contract.Requires(value != null); + value = value ?? throw new ArgumentNullException(nameof(value)); var hadResources = false; if (_resources != null) { + (_resources as ISetResourceParent)?.SetParent(null); hadResources = _resources.Count > 0; _resources.ResourcesChanged -= ThisResourcesChanged; } @@ -260,9 +241,14 @@ namespace Avalonia _resources = value; _resources.ResourcesChanged += ThisResourcesChanged; + if (value is ISetResourceParent setParent && setParent.ResourceParent == null) + { + setParent.SetParent(this); + } + if (hadResources || _resources.Count > 0) { - ((ILogical)this).NotifyResourcesChanged(new ResourcesChangedEventArgs()); + NotifyResourcesChanged(new ResourcesChangedEventArgs()); } } } @@ -270,7 +256,7 @@ namespace Avalonia /// /// Gets the styled element whose lookless template this styled element is part of. /// - public ITemplatedControl TemplatedParent + public ITemplatedControl? TemplatedParent { get => _templatedParent; internal set => SetAndRaise(TemplatedParentProperty, ref _templatedParent, value); @@ -312,12 +298,12 @@ namespace Avalonia /// /// Gets the styled element's logical parent. /// - public IStyledElement Parent { get; private set; } + public IStyledElement? Parent { get; private set; } /// /// Gets the styled element's logical parent. /// - ILogical ILogical.LogicalParent => Parent; + ILogical? ILogical.LogicalParent => Parent; /// /// Gets the styled element's logical children. @@ -328,7 +314,7 @@ namespace Avalonia bool IResourceProvider.HasResources => _resources?.Count > 0 || Styles.HasResources; /// - IResourceNode IResourceNode.ResourceParent => ((IStyleHost)this).StylingParent as IResourceNode; + IResourceNode? IResourceNode.ResourceParent => ((IStyleHost)this).StylingParent as IResourceNode; /// IAvaloniaReadOnlyList IStyleable.Classes => Classes; @@ -344,14 +330,11 @@ namespace Avalonia /// Type IStyleable.StyleKey => GetType(); - /// - IObservable IStyleable.StyleDetach => _styleDetach; - /// bool IStyleHost.IsStylesInitialized => _styles != null; /// - IStyleHost IStyleHost.StylingParent => (IStyleHost)InheritanceParent; + IStyleHost? IStyleHost.StylingParent => (IStyleHost)InheritanceParent; /// public virtual void BeginInit() @@ -397,23 +380,20 @@ namespace Avalonia /// void ILogical.NotifyAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) { - this.OnAttachedToLogicalTreeCore(e); + OnAttachedToLogicalTreeCore(e); } /// void ILogical.NotifyDetachedFromLogicalTree(LogicalTreeAttachmentEventArgs e) { - this.OnDetachedFromLogicalTreeCore(e); + OnDetachedFromLogicalTreeCore(e); } /// - void ILogical.NotifyResourcesChanged(ResourcesChangedEventArgs e) - { - ResourcesChanged?.Invoke(this, new ResourcesChangedEventArgs()); - } + void ILogical.NotifyResourcesChanged(ResourcesChangedEventArgs e) => NotifyResourcesChanged(e); /// - bool IResourceProvider.TryGetResource(object key, out object value) + bool IResourceProvider.TryGetResource(object key, out object? value) { value = null; return (_resources?.TryGetResource(key, out value) ?? false) || @@ -424,7 +404,7 @@ namespace Avalonia /// Sets the styled element's logical parent. /// /// The parent. - void ISetLogicalParent.SetParent(ILogical parent) + void ISetLogicalParent.SetParent(ILogical? parent) { var old = Parent; @@ -440,7 +420,7 @@ namespace Avalonia InheritanceParent = parent as AvaloniaObject; } - Parent = (IStyledElement)parent; + Parent = (IStyledElement?)parent; if (_logicalRoot != null) { @@ -456,7 +436,8 @@ namespace Avalonia { Parent.ResourcesChanged += ThisResourcesChanged; } - ((ILogical)this).NotifyResourcesChanged(new ResourcesChangedEventArgs()); + + NotifyResourcesChanged(new ResourcesChangedEventArgs()); if (Parent is ILogicalRoot || Parent?.IsAttachedToLogicalTree == true || this is ILogicalRoot) { @@ -470,12 +451,13 @@ namespace Avalonia var e = new LogicalTreeAttachmentEventArgs(newRoot, this, parent); OnAttachedToLogicalTreeCore(e); } - +#nullable disable RaisePropertyChanged( ParentProperty, new Optional(old), new BindingValue(Parent), BindingPriority.LocalValue); +#nullable enable } } @@ -488,6 +470,16 @@ namespace Avalonia InheritanceParent = parent; } + void IStyleable.StyleApplied(IStyleInstance instance) + { + instance = instance ?? throw new ArgumentNullException(nameof(instance)); + + _appliedStyles ??= new List(); + _appliedStyles.Add(instance); + } + + void IStyleable.DetachStyles() => DetachStyles(); + protected virtual void LogicalChildrenCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -573,9 +565,12 @@ namespace Avalonia element._dataContextUpdating = true; element.OnDataContextBeginUpdate(); - foreach (var child in element.LogicalChildren) + var logicalChildren = element.LogicalChildren; + var logicalChildrenCount = logicalChildren.Count; + + for (var i = 0; i < logicalChildrenCount; i++) { - if (child is StyledElement s && + if (element.LogicalChildren[i] is StyledElement s && s.InheritanceParent == element && !s.IsSet(DataContextProperty)) { @@ -594,7 +589,7 @@ namespace Avalonia } } - private static ILogicalRoot FindLogicalRoot(IStyleHost e) + private static ILogicalRoot? FindLogicalRoot(IStyleHost e) { while (e != null) { @@ -646,9 +641,15 @@ namespace Avalonia AttachedToLogicalTree?.Invoke(this, e); } - foreach (var child in LogicalChildren.OfType()) + var logicalChildren = LogicalChildren; + var logicalChildrenCount = logicalChildren.Count; + + for (var i = 0; i < logicalChildrenCount; i++) { - child.OnAttachedToLogicalTreeCore(e); + if (logicalChildren[i] is StyledElement child) + { + child.OnAttachedToLogicalTreeCore(e); + } } } @@ -657,17 +658,23 @@ namespace Avalonia if (_logicalRoot != null) { _logicalRoot = null; - _styleDetach.OnNext(this); + DetachStyles(); OnDetachedFromLogicalTree(e); DetachedFromLogicalTree?.Invoke(this, e); - foreach (var child in LogicalChildren.OfType()) + var logicalChildren = LogicalChildren; + var logicalChildrenCount = logicalChildren.Count; + + for (var i = 0; i < logicalChildrenCount; i++) { - child.OnDetachedFromLogicalTreeCore(e); + if (logicalChildren[i] is StyledElement child) + { + child.OnDetachedFromLogicalTreeCore(e); + } } #if DEBUG - if (((INotifyCollectionChangedDebug)_classes).GetCollectionChangedSubscribers()?.Length > 0) + if (((INotifyCollectionChangedDebug)Classes).GetCollectionChangedSubscribers()?.Length > 0) { Logger.TryGet(LogEventLevel.Warning)?.Log( LogArea.Control, @@ -695,6 +702,19 @@ namespace Avalonia } } + private void DetachStyles() + { + if (_appliedStyles is object) + { + foreach (var i in _appliedStyles) + { + i.Dispose(); + } + + _appliedStyles.Clear(); + } + } + private void ClearLogicalParent(IEnumerable children) { foreach (var i in children) @@ -706,9 +726,29 @@ namespace Avalonia } } + private void NotifyResourcesChanged(ResourcesChangedEventArgs e) + { + if (_notifyingResourcesChanged) + { + return; + } + + try + { + _notifyingResourcesChanged = true; + (_resources as ISetResourceParent)?.ParentResourcesChanged(e); + (_styles as ISetResourceParent)?.ParentResourcesChanged(e); + ResourcesChanged?.Invoke(this, new ResourcesChangedEventArgs()); + } + finally + { + _notifyingResourcesChanged = false; + } + } + private void ThisResourcesChanged(object sender, ResourcesChangedEventArgs e) { - ((ILogical)this).NotifyResourcesChanged(e); + NotifyResourcesChanged(e); } } } diff --git a/src/Avalonia.Styling/Styling/ActivatedObservable.cs b/src/Avalonia.Styling/Styling/ActivatedObservable.cs deleted file mode 100644 index 5b2774943a..0000000000 --- a/src/Avalonia.Styling/Styling/ActivatedObservable.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; - -namespace Avalonia.Styling -{ - /// - /// An observable which is switched on or off according to an activator observable. - /// - /// - /// An has two inputs: an activator observable and a - /// observable which produces the activated value. When the activator - /// produces true, the will produce the current activated - /// value. When the activator produces false it will produce - /// . - /// - internal class ActivatedObservable : ActivatedValue, IDescription - { - private IDisposable _sourceSubscription; - - /// - /// Initializes a new instance of the class. - /// - /// The activator. - /// An observable that produces the activated value. - /// The binding description. - public ActivatedObservable( - IObservable activator, - IObservable source, - string description) - : base(activator, AvaloniaProperty.UnsetValue, description) - { - Contract.Requires(source != null); - - Source = source; - } - - /// - /// Gets an observable which produces the . - /// - public IObservable Source { get; } - - protected override ActivatorListener CreateListener() => new ValueListener(this); - - protected override void Deinitialize() - { - base.Deinitialize(); - _sourceSubscription.Dispose(); - _sourceSubscription = null; - } - - protected override void Initialize() - { - base.Initialize(); - _sourceSubscription = Source.Subscribe((ValueListener)Listener); - } - - protected virtual void NotifyValue(object value) - { - Value = value; - } - - private class ValueListener : ActivatorListener, IObserver - { - public ValueListener(ActivatedObservable parent) - : base(parent) - { - } - protected new ActivatedObservable Parent => (ActivatedObservable)base.Parent; - - void IObserver.OnCompleted() => Parent.CompletedReceived(); - void IObserver.OnError(Exception error) => Parent.ErrorReceived(error); - void IObserver.OnNext(object value) => Parent.NotifyValue(value); - } - } -} diff --git a/src/Avalonia.Styling/Styling/ActivatedSubject.cs b/src/Avalonia.Styling/Styling/ActivatedSubject.cs deleted file mode 100644 index a8446c4bfb..0000000000 --- a/src/Avalonia.Styling/Styling/ActivatedSubject.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Reactive.Subjects; - -namespace Avalonia.Styling -{ - /// - /// A subject which is switched on or off according to an activator observable. - /// - /// - /// An extends to - /// be an . When the object is active then values - /// received via will be passed to the source subject. - /// - internal class ActivatedSubject : ActivatedObservable, ISubject, IDescription - { - private bool _completed; - private object _pushValue; - - /// - /// Initializes a new instance of the class. - /// - /// The activator. - /// An observable that produces the activated value. - /// The binding description. - public ActivatedSubject( - IObservable activator, - ISubject source, - string description) - : base(activator, source, description) - { - } - - /// - /// Gets the underlying subject. - /// - public new ISubject Source - { - get { return (ISubject)base.Source; } - } - - public void OnCompleted() - { - Source.OnCompleted(); - } - - public void OnError(Exception error) - { - Source.OnError(error); - } - - public void OnNext(object value) - { - _pushValue = value; - - if (IsActive == true && !_completed) - { - Source.OnNext(_pushValue); - } - } - - protected override void ActiveChanged(bool active) - { - bool first = !IsActive.HasValue; - - base.ActiveChanged(active); - - if (!first) - { - Source.OnNext(active ? _pushValue : AvaloniaProperty.UnsetValue); - } - } - - protected override void CompletedReceived() - { - base.CompletedReceived(); - - if (!_completed) - { - Source.OnCompleted(); - _completed = true; - } - } - - protected override void ErrorReceived(Exception error) - { - base.ErrorReceived(error); - - if (!_completed) - { - Source.OnError(error); - _completed = true; - } - } - - private void ActivatorCompleted() - { - _completed = true; - Source.OnCompleted(); - } - - private void ActivatorError(Exception e) - { - _completed = true; - Source.OnError(e); - } - } -} diff --git a/src/Avalonia.Styling/Styling/ActivatedValue.cs b/src/Avalonia.Styling/Styling/ActivatedValue.cs deleted file mode 100644 index 908d89b751..0000000000 --- a/src/Avalonia.Styling/Styling/ActivatedValue.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using Avalonia.Reactive; - -namespace Avalonia.Styling -{ - /// - /// An value which is switched on or off according to an activator observable. - /// - /// - /// An has two inputs: an activator observable and an - /// . When the activator produces true, the - /// will produce the current value. When the activator - /// produces false it will produce . - /// - internal class ActivatedValue : LightweightObservableBase, IDescription - { - private static readonly object NotSent = new object(); - private IDisposable _activatorSubscription; - private object _value; - private object _last = NotSent; - - /// - /// Initializes a new instance of the class. - /// - /// The activator. - /// The activated value. - /// The binding description. - public ActivatedValue( - IObservable activator, - object value, - string description) - { - Contract.Requires(activator != null); - - Activator = activator; - Value = value; - Description = description; - Listener = CreateListener(); - } - - /// - /// Gets the activator observable. - /// - public IObservable Activator { get; } - - /// - /// Gets a description of the binding. - /// - public string Description { get; } - - /// - /// Gets a value indicating whether the activator is active. - /// - public bool? IsActive { get; private set; } - - /// - /// Gets the value that will be produced when is true. - /// - public object Value - { - get => _value; - protected set - { - _value = value; - PublishValue(); - } - } - - protected ActivatorListener Listener { get; } - - protected virtual void ActiveChanged(bool active) - { - IsActive = active; - PublishValue(); - } - - protected virtual void CompletedReceived() => PublishCompleted(); - - protected virtual ActivatorListener CreateListener() => new ActivatorListener(this); - - protected override void Deinitialize() - { - _activatorSubscription.Dispose(); - _activatorSubscription = null; - } - - protected virtual void ErrorReceived(Exception error) => PublishError(error); - - protected override void Initialize() - { - _activatorSubscription = Activator.Subscribe(Listener); - } - - protected override void Subscribed(IObserver observer, bool first) - { - if (IsActive == true && !first) - { - observer.OnNext(Value); - } - } - - private void PublishValue() - { - if (IsActive.HasValue) - { - var v = IsActive.Value ? Value : AvaloniaProperty.UnsetValue; - - if (!Equals(v, _last)) - { - PublishNext(v); - _last = v; - } - } - } - - protected class ActivatorListener : IObserver - { - public ActivatorListener(ActivatedValue parent) - { - Parent = parent; - } - - protected ActivatedValue Parent { get; } - - void IObserver.OnCompleted() => Parent.CompletedReceived(); - void IObserver.OnError(Exception error) => Parent.ErrorReceived(error); - void IObserver.OnNext(bool value) => Parent.ActiveChanged(value); - } - } -} diff --git a/src/Avalonia.Styling/Styling/Activators/AndActivator.cs b/src/Avalonia.Styling/Styling/Activators/AndActivator.cs new file mode 100644 index 0000000000..0e1e3b565b --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/AndActivator.cs @@ -0,0 +1,71 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Avalonia.Styling.Activators +{ + /// + /// An aggregate which is active when all of its inputs are + /// active. + /// + internal class AndActivator : StyleActivatorBase, IStyleActivatorSink + { + private List? _sources; + private ulong _flags; + private ulong _mask; + + public int Count => _sources?.Count ?? 0; + + public void Add(IStyleActivator activator) + { + _sources ??= new List(); + _sources.Add(activator); + } + + void IStyleActivatorSink.OnNext(bool value, int tag) + { + if (value) + { + _flags |= 1ul << tag; + } + else + { + _flags &= ~(1ul << tag); + } + + if (_mask != 0) + { + PublishNext(_flags == _mask); + } + } + + protected override void Initialize() + { + if (_sources is object) + { + var i = 0; + + foreach (var source in _sources) + { + source.Subscribe(this, i++); + } + + _mask = (1ul << Count) - 1; + PublishNext(_flags == _mask); + } + } + + protected override void Deinitialize() + { + if (_sources is object) + { + foreach (var source in _sources) + { + source.Unsubscribe(this); + } + } + + _mask = 0; + } + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/AndActivatorBuilder.cs b/src/Avalonia.Styling/Styling/Activators/AndActivatorBuilder.cs new file mode 100644 index 0000000000..b831c330f8 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/AndActivatorBuilder.cs @@ -0,0 +1,43 @@ +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// Builds an . + /// + /// + /// When ANDing style activators, if there is more than one input then creates an instance of + /// . If there is only one input, returns the input directly. + /// + internal struct AndActivatorBuilder + { + private IStyleActivator? _single; + private AndActivator? _multiple; + + public void Add(IStyleActivator? activator) + { + if (activator == null) + { + return; + } + + if (_single is null && _multiple is null) + { + _single = activator; + } + else + { + if (_multiple is null) + { + _multiple = new AndActivator(); + _multiple.Add(_single!); + _single = null; + } + + _multiple.Add(activator); + } + } + + public IStyleActivator Get() => _single ?? _multiple!; + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/IStyleActivator.cs b/src/Avalonia.Styling/Styling/Activators/IStyleActivator.cs new file mode 100644 index 0000000000..479100ed8a --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/IStyleActivator.cs @@ -0,0 +1,33 @@ +#nullable enable + +using System; + +namespace Avalonia.Styling.Activators +{ + /// + /// Defines a style activator. + /// + /// + /// A style activator is very similar to an `IObservable{bool}` but is optimized for the + /// particular use-case of activating a style according to a selector. It differs from + /// an observable in two major ways: + /// + /// - Can only have a single subscription + /// - The subscription can have a tag associated with it, allowing a subscriber to index + /// into a list of subscriptions without having to allocate additional objects. + /// + public interface IStyleActivator : IDisposable + { + /// + /// Subscribes to the activator. + /// + /// The listener. + /// An optional tag. + void Subscribe(IStyleActivatorSink sink, int tag = 0); + + /// + /// Unsubscribes from the activator. + /// + void Unsubscribe(IStyleActivatorSink sink); + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/IStyleActivatorSink.cs b/src/Avalonia.Styling/Styling/Activators/IStyleActivatorSink.cs new file mode 100644 index 0000000000..a1a6ef5c28 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/IStyleActivatorSink.cs @@ -0,0 +1,17 @@ +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// Receives notifications from an . + /// + public interface IStyleActivatorSink + { + /// + /// Called when the subscribed activator value changes. + /// + /// The new value. + /// The subscription tag. + void OnNext(bool value, int tag); + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/NotActivator.cs b/src/Avalonia.Styling/Styling/Activators/NotActivator.cs new file mode 100644 index 0000000000..1bb6ed3cd2 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/NotActivator.cs @@ -0,0 +1,16 @@ +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// An which inverts the state of an input activator. + /// + internal class NotActivator : StyleActivatorBase, IStyleActivatorSink + { + private readonly IStyleActivator _source; + public NotActivator(IStyleActivator source) => _source = source; + void IStyleActivatorSink.OnNext(bool value, int tag) => PublishNext(!value); + protected override void Initialize() => _source.Subscribe(this, 0); + protected override void Deinitialize() => _source.Unsubscribe(this); + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/OrActivator.cs b/src/Avalonia.Styling/Styling/Activators/OrActivator.cs new file mode 100644 index 0000000000..fcb7d71e60 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/OrActivator.cs @@ -0,0 +1,71 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Avalonia.Styling.Activators +{ + /// + /// An aggregate which is active when any of its inputs are + /// active. + /// + internal class OrActivator : StyleActivatorBase, IStyleActivatorSink + { + private List? _sources; + private ulong _flags; + private bool _initializing; + + public int Count => _sources?.Count ?? 0; + + public void Add(IStyleActivator activator) + { + _sources ??= new List(); + _sources.Add(activator); + } + + void IStyleActivatorSink.OnNext(bool value, int tag) + { + if (value) + { + _flags |= 1ul << tag; + } + else + { + _flags &= ~(1ul << tag); + } + + if (!_initializing) + { + PublishNext(_flags != 0); + } + } + + protected override void Initialize() + { + if (_sources is object) + { + var i = 0; + + _initializing = true; + + foreach (var source in _sources) + { + source.Subscribe(this, i++); + } + + _initializing = false; + PublishNext(_flags != 0); + } + } + + protected override void Deinitialize() + { + if (_sources is object) + { + foreach (var source in _sources) + { + source.Unsubscribe(this); + } + } + } + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/OrActivatorBuilder.cs b/src/Avalonia.Styling/Styling/Activators/OrActivatorBuilder.cs new file mode 100644 index 0000000000..4b3fbcfca5 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/OrActivatorBuilder.cs @@ -0,0 +1,45 @@ +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// Builds an . + /// + /// + /// When ORing style activators, if there is more than one input then creates an instance of + /// . If there is only one input, returns the input directly. + /// + internal struct OrActivatorBuilder + { + private IStyleActivator? _single; + private OrActivator? _multiple; + + public int Count => _multiple?.Count ?? (_single is object ? 1 : 0); + + public void Add(IStyleActivator? activator) + { + if (activator == null) + { + return; + } + + if (_single is null && _multiple is null) + { + _single = activator; + } + else + { + if (_multiple is null) + { + _multiple = new OrActivator(); + _multiple.Add(_single!); + _single = null; + } + + _multiple.Add(activator); + } + } + + public IStyleActivator Get() => _single ?? _multiple!; + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/PropertyEqualsActivator.cs b/src/Avalonia.Styling/Styling/Activators/PropertyEqualsActivator.cs new file mode 100644 index 0000000000..9e30e4fa14 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/PropertyEqualsActivator.cs @@ -0,0 +1,38 @@ +using System; + +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// An which listens to a property value on a control. + /// + internal class PropertyEqualsActivator : StyleActivatorBase, IObserver + { + private readonly IStyleable _control; + private readonly AvaloniaProperty _property; + private readonly object? _value; + private IDisposable? _subscription; + + public PropertyEqualsActivator( + IStyleable control, + AvaloniaProperty property, + object? value) + { + _control = control ?? throw new ArgumentNullException(nameof(control)); + _property = property ?? throw new ArgumentNullException(nameof(property)); + _value = value; + } + + protected override void Initialize() + { + _subscription = _control.GetObservable(_property).Subscribe(this); + } + + protected override void Deinitialize() => _subscription?.Dispose(); + + void IObserver.OnCompleted() { } + void IObserver.OnError(Exception error) { } + void IObserver.OnNext(object value) => PublishNext(Equals(value, _value)); + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/StyleActivatorBase.cs b/src/Avalonia.Styling/Styling/Activators/StyleActivatorBase.cs new file mode 100644 index 0000000000..578098b2b0 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/StyleActivatorBase.cs @@ -0,0 +1,58 @@ +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// Base class implementation of . + /// + internal abstract class StyleActivatorBase : IStyleActivator + { + private IStyleActivatorSink? _sink; + private int _tag; + private bool? _value; + + public void Subscribe(IStyleActivatorSink sink, int tag = 0) + { + if (_sink is null) + { + _sink = sink; + _tag = tag; + _value = null; + Initialize(); + } + else + { + throw new AvaloniaInternalException("Cannot subscribe to a StyleActivator more than once."); + } + } + + public void Unsubscribe(IStyleActivatorSink sink) + { + if (_sink != sink) + { + throw new AvaloniaInternalException("StyleActivatorSink is not subscribed."); + } + + _sink = null; + Deinitialize(); + } + + public void PublishNext(bool value) + { + if (_value != value) + { + _value = value; + _sink?.OnNext(value, _tag); + } + } + + public void Dispose() + { + _sink = null; + Deinitialize(); + } + + protected abstract void Initialize(); + protected abstract void Deinitialize(); + } +} diff --git a/src/Avalonia.Styling/Styling/Activators/StyleClassActivator.cs b/src/Avalonia.Styling/Styling/Activators/StyleClassActivator.cs new file mode 100644 index 0000000000..7906a29cb5 --- /dev/null +++ b/src/Avalonia.Styling/Styling/Activators/StyleClassActivator.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using Avalonia.Collections; + +#nullable enable + +namespace Avalonia.Styling.Activators +{ + /// + /// An which is active when a set of classes match those on a + /// control. + /// + internal sealed class StyleClassActivator : StyleActivatorBase + { + private readonly IList _match; + private readonly IAvaloniaReadOnlyList _classes; + private NotifyCollectionChangedEventHandler? _classesChangedHandler; + + public StyleClassActivator(IAvaloniaReadOnlyList classes, IList match) + { + _classes = classes; + _match = match; + } + + private NotifyCollectionChangedEventHandler ClassesChangedHandler => + _classesChangedHandler ??= ClassesChanged; + + public static bool AreClassesMatching(IReadOnlyList classes, IList toMatch) + { + int remainingMatches = toMatch.Count; + int classesCount = classes.Count; + + // Early bail out - we can't match if control does not have enough classes. + if (classesCount < remainingMatches) + { + return false; + } + + for (var i = 0; i < classesCount; i++) + { + var c = classes[i]; + + if (toMatch.Contains(c)) + { + --remainingMatches; + + // Already matched so we can skip checking other classes. + if (remainingMatches == 0) + { + break; + } + } + } + + return remainingMatches == 0; + } + + protected override void Initialize() + { + PublishNext(IsMatching()); + _classes.CollectionChanged += ClassesChangedHandler; + } + + protected override void Deinitialize() + { + _classes.CollectionChanged -= ClassesChangedHandler; + } + + private void ClassesChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action != NotifyCollectionChangedAction.Move) + { + PublishNext(IsMatching()); + } + } + + private bool IsMatching() => AreClassesMatching(_classes, _match); + } +} diff --git a/src/Avalonia.Styling/Styling/DescendentSelector.cs b/src/Avalonia.Styling/Styling/DescendentSelector.cs index a81908f23d..896197d183 100644 --- a/src/Avalonia.Styling/Styling/DescendentSelector.cs +++ b/src/Avalonia.Styling/Styling/DescendentSelector.cs @@ -2,24 +2,21 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections.Generic; using Avalonia.LogicalTree; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { internal class DescendantSelector : Selector { private readonly Selector _parent; - private string _selectorString; + private string? _selectorString; - public DescendantSelector(Selector parent) + public DescendantSelector(Selector? parent) { - if (parent == null) - { - throw new InvalidOperationException("Descendant selector must be preceeded by a selector."); - } - - _parent = parent; + _parent = parent ?? throw new InvalidOperationException("Descendant selector must be preceeded by a selector."); } /// @@ -29,7 +26,7 @@ namespace Avalonia.Styling public override bool InTemplate => _parent.InTemplate; /// - public override Type TargetType => null; + public override Type? TargetType => null; public override string ToString() { @@ -43,8 +40,8 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) { - ILogical c = (ILogical)control; - List> descendantMatches = new List>(); + var c = (ILogical)control; + var descendantMatches = new OrActivatorBuilder(); while (c != null) { @@ -67,7 +64,7 @@ namespace Avalonia.Styling if (descendantMatches.Count > 0) { - return new SelectorMatch(StyleActivator.Or(descendantMatches)); + return new SelectorMatch(descendantMatches.Get()); } else { @@ -75,6 +72,6 @@ namespace Avalonia.Styling } } - protected override Selector MovePrevious() => null; + protected override Selector? MovePrevious() => null; } } diff --git a/src/Avalonia.Styling/Styling/ISetter.cs b/src/Avalonia.Styling/Styling/ISetter.cs index da97638f07..3d945c6772 100644 --- a/src/Avalonia.Styling/Styling/ISetter.cs +++ b/src/Avalonia.Styling/Styling/ISetter.cs @@ -3,6 +3,8 @@ using System; +#nullable enable + namespace Avalonia.Styling { /// @@ -11,11 +13,15 @@ namespace Avalonia.Styling public interface ISetter { /// - /// Applies the setter to a control. + /// Instances a setter on a control. /// - /// The style that is being applied. - /// The control. - /// An optional activator. - IDisposable Apply(IStyle style, IStyleable control, IObservable activator); + /// The control. + /// An . + /// + /// This method should return an which can be used to apply + /// the setter to the specified control. Note that it should not apply the setter value + /// until is called. + /// + ISetterInstance Instance(IStyleable target); } -} \ No newline at end of file +} diff --git a/src/Avalonia.Styling/Styling/ISetterInstance.cs b/src/Avalonia.Styling/Styling/ISetterInstance.cs new file mode 100644 index 0000000000..a299a87b64 --- /dev/null +++ b/src/Avalonia.Styling/Styling/ISetterInstance.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System; + +namespace Avalonia.Styling +{ + /// + /// Represents a setter that has been instanced on a control. + /// + public interface ISetterInstance : IDisposable + { + /// + /// Starts the setter instance. + /// + /// Whether the parent style has an activator. + /// + /// If is false then the setter should be immediately + /// applied and and should not be called. + /// If true, then bindings etc should be initiated but not produce a value until + /// called. + /// + public void Start(bool hasActivator); + + /// + /// Activates the setter. + /// + /// + /// Should only be called if hasActivator was true when was called. + /// + public void Activate(); + + /// + /// Deactivates the setter. + /// + /// + /// Should only be called if hasActivator was true when was called. + /// + public void Deactivate(); + } +} diff --git a/src/Avalonia.Styling/Styling/IStyle.cs b/src/Avalonia.Styling/Styling/IStyle.cs index da2a08f04d..8151aacf54 100644 --- a/src/Avalonia.Styling/Styling/IStyle.cs +++ b/src/Avalonia.Styling/Styling/IStyle.cs @@ -3,6 +3,8 @@ using Avalonia.Controls; +#nullable enable + namespace Avalonia.Styling { /// @@ -13,17 +15,11 @@ namespace Avalonia.Styling /// /// Attaches the style to a control if the style's selector matches. /// - /// The control to attach to. - /// - /// The control that contains this style. May be null. - /// + /// The control to attach to. + /// The element that hosts the style. /// - /// True if the style can match a control of type - /// (even if it does not match this control specifically); false if the style - /// can never match. + /// A describing how the style matches the control. /// - bool Attach(IStyleable control, IStyleHost container); - - void Detach(); + SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host); } } diff --git a/src/Avalonia.Styling/Styling/IStyleInstance.cs b/src/Avalonia.Styling/Styling/IStyleInstance.cs new file mode 100644 index 0000000000..cb094badd2 --- /dev/null +++ b/src/Avalonia.Styling/Styling/IStyleInstance.cs @@ -0,0 +1,22 @@ +using System; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// Represents a style that has been instanced on a control. + /// + public interface IStyleInstance : IDisposable + { + /// + /// Gets the source style. + /// + IStyle Source { get; } + + /// + /// Instructs the style to start acting upon the control. + /// + void Start(); + } +} diff --git a/src/Avalonia.Styling/Styling/IStyleable.cs b/src/Avalonia.Styling/Styling/IStyleable.cs index 5ad97d8a61..b01c779bcc 100644 --- a/src/Avalonia.Styling/Styling/IStyleable.cs +++ b/src/Avalonia.Styling/Styling/IStyleable.cs @@ -4,6 +4,8 @@ using System; using Avalonia.Collections; +#nullable enable + namespace Avalonia.Styling { /// @@ -11,11 +13,6 @@ namespace Avalonia.Styling /// public interface IStyleable : IAvaloniaObject, INamed { - /// - /// Signaled when the control's style should be removed. - /// - IObservable StyleDetach { get; } - /// /// Gets the list of classes for the control. /// @@ -29,6 +26,17 @@ namespace Avalonia.Styling /// /// Gets the template parent of this element if the control comes from a template. /// - ITemplatedControl TemplatedParent { get; } + ITemplatedControl? TemplatedParent { get; } + + /// + /// Notifies the element that a style has been applied. + /// + /// The style instance. + void StyleApplied(IStyleInstance instance); + + /// + /// Detaches all styles applied to the element. + /// + void DetachStyles(); } } diff --git a/src/Avalonia.Styling/Styling/NotSelector.cs b/src/Avalonia.Styling/Styling/NotSelector.cs index bcf76620be..c1ab662887 100644 --- a/src/Avalonia.Styling/Styling/NotSelector.cs +++ b/src/Avalonia.Styling/Styling/NotSelector.cs @@ -2,7 +2,9 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Reactive.Linq; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -11,16 +13,16 @@ namespace Avalonia.Styling /// internal class NotSelector : Selector { - private readonly Selector _previous; + private readonly Selector? _previous; private readonly Selector _argument; - private string _selectorString; + private string? _selectorString; /// /// Initializes a new instance of the class. /// /// The previous selector. /// The selector to be not-ed. - public NotSelector(Selector previous, Selector argument) + public NotSelector(Selector? previous, Selector argument) { _previous = previous; _argument = argument ?? throw new InvalidOperationException("Not selector must have a selector argument."); @@ -33,14 +35,14 @@ namespace Avalonia.Styling public override bool IsCombinator => false; /// - public override Type TargetType => _previous?.TargetType; + public override Type? TargetType => _previous?.TargetType; /// public override string ToString() { if (_selectorString == null) { - _selectorString = ":not(" + _argument.ToString() + ")"; + _selectorString = $"{_previous?.ToString()}:not({_argument})"; } return _selectorString; @@ -61,12 +63,12 @@ namespace Avalonia.Styling case SelectorMatchResult.NeverThisType: return SelectorMatch.AlwaysThisType; case SelectorMatchResult.Sometimes: - return new SelectorMatch(innerResult.Activator.Select(x => !x)); + return new SelectorMatch(new NotActivator(innerResult.Activator!)); default: throw new InvalidOperationException("Invalid SelectorMatchResult."); } } - protected override Selector MovePrevious() => _previous; + protected override Selector? MovePrevious() => _previous; } } diff --git a/src/Avalonia.Styling/Styling/OrSelector.cs b/src/Avalonia.Styling/Styling/OrSelector.cs index 58c5c778fb..b121658826 100644 --- a/src/Avalonia.Styling/Styling/OrSelector.cs +++ b/src/Avalonia.Styling/Styling/OrSelector.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -12,8 +15,8 @@ namespace Avalonia.Styling internal class OrSelector : Selector { private readonly IReadOnlyList _selectors; - private string _selectorString; - private Type _targetType; + private string? _selectorString; + private Type? _targetType; /// /// Initializes a new instance of the class. @@ -21,8 +24,15 @@ namespace Avalonia.Styling /// The selectors to OR. public OrSelector(IReadOnlyList selectors) { - Contract.Requires(selectors != null); - Contract.Requires(selectors.Count > 1); + if (selectors is null) + { + throw new ArgumentNullException(nameof(selectors)); + } + + if (selectors.Count <= 1) + { + throw new ArgumentException("Need more than one selector to OR."); + } _selectors = selectors; } @@ -34,7 +44,7 @@ namespace Avalonia.Styling public override bool IsCombinator => false; /// - public override Type TargetType + public override Type? TargetType { get { @@ -60,7 +70,7 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) { - var activators = new List>(); + var activators = new OrActivatorBuilder(); var neverThisInstance = false; foreach (var selector in _selectors) @@ -76,18 +86,14 @@ namespace Avalonia.Styling neverThisInstance = true; break; case SelectorMatchResult.Sometimes: - activators.Add(match.Activator); + activators.Add(match.Activator!); break; } } - if (activators.Count > 1) - { - return new SelectorMatch(StyleActivator.Or(activators)); - } - else if (activators.Count == 1) + if (activators.Count > 0) { - return new SelectorMatch(activators[0]); + return new SelectorMatch(activators.Get()); } else if (neverThisInstance) { @@ -99,11 +105,11 @@ namespace Avalonia.Styling } } - protected override Selector MovePrevious() => null; + protected override Selector? MovePrevious() => null; - private Type EvaluateTargetType() + private Type? EvaluateTargetType() { - var result = default(Type); + Type? result = null; foreach (var selector in _selectors) { diff --git a/src/Avalonia.Styling/Styling/PropertyEqualsSelector.cs b/src/Avalonia.Styling/Styling/PropertyEqualsSelector.cs index cfc0998fe0..d7e1f46a94 100644 --- a/src/Avalonia.Styling/Styling/PropertyEqualsSelector.cs +++ b/src/Avalonia.Styling/Styling/PropertyEqualsSelector.cs @@ -2,8 +2,10 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Reactive.Linq; using System.Text; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -13,14 +15,14 @@ namespace Avalonia.Styling /// internal class PropertyEqualsSelector : Selector { - private readonly Selector _previous; + private readonly Selector? _previous; private readonly AvaloniaProperty _property; - private readonly object _value; - private string _selectorString; + private readonly object? _value; + private string? _selectorString; - public PropertyEqualsSelector(Selector previous, AvaloniaProperty property, object value) + public PropertyEqualsSelector(Selector? previous, AvaloniaProperty property, object? value) { - Contract.Requires(property != null); + property = property ?? throw new ArgumentNullException(nameof(property)); _previous = previous; _property = property; @@ -33,13 +35,8 @@ namespace Avalonia.Styling /// public override bool IsCombinator => false; - /// - /// Gets the name of the control to match. - /// - public string Name { get; private set; } - /// - public override Type TargetType => _previous?.TargetType; + public override Type? TargetType => _previous?.TargetType; /// public override string ToString() @@ -77,7 +74,7 @@ namespace Avalonia.Styling { if (subscribe) { - return new SelectorMatch(control.GetObservable(_property).Select(v => Equals(v ?? string.Empty, _value))); + return new SelectorMatch(new PropertyEqualsActivator(control, _property, _value)); } else { @@ -86,6 +83,6 @@ namespace Avalonia.Styling } } - protected override Selector MovePrevious() => _previous; + protected override Selector? MovePrevious() => _previous; } } diff --git a/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs b/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs new file mode 100644 index 0000000000..1400bc2ac3 --- /dev/null +++ b/src/Avalonia.Styling/Styling/PropertySetterBindingInstance.cs @@ -0,0 +1,180 @@ +using System; +using System.Reactive.Subjects; +using Avalonia.Data; +using Avalonia.Reactive; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// A which has been instanced on a control and has an + /// as its value. + /// + /// The target property type. + internal class PropertySetterBindingInstance : SingleSubscriberObservableBase>, + ISubject>, + ISetterInstance + { + private readonly IStyleable _target; + private readonly StyledPropertyBase? _styledProperty; + private readonly DirectPropertyBase? _directProperty; + private readonly InstancedBinding _binding; + private readonly Inner _inner; + private BindingValue _value; + private IDisposable? _subscription; + private IDisposable? _subscriptionTwoWay; + private bool _isActive; + + public PropertySetterBindingInstance( + IStyleable target, + StyledPropertyBase property, + IBinding binding) + { + _target = target; + _styledProperty = property; + _binding = binding.Initiate(_target, property); + + if (_binding.Mode == BindingMode.OneTime) + { + // For the moment, we don't support OneTime bindings in setters, because I'm not + // sure what the semantics should be in the case of activation/deactivation. + throw new NotSupportedException("OneTime bindings are not supported in setters."); + } + + _inner = new Inner(this); + } + + public PropertySetterBindingInstance( + IStyleable target, + DirectPropertyBase property, + IBinding binding) + { + _target = target; + _directProperty = property; + _binding = binding.Initiate(_target, property); + _inner = new Inner(this); + } + + public void Start(bool hasActivator) + { + _isActive = !hasActivator; + + if (_styledProperty is object) + { + if (_binding.Mode != BindingMode.OneWayToSource) + { + var priority = hasActivator ? BindingPriority.StyleTrigger : BindingPriority.Style; + _subscription = _target.Bind(_styledProperty, this, priority); + } + + if (_binding.Mode == BindingMode.TwoWay) + { + _subscriptionTwoWay = _target.GetBindingObservable(_styledProperty).Subscribe(this); + } + } + else + { + if (_binding.Mode != BindingMode.OneWayToSource) + { + _subscription = _target.Bind(_directProperty!, this); + } + + if (_binding.Mode == BindingMode.TwoWay) + { + _subscriptionTwoWay = _target.GetBindingObservable(_directProperty!).Subscribe(this); + } + } + } + + public void Activate() + { + if (!_isActive) + { + _isActive = true; + PublishNext(); + } + } + + public void Deactivate() + { + if (_isActive) + { + _isActive = false; + PublishNext(); + } + } + + public override void Dispose() + { + if (_subscription is object) + { + var sub = _subscription; + _subscription = null; + sub.Dispose(); + } + + if (_subscriptionTwoWay is object) + { + var sub = _subscriptionTwoWay; + _subscriptionTwoWay = null; + sub.Dispose(); + } + + base.Dispose(); + } + + void IObserver>.OnCompleted() + { + // This is the observable coming from the target control. It should not complete. + } + + void IObserver>.OnError(Exception error) + { + // This is the observable coming from the target control. It should not error. + } + + void IObserver>.OnNext(BindingValue value) + { + if (value.HasValue && _isActive) + { + _binding.Subject.OnNext(value.Value); + } + } + + protected override void Subscribed() + { + _subscription = _binding.Observable.Subscribe(_inner); + } + + protected override void Unsubscribed() + { + _subscription?.Dispose(); + _subscription = null; + } + + private void PublishNext() + { + PublishNext(_isActive ? _value : default); + } + + private void ConvertAndPublishNext(object? value) + { + _value = value is T v ? v : BindingValue.FromUntyped(value).Convert(); + + if (_isActive) + { + PublishNext(); + } + } + + private class Inner : IObserver + { + private readonly PropertySetterBindingInstance _owner; + public Inner(PropertySetterBindingInstance owner) => _owner = owner; + public void OnCompleted() => _owner.PublishCompleted(); + public void OnError(Exception error) => _owner.PublishError(error); + public void OnNext(object? value) => _owner.ConvertAndPublishNext(value); + } + } +} diff --git a/src/Avalonia.Styling/Styling/PropertySetterInstance.cs b/src/Avalonia.Styling/Styling/PropertySetterInstance.cs new file mode 100644 index 0000000000..689438dfd7 --- /dev/null +++ b/src/Avalonia.Styling/Styling/PropertySetterInstance.cs @@ -0,0 +1,118 @@ +using System; +using Avalonia.Data; +using Avalonia.Reactive; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// A which has been instance on a control. + /// + /// The target property type. + internal class PropertySetterInstance : SingleSubscriberObservableBase>, + ISetterInstance + { + private readonly IStyleable _target; + private readonly StyledPropertyBase? _styledProperty; + private readonly DirectPropertyBase? _directProperty; + private readonly T _value; + private IDisposable? _subscription; + private bool _isActive; + + public PropertySetterInstance( + IStyleable target, + StyledPropertyBase property, + T value) + { + _target = target; + _styledProperty = property; + _value = value; + } + + public PropertySetterInstance( + IStyleable target, + DirectPropertyBase property, + T value) + { + _target = target; + _directProperty = property; + _value = value; + } + + public void Start(bool hasActivator) + { + if (hasActivator) + { + if (_styledProperty is object) + { + _subscription = _target.Bind(_styledProperty, this, BindingPriority.StyleTrigger); + } + else + { + _subscription = _target.Bind(_directProperty, this); + } + } + else + { + if (_styledProperty is object) + { + _subscription = _target.SetValue(_styledProperty, _value, BindingPriority.Style); + } + else + { + _target.SetValue(_directProperty!, _value); + } + } + } + + public void Activate() + { + if (!_isActive) + { + _isActive = true; + PublishNext(); + } + } + + public void Deactivate() + { + if (_isActive) + { + _isActive = false; + PublishNext(); + } + } + + public override void Dispose() + { + if (_subscription is object) + { + var sub = _subscription; + _subscription = null; + sub.Dispose(); + } + else if (_isActive) + { + if (_styledProperty is object) + { + _target.ClearValue(_styledProperty); + } + else + { + _target.ClearValue(_directProperty); + } + } + + base.Dispose(); + } + + protected override void Subscribed() => PublishNext(); + protected override void Unsubscribed() { } + + private void PublishNext() + { + PublishNext(_isActive ? new BindingValue(_value) : default); + } + } +} diff --git a/src/Avalonia.Styling/Styling/Selector.cs b/src/Avalonia.Styling/Styling/Selector.cs index 7d4e92baeb..0404ee14ae 100644 --- a/src/Avalonia.Styling/Styling/Selector.cs +++ b/src/Avalonia.Styling/Styling/Selector.cs @@ -2,9 +2,9 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections.Generic; -using System.Diagnostics; -using Avalonia.Utilities; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -30,7 +30,7 @@ namespace Avalonia.Styling /// /// Gets the target type of the selector, if available. /// - public abstract Type TargetType { get; } + public abstract Type? TargetType { get; } /// /// Tries to match the selector with a control. @@ -43,54 +43,28 @@ namespace Avalonia.Styling /// A . public SelectorMatch Match(IStyleable control, bool subscribe = true) { - ValueSingleOrList> inputs = default; - - var selector = this; - var alwaysThisType = true; - var hitCombinator = false; - - while (selector != null) + // First match the selector until a combinator is found. Selectors are stored from + // right-to-left, so MatchUntilCombinator reverses this order because the type selector + // will be on the left. + var match = MatchUntilCombinator(control, this, subscribe, out var combinator); + + // If the pre-combinator selector matches, we can now match the combinator, if any. + if (match.IsMatch && combinator is object) { - hitCombinator |= selector.IsCombinator; + match = match.And(combinator.Match(control, subscribe)); - var match = selector.Evaluate(control, subscribe); - - if (!match.IsMatch) - { - return hitCombinator ? SelectorMatch.NeverThisInstance : match; - } - else if (selector.InTemplate && control.TemplatedParent == null) + // If we have a combinator then we can never say that we always match a control of + // this type, because by definition the combinator matches on things outside of the + // control. + match = match.Result switch { - return SelectorMatch.NeverThisInstance; - } - else if (match.Result == SelectorMatchResult.AlwaysThisInstance) - { - alwaysThisType = false; - } - else if (match.Result == SelectorMatchResult.Sometimes) - { - Debug.Assert(match.Activator != null); - - inputs.Add(match.Activator); - } - - selector = selector.MovePrevious(); + SelectorMatchResult.AlwaysThisType => SelectorMatch.AlwaysThisInstance, + SelectorMatchResult.NeverThisType => SelectorMatch.NeverThisInstance, + _ => match + }; } - if (inputs.HasList) - { - return new SelectorMatch(StyleActivator.And(inputs.List)); - } - else if (inputs.IsSingle) - { - return new SelectorMatch(inputs.Single); - } - else - { - return alwaysThisType && !hitCombinator ? - SelectorMatch.AlwaysThisType : - SelectorMatch.AlwaysThisInstance; - } + return match; } /// @@ -107,6 +81,65 @@ namespace Avalonia.Styling /// /// Moves to the previous selector. /// - protected abstract Selector MovePrevious(); + protected abstract Selector? MovePrevious(); + + private static SelectorMatch MatchUntilCombinator( + IStyleable control, + Selector start, + bool subscribe, + out Selector? combinator) + { + combinator = null; + + var activators = new AndActivatorBuilder(); + var result = Match(control, start, subscribe, ref activators, ref combinator); + + return result == SelectorMatchResult.Sometimes ? + new SelectorMatch(activators.Get()) : + new SelectorMatch(result); + } + + private static SelectorMatchResult Match( + IStyleable control, + Selector selector, + bool subscribe, + ref AndActivatorBuilder activators, + ref Selector? combinator) + { + var previous = selector.MovePrevious(); + + // Selectors are stored from right-to-left, so we recurse into the selector in order to + // reverse this order, because the type selector will be on the left and is our best + // opportunity to exit early. + if (previous != null && !previous.IsCombinator) + { + var previousMatch = Match(control, previous, subscribe, ref activators, ref combinator); + + if (previousMatch < SelectorMatchResult.Sometimes) + { + return previousMatch; + } + } + + // Match this selector. + var match = selector.Evaluate(control, subscribe); + + if (!match.IsMatch) + { + combinator = null; + return match.Result; + } + else if (match.Activator is object) + { + activators.Add(match.Activator!); + } + + if (previous?.IsCombinator == true) + { + combinator = previous; + } + + return match.Result; + } } } diff --git a/src/Avalonia.Styling/Styling/SelectorMatch.cs b/src/Avalonia.Styling/Styling/SelectorMatch.cs index 63b89e9e97..a7e84dd9bd 100644 --- a/src/Avalonia.Styling/Styling/SelectorMatch.cs +++ b/src/Avalonia.Styling/Styling/SelectorMatch.cs @@ -2,6 +2,9 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -21,9 +24,9 @@ namespace Avalonia.Styling NeverThisInstance, /// - /// The selector always matches this type. + /// The selector matches this instance based on the . /// - AlwaysThisType, + Sometimes, /// /// The selector always matches this instance, but doesn't always match this type. @@ -31,9 +34,9 @@ namespace Avalonia.Styling AlwaysThisInstance, /// - /// The selector matches this instance based on the . + /// The selector always matches this type. /// - Sometimes, + AlwaysThisType, } /// @@ -43,7 +46,7 @@ namespace Avalonia.Styling /// A selector match describes whether and how a matches a control, and /// in addition whether the selector can ever match a control of the same type. /// - public class SelectorMatch + public readonly struct SelectorMatch { /// /// A selector match with the result of . @@ -70,20 +73,28 @@ namespace Avalonia.Styling /// result. /// /// The match activator. - public SelectorMatch(IObservable match) + public SelectorMatch(IStyleActivator match) { - Contract.Requires(match != null); + match = match ?? throw new ArgumentNullException(nameof(match)); Result = SelectorMatchResult.Sometimes; Activator = match; } - private SelectorMatch(SelectorMatchResult result) => Result = result; + /// + /// Initializes a new instance of the class with the specified result. + /// + /// The match result. + public SelectorMatch(SelectorMatchResult result) + { + Result = result; + Activator = null; + } /// /// Gets a value indicating whether the match was positive. /// - public bool IsMatch => Result >= SelectorMatchResult.AlwaysThisType; + public bool IsMatch => Result >= SelectorMatchResult.Sometimes; /// /// Gets the result of the match. @@ -91,9 +102,34 @@ namespace Avalonia.Styling public SelectorMatchResult Result { get; } /// - /// Gets an observable which tracks the selector match, in the case of selectors that can + /// Gets an activator which tracks the selector match, in the case of selectors that can /// change over time. /// - public IObservable Activator { get; } + public IStyleActivator? Activator { get; } + + /// + /// Logical ANDs this with another. + /// + /// + /// + public SelectorMatch And(in SelectorMatch other) + { + var result = (SelectorMatchResult)Math.Min((int)Result, (int)other.Result); + + if (result == SelectorMatchResult.Sometimes) + { + var activators = new AndActivatorBuilder(); + activators.Add(Activator); + activators.Add(other.Activator); + return new SelectorMatch(activators.Get()); + } + else + { + return new SelectorMatch(result); + } + } + + /// + public override string ToString() => Result.ToString(); } } diff --git a/src/Avalonia.Styling/Styling/Setter.cs b/src/Avalonia.Styling/Styling/Setter.cs index 9244232ff5..691a3087a5 100644 --- a/src/Avalonia.Styling/Styling/Setter.cs +++ b/src/Avalonia.Styling/Styling/Setter.cs @@ -2,11 +2,12 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Reactive.Disposables; using Avalonia.Animation; using Avalonia.Data; using Avalonia.Metadata; -using Avalonia.Reactive; +using Avalonia.Utilities; + +#nullable enable namespace Avalonia.Styling { @@ -17,9 +18,9 @@ namespace Avalonia.Styling /// A is used to set a value on a /// depending on a condition. /// - public class Setter : ISetter, IAnimationSetter + public class Setter : ISetter, IAnimationSetter, IAvaloniaPropertyVisitor { - private object _value; + private object? _value; /// /// Initializes a new instance of the class. @@ -42,11 +43,7 @@ namespace Avalonia.Styling /// /// Gets or sets the property to set. /// - public AvaloniaProperty Property - { - get; - set; - } + public AvaloniaProperty? Property { get; set; } /// /// Gets or sets the property value. @@ -54,13 +51,9 @@ namespace Avalonia.Styling [Content] [AssignBinding] [DependsOn(nameof(Property))] - public object Value + public object? Value { - get - { - return _value; - } - + get => _value; set { (value as ISetterValue)?.Initialize(this); @@ -68,99 +61,78 @@ namespace Avalonia.Styling } } - /// - /// Applies the setter to a control. - /// - /// The style that is being applied. - /// The control. - /// An optional activator. - public IDisposable Apply(IStyle style, IStyleable control, IObservable activator) + public ISetterInstance Instance(IStyleable target) { - Contract.Requires(control != null); - - var description = style?.ToString(); + target = target ?? throw new ArgumentNullException(nameof(target)); - if (Property == null) + if (Property is null) { throw new InvalidOperationException("Setter.Property must be set."); } var value = Value; - var binding = value as IBinding; - if (binding == null) + if (value is ITemplate template && + !typeof(ITemplate).IsAssignableFrom(Property.PropertyType)) { - if (value is ITemplate template) - { - bool isPropertyOfTypeITemplate = typeof(ITemplate).IsAssignableFrom(Property.PropertyType); - - if (!isPropertyOfTypeITemplate) - { - var materialized = template.Build(); - value = materialized; - } - } - - if (activator == null) - { - return control.Bind(Property, ObservableEx.SingleValue(value), BindingPriority.Style); - } - else - { - var activated = new ActivatedValue(activator, value, description); - return control.Bind(Property, activated, BindingPriority.StyleTrigger); - } + value = template.Build(); } - else - { - var source = binding.Initiate(control, Property); - if (source != null) - { - var cloned = Clone(source, source.Mode == BindingMode.Default ? Property.GetMetadata(control.GetType()).DefaultBindingMode : source.Mode, style, activator); - return BindingOperations.Apply(control, Property, cloned, null); - } - } + var data = new SetterVisitorData + { + target = target, + value = value, + }; - return Disposable.Empty; + Property.Accept(this, ref data); + return data.result!; } - private InstancedBinding Clone(InstancedBinding sourceInstance, BindingMode mode, IStyle style, IObservable activator) + void IAvaloniaPropertyVisitor.Visit( + StyledPropertyBase property, + ref SetterVisitorData data) { - if (activator != null) + if (data.value is IBinding binding) + { + data.result = new PropertySetterBindingInstance( + data.target, + property, + binding); + } + else { - var description = style?.ToString(); - - switch (mode) - { - case BindingMode.OneTime: - if (sourceInstance.Observable != null) - { - var activated = new ActivatedObservable(activator, sourceInstance.Observable, description); - return InstancedBinding.OneTime(activated, BindingPriority.StyleTrigger); - } - else - { - var activated = new ActivatedValue(activator, sourceInstance.Value, description); - return InstancedBinding.OneTime(activated, BindingPriority.StyleTrigger); - } - case BindingMode.OneWay: - { - var activated = new ActivatedObservable(activator, sourceInstance.Observable, description); - return InstancedBinding.OneWay(activated, BindingPriority.StyleTrigger); - } - default: - { - var activated = new ActivatedSubject(activator, sourceInstance.Subject, description); - return new InstancedBinding(activated, sourceInstance.Mode, BindingPriority.StyleTrigger); - } - } + data.result = new PropertySetterInstance( + data.target, + property, + (T)data.value); + } + } + void IAvaloniaPropertyVisitor.Visit( + DirectPropertyBase property, + ref SetterVisitorData data) + { + if (data.value is IBinding binding) + { + data.result = new PropertySetterBindingInstance( + data.target, + property, + binding); } else { - return sourceInstance.WithPriority(BindingPriority.Style); + data.result = new PropertySetterInstance( + data.target, + property, + (T)data.value); } } + + private struct SetterVisitorData + { + public IStyleable target; + public object? value; + public ISetterInstance? result; + } } } diff --git a/src/Avalonia.Styling/Styling/Style.cs b/src/Avalonia.Styling/Styling/Style.cs index 99ee8d8563..22adf573e8 100644 --- a/src/Avalonia.Styling/Styling/Style.cs +++ b/src/Avalonia.Styling/Styling/Style.cs @@ -3,12 +3,12 @@ using System; using System.Collections.Generic; -using System.Reactive.Disposables; -using System.Reactive.Linq; using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Metadata; +#nullable enable + namespace Avalonia.Styling { /// @@ -16,15 +16,10 @@ namespace Avalonia.Styling /// public class Style : AvaloniaObject, IStyle, ISetResourceParent { - private static Dictionary _applied = - new Dictionary(); - private IResourceNode _parent; - - private CompositeDisposable _subscriptions; - - private IResourceDictionary _resources; - - private IList _animations; + private IResourceNode? _parent; + private IResourceDictionary? _resources; + private List? _setters; + private List? _animations; /// /// Initializes a new instance of the class. @@ -37,13 +32,13 @@ namespace Avalonia.Styling /// Initializes a new instance of the class. /// /// The style selector. - public Style(Func selector) + public Style(Func selector) { Selector = selector(null); } /// - public event EventHandler ResourcesChanged; + public event EventHandler? ResourcesChanged; /// /// Gets or sets a dictionary of style resources. @@ -53,7 +48,7 @@ namespace Avalonia.Styling get => _resources ?? (Resources = new ResourceDictionary()); set { - Contract.Requires(value != null); + value = value ?? throw new ArgumentNullException(nameof(value)); var hadResources = false; @@ -76,102 +71,45 @@ namespace Avalonia.Styling /// /// Gets or sets the style's selector. /// - public Selector Selector { get; set; } + public Selector? Selector { get; set; } /// - /// Gets or sets the style's setters. + /// Gets the style's setters. /// [Content] - public IList Setters { get; set; } = new List(); + public IList Setters => _setters ??= new List(); - public IList Animations - { - get - { - return _animations ?? (_animations = new List()); - } - } - - private CompositeDisposable Subscriptions - { - get - { - return _subscriptions ?? (_subscriptions = new CompositeDisposable(2)); - } - } + /// + /// Gets the style's animations. + /// + public IList Animations => _animations ??= new List(); /// - IResourceNode IResourceNode.ResourceParent => _parent; + IResourceNode? IResourceNode.ResourceParent => _parent; /// bool IResourceProvider.HasResources => _resources?.Count > 0; /// - public bool Attach(IStyleable control, IStyleHost container) + public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) { - if (Selector != null) - { - var match = Selector.Match(control); + target = target ?? throw new ArgumentNullException(nameof(target)); - if (match.IsMatch) - { - var controlSubscriptions = GetSubscriptions(control); - - var subs = new CompositeDisposable(Setters.Count + Animations.Count); - - if (control is Animatable animatable) - { - foreach (var animation in Animations) - { - var obsMatch = match.Activator; - - if (match.Result == SelectorMatchResult.AlwaysThisType || - match.Result == SelectorMatchResult.AlwaysThisInstance) - { - obsMatch = Observable.Return(true); - } - - var sub = animation.Apply(animatable, null, obsMatch); - subs.Add(sub); - } - } - - foreach (var setter in Setters) - { - var sub = setter.Apply(this, control, match.Activator); - subs.Add(sub); - } - - controlSubscriptions.Add(subs); - controlSubscriptions.Add(Disposable.Create(() => Subscriptions.Remove(subs))); - Subscriptions.Add(subs); - } + var match = Selector is object ? Selector.Match(target) : + target == host ? SelectorMatch.AlwaysThisInstance : SelectorMatch.NeverThisInstance; - return match.Result != SelectorMatchResult.NeverThisType; - } - else if (control == container) + if (match.IsMatch && (_setters is object || _animations is object)) { - var controlSubscriptions = GetSubscriptions(control); - - var subs = new CompositeDisposable(Setters.Count); - - foreach (var setter in Setters) - { - var sub = setter.Apply(this, control, null); - subs.Add(sub); - } - - controlSubscriptions.Add(subs); - controlSubscriptions.Add(Disposable.Create(() => Subscriptions.Remove(subs))); - Subscriptions.Add(subs); - return true; + var instance = new StyleInstance(this, target, _setters, _animations, match.Activator); + target.StyleApplied(instance); + instance.Start(); } - return false; + return match.Result; } /// - public bool TryGetResource(object key, out object result) + public bool TryGetResource(object key, out object? result) { result = null; return _resources?.TryGetResource(key, out result) ?? false; @@ -207,46 +145,9 @@ namespace Avalonia.Styling throw new InvalidOperationException("The Style already has a parent."); } - if (parent == null) - { - Detach(); - } - _parent = parent; } - public void Detach() - { - _subscriptions?.Dispose(); - _subscriptions = null; - } - - private static CompositeDisposable GetSubscriptions(IStyleable control) - { - if (!_applied.TryGetValue(control, out var subscriptions)) - { - subscriptions = new CompositeDisposable(3); - subscriptions.Add(control.StyleDetach.Subscribe(ControlDetach)); - _applied.Add(control, subscriptions); - } - - return subscriptions; - } - - /// - /// Called when a control's is signaled to remove - /// all applied styles. - /// - /// The control. - private static void ControlDetach(IStyleable control) - { - var subscriptions = _applied[control]; - - subscriptions.Dispose(); - - _applied.Remove(control); - } - private void ResourceDictionaryChanged(object sender, ResourcesChangedEventArgs e) { ResourcesChanged?.Invoke(this, e); diff --git a/src/Avalonia.Styling/Styling/StyleActivator.cs b/src/Avalonia.Styling/Styling/StyleActivator.cs deleted file mode 100644 index 63945037d8..0000000000 --- a/src/Avalonia.Styling/Styling/StyleActivator.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reactive; -using System.Reactive.Linq; - -namespace Avalonia.Styling -{ - public enum ActivatorMode - { - And, - Or, - } - - public static class StyleActivator - { - public static IObservable And(IList> inputs) - { - if (inputs.Count == 0) - { - throw new ArgumentException("StyleActivator.And inputs may not be empty."); - } - else if (inputs.Count == 1) - { - return inputs[0]; - } - else - { - return inputs.CombineLatest() - .Select(values => values.All(x => x)) - .DistinctUntilChanged(); - } - } - - public static IObservable Or(IList> inputs) - { - if (inputs.Count == 0) - { - throw new ArgumentException("StyleActivator.Or inputs may not be empty."); - } - else if (inputs.Count == 1) - { - return inputs[0]; - } - else - { - return inputs.CombineLatest() - .Select(values => values.Any(x => x)) - .DistinctUntilChanged(); - } - } - } -} diff --git a/src/Avalonia.Styling/Styling/StyleInstance.cs b/src/Avalonia.Styling/Styling/StyleInstance.cs new file mode 100644 index 0000000000..8ca31d654f --- /dev/null +++ b/src/Avalonia.Styling/Styling/StyleInstance.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Subjects; +using Avalonia.Animation; +using Avalonia.Styling.Activators; + +#nullable enable + +namespace Avalonia.Styling +{ + /// + /// A which has been instanced on a control. + /// + internal class StyleInstance : IStyleInstance, IStyleActivatorSink + { + private readonly List? _setters; + private readonly List? _animations; + private readonly IStyleActivator? _activator; + private readonly Subject? _animationTrigger; + private bool _active; + + public StyleInstance( + IStyle source, + IStyleable target, + IReadOnlyList? setters, + IReadOnlyList? animations, + IStyleActivator? activator = null) + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + Target = target ?? throw new ArgumentNullException(nameof(target)); + _activator = activator; + + if (setters is object) + { + var setterCount = setters.Count; + + _setters = new List(setterCount); + + for (var i = 0; i < setterCount; ++i) + { + _setters.Add(setters[i].Instance(Target)); + } + } + + if (animations is object && target is Animatable animatable) + { + var animationsCount = animations.Count; + + _animations = new List(animationsCount); + _animationTrigger = new Subject(); + + for (var i = 0; i < animationsCount; ++i) + { + _animations.Add(animations[i].Apply(animatable, null, _animationTrigger)); + } + } + } + + public IStyle Source { get; } + public IStyleable Target { get; } + + public void Start() + { + var hasActivator = _activator is object; + + if (_setters is object) + { + foreach (var setter in _setters) + { + setter.Start(hasActivator); + } + } + + if (hasActivator) + { + _activator!.Subscribe(this, 0); + } + else if (_animationTrigger != null) + { + _animationTrigger.OnNext(true); + } + } + + public void Dispose() + { + if (_setters is object) + { + foreach (var setter in _setters) + { + setter.Dispose(); + } + } + + if (_animations is object) + { + foreach (var subscripion in _animations) + { + subscripion.Dispose(); + } + } + + _activator?.Dispose(); + } + + private void ActivatorChanged(bool value) + { + if (_active != value) + { + _active = value; + + _animationTrigger?.OnNext(value); + + if (_setters is object) + { + if (_active) + { + foreach (var setter in _setters) + { + setter.Activate(); + } + } + else + { + foreach (var setter in _setters) + { + setter.Deactivate(); + } + } + } + } + } + + void IStyleActivatorSink.OnNext(bool value, int tag) => ActivatorChanged(value); + } +} diff --git a/src/Avalonia.Styling/Styling/Styler.cs b/src/Avalonia.Styling/Styling/Styler.cs index 7ac5c89005..cfd9f65aee 100644 --- a/src/Avalonia.Styling/Styling/Styler.cs +++ b/src/Avalonia.Styling/Styling/Styler.cs @@ -3,35 +3,34 @@ using System; +#nullable enable + namespace Avalonia.Styling { public class Styler : IStyler { - public void ApplyStyles(IStyleable control) + public void ApplyStyles(IStyleable target) { - var styleHost = control as IStyleHost; + target = target ?? throw new ArgumentNullException(nameof(target)); - if (styleHost != null) + if (target is IStyleHost styleHost) { - ApplyStyles(control, styleHost); + ApplyStyles(target, styleHost); } } - private void ApplyStyles(IStyleable control, IStyleHost styleHost) + private void ApplyStyles(IStyleable target, IStyleHost host) { - Contract.Requires(control != null); - Contract.Requires(styleHost != null); - - var parentContainer = styleHost.StylingParent; + var parent = host.StylingParent; - if (parentContainer != null) + if (parent != null) { - ApplyStyles(control, parentContainer); + ApplyStyles(target, parent); } - if (styleHost.IsStylesInitialized) + if (host.IsStylesInitialized) { - styleHost.Styles.Attach(control, styleHost); + host.Styles.TryAttach(target, host); } } } diff --git a/src/Avalonia.Styling/Styling/Styles.cs b/src/Avalonia.Styling/Styling/Styles.cs index 0226288998..05cbba5ad2 100644 --- a/src/Avalonia.Styling/Styling/Styles.cs +++ b/src/Avalonia.Styling/Styling/Styles.cs @@ -9,6 +9,8 @@ using System.Linq; using Avalonia.Collections; using Avalonia.Controls; +#nullable enable + namespace Avalonia.Styling { /// @@ -16,10 +18,11 @@ namespace Avalonia.Styling /// public class Styles : AvaloniaObject, IAvaloniaList, IStyle, ISetResourceParent { - private IResourceNode _parent; - private IResourceDictionary _resources; - private AvaloniaList _styles = new AvaloniaList(); - private Dictionary> _cache; + private readonly AvaloniaList _styles = new AvaloniaList(); + private IResourceNode? _parent; + private IResourceDictionary? _resources; + private Dictionary?>? _cache; + private bool _notifyingResourcesChanged; public Styles() { @@ -38,7 +41,7 @@ namespace Avalonia.Styling ResourcesChanged?.Invoke(this, new ResourcesChangedEventArgs()); } - x.ResourcesChanged += SubResourceChanged; + x.ResourcesChanged += NotifyResourcesChanged; _cache = null; }, x => @@ -54,12 +57,18 @@ namespace Avalonia.Styling ResourcesChanged?.Invoke(this, new ResourcesChangedEventArgs()); } - x.ResourcesChanged -= SubResourceChanged; + x.ResourcesChanged -= NotifyResourcesChanged; _cache = null; }, () => { }); } + public Styles(IResourceNode parent) + : this() + { + _parent = parent; + } + public event NotifyCollectionChangedEventHandler CollectionChanged { add => _styles.CollectionChanged += value; @@ -67,7 +76,7 @@ namespace Avalonia.Styling } /// - public event EventHandler ResourcesChanged; + public event EventHandler? ResourcesChanged; /// public int Count => _styles.Count; @@ -83,18 +92,18 @@ namespace Avalonia.Styling get => _resources ?? (Resources = new ResourceDictionary()); set { - Contract.Requires(value != null); + value = value ?? throw new ArgumentNullException(nameof(Resources)); var hadResources = false; if (_resources != null) { hadResources = _resources.Count > 0; - _resources.ResourcesChanged -= ResourceDictionaryChanged; + _resources.ResourcesChanged -= NotifyResourcesChanged; } _resources = value; - _resources.ResourcesChanged += ResourceDictionaryChanged; + _resources.ResourcesChanged += NotifyResourcesChanged; if (hadResources || _resources.Count > 0) { @@ -104,7 +113,7 @@ namespace Avalonia.Styling } /// - IResourceNode IResourceNode.ResourceParent => _parent; + IResourceNode? IResourceNode.ResourceParent => _parent; /// bool ICollection.IsReadOnly => false; @@ -119,66 +128,50 @@ namespace Avalonia.Styling set => _styles[index] = value; } - /// - /// Attaches the style to a control if the style's selector matches. - /// - /// The control to attach to. - /// - /// The control that contains this style. May be null. - /// - public bool Attach(IStyleable control, IStyleHost container) + /// + public SelectorMatchResult TryAttach(IStyleable target, IStyleHost? host) { - if (_cache == null) - { - _cache = new Dictionary>(); - } + _cache ??= new Dictionary?>(); - if (_cache.TryGetValue(control.StyleKey, out var cached)) + if (_cache.TryGetValue(target.StyleKey, out var cached)) { - if (cached != null) + if (cached is object) { foreach (var style in cached) { - style.Attach(control, container); + style.TryAttach(target, host); } - return true; + return SelectorMatchResult.AlwaysThisType; + } + else + { + return SelectorMatchResult.NeverThisType; } - - return false; } else { - List result = null; + List? matches = null; - foreach (var style in this) + foreach (var child in this) { - if (style.Attach(control, container)) + if (child.TryAttach(target, host) != SelectorMatchResult.NeverThisType) { - if (result == null) - { - result = new List(); - } - - result.Add(style); + matches ??= new List(); + matches.Add(child); } } - _cache.Add(control.StyleKey, result); - return result != null; - } - } - - public void Detach() - { - foreach (IStyle style in this) - { - style.Detach(); + _cache.Add(target.StyleKey, matches); + + return matches is null ? + SelectorMatchResult.NeverThisType : + SelectorMatchResult.AlwaysThisType; } } /// - public bool TryGetResource(object key, out object value) + public bool TryGetResource(object key, out object? value) { if (_resources != null && _resources.TryGetResource(key, out value)) { @@ -239,8 +232,10 @@ namespace Avalonia.Styling /// public bool Remove(IStyle item) => _styles.Remove(item); + public AvaloniaList.Enumerator GetEnumerator() => _styles.GetEnumerator(); + /// - public IEnumerator GetEnumerator() => _styles.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _styles.GetEnumerator(); /// IEnumerator IEnumerable.GetEnumerator() => _styles.GetEnumerator(); @@ -259,34 +254,35 @@ namespace Avalonia.Styling /// void ISetResourceParent.ParentResourcesChanged(ResourcesChangedEventArgs e) { - ResourcesChanged?.Invoke(this, e); + NotifyResourcesChanged(e); } - private void ResourceDictionaryChanged(object sender, ResourcesChangedEventArgs e) + private void NotifyResourcesChanged(object sender, ResourcesChangedEventArgs e) { - foreach (var child in this) - { - (child as ISetResourceParent)?.ParentResourcesChanged(e); - } - - ResourcesChanged?.Invoke(this, e); + NotifyResourcesChanged(e); } - private void SubResourceChanged(object sender, ResourcesChangedEventArgs e) + private void NotifyResourcesChanged(ResourcesChangedEventArgs e) { - var foundSource = false; + if (_notifyingResourcesChanged) + { + return; + } - foreach (var child in this) + try { - if (foundSource) + _notifyingResourcesChanged = true; + foreach (var child in this) { (child as ISetResourceParent)?.ParentResourcesChanged(e); } - foundSource |= child == sender; + ResourcesChanged?.Invoke(this, e); + } + finally + { + _notifyingResourcesChanged = false; } - - ResourcesChanged?.Invoke(this, e); } } } diff --git a/src/Avalonia.Styling/Styling/TemplateSelector.cs b/src/Avalonia.Styling/Styling/TemplateSelector.cs index 7530339883..213b20161a 100644 --- a/src/Avalonia.Styling/Styling/TemplateSelector.cs +++ b/src/Avalonia.Styling/Styling/TemplateSelector.cs @@ -41,12 +41,11 @@ namespace Avalonia.Styling protected override SelectorMatch Evaluate(IStyleable control, bool subscribe) { - IStyleable templatedParent = control.TemplatedParent as IStyleable; + var templatedParent = control.TemplatedParent as IStyleable; if (templatedParent == null) { - throw new InvalidOperationException( - "Cannot call Template selector on control with null TemplatedParent."); + return SelectorMatch.NeverThisInstance; } return _parent.Match(templatedParent, subscribe); diff --git a/src/Avalonia.Styling/Styling/TypeNameAndClassSelector.cs b/src/Avalonia.Styling/Styling/TypeNameAndClassSelector.cs index 401fa54fb5..71b8828cba 100644 --- a/src/Avalonia.Styling/Styling/TypeNameAndClassSelector.cs +++ b/src/Avalonia.Styling/Styling/TypeNameAndClassSelector.cs @@ -3,11 +3,10 @@ using System; using System.Collections.Generic; -using System.Collections.Specialized; -using System.Reflection; using System.Text; -using Avalonia.Collections; -using Avalonia.Reactive; +using Avalonia.Styling.Activators; + +#nullable enable namespace Avalonia.Styling { @@ -17,13 +16,12 @@ namespace Avalonia.Styling /// internal class TypeNameAndClassSelector : Selector { - private readonly Selector _previous; + private readonly Selector? _previous; private readonly Lazy> _classes = new Lazy>(() => new List()); - private Type _targetType; - - private string _selectorString; + private Type? _targetType; + private string? _selectorString; - public static TypeNameAndClassSelector OfType(Selector previous, Type targetType) + public static TypeNameAndClassSelector OfType(Selector? previous, Type targetType) { var result = new TypeNameAndClassSelector(previous); result._targetType = targetType; @@ -32,7 +30,7 @@ namespace Avalonia.Styling return result; } - public static TypeNameAndClassSelector Is(Selector previous, Type targetType) + public static TypeNameAndClassSelector Is(Selector? previous, Type targetType) { var result = new TypeNameAndClassSelector(previous); result._targetType = targetType; @@ -41,7 +39,7 @@ namespace Avalonia.Styling return result; } - public static TypeNameAndClassSelector ForName(Selector previous, string name) + public static TypeNameAndClassSelector ForName(Selector? previous, string name) { var result = new TypeNameAndClassSelector(previous); result.Name = name; @@ -49,7 +47,7 @@ namespace Avalonia.Styling return result; } - public static TypeNameAndClassSelector ForClass(Selector previous, string className) + public static TypeNameAndClassSelector ForClass(Selector? previous, string className) { var result = new TypeNameAndClassSelector(previous); result.Classes.Add(className); @@ -57,7 +55,7 @@ namespace Avalonia.Styling return result; } - protected TypeNameAndClassSelector(Selector previous) + protected TypeNameAndClassSelector(Selector? previous) { _previous = previous; } @@ -68,10 +66,10 @@ namespace Avalonia.Styling /// /// Gets the name of the control to match. /// - public string Name { get; set; } + public string? Name { get; set; } /// - public override Type TargetType => _targetType ?? _previous?.TargetType; + public override Type? TargetType => _targetType ?? _previous?.TargetType; /// public override bool IsCombinator => false; @@ -130,12 +128,12 @@ namespace Avalonia.Styling { if (subscribe) { - var observable = new ClassObserver(control.Classes, _classes.Value); + var observable = new StyleClassActivator(control.Classes, _classes.Value); return new SelectorMatch(observable); } - if (!AreClassesMatching(control.Classes, Classes)) + if (!StyleClassActivator.AreClassesMatching(control.Classes, Classes)) { return SelectorMatch.NeverThisInstance; } @@ -144,7 +142,7 @@ namespace Avalonia.Styling return Name == null ? SelectorMatch.AlwaysThisType : SelectorMatch.AlwaysThisInstance; } - protected override Selector MovePrevious() => _previous; + protected override Selector? MovePrevious() => _previous; private string BuildSelectorString() { @@ -190,80 +188,5 @@ namespace Avalonia.Styling return builder.ToString(); } - - private static bool AreClassesMatching(IReadOnlyList classes, IList toMatch) - { - int remainingMatches = toMatch.Count; - int classesCount = classes.Count; - - // Early bail out - we can't match if control does not have enough classes. - if (classesCount < remainingMatches) - { - return false; - } - - for (var i = 0; i < classesCount; i++) - { - var c = classes[i]; - - if (toMatch.Contains(c)) - { - --remainingMatches; - - // Already matched so we can skip checking other classes. - if (remainingMatches == 0) - { - break; - } - } - } - - return remainingMatches == 0; - } - - private sealed class ClassObserver : LightweightObservableBase - { - private readonly IList _match; - private readonly IAvaloniaReadOnlyList _classes; - private bool _hasMatch; - - public ClassObserver(IAvaloniaReadOnlyList classes, IList match) - { - _classes = classes; - _match = match; - } - - protected override void Deinitialize() => _classes.CollectionChanged -= ClassesChanged; - - protected override void Initialize() - { - _hasMatch = IsMatching(); - _classes.CollectionChanged += ClassesChanged; - } - - protected override void Subscribed(IObserver observer, bool first) - { - observer.OnNext(_hasMatch); - } - - private void ClassesChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (e.Action != NotifyCollectionChangedAction.Move) - { - var hasMatch = IsMatching(); - - if (hasMatch != _hasMatch) - { - PublishNext(hasMatch); - _hasMatch = hasMatch; - } - } - } - - private bool IsMatching() - { - return AreClassesMatching(_classes, _match); - } - } } } diff --git a/src/Avalonia.Themes.Default/ComboBox.xaml b/src/Avalonia.Themes.Default/ComboBox.xaml index 2afed09b0c..95bd9550a5 100644 --- a/src/Avalonia.Themes.Default/ComboBox.xaml +++ b/src/Avalonia.Themes.Default/ComboBox.xaml @@ -37,7 +37,6 @@ MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" PlacementTarget="{TemplateBinding}" - ObeyScreenEdges="True" StaysOpen="False"> diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 93989d3782..314416cda0 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -1,6 +1,10 @@ - + + + + + diff --git a/src/Avalonia.Themes.Default/TextBox.xaml b/src/Avalonia.Themes.Default/TextBox.xaml index 423d19da14..2c4cafde26 100644 --- a/src/Avalonia.Themes.Default/TextBox.xaml +++ b/src/Avalonia.Themes.Default/TextBox.xaml @@ -12,7 +12,9 @@ Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"> - + netstandard2.0 Avalonia + true + + + diff --git a/src/Avalonia.Visuals/AvaloniaPropertyExtensions.cs b/src/Avalonia.Visuals/AvaloniaPropertyExtensions.cs new file mode 100644 index 0000000000..6aaa224b00 --- /dev/null +++ b/src/Avalonia.Visuals/AvaloniaPropertyExtensions.cs @@ -0,0 +1,27 @@ +using Avalonia.Media; + +#nullable enable + +namespace Avalonia +{ + /// + /// Extensions for . + /// + public static class AvaloniaPropertyExtensions + { + /// + /// Checks if values of given property can affect rendering (via ). + /// + /// Property to check. + public static bool CanValueAffectRender(this AvaloniaProperty property) + { + var propertyType = property.PropertyType; + + // Only case that we are sure that property value CAN'T affect render are sealed types that don't implement + // the interface. + var cannotAffectRender = propertyType.IsSealed && !typeof(IAffectsRender).IsAssignableFrom(propertyType); + + return !cannotAffectRender; + } + } +} diff --git a/src/Avalonia.Visuals/Media/ArcSegment.cs b/src/Avalonia.Visuals/Media/ArcSegment.cs index 0e9c4548a4..8502c09895 100644 --- a/src/Avalonia.Visuals/Media/ArcSegment.cs +++ b/src/Avalonia.Visuals/Media/ArcSegment.cs @@ -1,6 +1,8 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System.Globalization; + namespace Avalonia.Media { public sealed class ArcSegment : PathSegment @@ -99,5 +101,8 @@ namespace Avalonia.Media { ctx.ArcTo(Point, Size, RotationAngle, IsLargeArc, SweepDirection); } + + public override string ToString() + => $"A {Size} {RotationAngle.ToString(CultureInfo.InvariantCulture)} {(IsLargeArc ? 1 : 0)} {(int)SweepDirection} {Point}"; } } \ No newline at end of file diff --git a/src/Avalonia.Visuals/Media/BezierSegment .cs b/src/Avalonia.Visuals/Media/BezierSegment .cs index 8bea03bc23..ddb818c3ac 100644 --- a/src/Avalonia.Visuals/Media/BezierSegment .cs +++ b/src/Avalonia.Visuals/Media/BezierSegment .cs @@ -61,5 +61,8 @@ namespace Avalonia.Media { ctx.CubicBezierTo(Point1, Point2, Point3); } + + public override string ToString() + => $"C {Point1} {Point2} {Point3}"; } } \ No newline at end of file diff --git a/src/Avalonia.Visuals/Media/Brush.cs b/src/Avalonia.Visuals/Media/Brush.cs index f36cd88537..0d08f92685 100644 --- a/src/Avalonia.Visuals/Media/Brush.cs +++ b/src/Avalonia.Visuals/Media/Brush.cs @@ -69,14 +69,14 @@ namespace Avalonia.Media protected static void AffectsRender(params AvaloniaProperty[] properties) where T : Brush { - void Invalidate(AvaloniaPropertyChangedEventArgs e) + static void Invalidate(AvaloniaPropertyChangedEventArgs e) { (e.Sender as T)?.RaiseInvalidated(EventArgs.Empty); } foreach (var property in properties) { - property.Changed.Subscribe(Invalidate); + property.Changed.Subscribe(e => Invalidate(e)); } } diff --git a/src/Avalonia.Visuals/Media/FontManager.cs b/src/Avalonia.Visuals/Media/FontManager.cs index 0c5e88b47a..96a7e9a3b3 100644 --- a/src/Avalonia.Visuals/Media/FontManager.cs +++ b/src/Avalonia.Visuals/Media/FontManager.cs @@ -1,6 +1,7 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; @@ -19,7 +20,7 @@ namespace Avalonia.Media new ConcurrentDictionary(); private readonly FontFamily _defaultFontFamily; - private FontManager(IFontManagerImpl platformImpl) + public FontManager(IFontManagerImpl platformImpl) { PlatformImpl = platformImpl; @@ -39,14 +40,9 @@ namespace Avalonia.Media return current; } - var renderInterface = AvaloniaLocator.Current.GetService(); + var fontManagerImpl = AvaloniaLocator.Current.GetService(); - var fontManagerImpl = renderInterface?.CreateFontManager(); - - if (fontManagerImpl == null) - { - return null; - } + if (fontManagerImpl == null) throw new InvalidOperationException("No font manager implementation was registered."); current = new FontManager(fontManagerImpl); diff --git a/src/Avalonia.Visuals/Media/GlyphRun.cs b/src/Avalonia.Visuals/Media/GlyphRun.cs index 43151deece..9b10981fa7 100644 --- a/src/Avalonia.Visuals/Media/GlyphRun.cs +++ b/src/Avalonia.Visuals/Media/GlyphRun.cs @@ -13,13 +13,14 @@ namespace Avalonia.Media /// public sealed class GlyphRun : IDisposable { - private static readonly IPlatformRenderInterface s_platformRenderInterface = - AvaloniaLocator.Current.GetService(); + private static readonly IComparer s_ascendingComparer = Comparer.Default; + private static readonly IComparer s_descendingComparer = new ReverseComparer(); private IGlyphRunImpl _glyphRunImpl; private GlyphTypeface _glyphTypeface; private double _fontRenderingEmSize; private Rect? _bounds; + private int _biDiLevel; private ReadOnlySlice _glyphIndices; private ReadOnlySlice _glyphAdvances; @@ -45,7 +46,7 @@ namespace Avalonia.Media /// The glyph offsets. /// The characters. /// The glyph clusters. - /// The bidi level. + /// The bidi level. /// The bound. public GlyphRun( GlyphTypeface glyphTypeface, @@ -55,7 +56,7 @@ namespace Avalonia.Media ReadOnlySlice glyphOffsets = default, ReadOnlySlice characters = default, ReadOnlySlice glyphClusters = default, - int bidiLevel = 0, + int biDiLevel = 0, Rect? bounds = null) { GlyphTypeface = glyphTypeface; @@ -72,7 +73,7 @@ namespace Avalonia.Media GlyphClusters = glyphClusters; - BidiLevel = bidiLevel; + BiDiLevel = biDiLevel; Initialize(bounds); } @@ -143,21 +144,21 @@ namespace Avalonia.Media /// /// Gets or sets the bidirectional nesting level of the . /// - public int BidiLevel + public int BiDiLevel { - get; - set; + get => _biDiLevel; + set => Set(ref _biDiLevel, value); } /// - /// + /// Gets the scale of the current /// internal double Scale => FontRenderingEmSize / GlyphTypeface.DesignEmHeight; /// - /// + /// Returns true if the text direction is left-to-right. Otherwise, returns false. /// - internal bool IsLeftToRight => ((BidiLevel & 1) == 0); + public bool IsLeftToRight => ((BiDiLevel & 1) == 0); /// /// Gets or sets the conservative bounding box of the . @@ -173,9 +174,11 @@ namespace Avalonia.Media return _bounds.Value; } - set => _bounds = value; } + /// + /// The platform implementation of the . + /// public IGlyphRunImpl GlyphRunImpl { get @@ -189,19 +192,38 @@ namespace Avalonia.Media } } + /// + /// Retrieves the offset from the leading edge of the + /// to the leading or trailing edge of a caret stop containing the specified character hit. + /// + /// The to use for computing the offset. + /// + /// A that represents the offset from the leading edge of the + /// to the leading or trailing edge of a caret stop containing the character hit. + /// public double GetDistanceFromCharacterHit(CharacterHit characterHit) { var distance = 0.0; - var end = characterHit.FirstCharacterIndex + characterHit.TrailingLength; + if (characterHit.FirstCharacterIndex + characterHit.TrailingLength > Characters.End) + { + return Bounds.Width; + } + + var glyphIndex = FindGlyphIndex(characterHit.FirstCharacterIndex); + + var currentCluster = _glyphClusters[glyphIndex]; - for (var i = 0; i < _glyphClusters.Length; i++) + if (characterHit.TrailingLength > 0) { - if (_glyphClusters[i] >= end) + while (glyphIndex < _glyphClusters.Length && _glyphClusters[glyphIndex] == currentCluster) { - break; + glyphIndex++; } + } + for (var i = 0; i < glyphIndex; i++) + { if (GlyphAdvances.IsEmpty) { var glyph = GlyphIndices[i]; @@ -217,6 +239,15 @@ namespace Avalonia.Media return distance; } + /// + /// Retrieves the value that represents the character hit of the caret of the . + /// + /// Offset to use for computing the caret character hit. + /// Determines whether the character hit is inside the . + /// + /// A value that represents the character hit that is closest to the distance value. + /// The out parameter isInside returns true if the character hit is inside the ; otherwise, false. + /// public CharacterHit GetCharacterHitFromDistance(double distance, out bool isInside) { // Before @@ -245,37 +276,46 @@ namespace Avalonia.Media for (; index < GlyphIndices.Length; index++) { + double advance; + if (GlyphAdvances.IsEmpty) { var glyph = GlyphIndices[index]; - currentX += GlyphTypeface.GetGlyphAdvance(glyph) * Scale; + advance = GlyphTypeface.GetGlyphAdvance(glyph) * Scale; } else { - currentX += GlyphAdvances[index]; + advance = GlyphAdvances[index]; } - if (currentX > distance) + if (currentX + advance >= distance) { break; } - } - if (index == GlyphIndices.Length) - { - index--; + currentX += advance; } var characterHit = FindNearestCharacterHit(GlyphClusters[index], out var width); - isInside = distance < currentX && width > 0; + var offset = GetDistanceFromCharacterHit(new CharacterHit(characterHit.FirstCharacterIndex)); + + isInside = true; - var isTrailing = distance > currentX - width / 2; + var isTrailing = distance > offset + width / 2; return isTrailing ? characterHit : new CharacterHit(characterHit.FirstCharacterIndex); } + /// + /// Retrieves the next valid caret character hit in the logical direction in the . + /// + /// The to use for computing the next hit value. + /// + /// A that represents the next valid caret character hit in the logical direction. + /// If the return value is equal to characterHit, no further navigation is possible in the . + /// public CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) { if (characterHit.TrailingLength == 0) @@ -288,11 +328,24 @@ namespace Avalonia.Media return new CharacterHit(nextCharacterHit.FirstCharacterIndex); } + /// + /// Retrieves the previous valid caret character hit in the logical direction in the . + /// + /// The to use for computing the previous hit value. + /// + /// A cref="CharacterHit"/> that represents the previous valid caret character hit in the logical direction. + /// If the return value is equal to characterHit, no further navigation is possible in the . + /// public CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) { - return characterHit.TrailingLength == 0 ? - FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _) : - new CharacterHit(characterHit.FirstCharacterIndex); + if (characterHit.TrailingLength != 0) + { + return new CharacterHit(characterHit.FirstCharacterIndex); + } + + return characterHit.FirstCharacterIndex == Characters.Start ? + new CharacterHit(Characters.Start) : + FindNearestCharacterHit(characterHit.FirstCharacterIndex - 1, out _); } private class ReverseComparer : IComparer @@ -303,83 +356,121 @@ namespace Avalonia.Media } } - private static readonly IComparer s_ascendingComparer = Comparer.Default; - private static readonly IComparer s_descendingComparer = new ReverseComparer(); - - internal CharacterHit FindNearestCharacterHit(int index, out double width) + /// + /// Finds a glyph index for given character index. + /// + /// The character index. + /// + /// The glyph index. + /// + public int FindGlyphIndex(int characterIndex) { - width = 0.0; + if (IsLeftToRight) + { + if (characterIndex < _glyphClusters[0]) + { + return 0; + } - if (index < 0) + if (characterIndex > _glyphClusters[_glyphClusters.Length - 1]) + { + return _glyphClusters.End; + } + } + else { - return default; + if (characterIndex < _glyphClusters[_glyphClusters.Length - 1]) + { + return _glyphClusters.End; + } + + if (characterIndex > _glyphClusters[0]) + { + return 0; + } } var comparer = IsLeftToRight ? s_ascendingComparer : s_descendingComparer; - var clusters = _glyphClusters.AsSpan(); + var clusters = _glyphClusters.Buffer.Span; - int start; - - if (index == 0 && clusters[0] == 0) - { - start = 0; - } - else - { - // Find the start of the cluster at the character index. - start = clusters.BinarySearch((ushort)index, comparer); - } + // Find the start of the cluster at the character index. + var start = clusters.BinarySearch((ushort)characterIndex, comparer); // No cluster found. if (start < 0) { - while (index > 0 && start < 0) + while (characterIndex > 0 && start < 0) { - index--; + characterIndex--; - start = clusters.BinarySearch((ushort)index, comparer); + start = clusters.BinarySearch((ushort)characterIndex, comparer); } if (start < 0) { - return default; + return -1; } } - var trailingLength = 0; - - var currentCluster = clusters[start]; - - while (start > 0 && clusters[start - 1] == currentCluster) + while (start > 0 && clusters[start - 1] == clusters[start]) { start--; } - for (var lastIndex = start; lastIndex < _glyphClusters.Length; ++lastIndex) - { - if (_glyphClusters[lastIndex] != currentCluster) - { - break; - } + return start; + } + + /// + /// Finds the nearest at given index. + /// + /// The index. + /// The width of found cluster. + /// + /// The nearest . + /// + public CharacterHit FindNearestCharacterHit(int index, out double width) + { + width = 0.0; + + var start = FindGlyphIndex(index); + + var currentCluster = _glyphClusters[start]; + + var trailingLength = 0; + while (start < _glyphClusters.Length && _glyphClusters[start] == currentCluster) + { if (GlyphAdvances.IsEmpty) { - var glyph = GlyphIndices[lastIndex]; + var glyph = GlyphIndices[start]; width += GlyphTypeface.GetGlyphAdvance(glyph) * Scale; } else { - width += GlyphAdvances[lastIndex]; + width += GlyphAdvances[start]; } trailingLength++; + start++; + } + + if (start == _glyphClusters.Length && + currentCluster + trailingLength != Characters.Start + Characters.Length) + { + trailingLength = Characters.Start + Characters.Length - currentCluster; } return new CharacterHit(currentCluster, trailingLength); } + /// + /// Calculates the bounds of the . + /// + /// + /// The calculated bounds. + /// private Rect CalculateBounds() { var scale = FontRenderingEmSize / GlyphTypeface.DesignEmHeight; @@ -416,6 +507,10 @@ namespace Avalonia.Media field = value; } + /// + /// Initializes the . + /// + /// Optional pre computed bounds. private void Initialize(Rect? bounds) { if (GlyphIndices.Length == 0) @@ -435,7 +530,9 @@ namespace Avalonia.Media throw new InvalidOperationException(); } - _glyphRunImpl = s_platformRenderInterface.CreateGlyphRun(this, out var width); + var platformRenderInterface = AvaloniaLocator.Current.GetService(); + + _glyphRunImpl = platformRenderInterface.CreateGlyphRun(this, out var width); if (bounds.HasValue) { diff --git a/src/Avalonia.Visuals/Media/GlyphTypeface.cs b/src/Avalonia.Visuals/Media/GlyphTypeface.cs index 6468f701d6..f378cc597e 100644 --- a/src/Avalonia.Visuals/Media/GlyphTypeface.cs +++ b/src/Avalonia.Visuals/Media/GlyphTypeface.cs @@ -2,16 +2,15 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; - using Avalonia.Platform; namespace Avalonia.Media { public sealed class GlyphTypeface : IDisposable { - public GlyphTypeface(Typeface typeface) - { - PlatformImpl = FontManager.Current?.PlatformImpl.CreateGlyphTypeface(typeface); + public GlyphTypeface(Typeface typeface) + : this(FontManager.Current?.PlatformImpl.CreateGlyphTypeface(typeface)) + { } public GlyphTypeface(IGlyphTypefaceImpl platformImpl) @@ -75,7 +74,7 @@ namespace Avalonia.Media /// Returns an glyph index for the specified codepoint. /// /// - /// Returns 0 if a glyph isn't found. + /// Returns a replacement glyph if a glyph isn't found. /// /// The codepoint. /// @@ -83,6 +82,21 @@ namespace Avalonia.Media /// public ushort GetGlyph(uint codepoint) => PlatformImpl.GetGlyph(codepoint); + /// + /// Tries to get an glyph index for specified codepoint. + /// + /// The codepoint. + /// A glyph index. + /// + /// true if an glyph index was found, false otherwise. + /// + public bool TryGetGlyph(uint codepoint, out ushort glyph) + { + glyph = PlatformImpl.GetGlyph(codepoint); + + return glyph != 0; + } + /// /// Returns an array of glyph indices. Codepoints that are not represented by the font are returned as 0. /// diff --git a/src/Avalonia.Visuals/Media/Imaging/CroppedBitmap.cs b/src/Avalonia.Visuals/Media/Imaging/CroppedBitmap.cs new file mode 100644 index 0000000000..70823e114c --- /dev/null +++ b/src/Avalonia.Visuals/Media/Imaging/CroppedBitmap.cs @@ -0,0 +1,96 @@ +using System; +using Avalonia.Visuals.Media.Imaging; + +namespace Avalonia.Media.Imaging +{ + /// + /// Crops a Bitmap. + /// + public class CroppedBitmap : AvaloniaObject, IImage, IAffectsRender, IDisposable + { + /// + /// Defines the property. + /// + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty SourceRectProperty = + AvaloniaProperty.Register(nameof(SourceRect)); + + public event EventHandler Invalidated; + + static CroppedBitmap() + { + SourceRectProperty.Changed.AddClassHandler((x, e) => x.SourceRectChanged(e)); + SourceProperty.Changed.AddClassHandler((x, e) => x.SourceChanged(e)); + } + + /// + /// Gets or sets the source for the bitmap. + /// + public IImage Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + /// + /// Gets or sets the rectangular area that the bitmap is cropped to. + /// + public PixelRect SourceRect + { + get => GetValue(SourceRectProperty); + set => SetValue(SourceRectProperty, value); + } + + public CroppedBitmap() + { + Source = null; + SourceRect = default; + } + + public CroppedBitmap(IImage source, PixelRect sourceRect) + { + Source = source; + SourceRect = sourceRect; + } + + private void SourceChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue == null) + return; + if (!(e.NewValue is IBitmap)) + throw new ArgumentException("Only IBitmap supported as source"); + Invalidated?.Invoke(this, e); + } + + private void SourceRectChanged(AvaloniaPropertyChangedEventArgs e) => Invalidated?.Invoke(this, e); + + public virtual void Dispose() + { + (Source as IBitmap)?.Dispose(); + } + + public Size Size { + get + { + if (Source == null) + return Size.Empty; + if (SourceRect.IsEmpty) + return Source.Size; + return SourceRect.Size.ToSizeWithDpi((Source as IBitmap).Dpi); + } + } + + public void Draw(DrawingContext context, Rect sourceRect, Rect destRect, BitmapInterpolationMode bitmapInterpolationMode) + { + if (Source == null) + return; + var topLeft = SourceRect.TopLeft.ToPointWithDpi((Source as IBitmap).Dpi); + Source.Draw(context, sourceRect.Translate(new Vector(topLeft.X, topLeft.Y)), destRect, bitmapInterpolationMode); + } + } +} diff --git a/src/Avalonia.Visuals/Media/Immutable/ImmutableTextDecoration.cs b/src/Avalonia.Visuals/Media/Immutable/ImmutableTextDecoration.cs new file mode 100644 index 0000000000..9fcf6a6a82 --- /dev/null +++ b/src/Avalonia.Visuals/Media/Immutable/ImmutableTextDecoration.cs @@ -0,0 +1,56 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media.Immutable +{ + /// + /// An immutable representation of a . + /// + public class ImmutableTextDecoration + { + public ImmutableTextDecoration(TextDecorationLocation location, ImmutablePen pen, + TextDecorationUnit penThicknessUnit, + double penOffset, TextDecorationUnit penOffsetUnit) + { + Location = location; + Pen = pen; + PenThicknessUnit = penThicknessUnit; + PenOffset = penOffset; + PenOffsetUnit = penOffsetUnit; + } + + /// + /// Gets or sets the location. + /// + /// + /// The location. + /// + public TextDecorationLocation Location { get; } + + /// + /// Gets or sets the pen. + /// + /// + /// The pen. + /// + public ImmutablePen Pen { get; } + + /// + /// Gets the units in which the Thickness of the text decoration's is expressed. + /// + public TextDecorationUnit PenThicknessUnit { get; } + + /// + /// Gets or sets the pen offset. + /// + /// + /// The pen offset. + /// + public double PenOffset { get; } + + /// + /// Gets the units in which the value is expressed. + /// + public TextDecorationUnit PenOffsetUnit { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/LineSegment.cs b/src/Avalonia.Visuals/Media/LineSegment.cs index d81f67d99f..81e53ff7c6 100644 --- a/src/Avalonia.Visuals/Media/LineSegment.cs +++ b/src/Avalonia.Visuals/Media/LineSegment.cs @@ -27,5 +27,8 @@ namespace Avalonia.Media { ctx.LineTo(Point); } + + public override string ToString() + => $"L {Point}"; } } \ No newline at end of file diff --git a/src/Avalonia.Visuals/Media/PathFigure.cs b/src/Avalonia.Visuals/Media/PathFigure.cs index 6a438a85ee..720fca8337 100644 --- a/src/Avalonia.Visuals/Media/PathFigure.cs +++ b/src/Avalonia.Visuals/Media/PathFigure.cs @@ -98,5 +98,8 @@ namespace Avalonia.Media } private PathSegments _segments; + + public override string ToString() + => $"M {StartPoint} {string.Join(" ", _segments)}{(IsClosed ? "Z" : "")}"; } } \ No newline at end of file diff --git a/src/Avalonia.Visuals/Media/PathGeometry.cs b/src/Avalonia.Visuals/Media/PathGeometry.cs index 3a91c924d2..be7ffe57a7 100644 --- a/src/Avalonia.Visuals/Media/PathGeometry.cs +++ b/src/Avalonia.Visuals/Media/PathGeometry.cs @@ -112,5 +112,9 @@ namespace Avalonia.Media () => InvalidateGeometry()); _figuresPropertiesObserver = figures?.TrackItemPropertyChanged(_ => InvalidateGeometry()); } + + + public override string ToString() + => $"{(FillRule != FillRule.EvenOdd ? "F1 " : "")}{(string.Join(" ", Figures))}"; } } diff --git a/src/Avalonia.Visuals/Media/Pen.cs b/src/Avalonia.Visuals/Media/Pen.cs index b88fae28ff..8ab2b11331 100644 --- a/src/Avalonia.Visuals/Media/Pen.cs +++ b/src/Avalonia.Visuals/Media/Pen.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See licence.md file in the project root for full license information. using System; -using System.Collections.Generic; using Avalonia.Media.Immutable; using Avalonia.Utilities; @@ -197,7 +196,15 @@ namespace Avalonia.Media protected static void AffectsRender(params AvaloniaProperty[] properties) where T : Pen { - void Invalidate(AvaloniaPropertyChangedEventArgs e) + static void Invalidate(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is T sender) + { + sender.RaiseInvalidated(EventArgs.Empty); + } + } + + static void InvalidateAndSubscribe(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is T sender) { @@ -223,7 +230,14 @@ namespace Avalonia.Media foreach (var property in properties) { - property.Changed.Subscribe(Invalidate); + if (property.CanValueAffectRender()) + { + property.Changed.Subscribe(e => InvalidateAndSubscribe(e)); + } + else + { + property.Changed.Subscribe(e => Invalidate(e)); + } } } diff --git a/src/Avalonia.Visuals/Media/QuadraticBezierSegment .cs b/src/Avalonia.Visuals/Media/QuadraticBezierSegment .cs index 31c364edf4..f1a0ccaaa0 100644 --- a/src/Avalonia.Visuals/Media/QuadraticBezierSegment .cs +++ b/src/Avalonia.Visuals/Media/QuadraticBezierSegment .cs @@ -42,5 +42,8 @@ namespace Avalonia.Media { ctx.QuadraticBezierTo(Point1, Point2); } + + public override string ToString() + => $"Q {Point1} {Point2}"; } } \ No newline at end of file diff --git a/src/Avalonia.Visuals/Media/TextDecoration.cs b/src/Avalonia.Visuals/Media/TextDecoration.cs new file mode 100644 index 0000000000..a8cf0eaa76 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextDecoration.cs @@ -0,0 +1,106 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Media.Immutable; + +namespace Avalonia.Media +{ + /// + /// Represents a text decoration, which is a visual ornamentation that is added to text (such as an underline). + /// + public class TextDecoration : AvaloniaObject + { + /// + /// Defines the property. + /// + public static readonly StyledProperty LocationProperty = + AvaloniaProperty.Register(nameof(Location)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PenProperty = + AvaloniaProperty.Register(nameof(Pen)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PenThicknessUnitProperty = + AvaloniaProperty.Register(nameof(PenThicknessUnit)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PenOffsetProperty = + AvaloniaProperty.Register(nameof(PenOffset)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty PenOffsetUnitProperty = + AvaloniaProperty.Register(nameof(PenOffsetUnit)); + + /// + /// Gets or sets the location. + /// + /// + /// The location. + /// + public TextDecorationLocation Location + { + get => GetValue(LocationProperty); + set => SetValue(LocationProperty, value); + } + + /// + /// Gets or sets the pen. + /// + /// + /// The pen. + /// + public IPen Pen + { + get => GetValue(PenProperty); + set => SetValue(PenProperty, value); + } + + /// + /// Gets the units in which the Thickness of the text decoration's is expressed. + /// + public TextDecorationUnit PenThicknessUnit + { + get => GetValue(PenThicknessUnitProperty); + set => SetValue(PenThicknessUnitProperty, value); + } + + /// + /// Gets or sets the pen offset. + /// + /// + /// The pen offset. + /// + public double PenOffset + { + get => GetValue(PenOffsetProperty); + set => SetValue(PenOffsetProperty, value); + } + + /// + /// Gets the units in which the value is expressed. + /// + public TextDecorationUnit PenOffsetUnit + { + get => GetValue(PenOffsetUnitProperty); + set => SetValue(PenOffsetUnitProperty, value); + } + + /// + /// Creates an immutable clone of the . + /// + /// The immutable clone. + public ImmutableTextDecoration ToImmutable() + { + return new ImmutableTextDecoration(Location, Pen?.ToImmutable(), PenThicknessUnit, PenOffset, PenOffsetUnit); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextDecorationCollection.cs b/src/Avalonia.Visuals/Media/TextDecorationCollection.cs new file mode 100644 index 0000000000..1380a41a3f --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextDecorationCollection.cs @@ -0,0 +1,82 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using Avalonia.Collections; +using Avalonia.Media.Immutable; +using Avalonia.Utilities; + +namespace Avalonia.Media +{ + /// + /// A collection that holds objects. + /// + public class TextDecorationCollection : AvaloniaList + { + /// + /// Creates an immutable clone of the . + /// + /// The immutable clone. + public ImmutableTextDecoration[] ToImmutable() + { + var immutable = new ImmutableTextDecoration[Count]; + + for (var i = 0; i < Count; i++) + { + immutable[i] = this[i].ToImmutable(); + } + + return immutable; + } + + /// + /// Parses a string. + /// + /// The string. + /// The . + public static TextDecorationCollection Parse(string s) + { + var locations = new List(); + + using (var tokenizer = new StringTokenizer(s, ',', "Invalid text decoration.")) + { + while (tokenizer.TryReadString(out var name)) + { + var location = GetTextDecorationLocation(name); + + if (locations.Contains(location)) + { + throw new ArgumentException("Text decoration already specified.", nameof(s)); + } + + locations.Add(location); + } + } + + var textDecorations = new TextDecorationCollection(); + + foreach (var textDecorationLocation in locations) + { + textDecorations.Add(new TextDecoration { Location = textDecorationLocation }); + } + + return textDecorations; + } + + /// + /// Parses a string. + /// + /// The string. + /// The . + private static TextDecorationLocation GetTextDecorationLocation(string s) + { + if (Enum.TryParse(s,true, out var location)) + { + return location; + } + + throw new ArgumentException("Could not parse text decoration.", nameof(s)); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextDecorationLocation.cs b/src/Avalonia.Visuals/Media/TextDecorationLocation.cs new file mode 100644 index 0000000000..6bc90b2ccf --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextDecorationLocation.cs @@ -0,0 +1,31 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media +{ + /// + /// Specifies the vertical position of a object. + /// + public enum TextDecorationLocation + { + /// + /// The underline position. + /// + Underline = 0, + + /// + /// The over line position. + /// + Overline = 1, + + /// + /// The strikethrough position. + /// + Strikethrough = 2, + + /// + /// The baseline position. + /// + Baseline = 3, + } +} diff --git a/src/Avalonia.Visuals/Media/TextDecorationUnit.cs b/src/Avalonia.Visuals/Media/TextDecorationUnit.cs new file mode 100644 index 0000000000..6336485175 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextDecorationUnit.cs @@ -0,0 +1,29 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media +{ + /// + /// Specifies the unit type of either a or a thickness value. + /// + public enum TextDecorationUnit + { + /// + /// A unit value that is relative to the font used for the . + /// If the decoration spans multiple fonts, an average recommended value is calculated. + /// This is the default value. + /// + FontRecommended, + + /// + /// A unit value that is relative to the em size of the font. + /// The value of the offset or thickness is equal to the offset or thickness value multiplied by the font em size. + /// + FontRenderingEmSize, + + /// + /// A unit value that is expressed in pixels. + /// + Pixel + } +} diff --git a/src/Avalonia.Visuals/Media/TextDecorations.cs b/src/Avalonia.Visuals/Media/TextDecorations.cs new file mode 100644 index 0000000000..6430f80630 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextDecorations.cs @@ -0,0 +1,66 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media +{ + /// + /// Defines a set of commonly used text decorations. + /// + public static class TextDecorations + { + static TextDecorations() + { + Underline = new TextDecorationCollection + { + new TextDecoration + { + Location = TextDecorationLocation.Underline + } + }; + + Strikethrough = new TextDecorationCollection + { + new TextDecoration + { + Location = TextDecorationLocation.Strikethrough + } + }; + + Overline = new TextDecorationCollection + { + new TextDecoration + { + Location = TextDecorationLocation.Overline + } + }; + + Baseline = new TextDecorationCollection + { + new TextDecoration + { + Location = TextDecorationLocation.Baseline + } + }; + } + + /// + /// Gets a containing an underline. + /// + public static TextDecorationCollection Underline { get; } + + /// + /// Gets a containing a strikethrough. + /// + public static TextDecorationCollection Strikethrough { get; } + + /// + /// Gets a containing an overline. + /// + public static TextDecorationCollection Overline { get; } + + /// + /// Gets a containing a baseline. + /// + public static TextDecorationCollection Baseline { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs new file mode 100644 index 0000000000..4903342cea --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/DrawableTextRun.cs @@ -0,0 +1,22 @@ +using Avalonia.Platform; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that supports drawing content. + /// + public abstract class DrawableTextRun : TextRun + { + /// + /// Gets the bounds. + /// + public abstract Rect Bounds { get; } + + /// + /// Draws the at the given origin. + /// + /// The drawing context. + /// The origin. + public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs new file mode 100644 index 0000000000..8e2d5cdfac --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/FontMetrics.cs @@ -0,0 +1,74 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A metric that holds information about font specific measurements. + /// + public readonly struct FontMetrics + { + public FontMetrics(Typeface typeface, double fontSize) + { + var glyphTypeface = typeface.GlyphTypeface; + + var scale = fontSize / glyphTypeface.DesignEmHeight; + + Ascent = glyphTypeface.Ascent * scale; + + Descent = glyphTypeface.Descent * scale; + + LineGap = glyphTypeface.LineGap * scale; + + LineHeight = Descent - Ascent + LineGap; + + UnderlineThickness = glyphTypeface.UnderlineThickness * scale; + + UnderlinePosition = glyphTypeface.UnderlinePosition * scale; + + StrikethroughThickness = glyphTypeface.StrikethroughThickness * scale; + + StrikethroughPosition = glyphTypeface.StrikethroughPosition * scale; + } + + /// + /// Gets the recommended distance above the baseline. + /// + public double Ascent { get; } + + /// + /// Gets the recommended distance under the baseline. + /// + public double Descent { get; } + + /// + /// Gets the recommended additional space between two lines of text. + /// + public double LineGap { get; } + + /// + /// Gets the estimated line height. + /// + public double LineHeight { get; } + + /// + /// Gets a value that indicates the thickness of the underline. + /// + public double UnderlineThickness { get; } + + /// + /// Gets a value that indicates the distance of the underline from the baseline. + /// + public double UnderlinePosition { get; } + + /// + /// Gets a value that indicates the thickness of the underline. + /// + public double StrikethroughThickness { get; } + + /// + /// Gets a value that indicates the distance of the strikethrough from the baseline. + /// + public double StrikethroughPosition { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ITextSource.cs b/src/Avalonia.Visuals/Media/TextFormatting/ITextSource.cs new file mode 100644 index 0000000000..0f9994bc65 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/ITextSource.cs @@ -0,0 +1,15 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Produces objects that are used by the . + /// + public interface ITextSource + { + /// + /// Gets a for specified text source index. + /// + /// The text source index. + /// The text run. + TextRun GetTextRun(int textSourceIndex); + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs new file mode 100644 index 0000000000..00f9b918cb --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/ShapedTextRun.cs @@ -0,0 +1,212 @@ +using Avalonia.Media.Immutable; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that holds a shaped glyph run. + /// + public sealed class ShapedTextRun : DrawableTextRun + { + public ShapedTextRun(ReadOnlySlice text, TextStyle style) : this( + TextShaper.Current.ShapeText(text, style.TextFormat), style) + { + } + + public ShapedTextRun(GlyphRun glyphRun, TextStyle style) + { + Text = glyphRun.Characters; + Style = style; + GlyphRun = glyphRun; + } + + /// + public override Rect Bounds => GlyphRun.Bounds; + + /// + /// Gets the glyph run. + /// + /// + /// The glyphs. + /// + public GlyphRun GlyphRun { get; } + + /// + public override void Draw(IDrawingContextImpl drawingContext, Point origin) + { + if (GlyphRun.GlyphIndices.Length == 0) + { + return; + } + + if (Style.TextFormat.Typeface == null) + { + return; + } + + if (Style.Foreground == null) + { + return; + } + + drawingContext.DrawGlyphRun(Style.Foreground, GlyphRun, origin); + + if (Style.TextDecorations == null) + { + return; + } + + foreach (var textDecoration in Style.TextDecorations) + { + DrawTextDecoration(drawingContext, textDecoration, origin); + } + } + + /// + /// Draws the at given origin. + /// + /// The drawing context. + /// The text decoration. + /// The origin. + private void DrawTextDecoration(IDrawingContextImpl drawingContext, ImmutableTextDecoration textDecoration, Point origin) + { + var textFormat = Style.TextFormat; + + var fontMetrics = Style.TextFormat.FontMetrics; + + var thickness = textDecoration.Pen?.Thickness ?? 1.0; + + switch (textDecoration.PenThicknessUnit) + { + case TextDecorationUnit.FontRecommended: + switch (textDecoration.Location) + { + case TextDecorationLocation.Underline: + thickness = fontMetrics.UnderlineThickness; + break; + case TextDecorationLocation.Strikethrough: + thickness = fontMetrics.StrikethroughThickness; + break; + } + break; + case TextDecorationUnit.FontRenderingEmSize: + thickness = textFormat.FontRenderingEmSize * thickness; + break; + } + + switch (textDecoration.Location) + { + case TextDecorationLocation.Overline: + origin += new Point(0, textFormat.FontMetrics.Ascent); + break; + case TextDecorationLocation.Strikethrough: + origin += new Point(0, -textFormat.FontMetrics.StrikethroughPosition); + break; + case TextDecorationLocation.Underline: + origin += new Point(0, -textFormat.FontMetrics.UnderlinePosition); + break; + } + + switch (textDecoration.PenOffsetUnit) + { + case TextDecorationUnit.FontRenderingEmSize: + origin += new Point(0, textDecoration.PenOffset * textFormat.FontRenderingEmSize); + break; + case TextDecorationUnit.Pixel: + origin += new Point(0, textDecoration.PenOffset); + break; + } + + var pen = new ImmutablePen( + textDecoration.Pen?.Brush ?? Style.Foreground.ToImmutable(), + thickness, + textDecoration.Pen?.DashStyle?.ToImmutable(), + textDecoration.Pen?.LineCap ?? default, + textDecoration.Pen?.LineJoin ?? PenLineJoin.Miter, + textDecoration.Pen?.MiterLimit ?? 10.0); + + drawingContext.DrawLine(pen, origin, origin + new Point(GlyphRun.Bounds.Width, 0)); + } + + /// + /// Splits the at specified length. + /// + /// The length. + /// The split result. + public SplitTextCharactersResult Split(int length) + { + var glyphCount = 0; + + var firstCharacters = GlyphRun.Characters.Take(length); + + var codepointEnumerator = new CodepointEnumerator(firstCharacters); + + while (codepointEnumerator.MoveNext()) + { + glyphCount++; + } + + if (GlyphRun.Characters.Length == length) + { + return new SplitTextCharactersResult(this, null); + } + + if (GlyphRun.GlyphIndices.Length == glyphCount) + { + return new SplitTextCharactersResult(this, null); + } + + var firstGlyphRun = new GlyphRun( + Style.TextFormat.Typeface.GlyphTypeface, + Style.TextFormat.FontRenderingEmSize, + GlyphRun.GlyphIndices.Take(glyphCount), + GlyphRun.GlyphAdvances.Take(glyphCount), + GlyphRun.GlyphOffsets.Take(glyphCount), + GlyphRun.Characters.Take(length), + GlyphRun.GlyphClusters.Take(length)); + + var firstTextRun = new ShapedTextRun(firstGlyphRun, Style); + + var secondGlyphRun = new GlyphRun( + Style.TextFormat.Typeface.GlyphTypeface, + Style.TextFormat.FontRenderingEmSize, + GlyphRun.GlyphIndices.Skip(glyphCount), + GlyphRun.GlyphAdvances.Skip(glyphCount), + GlyphRun.GlyphOffsets.Skip(glyphCount), + GlyphRun.Characters.Skip(length), + GlyphRun.GlyphClusters.Skip(length)); + + var secondTextRun = new ShapedTextRun(secondGlyphRun, Style); + + return new SplitTextCharactersResult(firstTextRun, secondTextRun); + } + + public readonly struct SplitTextCharactersResult + { + public SplitTextCharactersResult(ShapedTextRun first, ShapedTextRun second) + { + First = first; + + Second = second; + } + + /// + /// Gets the first text run. + /// + /// + /// The first text run. + /// + public ShapedTextRun First { get; } + + /// + /// Gets the second text run. + /// + /// + /// The second text run. + /// + public ShapedTextRun Second { get; } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs new file mode 100644 index 0000000000..30d513386e --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextFormatter.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + internal class SimpleTextFormatter : TextFormatter + { + private static readonly ReadOnlySlice s_ellipsis = new ReadOnlySlice(new[] { '\u2026' }); + + /// + /// Formats a text line. + /// + /// The text source. + /// The first character index to start the text line from. + /// A value that specifies the width of the paragraph that the line fills. + /// A value that represents paragraph properties, + /// such as TextWrapping, TextAlignment, or TextStyle. + /// The formatted line. + public override TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, + TextParagraphProperties paragraphProperties) + { + var textTrimming = paragraphProperties.TextTrimming; + var textWrapping = paragraphProperties.TextWrapping; + TextLine textLine; + + var textRuns = FormatTextRuns(textSource, firstTextSourceIndex, out var textPointer); + + if (textTrimming != TextTrimming.None) + { + textLine = PerformTextTrimming(textPointer, textRuns, paragraphWidth, paragraphProperties); + } + else + { + if (textWrapping == TextWrapping.Wrap) + { + textLine = PerformTextWrapping(textPointer, textRuns, paragraphWidth, paragraphProperties); + } + else + { + var textLineMetrics = + TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment); + + textLine = new SimpleTextLine(textPointer, textRuns, textLineMetrics); + } + } + + return textLine; + } + + /// + /// Formats text runs with optional text style overrides. + /// + /// The text source. + /// The first text source index. + /// The text pointer that covers the formatted text runs. + /// + /// The formatted text runs. + /// + private List FormatTextRuns(ITextSource textSource, int firstTextSourceIndex, out TextPointer textPointer) + { + var start = firstTextSourceIndex; + + var textRuns = new List(); + + while (true) + { + var textRun = textSource.GetTextRun(firstTextSourceIndex); + + if (textRun.Text.IsEmpty) + { + break; + } + + if (textRun is TextEndOfLine) + { + break; + } + + if (!(textRun is TextCharacters)) + { + throw new NotSupportedException("Run type not supported by the formatter."); + } + + var runText = textRun.Text; + + while (!runText.IsEmpty) + { + var shapableTextStyleRun = CreateShapableTextStyleRun(runText, textRun.Style); + + var shapedRun = new ShapedTextRun(runText.Take(shapableTextStyleRun.TextPointer.Length), + shapableTextStyleRun.Style); + + textRuns.Add(shapedRun); + + runText = runText.Skip(shapedRun.Text.Length); + } + + firstTextSourceIndex += textRun.Text.Length; + } + + textPointer = new TextPointer(start, firstTextSourceIndex - start); + + return textRuns; + } + + /// + /// Performs text trimming and returns a trimmed line. + /// + /// A value that specifies the width of the paragraph that the line fills. + /// A value that represents paragraph properties, + /// such as TextWrapping, TextAlignment, or TextStyle. + /// The text runs to perform the trimming on. + /// The text that was used to construct the text runs. + /// + private TextLine PerformTextTrimming(TextPointer text, IReadOnlyList textRuns, + double paragraphWidth, TextParagraphProperties paragraphProperties) + { + var textTrimming = paragraphProperties.TextTrimming; + var availableWidth = paragraphWidth; + var currentWidth = 0.0; + var runIndex = 0; + + while (runIndex < textRuns.Count) + { + var currentRun = textRuns[runIndex]; + + currentWidth += currentRun.GlyphRun.Bounds.Width; + + if (currentWidth > availableWidth) + { + var ellipsisRun = CreateEllipsisRun(currentRun.Style); + + var measuredLength = MeasureText(currentRun, availableWidth - ellipsisRun.GlyphRun.Bounds.Width); + + if (textTrimming == TextTrimming.WordEllipsis) + { + if (measuredLength < text.End) + { + var currentBreakPosition = 0; + + var lineBreaker = new LineBreakEnumerator(currentRun.Text); + + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) + { + var nextBreakPosition = lineBreaker.Current.PositionWrap; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition > measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; + } + + measuredLength = currentBreakPosition; + } + } + + var splitResult = SplitTextRuns(textRuns, measuredLength); + + var trimmedRuns = new List(splitResult.First.Count + 1); + + trimmedRuns.AddRange(splitResult.First); + + trimmedRuns.Add(ellipsisRun); + + var textLineMetrics = + TextLineMetrics.Create(trimmedRuns, paragraphWidth, paragraphProperties.TextAlignment); + + return new SimpleTextLine(text.Take(measuredLength), trimmedRuns, textLineMetrics); + } + + availableWidth -= currentRun.GlyphRun.Bounds.Width; + + runIndex++; + } + + return new SimpleTextLine(text, textRuns, + TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment)); + } + + /// + /// Performs text wrapping returns a list of text lines. + /// + /// The text paragraph properties. + /// The text run'S. + /// The text to analyze for break opportunities. + /// + /// + private TextLine PerformTextWrapping(TextPointer text, IReadOnlyList textRuns, + double paragraphWidth, TextParagraphProperties paragraphProperties) + { + var availableWidth = paragraphWidth; + var currentWidth = 0.0; + var runIndex = 0; + var length = 0; + + while (runIndex < textRuns.Count) + { + var currentRun = textRuns[runIndex]; + + if (currentWidth + currentRun.GlyphRun.Bounds.Width > availableWidth) + { + var measuredLength = MeasureText(currentRun, paragraphWidth - currentWidth); + + if (measuredLength < currentRun.Text.Length) + { + var currentBreakPosition = -1; + + var lineBreaker = new LineBreakEnumerator(currentRun.Text); + + while (currentBreakPosition < measuredLength && lineBreaker.MoveNext()) + { + var nextBreakPosition = lineBreaker.Current.PositionWrap; + + if (nextBreakPosition == 0) + { + break; + } + + if (nextBreakPosition > measuredLength) + { + break; + } + + currentBreakPosition = nextBreakPosition; + } + + if (currentBreakPosition != -1) + { + measuredLength = currentBreakPosition; + } + } + + length += measuredLength; + + var splitResult = SplitTextRuns(textRuns, length); + + var textLineMetrics = + TextLineMetrics.Create(splitResult.First, paragraphWidth, paragraphProperties.TextAlignment); + + return new SimpleTextLine(text.Take(length), splitResult.First, textLineMetrics); + } + + currentWidth += currentRun.GlyphRun.Bounds.Width; + + length += currentRun.GlyphRun.Characters.Length; + + runIndex++; + } + + return new SimpleTextLine(text, textRuns, + TextLineMetrics.Create(textRuns, paragraphWidth, paragraphProperties.TextAlignment)); + } + + /// + /// Measures the number of characters that fits into available width. + /// + /// The text run. + /// The available width. + /// + private int MeasureText(ShapedTextRun textRun, double availableWidth) + { + if (textRun.GlyphRun.Bounds.Width < availableWidth) + { + return textRun.Text.Length; + } + + var measuredWidth = 0.0; + + var index = 0; + + for (; index < textRun.GlyphRun.GlyphAdvances.Length; index++) + { + var advance = textRun.GlyphRun.GlyphAdvances[index]; + + if (measuredWidth + advance > availableWidth) + { + index--; + break; + } + + measuredWidth += advance; + } + + if(index < 0) + { + return 0; + } + + var cluster = textRun.GlyphRun.GlyphClusters[index]; + + var characterHit = textRun.GlyphRun.FindNearestCharacterHit(cluster, out _); + + return characterHit.FirstCharacterIndex - textRun.GlyphRun.Characters.Start + + (textRun.GlyphRun.IsLeftToRight ? characterHit.TrailingLength : 0); + } + + /// + /// Creates an ellipsis. + /// + /// The text style. + /// + private static ShapedTextRun CreateEllipsisRun(TextStyle textStyle) + { + var formatterImpl = AvaloniaLocator.Current.GetService(); + + var glyphRun = formatterImpl.ShapeText(s_ellipsis, textStyle.TextFormat); + + return new ShapedTextRun(glyphRun, textStyle); + } + + private readonly struct SplitTextRunsResult + { + public SplitTextRunsResult(IReadOnlyList first, IReadOnlyList second) + { + First = first; + + Second = second; + } + + /// + /// Gets the first text runs. + /// + /// + /// The first text runs. + /// + public IReadOnlyList First { get; } + + /// + /// Gets the second text runs. + /// + /// + /// The second text runs. + /// + public IReadOnlyList Second { get; } + } + + /// + /// Split a sequence of runs into two segments at specified length. + /// + /// The text run's. + /// The length to split at. + /// + private static SplitTextRunsResult SplitTextRuns(IReadOnlyList textRuns, int length) + { + var currentLength = 0; + + for (var i = 0; i < textRuns.Count; i++) + { + var currentRun = textRuns[i]; + + if (currentLength + currentRun.GlyphRun.Characters.Length < length) + { + currentLength += currentRun.GlyphRun.Characters.Length; + continue; + } + + var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i; + + var first = new ShapedTextRun[firstCount]; + + if (firstCount > 1) + { + for (var j = 0; j < i; j++) + { + first[j] = textRuns[j]; + } + } + + var secondCount = textRuns.Count - firstCount; + + if (currentLength + currentRun.GlyphRun.Characters.Length == length) + { + var second = new ShapedTextRun[secondCount]; + + var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; + + if (secondCount > 0) + { + for (var j = 0; j < secondCount; j++) + { + second[j] = textRuns[i + j + offset]; + } + } + + first[i] = currentRun; + + return new SplitTextRunsResult(first, second); + } + else + { + secondCount++; + + var second = new ShapedTextRun[secondCount]; + + if (secondCount > 0) + { + for (var j = 1; j < secondCount; j++) + { + second[j] = textRuns[i + j]; + } + } + + var split = currentRun.Split(length - currentLength); + + first[i] = split.First; + + second[0] = split.Second; + + return new SplitTextRunsResult(first, second); + } + } + + return new SplitTextRunsResult(textRuns, null); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs new file mode 100644 index 0000000000..11d241bc34 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/SimpleTextLine.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using Avalonia.Platform; + +namespace Avalonia.Media.TextFormatting +{ + internal class SimpleTextLine : TextLine + { + private readonly IReadOnlyList _textRuns; + + public SimpleTextLine(TextPointer textPointer, IReadOnlyList textRuns, TextLineMetrics lineMetrics) + { + Text = textPointer; + _textRuns = textRuns; + LineMetrics = lineMetrics; + } + + /// + public override TextPointer Text { get; } + + /// + public override IReadOnlyList TextRuns => _textRuns; + + /// + public override TextLineMetrics LineMetrics { get; } + + /// + public override void Draw(IDrawingContextImpl drawingContext, Point origin) + { + var currentX = origin.X; + + foreach (var textRun in _textRuns) + { + var baselineOrigin = new Point(currentX + LineMetrics.BaselineOrigin.X, + origin.Y + LineMetrics.BaselineOrigin.Y); + + textRun.Draw(drawingContext, baselineOrigin); + + currentX += textRun.Bounds.Width; + } + } + + /// + public override CharacterHit GetCharacterHitFromDistance(double distance) + { + if (distance < 0) + { + // hit happens before the line, return the first position + return new CharacterHit(Text.Start); + } + + // process hit that happens within the line + var characterHit = new CharacterHit(); + + foreach (var run in _textRuns) + { + characterHit = run.GlyphRun.GetCharacterHitFromDistance(distance, out _); + + if (distance <= run.Bounds.Width) + { + break; + } + + distance -= run.Bounds.Width; + } + + return characterHit; + } + + /// + public override double GetDistanceFromCharacterHit(CharacterHit characterHit) + { + return DistanceFromCodepointIndex(characterHit.FirstCharacterIndex + (characterHit.TrailingLength != 0 ? 1 : 0)); + } + + /// + public override CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit) + { + int nextVisibleCp; + bool navigableCpFound; + + if (characterHit.TrailingLength == 0) + { + navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex, out nextVisibleCp); + + if (navigableCpFound) + { + // Move from leading to trailing edge + return new CharacterHit(nextVisibleCp, 1); + } + } + + navigableCpFound = FindNextCodepointIndex(characterHit.FirstCharacterIndex + 1, out nextVisibleCp); + + if (navigableCpFound) + { + // Move from trailing edge of current character to trailing edge of next + return new CharacterHit(nextVisibleCp, 1); + } + + // Can't move, we're after the last character + return characterHit; + } + + /// + public override CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit) + { + int previousCodepointIndex; + bool codepointIndexFound; + + var cpHit = characterHit.FirstCharacterIndex; + var trailingHit = characterHit.TrailingLength != 0; + + // Input can be right after the end of the current line. Snap it to be at the end of the line. + if (cpHit >= Text.Start + Text.Length) + { + cpHit = Text.Start + Text.Length - 1; + + trailingHit = true; + } + + if (trailingHit) + { + codepointIndexFound = FindPreviousCodepointIndex(cpHit, out previousCodepointIndex); + + if (codepointIndexFound) + { + // Move from trailing to leading edge + return new CharacterHit(previousCodepointIndex, 0); + } + } + + codepointIndexFound = FindPreviousCodepointIndex(cpHit - 1, out previousCodepointIndex); + + if (codepointIndexFound) + { + // Move from leading edge of current character to leading edge of previous + return new CharacterHit(previousCodepointIndex, 0); + } + + // Can't move, we're before the first character + return characterHit; + } + + /// + public override CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit) + { + // same operation as move-to-previous + return GetPreviousCaretCharacterHit(characterHit); + } + + /// + /// Get distance from line start to the specified codepoint index + /// + private double DistanceFromCodepointIndex(int codepointIndex) + { + var currentDistance = 0.0; + + foreach (var textRun in _textRuns) + { + if (codepointIndex > textRun.Text.End) + { + currentDistance += textRun.Bounds.Width; + + continue; + } + + return currentDistance + textRun.GlyphRun.GetDistanceFromCharacterHit(new CharacterHit(codepointIndex)); + } + + return currentDistance; + } + + /// + /// Search forward from the given codepoint index (inclusive) to find the next navigable codepoint index. + /// Return true if one such codepoint index is found, false otherwise. + /// + private bool FindNextCodepointIndex(int codepointIndex, out int nextCodepointIndex) + { + nextCodepointIndex = codepointIndex; + + if (codepointIndex >= Text.Start + Text.Length) + { + return false; // Cannot go forward anymore + } + + GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var cpRunStart); + + while (runIndex < TextRuns.Count) + { + // When navigating forward, only the trailing edge of visible content is + // navigable. + if (runIndex < TextRuns.Count) + { + nextCodepointIndex = Math.Max(cpRunStart, codepointIndex); + return true; + } + + cpRunStart += TextRuns[runIndex++].Text.Length; + } + + return false; + } + + /// + /// Search backward from the given codepoint index (inclusive) to find the previous navigable codepoint index. + /// Return true if one such codepoint is found, false otherwise. + /// + private bool FindPreviousCodepointIndex(int codepointIndex, out int previousCodepointIndex) + { + previousCodepointIndex = codepointIndex; + + if (codepointIndex < Text.Start) + { + return false; // Cannot go backward anymore. + } + + // Position the cpRunEnd at the end of the span that contains the given cp + GetRunIndexAtCodepointIndex(codepointIndex, out var runIndex, out var codepointIndexAtRunEnd); + + codepointIndexAtRunEnd += TextRuns[runIndex].Text.End; + + while (runIndex >= 0) + { + // Visible content has caret stops at its leading edge. + if (runIndex + 1 < TextRuns.Count) + { + previousCodepointIndex = Math.Min(codepointIndexAtRunEnd, codepointIndex); + return true; + } + + // Newline sequence has caret stops at its leading edge. + if (runIndex == TextRuns.Count) + { + // Get the cp index at the beginning of the newline sequence. + previousCodepointIndex = codepointIndexAtRunEnd - TextRuns[runIndex].Text.Length + 1; + return true; + } + + codepointIndexAtRunEnd -= TextRuns[runIndex--].Text.Length; + } + + return false; + } + + private void GetRunIndexAtCodepointIndex(int codepointIndex, out int runIndex, out int codepointIndexAtRunStart) + { + codepointIndexAtRunStart = Text.Start; + runIndex = 0; + + // Find the span that contains the given cp + while (runIndex < TextRuns.Count && + codepointIndexAtRunStart + TextRuns[runIndex].Text.Length <= codepointIndex) + { + codepointIndexAtRunStart += TextRuns[runIndex++].Text.Length; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs new file mode 100644 index 0000000000..d9b27958ab --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextCharacters.cs @@ -0,0 +1,21 @@ +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that holds text characters. + /// + public class TextCharacters : TextRun + { + protected TextCharacters() + { + + } + + public TextCharacters(ReadOnlySlice text, TextStyle style) + { + Text = text; + Style = style; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfLine.cs new file mode 100644 index 0000000000..fd71fb53e7 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfLine.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that indicates the end of a line. + /// + public class TextEndOfLine : TextRun + { + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs new file mode 100644 index 0000000000..682fd930f6 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextEndOfParagraph.cs @@ -0,0 +1,9 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// A text run that indicates the end of a paragraph. + /// + public class TextEndOfParagraph : TextEndOfLine + { + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs new file mode 100644 index 0000000000..37e5831884 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormat.cs @@ -0,0 +1,74 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Unique text formatting properties that are used by the . + /// + public readonly struct TextFormat : IEquatable + { + public TextFormat(Typeface typeface, double fontRenderingEmSize) + { + Typeface = typeface; + FontRenderingEmSize = fontRenderingEmSize; + FontMetrics = new FontMetrics(typeface, fontRenderingEmSize); + } + + /// + /// Gets the typeface. + /// + /// + /// The typeface. + /// + public Typeface Typeface { get; } + + /// + /// Gets the font rendering em size. + /// + /// + /// The em rendering size of the font. + /// + public double FontRenderingEmSize { get; } + + /// + /// Gets the font metrics. + /// + /// + /// The metrics of the font. + /// + public FontMetrics FontMetrics { get; } + + public static bool operator ==(TextFormat self, TextFormat other) + { + return self.Equals(other); + } + + public static bool operator !=(TextFormat self, TextFormat other) + { + return !(self == other); + } + + public bool Equals(TextFormat other) + { + return Typeface.Equals(other.Typeface) && FontRenderingEmSize.Equals(other.FontRenderingEmSize); + } + + public override bool Equals(object obj) + { + return obj is TextFormat other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (Typeface != null ? Typeface.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ FontRenderingEmSize.GetHashCode(); + return hashCode; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs new file mode 100644 index 0000000000..7956c5f260 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatter.cs @@ -0,0 +1,186 @@ +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a base class for text formatting. + /// + public abstract class TextFormatter + { + /// + /// Gets the current that is used for non complex text formatting. + /// + public static TextFormatter Current + { + get + { + var current = AvaloniaLocator.Current.GetService(); + + if (current != null) + { + return current; + } + + current = new SimpleTextFormatter(); + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(current); + + return current; + } + } + + /// + /// Formats a text line. + /// + /// The text source. + /// The first character index to start the text line from. + /// A value that specifies the width of the paragraph that the line fills. + /// A value that represents paragraph properties, + /// such as TextWrapping, TextAlignment, or TextStyle. + /// The formatted line. + public abstract TextLine FormatLine(ITextSource textSource, int firstTextSourceIndex, double paragraphWidth, + TextParagraphProperties paragraphProperties); + + /// + /// Creates a text style run with unique properties. + /// + /// The text to create text runs from. + /// + /// A list of text runs. + protected TextStyleRun CreateShapableTextStyleRun(ReadOnlySlice text, TextStyle defaultStyle) + { + var defaultTypeface = defaultStyle.TextFormat.Typeface; + + var currentTypeface = defaultTypeface; + + if (TryGetRunProperties(text, currentTypeface, defaultTypeface, out var count)) + { + return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface, + defaultStyle.TextFormat.FontRenderingEmSize, + defaultStyle.Foreground, defaultStyle.TextDecorations)); + + } + + var codepoint = Codepoint.ReadAt(text, count, out _); + + //ToDo: Fix FontFamily fallback + currentTypeface = + FontManager.Current.MatchCharacter(codepoint, defaultTypeface.Weight, defaultTypeface.Style); + + if (currentTypeface != null && TryGetRunProperties(text, currentTypeface, defaultTypeface, out count)) + { + //Fallback found + return new TextStyleRun(new TextPointer(text.Start, count), new TextStyle(currentTypeface, + defaultStyle.TextFormat.FontRenderingEmSize, + defaultStyle.Foreground, defaultStyle.TextDecorations)); + + } + + // no fallback found + currentTypeface = defaultTypeface; + + var glyphTypeface = currentTypeface.GlyphTypeface; + + var enumerator = new GraphemeEnumerator(text); + + while (enumerator.MoveNext()) + { + var grapheme = enumerator.Current; + + if (!grapheme.FirstCodepoint.IsWhiteSpace && glyphTypeface.TryGetGlyph(grapheme.FirstCodepoint, out _)) + { + break; + } + + count += grapheme.Text.Length; + } + + return new TextStyleRun(new TextPointer(text.Start, count), + new TextStyle(currentTypeface, defaultStyle.TextFormat.FontRenderingEmSize, + defaultStyle.Foreground, defaultStyle.TextDecorations)); + } + + /// + /// Tries to get run properties. + /// + /// + /// + /// The typeface that is used to find matching characters. + /// + /// + protected bool TryGetRunProperties(ReadOnlySlice text, Typeface typeface, Typeface defaultTypeface, + out int count) + { + if (text.Length == 0) + { + count = 0; + return false; + } + + var isFallback = typeface != defaultTypeface; + + count = 0; + var script = Script.Common; + //var direction = BiDiClass.LeftToRight; + + var font = typeface.GlyphTypeface; + var defaultFont = defaultTypeface.GlyphTypeface; + + var enumerator = new GraphemeEnumerator(text); + + while (enumerator.MoveNext()) + { + var grapheme = enumerator.Current; + + var currentScript = grapheme.FirstCodepoint.Script; + + //var currentDirection = grapheme.FirstCodepoint.BiDiClass; + + //// ToDo: Implement BiDi algorithm + //if (currentScript.HorizontalDirection != direction) + //{ + // if (!UnicodeUtility.IsWhiteSpace(grapheme.FirstCodepoint)) + // { + // break; + // } + //} + + if (currentScript != script) + { + if (currentScript != Script.Inherited && currentScript != Script.Common) + { + if (script == Script.Inherited || script == Script.Common) + { + script = currentScript; + } + else + { + break; + } + } + } + + if (isFallback) + { + if (defaultFont.TryGetGlyph(grapheme.FirstCodepoint, out _)) + { + break; + } + } + + if (!font.TryGetGlyph(grapheme.FirstCodepoint, out _)) + { + if (!grapheme.FirstCodepoint.IsWhiteSpace) + { + break; + } + } + + count += grapheme.Text.Length; + } + + return count > 0; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs new file mode 100644 index 0000000000..3f0cf7c680 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -0,0 +1,393 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Media.Immutable; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a multi line text layout. + /// + public class TextLayout + { + private static readonly ReadOnlySlice s_empty = new ReadOnlySlice(new[] { '\u200B' }); + + private readonly ReadOnlySlice _text; + private readonly TextParagraphProperties _paragraphProperties; + private readonly IReadOnlyList _textStyleOverrides; + + /// + /// Initializes a new instance of the class. + /// + /// The text. + /// The typeface. + /// Size of the font. + /// The foreground. + /// The text alignment. + /// The text wrapping. + /// The text trimming. + /// The text decorations. + /// The maximum width. + /// The maximum height. + /// The text style overrides. + public TextLayout( + string text, + Typeface typeface, + double fontSize, + IBrush foreground, + TextAlignment textAlignment = TextAlignment.Left, + TextWrapping textWrapping = TextWrapping.NoWrap, + TextTrimming textTrimming = TextTrimming.None, + TextDecorationCollection textDecorations = null, + double maxWidth = double.PositiveInfinity, + double maxHeight = double.PositiveInfinity, + IReadOnlyList textStyleOverrides = null) + { + _text = string.IsNullOrEmpty(text) ? + new ReadOnlySlice() : + new ReadOnlySlice(text.AsMemory()); + + _paragraphProperties = + CreateTextParagraphProperties(typeface, fontSize, foreground, textAlignment, textWrapping, textTrimming, textDecorations?.ToImmutable()); + + _textStyleOverrides = textStyleOverrides; + + MaxWidth = maxWidth; + + MaxHeight = maxHeight; + + UpdateLayout(); + } + + /// + /// Gets the maximum width. + /// + public double MaxWidth { get; } + + + /// + /// Gets the maximum height. + /// + public double MaxHeight { get; } + + /// + /// Gets the text lines. + /// + /// + /// The text lines. + /// + public IReadOnlyList TextLines { get; private set; } + + /// + /// Gets the bounds of the layout. + /// + /// + /// The bounds. + /// + public Rect Bounds { get; private set; } + + /// + /// Draws the text layout. + /// + /// The drawing context. + /// The origin. + public void Draw(IDrawingContextImpl context, Point origin) + { + if (!TextLines.Any()) + { + return; + } + + var currentY = origin.Y; + + foreach (var textLine in TextLines) + { + textLine.Draw(context, new Point(origin.X, currentY)); + + currentY += textLine.LineMetrics.Size.Height; + } + } + + /// + /// Creates the default that are used by the . + /// + /// The typeface. + /// The font size. + /// The foreground. + /// The text alignment. + /// The text wrapping. + /// The text trimming. + /// The text decorations. + /// + private static TextParagraphProperties CreateTextParagraphProperties(Typeface typeface, double fontSize, + IBrush foreground, TextAlignment textAlignment, TextWrapping textWrapping, TextTrimming textTrimming, + ImmutableTextDecoration[] textDecorations) + { + var textRunStyle = new TextStyle(typeface, fontSize, foreground, textDecorations); + + return new TextParagraphProperties(textRunStyle, textAlignment, textWrapping, textTrimming); + } + + /// + /// Updates the current bounds. + /// + /// The text line. + /// The left. + /// The right. + /// The bottom. + private static void UpdateBounds(TextLine textLine, ref double left, ref double right, ref double bottom) + { + if (right < textLine.LineMetrics.BaselineOrigin.X + textLine.LineMetrics.Size.Width) + { + right = textLine.LineMetrics.BaselineOrigin.X + textLine.LineMetrics.Size.Width; + } + + if (left < textLine.LineMetrics.BaselineOrigin.X) + { + left = textLine.LineMetrics.BaselineOrigin.X; + } + + bottom += textLine.LineMetrics.Size.Height; + } + + /// + /// Creates an empty text line. + /// + /// The empty text line. + private TextLine CreateEmptyTextLine(int startingIndex) + { + var textFormat = _paragraphProperties.DefaultTextStyle.TextFormat; + + var glyphRun = TextShaper.Current.ShapeText(s_empty, textFormat); + + var textRuns = new[] { new ShapedTextRun(glyphRun, _paragraphProperties.DefaultTextStyle) }; + + return new SimpleTextLine(new TextPointer(startingIndex, 0), textRuns, + TextLineMetrics.Create(textRuns, MaxWidth, _paragraphProperties.TextAlignment)); + } + + /// + /// Updates the layout and applies specified text style overrides. + /// + private void UpdateLayout() + { + if (_text.IsEmpty || Math.Abs(MaxWidth) < double.Epsilon || Math.Abs(MaxHeight) < double.Epsilon) + { + var textLine = CreateEmptyTextLine(0); + + TextLines = new List { textLine }; + + Bounds = new Rect(textLine.LineMetrics.BaselineOrigin.X, 0, 0, textLine.LineMetrics.Size.Height); + } + else + { + var textLines = new List(); + + double left = 0.0, right = 0.0, bottom = 0.0; + + var lineBreaker = new LineBreakEnumerator(_text); + + var currentPosition = 0; + + while (currentPosition < _text.Length) + { + int length; + + if (lineBreaker.MoveNext()) + { + if (!lineBreaker.Current.Required) + { + continue; + } + + length = lineBreaker.Current.PositionWrap - currentPosition; + + if (currentPosition + length < _text.Length) + { + //The line breaker isn't treating \n\r as a pair so we have to fix that here. + if (_text[lineBreaker.Current.PositionMeasure] == '\n' + && _text[lineBreaker.Current.PositionWrap] == '\r') + { + length++; + } + } + } + else + { + length = _text.Length - currentPosition; + } + + var remainingLength = length; + + while (remainingLength > 0) + { + var textSlice = _text.AsSlice(currentPosition, remainingLength); + + var textSource = new FormattedTextSource(textSlice, _paragraphProperties.DefaultTextStyle, _textStyleOverrides); + + var textLine = TextFormatter.Current.FormatLine(textSource, 0, MaxWidth, _paragraphProperties); + + if (!double.IsPositiveInfinity(MaxHeight) && bottom + textLine.LineMetrics.Size.Height > MaxHeight) + { + currentPosition = _text.Length; + break; + } + + UpdateBounds(textLine, ref left, ref right, ref bottom); + + textLines.Add(textLine); + + if (_paragraphProperties.TextTrimming != TextTrimming.None) + { + currentPosition += remainingLength; + + break; + } + + remainingLength -= textLine.Text.Length; + + currentPosition += textLine.Text.Length; + } + + if (lineBreaker.Current.Required && currentPosition == _text.Length) + { + var emptyTextLine = CreateEmptyTextLine(currentPosition); + + if (!double.IsPositiveInfinity(MaxHeight) && bottom + emptyTextLine.LineMetrics.Size.Height > MaxHeight) + { + break; + } + + UpdateBounds(emptyTextLine, ref left, ref right, ref bottom); + + textLines.Add(emptyTextLine); + + break; + } + } + + Bounds = new Rect(left, 0, right, bottom); + + TextLines = textLines; + } + } + + private struct FormattedTextSource : ITextSource + { + private readonly ReadOnlySlice _text; + private readonly TextStyle _defaultStyle; + private readonly IReadOnlyList _textStyleOverrides; + + public FormattedTextSource(ReadOnlySlice text, TextStyle defaultStyle, + IReadOnlyList textStyleOverrides) + { + _text = text; + _defaultStyle = defaultStyle; + _textStyleOverrides = textStyleOverrides; + } + + public TextRun GetTextRun(int textSourceIndex) + { + var runText = _text.Skip(textSourceIndex); + + if (runText.IsEmpty) + { + return new TextEndOfLine(); + } + + var textStyleRun = CreateTextStyleRunWithOverride(runText, _defaultStyle, _textStyleOverrides); + + return new TextCharacters(runText.Take(textStyleRun.TextPointer.Length), textStyleRun.Style); + } + + /// + /// Creates a text style run that has overrides applied. Only overrides with equal TextStyle. + /// If optimizeForShaping is true Foreground is ignored. + /// + /// The text to create the run for. + /// The default text style for segments that don't have an override. + /// The text style overrides. + /// + /// The created text style run. + /// + private static TextStyleRun CreateTextStyleRunWithOverride(ReadOnlySlice text, + TextStyle defaultTextStyle, IReadOnlyList textStyleOverrides) + { + if(textStyleOverrides == null || textStyleOverrides.Count == 0) + { + return new TextStyleRun(new TextPointer(text.Start, text.Length), defaultTextStyle); + } + + var currentTextStyle = defaultTextStyle; + + var hasOverride = false; + + var i = 0; + + var length = 0; + + for (; i < textStyleOverrides.Count; i++) + { + var styleOverride = textStyleOverrides[i]; + + var textPointer = styleOverride.TextPointer; + + if (textPointer.End < text.Start) + { + continue; + } + + if (textPointer.Start > text.End) + { + length = text.Length; + break; + } + + if (textPointer.Start > text.Start) + { + if (styleOverride.Style.TextFormat != currentTextStyle.TextFormat || + !currentTextStyle.Foreground.Equals(styleOverride.Style.Foreground)) + { + length = Math.Min(Math.Abs(textPointer.Start - text.Start), text.Length); + + break; + } + } + + length += Math.Min(text.Length - length, textPointer.Length); + + if (hasOverride) + { + continue; + } + + hasOverride = true; + + currentTextStyle = styleOverride.Style; + } + + if (length < text.Length && i == textStyleOverrides.Count) + { + if (currentTextStyle.Foreground.Equals(defaultTextStyle.Foreground) && + currentTextStyle.TextFormat == defaultTextStyle.TextFormat) + { + length = text.Length; + } + } + + if (length != text.Length) + { + text = text.Take(length); + } + + return new TextStyleRun(new TextPointer(text.Start, length), currentTextStyle); + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs new file mode 100644 index 0000000000..296aef0fed --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLine.cs @@ -0,0 +1,109 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Collections.Generic; +using Avalonia.Platform; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a line of text that is used for text rendering. + /// + public abstract class TextLine + { + /// + /// Gets the text. + /// + /// + /// The text pointer. + /// + public abstract TextPointer Text { get; } + + /// + /// Gets the text runs. + /// + /// + /// The text runs. + /// + public abstract IReadOnlyList TextRuns { get; } + + /// + /// Gets the line metrics. + /// + /// + /// The line metrics. + /// + public abstract TextLineMetrics LineMetrics { get; } + + /// + /// Draws the at the given origin. + /// + /// The drawing context. + /// The origin. + public abstract void Draw(IDrawingContextImpl drawingContext, Point origin); + + /// + /// Client to get the character hit corresponding to the specified + /// distance from the beginning of the line. + /// + /// distance in text flow direction from the beginning of the line + /// The + public abstract CharacterHit GetCharacterHitFromDistance(double distance); + + /// + /// Client to get the distance from the beginning of the line from the specified + /// . + /// + /// of the character to query the distance. + /// Distance in text flow direction from the beginning of the line. + public abstract double GetDistanceFromCharacterHit(CharacterHit characterHit); + + /// + /// Client to get the next for caret navigation. + /// + /// The current . + /// The next . + public abstract CharacterHit GetNextCaretCharacterHit(CharacterHit characterHit); + + /// + /// Client to get the previous character hit for caret navigation + /// + /// the current character hit + /// The previous + public abstract CharacterHit GetPreviousCaretCharacterHit(CharacterHit characterHit); + + /// + /// Client to get the previous character hit after backspacing + /// + /// the current character hit + /// The after backspacing + public abstract CharacterHit GetBackspaceCaretCharacterHit(CharacterHit characterHit); + + /// + /// Gets the text line offset x. + /// + /// The line width. + /// The paragraph width. + /// The text alignment. + /// The paragraph offset. + internal static double GetParagraphOffsetX(double lineWidth, double paragraphWidth, TextAlignment textAlignment) + { + if (double.IsPositiveInfinity(paragraphWidth)) + { + return 0; + } + + switch (textAlignment) + { + case TextAlignment.Center: + return (paragraphWidth - lineWidth) / 2; + + case TextAlignment.Right: + return paragraphWidth - lineWidth; + + default: + return 0.0f; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs new file mode 100644 index 0000000000..b2235280c2 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineMetrics.cs @@ -0,0 +1,106 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Collections.Generic; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a metric for a objects, + /// that holds information about ascent, descent, line gap, size and origin of the text line. + /// + public readonly struct TextLineMetrics + { + public TextLineMetrics(double width, double xOrigin, double ascent, double descent, double lineGap) + { + Ascent = ascent; + Descent = descent; + LineGap = lineGap; + Size = new Size(width, descent - ascent + lineGap); + BaselineOrigin = new Point(xOrigin, -ascent); + } + + /// + /// Gets the overall recommended distance above the baseline. + /// + /// + /// The ascent. + /// + public double Ascent { get; } + + /// + /// Gets the overall recommended distance under the baseline. + /// + /// + /// The descent. + /// + public double Descent { get; } + + /// + /// Gets the overall recommended additional space between two lines of text. + /// + /// + /// The leading. + /// + public double LineGap { get; } + + /// + /// Gets the size of the text line. + /// + /// + /// The size. + /// + public Size Size { get; } + + /// + /// Gets the baseline origin. + /// + /// + /// The baseline origin. + /// + public Point BaselineOrigin { get; } + + /// + /// Creates the text line metrics. + /// + /// The text runs. + /// The paragraph width. + /// The text alignment. + /// + public static TextLineMetrics Create(IEnumerable textRuns, double paragraphWidth, TextAlignment textAlignment) + { + var lineWidth = 0.0; + var ascent = 0.0; + var descent = 0.0; + var lineGap = 0.0; + + foreach (var textRun in textRuns) + { + var shapedRun = (ShapedTextRun)textRun; + + lineWidth += shapedRun.Bounds.Width; + + var textFormat = textRun.Style.TextFormat; + + if (ascent > textRun.Style.TextFormat.FontMetrics.Ascent) + { + ascent = textFormat.FontMetrics.Ascent; + } + + if (descent < textFormat.FontMetrics.Descent) + { + descent = textFormat.FontMetrics.Descent; + } + + if (lineGap < textFormat.FontMetrics.LineGap) + { + lineGap = textFormat.FontMetrics.LineGap; + } + } + + var xOrigin = TextLine.GetParagraphOffsetX(lineWidth, paragraphWidth, textAlignment); + + return new TextLineMetrics(lineWidth, xOrigin, ascent, descent, lineGap); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs new file mode 100644 index 0000000000..1368f1777a --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextParagraphProperties.cs @@ -0,0 +1,40 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Provides a set of properties that are used during the paragraph layout. + /// + public readonly struct TextParagraphProperties + { + public TextParagraphProperties( + TextStyle defaultTextStyle, + TextAlignment textAlignment = TextAlignment.Left, + TextWrapping textWrapping = TextWrapping.NoWrap, + TextTrimming textTrimming = TextTrimming.None) + { + DefaultTextStyle = defaultTextStyle; + TextAlignment = textAlignment; + TextWrapping = textWrapping; + TextTrimming = textTrimming; + } + + /// + /// Gets the default text style. + /// + public TextStyle DefaultTextStyle { get; } + + /// + /// Gets the text alignment. + /// + public TextAlignment TextAlignment { get; } + + /// + /// Gets the text wrapping. + /// + public TextWrapping TextWrapping { get; } + + /// + /// Gets the text trimming. + /// + public TextTrimming TextTrimming { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs new file mode 100644 index 0000000000..65d5c04b4c --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextPointer.cs @@ -0,0 +1,70 @@ +using System; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// References a portion of a text buffer. + /// + public readonly struct TextPointer + { + public TextPointer(int start, int length) + { + Start = start; + Length = length; + } + + /// + /// Gets the start. + /// + /// + /// The start. + /// + public int Start { get; } + + /// + /// Gets the length. + /// + /// + /// The length. + /// + public int Length { get; } + + /// + /// Gets the end. + /// + /// + /// The end. + /// + public int End => Start + Length - 1; + + /// + /// Returns a specified number of contiguous elements from the start of the slice. + /// + /// The number of elements to return. + /// A that contains the specified number of elements from the start of this slice. + public TextPointer Take(int length) + { + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new TextPointer(Start, length); + } + + /// + /// Bypasses a specified number of elements in the slice and then returns the remaining elements. + /// + /// The number of elements to skip before returning the remaining elements. + /// A that contains the elements that occur after the specified index in this slice. + public TextPointer Skip(int length) + { + if (length > Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return new TextPointer(Start + length, Length - length); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs new file mode 100644 index 0000000000..ed48c2bfdc --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextRun.cs @@ -0,0 +1,51 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using System.Diagnostics; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a portion of a object. + /// + [DebuggerTypeProxy(typeof(TextRunDebuggerProxy))] + public abstract class TextRun + { + /// + /// Gets the text run's text. + /// + public ReadOnlySlice Text { get; protected set; } + + /// + /// Gets the text run's style. + /// + public TextStyle Style { get; protected set; } + + private class TextRunDebuggerProxy + { + private readonly TextRun _textRun; + + public TextRunDebuggerProxy(TextRun textRun) + { + _textRun = textRun; + } + + public string Text + { + get + { + unsafe + { + fixed (char* charsPtr = _textRun.Text.Buffer.Span) + { + return new string(charsPtr, 0, _textRun.Text.Length); + } + } + } + } + + public TextStyle Style => _textRun.Style; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs new file mode 100644 index 0000000000..eb3a4129bc --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextShaper.cs @@ -0,0 +1,52 @@ +using System; +using Avalonia.Platform; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// A class that is responsible for text shaping. + /// + public class TextShaper + { + private readonly ITextShaperImpl _platformImpl; + + public TextShaper(ITextShaperImpl platformImpl) + { + _platformImpl = platformImpl; + } + + /// + /// Gets the current text shaper. + /// + public static TextShaper Current + { + get + { + var current = AvaloniaLocator.Current.GetService(); + + if (current != null) + { + return current; + } + + var textShaperImpl = AvaloniaLocator.Current.GetService(); + + if (textShaperImpl == null) + throw new InvalidOperationException("No text shaper implementation was registered."); + + current = new TextShaper(textShaperImpl); + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(current); + + return current; + } + } + + /// + public GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat) + { + return _platformImpl.ShapeText(text, textFormat); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs new file mode 100644 index 0000000000..1a5c476e80 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextStyle.cs @@ -0,0 +1,42 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Media.Immutable; + +namespace Avalonia.Media.TextFormatting +{ + /// + /// Unique text formatting properties that effect the styling of a text. + /// + public readonly struct TextStyle + { + public TextStyle(Typeface typeface, double fontRenderingEmSize = 12, IBrush foreground = null, + ImmutableTextDecoration[] textDecorations = null) + : this(new TextFormat(typeface, fontRenderingEmSize), foreground, textDecorations) + { + } + + public TextStyle(TextFormat textFormat, IBrush foreground = null, + ImmutableTextDecoration[] textDecorations = null) + { + TextFormat = textFormat; + Foreground = foreground; + TextDecorations = textDecorations; + } + + /// + /// Gets the text format. + /// + public TextFormat TextFormat { get; } + + /// + /// Gets the foreground. + /// + public IBrush Foreground { get; } + + /// + /// Gets the text decorations. + /// + public ImmutableTextDecoration[] TextDecorations { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs new file mode 100644 index 0000000000..55f8999182 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextStyleRun.cs @@ -0,0 +1,24 @@ +namespace Avalonia.Media.TextFormatting +{ + /// + /// Represents a text run's style and is used during the layout process of the . + /// + public readonly struct TextStyleRun + { + public TextStyleRun(TextPointer textPointer, TextStyle style) + { + TextPointer = textPointer; + Style = style; + } + + /// + /// Gets the text pointer. + /// + public TextPointer TextPointer { get; } + + /// + /// Gets the text style. + /// + public TextStyle Style { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs new file mode 100644 index 0000000000..03576a4c40 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BiDiClass.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum BiDiClass + { + ArabicLetter, //AL + ArabicNumber, //AN + ParagraphSeparator, //B + BoundaryNeutral, //BN + CommonSeparator, //CS + EuropeanNumber, //EN + EuropeanSeparator, //ES + EuropeanTerminator, //ET + FirstStrongIsolate, //FSI + LeftToRight, //L + LeftToRightEmbedding, //LRE + LeftToRightIsolate, //LRI + LeftToRightOverride, //LRO + NonspacingMark, //NSM + OtherNeutral, //ON + PopDirectionalFormat, //PDF + PopDirectionalIsolate, //PDI + RightToLeft, //R + RightToLeftEmbedding, //RLE + RightToLeftIsolate, //RLI + RightToLeftOverride, //RLO + SegmentSeparator, //S + WhiteSpace, //WS + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BinaryReaderExtensions.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BinaryReaderExtensions.cs new file mode 100644 index 0000000000..412007c6e0 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BinaryReaderExtensions.cs @@ -0,0 +1,72 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// Copied from: https://github.com/toptensoftware/RichTextKit + +using System; +using System.IO; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal static class BinaryReaderExtensions + { + public static int ReadInt32BE(this BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + return BitConverter.ToInt32(bytes, 0); + } + + public static uint ReadUInt32BE(this BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + return BitConverter.ToUInt32(bytes, 0); + } + + public static void WriteBE(this BinaryWriter writer, int value) + { + var bytes = BitConverter.GetBytes(value); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + writer.Write(bytes); + } + + public static void WriteBE(this BinaryWriter writer, uint value) + { + var bytes = BitConverter.GetBytes(value); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + writer.Write(bytes); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BreakPairTable.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BreakPairTable.cs new file mode 100644 index 0000000000..c13074711e --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/BreakPairTable.cs @@ -0,0 +1,55 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal static class BreakPairTable + { + private static readonly byte[][] s_breakPairTable = + { + new byte[] {4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,3,4,4,4,4,4,4,4,4,4,4}, + new byte[] {0,4,4,1,1,4,4,4,4,1,1,0,0,0,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,4,4,4,4,1,1,1,1,1,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {4,4,4,1,1,1,4,4,4,1,1,1,1,1,1,1,1,1,1,1,4,2,4,1,1,1,1,1,1,1,1,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,1,1,1,1,1,1,4,2,4,1,1,1,1,1,1,1,1,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,1,0,1,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,1,1,1,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,0,0,1,1,1,1,0,1,1,0,0,4,2,4,1,1,1,1,1,0,1,1,1}, + new byte[] {1,4,4,1,1,1,4,4,4,0,0,1,1,1,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,0,1,4,4,4,0,0,1,0,0,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,0,1,4,4,4,0,0,0,0,0,0,0,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,1,1,1,1,1,1,4,2,4,1,1,1,1,1,1,1,1,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,0,0,1,1,0,4,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {1,4,4,1,1,1,4,4,4,1,1,1,1,1,1,1,1,1,1,1,4,2,4,1,1,1,1,1,1,1,1,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,1,1,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,1,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,1,1,1,1,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,1,1,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,1,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,0,0,1,1,0,0,4,2,4,0,0,0,0,0,1,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,1,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,1,0,0,0,0,1,1,1,0,0,4,2,4,0,0,0,0,0,0,0,0,1}, + new byte[] {0,4,4,1,1,1,4,4,4,0,0,0,0,0,1,0,1,1,0,0,4,2,4,0,0,0,0,0,0,1,1,1}, + }; + + public static PairBreakType Map(LineBreakClass first, LineBreakClass second) + { + return (PairBreakType)s_breakPairTable[(int)first][(int)second]; + } + } + + internal enum PairBreakType : byte + { + DI = 0, // Direct break opportunity + IN = 1, // Indirect break opportunity + CI = 2, // Indirect break opportunity for combining marks + CP = 3, // Prohibited break for combining marks + PR = 4 // Prohibited break + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs new file mode 100644 index 0000000000..94171b7324 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Codepoint.cs @@ -0,0 +1,169 @@ +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + public readonly struct Codepoint + { + /// + /// The replacement codepoint that is used for non supported values. + /// + public static readonly Codepoint ReplacementCodepoint = new Codepoint('\uFFFD'); + + private readonly int _value; + + public Codepoint(int value) + { + _value = value; + } + + /// + /// Gets the . + /// + public GeneralCategory GeneralCategory => UnicodeData.GetGeneralCategory(_value); + + /// + /// Gets the . + /// + public Script Script => UnicodeData.GetScript(_value); + + /// + /// Gets the . + /// + public BiDiClass BiDiClass => UnicodeData.GetBiDiClass(_value); + + /// + /// Gets the . + /// + public LineBreakClass LineBreakClass => UnicodeData.GetLineBreakClass(_value); + + /// + /// Gets the . + /// + public GraphemeBreakClass GraphemeBreakClass => UnicodeData.GetGraphemeClusterBreak(_value); + + /// + /// Determines whether this is a break char. + /// + /// + /// true if [is break character]; otherwise, false. + /// + public bool IsBreakChar + { + get + { + switch (_value) + { + case '\u000A': + case '\u000B': + case '\u000C': + case '\u000D': + case '\u0085': + case '\u2028': + case '\u2029': + return true; + default: + return false; + } + } + } + + /// + /// Determines whether this is white space. + /// + /// + /// true if [is whitespace]; otherwise, false. + /// + public bool IsWhiteSpace + { + get + { + switch (GeneralCategory) + { + case GeneralCategory.Control: + case GeneralCategory.NonspacingMark: + case GeneralCategory.Format: + case GeneralCategory.SpaceSeparator: + case GeneralCategory.SpacingMark: + return true; + } + + return false; + } + } + + public static implicit operator int(Codepoint codepoint) + { + return codepoint._value; + } + + public static implicit operator uint(Codepoint codepoint) + { + return (uint)codepoint._value; + } + + /// + /// Reads the at specified position. + /// + /// The buffer to read from. + /// The index to read at. + /// The count of character that were read. + /// + public static Codepoint ReadAt(ReadOnlySlice text, int index, out int count) + { + count = 1; + + if (index > text.End) + { + return ReplacementCodepoint; + } + + var code = text[index]; + + ushort hi, low; + + //# High surrogate + if (0xD800 <= code && code <= 0xDBFF) + { + hi = code; + + if (index + 1 == text.Length) + { + return ReplacementCodepoint; + } + + low = text[index + 1]; + + if (0xDC00 <= low && low <= 0xDFFF) + { + count = 2; + return new Codepoint((hi - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000); + } + + return ReplacementCodepoint; + } + + //# Low surrogate + if (0xDC00 <= code && code <= 0xDFFF) + { + if (index == 0) + { + return ReplacementCodepoint; + } + + hi = text[index - 1]; + + low = code; + + if (0xD800 <= hi && hi <= 0xDBFF) + { + count = 2; + return new Codepoint((hi - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000); + } + + return ReplacementCodepoint; + } + + return new Codepoint(code); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs new file mode 100644 index 0000000000..5de4b92760 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/CodepointEnumerator.cs @@ -0,0 +1,43 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal ref struct CodepointEnumerator + { + private ReadOnlySlice _text; + + public CodepointEnumerator(ReadOnlySlice text) + { + _text = text; + Current = Codepoint.ReplacementCodepoint; + } + + /// + /// Gets the current . + /// + public Codepoint Current { get; private set; } + + /// + /// Moves to the next . + /// + /// + public bool MoveNext() + { + if (_text.IsEmpty) + { + Current = Codepoint.ReplacementCodepoint; + + return false; + } + + Current = Codepoint.ReadAt(_text, 0, out var count); + + _text = _text.Skip(count); + + return true; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GeneralCategory.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GeneralCategory.cs new file mode 100644 index 0000000000..3ca55e1336 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GeneralCategory.cs @@ -0,0 +1,44 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum GeneralCategory + { + Other, //C# Cc | Cf | Cn | Co | Cs + Control, //Cc + Format, //Cf + Unassigned, //Cn + PrivateUse, //Co + Surrogate, //Cs + Letter, //L# Ll | Lm | Lo | Lt | Lu + CasedLetter, //LC# Ll | Lt | Lu + LowercaseLetter, //Ll + ModifierLetter, //Lm + OtherLetter, //Lo + TitlecaseLetter, //Lt + UppercaseLetter, //Lu + Mark, //M + SpacingMark, //Mc + EnclosingMark, //Me + NonspacingMark, //Mn + Number, //N# Nd | Nl | No + DecimalNumber, //Nd + LetterNumber, //Nl + OtherNumber, //No + Punctuation, //P + ConnectorPunctuation, //Pc + DashPunctuation, //Pd + ClosePunctuation, //Pe + FinalPunctuation, //Pf + InitialPunctuation, //Pi + OtherPunctuation, //Po + OpenPunctuation, //Ps + Symbol, //S# Sc | Sk | Sm | So + CurrencySymbol, //Sc + ModifierSymbol, //Sk + MathSymbol, //Sm + OtherSymbol, //So + Separator, //Z# Zl | Zp | Zs + LineSeparator, //Zl + ParagraphSeparator, //Zp + SpaceSeparator, //Zs + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs new file mode 100644 index 0000000000..a6791b4a53 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Grapheme.cs @@ -0,0 +1,26 @@ +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + /// + /// Represents the smallest unit of a writing system of any given language. + /// + public readonly struct Grapheme + { + public Grapheme(Codepoint firstCodepoint, ReadOnlySlice text) + { + FirstCodepoint = firstCodepoint; + Text = text; + } + + /// + /// The first of the grapheme cluster. + /// + public Codepoint FirstCodepoint { get; } + + /// + /// The text that is representing the . + /// + public ReadOnlySlice Text { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeBreakClass.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeBreakClass.cs new file mode 100644 index 0000000000..684baae51f --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeBreakClass.cs @@ -0,0 +1,25 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum GraphemeBreakClass + { + Control, //CN + CR, //CR + EBase, //EB + EBaseGAZ, //EBG + EModifier, //EM + Extend, //EX + GlueAfterZwj, //GAZ + L, //L + LF, //LF + LV, //LV + LVT, //LVT + Prepend, //PP + RegionalIndicator, //RI + SpacingMark, //SM + T, //T + V, //V + Other, //XX + ZWJ, //ZWJ + ExtendedPictographic + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs new file mode 100644 index 0000000000..fd7831dfe6 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/GraphemeEnumerator.cs @@ -0,0 +1,263 @@ +// This source file is adapted from the .NET cross-platform runtime project. +// (https://github.com/dotnet/runtime/) +// +// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation. + +using System.Runtime.InteropServices; +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + public ref struct GraphemeEnumerator + { + private ReadOnlySlice _text; + + public GraphemeEnumerator(ReadOnlySlice text) + { + _text = text; + Current = default; + } + + /// + /// Gets the current . + /// + public Grapheme Current { get; private set; } + + /// + /// Moves to the next . + /// + /// + public bool MoveNext() + { + if (_text.IsEmpty) + { + return false; + } + + // Algorithm given at https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundary_Rules. + + var processor = new Processor(_text); + + processor.MoveNext(); + + var firstCodepoint = processor.CurrentCodepoint; + + // First, consume as many Prepend scalars as we can (rule GB9b). + while (processor.CurrentType == GraphemeBreakClass.Prepend) + { + processor.MoveNext(); + } + + // Next, make sure we're not about to violate control character restrictions. + // Essentially, if we saw Prepend data, we can't have Control | CR | LF data afterward (rule GB5). + if (processor.CurrentCodeUnitOffset > 0) + { + if (processor.CurrentType == GraphemeBreakClass.Control + || processor.CurrentType == GraphemeBreakClass.CR + || processor.CurrentType == GraphemeBreakClass.LF) + { + goto Return; + } + } + + // Now begin the main state machine. + + var previousClusterBreakType = processor.CurrentType; + + processor.MoveNext(); + + switch (previousClusterBreakType) + { + case GraphemeBreakClass.CR: + if (processor.CurrentType != GraphemeBreakClass.LF) + { + goto Return; // rules GB3 & GB4 (only can follow ) + } + + processor.MoveNext(); + goto case GraphemeBreakClass.LF; + + case GraphemeBreakClass.Control: + case GraphemeBreakClass.LF: + goto Return; // rule GB4 (no data after Control | LF) + + case GraphemeBreakClass.L: + if (processor.CurrentType == GraphemeBreakClass.L) + { + processor.MoveNext(); // rule GB6 (L x L) + goto case GraphemeBreakClass.L; + } + else if (processor.CurrentType == GraphemeBreakClass.V) + { + processor.MoveNext(); // rule GB6 (L x V) + goto case GraphemeBreakClass.V; + } + else if (processor.CurrentType == GraphemeBreakClass.LV) + { + processor.MoveNext(); // rule GB6 (L x LV) + goto case GraphemeBreakClass.LV; + } + else if (processor.CurrentType == GraphemeBreakClass.LVT) + { + processor.MoveNext(); // rule GB6 (L x LVT) + goto case GraphemeBreakClass.LVT; + } + else + { + break; + } + + case GraphemeBreakClass.LV: + case GraphemeBreakClass.V: + if (processor.CurrentType == GraphemeBreakClass.V) + { + processor.MoveNext(); // rule GB7 (LV | V x V) + goto case GraphemeBreakClass.V; + } + else if (processor.CurrentType == GraphemeBreakClass.T) + { + processor.MoveNext(); // rule GB7 (LV | V x T) + goto case GraphemeBreakClass.T; + } + else + { + break; + } + + case GraphemeBreakClass.LVT: + case GraphemeBreakClass.T: + if (processor.CurrentType == GraphemeBreakClass.T) + { + processor.MoveNext(); // rule GB8 (LVT | T x T) + goto case GraphemeBreakClass.T; + } + else + { + break; + } + + case GraphemeBreakClass.ExtendedPictographic: + // Attempt processing extended pictographic (rules GB11, GB9). + // First, drain any Extend scalars that might exist + while (processor.CurrentType == GraphemeBreakClass.Extend) + { + processor.MoveNext(); + } + + // Now see if there's a ZWJ + extended pictograph again. + if (processor.CurrentType != GraphemeBreakClass.ZWJ) + { + break; + } + + processor.MoveNext(); + if (processor.CurrentType != GraphemeBreakClass.ExtendedPictographic) + { + break; + } + + processor.MoveNext(); + goto case GraphemeBreakClass.ExtendedPictographic; + + case GraphemeBreakClass.RegionalIndicator: + // We've consumed a single RI scalar. Try to consume another (to make it a pair). + + if (processor.CurrentType == GraphemeBreakClass.RegionalIndicator) + { + processor.MoveNext(); + } + + // Standlone RI scalars (or a single pair of RI scalars) can only be followed by trailers. + + break; // nothing but trailers after the final RI + + default: + break; + } + + // rules GB9, GB9a + while (processor.CurrentType == GraphemeBreakClass.Extend + || processor.CurrentType == GraphemeBreakClass.ZWJ + || processor.CurrentType == GraphemeBreakClass.SpacingMark) + { + processor.MoveNext(); + } + + Return: + + var text = _text.Take(processor.CurrentCodeUnitOffset); + + Current = new Grapheme(firstCodepoint, text); + + _text = _text.Skip(processor.CurrentCodeUnitOffset); + + return true; // rules GB2, GB999 + } + + [StructLayout(LayoutKind.Auto)] + private ref struct Processor + { + private readonly ReadOnlySlice _buffer; + private int _codeUnitLengthOfCurrentScalar; + + internal Processor(ReadOnlySlice buffer) + { + _buffer = buffer; + _codeUnitLengthOfCurrentScalar = 0; + CurrentCodepoint = Codepoint.ReplacementCodepoint; + CurrentType = GraphemeBreakClass.Other; + CurrentCodeUnitOffset = 0; + } + + public int CurrentCodeUnitOffset { get; private set; } + + /// + /// Will be if invalid data or EOF reached. + /// Caller shouldn't need to special-case this since the normal rules will halt on this condition. + /// + public GraphemeBreakClass CurrentType { get; private set; } + + /// + /// Get the currently processed . + /// + public Codepoint CurrentCodepoint { get; private set; } + + public void MoveNext() + { + // For ill-formed subsequences (like unpaired UTF-16 surrogate code points), we rely on + // the decoder's default behavior of interpreting these ill-formed subsequences as + // equivalent to U+FFFD REPLACEMENT CHARACTER. This code point has a boundary property + // of Other (XX), which matches the modifications made to UAX#29, Rev. 35. + // See: https://www.unicode.org/reports/tr29/tr29-35.html#Modifications + // This change is also reflected in the UCD files. For example, Unicode 11.0's UCD file + // https://www.unicode.org/Public/11.0.0/ucd/auxiliary/GraphemeBreakProperty.txt + // has the line "D800..DFFF ; Control # Cs [2048] ..", + // but starting with Unicode 12.0 that line has been removed. + // + // If a later version of the Unicode Standard further modifies this guidance we should reflect + // that here. + + if (CurrentCodeUnitOffset == _buffer.Length) + { + CurrentCodepoint = Codepoint.ReplacementCodepoint; + } + else + { + CurrentCodeUnitOffset += _codeUnitLengthOfCurrentScalar; + + if (CurrentCodeUnitOffset < _buffer.Length) + { + CurrentCodepoint = Codepoint.ReadAt(_buffer, CurrentCodeUnitOffset, + out _codeUnitLengthOfCurrentScalar); + } + else + { + CurrentCodepoint = Codepoint.ReplacementCodepoint; + } + } + + CurrentType = CurrentCodepoint.GraphemeBreakClass; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreak.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreak.cs new file mode 100644 index 0000000000..34b14f008f --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreak.cs @@ -0,0 +1,63 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// +// Ported from: https://github.com/foliojs/linebreak +// Copied from: https://github.com/toptensoftware/RichTextKit + +using System.Diagnostics; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + /// + /// Information about a potential line break position + /// + [DebuggerDisplay("{PositionMeasure}/{PositionWrap} @ {Required}")] + public readonly struct LineBreak + { + /// + /// Constructor + /// + /// The code point index to measure to + /// The code point index to actually break the line at + /// True if this is a required line break; otherwise false + public LineBreak(int positionMeasure, int positionWrap, bool required = false) + { + PositionMeasure = positionMeasure; + PositionWrap = positionWrap; + Required = required; + } + + /// + /// The break position, before any trailing whitespace + /// + /// + /// This doesn't include trailing whitespace + /// + public int PositionMeasure { get; } + + /// + /// The break position, after any trailing whitespace + /// + /// + /// This includes trailing whitespace + /// + public int PositionWrap { get; } + + /// + /// True if there should be a forced line break here + /// + public bool Required { get; } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakClass.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakClass.cs new file mode 100644 index 0000000000..925706dd4f --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakClass.cs @@ -0,0 +1,50 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum LineBreakClass + { + OpenPunctuation, //OP + ClosePunctuation, //CL + CloseParenthesis, //CP + Quotation, //QU + Glue, //GL + Nonstarter, //NS + Exclamation, //EX + BreakSymbols, //SY + InfixNumeric, //IS + PrefixNumeric, //PR + PostfixNumeric, //PO + Numeric, //NU + Alphabetic, //AL + HebrewLetter, //HL + Ideographic, //ID + Inseparable, //IN + Hyphen, //HY + BreakAfter, //BA + BreakBefore, //BB + BreakBoth, //B2 + ZWSpace, //ZW + CombiningMark, //CM + WordJoiner, //WJ + H2, //H2 + H3, //H3 + JL, //JL + JV, //JV + JT, //JT + RegionalIndicator, //RI + EBase, //EB + EModifier, //EM + ZWJ, //ZWJ + + Ambiguous, //AI + MandatoryBreak, //BK + ContingentBreak, //CB + ConditionalJapaneseStarter, //CJ + CarriageReturn, //CR + LineFeed, //LF + NextLine, //NL + ComplexContext, //SA + Surrogate, //SG + Space, //SP + Unknown, //XX + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs new file mode 100644 index 0000000000..a11c008409 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/LineBreakEnumerator.cs @@ -0,0 +1,243 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// +// Ported from: https://github.com/foliojs/linebreak +// Copied from: https://github.com/toptensoftware/RichTextKit + +using Avalonia.Utility; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + /// + /// Implementation of the Unicode Line Break Algorithm + /// + public ref struct LineBreakEnumerator + { + // State + private readonly ReadOnlySlice _text; + private int _pos; + private int _lastPos; + private LineBreakClass? _curClass; + private LineBreakClass? _nextClass; + + public LineBreakEnumerator(ReadOnlySlice text) + { + _text = text; + _pos = 0; + _lastPos = 0; + _curClass = null; + _nextClass = null; + Current = default; + } + + public LineBreak Current { get; private set; } + + public bool MoveNext() + { + // get the first char if we're at the beginning of the string + if (!_curClass.HasValue) + { + _curClass = PeekCharClass() == LineBreakClass.Space ? LineBreakClass.WordJoiner : MapFirst(ReadCharClass()); + } + + while (_pos < _text.Length) + { + _lastPos = _pos; + var lastClass = _nextClass; + _nextClass = ReadCharClass(); + + // explicit newline + if (_curClass.HasValue && (_curClass == LineBreakClass.MandatoryBreak || _curClass == LineBreakClass.CarriageReturn && _nextClass != LineBreakClass.LineFeed)) + { + _curClass = MapFirst(MapClass(_nextClass.Value)); + Current = new LineBreak(FindPriorNonWhitespace(_lastPos), _lastPos, true); + return true; + } + + // handle classes not handled by the pair table + LineBreakClass? cur = null; + switch (_nextClass.Value) + { + case LineBreakClass.Space: + cur = _curClass; + break; + + case LineBreakClass.MandatoryBreak: + case LineBreakClass.LineFeed: + case LineBreakClass.NextLine: + cur = LineBreakClass.MandatoryBreak; + break; + + case LineBreakClass.CarriageReturn: + cur = LineBreakClass.CarriageReturn; + break; + + case LineBreakClass.ContingentBreak: + cur = LineBreakClass.BreakAfter; + break; + } + + if (cur != null) + { + _curClass = cur; + + if (_nextClass.Value == LineBreakClass.MandatoryBreak) + { + Current = new LineBreak(FindPriorNonWhitespace(_lastPos), _lastPos); + return true; + } + + continue; + } + + // if not handled already, use the pair table + var shouldBreak = false; + switch (BreakPairTable.Map(_curClass.Value,_nextClass.Value)) + { + case PairBreakType.DI: // Direct break + shouldBreak = true; + break; + + case PairBreakType.IN: // possible indirect break + shouldBreak = lastClass.HasValue && lastClass.Value == LineBreakClass.Space; + break; + + case PairBreakType.CI: + shouldBreak = lastClass.HasValue && lastClass.Value == LineBreakClass.Space; + if (!shouldBreak) + { + continue; + } + break; + + case PairBreakType.CP: // prohibited for combining marks + if (!lastClass.HasValue || lastClass.Value != LineBreakClass.Space) + { + continue; + } + break; + } + + _curClass = _nextClass; + + if (shouldBreak) + { + Current = new LineBreak(FindPriorNonWhitespace(_lastPos), _lastPos); + return true; + } + } + + if (_pos >= _text.Length) + { + if (_lastPos < _text.Length) + { + _lastPos = _text.Length; + var cls = Codepoint.ReadAt(_text, _text.Length - 1, out _).LineBreakClass; + bool required = cls == LineBreakClass.MandatoryBreak || cls == LineBreakClass.LineFeed || cls == LineBreakClass.CarriageReturn; + Current = new LineBreak(FindPriorNonWhitespace(_text.Length), _text.Length, required); + return true; + } + } + + return false; + } + + private int FindPriorNonWhitespace(int from) + { + if (from > 0) + { + var cp = Codepoint.ReadAt(_text, from - 1, out var count); + + var cls = cp.LineBreakClass; + + if (cls == LineBreakClass.MandatoryBreak || cls == LineBreakClass.LineFeed || cls == LineBreakClass.CarriageReturn) + { + from -= count; + } + } + + while (from > 0) + { + var cp = Codepoint.ReadAt(_text, from - 1, out var count); + + var cls = cp.LineBreakClass; + + if (cls == LineBreakClass.Space) + { + from -= count; + } + else + { + break; + } + } + return from; + } + + // Get the next character class + private LineBreakClass ReadCharClass() + { + var cp = Codepoint.ReadAt(_text, _pos, out var count); + + _pos += count; + + return MapClass(cp.LineBreakClass); + } + + private LineBreakClass PeekCharClass() + { + return MapClass(Codepoint.ReadAt(_text, _pos, out _).LineBreakClass); + } + + private static LineBreakClass MapClass(LineBreakClass c) + { + switch (c) + { + case LineBreakClass.Ambiguous: + return LineBreakClass.Alphabetic; + + case LineBreakClass.ComplexContext: + case LineBreakClass.Surrogate: + case LineBreakClass.Unknown: + return LineBreakClass.Alphabetic; + + case LineBreakClass.ConditionalJapaneseStarter: + return LineBreakClass.Nonstarter; + + default: + return c; + } + } + + private static LineBreakClass MapFirst(LineBreakClass c) + { + switch (c) + { + case LineBreakClass.LineFeed: + case LineBreakClass.NextLine: + return LineBreakClass.MandatoryBreak; + + case LineBreakClass.ContingentBreak: + return LineBreakClass.BreakAfter; + + case LineBreakClass.Space: + return LineBreakClass.WordJoiner; + + default: + return c; + } + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Script.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Script.cs new file mode 100644 index 0000000000..e9681d4c24 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/Script.cs @@ -0,0 +1,160 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum Script + { + Adlam, //Adlm + CaucasianAlbanian, //Aghb + Ahom, //Ahom + Arabic, //Arab + ImperialAramaic, //Armi + Armenian, //Armn + Avestan, //Avst + Balinese, //Bali + Bamum, //Bamu + BassaVah, //Bass + Batak, //Batk + Bengali, //Beng + Bhaiksuki, //Bhks + Bopomofo, //Bopo + Brahmi, //Brah + Braille, //Brai + Buginese, //Bugi + Buhid, //Buhd + Chakma, //Cakm + CanadianAboriginal, //Cans + Carian, //Cari + Cham, //Cham + Cherokee, //Cher + Coptic, //Copt + Cypriot, //Cprt + Cyrillic, //Cyrl + Devanagari, //Deva + Dogra, //Dogr + Deseret, //Dsrt + Duployan, //Dupl + EgyptianHieroglyphs, //Egyp + Elbasan, //Elba + Elymaic, //Elym + Ethiopic, //Ethi + Georgian, //Geor + Glagolitic, //Glag + GunjalaGondi, //Gong + MasaramGondi, //Gonm + Gothic, //Goth + Grantha, //Gran + Greek, //Grek + Gujarati, //Gujr + Gurmukhi, //Guru + Hangul, //Hang + Han, //Hani + Hanunoo, //Hano + Hatran, //Hatr + Hebrew, //Hebr + Hiragana, //Hira + AnatolianHieroglyphs, //Hluw + PahawhHmong, //Hmng + NyiakengPuachueHmong, //Hmnp + KatakanaOrHiragana, //Hrkt + OldHungarian, //Hung + OldItalic, //Ital + Javanese, //Java + KayahLi, //Kali + Katakana, //Kana + Kharoshthi, //Khar + Khmer, //Khmr + Khojki, //Khoj + Kannada, //Knda + Kaithi, //Kthi + TaiTham, //Lana + Lao, //Laoo + Latin, //Latn + Lepcha, //Lepc + Limbu, //Limb + LinearA, //Lina + LinearB, //Linb + Lisu, //Lisu + Lycian, //Lyci + Lydian, //Lydi + Mahajani, //Mahj + Makasar, //Maka + Mandaic, //Mand + Manichaean, //Mani + Marchen, //Marc + Medefaidrin, //Medf + MendeKikakui, //Mend + MeroiticCursive, //Merc + MeroiticHieroglyphs, //Mero + Malayalam, //Mlym + Modi, //Modi + Mongolian, //Mong + Mro, //Mroo + MeeteiMayek, //Mtei + Multani, //Mult + Myanmar, //Mymr + Nandinagari, //Nand + OldNorthArabian, //Narb + Nabataean, //Nbat + Newa, //Newa + Nko, //Nkoo + Nushu, //Nshu + Ogham, //Ogam + OlChiki, //Olck + OldTurkic, //Orkh + Oriya, //Orya + Osage, //Osge + Osmanya, //Osma + Palmyrene, //Palm + PauCinHau, //Pauc + OldPermic, //Perm + PhagsPa, //Phag + InscriptionalPahlavi, //Phli + PsalterPahlavi, //Phlp + Phoenician, //Phnx + Miao, //Plrd + InscriptionalParthian, //Prti + Rejang, //Rjng + HanifiRohingya, //Rohg + Runic, //Runr + Samaritan, //Samr + OldSouthArabian, //Sarb + Saurashtra, //Saur + SignWriting, //Sgnw + Shavian, //Shaw + Sharada, //Shrd + Siddham, //Sidd + Khudawadi, //Sind + Sinhala, //Sinh + Sogdian, //Sogd + OldSogdian, //Sogo + SoraSompeng, //Sora + Soyombo, //Soyo + Sundanese, //Sund + SylotiNagri, //Sylo + Syriac, //Syrc + Tagbanwa, //Tagb + Takri, //Takr + TaiLe, //Tale + NewTaiLue, //Talu + Tamil, //Taml + Tangut, //Tang + TaiViet, //Tavt + Telugu, //Telu + Tifinagh, //Tfng + Tagalog, //Tglg + Thaana, //Thaa + Thai, //Thai + Tibetan, //Tibt + Tirhuta, //Tirh + Ugaritic, //Ugar + Vai, //Vaii + WarangCiti, //Wara + Wancho, //Wcho + OldPersian, //Xpeo + Cuneiform, //Xsux + Yi, //Yiii + ZanabazarSquare, //Zanb + Inherited, //Zinh + Common, //Zyyy + Unknown, //Zzzz + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs new file mode 100644 index 0000000000..3c00c49707 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeData.cs @@ -0,0 +1,89 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + /// + /// Helper for looking up unicode character class information + /// + internal static class UnicodeData + { + internal const int CATEGORY_BITS = 6; + internal const int SCRIPT_BITS = 8; + internal const int BIDI_BITS = 5; + internal const int LINEBREAK_BITS = 6; + + internal const int SCRIPT_SHIFT = CATEGORY_BITS; + internal const int BIDI_SHIFT = CATEGORY_BITS + SCRIPT_BITS; + internal const int LINEBREAK_SHIFT = CATEGORY_BITS + SCRIPT_BITS + BIDI_BITS; + + internal const int CATEGORY_MASK = (1 << CATEGORY_BITS) - 1; + internal const int SCRIPT_MASK = (1 << SCRIPT_BITS) - 1; + internal const int BIDI_MASK = (1 << BIDI_BITS) - 1; + internal const int LINEBREAK_MASK = (1 << LINEBREAK_BITS) - 1; + + private static readonly UnicodeTrie s_unicodeDataTrie; + private static readonly UnicodeTrie s_graphemeBreakTrie; + + static UnicodeData() + { + s_unicodeDataTrie = new UnicodeTrie(typeof(UnicodeData).Assembly.GetManifestResourceStream("Avalonia.Assets.UnicodeData.trie")); + s_graphemeBreakTrie = new UnicodeTrie(typeof(UnicodeData).Assembly.GetManifestResourceStream("Avalonia.Assets.GraphemeBreak.trie")); + } + + /// + /// Gets the for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's general category. + public static GeneralCategory GetGeneralCategory(int codepoint) + { + var value = s_unicodeDataTrie.Get(codepoint); + + return (GeneralCategory)(value & CATEGORY_MASK); + } + + /// + /// Gets the for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's script. + public static Script GetScript(int codepoint) + { + var value = s_unicodeDataTrie.Get(codepoint); + + return (Script)((value >> SCRIPT_SHIFT) & SCRIPT_MASK); + } + + /// + /// Gets the for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's biDi class. + public static BiDiClass GetBiDiClass(int codepoint) + { + var value = s_unicodeDataTrie.Get(codepoint); + + return (BiDiClass)((value >> BIDI_SHIFT) & BIDI_MASK); + } + + /// + /// Gets the line break class for a Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's line break class. + public static LineBreakClass GetLineBreakClass(int codepoint) + { + var value = s_unicodeDataTrie.Get(codepoint); + + return (LineBreakClass)((value >> LINEBREAK_SHIFT) & LINEBREAK_MASK); + } + + /// + /// Gets the grapheme break type for the Unicode codepoint. + /// + /// The codepoint in question. + /// The code point's grapheme break type. + public static GraphemeBreakClass GetGraphemeClusterBreak(int codepoint) + { + return (GraphemeBreakClass)s_graphemeBreakTrie.Get(codepoint); + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs new file mode 100644 index 0000000000..3385116f26 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeGeneralCategory.cs @@ -0,0 +1,44 @@ +namespace Avalonia.Media.TextFormatting.Unicode +{ + public enum UnicodeGeneralCategory : byte + { + Other, //C# Cc | Cf | Cn | Co | Cs + Control, //Cc + Format, //Cf + Unassigned, //Cn + PrivateUse, //Co + Surrogate, //Cs + Letter, //L# Ll | Lm | Lo | Lt | Lu + CasedLetter, //LC# Ll | Lt | Lu + LowercaseLetter, //Ll + ModifierLetter, //Lm + OtherLetter, //Lo + TitlecaseLetter, //Lt + UppercaseLetter, //Lu + Mark, //M + SpacingMark, //Mc + EnclosingMark, //Me + NonspacingMark, //Mn + Number, //N# Nd | Nl | No + DecimalNumber, //Nd + LetterNumber, //Nl + OtherNumber, //No + Punctuation, //P + ConnectorPunctuation, //Pc + DashPunctuation, //Pd + ClosePunctuation, //Pe + FinalPunctuation, //Pf + InitialPunctuation, //Pi + OtherPunctuation, //Po + OpenPunctuation, //Ps + Symbol, //S# Sc | Sk | Sm | So + CurrencySymbol, //Sc + ModifierSymbol, //Sk + MathSymbol, //Sm + OtherSymbol, //So + Separator, //Z# Zl | Zp | Zs + LineSeparator, //Zl + ParagraphSeparator, //Zp + SpaceSeparator, //Zs + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrie.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrie.cs new file mode 100644 index 0000000000..08b019ed33 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrie.cs @@ -0,0 +1,128 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// Ported from: https://github.com/foliojs/unicode-trie +// Copied from: https://github.com/toptensoftware/RichTextKit + +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal class UnicodeTrie + { + private readonly int[] _data; + private readonly int _highStart; + private readonly uint _errorValue; + + public UnicodeTrie(Stream stream) + { + int dataLength; + using (var bw = new BinaryReader(stream, Encoding.UTF8, true)) + { + _highStart = bw.ReadInt32BE(); + _errorValue = bw.ReadUInt32BE(); + dataLength = bw.ReadInt32BE() / 4; + } + + using (var infl1 = new DeflateStream(stream, CompressionMode.Decompress, true)) + using (var infl2 = new DeflateStream(infl1, CompressionMode.Decompress, true)) + using (var bw = new BinaryReader(infl2, Encoding.UTF8, true)) + { + _data = new int[dataLength]; + for (int i = 0; i < _data.Length; i++) + { + _data[i] = bw.ReadInt32(); + } + } + } + + public UnicodeTrie(byte[] buf) : this(new MemoryStream(buf)) + { + + } + + internal UnicodeTrie(int[] data, int highStart, uint errorValue) + { + _data = data; + _highStart = highStart; + _errorValue = errorValue; + } + + internal void Save(Stream stream) + { + // Write the header info + using (var bw = new BinaryWriter(stream, Encoding.UTF8, true)) + { + bw.WriteBE(_highStart); + bw.WriteBE(_errorValue); + bw.WriteBE(_data.Length * 4); + } + + // Double compress the data + using (var def1 = new DeflateStream(stream, CompressionLevel.Optimal, true)) + using (var def2 = new DeflateStream(def1, CompressionLevel.Optimal, true)) + using (var bw = new BinaryWriter(def2, Encoding.UTF8, true)) + { + foreach (var v in _data) + { + bw.Write(v); + } + bw.Flush(); + def2.Flush(); + def1.Flush(); + } + } + + public uint Get(int codePoint) + { + int index; + if ((codePoint < 0) || (codePoint > 0x10ffff)) + { + return _errorValue; + } + + if ((codePoint < 0xd800) || ((codePoint > 0xdbff) && (codePoint <= 0xffff))) + { + // Ordinary BMP code point, excluding leading surrogates. + // BMP uses a single level lookup. BMP index starts at offset 0 in the index. + // data is stored in the index array itself. + index = (_data[codePoint >> UnicodeTrieBuilder.SHIFT_2] << UnicodeTrieBuilder.INDEX_SHIFT) + (codePoint & UnicodeTrieBuilder.DATA_MASK); + return (uint)_data[index]; + } + + if (codePoint <= 0xffff) + { + // Lead Surrogate Code Point. A Separate index section is stored for + // lead surrogate code units and code points. + // The main index has the code unit data. + // For this function, we need the code point data. + index = (_data[UnicodeTrieBuilder.LSCP_INDEX_2_OFFSET + ((codePoint - 0xd800) >> UnicodeTrieBuilder.SHIFT_2)] << UnicodeTrieBuilder.INDEX_SHIFT) + (codePoint & UnicodeTrieBuilder.DATA_MASK); + return (uint)_data[index]; + } + + if (codePoint < _highStart) + { + // Supplemental code point, use two-level lookup. + index = _data[(UnicodeTrieBuilder.INDEX_1_OFFSET - UnicodeTrieBuilder.OMITTED_BMP_INDEX_1_LENGTH) + (codePoint >> UnicodeTrieBuilder.SHIFT_1)]; + index = _data[index + ((codePoint >> UnicodeTrieBuilder.SHIFT_2) & UnicodeTrieBuilder.INDEX_2_MASK)]; + index = (index << UnicodeTrieBuilder.INDEX_SHIFT) + (codePoint & UnicodeTrieBuilder.DATA_MASK); + return (uint)_data[index]; + } + + return (uint)_data[_data.Length - UnicodeTrieBuilder.DATA_GRANULARITY]; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.Constants.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.Constants.cs new file mode 100644 index 0000000000..29ee45acc2 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.Constants.cs @@ -0,0 +1,159 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// Ported from: https://github.com/foliojs/unicode-trie +// Copied from: https://github.com/toptensoftware/RichTextKit + +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal partial class UnicodeTrieBuilder + { + // Shift size for getting the index-1 table offset. + internal const int SHIFT_1 = 6 + 5; + + // Shift size for getting the index-2 table offset. + internal const int SHIFT_2 = 5; + + // Difference between the two shift sizes, + // for getting an index-1 offset from an index-2 offset. 6=11-5 + const int SHIFT_1_2 = SHIFT_1 - SHIFT_2; + + // Number of index-1 entries for the BMP. 32=0x20 + // This part of the index-1 table is omitted from the serialized form. + internal const int OMITTED_BMP_INDEX_1_LENGTH = 0x10000 >> SHIFT_1; + + // Number of code points per index-1 table entry. 2048=0x800 + const int CP_PER_INDEX_1_ENTRY = 1 << SHIFT_1; + + // Number of entries in an index-2 block. 64=0x40 + const int INDEX_2_BLOCK_LENGTH = 1 << SHIFT_1_2; + + // Mask for getting the lower bits for the in-index-2-block offset. */ + internal const int INDEX_2_MASK = INDEX_2_BLOCK_LENGTH - 1; + + // Number of entries in a data block. 32=0x20 + const int DATA_BLOCK_LENGTH = 1 << SHIFT_2; + + // Mask for getting the lower bits for the in-data-block offset. + internal const int DATA_MASK = DATA_BLOCK_LENGTH - 1; + + // Shift size for shifting left the index array values. + // Increases possible data size with 16-bit index values at the cost + // of compactability. + // This requires data blocks to be aligned by DATA_GRANULARITY. + internal const int INDEX_SHIFT = 2; + + // The alignment size of a data block. Also the granularity for compaction. + internal const int DATA_GRANULARITY = 1 << INDEX_SHIFT; + + // The BMP part of the index-2 table is fixed and linear and starts at offset 0. + // Length=2048=0x800=0x10000>>SHIFT_2. + const int INDEX_2_OFFSET = 0; + + // The part of the index-2 table for U+D800..U+DBFF stores values for + // lead surrogate code _units_ not code _points_. + // Values for lead surrogate code _points_ are indexed with this portion of the table. + // Length=32=0x20=0x400>>SHIFT_2. (There are 1024=0x400 lead surrogates.) + internal const int LSCP_INDEX_2_OFFSET = 0x10000 >> SHIFT_2; + const int LSCP_INDEX_2_LENGTH = 0x400 >> SHIFT_2; + + // Count the lengths of both BMP pieces. 2080=0x820 + const int INDEX_2_BMP_LENGTH = LSCP_INDEX_2_OFFSET + LSCP_INDEX_2_LENGTH; + + // The 2-byte UTF-8 version of the index-2 table follows at offset 2080=0x820. + // Length 32=0x20 for lead bytes C0..DF, regardless of SHIFT_2. + const int UTF8_2B_INDEX_2_OFFSET = INDEX_2_BMP_LENGTH; + const int UTF8_2B_INDEX_2_LENGTH = 0x800 >> 6; // U+0800 is the first code point after 2-byte UTF-8 + + // The index-1 table, only used for supplementary code points, at offset 2112=0x840. + // Variable length, for code points up to highStart, where the last single-value range starts. + // Maximum length 512=0x200=0x100000>>SHIFT_1. + // (For 0x100000 supplementary code points U+10000..U+10ffff.) + // + // The part of the index-2 table for supplementary code points starts + // after this index-1 table. + // + // Both the index-1 table and the following part of the index-2 table + // are omitted completely if there is only BMP data. + internal const int INDEX_1_OFFSET = UTF8_2B_INDEX_2_OFFSET + UTF8_2B_INDEX_2_LENGTH; + const int MAX_INDEX_1_LENGTH = 0x100000 >> SHIFT_1; + + // The illegal-UTF-8 data block follows the ASCII block, at offset 128=0x80. + // Used with linear access for single bytes 0..0xbf for simple error handling. + // Length 64=0x40, not DATA_BLOCK_LENGTH. + const int BAD_UTF8_DATA_OFFSET = 0x80; + + // The start of non-linear-ASCII data blocks, at offset 192=0xc0. + // !!!! + const int DATA_START_OFFSET = 0xc0; + + // The null data block. + // Length 64=0x40 even if DATA_BLOCK_LENGTH is smaller, + // to work with 6-bit trail bytes from 2-byte UTF-8. + const int DATA_NULL_OFFSET = DATA_START_OFFSET; + + // The start of allocated data blocks. + const int NEW_DATA_START_OFFSET = DATA_NULL_OFFSET + 0x40; + + // The start of data blocks for U+0800 and above. + // Below, compaction uses a block length of 64 for 2-byte UTF-8. + // From here on, compaction uses DATA_BLOCK_LENGTH. + // Data values for 0x780 code points beyond ASCII. + const int DATA_0800_OFFSET = NEW_DATA_START_OFFSET + 0x780; + + // Start with allocation of 16k data entries. */ + const int INITIAL_DATA_LENGTH = 1 << 14; + + // Grow about 8x each time. + const int MEDIUM_DATA_LENGTH = 1 << 17; + + // Maximum length of the runtime data array. + // Limited by 16-bit index values that are left-shifted by INDEX_SHIFT, + // and by uint16_t UTrie2Header.shiftedDataLength. + const int MAX_DATA_LENGTH_RUNTIME = 0xffff << INDEX_SHIFT; + + const int INDEX_1_LENGTH = 0x110000 >> SHIFT_1; + + // Maximum length of the build-time data array. + // One entry per 0x110000 code points, plus the illegal-UTF-8 block and the null block, + // plus values for the 0x400 surrogate code units. + const int MAX_DATA_LENGTH_BUILDTIME = 0x110000 + 0x40 + 0x40 + 0x400; + + // At build time, leave a gap in the index-2 table, + // at least as long as the maximum lengths of the 2-byte UTF-8 index-2 table + // and the supplementary index-1 table. + // Round up to INDEX_2_BLOCK_LENGTH for proper compacting. + const int INDEX_GAP_OFFSET = INDEX_2_BMP_LENGTH; + const int INDEX_GAP_LENGTH = ((UTF8_2B_INDEX_2_LENGTH + MAX_INDEX_1_LENGTH) + INDEX_2_MASK) & ~INDEX_2_MASK; + + // Maximum length of the build-time index-2 array. + // Maximum number of Unicode code points (0x110000) shifted right by SHIFT_2, + // plus the part of the index-2 table for lead surrogate code points, + // plus the build-time index gap, + // plus the null index-2 block.) + const int MAX_INDEX_2_LENGTH = (0x110000 >> SHIFT_2) + LSCP_INDEX_2_LENGTH + INDEX_GAP_LENGTH + INDEX_2_BLOCK_LENGTH; + + // The null index-2 block, following the gap in the index-2 table. + const int INDEX_2_NULL_OFFSET = INDEX_GAP_OFFSET + INDEX_GAP_LENGTH; + + // The start of allocated index-2 blocks. + const int INDEX_2_START_OFFSET = INDEX_2_NULL_OFFSET + INDEX_2_BLOCK_LENGTH; + + // Maximum length of the runtime index array. + // Limited by its own 16-bit index values, and by uint16_t UTrie2Header.indexLength. + // (The actual maximum length is lower, + // (0x110000>>SHIFT_2)+UTF8_2B_INDEX_2_LENGTH+MAX_INDEX_1_LENGTH.) + const int MAX_INDEX_LENGTH = 0xffff; + } +} diff --git a/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.cs b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.cs new file mode 100644 index 0000000000..a60bac4ce4 --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextFormatting/Unicode/UnicodeTrieBuilder.cs @@ -0,0 +1,984 @@ +// RichTextKit +// Copyright © 2019 Topten Software. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this product except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// Ported from: https://github.com/foliojs/unicode-trie +// Copied from: https://github.com/toptensoftware/RichTextKit + +using System; +using System.Collections.Generic; +using System.IO; + +namespace Avalonia.Media.TextFormatting.Unicode +{ + internal partial class UnicodeTrieBuilder + { + private readonly uint _initialValue; + private readonly uint _errorValue; + private readonly int[] _index1; + private readonly int[] _index2; + private int _highStart; + private uint[] _data; + private int _dataCapacity; + private int _firstFreeBlock; + private bool _isCompacted; + private readonly int[] _map; + private int _dataNullOffset; + private int _dataLength; + private int _index2NullOffset; + private int _index2Length; + + public UnicodeTrieBuilder(uint initialValue = 0, uint errorValue = 0) + { + _initialValue = initialValue; + _errorValue = errorValue; + _index1 = new int[INDEX_1_LENGTH]; + _index2 = new int[MAX_INDEX_2_LENGTH]; + _highStart = 0x110000; + + _data = new uint[INITIAL_DATA_LENGTH]; + _dataCapacity = INITIAL_DATA_LENGTH; + + _firstFreeBlock = 0; + _isCompacted = false; + + // Multi-purpose per-data-block table. + // + // Before compacting: + // + // Per-data-block reference counters/free-block list. + // 0: unused + // >0: reference counter (number of index-2 entries pointing here) + // <0: next free data block in free-block list + // + // While compacting: + // + // Map of adjusted indexes, used in compactData() and compactIndex2(). + // Maps from original indexes to new ones. + _map = new int[MAX_DATA_LENGTH_BUILDTIME >> SHIFT_2]; + + int i; + for (i = 0; i < 0x80; i++) + { + _data[i] = _initialValue; + } + + for (; i < 0xc0; i++) + { + _data[i] = _errorValue; + } + + for (i = DATA_NULL_OFFSET; i < NEW_DATA_START_OFFSET; i++) + { + _data[i] = _initialValue; + } + + _dataNullOffset = DATA_NULL_OFFSET; + _dataLength = NEW_DATA_START_OFFSET; + + // set the index-2 indexes for the 2=0x80>>SHIFT_2 ASCII data blocks + int j; + i = 0; + for (j = 0; j < 0x80; j += DATA_BLOCK_LENGTH) { + _index2[i] = j; + _map[i++] = 1; + } + + // reference counts for the bad-UTF-8-data block + for (; j < 0xc0; j += DATA_BLOCK_LENGTH) { + _map[i++] = 0; + } + + // Reference counts for the null data block: all blocks except for the ASCII blocks. + // Plus 1 so that we don't drop this block during compaction. + // Plus as many as needed for lead surrogate code points. + // i==newTrie->dataNullOffset + _map[i++] = ((0x110000 >> SHIFT_2) - (0x80 >> SHIFT_2)) + 1 + LSCP_INDEX_2_LENGTH; + j += DATA_BLOCK_LENGTH; + for (; j < NEW_DATA_START_OFFSET; j += DATA_BLOCK_LENGTH) { + _map[i++] = 0; + } + + // set the remaining indexes in the BMP index-2 block + // to the null data block + for (i = 0x80 >> SHIFT_2; i < INDEX_2_BMP_LENGTH; i++) { + _index2[i] = DATA_NULL_OFFSET; + } + + // Fill the index gap with impossible values so that compaction + // does not overlap other index-2 blocks with the gap. + for (i = 0; i < INDEX_GAP_LENGTH; i++) { + _index2[INDEX_GAP_OFFSET + i] = -1; + } + + // set the indexes in the null index-2 block + for (i = 0; i < INDEX_2_BLOCK_LENGTH; i++) { + _index2[INDEX_2_NULL_OFFSET + i] = DATA_NULL_OFFSET; + } + + _index2NullOffset = INDEX_2_NULL_OFFSET; + _index2Length = INDEX_2_START_OFFSET; + + // set the index-1 indexes for the linear index-2 block + j = 0; + for (i = 0; i < OMITTED_BMP_INDEX_1_LENGTH; i++) { + _index1[i] = j; + j += INDEX_2_BLOCK_LENGTH; + } + + // set the remaining index-1 indexes to the null index-2 block + for (; i < INDEX_1_LENGTH; i++) { + _index1[i] = INDEX_2_NULL_OFFSET; + } + + // Preallocate and reset data for U+0080..U+07ff, + // for 2-byte UTF-8 which will be compacted in 64-blocks + // even if DATA_BLOCK_LENGTH is smaller. + for (i = 0x80; i < 0x800; i += DATA_BLOCK_LENGTH) { + Set(i, _initialValue); + } + + } + + public UnicodeTrieBuilder Set(int codePoint, uint value) + { + if ((codePoint < 0) || (codePoint > 0x10ffff)) + { + throw new InvalidOperationException("Invalid code point"); + } + + if (_isCompacted) + { + throw new InvalidOperationException("Already compacted"); + } + + var block = GetDataBlock(codePoint, true); + _data[block + (codePoint & DATA_MASK)] = value; + return this; + } + + public UnicodeTrieBuilder SetRange(int start, int end, uint value, bool overwrite = true) + { + + if ((start > 0x10ffff) || (end > 0x10ffff) || (start > end)) + { + throw new InvalidOperationException("Invalid code point"); + } + + if (_isCompacted) + { + throw new InvalidOperationException("Already compacted"); + } + + if (!overwrite && (value == _initialValue)) + { + return this; // nothing to do + } + + var limit = end + 1; + if ((start & DATA_MASK) != 0) + { + // set partial block at [start..following block boundary + var block = GetDataBlock(start, true); + + var nextStart = (start + DATA_BLOCK_LENGTH) & ~DATA_MASK; + if (nextStart <= limit) + { + FillBlock(block, start & DATA_MASK, DATA_BLOCK_LENGTH, value, _initialValue, overwrite); + start = nextStart; + } + else + { + FillBlock(block, start & DATA_MASK, limit & DATA_MASK, value, _initialValue, overwrite); + return this; + } + } + + // number of positions in the last, partial block + var rest = limit & DATA_MASK; + + // round down limit to a block boundary + limit &= ~DATA_MASK; + + // iterate over all-value blocks + int repeatBlock; + if (value == _initialValue) + { + repeatBlock = _dataNullOffset; + } + else + { + repeatBlock = -1; + } + + while (start < limit) + { + var setRepeatBlock = false; + + if ((value == _initialValue) && IsInNullBlock(start, true)) + { + start += DATA_BLOCK_LENGTH; // nothing to do + continue; + } + + // get index value + var i2 = GetIndex2Block(start, true); + i2 += (start >> SHIFT_2) & INDEX_2_MASK; + + var block = _index2[i2]; + if (IsWritableBlock(block)) + { + // already allocated + if (overwrite && (block >= DATA_0800_OFFSET)) + { + // We overwrite all values, and it's not a + // protected (ASCII-linear or 2-byte UTF-8) block: + // replace with the repeatBlock. + setRepeatBlock = true; + } + else + { + // protected block: just write the values into this block + FillBlock(block, 0, DATA_BLOCK_LENGTH, value, _initialValue, overwrite); + } + + } + else if ((_data[block] != value) && (overwrite || (block == _dataNullOffset))) + { + // Set the repeatBlock instead of the null block or previous repeat block: + // + // If !isWritableBlock() then all entries in the block have the same value + // because it's the null block or a range block (the repeatBlock from a previous + // call to utrie2_setRange32()). + // No other blocks are used multiple times before compacting. + // + // The null block is the only non-writable block with the initialValue because + // of the repeatBlock initialization above. (If value==initialValue, then + // the repeatBlock will be the null data block.) + // + // We set our repeatBlock if the desired value differs from the block's value, + // and if we overwrite any data or if the data is all initial values + // (which is the same as the block being the null block, see above). + setRepeatBlock = true; + } + + if (setRepeatBlock) + { + if (repeatBlock >= 0) + { + SetIndex2Entry(i2, repeatBlock); + } + else + { + // create and set and fill the repeatBlock + repeatBlock = GetDataBlock(start, true); + WriteBlock(repeatBlock, value); + } + } + + start += DATA_BLOCK_LENGTH; + } + + if (rest > 0) + { + // set partial block at [last block boundary..limit + var block = GetDataBlock(start, true); + FillBlock(block, 0, rest, value, _initialValue, overwrite); + } + + return this; + } + + public uint Get(int c, bool fromLSCP = true) + { + if ((c < 0) || (c > 0x10ffff)) + { + return _errorValue; + } + + if ((c >= _highStart) && (!((c >= 0xd800) && (c < 0xdc00)) || fromLSCP)) + { + return _data[_dataLength - DATA_GRANULARITY]; + } + + int i2; + if (((c >= 0xd800) && (c < 0xdc00)) && fromLSCP) + { + i2 = (LSCP_INDEX_2_OFFSET - (0xd800 >> SHIFT_2)) + (c >> SHIFT_2); + } + else + { + i2 = _index1[c >> SHIFT_1] + ((c >> SHIFT_2) & INDEX_2_MASK); + } + + var block = _index2[i2]; + return _data[block + (c & DATA_MASK)]; + } + + public byte[] ToBuffer() + { + var mem = new MemoryStream(); + Save(mem); + return mem.GetBuffer(); + } + + public void Save(Stream stream) + { + var trie = this.Freeze(); + trie.Save(stream); + } + + public UnicodeTrie Freeze() + { + int allIndexesLength, i; + if (!_isCompacted) + { + Compact(); + } + + if (_highStart <= 0x10000) + { + allIndexesLength = INDEX_1_OFFSET; + } + else + { + allIndexesLength = _index2Length; + } + + var dataMove = allIndexesLength; + + // are indexLength and dataLength within limits? + if ((allIndexesLength > MAX_INDEX_LENGTH) || // for unshifted indexLength + ((dataMove + _dataNullOffset) > 0xffff) || // for unshifted dataNullOffset + ((dataMove + DATA_0800_OFFSET) > 0xffff) || // for unshifted 2-byte UTF-8 index-2 values + ((dataMove + _dataLength) > MAX_DATA_LENGTH_RUNTIME)) + { // for shiftedDataLength + throw new InvalidOperationException("Trie data is too large."); + } + + // calculate the sizes of, and allocate, the index and data arrays + var indexLength = allIndexesLength + _dataLength; + var data = new int[indexLength]; + + // write the index-2 array values shifted right by INDEX_SHIFT, after adding dataMove + var destIdx = 0; + for (i = 0; i < INDEX_2_BMP_LENGTH; i++) + { + data[destIdx++] = ((_index2[i] + dataMove) >> INDEX_SHIFT); + } + + // write UTF-8 2-byte index-2 values, not right-shifted + for (i = 0; i < 0xc2 - 0xc0; i++) + { // C0..C1 + data[destIdx++] = (dataMove + BAD_UTF8_DATA_OFFSET); + } + + for (; i < 0xe0 - 0xc0; i++) + { // C2..DF + data[destIdx++] = (dataMove + _index2[i << (6 - SHIFT_2)]); + } + + if (_highStart > 0x10000) + { + var index1Length = (_highStart - 0x10000) >> SHIFT_1; + var index2Offset = INDEX_2_BMP_LENGTH + UTF8_2B_INDEX_2_LENGTH + index1Length; + + // write 16-bit index-1 values for supplementary code points + for (i = 0; i < index1Length; i++) + { + data[destIdx++] = (INDEX_2_OFFSET + _index1[i + OMITTED_BMP_INDEX_1_LENGTH]); + } + + // write the index-2 array values for supplementary code points, + // shifted right by INDEX_SHIFT, after adding dataMove + for (i = 0; i < _index2Length - index2Offset; i++) + { + data[destIdx++] = ((dataMove + _index2[index2Offset + i]) >> INDEX_SHIFT); + } + } + + // write 16-bit data values + for (i = 0; i < _dataLength; i++) + { + data[destIdx++] = (int)_data[i]; + } + + return new UnicodeTrie(data, _highStart, _errorValue); + } + + private bool IsInNullBlock(int c, bool forLSCP) + { + int i2; + if (((c & 0xfffffc00) == 0xd800) && forLSCP) + { + i2 = (LSCP_INDEX_2_OFFSET - (0xd800 >> SHIFT_2)) + (c >> SHIFT_2); + } + else + { + i2 = _index1[c >> SHIFT_1] + ((c >> SHIFT_2) & INDEX_2_MASK); + } + + var block = _index2[i2]; + return block == _dataNullOffset; + } + + private int AllocIndex2Block() + { + var newBlock = _index2Length; + var newTop = newBlock + INDEX_2_BLOCK_LENGTH; + if (newTop > _index2.Length) + { + // Should never occur. + // Either MAX_BUILD_TIME_INDEX_LENGTH is incorrect, + // or the code writes more values than should be possible. + throw new InvalidOperationException("Internal error in Trie2 creation."); + } + + _index2Length = newTop; + Array.Copy(_index2, _index2NullOffset, _index2, newBlock, INDEX_2_BLOCK_LENGTH); + + return newBlock; + } + + private int GetIndex2Block(int c, bool forLSCP) + { + if ((c >= 0xd800) && (c < 0xdc00) && forLSCP) + { + return LSCP_INDEX_2_OFFSET; + } + + var i1 = c >> SHIFT_1; + var i2 = _index1[i1]; + if (i2 == _index2NullOffset) + { + i2 = AllocIndex2Block(); + _index1[i1] = i2; + } + + return i2; + } + + private bool IsWritableBlock(int block) + { + return (block != _dataNullOffset) && (_map[block >> SHIFT_2] == 1); + } + + private int AllocDataBlock(int copyBlock) + { + int newBlock; + if (_firstFreeBlock != 0) + { + // get the first free block + newBlock = _firstFreeBlock; + _firstFreeBlock = -_map[newBlock >> SHIFT_2]; + } + else + { + // get a new block from the high end + newBlock = _dataLength; + var newTop = newBlock + DATA_BLOCK_LENGTH; + if (newTop > _dataCapacity) + { + // out of memory in the data array + int capacity; + if (_dataCapacity < MEDIUM_DATA_LENGTH) + { + capacity = MEDIUM_DATA_LENGTH; + } + else if (_dataCapacity < MAX_DATA_LENGTH_BUILDTIME) + { + capacity = MAX_DATA_LENGTH_BUILDTIME; + } + else + { + // Should never occur. + // Either MAX_DATA_LENGTH_BUILDTIME is incorrect, + // or the code writes more values than should be possible. + throw new InvalidOperationException("Internal error in Trie2 creation."); + } + + var newData = new UInt32[capacity]; + Array.Copy(_data, newData, _dataLength); + _data = newData; + _dataCapacity = capacity; + } + + _dataLength = newTop; + } + + Array.Copy(_data, copyBlock, _data, newBlock, DATA_BLOCK_LENGTH); + //_data.set(_data.subarray(copyBlock, copyBlock + DATA_BLOCK_LENGTH), newBlock); + _map[newBlock >> SHIFT_2] = 0; + return newBlock; + } + + private void ReleaseDataBlock(int block) + { + // put this block at the front of the free-block chain + _map[block >> SHIFT_2] = -_firstFreeBlock; + _firstFreeBlock = block; + } + + private void SetIndex2Entry(int i2, int block) + { + ++_map[block >> SHIFT_2]; // increment first, in case block == oldBlock! + var oldBlock = _index2[i2]; + if (--_map[oldBlock >> SHIFT_2] == 0) + { + ReleaseDataBlock(oldBlock); + } + + _index2[i2] = block; + } + + private int GetDataBlock(int c, bool forLSCP) + { + var i2 = GetIndex2Block(c, forLSCP); + i2 += (c >> SHIFT_2) & INDEX_2_MASK; + + var oldBlock = _index2[i2]; + if (IsWritableBlock(oldBlock)) + { + return oldBlock; + } + + // allocate a new data block + var newBlock = AllocDataBlock(oldBlock); + SetIndex2Entry(i2, newBlock); + return newBlock; + } + + private void FillBlock(int block, int start, int limit, uint value, uint initialValue, bool overwrite) + { + int i; + if (overwrite) + { + for (i = block + start; i < block + limit; i++) + { + _data[i] = value; + } + } + else + { + for (i = block + start; i < block + limit; i++) + { + if (_data[i] == initialValue) + { + _data[i] = value; + } + } + } + } + + private void WriteBlock(int block, uint value) + { + var limit = block + DATA_BLOCK_LENGTH; + while (block < limit) + { + _data[block++] = value; + } + } + + private int FindHighStart(uint highValue) + { + int prevBlock, prevI2Block; + + // set variables for previous range + if (highValue == _initialValue) + { + prevI2Block = _index2NullOffset; + prevBlock = _dataNullOffset; + } + else + { + prevI2Block = -1; + prevBlock = -1; + } + + int prev = 0x110000; + + // enumerate index-2 blocks + var i1 = INDEX_1_LENGTH; + var c = prev; + while (c > 0) + { + var i2Block = _index1[--i1]; + if (i2Block == prevI2Block) + { + // the index-2 block is the same as the previous one, and filled with highValue + c -= CP_PER_INDEX_1_ENTRY; + continue; + } + + prevI2Block = i2Block; + if (i2Block == _index2NullOffset) + { + // this is the null index-2 block + if (highValue != _initialValue) + { + return c; + } + c -= CP_PER_INDEX_1_ENTRY; + } + else + { + // enumerate data blocks for one index-2 block + var i2 = INDEX_2_BLOCK_LENGTH; + while (i2 > 0) + { + var block = _index2[i2Block + --i2]; + if (block == prevBlock) + { + // the block is the same as the previous one, and filled with highValue + c -= DATA_BLOCK_LENGTH; + continue; + } + + prevBlock = block; + if (block == _dataNullOffset) + { + // this is the null data block + if (highValue != _initialValue) + { + return c; + } + c -= DATA_BLOCK_LENGTH; + } + else + { + var j = DATA_BLOCK_LENGTH; + while (j > 0) + { + var value = _data[block + --j]; + if (value != highValue) + { + return c; + } + --c; + } + } + } + } + } + + // deliver last range + return 0; + } + + private int FindSameDataBlock(int dataLength, int otherBlock, int blockLength) + { + // ensure that we do not even partially get past dataLength + dataLength -= blockLength; + var block = 0; + while (block <= dataLength) + { + if (EqualSequence(_data, block, otherBlock, blockLength)) + { + return block; + } + block += DATA_GRANULARITY; + } + + return -1; + } + + private int FindSameIndex2Block(int index2Length, int otherBlock) { + // ensure that we do not even partially get past index2Length + index2Length -= INDEX_2_BLOCK_LENGTH; + for (var block = 0; block <= index2Length; block++) + { + if (EqualSequence(_index2, block, otherBlock, INDEX_2_BLOCK_LENGTH)) + { + return block; + } + } + + return -1; + } + + private void CompactData() + { + // do not compact linear-ASCII data + var newStart = DATA_START_OFFSET; + var start = 0; + var i = 0; + + while (start < newStart) + { + _map[i++] = start; + start += DATA_BLOCK_LENGTH; + } + + // Start with a block length of 64 for 2-byte UTF-8, + // then switch to DATA_BLOCK_LENGTH. + var blockLength = 64; + var blockCount = blockLength >> SHIFT_2; + start = newStart; + while (start < _dataLength) + { + // start: index of first entry of current block + // newStart: index where the current block is to be moved + // (right after current end of already-compacted data) + int mapIndex, movedStart; + if (start == DATA_0800_OFFSET) + { + blockLength = DATA_BLOCK_LENGTH; + blockCount = 1; + } + + // skip blocks that are not used + if (_map[start >> SHIFT_2] <= 0) + { + // advance start to the next block + start += blockLength; + + // leave newStart with the previous block! + continue; + } + + // search for an identical block + if ((movedStart = FindSameDataBlock(newStart, start, blockLength)) >= 0) + { + // found an identical block, set the other block's index value for the current block + mapIndex = start >> SHIFT_2; + for (i = blockCount; i > 0; i--) + { + _map[mapIndex++] = movedStart; + movedStart += DATA_BLOCK_LENGTH; + } + + // advance start to the next block + start += blockLength; + + // leave newStart with the previous block! + continue; + } + + // see if the beginning of this block can be overlapped with the end of the previous block + // look for maximum overlap (modulo granularity) with the previous, adjacent block + var overlap = blockLength - DATA_GRANULARITY; + while ((overlap > 0) && !EqualSequence(_data, (newStart - overlap), start, overlap)) + { + overlap -= DATA_GRANULARITY; + } + + if ((overlap > 0) || (newStart < start)) + { + // some overlap, or just move the whole block + movedStart = newStart - overlap; + mapIndex = start >> SHIFT_2; + + for (i = blockCount; i > 0; i--) + { + _map[mapIndex++] = movedStart; + movedStart += DATA_BLOCK_LENGTH; + } + + // move the non-overlapping indexes to their new positions + start += overlap; + for (i = blockLength - overlap; i > 0; i--) + { + _data[newStart++] = _data[start++]; + } + + } + else + { // no overlap && newStart==start + mapIndex = start >> SHIFT_2; + for (i = blockCount; i > 0; i--) + { + _map[mapIndex++] = start; + start += DATA_BLOCK_LENGTH; + } + + newStart = start; + } + } + + // now adjust the index-2 table + i = 0; + while (i < _index2Length) + { + // Gap indexes are invalid (-1). Skip over the gap. + if (i == INDEX_GAP_OFFSET) + { + i += INDEX_GAP_LENGTH; + } + _index2[i] = _map[_index2[i] >> SHIFT_2]; + ++i; + } + + _dataNullOffset = _map[_dataNullOffset >> SHIFT_2]; + + // ensure dataLength alignment + while ((newStart & (DATA_GRANULARITY - 1)) != 0) + { + _data[newStart++] = _initialValue; + } + _dataLength = newStart; + } + + private void CompactIndex2() + { + // do not compact linear-BMP index-2 blocks + var newStart = INDEX_2_BMP_LENGTH; + var start = 0; + var i = 0; + + while (start < newStart) + { + _map[i++] = start; + start += INDEX_2_BLOCK_LENGTH; + } + + // Reduce the index table gap to what will be needed at runtime. + newStart += UTF8_2B_INDEX_2_LENGTH + ((_highStart - 0x10000) >> SHIFT_1); + start = INDEX_2_NULL_OFFSET; + while (start < _index2Length) + { + // start: index of first entry of current block + // newStart: index where the current block is to be moved + // (right after current end of already-compacted data) + + // search for an identical block + int movedStart; + if ((movedStart = FindSameIndex2Block(newStart, start)) >= 0) + { + // found an identical block, set the other block's index value for the current block + _map[start >> SHIFT_1_2] = movedStart; + + // advance start to the next block + start += INDEX_2_BLOCK_LENGTH; + + // leave newStart with the previous block! + continue; + } + + // see if the beginning of this block can be overlapped with the end of the previous block + // look for maximum overlap with the previous, adjacent block + var overlap = INDEX_2_BLOCK_LENGTH - 1; + while ((overlap > 0) && !EqualSequence(_index2, (newStart - overlap), start, overlap)) + { + --overlap; + } + + if ((overlap > 0) || (newStart < start)) + { + // some overlap, or just move the whole block + _map[start >> SHIFT_1_2] = newStart - overlap; + + // move the non-overlapping indexes to their new positions + start += overlap; + for (i = INDEX_2_BLOCK_LENGTH - overlap; i > 0; i--) + { + _index2[newStart++] = _index2[start++]; + } + + } + else + { // no overlap && newStart==start + _map[start >> SHIFT_1_2] = start; + start += INDEX_2_BLOCK_LENGTH; + newStart = start; + } + } + + // now adjust the index-1 table + for (i = 0; i < INDEX_1_LENGTH; i++) + { + _index1[i] = _map[_index1[i] >> SHIFT_1_2]; + } + + _index2NullOffset = _map[_index2NullOffset >> SHIFT_1_2]; + + // Ensure data table alignment: + // Needs to be granularity-aligned for 16-bit trie + // (so that dataMove will be down-shiftable), + // and 2-aligned for uint32_t data. + + // Arbitrary value: 0x3fffc not possible for real data. + while ((newStart & ((DATA_GRANULARITY - 1) | 1)) != 0) + { + _index2[newStart++] = 0x0000ffff << INDEX_SHIFT; + } + + _index2Length = newStart; + } + + private void Compact() + { + // find highStart and round it up + var highValue = Get(0x10ffff); + var highStart = FindHighStart(highValue); + highStart = (highStart + (CP_PER_INDEX_1_ENTRY - 1)) & ~(CP_PER_INDEX_1_ENTRY - 1); + if (highStart == 0x110000) + { + highValue = _errorValue; + } + + // Set trie->highStart only after utrie2_get32(trie, highStart). + // Otherwise utrie2_get32(trie, highStart) would try to read the highValue. + _highStart = highStart; + if (_highStart < 0x110000) + { + // Blank out [highStart..10ffff] to release associated data blocks. + var suppHighStart = _highStart <= 0x10000 ? 0x10000 : _highStart; + SetRange(suppHighStart, 0x10ffff, _initialValue); + } + + CompactData(); + + if (_highStart > 0x10000) + { + CompactIndex2(); + } + + // Store the highValue in the data array and round up the dataLength. + // Must be done after compactData() because that assumes that dataLength + // is a multiple of DATA_BLOCK_LENGTH. + _data[_dataLength++] = highValue; + while ((_dataLength & (DATA_GRANULARITY - 1)) != 0) + { + _data[_dataLength++] = _initialValue; + } + + _isCompacted = true; + } + + private static bool EqualSequence(IReadOnlyList a, int s, int t, int length) + { + for (var i = 0; i < length; i++) + { + if (a[s + i] != a[t + i]) + { + return false; + } + } + + return true; + } + + private static bool EqualSequence(IReadOnlyList a, int s, int t, int length) + { + for (var i = 0; i < length; i++) + { + if (a[s + i] != a[t + i]) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/Avalonia.Visuals/Media/TextTrimming.cs b/src/Avalonia.Visuals/Media/TextTrimming.cs new file mode 100644 index 0000000000..390adfbf7a --- /dev/null +++ b/src/Avalonia.Visuals/Media/TextTrimming.cs @@ -0,0 +1,26 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +namespace Avalonia.Media +{ + /// + /// Describes how text is trimmed when it overflows. + /// + public enum TextTrimming + { + /// + /// Text is not trimmed. + /// + None, + + /// + /// Text is trimmed at a character boundary. An ellipsis (...) is drawn in place of remaining text. + /// + CharacterEllipsis, + + /// + /// Text is trimmed at a word boundary. An ellipsis (...) is drawn in place of remaining text. + /// + WordEllipsis + } +} diff --git a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs index 7ae0eaf8f2..f5f6ab41b0 100644 --- a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs +++ b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs @@ -112,12 +112,6 @@ namespace Avalonia.Platform /// An . IBitmapImpl LoadBitmap(PixelFormat format, IntPtr data, PixelSize size, Vector dpi, int stride); - /// - /// Creates a font manager implementation. - /// - /// The font manager. - IFontManagerImpl CreateFontManager(); - /// /// Creates a platform implementation of a glyph run. /// diff --git a/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs b/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs new file mode 100644 index 0000000000..aa59fb3d8b --- /dev/null +++ b/src/Avalonia.Visuals/Platform/ITextShaperImpl.cs @@ -0,0 +1,23 @@ +// Copyright (c) The Avalonia Project. All rights reserved. +// Licensed under the MIT license. See licence.md file in the project root for full license information. + +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Utility; + +namespace Avalonia.Platform +{ + /// + /// An abstraction that is used produce shaped text. + /// + public interface ITextShaperImpl + { + /// + /// Shapes the specified region within the text and returns a resulting glyph run. + /// + /// The text. + /// The text format. + /// A shaped glyph run. + GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat); + } +} diff --git a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs index 05c3d7e62a..29cc365251 100644 --- a/src/Avalonia.Visuals/Properties/AssemblyInfo.cs +++ b/src/Avalonia.Visuals/Properties/AssemblyInfo.cs @@ -8,7 +8,9 @@ using Avalonia.Metadata; [assembly: InternalsVisibleTo("Avalonia.Visuals.UnitTests")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Animation")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media")] +[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia.Media.Imaging")] [assembly: XmlnsDefinition("https://github.com/avaloniaui", "Avalonia")] [assembly: InternalsVisibleTo("Avalonia.Direct2D1.RenderTests")] -[assembly: InternalsVisibleTo("Avalonia.Skia.RenderTests")] \ No newline at end of file +[assembly: InternalsVisibleTo("Avalonia.Skia.RenderTests")] +[assembly: InternalsVisibleTo("Avalonia.Skia.UnitTests")] diff --git a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs index b065079564..f11bd76a7b 100644 --- a/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs +++ b/src/Avalonia.Visuals/Rendering/DeferredRenderer.cs @@ -355,15 +355,21 @@ namespace Avalonia.Rendering node.BeginRender(context, isLayerRoot); - foreach (var operation in node.DrawOperations) + var drawOperations = node.DrawOperations; + var drawOperationsCount = drawOperations.Count; + for (int i = 0; i < drawOperationsCount; i++) { + var operation = drawOperations[i]; _currentDraw = operation; operation.Item.Render(context); _currentDraw = null; } - foreach (var child in node.Children) + var children = node.Children; + var childrenCount = children.Count; + for (int i = 0; i < childrenCount; i++) { + var child = children[i]; Render(context, (VisualNode)child, layer, clipBounds); } diff --git a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs b/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs index c54ccc8ef1..f0feb7958e 100644 --- a/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs +++ b/src/Avalonia.Visuals/Utility/ReadOnlySlice.cs @@ -4,6 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using Avalonia.Utilities; namespace Avalonia.Utility @@ -12,6 +13,7 @@ namespace Avalonia.Utility /// ReadOnlySlice enables the ability to work with a sequence within a region of memory and retains the position in within that region. /// /// The type of elements in the slice. + [DebuggerTypeProxy(typeof(ReadOnlySlice<>.ReadOnlySliceDebugView))] public readonly struct ReadOnlySlice : IReadOnlyList { public ReadOnlySlice(ReadOnlyMemory buffer) : this(buffer, 0, buffer.Length) { } @@ -57,16 +59,7 @@ namespace Avalonia.Utility /// public ReadOnlyMemory Buffer { get; } - public T this[int index] => Buffer.Span[Start + index]; - - /// - /// Returns a span of the underlying buffer. - /// - /// The of the underlying buffer. - public ReadOnlySpan AsSpan() - { - return Buffer.Span.Slice(Start, Length); - } + public T this[int index] => Buffer.Span[index]; /// /// Returns a sub slice of elements that start at the specified index and has the specified number of elements. @@ -76,17 +69,19 @@ namespace Avalonia.Utility /// A that contains the specified number of elements from the specified start. public ReadOnlySlice AsSlice(int start, int length) { - if (start < 0 || start >= Length) + if (start < Start || start > End) { throw new ArgumentOutOfRangeException(nameof(start)); } - if (Start + start > End) + if (start + length > Start + Length) { throw new ArgumentOutOfRangeException(nameof(length)); } - return new ReadOnlySlice(Buffer, Start + start, length); + var bufferOffset = start - Start; + + return new ReadOnlySlice(Buffer.Slice(bufferOffset), start, length); } /// @@ -101,7 +96,7 @@ namespace Avalonia.Utility throw new ArgumentOutOfRangeException(nameof(length)); } - return new ReadOnlySlice(Buffer, Start, length); + return new ReadOnlySlice(Buffer.Slice(0, length), Start, length); } /// @@ -116,7 +111,7 @@ namespace Avalonia.Utility throw new ArgumentOutOfRangeException(nameof(length)); } - return new ReadOnlySlice(Buffer, Start + length, Length - length); + return new ReadOnlySlice(Buffer.Slice(length), Start + length, Length - length); } /// @@ -150,5 +145,25 @@ namespace Avalonia.Utility { return new ReadOnlySlice(memory); } + + internal class ReadOnlySliceDebugView + { + private readonly ReadOnlySlice _readOnlySlice; + + public ReadOnlySliceDebugView(ReadOnlySlice readOnlySlice) + { + _readOnlySlice = readOnlySlice; + } + + public int Start => _readOnlySlice.Start; + + public int End => _readOnlySlice.End; + + public int Length => _readOnlySlice.Length; + + public bool IsEmpty => _readOnlySlice.IsEmpty; + + public ReadOnlyMemory Items => _readOnlySlice.Buffer; + } } } diff --git a/src/Avalonia.Visuals/Visual.cs b/src/Avalonia.Visuals/Visual.cs index ef7a254f86..d9032cfa3e 100644 --- a/src/Avalonia.Visuals/Visual.cs +++ b/src/Avalonia.Visuals/Visual.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Specialized; -using System.Linq; using Avalonia.Collections; using Avalonia.Data; using Avalonia.Logging; @@ -336,7 +335,15 @@ namespace Avalonia protected static void AffectsRender(params AvaloniaProperty[] properties) where T : Visual { - void Invalidate(AvaloniaPropertyChangedEventArgs e) + static void Invalidate(AvaloniaPropertyChangedEventArgs e) + { + if (e.Sender is T sender) + { + sender.InvalidateVisual(); + } + } + + static void InvalidateAndSubscribe(AvaloniaPropertyChangedEventArgs e) { if (e.Sender is T sender) { @@ -347,7 +354,7 @@ namespace Avalonia if (e.NewValue is IAffectsRender newValue) { - WeakEventHandlerManager.Subscribe(newValue, nameof(newValue.Invalidated), sender.AffectsRenderInvalidated); + WeakEventHandlerManager.Subscribe(newValue, nameof(newValue.Invalidated), sender.AffectsRenderInvalidated); } sender.InvalidateVisual(); @@ -356,7 +363,14 @@ namespace Avalonia foreach (var property in properties) { - property.Changed.Subscribe(Invalidate); + if (property.CanValueAffectRender()) + { + property.Changed.Subscribe(e => InvalidateAndSubscribe(e)); + } + else + { + property.Changed.Subscribe(e => Invalidate(e)); + } } } @@ -386,11 +400,18 @@ namespace Avalonia AttachedToVisualTree?.Invoke(this, e); InvalidateVisual(); - if (VisualChildren != null) + var visualChildren = VisualChildren; + + if (visualChildren != null) { - foreach (Visual child in VisualChildren.OfType()) + var visualChildrenCount = visualChildren.Count; + + for (var i = 0; i < visualChildrenCount; i++) { - child.OnAttachedToVisualTreeCore(e); + if (visualChildren[i] is Visual child) + { + child.OnAttachedToVisualTreeCore(e); + } } } } @@ -415,11 +436,18 @@ namespace Avalonia DetachedFromVisualTree?.Invoke(this, e); e.Root?.Renderer?.AddDirty(this); - if (VisualChildren != null) + var visualChildren = VisualChildren; + + if (visualChildren != null) { - foreach (Visual child in VisualChildren.OfType()) + var visualChildrenCount = visualChildren.Count; + + for (var i = 0; i < visualChildrenCount; i++) { - child.OnDetachedFromVisualTreeCore(e); + if (visualChildren[i] is Visual child) + { + child.OnDetachedFromVisualTreeCore(e); + } } } } diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 94f1dbc8d1..2492f5a43a 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -173,6 +173,7 @@ namespace Avalonia.X11 Surfaces = surfaces.ToArray(); UpdateMotifHints(); + UpdateSizeHints(null); _xic = XCreateIC(_x11.Xim, XNames.XNInputStyle, XIMProperties.XIMPreeditNothing | XIMProperties.XIMStatusNothing, XNames.XNClientWindow, _handle, IntPtr.Zero); XFlush(_x11.Display); @@ -219,12 +220,16 @@ namespace Avalonia.X11 var decorations = MotifDecorations.Menu | MotifDecorations.Title | MotifDecorations.Border | MotifDecorations.Maximize | MotifDecorations.Minimize | MotifDecorations.ResizeH; - if (_popup || !_systemDecorations) + if (_popup || _systemDecorations == SystemDecorations.None) { decorations = 0; } + else if (_systemDecorations == SystemDecorations.BorderOnly) + { + decorations = MotifDecorations.Border; + } - if (!_canResize) + if (!_canResize || _systemDecorations == SystemDecorations.BorderOnly) { functions &= ~(MotifFunctions.Resize | MotifFunctions.Maximize); decorations &= ~(MotifDecorations.Maximize | MotifDecorations.ResizeH); @@ -247,7 +252,7 @@ namespace Avalonia.X11 var min = _minMaxSize.minSize; var max = _minMaxSize.maxSize; - if (!_canResize) + if (!_canResize || _systemDecorations == SystemDecorations.BorderOnly) max = min = _realSize; if (preResize.HasValue) @@ -621,7 +626,7 @@ namespace Avalonia.X11 return rv; } - private bool _systemDecorations = true; + private SystemDecorations _systemDecorations = SystemDecorations.Full; private bool _canResize = true; private const int MaxWindowDimension = 100000; @@ -777,10 +782,11 @@ namespace Avalonia.X11 (int)(point.X * Scaling + Position.X), (int)(point.Y * Scaling + Position.Y)); - public void SetSystemDecorations(bool enabled) + public void SetSystemDecorations(SystemDecorations enabled) { _systemDecorations = enabled; UpdateMotifHints(); + UpdateSizeHints(null); } diff --git a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs index 41eab79ed8..e5e28b344f 100644 --- a/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs +++ b/src/Markup/Avalonia.Markup.Xaml/Styling/StyleInclude.cs @@ -4,6 +4,7 @@ using Avalonia.Styling; using System; using Avalonia.Controls; +using System.Collections.Generic; namespace Avalonia.Markup.Xaml.Styling { @@ -67,23 +68,7 @@ namespace Avalonia.Markup.Xaml.Styling IResourceNode IResourceNode.ResourceParent => _parent; /// - public bool Attach(IStyleable control, IStyleHost container) - { - if (Source != null) - { - return Loaded.Attach(control, container); - } - - return false; - } - - public void Detach() - { - if (Source != null) - { - Loaded.Detach(); - } - } + public SelectorMatchResult TryAttach(IStyleable target, IStyleHost host) => Loaded.TryAttach(target, host); /// public bool TryGetResource(object key, out object value) => Loaded.TryGetResource(key, out value); diff --git a/src/Skia/Avalonia.Skia/FontManagerImpl.cs b/src/Skia/Avalonia.Skia/FontManagerImpl.cs index 60d6ecaabc..f2c9e1848d 100644 --- a/src/Skia/Avalonia.Skia/FontManagerImpl.cs +++ b/src/Skia/Avalonia.Skia/FontManagerImpl.cs @@ -109,7 +109,7 @@ namespace Avalonia.Skia { var fontCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(typeface.FontFamily); - skTypeface = fontCollection.Get(typeface.FontFamily, typeface.Weight, typeface.Style); + skTypeface = fontCollection.Get(typeface); } return new GlyphTypefaceImpl(skTypeface); diff --git a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs index 8effb94ca9..8f608ddb70 100644 --- a/src/Skia/Avalonia.Skia/FormattedTextImpl.cs +++ b/src/Skia/Avalonia.Skia/FormattedTextImpl.cs @@ -642,6 +642,11 @@ namespace Avalonia.Skia var lastLine = _skiaLines[_skiaLines.Count - 1]; _bounds = new Rect(0, 0, maxX, lastLine.Top + lastLine.Height); + if (double.IsPositiveInfinity(Constraint.Width)) + { + return; + } + switch (_paint.TextAlign) { case SKTextAlign.Center: diff --git a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs index bb2650a5c6..de487dc36c 100644 --- a/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs +++ b/src/Skia/Avalonia.Skia/GlyphTypefaceImpl.cs @@ -16,7 +16,7 @@ namespace Avalonia.Skia public GlyphTypefaceImpl(SKTypeface typeface) { - Typeface = typeface; + Typeface = typeface ?? throw new ArgumentNullException(nameof(typeface)); Face = new Face(GetTable) { diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index 65ed1f506e..63b6cb70da 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -152,12 +152,6 @@ namespace Avalonia.Skia return new WriteableBitmapImpl(size, dpi, format); } - /// - public IFontManagerImpl CreateFontManager() - { - return new FontManagerImpl(); - } - /// public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width) { @@ -206,7 +200,7 @@ namespace Avalonia.Skia } } - buffer.SetGlyphs(glyphRun.GlyphIndices.AsSpan()); + buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); } else { @@ -232,7 +226,7 @@ namespace Avalonia.Skia } } - buffer.SetGlyphs(glyphRun.GlyphIndices.AsSpan()); + buffer.SetGlyphs(glyphRun.GlyphIndices.Buffer.Span); width = currentX; } diff --git a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs index d1c1961a8a..c4bb6a75f5 100644 --- a/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs +++ b/src/Skia/Avalonia.Skia/SKTypefaceCollection.cs @@ -20,9 +20,9 @@ namespace Avalonia.Skia _typefaces.TryAdd(key, typeface); } - public SKTypeface Get(FontFamily fontFamily, FontWeight fontWeight, FontStyle fontStyle) + public SKTypeface Get(Typeface typeface) { - var key = new FontKey(fontFamily, fontWeight, fontStyle); + var key = new FontKey(typeface.FontFamily, typeface.Weight, typeface.Style); return GetNearestMatch(_typefaces, key); } diff --git a/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs b/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs index 71edae26df..a7342404ee 100644 --- a/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs +++ b/src/Skia/Avalonia.Skia/SKTypefaceCollectionCache.cs @@ -1,6 +1,7 @@ // Copyright (c) The Avalonia Project. All rights reserved. // Licensed under the MIT license. See licence.md file in the project root for full license information. +using System; using System.Collections.Concurrent; using Avalonia.Media; using Avalonia.Media.Fonts; @@ -11,11 +12,11 @@ namespace Avalonia.Skia { internal static class SKTypefaceCollectionCache { - private static readonly ConcurrentDictionary s_cachedCollections; + private static readonly ConcurrentDictionary s_cachedCollections; static SKTypefaceCollectionCache() { - s_cachedCollections = new ConcurrentDictionary(); + s_cachedCollections = new ConcurrentDictionary(); } /// @@ -25,7 +26,7 @@ namespace Avalonia.Skia /// public static SKTypefaceCollection GetOrAddTypefaceCollection(FontFamily fontFamily) { - return s_cachedCollections.GetOrAdd(fontFamily.Key, x => CreateCustomFontCollection(fontFamily)); + return s_cachedCollections.GetOrAdd(fontFamily, x => CreateCustomFontCollection(fontFamily)); } /// @@ -45,8 +46,17 @@ namespace Avalonia.Skia { var assetStream = assetLoader.Open(asset); + if (assetStream == null) throw new InvalidOperationException("Asset could not be loaded."); + var typeface = SKTypeface.FromStream(assetStream); + if(typeface == null) throw new InvalidOperationException("Typeface could not be loaded."); + + if (typeface.FamilyName != fontFamily.Name) + { + continue; + } + var key = new FontKey(fontFamily, (FontWeight)typeface.FontWeight, (FontStyle)typeface.FontSlant); typeFaceCollection.AddTypeface(key, typeface); diff --git a/src/Skia/Avalonia.Skia/SkiaPlatform.cs b/src/Skia/Avalonia.Skia/SkiaPlatform.cs index f16e967f42..d20ac0a45d 100644 --- a/src/Skia/Avalonia.Skia/SkiaPlatform.cs +++ b/src/Skia/Avalonia.Skia/SkiaPlatform.cs @@ -24,7 +24,9 @@ namespace Avalonia.Skia var renderInterface = new PlatformRenderInterface(customGpu); AvaloniaLocator.CurrentMutable - .Bind().ToConstant(renderInterface); + .Bind().ToConstant(renderInterface) + .Bind().ToConstant(new FontManagerImpl()) + .Bind().ToConstant(new TextShaperImpl()); } /// diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs new file mode 100644 index 0000000000..32fe48fe49 --- /dev/null +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -0,0 +1,116 @@ +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utility; +using HarfBuzzSharp; +using Buffer = HarfBuzzSharp.Buffer; + +namespace Avalonia.Skia +{ + internal class TextShaperImpl : ITextShaperImpl + { + public GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat) + { + using (var buffer = new Buffer()) + { + buffer.ContentType = ContentType.Unicode; + + var breakCharPosition = text.Length - 1; + + var codepoint = Codepoint.ReadAt(text, breakCharPosition, out var count); + + if (codepoint.IsBreakChar) + { + var breakCharCount = 1; + + if (text.Length > 1) + { + var previousCodepoint = Codepoint.ReadAt(text, breakCharPosition - count, out _); + + if (codepoint == '\r' && previousCodepoint == '\n' + || codepoint == '\n' && previousCodepoint == '\r') + { + breakCharCount = 2; + } + } + + if (breakCharPosition != text.Start) + { + buffer.AddUtf16(text.Buffer.Span.Slice(0, text.Length - breakCharCount)); + } + + var cluster = buffer.GlyphInfos.Length > 0 ? + buffer.GlyphInfos[buffer.Length - 1].Cluster + 1 : + (uint)text.Start; + + switch (breakCharCount) + { + case 1: + buffer.Add('\u200C', cluster); + break; + case 2: + buffer.Add('\u200C', cluster); + buffer.Add('\u200D', cluster); + break; + } + } + else + { + buffer.AddUtf16(text.Buffer.Span); + } + + buffer.GuessSegmentProperties(); + + var glyphTypeface = textFormat.Typeface.GlyphTypeface; + + var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font; + + font.Shape(buffer); + + font.GetScale(out var scaleX, out _); + + var textScale = textFormat.FontRenderingEmSize / scaleX; + + var len = buffer.Length; + + var info = buffer.GetGlyphInfoSpan(); + + var pos = buffer.GetGlyphPositionSpan(); + + var glyphIndices = new ushort[len]; + + var clusters = new ushort[len]; + + var glyphAdvances = new double[len]; + + var glyphOffsets = new Vector[len]; + + for (var i = 0; i < len; i++) + { + glyphIndices[i] = (ushort)info[i].Codepoint; + + clusters[i] = (ushort)(text.Start + info[i].Cluster); + + var advanceX = pos[i].XAdvance * textScale; + // Depends on direction of layout + //var advanceY = pos[i].YAdvance * textScale; + + glyphAdvances[i] = advanceX; + + var offsetX = pos[i].XOffset * textScale; + var offsetY = pos[i].YOffset * textScale; + + glyphOffsets[i] = new Vector(offsetX, offsetY); + } + + return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, + new ReadOnlySlice(glyphIndices), + new ReadOnlySlice(glyphAdvances), + new ReadOnlySlice(glyphOffsets), + text, + new ReadOnlySlice(clusters)); + } + } + } +} diff --git a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj index 7d47b95ede..cda95d2ebb 100644 --- a/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj +++ b/src/Windows/Avalonia.Direct2D1/Avalonia.Direct2D1.csproj @@ -3,6 +3,7 @@ netstandard2.0 true Avalonia.Direct2D1 + true diff --git a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs index a2bedf3190..88964dc489 100644 --- a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs +++ b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs @@ -109,7 +109,10 @@ namespace Avalonia.Direct2D1 public static void Initialize() { InitializeDirect2D(); - AvaloniaLocator.CurrentMutable.Bind().ToConstant(s_instance); + AvaloniaLocator.CurrentMutable + .Bind().ToConstant(s_instance) + .Bind().ToConstant(new FontManagerImpl()) + .Bind().ToConstant(new TextShaperImpl()); SharpDX.Configuration.EnableReleaseOnFinalizer = true; } @@ -194,12 +197,6 @@ namespace Avalonia.Direct2D1 return new WicBitmapImpl(format, data, size, dpi, stride); } - /// - public IFontManagerImpl CreateFontManager() - { - return new FontManagerImpl(); - } - public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width) { var glyphTypeface = (GlyphTypefaceImpl)glyphRun.GlyphTypeface.PlatformImpl; diff --git a/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs new file mode 100644 index 0000000000..2d2865e2b9 --- /dev/null +++ b/src/Windows/Avalonia.Direct2D1/Media/TextShaperImpl.cs @@ -0,0 +1,116 @@ +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.Platform; +using Avalonia.Utility; +using HarfBuzzSharp; +using Buffer = HarfBuzzSharp.Buffer; + +namespace Avalonia.Direct2D1.Media +{ + internal class TextShaperImpl : ITextShaperImpl + { + public GlyphRun ShapeText(ReadOnlySlice text, TextFormat textFormat) + { + using (var buffer = new Buffer()) + { + buffer.ContentType = ContentType.Unicode; + + var breakCharPosition = text.Length - 1; + + var codepoint = Codepoint.ReadAt(text, breakCharPosition, out var count); + + if (codepoint.IsBreakChar) + { + var breakCharCount = 1; + + if (text.Length > 1) + { + var previousCodepoint = Codepoint.ReadAt(text, breakCharPosition - count, out _); + + if (codepoint == '\r' && previousCodepoint == '\n' + || codepoint == '\n' && previousCodepoint == '\r') + { + breakCharCount = 2; + } + } + + if (breakCharPosition != text.Start) + { + buffer.AddUtf16(text.Buffer.Span.Slice(0, text.Length - breakCharCount)); + } + + var cluster = buffer.GlyphInfos.Length > 0 ? + buffer.GlyphInfos[buffer.Length - 1].Cluster + 1 : + (uint)text.Start; + + switch (breakCharCount) + { + case 1: + buffer.Add('\u200C', cluster); + break; + case 2: + buffer.Add('\u200C', cluster); + buffer.Add('\u200D', cluster); + break; + } + } + else + { + buffer.AddUtf16(text.Buffer.Span); + } + + buffer.GuessSegmentProperties(); + + var glyphTypeface = textFormat.Typeface.GlyphTypeface; + + var font = ((GlyphTypefaceImpl)glyphTypeface.PlatformImpl).Font; + + font.Shape(buffer); + + font.GetScale(out var scaleX, out _); + + var textScale = textFormat.FontRenderingEmSize / scaleX; + + var len = buffer.Length; + + var info = buffer.GetGlyphInfoSpan(); + + var pos = buffer.GetGlyphPositionSpan(); + + var glyphIndices = new ushort[len]; + + var clusters = new ushort[len]; + + var glyphAdvances = new double[len]; + + var glyphOffsets = new Vector[len]; + + for (var i = 0; i < len; i++) + { + glyphIndices[i] = (ushort)info[i].Codepoint; + + clusters[i] = (ushort)(text.Start + info[i].Cluster); + + var advanceX = pos[i].XAdvance * textScale; + // Depends on direction of layout + //var advanceY = pos[i].YAdvance * textScale; + + glyphAdvances[i] = advanceX; + + var offsetX = pos[i].XOffset * textScale; + var offsetY = pos[i].YOffset * textScale; + + glyphOffsets[i] = new Vector(offsetX, offsetY); + } + + return new GlyphRun(glyphTypeface, textFormat.FontRenderingEmSize, + new ReadOnlySlice(glyphIndices), + new ReadOnlySlice(glyphAdvances), + new ReadOnlySlice(glyphOffsets), + text, + new ReadOnlySlice(clusters)); + } + } + } +} diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index 904e122382..50b568cab2 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1298,7 +1298,17 @@ namespace Avalonia.Win32.Interop [DllImport("ole32.dll", CharSet = CharSet.Auto, ExactSpelling = true, PreserveSig = false)] internal static extern void DoDragDrop(IOleDataObject dataObject, IDropSource dropSource, int allowedEffects, out int finalEffect); + [DllImport("dwmapi.dll")] + public static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref MARGINS margins); + [StructLayout(LayoutKind.Sequential)] + internal struct MARGINS + { + public int cxLeftWidth; + public int cxRightWidth; + public int cyTopHeight; + public int cyBottomHeight; + } public enum MONITOR { diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index cea9f88799..6900d73c93 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -34,7 +34,7 @@ namespace Avalonia.Win32 private IInputRoot _owner; private ManagedDeferredRendererLock _rendererLock = new ManagedDeferredRendererLock(); private bool _trackingMouse; - private bool _decorated = true; + private SystemDecorations _decorated = SystemDecorations.Full; private bool _resizable = true; private bool _topmost = false; private bool _taskbarIcon = true; @@ -97,7 +97,7 @@ namespace Avalonia.Win32 { get { - if (_decorated) + if (_decorated == SystemDecorations.Full) { var style = UnmanagedMethods.GetWindowLong(_hwnd, (int)UnmanagedMethods.WindowLongParam.GWL_STYLE); var exStyle = UnmanagedMethods.GetWindowLong(_hwnd, (int)UnmanagedMethods.WindowLongParam.GWL_EXSTYLE); @@ -281,7 +281,7 @@ namespace Avalonia.Win32 UnmanagedMethods.ShowWindow(_hwnd, UnmanagedMethods.ShowWindowCommand.Hide); } - public void SetSystemDecorations(bool value) + public void SetSystemDecorations(SystemDecorations value) { if (value == _decorated) { @@ -464,7 +464,7 @@ namespace Avalonia.Win32 return IntPtr.Zero; case WindowsMessage.WM_NCCALCSIZE: - if (ToInt32(wParam) == 1 && !_decorated) + if (ToInt32(wParam) == 1 && _decorated != SystemDecorations.Full) { return IntPtr.Zero; } @@ -682,14 +682,14 @@ namespace Avalonia.Win32 break; case WindowsMessage.WM_NCPAINT: - if (!_decorated) + if (_decorated != SystemDecorations.Full) { return IntPtr.Zero; } break; case WindowsMessage.WM_NCACTIVATE: - if (!_decorated) + if (_decorated != SystemDecorations.Full) { return new IntPtr(1); } @@ -1001,7 +1001,7 @@ namespace Avalonia.Win32 style |= WindowStyles.WS_OVERLAPPEDWINDOW; - if (!_decorated) + if (_decorated != SystemDecorations.Full) { style ^= (WindowStyles.WS_CAPTION | WindowStyles.WS_SYSMENU); } @@ -1011,6 +1011,10 @@ namespace Avalonia.Win32 style ^= (WindowStyles.WS_SIZEFRAME); } + MARGINS margins = new MARGINS(); + margins.cyBottomHeight = _decorated == SystemDecorations.BorderOnly ? 1 : 0; + UnmanagedMethods.DwmExtendFrameIntoClientArea(_hwnd, ref margins); + GetClientRect(_hwnd, out var oldClientRect); var oldClientRectOrigin = new UnmanagedMethods.POINT(); ClientToScreen(_hwnd, ref oldClientRectOrigin); @@ -1024,7 +1028,7 @@ namespace Avalonia.Win32 if (oldDecorated != _decorated) { var newRect = oldClientRect; - if (_decorated) + if (_decorated == SystemDecorations.Full) AdjustWindowRectEx(ref newRect, (uint)style, false, GetWindowLong(_hwnd, (int)WindowLongParam.GWL_EXSTYLE)); SetWindowPos(_hwnd, IntPtr.Zero, newRect.left, newRect.top, newRect.Width, newRect.Height, diff --git a/src/iOS/Avalonia.iOS/EmbeddableImpl.cs b/src/iOS/Avalonia.iOS/EmbeddableImpl.cs index 65a6c15971..838bf49846 100644 --- a/src/iOS/Avalonia.iOS/EmbeddableImpl.cs +++ b/src/iOS/Avalonia.iOS/EmbeddableImpl.cs @@ -20,7 +20,7 @@ namespace Avalonia.iOS return Disposable.Empty; } - public void SetSystemDecorations(bool enabled) + public void SetSystemDecorations(SystemDecorations enabled) { } diff --git a/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj b/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj index 1c7077870a..372cb8476b 100644 --- a/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj +++ b/src/tools/Avalonia.Designer.HostApp/Avalonia.Designer.HostApp.csproj @@ -1,7 +1,7 @@  Exe - net461;netcoreapp2.0 + net461;netcoreapp3.1 diff --git a/tests/Avalonia.Animation.UnitTests/Avalonia.Animation.UnitTests.csproj b/tests/Avalonia.Animation.UnitTests/Avalonia.Animation.UnitTests.csproj index ef515ce155..dd50eff2b6 100644 --- a/tests/Avalonia.Animation.UnitTests/Avalonia.Animation.UnitTests.csproj +++ b/tests/Avalonia.Animation.UnitTests/Avalonia.Animation.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true diff --git a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj index 6e885f33be..0d87f0eb03 100644 --- a/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj +++ b/tests/Avalonia.Base.UnitTests/Avalonia.Base.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs index 4c00d2a1ea..2839fde320 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs @@ -132,6 +132,111 @@ namespace Avalonia.Base.UnitTests Assert.Equal("foo", target.GetValue(property)); } + [Fact] + public void Completing_LocalValue_Binding_Raises_PropertyChanged() + { + var target = new Class1(); + var source = new BehaviorSubject>("foo"); + var property = Class1.FooProperty; + var raised = 0; + + target.Bind(property, source); + Assert.Equal("foo", target.GetValue(property)); + + target.PropertyChanged += (s, e) => + { + Assert.Equal(BindingPriority.Unset, e.Priority); + Assert.Equal(property, e.Property); + Assert.Equal("foo", e.OldValue as string); + Assert.Equal("foodefault", e.NewValue as string); + ++raised; + }; + + source.OnCompleted(); + + Assert.Equal("foodefault", target.GetValue(property)); + Assert.Equal(1, raised); + } + + [Fact] + public void Completing_Style_Binding_Raises_PropertyChanged() + { + var target = new Class1(); + var source = new BehaviorSubject>("foo"); + var property = Class1.FooProperty; + var raised = 0; + + target.Bind(property, source, BindingPriority.Style); + Assert.Equal("foo", target.GetValue(property)); + + target.PropertyChanged += (s, e) => + { + Assert.Equal(BindingPriority.Unset, e.Priority); + Assert.Equal(property, e.Property); + Assert.Equal("foo", e.OldValue as string); + Assert.Equal("foodefault", e.NewValue as string); + ++raised; + }; + + source.OnCompleted(); + + Assert.Equal("foodefault", target.GetValue(property)); + Assert.Equal(1, raised); + } + + [Fact] + public void Completing_LocalValue_Binding_With_Style_Binding_Raises_PropertyChanged() + { + var target = new Class1(); + var source = new BehaviorSubject>("foo"); + var property = Class1.FooProperty; + var raised = 0; + + target.Bind(property, new BehaviorSubject("bar"), BindingPriority.Style); + target.Bind(property, source); + Assert.Equal("foo", target.GetValue(property)); + + target.PropertyChanged += (s, e) => + { + Assert.Equal(BindingPriority.Style, e.Priority); + Assert.Equal(property, e.Property); + Assert.Equal("foo", e.OldValue as string); + Assert.Equal("bar", e.NewValue as string); + ++raised; + }; + + source.OnCompleted(); + + Assert.Equal("bar", target.GetValue(property)); + Assert.Equal(1, raised); + } + + [Fact] + public void Disposing_LocalValue_Binding_Raises_PropertyChanged() + { + var target = new Class1(); + var source = new BehaviorSubject>("foo"); + var property = Class1.FooProperty; + var raised = 0; + + var sub = target.Bind(property, source); + Assert.Equal("foo", target.GetValue(property)); + + target.PropertyChanged += (s, e) => + { + Assert.Equal(BindingPriority.Unset, e.Priority); + Assert.Equal(property, e.Property); + Assert.Equal("foo", e.OldValue as string); + Assert.Equal("foodefault", e.NewValue as string); + ++raised; + }; + + sub.Dispose(); + + Assert.Equal("foodefault", target.GetValue(property)); + Assert.Equal(1, raised); + } + [Fact] public void Setting_Style_Value_Overrides_Binding_Permanently() { diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs index b1a5b5ae92..ca17afb94f 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Direct.cs @@ -188,6 +188,7 @@ namespace Avalonia.Base.UnitTests target.PropertyChanged += (s, e) => { Assert.Same(target, s); + Assert.Equal(BindingPriority.LocalValue, e.Priority); Assert.Equal(Class1.FooProperty, e.Property); Assert.Equal("newvalue", (string)e.OldValue); Assert.Equal("unset", (string)e.NewValue); diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_SetValue.cs b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_SetValue.cs index 40631d04cf..1b8cd787f2 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_SetValue.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_SetValue.cs @@ -30,6 +30,7 @@ namespace Avalonia.Base.UnitTests target.PropertyChanged += (s, e) => { Assert.Same(target, s); + Assert.Equal(BindingPriority.Unset, e.Priority); Assert.Equal(Class1.FooProperty, e.Property); Assert.Equal("newvalue", (string)e.OldValue); Assert.Equal("foodefault", (string)e.NewValue); @@ -239,6 +240,17 @@ namespace Avalonia.Base.UnitTests Assert.Equal("two", target.GetValue(Class1.FooProperty)); } + [Fact] + public void SetValue_Animation_Overrides_LocalValue() + { + Class1 target = new Class1(); + + target.SetValue(Class1.FooProperty, "one", BindingPriority.LocalValue); + Assert.Equal("one", target.GetValue(Class1.FooProperty)); + target.SetValue(Class1.FooProperty, "two", BindingPriority.Animation); + Assert.Equal("two", target.GetValue(Class1.FooProperty)); + } + [Fact] public void Setting_UnsetValue_Reverts_To_Default_Value() { @@ -272,6 +284,41 @@ namespace Avalonia.Base.UnitTests Assert.Equal("newvalue", target.GetValue(Class1.FrankProperty)); } + [Fact] + public void Disposing_Style_SetValue_Reverts_To_DefaultValue() + { + Class1 target = new Class1(); + + var d = target.SetValue(Class1.FooProperty, "foo", BindingPriority.Style); + d.Dispose(); + + Assert.Equal("foodefault", target.GetValue(Class1.FooProperty)); + } + + [Fact] + public void Disposing_Style_SetValue_Reverts_To_Previous_Style_Value() + { + Class1 target = new Class1(); + + target.SetValue(Class1.FooProperty, "foo", BindingPriority.Style); + var d = target.SetValue(Class1.FooProperty, "bar", BindingPriority.Style); + d.Dispose(); + + Assert.Equal("foo", target.GetValue(Class1.FooProperty)); + } + + [Fact] + public void Disposing_Animation_SetValue_Reverts_To_Previous_Local_Value() + { + Class1 target = new Class1(); + + target.SetValue(Class1.FooProperty, "foo", BindingPriority.LocalValue); + var d = target.SetValue(Class1.FooProperty, "bar", BindingPriority.Animation); + d.Dispose(); + + Assert.Equal("foo", target.GetValue(Class1.FooProperty)); + } + private class Class1 : AvaloniaObject { public static readonly StyledProperty FooProperty = diff --git a/tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs b/tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs index 6bb8dfe1f5..c7eebdd70a 100644 --- a/tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs +++ b/tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs @@ -3,6 +3,7 @@ using System; using Avalonia.Data; +using Avalonia.Utilities; using Xunit; namespace Avalonia.Base.UnitTests @@ -123,6 +124,11 @@ namespace Avalonia.Base.UnitTests OverrideMetadata(typeof(T), metadata); } + public override void Accept(IAvaloniaPropertyVisitor vistor, ref TData data) + { + throw new NotImplementedException(); + } + internal override IDisposable RouteBind( IAvaloniaObject o, IObservable> source, @@ -146,7 +152,7 @@ namespace Avalonia.Base.UnitTests throw new NotImplementedException(); } - internal override void RouteSetValue( + internal override IDisposable RouteSetValue( IAvaloniaObject o, object value, BindingPriority priority) diff --git a/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs b/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs index ecf559951a..f29dabb6bf 100644 --- a/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs +++ b/tests/Avalonia.Base.UnitTests/Data/DefaultValueConverterTests.cs @@ -50,6 +50,18 @@ namespace Avalonia.Base.UnitTests.Data.Converters Assert.Equal(TestEnum.Bar, result); } + [Fact] + public void Can_Convert_String_To_TimeSpan() + { + var result = DefaultValueConverter.Instance.Convert( + "00:00:10", + typeof(TimeSpan), + null, + CultureInfo.InvariantCulture); + + Assert.Equal(TimeSpan.FromSeconds(10), result); + } + [Fact] public void Can_Convert_Int_To_Enum() { diff --git a/tests/Avalonia.Base.UnitTests/PriorityValueTests.cs b/tests/Avalonia.Base.UnitTests/PriorityValueTests.cs index 8c76445645..5e69b8490d 100644 --- a/tests/Avalonia.Base.UnitTests/PriorityValueTests.cs +++ b/tests/Avalonia.Base.UnitTests/PriorityValueTests.cs @@ -24,7 +24,11 @@ namespace Avalonia.Base.UnitTests Owner, TestProperty, NullSink, - new ConstantValueEntry(TestProperty, "1", BindingPriority.StyleTrigger)); + new ConstantValueEntry( + TestProperty, + "1", + BindingPriority.StyleTrigger, + NullSink)); Assert.Equal("1", target.Value.Value); Assert.Equal(BindingPriority.StyleTrigger, target.ValuePriority); diff --git a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj index f503bf66a7..5fc3dce671 100644 --- a/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj +++ b/tests/Avalonia.Benchmarks/Avalonia.Benchmarks.csproj @@ -1,7 +1,7 @@  Exe - netcoreapp2.0 + netcoreapp3.1 Exe false diff --git a/tests/Avalonia.Benchmarks/NullGlyphRun.cs b/tests/Avalonia.Benchmarks/NullGlyphRun.cs new file mode 100644 index 0000000000..5ba0822649 --- /dev/null +++ b/tests/Avalonia.Benchmarks/NullGlyphRun.cs @@ -0,0 +1,11 @@ +using Avalonia.Platform; + +namespace Avalonia.Benchmarks +{ + internal class NullGlyphRun : IGlyphRunImpl + { + public void Dispose() + { + } + } +} diff --git a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs index 101d40f00a..1f983069c2 100644 --- a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs +++ b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs @@ -72,7 +72,9 @@ namespace Avalonia.Benchmarks public IGlyphRunImpl CreateGlyphRun(GlyphRun glyphRun, out double width) { - throw new NotImplementedException(); + width = default; + + return new NullGlyphRun(); } } } diff --git a/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs b/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs index d24a646f74..b9d0b53728 100644 --- a/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs +++ b/tests/Avalonia.Benchmarks/Styling/ApplyStyling.cs @@ -45,7 +45,7 @@ namespace Avalonia.Benchmarks.Styling { _window.Styles.Add(new Style(x => x.OfType().Class("foo").Class("bar").Class("baz")) { - Setters = new[] + Setters = { new Setter(TextBox.TextProperty, "foo"), } diff --git a/tests/Avalonia.Benchmarks/Styling/StyleAttachBenchmark.cs b/tests/Avalonia.Benchmarks/Styling/StyleAttachBenchmark.cs new file mode 100644 index 0000000000..7dad517e51 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Styling/StyleAttachBenchmark.cs @@ -0,0 +1,47 @@ +using System; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Styling; +using Avalonia.UnitTests; +using BenchmarkDotNet.Attributes; + +namespace Avalonia.Benchmarks.Styling +{ + [MemoryDiagnoser] + public class StyleAttachBenchmark : IDisposable + { + private readonly IDisposable _app; + private readonly TestRoot _root; + private readonly TextBox _control; + + public StyleAttachBenchmark() + { + _app = UnitTestApplication.Start( + TestServices.StyledWindow.With( + renderInterface: new NullRenderingPlatform(), + threadingInterface: new NullThreadingPlatform())); + + _root = new TestRoot(true, null) + { + Renderer = new NullRenderer(), + }; + + _control = new TextBox(); + } + + [Benchmark] + [MethodImpl(MethodImplOptions.NoInlining)] + public void AttachTextBoxStyles() + { + var styles = UnitTestApplication.Current.Styles; + + styles.TryAttach(_control, UnitTestApplication.Current); + ((IStyleable)_control).DetachStyles(); + } + + public void Dispose() + { + _app.Dispose(); + } + } +} diff --git a/tests/Avalonia.Controls.DataGrid.UnitTests/Avalonia.Controls.DataGrid.UnitTests.csproj b/tests/Avalonia.Controls.DataGrid.UnitTests/Avalonia.Controls.DataGrid.UnitTests.csproj index 7fec6f2770..396dfee691 100644 --- a/tests/Avalonia.Controls.DataGrid.UnitTests/Avalonia.Controls.DataGrid.UnitTests.csproj +++ b/tests/Avalonia.Controls.DataGrid.UnitTests/Avalonia.Controls.DataGrid.UnitTests.csproj @@ -1,6 +1,6 @@ - netcoreapp2.0;net47 + netcoreapp3.1;net47 latest Library true diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index aa470bce9d..373b1bc82f 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 latest Library true diff --git a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs b/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs index 23dae8f341..d49ee35901 100644 --- a/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs +++ b/tests/Avalonia.Controls.UnitTests/Presenters/TextPresenter_Tests.cs @@ -1,4 +1,5 @@ using Avalonia.Controls.Presenters; +using Avalonia.UnitTests; using Xunit; namespace Avalonia.Controls.UnitTests.Presenters @@ -8,33 +9,40 @@ namespace Avalonia.Controls.UnitTests.Presenters [Fact] public void TextPresenter_Can_Contain_Null_With_Password_Char_Set() { - var target = new TextPresenter + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - PasswordChar = '*' - }; + var target = new TextPresenter + { + PasswordChar = '*' + }; - Assert.NotNull(target.FormattedText); + Assert.NotNull(target.FormattedText); + } } [Fact] public void TextPresenter_Can_Contain_Null_WithOut_Password_Char_Set() { - var target = new TextPresenter(); + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + + var target = new TextPresenter(); - Assert.NotNull(target.FormattedText); + Assert.NotNull(target.FormattedText); + } } [Fact] public void Text_Presenter_Replaces_Formatted_Text_With_Password_Char() { - var target = new TextPresenter + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - PasswordChar = '*', - Text = "Test" - }; - Assert.NotNull(target.FormattedText); - Assert.Equal("****", target.FormattedText.Text); + var target = new TextPresenter { PasswordChar = '*', Text = "Test" }; + + Assert.NotNull(target.FormattedText); + Assert.Equal("****", target.FormattedText.Text); + } } } } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs index d36d0b609b..53b5b87ea2 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/TemplatedControlTests.cs @@ -363,7 +363,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter( TemplatedControl.TemplateProperty, @@ -399,7 +399,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter( TemplatedControl.TemplateProperty, @@ -438,7 +438,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter( TemplatedControl.TemplateProperty, @@ -458,7 +458,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter( TemplatedControl.TemplateProperty, diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index a9e86d71ee..6101e7b3d4 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -163,7 +163,7 @@ namespace Avalonia.Controls.UnitTests { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter(TemplatedControl.TemplateProperty, template) } diff --git a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs index 225eca17b2..14251454ea 100644 --- a/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TextBoxTests.cs @@ -385,6 +385,49 @@ namespace Avalonia.Controls.UnitTests Assert.True(target.SelectionEnd <= "123".Length); } } + + [Fact] + public void SelectedText_Changes_OnSelectionChange() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "0123456789" + }; + + Assert.True(target.SelectedText == ""); + + target.SelectionStart = 2; + target.SelectionEnd = 4; + + Assert.True(target.SelectedText == "23"); + } + } + + [Fact] + public void SelectedText_EditsText() + { + using (UnitTestApplication.Start(Services)) + { + var target = new TextBox + { + Template = CreateTemplate(), + Text = "0123" + }; + + target.SelectedText = "AA"; + Assert.True(target.Text == "AA0123"); + + target.SelectionStart = 1; + target.SelectionEnd = 3; + target.SelectedText = "BB"; + + Assert.True(target.Text == "ABB123"); + } + } + [Fact] public void CoerceCaretIndex_Doesnt_Cause_Exception_with_malformed_line_ending() { diff --git a/tests/Avalonia.Controls.UnitTests/UserControlTests.cs b/tests/Avalonia.Controls.UnitTests/UserControlTests.cs index 9d3e568582..fddef4ec88 100644 --- a/tests/Avalonia.Controls.UnitTests/UserControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/UserControlTests.cs @@ -25,7 +25,7 @@ namespace Avalonia.Controls.UnitTests { new Style(x => x.OfType()) { - Setters = new[] + Setters = { new Setter(TemplatedControl.TemplateProperty, GetTemplate()) } diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 0508edd92f..81699b075c 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -341,11 +341,62 @@ namespace Avalonia.Controls.UnitTests } } + [Fact] + public void Child_Should_Be_Measured_With_Width_And_Height_If_SizeToContent_Is_Manual() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var child = new ChildControl(); + var target = new Window + { + Width = 100, + Height = 50, + SizeToContent = SizeToContent.Manual, + Content = child + }; + + target.Show(); + + Assert.Equal(new Size(100, 50), child.MeasureSize); + } + } + + [Fact] + public void Child_Should_Be_Measured_With_Infinity_If_SizeToContent_Is_WidthAndHeight() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var child = new ChildControl(); + var target = new Window + { + Width = 100, + Height = 50, + SizeToContent = SizeToContent.WidthAndHeight, + Content = child + }; + + target.Show(); + + Assert.Equal(Size.Infinity, child.MeasureSize); + } + } + private IWindowImpl CreateImpl(Mock renderer) { return Mock.Of(x => x.Scaling == 1 && x.CreateRenderer(It.IsAny()) == renderer.Object); } + + private class ChildControl : Control + { + public Size MeasureSize { get; private set; } + + protected override Size MeasureOverride(Size availableSize) + { + MeasureSize = availableSize; + return base.MeasureOverride(availableSize); + } + } } } diff --git a/tests/Avalonia.DesignerSupport.TestApp/Avalonia.DesignerSupport.TestApp.csproj b/tests/Avalonia.DesignerSupport.TestApp/Avalonia.DesignerSupport.TestApp.csproj index 6bba5e7348..73a1d586b5 100644 --- a/tests/Avalonia.DesignerSupport.TestApp/Avalonia.DesignerSupport.TestApp.csproj +++ b/tests/Avalonia.DesignerSupport.TestApp/Avalonia.DesignerSupport.TestApp.csproj @@ -1,7 +1,7 @@  Exe - netcoreapp2.0 + netcoreapp3.1 false diff --git a/tests/Avalonia.DesignerSupport.Tests/Avalonia.DesignerSupport.Tests.csproj b/tests/Avalonia.DesignerSupport.Tests/Avalonia.DesignerSupport.Tests.csproj index 94c2ed934d..dad500e3c6 100644 --- a/tests/Avalonia.DesignerSupport.Tests/Avalonia.DesignerSupport.Tests.csproj +++ b/tests/Avalonia.DesignerSupport.Tests/Avalonia.DesignerSupport.Tests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0 + netcoreapp3.1 Library diff --git a/tests/Avalonia.DesignerSupport.Tests/DesignerSupportTests.cs b/tests/Avalonia.DesignerSupport.Tests/DesignerSupportTests.cs index 01fa0e7783..319e91d2ba 100644 --- a/tests/Avalonia.DesignerSupport.Tests/DesignerSupportTests.cs +++ b/tests/Avalonia.DesignerSupport.Tests/DesignerSupportTests.cs @@ -18,7 +18,7 @@ namespace Avalonia.DesignerSupport.Tests { public class DesignerSupportTests { - private const string DesignerAppPath = "../../../../../src/tools/Avalonia.Designer.HostApp/bin/$BUILD/netcoreapp2.0/Avalonia.Designer.HostApp.dll"; + private const string DesignerAppPath = "../../../../../src/tools/Avalonia.Designer.HostApp/bin/$BUILD/netcoreapp3.1/Avalonia.Designer.HostApp.dll"; private readonly Xunit.Abstractions.ITestOutputHelper outputHelper; public DesignerSupportTests(Xunit.Abstractions.ITestOutputHelper outputHelper) @@ -28,12 +28,12 @@ namespace Avalonia.DesignerSupport.Tests [SkippableTheory, InlineData( - @"..\..\..\..\..\tests/Avalonia.DesignerSupport.TestApp/bin/$BUILD/netcoreapp2.0/", + @"..\..\..\..\..\tests/Avalonia.DesignerSupport.TestApp/bin/$BUILD/netcoreapp3.1/", "Avalonia.DesignerSupport.TestApp", "Avalonia.DesignerSupport.TestApp.dll", @"..\..\..\..\..\tests\Avalonia.DesignerSupport.TestApp\MainWindow.xaml"), InlineData( - @"..\..\..\..\..\samples\ControlCatalog.NetCore\bin\$BUILD\netcoreapp2.0\", + @"..\..\..\..\..\samples\ControlCatalog.NetCore\bin\$BUILD\netcoreapp3.1\", "ControlCatalog.NetCore", "ControlCatalog.dll", @"..\..\..\..\..\samples\ControlCatalog\MainWindow.xaml")] diff --git a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj index ecc928461e..1cfc1a6ab8 100644 --- a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj +++ b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj @@ -1,10 +1,13 @@  - netcoreapp2.0 + netcoreapp3.1 + + + diff --git a/tests/Avalonia.Direct2D1.UnitTests/Avalonia.Direct2D1.UnitTests.csproj b/tests/Avalonia.Direct2D1.UnitTests/Avalonia.Direct2D1.UnitTests.csproj index b09902332b..e346ce944d 100644 --- a/tests/Avalonia.Direct2D1.UnitTests/Avalonia.Direct2D1.UnitTests.csproj +++ b/tests/Avalonia.Direct2D1.UnitTests/Avalonia.Direct2D1.UnitTests.csproj @@ -1,12 +1,15 @@  - netcoreapp2.0 + netcoreapp3.1 + + + diff --git a/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs b/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs index 3320bcebca..572749a58a 100644 --- a/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs +++ b/tests/Avalonia.Direct2D1.UnitTests/Media/FontManagerImplTests.cs @@ -1,8 +1,5 @@ -using System; -using System.Reflection; -using Avalonia.Direct2D1.Media; +using Avalonia.Direct2D1.Media; using Avalonia.Media; -using Avalonia.Platform; using Avalonia.UnitTests; using Xunit; @@ -10,7 +7,7 @@ namespace Avalonia.Direct2D1.UnitTests.Media { public class FontManagerImplTests { - private static string s_fontUri = "resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono"; + private static string s_fontUri = "resm:Avalonia.Direct2D1.UnitTests.Assets?assembly=Avalonia.Direct2D1.UnitTests#Noto Mono"; [Fact] public void Should_Create_Typeface_From_Fallback() @@ -21,8 +18,6 @@ namespace Avalonia.Direct2D1.UnitTests.Media var fontManager = new FontManagerImpl(); - var defaultName = fontManager.GetDefaultFontFamilyName(); - var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( new Typeface(new FontFamily("A, B, Arial"))); @@ -45,8 +40,6 @@ namespace Avalonia.Direct2D1.UnitTests.Media var fontManager = new FontManagerImpl(); - var defaultName = fontManager.GetDefaultFontFamilyName(); - var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( new Typeface(new FontFamily("A, B, Arial"), FontWeight.Bold)); @@ -87,20 +80,14 @@ namespace Avalonia.Direct2D1.UnitTests.Media [Fact] public void Should_Load_Typeface_From_Resource() { - using (AvaloniaLocator.EnterScope()) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { Direct2D1Platform.Initialize(); - var assetLoaderType = typeof(TestRoot).Assembly.GetType("Avalonia.Shared.PlatformSupport.AssetLoader"); - - var assetLoader = (IAssetLoader)Activator.CreateInstance(assetLoaderType, (Assembly)null); - - AvaloniaLocator.CurrentMutable.Bind().ToConstant(assetLoader); - var fontManager = new FontManagerImpl(); var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( - new Typeface(new FontFamily(s_fontUri))); + new Typeface(s_fontUri)); var font = glyphTypeface.DWFont; @@ -111,20 +98,14 @@ namespace Avalonia.Direct2D1.UnitTests.Media [Fact] public void Should_Load_Nearest_Matching_Font() { - using (AvaloniaLocator.EnterScope()) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { Direct2D1Platform.Initialize(); - var assetLoaderType = typeof(TestRoot).Assembly.GetType("Avalonia.Shared.PlatformSupport.AssetLoader"); - - var assetLoader = (IAssetLoader)Activator.CreateInstance(assetLoaderType, (Assembly)null); - - AvaloniaLocator.CurrentMutable.Bind().ToConstant(assetLoader); - var fontManager = new FontManagerImpl(); var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( - new Typeface(new FontFamily(s_fontUri), FontWeight.Black, FontStyle.Italic)); + new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic)); var font = glyphTypeface.DWFont; diff --git a/tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj b/tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj index ef515ce155..dd50eff2b6 100644 --- a/tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj +++ b/tests/Avalonia.Input.UnitTests/Avalonia.Input.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true diff --git a/tests/Avalonia.Input.UnitTests/KeyGestureTests.cs b/tests/Avalonia.Input.UnitTests/KeyGestureTests.cs index 95eaab30de..90f9a0a985 100644 --- a/tests/Avalonia.Input.UnitTests/KeyGestureTests.cs +++ b/tests/Avalonia.Input.UnitTests/KeyGestureTests.cs @@ -1,19 +1,31 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Xunit; namespace Avalonia.Input.UnitTests { public class KeyGestureTests { - public static readonly IEnumerable SampleData = new object[][] + public static readonly IEnumerable ParseData = new object[][] { - new object[]{"Ctrl+A", new KeyGesture(Key.A, InputModifiers.Control)}, - new object[]{" \tShift\t+Alt +B", new KeyGesture(Key.B, InputModifiers.Shift | InputModifiers.Alt) }, - new object[]{"Control++", new KeyGesture(Key.OemPlus, InputModifiers.Control) } + new object[]{"Ctrl+A", new KeyGesture(Key.A, KeyModifiers.Control)}, + new object[]{" \tShift\t+Alt +B", new KeyGesture(Key.B, KeyModifiers.Shift | KeyModifiers.Alt) }, + new object[]{"Control++", new KeyGesture(Key.OemPlus, KeyModifiers.Control) }, + new object[]{ "Shift+⌘+A", new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift) }, + new object[]{ "Shift+Cmd+A", new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift) }, + }; + + public static readonly IEnumerable ToStringData = new object[][] + { + new object[]{new KeyGesture(Key.A), "A"}, + new object[]{new KeyGesture(Key.A, KeyModifiers.Control), "Ctrl+A"}, + new object[]{new KeyGesture(Key.A, KeyModifiers.Control | KeyModifiers.Shift), "Ctrl+Shift+A"}, + new object[]{new KeyGesture(Key.A, KeyModifiers.Alt | KeyModifiers.Shift), "Shift+Alt+A"}, + new object[]{new KeyGesture(Key.A, KeyModifiers.Control | KeyModifiers.Alt | KeyModifiers.Shift), "Ctrl+Shift+Alt+A"}, + new object[]{new KeyGesture(Key.A, KeyModifiers.Meta | KeyModifiers.Shift), "Shift+Cmd+A"}, }; [Theory] - [MemberData(nameof(SampleData))] + [MemberData(nameof(ParseData))] public void Key_Gesture_Is_Able_To_Parse_Sample_Data(string text, KeyGesture gesture) { Assert.Equal(gesture, KeyGesture.Parse(text)); @@ -32,5 +44,12 @@ namespace Avalonia.Input.UnitTests Key = pressedKey })); } + + [Theory] + [MemberData(nameof(ToStringData))] + public void ToString_Produces_Correct_Results(KeyGesture gesture, string expected) + { + Assert.Equal(expected, gesture.ToString()); + } } } diff --git a/tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj b/tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj index 2bde78ad63..a9f5318e8f 100644 --- a/tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj +++ b/tests/Avalonia.Interactivity.UnitTests/Avalonia.Interactivity.UnitTests.csproj @@ -1,7 +1,6 @@  - netcoreapp2.0 - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true latest diff --git a/tests/Avalonia.Interactivity.UnitTests/InteractiveTests.cs b/tests/Avalonia.Interactivity.UnitTests/InteractiveTests.cs index 414e67bb94..ef3770d1d9 100644 --- a/tests/Avalonia.Interactivity.UnitTests/InteractiveTests.cs +++ b/tests/Avalonia.Interactivity.UnitTests/InteractiveTests.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Avalonia.Interactivity; using Avalonia.VisualTree; using Xunit; @@ -358,6 +357,29 @@ namespace Avalonia.Interactivity.UnitTests Assert.Equal(1, called); } + [Fact] + public void Removing_Control_In_Handler_Should_Not_Stop_Event() + { + // Issue #3176 + var ev = new RoutedEvent("test", RoutingStrategies.Bubble, typeof(RoutedEventArgs), typeof(TestInteractive)); + var invoked = new List(); + EventHandler handler = (s, e) => invoked.Add(((TestInteractive)s).Name); + var parent = CreateTree(ev, handler, RoutingStrategies.Bubble | RoutingStrategies.Tunnel); + var target = (IInteractive)parent.GetVisualChildren().Single(); + + EventHandler removeHandler = (s, e) => + { + parent.Children = Array.Empty(); + }; + + target.AddHandler(ev, removeHandler); + + var args = new RoutedEventArgs(ev, target); + target.RaiseEvent(args); + + Assert.Equal(new[] { "3", "2b", "1" }, invoked); + } + private TestInteractive CreateTree( RoutedEvent ev, EventHandler handler, @@ -414,6 +436,7 @@ namespace Avalonia.Interactivity.UnitTests set { + VisualChildren.Clear(); VisualChildren.AddRange(value.Cast()); } } diff --git a/tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj b/tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj index 63f7f3ec0e..463bc50008 100644 --- a/tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj +++ b/tests/Avalonia.Layout.UnitTests/Avalonia.Layout.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0 + netcoreapp3.1 Library diff --git a/tests/Avalonia.Layout.UnitTests/FullLayoutTests.cs b/tests/Avalonia.Layout.UnitTests/FullLayoutTests.cs index f063d59ca4..a6ee2d690d 100644 --- a/tests/Avalonia.Layout.UnitTests/FullLayoutTests.cs +++ b/tests/Avalonia.Layout.UnitTests/FullLayoutTests.cs @@ -182,8 +182,6 @@ namespace Avalonia.Layout.UnitTests It.IsAny>())) .Returns(new FormattedTextMock("TEST")); - renderInterface.Setup(x => x.CreateFontManager()).Returns(new MockFontManagerImpl()); - var streamGeometry = new Mock(); streamGeometry.Setup(x => x.Open()) @@ -210,6 +208,8 @@ namespace Avalonia.Layout.UnitTests .Bind().ToConstant(new AppBuilder().RuntimePlatform) .Bind().ToConstant(renderInterface.Object) .Bind().ToConstant(new Styler()) + .Bind().ToConstant(new MockFontManagerImpl()) + .Bind().ToConstant(new MockTextShaperImpl()) .Bind().ToConstant(new Avalonia.Controls.UnitTests.WindowingPlatformMock(() => windowImpl.Object)); var theme = new DefaultTheme(); diff --git a/tests/Avalonia.LeakTests/ControlTests.cs b/tests/Avalonia.LeakTests/ControlTests.cs index 389b3c8df8..3b288dbf66 100644 --- a/tests/Avalonia.LeakTests/ControlTests.cs +++ b/tests/Avalonia.LeakTests/ControlTests.cs @@ -8,8 +8,10 @@ using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Diagnostics; using Avalonia.Layout; +using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering; +using Avalonia.Styling; using Avalonia.UnitTests; using Avalonia.VisualTree; using JetBrains.dotMemoryUnit; @@ -370,6 +372,56 @@ namespace Avalonia.LeakTests } } + [Fact] + public void Control_With_Style_RenderTransform_Is_Freed() + { + // # Issue #3545 + using (Start()) + { + Func run = () => + { + var window = new Window + { + Styles = + { + new Style(x => x.OfType()) + { + Setters = + { + new Setter + { + Property = Visual.RenderTransformProperty, + Value = new RotateTransform(45), + } + } + } + }, + Content = new Canvas() + }; + + window.Show(); + + // Do a layout and make sure that Canvas gets added to visual tree with + // its render transform. + window.LayoutManager.ExecuteInitialLayoutPass(window); + var canvas = Assert.IsType(window.Presenter.Child); + Assert.IsType(canvas.RenderTransform); + + // Clear the content and ensure the Canvas is removed. + window.Content = null; + window.LayoutManager.ExecuteLayoutPass(); + Assert.Null(window.Presenter.Child); + + return window; + }; + + var result = run(); + + dotMemory.Check(memory => + Assert.Equal(0, memory.GetObjects(where => where.Type.Is()).ObjectsCount)); + } + } + private IDisposable Start() { return UnitTestApplication.Start(TestServices.StyledWindow); diff --git a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj index aba5f2d469..ec5b2f0ed1 100644 --- a/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj +++ b/tests/Avalonia.Markup.UnitTests/Avalonia.Markup.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj index 3107f4cf22..e8c4daa7bc 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Avalonia.Markup.Xaml.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library true diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs index d82300b964..ec9a6ba77f 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/Converters/AvaloniaPropertyConverterTest.cs @@ -139,7 +139,15 @@ namespace Avalonia.Markup.Xaml.UnitTests.Converters get { throw new NotImplementedException(); } } - IObservable IStyleable.StyleDetach { get; } + public void DetachStyles() + { + throw new NotImplementedException(); + } + + public void StyleApplied(IStyleInstance instance) + { + throw new NotImplementedException(); + } } private class AttachedOwner diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs index 96955539c1..adeae810c6 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/DynamicResourceExtensionTests.cs @@ -627,6 +627,137 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions Assert.Equal(0xff506070, brush.Color.ToUint32()); } + [Fact] + public void Resource_With_DynamicResource_Is_Updated_When_Added_To_Parent() + { + var xaml = @" + + + + + + +"; + + var loader = new AvaloniaXamlLoader(); + var userControl = (UserControl)loader.Load(xaml); + var border = userControl.FindControl("border"); + + DelayedBinding.ApplyBindings(border); + + var brush = (SolidColorBrush)border.Background; + Assert.Equal(0u, brush.Color.ToUint32()); + + brush.GetObservable(SolidColorBrush.ColorProperty).Subscribe(_ => { }); + + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window + { + Resources = + { + { "color", Colors.Red } + }, + Content = userControl, + }; + + window.Show(); + + Assert.Equal(Colors.Red, brush.Color); + } + } + + [Fact] + public void MergedDictionary_Resource_With_DynamicResource_Is_Updated_When_Added_To_Parent() + { + var xaml = @" + + + + + + + + + + + + +"; + + var loader = new AvaloniaXamlLoader(); + var userControl = (UserControl)loader.Load(xaml); + var border = userControl.FindControl("border"); + + DelayedBinding.ApplyBindings(border); + + var brush = (SolidColorBrush)border.Background; + Assert.Equal(0u, brush.Color.ToUint32()); + + brush.GetObservable(SolidColorBrush.ColorProperty).Subscribe(_ => { }); + + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window + { + Resources = + { + { "color", Colors.Red } + }, + Content = userControl, + }; + + window.Show(); + + Assert.Equal(Colors.Red, brush.Color); + } + } + + [Fact] + public void Style_Resource_With_DynamicResource_Is_Updated_When_Added_To_Parent() + { + var xaml = @" + + + + + + +"; + + var loader = new AvaloniaXamlLoader(); + var userControl = (UserControl)loader.Load(xaml); + var border = userControl.FindControl("border"); + + DelayedBinding.ApplyBindings(border); + + var brush = (SolidColorBrush)border.Background; + Assert.Equal(0u, brush.Color.ToUint32()); + + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var window = new Window + { + Resources = + { + { "color", Colors.Red } + }, + Content = userControl, + }; + + window.Show(); + + Assert.Equal(Colors.Red, brush.Color); + } + } + private IDisposable StyledWindow(params (string, string)[] assets) { var services = TestServices.StyledWindow.With( diff --git a/tests/Avalonia.Markup.Xaml.UnitTests/StyleTests.cs b/tests/Avalonia.Markup.Xaml.UnitTests/StyleTests.cs index 2dc6c4a7fb..7069c1035f 100644 --- a/tests/Avalonia.Markup.Xaml.UnitTests/StyleTests.cs +++ b/tests/Avalonia.Markup.Xaml.UnitTests/StyleTests.cs @@ -53,42 +53,7 @@ namespace Avalonia.Markup.Xaml.UnitTests } }; - setter.Apply(null, control, null); - Assert.Equal("foo", control.Text); - - control.Text = "bar"; - Assert.Equal("bar", data.Foo); - } - } - - [Fact] - public void Setter_With_TwoWay_Binding_And_Activator_Should_Update_Source() - { - using (UnitTestApplication.Start(TestServices.MockThreadingInterface)) - { - var data = new Data - { - Foo = "foo", - }; - - var control = new TextBox - { - DataContext = data, - }; - - var setter = new Setter - { - Property = TextBox.TextProperty, - Value = new Binding - { - Path = "Foo", - Mode = BindingMode.TwoWay - } - }; - - var activator = Observable.Never().StartWith(true); - - setter.Apply(null, control, activator); + setter.Instance(control).Start(false); Assert.Equal("foo", control.Text); control.Text = "bar"; diff --git a/tests/Avalonia.ReactiveUI.UnitTests/Avalonia.ReactiveUI.UnitTests.csproj b/tests/Avalonia.ReactiveUI.UnitTests/Avalonia.ReactiveUI.UnitTests.csproj index 7c0ff79183..32dc498781 100644 --- a/tests/Avalonia.ReactiveUI.UnitTests/Avalonia.ReactiveUI.UnitTests.csproj +++ b/tests/Avalonia.ReactiveUI.UnitTests/Avalonia.ReactiveUI.UnitTests.csproj @@ -1,6 +1,6 @@ - netcoreapp2.0 + netcoreapp3.1 diff --git a/tests/Avalonia.UnitTests/Assets/NotoMono-Regular.ttf b/tests/Avalonia.RenderTests/Assets/NotoMono-Regular.ttf similarity index 100% rename from tests/Avalonia.UnitTests/Assets/NotoMono-Regular.ttf rename to tests/Avalonia.RenderTests/Assets/NotoMono-Regular.ttf diff --git a/tests/Avalonia.RenderTests/Assets/TwitterColorEmoji-SVGinOT.ttf b/tests/Avalonia.RenderTests/Assets/TwitterColorEmoji-SVGinOT.ttf new file mode 100644 index 0000000000..f88c643122 Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/TwitterColorEmoji-SVGinOT.ttf differ diff --git a/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs b/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs index 7c53f4516a..2ba6719bed 100644 --- a/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs +++ b/tests/Avalonia.RenderTests/Media/VisualBrushTests.cs @@ -48,7 +48,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media Child = new TextBlock { FontSize = 24, - FontFamily = new FontFamily("Arial"), + FontFamily = TestFontFamily, Background = Brushes.Green, Foreground = Brushes.Yellow, Text = "VisualBrush", @@ -59,7 +59,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media } } - [Fact] + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_Alignment_TopLeft() { Decorator target = new Decorator @@ -84,11 +84,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_Alignment_Center() { Decorator target = new Decorator @@ -113,7 +109,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } - [Fact] + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_Alignment_BottomRight() { Decorator target = new Decorator @@ -138,11 +134,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_Fill_NoTile() { Decorator target = new Decorator @@ -165,11 +157,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_Uniform_NoTile() { Decorator target = new Decorator @@ -192,11 +180,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_UniformToFill_NoTile() { Decorator target = new Decorator @@ -219,7 +203,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } - [Fact] + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_BottomRightQuarterSource() { Decorator target = new Decorator @@ -243,11 +227,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_BottomRightQuarterDest() { Decorator target = new Decorator @@ -271,7 +251,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } - [Fact] + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_NoTile_BottomRightQuarterSource_BottomRightQuarterDest() { Decorator target = new Decorator @@ -296,7 +276,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } - [Fact] + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_Tile_BottomRightQuarterSource_CenterQuarterDest() { Decorator target = new Decorator @@ -321,11 +301,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_FlipX_TopLeftDest() { Decorator target = new Decorator @@ -349,11 +325,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_FlipY_TopLeftDest() { Decorator target = new Decorator @@ -377,11 +349,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_NoStretch_FlipXY_TopLeftDest() { Decorator target = new Decorator @@ -405,11 +373,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media CompareImages(); } -#if AVALONIA_SKIA_SKIP_FAIL - [Fact(Skip = "FIXME")] -#else - [Fact] -#endif + [Fact(Skip = "Visual brush is broken in combination with text rendering.")] public async Task VisualBrush_InTree_Visual() { Border source; @@ -429,7 +393,7 @@ namespace Avalonia.Direct2D1.RenderTests.Media HorizontalAlignment = HorizontalAlignment.Left, Child = new TextBlock { - FontFamily = new FontFamily("Courier New"), + FontFamily = TestFontFamily, Text = "Visual" } }), diff --git a/tests/Avalonia.RenderTests/TestBase.cs b/tests/Avalonia.RenderTests/TestBase.cs index 2efd28c2d5..274fbdc185 100644 --- a/tests/Avalonia.RenderTests/TestBase.cs +++ b/tests/Avalonia.RenderTests/TestBase.cs @@ -13,6 +13,7 @@ using Avalonia.Platform; using System.Threading.Tasks; using System; using System.Threading; +using Avalonia.Media; using Avalonia.Threading; #if AVALONIA_SKIA using Avalonia.Skia; @@ -26,11 +27,22 @@ namespace Avalonia.Skia.RenderTests namespace Avalonia.Direct2D1.RenderTests #endif { + using Avalonia.Shared.PlatformSupport; + public class TestBase { +#if AVALONIA_SKIA + private static string s_fontUri = "resm:Avalonia.Skia.RenderTests.Assets?assembly=Avalonia.Skia.RenderTests#Noto Mono"; +#else + private static string s_fontUri = "resm:Avalonia.Direct2D1.RenderTests.Assets?assembly=Avalonia.Direct2D1.RenderTests#Noto Mono"; +#endif + public static FontFamily TestFontFamily = new FontFamily(s_fontUri); + private static readonly TestThreadingInterface threadingInterface = new TestThreadingInterface(); + private static readonly IAssetLoader assetLoader = new AssetLoader(); + static TestBase() { #if AVALONIA_SKIA @@ -42,6 +54,9 @@ namespace Avalonia.Direct2D1.RenderTests .Bind() .ToConstant(threadingInterface); + AvaloniaLocator.CurrentMutable + .Bind() + .ToConstant(assetLoader); } public TestBase(string outputPath) diff --git a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj index a9452b4def..65df5e7cb0 100644 --- a/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj +++ b/tests/Avalonia.Skia.RenderTests/Avalonia.Skia.RenderTests.csproj @@ -1,11 +1,14 @@  - netcoreapp2.0 + netcoreapp3.1 AVALONIA_SKIA;AVALONIA_SKIA_SKIP_FAIL + + + diff --git a/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj b/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj index 0473355fcd..e0bd082fb3 100644 --- a/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj +++ b/tests/Avalonia.Skia.UnitTests/Avalonia.Skia.UnitTests.csproj @@ -1,12 +1,15 @@  - netcoreapp2.0 + netcoreapp3.1 + + + diff --git a/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs new file mode 100644 index 0000000000..a53e2ab188 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/CustomFontManagerImpl.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia.Media; +using Avalonia.Media.Fonts; +using Avalonia.Platform; +using SkiaSharp; + +namespace Avalonia.Skia.UnitTests +{ + public class CustomFontManagerImpl : IFontManagerImpl + { + private readonly Typeface[] _customTypefaces; + + private readonly Typeface _defaultTypeface = + new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); + private readonly Typeface _emojiTypeface = + new Typeface("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Twitter Color Emoji"); + + public CustomFontManagerImpl() + { + _customTypefaces = new[] { _emojiTypeface, _defaultTypeface }; + } + + public string GetDefaultFontFamilyName() + { + return _defaultTypeface.FontFamily.ToString(); + } + + public IEnumerable GetInstalledFontFamilyNames(bool checkForUpdates = false) + { + return _customTypefaces.Select(x => x.FontFamily.Name); + } + + public bool TryMatchCharacter(int codepoint, FontWeight fontWeight, FontStyle fontStyle, FontFamily fontFamily, + CultureInfo culture, out FontKey fontKey) + { + foreach (var customTypeface in _customTypefaces) + { + if (customTypeface.GlyphTypeface.GetGlyph((uint)codepoint) == 0) + continue; + fontKey = new FontKey(customTypeface.FontFamily, fontWeight, fontStyle); + + return true; + } + + var fallback = SKFontManager.Default.MatchCharacter(codepoint); + + fontKey = new FontKey(fallback?.FamilyName ?? SKTypeface.Default.FamilyName, fontWeight, fontStyle); + + return true; + } + + public IGlyphTypefaceImpl CreateGlyphTypeface(Typeface typeface) + { + switch (typeface.FontFamily.Name) + { + case "Twitter Color Emoji": + case "Noto Mono": + var typefaceCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(typeface.FontFamily); + var skTypeface = typefaceCollection.Get(typeface); + return new GlyphTypefaceImpl(skTypeface); + default: + return new GlyphTypefaceImpl(SKTypeface.FromFamilyName(typeface.FontFamily.Name, + (SKFontStyleWeight)typeface.Weight, SKFontStyleWidth.Normal, (SKFontStyleSlant)typeface.Style)); + } + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs b/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs index fdd88dab0e..dc2a40aeba 100644 --- a/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs +++ b/tests/Avalonia.Skia.UnitTests/FontManagerImplTests.cs @@ -11,7 +11,7 @@ namespace Avalonia.Skia.UnitTests { public class FontManagerImplTests { - private static string s_fontUri = "resm:Avalonia.UnitTests.Assets?assembly=Avalonia.UnitTests#Noto Mono"; + private static string s_fontUri = "resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"; [Fact] public void Should_Create_Typeface_From_Fallback() @@ -44,7 +44,7 @@ namespace Avalonia.Skia.UnitTests var skTypeface = glyphTypeface.Typeface; Assert.Equal(fontName, skTypeface.FamilyName); - Assert.Equal(SKFontStyle.Bold.Weight, skTypeface.FontWeight); + Assert.True(skTypeface.FontWeight >= 600); } [Fact] @@ -67,18 +67,12 @@ namespace Avalonia.Skia.UnitTests [Fact] public void Should_Load_Typeface_From_Resource() { - using (AvaloniaLocator.EnterScope()) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - var assetLoaderType = typeof(TestRoot).Assembly.GetType("Avalonia.Shared.PlatformSupport.AssetLoader"); - - var assetLoader = (IAssetLoader)Activator.CreateInstance(assetLoaderType, (Assembly)null); - - AvaloniaLocator.CurrentMutable.Bind().ToConstant(assetLoader); - var fontManager = new FontManagerImpl(); var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( - new Typeface(new FontFamily(s_fontUri))); + new Typeface(s_fontUri)); var skTypeface = glyphTypeface.Typeface; @@ -89,18 +83,12 @@ namespace Avalonia.Skia.UnitTests [Fact] public void Should_Load_Nearest_Matching_Font() { - using (AvaloniaLocator.EnterScope()) + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { - var assetLoaderType = typeof(TestRoot).Assembly.GetType("Avalonia.Shared.PlatformSupport.AssetLoader"); - - var assetLoader = (IAssetLoader)Activator.CreateInstance(assetLoaderType, (Assembly)null); - - AvaloniaLocator.CurrentMutable.Bind().ToConstant(assetLoader); - var fontManager = new FontManagerImpl(); var glyphTypeface = (GlyphTypefaceImpl)fontManager.CreateGlyphTypeface( - new Typeface(new FontFamily(s_fontUri), FontWeight.Black, FontStyle.Italic)); + new Typeface(s_fontUri, FontWeight.Black, FontStyle.Italic)); var skTypeface = glyphTypeface.Typeface; diff --git a/tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs b/tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs new file mode 100644 index 0000000000..726052351b --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/SKTypefaceCollectionCacheTests.cs @@ -0,0 +1,32 @@ +using Avalonia.Media; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Skia.UnitTests +{ + public class SKTypefaceCollectionCacheTests + { + [Fact] + public void Should_Load_Typefaces_From_Invalid_Name() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var notoMono = + new FontFamily("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Noto Mono"); + + var colorEmoji = + new FontFamily("resm:Avalonia.Skia.UnitTests.Assets?assembly=Avalonia.Skia.UnitTests#Twitter Color Emoji"); + + var notoMonoCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(notoMono); + + var typeface = new Typeface("ABC", FontWeight.Bold, FontStyle.Italic); + + Assert.Equal("Noto Mono", notoMonoCollection.Get(typeface).FamilyName); + + var notoColorEmojiCollection = SKTypefaceCollectionCache.GetOrAddTypefaceCollection(colorEmoji); + + Assert.Equal("Twitter Color Emoji", notoColorEmojiCollection.Get(typeface).FamilyName); + } + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs new file mode 100644 index 0000000000..f424027910 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/SimpleTextFormatterTests.cs @@ -0,0 +1,311 @@ +using System; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.UnitTests; +using Avalonia.Utility; +using Xunit; + +namespace Avalonia.Skia.UnitTests +{ + public class SimpleTextFormatterTests + { + [Fact] + public void Should_Format_TextRuns_With_Default_Style() + { + using (Start()) + { + const string text = "0123456789"; + + var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); + + var textSource = new SimpleTextSource(text, defaultTextRunStyle); + + var formatter = new SimpleTextFormatter(); + + var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + Assert.Single(textLine.TextRuns); + + var textRun = textLine.TextRuns[0]; + + Assert.Equal(defaultTextRunStyle.TextFormat, textRun.Style.TextFormat); + + Assert.Equal(defaultTextRunStyle.Foreground, textRun.Style.Foreground); + + Assert.Equal(text.Length, textRun.Text.Length); + } + } + + [Fact] + public void Should_Format_TextRuns_With_Multiple_Buffers() + { + using (Start()) + { + var defaultTextRunStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); + + var textSource = new MultiBufferTextSource(defaultTextRunStyle); + + var formatter = new SimpleTextFormatter(); + + var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, + new TextParagraphProperties(defaultTextRunStyle)); + + Assert.Equal(5, textLine.TextRuns.Count); + + Assert.Equal(50, textLine.Text.Length); + } + } + + private class MultiBufferTextSource : ITextSource + { + private readonly string[] _runTexts; + private readonly TextStyle _defaultStyle; + + public MultiBufferTextSource(TextStyle defaultStyle) + { + _defaultStyle = defaultStyle; + + _runTexts = new[] { "A123456789", "B123456789", "C123456789", "D123456789", "E123456789" }; + } + + public TextPointer TextPointer => new TextPointer(0, 50); + + public TextRun GetTextRun(int textSourceIndex) + { + if (textSourceIndex == 50) + { + return new TextEndOfParagraph(); + } + + var index = textSourceIndex / 10; + + var runText = _runTexts[index]; + + return new TextCharacters( + new ReadOnlySlice(runText.AsMemory(), textSourceIndex, runText.Length), _defaultStyle); + } + } + + [Fact] + public void Should_Format_TextRuns_With_TextRunStyles() + { + using (Start()) + { + const string text = "0123456789"; + + var defaultStyle = new TextStyle(Typeface.Default, 12, Brushes.Black); + + var textStyleRuns = new[] + { + new TextStyleRun(new TextPointer(0, 3), defaultStyle ), + new TextStyleRun(new TextPointer(3, 3), new TextStyle(Typeface.Default, 13, Brushes.Black) ), + new TextStyleRun(new TextPointer(6, 3), new TextStyle(Typeface.Default, 14, Brushes.Black) ), + new TextStyleRun(new TextPointer(9, 1), defaultStyle ) + }; + + var textSource = new FormattableTextSource(text, defaultStyle, textStyleRuns); + + var formatter = new SimpleTextFormatter(); + + var textLine = formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + Assert.Equal(text.Length, textLine.Text.Length); + + for (var i = 0; i < textStyleRuns.Length; i++) + { + var textStyleRun = textStyleRuns[i]; + + var textRun = textLine.TextRuns[i]; + + Assert.Equal(textStyleRun.TextPointer.Length, textRun.Text.Length); + } + } + } + + private class FormattableTextSource : ITextSource + { + private readonly ReadOnlySlice _text; + private readonly TextStyle _defaultStyle; + private ReadOnlySlice _textStyleRuns; + + public FormattableTextSource(string text, TextStyle defaultStyle, ReadOnlySlice textStyleRuns) + { + _text = text.AsMemory(); + + _defaultStyle = defaultStyle; + + _textStyleRuns = textStyleRuns; + } + + public TextRun GetTextRun(int textSourceIndex) + { + if (_textStyleRuns.IsEmpty) + { + return new TextEndOfParagraph(); + } + + var styleRun = _textStyleRuns[0]; + + _textStyleRuns = _textStyleRuns.Skip(1); + + return new TextCharacters(_text.AsSlice(styleRun.TextPointer.Start, styleRun.TextPointer.Length), + _defaultStyle); + } + } + + [Theory] + [InlineData("0123", 1)] + [InlineData("\r\n", 1)] + [InlineData("👍b", 2)] + [InlineData("a👍b", 3)] + [InlineData("a👍子b", 4)] + public void Should_Produce_Unique_Runs(string text, int numberOfRuns) + { + using (Start()) + { + var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); + + var formatter = new SimpleTextFormatter(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + Assert.Equal(numberOfRuns, textLine.TextRuns.Count); + } + } + + private class SimpleTextSource : ITextSource + { + private readonly ReadOnlySlice _text; + private readonly TextStyle _defaultTextStyle; + + public SimpleTextSource(string text, TextStyle defaultText) + { + _text = text.AsMemory(); + _defaultTextStyle = defaultText; + } + + public TextRun GetTextRun(int textSourceIndex) + { + var runText = _text.Skip(textSourceIndex); + + if (runText.IsEmpty) + { + return new TextEndOfParagraph(); + } + + return new TextCharacters(runText, _defaultTextStyle); + } + } + + [Fact] + public void Should_Split_Run_On_Script() + { + using (Start()) + { + const string text = "1234الدولي"; + + var textSource = new SimpleTextSource(text, new TextStyle(Typeface.Default)); + + var formatter = new SimpleTextFormatter(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + Assert.Equal(4, textLine.TextRuns[0].Text.Length); + } + } + + [Fact] + public void Should_Get_Distance_From_CharacterHit() + { + using (Start()) + { + var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); + + var formatter = new SimpleTextFormatter(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + var currentDistance = 0.0; + + foreach (var run in textLine.TextRuns) + { + var textRun = (ShapedTextRun)run; + + var glyphRun = textRun.GlyphRun; + + for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) + { + var cluster = glyphRun.GlyphClusters[i]; + + var advance = glyphRun.GlyphAdvances[i]; + + var distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(cluster)); + + Assert.Equal(currentDistance, distance); + + currentDistance += advance; + } + } + + Assert.Equal(currentDistance, textLine.GetDistanceFromCharacterHit(new CharacterHit(textSource.TextPointer.Length))); + } + } + + [Fact] + public void Should_Get_CharacterHit_From_Distance() + { + using (Start()) + { + var textSource = new MultiBufferTextSource(new TextStyle(Typeface.Default)); + + var formatter = new SimpleTextFormatter(); + + var textLine = + formatter.FormatLine(textSource, 0, double.PositiveInfinity, new TextParagraphProperties()); + + var currentDistance = 0.0; + + CharacterHit characterHit; + + foreach (var run in textLine.TextRuns) + { + var textRun = (ShapedTextRun)run; + + var glyphRun = textRun.GlyphRun; + + for (var i = 0; i < glyphRun.GlyphClusters.Length; i++) + { + var cluster = glyphRun.GlyphClusters[i]; + + var advance = glyphRun.GlyphAdvances[i]; + + characterHit = textLine.GetCharacterHitFromDistance(currentDistance); + + Assert.Equal(cluster, characterHit.FirstCharacterIndex + characterHit.TrailingLength); + + currentDistance += advance; + } + } + + characterHit = textLine.GetCharacterHitFromDistance(textLine.LineMetrics.Size.Width); + + Assert.Equal(textSource.TextPointer.End, characterHit.FirstCharacterIndex); + } + } + + public static IDisposable Start() + { + var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface + .With(renderInterface: new PlatformRenderInterface(null), + textShaperImpl: new TextShaperImpl())); + + AvaloniaLocator.CurrentMutable + .Bind().ToConstant(new FontManager(new CustomFontManagerImpl())); + + return disposable; + } + } +} diff --git a/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs b/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs new file mode 100644 index 0000000000..702e2118f5 --- /dev/null +++ b/tests/Avalonia.Skia.UnitTests/TextLayoutTests.cs @@ -0,0 +1,493 @@ +using System; +using System.Linq; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; +using Avalonia.Media.TextFormatting.Unicode; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Skia.UnitTests +{ + public class TextLayoutTests + { + private static readonly string s_singleLineText = "0123456789"; + private static readonly string s_multiLineText = "012345678\r\r0123456789"; + + [Fact] + public void Should_Apply_TextStyleSpan_To_Text_In_Between() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(1, 2), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textStyleOverrides : spans); + + var textLine = layout.TextLines[0]; + + Assert.Equal(3, textLine.TextRuns.Count); + + var textRun = textLine.TextRuns[1]; + + Assert.Equal(2, textRun.Text.Length); + + var actual = textRun.Text.Buffer.Span.ToString(); + + Assert.Equal("12", actual); + + Assert.Equal(foreground, textRun.Style.Foreground); + } + } + + [Fact] + public void Should_Not_Alter_Lines_After_TextStyleSpan_Was_Applied() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + for (var i = 4; i < s_multiLineText.Length; i++) + { + var spans = new[] + { + new TextStyleRun( + new TextPointer(0, i), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var expected = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textWrapping: TextWrapping.Wrap, + maxWidth : 25); + + var actual = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textWrapping : TextWrapping.Wrap, + maxWidth : 25, + textStyleOverrides : spans); + + Assert.Equal(expected.TextLines.Count, actual.TextLines.Count); + + for (var j = 0; j < actual.TextLines.Count; j++) + { + Assert.Equal(expected.TextLines[j].Text.Length, actual.TextLines[j].Text.Length); + + Assert.Equal(expected.TextLines[j].TextRuns.Sum(x => x.Text.Length), + actual.TextLines[j].TextRuns.Sum(x => x.Text.Length)); + } + } + } + } + + [Fact] + public void Should_Apply_TextStyleSpan_To_Text_At_Start() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(0, 2), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + s_singleLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textStyleOverrides : spans); + + var textLine = layout.TextLines[0]; + + Assert.Equal(2, textLine.TextRuns.Count); + + var textRun = textLine.TextRuns[0]; + + Assert.Equal(2, textRun.Text.Length); + + var actual = s_singleLineText.Substring(textRun.Text.Start, + textRun.Text.Length); + + Assert.Equal("01", actual); + + Assert.Equal(foreground, textRun.Style.Foreground); + } + } + + [Fact] + public void Should_Apply_TextStyleSpan_To_Text_At_End() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(8, 2), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + s_singleLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textStyleOverrides : spans); + + var textLine = layout.TextLines[0]; + + Assert.Equal(2, textLine.TextRuns.Count); + + var textRun = textLine.TextRuns[1]; + + Assert.Equal(2, textRun.Text.Length); + + var actual = textRun.Text.Buffer.Span.ToString(); + + Assert.Equal("89", actual); + + Assert.Equal(foreground, textRun.Style.Foreground); + } + } + + [Fact] + public void Should_Apply_TextStyleSpan_To_Single_Character() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(0, 1), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + "0", + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textStyleOverrides : spans); + + var textLine = layout.TextLines[0]; + + Assert.Equal(1, textLine.TextRuns.Count); + + var textRun = textLine.TextRuns[0]; + + Assert.Equal(1, textRun.Text.Length); + + Assert.Equal(foreground, textRun.Style.Foreground); + } + } + + [Fact] + public void Should_Apply_TextSpan_To_Unicode_String_In_Between() + { + using (Start()) + { + const string text = "😄😄😄😄"; + + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(2, 2), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + text, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textStyleOverrides: spans); + + var textLine = layout.TextLines[0]; + + Assert.Equal(3, textLine.TextRuns.Count); + + var textRun = textLine.TextRuns[1]; + + Assert.Equal(2, textRun.Text.Length); + + var actual = textRun.Text.Buffer.Span.ToString(); + + Assert.Equal("😄", actual); + + Assert.Equal(foreground, textRun.Style.Foreground); + } + } + + [Fact] + public void TextLength_Should_Be_Equal_To_TextLine_Length_Sum() + { + using (Start()) + { + var layout = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + Assert.Equal(s_multiLineText.Length, layout.TextLines.Sum(x => x.Text.Length)); + } + } + + [Fact] + public void TextLength_Should_Be_Equal_To_TextRun_TextLength_Sum() + { + using (Start()) + { + var layout = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + Assert.Equal( + s_multiLineText.Length, + layout.TextLines.Select(textLine => + textLine.TextRuns.Sum(textRun => textRun.Text.Length)) + .Sum()); + } + } + + [Fact] + public void TextLength_Should_Be_Equal_To_TextRun_TextLength_Sum_After_Wrap_With_Style_Applied() + { + using (Start()) + { + const string text = + "Multiline TextBox with TextWrapping.\r\rLorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Vivamus magna. Cras in mi at felis aliquet congue. Ut a est eget ligula molestie gravida. " + + "Curabitur massa. Donec eleifend, libero at sagittis mollis, tellus est malesuada tellus, at luctus turpis elit sit amet quam. Vivamus pretium ornare est."; + + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(0, 24), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + text, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + textWrapping : TextWrapping.Wrap, + maxWidth : 180, + textStyleOverrides: spans); + + Assert.Equal( + text.Length, + layout.TextLines.Select(textLine => + textLine.TextRuns.Sum(textRun => textRun.Text.Length)) + .Sum()); + } + } + + [Fact] + public void Should_Apply_TextStyleSpan_To_MultiLine() + { + using (Start()) + { + var foreground = new SolidColorBrush(Colors.Red).ToImmutable(); + + var spans = new[] + { + new TextStyleRun( + new TextPointer(5, 20), + new TextStyle(Typeface.Default, 12, foreground)) + }; + + var layout = new TextLayout( + s_multiLineText, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable(), + maxWidth : 200, + maxHeight : 125, + textStyleOverrides: spans); + + Assert.Equal(foreground, layout.TextLines[0].TextRuns[1].Style.Foreground); + Assert.Equal(foreground, layout.TextLines[1].TextRuns[0].Style.Foreground); + Assert.Equal(foreground, layout.TextLines[2].TextRuns[0].Style.Foreground); + } + } + + [Fact] + public void Should_Hit_Test_SurrogatePair() + { + using (Start()) + { + const string text = "😄😄"; + + var layout = new TextLayout( + text, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + var shapedRun = (ShapedTextRun)layout.TextLines[0].TextRuns[0]; + + var glyphRun = shapedRun.GlyphRun; + + var width = glyphRun.Bounds.Width; + + var characterHit = glyphRun.GetCharacterHitFromDistance(width, out _); + + Assert.Equal(2, characterHit.FirstCharacterIndex); + + Assert.Equal(2, characterHit.TrailingLength); + } + } + + + [Theory] + [InlineData("☝🏿", new ushort[] { 0 })] + [InlineData("☝🏿 ab", new ushort[] { 0, 3, 4, 5 })] + [InlineData("ab ☝🏿", new ushort[] { 0, 1, 2, 3 })] + public void Should_Create_Valid_Clusters_For_Text(string text, ushort[] clusters) + { + using (Start()) + { + var layout = new TextLayout( + text, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + var textLine = layout.TextLines[0]; + + var index = 0; + + foreach (var textRun in textLine.TextRuns) + { + var shapedRun = (ShapedTextRun)textRun; + + var glyphRun = shapedRun.GlyphRun; + + var glyphClusters = glyphRun.GlyphClusters; + + var expected = clusters.Skip(index).Take(glyphClusters.Length).ToArray(); + + Assert.Equal(expected, glyphRun.GlyphClusters); + + index += glyphClusters.Length; + } + } + } + + [Theory] + [InlineData("abcde\r\n", 7)] // Carriage Return + Line Feed + [InlineData("abcde\n\r", 7)] // This isn't valid but we somehow have to support it. + [InlineData("abcde\u000A", 6)] // Line Feed + [InlineData("abcde\u000B", 6)] // Vertical Tab + [InlineData("abcde\u000C", 6)] // Form Feed + [InlineData("abcde\u000D", 6)] // Carriage Return + public void Should_Break_With_BreakChar(string text, int expectedLength) + { + using (Start()) + { + var layout = new TextLayout( + text, + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + Assert.Equal(2, layout.TextLines.Count); + + Assert.Equal(1, layout.TextLines[0].TextRuns.Count); + + Assert.Equal(expectedLength, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters.Length); + + Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[5]); + + if(expectedLength == 7) + { + Assert.Equal(5, ((ShapedTextRun)layout.TextLines[0].TextRuns[0]).GlyphRun.GlyphClusters[6]); + } + } + } + + [Fact] + public void Should_Have_One_Run_With_Common_Script() + { + using (Start()) + { + var layout = new TextLayout( + "abcde\r\n", + Typeface.Default, + 12.0f, + Brushes.Black.ToImmutable()); + + Assert.Equal(1, layout.TextLines[0].TextRuns.Count); + } + } + + [Fact] + public void Should_Layout_Corrupted_Text() + { + using (Start()) + { + var text = new string(new[] { '\uD802', '\uD802', '\uD802', '\uD802', '\uD802', '\uD802', '\uD802' }); + + var layout = new TextLayout( + text, + Typeface.Default, + 12, + Brushes.Black.ToImmutable()); + + var textLine = layout.TextLines[0]; + + var textRun = (ShapedTextRun)textLine.TextRuns[0]; + + Assert.Equal(7, textRun.Text.Length); + + var replacementGlyph = Typeface.Default.GlyphTypeface.GetGlyph(Codepoint.ReplacementCodepoint); + + foreach (var glyph in textRun.GlyphRun.GlyphIndices) + { + Assert.Equal(replacementGlyph, glyph); + } + } + } + + public static IDisposable Start() + { + var disposable = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface + .With(renderInterface: new PlatformRenderInterface(null), + textShaperImpl: new TextShaperImpl(), + fontManagerImpl : new CustomFontManagerImpl())); + + return disposable; + } + } +} diff --git a/tests/Avalonia.Styling.UnitTests/ActivatedObservableTests.cs b/tests/Avalonia.Styling.UnitTests/ActivatedObservableTests.cs deleted file mode 100644 index 7773d4767a..0000000000 --- a/tests/Avalonia.Styling.UnitTests/ActivatedObservableTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Xunit; - -namespace Avalonia.Styling.UnitTests -{ - public class ActivatedObservableTests - { - [Fact] - public void Should_Produce_Correct_Values() - { - var activator = new BehaviorSubject(false); - var source = new BehaviorSubject(1); - var target = new ActivatedObservable(activator, source, string.Empty); - var result = new List(); - - target.Subscribe(x => result.Add(x)); - - activator.OnNext(true); - source.OnNext(2); - activator.OnNext(false); - source.OnNext(3); - activator.OnNext(true); - - Assert.Equal( - new[] - { - AvaloniaProperty.UnsetValue, - 1, - 2, - AvaloniaProperty.UnsetValue, - 3, - }, - result); - } - - [Fact] - public void Should_Complete_When_Source_Completes() - { - var activator = new BehaviorSubject(false); - var source = new BehaviorSubject(1); - var target = new ActivatedObservable(activator, source, string.Empty); - var completed = false; - - target.Subscribe(_ => { }, () => completed = true); - source.OnCompleted(); - - Assert.True(completed); - } - - [Fact] - public void Should_Error_When_Source_Errors() - { - var activator = new BehaviorSubject(false); - var source = new BehaviorSubject(1); - var target = new ActivatedObservable(activator, source, string.Empty); - var error = new Exception(); - var completed = false; - - target.Subscribe(_ => { }, x => completed = true); - source.OnError(error); - - Assert.True(completed); - } - } -} diff --git a/tests/Avalonia.Styling.UnitTests/ActivatedSubjectTests.cs b/tests/Avalonia.Styling.UnitTests/ActivatedSubjectTests.cs deleted file mode 100644 index 03f91d97a1..0000000000 --- a/tests/Avalonia.Styling.UnitTests/ActivatedSubjectTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Reactive.Disposables; -using System.Reactive.Subjects; -using Xunit; - -namespace Avalonia.Styling.UnitTests -{ - public class ActivatedSubjectTests - { - [Fact] - public void Should_Set_Values() - { - var activator = new BehaviorSubject(false); - var source = new TestSubject(); - var target = new ActivatedSubject(activator, source, string.Empty); - - target.Subscribe(); - target.OnNext("bar"); - Assert.Equal(AvaloniaProperty.UnsetValue, source.Value); - activator.OnNext(true); - target.OnNext("baz"); - Assert.Equal("baz", source.Value); - activator.OnNext(false); - Assert.Equal(AvaloniaProperty.UnsetValue, source.Value); - target.OnNext("bax"); - activator.OnNext(true); - Assert.Equal("bax", source.Value); - } - - [Fact] - public void Should_Invoke_OnCompleted_On_Activator_Completed() - { - var activator = new BehaviorSubject(false); - var source = new TestSubject(); - var target = new ActivatedSubject(activator, source, string.Empty); - - target.Subscribe(); - activator.OnCompleted(); - - Assert.True(source.Completed); - } - - [Fact] - public void Should_Invoke_OnError_On_Activator_Error() - { - var activator = new BehaviorSubject(false); - var source = new TestSubject(); - var target = new ActivatedSubject(activator, source, string.Empty); - var targetError = default(Exception); - var error = new Exception(); - - target.Subscribe(_ => { }, e => targetError = e); - activator.OnError(error); - - Assert.Same(error, source.Error); - Assert.Same(error, targetError); - } - - private class TestSubject : ISubject - { - private IObserver _observer; - - public bool Completed { get; set; } - public Exception Error { get; set; } - public object Value { get; set; } = AvaloniaProperty.UnsetValue; - - public void OnCompleted() - { - Completed = true; - } - - public void OnError(Exception error) - { - Error = error; - } - - public void OnNext(object value) - { - Value = value; - } - - public IDisposable Subscribe(IObserver observer) - { - _observer = observer; - return Disposable.Empty; - } - } - } -} diff --git a/tests/Avalonia.Styling.UnitTests/ActivatedValueTests.cs b/tests/Avalonia.Styling.UnitTests/ActivatedValueTests.cs deleted file mode 100644 index 92a7c1bd1f..0000000000 --- a/tests/Avalonia.Styling.UnitTests/ActivatedValueTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) The Avalonia Project. All rights reserved. -// Licensed under the MIT license. See licence.md file in the project root for full license information. - -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Microsoft.Reactive.Testing; -using Xunit; - -namespace Avalonia.Styling.UnitTests -{ - public class ActivatedValueTests - { - [Fact] - public void Should_Produce_Correct_Values() - { - var activator = new BehaviorSubject(false); - var target = new ActivatedValue(activator, 1, string.Empty); - var result = new List(); - - target.Subscribe(x => result.Add(x)); - - activator.OnNext(true); - activator.OnNext(false); - - Assert.Equal(new[] { AvaloniaProperty.UnsetValue, 1, AvaloniaProperty.UnsetValue }, result); - } - - [Fact] - public void Should_Complete_When_Activator_Completes() - { - var activator = new BehaviorSubject(false); - var target = new ActivatedValue(activator, 1, string.Empty); - var completed = false; - - target.Subscribe(_ => { }, () => completed = true); - activator.OnCompleted(); - - Assert.True(completed); - } - - [Fact] - public void Should_Error_When_Activator_Errors() - { - var activator = new BehaviorSubject(false); - var target = new ActivatedValue(activator, 1, string.Empty); - var error = new Exception(); - var completed = false; - - target.Subscribe(_ => { }, x => completed = true); - activator.OnError(error); - - Assert.True(completed); - } - - [Fact] - public void Should_Unsubscribe_From_Activator_When_All_Subscriptions_Disposed() - { - var scheduler = new TestScheduler(); - var activator1 = scheduler.CreateColdObservable(); - var activator2 = scheduler.CreateColdObservable(); - var activator = StyleActivator.And(new[] { activator1, activator2 }); - var target = new ActivatedValue(activator, 1, string.Empty); - - var subscription = target.Subscribe(_ => { }); - Assert.Equal(1, activator1.Subscriptions.Count); - Assert.Equal(Subscription.Infinite, activator1.Subscriptions[0].Unsubscribe); - - subscription.Dispose(); - Assert.Equal(1, activator1.Subscriptions.Count); - Assert.Equal(0, activator1.Subscriptions[0].Unsubscribe); - } - } -} diff --git a/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj b/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj index 3fd280f7ab..2b7f6263a7 100644 --- a/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj +++ b/tests/Avalonia.Styling.UnitTests/Avalonia.Styling.UnitTests.csproj @@ -1,6 +1,6 @@  - netcoreapp2.0;net47 + netcoreapp3.1;net47 Library CS0067 true diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Class.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Class.cs index fd25b17ba4..00b90f1239 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_Class.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Class.cs @@ -36,7 +36,7 @@ namespace Avalonia.Styling.UnitTests { var control = new Control1 { - Classes = new Classes { "foo" }, + Classes = { "foo" }, }; var target = default(Selector).Class("foo"); @@ -51,7 +51,7 @@ namespace Avalonia.Styling.UnitTests { var control = new Control1 { - Classes = new Classes { "bar" }, + Classes = { "bar" }, }; var target = default(Selector).Class("foo"); @@ -66,7 +66,7 @@ namespace Avalonia.Styling.UnitTests { var control = new Control1 { - Classes = new Classes { "foo" }, + Classes = { "foo" }, TemplatedParent = new Mock().Object, }; @@ -83,7 +83,7 @@ namespace Avalonia.Styling.UnitTests var control = new Control1(); var target = default(Selector).Class("foo"); - var activator = target.Match(control).Activator; + var activator = target.Match(control).Activator.ToObservable(); Assert.False(await activator.Take(1)); control.Classes.Add("foo"); @@ -95,11 +95,11 @@ namespace Avalonia.Styling.UnitTests { var control = new Control1 { - Classes = new Classes { "foo" }, + Classes = { "foo" }, }; var target = default(Selector).Class("foo"); - var activator = target.Match(control).Activator; + var activator = target.Match(control).Activator.ToObservable(); Assert.True(await activator.Take(1)); control.Classes.Remove("foo"); @@ -111,7 +111,7 @@ namespace Avalonia.Styling.UnitTests { var control = new Control1(); var target = default(Selector).Class("foo").Class("bar"); - var activator = target.Match(control).Activator; + var activator = target.Match(control).Activator.ToObservable(); Assert.False(await activator.Take(1)); control.Classes.Add("foo"); @@ -128,7 +128,7 @@ namespace Avalonia.Styling.UnitTests // Test for #1698 var control = new Control1 { - Classes = new Classes { "foo" }, + Classes = { "foo" }, }; var target = default(Selector).Class("foo"); diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs index 099562b1cf..1128120824 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Descendent.cs @@ -90,7 +90,7 @@ namespace Avalonia.Styling.UnitTests child.LogicalParent = parent; var selector = default(Selector).OfType().Class("foo").Descendant().OfType(); - var activator = selector.Match(child).Activator; + var activator = selector.Match(child).Activator.ToObservable(); Assert.False(await activator.Take(1)); parent.Classes.Add("foo"); diff --git a/tests/Avalonia.Styling.UnitTests/SelectorTests_Multiple.cs b/tests/Avalonia.Styling.UnitTests/SelectorTests_Multiple.cs index e8be44ed3b..b43b4f686b 100644 --- a/tests/Avalonia.Styling.UnitTests/SelectorTests_Multiple.cs +++ b/tests/Avalonia.Styling.UnitTests/SelectorTests_Multiple.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.VisualTree; @@ -85,6 +86,35 @@ namespace Avalonia.Styling.UnitTests Assert.Equal(SelectorMatchResult.NeverThisType, match.Result); } + [Fact] + public void Control_With_Class_Descendent_Of_Control_With_Two_Classes() + { + var textBlock = new TextBlock(); + var control = new Button { Content = textBlock }; + + control.ApplyTemplate(); + + var selector = default(Selector) + .OfType