diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 43de3ddefd..1ae5315758 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,3 +1,16 @@
When running code review, please refer to the following documents if they are relevant to the changed parts of the codebase:
- `native/Avalonia.Native/**` -> `native/Avalonia.Native/README.md`
+- `src/Avalonia.Wayland/**` -> `src/Avalonia.Wayland/README.md`
+
+## SBOM (EU Cyber Resilience Act)
+
+We generate a CycloneDX SBOM per published NuGet package (`nukebuild/SbomGenerator.cs`, `CreateSbom` target) so that every shipped package carries an accurate bill of materials, as required for EU CRA compliance. When reviewing, flag the following:
+
+- **New shipped package:** if a change adds a new NuGet package that is published to end users (a new packable project, or a new final package in `nukebuild/numerge.json`), it must be covered by SBOM generation. Confirm `CreateSbom` produces an SBOM for it, and call it out if the package would ship without one.
+- **New kinds of shipped dependencies:** if a change alters what dependencies are delivered with a component in a way the existing scan doesn't already understand, `nukebuild/SbomGenerator.cs` must be updated so those dependencies appear in the SBOM. In particular:
+ - Adding npm/JS dependencies to a component that was previously .NET-only (e.g. a new `webapp` bundled into a package), or changing how existing bundled JS is built.
+ - Bundling third-party binaries directly into a package (copy-local/ILRepack/embedded assemblies) rather than referencing them as normal NuGet dependencies.
+ - New Numerge merge groups, or any other mechanism that folds a project's shipped output into a package other than via ordinary `PackageReference`/`ProjectReference`.
+
+ The scope is what is *delivered* with the package (BSI TR-03183-2 "scope of delivery"), not build/test-only tooling. Purely development-time dependencies that are never shipped do not need to be in the SBOM.
diff --git a/.gitignore b/.gitignore
index 22077f6402..e2e3a8ef73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -227,3 +227,5 @@ src/Browser/Avalonia.Browser/staticwebassets
/.claude/settings.local.json
/planning
BenchmarkDotNet.Artifacts.*
+.tokensave
+/CLAUDE.md
diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json
index b5f565a399..9b765c53ca 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -33,6 +33,7 @@
"CompileNative",
"CreateIntermediateNugetPackages",
"CreateNugetPackages",
+ "CreateSbom",
"DownloadApiBaselinePackages",
"GenerateCppHeaders",
"GenerateUnicodeData",
diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf
index 3edf421eb4..2d66481319 100644
--- a/Avalonia.Desktop.slnf
+++ b/Avalonia.Desktop.slnf
@@ -34,6 +34,7 @@
"src\\Avalonia.Themes.Fluent\\Avalonia.Themes.Fluent.csproj",
"src\\Avalonia.Themes.Simple\\Avalonia.Themes.Simple.csproj",
"src\\Avalonia.Vulkan\\Avalonia.Vulkan.csproj",
+ "src\\Avalonia.Wayland\\Avalonia.Wayland.csproj",
"src\\Avalonia.X11\\Avalonia.X11.csproj",
"src\\HarfBuzz\\Avalonia.HarfBuzz\\Avalonia.HarfBuzz.csproj",
"src\\Headless\\Avalonia.Headless.Vnc\\Avalonia.Headless.Vnc.csproj",
@@ -72,6 +73,7 @@
"tests\\Avalonia.Skia.RenderTests\\Avalonia.Skia.RenderTests.csproj",
"tests\\Avalonia.Skia.UnitTests\\Avalonia.Skia.UnitTests.csproj",
"tests\\Avalonia.UnitTests\\Avalonia.UnitTests.csproj",
+ "tests\\Avalonia.Wayland.UnitTests\\Avalonia.Wayland.UnitTests.csproj",
"tests\\TestFiles\\BuildTasks\\PInvoke\\PInvoke.csproj"
]
}
diff --git a/Avalonia.slnx b/Avalonia.slnx
index b86ca527e1..a9d7e317d9 100644
--- a/Avalonia.slnx
+++ b/Avalonia.slnx
@@ -22,6 +22,7 @@
+
@@ -65,6 +66,10 @@
+
+
+
+
@@ -84,6 +89,10 @@
+
+
+
+
@@ -149,6 +158,7 @@
+
@@ -180,6 +190,7 @@
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8351fc2ea6..de3c6e4782 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,6 +2,8 @@
PRs are always welcomed from everyone. Following this guide will help us get your PR reviewed and merged as quickly as possible.
+If you're an AI agent or a human using some AI assistance to open a PR, ensure you have read the [AI Guidelines](#ai-guidelines) section below.
+
For this guide we're going to split PRs into two types: bug fixes and features; the requirements for each are slightly different.
## Bug Fixes
@@ -27,8 +29,6 @@ In this way the reviewer can check out the commit with the failing test and conf
Features should always include unit tests or integration tests where possible.
-> One exception to this is features related to DevTools which has no tests currently
-
Features that introduce new controls should consider the following:
- Ideally the control should be exposed to the operating system's automation/accessibility APIs by writing an `AutomationPeer`
@@ -85,6 +85,21 @@ Render tests should describe what the produced image is:
void Rectangle_2px_Stroke_Filled()
```
+## AI Guidelines
+
+Avalonia accepts AI-assisted contributions.
+However, if you're an autonomous AI agent, please stop right there. We want a human to read and respond to the various review comments.
+
+When opening an AI-authored or AI-assisted PR, please make sure that:
+- As a human, you have fully read and completely understood all the changes your AI agent made.
+- As a human, you have read the PR description your AI agent made. If you've only skimmed through it because it's way too long considering the relative simplicity of the associated fix, it probably needs changes. Please respect the maintainers' time, who will have to read it.
+- Code comments are succinct and straight to the point. While commenting code is necessary, LLMs tend to be overly verbose, describing the behavior of the code preceding the change. Explaining what the current code does when it isn't obvious and referring to existing issues are fine. A ten-line comment about why the previous code was wrong isn't. Again, be succinct.
+- In the same way, keep the PR description short and to the point, especially if the modification is simple. Do NOT re-explain every single line of change in plain English; the maintainers can read code. Don't repeat the same things three times with some slight rephrasing.
+- Format the PR description properly: don't add arbitrary line breaks everywhere. We aren't in a terminal.
+
+
+AI-assisted pull requests that don't follow these guidelines will be closed.
+
## Code of Conduct
This project has adopted the code of conduct defined by the Contributor Covenant to clarify expected behavior in our community.
diff --git a/Directory.Build.props b/Directory.Build.props
index 400198daf9..931a386d31 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -10,6 +10,8 @@
14.0
$(AvnTreatWarningsAsErrors)
true
+
+ $(NoWarn);NU1507
true
true
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f4e677d631..983d369fab 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -2,12 +2,11 @@
true
-
-
+
@@ -23,23 +22,27 @@
-
-
-
+
+
+
+
+
-
+
+
+
@@ -62,17 +65,18 @@
+
+
-
-
+
\ No newline at end of file
diff --git a/SECURITY.md b/SECURITY.md
index 7b30c0cdf8..2c1637fd04 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,4 +1,5 @@
# Security Policy
+Avalonia is free and open-source software published by AvaloniaUI OÜ. The framework is made publicly available at no charge and is not placed on the market within the meaning of the EU Cyber Resilience Act (Regulation (EU) 2024/2847). AvaloniaUI OÜ acts as the project's open-source software steward and maintains this cybersecurity policy in that capacity, in line with Article 24 of that Regulation.
## Reporting a Vulnerability
@@ -21,5 +22,25 @@ https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/
### Reporting via Email
Alternatively, you may report security vulnerabilities by emailing security@avaloniaui.net.
-### Misc
+### What to Expect
+
+- We aim to acknowledge your report within **2 business days**.
+- We aim to confirm whether we consider it a vulnerability and to share a remediation timeline within **10 business days**.
+- We will keep you informed of progress and coordinate the disclosure date with you.
+- We will credit you in the published advisory unless you ask us not to.
+
+These timescales are the targets we work to for the open-source project; they are not contractual commitments. Organisations that require contractually binding response and remediation times can obtain them under a commercial agreement.
+
Please note that Avalonia does not operate a bug bounty programme.
+
+## Versions Receiving Security Fixes
+| Version | Security fixes |
+|---|---|
+| 11.x and older | Not provided |
+| 12.x (current stable) | Provided |
+
+Security fixes are delivered at the head of the current stable series. If a vulnerability affects earlier 12.x releases, we fix it in a new release of the latest version, and the remediation path is to upgrade to that release. Security fixes for the current series are published openly and free of charge. Access to Avalonia's open-source releases, updates and security fixes is never conditional on payment.
+
+## Scope
+
+This policy covers the Avalonia framework packages published from this repository (`Avalonia` and the `Avalonia.*` platform and integration packages).
diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml
index fd711d37c0..1dda132a59 100644
--- a/api/Avalonia.nupkg.xml
+++ b/api/Avalonia.nupkg.xml
@@ -1,4 +1,4 @@
-
+
@@ -19,12 +19,60 @@
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32})
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32})
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@,Avalonia.Size@)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@)
baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean)
+ baseline/Avalonia/lib/net10.0/Avalonia.OpenGL.dll
+ current/Avalonia/lib/net10.0/Avalonia.OpenGL.dll
+
CP0002
M:Avalonia.Input.PointerEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent,System.Object,Avalonia.Input.IPointer,Avalonia.Visual,Avalonia.Point,System.UInt64,Avalonia.Input.PointerPointProperties,Avalonia.Input.KeyModifiers,System.Lazy{System.Collections.Generic.IReadOnlyList{Avalonia.Input.Raw.RawPointerPoint}})
@@ -43,10 +91,178 @@
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+ CP0002
+ M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,System.Nullable{System.Int32})
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IBitmapImpl.Save(System.String,System.Nullable{System.Int32})
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double,Avalonia.Size)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.#ctor(Avalonia.PixelSize,System.Double)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0002
+ M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@,Avalonia.Size@)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
CP0002
M:Avalonia.Platform.IRenderTarget.RenderTargetSceneInfo.Deconstruct(Avalonia.PixelSize@,System.Double@)
baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
current/Avalonia/lib/net8.0/Avalonia.Base.dll
-
\ No newline at end of file
+
+ CP0002
+ M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0002
+ M:Avalonia.OpenGL.Egl.EglPlatformSurfaceRenderTargetBase.BeginDraw(Avalonia.OpenGL.Egl.EglSurface,Avalonia.PixelSize,System.Double,System.Action,System.Boolean)
+ baseline/Avalonia/lib/net8.0/Avalonia.OpenGL.dll
+ current/Avalonia/lib/net8.0/Avalonia.OpenGL.dll
+
+
+ CP0006
+ E:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguageChanged
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ P:Avalonia.Media.IImageBrushSource.Bitmap
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ P:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguage
+ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll
+ current/Avalonia/lib/net10.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl.SetWindowCornerPreference(Avalonia.Controls.Win32Properties.WindowCornerPreference)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean)
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0006
+ M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant})
+ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net10.0/Avalonia.Controls.dll
+
+
+ CP0006
+ E:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguageChanged
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IBitmapImpl.Save(System.IO.Stream,Avalonia.Media.Imaging.BitmapEncoderOptions)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ P:Avalonia.Media.IImageBrushSource.Bitmap
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ P:Avalonia.Platform.IPlatformSettings.PreferredApplicationLanguage
+ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll
+ current/Avalonia/lib/net8.0/Avalonia.Base.dll
+
+
+ CP0006
+ M:Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl.SetWindowCornerPreference(Avalonia.Controls.Win32Properties.WindowCornerPreference)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0006
+ M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean)
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
+ CP0006
+ M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant})
+ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll
+ current/Avalonia/lib/net8.0/Avalonia.Controls.dll
+
+
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 06da4bcd2b..b61baece36 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -132,6 +132,12 @@ jobs:
artifactName: 'NuGetOSX'
condition: succeeded()
+ - task: PublishBuildArtifacts@1
+ inputs:
+ pathtoPublish: '$(Build.SourcesDirectory)/artifacts/sbom'
+ artifactName: 'SBOMOSX'
+ condition: succeeded()
+
- job: Windows
pool:
vmImage: 'windows-2025'
@@ -187,3 +193,9 @@ jobs:
artifactName: 'Samples'
condition: succeeded()
+ - task: PublishBuildArtifacts@1
+ inputs:
+ pathtoPublish: '$(Build.SourcesDirectory)/artifacts/sbom'
+ artifactName: 'SBOM'
+ condition: succeeded()
+
diff --git a/build/SampleApp.props b/build/SampleApp.props
index 6816a38cbe..e8a9b9166e 100644
--- a/build/SampleApp.props
+++ b/build/SampleApp.props
@@ -14,7 +14,7 @@
-
+
diff --git a/build/SharedVersion.props b/build/SharedVersion.props
index cff9a24f54..2a4e07c3d2 100644
--- a/build/SharedVersion.props
+++ b/build/SharedVersion.props
@@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
Avalonia
- 12.1.999
+ 12.2.999
Avalonia Team
Copyright 2013-$([System.DateTime]::Now.ToString(`yyyy`)) © The AvaloniaUI Project
https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link
diff --git a/build/TargetFrameworks.props b/build/TargetFrameworks.props
index 96541e7ecf..a380009e23 100644
--- a/build/TargetFrameworks.props
+++ b/build/TargetFrameworks.props
@@ -8,6 +8,7 @@
$(AvsCurrentTargetFramework)-ios26.0
$(AvsCurrentTargetFramework)-tvos26.0
$(AvsCurrentTargetFramework)-browser
+ $(AvsCurrentTargetFramework)-windows10.0.19041.0
net8.0
diff --git a/build/TrimmingEnable.props b/build/TrimmingEnable.props
index cf84ae67b4..da921eeffe 100644
--- a/build/TrimmingEnable.props
+++ b/build/TrimmingEnable.props
@@ -1,10 +1,10 @@
- false
- true
- false
- true
+ false
+ true
+ false
+ true
true
$(TreatWarningsAsErrors)
diff --git a/dirs.proj b/dirs.proj
index 20df84439e..43c2518f78 100644
--- a/dirs.proj
+++ b/dirs.proj
@@ -14,12 +14,18 @@
+
+
+
+
+
+
diff --git a/external/XamlX b/external/XamlX
index 86f5a26fad..d7e37ca63d 160000
--- a/external/XamlX
+++ b/external/XamlX
@@ -1 +1 @@
-Subproject commit 86f5a26fadb745e95169c3c805ebd9fd7c9b44d2
+Subproject commit d7e37ca63dc9b13cdc95ca165938d4904fa0eddf
diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h
index 4e5116ee71..dd10ae28b4 100644
--- a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h
+++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.h
@@ -38,7 +38,9 @@ public:
virtual void SetCursorRect (AvnRect rect) override;
- virtual void SetSurroundingText (char* text, int anchorOffset, int cursorOffset) override;
+ virtual void SetSurroundingText (char* text, int start, int end) override;
+
+ virtual void SetSelectionInSurroundingText (int start, int end) override;
public:
ComPtr Client;
diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm
index 8c3ae080fa..a08f3886c8 100644
--- a/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm
+++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethod.mm
@@ -29,13 +29,21 @@ HRESULT AvnTextInputMethod::SetClient(IAvnTextInputMethodClient *client) {
}
void AvnTextInputMethod::Reset() {
+ [_inputMethodDelegate resetInputMethod];
}
-void AvnTextInputMethod::SetSurroundingText(char* text, int anchorOffset, int cursorOffset) {
- [_inputMethodDelegate setText:[NSString stringWithUTF8String:text]];
- [_inputMethodDelegate setSelection: anchorOffset : cursorOffset];
+void AvnTextInputMethod::SetSurroundingText(char* text, int start, int end) {
+ // stringWithUTF8String: throws on a null pointer and returns nil for invalid UTF-8.
+ NSString* surroundingText = text != nullptr ? [NSString stringWithUTF8String:text] : nil;
+
+ [_inputMethodDelegate setText:surroundingText != nil ? surroundingText : @""];
+ [_inputMethodDelegate setSelection: start:end];
}
void AvnTextInputMethod::SetCursorRect(AvnRect rect) {
[_inputMethodDelegate setCursorRect: rect];
}
+
+void AvnTextInputMethod::SetSelectionInSurroundingText(int start, int end) {
+ [_inputMethodDelegate setSelection: start:end];
+}
diff --git a/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h b/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h
index 9f321ca595..f5751d93b4 100644
--- a/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h
+++ b/native/Avalonia.Native/src/OSX/AvnTextInputMethodDelegate.h
@@ -14,6 +14,7 @@
-(void) setText:(NSString* _Nonnull) text;
-(void) setCursorRect:(AvnRect) cursorRect;
-(void) setSelection: (int) start : (int) end;
+-(void) resetInputMethod;
@end
diff --git a/native/Avalonia.Native/src/OSX/AvnView.mm b/native/Avalonia.Native/src/OSX/AvnView.mm
index 600dca865f..805cd7ee02 100644
--- a/native/Avalonia.Native/src/OSX/AvnView.mm
+++ b/native/Avalonia.Native/src/OSX/AvnView.mm
@@ -4,6 +4,7 @@
//
#import
+#import /* For the TIS functions used to classify the keyboard input source. */
#include "AvnView.h"
#include "automation.h"
#import "WindowInterfaces.h"
@@ -22,8 +23,9 @@
NSMutableAttributedString* _text;
NSRange _selectedRange;
NSRange _markedRange;
- NSEvent* _lastKeyDownEvent;
NSMutableArray* _accessibilityChildren;
+ NSString* _keyboardInputSourceId;
+ bool _keyboardInputSourceComposes;
}
- (void)onClosed
@@ -375,7 +377,15 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
[self becomeFirstResponder];
}
}
-
+
+ if([self hasMarkedText] &&
+ (type == LeftButtonDown || type == RightButtonDown || type == MiddleButtonDown ||
+ type == XButton1Down || type == XButton2Down) &&
+ [self inputContext] != nil)
+ {
+ [[self inputContext] handleEvent:event];
+ }
+
auto parent = _parent.tryGet();
if(parent != nullptr)
{
@@ -641,9 +651,100 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
[super flagsChanged:event];
}
-- (bool) handleKeyDown: (NSTimeInterval) timestamp withKey:(AvnKey)key withPhysicalKey:(AvnPhysicalKey)physicalKey withModifiers:(AvnInputModifiers)modifiers withKeySymbol:(NSString*)keySymbol {
- auto parent = _parent.tryGet();
- return parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, modifiers, key, physicalKey, [keySymbol UTF8String]);
+// The input mode an input method uses for direct alphanumeric typing. In this mode the input
+// method passes keys straight through instead of composing, so keys must not be masked.
+static CFStringRef const AlphanumericInputModeId = CFSTR("com.apple.inputmethod.Roman");
+
+static bool InputSourceComposes(TISInputSourceRef source)
+{
+ // A plain keyboard layout never composes. Dead keys are not driven by the input source, they
+ // show up as a composition in progress and are covered by hasMarkedText.
+ auto type = static_cast(TISGetInputSourceProperty(source, kTISPropertyInputSourceType));
+
+ if(type == nullptr || CFEqual(type, kTISTypeKeyboardLayout))
+ {
+ return false;
+ }
+
+ auto mode = static_cast(TISGetInputSourceProperty(source, kTISPropertyInputModeID));
+
+ return mode == nullptr || !CFEqual(mode, AlphanumericInputModeId);
+}
+
+// Whether this keystroke could be the one that starts a composition. Only a key that produces a
+// printable character can, and masking any of the others would stop controls like TextBox from
+// reacting to them at all.
+static bool CanStartComposition(NSString* keySymbol, NSEventModifierFlags modifierFlags)
+{
+ // No symbol at all: the arrows, the function keys, Home, End and friends.
+ if(keySymbol == nullptr || [keySymbol length] == 0)
+ {
+ return false;
+ }
+
+ // KeySymbolFromScanCode deliberately reports the control character for Backspace, Enter, Tab
+ // and Escape, and Forward Delete reports DEL (0x7F), so a symbol alone does not mean the key
+ // produces text. Those keys edit or cancel, they never start a composition.
+ auto firstChar = [keySymbol characterAtIndex:0];
+
+ if(firstChar < 0x20 || firstChar == 0x7F)
+ {
+ return false;
+ }
+
+ // Command and Control combinations are shortcuts, which an input method does not consume.
+ return (modifierFlags & (NSEventModifierFlagCommand | NSEventModifierFlagControl)) == 0;
+}
+
+// Returns true when the input source with the given id is an input method that can start a
+// composition, as opposed to a plain keyboard layout or an input method in alphanumeric mode.
+static bool KeyboardInputSourceComposes(NSString* sourceId)
+{
+ NSDictionary* filter = @{ (__bridge NSString*)kTISPropertyInputSourceID: sourceId };
+
+ // Include every installed source, not just the enabled ones: an input method mode can be
+ // selected without being listed as enabled, and failing to find it would classify a
+ // composing input method as a plain layout.
+ auto sources = TISCreateInputSourceList((__bridge CFDictionaryRef)filter, true);
+
+ if(sources == nullptr)
+ {
+ return false;
+ }
+
+ auto composes = CFArrayGetCount(sources) > 0 &&
+ InputSourceComposes((TISInputSourceRef)CFArrayGetValueAtIndex(sources, 0));
+
+ CFRelease(sources);
+
+ return composes;
+}
+
+// Whether the currently selected keyboard input source is a composing input method. The result
+// is cached per input source id, since this is queried for every key down.
+- (bool)isComposingInputSourceSelected
+{
+ auto inputContext = [self inputContext];
+
+ if(inputContext == nullptr)
+ {
+ return false;
+ }
+
+ NSString* sourceId = [inputContext selectedKeyboardInputSource];
+
+ if(sourceId == nullptr)
+ {
+ return false;
+ }
+
+ if(![sourceId isEqualToString:_keyboardInputSourceId])
+ {
+ _keyboardInputSourceId = sourceId;
+ _keyboardInputSourceComposes = KeyboardInputSourceComposes(sourceId);
+ }
+
+ return _keyboardInputSourceComposes;
}
- (void)keyDown:(NSEvent *)event
@@ -653,57 +754,57 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
{
return;
}
-
- _lastKeyDownEvent = event;
-
+
auto timestamp = static_cast([event timestamp] * 1000);
-
+
auto scanCode = [event keyCode];
- auto key = VirtualKeyFromScanCode(scanCode, [event modifierFlags]);
auto physicalKey = PhysicalKeyFromScanCode(scanCode);
auto keySymbol = KeySymbolFromScanCode(scanCode, [event modifierFlags]);
-
+ auto keySymbolUtf8 = keySymbol == nullptr ? nullptr : [keySymbol UTF8String];
+
auto modifiers = [self getModifiers:[event modifierFlags]];
-
- //InputMethod is active
- if(parent->InputMethod->IsActive()){
- auto hasInputModifier = modifiers != AvnInputModifiersNone;
-
- //Handle keyDown first if an input modifier is present
- if(hasInputModifier){
- if([self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol]){
- //User code has handled the event
- _lastKeyDownEvent = nullptr;
-
- return;
- }
- }
-
- if([[self inputContext] handleEvent:event] == NO){
- //KeyDown has not been consumed by the input context
-
- //Only raise a keyDown if we don't have a modifier
- if(!hasInputModifier){
- [self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol];
- }
- }
-
+
+ // The keystroke belongs to the input method when a composition is already in progress, since
+ // it then selects a candidate, commits or cancels it. It also belongs to the input method
+ // when a character key is typed into a text input client while a composing input method is
+ // selected, because that keystroke may be the one that starts the composition and we cannot
+ // know that before the input context has seen it.
+ //
+ // In both cases mask the key the way Win32 reports VK_PROCESSKEY, so that user code still
+ // observes a KeyDown but no KeyGesture can match it. The physical key and the key symbol
+ // keep their real values, so the underlying key remains recoverable.
+ //
+ // Both cases require an active client: without one the input context is never consulted, so
+ // marked text left over from a composition that lost its client mid-way must not mask keys.
+ auto imeProcessed = parent->InputMethod->IsActive() &&
+ ([self hasMarkedText] ||
+ (CanStartComposition(keySymbol, [event modifierFlags]) &&
+ [self isComposingInputSourceSelected]));
+
+ auto key = imeProcessed
+ ? AvnKeyImeProcessed
+ : VirtualKeyFromScanCode(scanCode, [event modifierFlags]);
+
+ // A KeyDown is always raised before the input context sees the event, otherwise the
+ // input context silently consumes printable keys (space, letters, digits) and user code
+ // never gets a chance to react to them.
+ auto handled = parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, modifiers, key, physicalKey, keySymbolUtf8);
+
+ if(handled)
+ {
+ // User code has handled the event, so no text may be produced from it.
+ return;
}
- //InputMethod not active
- else{
- auto keyDownHandled = [self handleKeyDown:timestamp withKey:key withPhysicalKey:physicalKey withModifiers:modifiers withKeySymbol:keySymbol];
-
- //Raise text input event for unhandled key down
- if(!keyDownHandled){
- if(keySymbol != nullptr && key != AvnKeyEnter){
- auto timestamp = static_cast([event timestamp] * 1000);
-
- parent->TopLevelEvents->RawTextInputEvent(timestamp, [keySymbol UTF8String]);
- }
- }
+
+ if(parent->InputMethod->IsActive())
+ {
+ // Let the input context produce the text or drive the composition.
+ [[self inputContext] handleEvent:event];
+ }
+ else if(keySymbol != nullptr && key != AvnKeyEnter)
+ {
+ parent->TopLevelEvents->RawTextInputEvent(timestamp, keySymbolUtf8);
}
-
- _lastKeyDownEvent = nullptr;
}
- (void)keyUp:(NSEvent *)event
@@ -713,9 +814,9 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
}
- (void) doCommandBySelector:(SEL)selector{
- if(_lastKeyDownEvent != nullptr){
- [self keyboardEvent:_lastKeyDownEvent withType:KeyDown];
- }
+ // -keyDown: already raised a KeyDown for this event before handing it to the input
+ // context, so nothing is left to do here. The method still has to be implemented:
+ // falling back to NSResponder would perform the default action and emit a system beep.
}
- (AvnInputModifiers)getModifiers:(NSEventModifierFlags)mod
@@ -747,25 +848,48 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
return (AvnInputModifiers)rv;
}
+// Clamps a range so that it can never be used to index outside of _text.
+// Ranges reaching us from AppKit or from the managed side are not guaranteed to be valid.
+- (NSRange)clampRangeToText:(NSRange)range
+{
+ if (range.location == NSNotFound)
+ return NSMakeRange(NSNotFound, 0);
+
+ NSUInteger length = _text.length;
+
+ if (range.location > length)
+ return NSMakeRange(length, 0);
+
+ // Avoids the overflow of location + length that a plain bounds check would have.
+ return NSMakeRange(range.location, MIN(range.length, length - range.location));
+}
+
- (BOOL)hasMarkedText
{
- return _markedRange.length > 0;
+ return _markedRange.location != NSNotFound && _markedRange.length > 0;
}
- (NSRange)markedRange
{
- return _markedRange;
+ // From the docs: returns {NSNotFound, 0} if there is no marked range.
+ if (![self hasMarkedText])
+ return NSMakeRange(NSNotFound, 0);
+
+ // The preedit isn't necessarily part of the surrounding text we got from the managed side,
+ // so only the location is clamped here. An overlong length is handled by
+ // attributedSubstringForProposedRange:actualRange:, as the docs require.
+ return NSMakeRange(MIN(_markedRange.location, _text.length), _markedRange.length);
}
- (NSRange)selectedRange
{
- return _selectedRange;
+ return [self clampRangeToText:_selectedRange];
}
- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange
{
NSString* markedText;
-
+
if([string isKindOfClass:[NSAttributedString class]])
{
markedText = [string string];
@@ -774,24 +898,47 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
{
markedText = (NSString*) string;
}
-
- _markedRange = NSMakeRange(_selectedRange.location, [markedText length]);
+
+ if (markedText == nil)
+ {
+ markedText = @"";
+ }
+
auto parent = _parent.tryGet();
- if(parent->InputMethod->IsActive()){
- parent->InputMethod->Client->SetPreeditText((char*)[markedText UTF8String]);
+ // Delete any replaced range
+ auto finalReplacementRange = [self clampRangeToText:replacementRange];
+
+ if (finalReplacementRange.location != NSNotFound && parent != nullptr && parent->InputMethod->IsActive())
+ {
+ parent->InputMethod->Client->SelectInSurroundingText((int)finalReplacementRange.location, (int)(finalReplacementRange.location + finalReplacementRange.length));
+ uint64_t timestamp = static_cast([NSDate timeIntervalSinceReferenceDate] * 1000);
+ parent->TopLevelEvents->RawKeyEvent(KeyDown, timestamp, AvnInputModifiersNone, AvnKeyBack, AvnPhysicalKeyNone, "\b");
+ parent->TopLevelEvents->RawKeyEvent(KeyUp, timestamp, AvnInputModifiersNone, AvnKeyBack, AvnPhysicalKeyNone, "\b");
+ }
+
+ auto markedLocation = [self selectedRange].location;
+
+ _markedRange = NSMakeRange(markedLocation == NSNotFound ? 0 : markedLocation, [markedText length]);
+
+ if (parent != nullptr && parent->InputMethod->IsActive())
+ {
+ const char* utf8MarkedText = [markedText UTF8String];
+ parent->InputMethod->Client->SetPreeditText((char*)(utf8MarkedText != nullptr ? utf8MarkedText : ""));
}
}
- (void)unmarkText
{
auto parent = _parent.tryGet();
- if(parent->InputMethod->IsActive()){
+ if(parent != nullptr && parent->InputMethod->IsActive()){
parent->InputMethod->Client->SetPreeditText(nullptr);
}
-
- _markedRange = NSMakeRange(_selectedRange.location, 0);
-
+
+ auto selectionLocation = [self selectedRange].location;
+
+ _markedRange = NSMakeRange(selectionLocation == NSNotFound ? 0 : selectionLocation, 0);
+
if([self inputContext]) {
[[self inputContext] discardMarkedText];
}
@@ -804,13 +951,27 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actualRange
{
- if(actualRange){
- range = *actualRange;
+ // From the docs: an implementation of this method should be prepared for aRange to be out of bounds.
+ // In this case, you should return the intersection of the document's range and aRange.
+ // If the location of aRange is completely outside of the document's range, return nil.
+ // actualRange is an out parameter: it is uninitialized on entry and must only be written to.
+ NSRange docRange = NSMakeRange(0, _text.length);
+ NSRange finalRange = NSIntersectionRange(range, docRange);
+
+ if (finalRange.length == 0)
+ {
+ if (actualRange) {
+ *actualRange = NSMakeRange(NSNotFound, 0);
+ }
+
+ return nil;
}
-
- NSAttributedString* subString = [_text attributedSubstringFromRange:range];
-
- return subString;
+
+ if (actualRange) {
+ *actualRange = finalRange;
+ }
+
+ return [_text attributedSubstringFromRange:finalRange];
}
- (void)insertText:(id)string replacementRange:(NSRange)replacementRange
@@ -819,9 +980,9 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
if(parent == nullptr){
return;
}
-
+
NSString* text;
-
+
if([string isKindOfClass:[NSAttributedString class]])
{
text = [string string];
@@ -830,12 +991,28 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
{
text = (NSString*) string;
}
-
+
+ if (text == nil)
+ {
+ text = @"";
+ }
+
+ auto finalReplacementRange = [self clampRangeToText:replacementRange];
+
+ if (finalReplacementRange.location != NSNotFound &&
+ ![self hasMarkedText] &&
+ parent->InputMethod->IsActive())
+ {
+ parent->InputMethod->Client->SelectInSurroundingText((int)finalReplacementRange.location, (int)(finalReplacementRange.location + finalReplacementRange.length));
+ }
+
[self unmarkText];
-
+
uint64_t timestamp = static_cast([NSDate timeIntervalSinceReferenceDate] * 1000);
-
- parent->TopLevelEvents->RawTextInputEvent(timestamp, [text UTF8String]);
+
+ const char* utf8Text = [text UTF8String];
+
+ parent->TopLevelEvents->RawTextInputEvent(timestamp, utf8Text != nullptr ? utf8Text : "");
}
- (NSUInteger)characterIndexForPoint:(NSPoint)point
@@ -845,11 +1022,17 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange
{
+ // actualRange is an out parameter: it is uninitialized on entry and must only be written to.
+ // We only ever report a single rect, so the requested range is echoed back clamped to the document.
+ if (actualRange) {
+ *actualRange = [self clampRangeToText:range];
+ }
+
auto parent = _parent.tryGet();
- if(!parent->InputMethod->IsActive()){
+ if(parent == nullptr || !parent->InputMethod->IsActive()){
return NSZeroRect;
}
-
+
return _cursorRect;
}
@@ -1003,11 +1186,45 @@ static void ConvertTilt(NSPoint tilt, float* xTilt, float* yTilt)
}
- (void) setText:(NSString *)text{
- [[_text mutableString] setString:text];
+ [[_text mutableString] setString:text != nil ? text : @""];
+
+ // The document changed, so the stored ranges can now point outside of it.
+ _selectedRange = [self clampRangeToText:_selectedRange];
+
+ if (_markedRange.location != NSNotFound)
+ {
+ _markedRange = NSMakeRange(MIN(_markedRange.location, _text.length), _markedRange.length);
+ }
}
- (void) setSelection:(int)start :(int)end{
- _selectedRange = NSMakeRange(start, end - start);
+ if (end < start)
+ {
+ auto temp = start;
+ start = end;
+ end = temp;
+ }
+
+ auto length = (int)_text.length;
+
+ start = MAX(0, MIN(start, length));
+ end = MAX(start, MIN(end, length));
+
+ _selectedRange = NSMakeRange((NSUInteger)start, (NSUInteger)(end - start));
+}
+
+- (void) resetInputMethod{
+ auto parent = _parent.tryGet();
+
+ if(parent != nullptr && parent->InputMethod->IsActive()){
+ parent->InputMethod->Client->SetPreeditText(nullptr);
+ }
+
+ _markedRange = NSMakeRange(_selectedRange.location, 0);
+
+ if([self inputContext]) {
+ [[self inputContext] discardMarkedText];
+ }
}
- (void) setCursorRect:(AvnRect)rect{
diff --git a/native/Avalonia.Native/src/OSX/AvnWindow.mm b/native/Avalonia.Native/src/OSX/AvnWindow.mm
index b2f20d556b..de0c796d4a 100644
--- a/native/Avalonia.Native/src/OSX/AvnWindow.mm
+++ b/native/Avalonia.Native/src/OSX/AvnWindow.mm
@@ -628,14 +628,18 @@
return GetNSStringAndRelease(automationPeer->GetAutomationId());
}
-- (IAvnAutomationPeer* _Nonnull) automationPeer
+- (IAvnAutomationPeer* _Nullable) automationPeer
{
auto parent = _parent.tryGet();
if (parent && _automationPeer == nullptr)
{
- _automationPeer = parent->BaseEvents->GetAutomationPeer();
- _automationNode = new AvnAutomationNode(self);
- _automationPeer->SetNode(_automationNode);
+ auto peer = parent->BaseEvents->GetAutomationPeer();
+ if (peer != nullptr)
+ {
+ _automationPeer = peer;
+ _automationNode = new AvnAutomationNode(self);
+ _automationPeer->SetNode(_automationNode);
+ }
}
return _automationPeer;
diff --git a/native/Avalonia.Native/src/OSX/PlatformSettings.mm b/native/Avalonia.Native/src/OSX/PlatformSettings.mm
index 34ceb93186..d30d55e2ec 100644
--- a/native/Avalonia.Native/src/OSX/PlatformSettings.mm
+++ b/native/Avalonia.Native/src/OSX/PlatformSettings.mm
@@ -1,12 +1,19 @@
#include "common.h"
+#include "AvnString.h"
@interface CocoaThemeObserver : NSObject
-(id)initWithCallback:(IAvnActionCallback *)callback;
@end
+@interface CocoaLocaleObserver : NSObject
+-(id)initWithCallback:(IAvnActionCallback *)callback;
+-(void)localeDidChange:(NSNotification *)notification;
+@end
+
class PlatformSettings : public ComSingleObject
{
CocoaThemeObserver* observer;
+ CocoaLocaleObserver* localeObserver;
public:
FORWARD_IUNKNOWN()
@@ -58,6 +65,28 @@ public:
[[NSApplication sharedApplication] addObserver:observer forKeyPath:@"effectiveAppearance" options:NSKeyValueObservingOptionNew context:nil];
}
}
+
+ virtual HRESULT GetPreferredLanguage(IAvnString** ret) override
+ {
+ @autoreleasepool
+ {
+ if (ret == nullptr)
+ return E_POINTER;
+
+ auto language = [[NSLocale preferredLanguages] firstObject];
+ *ret = language == nil ? nullptr : CreateAvnString(language);
+ return S_OK;
+ }
+ }
+
+ virtual void RegisterLanguageChange(IAvnActionCallback *callback) override
+ {
+ localeObserver = [[CocoaLocaleObserver alloc] initWithCallback: callback];
+ [[NSNotificationCenter defaultCenter] addObserver:localeObserver
+ selector:@selector(localeDidChange:)
+ name:NSCurrentLocaleDidChangeNotification
+ object:nil];
+ }
private:
unsigned int to_argb(NSColor* color)
@@ -107,6 +136,23 @@ private:
}
@end
+@implementation CocoaLocaleObserver
+{
+ ComPtr _callback;
+}
+- (id) initWithCallback:(IAvnActionCallback *)callback {
+ self = [super init];
+ if (self) {
+ _callback = callback;
+ }
+ return self;
+}
+
+- (void)localeDidChange:(NSNotification *)notification {
+ _callback->Run();
+}
+@end
+
extern IAvnPlatformSettings* CreatePlatformSettings()
{
return new PlatformSettings();
diff --git a/native/Avalonia.Native/src/OSX/PopupImpl.mm b/native/Avalonia.Native/src/OSX/PopupImpl.mm
index 13131bf91e..8a188c89b3 100644
--- a/native/Avalonia.Native/src/OSX/PopupImpl.mm
+++ b/native/Avalonia.Native/src/OSX/PopupImpl.mm
@@ -43,6 +43,17 @@ public:
return WindowBaseImpl::Show(activate, true);
}
+ virtual HRESULT SetHitTestVisible(bool value) override
+ {
+ START_COM_CALL;
+
+ @autoreleasepool
+ {
+ [Window setIgnoresMouseEvents:!value];
+ return S_OK;
+ }
+ }
+
virtual bool ShouldTakeFocusOnShow() override
{
auto parent = Parent.tryGet();
diff --git a/native/Avalonia.Native/src/OSX/StorageProvider.mm b/native/Avalonia.Native/src/OSX/StorageProvider.mm
index a608f6ffaf..8e3ad185de 100644
--- a/native/Avalonia.Native/src/OSX/StorageProvider.mm
+++ b/native/Avalonia.Native/src/OSX/StorageProvider.mm
@@ -165,6 +165,17 @@ public:
return nullptr;
}
+
+ static int GetSelectedFilterIndex(NSSavePanel* _Nonnull panel)
+ {
+ if (panel.accessoryView != nil)
+ {
+ auto popup = [panel.accessoryView viewWithTag:kFileTypePopupTag];
+ if ([popup isKindOfClass:[NSPopUpButton class]])
+ return (int)[(NSPopUpButton*)popup indexOfSelectedItem];
+ }
+ return -1;
+ }
virtual void SelectFolderDialog (IAvnTopLevel* parentTopLevel,
IAvnSystemDialogEvents* events,
@@ -181,6 +192,7 @@ public:
panel.canChooseDirectories = true;
panel.canCreateDirectories = true;
panel.canChooseFiles = false;
+ panel.treatsFilePackagesAsDirectories = true;
if(title != nullptr)
{
@@ -270,30 +282,32 @@ public:
auto parentWindow = GetEffectiveNSWindow(parentTopLevel);
auto handler = ^(NSModalResponse result) {
+ auto selectedIndex = GetSelectedFilterIndex(panel);
+
if(result == NSFileHandlingPanelOKButton)
{
auto urls = [panel URLs];
-
+
if(urls.count > 0)
{
auto uriStrings = CreateAvnStringArray(urls);
- ownedEvents->OnCompleted(uriStrings);
+ ownedEvents->OnCompletedWithFilter(uriStrings, selectedIndex);
[panel orderOut:panel];
-
+
if (parentWindow != nullptr)
{
[parentWindow makeKeyAndOrderFront:parentWindow];
}
-
+
return;
}
}
-
- ownedEvents->OnCompleted(nullptr);
-
+
+ ownedEvents->OnCompletedWithFilter(nullptr, selectedIndex);
+
};
-
+
if (parentWindow != nullptr)
{
[panel beginSheetModalForWindow:parentWindow completionHandler:handler];
@@ -304,7 +318,7 @@ public:
}
}
}
-
+
virtual void SaveFileDialog (IAvnTopLevel* parentTopLevel,
IAvnSystemDialogEvents* events,
const char* title,
@@ -340,15 +354,7 @@ public:
auto parentWindow = GetEffectiveNSWindow(parentTopLevel);
auto handler = ^(NSModalResponse result) {
- int selectedIndex = -1;
- if (panel.accessoryView != nil)
- {
- auto popup = [panel.accessoryView viewWithTag:kFileTypePopupTag];
- if ([popup isKindOfClass:[NSPopUpButton class]])
- {
- selectedIndex = (int)[(NSPopUpButton*)popup indexOfSelectedItem];
- }
- }
+ auto selectedIndex = GetSelectedFilterIndex(panel);
if(result == NSFileHandlingPanelOKButton)
{
diff --git a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
index 2a1de62b05..205f1efbdf 100644
--- a/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
+++ b/native/Avalonia.Native/src/OSX/WindowBaseImpl.mm
@@ -21,6 +21,24 @@
@class AutoFitContentView;
+
+// Activates the app, but defers until the run loop is running if it isn't yet.
+// Windows are shown (and the app activated) from OnFrameworkInitializationCompleted,
+// which runs before -[NSApplication run]. Activating that early yields a "degraded"
+// activation: the app looks frontmost but the WindowServer never does a real
+// become-active, which leaves the out-of-process file picker unable to receive
+// clicks in its file list/sidebar until a manual app switch. Deferring makes the first
+// activation a clean transition.
+static void ActivateApplication() {
+ if ([NSApp isRunning]) {
+ [NSApp activateIgnoringOtherApps:YES];
+ } else {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [NSApp activateIgnoringOtherApps:YES];
+ });
+ }
+}
+
WindowBaseImpl::~WindowBaseImpl() {
View = nullptr;
Window = nullptr;
@@ -99,7 +117,7 @@ HRESULT WindowBaseImpl::Show(bool activate, bool isDialog) {
[Window orderFront:Window];
[Window makeKeyAndOrderFront:Window];
[Window makeFirstResponder:View];
- [NSApp activateIgnoringOtherApps:YES];
+ ActivateApplication();
} else {
[Window orderFront:Window];
}
@@ -177,7 +195,7 @@ HRESULT WindowBaseImpl::Activate() {
@autoreleasepool {
if (Window != nullptr) {
[Window makeKeyAndOrderFront:nil];
- [NSApp activateIgnoringOtherApps:YES];
+ ActivateApplication();
}
}
@@ -329,6 +347,21 @@ HRESULT WindowBaseImpl::BeginMoveDrag() {
@autoreleasepool {
auto lastEvent = [View lastMouseDownEvent];
+ if (lastEvent == nullptr) {
+ // A press that begins inside an embedded native view (for example a webview hosted
+ // through NativeControlHost) is consumed by that view and never delivered to the
+ // Avalonia view, so no mouse-down is recorded; fall back to the event the
+ // application is tracking right now, provided it is a left-button press or drag
+ // that belongs to this window.
+ auto currentEvent = [NSApp currentEvent];
+
+ if (currentEvent != nullptr && [currentEvent window] == Window &&
+ ([currentEvent type] == NSEventTypeLeftMouseDown ||
+ [currentEvent type] == NSEventTypeLeftMouseDragged)) {
+ lastEvent = currentEvent;
+ }
+ }
+
if (lastEvent == nullptr) {
return S_OK;
}
@@ -432,11 +465,21 @@ void WindowBaseImpl::CleanNSWindow() {
}
void WindowBaseImpl::CreateNSWindow(bool usePanel) {
+ // Anchor the content rect to the primary screen's origin. It's usually at (0,0), but can be offset while the
+ // display server is mid-reconfiguration. This ensures the window is always on a proper screen.
+ // See https://github.com/AvaloniaUI/Avalonia/issues/18895
+ NSPoint origin = NSZeroPoint;
+ NSArray* screens = [NSScreen screens];
+ if (screens.count > 0)
+ origin = screens.firstObject.frame.origin;
+
+ NSRect contentRect = NSRect { origin.x, origin.y, lastSize };
+
if (usePanel) {
- Window = [[AvnPanel alloc] initWithParent:this contentRect:NSRect{0, 0, lastSize} styleMask:NSWindowStyleMaskBorderless];
+ Window = [[AvnPanel alloc] initWithParent:this contentRect:contentRect styleMask:NSWindowStyleMaskBorderless];
[Window setHidesOnDeactivate:false];
} else {
- Window = [[AvnWindow alloc] initWithParent:this contentRect:NSRect{0, 0, lastSize} styleMask:NSWindowStyleMaskBorderless];
+ Window = [[AvnWindow alloc] initWithParent:this contentRect:contentRect styleMask:NSWindowStyleMaskBorderless];
}
}
diff --git a/native/Avalonia.Native/src/OSX/WindowProtocol.h b/native/Avalonia.Native/src/OSX/WindowProtocol.h
index 5d1df951a6..0e1091b86c 100644
--- a/native/Avalonia.Native/src/OSX/WindowProtocol.h
+++ b/native/Avalonia.Native/src/OSX/WindowProtocol.h
@@ -17,7 +17,7 @@ struct IAvnAutomationPeer;
-(void) showAppMenuOnly;
-(void) showWindowMenuWithAppMenu;
-(void) applyMenu:(AvnMenu* _Nullable)menu;
--(IAvnAutomationPeer* _Nonnull) automationPeer;
+-(IAvnAutomationPeer* _Nullable) automationPeer;
-(double) getExtendedTitleBarHeight;
-(void) setIsExtended:(bool)value;
diff --git a/native/Avalonia.Native/src/OSX/automation.mm b/native/Avalonia.Native/src/OSX/automation.mm
index 6fd2cb6218..8750e9986c 100644
--- a/native/Avalonia.Native/src/OSX/automation.mm
+++ b/native/Avalonia.Native/src/OSX/automation.mm
@@ -96,7 +96,7 @@
case AutomationEdit: return NSAccessibilityTextFieldRole;
case AutomationHyperlink: return NSAccessibilityLinkRole;
case AutomationImage: return NSAccessibilityImageRole;
- case AutomationListItem: return NSAccessibilityGroupRole;
+ case AutomationListItem: return NSAccessibilityRowRole;
case AutomationList: return NSAccessibilityListRole;
case AutomationMenu: return NSAccessibilityMenuBarRole;
case AutomationMenuBar: return NSAccessibilityMenuBarRole;
@@ -141,6 +141,7 @@
auto controlType = _peer->GetAutomationControlType();
switch (controlType) {
case AutomationList: return @"AXContentList";
+ case AutomationListItem: return NSAccessibilityTableRowSubrole;
}
auto landmarkType = _peer->GetLandmarkType();
@@ -259,20 +260,36 @@
- (void)setAccessibilityValue:(id)newValue
{
- if (_peer->IsValueProvider())
+ if (!_peer->IsEnabled())
+ return;
+ if (_peer->IsValueProvider() && !_peer->ValueProvider_IsReadOnly())
{
if (newValue == nil)
_peer->ValueProvider_SetValue(nil);
else if ([newValue isKindOfClass:[NSString class]])
_peer->ValueProvider_SetValue([(NSString*)newValue UTF8String]);
}
- else if (_peer->IsRangeValueProvider())
+ else if (_peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly())
{
if ([newValue isKindOfClass:[NSNumber class]])
_peer->RangeValueProvider_SetValue([(NSNumber*)newValue doubleValue]);
}
}
+- (BOOL)accessibilityIsAttributeSettable:(NSAccessibilityAttributeName)attribute
+{
+ if ([attribute isEqualToString:NSAccessibilityValueAttribute])
+ {
+ if (_peer->IsValueProvider())
+ return !_peer->ValueProvider_IsReadOnly();
+ if (_peer->IsRangeValueProvider())
+ return !_peer->RangeValueProvider_IsReadOnly();
+ return NO;
+ }
+
+ return [super accessibilityIsAttributeSettable:attribute];
+}
+
- (id)accessibilityMinValue
{
if (_peer->IsRangeValueProvider())
@@ -357,6 +374,20 @@
return [AvnAccessibilityElement acquire:rootPeer];
}
+- (id)accessibilityHorizontalScrollBar
+{
+ if (_peer == nullptr)
+ return nil;
+ return [AvnAccessibilityElement acquire:_peer->ScrollProvider_GetHorizontalScrollBar()];
+}
+
+- (id)accessibilityVerticalScrollBar
+{
+ if (_peer == nullptr)
+ return nil;
+ return [AvnAccessibilityElement acquire:_peer->ScrollProvider_GetVerticalScrollBar()];
+}
+
- (BOOL)isAccessibilityExpanded
{
if (!_peer->IsExpandCollapseProvider())
@@ -366,7 +397,7 @@
- (void)setAccessibilityExpanded:(BOOL)accessibilityExpanded
{
- if (!_peer->IsExpandCollapseProvider())
+ if (!_peer->IsExpandCollapseProvider() || !_peer->IsEnabled())
return;
if (accessibilityExpanded)
_peer->ExpandCollapseProvider_Expand();
@@ -376,13 +407,18 @@
- (BOOL)accessibilityPerformPress
{
+ if (!_peer->IsEnabled())
+ return NO;
if (_peer->IsInvokeProvider())
{
_peer->InvokeProvider_Invoke();
}
else if (_peer->IsExpandCollapseProvider())
{
- _peer->ExpandCollapseProvider_Expand();
+ if (_peer->ExpandCollapseProvider_GetIsExpanded())
+ _peer->ExpandCollapseProvider_Collapse();
+ else
+ _peer->ExpandCollapseProvider_Expand();
}
else if (_peer->IsToggleProvider())
{
@@ -393,7 +429,7 @@
- (BOOL)accessibilityPerformIncrement
{
- if (!_peer->IsRangeValueProvider())
+ if (!_peer->IsRangeValueProvider() || _peer->RangeValueProvider_IsReadOnly() || !_peer->IsEnabled())
return NO;
auto value = _peer->RangeValueProvider_GetValue();
value += _peer->RangeValueProvider_GetSmallChange();
@@ -403,7 +439,7 @@
- (BOOL)accessibilityPerformDecrement
{
- if (!_peer->IsRangeValueProvider())
+ if (!_peer->IsRangeValueProvider() || _peer->RangeValueProvider_IsReadOnly() || !_peer->IsEnabled())
return NO;
auto value = _peer->RangeValueProvider_GetValue();
value -= _peer->RangeValueProvider_GetSmallChange();
@@ -413,7 +449,7 @@
- (BOOL)accessibilityPerformShowMenu
{
- if (!_peer->IsExpandCollapseProvider())
+ if (!_peer->IsExpandCollapseProvider() || !_peer->IsEnabled())
return NO;
_peer->ExpandCollapseProvider_Expand();
return YES;
@@ -445,11 +481,22 @@
return NO;
}
+- (void)setAccessibilitySelected:(BOOL)accessibilitySelected
+{
+ if (!_peer->IsSelectionItemProvider() || !_peer->IsEnabled())
+ return;
+ if (accessibilitySelected)
+ _peer->SelectionItemProvider_AddToSelection();
+ else
+ _peer->SelectionItemProvider_RemoveFromSelection();
+}
+
- (BOOL)isAccessibilitySelectorAllowed:(SEL)selector
{
if (selector == @selector(setAccessibilityValue:))
{
- return _peer->IsValueProvider() || _peer->IsRangeValueProvider();
+ return (_peer->IsValueProvider() && !_peer->ValueProvider_IsReadOnly()) ||
+ (_peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly());
}
else if (selector == @selector(accessibilityPerformShowMenu))
{
@@ -463,9 +510,16 @@
{
return _peer->IsInvokeProvider() || _peer->IsExpandCollapseProvider() || _peer->IsToggleProvider();
}
+ else if (selector == @selector(setAccessibilitySelected:))
+ {
+ return _peer->IsSelectionItemProvider();
+ }
else if (selector == @selector(accessibilityPerformIncrement) ||
- selector == @selector(accessibilityPerformDecrement) ||
- selector == @selector(accessibilityMinValue) ||
+ selector == @selector(accessibilityPerformDecrement))
+ {
+ return _peer->IsRangeValueProvider() && !_peer->RangeValueProvider_IsReadOnly();
+ }
+ else if (selector == @selector(accessibilityMinValue) ||
selector == @selector(accessibilityMaxValue))
{
return _peer->IsRangeValueProvider();
@@ -501,8 +555,31 @@
if (_children)
[changed addObjectsFromArray:_children];
+ /*
+ For future reference, upon testing with a sample SwiftUI app:
+
+ 1) Containers vanish. VStack/HStack don't appear in the accessibility tree at all,
+ only real elements (Text, Button, List) do, parented to one root AXHostingView.
+
+ 2) Changes post on the nearest real element, not the container.
+ Toggling a child two VStack/HStack levels deep fired AXLayoutChanged on the AXHostingView
+ (the nearest real element), never on the hidden containers.
+
+ 3) Real controls get their own notification. The List (AXOutline) posted AXRowCountChanged on itself.
+
+ Apple never posts a structural notification on a non-accessibility container.
+ It targets the nearest element that's actually in the tree.
+ That's exactly what the code below does (templated parent, else walk to the nearest exposed ancestor).
+ */
+
+ id target = [AvnAccessibilityElement acquire:_peer->GetTemplatedParent()];
+ if (target == nil)
+ target = self;
+ while ([target isKindOfClass:[AvnAccessibilityElement class]] && ![(AvnAccessibilityElement*)target isAccessibilityElement])
+ target = [(AvnAccessibilityElement*)target accessibilityParent];
+
NSAccessibilityPostNotificationWithUserInfo(
- self,
+ target,
NSAccessibilityLayoutChangedNotification,
@{ NSAccessibilityUIElementsKey: [changed allObjects]});
}
diff --git a/native/Avalonia.Native/src/OSX/menu.mm b/native/Avalonia.Native/src/OSX/menu.mm
index a67c96a796..e664ee0ad6 100644
--- a/native/Avalonia.Native/src/OSX/menu.mm
+++ b/native/Avalonia.Native/src/OSX/menu.mm
@@ -101,20 +101,29 @@ NSMenuItem* AvnAppMenuItem::GetNative()
HRESULT AvnAppMenuItem::SetSubMenu (IAvnMenu* menu)
{
START_COM_CALL;
-
+
@autoreleasepool
{
if(menu != nullptr)
{
auto nsMenu = dynamic_cast(menu)->GetNative();
-
+
[_native setSubmenu: nsMenu];
+
+ // Parity with -[NSMenu setSubmenu:forItem:]: without submenuAction: a click is dispatched to
+ // didSelectItem: and dismisses the menu instead of opening the submenu.
+ [_native setTarget: nil];
+ [_native setAction: @selector(submenuAction:)];
}
else
{
[_native setSubmenu: nullptr];
+
+ // The item is reused, so put its own action back.
+ [_native setTarget: _native];
+ [_native setAction: @selector(didSelectItem:)];
}
-
+
return S_OK;
}
}
diff --git a/native/Avalonia.Native/src/OSX/metal.mm b/native/Avalonia.Native/src/OSX/metal.mm
index 3da1282306..05f9fe0e34 100644
--- a/native/Avalonia.Native/src/OSX/metal.mm
+++ b/native/Avalonia.Native/src/OSX/metal.mm
@@ -181,12 +181,13 @@ class AvnMetalRenderSession : public ComSingleObject drawable, const AvnPixelSize &size, double scaling)
+ AvnMetalRenderSession(AvnMetalDevice* device, CAMetalLayer* layer, id drawable, const AvnPixelSize &size, double scaling, bool presentWithTransaction)
: _drawable(drawable), _size(size), _scaling(scaling), _queue(device->queue),
- _texture([drawable texture]) {
+ _texture([drawable texture]), _presentWithTransaction(presentWithTransaction) {
_layer = layer;
}
@@ -207,8 +208,19 @@ public:
{
START_ARP_CALL;
auto buffer = [_queue commandBuffer];
- [buffer presentDrawable: _drawable];
- [buffer commit];
+ if(_presentWithTransaction)
+ {
+ [buffer commit];
+ [buffer waitUntilScheduled];
+ [_drawable present];
+ // Restore the default asynchronous presentation for the off-thread render loop.
+ _layer.presentsWithTransaction = NO;
+ }
+ else
+ {
+ [buffer presentDrawable: _drawable];
+ [buffer commit];
+ }
}
};
@@ -230,7 +242,8 @@ public:
HRESULT BeginDrawing(IAvnMetalRenderingSession **ret) override {
START_COM_ARP_CALL;
- if([NSThread isMainThread])
+ bool onMainThread = [NSThread isMainThread];
+ if(onMainThread)
{
// Flush all existing rendering
auto buffer = [_device->queue commandBuffer];
@@ -240,15 +253,21 @@ public:
_scaling= PendingScaling;
CGSize layerSize = {(CGFloat)_size.Width, (CGFloat)_size.Height};
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
[_layer setDrawableSize: layerSize];
+ _layer.presentsWithTransaction = YES;
+ [CATransaction commit];
}
auto drawable = [_layer nextDrawable];
if(drawable == nil)
{
- ret = nil;
+ if(onMainThread)
+ _layer.presentsWithTransaction = NO;
+ *ret = nullptr;
return E_FAIL;
}
- *ret = new AvnMetalRenderSession(_device, _layer, drawable, _size, _scaling);
+ *ret = new AvnMetalRenderSession(_device, _layer, drawable, _size, _scaling, onMainThread);
return 0;
}
};
diff --git a/nukebuild/ApiDiffHelper.cs b/nukebuild/ApiDiffHelper.cs
index d16495969e..8325ca41f4 100644
--- a/nukebuild/ApiDiffHelper.cs
+++ b/nukebuild/ApiDiffHelper.cs
@@ -399,7 +399,11 @@ public static class ApiDiffHelper
{
var packageSource = new PackageSource(NightlyFeedUri) { ProtocolVersion = 3 };
var repository = Repository.Factory.GetCoreV3(packageSource);
- var findPackageByIdResource = await repository.GetResourceAsync();
+
+ var findPackageByIdResource =
+ await repository.GetResourceAsync() ??
+ throw new InvalidOperationException($"{nameof(FindPackageByIdResource)} not found on {packageSource}");
+
return new NuGetDownloadContext(packageSource, findPackageByIdResource);
}
diff --git a/nukebuild/Build.cs b/nukebuild/Build.cs
index e09204485f..5bcd8d60ef 100644
--- a/nukebuild/Build.cs
+++ b/nukebuild/Build.cs
@@ -43,6 +43,9 @@ partial class Build : NukeBuild
[NuGetPackage("dotnet-ilrepack", "ILRepackTool.dll", Framework = "net8.0")]
Tool IlRepackTool;
+ [NuGetPackage("CycloneDX", "CycloneDX.dll", Framework = "net10.0")]
+ Tool CycloneDxTool;
+
protected override void OnBuildInitialized()
{
Parameters = new BuildParameters(this, ScheduledTargets.Contains(BuildToNuGetCache));
@@ -227,6 +230,10 @@ partial class Build : NukeBuild
{
tfm = "net8.0";
}
+ if (tfm == "$(AvsCurrentWindowsTargetFramework)")
+ {
+ tfm = "net10.0-windows";
+ }
if (tfm.StartsWith("net4")
&& (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
@@ -280,6 +287,9 @@ partial class Build : NukeBuild
RunCoreTest("Avalonia.Headless.XUnit.PerAssembly.UnitTests");
RunCoreTest("Avalonia.Headless.XUnit.PerTest.UnitTests");
RunCoreTest("Avalonia.Themes.Fluent2.UnitTests");
+
+ if (Parameters.IsRunningOnWindows)
+ RunCoreTest("Avalonia.UnitTests.WpfCompare");
});
Target RunRenderTests => _ => _
@@ -309,7 +319,9 @@ partial class Build : NukeBuild
});
Target ZipFiles => _ => _
- .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package)
+ // CreateSbom embeds the SBOM into each .nupkg in NugetRoot, so it must run before we zip
+ // that directory - otherwise the zipped NuGet artifacts would omit the embedded SBOM.
+ .After(CreateNugetPackages, Compile, RunCoreLibsTests, Package, CreateSbom)
.Executes(() =>
{
var data = Parameters;
@@ -341,6 +353,20 @@ partial class Build : NukeBuild
Parameters.NugetRoot / $"Avalonia.{Parameters.Version}.snupkg");
});
+ Target CreateSbom => _ => _
+ .DependsOn(CreateNugetPackages)
+ .Executes(() =>
+ {
+ SbomGenerator.Generate(
+ CycloneDxTool,
+ RootDirectory,
+ Parameters.NugetRoot,
+ Parameters.NugetIntermediateRoot,
+ RootDirectory / "nukebuild" / "numerge.json",
+ Parameters.SbomRoot,
+ Parameters.Version);
+ });
+
Target DownloadApiBaselinePackages => _ => _
.DependsOn(CreateNugetPackages)
.Executes(async () =>
@@ -410,12 +436,14 @@ partial class Build : NukeBuild
Target CiAzureOSX => _ => _
.DependsOn(Package)
- .DependsOn(ZipFiles);
+ .DependsOn(ZipFiles)
+ .DependsOn(CreateSbom);
Target CiAzureWindows => _ => _
.DependsOn(Package)
.DependsOn(VerifyXamlCompilation)
- .DependsOn(ZipFiles);
+ .DependsOn(ZipFiles)
+ .DependsOn(CreateSbom);
Target BuildToNuGetCache => _ => _
.DependsOn(CreateNugetPackages)
@@ -429,12 +457,10 @@ partial class Build : NukeBuild
foreach (var path in Parameters.NugetRoot.GlobFiles("*.nupkg"))
{
+ var packageId = SbomGenerator.ReadPackageId(path);
+
using var f = File.Open(path.ToString(), FileMode.Open, FileAccess.Read);
using var zip = new ZipArchive(f, ZipArchiveMode.Read);
- var nuspecEntry = zip.Entries.First(e => e.FullName.EndsWith(".nuspec") && e.FullName == e.Name);
- var packageId = XDocument.Load(nuspecEntry.Open()).Document.Root
- .Elements().First(x => x.Name.LocalName == "metadata")
- .Elements().First(x => x.Name.LocalName == "id").Value;
var packagePath = Path.Combine(
globalPackagesFolder,
diff --git a/nukebuild/BuildParameters.cs b/nukebuild/BuildParameters.cs
index 4b55e1691e..18d9e154ca 100644
--- a/nukebuild/BuildParameters.cs
+++ b/nukebuild/BuildParameters.cs
@@ -60,6 +60,7 @@ public partial class Build
public AbsolutePath ArtifactsDir { get; }
public AbsolutePath NugetIntermediateRoot { get; }
public AbsolutePath NugetRoot { get; }
+ public AbsolutePath SbomRoot { get; }
public AbsolutePath ZipRoot { get; }
public AbsolutePath TestResultsRoot { get; }
public string DirSuffix { get; }
@@ -139,6 +140,7 @@ public partial class Build
ArtifactsDir = RootDirectory / "artifacts";
NugetRoot = ArtifactsDir / "nuget";
NugetIntermediateRoot = RootDirectory / "build-intermediate" / "nuget";
+ SbomRoot = ArtifactsDir / "sbom";
ZipRoot = ArtifactsDir / "zip";
TestResultsRoot = ArtifactsDir / "test-results";
BuildDirs = RootDirectory.GlobDirectories("**/bin")
diff --git a/nukebuild/SbomGenerator.cs b/nukebuild/SbomGenerator.cs
new file mode 100644
index 0000000000..a35620f2db
--- /dev/null
+++ b/nukebuild/SbomGenerator.cs
@@ -0,0 +1,740 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using Nuke.Common.IO;
+using Nuke.Common.Tooling;
+using static Serilog.Log;
+
+// Generates one CycloneDX SBOM per published NuGet package, using the already-restored
+// solution (obj/project.assets.json) so component versions are the ones actually resolved
+// into the build, rather than the floating PackageReference ranges GitHub's own dependency
+// graph reports.
+//
+// Numerge (see numerge.json) merges several source projects' packed output into a handful of
+// the final NuGet packages (e.g. "Avalonia" absorbs Avalonia.Base, Avalonia.Controls, the
+// build tasks/analyzers, etc). That merge happens on the built .nupkg files, not via MSBuild
+// ProjectReferences, so it can't be discovered by pointing CycloneDX at a single project with
+// -rs/--recursive. Instead this compares the intermediate and final package sets to work out
+// which source projects were folded into which final package, generates a BOM per constituent
+// project, and unions their components (de-duplicated by purl) into one BOM per final package.
+public static class SbomGenerator
+{
+ public static void Generate(
+ Tool cycloneDx,
+ AbsolutePath rootDirectory,
+ AbsolutePath nugetRoot,
+ AbsolutePath nugetIntermediateRoot,
+ AbsolutePath numergeConfigPath,
+ AbsolutePath outputDirectory,
+ string version)
+ {
+ outputDirectory.CreateOrCleanDirectory();
+
+ // Read each final package's id exactly once here (opening/unzipping a nupkg to parse its
+ // nuspec isn't free) and reuse the id->path map when locating each package's own nupkg
+ // below, rather than re-scanning every nupkg per final package.
+ var finalPackagePathsById = nugetRoot.GlobFiles("*.nupkg")
+ .GroupBy(p => ReadPackageId((string)p))
+ .ToDictionary(g => g.Key, g => g.First());
+ var finalPackageIds = finalPackagePathsById.Keys.ToHashSet();
+ var intermediatePackageIds = nugetIntermediateRoot.GlobFiles("*.nupkg")
+ .Select(p => ReadPackageId((string)p)).Distinct();
+
+ var numerge = Numerge.MergeConfiguration.LoadFile(numergeConfigPath);
+ var explicitParentByChild = numerge.Packages
+ .SelectMany(p => p.Merge.Select(c => (Parent: p.Id, Child: c.Id)))
+ .ToDictionary(x => x.Child, x => x.Parent);
+ var mergeAllParent = numerge.Packages.FirstOrDefault(p => p.MergeAll)?.Id;
+
+ // Every final package starts out as its own sole constituent; leftover intermediate
+ // packages (ones that never shipped standalone) get assigned to whichever final
+ // package absorbed them, per numerge.json.
+ var constituentProjectIdsByFinalId = finalPackageIds.ToDictionary(id => id, id => new List { id });
+ foreach (var id in intermediatePackageIds.Where(id => !finalPackageIds.Contains(id)))
+ {
+ var owner = explicitParentByChild.TryGetValue(id, out var explicitOwner) ? explicitOwner : mergeAllParent;
+ if (owner is not null && constituentProjectIdsByFinalId.TryGetValue(owner, out var siblings))
+ siblings.Add(id);
+ else
+ Warning($"SBOM: couldn't determine which published package absorbs intermediate package '{id}' - it will be missing from all generated SBOMs.");
+ }
+
+ foreach (var (finalId, projectIds) in constituentProjectIdsByFinalId)
+ GenerateForPackage(cycloneDx, rootDirectory, finalPackagePathsById, outputDirectory, version, finalId, projectIds);
+ }
+
+ static void GenerateForPackage(Tool cycloneDx, AbsolutePath rootDirectory,
+ IReadOnlyDictionary finalPackagePathsById,
+ AbsolutePath outputDirectory, string version, string finalId, List projectIds)
+ {
+ JsonObject? merged = null;
+ var seenComponentKeys = new HashSet();
+ var scannedProjectDirs = new List();
+
+ foreach (var projectId in projectIds)
+ {
+ var project = rootDirectory.GlobFiles($"src/**/{projectId}.csproj")
+ .Concat(rootDirectory.GlobFiles($"packages/**/{projectId}.csproj"))
+ .FirstOrDefault();
+ if (project is null)
+ {
+ Warning($"SBOM: couldn't locate source project for '{projectId}', skipping it in the SBOM for '{finalId}'.");
+ continue;
+ }
+ scannedProjectDirs.Add(project.Parent);
+
+ var tempBom = outputDirectory / $"_{projectId}.tmp.json";
+ cycloneDx(
+ $"\"{project}\" -o \"{outputDirectory}\" -fn \"{tempBom.Name}\" -F Json -dpr -ed -sn \"{finalId}\" -sv \"{version}\"",
+ workingDirectory: rootDirectory);
+
+ var doc = JsonNode.Parse(File.ReadAllText(tempBom))!.AsObject();
+ File.Delete(tempBom);
+
+ var components = doc["components"]?.AsArray() ?? new JsonArray();
+ if (merged is null)
+ {
+ merged = doc;
+ foreach (var component in components)
+ seenComponentKeys.Add(ComponentKey(component));
+ }
+ else
+ {
+ var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
+ foreach (var component in components)
+ {
+ if (seenComponentKeys.Add(ComponentKey(component)))
+ target.Add(component!.DeepClone());
+ }
+
+ // Every constituent project's own -sn/-sv override makes its root component (and
+ // therefore its dependency-graph "ref") identical to the final package's, so merging
+ // by ref correctly unions all constituents' dependsOn edges onto that shared root
+ // instead of silently keeping only the first project's edges.
+ MergeDependencyGraph(merged, doc["dependencies"]?.AsArray() ?? new JsonArray());
+ }
+ }
+
+ if (merged is null)
+ {
+ Warning($"SBOM: no source projects could be scanned for '{finalId}', no SBOM was generated for it.");
+ return;
+ }
+
+ // cyclonedx-dotnet only sees the MSBuild/NuGet graph. Some projects also bundle a
+ // Bun/npm-built webapp directly into their published package (e.g. Avalonia.Browser's
+ // staticwebassets, Avalonia.DesignerSupport's embedded previewer) - scan those separately
+ // so their shipped JS dependencies aren't silently absent from the SBOM.
+ var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue();
+ foreach (var projectDir in scannedProjectDirs)
+ AddNpmComponents(merged, seenComponentKeys, projectDir, rootRef);
+
+ // The final .nupkg carries the authoritative publisher/license/repository metadata and the
+ // actual shipped binaries; use it to flesh out the thin root component cyclonedx-dotnet
+ // emits and to verify nothing ships that the dependency scan didn't already account for.
+ var finalNupkg = finalPackagePathsById.GetValueOrDefault(finalId);
+ if (finalNupkg is not null)
+ {
+ var nuspec = ReadNuspecMetadata((string)finalNupkg);
+ EnrichRootComponent(merged, nuspec);
+ AddPackageContentComponents(merged, seenComponentKeys, (string)finalNupkg, finalId, projectIds, nuspec);
+ }
+ else
+ {
+ Warning($"SBOM: couldn't find the built .nupkg for '{finalId}' - root metadata and package-content verification were skipped.");
+ }
+
+ // cyclonedx-dotnet leaves dependsOn edges pointing at packages it excluded as dev
+ // dependencies (e.g. analyzers stripped by -ed), which dangle once the component is gone
+ // (upstream bug CycloneDX/cyclonedx-dotnet#761, still reproducing in 6.2.0). Drop those so
+ // the graph only references components actually present in the SBOM.
+ PruneDanglingDependencyEdges(merged);
+
+ var sbomJson = merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(outputDirectory / $"{finalId}.{version}.cdx.json", sbomJson);
+
+ // Embed the SBOM inside the shipped .nupkg so it travels with the package, in addition to
+ // the standalone copy written above (which CI publishes as the SBOM artifact) - belt and
+ // suspenders: consumers who only ever see the package still get its bill of materials.
+ if (finalNupkg is not null)
+ EmbedSbomInPackage(finalNupkg, sbomJson);
+ }
+
+ // The path inside the .nupkg where the CycloneDX SBOM is embedded. Mirrors the _manifest/
+ // layout Microsoft.Sbom.Targets uses for its SPDX manifest, but keeps CycloneDX's recognised
+ // *.cdx.json filename so tools that scan for that pattern still find it once unpacked.
+ const string EmbeddedSbomEntryPath = "_manifest/cyclonedx/bom.cdx.json";
+
+ // Adds the generated SBOM as a new part inside the shipped package. Must run before the package
+ // is signed - a NuGet signature covers the whole archive, so adding a part afterwards would
+ // invalidate it. That holds here: these packages are signed server-side by nuget.org on push,
+ // which happens after this build step.
+ static void EmbedSbomInPackage(AbsolutePath nupkgPath, string sbomJson)
+ {
+ using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.ReadWrite);
+ using var zip = new ZipArchive(file, ZipArchiveMode.Update);
+
+ if (zip.Entries.Any(e => e.FullName.EndsWith(".signature.p7s", StringComparison.OrdinalIgnoreCase)))
+ {
+ Warning($"SBOM: '{nupkgPath.Name}' is already signed - skipping embed so its signature stays valid.");
+ return;
+ }
+
+ // Re-embedding (e.g. a re-run over the same output) should replace, not stack duplicates.
+ zip.GetEntry(EmbeddedSbomEntryPath)?.Delete();
+ using (var entryStream = zip.CreateEntry(EmbeddedSbomEntryPath).Open())
+ using (var writer = new StreamWriter(entryStream))
+ writer.Write(sbomJson);
+
+ EnsureJsonContentTypeRegistered(zip);
+ }
+
+ // A .nupkg is an OPC package: every part's extension must be declared in [Content_Types].xml or
+ // strict OPC readers - including NuGet's own signature verification - reject the package. The
+ // SBOM is a .json part, so register that extension before (or as) we add it.
+ static void EnsureJsonContentTypeRegistered(ZipArchive zip)
+ {
+ const string contentTypesEntryName = "[Content_Types].xml";
+ XNamespace ns = "http://schemas.openxmlformats.org/package/2006/content-types";
+
+ var entry = zip.GetEntry(contentTypesEntryName);
+ if (entry is null)
+ return; // not a well-formed OPC package; don't fabricate one
+
+ XDocument doc;
+ using (var read = entry.Open())
+ doc = XDocument.Load(read);
+
+ var alreadyRegistered = doc.Root!.Elements(ns + "Default")
+ .Any(d => string.Equals((string?)d.Attribute("Extension"), "json", StringComparison.OrdinalIgnoreCase));
+ if (alreadyRegistered)
+ return;
+
+ doc.Root.Add(new XElement(ns + "Default",
+ new XAttribute("Extension", "json"),
+ new XAttribute("ContentType", "application/json")));
+
+ entry.Delete();
+ using var write = zip.CreateEntry(contentTypesEntryName).Open();
+ doc.Save(write);
+ }
+
+ static void PruneDanglingDependencyEdges(JsonObject merged)
+ {
+ var deps = merged["dependencies"]?.AsArray();
+ if (deps is null)
+ return;
+
+ var known = new HashSet();
+ var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue();
+ if (rootRef is not null)
+ known.Add(rootRef);
+ foreach (var component in merged["components"]?.AsArray() ?? new JsonArray())
+ {
+ if (component?["bom-ref"]?.GetValue() is { } bomRef)
+ known.Add(bomRef);
+ if (component?["purl"]?.GetValue() is { } purl)
+ known.Add(purl);
+ }
+
+ foreach (var node in deps.OfType())
+ {
+ var dependsOn = node["dependsOn"]?.AsArray();
+ if (dependsOn is null)
+ continue;
+ var kept = new JsonArray();
+ foreach (var edge in dependsOn)
+ if (known.Contains(edge!.GetValue()))
+ kept.Add(edge.GetValue());
+ node["dependsOn"] = kept;
+ }
+ }
+
+ static void MergeDependencyGraph(JsonObject target, JsonArray incoming)
+ {
+ var targetDeps = target["dependencies"]?.AsArray() ?? (JsonArray)(target["dependencies"] = new JsonArray());
+ var byRef = targetDeps.OfType().ToDictionary(d => d["ref"]!.GetValue());
+
+ foreach (var node in incoming.OfType())
+ {
+ var nodeRef = node["ref"]!.GetValue();
+ var dependsOn = node["dependsOn"]?.AsArray().Select(x => x!.GetValue()) ?? Enumerable.Empty();
+
+ if (!byRef.TryGetValue(nodeRef, out var existing))
+ {
+ existing = node.DeepClone().AsObject();
+ targetDeps.Add(existing);
+ byRef[nodeRef] = existing;
+ }
+
+ var existingDependsOn = existing["dependsOn"]?.AsArray() ?? (JsonArray)(existing["dependsOn"] = new JsonArray());
+ var seen = existingDependsOn.Select(x => x!.GetValue()).ToHashSet();
+ foreach (var dep in dependsOn)
+ if (seen.Add(dep))
+ existingDependsOn.Add(dep);
+ }
+ }
+
+ // Scans /**/webapp/package.json for production "dependencies" (deliberately
+ // ignoring devDependencies, which never ship) and adds them - plus their transitive
+ // dependencies, walked through the installed node_modules - as fully-formed components:
+ // bom-ref, resolved version, license, and dependency-graph edges, matching the shape of the
+ // NuGet components cyclonedx-dotnet emits so npm packages aren't second-class SBOM entries.
+ // Versions come from the actually-installed node_modules (same rationale as reading
+ // project.assets.json rather than trusting floating ranges).
+ static void AddNpmComponents(JsonObject merged, HashSet seenComponentKeys, AbsolutePath projectDir,
+ string? rootRef)
+ {
+ foreach (string packageJsonPath in projectDir.GlobFiles("**/webapp/package.json"))
+ {
+ var packageJson = JsonNode.Parse(File.ReadAllText(packageJsonPath))!.AsObject();
+ var nodeModules = ((AbsolutePath)packageJsonPath).Parent / "node_modules";
+ var dependencies = packageJson["dependencies"]?.AsObject() ?? new JsonObject();
+
+ // The webapp is bundled into the shipped package, so its direct production
+ // dependencies are direct dependencies of the final NuGet package.
+ foreach (var (name, rangeNode) in dependencies)
+ {
+ if (IsTypeOnlyPackage(name))
+ continue;
+ var purl = AddNpmComponentTree(merged, seenComponentKeys, nodeModules, name,
+ rangeNode!.GetValue(), nodeModules);
+ if (rootRef is not null)
+ AddDependsOn(merged, rootRef, purl);
+ }
+ }
+ }
+
+ // Adds the component for (name, range) and, recursively, everything it depends on, returning
+ // its purl. The component/graph node/subtree are materialised only the first time a purl is
+ // seen (which also breaks any dependency cycles); repeat encounters just return the purl so
+ // the caller can still record its own edge to it.
+ static string AddNpmComponentTree(JsonObject merged, HashSet seenComponentKeys,
+ AbsolutePath topLevelNodeModules, string name, string declaredRange, AbsolutePath parentNodeModules)
+ {
+ var (purl, componentVersion, installedDir) =
+ ResolveNpmComponent(name, declaredRange, parentNodeModules, topLevelNodeModules);
+
+ if (!seenComponentKeys.Add(purl))
+ return purl;
+
+ var installed = installedDir is not null && File.Exists(installedDir / "package.json")
+ ? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!.AsObject()
+ : null;
+
+ var component = new JsonObject
+ {
+ ["type"] = "library",
+ ["bom-ref"] = purl,
+ ["name"] = name,
+ ["version"] = componentVersion,
+ ["purl"] = purl
+ };
+ // No hashes: npm's verifiable hashes live in the (binary bun) lockfile, not the installed
+ // tree, and a hash of the unpacked directory wouldn't be checkable against a registry.
+ var licenses = installed is null ? null : BuildLicenses(installed);
+ if (licenses is not null)
+ component["licenses"] = licenses;
+
+ var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
+ target.Add(component);
+
+ var node = new JsonObject { ["ref"] = purl, ["dependsOn"] = new JsonArray() };
+ (merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray())).Add(node);
+
+ var childDeps = installed?["dependencies"]?.AsObject() ?? new JsonObject();
+ var childNodeModules = installedDir is not null ? installedDir / "node_modules" : parentNodeModules;
+ var dependsOn = node["dependsOn"]!.AsArray();
+ foreach (var (childName, childRange) in childDeps)
+ {
+ if (IsTypeOnlyPackage(childName))
+ continue;
+ var childPurl = AddNpmComponentTree(merged, seenComponentKeys, topLevelNodeModules, childName,
+ childRange!.GetValue(), childNodeModules);
+ dependsOn.Add(childPurl);
+ }
+
+ return purl;
+ }
+
+ // @types/* packages are TypeScript declaration stubs: esbuild strips them at build time, so
+ // they're never part of the shipped bytes and don't belong in a scope-of-delivery SBOM.
+ static bool IsTypeOnlyPackage(string name) => name.StartsWith("@types/", StringComparison.Ordinal);
+
+ static void AddDependsOn(JsonObject merged, string fromRef, string toPurl)
+ {
+ var deps = merged["dependencies"]?.AsArray() ?? (JsonArray)(merged["dependencies"] = new JsonArray());
+ var node = deps.OfType().FirstOrDefault(d => d["ref"]?.GetValue() == fromRef);
+ if (node is null)
+ {
+ node = new JsonObject { ["ref"] = fromRef, ["dependsOn"] = new JsonArray() };
+ deps.Add(node);
+ }
+
+ var dependsOn = node["dependsOn"]?.AsArray() ?? (JsonArray)(node["dependsOn"] = new JsonArray());
+ if (!dependsOn.Any(x => x!.GetValue() == toPurl))
+ dependsOn.Add(toPurl);
+ }
+
+ static JsonArray? BuildLicenses(JsonObject installedPackageJson)
+ {
+ // Modern npm: "license" is an SPDX id or expression. Legacy: "license"/"licenses" objects.
+ if (installedPackageJson["license"] is JsonValue licenseValue && licenseValue.TryGetValue(out string? spdx))
+ {
+ var licenses = SpdxToLicenses(spdx);
+ if (licenses is not null)
+ return licenses;
+ }
+
+ var legacy = (installedPackageJson["license"] as JsonObject)?["type"]?.GetValue()
+ ?? (installedPackageJson["licenses"] as JsonArray)?.OfType()
+ .FirstOrDefault()?["type"]?.GetValue();
+ return legacy is null
+ ? null
+ : new JsonArray(new JsonObject { ["license"] = new JsonObject { ["name"] = legacy } });
+ }
+
+ // Turns an SPDX string into a CycloneDX licenses array: a single license id becomes a
+ // {license:{id}} entry, a compound SPDX expression becomes an {expression} entry.
+ static JsonArray? SpdxToLicenses(string? spdx)
+ {
+ if (string.IsNullOrWhiteSpace(spdx))
+ return null;
+ var isExpression = spdx.IndexOf(" OR ", StringComparison.Ordinal) >= 0
+ || spdx.IndexOf(" AND ", StringComparison.Ordinal) >= 0
+ || spdx.IndexOf(" WITH ", StringComparison.Ordinal) >= 0;
+ return new JsonArray(isExpression
+ ? new JsonObject { ["expression"] = spdx }
+ : new JsonObject { ["license"] = new JsonObject { ["id"] = spdx } });
+ }
+
+ static (string Purl, string Version, AbsolutePath? InstalledDir) ResolveNpmComponent(
+ string name, string declaredRange, AbsolutePath parentNodeModules, AbsolutePath topLevelNodeModules)
+ {
+ // npm/bun hoist most packages to the top level but may nest a conflicting version under
+ // the depending package, so prefer the nested copy and fall back to the hoisted one.
+ var installedDir = new[] { parentNodeModules / name, topLevelNodeModules / name }
+ .FirstOrDefault(d => File.Exists(d / "package.json"));
+
+ if (declaredRange.StartsWith("github:") || declaredRange.StartsWith("git") || declaredRange.Contains("://"))
+ {
+ if (TryParseGitHubDependency(declaredRange, out var owner, out var repo, out var reference))
+ return ($"pkg:github/{owner}/{repo}@{reference}", reference, installedDir);
+
+ // A non-GitHub git/URL specifier (GitLab/Bitbucket, a raw tarball URL, ...). We have no
+ // provider-specific purl for it, so degrade to a generic component - preferring the
+ // version installed on disk - rather than throwing and failing the whole release over a
+ // single dependency we can't classify precisely.
+ var resolvedVersion = installedDir is not null
+ ? JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue()
+ : null;
+ resolvedVersion ??= declaredRange;
+ Warning($"SBOM: npm dependency '{name}' uses an unrecognised git/URL specifier '{declaredRange}' - recording it as a generic component with version '{resolvedVersion}'.");
+ return ($"pkg:generic/{EncodeNpmName(name)}@{resolvedVersion}", resolvedVersion, installedDir);
+ }
+
+ if (installedDir is null)
+ {
+ Warning($"SBOM: npm dependency '{name}' isn't installed near {parentNodeModules} - recording its declared range '{declaredRange}' instead of a resolved version.");
+ return ($"pkg:npm/{EncodeNpmName(name)}@{declaredRange}", declaredRange, null);
+ }
+
+ var installedVersion = JsonNode.Parse(File.ReadAllText(installedDir / "package.json"))!["version"]?.GetValue();
+ if (installedVersion is null)
+ {
+ // package.json without a "version" is valid for private packages; don't let it NRE.
+ Warning($"SBOM: npm dependency '{name}' installed at {installedDir} has no version in its package.json - recording its declared range '{declaredRange}' instead.");
+ installedVersion = declaredRange;
+ }
+ return ($"pkg:npm/{EncodeNpmName(name)}@{installedVersion}", installedVersion, installedDir);
+ }
+
+ static string EncodeNpmName(string name) => name.StartsWith("@") ? $"%40{name[1..]}" : name;
+
+ static bool TryParseGitHubDependency(string spec, out string owner, out string repo, out string reference)
+ {
+ owner = repo = "";
+ var hashIndex = spec.IndexOf('#');
+ reference = hashIndex >= 0 ? spec[(hashIndex + 1)..] : "HEAD";
+ var withoutRef = hashIndex >= 0 ? spec[..hashIndex] : spec;
+
+ var match = Regex.Match(withoutRef, @"github(?:\.com)?[:/]+([^/]+)/([^/#]+?)(?:\.git)?$");
+ if (!match.Success)
+ return false;
+ owner = match.Groups[1].Value;
+ repo = match.Groups[2].Value;
+ return true;
+ }
+
+ // Prefer purl (or bom-ref) as the identity; both encode version. Fall back to name+version so
+ // distinct versions of the same package aren't collapsed into one, and to a fresh GUID when
+ // there's nothing identifiable to key on (treating it as unique rather than deduping blindly).
+ static string ComponentKey(JsonNode? component) =>
+ component?["purl"]?.GetValue()
+ ?? component?["bom-ref"]?.GetValue()
+ ?? (component?["name"]?.GetValue() is { } name
+ ? $"{name}@{component?["version"]?.GetValue()}"
+ : Guid.NewGuid().ToString());
+
+ // Fills in the root component with the publisher, licence, description and repository details
+ // from the shipped .nuspec - cyclonedx-dotnet only emits type/name/version, which is far short
+ // of the manufacturer/provenance information a CRA-facing SBOM is expected to carry.
+ static void EnrichRootComponent(JsonObject merged, NuspecMetadata meta)
+ {
+ var component = merged["metadata"]?["component"]?.AsObject();
+ if (component is null)
+ return;
+
+ // These are shipped libraries, not applications (cyclonedx-dotnet's default type).
+ component["type"] = "library";
+ component["purl"] = $"pkg:nuget/{meta.Id}@{meta.Version}";
+ if (meta.Description is not null)
+ component["description"] = meta.Description;
+ if (meta.Copyright is not null)
+ component["copyright"] = meta.Copyright;
+
+ JsonObject? supplier = null;
+ if (meta.Authors is not null)
+ {
+ component["publisher"] = meta.Authors;
+ component["author"] = meta.Authors;
+ supplier = new JsonObject { ["name"] = meta.Authors };
+ if (meta.ProjectUrl is not null)
+ supplier["url"] = new JsonArray(meta.ProjectUrl);
+ component["supplier"] = supplier;
+ }
+
+ var licenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId);
+ // A file license has no SPDX id; record it by name rather than emitting an invalid id.
+ if (licenses is null && meta.LicenseFile is not null)
+ licenses = new JsonArray(new JsonObject
+ { ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } });
+ if (licenses is not null)
+ component["licenses"] = licenses;
+
+ var externalReferences = new JsonArray();
+ if (meta.ProjectUrl is not null)
+ externalReferences.Add(new JsonObject { ["url"] = meta.ProjectUrl, ["type"] = "website" });
+ if (meta.RepositoryUrl is not null)
+ externalReferences.Add(new JsonObject { ["url"] = meta.RepositoryUrl, ["type"] = "vcs" });
+ if (externalReferences.Count > 0)
+ component["externalReferences"] = externalReferences;
+
+ // Also record the manufacturer at the document level (the entity that supplied the BOM).
+ if (supplier is not null && merged["metadata"] is JsonObject metadata)
+ metadata["supplier"] = supplier.DeepClone();
+ }
+
+ // Cross-checks what the package actually ships against the components derived from the
+ // dependency graph. Avalonia's own merged modules (Numerge folds several projects' assemblies
+ // into one package) are recorded as manufacturer-supplied components with a verifiable SHA-512
+ // of the shipped bytes; any third-party binary that no restored dependency accounts for is
+ // added and flagged, so a future bundling regression can't silently escape the SBOM.
+ //
+ // These shipped binaries are *constituents* of the package, not dependencies of it: the package
+ // is made up of them (Component C bundles D and E, in CycloneDX's terms), so they're modelled as
+ // a CycloneDX "assembly" via a top-level `compositions` entry rather than with a dependsOn edge
+ // from the root - "contains" and "depends on" are different relationships, and an assembly does
+ // not imply a dependency. They stay flat top-level components (not nested subcomponents) so their
+ // per-assembly hashes remain visible to scanners that ignore nested components.
+ static void AddPackageContentComponents(JsonObject merged, HashSet seenComponentKeys,
+ string nupkgPath, string finalId, List constituentProjectIds, NuspecMetadata meta)
+ {
+ var productNames = new HashSet(constituentProjectIds, StringComparer.OrdinalIgnoreCase) { finalId };
+ var representedNames = (merged["components"]?.AsArray() ?? new JsonArray())
+ .Select(c => c?["name"]?.GetValue())
+ .Where(n => n is not null)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase)!;
+
+ var supplier = meta.Authors is null ? null : new JsonObject { ["name"] = meta.Authors };
+ var target = merged["components"]?.AsArray() ?? (JsonArray)(merged["components"] = new JsonArray());
+ var rootRef = merged["metadata"]?["component"]?["bom-ref"]?.GetValue();
+
+ // The bundled first-party assemblies share the package's licence; cyclonedx-dotnet only puts
+ // it on the root component, so carry it onto them too (same derivation as EnrichRootComponent).
+ var productLicenses = SpdxToLicenses(meta.LicenseExpression ?? meta.LicenseId);
+ if (productLicenses is null && meta.LicenseFile is not null)
+ productLicenses = new JsonArray(new JsonObject
+ { ["license"] = new JsonObject { ["name"] = Path.GetFileName(meta.LicenseFile) } });
+
+ // bom-refs of every shipped binary added below; declared as a complete assembly at the end.
+ var assemblyRefs = new List();
+
+ using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read);
+ using var zip = new ZipArchive(file, ZipArchiveMode.Read);
+ foreach (var entry in zip.Entries)
+ {
+ var path = entry.FullName;
+ // Reference assemblies under ref/ are compile-time surface, not shipped runtime code;
+ // the real implementation lives under lib/ and is scanned there.
+ if (!IsShippedBinary(path) || path.StartsWith("ref/", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var bytes = ReadEntry(entry);
+ var assemblyName = TryReadAssemblyName(bytes, out var assemblyVersion);
+ var simpleName = assemblyName ?? Path.GetFileNameWithoutExtension(path);
+
+ // Third-party binaries already represented by a NuGet/npm component need no duplicate.
+ if (assemblyName is not null && representedNames.Contains(simpleName))
+ continue;
+
+ var isProduct = productNames.Contains(simpleName)
+ || simpleName.StartsWith("Avalonia.", StringComparison.OrdinalIgnoreCase)
+ || simpleName.Equals("Avalonia", StringComparison.OrdinalIgnoreCase);
+
+ // The package's primary assembly is the root component itself - don't list it as its own subcomponent.
+ if (isProduct && simpleName.Equals(finalId, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var version = assemblyVersion ?? meta.Version;
+ var bomRef = $"binary:{simpleName}@{version}";
+ if (!seenComponentKeys.Add(bomRef))
+ continue;
+
+ if (!isProduct)
+ Warning($"SBOM: package '{finalId}' ships '{path}' ({simpleName}) which no restored dependency accounts for - added from package contents, please verify its provenance.");
+
+ var component = new JsonObject
+ {
+ ["type"] = "library",
+ ["bom-ref"] = bomRef,
+ ["name"] = simpleName,
+ ["version"] = version,
+ ["scope"] = "required",
+ ["hashes"] = new JsonArray(new JsonObject
+ {
+ ["alg"] = "SHA-512",
+ ["content"] = Convert.ToHexString(SHA512.HashData(bytes))
+ }),
+ ["properties"] = new JsonArray(new JsonObject
+ {
+ ["name"] = "avalonia:packagePath",
+ ["value"] = path
+ })
+ };
+ if (isProduct)
+ {
+ if (supplier is not null)
+ component["supplier"] = supplier.DeepClone();
+ if (productLicenses is not null)
+ component["licenses"] = productLicenses.DeepClone();
+ }
+ target.Add(component);
+
+ assemblyRefs.Add(bomRef);
+ }
+
+ // Declare the package's assembly: the root component consists of exactly these bundled
+ // binaries. We've read every shipped binary out of the .nupkg, so the enumeration is
+ // complete (third-party dependencies remain in the dependency graph, as they should).
+ if (assemblyRefs.Count > 0)
+ {
+ var assemblies = new JsonArray();
+ if (rootRef is not null)
+ assemblies.Add(rootRef);
+ foreach (var bomRef in assemblyRefs)
+ assemblies.Add(bomRef);
+
+ var compositions = merged["compositions"]?.AsArray()
+ ?? (JsonArray)(merged["compositions"] = new JsonArray());
+ compositions.Add(new JsonObject
+ {
+ ["aggregate"] = "complete",
+ ["assemblies"] = assemblies
+ });
+ }
+ }
+
+ static bool IsShippedBinary(string path)
+ {
+ var ext = Path.GetExtension(path).ToLowerInvariant();
+ return ext is ".dll" or ".so" or ".dylib" or ".wasm" or ".node" or ".a";
+ }
+
+ static byte[] ReadEntry(ZipArchiveEntry entry)
+ {
+ using var stream = entry.Open();
+ using var ms = new MemoryStream();
+ stream.CopyTo(ms);
+ return ms.ToArray();
+ }
+
+ // Returns the managed assembly's simple name (and version), or null for native / non-managed binaries.
+ static string? TryReadAssemblyName(byte[] bytes, out string? version)
+ {
+ version = null;
+ try
+ {
+ using var pe = new PEReader(new MemoryStream(bytes));
+ if (!pe.HasMetadata)
+ return null;
+ var reader = pe.GetMetadataReader();
+ if (!reader.IsAssembly)
+ return null;
+ var assembly = reader.GetAssemblyDefinition();
+ version = assembly.Version.ToString();
+ return reader.GetString(assembly.Name);
+ }
+ catch (BadImageFormatException)
+ {
+ return null;
+ }
+ }
+
+ class NuspecMetadata
+ {
+ public string Id = "";
+ public string Version = "";
+ public string? Authors;
+ public string? LicenseId;
+ public string? LicenseExpression;
+ public string? LicenseFile;
+ public string? ProjectUrl;
+ public string? RepositoryUrl;
+ public string? Description;
+ public string? Copyright;
+ }
+
+ static NuspecMetadata ReadNuspecMetadata(string nupkgPath)
+ {
+ using var file = File.Open(nupkgPath, FileMode.Open, FileAccess.Read);
+ using var zip = new ZipArchive(file, ZipArchiveMode.Read);
+ var nuspecEntry = zip.Entries.First(e => e.FullName.EndsWith(".nuspec", StringComparison.Ordinal) && e.FullName == e.Name);
+ using var nuspecStream = nuspecEntry.Open();
+ var metadata = XDocument.Load(nuspecStream).Root!
+ .Elements().First(x => x.Name.LocalName == "metadata");
+
+ string? Value(string name) => metadata.Elements().FirstOrDefault(x => x.Name.LocalName == name)?.Value;
+ var license = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "license");
+ var repository = metadata.Elements().FirstOrDefault(x => x.Name.LocalName == "repository");
+
+ return new NuspecMetadata
+ {
+ Id = Value("id") ?? "",
+ Version = Value("version") ?? "",
+ Authors = Value("authors"),
+ // A nuspec is either type="expression" (an SPDX expression) or type="file"
+ // (a path to a bundled licence file); only the former is a valid SPDX id/expression.
+ LicenseExpression = license?.Attribute("type")?.Value == "expression" ? license.Value : null,
+ LicenseFile = license?.Attribute("type")?.Value == "file" ? license.Value : null,
+ LicenseId = license?.Attribute("type")?.Value is "expression" or "file" ? null : license?.Value,
+ ProjectUrl = Value("projectUrl"),
+ RepositoryUrl = repository?.Attribute("url")?.Value,
+ Description = Value("description"),
+ Copyright = Value("copyright")
+ };
+ }
+
+ public static string ReadPackageId(string nupkgPath) => ReadNuspecMetadata(nupkgPath).Id;
+}
diff --git a/nukebuild/_build.csproj b/nukebuild/_build.csproj
index c58a2b894b..045f652291 100644
--- a/nukebuild/_build.csproj
+++ b/nukebuild/_build.csproj
@@ -13,13 +13,13 @@
-
-
+
+
diff --git a/packages/Avalonia/AvaloniaBuildTasks.targets b/packages/Avalonia/AvaloniaBuildTasks.targets
index d3750935f9..ec1ef7b46c 100644
--- a/packages/Avalonia/AvaloniaBuildTasks.targets
+++ b/packages/Avalonia/AvaloniaBuildTasks.targets
@@ -39,6 +39,23 @@
Condition="'$(ApplicationIcon)' != ''" />
+
+
+
+
+
+
+ <_AvaloniaResourceXamlOnly Include="@(AvaloniaResource)"
+ Condition="'%(Extension)' == '.xaml' OR '%(Extension)' == '.axaml'"
+ Exclude="@(AvaloniaXaml)" />
+
+
+
+
+ @(AvaloniaResource->'%(FullPath)', ',')
+
+
+
diff --git a/readme.md b/readme.md
index 9ed6c59f90..e291498703 100644
--- a/readme.md
+++ b/readme.md
@@ -10,7 +10,7 @@
Considered by many to be the spiritual successor to WPF, Avalonia UI provides a familiar, modern development experience for XAML developers creating cross-platform applications. While Avalonia UI is [similar to WPF](https://docs.avaloniaui.net/docs/next/get-started/wpf/), it isn't a 1:1 copy, and you'll find plenty of improvements.
-For those seeking a cross-platform WPF, we have created [Avalonia XPF]([https://avaloniaui.net/xpf](https://avaloniaui.net/xpf?utm_source=github&utm_medium=referral&utm_content=readme_link)), enabling WPF applications to run on macOS and Linux with little to no code changes. Avalonia XPF is a commercial product and is licensed per-app, per-platform.
+For those seeking a cross-platform WPF, we have created [Avalonia XPF](https://avaloniaui.net/xpf?utm_source=github&utm_medium=referral&utm_content=readme_link), enabling WPF applications to run on macOS and Linux with little to no code changes. Avalonia XPF is a commercial product and is licensed per-app, per-platform.
### Blog
To see the latest announcements and read about the state of Avalonia, check out the [Avalonia UI Blog](https://www.avaloniaui.net/blog?utm_source=github&utm_medium=referral&utm_content=readme_link).
diff --git a/samples/AppWithoutLifetime/Program.cs b/samples/AppWithoutLifetime/Program.cs
index 60ab32824e..80be3f76bd 100644
--- a/samples/AppWithoutLifetime/Program.cs
+++ b/samples/AppWithoutLifetime/Program.cs
@@ -24,6 +24,8 @@ class Program
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
}
diff --git a/samples/BindingDemo/App.xaml.cs b/samples/BindingDemo/App.xaml.cs
index e76f61ac63..08ae7d183f 100644
--- a/samples/BindingDemo/App.xaml.cs
+++ b/samples/BindingDemo/App.xaml.cs
@@ -24,7 +24,10 @@ namespace BindingDemo
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+ .WithDataAnnotationsValidation()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
}
}
diff --git a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
index a630799c05..978eb361c4 100644
--- a/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
+++ b/samples/ControlCatalog.Android/ControlCatalog.Android.csproj
@@ -14,16 +14,6 @@
-
- True
- True
-
-
-
-
-
-
-
diff --git a/samples/ControlCatalog.Android/environment.device.txt b/samples/ControlCatalog.Android/environment.device.txt
deleted file mode 100644
index 107d68ca1b..0000000000
--- a/samples/ControlCatalog.Android/environment.device.txt
+++ /dev/null
@@ -1 +0,0 @@
-DOTNET_DiagnosticPorts=127.0.0.1:9000,suspend
diff --git a/samples/ControlCatalog.Android/environment.emulator.txt b/samples/ControlCatalog.Android/environment.emulator.txt
deleted file mode 100644
index 299a0ec30b..0000000000
--- a/samples/ControlCatalog.Android/environment.emulator.txt
+++ /dev/null
@@ -1 +0,0 @@
-DOTNET_DiagnosticPorts=10.0.2.2:9001,suspend
diff --git a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj
index 22a091449f..779e9b3462 100644
--- a/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj
+++ b/samples/ControlCatalog.Desktop/ControlCatalog.Desktop.csproj
@@ -21,13 +21,12 @@
+
-
-
diff --git a/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs b/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs
index 523503cc38..e02b1b7428 100644
--- a/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs
+++ b/samples/ControlCatalog.Desktop/NativeControls/Gtk/EmbedSample.Gtk.cs
@@ -21,10 +21,11 @@ public class EmbedSampleGtk : INativeDemoControl
}
var control = createDefault();
- var nodes = Path.GetFullPath(Path.Combine(typeof(EmbedSample).Assembly.GetModules()[0].FullyQualifiedName,
- "..", "NativeControls", "Gtk", "nodes.mp4"));
+ var nodesFile = Path.Combine(AppContext.BaseDirectory, "NativeControls", "Gtk", "nodes.mp4");
+ nodesFile = Path.GetFullPath(nodesFile);
+
_mplayer = Process.Start(new ProcessStartInfo("mplayer",
- $"-vo x11 -zoom -loop 0 -wid {control.Handle.ToInt64()} \"{nodes}\"")
+ $"-vo x11 -zoom -loop 0 -wid {control.Handle.ToInt64()} \"{nodesFile}\"")
{
UseShellExecute = false,
diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs
index b761ce0972..cd8752825a 100644
--- a/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs
+++ b/samples/ControlCatalog.Desktop/NativeControls/Mac/EmbedSample.Mac.cs
@@ -1,29 +1,57 @@
using System;
+using System.Runtime.InteropServices;
+using Avalonia.Controls.Platform;
using Avalonia.Platform;
-using Avalonia.Threading;
-
using ControlCatalog.Pages;
-using MonoMac.Foundation;
-using MonoMac.WebKit;
-
namespace ControlCatalog.Desktop;
public class EmbedSampleMac : INativeDemoControl
{
+ private static bool? s_webKitLoaded;
+
public IPlatformHandle CreateControl(bool isSecond, IPlatformHandle parent, Func createDefault)
{
- // Note: We are using MonoMac for example purposes
- // It shouldn't be used in production apps
- MacHelper.EnsureInitialized();
+ s_webKitLoaded ??= NativeLibrary.TryLoad("/System/Library/Frameworks/WebKit.framework/WebKit", out _);
- var webView = new WebView();
- Dispatcher.UIThread.Post(() =>
+ if (s_webKitLoaded is false)
{
- webView.MainFrame.LoadRequest(new NSUrlRequest(new NSUrl(
- isSecond ? "https://bing.com" : "https://google.com/")));
- });
+ return createDefault();
+ }
+
+ // alloc
+ var webView = ObjC.MsgSend(ObjC.GetClass("WKWebView"), ObjC.GetUid("alloc"));
+ // init
+ webView = ObjC.MsgSend(webView, ObjC.GetUid("init"));
+
+ // ns url
+ var urlNsString = ObjC.CreateString(isSecond ? "https://bing.com" : "https://google.com/");
+ var url = ObjC.MsgSend(
+ ObjC.GetClass("NSURL"), ObjC.GetUid("URLWithString:"),
+ urlNsString);
+
+ // ns url request
+ var request = ObjC.MsgSend(ObjC.GetClass("NSURLRequest"), ObjC.GetUid("requestWithURL:"), url);
+
+ // load request
+ ObjC.MsgSend(webView, ObjC.GetUid("loadRequest:"), request);
+
return new MacOSViewHandle(webView);
}
+
+ private sealed class MacOSViewHandle(IntPtr view) : INativeControlHostDestroyableControlHandle
+ {
+ public IntPtr Handle { get; private set; } = view;
+ public string HandleDescriptor => "NSView";
+
+ public void Destroy()
+ {
+ if (Handle != IntPtr.Zero)
+ {
+ ObjC.MsgSend(Handle, ObjC.GetUid("release"));
+ Handle = IntPtr.Zero;
+ }
+ }
+ }
}
diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs
deleted file mode 100644
index 01325a5fa9..0000000000
--- a/samples/ControlCatalog.Desktop/NativeControls/Mac/MacHelper.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using System;
-
-using Avalonia.Controls.Platform;
-using MonoMac.AppKit;
-
-namespace ControlCatalog.Desktop;
-
-internal class MacHelper
-{
- private static bool _isInitialized;
-
- public static void EnsureInitialized()
- {
- if (_isInitialized)
- return;
- _isInitialized = true;
- NSApplication.Init();
- }
-}
-
-internal class MacOSViewHandle : INativeControlHostDestroyableControlHandle
-{
- private NSView? _view;
-
- public MacOSViewHandle(NSView view)
- {
- _view = view;
- }
-
- public IntPtr Handle => _view?.Handle ?? IntPtr.Zero;
- public string HandleDescriptor => "NSView";
-
- public void Destroy()
- {
- _view?.Dispose();
- _view = null;
- }
-}
diff --git a/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs b/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs
new file mode 100644
index 0000000000..3ef5c77ff7
--- /dev/null
+++ b/samples/ControlCatalog.Desktop/NativeControls/Mac/ObjC.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Runtime.InteropServices;
+using Avalonia.Platform.Interop;
+
+namespace ControlCatalog.Desktop;
+
+internal static partial class ObjC
+{
+ private const string LibObjC = "/usr/lib/libobjc.dylib";
+
+ public static IntPtr CreateString(string value)
+ {
+ using var utf8 = new Utf8Buffer(value);
+ return MsgSend(GetClass("NSString"), GetUid("stringWithUTF8String:"), utf8);
+ }
+
+ [LibraryImport(LibObjC, EntryPoint = "objc_getClass", StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr GetClass(string name);
+
+ [LibraryImport(LibObjC, EntryPoint = "sel_getUid", StringMarshalling = StringMarshalling.Utf8)]
+ internal static partial IntPtr GetUid(string selector);
+
+ [LibraryImport(LibObjC, EntryPoint = "objc_msgSend")]
+ internal static partial IntPtr MsgSend(IntPtr receiver, IntPtr selector);
+
+ [LibraryImport(LibObjC, EntryPoint = "objc_msgSend")]
+ internal static partial IntPtr MsgSend(IntPtr receiver, IntPtr selector, IntPtr arg);
+}
diff --git a/samples/ControlCatalog.Desktop/Program.cs b/samples/ControlCatalog.Desktop/Program.cs
index 819072db18..7cdee5056a 100644
--- a/samples/ControlCatalog.Desktop/Program.cs
+++ b/samples/ControlCatalog.Desktop/Program.cs
@@ -142,6 +142,8 @@ namespace ControlCatalog.Desktop
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+ .WithDataAnnotationsValidation()
+ .UseWaylandWithFallback()
.With(new X11PlatformOptions
{
EnableMultiTouch = true,
@@ -162,7 +164,9 @@ namespace ControlCatalog.Desktop
})
.UseSkia()
.WithInterFont()
+#if DEBUG
.WithDeveloperTools()
+#endif
.AfterSetup(builder =>
{
EmbedSample.Implementation = OperatingSystem.IsWindows() ? new EmbedSampleWin()
diff --git a/samples/ControlCatalog/App.xaml b/samples/ControlCatalog/App.xaml
index 84fbadf525..4a3abb8a30 100644
--- a/samples/ControlCatalog/App.xaml
+++ b/samples/ControlCatalog/App.xaml
@@ -60,6 +60,9 @@
+
diff --git a/samples/ControlCatalog/ControlCatalog.csproj b/samples/ControlCatalog/ControlCatalog.csproj
index c8dc7fc5d6..b826a3ae3e 100644
--- a/samples/ControlCatalog/ControlCatalog.csproj
+++ b/samples/ControlCatalog/ControlCatalog.csproj
@@ -42,12 +42,6 @@
-
-
- MSBuild:Compile
-
-
-
diff --git a/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs b/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs
new file mode 100644
index 0000000000..c5c3e0e2ed
--- /dev/null
+++ b/samples/ControlCatalog/Converter/FlexDemoNumberToThicknessConverter.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Data.Converters;
+
+namespace ControlCatalog.Converter
+{
+ internal sealed class FlexDemoNumberToThicknessConverter : IValueConverter
+ {
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is int x && targetType.IsAssignableFrom(typeof(Thickness)))
+ {
+ var y = 16 + 2 * ((x * 5) % 9);
+ return new Thickness(2 * y, y);
+ }
+
+ throw new NotSupportedException();
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/samples/ControlCatalog/Converter/GdpToBrushConverter.cs b/samples/ControlCatalog/Converter/GdpToBrushConverter.cs
new file mode 100644
index 0000000000..d5d6f5cd07
--- /dev/null
+++ b/samples/ControlCatalog/Converter/GdpToBrushConverter.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+
+namespace ControlCatalog.Converter;
+
+public sealed class GdpToBrushConverter : IValueConverter
+{
+ private readonly ImmutableSolidColorBrush _orangeBrush = new(Colors.Orange, 0.6);
+ private readonly ImmutableSolidColorBrush _yellowBrush = new(Colors.Yellow, 0.6);
+ private readonly ImmutableSolidColorBrush _greenBrush = new(Colors.LightGreen, 0.6);
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is not int gdp)
+ return AvaloniaProperty.UnsetValue;
+
+ return gdp switch
+ {
+ <= 5000 => _orangeBrush,
+ <= 10000 => _yellowBrush,
+ _ => _greenBrush
+ };
+ }
+
+ object IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs b/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs
new file mode 100644
index 0000000000..e9f976f6f3
--- /dev/null
+++ b/samples/ControlCatalog/Converter/TableViewColumnWidthConverter.cs
@@ -0,0 +1,27 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data.Converters;
+
+namespace ControlCatalog.Converter;
+
+public sealed class TableViewColumnWidthConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool useStarSize &&
+ parameter is string stringParameter &&
+ double.TryParse(stringParameter, NumberStyles.Number, CultureInfo.InvariantCulture, out var baseWidth))
+ {
+ return useStarSize ?
+ new GridLength(baseWidth, GridUnitType.Star) :
+ new GridLength(baseWidth * 100, GridUnitType.Pixel);
+ }
+
+ return AvaloniaProperty.UnsetValue;
+ }
+
+ object? IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/samples/ControlCatalog/MainView.xaml b/samples/ControlCatalog/MainView.xaml
index 94f6376896..84f504f4d9 100644
--- a/samples/ControlCatalog/MainView.xaml
+++ b/samples/ControlCatalog/MainView.xaml
@@ -159,4 +159,4 @@
-
+
\ No newline at end of file
diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml
index 4c0118872b..308b29c572 100644
--- a/samples/ControlCatalog/MainWindow.xaml
+++ b/samples/ControlCatalog/MainWindow.xaml
@@ -12,6 +12,8 @@
CanResize="{Binding CanResize}"
CanMinimize="{Binding CanMinimize}"
CanMaximize="{Binding CanMaximize}"
+ WindowDrawnDecorations.TitleBarDecorations="{Binding TitleBarDecorations}"
+ Win32Properties.WindowCornerPreference="{Binding Win32WindowCornerPreference}"
x:Class="ControlCatalog.MainWindow" WindowState="{Binding WindowState, Mode=TwoWay}"
x:DataType="vm:MainWindowViewModel">
diff --git a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs
index 295aa9d139..1624fc563f 100644
--- a/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/AutoCompleteBoxPage.xaml.cs
@@ -102,8 +102,8 @@ namespace ControlCatalog.Pages
return String.Format("{0} ({1})", parts.ToArray());
});
var binding = new MultiBinding { Converter = converter };
- binding.Bindings.Add(new Binding("Name"));
- binding.Bindings.Add(new Binding("Abbreviation"));
+ binding.Bindings.Add(CompiledBinding.Create(s => s.Name));
+ binding.Bindings.Add(CompiledBinding.Create(s => s.Abbreviation));
MultiBindingBox.ValueMemberBinding = binding;
diff --git a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml
index 769dbc0dac..c00af6c801 100644
--- a/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml
+++ b/samples/ControlCatalog/Pages/CalendarDatePickerPage.xaml
@@ -49,6 +49,9 @@
+
+
+
diff --git a/samples/ControlCatalog/Pages/CalendarPage.xaml b/samples/ControlCatalog/Pages/CalendarPage.xaml
index 99727e9124..44ab767dea 100644
--- a/samples/ControlCatalog/Pages/CalendarPage.xaml
+++ b/samples/ControlCatalog/Pages/CalendarPage.xaml
@@ -48,6 +48,17 @@
+
+
+
+
+ #
+
+
+
diff --git a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs
index bc8be3ea87..16f1ba0ddc 100644
--- a/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/CarouselPage/CareCompanionAppPage.xaml.cs
@@ -5,6 +5,7 @@ using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
+using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
@@ -130,7 +131,7 @@ public partial class CareCompanionAppPage : UserControl
};
pager.Bind(PipsPager.SelectedPageIndexProperty,
- new Avalonia.Data.Binding("SelectedIndex") { Source = carousel, Mode = Avalonia.Data.BindingMode.TwoWay });
+ CompiledBinding.Create(c => c.SelectedIndex, carousel, mode: BindingMode.TwoWay));
return pager;
}
diff --git a/samples/ControlCatalog/Pages/CompositionPage.axaml b/samples/ControlCatalog/Pages/CompositionPage.axaml
index a3e9b047a8..0e4fcf2ab6 100644
--- a/samples/ControlCatalog/Pages/CompositionPage.axaml
+++ b/samples/ControlCatalog/Pages/CompositionPage.axaml
@@ -1,61 +1,204 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Resize me
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Precise dirty rects
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Resize me
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Precise dirty rects
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/CompositionPage.axaml.cs b/samples/ControlCatalog/Pages/CompositionPage.axaml.cs
index 8f5d75f4ce..6bcbdf2c7e 100644
--- a/samples/ControlCatalog/Pages/CompositionPage.axaml.cs
+++ b/samples/ControlCatalog/Pages/CompositionPage.axaml.cs
@@ -315,7 +315,7 @@ public partial class CompositionPage : TabbedPage
private void ButtonThreadSleep(object? sender, RoutedEventArgs e)
{
- Thread.Sleep(10000);
+ Thread.Sleep(5000);
}
private void ButtonStartCustomVisual(object? sender, RoutedEventArgs e)
@@ -334,6 +334,183 @@ public partial class CompositionPage : TabbedPage
? CustomVisualHandler.UsePreciseDirtyRects
: CustomVisualHandler.UseNonPreciseDirtyRects);
}
+
+ private void SolidBrushCreateAnimate_Click(object? sender, RoutedEventArgs e)
+ {
+ var visual = ElementComposition.GetElementVisual(SolidBrushHost);
+ if (visual == null)
+ return;
+
+ var compositor = visual.Compositor;
+
+ var brush = SolidBrushHost.Background as CompositionSolidColorBrush
+ ?? compositor.CreateSolidColorBrush();
+
+ brush.Color = Color.FromRgb(168, 213, 85);
+ SolidBrushHost.Background = brush;
+
+ var animation = compositor.CreateColorKeyFrameAnimation();
+ animation.InsertKeyFrame(0f, Color.FromRgb(168, 213, 85));
+ animation.InsertKeyFrame(0.25f, Color.FromRgb(31, 167, 168));
+ animation.InsertKeyFrame(0.75f, Color.FromRgb(44, 121, 251));
+ animation.InsertKeyFrame(1f, Color.FromRgb(168, 213, 85));
+ animation.Duration = TimeSpan.FromSeconds(4);
+ animation.IterationBehavior = AnimationIterationBehavior.Forever;
+ brush.StartAnimation("Color", animation);
+ }
+
+ private void LinearBrushCreateAnimate_Click(object? sender, RoutedEventArgs e)
+ {
+ var visual = ElementComposition.GetElementVisual(LinearBrushHost);
+ if (visual == null)
+ return;
+
+ var compositor = visual.Compositor;
+
+ var brush = LinearBrushHost.Background as CompositionLinearGradientBrush
+ ?? compositor.CreateLinearGradientBrush();
+
+ brush.GradientStops.Clear();
+ brush.GradientStops.Add(compositor.CreateGradientStop(0f, Color.FromRgb(168, 213, 85)));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.33f, Color.FromRgb(31, 167, 168)));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.66f, Color.FromRgb(44, 121, 251)));
+ brush.GradientStops.Add(compositor.CreateGradientStop(1f, Color.FromRgb(168, 213, 85)));
+ brush.StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative);
+ brush.EndPoint = new RelativePoint(1, 0, RelativeUnit.Relative);
+
+ LinearBrushHost.Background = brush;
+
+ var start = compositor.CreateRelativePointKeyFrameAnimation();
+ start.Duration = TimeSpan.FromSeconds(4);
+ start.IterationBehavior = AnimationIterationBehavior.Forever;
+ start.InsertKeyFrame(0f, new RelativePoint(-1, 0, RelativeUnit.Relative));
+ start.InsertKeyFrame(0.5f, new RelativePoint(1, 0, RelativeUnit.Relative));
+ start.InsertKeyFrame(1f, new RelativePoint(-1, 0, RelativeUnit.Relative));
+
+ var end = compositor.CreateRelativePointKeyFrameAnimation();
+ end.Duration = start.Duration;
+ end.IterationBehavior = AnimationIterationBehavior.Forever;
+ end.InsertKeyFrame(0f, new RelativePoint(0, 0, RelativeUnit.Relative));
+ end.InsertKeyFrame(0.5f, new RelativePoint(2, 0, RelativeUnit.Relative));
+ end.InsertKeyFrame(1f, new RelativePoint(0, 0, RelativeUnit.Relative));
+
+ brush.StartAnimation("StartPoint", start);
+ brush.StartAnimation("EndPoint", end);
+
+ AnimateGradientStops(brush);
+ }
+
+ private void RadialBrushCreateAnimate_Click(object? sender, RoutedEventArgs e)
+ {
+ var visual = ElementComposition.GetElementVisual(RadialBrushHost);
+ if (visual == null)
+ return;
+
+ var compositor = visual.Compositor;
+
+ var brush = RadialBrushHost.Background as CompositionRadialGradientBrush
+ ?? compositor.CreateRadialGradientBrush();
+
+ brush.GradientStops.Clear();
+ brush.GradientStops.Add(compositor.CreateGradientStop(0f, Colors.Yellow));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.6f, Colors.Orange));
+ brush.GradientStops.Add(compositor.CreateGradientStop(1f, Colors.Red));
+ brush.Center = RelativePoint.Center;
+ brush.GradientOrigin = RelativePoint.Center;
+
+ RadialBrushHost.Background = brush;
+
+ var centerAnim = compositor.CreateRelativePointKeyFrameAnimation();
+ centerAnim.Duration = TimeSpan.FromSeconds(3);
+ centerAnim.IterationBehavior = AnimationIterationBehavior.Forever;
+ centerAnim.InsertKeyFrame(0f, new RelativePoint(0.2, 0.5, RelativeUnit.Relative));
+ centerAnim.InsertKeyFrame(0.5f, new RelativePoint(0.8, 0.5, RelativeUnit.Relative));
+ centerAnim.InsertKeyFrame(1f, new RelativePoint(0.2, 0.5, RelativeUnit.Relative));
+
+ brush.StartAnimation("Center", centerAnim);
+ var radiusXExpression = compositor.CreateExpressionAnimation(
+ "RelativeScalar(Clamp(0.2 + Abs(this.Target.Center.X - 0.5) * 0.9, 0.2, 0.85), RelativeUnit.Relative)");
+ var radiusYExpression = compositor.CreateExpressionAnimation(
+ "RelativeScalar(Clamp(0.2 + Abs(this.Target.Center.Y - 0.5) * 0.9, 0.2, 0.85), RelativeUnit.Relative)");
+ brush.StartAnimation("RadiusX", radiusXExpression);
+ brush.StartAnimation("RadiusY", radiusYExpression);
+
+ AnimateGradientStops(brush);
+ }
+
+ private void ConicBrushCreateAnimate_Click(object? sender, RoutedEventArgs e)
+ {
+ var visual = ElementComposition.GetElementVisual(ConicBrushHost);
+ if (visual == null)
+ return;
+
+ var compositor = visual.Compositor;
+
+ var brush = ConicBrushHost.Background as CompositionConicGradientBrush
+ ?? compositor.CreateConicGradientBrush();
+
+ brush.GradientStops.Clear();
+ brush.GradientStops.Add(compositor.CreateGradientStop(0f, Colors.Cyan));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.25f, Colors.Magenta));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.5f, Colors.Yellow));
+ brush.GradientStops.Add(compositor.CreateGradientStop(0.75f, Colors.Lime));
+ brush.GradientStops.Add(compositor.CreateGradientStop(1f, Colors.Cyan));
+
+ brush.Center = RelativePoint.Center;
+
+ ConicBrushHost.Background = brush;
+
+ var angleAnim = compositor.CreateScalarKeyFrameAnimation();
+ angleAnim.Duration = TimeSpan.FromSeconds(5);
+ angleAnim.IterationBehavior = AnimationIterationBehavior.Forever;
+ angleAnim.InsertKeyFrame(0f, 0f);
+ angleAnim.InsertKeyFrame(1f, 360f);
+ brush.StartAnimation("Angle", angleAnim);
+ var centerExpression = compositor.CreateExpressionAnimation(
+ "RelativePoint(Abs(Cos(ToRadians(this.Target.Angle))), Abs(Sin(ToRadians(this.Target.Angle))), RelativeUnit.Relative)");
+ brush.StartAnimation("Center", centerExpression);
+
+ AnimateGradientStops(brush, false);
+ }
+
+ private void AnimateGradientStops(CompositionGradientBrush? brush, bool animateOffsets = true)
+ {
+ if (brush == null)
+ return;
+ var compositor = brush.Compositor;
+ foreach (var stop in brush.GradientStops)
+ {
+ if (stop is not CompositionGradientStop gs)
+ continue;
+
+ var colorAnim = compositor.CreateColorKeyFrameAnimation();
+ colorAnim.Duration = TimeSpan.FromSeconds(4);
+ colorAnim.IterationBehavior = AnimationIterationBehavior.Forever;
+ colorAnim.InsertKeyFrame(0f, gs.Color);
+ colorAnim.InsertKeyFrame(0.5f, ShiftColor(gs.Color));
+ colorAnim.InsertKeyFrame(1f, gs.Color);
+ gs.StartAnimation("Color", colorAnim);
+
+ if (!animateOffsets)
+ continue;
+ var offsetAnim = compositor.CreateScalarKeyFrameAnimation();
+ offsetAnim.Duration = TimeSpan.FromSeconds(4);
+ offsetAnim.IterationBehavior = AnimationIterationBehavior.Forever;
+ var o = (float)gs.Offset;
+ var o2 = Math.Clamp(gs.Offset + 0.1, 0, 1);
+ offsetAnim.InsertKeyFrame(0f, o);
+ offsetAnim.InsertKeyFrame(0.5f, (float)o2);
+ offsetAnim.InsertKeyFrame(1f, o);
+ gs.StartAnimation("Offset", offsetAnim);
+ }
+ }
+ private static Color ShiftColor(Color c)
+ {
+ var r = (byte)Math.Min(255, (int)(c.R * 1.15) + 30);
+ var g = (byte)Math.Max(0, (int)(c.G * 0.75) - 10);
+ var b = (byte)Math.Min(255, (int)(c.B * 1.2) + 20);
+ return Color.FromArgb(c.A, r, g, b);
+ }
}
public class CompositionPageColorItem
diff --git a/samples/ControlCatalog/Pages/DataGridPage.xaml b/samples/ControlCatalog/Pages/DataGridPage.xaml
deleted file mode 100644
index 04adf56700..0000000000
--- a/samples/ControlCatalog/Pages/DataGridPage.xaml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/samples/ControlCatalog/Pages/DataGridPage.xaml.cs b/samples/ControlCatalog/Pages/DataGridPage.xaml.cs
deleted file mode 100644
index 1154506e2e..0000000000
--- a/samples/ControlCatalog/Pages/DataGridPage.xaml.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Diagnostics;
-using Avalonia.Controls;
-using Avalonia.Interactivity;
-
-namespace ControlCatalog.Pages;
-
-public partial class DataGridPage : ContentPage
-{
- public DataGridPage()
- {
- InitializeComponent();
- }
-
- private void OnLinkClicked(object? sender, RoutedEventArgs e)
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = "https://github.com/AvaloniaUI/Avalonia.Controls.DataGrid",
- UseShellExecute = true
- });
- }
-}
diff --git a/samples/ControlCatalog/Pages/DragAndDropPage.xaml b/samples/ControlCatalog/Pages/DragAndDropPage.xaml
index 2ca9ed27a0..7300f8c009 100644
--- a/samples/ControlCatalog/Pages/DragAndDropPage.xaml
+++ b/samples/ControlCatalog/Pages/DragAndDropPage.xaml
@@ -44,14 +44,14 @@
MaxWidth="260"
Background="{DynamicResource SystemAccentColorDark1}"
DragDrop.AllowDrop="True">
- Drop some text or files here (Copy)
+ Drop some text, files, bitmap or custom format here (Copy)
- Drop some text or files here (Move)
+ Drop some text or custom format here (Move)
diff --git a/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs b/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs
index b2a91da381..d9fe1af1d8 100644
--- a/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/DragAndDropPage.xaml.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@@ -38,9 +39,11 @@ namespace ControlCatalog.Pages
"Files",
async d =>
{
- if (Assembly.GetEntryAssembly()?.GetModules().FirstOrDefault()?.FullyQualifiedName is { } name &&
+ var currentFile = Environment.ProcessPath;
+
+ if (File.Exists(currentFile) &&
TopLevel.GetTopLevel(this) is { } topLevel &&
- await topLevel.StorageProvider.TryGetFileFromPathAsync(name) is { } storageFile)
+ await topLevel.StorageProvider.TryGetFileFromPathAsync(currentFile) is { } storageFile)
{
d.Add(DataTransferItem.Create(DataFormat.File, storageFile));
}
@@ -51,6 +54,9 @@ namespace ControlCatalog.Pages
"Bitmap",
d => d.Add(DataTransferItem.Create(DataFormat.Bitmap, new Bitmap(AssetLoader.Open(new Uri("avares://ControlCatalog/Assets/image1.jpg"))))),
DragDropEffects.Copy);
+
+ AddHandlers(CopyTarget, DragDropEffects.Copy);
+ AddHandlers(MoveTarget, DragDropEffects.Move);
}
private void SetupDnd(string suffix, Action factory, DragDropEffects effects) =>
@@ -94,16 +100,18 @@ namespace ControlCatalog.Pages
}
}
+ dragMe.PointerPressed += DoDrag;
+ }
+
+ private void AddHandlers(Control target, DragDropEffects allowedEffects)
+ {
+ DragDrop.AddDragEnterHandler(target, DragOver);
+ DragDrop.AddDragOverHandler(target, DragOver);
+ DragDrop.AddDropHandler(target, Drop);
+
void DragOver(object? sender, DragEventArgs e)
{
- if (e.Source is Control c && c.Name == "MoveTarget")
- {
- e.DragEffects = e.DragEffects & (DragDropEffects.Move);
- }
- else
- {
- e.DragEffects = e.DragEffects & (DragDropEffects.Copy);
- }
+ e.DragEffects &= allowedEffects;
// Only allow if the dragged data contains text or filenames.
if (!e.DataTransfer.Contains(DataFormat.Text)
@@ -115,14 +123,10 @@ namespace ControlCatalog.Pages
async void Drop(object? sender, DragEventArgs e)
{
- if (e.Source is Control c && c.Name == "MoveTarget")
- {
- e.DragEffects = e.DragEffects & (DragDropEffects.Move);
- }
- else
- {
- e.DragEffects = e.DragEffects & (DragDropEffects.Copy);
- }
+ e.DragEffects &= allowedEffects;
+
+ if (e.DragEffects == DragDropEffects.None)
+ return;
if (e.DataTransfer.Contains(DataFormat.Text))
{
@@ -165,11 +169,6 @@ namespace ControlCatalog.Pages
DropState.Content = "Custom: " + e.DataTransfer.TryGetValue(_customFormat);
}
}
-
- dragMe.PointerPressed += DoDrag;
-
- AddHandler(DragDrop.DropEvent, Drop);
- AddHandler(DragDrop.DragOverEvent, DragOver);
}
}
}
diff --git a/samples/ControlCatalog/Pages/FlexPage.axaml b/samples/ControlCatalog/Pages/FlexPage.axaml
new file mode 100644
index 0000000000..16596320ec
--- /dev/null
+++ b/samples/ControlCatalog/Pages/FlexPage.axaml
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/FlexPage.axaml.cs b/samples/ControlCatalog/Pages/FlexPage.axaml.cs
new file mode 100644
index 0000000000..397def77ae
--- /dev/null
+++ b/samples/ControlCatalog/Pages/FlexPage.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages
+{
+ public partial class FlexPage : ContentPage
+ {
+ public FlexPage()
+ {
+ InitializeComponent();
+
+ DataContext = new FlexViewModel();
+ }
+
+ private void OnItemTapped(object? sender, RoutedEventArgs e)
+ {
+ if (sender is ListBoxItem control && DataContext is FlexViewModel vm && control.DataContext is FlexItemViewModel item)
+ {
+ if (vm.SelectedItem != null)
+ {
+ vm.SelectedItem.IsSelected = false;
+ }
+
+ if (vm.SelectedItem == item)
+ {
+ vm.SelectedItem = null;
+ }
+ else
+ {
+ vm.SelectedItem = item;
+ vm.SelectedItem.IsSelected = true;
+ }
+ }
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/NotificationsPage.xaml b/samples/ControlCatalog/Pages/NotificationsPage.xaml
index 2ae1db7d43..61d8afdea4 100644
--- a/samples/ControlCatalog/Pages/NotificationsPage.xaml
+++ b/samples/ControlCatalog/Pages/NotificationsPage.xaml
@@ -17,11 +17,7 @@
XAML only notification manager.
+ Content="Show XAML only Notification" Click="ShowNotification" />
diff --git a/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs b/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs
index 5ff67e10dc..cc1ad3481c 100644
--- a/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NotificationsPage.xaml.cs
@@ -1,6 +1,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
+using Avalonia.Interactivity;
using ControlCatalog.ViewModels;
namespace ControlCatalog.Pages
@@ -25,9 +26,14 @@ namespace ControlCatalog.Pages
_viewModel.NotificationManager = new WindowNotificationManager(TopLevel.GetTopLevel(this)!);
}
- public void NotificationOnClick()
+ private void ShowNotification(object? sender, RoutedEventArgs e)
{
- ControlNotifications.Show("Notification clicked");
+ ControlNotifications.Show(new Notification
+ {
+ OnClick = () => ControlNotifications.Show("Notification clicked"),
+ Title = "Title",
+ Message = "Message"
+ });
}
}
}
diff --git a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
index db64fba0ef..0254fbaa55 100644
--- a/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
+++ b/samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs
@@ -71,7 +71,7 @@ namespace ControlCatalog.Pages
if (_spinnerLocations == null)
{
_spinnerLocations = new List();
- foreach (Location value in Enum.GetValues(typeof(Location)))
+ foreach (var value in Enum.GetValues())
{
_spinnerLocations.Add(value);
}
diff --git a/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml
new file mode 100644
index 0000000000..a6213558cb
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs
new file mode 100644
index 0000000000..8f3e6f4b48
--- /dev/null
+++ b/samples/ControlCatalog/Pages/PlatformSettingsPage.xaml.cs
@@ -0,0 +1,30 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.VisualTree;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages
+{
+ public partial class PlatformSettingsPage : ContentPage
+ {
+ private readonly PlatformSettingsViewModel _viewModel = new();
+
+ public PlatformSettingsPage()
+ {
+ InitializeComponent();
+ DataContext = _viewModel;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _viewModel.Subscribe(this.GetPlatformSettings() ?? Application.Current?.PlatformSettings);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ _viewModel.Unsubscribe();
+ base.OnDetachedFromVisualTree(e);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
index 06f54dd28a..5e25d927ba 100644
--- a/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
+++ b/samples/ControlCatalog/Pages/RefreshContainerPage.axaml
@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:ControlCatalog.ViewModels"
+ xmlns:generic="clr-namespace:System.Collections.Generic;assembly=netstandard"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
@@ -12,12 +13,28 @@
-
+
+
+
+ Used pull direction:
+
+
+
+ TopToBottom
+ LeftToRight
+ RightToLeft
+ BottomToTop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/TableViewPage.axaml.cs b/samples/ControlCatalog/Pages/TableViewPage.axaml.cs
new file mode 100644
index 0000000000..af753dd79d
--- /dev/null
+++ b/samples/ControlCatalog/Pages/TableViewPage.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages;
+
+public partial class TableViewPage : ContentPage
+{
+ public TableViewPage()
+ {
+ InitializeComponent();
+ DataContext = new TableViewPageViewModel();
+ }
+}
diff --git a/samples/ControlCatalog/Pages/TextBlockPage.xaml b/samples/ControlCatalog/Pages/TextBlockPage.xaml
index 3418cffe74..5993f65b53 100644
--- a/samples/ControlCatalog/Pages/TextBlockPage.xaml
+++ b/samples/ControlCatalog/Pages/TextBlockPage.xaml
@@ -119,15 +119,20 @@
-
- This is a
- TextBlock
- with several
- Span elements,
-
- using a variety of styles
- .
-
+
+
+ This is a
+ TextBlock
+ with several
+ Span elements,
+
+ using a variety of styles
+ .
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/TextBoxPage.xaml b/samples/ControlCatalog/Pages/TextBoxPage.xaml
index 9b5a412f86..f09095c2fb 100644
--- a/samples/ControlCatalog/Pages/TextBoxPage.xaml
+++ b/samples/ControlCatalog/Pages/TextBoxPage.xaml
@@ -57,9 +57,9 @@
-
-
@@ -80,6 +80,7 @@
Width="200"
Height="125"
LineHeight="32"
+ TextInputOptions.Multiline="True"
Text="Multiline TextBox with TextWrapping and increased LineHeight.
Lorem 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." />
diff --git a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml
index a782dbec23..f304f78898 100644
--- a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml
+++ b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml
@@ -9,28 +9,58 @@
x:DataType="viewModels:MainWindowViewModel"
x:CompileBindings="True"
DataContext="{Binding $parent[Window].DataContext}">
-
+
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -42,13 +72,27 @@
IsChecked="{Binding CanMaximize}"
IsEnabled="{Binding CanResize}" />
+
+
+
+
+
+
+
+
-
-
+
+
-
+
diff --git a/samples/ControlCatalog/Pages/WrapPanelPage.axaml b/samples/ControlCatalog/Pages/WrapPanelPage.axaml
new file mode 100644
index 0000000000..a10d6106f0
--- /dev/null
+++ b/samples/ControlCatalog/Pages/WrapPanelPage.axaml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlCatalog/Pages/WrapPanelPage.axaml.cs b/samples/ControlCatalog/Pages/WrapPanelPage.axaml.cs
new file mode 100644
index 0000000000..802b10b672
--- /dev/null
+++ b/samples/ControlCatalog/Pages/WrapPanelPage.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+using ControlCatalog.ViewModels;
+
+namespace ControlCatalog.Pages;
+
+public partial class WrapPanelPage : ContentPage
+{
+ public WrapPanelPage()
+ {
+ InitializeComponent();
+ DataContext = new WrapPanelPageViewModel();
+ }
+}
diff --git a/samples/ControlCatalog/ScrollPage.xaml b/samples/ControlCatalog/ScrollPage.xaml
index 6d5ecb815e..8c6157018f 100644
--- a/samples/ControlCatalog/ScrollPage.xaml
+++ b/samples/ControlCatalog/ScrollPage.xaml
@@ -15,7 +15,7 @@
HorizontalContentAlignment="Stretch"
IsVisible="False" />
-
()
+ StandardCursors = Enum.GetValues()
.Select(x => new StandardCursorModel(x))
.ToList();
diff --git a/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs b/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs
index 3b2c37699c..a50c9227ee 100644
--- a/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs
+++ b/samples/ControlCatalog/ViewModels/DataValidationViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
+using System.Diagnostics.CodeAnalysis;
using MiniMvvm;
namespace ControlCatalog.ViewModels;
@@ -11,6 +12,7 @@ public class DataValidationViewModel : ViewModelBase
[Required]
[EmailAddress]
+ [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Does not apply to 'string' type.")]
[MinLength(5)]
public string? DataAnnotationsSample
{
diff --git a/samples/ControlCatalog/ViewModels/FlexItemViewModel.cs b/samples/ControlCatalog/ViewModels/FlexItemViewModel.cs
new file mode 100644
index 0000000000..9ffade90a0
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/FlexItemViewModel.cs
@@ -0,0 +1,118 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels
+{
+ public sealed class FlexItemViewModel : ViewModelBase
+ {
+ internal const FlexAlignItems AlignSelfAuto = (FlexAlignItems)(-1);
+
+ private FlexAlignItems _alignSelf;
+
+ private bool _isSelected;
+ private bool _isVisible = true;
+
+ private FlexAlignItems _alignSelfItem = AlignSelfAuto;
+ private int _order;
+ private double _shrink = 1.0;
+ private double _grow;
+ private double _basisValue = 100.0;
+ private FlexBasisKind _basisKind;
+ private HorizontalAlignment _horizontalAlignment;
+ private VerticalAlignment _verticalAlignment;
+
+ public FlexItemViewModel(int value)
+ {
+ Value = value;
+
+ _alignSelf = AlignSelfItem == AlignSelfAuto ? default(FlexAlignItems) : AlignSelfItem;
+
+ var color = Random.Shared.Next();
+
+ Color = new SolidColorBrush((uint)color);
+ }
+
+ public int Value { get; }
+
+ public Brush Color { get; }
+
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set => this.RaiseAndSetIfChanged(ref _isSelected, value);
+ }
+
+ public bool IsVisible
+ {
+ get => _isVisible;
+ set => this.RaiseAndSetIfChanged(ref _isVisible, value);
+ }
+
+ public FlexAlignItems AlignSelfItem
+ {
+ get => _alignSelfItem;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _alignSelfItem, value);
+ this.RaisePropertyChanged(nameof(AlignSelf));
+ }
+ }
+
+ public FlexAlignItems? AlignSelf => _alignSelf;
+
+ public int Order
+ {
+ get => _order;
+ set => this.RaiseAndSetIfChanged(ref _order, value);
+ }
+
+ public double Shrink
+ {
+ get => _shrink;
+ set => this.RaiseAndSetIfChanged(ref _shrink, value);
+ }
+
+ public double Grow
+ {
+ get => _grow;
+ set => this.RaiseAndSetIfChanged(ref _grow, value);
+ }
+
+ public double BasisValue
+ {
+ get => _basisValue;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _basisValue, value);
+ this.RaisePropertyChanged(nameof(Basis));
+ }
+ }
+
+ public FlexBasisKind BasisKind
+ {
+ get => _basisKind;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _basisKind, value);
+ this.RaisePropertyChanged(nameof(Basis));
+ }
+ }
+
+ public FlexBasis Basis => new(_basisValue, _basisKind);
+
+ public HorizontalAlignment HorizontalAlignment
+ {
+ get => _horizontalAlignment;
+ set => this.RaiseAndSetIfChanged(ref _horizontalAlignment, value);
+ }
+
+ public VerticalAlignment VerticalAlignment
+ {
+ get => _verticalAlignment;
+ set => this.RaiseAndSetIfChanged(ref _verticalAlignment, value);
+ }
+ }
+}
diff --git a/samples/ControlCatalog/ViewModels/FlexViewModel.cs b/samples/ControlCatalog/ViewModels/FlexViewModel.cs
new file mode 100644
index 0000000000..abc7143659
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/FlexViewModel.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows.Input;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels
+{
+ public sealed class FlexViewModel : ViewModelBase
+ {
+ private readonly ObservableCollection _numbers;
+
+ private FlexDirection _direction = FlexDirection.Row;
+ private FlexJustifyContent _justifyContent = FlexJustifyContent.FlexStart;
+ private FlexAlignItems _alignItems = FlexAlignItems.FlexStart;
+ private FlexAlignContent _alignContent = FlexAlignContent.FlexStart;
+ private FlexWrap _wrap = FlexWrap.Wrap;
+
+ private int _columnSpacing = 64;
+ private int _rowSpacing = 32;
+
+ private int _currentNumber = 41;
+
+ private FlexItemViewModel? _selectedItem;
+
+ public FlexViewModel()
+ {
+ _numbers = new ObservableCollection(Enumerable.Range(1, 40).Select(x => new FlexItemViewModel(x)));
+
+ Numbers = new ReadOnlyObservableCollection(_numbers);
+
+ AddItemCommand = MiniCommand.Create(AddItem);
+ RemoveItemCommand = MiniCommand.Create(RemoveItem);
+ }
+
+ public IEnumerable DirectionValues { get; } = Enum.GetValues();
+
+ public IEnumerable JustifyContentValues { get; } = Enum.GetValues();
+
+ public IEnumerable AlignItemsValues { get; } = Enum.GetValues();
+
+ public IEnumerable AlignContentValues { get; } = Enum.GetValues();
+
+ public IEnumerable WrapValues { get; } = Enum.GetValues();
+
+ public IEnumerable FlexBasisKindValues { get; } = Enum.GetValues();
+
+ public IEnumerable HorizontalAlignmentValues { get; } = Enum.GetValues();
+
+ public IEnumerable VerticalAlignmentValues { get; } = Enum.GetValues();
+
+ public IEnumerable AlignSelfValues { get; } = Enum.GetValues().Cast().Prepend(FlexItemViewModel.AlignSelfAuto);
+
+ public FlexDirection Direction
+ {
+ get => _direction;
+ set => this.RaiseAndSetIfChanged(ref _direction, value);
+ }
+
+ public FlexJustifyContent JustifyContent
+ {
+ get => _justifyContent;
+ set => this.RaiseAndSetIfChanged(ref _justifyContent, value);
+ }
+
+ public FlexAlignItems AlignItems
+ {
+ get => _alignItems;
+ set => this.RaiseAndSetIfChanged(ref _alignItems, value);
+ }
+
+ public FlexAlignContent AlignContent
+ {
+ get => _alignContent;
+ set => this.RaiseAndSetIfChanged(ref _alignContent, value);
+ }
+
+ public FlexWrap Wrap
+ {
+ get => _wrap;
+ set => this.RaiseAndSetIfChanged(ref _wrap, value);
+ }
+
+ public int ColumnSpacing
+ {
+ get => _columnSpacing;
+ set => this.RaiseAndSetIfChanged(ref _columnSpacing, value);
+ }
+
+ public int RowSpacing
+ {
+ get => _rowSpacing;
+ set => this.RaiseAndSetIfChanged(ref _rowSpacing, value);
+ }
+
+ public ReadOnlyObservableCollection Numbers { get; }
+
+ public FlexItemViewModel? SelectedItem
+ {
+ get => _selectedItem;
+ set => this.RaiseAndSetIfChanged(ref _selectedItem, value);
+ }
+
+ public ICommand AddItemCommand { get; }
+
+ public ICommand RemoveItemCommand { get; }
+
+ private void AddItem() => _numbers.Add(new FlexItemViewModel(_currentNumber++));
+
+ private void RemoveItem()
+ {
+ if (SelectedItem is null)
+ {
+ return;
+ }
+
+ _numbers.Remove(SelectedItem);
+
+ SelectedItem.IsSelected = false;
+ SelectedItem = null;
+ }
+ }
+}
diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs
index 7b9bbac603..d3de1c7f47 100644
--- a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs
+++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs
@@ -1,8 +1,10 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Chrome;
using Avalonia.Dialogs;
using System;
using System.ComponentModel.DataAnnotations;
+using System.Runtime.CompilerServices;
using Avalonia;
using MiniMvvm;
@@ -123,6 +125,55 @@ namespace ControlCatalog.ViewModels
set { RaiseAndSetIfChanged(ref _selectedDecorationIndex, value); }
}
+ public TitleBarDecorations TitleBarDecorations
+ {
+ get;
+ set { RaiseAndSetIfChanged(ref field, value); }
+ } = TitleBarDecorations.All;
+
+ public bool ShowTitle
+ {
+ get => HasTitleBarDecoration(TitleBarDecorations.Title);
+ set => SetTitleBarDecoration(TitleBarDecorations.Title, value);
+ }
+
+ public bool ShowFullScreenButton
+ {
+ get => HasTitleBarDecoration(TitleBarDecorations.FullScreenButton);
+ set => SetTitleBarDecoration(TitleBarDecorations.FullScreenButton, value);
+ }
+
+ public bool ShowMinimizeButton
+ {
+ get => HasTitleBarDecoration(TitleBarDecorations.MinimizeButton);
+ set => SetTitleBarDecoration(TitleBarDecorations.MinimizeButton, value);
+ }
+
+ public bool ShowMaximizeButton
+ {
+ get => HasTitleBarDecoration(TitleBarDecorations.MaximizeButton);
+ set => SetTitleBarDecoration(TitleBarDecorations.MaximizeButton, value);
+ }
+
+ public bool ShowCloseButton
+ {
+ get => HasTitleBarDecoration(TitleBarDecorations.CloseButton);
+ set => SetTitleBarDecoration(TitleBarDecorations.CloseButton, value);
+ }
+
+ private bool HasTitleBarDecoration(TitleBarDecorations decoration)
+ => (TitleBarDecorations & decoration) != 0;
+
+ private void SetTitleBarDecoration(TitleBarDecorations decoration, bool value, [CallerMemberName] string? propertyName = null)
+ {
+ var newDecorations = value ? TitleBarDecorations | decoration : TitleBarDecorations & ~decoration;
+ if (newDecorations == TitleBarDecorations)
+ return;
+
+ TitleBarDecorations = newDecorations;
+ RaisePropertyChanged(propertyName);
+ RaisePropertyChanged(nameof(TitleBarDecorations));
+ }
public MiniCommand AboutCommand { get; }
@@ -141,5 +192,14 @@ namespace ControlCatalog.ViewModels
get => _validatedDateExample;
set => RaiseAndSetIfChanged(ref _validatedDateExample, value);
}
+
+ public Win32Properties.WindowCornerPreference[] Win32WindowCornerPreferences { get; } =
+ Enum.GetValues();
+
+ public Win32Properties.WindowCornerPreference Win32WindowCornerPreference
+ {
+ get;
+ set { RaiseAndSetIfChanged(ref field, value); }
+ }
}
}
diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
index 8142004af8..3f1f024220 100644
--- a/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
+++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel_PageList.cs
@@ -59,6 +59,7 @@ namespace ControlCatalog.ViewModels
new PageItem("Drag+Drop",() => new DragAndDropPage(), Icons.DragDrop, "Drag data within and between applications", "Interaction"),
new PageItem("DrawerPage",() => new DrawerDemoPage(), Icons.Drawer, "A page with a sliding navigation drawer", "Navigation & Pages"),
new PageItem("Expander",() => new ExpanderPage(), Icons.Expand, "A header that expands to reveal content", "Layout"),
+ new PageItem("Flex Panel",() => new FlexPage(), Icons.Grid, "TODO"),
new PageItem("Flyouts",() => new FlyoutsPage(), Icons.Flyout, "Lightweight popups anchored to controls", "Menus & Flyouts"),
new PageItem("Focus",() => new FocusPage(), Icons.Target, "Track and control keyboard focus", "Interaction"),
new PageItem("Gestures",() => new GesturePage(), Icons.Gesture, "Tap, scroll and pinch gesture recognition", "Interaction"),
@@ -74,6 +75,7 @@ namespace ControlCatalog.ViewModels
new PageItem("OpenGL Lease",() => new OpenGlLeasePage(), Icons.Cube3D, "Low-level access to the OpenGL context", "Media & Graphics"),
new PageItem("PipsPager",() => new PipsPagerPage(), Icons.HorizontalDots, "Dot-style pager for paginated content", "Collections & Data"),
new PageItem("Platform Information",() => new PlatformInfoPage(), Icons.Info, "Runtime platform and capability info", "Window & Platform"),
+ new PageItem("Platform Settings",() => new PlatformSettingsPage(), Icons.Tune, "TODO"),
new PageItem("Pointers",() => new PointersPage(), Icons.Cursor, "Raw pointer input and capture", "Interaction"),
new PageItem("ProgressBar",() => new ProgressBarPage(), Icons.Progress, "Determinate and indeterminate progress", "Status & Feedback"),
new PageItem("RadioButton",() => new RadioButtonPage(), Icons.Radio, "Mutually exclusive option groups", "Basic Input"),
@@ -85,6 +87,7 @@ namespace ControlCatalog.ViewModels
new PageItem("TabbedPage",() => new TabbedDemoPage(), Icons.Tab, "Tab-based page navigation", "Navigation & Pages"),
new PageItem("TabControl",() => new TabControlPage(), Icons.Tab, "Switch between tabbed content views", "Navigation & Pages"),
new PageItem("TabStrip",() => new TabStripPage(), Icons.Tab, "A standalone strip of selectable tabs", "Navigation & Pages"),
+ new PageItem("TableView",() => new TableViewPage(), Icons.Grid, "TODO"),
new PageItem("TextBox",() => new TextBoxPage(), Icons.TextInput, "Single- and multi-line text editing", "Text"),
new PageItem("TextBlock",() => new TextBlockPage(), Icons.TextInput, "Styled read-only text display", "Text"),
new PageItem("Theme Variants",() => new ThemePage(), Icons.Theme, "Switch between light and dark variants", "Window & Platform"),
@@ -93,6 +96,7 @@ namespace ControlCatalog.ViewModels
new PageItem("TransitioningContentControl",() => new TransitioningContentControlPage(), Icons.Transition, "Animate between content changes", "Media & Graphics"),
new PageItem("TreeView",() => new TreeViewPage(), Icons.Tree, "Hierarchical data with expandable nodes", "Collections & Data"),
new PageItem("Viewbox",() => new ViewboxPage(), Icons.Viewbox, "Scale content to fit available space", "Layout"),
+ new PageItem("WrapPanel",() => new WrapPanelPage(), Icons.Layout, "TODO"),
new PageItem("Native Embed",() => new NativeEmbedPage(), Icons.Puzzle, "Host native platform controls", "Window & Platform"),
new PageItem("Window Customizations",() => new WindowCustomizationsPage(), Icons.Window, "Custom chrome, decorations and sizing", "Window & Platform"),
new PageItem("HeaderedContentControl",() => new HeaderedContentPage(), Icons.Header, "Content paired with a header", "Layout"),
diff --git a/samples/ControlCatalog/ViewModels/PlatformSettingsViewModel.cs b/samples/ControlCatalog/ViewModels/PlatformSettingsViewModel.cs
new file mode 100644
index 0000000000..070a486f07
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/PlatformSettingsViewModel.cs
@@ -0,0 +1,80 @@
+using System;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Platform;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels;
+
+public class PlatformSettingsViewModel : ViewModelBase
+{
+ private IPlatformSettings? _platformSettings;
+ private PlatformColorValues? _colorValues;
+ private string? _preferredLanguage;
+
+ public void Subscribe(IPlatformSettings? platformSettings)
+ {
+ _platformSettings = platformSettings;
+
+ if (platformSettings is not null)
+ {
+ OnColorValuesChanged(platformSettings, platformSettings.GetColorValues());
+ OnPreferredLanguageChanged(platformSettings, EventArgs.Empty);
+
+ platformSettings?.ColorValuesChanged += OnColorValuesChanged;
+ platformSettings?.PreferredApplicationLanguageChanged += OnPreferredLanguageChanged;
+ }
+ }
+
+ public void Unsubscribe()
+ {
+ if (_platformSettings != null)
+ {
+ _platformSettings.ColorValuesChanged -= OnColorValuesChanged;
+ _platformSettings.PreferredApplicationLanguageChanged -= OnPreferredLanguageChanged;
+ _platformSettings = null;
+ }
+ }
+
+ private void OnColorValuesChanged(object? sender, PlatformColorValues e)
+ {
+ _colorValues = e;
+ RaisePropertyChanged(nameof(ThemeVariant));
+ RaisePropertyChanged(nameof(ContrastPreference));
+ RaisePropertyChanged(nameof(AccentColor1));
+ RaisePropertyChanged(nameof(AccentColor2));
+ RaisePropertyChanged(nameof(AccentColor3));
+ }
+
+ private void OnPreferredLanguageChanged(object? sender, EventArgs e)
+ {
+ _preferredLanguage = ((IPlatformSettings)sender!).PreferredApplicationLanguage;
+ RaisePropertyChanged(nameof(PreferredLanguage));
+ }
+
+ public string PreferredLanguage => _preferredLanguage ?? "Not available";
+
+ public string ThemeVariant => _colorValues?.ThemeVariant.ToString() ?? "Not available";
+
+ public string ContrastPreference => _colorValues?.ContrastPreference.ToString() ?? "Not available";
+
+ public Color AccentColor1 => _colorValues?.AccentColor1 ?? Colors.Gray;
+
+ public Color AccentColor2 => _colorValues?.AccentColor2 ?? Colors.Gray;
+
+ public Color AccentColor3 => _colorValues?.AccentColor3 ?? Colors.Gray;
+
+ public string HoldWaitDuration => _platformSettings?.HoldWaitDuration.ToString() ?? "Not available";
+
+ public string TapSizeTouch => _platformSettings?.GetTapSize(PointerType.Touch).ToString() ?? "Not available";
+
+ public string TapSizeMouse => _platformSettings?.GetTapSize(PointerType.Mouse).ToString() ?? "Not available";
+
+ public string DoubleTapSizeTouch => _platformSettings?.GetDoubleTapSize(PointerType.Touch).ToString() ?? "Not available";
+
+ public string DoubleTapSizeMouse => _platformSettings?.GetDoubleTapSize(PointerType.Mouse).ToString() ?? "Not available";
+
+ public string DoubleTapTimeTouch => _platformSettings?.GetDoubleTapTime(PointerType.Touch).ToString() ?? "Not available";
+
+ public string DoubleTapTimeMouse => _platformSettings?.GetDoubleTapTime(PointerType.Mouse).ToString() ?? "Not available";
+}
diff --git a/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs
index aa15d7758b..d2361178e1 100644
--- a/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs
+++ b/samples/ControlCatalog/ViewModels/RefreshContainerViewModel.cs
@@ -1,8 +1,6 @@
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
-using Avalonia.Controls.Notifications;
-using ControlCatalog.Pages;
using MiniMvvm;
namespace ControlCatalog.ViewModels
diff --git a/samples/ControlCatalog/ViewModels/TableViewPageViewModel.cs b/samples/ControlCatalog/ViewModels/TableViewPageViewModel.cs
new file mode 100644
index 0000000000..1a969ceea2
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/TableViewPageViewModel.cs
@@ -0,0 +1,246 @@
+using System.Collections.ObjectModel;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels;
+
+public sealed class TableViewPageViewModel : ViewModelBase
+{
+ public ObservableCollection Countries { get; } =
+ [
+ new("Afghanistan", "ASIA (EX. NEAR EAST)", 31056997, 647500, 700),
+ new("Albania", "EASTERN EUROPE", 3581655, 28748, 4500),
+ new("Algeria", "NORTHERN AFRICA", 32930091, 2381740, 6000),
+ new("American Samoa", "OCEANIA", 57794, 199, 8000),
+ new("Andorra", "WESTERN EUROPE", 71201, 468, 19000),
+ new("Angola", "SUB-SAHARAN AFRICA", 12127071, 1246700, 1900),
+ new("Anguilla", "LATIN AMER. & CARIB", 13477, 102, 8600),
+ new("Antigua & Barbuda", "LATIN AMER. & CARIB", 69108, 443, 11000),
+ new("Argentina", "LATIN AMER. & CARIB", 39921833, 2766890, 11200),
+ new("Armenia", "C.W. OF IND. STATES", 2976372, 29800, 3500),
+ new("Aruba", "LATIN AMER. & CARIB", 71891, 193, 28000),
+ new("Australia", "OCEANIA", 20264082, 7686850, 29000),
+ new("Austria", "WESTERN EUROPE", 8192880, 83870, 30000),
+ new("Azerbaijan", "C.W. OF IND. STATES", 7961619, 86600, 3400),
+ new("The Bahamas", "LATIN AMER. & CARIB", 303770, 13940, 16700),
+ new("Bahrain", "NEAR EAST", 698585, 665, 16900),
+ new("Bangladesh", "ASIA (EX. NEAR EAST)", 147365352, 144000, 1900),
+ new("Barbados", "LATIN AMER. & CARIB", 279912, 431, 15700),
+ new("Belarus", "C.W. OF IND. STATES", 10293011, 207600, 6100),
+ new("Belgium", "WESTERN EUROPE", 10379067, 30528, 29100),
+ new("Belize", "LATIN AMER. & CARIB", 287730, 22966, 4900),
+ new("Benin", "SUB-SAHARAN AFRICA", 7862944, 112620, 1100),
+ new("Bermuda", "NORTHERN AMERICA", 65773, 53, 36000),
+ new("Bhutan", "ASIA (EX. NEAR EAST)", 2279723, 47000, 1300),
+ new("Bolivia", "LATIN AMER. & CARIB", 8989046, 1098580, 2400),
+ new("Bosnia & Herzegovina", "EASTERN EUROPE", 4498976, 51129, 6100),
+ new("Botswana", "SUB-SAHARAN AFRICA", 1639833, 600370, 9000),
+ new("Brazil", "LATIN AMER. & CARIB", 188078227, 8511965, 7600),
+ new("British Virgin Is.", "LATIN AMER. & CARIB", 23098, 153, 16000),
+ new("Brunei", "ASIA (EX. NEAR EAST)", 379444, 5770, 18600),
+ new("Bulgaria", "EASTERN EUROPE", 7385367, 110910, 7600),
+ new("Burkina Faso", "SUB-SAHARAN AFRICA", 13902972, 274200, 1100),
+ new("Burma", "ASIA (EX. NEAR EAST)", 47382633, 678500, 1800),
+ new("Burundi", "SUB-SAHARAN AFRICA", 8090068, 27830, 600),
+ new("Cambodia", "ASIA (EX. NEAR EAST)", 13881427, 181040, 1900),
+ new("Cameroon", "SUB-SAHARAN AFRICA", 17340702, 475440, 1800),
+ new("Canada", "NORTHERN AMERICA", 33098932, 9984670, 29800),
+ new("Cape Verde", "SUB-SAHARAN AFRICA", 420979, 4033, 1400),
+ new("Cayman Islands", "LATIN AMER. & CARIB", 45436, 262, 35000),
+ new("Central African Rep.", "SUB-SAHARAN AFRICA", 4303356, 622984, 1100),
+ new("Chad", "SUB-SAHARAN AFRICA", 9944201, 1284000, 1200),
+ new("Chile", "LATIN AMER. & CARIB", 16134219, 756950, 9900),
+ new("China", "ASIA (EX. NEAR EAST)", 1313973713, 9596960, 5000),
+ new("Colombia", "LATIN AMER. & CARIB", 43593035, 1138910, 6300),
+ new("Comoros", "SUB-SAHARAN AFRICA", 690948, 2170, 700),
+ new("Congo, Dem.Rep.", "SUB - SAHARAN AFRICA", 62660551, 2345410, 700),
+ new("Congo, Repub.of the", "SUB - SAHARAN AFRICA", 3702314, 342000, 700),
+ new("Cook Islands", "OCEANIA", 21388, 240, 5000),
+ new("Costa Rica", "LATIN AMER. & CARIB", 4075261, 51100, 9100),
+ new("Cote d'Ivoire", "SUB-SAHARAN AFRICA", 17654843, 322460, 1400),
+ new("Croatia", "EASTERN EUROPE", 4494749, 56542, 10600),
+ new("Cuba", "LATIN AMER. & CARIB", 11382820, 110860, 2900),
+ new("Cyprus", "NEAR EAST", 784301, 9250, 19200),
+ new("Czech Republic", "EASTERN EUROPE", 10235455, 78866, 15700),
+ new("Denmark", "WESTERN EUROPE", 5450661, 43094, 31100),
+ new("Djibouti", "SUB-SAHARAN AFRICA", 486530, 23000, 1300),
+ new("Dominica", "LATIN AMER. & CARIB", 68910, 754, 5400),
+ new("Dominican Republic", "LATIN AMER. & CARIB", 9183984, 48730, 6000),
+ new("East Timor", "ASIA (EX. NEAR EAST)", 1062777, 15007, 500),
+ new("Ecuador", "LATIN AMER. & CARIB", 13547510, 283560, 3300),
+ new("Egypt", "NORTHERN AFRICA", 78887007, 1001450, 4000),
+ new("El Salvador", "LATIN AMER. & CARIB", 6822378, 21040, 4800),
+ new("Equatorial Guinea", "SUB-SAHARAN AFRICA", 540109, 28051, 2700),
+ new("Eritrea", "SUB-SAHARAN AFRICA", 4786994, 121320, 700),
+ new("Estonia", "BALTICS", 1324333, 45226, 12300),
+ new("Ethiopia", "SUB-SAHARAN AFRICA", 74777981, 1127127, 700),
+ new("Faroe Islands", "WESTERN EUROPE", 47246, 1399, 22000),
+ new("Fiji", "OCEANIA", 905949, 18270, 5800),
+ new("Finland", "WESTERN EUROPE", 5231372, 338145, 27400),
+ new("France", "WESTERN EUROPE", 60876136, 547030, 27600),
+ new("French Guiana", "LATIN AMER. & CARIB", 199509, 91000, 8300),
+ new("French Polynesia", "OCEANIA", 274578, 4167, 17500),
+ new("Gabon", "SUB-SAHARAN AFRICA", 1424906, 267667, 5500),
+ new("Gambia, The", "SUB - SAHARAN AFRICA", 1641564, 11300, 1700),
+ new("Gaza Strip", "NEAR EAST", 1428757, 360, 600),
+ new("Georgia", "C.W. OF IND. STATES", 4661473, 69700, 2500),
+ new("Germany", "WESTERN EUROPE", 82422299, 357021, 27600),
+ new("Ghana", "SUB-SAHARAN AFRICA", 22409572, 239460, 2200),
+ new("Gibraltar", "WESTERN EUROPE", 27928, 7, 17500),
+ new("Greece", "WESTERN EUROPE", 10688058, 131940, 20000),
+ new("Greenland", "NORTHERN AMERICA", 56361, 2166086, 20000),
+ new("Grenada", "LATIN AMER. & CARIB", 89703, 344, 5000),
+ new("Guadeloupe", "LATIN AMER. & CARIB", 452776, 1780, 8000),
+ new("Guam", "OCEANIA", 171019, 541, 21000),
+ new("Guatemala", "LATIN AMER. & CARIB", 12293545, 108890, 4100),
+ new("Guernsey", "WESTERN EUROPE", 65409, 78, 20000),
+ new("Guinea", "SUB-SAHARAN AFRICA", 9690222, 245857, 2100),
+ new("Guinea-Bissau", "SUB-SAHARAN AFRICA", 1442029, 36120, 800),
+ new("Guyana", "LATIN AMER. & CARIB", 767245, 214970, 4000),
+ new("Haiti", "LATIN AMER. & CARIB", 8308504, 27750, 1600),
+ new("Honduras", "LATIN AMER. & CARIB", 7326496, 112090, 2600),
+ new("Hong Kong", "ASIA (EX. NEAR EAST)", 6940432, 1092, 28800),
+ new("Hungary", "EASTERN EUROPE", 9981334, 93030, 13900),
+ new("Iceland", "WESTERN EUROPE", 299388, 103000, 30900),
+ new("India", "ASIA (EX. NEAR EAST)", 1095351995, 3287590, 2900),
+ new("Indonesia", "ASIA (EX. NEAR EAST)", 245452739, 1919440, 3200),
+ new("Iran", "ASIA (EX. NEAR EAST)", 68688433, 1648000, 7000),
+ new("Iraq", "NEAR EAST", 26783383, 437072, 1500),
+ new("Ireland", "WESTERN EUROPE", 4062235, 70280, 29600),
+ new("Isle of Man", "WESTERN EUROPE", 75441, 572, 21000),
+ new("Israel", "NEAR EAST", 6352117, 20770, 19800),
+ new("Italy", "WESTERN EUROPE", 58133509, 301230, 26700),
+ new("Jamaica", "LATIN AMER. & CARIB", 2758124, 10991, 3900),
+ new("Japan", "ASIA (EX. NEAR EAST)", 127463611, 377835, 28200),
+ new("Jersey", "WESTERN EUROPE", 91084, 116, 24800),
+ new("Jordan", "NEAR EAST", 5906760, 92300, 4300),
+ new("Kazakhstan", "C.W. OF IND. STATES", 15233244, 2717300, 6300),
+ new("Kenya", "SUB-SAHARAN AFRICA", 34707817, 582650, 1000),
+ new("Kiribati", "OCEANIA", 105432, 811, 800),
+ new("North Korea", "ASIA(EX.NEAR EAST)", 23113019, 120540, 1300),
+ new("South Korea", "ASIA(EX.NEAR EAST)", 48846823, 98480, 17800),
+ new("Kuwait", "NEAR EAST", 2418393, 17820, 19000),
+ new("Kyrgyzstan", "C.W. OF IND. STATES", 5213898, 198500, 1600),
+ new("Laos", "ASIA (EX. NEAR EAST)", 6368481, 236800, 1700),
+ new("Latvia", "BALTICS", 2274735, 64589, 10200),
+ new("Lebanon", "NEAR EAST", 3874050, 10400, 4800),
+ new("Lesotho", "SUB-SAHARAN AFRICA", 2022331, 30355, 3000),
+ new("Liberia", "SUB-SAHARAN AFRICA", 3042004, 111370, 1000),
+ new("Libya", "NORTHERN AFRICA", 5900754, 1759540, 6400),
+ new("Liechtenstein", "WESTERN EUROPE", 33987, 160, 25000),
+ new("Lithuania", "BALTICS", 3585906, 65200, 11400),
+ new("Luxembourg", "WESTERN EUROPE", 474413, 2586, 55100),
+ new("Macau", "ASIA (EX. NEAR EAST)", 453125, 28, 19400),
+ new("Macedonia", "EASTERN EUROPE", 2050554, 25333, 6700),
+ new("Madagascar", "SUB-SAHARAN AFRICA", 18595469, 587040, 800),
+ new("Malawi", "SUB-SAHARAN AFRICA", 13013926, 118480, 600),
+ new("Malaysia", "ASIA (EX. NEAR EAST)", 24385858, 329750, 9000),
+ new("Maldives", "ASIA (EX. NEAR EAST)", 359008, 300, 3900),
+ new("Mali", "SUB-SAHARAN AFRICA", 11716829, 1240000, 900),
+ new("Malta", "WESTERN EUROPE", 400214, 316, 17700),
+ new("Marshall Islands", "OCEANIA", 60422, 11854, 1600),
+ new("Martinique", "LATIN AMER. & CARIB", 436131, 1100, 14400),
+ new("Mauritania", "SUB-SAHARAN AFRICA", 3177388, 1030700, 1800),
+ new("Mauritius", "SUB-SAHARAN AFRICA", 1240827, 2040, 11400),
+ new("Mayotte", "SUB-SAHARAN AFRICA", 201234, 374, 2600),
+ new("Mexico", "LATIN AMER. & CARIB", 107449525, 1972550, 9000),
+ new("Micronesia, Fed.St.", "OCEANIA", 108004, 702, 2000),
+ new("Moldova", "C.W. OF IND. STATES", 4466706, 33843, 1800),
+ new("Monaco", "WESTERN EUROPE", 32543, 2, 27000),
+ new("Mongolia", "ASIA (EX. NEAR EAST)", 2832224, 1564116, 1800),
+ new("Montserrat", "LATIN AMER. & CARIB", 9439, 102, 3400),
+ new("Morocco", "NORTHERN AFRICA", 33241259, 446550, 4000),
+ new("Mozambique", "SUB-SAHARAN AFRICA", 19686505, 801590, 1200),
+ new("Namibia", "SUB-SAHARAN AFRICA", 2044147, 825418, 7200),
+ new("Nauru", "OCEANIA", 13287, 21, 5000),
+ new("Nepal", "ASIA (EX. NEAR EAST)", 28287147, 147181, 1400),
+ new("Netherlands", "WESTERN EUROPE", 16491461, 41526, 28600),
+ new("Netherlands Antilles", "LATIN AMER. & CARIB", 221736, 960, 11400),
+ new("New Caledonia", "OCEANIA", 219246, 19060, 15000),
+ new("New Zealand", "OCEANIA", 4076140, 268680, 21600),
+ new("Nicaragua", "LATIN AMER. & CARIB", 5570129, 129494, 2300),
+ new("Niger", "SUB-SAHARAN AFRICA", 12525094, 1267000, 800),
+ new("Nigeria", "SUB-SAHARAN AFRICA", 131859731, 923768, 900),
+ new("N. Mariana Islands", "OCEANIA", 82459, 477, 12500),
+ new("Norway", "WESTERN EUROPE", 4610820, 323802, 37800),
+ new("Oman", "NEAR EAST", 3102229, 212460, 13100),
+ new("Pakistan", "ASIA (EX. NEAR EAST)", 165803560, 803940, 2100),
+ new("Palau", "OCEANIA", 20579, 458, 9000),
+ new("Panama", "LATIN AMER. & CARIB", 3191319, 78200, 6300),
+ new("Papua New Guinea", "OCEANIA", 5670544, 462840, 2200),
+ new("Paraguay", "LATIN AMER. & CARIB", 6506464, 406750, 4700),
+ new("Peru", "LATIN AMER. & CARIB", 28302603, 1285220, 5100),
+ new("Philippines", "ASIA (EX. NEAR EAST)", 89468677, 300000, 4600),
+ new("Poland", "EASTERN EUROPE", 38536869, 312685, 11100),
+ new("Portugal", "WESTERN EUROPE", 10605870, 92391, 18000),
+ new("Puerto Rico", "LATIN AMER. & CARIB", 3927188, 13790, 16800),
+ new("Qatar", "NEAR EAST", 885359, 11437, 21500),
+ new("Reunion", "SUB-SAHARAN AFRICA", 787584, 2517, 5800),
+ new("Romania", "EASTERN EUROPE", 22303552, 237500, 7000),
+ new("Russia", "C.W. OF IND. STATES", 142893540, 17075200, 8900),
+ new("Rwanda", "SUB-SAHARAN AFRICA", 8648248, 26338, 1300),
+ new("Saint Helena", "SUB-SAHARAN AFRICA", 7502, 413, 2500),
+ new("Saint Kitts & Nevis", "LATIN AMER. & CARIB", 39129, 261, 8800),
+ new("Saint Lucia", "LATIN AMER. & CARIB", 168458, 616, 5400),
+ new("St Pierre & Miquelon", "NORTHERN AMERICA", 7026, 242, 6900),
+ new("Saint Vincent and the Grenadines", "LATIN AMER. & CARIB", 117848, 389, 2900),
+ new("Samoa", "OCEANIA", 176908, 2944, 5600),
+ new("San Marino", "WESTERN EUROPE", 29251, 61, 34600),
+ new("Sao Tome & Principe", "SUB-SAHARAN AFRICA", 193413, 1001, 1200),
+ new("Saudi Arabia", "NEAR EAST", 27019731, 1960582, 11800),
+ new("Senegal", "SUB-SAHARAN AFRICA", 11987121, 196190, 1600),
+ new("Serbia", "EASTERN EUROPE", 9396411, 88361, 2200),
+ new("Seychelles", "SUB-SAHARAN AFRICA", 81541, 455, 7800),
+ new("Sierra Leone", "SUB-SAHARAN AFRICA", 6005250, 71740, 500),
+ new("Singapore", "ASIA (EX. NEAR EAST)", 4492150, 693, 23700),
+ new("Slovakia", "EASTERN EUROPE", 5439448, 48845, 13300),
+ new("Slovenia", "EASTERN EUROPE", 2010347, 20273, 19000),
+ new("Solomon Islands", "OCEANIA", 552438, 28450, 1700),
+ new("Somalia", "SUB-SAHARAN AFRICA", 8863338, 637657, 500),
+ new("South Africa", "SUB-SAHARAN AFRICA", 44187637, 1219912, 10700),
+ new("Spain", "WESTERN EUROPE", 40397842, 504782, 22000),
+ new("Sri Lanka", "ASIA (EX. NEAR EAST)", 20222240, 65610, 3700),
+ new("Sudan", "SUB-SAHARAN AFRICA", 41236378, 2505810, 1900),
+ new("Suriname", "LATIN AMER. & CARIB", 439117, 163270, 4000),
+ new("Swaziland", "SUB-SAHARAN AFRICA", 1136334, 17363, 4900),
+ new("Sweden", "WESTERN EUROPE", 9016596, 449964, 26800),
+ new("Switzerland", "WESTERN EUROPE", 7523934, 41290, 32700),
+ new("Syria", "NEAR EAST", 18881361, 185180, 3300),
+ new("Taiwan", "ASIA (EX. NEAR EAST)", 23036087, 35980, 23400),
+ new("Tajikistan", "C.W. OF IND. STATES", 7320815, 143100, 1000),
+ new("Tanzania", "SUB-SAHARAN AFRICA", 37445392, 945087, 600),
+ new("Thailand", "ASIA (EX. NEAR EAST)", 64631595, 514000, 7400),
+ new("Togo", "SUB-SAHARAN AFRICA", 5548702, 56785, 1500),
+ new("Tonga", "OCEANIA", 114689, 748, 2200),
+ new("Trinidad & Tobago", "LATIN AMER. & CARIB", 1065842, 5128, 9500),
+ new("Tunisia", "NORTHERN AFRICA", 10175014, 163610, 6900),
+ new("Turkey", "NEAR EAST", 70413958, 780580, 6700),
+ new("Turkmenistan", "C.W. OF IND. STATES", 5042920, 488100, 5800),
+ new("Turks & Caicos Is", "LATIN AMER. & CARIB", 21152, 430, 9600),
+ new("Tuvalu", "OCEANIA", 11810, 26, 1100),
+ new("Uganda", "SUB-SAHARAN AFRICA", 28195754, 236040, 1400),
+ new("Ukraine", "C.W. OF IND. STATES", 46710816, 603700, 5400),
+ new("United Arab Emirates", "NEAR EAST", 2602713, 82880, 23200),
+ new("United Kingdom", "WESTERN EUROPE", 60609153, 244820, 27700),
+ new("United States", "NORTHERN AMERICA", 298444215, 9631420, 37800),
+ new("Uruguay", "LATIN AMER. & CARIB", 3431932, 176220, 12800),
+ new("Uzbekistan", "C.W. OF IND. STATES", 27307134, 447400, 1700),
+ new("Vanuatu", "OCEANIA", 208869, 12200, 2900),
+ new("Venezuela", "LATIN AMER. & CARIB", 25730435, 912050, 4800),
+ new("Vietnam", "ASIA (EX. NEAR EAST)", 84402966, 329560, 2500),
+ new("Virgin Islands", "LATIN AMER. & CARIB", 108605, 1910, 17200),
+ new("Wallis and Futuna", "OCEANIA", 16025, 274, 3700),
+ new("West Bank", "NEAR EAST", 2460492, 5860, 800),
+ new("Yemen", "NEAR EAST", 21456188, 527970, 800),
+ new("Zambia", "SUB-SAHARAN AFRICA", 11502010, 752614, 800),
+ new("Zimbabwe", "SUB-SAHARAN AFRICA", 12236805, 390580, 1900)
+ ];
+
+ public sealed class Country(string name, string region, int population, int area, int gdp)
+ {
+ public string Name { get; set; } = name;
+ public string Region { get; set; } = region;
+ public int Population { get; set; } = population;
+ public int Area { get; set; } = area; // Square miles
+ public int GDP { get; set; } = gdp;
+ }
+}
diff --git a/samples/ControlCatalog/ViewModels/WrapPanelPageViewModel.cs b/samples/ControlCatalog/ViewModels/WrapPanelPageViewModel.cs
new file mode 100644
index 0000000000..a2b36fcef0
--- /dev/null
+++ b/samples/ControlCatalog/ViewModels/WrapPanelPageViewModel.cs
@@ -0,0 +1,105 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using MiniMvvm;
+
+namespace ControlCatalog.ViewModels;
+
+public class WrapPanelPageViewModel : ViewModelBase
+{
+ public WrapPanelItemsAlignment[] ItemsAlignments { get; } = Enum.GetValues();
+
+ public Orientation[] Orientations { get; } = Enum.GetValues();
+
+ public IReadOnlyList Items { get; } = CreateItems();
+
+ public WrapPanelItemsAlignment ItemsAlignment
+ {
+ get;
+ set => RaiseAndSetIfChanged(ref field, value);
+ } = WrapPanelItemsAlignment.Start;
+
+ public Orientation Orientation
+ {
+ get;
+ set => RaiseAndSetIfChanged(ref field, value);
+ } = Orientation.Horizontal;
+
+ public double ItemSpacing
+ {
+ get;
+ set => RaiseAndSetIfChanged(ref field, value);
+ } = 5;
+
+ public double LineSpacing
+ {
+ get;
+ set => RaiseAndSetIfChanged(ref field, value);
+ } = 5;
+
+ public bool IsItemWidthEnabled
+ {
+ get;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref field, value))
+ RaisePropertyChanged(nameof(EffectiveItemWidth));
+ }
+ }
+
+ public double ItemWidthValue
+ {
+ get;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref field, value))
+ RaisePropertyChanged(nameof(EffectiveItemWidth));
+ }
+ } = 140;
+
+ public bool IsItemHeightEnabled
+ {
+ get;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref field, value))
+ RaisePropertyChanged(nameof(EffectiveItemHeight));
+ }
+ }
+
+ public double ItemHeightValue
+ {
+ get;
+ set
+ {
+ if (RaiseAndSetIfChanged(ref field, value))
+ RaisePropertyChanged(nameof(EffectiveItemHeight));
+ }
+ } = 90;
+
+ public double EffectiveItemWidth
+ => IsItemWidthEnabled ? ItemWidthValue : double.NaN;
+
+ public double EffectiveItemHeight
+ => IsItemHeightEnabled ? ItemHeightValue : double.NaN;
+
+ private static WrapPanelItemViewModel[] CreateItems()
+ {
+ var random = new Random(42);
+ var items = new WrapPanelItemViewModel[50];
+
+ for (var i = 0; i < items.Length; ++i)
+ items[i] = new WrapPanelItemViewModel(i + 1, new Thickness(random.Next(15, 56), random.Next(8, 31)));
+
+ return items;
+ }
+}
+
+public class WrapPanelItemViewModel(int number, Thickness padding)
+{
+ public int Number { get; } = number;
+
+ public Thickness Padding { get; } = padding;
+}
diff --git a/samples/ControlGallery.WinUI/App.xaml b/samples/ControlGallery.WinUI/App.xaml
new file mode 100644
index 0000000000..88b20eca37
--- /dev/null
+++ b/samples/ControlGallery.WinUI/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/App.xaml.cs b/samples/ControlGallery.WinUI/App.xaml.cs
new file mode 100644
index 0000000000..5d26d06383
--- /dev/null
+++ b/samples/ControlGallery.WinUI/App.xaml.cs
@@ -0,0 +1,40 @@
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.ApplicationLifetimes;
+using global::Avalonia.Skia;
+using global::Avalonia.Win32;
+using WinUIApplication = Microsoft.UI.Xaml.Application;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Window = Microsoft.UI.Xaml.Window;
+
+namespace ControlGallery.WinUI
+{
+ public partial class App : WinUIApplication
+ {
+ private Window? _window;
+
+ internal static SingleViewLifetime Lifetime { get; } = new();
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ AppBuilder.Configure()
+ .UseWin32()
+ .UseSkia()
+ .UseHarfBuzz()
+ .SetupWithLifetime(Lifetime);
+
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+
+ internal sealed class SingleViewLifetime : ISingleViewApplicationLifetime
+ {
+ public Control? MainView { get; set; }
+ }
+}
diff --git a/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png b/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png
new file mode 100644
index 0000000000..7440f0d4bf
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/LockScreenLogo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png b/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png
new file mode 100644
index 0000000000..32f486a867
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/SplashScreen.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png
new file mode 100644
index 0000000000..53ee3777ea
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square150x150Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png
new file mode 100644
index 0000000000..f713bba67f
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
new file mode 100644
index 0000000000..dc9f5bea0c
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/StoreLogo.png b/samples/ControlGallery.WinUI/Assets/StoreLogo.png
new file mode 100644
index 0000000000..a4586f26bd
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/StoreLogo.png differ
diff --git a/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png b/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png
new file mode 100644
index 0000000000..8b4a5d0dd5
Binary files /dev/null and b/samples/ControlGallery.WinUI/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj b/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj
new file mode 100644
index 0000000000..49c2e6b8c3
--- /dev/null
+++ b/samples/ControlGallery.WinUI/ControlGallery.WinUI.csproj
@@ -0,0 +1,69 @@
+
+
+ WinExe
+ $(AvsCurrentWinUITargetFramework)
+ 10.0.17763.0
+ ControlGallery.WinUI
+ app.manifest
+ x86;x64;ARM64
+ win-x86;win-x64;win-arm64
+ true
+ false
+ true
+ enable
+ true
+ true
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+ False
+ True
+ False
+ True
+
+
diff --git a/samples/ControlGallery.WinUI/MainWindow.xaml b/samples/ControlGallery.WinUI/MainWindow.xaml
new file mode 100644
index 0000000000..9595ef8253
--- /dev/null
+++ b/samples/ControlGallery.WinUI/MainWindow.xaml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/MainWindow.xaml.cs b/samples/ControlGallery.WinUI/MainWindow.xaml.cs
new file mode 100644
index 0000000000..7f9b40b82e
--- /dev/null
+++ b/samples/ControlGallery.WinUI/MainWindow.xaml.cs
@@ -0,0 +1,11 @@
+namespace ControlGallery.WinUI
+{
+ public sealed partial class MainWindow : Microsoft.UI.Xaml.Window
+ {
+ public MainWindow()
+ {
+ InitializeComponent();
+ AvaloniaPanel.Content = App.Lifetime.MainView;
+ }
+ }
+}
diff --git a/samples/ControlGallery.WinUI/Package.appxmanifest b/samples/ControlGallery.WinUI/Package.appxmanifest
new file mode 100644
index 0000000000..fcd91fc1ac
--- /dev/null
+++ b/samples/ControlGallery.WinUI/Package.appxmanifest
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+ ControlGallery.WinUI
+ drasticactions
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/ControlGallery.WinUI/Properties/launchSettings.json b/samples/ControlGallery.WinUI/Properties/launchSettings.json
new file mode 100644
index 0000000000..ddb3c41a5a
--- /dev/null
+++ b/samples/ControlGallery.WinUI/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "ControlGallery.WinUI (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "ControlGallery.WinUI (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
\ No newline at end of file
diff --git a/samples/ControlGallery.WinUI/app.manifest b/samples/ControlGallery.WinUI/app.manifest
new file mode 100644
index 0000000000..e6d75d93fc
--- /dev/null
+++ b/samples/ControlGallery.WinUI/app.manifest
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
\ No newline at end of file
diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props
index ae7dba55dc..0e3bbf60a0 100644
--- a/samples/Directory.Build.props
+++ b/samples/Directory.Build.props
@@ -8,10 +8,13 @@
14.0
enable
$(NoWarn);CS8002
+ true
+
+
diff --git a/samples/Generators.Sandbox/Assets/Icon.png b/samples/Generators.Sandbox/Assets/Icon.png
new file mode 100644
index 0000000000..41a2a618fb
Binary files /dev/null and b/samples/Generators.Sandbox/Assets/Icon.png differ
diff --git a/samples/Generators.Sandbox/Assets/Strings.xaml b/samples/Generators.Sandbox/Assets/Strings.xaml
new file mode 100644
index 0000000000..b979094031
--- /dev/null
+++ b/samples/Generators.Sandbox/Assets/Strings.xaml
@@ -0,0 +1,6 @@
+
+
+ Welcome to the application!
+
+
diff --git a/samples/Generators.Sandbox/Generators.Sandbox.csproj b/samples/Generators.Sandbox/Generators.Sandbox.csproj
index 2654e1b5b6..22ea309db2 100644
--- a/samples/Generators.Sandbox/Generators.Sandbox.csproj
+++ b/samples/Generators.Sandbox/Generators.Sandbox.csproj
@@ -7,9 +7,11 @@
-
-
-
+
+
+
+
+
diff --git a/samples/GpuInterop/Program.cs b/samples/GpuInterop/Program.cs
index 46cf738304..8bfddfdf5e 100644
--- a/samples/GpuInterop/Program.cs
+++ b/samples/GpuInterop/Program.cs
@@ -34,7 +34,9 @@ namespace GpuInterop
UseDebug = true
}
})
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace(LogEventLevel.Debug, "Vulkan");
}
}
diff --git a/samples/GpuInterop/VulkanDemo/VulkanImage.cs b/samples/GpuInterop/VulkanDemo/VulkanImage.cs
index 216cb3f815..15d2952cfe 100644
--- a/samples/GpuInterop/VulkanDemo/VulkanImage.cs
+++ b/samples/GpuInterop/VulkanDemo/VulkanImage.cs
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
+using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Vulkan;
using Silk.NET.Core.Native;
@@ -355,7 +356,7 @@ public unsafe class VulkanImage : IDisposable
using var b = new Avalonia.Media.Imaging.Bitmap(PixelFormat.Bgra8888,
AlphaFormat.Premul, pSurface, new PixelSize(w, h),
new Vector(96, 96), (int)sstride);
- b.Save(path);
+ b.Save(path, PngBitmapEncoderOptions.Default);
NativeMethods.IOSurfaceUnlock(surf, 0, IntPtr.Zero);
return;
diff --git a/samples/IntegrationTestApp/MainWindow.axaml.cs b/samples/IntegrationTestApp/MainWindow.axaml.cs
index 7d6a2c2c3e..355df243ef 100644
--- a/samples/IntegrationTestApp/MainWindow.axaml.cs
+++ b/samples/IntegrationTestApp/MainWindow.axaml.cs
@@ -86,9 +86,11 @@ namespace IntegrationTestApp
new("DragDrop", () => new DragDropPage()),
new("Embedding", () => new EmbeddingPage()),
new("Gestures", () => new GesturesPage()),
+ new("Keyboard", () => new KeyboardPage()),
new("ListBox", () => new ListBoxPage()),
new("Menu", () => new MenuPage()),
new("Pointer", () => new PointerPage()),
+ new("Popups", () => new PopupsPage()),
new("RadioButton", () => new RadioButtonPage()),
new("Screens", () => new ScreensPage()),
new("ScrollBar", () => new ScrollBarPage()),
diff --git a/samples/IntegrationTestApp/Pages/KeyboardPage.axaml b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml
new file mode 100644
index 0000000000..23dba97f40
--- /dev/null
+++ b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml
@@ -0,0 +1,45 @@
+
+
+
+
+ Key bindings in scope: Space, A, Ctrl+G
+
+
+
+
+
+ Gesture text box content:
+
+
+
+
+ Last key binding:
+
+
+
+ No key bindings in scope, used to inspect raw key downs:
+
+
+
+ Last key down:
+
+
+
+
+ Key down count:
+
+
+
+
+ Last text input:
+
+
+
+
+
+
diff --git a/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs
new file mode 100644
index 0000000000..62b386b40f
--- /dev/null
+++ b/samples/IntegrationTestApp/Pages/KeyboardPage.axaml.cs
@@ -0,0 +1,63 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace IntegrationTestApp.Pages;
+
+public partial class KeyboardPage : UserControl
+{
+ private int _keyDownCount;
+
+ public KeyboardPage()
+ {
+ InitializeComponent();
+
+ // Gestures without a modifier are the interesting case: on macOS the key down used to be
+ // swallowed by the input context while a text input client was active.
+ AddKeyBinding(new KeyGesture(Key.Space), "Space");
+ AddKeyBinding(new KeyGesture(Key.A), "A");
+ AddKeyBinding(new KeyGesture(Key.G, KeyModifiers.Control), "Ctrl+G");
+
+ // TextBox handles TextInput, and KeyDown for keys like Backspace and the arrows, in a
+ // class handler that runs before instance handlers. Subscribe with handledEventsToo so
+ // those keys stay visible here.
+ KeyDownTextBox.AddHandler(KeyDownEvent, KeyDownTextBox_KeyDown, handledEventsToo: true);
+ KeyDownTextBox.AddHandler(TextInputEvent, KeyDownTextBox_TextInput, handledEventsToo: true);
+ }
+
+ private void AddKeyBinding(KeyGesture gesture, string name)
+ {
+ GestureScope.KeyBindings.Add(new KeyBinding
+ {
+ Gesture = gesture,
+ Command = new DelegateCommand(() => LastKeyBinding.Text = name)
+ });
+ }
+
+ private void KeyDownTextBox_KeyDown(object? sender, KeyEventArgs e)
+ {
+ // While an input method is composing, the key is masked as Key.ImeProcessed but the
+ // physical key and the key symbol keep their real values. The key symbol is bracketed
+ // to keep whitespace symbols like the space key's visible and assertable.
+ LastKeyDown.Text = $"{e.Key}|{e.PhysicalKey}|[{e.KeySymbol}]";
+
+ // Counts every key down, including the ones flagsChanged raises for modifier keys.
+ KeyDownCount.Text = (++_keyDownCount).ToString();
+ }
+
+ private void KeyDownTextBox_TextInput(object? sender, TextInputEventArgs e)
+ {
+ LastTextInput.Text = $"[{e.Text}]";
+ }
+
+ private void ResetKeyboard_Click(object? sender, RoutedEventArgs e)
+ {
+ _keyDownCount = 0;
+ KeyDownCount.Text = string.Empty;
+ LastKeyBinding.Text = string.Empty;
+ LastKeyDown.Text = string.Empty;
+ LastTextInput.Text = string.Empty;
+ GestureTextBox.Text = string.Empty;
+ KeyDownTextBox.Text = string.Empty;
+ }
+}
diff --git a/samples/IntegrationTestApp/Pages/PopupsPage.axaml b/samples/IntegrationTestApp/Pages/PopupsPage.axaml
new file mode 100644
index 0000000000..e5c4e7636f
--- /dev/null
+++ b/samples/IntegrationTestApp/Pages/PopupsPage.axaml
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/IntegrationTestApp/Pages/PopupsPage.axaml.cs b/samples/IntegrationTestApp/Pages/PopupsPage.axaml.cs
new file mode 100644
index 0000000000..feca539d90
--- /dev/null
+++ b/samples/IntegrationTestApp/Pages/PopupsPage.axaml.cs
@@ -0,0 +1,48 @@
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Primitives.PopupPositioning;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+
+namespace IntegrationTestApp.Pages;
+
+public partial class PopupsPage : UserControl
+{
+ public PopupsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void ButtonLightDismiss_OnClick(object sender, RoutedEventArgs e)
+ {
+ LightDismissPopup.Open();
+ }
+
+ private void ButtonPopupStaysOpen_OnClick(object sender, RoutedEventArgs e)
+ {
+ StaysOpenPopup.Open();
+ }
+ private void StaysOpenPopupCloseButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ StaysOpenPopup.Close();
+ }
+
+ private void ButtonTopMostPopupStaysOpen(object sender, RoutedEventArgs e)
+ {
+ TopMostPopup.Open();
+ }
+ private void TopMostPopupCloseButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ TopMostPopup.Close();
+ }
+
+ private void OpenRegularNewWindow_Click(object? sender, RoutedEventArgs e)
+ {
+ var newWindow = new ShowWindowTest();
+ newWindow.Show((Window)TopLevel.GetTopLevel(this)!);
+ }
+}
diff --git a/samples/IntegrationTestApp/Pages/ScreensPage.axaml b/samples/IntegrationTestApp/Pages/ScreensPage.axaml
index 7f0b341714..183351b5d6 100644
--- a/samples/IntegrationTestApp/Pages/ScreensPage.axaml
+++ b/samples/IntegrationTestApp/Pages/ScreensPage.axaml
@@ -3,11 +3,15 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+ AttachedToVisualTree="OnAttachedToVisualTree" DetachedFromVisualTree="OnDetachedFromVisualTree"
x:Class="IntegrationTestApp.Pages.ScreensPage">
+
@@ -15,5 +19,6 @@
+
diff --git a/samples/IntegrationTestApp/Pages/ScreensPage.axaml.cs b/samples/IntegrationTestApp/Pages/ScreensPage.axaml.cs
index a9348f86e3..9415b58097 100644
--- a/samples/IntegrationTestApp/Pages/ScreensPage.axaml.cs
+++ b/samples/IntegrationTestApp/Pages/ScreensPage.axaml.cs
@@ -1,3 +1,4 @@
+using System;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
@@ -9,12 +10,37 @@ namespace IntegrationTestApp.Pages;
public partial class ScreensPage : UserControl
{
private Screen? _lastScreen;
+ private int _onScreenChangedCounter;
+
+ private Window? _lastWindow;
public ScreensPage()
{
InitializeComponent();
}
+ private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _lastWindow = TopLevel.GetTopLevel(this) as Window ??
+ throw new AvaloniaInternalException("ScreensPage is not attached to a Window.");
+ _lastWindow.Screens.Changed += OnScreenChanged;
+
+ _onScreenChangedCounter = 0;
+ ScreenOnChangedCounter.Text = _onScreenChangedCounter.ToString();
+ }
+
+ private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
+ {
+ _lastWindow?.Screens.Changed -= OnScreenChanged;
+ _lastWindow = null;
+ }
+
+ private void OnScreenChanged(object? sender, EventArgs e)
+ {
+ _onScreenChangedCounter++;
+ ScreenOnChangedCounter.Text = _onScreenChangedCounter.ToString();
+ }
+
private void ScreenRefresh_Click(object? sender, RoutedEventArgs e)
{
var window = TopLevel.GetTopLevel(this) as Window ??
@@ -29,4 +55,16 @@ public partial class ScreensPage : UserControl
ScreenOrientation.Text = screen?.CurrentOrientation.ToString();
ScreenSameReference.Text = ReferenceEquals(lastScreen, screen).ToString();
}
+
+ private void UpdateViewOnly_OnClick(object? sender, RoutedEventArgs e)
+ {
+ var screen = _lastScreen;
+ ScreenName.Text = screen?.DisplayName;
+ ScreenHandle.Text = screen?.TryGetPlatformHandle()?.ToString();
+ ScreenBounds.Text = screen?.Bounds.ToString();
+ ScreenWorkArea.Text = screen?.WorkingArea.ToString();
+ ScreenScaling.Text = screen?.Scaling.ToString(CultureInfo.InvariantCulture);
+ ScreenOrientation.Text = screen?.CurrentOrientation.ToString();
+ ScreenSameReference.Text = ReferenceEquals(_lastScreen, screen).ToString();
+ }
}
diff --git a/samples/IntegrationTestApp/Program.cs b/samples/IntegrationTestApp/Program.cs
index 29b7557f6a..c2655178cd 100644
--- a/samples/IntegrationTestApp/Program.cs
+++ b/samples/IntegrationTestApp/Program.cs
@@ -31,7 +31,9 @@ namespace IntegrationTestApp
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.AfterSetup(builder =>
{
NativeTextBox.Factory =
diff --git a/samples/RenderDemo/App.xaml.cs b/samples/RenderDemo/App.xaml.cs
index e08c48475b..b1e44a2e96 100644
--- a/samples/RenderDemo/App.xaml.cs
+++ b/samples/RenderDemo/App.xaml.cs
@@ -31,7 +31,9 @@ namespace RenderDemo
OverlayPopups = true,
})
.UsePlatformDetect()
- .WithDeveloperTools()
+#if DEBUG
+ .WithDeveloperTools()
+#endif
.LogToTrace();
}
}
diff --git a/samples/RenderDemo/Controls/ResizePattern.cs b/samples/RenderDemo/Controls/ResizePattern.cs
new file mode 100644
index 0000000000..23a269c09d
--- /dev/null
+++ b/samples/RenderDemo/Controls/ResizePattern.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace RenderDemo.Controls
+{
+ public sealed class ResizePattern : Control
+ {
+ private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.FromRgb(60, 90, 130)), 1);
+ private static readonly IPen DiagonalPen = new Pen(new SolidColorBrush(Color.FromRgb(90, 90, 90)), 1);
+ private static readonly IPen CirclePen = new Pen(Brushes.OrangeRed, 4);
+ private static readonly IPen EdgePen = new Pen(Brushes.Lime, 6);
+ private static readonly IBrush Background = new SolidColorBrush(Color.FromRgb(16, 16, 24));
+ private static readonly IBrush TextBrush = Brushes.White;
+
+ private const double GridStep = 40;
+
+ static ResizePattern()
+ {
+ AffectsRender(BoundsProperty);
+ }
+
+ public override void Render(DrawingContext context)
+ {
+ var w = Bounds.Width;
+ var h = Bounds.Height;
+ if (w <= 0 || h <= 0)
+ return;
+
+ var rect = new Rect(0, 0, w, h);
+ context.FillRectangle(Background, rect);
+
+ // Grid -- uneven spacing on the far edges is the tell-tale of stretching.
+ for (double x = 0; x <= w; x += GridStep)
+ context.DrawLine(GridPen, new Point(x, 0), new Point(x, h));
+ for (double y = 0; y <= h; y += GridStep)
+ context.DrawLine(GridPen, new Point(0, y), new Point(w, y));
+
+ // Corner-to-corner diagonals -- they only meet exactly at the centre
+ // when the aspect ratio is correct.
+ context.DrawLine(DiagonalPen, new Point(0, 0), new Point(w, h));
+ context.DrawLine(DiagonalPen, new Point(w, 0), new Point(0, h));
+
+ // Edge frame -- a stretched drawable makes this peel away from the window edge.
+ context.DrawRectangle(null, EdgePen, rect.Deflate(3));
+
+ // Concentric perfect circles centred in the window.
+ var center = new Point(w / 2, h / 2);
+ var maxRadius = Math.Max(10, Math.Min(w, h) / 2 - 16);
+ for (var i = 1; i <= 3; i++)
+ {
+ var r = maxRadius * i / 3;
+ context.DrawEllipse(null, CirclePen, center, r, r);
+ }
+
+ // Centre crosshair.
+ context.DrawLine(CirclePen, new Point(center.X - 20, center.Y), new Point(center.X + 20, center.Y));
+ context.DrawLine(CirclePen, new Point(center.X, center.Y - 20), new Point(center.X, center.Y + 20));
+
+ // Live size read-out.
+ var text = new FormattedText(
+ string.Create(CultureInfo.InvariantCulture, $"{w:0} x {h:0} DIP"),
+ CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight,
+ Typeface.Default,
+ 22,
+ TextBrush);
+ context.DrawText(text, new Point(12, 8));
+ }
+ }
+}
diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml
index e3afd0c241..e463578709 100644
--- a/samples/RenderDemo/MainWindow.xaml
+++ b/samples/RenderDemo/MainWindow.xaml
@@ -43,12 +43,17 @@
+
+
+
@@ -67,6 +72,9 @@
+
+
+
@@ -97,5 +105,8 @@
+
+
+
diff --git a/samples/RenderDemo/MainWindow.xaml.cs b/samples/RenderDemo/MainWindow.xaml.cs
index a2f3f0eac8..96e21f0d2f 100644
--- a/samples/RenderDemo/MainWindow.xaml.cs
+++ b/samples/RenderDemo/MainWindow.xaml.cs
@@ -1,6 +1,7 @@
using System;
using System.Linq.Expressions;
using Avalonia.Controls;
+using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Rendering;
using RenderDemo.ViewModels;
@@ -37,5 +38,15 @@ namespace RenderDemo
{
AvaloniaXamlLoader.Load(this);
}
+
+ private void SetNotTransparencyMenuItem_OnClick(object? sender, RoutedEventArgs e)
+ {
+ TransparencyLevelHint = [WindowTransparencyLevel.None];
+ }
+
+ private void SetTransparencyMenuItem_OnClick(object? sender, RoutedEventArgs e)
+ {
+ TransparencyLevelHint = [WindowTransparencyLevel.Transparent];
+ }
}
}
diff --git a/samples/RenderDemo/Pages/AnimationSpeedPage.xaml b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml
new file mode 100644
index 0000000000..08e88e53e7
--- /dev/null
+++ b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs
new file mode 100644
index 0000000000..00dce1d055
--- /dev/null
+++ b/samples/RenderDemo/Pages/AnimationSpeedPage.xaml.cs
@@ -0,0 +1,23 @@
+using System.Reactive.Linq;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Markup.Xaml;
+using RenderDemo.ViewModels;
+
+namespace RenderDemo.Pages;
+
+public class AnimationSpeedPage : UserControl
+{
+ public AnimationSpeedPage()
+ {
+ InitializeComponent();
+ this.DataContext = new AnimationSpeedPageViewModel();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml b/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml
new file mode 100644
index 0000000000..f5c7c2cf23
--- /dev/null
+++ b/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml.cs b/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml.cs
new file mode 100644
index 0000000000..854a5957a4
--- /dev/null
+++ b/samples/RenderDemo/Pages/GeometryHitTestingPage.axaml.cs
@@ -0,0 +1,109 @@
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+
+namespace RenderDemo.Pages;
+
+public class GeometryHitTestingPage : UserControl
+{
+ private readonly IBrush _hitStroke = Brushes.Yellow;
+ private readonly TranslateTransform _probePosition = new();
+ private readonly Dictionary _strokes = new();
+ private readonly List<(Shape Shape, IntersectionResult Intersection)> _hits = new();
+ private readonly Geometry _probeGeometry;
+ private readonly Panel _scene;
+ private readonly Path _probe;
+ private readonly TextBlock _status;
+
+ public GeometryHitTestingPage()
+ {
+ InitializeComponent();
+
+ _scene = this.Get("Scene");
+ _probe = this.Get("Probe");
+ _status = this.Get("Status");
+
+ _probeGeometry = CreateProbeGeometry();
+ _probeGeometry.Transform = _probePosition;
+ _probe.Data = _probeGeometry;
+
+ foreach (var shape in _scene.Children.OfType())
+ {
+ if (shape != _probe)
+ _strokes.Add(shape, shape.Stroke);
+ }
+
+ _scene.PointerEntered += SceneOnPointerMoved;
+ _scene.PointerMoved += SceneOnPointerMoved;
+ _scene.PointerExited += SceneOnPointerExited;
+
+ UpdateStatus();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private Geometry CreateProbeGeometry()
+ => new PolylineGeometry([new(0, -42), new(38, 26), new(-38, 26)], true);
+
+ private void SceneOnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ _probe.IsVisible = true;
+ MoveProbe(e.GetPosition(_scene));
+ }
+
+ private void MoveProbe(Point position)
+ {
+ _probePosition.X = position.X;
+ _probePosition.Y = position.Y;
+
+ ClearHits();
+
+ foreach (var result in _scene.GetInputElementsAt(_probeGeometry))
+ {
+ if (result?.VisualHit is Shape shape && _strokes.ContainsKey(shape))
+ {
+ shape.Stroke = _hitStroke;
+ _hits.Add((shape, result.IntersectionResult));
+ }
+ }
+
+ UpdateStatus();
+ }
+
+ private void SceneOnPointerExited(object? sender, PointerEventArgs e)
+ {
+ _probe.IsVisible = false;
+ ClearHits();
+ UpdateStatus();
+ }
+
+ private void ClearHits()
+ {
+ foreach (var hit in _hits)
+ {
+ hit.Shape.Stroke = _strokes[hit.Shape];
+ }
+
+ _hits.Clear();
+ }
+
+ private void UpdateStatus()
+ {
+ if (_hits.Count == 0)
+ {
+ _status.Text = "No intersection";
+ return;
+ }
+
+ _status.Text = "Intersecting, " + string.Join(", ", _hits.Select(hit => $"{hit.Shape.Name} ({hit.Intersection})"));
+ }
+}
+
diff --git a/samples/RenderDemo/Pages/HitTestingPage.cs b/samples/RenderDemo/Pages/HitTestingPage.cs
index f7b2e454b2..621b4dbf1c 100644
--- a/samples/RenderDemo/Pages/HitTestingPage.cs
+++ b/samples/RenderDemo/Pages/HitTestingPage.cs
@@ -1,10 +1,13 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Linq;
using System.Numerics;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering.Composition;
@@ -15,6 +18,7 @@ namespace RenderDemo.Pages
{
public class HitTestingPage : UserControl
{
+ private const int RegionSize = 80;
private const int GroupColumns = 8;
private const int GroupRows = 5;
private const int CellsPerGroupSide = 10;
@@ -44,6 +48,9 @@ namespace RenderDemo.Pages
private bool _animationsStarted;
private Cell? _lastHitCell;
private Cell? _lastClickedCell;
+ private bool _regionSelection;
+ private Ellipse _region;
+ private List _regionHits = new List| ();
public HitTestingPage()
{
@@ -54,6 +61,9 @@ namespace RenderDemo.Pages
Background = Brushes.Transparent
};
+ _scene.PointerMoved += Scene_PointerMoved;
+ _scene.PointerPressed += Scene_PointerPressed;
+
_stats = new TextBlock
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
@@ -65,6 +75,31 @@ namespace RenderDemo.Pages
IsHitTestVisible = false
};
+ var regionSelection = new CheckBox()
+ {
+ Content = "Region Selection"
+ };
+
+ _region = new Ellipse()
+ {
+ Width = RegionSize * 2,
+ Height = RegionSize,
+ Fill = Brushes.Transparent,
+ StrokeThickness = 2,
+ Stroke = Brushes.Green,
+ IsVisible = false,
+ IsHitTestVisible = false
+ };
+
+ regionSelection.IsCheckedChanged += (s, e) =>
+ {
+ _regionSelection = !_regionSelection;
+
+ _region.IsVisible = _regionSelection;
+
+ ClearRegionHighlights();
+ };
+
var numberOfHitTests = new NumericUpDown
{
Minimum = 0,
@@ -92,7 +127,8 @@ namespace RenderDemo.Pages
Children =
{
new TextBlock { Text = "Hit tests per update:" },
- numberOfHitTests
+ numberOfHitTests,
+ regionSelection
}
};
@@ -119,6 +155,57 @@ namespace RenderDemo.Pages
ResetState();
}
+ private void ClearRegionHighlights()
+ {
+ _lastClickedCell?.ClearHighlight();
+ _lastClickedCell = null;
+ foreach (var cell in _regionHits)
+ {
+ cell.ClearHighlight();
+ }
+ _regionHits.Clear();
+ }
+
+ private void Scene_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if(_regionSelection)
+ {
+ var bounds = _region.Bounds;
+
+ ClearRegionHighlights();
+
+ var geometry = new EllipseGeometry(bounds);
+
+ var cells = _scene.GetVisualsAt(geometry);
+
+ if (cells == null)
+ return;
+
+ foreach (var cell in cells)
+ {
+ if(cell?.VisualHit is Cell c)
+ {
+ c.IsLatestClick = true;
+ c.UpdateHighlight();
+ _regionHits.Add(c);
+ }
+ }
+ }
+ }
+
+ private void Scene_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if(_regionSelection)
+ {
+ var point = e.GetPosition(_scene);
+
+ var rect = new Rect(point, new Size()).Inflate(new Thickness(_region.Bounds.Width / 2, _region.Bounds.Height / 2));
+
+ Canvas.SetLeft(_region, rect.X);
+ Canvas.SetTop(_region, rect.Y);
+ }
+ }
+
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@@ -177,6 +264,8 @@ namespace RenderDemo.Pages
}
}
}
+
+ _scene.Children.Add(_region);
}
private void OnCompositionUpdate()
@@ -337,7 +426,7 @@ namespace RenderDemo.Pages
private void OnCellPointerPressed(object? sender, PointerPressedEventArgs e)
{
- if (sender is not Cell cell)
+ if (sender is not Cell cell || _regionSelection)
return;
SetLastClickedCell(cell);
diff --git a/samples/RenderDemo/Pages/ResizePatternPage.xaml b/samples/RenderDemo/Pages/ResizePatternPage.xaml
new file mode 100644
index 0000000000..8e03879b84
--- /dev/null
+++ b/samples/RenderDemo/Pages/ResizePatternPage.xaml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/samples/RenderDemo/Pages/ResizePatternPage.xaml.cs b/samples/RenderDemo/Pages/ResizePatternPage.xaml.cs
new file mode 100644
index 0000000000..5c7f2b7916
--- /dev/null
+++ b/samples/RenderDemo/Pages/ResizePatternPage.xaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace RenderDemo.Pages
+{
+ public class ResizePatternPage : UserControl
+ {
+ public ResizePatternPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+ }
+}
diff --git a/samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs b/samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs
new file mode 100644
index 0000000000..3fd45639da
--- /dev/null
+++ b/samples/RenderDemo/ViewModels/AnimationSpeedPageViewModel.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Collections;
+using Avalonia.Animation;
+using MiniMvvm;
+
+namespace RenderDemo.ViewModels;
+
+public class AnimationSpeedPageViewModel : ViewModelBase
+{
+ private double _speedRatio;
+ public double SpeedRatio
+ {
+ get => _speedRatio;
+ set => RaiseAndSetIfChanged(ref _speedRatio, value);
+ }
+
+ private static readonly PlaybackDirection[] s_playbackDirections = [
+ PlaybackDirection.Normal,
+ PlaybackDirection.Reverse,
+ PlaybackDirection.Alternate,
+ PlaybackDirection.AlternateReverse
+ ];
+ public PlaybackDirection[] PlaybackDirections
+ {
+ get => s_playbackDirections;
+ }
+
+ private PlaybackDirection GetOppositePlaybackDirection(PlaybackDirection value)
+ {
+ switch (value)
+ {
+ case PlaybackDirection.Normal:
+ return PlaybackDirection.Reverse;
+ case PlaybackDirection.Reverse:
+ return PlaybackDirection.Normal;
+ case PlaybackDirection.Alternate:
+ return PlaybackDirection.AlternateReverse;
+ case PlaybackDirection.AlternateReverse:
+ return PlaybackDirection.Alternate;
+ }
+ throw new ArgumentOutOfRangeException();
+ }
+
+ private PlaybackDirection _playbackDirection;
+ public PlaybackDirection PlaybackDirection
+ {
+ get => _playbackDirection;
+ set
+ {
+ _playbackDirection = value;
+ RaisePropertyChanged(nameof(PlaybackDirection));
+ _playbackDirectionOpposite = GetOppositePlaybackDirection(value);
+ RaisePropertyChanged(nameof(PlaybackDirectionOpposite));
+ }
+ }
+
+ private PlaybackDirection _playbackDirectionOpposite;
+ public PlaybackDirection PlaybackDirectionOpposite
+ {
+ get => _playbackDirectionOpposite;
+ set
+ {
+ _playbackDirectionOpposite = value;
+ RaisePropertyChanged(nameof(PlaybackDirectionOpposite));
+ _playbackDirection = GetOppositePlaybackDirection(value);
+ RaisePropertyChanged(nameof(PlaybackDirection));
+ }
+ }
+
+ private TimeSpan _delay;
+ public TimeSpan Delay
+ {
+ get => _delay;
+ set => RaiseAndSetIfChanged(ref _delay, value);
+ }
+
+ public double DelayInput
+ {
+ get => _delay.TotalSeconds;
+ set => Delay = TimeSpan.FromSeconds(value);
+ }
+
+ private TimeSpan _delayIters;
+ public TimeSpan DelayIters
+ {
+ get => _delayIters;
+ set => RaiseAndSetIfChanged(ref _delayIters, value);
+ }
+
+ public double DelayItersInput
+ {
+ get => _delayIters.TotalSeconds;
+ set => DelayIters = TimeSpan.FromSeconds(value);
+ }
+
+ public AnimationSpeedPageViewModel()
+ {
+ SpeedRatio = 1;
+ PlaybackDirection = PlaybackDirection.Normal;
+ DelayInput = 0;
+ DelayItersInput = 0;
+ }
+}
diff --git a/samples/SafeAreaDemo.Desktop/Program.cs b/samples/SafeAreaDemo.Desktop/Program.cs
index 05593b3313..f8d86a8c00 100644
--- a/samples/SafeAreaDemo.Desktop/Program.cs
+++ b/samples/SafeAreaDemo.Desktop/Program.cs
@@ -16,7 +16,9 @@ namespace SafeAreaDemo.Desktop
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
}
}
diff --git a/samples/SafeAreaDemo/App.xaml b/samples/SafeAreaDemo/App.xaml
index f406bfe710..70fd759dad 100644
--- a/samples/SafeAreaDemo/App.xaml
+++ b/samples/SafeAreaDemo/App.xaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:SafeAreaDemo"
x:Class="SafeAreaDemo.App"
- RequestedThemeVariant="Light">
+ RequestedThemeVariant="Default">
diff --git a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs
index 4679948a6a..ef7fd35651 100644
--- a/samples/SafeAreaDemo/ViewModels/MainViewModel.cs
+++ b/samples/SafeAreaDemo/ViewModels/MainViewModel.cs
@@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
+using Avalonia.Styling;
using MiniMvvm;
namespace SafeAreaDemo.ViewModels
@@ -33,7 +34,7 @@ namespace SafeAreaDemo.ViewModels
public Rect InputPaneRect => _inputPane?.OccludedRect ?? default;
public Rect CanvasSize { get; set; }
-
+
public Thickness SafeAreaPadding
{
get
@@ -105,7 +106,7 @@ namespace SafeAreaDemo.ViewModels
set
{
_autoSafeAreaPadding = value;
-
+
RaisePropertyChanged();
RaiseSafeAreaChanged();
}
@@ -159,7 +160,7 @@ namespace SafeAreaDemo.ViewModels
this.RaisePropertyChanged(nameof(ViewPadding));
this.RaisePropertyChanged(nameof(InputPaneMarkerMargin));
}
-
+
private void RaiseKeyboardChanged()
{
this.RaisePropertyChanged(nameof(InputPaneState));
@@ -168,5 +169,29 @@ namespace SafeAreaDemo.ViewModels
this.RaisePropertyChanged(nameof(InputPaneDuration));
this.RaisePropertyChanged(nameof(InputPaneMarkerMargin));
}
+
+ public void SetLight()
+ {
+ if (Application.Current is { } application)
+ {
+ application.RequestedThemeVariant = ThemeVariant.Light;
+ }
+ }
+
+ public void SetDark()
+ {
+ if (Application.Current is { } application)
+ {
+ application.RequestedThemeVariant = ThemeVariant.Dark;
+ }
+ }
+
+ public void SetDefault()
+ {
+ if (Application.Current is { } application)
+ {
+ application.RequestedThemeVariant = ThemeVariant.Default;
+ }
+ }
}
}
diff --git a/samples/SafeAreaDemo/Views/MainView.xaml b/samples/SafeAreaDemo/Views/MainView.xaml
index c03ffc1f39..1a33683ab6 100644
--- a/samples/SafeAreaDemo/Views/MainView.xaml
+++ b/samples/SafeAreaDemo/Views/MainView.xaml
@@ -8,60 +8,78 @@
d:DesignHeight="450"
x:Class="SafeAreaDemo.Views.MainView"
x:DataType="vm:MainViewModel"
- Background="#ccc"
TopLevel.AutoSafeAreaPadding="{Binding AutoSafeAreaPadding, Mode=TwoWay}">
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+ Display Edge To Edge
+ Use Safe Area
+ Automatic Paddings
+ Hide System Bars
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
- Display Edge To Edge
- Use Safe Area
- Automatic Paddings
- Hide System Bars
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/samples/Sandbox/Program.cs b/samples/Sandbox/Program.cs
index b676992111..551d6deb23 100644
--- a/samples/Sandbox/Program.cs
+++ b/samples/Sandbox/Program.cs
@@ -10,7 +10,9 @@ namespace Sandbox
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
}
}
diff --git a/samples/TextTestApp/Program.cs b/samples/TextTestApp/Program.cs
index e3931f97d8..3fc3ee2bcd 100644
--- a/samples/TextTestApp/Program.cs
+++ b/samples/TextTestApp/Program.cs
@@ -23,7 +23,9 @@ namespace TextTestApp
{
return AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
}
}
diff --git a/samples/VirtualizationDemo/Program.cs b/samples/VirtualizationDemo/Program.cs
index dd289b3b25..2bed2ba006 100644
--- a/samples/VirtualizationDemo/Program.cs
+++ b/samples/VirtualizationDemo/Program.cs
@@ -7,7 +7,9 @@ class Program
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()
.UsePlatformDetect()
+#if DEBUG
.WithDeveloperTools()
+#endif
.LogToTrace();
public static int Main(string[] args)
diff --git a/samples/WinUIEmbedSample/App.xaml b/samples/WinUIEmbedSample/App.xaml
new file mode 100644
index 0000000000..0bfa0c7b64
--- /dev/null
+++ b/samples/WinUIEmbedSample/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/App.xaml.cs b/samples/WinUIEmbedSample/App.xaml.cs
new file mode 100644
index 0000000000..6524572edd
--- /dev/null
+++ b/samples/WinUIEmbedSample/App.xaml.cs
@@ -0,0 +1,40 @@
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.ApplicationLifetimes;
+using global::Avalonia.Skia;
+using global::Avalonia.Win32;
+using WinUIApplication = Microsoft.UI.Xaml.Application;
+using LaunchActivatedEventArgs = Microsoft.UI.Xaml.LaunchActivatedEventArgs;
+using Window = Microsoft.UI.Xaml.Window;
+
+namespace WinUIEmbedSample
+{
+ public partial class App : WinUIApplication
+ {
+ private Window? _window;
+
+ internal static SingleViewLifetime Lifetime { get; } = new();
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ AppBuilder.Configure()
+ .UseWin32()
+ .UseSkia()
+ .UseHarfBuzz()
+ .SetupWithLifetime(Lifetime);
+
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+
+ internal sealed class SingleViewLifetime : ISingleViewApplicationLifetime
+ {
+ public Control? MainView { get; set; }
+ }
+}
diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml b/samples/WinUIEmbedSample/AvaloniaApp.axaml
new file mode 100644
index 0000000000..03c11e5146
--- /dev/null
+++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs
new file mode 100644
index 0000000000..854a7bd186
--- /dev/null
+++ b/samples/WinUIEmbedSample/AvaloniaApp.axaml.cs
@@ -0,0 +1,13 @@
+using global::Avalonia;
+using global::Avalonia.Markup.Xaml;
+using AvApplication = global::Avalonia.Application;
+
+namespace WinUIEmbedSample;
+
+public partial class AvaloniaApp : AvApplication
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/samples/WinUIEmbedSample/EmbeddedView.axaml b/samples/WinUIEmbedSample/EmbeddedView.axaml
new file mode 100644
index 0000000000..9cfbd4cb1b
--- /dev/null
+++ b/samples/WinUIEmbedSample/EmbeddedView.axaml
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/EmbeddedView.axaml.cs b/samples/WinUIEmbedSample/EmbeddedView.axaml.cs
new file mode 100644
index 0000000000..0c918b0695
--- /dev/null
+++ b/samples/WinUIEmbedSample/EmbeddedView.axaml.cs
@@ -0,0 +1,183 @@
+using System.Linq;
+using global::Avalonia;
+using global::Avalonia.Controls;
+using global::Avalonia.Controls.Shapes;
+using global::Avalonia.Input;
+using global::Avalonia.Interactivity;
+using global::Avalonia.Markup.Xaml;
+using global::Avalonia.Markup.Xaml.MarkupExtensions;
+using global::Avalonia.Media;
+using global::Avalonia.Platform.Storage;
+
+namespace WinUIEmbedSample;
+
+public partial class EmbeddedView : UserControl
+{
+ private int _clicks;
+ private int _pointCount;
+ private int _intermediateCount;
+
+ public EmbeddedView()
+ {
+ InitializeComponent();
+ AvSlider.PropertyChanged += OnSliderPropertyChanged;
+ HoverPanel.PointerEntered += (_, _) => HoverState.Text = "over";
+ HoverPanel.PointerExited += (_, _) => HoverState.Text = "out";
+ AddHandler(KeyDownEvent, OnKeyReadout, handledEventsToo: true);
+
+ DragDrop.SetAllowDrop(DropTargetBorder, true);
+ DropTargetBorder.AddHandler(DragDrop.DragOverEvent, OnDropDragOver);
+ DropTargetBorder.AddHandler(DragDrop.DropEvent, OnDrop);
+
+ DragSourceBorder.PointerPressed += OnDragSourcePointerPressed;
+ }
+
+ private async void OnClipboardCopy(object? sender, RoutedEventArgs e)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ ClipboardStatus.Text = "no clipboard";
+ return;
+ }
+ var data = new global::Avalonia.Input.DataTransfer();
+ data.Add(DataTransferItem.CreateText(ClipboardText.Text ?? ""));
+ await clipboard.SetDataAsync(data);
+ ClipboardStatus.Text = $"copied {(ClipboardText.Text ?? "").Length} chars";
+ }
+
+ private async void OnClipboardPaste(object? sender, RoutedEventArgs e)
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null)
+ {
+ ClipboardStatus.Text = "no clipboard";
+ return;
+ }
+ using var data = await clipboard.TryGetDataAsync();
+ var text = data is null ? null : await data.TryGetTextAsync();
+ if (text is not null)
+ {
+ ClipboardText.Text = text;
+ ClipboardStatus.Text = $"pasted {text.Length} chars";
+ }
+ else
+ {
+ ClipboardStatus.Text = "no text on clipboard";
+ }
+ }
+
+ private async void OnDragSourcePointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var data = new global::Avalonia.Input.DataTransfer();
+ data.Add(DataTransferItem.CreateText("Hello from Avalonia"));
+ try
+ {
+ await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Copy | DragDropEffects.Move);
+ }
+ catch
+ {
+ // Ignore — drag may be cancelled or the source unavailable.
+ }
+ }
+
+ private void OnDropDragOver(object? sender, DragEventArgs e)
+ {
+ e.DragEffects = e.DragEffects & (DragDropEffects.Copy | DragDropEffects.Link | DragDropEffects.Move);
+ if (e.DragEffects == DragDropEffects.None)
+ e.DragEffects = DragDropEffects.Copy;
+ e.Handled = true;
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ var dt = e.DataTransfer;
+ var files = dt.TryGetValues(DataFormat.File)?.ToArray() ?? System.Array.Empty();
+ var text = dt.TryGetValue(DataFormat.Text);
+
+ if (files.Length > 0)
+ DropStatus.Text = $"{files.Length} file(s):\n" +
+ string.Join('\n', files.Select(f => f.Path.LocalPath));
+ else if (!string.IsNullOrEmpty(text))
+ DropStatus.Text = $"text: {text}";
+ else
+ DropStatus.Text = "(unknown payload)";
+
+ e.Handled = true;
+ }
+
+ private void OnKeyReadout(object? sender, KeyEventArgs e)
+ {
+ var symbol = e.KeySymbol is { Length: > 0 } s ? $"\"{s}\"" : "null";
+ KeyReadout.Text =
+ $"Key={e.Key} Physical={e.PhysicalKey} Symbol={symbol}\n" +
+ $"Modifiers={e.KeyModifiers}";
+ }
+
+ private void OnAvButtonClick(object? sender, RoutedEventArgs e)
+ {
+ AvClickCount.Text = $"Clicked {++_clicks} times";
+ }
+
+ private void OnSliderPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == Slider.ValueProperty)
+ AvSliderValue.Text = $"Slider: {AvSlider.Value:F0}";
+ }
+
+ private void OnInkPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ AddDot(e.GetPosition(InkCanvas), Brushes.Red);
+ _pointCount++;
+ e.Pointer.Capture(InkCanvas);
+ UpdateStats();
+ e.Handled = true;
+ }
+
+ private void OnInkPointerMoved(object? sender, PointerEventArgs e)
+ {
+ var intermediates = e.GetIntermediatePoints(InkCanvas);
+ for (var i = 0; i < intermediates.Count; i++)
+ AddDot(intermediates[i].Position, Brushes.Orange);
+ _intermediateCount += intermediates.Count;
+
+ AddDot(e.GetPosition(InkCanvas), Brushes.Green);
+ _pointCount++;
+ UpdateStats();
+ }
+
+ private void OnInkPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ AddDot(e.GetPosition(InkCanvas), Brushes.Blue);
+ _pointCount++;
+ UpdateStats();
+ e.Handled = true;
+ }
+
+ private void AddDot(Point position, IBrush brush)
+ {
+ const double size = 6;
+ var dot = new Ellipse
+ {
+ Width = size,
+ Height = size,
+ Fill = brush,
+ };
+ Canvas.SetLeft(dot, position.X - size / 2);
+ Canvas.SetTop(dot, position.Y - size / 2);
+ InkCanvas.Children.Add(dot);
+ }
+
+ private void OnClearInk(object? sender, RoutedEventArgs e)
+ {
+ InkCanvas.Children.Clear();
+ _pointCount = 0;
+ _intermediateCount = 0;
+ UpdateStats();
+ }
+
+ private void UpdateStats()
+ {
+ InkStats.Text = $"{_pointCount} points / {_intermediateCount} intermediate";
+ }
+}
diff --git a/samples/WinUIEmbedSample/MainWindow.xaml b/samples/WinUIEmbedSample/MainWindow.xaml
new file mode 100644
index 0000000000..bc5f9daaf2
--- /dev/null
+++ b/samples/WinUIEmbedSample/MainWindow.xaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Light
+ Dark
+ System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/MainWindow.xaml.cs b/samples/WinUIEmbedSample/MainWindow.xaml.cs
new file mode 100644
index 0000000000..9613b2da81
--- /dev/null
+++ b/samples/WinUIEmbedSample/MainWindow.xaml.cs
@@ -0,0 +1,175 @@
+using System;
+using System.Text;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Automation.Peers;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using global::Avalonia.Styling;
+using AvApplication = global::Avalonia.Application;
+
+namespace WinUIEmbedSample
+{
+ public sealed partial class MainWindow : Microsoft.UI.Xaml.Window
+ {
+ private int _clicks;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ App.Lifetime.MainView = new EmbeddedView();
+ AvaloniaPanel.Content = App.Lifetime.MainView;
+
+ // Probe the automation peer *before* the panel is loaded — answers the
+ // open question of whether GetChildren() works before _root.Prepare().
+ AppendDiagnostic("ctor: " + DescribePeer());
+
+ AvaloniaPanel.Loading += (_, _) => AppendDiagnostic("Loading: " + DescribePeer());
+ AvaloniaPanel.Loaded += (_, _) =>
+ {
+ AppendDiagnostic("Loaded: " + DescribePeer());
+ };
+ }
+
+ private string DescribePeer()
+ {
+ try
+ {
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+ if (peer is null)
+ return "peer=null";
+ var children = peer.GetChildren();
+ var count = children?.Count ?? 0;
+ var sb = new StringBuilder();
+ sb.Append("peer=").Append(peer.GetType().Name)
+ .Append(" class=").Append(peer.GetClassName())
+ .Append(" childCount=").Append(count);
+ if (count > 0)
+ {
+ sb.Append(" first=[");
+ var first = children![0];
+ sb.Append(first.GetClassName()).Append('/').Append(first.GetAutomationControlType());
+ sb.Append("] firstChildren=").Append(first.GetChildren()?.Count ?? 0);
+ }
+ return sb.ToString();
+ }
+ catch (Exception ex)
+ {
+ return "threw: " + ex.GetType().Name + ": " + ex.Message;
+ }
+ }
+
+ private void ProbePeerButton_Click(object sender, RoutedEventArgs e)
+ {
+ AppendDiagnostic("Probe: " + DescribePeer());
+ }
+
+ private async void ProbeWorkerButton_Click(object sender, RoutedEventArgs e)
+ {
+ // Cache the peer on the UI thread (CreatePeerForElement is UI-thread bound).
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+
+ // Walk on a worker thread — mimics how UIA queries (it calls from a separate thread).
+ var result = await System.Threading.Tasks.Task.Run(() =>
+ {
+ var sb = new StringBuilder();
+ sb.Append("Worker-thread walk (TID=").Append(Environment.CurrentManagedThreadId).Append("):\n");
+ try { WalkPeer(peer, 0, sb, maxDepth: 20); }
+ catch (Exception ex) { sb.Append("THREW: ").Append(ex); }
+ return sb.ToString();
+ });
+
+ AppendDiagnostic(result);
+ }
+
+ private void ProbeDeepButton_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ var peer = FrameworkElementAutomationPeer.CreatePeerForElement(AvaloniaPanel);
+ var sb = new StringBuilder();
+ sb.Append("Deep walk:\n");
+ WalkPeer(peer, 0, sb, maxDepth: 20);
+ AppendDiagnostic(sb.ToString());
+ }
+ catch (Exception ex)
+ {
+ AppendDiagnostic("Deep probe threw: " + ex.Message);
+ }
+ }
+
+ private static void WalkPeer(AutomationPeer peer, int depth, StringBuilder sb, int maxDepth)
+ {
+ if (peer is null || depth > maxDepth)
+ return;
+
+ for (var i = 0; i < depth; i++) sb.Append(" ");
+ var className = peer.GetClassName();
+ var name = peer.GetName();
+ var type = peer.GetAutomationControlType();
+ var children = peer.GetChildren();
+ sb.Append('[').Append(className).Append(']')
+ .Append(" type=").Append(type)
+ .Append(" name=\"").Append(name).Append('"')
+ .Append(" children=").Append(children?.Count ?? 0)
+ .Append('\n');
+
+ if (children is null) return;
+ foreach (var child in children)
+ WalkPeer(child, depth + 1, sb, maxDepth);
+ }
+
+ private void AppendDiagnostic(string line)
+ {
+ if (DiagnosticsLog is null)
+ return;
+ DiagnosticsLog.Text += "[" + DateTime.Now.ToString("HH:mm:ss.fff") + "] " + line + "\n";
+ }
+
+ private void WinUiButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e)
+ {
+ WinUiClickCount.Text = $"Clicked {++_clicks} times";
+ }
+
+ private void WinUiDragSource_DragStarting(
+ Microsoft.UI.Xaml.UIElement sender,
+ Microsoft.UI.Xaml.DragStartingEventArgs e)
+ {
+ e.Data.SetText("Hello from WinUI");
+ e.AllowedOperations =
+ Windows.ApplicationModel.DataTransfer.DataPackageOperation.Copy |
+ Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
+ }
+
+ private void WinUiSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (WinUiSliderValue is not null)
+ WinUiSliderValue.Text = $"Slider: {e.NewValue:F0}";
+ }
+
+ private void WinUiThemeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var selected = WinUiThemeCombo.SelectedIndex;
+
+ if (Content is FrameworkElement root)
+ {
+ root.RequestedTheme = selected switch
+ {
+ 0 => ElementTheme.Light,
+ 1 => ElementTheme.Dark,
+ _ => ElementTheme.Default
+ };
+ }
+
+ if (AvApplication.Current is { } avApp)
+ {
+ avApp.RequestedThemeVariant = selected switch
+ {
+ 0 => ThemeVariant.Light,
+ 1 => ThemeVariant.Dark,
+ _ => ThemeVariant.Default
+ };
+ }
+ }
+ }
+}
diff --git a/samples/WinUIEmbedSample/Package.appxmanifest b/samples/WinUIEmbedSample/Package.appxmanifest
new file mode 100644
index 0000000000..7601bc9f58
--- /dev/null
+++ b/samples/WinUIEmbedSample/Package.appxmanifest
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+ WinUIEmbedSample
+ AvaloniaUI
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/WinUIEmbedSample/Properties/launchSettings.json b/samples/WinUIEmbedSample/Properties/launchSettings.json
new file mode 100644
index 0000000000..f4df532519
--- /dev/null
+++ b/samples/WinUIEmbedSample/Properties/launchSettings.json
@@ -0,0 +1,10 @@
+{
+ "profiles": {
+ "WinUIEmbedSample (Package)": {
+ "commandName": "MsixPackage"
+ },
+ "WinUIEmbedSample (Unpackaged)": {
+ "commandName": "Project"
+ }
+ }
+}
diff --git a/samples/WinUIEmbedSample/WinUIEmbedSample.csproj b/samples/WinUIEmbedSample/WinUIEmbedSample.csproj
new file mode 100644
index 0000000000..3c7a711cbf
--- /dev/null
+++ b/samples/WinUIEmbedSample/WinUIEmbedSample.csproj
@@ -0,0 +1,68 @@
+
+
+ WinExe
+ $(AvsCurrentWinUITargetFramework)
+ 10.0.17763.0
+ WinUIEmbedSample
+ app.manifest
+ x86;x64;ARM64
+ win-x86;win-x64;win-arm64
+ true
+ false
+ true
+ enable
+ true
+ true
+ true
+ true
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+ False
+ True
+ False
+ True
+
+
diff --git a/samples/WinUIEmbedSample/app.manifest b/samples/WinUIEmbedSample/app.manifest
new file mode 100644
index 0000000000..09e48a29d1
--- /dev/null
+++ b/samples/WinUIEmbedSample/app.manifest
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+
+
+
diff --git a/samples/XEmbedSample/Program.cs b/samples/XEmbedSample/Program.cs
index b8f00bd86d..81035dcf9d 100644
--- a/samples/XEmbedSample/Program.cs
+++ b/samples/XEmbedSample/Program.cs
@@ -17,6 +17,7 @@ class Program
HarfbuzzWorkaround.Apply();
AppBuilder.Configure()
.UseSkia()
+ .UseHarfBuzz()
.With(new X11PlatformOptions()
{
UseGLibMainLoop = true,
diff --git a/samples/XEmbedSample/XEmbedSample.csproj b/samples/XEmbedSample/XEmbedSample.csproj
index 1b51999ada..46388eea83 100644
--- a/samples/XEmbedSample/XEmbedSample.csproj
+++ b/samples/XEmbedSample/XEmbedSample.csproj
@@ -13,6 +13,8 @@
+
+
diff --git a/src/Android/Avalonia.Android/Avalonia.Android.csproj b/src/Android/Avalonia.Android/Avalonia.Android.csproj
index befdcbcd5e..e22990d18c 100644
--- a/src/Android/Avalonia.Android/Avalonia.Android.csproj
+++ b/src/Android/Avalonia.Android/Avalonia.Android.csproj
@@ -12,6 +12,7 @@
+
| |
diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs
index a71e42f894..0bec287fed 100644
--- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs
+++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs
@@ -8,18 +8,55 @@ using Avalonia.Android.Automation;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;
-using Avalonia.Controls;
using Java.Lang;
namespace Avalonia.Android
{
internal class AvaloniaAccessHelper : ExploreByTouchHelper
{
+ private static readonly HashSet s_containerTypes =
+ new HashSet()
+ {
+ AutomationControlType.Calendar,
+ AutomationControlType.ComboBoxItem,
+ AutomationControlType.Custom,
+ AutomationControlType.DataGrid,
+ AutomationControlType.DataItem,
+ AutomationControlType.Document,
+ AutomationControlType.Expander,
+ AutomationControlType.Group,
+ AutomationControlType.List,
+ AutomationControlType.ListItem,
+ AutomationControlType.Menu,
+ AutomationControlType.MenuBar,
+ AutomationControlType.MenuItem,
+ AutomationControlType.None,
+ AutomationControlType.Pane,
+ AutomationControlType.ScrollViewer,
+ AutomationControlType.SplitButton,
+ AutomationControlType.Tab,
+ AutomationControlType.TabItem,
+ AutomationControlType.Table,
+ AutomationControlType.TitleBar,
+ AutomationControlType.ToolBar,
+ AutomationControlType.Tree,
+ AutomationControlType.TreeItem,
+ AutomationControlType.Window,
+ };
+
private readonly Dictionary _peers;
private readonly Dictionary _peerIds;
private readonly Dictionary> _peerNodeInfoProviders;
+ ///
+ /// Virtual view IDs must be allocated from a monotonic counter rather than derived from
+ /// the size of : entries are now removed when their
+ /// owner leaves the visual tree, so the dictionary's count no longer grows monotonically
+ /// and reusing it would hand out an ID that is still in use.
+ ///
+ private int _nextPeerViewId;
+
private readonly AvaloniaView _view;
public AvaloniaAccessHelper(AvaloniaView view) : base(view)
@@ -56,16 +93,16 @@ namespace Avalonia.Android
}
else
{
- peerViewId = _peerNodeInfoProviders.Count;
+ peerViewId = _nextPeerViewId++;
_peers.Add(peerViewId, peer);
_peerIds.Add(peer, peerViewId);
nodeInfoProviders = new();
_peerNodeInfoProviders.Add(peer, nodeInfoProviders);
- peer.ChildrenChanged += (s, ev) => InvalidateVirtualView(peerViewId,
+ EventHandler childrenChanged = (s, ev) => InvalidateVirtualView(peerViewId,
AccessibilityEventCompat.ContentChangeTypeSubtree);
- peer.PropertyChanged += (s, ev) =>
+ EventHandler propertyChanged = (s, ev) =>
{
if (ev.Property == AutomationElementIdentifiers.NameProperty)
{
@@ -75,26 +112,55 @@ namespace Avalonia.Android
{
InvalidateVirtualView(peerViewId, AccessibilityEventCompat.ContentChangeTypeContentDescription);
}
- else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty ||
+ else if (ev.Property == AutomationElementIdentifiers.BoundingRectangleProperty ||
ev.Property == AutomationElementIdentifiers.ClassNameProperty)
{
InvalidateVirtualView(peerViewId);
}
};
- if (peer is IExpandCollapseProvider)
+ peer.ChildrenChanged += childrenChanged;
+ peer.PropertyChanged += propertyChanged;
+
+ // Drop the registration once the peer's control leaves the visual tree, otherwise
+ // every control ever explored by accessibility is kept alive for the lifetime of
+ // the view: the peer holds a strong reference to its Owner, and these three
+ // dictionaries were never pruned. On a long-running app that rebuilds its UI (for
+ // instance digital signage swapping screens), this retains each dead visual tree in
+ // full — measured at ~2.5 MB per rebuild on a real device.
+ // The root peer (ID 0) is deliberately never unregistered: GetVirtualViewAt and
+ // GetVisibleVirtualViews index _peers[0] directly, so removing it would throw.
+ // It is a single entry owned by the view itself, and dies with the helper.
+ if (peerViewId != 0 && peer is ControlAutomationPeer controlPeer)
+ {
+ EventHandler? detachedFromVisualTree = null;
+ detachedFromVisualTree = (s, ev) =>
+ {
+ controlPeer.Owner.DetachedFromVisualTree -= detachedFromVisualTree;
+ peer.ChildrenChanged -= childrenChanged;
+ peer.PropertyChanged -= propertyChanged;
+
+ _peers.Remove(peerViewId);
+ _peerIds.Remove(peer);
+ _peerNodeInfoProviders.Remove(peer);
+ };
+
+ controlPeer.Owner.DetachedFromVisualTree += detachedFromVisualTree;
+ }
+
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new ExpandCollapseNodeInfoProvider(this, peer, peerViewId));
- if (peer is IInvokeProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new InvokeNodeInfoProvider(this, peer, peerViewId));
- if (peer is IRangeValueProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new RangeValueNodeInfoProvider(this, peer, peerViewId));
- if (peer is IScrollProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new ScrollNodeInfoProvider(this, peer, peerViewId));
- if (peer is ISelectionItemProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new SelectionItemNodeInfoProvider(this, peer, peerViewId));
- if (peer is IToggleProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new ToggleNodeInfoProvider(this, peer, peerViewId));
- if (peer is IValueProvider)
+ if (peer.GetProvider() is not null)
nodeInfoProviders.Add(new ValueNodeInfoProvider(this, peer, peerViewId));
}
@@ -109,7 +175,17 @@ namespace Avalonia.Android
AutomationPeer? peer = embeddedRootProvider?.GetPeerFromPoint(p);
if (peer is not null)
{
- GetOrCreateNodeInfoProvidersFromPeer(peer, out int virtualViewId);
+ int virtualViewId;
+ if (peer.GetParent() is AutomationPeer parent &&
+ !s_containerTypes.Contains(parent.GetAutomationControlType()))
+ {
+ GetOrCreateNodeInfoProvidersFromPeer(parent, out virtualViewId);
+ }
+ else
+ {
+ GetOrCreateNodeInfoProvidersFromPeer(peer, out virtualViewId);
+ }
+
return virtualViewId == 0 ? InvalidId : virtualViewId;
}
else
@@ -193,13 +269,15 @@ namespace Avalonia.Android
// UI debug metadata
nodeInfo.ClassName = peer.GetClassName();
- nodeInfo.UniqueId = peer.GetAutomationId();
+ var automationId = peer.GetAutomationId();
+ nodeInfo.UniqueId = automationId;
+ nodeInfo.ViewIdResourceName = automationId;
// Common control state
nodeInfo.Enabled = peer.IsEnabled();
// Control focus state
- bool canFocusAtAll = peer.IsContentElement() && !peer.IsOffscreen();
+ bool canFocusAtAll = peer.IsControlElement() && !peer.IsOffscreen();
nodeInfo.ScreenReaderFocusable = canFocusAtAll;
nodeInfo.Focusable = canFocusAtAll && peer.IsKeyboardFocusable();
diff --git a/src/Android/Avalonia.Android/AvaloniaView.Input.cs b/src/Android/Avalonia.Android/AvaloniaView.Input.cs
index c829be56be..dcf368c19f 100644
--- a/src/Android/Avalonia.Android/AvaloniaView.Input.cs
+++ b/src/Android/Avalonia.Android/AvaloniaView.Input.cs
@@ -22,12 +22,17 @@ namespace Avalonia.Android
protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect)
{
base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect);
- _accessHelper.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect);
+ _accessHelper?.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect);
}
protected override bool DispatchHoverEvent(MotionEvent? e)
{
- return _accessHelper.DispatchHoverEvent(e!) || base.DispatchHoverEvent(e);
+ var res = _view.PointerHelper.DispatchMotionEvent(e, out var callBase);
+ callBase = (_accessHelper?.DispatchHoverEvent(e!) == true) && callBase;
+
+ var baseResult = callBase && base.DispatchHoverEvent(e);
+
+ return res ?? baseResult;
}
protected override bool DispatchGenericPointerEvent(MotionEvent? e)
@@ -44,7 +49,7 @@ namespace Avalonia.Android
var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase);
var baseResult = callBase && base.DispatchTouchEvent(e);
- if(result == true)
+ if (result == true)
{
// Request focus for this view
RequestFocus();
@@ -57,7 +62,7 @@ namespace Avalonia.Android
{
var res = _view.KeyboardHelper.DispatchKeyEvent(e, out var callBase);
if (res == false)
- callBase = !_accessHelper.DispatchKeyEvent(e!) && callBase;
+ callBase = _accessHelper?.DispatchKeyEvent(e!) == false && callBase;
var baseResult = callBase && base.DispatchKeyEvent(e);
diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs
index f1c3b95aa1..96a95b8a12 100644
--- a/src/Android/Avalonia.Android/AvaloniaView.cs
+++ b/src/Android/Avalonia.Android/AvaloniaView.cs
@@ -19,11 +19,11 @@ namespace Avalonia.Android
{
public partial class AvaloniaView : FrameLayout
{
- private EmbeddableControlRoot _root;
+ private EmbeddableControlRoot? _root;
private readonly ViewImpl _view;
private readonly ExploreByTouchHelper _accessHelper;
- private IDisposable? _timerSubscription;
+ private bool _isRendering;
private bool _surfaceCreated;
public AvaloniaView(Context context) : base(context)
@@ -36,7 +36,7 @@ namespace Avalonia.Android
_root.Prepare();
SetBackgroundColor(global::Android.Graphics.Color.Transparent);
- OnConfigurationChanged();
+ OnConfigurationChanged(null);
_view.InternalView!.SurfaceWindowCreated += InternalView_SurfaceWindowCreated;
_view.InternalView.SurfaceWindowDestroyed += InternalView_SurfaceWindowDestroyed;
@@ -69,8 +69,8 @@ namespace Avalonia.Android
public object? Content
{
- get { return _root.Content; }
- set { _root.Content = value; }
+ get => _root?.Content;
+ set => _root?.Content = value;
}
internal new void Dispose()
@@ -78,7 +78,14 @@ namespace Avalonia.Android
OnVisibilityChanged(false);
_surfaceCreated = false;
_root?.Dispose();
- _root = null!;
+ _root = null;
+ }
+
+ protected override void OnAttachedToWindow()
+ {
+ SendConfigurationChanged(Context?.Resources?.Configuration);
+
+ base.OnAttachedToWindow();
}
[SupportedOSPlatform("android24.0")]
@@ -98,13 +105,10 @@ namespace Avalonia.Android
{
if (_root == null || !_surfaceCreated)
return;
- if (isVisible && _timerSubscription == null)
- {
- if (AndroidPlatform.Timer is { } timer)
- {
- _timerSubscription = timer.SubscribeView(this);
- }
+ if (isVisible && !_isRendering)
+ {
+ _isRendering = true;
_root.StartRendering();
if (_view.TryGetFeature(out var insetsManager) == true)
@@ -112,27 +116,24 @@ namespace Avalonia.Android
(insetsManager as AndroidInsetsManager)?.ApplyStatusBarState();
}
}
- else if (!isVisible && _timerSubscription != null)
+ else if (!isVisible && _isRendering)
{
+ _isRendering = false;
_root.StopRendering();
- _timerSubscription?.Dispose();
- _timerSubscription = null;
}
}
-
+
protected override void OnConfigurationChanged(Configuration? newConfig)
{
base.OnConfigurationChanged(newConfig);
- OnConfigurationChanged();
+ SendConfigurationChanged(newConfig ?? Context?.Resources?.Configuration);
}
- private void OnConfigurationChanged()
+ private void SendConfigurationChanged(Configuration? newConfig)
{
- if (Context is { } context)
+ _view.InsetsManager?.SetDefaultSystemLightMode(!(newConfig?.UiMode.HasFlag(UiMode.NightYes) ?? false));
+ if (Context is not null && newConfig is not null)
{
- var settings =
- AvaloniaLocator.Current.GetRequiredService() as AndroidPlatformSettings;
- settings?.OnViewConfigurationChanged(context);
((AndroidScreens)_view.TryGetFeature()!).OnChanged();
}
}
@@ -146,7 +147,7 @@ namespace Avalonia.Android
private void ViewImpl_FocusChange(object? sender, FocusChangeEventArgs e)
{
- if(!e.HasFocus)
+ if (!e.HasFocus)
LostFocus?.Invoke();
}
}
diff --git a/src/Android/Avalonia.Android/ChoreographerTimer.cs b/src/Android/Avalonia.Android/ChoreographerTimer.cs
index 9bc8e78a52..d31a0aa915 100644
--- a/src/Android/Avalonia.Android/ChoreographerTimer.cs
+++ b/src/Android/Avalonia.Android/ChoreographerTimer.cs
@@ -1,9 +1,7 @@
using System;
-using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
-using Avalonia.Reactive;
using Avalonia.Rendering;
using static Avalonia.Android.Platform.SkiaPlatform.AndroidFramebuffer;
@@ -17,7 +15,6 @@ namespace Avalonia.Android
private readonly TaskCompletionSource _choreographer = new();
private readonly AutoResetEvent _event = new(false);
private readonly GCHandle _timerHandle;
- private readonly HashSet _views = new();
private Action? _tick;
private bool _pendingCallback;
private long _lastTime;
@@ -49,23 +46,6 @@ namespace Avalonia.Android
}
}
- internal IDisposable SubscribeView(AvaloniaView view)
- {
- lock (_lock)
- {
- _views.Add(view);
- PostFrameCallbackIfNeeded();
- }
-
- return Disposable.Create(
- () =>
- {
- lock (_lock)
- _views.Remove(view);
- }
- );
- }
-
private void Loop()
{
Looper.Prepare();
@@ -94,7 +74,7 @@ namespace Avalonia.Android
if(_pendingCallback)
return;
- if (_tick == null || _views.Count == 0)
+ if (_tick == null)
return;
_pendingCallback = true;
diff --git a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs
index a8ab825808..2cf2896c7b 100644
--- a/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs
+++ b/src/Android/Avalonia.Android/Platform/AndroidInsetsManager.cs
@@ -187,6 +187,11 @@ namespace Avalonia.Android.Platform
return insets;
}
+ internal void SetDefaultSystemLightMode(bool islightMode)
+ {
+ _isDefaultSystemBarLightTheme = islightMode;
+ }
+
private void NotifySafeAreaChanged(Thickness safeAreaPadding)
{
Dispatcher.UIThread.Send(_ => SafeAreaChanged?.Invoke(this, new SafeAreaChangedArgs(safeAreaPadding)));
diff --git a/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs b/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs
index 1e77b7d880..c89619749c 100644
--- a/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs
+++ b/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs
@@ -3,35 +3,48 @@ using Android.Content;
using Android.Content.Res;
using Android.Provider;
using Android.Views;
+using AndroidX.Core.Content;
using Avalonia.Input;
using Avalonia.Platform;
+using Avalonia.Threading;
using Color = Avalonia.Media.Color;
namespace Avalonia.Android.Platform;
-// TODO: ideally should be created per view/activity.
internal class AndroidPlatformSettings : DefaultPlatformSettings
{
- private PlatformColorValues _latestValues;
+ private PlatformColorValues _colorValues;
private TimeSpan _holdWaitDuration = TimeSpan.FromMilliseconds(300);
private TimeSpan _doubleTapTime = TimeSpan.FromMilliseconds(500);
private Size _doubleTapSize = new Size(16,16);
private Size _tapSize = new Size(10,10);
+ private string? _latestLanguage;
public AndroidPlatformSettings()
{
- _latestValues = base.GetColorValues();
if (global::Android.App.Application.Context is { } context)
{
- GetInputConfigValues(context);
+ UpdateInputConfigValues(context);
+ _colorValues = GetColorValuesFromContext(context);
+ _latestLanguage = QueryPreferredApplicationLanguage(context);
+
+ ContextCompat.RegisterReceiver(
+ context,
+ new ConfigurationChangedReceiver(this),
+ new IntentFilter(Intent.ActionConfigurationChanged),
+ ContextCompat.ReceiverNotExported);
}
+ else
+ _colorValues = base.GetColorValues();
}
public override PlatformColorValues GetColorValues()
{
- return _latestValues;
+ return _colorValues;
}
+ public override string PreferredApplicationLanguage => _latestLanguage ?? base.PreferredApplicationLanguage;
+
public override TimeSpan GetDoubleTapTime(PointerType type)
{
return type == PointerType.Mouse ? base.GetDoubleTapTime(type) : _doubleTapTime;
@@ -49,70 +62,87 @@ internal class AndroidPlatformSettings : DefaultPlatformSettings
public override TimeSpan HoldWaitDuration => _holdWaitDuration;
- internal void OnViewConfigurationChanged(Context context)
+ private void UpdateColorValues(Context context)
{
- if (context.Resources?.Configuration is null)
+ var oldColorValues = _colorValues;
+ var colorValues = GetColorValuesFromContext(context);
+
+ if (oldColorValues != colorValues)
{
- return;
+ _colorValues = colorValues;
+ OnColorValuesChanged(colorValues);
}
+ }
- var systemTheme = (context.Resources.Configuration.UiMode & UiMode.NightMask) switch
+ private void UpdatePreferredApplicationLanguage(Context context)
+ {
+ var oldLanguage = _latestLanguage;
+ var language = QueryPreferredApplicationLanguage(context);
+
+ if (oldLanguage != language)
{
- UiMode.NightYes => PlatformThemeVariant.Dark,
- UiMode.NightNo => PlatformThemeVariant.Light,
- _ => throw new ArgumentOutOfRangeException()
- };
+ _latestLanguage = language;
+ OnPreferredApplicationLanguageChanged();
+ }
+ }
+
+ private static PlatformColorValues GetColorValuesFromContext(Context context)
+ {
+ var uiMode = context.Resources?.Configuration?.UiMode & UiMode.NightMask ?? UiMode.NightNo;
+ var systemTheme = uiMode == UiMode.NightYes ? PlatformThemeVariant.Dark : PlatformThemeVariant.Light;
+ var contrastPreference = IsHighContrast(context);
if (OperatingSystem.IsAndroidVersionAtLeast(31))
{
- // See https://developer.android.com/reference/android/R.color
- var accent1 = context.Resources.GetColor(17170494, context.Theme); // Resource.Color.SystemAccent1500
- var accent2 = context.Resources.GetColor(17170507, context.Theme); // Resource.Color.SystemAccent2500
- var accent3 = context.Resources.GetColor(17170520, context.Theme); // Resource.Color.SystemAccent3500
-
- _latestValues = new PlatformColorValues
+ if (context.Resources is { } resources)
{
- ThemeVariant = systemTheme,
- ContrastPreference = IsHighContrast(context),
- AccentColor1 = new Color(accent1.A, accent1.R, accent1.G, accent1.B),
- AccentColor2 = new Color(accent2.A, accent2.R, accent2.G, accent2.B),
- AccentColor3 = new Color(accent3.A, accent3.R, accent3.G, accent3.B),
- };
+ // See https://developer.android.com/reference/android/R.color
+ var accent1 = resources.GetColor(17170494, context.Theme); // Resource.Color.SystemAccent1500
+ var accent2 = resources.GetColor(17170507, context.Theme); // Resource.Color.SystemAccent2500
+ var accent3 = resources.GetColor(17170520, context.Theme); // Resource.Color.SystemAccent3500
+
+ return new PlatformColorValues
+ {
+ ThemeVariant = systemTheme,
+ ContrastPreference = contrastPreference,
+ AccentColor1 = new Color(accent1.A, accent1.R, accent1.G, accent1.B),
+ AccentColor2 = new Color(accent2.A, accent2.R, accent2.G, accent2.B),
+ AccentColor3 = new Color(accent3.A, accent3.R, accent3.G, accent3.B),
+ };
+ }
}
else if (OperatingSystem.IsAndroidVersionAtLeast(23))
{
// See https://developer.android.com/reference/android/R.attr
- var array = context.Theme?.ObtainStyledAttributes(new[] { 16843829 }); // Resource.Attribute.ColorAccent
+ var array = context.Theme?.ObtainStyledAttributes([16843829]); // Resource.Attribute.ColorAccent
if (array is not null)
{
try
{
var accent = array.GetColor(0, 0);
- _latestValues = new PlatformColorValues
+ return new PlatformColorValues
{
ThemeVariant = systemTheme,
- ContrastPreference = IsHighContrast(context),
+ ContrastPreference = contrastPreference,
AccentColor1 = new Color(accent.A, accent.R, accent.G, accent.B)
};
}
finally
{
- array.Recycle();
+ array.Recycle();
}
}
}
- else
- {
- _latestValues = _latestValues with { ThemeVariant = systemTheme };
- }
-
- GetInputConfigValues(context);
- OnColorValuesChanged(_latestValues);
+ return new PlatformColorValues
+ {
+ ThemeVariant = systemTheme,
+ ContrastPreference = contrastPreference
+ };
}
- private void GetInputConfigValues(Context context)
+ private void UpdateInputConfigValues(Context context)
{
_holdWaitDuration = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout);
@@ -131,6 +161,12 @@ internal class AndroidPlatformSettings : DefaultPlatformSettings
}
}
+ private string? QueryPreferredApplicationLanguage(Context? context)
+ {
+ var locale = context?.Resources?.Configuration?.Locales?.Get(0);
+ return locale?.ToLanguageTag() is { Length: > 0 } tag ? tag : null;
+ }
+
private static ColorContrastPreference IsHighContrast(Context context)
{
try
@@ -143,4 +179,27 @@ internal class AndroidPlatformSettings : DefaultPlatformSettings
return ColorContrastPreference.NoPreference;
}
}
+
+ private sealed class ConfigurationChangedReceiver(AndroidPlatformSettings settings) : BroadcastReceiver
+ {
+ public override void OnReceive(Context? context, Intent? intent)
+ {
+ if (context is null)
+ return;
+
+ // The context might still have the old values at this point because they haven't been processed yet.
+ // Postpone the update 100ms arbitrarily. Not an ideal solution, but sufficient.
+ var timer = new DispatcherTimer(TimeSpan.FromMilliseconds(100), DispatcherPriority.Normal, Dispatcher.UIThread);
+
+ timer.Tick += (_, _) =>
+ {
+ timer.Stop();
+ settings.UpdateInputConfigValues(context);
+ settings.UpdateColorValues(context);
+ settings.UpdatePreferredApplicationLanguage(context);
+ };
+
+ timer.Start();
+ }
+ }
}
diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs
index 2e8e145ef8..668c433600 100644
--- a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs
+++ b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs
@@ -1,6 +1,8 @@
-using System;
+using System;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using Android.Content;
+using Android.OS;
using Android.Runtime;
using Android.Text;
using Android.Views;
@@ -206,6 +208,9 @@ namespace Avalonia.Android.Platform.Input
outAttrs.ImeOptions |= ImeFlags.NoFullscreen | ImeFlags.NoExtractUi;
+ if (options.LocaleHints?.Count > 0)
+ outAttrs.HintLocales = new LocaleList(options.LocaleHints.Select(Java.Util.Locale.ForLanguageTag).ToArray());
+
return _inputConnection;
});
}
diff --git a/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs b/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs
index a93803db09..b79043db32 100644
--- a/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs
+++ b/src/Android/Avalonia.Android/Platform/Input/AvaloniaInputConnection.cs
@@ -311,7 +311,7 @@ namespace Avalonia.Android.Platform.Input
public bool SendKeyEvent(KeyEvent? e)
{
- _inputMethod.View.DispatchKeyEvent(e);
+ QueueCommand(new KeyEventCommand(e));
return true;
}
diff --git a/src/Android/Avalonia.Android/Platform/Input/EditCommand.cs b/src/Android/Avalonia.Android/Platform/Input/EditCommand.cs
index c110807dd9..33d4449725 100644
--- a/src/Android/Avalonia.Android/Platform/Input/EditCommand.cs
+++ b/src/Android/Avalonia.Android/Platform/Input/EditCommand.cs
@@ -1,4 +1,5 @@
using System;
+using Android.Views;
using Avalonia.Input.TextInput;
namespace Avalonia.Android.Platform.Input
@@ -177,4 +178,19 @@ namespace Avalonia.Android.Platform.Input
buffer.Composition = default;
}
}
+
+ internal class KeyEventCommand : EditCommand
+ {
+ private readonly KeyEvent? _keyEvent;
+
+ public KeyEventCommand(KeyEvent? keyEvent)
+ {
+ _keyEvent = keyEvent;
+ }
+
+ public override void Apply(TextEditBuffer buffer)
+ {
+ buffer.DispatchKeyEvent(_keyEvent);
+ }
+ }
}
diff --git a/src/Android/Avalonia.Android/Platform/Input/TextEditBuffer.cs b/src/Android/Avalonia.Android/Platform/Input/TextEditBuffer.cs
index 9cbefab24a..3c25772d72 100644
--- a/src/Android/Avalonia.Android/Platform/Input/TextEditBuffer.cs
+++ b/src/Android/Avalonia.Android/Platform/Input/TextEditBuffer.cs
@@ -131,5 +131,11 @@ namespace Avalonia.Android.Platform.Input
Composition = null;
}
}
+
+
+ internal void DispatchKeyEvent(KeyEvent? keyEvent)
+ {
+ _textInputMethod?.View.DispatchKeyEvent(keyEvent);
+ }
}
}
diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
index a7bf5f626d..6209301146 100644
--- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
+++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs
@@ -8,6 +8,7 @@ using Android.OS;
using Android.Runtime;
using Android.Views;
using AndroidX.AppCompat.App;
+using AndroidX.Core.View;
using Avalonia.Android.Platform.Input;
using Avalonia.Android.Platform.Specific.Helpers;
using Avalonia.Android.Platform.Storage;
@@ -29,6 +30,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
{
class TopLevelImpl : ITopLevelImpl, EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfoWithWaitPolicy
{
+ private readonly Context _context;
private readonly AndroidKeyboardEventsHelper _keyboardHelper;
private readonly AndroidMotionEventsHelper _pointerHelper;
private readonly AndroidInputMethod _textInputMethod;
@@ -50,6 +52,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
throw new ArgumentException("AvaloniaView.Context must not be null");
}
+ _context = context;
_view = new SurfaceViewImpl(context, this, placeOnTop);
_textInputMethod = new AndroidInputMethod(avaloniaView);
_keyboardHelper = new AndroidKeyboardEventsHelper(this);
@@ -232,7 +235,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform
}
}
- public void SetFrameThemeVariant(PlatformThemeVariant themeVariant)
+ public void SetFrameThemeVariant(PlatformThemeVariant? themeVariant)
{
if (_insetsManager != null)
{
@@ -244,7 +247,16 @@ namespace Avalonia.Android.Platform.SkiaPlatform
};
}
- AppCompatDelegate.DefaultNightMode = themeVariant == PlatformThemeVariant.Light ? AppCompatDelegate.ModeNightNo : AppCompatDelegate.ModeNightYes;
+ if (_context is AppCompatActivity activity)
+ {
+ var nightMode = themeVariant == PlatformThemeVariant.Dark ?
+ AppCompatDelegate.ModeNightYes :
+ AppCompatDelegate.ModeNightNo;
+
+ // Don't use AppCompatDelegate.DefaultNightMode: doing so will force the app to use one night mode,
+ // ignoring the system's configuration and preventing us from detecting system theme changes.
+ activity.Delegate.SetLocalNightMode(nightMode);
+ }
}
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels => new(1, 1, 1);
@@ -254,8 +266,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform
PixelSize EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Size => _view?.Size ?? default;
double EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Scaling => _view?.Scaling ?? default;
+ internal AndroidInsetsManager? InsetsManager => _insetsManager;
internal AndroidKeyboardEventsHelper KeyboardHelper => _keyboardHelper;
-
internal AndroidMotionEventsHelper PointerHelper => _pointerHelper;
public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels)
diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
index a5ceb02312..fa26e0cac7 100644
--- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs
@@ -118,7 +118,8 @@ internal abstract class AndroidStorageItem : IStorageBookmarkItem
protected async Task EnsureExternalFilesPermission(bool write)
{
- if (!_needsExternalFilesPermission)
+ // Starting in API level 33, this permission has no effect.
+ if (!_needsExternalFilesPermission || OperatingSystem.IsAndroidVersionAtLeast(33))
{
return true;
}
diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
index b42d0f7aaa..66e56f3d6b 100644
--- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
+++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs
@@ -7,6 +7,7 @@ using Android;
using Android.App;
using Android.Content;
using Android.Provider;
+using AndroidX.DocumentFile.Provider;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using AndroidUri = Android.Net.Uri;
@@ -18,6 +19,8 @@ namespace Avalonia.Android.Platform.Storage;
internal class AndroidStorageProvider : IStorageProvider
{
public static ReadOnlySpan AndroidKey => "android"u8;
+ private const string ContentUriScheme = "content";
+ private const string FileUriScheme = "file";
private readonly Activity _activity;
public AndroidStorageProvider(Activity activity)
@@ -55,15 +58,39 @@ internal class AndroidStorageProvider : IStorageProvider
return null;
}
- await EnsureUriReadPermission(androidUri);
+ // About the READ_EXTERNAL_STORAGE permission:
+ // https://developer.android.com/reference/android/Manifest.permission#READ_EXTERNAL_STORAGE
+ // - "Starting in API level 33, this permission has no effect."
+ // - "Also starting in API level 19, this permission is not required
+ // to read or write files in your application-specific directories [...]"
+ // Consequently, we don't try to check for that permission here anymore.
- var javaFile = new JavaFile(androidUriPath);
- if (javaFile.Exists() && javaFile.IsFile)
+ if (filePath.Scheme == FileUriScheme && androidUri.Path is not null)
{
+ var javaFile = new JavaFile(androidUri.Path);
+ if (javaFile.Exists() && javaFile.IsFile)
+ {
+ return new BclStorageFile(new System.IO.FileInfo(javaFile.AbsolutePath));
+ }
return null;
}
+ else if (filePath.Scheme == ContentUriScheme && _activity.ContentResolver is not null)
+ {
+ try
+ {
+ using var stream = _activity.ContentResolver.OpenInputStream(androidUri);
+ if (stream is not null)
+ {
+ return new AndroidStorageFile(_activity, androidUri);
+ }
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
- return new AndroidStorageFile(_activity, androidUri);
+ return null;
}
public async Task TryGetFolderFromPathAsync(Uri folderPath)
@@ -84,15 +111,46 @@ internal class AndroidStorageProvider : IStorageProvider
return null;
}
- await EnsureUriReadPermission(androidUri);
-
- var javaFile = new JavaFile(androidUriPath);
- if (javaFile.Exists() && javaFile.IsDirectory)
+ if (folderPath.Scheme == FileUriScheme && androidUri.Path is not null)
{
+ var javaFile = new JavaFile(androidUri.Path);
+ if (javaFile.Exists() && javaFile.IsDirectory)
+ {
+ return new BclStorageFolder(new System.IO.DirectoryInfo(javaFile.AbsolutePath));
+ }
return null;
}
+ else if (folderPath.Scheme == ContentUriScheme)
+ {
+ try
+ {
+ if (OperatingSystem.IsAndroidVersionAtLeast(21))
+ {
+ if (DocumentsContract.IsTreeUri(androidUri))
+ {
+ var docId = DocumentsContract.GetTreeDocumentId(androidUri);
+ if (!string.IsNullOrEmpty(docId))
+ {
+ return new AndroidStorageFolder(_activity, androidUri, false);
+ }
+ }
+ else
+ {
+ var documentFile = DocumentFile.FromSingleUri(_activity, androidUri);
+ if (documentFile is not null && documentFile.Exists() && documentFile.IsDirectory)
+ {
+ return new AndroidStorageFolder(_activity, androidUri, false);
+ }
+ }
+ }
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
- return new AndroidStorageFolder(_activity, androidUri, false);
+ return null;
}
public Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder)
@@ -118,16 +176,9 @@ internal class AndroidStorageProvider : IStorageProvider
return Task.FromResult(null);
}
- var uri = AndroidUri.FromFile(dir);
- if (uri is null)
- {
- return Task.FromResult(null);
- }
-
- // To make TryGetWellKnownFolder API easier to use, we don't check for the permissions.
- // It will work with file picker activities, but it will fail on any direct access to the folder, like getting list of children.
- // We pass "needsExternalFilesPermission" parameter here, so folder itself can check for permissions on any FS access.
- return Task.FromResult(new WellKnownAndroidStorageFolder(_activity, dirCode, uri, true));
+ // From Android 10(API 29), WellKnownFolders points to the app's external files directories, rather than the system's.
+ // These paths can be access directly using File apis without need to go though the ContextResolver or requesting permissions.
+ return Task.FromResult(new BclStorageFolder(new System.IO.DirectoryInfo(dir.AbsolutePath)));
}
public Task OpenFileBookmarkAsync(string bookmark)
@@ -169,6 +220,12 @@ internal class AndroidStorageProvider : IStorageProvider
return uris.Select(u => new AndroidStorageFile(_activity, u)).ToArray();
}
+ public async Task OpenFilePickerWithResultAsync(FilePickerOpenOptions options)
+ {
+ var files = await OpenFilePickerAsync(options).ConfigureAwait(false);
+ return new OpenFilePickerResult { Files = files };
+ }
+
public async Task SaveFilePickerAsync(FilePickerSaveOptions options)
{
var mimeTypes = options.FileTypeChoices?.Where(t => t != FilePickerFileTypes.All)
@@ -284,30 +341,4 @@ internal class AndroidStorageProvider : IStorageProvider
return intent;
}
-
- private async Task EnsureUriReadPermission(AndroidUri androidUri)
- {
- bool hasPerms = false;
- Exception? innerEx = null;
- try
- {
- hasPerms = _activity.CheckUriPermission(androidUri,
- global::Android.OS.Process.MyPid(),
- global::Android.OS.Process.MyUid(),
- ActivityFlags.GrantReadUriPermission)
- == global::Android.Content.PM.Permission.Granted;
-
- // TODO: call RequestPermission or add proper permissions API, something like in Browser File API.
- hasPerms = hasPerms || await _activity.CheckPermission(Manifest.Permission.ReadExternalStorage);
- }
- catch (Exception ex)
- {
- innerEx = ex;
- }
-
- if (!hasPerms)
- {
- throw new InvalidOperationException("Application doesn't have READ_EXTERNAL_STORAGE permission. Make sure android manifest has this permission defined and user allowed it.", innerEx);
- }
- }
}
diff --git a/src/Android/Avalonia.Android/PlatformIconLoader.cs b/src/Android/Avalonia.Android/PlatformIconLoader.cs
index f557685dd2..5b5705753e 100644
--- a/src/Android/Avalonia.Android/PlatformIconLoader.cs
+++ b/src/Android/Avalonia.Android/PlatformIconLoader.cs
@@ -1,4 +1,5 @@
using System.IO;
+using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace Avalonia.Android
@@ -9,7 +10,7 @@ namespace Avalonia.Android
{
using (var stream = new MemoryStream())
{
- bitmap.Save(stream);
+ bitmap.Save(stream, PngBitmapEncoderOptions.Default);
return LoadIcon(stream);
}
}
diff --git a/src/Android/Avalonia.Android/Stubs.cs b/src/Android/Avalonia.Android/Stubs.cs
index 0620919e4d..57de1b1b10 100644
--- a/src/Android/Avalonia.Android/Stubs.cs
+++ b/src/Android/Avalonia.Android/Stubs.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace Avalonia.Android
@@ -22,7 +23,7 @@ namespace Avalonia.Android
{
using (var stream = new MemoryStream())
{
- bitmap.Save(stream);
+ bitmap.Save(stream, PngBitmapEncoderOptions.Default);
return LoadIcon(stream);
}
}
diff --git a/src/Avalonia.Base/Animation/AnimationInstance`1.cs b/src/Avalonia.Base/Animation/AnimationInstance`1.cs
index 757e5f8987..cdfa1f9dda 100644
--- a/src/Avalonia.Base/Animation/AnimationInstance`1.cs
+++ b/src/Avalonia.Base/Animation/AnimationInstance`1.cs
@@ -12,73 +12,109 @@ namespace Avalonia.Animation
///
internal class AnimationInstance : SingleSubscriberObservableBase
{
- private T _lastInterpValue;
- private T _firstKFValue;
- private ulong? _iterationCount;
- private ulong _currentIteration;
- private bool _gotFirstKFValue;
- private bool _playbackReversed;
- private FillMode _fillMode;
- private PlaybackDirection _playbackDirection;
private readonly Animator _animator;
private readonly Animation _animation;
private readonly Animatable _targetControl;
- private T _neutralValue;
- private double _speedRatioConv;
- private TimeSpan _initialDelay;
- private TimeSpan _iterationDelay;
- private TimeSpan _duration;
- private Easings.Easing? _easeFunc;
+ private readonly Visual? _visualTarget;
private readonly Action? _onCompleteAction;
- private readonly Func _interpolator;
private IDisposable? _timerSub;
- private readonly IClock _baseClock;
- private IClock? _clock;
private EventHandler? _propertyChangedDelegate;
private EventHandler? _visibilityChangedHandler;
private EventHandler? _detachedHandler;
+ private readonly IClock _baseClock;
+ private IClock? _clock;
+
+ private Easings.Easing? _easeFunc;
+ private readonly Func _interpolator;
+
+ private T _neutralValue;
+ private FillMode _fillMode;
+
+ private bool _isFirstFrame;
+ private bool _isInFirstInitialDelay;
+ private T _lastInterpValue;
+ private T _initialKFValue;
+ private long? _iterationCount;
+ private TimeSpan _initialDelay;
+ private TimeSpan _iterationDelay;
+ private TimeSpan _duration;
+ private TimeSpan _timePrev;
+ private double _animTimePrev;
+
+ private PlaybackDirection _playbackDirection;
+ private PlaybackDirection _playbackDirectionPrev;
+ private double _speedRatio;
+ private double _speedRatioPrev;
+ private bool _timeMovesBackwards;
+
+ private TimeSpan _timeOfLastChange;
+ private double _animTimeOfLastChange;
+
+ ///
+ /// Animation's smallest unit of time in terms of TimeSpan Ticks. This can be used
+ /// to increase possible runtime of animation before reaching over/under-flow.
+ ///
+ ///
+ /// Value to use here can be found with TimeSpan.FromMilliseconds(x).Ticks
+ ///
+ private const long PRECISION_IN_TICKS = 10_000;
+
private readonly bool _shouldPauseOnInvisible;
- public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock, Action? OnComplete, Func Interpolator, bool shouldPauseOnInvisible)
+ public AnimationInstance(Animation animation, Animatable control, Animator animator, IClock baseClock,
+ Action? OnComplete, Func Interpolator, bool shouldPauseOnInvisible, Visual? visualTarget)
{
+ _lastInterpValue = default!;
_animator = animator;
_animation = animation;
_targetControl = control;
_onCompleteAction = OnComplete;
_interpolator = Interpolator;
_baseClock = baseClock;
- _lastInterpValue = default!;
- _firstKFValue = default!;
+ _visualTarget = visualTarget;
+ _initialKFValue = default!;
_neutralValue = default!;
_shouldPauseOnInvisible = shouldPauseOnInvisible;
+ _isFirstFrame = true;
+ _isInFirstInitialDelay = true;
+ _speedRatio = 1;
FetchProperties();
}
private void FetchProperties()
{
- if (_animation.SpeedRatio < 0d)
- throw new InvalidOperationException("SpeedRatio value should not be negative.");
-
- if (_animation.Duration < TimeSpan.Zero)
- throw new InvalidOperationException("Duration value cannot be negative.");
-
- if (_animation.Delay < TimeSpan.Zero)
- throw new InvalidOperationException("Delay value cannot be negative.");
-
_easeFunc = _animation.Easing;
- _speedRatioConv = 1d / _animation.SpeedRatio;
+ _speedRatioPrev = _speedRatio;
+ _speedRatio = _animation.SpeedRatio;
+ if (_speedRatio < 0d)
+ throw new InvalidOperationException("SpeedRatio value cannot be negative.");
_initialDelay = _animation.Delay;
+ if (_initialDelay < TimeSpan.Zero)
+ throw new InvalidOperationException("Delay value cannot be negative.");
+
_duration = _animation.Duration;
+ if (_duration < TimeSpan.Zero)
+ throw new InvalidOperationException("Duration value cannot be negative.");
+
_iterationDelay = _animation.DelayBetweenIterations;
+ if (_iterationDelay < TimeSpan.Zero)
+ throw new InvalidOperationException("DelayBetweenIterations value cannot be negative.");
if (_animation.IterationCount.RepeatType == IterationType.Many)
- _iterationCount = _animation.IterationCount.Value;
+ {
+ if (_animation.IterationCount.Value > long.MaxValue)
+ throw new InvalidOperationException("IterationCount value cannot be larger than long.MaxValue.");
+ _iterationCount = (long)_animation.IterationCount.Value;
+ }
else
+ {
_iterationCount = null;
+ }
+ _playbackDirectionPrev = _playbackDirection;
_playbackDirection = _animation.PlaybackDirection;
_fillMode = _animation.FillMode;
}
@@ -93,12 +129,13 @@ namespace Avalonia.Animation
return;
// Animation may have been stopped before it has finished.
- ApplyFinalFill();
+ if (CanApplyFinalFill())
+ ApplyFinalFill(_lastInterpValue);
_targetControl.PropertyChanged -= _propertyChangedDelegate;
timerSub.Dispose();
- if (_targetControl is Visual visual)
+ if (_visualTarget is { } visual)
{
if (_visibilityChangedHandler is not null)
{
@@ -121,7 +158,7 @@ namespace Avalonia.Animation
_clock = new Clock(_baseClock);
_timerSub = _clock.Subscribe(Step);
- if (_targetControl is Visual visual)
+ if (_visualTarget is { } visual)
{
if (_shouldPauseOnInvisible)
{
@@ -148,11 +185,7 @@ namespace Avalonia.Animation
}
// Stop and dispose the animation when detached from the visual tree.
- _detachedHandler = (_, _) =>
- {
- SetFinalValue();
- DoComplete();
- };
+ _detachedHandler = (_, _) => DoComplete(false);
visual.DetachedFromVisualTree += _detachedHandler;
}
@@ -167,8 +200,8 @@ namespace Avalonia.Animation
{
if (_clock?.PlayState == PlayState.Pause)
return;
-
- InternalStep(frameTick);
+
+ InternalStep(frameTick);
}
catch (Exception e)
{
@@ -176,127 +209,274 @@ namespace Avalonia.Animation
}
}
- private void SetFinalValue()
+ private bool CanApplyFinalFill()
{
- var easedTime = _easeFunc!.Ease(_playbackReversed ? 0.0 : 1.0);
- _lastInterpValue = _interpolator(easedTime, _neutralValue);
+ return _fillMode is FillMode.Forward or FillMode.Both;
}
- private void ApplyFinalFill()
+ private void ApplyFinalFill(T interpolatedValue)
{
if (_animator.Property is null)
throw new InvalidOperationException("Animator has no property specified.");
- if (_fillMode is FillMode.Forward or FillMode.Both)
- _targetControl.SetValue(_animator.Property, _lastInterpValue);
+ _targetControl.SetValue(_animator.Property, interpolatedValue);
}
- private void DoComplete()
+ private void DoComplete(bool stopAsIs)
{
- ApplyFinalFill();
+ if (!stopAsIs)
+ {
+ // Update _lastInterpValue because PublishCompleted might perform final fill
+ // and we should ensure that filled value is snapped to last value that animation would
+ // reach if it was left running in current state.
+ var isIterationReversed = IsIterationReversed(IsAlternatingPlaybackDirection(), _iterationCount + 1);
+ _lastInterpValue = FindEdgeValue(IsAnimTimeGoingBackwards(), isIterationReversed);
+ }
_onCompleteAction?.Invoke();
PublishCompleted();
}
- private void DoDelay()
+ private void DoInitialDelay()
+ {
+ if (_isInFirstInitialDelay)
+ {
+ if (_fillMode is not (FillMode.Backward or FillMode.Both))
+ return;
+ }
+
+ PublishNext(_initialKFValue);
+ }
+
+ private void DoIterationDelay(bool isIterationReversed)
{
- if (_fillMode is not (FillMode.Backward or FillMode.Both)) return;
- PublishNext(_currentIteration == 0 ? _firstKFValue : _lastInterpValue);
+ _lastInterpValue = FindEdgeValue(false, isIterationReversed);
+ PublishNext(_lastInterpValue);
}
private void DoPlayStates()
{
if (_clock!.PlayState == PlayState.Stop || _baseClock.PlayState == PlayState.Stop)
- DoComplete();
+ DoComplete(true);
+
+ if (_isFirstFrame)
+ {
+ // In first frame of animation we determine the expected direction of time.
+ _timeMovesBackwards =
+ _animation.PlaybackDirection == PlaybackDirection.Reverse ||
+ _animation.PlaybackDirection == PlaybackDirection.AlternateReverse;
+
+ var initialKeyFrame = _timeMovesBackwards ? _animator.Last() : _animator.First();
+ _initialKFValue = initialKeyFrame.Value is T initialValue ? initialValue : _neutralValue;
+
+ _isFirstFrame = false;
+ }
+ }
+
+ private static bool IsIterationReversed(bool isAlternatingPlaybackDirection, long? iterationIndex)
+ {
+ if (isAlternatingPlaybackDirection)
+ return (iterationIndex & 1) != 0;
+ else
+ return false;
+ }
+
+ private T FindEdgeValue(bool isAnimTimeGoingBackwards, bool isIterationReversed)
+ {
+ double finalEaseValue = isAnimTimeGoingBackwards ^ isIterationReversed ? 0.0 : 1.0;
+
+ var easedTime = _easeFunc!.Ease(finalEaseValue);
+ return _interpolator(easedTime, _neutralValue);
+ }
+
+ private bool IsAnimTimeGoingBackwards()
+ {
+ return
+ _playbackDirection == PlaybackDirection.Reverse ||
+ _playbackDirection == PlaybackDirection.AlternateReverse;
+ }
+
+ private bool IsAlternatingPlaybackDirection()
+ {
+ return
+ _playbackDirection == PlaybackDirection.Alternate ||
+ _playbackDirection == PlaybackDirection.AlternateReverse;
+ }
+
+ private bool ApplyInitialDelay(ref long animTime)
+ {
+ // Handle all possible cases of applying initial delay and clamping.
+ long delay = _initialDelay.Ticks / PRECISION_IN_TICKS;
+ if (_iterationCount.HasValue)
+ {
+ animTime -= delay;
+ if (animTime <= 0)
+ {
+ if (animTime < -delay)
+ animTime = -delay;
+ return true;
+ }
+ }
+ else
+ {
+ // Determine the interval of initial delay.
+ long low = -delay;
+ long high = 0;
+
+ // Handle all 3 location cases based on above interval.
+ if (animTime < low)
+ {
+ animTime += delay;
+ if (animTime > 0)
+ {
+ animTime = 0;
+ return true;
+ }
+ }
+ else if (animTime > high)
+ {
+ animTime -= delay;
+ if (animTime < 0)
+ {
+ animTime = 0;
+ return true;
+ }
+ }
+ else // if (animTime >= low && animTime <= high)
+ {
+ animTime = 0;
+ return true;
+ }
+ }
+ return false;
+ }
- if (!_gotFirstKFValue)
+ private void ApplyLimitedClamp(ref double animTime)
+ {
+ if (_timeMovesBackwards)
+ {
+ if (animTime > 0)
+ animTime = 0;
+ }
+ else
{
- var firstKeyFrame = _animator.First();
- _firstKFValue = firstKeyFrame.Value is T value ? value : _neutralValue;
- _gotFirstKFValue = true;
+ if (animTime < 0)
+ animTime = 0;
}
}
private void InternalStep(TimeSpan time)
{
DoPlayStates();
-
FetchProperties();
- // Scale timebases according to speedratio.
- var indexTime = time.Ticks;
- var iterDuration = _duration.Ticks * _speedRatioConv;
- var iterDelay = _iterationDelay.Ticks * _speedRatioConv;
- var initDelay = _initialDelay.Ticks * _speedRatioConv;
+ if (_speedRatio != _speedRatioPrev || _playbackDirection != _playbackDirectionPrev)
+ {
+ // Remember the time when speed changed.
+ // All we can know is that it changed some time between current and prev frame.
+ // We assume that this happened exactly at prev frame.
+ _timeOfLastChange = _timePrev;
+ _animTimeOfLastChange = _animTimePrev;
+ }
+
+ bool isAnimTimeGoingBackwards = IsAnimTimeGoingBackwards();
- // This conditional checks if the time given is the very start/zero
- // and when we have an active delay time.
- if (initDelay > 0 && indexTime <= initDelay)
+ // Combine SpeedRatio and PlaybackDirection into a single signed speed ratio.
+ double speedRatio = isAnimTimeGoingBackwards ? -_speedRatio : _speedRatio;
+
+ // Calculate animation time. That's time that has passed inside
+ // the animation since its beginning.
+ var timeSinceLastChange = time - _timeOfLastChange;
+ var animTimeSinceLastChange = timeSinceLastChange.Ticks * speedRatio / PRECISION_IN_TICKS;
+ var animTimeExact = _animTimeOfLastChange + animTimeSinceLastChange;
+
+ if (_iterationCount.HasValue)
{
- DoDelay();
- return;
+ // Make sure animation time is inside a valid interval.
+ ApplyLimitedClamp(ref animTimeExact);
}
- // Calculate timebases.
- var iterationTime = iterDuration + iterDelay;
- var opsTime = indexTime - initDelay;
- var playbackTime = opsTime % iterationTime;
+ _timePrev = time;
+ _animTimePrev = animTimeExact;
+
+ var animTime = (long)animTimeExact;
+
+ // Get animation time oriented in the direction of time. Animation running in
+ // same direction as it was on first frame will always increase this variable.
+ long animTimePositive = _timeMovesBackwards ? -animTime : animTime;
+
+ if (_initialDelay > TimeSpan.Zero)
+ {
+ bool isCurrentlyInsideDelay = ApplyInitialDelay(ref animTimePositive);
+ if (isCurrentlyInsideDelay)
+ {
+ DoInitialDelay();
+ return;
+ }
+
+ _isInFirstInitialDelay = false;
+ }
- _currentIteration = (ulong)(opsTime / iterationTime);
+ var iterDuration = _duration.Ticks / PRECISION_IN_TICKS;
+ var iterDelay = _iterationDelay.Ticks / PRECISION_IN_TICKS;
+ var iterDurationTotal = iterDuration + iterDelay;
- // Stop animation when the current iteration is beyond the iteration count or
- // when the duration is set to zero while animating and snap to the last iterated value.
- if (_currentIteration + 1 > _iterationCount || _duration == TimeSpan.Zero)
+ // An iteration with no duration can't be interpolated, no matter how long the
+ // delay between iterations is: end the animation and snap to its final value.
+ if (iterDuration <= 0)
{
- SetFinalValue();
- DoComplete();
+ DoComplete(false);
return;
}
- if (playbackTime <= iterDuration)
+ // Calculate current iteration info.
+ var iterIndex = animTimePositive / iterDurationTotal;
+ var iterTime = animTimePositive % iterDurationTotal;
+
+ bool playbackReversed = animTimePositive < 0;
+ if (playbackReversed)
{
- // Normalize time for interpolation.
- var normalizedTime = playbackTime / iterDuration;
+ // Animation time is behind the starting point of animation.
+
+ // First negative iteration has index -1, first positive iteration has index 0.
+ iterIndex--;
+
+ // Move iteration delay to the front of iteration, which is (when moving
+ // backwards through animation time) effectively at the end of iteration.
+ iterTime = -iterTime;
+ }
+ playbackReversed ^= _timeMovesBackwards ^ IsIterationReversed(IsAlternatingPlaybackDirection(), iterIndex);
- // Check if normalized time needs to be reversed according to PlaybackDirection
+ var itersUntilEnd = _iterationCount - iterIndex;
+
+ // End animation when limit is reached.
+ if (itersUntilEnd <= 0)
+ {
+ DoComplete(false);
+ return;
+ }
- switch (_playbackDirection)
+ if (iterTime > iterDuration && iterTime <= iterDurationTotal && iterDelay > 0)
+ {
+ // The last iteration's trailing delay should be skipped.
+ if (_iterationCount.HasValue && itersUntilEnd <= 1)
{
- case PlaybackDirection.Normal:
- _playbackReversed = false;
- break;
- case PlaybackDirection.Reverse:
- _playbackReversed = true;
- break;
- case PlaybackDirection.Alternate:
- _playbackReversed = _currentIteration % 2 != 0;
- break;
- case PlaybackDirection.AlternateReverse:
- _playbackReversed = _currentIteration % 2 == 0;
- break;
- default:
- throw new InvalidOperationException(
- $"Animation direction value is unknown: {_playbackDirection}");
+ DoComplete(false);
+ return;
}
- if (_playbackReversed)
- normalizedTime = 1 - normalizedTime;
+ DoIterationDelay(playbackReversed);
+ }
+ else if (iterTime <= iterDuration)
+ {
+ // Ease and interpolate.
+ var normalized = iterTime / (double)iterDuration;
+ if (playbackReversed)
+ normalized = 1 - normalized;
- // Ease and interpolate
- var easedTime = _easeFunc!.Ease(normalizedTime);
+ var easedTime = _easeFunc!.Ease(normalized);
_lastInterpValue = _interpolator(easedTime, _neutralValue);
PublishNext(_lastInterpValue);
}
- else if (playbackTime > iterDuration &&
- playbackTime <= iterationTime &&
- iterDelay > 0)
- {
- // The last iteration's trailing delay should be skipped.
- if (_currentIteration + 1 < _iterationCount)
- DoDelay();
- else
- DoComplete();
- }
}
private void UpdateNeutralValue()
diff --git a/src/Avalonia.Base/Animation/Animators/Animator`1.cs b/src/Avalonia.Base/Animation/Animators/Animator`1.cs
index 20311c8389..88446fe533 100644
--- a/src/Avalonia.Base/Animation/Animators/Animator`1.cs
+++ b/src/Avalonia.Base/Animation/Animators/Animator`1.cs
@@ -18,8 +18,19 @@ namespace Avalonia.Animation.Animators
///
public virtual IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match, Action? onComplete, bool shouldPauseOnInvisible)
+ => Apply(animation, control, clock, match, onComplete, shouldPauseOnInvisible, control as Visual);
+
+ internal IDisposable? Apply(Animation animation, Animatable control, IClock? clock, IObservable match,
+ Action? onComplete, bool shouldPauseOnInvisible, Visual? visualTarget)
{
- var subject = new DisposeAnimationInstanceSubject(this, animation, control, clock, onComplete, shouldPauseOnInvisible);
+ var subject = new DisposeAnimationInstanceSubject(
+ this,
+ animation,
+ control,
+ clock,
+ onComplete,
+ shouldPauseOnInvisible,
+ visualTarget);
return new CompositeDisposable(match.Subscribe(subject), subject);
}
@@ -103,7 +114,8 @@ namespace Avalonia.Animation.Animators
///
/// Runs the KeyFrames Animation.
///
- internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete, bool shouldPauseOnInvisible)
+ internal IDisposable Run(Animation animation, Animatable control, IClock? clock, Action? onComplete,
+ bool shouldPauseOnInvisible, Visual? visualTarget)
{
var instance = new AnimationInstance(
animation,
@@ -112,7 +124,8 @@ namespace Avalonia.Animation.Animators
clock ?? control.Clock ?? Clock.GlobalClock,
onComplete,
InterpolationHandler,
- shouldPauseOnInvisible);
+ shouldPauseOnInvisible,
+ visualTarget);
return BindAnimation(control, instance);
}
diff --git a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs
index aef5c78f95..260a2a71ab 100644
--- a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs
+++ b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs
@@ -67,7 +67,7 @@ namespace Avalonia.Animation.Animators
if (renderTransformType == Property.OwnerType)
{
return _doubleAnimator.Apply(animation, (Transform) ctrl.RenderTransform, clock ?? control.Clock,
- obsMatch, onComplete, shouldPauseOnInvisible);
+ obsMatch, onComplete, shouldPauseOnInvisible, ctrl);
}
// It's a TransformGroup and try finding the target there.
else if (renderTransformType == typeof(TransformGroup))
@@ -77,7 +77,7 @@ namespace Avalonia.Animation.Animators
if (transform.GetType() == Property.OwnerType)
{
return _doubleAnimator.Apply(animation, transform, clock ?? control.Clock,
- obsMatch, onComplete, shouldPauseOnInvisible);
+ obsMatch, onComplete, shouldPauseOnInvisible, ctrl);
}
}
}
diff --git a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs
index 7d355cc77c..36ec54127c 100644
--- a/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs
+++ b/src/Avalonia.Base/Animation/DisposeAnimationInstanceSubject.cs
@@ -12,7 +12,8 @@ namespace Avalonia.Animation
Animatable control,
IClock? clock,
Action? onComplete,
- bool shouldPauseOnInvisible)
+ bool shouldPauseOnInvisible,
+ Visual? visualTarget)
: IObserver, IDisposable
{
private IDisposable? _lastInstance;
@@ -41,7 +42,13 @@ namespace Avalonia.Animation
if (matchVal)
{
- _lastInstance = animator.Run(animation, control, clock, onComplete, shouldPauseOnInvisible);
+ _lastInstance = animator.Run(
+ animation,
+ control,
+ clock,
+ onComplete,
+ shouldPauseOnInvisible,
+ visualTarget);
}
else
{
diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj
index 99524857a7..1d3929001b 100644
--- a/src/Avalonia.Base/Avalonia.Base.csproj
+++ b/src/Avalonia.Base/Avalonia.Base.csproj
@@ -40,6 +40,7 @@
+
@@ -50,6 +51,7 @@
+
diff --git a/src/Avalonia.Base/Collections/AvaloniaList.cs b/src/Avalonia.Base/Collections/AvaloniaList.cs
index 324e33e5af..4af60b2cc2 100644
--- a/src/Avalonia.Base/Collections/AvaloniaList.cs
+++ b/src/Avalonia.Base/Collections/AvaloniaList.cs
@@ -49,7 +49,7 @@ namespace Avalonia.Collections
///
public class AvaloniaList : IAvaloniaList, IList, INotifyCollectionChangedDebug
{
- private readonly List _inner;
+ private List _inner;
private NotifyCollectionChangedEventHandler? _collectionChanged;
///
@@ -144,6 +144,16 @@ namespace Avalonia.Collections
internal IAvaloniaListItemValidator? Validator { get; set; }
+ private protected List Inner
+ {
+ get => _inner;
+ set => _inner = value;
+ }
+
+ private protected virtual void OnMutating()
+ {
+ }
+
///
bool IList.IsFixedSize => false;
@@ -182,6 +192,7 @@ namespace Avalonia.Collections
if (!EqualityComparer.Default.Equals(old, value))
{
+ OnMutating();
_inner[index] = value;
if (_collectionChanged != null)
@@ -224,6 +235,9 @@ namespace Avalonia.Collections
public virtual void Add(T item)
{
Validator?.Validate(item);
+
+ OnMutating();
+
int index = _inner.Count;
_inner.Add(item);
NotifyAdd(item, index);
@@ -242,6 +256,8 @@ namespace Avalonia.Collections
{
if (Count > 0)
{
+ OnMutating();
+
if (_collectionChanged != null)
{
var e = ResetBehavior == ResetBehavior.Reset ?
@@ -331,6 +347,9 @@ namespace Avalonia.Collections
public virtual void Insert(int index, T item)
{
Validator?.Validate(item);
+
+ OnMutating();
+
_inner.Insert(index, item);
NotifyAdd(item, index);
}
@@ -351,6 +370,8 @@ namespace Avalonia.Collections
{
if (list.Count > 0)
{
+ OnMutating();
+
if (list is ICollection collection)
{
if (hasValidation)
@@ -395,6 +416,8 @@ namespace Avalonia.Collections
{
if (en.MoveNext())
{
+ OnMutating();
+
// Avoid allocating list for collection notification if there is no event subscriptions.
List? notificationItems = willRaiseCollectionChanged ?
new List() :
@@ -438,6 +461,9 @@ namespace Avalonia.Collections
public void Move(int oldIndex, int newIndex)
{
var item = this[oldIndex];
+
+ OnMutating();
+
_inner.RemoveAt(oldIndex);
_inner.Insert(newIndex, item);
@@ -462,6 +488,9 @@ namespace Avalonia.Collections
{
var items = _inner.GetRange(oldIndex, count);
var modifiedNewIndex = newIndex;
+
+ OnMutating();
+
_inner.RemoveRange(oldIndex, count);
if (newIndex > oldIndex)
@@ -515,6 +544,7 @@ namespace Avalonia.Collections
if (index != -1)
{
+ OnMutating();
_inner.RemoveAt(index);
NotifyRemove(item , index);
return true;
@@ -558,6 +588,7 @@ namespace Avalonia.Collections
public virtual void RemoveAt(int index)
{
T item = _inner[index];
+ OnMutating();
_inner.RemoveAt(index);
NotifyRemove(item , index);
}
@@ -572,6 +603,7 @@ namespace Avalonia.Collections
if (count > 0)
{
var list = _inner.GetRange(index, count);
+ OnMutating();
_inner.RemoveRange(index, count);
NotifyRemove(list, index);
}
diff --git a/src/Avalonia.Base/CombinedGeometry.cs b/src/Avalonia.Base/CombinedGeometry.cs
index a47e756c88..d7297b3749 100644
--- a/src/Avalonia.Base/CombinedGeometry.cs
+++ b/src/Avalonia.Base/CombinedGeometry.cs
@@ -55,6 +55,11 @@ namespace Avalonia.Media
public static readonly StyledProperty GeometryCombineModeProperty =
AvaloniaProperty.Register(nameof(GeometryCombineMode));
+ static CombinedGeometry()
+ {
+ AffectsGeometry(Geometry1Property, Geometry2Property, GeometryCombineModeProperty);
+ }
+
///
/// Initializes a new instance of the class.
///
@@ -145,6 +150,22 @@ namespace Avalonia.Media
return new CombinedGeometry(GeometryCombineMode, Geometry1, Geometry2, Transform);
}
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == Geometry1Property || change.Property == Geometry2Property)
+ {
+ var (oldValue, newValue) = change.GetOldAndNewValue();
+
+ oldValue?.Changed -= ChildGeometryChanged;
+ newValue?.Changed += ChildGeometryChanged;
+ }
+ }
+
+ private void ChildGeometryChanged(object? sender, EventArgs e)
+ => InvalidateGeometry();
+
private protected sealed override IGeometryImpl? CreateDefiningGeometry()
{
var g1 = Geometry1;
diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs
index 281e9f6b61..14c592b94c 100644
--- a/src/Avalonia.Base/Data/Core/BindingExpression.cs
+++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs
@@ -31,6 +31,7 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I
private readonly List _nodes;
private readonly TargetTypeConverter? _targetTypeConverter;
private readonly UncommonFields? _uncommon;
+ private int _updateTargetDepth;
private bool _shouldUpdateOneTimeBindingTarget;
///
@@ -167,10 +168,19 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I
var source = _nodes[0].Source;
- for (var i = 0; i < _nodes.Count; ++i)
- _nodes[i].SetSource(AvaloniaProperty.UnsetValue, null);
+ ++_updateTargetDepth;
+
+ try
+ {
+ for (var i = 0; i < _nodes.Count; ++i)
+ _nodes[i].SetSource(AvaloniaProperty.UnsetValue, null);
- _nodes[0].SetSource(source, null);
+ _nodes[0].SetSource(source, null);
+ }
+ finally
+ {
+ --_updateTargetDepth;
+ }
}
///
@@ -206,7 +216,9 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I
new BindingError(dataValidationError, BindingErrorType.DataValidationError) :
null;
- var forceUpdate = _mode == BindingMode.OneWay;
+ // UpdateTarget must reapply the source value even if this expression already
+ // has it cached: a two-way target may contain an uncommitted local value.
+ var forceUpdate = _mode == BindingMode.OneWay || _updateTargetDepth > 0;
ConvertAndPublishValue(value, error, forceUpdate);
}
}
diff --git a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionGrammar.cs b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionGrammar.cs
index c49468f1cd..36f3924d00 100644
--- a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionGrammar.cs
+++ b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionGrammar.cs
@@ -138,7 +138,13 @@ namespace Avalonia.Data.Core.Parsers
else if (ParseDot(ref r))
{
nodes.Add(new EmptyExpressionNode());
- return State.End;
+ return State.AfterMember;
+ }
+ else if (ParseStreamOperator(ref r))
+ {
+ nodes.Add(new EmptyExpressionNode());
+ nodes.Add(new StreamNode());
+ return State.AfterMember;
}
else
{
diff --git a/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs b/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs
index d2e6f23e29..5213a2180f 100644
--- a/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs
+++ b/src/Avalonia.Base/Data/Core/Plugins/ReflectionMethodAccessorPlugin.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
@@ -10,10 +11,9 @@ namespace Avalonia.Data.Core.Plugins
[RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)]
internal class ReflectionMethodAccessorPlugin : IPropertyAccessorPlugin
{
- private readonly Dictionary<(Type, string), MethodInfo?> _methodLookup =
- new Dictionary<(Type, string), MethodInfo?>();
+ private readonly Dictionary<(Type, string), MethodLookupResult> _methodLookup = new();
- public bool Match(object obj, string methodName) => GetFirstMethodWithName(obj.GetType(), methodName) != null;
+ public bool Match(object obj, string methodName) => GetMethod(obj.GetType(), methodName).IsMatch;
public IPropertyAccessor? Start(WeakReference
public const string Win32Platform = nameof(Win32Platform);
+
+ ///
+ /// The log event comes from the WinUI Platform.
+ ///
+ public const string WinUIPlatform = nameof(WinUIPlatform);
///
/// The log event comes from X11 Platform.
diff --git a/src/Avalonia.Base/Media/Color.cs b/src/Avalonia.Base/Media/Color.cs
index ae16c30320..624513f065 100644
--- a/src/Avalonia.Base/Media/Color.cs
+++ b/src/Avalonia.Base/Media/Color.cs
@@ -21,7 +21,7 @@ namespace Avalonia.Media
#if !BUILDTASK
public
#endif
- readonly struct Color : IEquatable
+ readonly struct Color : IEquatable, IFormattable
{
private const double byteToDouble = 1.0 / 255;
@@ -461,6 +461,97 @@ namespace Avalonia.Media
}
}
+ ///
+ /// Returns a formatted string representation of the color.
+ ///
+ ///
+ /// The format specifier. All color types support all specifiers via auto-conversion.
+ /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode:
+ ///
+ /// - null or "" — Default (known name or #aarrggbb)
+ /// - "X" — XAML hex with alpha: #AARRGGBB
+ /// - "x" — Hex without alpha: #RRGGBB
+ /// - "H" — HTML hex with alpha: #RRGGBBAA
+ /// - "R" — CSS rgba absolute: rgba(r, g, b, a)
+ /// - "r" — CSS rgb absolute: rgb(r, g, b)
+ /// - "R%" — CSS rgba percent: rgba(r%, g%, b%, a%)
+ /// - "r%" — CSS rgb percent: rgb(r%, g%, b%)
+ /// - "L"/"l"/"L%"/"l%" — CSS hsl/hsla (auto-converts to HSL)
+ /// - "V"/"v"/"V%"/"v%" — CSS hsv/hsva (auto-converts to HSV)
+ ///
+ /// "C" and "A" are reserved for future complex format strings.
+ ///
+ /// Ignored. Color formatting is culture-invariant.
+ /// The formatted string representation.
+ /// Thrown when is not recognized.
+ public string ToString(string? format, IFormatProvider? formatProvider)
+ {
+ if (string.IsNullOrEmpty(format))
+ {
+ return ToString();
+ }
+
+ return format switch
+ {
+ "X" => $"#{A:X2}{R:X2}{G:X2}{B:X2}",
+ "x" => $"#{R:X2}{G:X2}{B:X2}",
+ "H" => $"#{R:X2}{G:X2}{B:X2}{A:X2}",
+
+ "R" => FormatRgbCss(),
+ "r" => FormatRgbCss(includeAlpha: false),
+ "R%" => FormatRgbPercentCss(),
+ "r%" => FormatRgbPercentCss(includeAlpha: false),
+
+ "L" or "l" or "L%" or "l%" => ToHsl().ToString(format, formatProvider),
+ "V" or "v" or "V%" or "v%" => ToHsv().ToString(format, formatProvider),
+
+ _ => throw new FormatException($"Format string '{format}' is not supported.")
+ };
+ }
+
+ ///
+ /// Formats the color as a CSS rgb() or rgba() string using absolute RGB components and fractional alpha.
+ ///
+ private string FormatRgbCss(bool includeAlpha = true)
+ {
+ if (includeAlpha)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "rgba({0}, {1}, {2}, {3:F2})",
+ R,
+ G,
+ B,
+ A * byteToDouble);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "rgb({0}, {1}, {2})", R, G, B);
+ }
+
+ ///
+ /// Formats the color as a CSS rgb() or rgba() string with all components as percentages.
+ ///
+ private string FormatRgbPercentCss(bool includeAlpha = true)
+ {
+ int rPct = (int)Math.Round(R * byteToDouble * 100.0);
+ int gPct = (int)Math.Round(G * byteToDouble * 100.0);
+ int bPct = (int)Math.Round(B * byteToDouble * 100.0);
+
+ if (includeAlpha)
+ {
+ int aPct = (int)Math.Round(A * byteToDouble * 100.0);
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "rgba({0}%, {1}%, {2}%, {3}%)",
+ rPct,
+ gPct,
+ bPct,
+ aPct);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "rgb({0}%, {1}%, {2}%)", rPct, gPct, bPct);
+ }
+
///
/// Returns the integer representation of the color.
///
diff --git a/src/Avalonia.Base/Media/Drawing.cs b/src/Avalonia.Base/Media/Drawing.cs
index 584e5978fc..f754eb5bfa 100644
--- a/src/Avalonia.Base/Media/Drawing.cs
+++ b/src/Avalonia.Base/Media/Drawing.cs
@@ -1,4 +1,6 @@
-namespace Avalonia.Media
+using System;
+
+namespace Avalonia.Media
{
///
/// Abstract class that describes a 2-D drawing.
@@ -7,9 +9,16 @@
{
internal Drawing()
{
-
}
-
+
+ ///
+ /// Raised when the drawing changes in a way that requires its consumer to re-record it: a referenced resource
+ /// is replaced (e.g. a new brush, pen, geometry or child), or a value that is baked into the recorded drawing
+ /// commands changes (e.g. a transform, opacity or effect). Mutations _within_ a referenced compositor-aware
+ /// resource (a brush's color, a geometry's shape) are not signaled here: the compositor propagates those on its own.
+ ///
+ internal event EventHandler? Invalidated;
+
///
/// Draws this drawing to the given .
///
@@ -22,5 +31,48 @@
/// Gets the drawing's bounding rectangle.
///
public abstract Rect GetBounds();
+
+ private protected void RaiseInvalidated()
+ => Invalidated?.Invoke(this, EventArgs.Empty);
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ // Every property of a drawing is baked into the recorded drawing commands or selects which resource is used,
+ // so any change requires a re-record. Keep subscriptions to nested values whose _own_ changes are also baked
+ // (and thus can't be propagated by the compositor) in sync.
+ UpdateValueSubscription(change.OldValue, subscribe: false);
+ UpdateValueSubscription(change.NewValue, subscribe: true);
+
+ RaiseInvalidated();
+ }
+
+ private void UpdateValueSubscription(object? value, bool subscribe)
+ {
+ switch (value)
+ {
+ // A transform's matrix is baked into the recorded commands, so its value changes (not just its
+ // replacement) require a re-record.
+ case Transform transform:
+ if (subscribe)
+ transform.Changed += ValueInvalidated;
+ else
+ transform.Changed -= ValueInvalidated;
+ break;
+
+ // Effects are baked as immutable, and nested image sources (e.g. a DrawingImage) are inlined,
+ // so their visual changes require a re-record too.
+ case IAffectsRender affectsRender:
+ if (subscribe)
+ affectsRender.Invalidated += ValueInvalidated;
+ else
+ affectsRender.Invalidated -= ValueInvalidated;
+ break;
+ }
+
+ void ValueInvalidated(object? sender, EventArgs e)
+ => RaiseInvalidated();
+ }
}
}
diff --git a/src/Avalonia.Base/Media/DrawingBrush.cs b/src/Avalonia.Base/Media/DrawingBrush.cs
index c4e5dc8d13..c1e0900ae5 100644
--- a/src/Avalonia.Base/Media/DrawingBrush.cs
+++ b/src/Avalonia.Base/Media/DrawingBrush.cs
@@ -1,6 +1,4 @@
using System;
-using Avalonia.Media.Immutable;
-using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
@@ -19,7 +17,7 @@ namespace Avalonia.Media
///
public static readonly StyledProperty DrawingProperty =
AvaloniaProperty.Register(nameof(Drawing));
-
+
///
/// Initializes a new instance of the class.
///
@@ -49,7 +47,7 @@ namespace Avalonia.Media
{
if (Drawing == null)
return null;
-
+
using var recorder = new RenderDataDrawingContext(null);
Drawing?.Draw(recorder);
return recorder.GetImmediateSceneBrushContent(this, null, true);
@@ -58,38 +56,84 @@ namespace Avalonia.Media
internal override Func Factory =>
static c => new ServerCompositionSimpleContentBrush(c.Server);
- private InlineDictionary _renderDataDictionary;
-
- private protected override void OnReferencedFromCompositor(Compositor c)
+ private sealed class RenderDataItem(CompositionRenderData data) : IDisposable
{
- _renderDataDictionary.Add(c, CreateServerContent(c));
- base.OnReferencedFromCompositor(c);
+ public CompositionRenderData Data { get; } = data;
+ public bool IsDirty;
+ public void Dispose() => Data.Dispose();
}
+ private InlineDictionary _renderDataDictionary;
+
protected override void OnUnreferencedFromCompositor(Compositor c)
{
if (_renderDataDictionary.TryGetAndRemoveValue(c, out var content))
content?.Dispose();
base.OnUnreferencedFromCompositor(c);
}
-
+
private protected override void SerializeChanges(Compositor c, BatchStreamWriter writer)
{
base.SerializeChanges(c, writer);
- if (_renderDataDictionary.TryGetValue(c, out var content) && content != null)
- writer.WriteObject(new CompositionRenderDataSceneBrushContent.Properties(content.Server, null, true));
- else
- writer.WriteObject(null);
+
+ CompositionRenderDataSceneBrushContent.Properties? content = null;
+ if (IsOnCompositor(c)) // Should always be true here, but just in case do this check
+ {
+ _renderDataDictionary.TryGetValue(c, out var data);
+ if (data is null || data.IsDirty)
+ {
+ var created = CreateServerContent(c);
+ // Dispose the old render list _after_ creating a new one to avoid unnecessary detach/attach
+ // sequence for referenced resources
+ data?.Dispose();
+ _renderDataDictionary[c] = data = created;
+ }
+
+ if (data is not null)
+ content = new(data.Data.Server, null, true);
+ }
+
+ writer.WriteObject(content);
}
-
- CompositionRenderData? CreateServerContent(Compositor c)
+
+ private void InvalidateContent()
{
- if (Drawing == null)
+ foreach (var item in _renderDataDictionary)
+ item.Value?.IsDirty = true;
+
+ RegisterForSerialization();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ // Replacing the drawing, or a structural change inside it (e.g. a replaced brush, pen or geometry) changes
+ // the recorded content; mark it dirty so it's re-recorded. Mutations _within_ a referenced compositor-aware
+ // resource (a brush's color, a geometry's shape) are propagated by the server without re-recording.
+ if (change.Property == DrawingProperty)
+ {
+ var (oldValue, newValue) = change.GetOldAndNewValue();
+
+ oldValue?.Invalidated -= DrawingInvalidated;
+ newValue?.Invalidated += DrawingInvalidated;
+
+ InvalidateContent();
+ }
+
+ base.OnPropertyChanged(change);
+
+ void DrawingInvalidated(object? sender, EventArgs e)
+ => InvalidateContent();
+ }
+
+ private RenderDataItem? CreateServerContent(Compositor c)
+ {
+ if (Drawing is not { } drawing)
return null;
-
+
using var recorder = new RenderDataDrawingContext(c);
- Drawing?.Draw(recorder);
- return recorder.GetRenderResults();
+ drawing.Draw(recorder);
+ var renderData = recorder.GetRenderResults();
+ return renderData is null ? null : new RenderDataItem(renderData);
}
}
}
diff --git a/src/Avalonia.Base/Media/DrawingContext.cs b/src/Avalonia.Base/Media/DrawingContext.cs
index 8003fb7c32..d3b43f1b8b 100644
--- a/src/Avalonia.Base/Media/DrawingContext.cs
+++ b/src/Avalonia.Base/Media/DrawingContext.cs
@@ -90,8 +90,19 @@ namespace Avalonia.Media
/// The geometry.
public void DrawGeometry(IBrush? brush, IPen? pen, Geometry geometry)
{
- if ((brush != null || PenIsVisible(pen)) && geometry.PlatformImpl != null)
- DrawGeometryCore(brush, pen, geometry.PlatformImpl);
+ if (brush != null || PenIsVisible(pen))
+ DrawGeometryCore(brush, pen, geometry);
+ }
+
+ ///
+ /// Draws a mutable geometry. Implementations that support compositor-aware resources can keep a reference to
+ /// the so that subsequent changes to it are propagated without re-recording.
+ /// The default implementation simply forwards the geometry's current .
+ ///
+ protected virtual void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry)
+ {
+ if (geometry.PlatformImpl is { } geometryImpl)
+ DrawGeometryCore(brush, pen, geometryImpl);
}
///
diff --git a/src/Avalonia.Base/Media/DrawingGroup.cs b/src/Avalonia.Base/Media/DrawingGroup.cs
index b2181d74fb..1b9157751b 100644
--- a/src/Avalonia.Base/Media/DrawingGroup.cs
+++ b/src/Avalonia.Base/Media/DrawingGroup.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Collections.Specialized;
using Avalonia.Metadata;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
@@ -35,6 +36,9 @@ namespace Avalonia.Media
private DrawingCollection _children = new DrawingCollection();
+ public DrawingGroup()
+ => _children.CollectionChanged += ChildrenCollectionChanged;
+
public double Opacity
{
get => GetValue(OpacityProperty);
@@ -81,12 +85,46 @@ namespace Avalonia.Media
get => _children;
set
{
+ if (ReferenceEquals(_children, value))
+ return;
+
+ _children.CollectionChanged -= ChildrenCollectionChanged;
+ foreach (var child in _children)
+ child.Invalidated -= ChildInvalidated;
+
SetAndRaise(ChildrenProperty, ref _children, value);
+
+ _children.CollectionChanged += ChildrenCollectionChanged;
+ foreach (var child in _children)
+ child.Invalidated += ChildInvalidated;
}
}
public DrawingContext Open() => new DrawingGroupDrawingContext(this);
+ private void ChildrenCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (e.Action == NotifyCollectionChangedAction.Reset)
+ throw new NotSupportedException();
+
+ if (e.OldItems is not null)
+ {
+ foreach (Drawing child in e.OldItems)
+ child.Invalidated -= ChildInvalidated;
+ }
+
+ if (e.NewItems is not null)
+ {
+ foreach (Drawing child in e.NewItems)
+ child.Invalidated += ChildInvalidated;
+ }
+
+ RaiseInvalidated();
+ }
+
+ private void ChildInvalidated(object? sender, EventArgs e)
+ => RaiseInvalidated();
+
internal override void DrawCore(DrawingContext context)
{
// Compute local bounds from children only when EffectBounds is not explicitly set.
diff --git a/src/Avalonia.Base/Media/DrawingImage.cs b/src/Avalonia.Base/Media/DrawingImage.cs
index 7949eaa351..d34f1d5379 100644
--- a/src/Avalonia.Base/Media/DrawingImage.cs
+++ b/src/Avalonia.Base/Media/DrawingImage.cs
@@ -98,10 +98,22 @@ namespace Avalonia.Media
{
base.OnPropertyChanged(change);
- if (change.Property == DrawingProperty || change.Property == ViewboxProperty)
+ if (change.Property == DrawingProperty)
+ {
+ var (oldValue, newValue) = change.GetOldAndNewValue();
+
+ oldValue?.Invalidated -= DrawingInvalidated;
+ newValue?.Invalidated += DrawingInvalidated;
+
+ RaiseInvalidated(EventArgs.Empty);
+ }
+ else if (change.Property == ViewboxProperty)
{
RaiseInvalidated(EventArgs.Empty);
}
+
+ void DrawingInvalidated(object? sender, EventArgs e)
+ => RaiseInvalidated(EventArgs.Empty);
}
///
diff --git a/src/Avalonia.Base/Media/FontManager.cs b/src/Avalonia.Base/Media/FontManager.cs
index 60200dea85..481a69eaf7 100644
--- a/src/Avalonia.Base/Media/FontManager.cs
+++ b/src/Avalonia.Base/Media/FontManager.cs
@@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Logging;
using Avalonia.Media.Fonts;
+using Avalonia.Media.TextFormatting.Unicode;
using Avalonia.Platform;
using Avalonia.Utilities;
@@ -262,6 +263,17 @@ namespace Avalonia.Media
///
public bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, out Typeface typeface)
+ => TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily, culture,
+ Script.Unknown, out typeface);
+
+ ///
+ /// Character-to-typeface match with an optional shaping-capability constraint: when
+ /// is a complex script, only fonts that can shape it are
+ /// considered. imposes no constraint and is identical to the
+ /// public overload.
+ ///
+ internal bool TryMatchCharacter(int codepoint, FontStyle fontStyle, FontWeight fontWeight,
+ FontStretch fontStretch, FontFamily? fontFamily, CultureInfo? culture, Script shapingScript, out Typeface typeface)
{
if (_fontFallbacks != null)
{
@@ -271,7 +283,9 @@ namespace Avalonia.Media
{
typeface = new Typeface(fallback.FontFamily, fontStyle, fontWeight, fontStretch);
- if (TryGetGlyphTypeface(typeface, out var glyphTypeface) && glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ if (TryGetGlyphTypeface(typeface, out var glyphTypeface) &&
+ glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) &&
+ (shapingScript == Script.Unknown || glyphTypeface.CanShapeScript(shapingScript)))
{
return true;
}
@@ -298,7 +312,7 @@ namespace Avalonia.Media
if (TryGetFontCollection(source, out var fontCollection) &&
// With composite fonts we need to first check if the font collection contains the family if not we skip it
fontCollection.TryGetGlyphTypeface(familyName, fontStyle, fontWeight, fontStretch, out _) &&
- fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, out typeface))
+ TryMatchCharacterInCollection(fontCollection, codepoint, fontStyle, fontWeight, fontStretch, familyName, culture, shapingScript, out typeface))
{
if (typeface.FontFamily.Name == DefaultFontFamily.Name && i + 1 < compositeKey.Keys.Count)
{
@@ -315,7 +329,7 @@ namespace Avalonia.Media
if (fontUri.IsFontCollection())
{
if (TryGetFontCollection(fontUri, out var fontCollection) &&
- fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily.Name, culture, out typeface))
+ TryMatchCharacterInCollection(fontCollection, codepoint, fontStyle, fontWeight, fontStretch, fontFamily.Name, culture, shapingScript, out typeface))
{
return true;
}
@@ -323,7 +337,23 @@ namespace Avalonia.Media
}
//Try to find a match with the system font collection
- return SystemFonts.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, fontFamily?.Name,
+ return TryMatchCharacterInCollection(SystemFonts, codepoint, fontStyle, fontWeight, fontStretch,
+ fontFamily?.Name, culture, shapingScript, out typeface);
+ }
+
+ // Routes through the shaping-aware overload when the collection is a FontCollectionBase (all
+ // built-in collections are); a foreign IFontCollection gets the unconstrained match.
+ private static bool TryMatchCharacterInCollection(IFontCollection fontCollection, int codepoint,
+ FontStyle fontStyle, FontWeight fontWeight, FontStretch fontStretch, string? familyName,
+ CultureInfo? culture, Script shapingScript, out Typeface typeface)
+ {
+ if (fontCollection is FontCollectionBase fontCollectionBase)
+ {
+ return fontCollectionBase.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch,
+ familyName, culture, shapingScript, out typeface);
+ }
+
+ return fontCollection.TryMatchCharacter(codepoint, fontStyle, fontWeight, fontStretch, familyName,
culture, out typeface);
}
diff --git a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
index 649c47f730..778a326e72 100644
--- a/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
+++ b/src/Avalonia.Base/Media/Fonts/FontCollectionBase.cs
@@ -20,8 +20,10 @@ namespace Avalonia.Media.Fonts
// Make this internal for testing purposes
internal readonly ConcurrentDictionary> _glyphTypefaceCache = new();
- // Cache of resolved script/culture fallback family names. A null value is a *negative* cache
- // entry which prevents repeated platform-fallback calls for the same script bucket.
+ // Cache of resolved script/culture fallback family names. A non-null value is the preferred
+ // fallback family for that script bucket: a Tier B hint that is still re-checked for coverage
+ // per codepoint, and which does NOT by itself suppress a platform call. A null value is a
+ // *negative* entry that prevents repeated platform-fallback calls for the same script bucket.
private readonly ConcurrentDictionary _scriptFallbackCache = new();
private readonly object _fontFamiliesLock = new();
@@ -45,6 +47,16 @@ namespace Avalonia.Media.Fonts
public virtual bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
string? familyName, CultureInfo? culture, out Typeface match)
+ => TryMatchCharacter(codepoint, style, weight, stretch, familyName, culture, Script.Unknown, out match);
+
+ ///
+ /// Character-to-typeface match with an optional shaping-capability constraint. When
+ /// is a complex script, only candidates that can shape it
+ /// () are considered;
+ /// imposes no constraint and is identical to the public overload.
+ ///
+ internal bool TryMatchCharacter(int codepoint, FontStyle style, FontWeight weight, FontStretch stretch,
+ string? familyName, CultureInfo? culture, Script shapingScript, out Typeface match)
{
match = default;
@@ -57,7 +69,7 @@ namespace Avalonia.Media.Fonts
// --- Tier A: requested family, coverage-checked, culture-compatible ---
if (familyName != null &&
_glyphTypefaceCache.TryGetValue(familyName, out var requestedFamily) &&
- TryGetCoveringMatch(requestedFamily, key, codepoint, isLastResort: false, out var requestedGlyphTypeface) &&
+ TryGetCoveringMatchForFamily(requestedFamily, key, codepoint, culture, shapingScript, out var requestedGlyphTypeface) &&
IsCultureCompatible(requestedGlyphTypeface, culture, script))
{
match = BuildTypefaceWithSynthesis(requestedGlyphTypeface, key);
@@ -71,7 +83,7 @@ namespace Avalonia.Media.Fonts
cachedFamily != null &&
!string.Equals(cachedFamily, familyName, StringComparison.OrdinalIgnoreCase) &&
_glyphTypefaceCache.TryGetValue(cachedFamily, out var cachedTypefaces) &&
- TryGetCoveringMatch(cachedTypefaces, key, codepoint, isLastResort: false, out var cachedGlyphTypeface))
+ TryGetCoveringMatchForFamily(cachedTypefaces, key, codepoint, culture, shapingScript, out var cachedGlyphTypeface))
{
match = BuildTypefaceWithSynthesis(cachedGlyphTypeface, key);
return true;
@@ -79,8 +91,14 @@ namespace Avalonia.Media.Fonts
}
// --- Tier C: deterministic cache sweep (non last-resort), culture-scored ---
- if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, isLastResort: false, out var bestGt, out var bestFamilyName))
- {
+ if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, shapingScript, isLastResort: false, out var bestGt, out var bestFamilyName))
+ {
+ // The sweep returns the nearest cached key for the winning family. A codepoint the
+ // bucket family can't cover (e.g. the Simplified-only 华 when the bucket is a JP font)
+ // lands here, so the exact-key upgrade is needed here too: otherwise a Bold face cached
+ // for an earlier run is rendered for a Normal request.
+ bestGt = PreferExactKey(bestGt, key, codepoint, culture, shapingScript);
+
if (FontFallbackScriptHints.IsLocaleSensitive(refinedScript) || culture != null)
{
_scriptFallbackCache.TryAdd(scriptKey, bestFamilyName);
@@ -90,25 +108,41 @@ namespace Avalonia.Media.Fonts
return true;
}
- // --- Tier D: platform fallback (at most once per (script, culture)) ---
- var platformAlreadyAttempted = _scriptFallbackCache.ContainsKey(scriptKey);
-
- if (!platformAlreadyAttempted &&
- TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt))
+ // --- Tier D: platform fallback ---
+ // Only a *negative* cache entry (the platform had no font for this script bucket)
+ // suppresses a retry. A *positive* entry must not: it records a preferred family for the
+ // bucket, but that family may not cover THIS codepoint - e.g. 中 (U+4E2D) resolves to a CJK
+ // font that lacks the Simplified-only 华 (U+534E) - and the platform resolves per codepoint,
+ // so it may still place a codepoint the bucket's font cannot. Reaching Tier D already means
+ // no cached family covered this codepoint.
+ // Skipped for shaping-constrained queries: the platform match is not capability-checked,
+ // and the caller's unconstrained pass already covers the platform path (so the negative
+ // cache stays consistent with unconstrained lookups).
+ if (shapingScript == Script.Unknown)
{
- _scriptFallbackCache.TryAdd(scriptKey, platformGt.FamilyName);
- match = BuildTypefaceWithSynthesis(platformGt, key);
- return true;
- }
+ var hasCacheEntry = _scriptFallbackCache.TryGetValue(scriptKey, out var cachedFamily);
+ var platformAlreadyAttempted = hasCacheEntry && cachedFamily is null;
- if (!platformAlreadyAttempted)
- {
- // Remember the negative answer so we don't ask the platform again.
- _scriptFallbackCache.TryAdd(scriptKey, null);
+ if (!platformAlreadyAttempted &&
+ TryMatchCharacterFromPlatform(codepoint, key, familyName, culture, out var platformGt))
+ {
+ // Keep any existing positive hint (TryAdd won't overwrite); registering the match
+ // lets later lookups for this codepoint be served by Tier C without the platform.
+ _scriptFallbackCache.TryAdd(scriptKey, platformGt.FamilyName);
+ match = BuildTypefaceWithSynthesis(platformGt, key);
+ return true;
+ }
+
+ // Record a negative only when nothing was cached for the bucket yet, so a positive hint
+ // for one codepoint isn't downgraded to a negative by another the platform can't place.
+ if (!hasCacheEntry)
+ {
+ _scriptFallbackCache.TryAdd(scriptKey, null);
+ }
}
// --- Tier E: last-resort cache sweep ---
- if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, isLastResort: true, out var lrGt, out _))
+ if (TryMatchInCache(codepoint, key, familyName, culture, script, refinedScript, shapingScript, isLastResort: true, out var lrGt, out _))
{
match = BuildTypefaceWithSynthesis(lrGt, key);
return true;
@@ -124,6 +158,7 @@ namespace Avalonia.Media.Fonts
CultureInfo? culture,
Script script,
Script refinedScript,
+ Script shapingScript,
bool isLastResort,
[NotNullWhen(true)] out GlyphTypeface? bestGlyphTypeface,
[NotNullWhen(true)] out string? bestFamilyName)
@@ -149,7 +184,7 @@ namespace Avalonia.Media.Fonts
continue;
}
- if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, out var candidate))
+ if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort, shapingScript, out var candidate))
{
continue;
}
@@ -280,7 +315,7 @@ namespace Avalonia.Media.Fonts
///
/// Hook for platform-backed collections (e.g. ) to consult
/// the underlying font manager for a fallback typeface. Invoked at most once per
- /// (script-bucket, culture) pair from .
+ /// (script-bucket, culture) pair from .
///
protected virtual bool TryMatchCharacterFromPlatform(
int codepoint,
@@ -293,23 +328,82 @@ namespace Avalonia.Media.Fonts
return false;
}
+ ///
+ /// Resolves a covering face for a single family at the requested key. Takes the cheap cached
+ /// covering match first (an exact-key hit needs nothing more) and only escalates to the exact
+ /// key when that match differs in any axis, so a Bold (or Italic, or Condensed) face cached for
+ /// one run is not reused for a differently-keyed request of the same family.
+ ///
+ private bool TryGetCoveringMatchForFamily(
+ ConcurrentDictionary glyphTypefaces,
+ FontCollectionKey key,
+ int codepoint,
+ CultureInfo? culture,
+ Script shapingScript,
+ [NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
+ {
+ if (!TryGetCoveringMatch(glyphTypefaces, key, codepoint, isLastResort: false, shapingScript, out glyphTypeface))
+ {
+ return false;
+ }
+
+ glyphTypeface = PreferExactKey(glyphTypeface, key, codepoint, culture, shapingScript);
+ return true;
+ }
+
+ ///
+ /// When differs from the requested in
+ /// any axis (style, weight or stretch), asks the platform for the exact-key face of the same
+ /// family - the only source of a key the cache lacks - via ,
+ /// and returns it when it is an exact, shapeable match; otherwise returns the input unchanged.
+ /// The platform is consulted only on a mismatch, and a collection without one keeps the
+ /// neighbouring match. This guards every key axis, not just weight.
+ ///
+ private GlyphTypeface PreferExactKey(
+ GlyphTypeface glyphTypeface,
+ FontCollectionKey key,
+ int codepoint,
+ CultureInfo? culture,
+ Script shapingScript)
+ {
+ // The platform's character match, biased by the family already resolved, yields that family
+ // at the requested key when it has that face (MatchCharacter covers the codepoint, so no
+ // extra coverage check is needed). Accept it only when it is the exact key and can shape.
+ if (glyphTypeface.ToFontCollectionKey() != key &&
+ TryMatchCharacterFromPlatform(codepoint, key, glyphTypeface.FamilyName, culture, out var exact) &&
+ exact.ToFontCollectionKey() == key &&
+ CanShape(exact, shapingScript))
+ {
+ return exact;
+ }
+
+ return glyphTypeface;
+ }
+
///
/// Picks a variant of the family that both is close to the requested key and actually maps
/// the requested codepoint. Falls back through the existing weight/stretch search but
/// filters every candidate through the font's character-to-glyph map.
///
+ // A candidate satisfies a shaping-capability constraint when it can shape the requested
+ // script; Script.Unknown means no constraint (the historical, unconstrained behaviour).
+ private static bool CanShape(GlyphTypeface glyphTypeface, Script shapingScript)
+ => shapingScript == Script.Unknown || glyphTypeface.CanShapeScript(shapingScript);
+
private static bool TryGetCoveringMatch(
ConcurrentDictionary glyphTypefaces,
FontCollectionKey key,
int codepoint,
bool isLastResort,
+ Script shapingScript,
[NotNullWhen(true)] out GlyphTypeface? glyphTypeface)
{
// Exact key first.
if (glyphTypefaces.TryGetValue(key, out glyphTypeface) &&
glyphTypeface != null &&
glyphTypeface.IsLastResort == isLastResort &&
- glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) &&
+ CanShape(glyphTypeface, shapingScript))
{
return true;
}
@@ -330,7 +424,8 @@ namespace Avalonia.Media.Fonts
continue;
}
- if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ if (!candidate.CharacterToGlyphMap.TryGetGlyph(codepoint, out _) ||
+ !CanShape(candidate, shapingScript))
{
continue;
}
diff --git a/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs b/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
index c1912f62e9..ed05886f35 100644
--- a/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
+++ b/src/Avalonia.Base/Media/Fonts/FontFallbackScriptHints.cs
@@ -5,7 +5,7 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.Fonts
{
///
- /// Script-aware hints used by to
+ /// Script-aware hints used by to
/// disambiguate locale-sensitive scripts (e.g. CJK), to provide deterministic probe
/// codepoints for scoring candidate fonts, and to decide whether a candidate font is
/// compatible with the caller's culture.
@@ -177,5 +177,73 @@ namespace Avalonia.Media.Fonts
return bit >= 0;
}
+
+ // OpenType GSUB/GPOS script tags a font must declare to shape each complex script. Indic
+ // and Myanmar expose a modern ("dev2") and a legacy ("deva") tag; either is accepted.
+ private static readonly OpenTypeTag Arab = OpenTypeTag.Parse("arab");
+ private static readonly OpenTypeTag Syrc = OpenTypeTag.Parse("syrc");
+ private static readonly OpenTypeTag Mong = OpenTypeTag.Parse("mong");
+ private static readonly OpenTypeTag Thaa = OpenTypeTag.Parse("thaa");
+ private static readonly OpenTypeTag Khmr = OpenTypeTag.Parse("khmr");
+ private static readonly OpenTypeTag Tibt = OpenTypeTag.Parse("tibt");
+ private static readonly OpenTypeTag Sinh = OpenTypeTag.Parse("sinh");
+ private static readonly OpenTypeTag Dev2 = OpenTypeTag.Parse("dev2");
+ private static readonly OpenTypeTag Deva = OpenTypeTag.Parse("deva");
+ private static readonly OpenTypeTag Bng2 = OpenTypeTag.Parse("bng2");
+ private static readonly OpenTypeTag Beng = OpenTypeTag.Parse("beng");
+ private static readonly OpenTypeTag Gur2 = OpenTypeTag.Parse("gur2");
+ private static readonly OpenTypeTag Guru = OpenTypeTag.Parse("guru");
+ private static readonly OpenTypeTag Gjr2 = OpenTypeTag.Parse("gjr2");
+ private static readonly OpenTypeTag Gujr = OpenTypeTag.Parse("gujr");
+ private static readonly OpenTypeTag Ory2 = OpenTypeTag.Parse("ory2");
+ private static readonly OpenTypeTag Orya = OpenTypeTag.Parse("orya");
+ private static readonly OpenTypeTag Tml2 = OpenTypeTag.Parse("tml2");
+ private static readonly OpenTypeTag Taml = OpenTypeTag.Parse("taml");
+ private static readonly OpenTypeTag Tel2 = OpenTypeTag.Parse("tel2");
+ private static readonly OpenTypeTag Telu = OpenTypeTag.Parse("telu");
+ private static readonly OpenTypeTag Knd2 = OpenTypeTag.Parse("knd2");
+ private static readonly OpenTypeTag Knda = OpenTypeTag.Parse("knda");
+ private static readonly OpenTypeTag Mlm2 = OpenTypeTag.Parse("mlm2");
+ private static readonly OpenTypeTag Mlym = OpenTypeTag.Parse("mlym");
+ private static readonly OpenTypeTag Mym2 = OpenTypeTag.Parse("mym2");
+ private static readonly OpenTypeTag Mymr = OpenTypeTag.Parse("mymr");
+
+ ///
+ /// For scripts that require OpenType complex shaping (cursive joining, reordering, conjunct
+ /// formation, dependent-vowel positioning, …), returns the GSUB/GPOS script tag(s) a font
+ /// must declare in order to shape them. A font that maps the codepoints through cmap but
+ /// declares none of these tags cannot shape the script and should be skipped during fallback.
+ ///
+ ///
+ /// true with one or two acceptable script tags (Indic and Myanmar expose both a modern
+ /// "dev2"-style tag and a legacy "deva"-style tag; for single-tag scripts
+ /// equals ). false for scripts
+ /// that render acceptably from cmap alone (Latin, CJK, Hangul, Hebrew, Thai, …), for which no
+ /// layout-table gate should apply.
+ ///
+ public static bool TryGetComplexShapingTags(Script script, out OpenTypeTag primary, out OpenTypeTag secondary)
+ {
+ switch (script)
+ {
+ case Script.Arabic: primary = secondary = Arab; return true;
+ case Script.Syriac: primary = secondary = Syrc; return true;
+ case Script.Mongolian: primary = secondary = Mong; return true;
+ case Script.Thaana: primary = secondary = Thaa; return true;
+ case Script.Khmer: primary = secondary = Khmr; return true;
+ case Script.Tibetan: primary = secondary = Tibt; return true;
+ case Script.Sinhala: primary = secondary = Sinh; return true;
+ case Script.Devanagari: primary = Dev2; secondary = Deva; return true;
+ case Script.Bengali: primary = Bng2; secondary = Beng; return true;
+ case Script.Gurmukhi: primary = Gur2; secondary = Guru; return true;
+ case Script.Gujarati: primary = Gjr2; secondary = Gujr; return true;
+ case Script.Oriya: primary = Ory2; secondary = Orya; return true;
+ case Script.Tamil: primary = Tml2; secondary = Taml; return true;
+ case Script.Telugu: primary = Tel2; secondary = Telu; return true;
+ case Script.Kannada: primary = Knd2; secondary = Knda; return true;
+ case Script.Malayalam: primary = Mlm2; secondary = Mlym; return true;
+ case Script.Myanmar: primary = Mym2; secondary = Mymr; return true;
+ default: primary = default; secondary = default; return false;
+ }
+ }
}
}
diff --git a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs
index c7b013729c..390f4f4719 100644
--- a/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs
+++ b/src/Avalonia.Base/Media/Fonts/SystemFontCollection.cs
@@ -67,6 +67,10 @@ namespace Avalonia.Media.Fonts
//Add to cache with platform typeface family name first
TryAddGlyphTypeface(platformTypeface.FamilyName, key, glyphTypeface);
+
+ // Then the requested family name
+ if (familyName != platformTypeface.FamilyName)
+ TryAddGlyphTypeface(familyName, key, glyphTypeface);
//Add to cache
if (!TryAddGlyphTypeface(glyphTypeface))
diff --git a/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs b/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs
new file mode 100644
index 0000000000..adae14c335
--- /dev/null
+++ b/src/Avalonia.Base/Media/Fonts/Tables/ScriptListTable.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+
+namespace Avalonia.Media.Fonts.Tables
+{
+ ///
+ /// Reads the script tags declared in a font's OpenType Layout (GSUB/GPOS)
+ /// ScriptList. A declared script tag signals that the font carries shaping rules for that
+ /// script — the signal used to decide whether a font can actually shape a complex
+ /// script rather than merely map its codepoints through cmap.
+ ///
+ ///
+ internal static class ScriptListTable
+ {
+ private static readonly OpenTypeTag s_gsub = OpenTypeTag.Parse("GSUB");
+ private static readonly OpenTypeTag s_gpos = OpenTypeTag.Parse("GPOS");
+
+ ///
+ /// Adds the GSUB and GPOS script tags declared by to
+ /// .
+ ///
+ ///
+ /// false if a layout table is present but could not be parsed — the caller should
+ /// then treat shaping capability as unknown rather than unsupported (don't reject the font
+ /// on the strength of an empty set). An absent table is not a failure.
+ ///
+ public static bool TryReadScriptTags(GlyphTypeface glyphTypeface, HashSet scriptTags)
+ {
+ // Non-short-circuiting '&' so both tables are always attempted.
+ return TryReadScriptList(glyphTypeface, s_gsub, scriptTags)
+ & TryReadScriptList(glyphTypeface, s_gpos, scriptTags);
+ }
+
+ private static bool TryReadScriptList(GlyphTypeface glyphTypeface, OpenTypeTag tableTag,
+ HashSet scriptTags)
+ {
+ if (!glyphTypeface.PlatformTypeface.TryGetTable(tableTag, out var table))
+ {
+ // An absent table simply contributes no scripts
+ return true;
+ }
+
+ try
+ {
+ var reader = new BigEndianBinaryReader(table.Span);
+
+ // GSUB/GPOS header: majorVersion, minorVersion, scriptListOffset, featureListOffset,
+ // lookupListOffset.
+ reader.ReadUInt16();
+ reader.ReadUInt16();
+ var scriptListOffset = reader.ReadOffset16();
+
+ // ScriptList: scriptCount, then scriptCount ScriptRecords of (Tag, Offset16).
+ reader.Seek(scriptListOffset);
+
+ var scriptCount = reader.ReadUInt16();
+
+ for (var i = 0; i < scriptCount; i++)
+ {
+ scriptTags.Add(new OpenTypeTag(reader.ReadUInt32()));
+ reader.ReadOffset16(); // scriptOffset — not needed
+ }
+
+ return true;
+ }
+ catch (Exception)
+ {
+ // Malformed layout table — capability unknown.
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Media/Geometry.cs b/src/Avalonia.Base/Media/Geometry.cs
index 87b7c147d7..1499816122 100644
--- a/src/Avalonia.Base/Media/Geometry.cs
+++ b/src/Avalonia.Base/Media/Geometry.cs
@@ -1,9 +1,13 @@
using System;
-using Avalonia.Platform;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
+using Avalonia.Platform;
using Avalonia.Reactive;
+using Avalonia.Rendering.Composition;
+using Avalonia.Rendering.Composition.Drawing;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Rendering.Composition.Transport;
namespace Avalonia.Media
@@ -12,7 +16,10 @@ namespace Avalonia.Media
/// Defines a geometric shape.
///
[TypeConverter(typeof(GeometryTypeConverter))]
- public abstract class Geometry : AvaloniaObject
+ public abstract class Geometry :
+ AvaloniaObject,
+ ICompositionRenderResource,
+ ICompositorSerializable
{
///
/// Defines the property.
@@ -113,6 +120,16 @@ namespace Avalonia.Media
return PlatformImpl?.FillContains(point) == true;
}
+ ///
+ /// Returns a value that describes the intersection between the current geometry and the specified geometry
+ ///
+ /// The geometry to test for containment.
+ /// The describing the intersection between the geometries
+ public IntersectionResult? GetFillIntersectionResult(Geometry geometry)
+ {
+ return geometry.PlatformImpl == null ? IntersectionResult.Empty : PlatformImpl?.GetFillIntersectionResult(geometry.PlatformImpl);
+ }
+
///
/// Indicates whether the geometry's stroke contains the specified point.
///
@@ -168,6 +185,7 @@ namespace Avalonia.Media
_isDirty = true;
_platformImpl = null;
+ RegisterForSerialization();
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -209,6 +227,7 @@ namespace Avalonia.Media
_platformImpl = _platformImpl.WithTransform(transform.Value);
}
+ RegisterForSerialization();
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -297,6 +316,26 @@ namespace Avalonia.Media
segmentGeometry = new PlatformGeometry(segment);
return true;
}
+
+ private CompositorResourceHolder _resource;
+
+ private protected void RegisterForSerialization()
+ => _resource.RegisterForInvalidationOnAllCompositors(this);
+
+ ServerCompositionSimpleGeometry ICompositionRenderResource.GetForCompositor(Compositor c)
+ => _resource.GetForCompositor(c);
+
+ void ICompositionRenderResource.AddRefOnCompositor(Compositor c)
+ => _resource.CreateOrAddRef(c, this, out _, static c => new ServerCompositionSimpleGeometry(c.Server));
+
+ void ICompositionRenderResource.ReleaseOnCompositor(Compositor c)
+ => _resource.Release(c);
+
+ SimpleServerObject? ICompositorSerializable.TryGetServer(Compositor c)
+ => _resource.TryGetForCompositor(c);
+
+ void ICompositorSerializable.SerializeChanges(Compositor c, BatchStreamWriter writer)
+ => ServerCompositionSimpleGeometry.SerializeAllChanges(writer, PlatformImpl);
}
public class GeometryTypeConverter : TypeConverter
diff --git a/src/Avalonia.Base/Media/GeometryCollection.cs b/src/Avalonia.Base/Media/GeometryCollection.cs
index 2afa191dcf..3f2732943b 100644
--- a/src/Avalonia.Base/Media/GeometryCollection.cs
+++ b/src/Avalonia.Base/Media/GeometryCollection.cs
@@ -15,10 +15,12 @@ namespace Avalonia.Media
this.ForEachItem(
x =>
{
+ x.Changed += ChildChanged;
Parent?.Invalidate();
},
x =>
{
+ x.Changed -= ChildChanged;
Parent?.Invalidate();
},
() => throw new NotSupportedException());
@@ -31,15 +33,20 @@ namespace Avalonia.Media
this.ForEachItem(
x =>
{
+ x.Changed += ChildChanged;
Parent?.Invalidate();
},
x =>
{
+ x.Changed -= ChildChanged;
Parent?.Invalidate();
},
() => throw new NotSupportedException());
}
public GeometryGroup? Parent { get; set; }
+
+ private void ChildChanged(object? sender, EventArgs e)
+ => Parent?.Invalidate();
}
}
diff --git a/src/Avalonia.Base/Media/GeometryHitTestResult.cs b/src/Avalonia.Base/Media/GeometryHitTestResult.cs
new file mode 100644
index 0000000000..c43574a385
--- /dev/null
+++ b/src/Avalonia.Base/Media/GeometryHitTestResult.cs
@@ -0,0 +1,9 @@
+namespace Avalonia.Media
+{
+ ///
+ /// Returns the results of a hit test that uses a as a hit test parameter.
+ ///
+ /// Gets the that is returned from a hit test result.
+ /// Gets the value of the hit test.
+ public record class GeometryHitTestResult(Visual VisualHit, IntersectionResult IntersectionResult);
+}
diff --git a/src/Avalonia.Base/Media/GlyphTypeface.cs b/src/Avalonia.Base/Media/GlyphTypeface.cs
index 22aaf1c1c3..9edee48991 100644
--- a/src/Avalonia.Base/Media/GlyphTypeface.cs
+++ b/src/Avalonia.Base/Media/GlyphTypeface.cs
@@ -44,6 +44,13 @@ namespace Avalonia.Media
private ITextShaperTypeface? _textShaperTypeface;
private UnicodeRange? _supportedUnicodeRange;
+ // Lazily-built set of OpenType script tags the font declares in GSUB/GPOS, used by
+ // CanShapeScript. Parsing copies the layout tables, so it is deferred until a complex script
+ // is actually queried (most text never triggers it). Published via the volatile field.
+ private volatile HashSet? _shapingScriptTags;
+ private bool _shapingScriptTagsUnknown;
+ private readonly object _shapingScriptTagsLock = new();
+
///
/// Initializes a new instance of the class with the specified platform typeface and
/// font simulations.
@@ -543,6 +550,59 @@ namespace Avalonia.Media
return false;
}
+ ///
+ /// Determines whether this font can shape the specified script, not merely map its
+ /// codepoints. Scripts that need OpenType complex shaping (e.g. Arabic joining, Indic
+ /// conjuncts) require the font to declare the script in its GSUB/GPOS tables; scripts that
+ /// render acceptably from cmap alone always return true. Used by the fallback itemizer
+ /// to avoid selecting a font that has the glyphs but cannot form them correctly.
+ ///
+ public bool CanShapeScript(Script script)
+ {
+ if (!FontFallbackScriptHints.TryGetComplexShapingTags(script, out var primary, out var secondary))
+ {
+ // Simple script: cmap coverage (checked by the caller) is sufficient.
+ return true;
+ }
+
+ var tags = EnsureShapingScriptTags();
+
+ // A present-but-unparseable GSUB/GPOS leaves capability unknown — don't reject on that
+ // basis; cmap remains the authority as it was before.
+ if (_shapingScriptTagsUnknown)
+ {
+ return true;
+ }
+
+ return tags.Contains(primary) || tags.Contains(secondary);
+ }
+
+ private HashSet EnsureShapingScriptTags()
+ {
+ var tags = _shapingScriptTags;
+
+ if (tags is not null)
+ {
+ return tags;
+ }
+
+ lock (_shapingScriptTagsLock)
+ {
+ if (_shapingScriptTags is not null)
+ {
+ return _shapingScriptTags;
+ }
+
+ var set = new HashSet();
+
+ // Set the "unknown" flag before publishing the set so a lock-free reader that sees the
+ // volatile set also sees the flag.
+ _shapingScriptTagsUnknown = !ScriptListTable.TryReadScriptTags(this, set);
+
+ return _shapingScriptTags = set;
+ }
+ }
+
private UnicodeRange BuildSupportedUnicodeRange()
{
var segments = new List();
diff --git a/src/Avalonia.Base/Media/HslColor.cs b/src/Avalonia.Base/Media/HslColor.cs
index 02176d65d7..2a09575d69 100644
--- a/src/Avalonia.Base/Media/HslColor.cs
+++ b/src/Avalonia.Base/Media/HslColor.cs
@@ -17,7 +17,7 @@ namespace Avalonia.Media
#if !BUILDTASK
public
#endif
- readonly struct HslColor : IEquatable
+ readonly struct HslColor : IEquatable, IFormattable
{
///
/// Initializes a new instance of the struct.
@@ -177,6 +177,99 @@ namespace Avalonia.Media
return HslColor.ToHsv(H, S, L, A);
}
+ ///
+ /// Returns a formatted string representation of the HSL color.
+ ///
+ ///
+ /// The format specifier. All color types support all specifiers via auto-conversion.
+ /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode.
+ ///
+ /// - null or "" — Default (full-precision hsla(h, s, l, a))
+ /// - "X" — XAML hex: #AARRGGBB
+ /// - "x" — Hex: #RRGGBB
+ /// - "H" — HTML hex: #RRGGBBAA
+ /// - "R"/"r"/"R%"/"r%" — CSS rgb/rgba (auto-converts to RGB)
+ /// - "L" — CSS hsla: hsla(h, s%, l%, a)
+ /// - "l" — CSS hsl: hsl(h, s%, l%)
+ /// - "L%" — All percent: hsla(h%, s%, l%, a%)
+ /// - "l%" — All percent: hsl(h%, s%, l%)
+ /// - "V"/"v"/"V%"/"v%" — CSS hsv/hsva (auto-converts to HSV)
+ ///
+ /// "C" and "A" are reserved for future complex format strings.
+ ///
+ /// Ignored. Color formatting is culture-invariant.
+ /// The formatted string representation.
+ /// Thrown when is not recognized.
+ public string ToString(string? format, IFormatProvider? formatProvider)
+ {
+ if (string.IsNullOrEmpty(format))
+ {
+ return ToString();
+ }
+
+ return format switch
+ {
+ "X" or "x" or "H" or "R" or "r" or "R%" or "r%" => ToRgb().ToString(format, formatProvider),
+
+ "L" => FormatHslCss(),
+ "l" => FormatHslCss(includeAlpha: false),
+ "L%" => FormatHslPercentCss(),
+ "l%" => FormatHslPercentCss(includeAlpha: false),
+
+ "V" or "v" or "V%" or "v%" => ToHsv().ToString(format, formatProvider),
+
+ _ => throw new FormatException($"Format string '{format}' is not supported.")
+ };
+ }
+
+ ///
+ /// Formats the color as a CSS hsl() or hsla() string using degrees, percent saturation/lightness,
+ /// and fractional alpha.
+ ///
+ private string FormatHslCss(bool includeAlpha = true)
+ {
+ int hDeg = (int)Math.Round(H);
+ int sPct = (int)Math.Round(S * 100.0);
+ int lPct = (int)Math.Round(L * 100.0);
+
+ if (includeAlpha)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "hsla({0}, {1}%, {2}%, {3:F2})",
+ hDeg,
+ sPct,
+ lPct,
+ A);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "hsl({0}, {1}%, {2}%)", hDeg, sPct, lPct);
+ }
+
+ ///
+ /// Formats the color as a CSS hsl() or hsla() string with all components as percentages.
+ ///
+ private string FormatHslPercentCss(bool includeAlpha = true)
+ {
+ int hPct = (int)Math.Round(H / 360.0 * 100.0);
+ int sPct = (int)Math.Round(S * 100.0);
+ int lPct = (int)Math.Round(L * 100.0);
+
+ if (includeAlpha)
+ {
+ int aPct = (int)Math.Round(A * 100.0);
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "hsla({0}%, {1}%, {2}%, {3}%)",
+ hPct,
+ sPct,
+ lPct,
+ aPct);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "hsl({0}%, {1}%, {2}%)", hPct, sPct, lPct);
+ }
+
///
public override string ToString()
{
@@ -193,7 +286,7 @@ namespace Avalonia.Media
// hsla(230, 1.0, 0.5, 1.0)
//
- sb.Append("hsva(");
+ sb.Append("hsla(");
sb.Append(H.ToString(CultureInfo.InvariantCulture));
sb.Append(", ");
sb.Append(S.ToString(CultureInfo.InvariantCulture));
diff --git a/src/Avalonia.Base/Media/HsvColor.cs b/src/Avalonia.Base/Media/HsvColor.cs
index 03949d32aa..7ff180c8c5 100644
--- a/src/Avalonia.Base/Media/HsvColor.cs
+++ b/src/Avalonia.Base/Media/HsvColor.cs
@@ -17,7 +17,7 @@ namespace Avalonia.Media
#if !BUILDTASK
public
#endif
- readonly struct HsvColor : IEquatable
+ readonly struct HsvColor : IEquatable, IFormattable
{
///
/// Initializes a new instance of the struct.
@@ -207,6 +207,99 @@ namespace Avalonia.Media
return HsvColor.ToHsl(H, S, V, A);
}
+ ///
+ /// Returns a formatted string representation of the HSV color.
+ ///
+ ///
+ /// The format specifier. All color types support all specifiers via auto-conversion.
+ /// Uppercase includes alpha, lowercase excludes it. % suffix = percent mode.
+ ///
+ /// - null or "" — Default (full-precision hsva(h, s, v, a))
+ /// - "X" — XAML hex: #AARRGGBB
+ /// - "x" — Hex: #RRGGBB
+ /// - "H" — HTML hex: #RRGGBBAA
+ /// - "R"/"r"/"R%"/"r%" — CSS rgb/rgba (auto-converts to RGB)
+ /// - "L"/"l"/"L%"/"l%" — CSS hsl/hsla (auto-converts to HSL)
+ /// - "V" — CSS hsva: hsva(h, s%, v%, a)
+ /// - "v" — CSS hsv: hsv(h, s%, v%)
+ /// - "V%" — All percent: hsva(h%, s%, v%, a%)
+ /// - "v%" — All percent: hsv(h%, s%, v%)
+ ///
+ /// "C" and "A" are reserved for future complex format strings.
+ ///
+ /// Ignored. Color formatting is culture-invariant.
+ /// The formatted string representation.
+ /// Thrown when is not recognized.
+ public string ToString(string? format, IFormatProvider? formatProvider)
+ {
+ if (string.IsNullOrEmpty(format))
+ {
+ return ToString();
+ }
+
+ return format switch
+ {
+ "X" or "x" or "H" or "R" or "r" or "R%" or "r%" => ToRgb().ToString(format, formatProvider),
+
+ "L" or "l" or "L%" or "l%" => ToHsl().ToString(format, formatProvider),
+
+ "V" => FormatHsvCss(),
+ "v" => FormatHsvCss(includeAlpha: false),
+ "V%" => FormatHsvPercentCss(),
+ "v%" => FormatHsvPercentCss(includeAlpha: false),
+
+ _ => throw new FormatException($"Format string '{format}' is not supported.")
+ };
+ }
+
+ ///
+ /// Formats the color as a CSS hsv() or hsva() string using degrees, percent saturation/value,
+ /// and fractional alpha.
+ ///
+ private string FormatHsvCss(bool includeAlpha = true)
+ {
+ int hDeg = (int)Math.Round(H);
+ int sPct = (int)Math.Round(S * 100.0);
+ int vPct = (int)Math.Round(V * 100.0);
+
+ if (includeAlpha)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "hsva({0}, {1}%, {2}%, {3:F2})",
+ hDeg,
+ sPct,
+ vPct,
+ A);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "hsv({0}, {1}%, {2}%)", hDeg, sPct, vPct);
+ }
+
+ ///
+ /// Formats the color as a CSS hsv() or hsva() string with all components as percentages.
+ ///
+ private string FormatHsvPercentCss(bool includeAlpha = true)
+ {
+ int hPct = (int)Math.Round(H / 360.0 * 100.0);
+ int sPct = (int)Math.Round(S * 100.0);
+ int vPct = (int)Math.Round(V * 100.0);
+
+ if (includeAlpha)
+ {
+ int aPct = (int)Math.Round(A * 100.0);
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "hsva({0}%, {1}%, {2}%, {3}%)",
+ hPct,
+ sPct,
+ vPct,
+ aPct);
+ }
+
+ return string.Format(CultureInfo.InvariantCulture, "hsv({0}%, {1}%, {2}%)", hPct, sPct, vPct);
+ }
+
///
public override string ToString()
{
diff --git a/src/Avalonia.Base/Media/IImageBrush.cs b/src/Avalonia.Base/Media/IImageBrush.cs
index 439a9401d5..0852f55e8f 100644
--- a/src/Avalonia.Base/Media/IImageBrush.cs
+++ b/src/Avalonia.Base/Media/IImageBrush.cs
@@ -17,9 +17,21 @@ namespace Avalonia.Media
IImageBrushSource? Source { get; }
}
+ ///
+ /// Provides access to the image source of an .
+ ///
[NotClientImplementable]
public interface IImageBrushSource
{
internal IRef? Bitmap { get; }
+
+ ///
+ /// Gets the bitmap implementation of the image source.
+ ///
+ ///
+ /// The instance if available; otherwise, null.
+ ///
+ [PrivateApi]
+ IBitmapImpl? GetBitmap() => Bitmap?.Item;
}
}
diff --git a/src/Avalonia.Base/Media/Imaging/Bitmap.cs b/src/Avalonia.Base/Media/Imaging/Bitmap.cs
index dc1541414b..e04603914c 100644
--- a/src/Avalonia.Base/Media/Imaging/Bitmap.cs
+++ b/src/Avalonia.Base/Media/Imaging/Bitmap.cs
@@ -147,7 +147,7 @@ namespace Avalonia.Media.Imaging
IRef IBitmap.PlatformImpl => PlatformImpl;
///
- /// Saves the bitmap to a file.
+ /// Saves the bitmap to a file, in PNG format.
///
/// The filename.
///
@@ -155,23 +155,44 @@ namespace Avalonia.Media.Imaging
/// The quality value is interpreted from 0 - 100. If quality is null the default quality
/// setting is applied.
///
+ [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")]
public void Save(string fileName, int? quality = null)
+ => Save(fileName, PngBitmapEncoderOptions.Default);
+
+ ///
+ /// Saves the bitmap to a file with the specified options.
+ ///
+ /// The filename.
+ ///
+ /// The options specifying the format and settings to use.
+ /// Typical usages include and .
+ ///
+ public void Save(string fileName, BitmapEncoderOptions options)
{
- PlatformImpl.Item.Save(fileName, quality);
+ using var stream = File.Create(fileName);
+
+ Save(stream, options);
}
///
- /// Saves the bitmap to a stream.
+ /// Saves the bitmap to a stream, in PNG format.
///
/// The stream.
///
- /// The optional quality for compression.
- /// The quality value is interpreted from 0 - 100. If quality is null the default quality
+ /// The optional quality for compression.
+ /// The quality value is interpreted from 0 - 100. If quality is null the default quality
/// setting is applied.
///
+ [Obsolete($"Use the overload accepting {nameof(BitmapEncoderOptions)} instead.")]
public void Save(Stream stream, int? quality = null)
{
- PlatformImpl.Item.Save(stream, quality);
+ PlatformImpl.Item.Save(stream, PngBitmapEncoderOptions.Default);
+ }
+
+ ///
+ public void Save(Stream stream, BitmapEncoderOptions options)
+ {
+ PlatformImpl.Item.Save(stream, options);
}
public virtual PixelFormat? Format => (PlatformImpl.Item as IReadableBitmapImpl)?.Format;
@@ -196,32 +217,63 @@ namespace Avalonia.Media.Imaging
return sourceRect;
}
- private protected unsafe void CopyPixelsCore(PixelRect sourceRect, IntPtr buffer, int bufferSize, int stride,
- ILockedFramebuffer fb)
+ ///
+ /// Performs a row-by-row copy of pixels from a source buffer into a destination buffer.
+ ///
+ ///
+ /// is signed and may be negative: a negative value means the
+ /// source rows are laid out bottom-up, with pointing at the
+ /// first (top) row. The destination must be positive and at least the
+ /// tightly-packed row size. The caller is responsible for validating
+ /// against the source bounds (e.g. via ).
+ ///
+ internal static unsafe void CopyPixelsCore(PixelRect sourceRect, IntPtr sourceAddress, int sourceRowBytes,
+ PixelFormat sourceFormat, IntPtr buffer, int bufferSize, int stride)
{
- if (Format == null)
- throw new NotSupportedException("CopyPixels is not supported for this bitmap type");
-
- sourceRect = ValidateSourceRect(sourceRect);
-
- int minStride = checked(((sourceRect.Width * fb.Format.BitsPerPixel) + 7) / 8);
+ int minStride = checked(((sourceRect.Width * sourceFormat.BitsPerPixel) + 7) / 8);
if (stride < minStride)
throw new ArgumentOutOfRangeException(nameof(stride));
- var minBufferSize = stride * sourceRect.Height;
+ // 64-bit to avoid overflowing the guard for very large strides/heights, which would
+ // otherwise let an oversized contiguous blit/loop run past the buffers.
+ var minBufferSize = (long)stride * sourceRect.Height;
if (minBufferSize > bufferSize)
throw new ArgumentOutOfRangeException(nameof(bufferSize));
- var offsetX = checked(((sourceRect.X * Format.Value.BitsPerPixel) + 7) / 8);
+ var offsetX = checked(((sourceRect.X * sourceFormat.BitsPerPixel) + 7) / 8);
+
+ // Fast-path: when the source and destination layouts are identical, tightly-packed and
+ // forward (no row padding, no X offset, positive stride), the whole region is contiguous in
+ // both buffers and can be copied with a single blit. This is meaningfully faster than the
+ // per-row loop (up to ~5x for small images, ~30% for large ones). Requiring stride == minStride
+ // also guarantees we don't read past the source's last row.
+ if (offsetX == 0 && sourceRowBytes == stride && stride == minStride)
+ {
+ Unsafe.CopyBlock(buffer.ToPointer(),
+ (sourceAddress + sourceRowBytes * sourceRect.Y).ToPointer(), (uint)minBufferSize);
+ return;
+ }
for (var y = 0; y < sourceRect.Height; y++)
{
- var srcAddress = fb.Address + fb.RowBytes * (sourceRect.Y + y) + offsetX;
+ var srcAddress = sourceAddress + sourceRowBytes * (sourceRect.Y + y) + offsetX;
var dstAddress = buffer + stride * y;
Unsafe.CopyBlock(dstAddress.ToPointer(), srcAddress.ToPointer(), (uint)minStride);
}
}
+ ///
+ /// Validates against this bitmap and copies pixels out of the
+ /// given framebuffer. Self-contained convenience wrapper around the static
+ /// for inheritors.
+ ///
+ private protected void CopyPixelsCore(PixelRect sourceRect, IntPtr buffer, int bufferSize, int stride,
+ ILockedFramebuffer fb)
+ {
+ sourceRect = ValidateSourceRect(sourceRect);
+ CopyPixelsCore(sourceRect, fb.Address, fb.RowBytes, fb.Format, buffer, bufferSize, stride);
+ }
+
public virtual void CopyPixels(PixelRect sourceRect, IntPtr buffer, int bufferSize, int stride)
{
if (
@@ -235,7 +287,7 @@ namespace Avalonia.Media.Imaging
if (_isTranscoded)
throw new NotSupportedException("CopyPixels is not supported for transcoded bitmaps");
-
+
using (var fb = readable.Lock())
CopyPixelsCore(sourceRect, buffer, bufferSize, stride, fb);
}
diff --git a/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs
new file mode 100644
index 0000000000..e75e2ab65c
--- /dev/null
+++ b/src/Avalonia.Base/Media/Imaging/BitmapEncoderOptions.cs
@@ -0,0 +1,14 @@
+using System.IO;
+
+namespace Avalonia.Media.Imaging;
+
+///
+/// Represents the options used while saving a bitmap using .
+/// Common implementations are , .
+///
+public abstract class BitmapEncoderOptions
+{
+ internal BitmapEncoderOptions()
+ {
+ }
+}
diff --git a/src/Avalonia.Base/Media/Imaging/IBitmap.cs b/src/Avalonia.Base/Media/Imaging/IBitmap.cs
index f2cbc7a11e..e2e1611383 100644
--- a/src/Avalonia.Base/Media/Imaging/IBitmap.cs
+++ b/src/Avalonia.Base/Media/Imaging/IBitmap.cs
@@ -32,25 +32,13 @@ namespace Avalonia.Media.Imaging
IRef PlatformImpl { get; }
///
- /// Saves the bitmap to a file.
- ///
- /// The filename.
- ///
- /// The optional quality for compression if supported by the specific backend.
- /// The quality value is interpreted from 0 - 100. If quality is null the default quality
- /// setting of the backend is applied.
- ///
- void Save(string fileName, int? quality = null);
-
- ///
- /// Saves the bitmap to a stream in png format.
+ /// Saves the bitmap to a stream with the specified options.
///
/// The stream.
- ///
- /// The optional quality for compression if supported by the specific backend.
- /// The quality value is interpreted from 0 - 100. If quality is null the default quality
- /// setting of the backend is applied.
+ ///
+ /// The options specifying the format and settings to use.
+ /// Typical usages include and .
///
- void Save(Stream stream, int? quality = null);
+ void Save(Stream stream, BitmapEncoderOptions options);
}
}
diff --git a/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs
new file mode 100644
index 0000000000..9745c60f92
--- /dev/null
+++ b/src/Avalonia.Base/Media/Imaging/JpegBitmapEncoderOptions.cs
@@ -0,0 +1,18 @@
+namespace Avalonia.Media.Imaging;
+
+///
+/// Represents the options used to save a bitmap in the JPEG format.
+///
+public sealed class JpegBitmapEncoderOptions : BitmapEncoderOptions
+{
+ ///
+ /// Gets the default JPEG encoder options.
+ ///
+ public static JpegBitmapEncoderOptions Default { get; } = new();
+
+ ///
+ /// Gets or sets the quality to use, from 0 (lowest) to 100 (highest).
+ /// Defaults to 100.
+ ///
+ public int Quality { get; init; } = 100;
+}
diff --git a/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs b/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs
new file mode 100644
index 0000000000..0931ec9b8c
--- /dev/null
+++ b/src/Avalonia.Base/Media/Imaging/PngBitmapEncoderOptions.cs
@@ -0,0 +1,20 @@
+using System.IO.Compression;
+
+namespace Avalonia.Media.Imaging;
+
+///
+/// Represents the options used to save a bitmap in the PNG format.
+///
+public sealed class PngBitmapEncoderOptions : BitmapEncoderOptions
+{
+ ///
+ /// Gets the default PNG encoder options.
+ ///
+ public static PngBitmapEncoderOptions Default { get; } = new();
+
+ ///
+ /// Gets or sets the compression level to use.
+ /// Defaults to .
+ ///
+ public CompressionLevel CompressionLevel { get; init; } = CompressionLevel.Optimal;
+}
diff --git a/src/Avalonia.Base/Media/IntersectionResult.cs b/src/Avalonia.Base/Media/IntersectionResult.cs
new file mode 100644
index 0000000000..cab4c70d0d
--- /dev/null
+++ b/src/Avalonia.Base/Media/IntersectionResult.cs
@@ -0,0 +1,33 @@
+namespace Avalonia.Media
+{
+ ///
+ /// Provides information about the intersection between a hit geometry and a target geometry or visual.
+ ///
+ public enum IntersectionResult
+ {
+ ///
+ /// The IntersectionDetail value is not calculated.
+ ///
+ NotCalculated = 0,
+
+ ///
+ /// The hit test parameter and the target visual, or geometry, do not intersect.
+ ///
+ Empty = 1,
+
+ ///
+ /// The target geometry or visual is fully inside the hit test .
+ ///
+ FullyInside = 2,
+
+ ///
+ /// The hit test parameter is fully contained within the boundary of the target visual or geometry.
+ ///
+ FullyContains = 3,
+
+ ///
+ /// The hit test parameter and the target visual, or geometry, intersect. This means that the two elements overlap, but neither element contains the other.
+ ///
+ Intersects = 4
+ }
+}
diff --git a/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs b/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs
index 0eb98533d2..3c9d0894c7 100644
--- a/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/IndexedTextRun.cs
@@ -5,6 +5,6 @@
public int TextSourceCharacterIndex { get; init; }
public int RunIndex { get; set; }
public int NextRunIndex { get; set; }
- public TextRun? TextRun { get; init; }
+ public TextRun? TextRun { get; set; }
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
index 75249ff7e7..692ed09d08 100644
--- a/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/InterWordJustification.cs
@@ -4,6 +4,21 @@ using Avalonia.Media.TextFormatting.Unicode;
namespace Avalonia.Media.TextFormatting
{
+ ///
+ /// Distributes the remaining line width across the break opportunities reported by the line
+ /// breaker - inter-word gaps for space-delimited scripts, and inter-character/inter-syllable
+ /// gaps for CJK and Korean (which the line breaker treats as break opportunities).
+ ///
+ ///
+ /// Known limitations:
+ ///
+ /// - Thai, Lao, Khmer and Myanmar produce no line-break opportunities without dictionary or
+ /// ML based word segmentation (character-class line breaking cannot find their word boundaries),
+ /// so such lines are left un-justified (start-aligned) rather than spaced incorrectly.
+ /// - Arabic and Hebrew are justified with inter-word spacing rather than the idiomatic
+ /// kashida (tatweel) elongation, which would require shaper-level support.
+ ///
+ ///
internal class InterWordJustification : JustificationProperties
{
public InterWordJustification(double width)
@@ -31,6 +46,12 @@ namespace Avalonia.Media.TextFormatting
var currentPosition = textLine.FirstTextSourceIndex;
+ // Note: trailing whitespace needs no special handling here. The LineBreakEnumerator does
+ // not emit a non-required break inside trailing whitespace (LB07 forbids breaking before
+ // a space, LB06 before a hard break), and the run-final break is excluded by
+ // PositionWrap != textRun.Length below - so no break opportunity ever targets a glyph in
+ // the trailing whitespace. Verified for ASCII and ideographic (U+3000) spaces by
+ // Justify_Does_Not_Space_Trailing_Whitespace.
for (var i = 0; i < lineImpl.TextRuns.Count; ++i)
{
var textRun = lineImpl.TextRuns[i];
@@ -47,7 +68,25 @@ namespace Avalonia.Media.TextFormatting
{
if (!currentBreak.Required && currentBreak.PositionWrap != textRun.Length)
{
- breakOportunities.Enqueue(currentPosition + currentBreak.PositionMeasure);
+ // The extra advance must land on the glyph that ENDS at the break
+ // boundary (the last glyph before the break), so the widened gap sits on
+ // the break itself. For whitespace breaks GetLineBreak has already pulled
+ // PositionMeasure back onto the trailing whitespace glyph
+ // (PositionMeasure < PositionWrap), so that position is the target as-is.
+ // For zero-width breaks - CJK/Korean ideograph boundaries, hyphens and
+ // other break-after punctuation - PositionMeasure == PositionWrap and
+ // points one glyph PAST the boundary; step back one so we widen the gap the
+ // break represents rather than the following gap. This also keeps the last
+ // visible glyph of a CJK/Korean line unstretched: its only inbound break is
+ // the run-final break, already excluded by PositionWrap != textRun.Length.
+ var target = currentPosition + currentBreak.PositionMeasure;
+
+ if (currentBreak.PositionMeasure == currentBreak.PositionWrap)
+ {
+ target -= 1;
+ }
+
+ breakOportunities.Enqueue(target);
}
}
@@ -59,46 +98,72 @@ namespace Avalonia.Media.TextFormatting
return;
}
- var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.WidthIncludingTrailingWhitespace);
+ // Fill the visible content to the paragraph width, not the width including trailing
+ // whitespace. A wrapped line keeps the space at its wrap point as trailing whitespace,
+ // which can push WidthIncludingTrailingWhitespace to or past the paragraph width; using
+ // it here would leave remainingSpace at zero and the line unjustified. The distributed
+ // space only ever lands on visible glyphs (trailing whitespace gets no break), so the
+ // visible content reaches the margin and the trailing whitespace hangs past it.
+ var remainingSpace = Math.Max(0, paragraphWidth - lineImpl.Width);
var spacing = remainingSpace / breakOportunities.Count;
currentPosition = textLine.FirstTextSourceIndex;
- foreach (var textRun in lineImpl.TextRuns)
+ for (var runIndex = 0; runIndex < lineImpl.TextRuns.Count; runIndex++)
{
- var text = textRun.Text;
-
- if (text.IsEmpty)
+ var textRun = lineImpl.TextRuns[runIndex];
+ var runLength = textRun.Length;
+ var runEnd = currentPosition + runLength;
+
+ var shapedText = textRun.Text.IsEmpty ? null : textRun as ShapedTextRun;
+ var glyphRun = shapedText?.GlyphRun;
+ ShapedBuffer? writableBuffer = null;
+
+ // Consume only the break opportunities that fall inside this run's range. The queue
+ // is in ascending position order, so once the front break is at or past runEnd it
+ // belongs to a later run and must be left for it, instead of draining the whole
+ // queue against this run.
+ while (breakOportunities.Count > 0)
{
- continue;
- }
+ var characterIndex = breakOportunities.Peek();
- if (textRun is ShapedTextRun shapedText)
- {
- var glyphRun = shapedText.GlyphRun;
- var shapedBuffer = shapedText.ShapedBuffer;
+ if (characterIndex >= runEnd)
+ {
+ break;
+ }
- while (breakOportunities.Count > 0)
+ breakOportunities.Dequeue();
+
+ // Skip stale breaks and breaks in runs we cannot justify (non-shaped).
+ if (characterIndex < currentPosition || shapedText is null)
{
- var characterIndex = breakOportunities.Dequeue();
+ continue;
+ }
- if (characterIndex < currentPosition)
- {
- continue;
- }
+ // Copy-on-write: the run's own ShapedBuffer may share its pooled glyph storage
+ // with a TextRunCache entry, a Split sibling or a WithBidiLevel alias, so
+ // mutating it in place would corrupt those. Adjust a private clone and swap in a
+ // fresh run below.
+ writableBuffer ??= shapedText.ShapedBuffer.CloneWritable();
- var offset = Math.Max(0, currentPosition - glyphRun.Metrics.FirstCluster);
- var glyphIndex = glyphRun.FindGlyphIndex(characterIndex - offset);
- var glyphInfo = shapedBuffer[glyphIndex];
+ var offset = Math.Max(0, currentPosition - glyphRun!.Metrics.FirstCluster);
+ var glyphIndex = glyphRun.FindGlyphIndex(characterIndex - offset);
+ var glyphInfo = writableBuffer[glyphIndex];
- shapedBuffer[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
- glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
- }
+ writableBuffer[glyphIndex] = new GlyphInfo(glyphInfo.GlyphIndex,
+ glyphInfo.GlyphCluster, glyphInfo.GlyphAdvance + spacing);
+ }
+
+ if (writableBuffer != null)
+ {
+ var justifiedRun = new ShapedTextRun(writableBuffer, shapedText!.Properties);
- glyphRun.GlyphInfos = shapedBuffer;
+ lineImpl.ReplaceTextRun(runIndex, justifiedRun);
+
+ shapedText.Dispose();
}
- currentPosition += textRun.Length;
+ currentPosition += runLength;
}
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
index 7be486717e..0a9f2f1e86 100644
--- a/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/ShapedBuffer.cs
@@ -562,6 +562,23 @@ namespace Avalonia.Media.TextFormatting
_glyphRef, prefixRef, startsRef, startIdx, count, _cacheGeneration);
}
+ ///
+ /// Creates a deep copy backed by a fresh, non-pooled array so the
+ /// copy's advances can be mutated (e.g. by justification) without touching glyph storage
+ /// shared with a entry, a sibling or a
+ /// alias. The copy owns no ref-counted pooled handles (its
+ /// _glyphRef is null) and builds its own cluster cache lazily.
+ ///
+ internal ShapedBuffer CloneWritable()
+ {
+ var span = _glyphInfos.Span;
+ var glyphs = new GlyphInfo[span.Length];
+
+ span.CopyTo(glyphs);
+
+ return new ShapedBuffer(Text, new ArraySlice(glyphs), GlyphTypeface, FontRenderingEmSize, BidiLevel);
+ }
+
int IReadOnlyCollection.Count => _glyphInfos.Length;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
index cc48ed47c6..b52b992746 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextCharacters.cs
@@ -1,4 +1,6 @@
using System;
+using Avalonia.Logging;
+using Avalonia.Media.Fonts;
using Avalonia.Media.TextFormatting.Unicode;
using static Avalonia.Media.TextFormatting.FormattingObjectPool;
@@ -9,7 +11,11 @@ namespace Avalonia.Media.TextFormatting
///
public class TextCharacters : TextRun
{
- private static char ZeroWidthSpace = '\u200b';
+ // NUL characters render nothing but must keep their position in the run. WORD JOINER (U+2060)
+ // is a zero-width, default-ignorable, non-breaking filler; unlike ZERO WIDTH SPACE (U+200B)
+ // it introduces no line-break opportunity, matching NUL's lack of break semantics.
+ private const char WordJoiner = '\u2060';
+ private static readonly string s_wordJoinerRun = new string(WordJoiner, 8);
///
/// Constructs a run for text content from a string.
@@ -95,60 +101,141 @@ namespace Avalonia.Media.TextFormatting
//Detect null terminator
if (count > 0)
{
- return new UnshapedTextRun(new string(ZeroWidthSpace, count).AsMemory(), defaultProperties, biDiLevel);
+ // Reuse a cached run of WORD JOINERs for the common short case to avoid an allocation.
+ var nullReplacement = count <= s_wordJoinerRun.Length
+ ? s_wordJoinerRun.AsMemory(0, count)
+ : new string(WordJoiner, count).AsMemory();
+
+ return new UnshapedTextRun(nullReplacement, defaultProperties, biDiLevel);
}
- if (TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, out count))
+ // The first scalar's script drives both the locale-sensitivity check and the complex-script
+ // capability preference below.
+ var firstScript = Codepoint.ReadAt(textSpan, 0, out _).Script;
+
+ // The previous run's font is reused as a cheap anti-thrashing bias, but it bypasses the
+ // culture-aware fallback scorer. For locale-sensitive scripts (CJK Han unification) skip
+ // the reuse when the culture changed between runs so the culture-scored fallback runs
+ // instead - otherwise e.g. a zh run's font would pin ja text. Same-culture runs keep the
+ // reuse, so the common case pays only a culture comparison.
+ var allowPreviousTypeface = true;
+
+ if (previousGlyphTypeface is not null &&
+ !Equals(previousProperties!.CultureInfo, defaultProperties.CultureInfo) &&
+ FontFallbackScriptHints.IsLocaleSensitive(firstScript))
{
- return new UnshapedTextRun(text.Slice(0, count), defaultProperties.WithTypeface(defaultTypeface),
- biDiLevel);
+ allowPreviousTypeface = false;
}
- if (previousGlyphTypeface is not null)
+ // Capability preference (Strategy A): for a complex script, first try fonts that declare
+ // the script in GSUB/GPOS (so they can actually shape it), then fall back to cmap-only
+ // coverage. Simple scripts use a single cmap tier, so the common path is unchanged.
+ var capabilityTiers =
+ FontFallbackScriptHints.TryGetComplexShapingTags(firstScript, out _, out _) ? 2 : 1;
+
+ for (var capabilityTier = 0; capabilityTier < capabilityTiers; capabilityTier++)
{
- if (TryGetShapeableLength(textSpan, previousGlyphTypeface, defaultGlyphTypeface, out count))
+ // Tier 0 (only present when there are two tiers) requires the font to declare shaping
+ // support for the script; tier 1 accepts cmap coverage alone (the historical gate).
+ var requireShapingCapability = capabilityTiers == 2 && capabilityTier == 0;
+
+ // When this tier requires shaping capability, constrain the fallback search to fonts
+ // that can shape the script (Script.Unknown = the historical, unconstrained search).
+ var shapingConstraint = requireShapingCapability ? firstScript : Script.Unknown;
+
+ // Coverage tiers (full cluster, then base-only) run inside each capability tier. The
+ // fallback is resolved once per capability tier because the search constraint differs.
+ Typeface fallbackTypeface = default;
+ GlyphTypeface? fallbackGlyphTypeface = null;
+ var fallbackResolved = false;
+
+ // A primary that cannot shape this tier's script is not a valid "return target": pass
+ // null so the return-to-primary check doesn't hand clusters back to it, which would
+ // otherwise block a shaping-capable fallback that merely shares the primary's cmap.
+ var defaultCanShape = !requireShapingCapability || defaultGlyphTypeface.CanShapeScript(firstScript);
+ var primaryForReturn = defaultCanShape ? defaultGlyphTypeface : null;
+
+ for (var pass = 0; pass < 2; pass++)
{
- return new UnshapedTextRun(text.Slice(0, count),
- defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
- }
- }
+ var requireFullCluster = pass == 0;
- var codepoint = Codepoint.ReplacementCodepoint;
+ if (defaultCanShape &&
+ TryGetShapeableLength(textSpan, defaultGlyphTypeface, null, requireFullCluster, out count))
+ {
+ // Primary font: the properties already carry this typeface, so reuse them
+ // directly. This avoids a needless copy and preserves a custom
+ // TextRunProperties subclass that WithTypeface would otherwise flatten.
+ return new UnshapedTextRun(text.Slice(0, count), defaultProperties, biDiLevel);
+ }
- var graphemeEnumerator = new GraphemeEnumerator(text.Slice(count).Span);
+ if (allowPreviousTypeface && previousGlyphTypeface is not null &&
+ (!requireShapingCapability || previousGlyphTypeface.CanShapeScript(firstScript)) &&
+ TryGetShapeableLength(textSpan, previousGlyphTypeface, primaryForReturn, requireFullCluster, out count))
+ {
+ return new UnshapedTextRun(text.Slice(0, count),
+ defaultProperties.WithTypeface(previousTypeface!.Value), biDiLevel);
+ }
- if (graphemeEnumerator.MoveNext(out var grapheme))
- {
- codepoint = grapheme.FirstCodepoint;
- }
+ // Resolve the fallback once, after the primary/previous probes fail. It is keyed on
+ // the first scalar the primary font cannot render - the base for an unsupported
+ // script, or the combining mark for an otherwise-supported cluster - so the search
+ // can find a font for the mark, not just the base. Reused by every pass.
+ if (!fallbackResolved)
+ {
+ fallbackResolved = true;
- var matchFound =
- fontManager.TryMatchCharacter(codepoint, defaultTypeface.Style, defaultTypeface.Weight,
- defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo,
- out var fallbackTypeface);
-
- if (matchFound)
- {
- // Fallback found
- if(fontManager.TryGetGlyphTypeface(fallbackTypeface, out var fallbackGlyphTypeface))
- {
- if (TryGetShapeableLength(textSpan, fallbackGlyphTypeface, defaultGlyphTypeface, out count))
+ var fallbackCodepoint = GetFallbackCodepoint(textSpan, defaultGlyphTypeface);
+
+ if (fontManager.TryMatchCharacter(fallbackCodepoint, defaultTypeface.Style, defaultTypeface.Weight,
+ defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo,
+ shapingConstraint, out fallbackTypeface)
+ && !fontManager.TryGetGlyphTypeface(fallbackTypeface, out fallbackGlyphTypeface))
+ {
+ // The platform matched a fallback family but its glyph typeface could not
+ // be loaded; the cluster degrades to .notdef. Surface it for diagnosis.
+ Logger.TryGet(LogEventLevel.Warning, LogArea.Fonts)?.Log(null,
+ "Matched fallback typeface {FamilyName} for codepoint U+{Codepoint} but could not load its glyph typeface.",
+ fallbackTypeface.FontFamily.Name, ((uint)fallbackCodepoint).ToString("X4"));
+ }
+ }
+
+ if (fallbackGlyphTypeface is not null &&
+ TryGetShapeableLength(textSpan, fallbackGlyphTypeface, primaryForReturn, requireFullCluster, out count))
{
- return new UnshapedTextRun(text.Slice(0, count), defaultProperties.WithTypeface(fallbackTypeface),
- biDiLevel);
+ return new UnshapedTextRun(text.Slice(0, count),
+ defaultProperties.WithTypeface(fallbackTypeface), biDiLevel);
}
- }
+ }
}
- // no fallback found
+ // No font (not even a last-resort match) covers the first cluster. Coalesce the
+ // following clusters that likewise have no home into a single .notdef ("tofu") run,
+ // then hand control back so the next run can be selected normally. We must stop as
+ // soon as a cluster the primary font - or any fallback - can render is reached;
+ // otherwise a renderable cluster following an unmatchable one would be swallowed as
+ // tofu too (e.g. a private-use codepoint immediately followed by CJK text).
var enumerator = new GraphemeEnumerator(textSpan);
- //Move forward until we reach the next base character
- while (enumerator.MoveNext(out grapheme))
+ while (enumerator.MoveNext(out var grapheme))
{
- if (!grapheme.FirstCodepoint.IsWhiteSpace && defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(grapheme.FirstCodepoint, out _))
+ var firstCodepoint = grapheme.FirstCodepoint;
+
+ if (!firstCodepoint.IsWhiteSpace)
{
- break;
+ // Primary font regained coverage - return to it.
+ if (defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(firstCodepoint, out _))
+ {
+ break;
+ }
+
+ // A fallback exists for this cluster - stop so the next run can use it. The
+ // first cluster is skipped (count == 0): we already know it has no match.
+ if (count > 0 &&
+ fontManager.TryMatchCharacter(firstCodepoint, defaultTypeface.Style, defaultTypeface.Weight,
+ defaultTypeface.Stretch, defaultTypeface.FontFamily, defaultProperties.CultureInfo, out _))
+ {
+ break;
+ }
}
count += grapheme.Length;
@@ -163,12 +250,18 @@ namespace Avalonia.Media.TextFormatting
/// The characters to shape.
/// The typeface that is used to find matching characters.
/// The default typeface.
+ ///
+ /// When true, a grapheme cluster only counts as supported when the typeface has a glyph
+ /// for every scalar it contains (base plus combining marks); when false, only the base
+ /// scalar is tested.
+ ///
/// The shapeable length.
///
internal static bool TryGetShapeableLength(
ReadOnlySpan text,
GlyphTypeface glyphTypeface,
GlyphTypeface? defaultGlyphTypeface,
+ bool requireFullCluster,
out int length)
{
length = 0;
@@ -192,17 +285,19 @@ namespace Avalonia.Media.TextFormatting
break;
}
+ var clusterText = text.Slice(currentGrapheme.Offset, currentGrapheme.Length);
+
if (!currentCodepoint.IsWhiteSpace
&& defaultGlyphTypeface != null
- && defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _))
+ && ClusterIsCovered(clusterText, currentCodepoint, defaultGlyphTypeface, requireFullCluster))
{
break;
}
- //Stop at the first missing glyph
+ //Stop at the first cluster this typeface can't render
if (!currentCodepoint.IsBreakChar &&
currentCodepoint.GeneralCategory != GeneralCategory.Control &&
- !glyphTypeface.CharacterToGlyphMap.TryGetGlyph(currentCodepoint, out _))
+ !ClusterIsCovered(clusterText, currentCodepoint, glyphTypeface, requireFullCluster))
{
break;
}
@@ -228,5 +323,75 @@ namespace Avalonia.Media.TextFormatting
return length > 0;
}
+
+ ///
+ /// Determines whether can render the first grapheme cluster in
+ /// . For a single-scalar cluster, or when
+ /// is false, only the base scalar is tested.
+ /// Otherwise every scalar that needs a glyph (excluding break chars and control/format
+ /// codepoints) must be present, so a base+mark cluster is only covered by a font that has the
+ /// marks too.
+ ///
+ private static bool ClusterIsCovered(ReadOnlySpan clusterText, Codepoint firstCodepoint,
+ GlyphTypeface glyphTypeface, bool requireFullCluster)
+ {
+ var baseLength = firstCodepoint.Value > 0xFFFF ? 2 : 1;
+
+ if (!requireFullCluster || clusterText.Length <= baseLength)
+ {
+ return glyphTypeface.CharacterToGlyphMap.TryGetGlyph(firstCodepoint, out _);
+ }
+
+ var codepoints = new CodepointEnumerator(clusterText);
+
+ while (codepoints.MoveNext(out var codepoint))
+ {
+ if (codepoint.IsBreakChar || codepoint.GeneralCategory is GeneralCategory.Control or GeneralCategory.Format)
+ {
+ continue;
+ }
+
+ if (!glyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns the first scalar of the run's first grapheme cluster that
+ /// cannot render - the base for an unsupported script,
+ /// or a combining mark for an otherwise-supported cluster. Keying the fallback search on this
+ /// lets it find a font for the mark, not just the base. Falls back to the cluster's first
+ /// scalar when every scalar is already covered.
+ ///
+ private static Codepoint GetFallbackCodepoint(ReadOnlySpan text, GlyphTypeface defaultGlyphTypeface)
+ {
+ var graphemeEnumerator = new GraphemeEnumerator(text);
+
+ if (!graphemeEnumerator.MoveNext(out var grapheme))
+ {
+ return Codepoint.ReplacementCodepoint;
+ }
+
+ var codepoints = new CodepointEnumerator(text.Slice(grapheme.Offset, grapheme.Length));
+
+ while (codepoints.MoveNext(out var codepoint))
+ {
+ if (codepoint.IsBreakChar || codepoint.GeneralCategory is GeneralCategory.Control or GeneralCategory.Format)
+ {
+ continue;
+ }
+
+ if (!defaultGlyphTypeface.CharacterToGlyphMap.TryGetGlyph(codepoint, out _))
+ {
+ return codepoint;
+ }
+ }
+
+ return grapheme.FirstCodepoint;
+ }
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
index 0e7a624d17..9810935c5a 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLayout.cs
@@ -603,7 +603,7 @@ namespace Avalonia.Media.TextFormatting
//Fulfill max height constraint
if (textLines.Count > 0 && !double.IsPositiveInfinity(MaxHeight)
- && Height + textLine.Height > MaxHeight)
+ && MathUtilities.GreaterThan(Height + textLine.Height, MaxHeight))
{
if (previousLine?.TextLineBreak != null && _textTrimming != TextTrimming.None)
{
@@ -634,7 +634,7 @@ namespace Avalonia.Media.TextFormatting
{
if (textLine.TextLineBreak is { IsSplit: true })
{
- textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(WidthIncludingTrailingWhitespace));
+ textLines[textLines.Count - 1] = textLine.Collapse(GetCollapsingProperties(textLine.WidthIncludingTrailingWhitespace));
}
break;
@@ -657,14 +657,14 @@ namespace Avalonia.Media.TextFormatting
if (_paragraphProperties.TextAlignment == TextAlignment.Justify)
{
+ // Justify fills each line to the column width, which is MaxWidth for both
+ // wrapped and non-wrapped text. Targeting the widest produced line instead
+ // (the previous behaviour for wrapping) leaves wrapped text short of the
+ // margin, since the full non-last lines already equal that width. When
+ // MaxWidth is infinite there is no column to fill, so skip.
var justificationWidth = MaxWidth;
- if (_paragraphProperties.TextWrapping != TextWrapping.NoWrap)
- {
- justificationWidth = WidthIncludingTrailingWhitespace;
- }
-
- if (justificationWidth > 0)
+ if (!double.IsInfinity(justificationWidth) && justificationWidth > 0)
{
var justificationProperties = new InterWordJustification(justificationWidth);
@@ -672,6 +672,18 @@ namespace Avalonia.Media.TextFormatting
{
var line = textLines[i];
+ // Only width-driven wrapped lines are stretched to the column. Skip
+ // the last line of the layout, any line ended by a newline
+ // (NewLineLength > 0), and any line ended by a source-provided required
+ // break (TextEndOfLine) - these stay start-aligned per standard
+ // typographic convention.
+ if (i == textLines.Count - 1
+ || line.NewLineLength > 0
+ || line.TextLineBreak?.TextEndOfLine != null)
+ {
+ continue;
+ }
+
line.Justify(justificationProperties);
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
index 6437d04298..2289f9508e 100644
--- a/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/TextLineImpl.cs
@@ -197,6 +197,39 @@ namespace Avalonia.Media.TextFormatting
_textLineMetrics = CreateLineMetrics();
}
+ ///
+ /// Replaces the run at in and re-points the
+ /// matching bidi-reordered at the new run, so draw and hit-test
+ /// paths that resolve runs through observe the replacement.
+ /// Used by justification to swap in a copy-on-write run without mutating shared glyph storage.
+ ///
+ internal void ReplaceTextRun(int index, TextRun textRun)
+ {
+ var oldRun = _textRuns[index];
+
+ if (ReferenceEquals(oldRun, textRun))
+ {
+ return;
+ }
+
+ _textRuns[index] = textRun;
+
+ if (_indexedTextRuns is null)
+ {
+ return;
+ }
+
+ for (var i = 0; i < _indexedTextRuns.Count; i++)
+ {
+ if (ReferenceEquals(_indexedTextRuns[i].TextRun, oldRun))
+ {
+ _indexedTextRuns[i].TextRun = textRun;
+
+ break;
+ }
+ }
+ }
+
///
public override CharacterHit GetCharacterHitFromDistance(double distance)
{
@@ -1428,6 +1461,15 @@ namespace Avalonia.Media.TextFormatting
var whitespaceWidth = glyphRun.Bounds.Width - glyphRunMetrics.Width;
width -= whitespaceWidth;
+
+ if (glyphRunMetrics.TrailingWhitespaceLength != currentRun.Length)
+ {
+ // This run has visible content before its own trailing whitespace, so it -
+ // not an earlier run - is the true end of the line's visible content. An
+ // earlier run's own trailing whitespace is interior to the line (followed by
+ // this run's visible content) and must not be excluded from Width too.
+ break;
+ }
}
}
diff --git a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
index 576f0ad872..2198a18cd3 100644
--- a/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
+++ b/src/Avalonia.Base/Media/TextFormatting/Unicode/LineBreakEnumerator.cs
@@ -24,6 +24,11 @@ namespace Avalonia.Media.TextFormatting.Unicode
{
lineBreak = default;
+ if (_text.IsEmpty)
+ {
+ return false;
+ }
+
if (_state.Current.EndOfText)
{
return false;
diff --git a/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs b/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs
index cce11db12d..cd8f1f3f21 100644
--- a/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs
+++ b/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Media;
@@ -41,6 +42,9 @@ namespace Avalonia.Platform
public PlatformHotkeyConfiguration HotkeyConfiguration =>
AvaloniaLocator.Current.GetRequiredService();
+ public virtual string PreferredApplicationLanguage =>
+ CultureInfo.InstalledUICulture.Name;
+
public virtual PlatformColorValues GetColorValues()
{
return new PlatformColorValues
@@ -50,11 +54,18 @@ namespace Avalonia.Platform
}
public virtual event EventHandler? ColorValuesChanged;
+ public virtual event EventHandler? PreferredApplicationLanguageChanged;
protected void OnColorValuesChanged(PlatformColorValues colorValues)
{
Dispatcher.UIThread.Send(
_ => ColorValuesChanged?.Invoke(this, colorValues));
}
+
+ protected void OnPreferredApplicationLanguageChanged()
+ {
+ Dispatcher.UIThread.Send(
+ _ => PreferredApplicationLanguageChanged?.Invoke(this, EventArgs.Empty));
+ }
}
}
diff --git a/src/Avalonia.Base/Platform/IBitmapImpl.cs b/src/Avalonia.Base/Platform/IBitmapImpl.cs
index 586ac76a3a..bec184e6aa 100644
--- a/src/Avalonia.Base/Platform/IBitmapImpl.cs
+++ b/src/Avalonia.Base/Platform/IBitmapImpl.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using Avalonia.Media.Imaging;
using Avalonia.Metadata;
namespace Avalonia.Platform
@@ -26,25 +27,13 @@ namespace Avalonia.Platform
int Version { get; }
///
- /// Saves the bitmap to a file.
- ///
- /// The filename.
- ///
- /// The optional quality for compression if supported by the specific backend.
- /// The quality value is interpreted from 0 - 100. If quality is null the default quality
- /// setting of the backend is applied.
- ///
- void Save(string fileName, int? quality = null);
-
- ///
- /// Saves the bitmap to a stream in png format.
+ /// Saves the bitmap to a stream with the specified options.
///
/// The stream.
- ///
- /// The optional quality for compression if supported by the specific backend.
- /// The quality value is interpreted from 0 - 100. If quality is null the default quality
- /// setting of the backend is applied.
+ ///
+ /// The options specifying the format and settings to use.
+ /// Typical usages include and .
///
- void Save(Stream stream, int? quality = null);
+ void Save(Stream stream, BitmapEncoderOptions options);
}
}
diff --git a/src/Avalonia.Base/Platform/IGeometryImpl.cs b/src/Avalonia.Base/Platform/IGeometryImpl.cs
index 0d1e7b972c..ae8eb6d765 100644
--- a/src/Avalonia.Base/Platform/IGeometryImpl.cs
+++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Avalonia.Media;
using Avalonia.Metadata;
+using Avalonia.Rendering.Composition.Drawing;
namespace Avalonia.Platform
{
@@ -8,8 +9,10 @@ namespace Avalonia.Platform
/// Defines the platform-specific interface for a .
///
[Unstable]
- public interface IGeometryImpl
+ public interface IGeometryImpl : IRenderDataGeometry
{
+ IGeometryImpl IRenderDataGeometry.GeometryImpl => this;
+
///
/// Gets the geometry's bounding rectangle.
///
@@ -43,6 +46,13 @@ namespace Avalonia.Platform
/// true if the geometry contains the point; otherwise, false.
bool FillContains(Point point);
+ ///
+ /// Returns a value that describes the intersection between the current geometry and the specified geometry
+ ///
+ /// The geometry to test for containment.
+ /// The describing the intersection between the geometries
+ IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry);
+
///
/// Intersects the geometry with another geometry.
///
diff --git a/src/Avalonia.Base/Platform/IPlatformSettings.cs b/src/Avalonia.Base/Platform/IPlatformSettings.cs
index 46980c6d51..a45d8dc20f 100644
--- a/src/Avalonia.Base/Platform/IPlatformSettings.cs
+++ b/src/Avalonia.Base/Platform/IPlatformSettings.cs
@@ -37,12 +37,26 @@ namespace Avalonia.Platform
/// Holding duration between pointer press and when event is fired.
///
TimeSpan HoldWaitDuration { get; }
-
+
///
/// Get a configuration for platform-specific hotkeys in an Avalonia application.
///
PlatformHotkeyConfiguration HotkeyConfiguration { get; }
-
+
+ ///
+ /// Gets the preferred application language as specified in the operating system settings.
+ ///
+ ///
+ /// BCP 47 language tag. Examples: en, en-US or zh-Hans-CN.
+ /// Can be empty if the operating system doesn't report a preferred language.
+ ///
+ ///
+ /// Most platforms read this value from the operating system instead of the .NET runtime,
+ /// so a language tag can be returned even in globalization-invariant mode.
+ /// When that mode is disabled, the tag can be passed to .
+ ///
+ string PreferredApplicationLanguage { get; }
+
///
/// Gets current system color values including dark mode and accent colors.
///
@@ -52,5 +66,10 @@ namespace Avalonia.Platform
/// Raises when current system color values are changed. Including changing of a dark mode and accent colors.
///
event EventHandler? ColorValuesChanged;
+
+ ///
+ /// Raises when the preferred application language is changed in the operating system settings.
+ ///
+ event EventHandler? PreferredApplicationLanguageChanged;
}
}
diff --git a/src/Avalonia.Base/Platform/IRenderTarget.cs b/src/Avalonia.Base/Platform/IRenderTarget.cs
index b9da6a35b6..0bc84c7a14 100644
--- a/src/Avalonia.Base/Platform/IRenderTarget.cs
+++ b/src/Avalonia.Base/Platform/IRenderTarget.cs
@@ -1,5 +1,6 @@
using System;
using Avalonia.Metadata;
+using Avalonia.Rendering.Composition;
namespace Avalonia.Platform
{
@@ -32,9 +33,9 @@ namespace Avalonia.Platform
///
PlatformRenderTargetState PlatformRenderTargetState => PlatformRenderTargetState.Ready;
- public record struct RenderTargetSceneInfo(PixelSize Size, double Scaling, Size LogicalSize)
+ public record struct RenderTargetSceneInfo(PixelSize Size, double Scaling, Size LogicalSize, CompositionTransparencyLevel TransparencyLevel)
{
- public RenderTargetSceneInfo(PixelSize size, double scaling) : this(size, scaling, size.ToSize(scaling))
+ public RenderTargetSceneInfo(PixelSize size, double scaling, CompositionTransparencyLevel transparencyLevel) : this(size, scaling, size.ToSize(scaling), transparencyLevel)
{
}
}
diff --git a/src/Avalonia.Base/Platform/LtrbRect.cs b/src/Avalonia.Base/Platform/LtrbRect.cs
index 97cbec0ab2..37615b8205 100644
--- a/src/Avalonia.Base/Platform/LtrbRect.cs
+++ b/src/Avalonia.Base/Platform/LtrbRect.cs
@@ -301,4 +301,4 @@ public struct LtrbPixelRect
internal static LtrbPixelRect FromRectUnscaled(LtrbRect rect) =>
new((int)rect.Left, (int)rect.Top, (int)Math.Ceiling(rect.Right),
(int)Math.Ceiling(rect.Bottom));
-}
\ No newline at end of file
+}
diff --git a/src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs
index 0852bbf329..d5760408a0 100644
--- a/src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs
+++ b/src/Avalonia.Base/Platform/Storage/FallbackStorageProvider.cs
@@ -65,6 +65,11 @@ internal class FallbackStorageProvider : IStorageProvider
return await (await GetFor(p => p.CanOpen)).OpenFilePickerAsync(options);
}
+ public async Task OpenFilePickerWithResultAsync(FilePickerOpenOptions options)
+ {
+ return await (await GetFor(p => p.CanOpen)).OpenFilePickerWithResultAsync(options);
+ }
+
public async Task SaveFilePickerAsync(FilePickerSaveOptions options)
{
return await (await GetFor(p => p.CanSave)).SaveFilePickerAsync(options);
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs
index da4cb01df5..54e1424640 100644
--- a/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclLauncher.cs
@@ -2,6 +2,7 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using Avalonia.Logging;
namespace Avalonia.Platform.Storage.FileIO;
@@ -38,57 +39,49 @@ internal class BclLauncher : ILauncher
private static bool Exec(string urlOrFile)
{
- if (OperatingSystem.IsLinux())
+ try
{
- // If no associated application/json MimeType is found xdg-open opens return error
- // but it tries to open it anyway using the console editor (nano, vim, other..)
- var args = EscapeForShell(urlOrFile);
- ShellExecRaw($"xdg-open \\\"{args}\\\"", waitForExit: false);
- return true;
- }
- else if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
- {
- var info = new ProcessStartInfo
+ if (OperatingSystem.IsLinux())
{
- FileName = OperatingSystem.IsWindows() ? urlOrFile : "open",
- CreateNoWindow = true,
- UseShellExecute = OperatingSystem.IsWindows()
- };
- // Using the argument list avoids having to escape spaces and other special
- // characters that are part of valid macos file and folder paths.
- if (OperatingSystem.IsMacOS())
+ // If no associated application/json MimeType is found xdg-open opens return error
+ // but it tries to open it anyway using the console editor (nano, vim, other..)
+ var info = new ProcessStartInfo
+ {
+ FileName = "xdg-open",
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ };
+ // Using ArgumentList.Add() avoid escaping!
+ // This is similar to the following code for macos.
info.ArgumentList.Add(urlOrFile);
- using var process = Process.Start(info);
- return true;
+ using var process = Process.Start(info);
+ return true;
+ }
+ else if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS())
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = OperatingSystem.IsWindows() ? urlOrFile : "open",
+ CreateNoWindow = true,
+ UseShellExecute = OperatingSystem.IsWindows()
+ };
+ // Using the argument list avoids having to escape spaces and other special
+ // characters that are part of valid macos file and folder paths.
+ if (OperatingSystem.IsMacOS())
+ info.ArgumentList.Add(urlOrFile);
+ using var process = Process.Start(info);
+ return true;
+ }
+ else
+ {
+ return false;
+ }
}
- else
+ catch(Exception ex)
{
+ Logger.TryGet(LogEventLevel.Error, nameof(BclLauncher))?
+ .Log(null, "Exception during BclLauncher.Exec: {Error}", ex);
return false;
}
}
-
- private static string EscapeForShell(string input) => Regex
- .Replace(input, "(?=[`~!#&*()|;'<>])", "\\")
- .Replace("\"", "\\\\\\\"");
-
- private static void ShellExecRaw(string cmd, bool waitForExit = true)
- {
- using (var process = Process.Start(
- new ProcessStartInfo
- {
- FileName = "/bin/sh",
- Arguments = $"-c \"{cmd}\"",
- RedirectStandardOutput = true,
- UseShellExecute = false,
- CreateNoWindow = true,
- WindowStyle = ProcessWindowStyle.Hidden
- }
- ))
- {
- if (waitForExit)
- {
- process?.WaitForExit();
- }
- }
- }
}
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
index a471dba720..61a0674b2e 100644
--- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs
@@ -10,13 +10,26 @@ namespace Avalonia.Platform.Storage.FileIO;
internal abstract class BclStorageProvider : IStorageProvider
{
public abstract bool CanOpen { get; }
- public abstract Task> OpenFilePickerAsync(FilePickerOpenOptions options);
+
+ public async Task> OpenFilePickerAsync(FilePickerOpenOptions options)
+ {
+ var result = await OpenFilePickerWithResultAsync(options).ConfigureAwait(false);
+ return result.Files;
+ }
+ public abstract Task OpenFilePickerWithResultAsync(FilePickerOpenOptions options);
public abstract bool CanSave { get; }
- public abstract Task SaveFilePickerAsync(FilePickerSaveOptions options);
+
+ public async Task SaveFilePickerAsync(FilePickerSaveOptions options)
+ {
+ var result = await SaveFilePickerWithResultAsync(options).ConfigureAwait(false);
+ return result.File;
+ }
+
public abstract Task SaveFilePickerWithResultAsync(FilePickerSaveOptions options);
public abstract bool CanPickFolder { get; }
+
public abstract Task> OpenFolderPickerAsync(FolderPickerOpenOptions options);
public virtual Task OpenFileBookmarkAsync(string bookmark)
diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs b/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs
index 4c43331803..cafbfdfa25 100644
--- a/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs
+++ b/src/Avalonia.Base/Platform/Storage/FileIO/StorageBookmarkHelper.cs
@@ -86,20 +86,19 @@ internal static class StorageBookmarkHelper
Span decodedBookmark;
- // Each base64 character represents 6 bits, but to be safe,
- var arrayPool = ArrayPool.Shared.Rent(HeaderLength + base64bookmark.Length * 6);
- if (Convert.TryFromBase64Chars(base64bookmark, arrayPool, out int bytesWritten))
- {
- decodedBookmark = arrayPool.AsSpan().Slice(0, bytesWritten);
- }
- else
- {
- nativeBookmark = null;
- return DecodeResult.InvalidFormat;
- }
+ var arrayPool = ArrayPool.Shared.Rent(HeaderLength + base64bookmark.Length / 4 * 3);
try
{
+ // Each base64 character represents 6 bits, but to be safe,
+ if (!Convert.TryFromBase64Chars(base64bookmark, arrayPool, out int bytesWritten))
+ {
+ nativeBookmark = null;
+ return DecodeResult.InvalidFormat;
+ }
+
+ decodedBookmark = arrayPool.AsSpan(..bytesWritten);
+
if (decodedBookmark.Length < HeaderLength
// Check if decoded string starts with the correct prefix, checking v1 at the same time.
&& !AvaHeaderPrefix.SequenceEqual(decodedBookmark.Slice(0, AvaHeaderPrefix.Length)))
diff --git a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
index 22f8d8bd0a..f4fe7ca5eb 100644
--- a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
+++ b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs
@@ -10,40 +10,46 @@ namespace Avalonia.Platform.Storage;
public interface IStorageProvider
{
///
- /// Returns true if it's possible to open file picker on the current platform.
+ /// Returns true if it's possible to open the file picker on the current platform.
///
bool CanOpen { get; }
///
- /// Opens file picker dialog.
+ /// Opens the file picker dialog.
///
/// Array of selected or empty collection if user canceled the dialog.
Task> OpenFilePickerAsync(FilePickerOpenOptions options);
///
- /// Returns true if it's possible to open save file picker on the current platform.
+ /// Opens the file picker dialog and returns additional information about the result.
+ ///
+ /// with selected files and additional dialog information such as selected file type.
+ Task OpenFilePickerWithResultAsync(FilePickerOpenOptions options);
+
+ ///
+ /// Returns true if it's possible to open the save file picker on the current platform.
///
bool CanSave { get; }
///
- /// Opens save file picker dialog.
+ /// Opens the save file picker dialog.
///
/// Saved or null if user canceled the dialog.
Task SaveFilePickerAsync(FilePickerSaveOptions options);
///
- /// Opens save file picker dialog and returns additional information about the result.
+ /// Opens the save file picker dialog and returns additional information about the result.
///
/// with saved file and additional dialog information such as selected file type.
Task SaveFilePickerWithResultAsync(FilePickerSaveOptions options);
///
- /// Returns true if it's possible to open folder picker on the current platform.
+ /// Returns true if it's possible to open the folder picker on the current platform.
///
bool CanPickFolder { get; }
///
- /// Opens folder picker dialog.
+ /// Opens the folder picker dialog.
///
/// Array of selected or empty collection if user canceled the dialog.
Task> OpenFolderPickerAsync(FolderPickerOpenOptions options);
diff --git a/src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
index 4b056d104e..c2c984d43d 100644
--- a/src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
+++ b/src/Avalonia.Base/Platform/Storage/NoopStorageProvider.cs
@@ -8,16 +8,13 @@ namespace Avalonia.Platform.Storage;
internal class NoopStorageProvider : BclStorageProvider
{
public override bool CanOpen => false;
- public override Task> OpenFilePickerAsync(FilePickerOpenOptions options)
+
+ public override Task OpenFilePickerWithResultAsync(FilePickerOpenOptions options)
{
- return Task.FromResult>(Array.Empty());
+ return Task.FromResult(new OpenFilePickerResult());
}
public override bool CanSave => false;
- public override Task SaveFilePickerAsync(FilePickerSaveOptions options)
- {
- return Task.FromResult(null);
- }
public override Task SaveFilePickerWithResultAsync(FilePickerSaveOptions options)
{
@@ -25,6 +22,7 @@ internal class NoopStorageProvider : BclStorageProvider
}
public override bool CanPickFolder => false;
+
public override Task> OpenFolderPickerAsync(FolderPickerOpenOptions options)
{
return Task.FromResult>(Array.Empty());
diff --git a/src/Avalonia.Base/Platform/Storage/OpenFilePickerResult.cs b/src/Avalonia.Base/Platform/Storage/OpenFilePickerResult.cs
new file mode 100644
index 0000000000..91e7c7eccf
--- /dev/null
+++ b/src/Avalonia.Base/Platform/Storage/OpenFilePickerResult.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+
+namespace Avalonia.Platform.Storage;
+
+///
+/// Represents the result of the operation.
+///
+public readonly record struct OpenFilePickerResult
+{
+ ///
+ /// Gets the list of files selected by the user, or empty if the user canceled the dialog.
+ ///
+ public IReadOnlyList Files
+ {
+ get => field ?? [];
+ init;
+ }
+
+ ///
+ /// Gets the file type selected by the user, or null if the platform does not support this feature.
+ ///
+ public FilePickerFileType? SelectedFileType { get; init; }
+}
diff --git a/src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs b/src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs
index 222b4a4dbc..c643011453 100644
--- a/src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs
+++ b/src/Avalonia.Base/Platform/Storage/SaveFilePickerResult.cs
@@ -1,17 +1,17 @@
namespace Avalonia.Platform.Storage;
///
-/// Extended result of the operation.
+/// Represents the result of the operation.
///
public readonly record struct SaveFilePickerResult
{
///
- /// Saved or null if user canceled the dialog.
+ /// Gets the file selected by the user, or null if the user canceled the dialog.
///
public IStorageFile? File { get; init; }
///
- /// Selected file type or null if not supported.
+ /// Gets the file type selected by the user, or null if the platform does not support this feature.
///
public FilePickerFileType? SelectedFileType { get; init; }
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs b/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs
index 33caef5e68..f801bd1c66 100644
--- a/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Animations/Interpolators.cs
@@ -98,4 +98,31 @@ namespace Avalonia.Rendering.Composition.Animations
public static BooleanInterpolator Instance { get; } = new BooleanInterpolator();
}
+
+ class RelativePointInterpolator : IInterpolator
+ {
+ public Avalonia.RelativePoint Interpolate(Avalonia.RelativePoint @from, Avalonia.RelativePoint to, float progress)
+ {
+ if (@from.Unit != to.Unit)
+ return progress >= 0.5f ? to : @from;
+
+ var x = DoubleInterpolator.Instance.Interpolate(@from.Point.X, to.Point.X, progress);
+ var y = DoubleInterpolator.Instance.Interpolate(@from.Point.Y, to.Point.Y, progress);
+ return new Avalonia.RelativePoint(x, y, @from.Unit);
+ }
+
+ public static RelativePointInterpolator Instance { get; } = new();
+ }
+
+ class RelativeScalarInterpolator : IInterpolator
+ {
+ public Avalonia.RelativeScalar Interpolate(Avalonia.RelativeScalar @from, Avalonia.RelativeScalar to, float progress)
+ {
+ if (@from.Unit != to.Unit)
+ return progress >= 0.5f ? to : @from;
+ return new RelativeScalar(@from.Scalar + (to.Scalar - @from.Scalar) * progress, from.Unit);
+ }
+
+ public static RelativeScalarInterpolator Instance { get; } = new();
+ }
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/CompositionBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/CompositionBrush.cs
new file mode 100644
index 0000000000..4cc67b4cee
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Brushes/CompositionBrush.cs
@@ -0,0 +1,110 @@
+using System.Collections.Generic;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+using Avalonia.Rendering.Composition;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Rendering.Composition.Transport;
+using Avalonia.Styling;
+
+namespace Avalonia.Rendering.Composition;
+
+partial class CompositionBrush : IBrush
+{
+ partial void InitializeDefaultsExtra()
+ {
+ Server.Activate();
+ }
+}
+
+partial class CompositionSolidColorBrush : ISolidColorBrush
+{
+ internal CompositionSolidColorBrush(Compositor compositor, ServerCompositionSolidColorBrush server, Color color) : base(compositor, server)
+ {
+ Server = server;
+ Color = color;
+ InitializeDefaults();
+ }
+}
+
+partial class CompositionLinearGradientBrush : ILinearGradientBrush
+{
+}
+
+partial class CompositionRadialGradientBrush : IRadialGradientBrush
+{
+ public double Radius => RadiusX.Scalar;
+}
+
+partial class CompositionConicGradientBrush : IConicGradientBrush
+{
+
+}
+
+
+public abstract partial class CompositionGradientBrush : CompositionBrush, IGradientBrush
+{
+ private List _gradientStops = [];
+ private GradientSpreadMethod _spreadMethod;
+
+ internal new ServerCompositionGradientBrush Server { get; }
+
+ ///
+ /// The gradient stops. Mutations of the list itself are not tracked - assign
+ /// the property to ship in-place edits made after a commit. Stops created via
+ /// stay live
+ /// on the server and animate individually; other stops are snapshotted at
+ /// serialization time.
+ ///
+ public List GradientStops
+ {
+ get => _gradientStops;
+ set
+ {
+ if (ReferenceEquals(_gradientStops, value))
+ return;
+ _gradientStops = value;
+ RegisterForSerialization();
+ }
+ }
+
+ IReadOnlyList IGradientBrush.GradientStops => GradientStops;
+
+ ///
+ /// How the gradient repeats outside the stop range.
+ ///
+ public GradientSpreadMethod SpreadMethod
+ {
+ get => _spreadMethod;
+ set
+ {
+ if (_spreadMethod == value)
+ return;
+ _spreadMethod = value;
+ RegisterForSerialization();
+ }
+ }
+
+ partial void OnRootChanged();
+ partial void OnRootChanging();
+
+ internal CompositionGradientBrush(Compositor compositor, ServerCompositionGradientBrush server) : base(compositor, server)
+ {
+ Server = server;
+ }
+ private protected override void SerializeChangesCore(BatchStreamWriter writer)
+ {
+ base.SerializeChangesCore(writer);
+ writer.Write(SpreadMethod);
+ writer.Write(GradientStops.Count);
+ foreach (var stop in GradientStops)
+ {
+ if (stop is CompositionGradientStop comp)
+ writer.WriteObject(comp.Server);
+ else
+ // A mutable UI-thread stop must not cross to the render thread
+ // by reference; ship its current values instead.
+ writer.WriteObject(stop as ImmutableGradientStop
+ ?? new ImmutableGradientStop(stop.Offset, stop.Color));
+ }
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs
new file mode 100644
index 0000000000..e537db319a
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+using Avalonia.Rendering.Composition.Transport;
+
+namespace Avalonia.Rendering.Composition.Server;
+
+internal partial class ServerCompositionBrush : IBrush
+{
+ ITransform? IBrush.Transform => Transform;
+}
+
+internal partial class ServerCompositionGradientBrush : ServerCompositionBrush, IGradientBrush
+{
+ internal ServerCompositionGradientBrush(ServerCompositor compositor) : base(compositor)
+ {
+
+ }
+
+ internal static CompositionProperty> s_IdOfGradientStopsProperty = CompositionProperty.Register>("GradientStops", obj => ((ServerCompositionGradientBrush)obj)._gradientStops, (obj, v) => ((ServerCompositionGradientBrush)obj)._gradientStops = v, null);
+ private List _gradientStops = new();
+ IReadOnlyList IGradientBrush.GradientStops => GradientStops;
+ public List GradientStops
+ {
+ get
+ {
+ return _gradientStops;
+ }
+
+ set
+ {
+ var changed = false;
+ if (_gradientStops != value)
+ {
+ OnGradientStopsChanging();
+ changed = true;
+ }
+
+ SetValue(s_IdOfGradientStopsProperty, ref _gradientStops, value);
+ if (changed)
+ OnGradientStopsChanged();
+ }
+ }
+
+ partial void OnGradientStopsChanged();
+ partial void OnGradientStopsChanging();
+
+ public GradientSpreadMethod SpreadMethod { get; private set; }
+
+ protected override void DeserializeChangesCore(BatchStreamReader reader, TimeSpan committedAt)
+ {
+ base.DeserializeChangesCore(reader, committedAt);
+ SpreadMethod = reader.Read();
+ var stops = new List();
+ var count = reader.Read();
+ for (var c = 0; c < count; c++)
+ {
+ var read = reader.ReadObject();
+ stops.Add(read);
+ }
+ GradientStops = stops;
+ }
+}
+
+partial class ServerCompositionConicGradientBrush : IConicGradientBrush
+{
+
+}
+
+partial class ServerCompositionLinearGradientBrush : ILinearGradientBrush
+{
+
+}
+
+partial class ServerCompositionRadialGradientBrush : IRadialGradientBrush
+{
+ public double Radius => RadiusX.Scalar;
+}
+
+partial class ServerCompositionSolidColorBrush : ISolidColorBrush
+{
+
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs
index 673804f7bd..2ff9f66787 100644
--- a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleContentBrush.cs
@@ -8,7 +8,7 @@ namespace Avalonia.Rendering.Composition.Server;
internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSimpleTileBrush, ITileBrush, ISceneBrush
{
private CompositionRenderDataSceneBrushContent.Properties? _content;
-
+
internal ServerCompositionSimpleContentBrush(ServerCompositor compositor) : base(compositor)
{
@@ -22,6 +22,20 @@ internal sealed class ServerCompositionSimpleContentBrush : ServerCompositionSim
protected override void DeserializeChangesCore(BatchStreamReader reader, TimeSpan committedAt)
{
base.DeserializeChangesCore(reader, committedAt);
- _content = reader.ReadObject();
+ var content = reader.ReadObject();
+
+ if (!ReferenceEquals(_content?.RenderData, content?.RenderData))
+ {
+ _content?.RenderData.RemoveObserver(this);
+ content?.RenderData.AddObserver(this);
+ }
+
+ _content = content;
+ }
+
+ public override void Dispose()
+ {
+ _content?.RenderData.RemoveObserver(this);
+ base.Dispose();
}
}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
index cef5eb62de..6cb4021713 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
@@ -4,9 +4,10 @@ using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Diagnostics;
-using Avalonia.Platform.Surfaces;
using Avalonia.Media;
+using Avalonia.Platform.Surfaces;
using Avalonia.Rendering.Composition.Drawing;
+using Avalonia.Rendering.Composition.HitTesting;
using Avalonia.Threading;
namespace Avalonia.Rendering.Composition;
@@ -92,6 +93,14 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
///
public IEnumerable HitTest(Point p, Visual? root, Func? filter)
+ => HitTest(p, root, filter, (visual, _) => visual);
+
+ public IEnumerable HitTest(Geometry geometry, Visual? root, Func? filter)
+ => HitTest(geometry, root, filter, (visual, intersection) => new GeometryHitTestResult(visual, intersection));
+
+ private IEnumerable HitTest(T input, Visual? root, Func? filter, Func resultSelector)
+ where THitTester : struct, ICompositionHitTester
+ where TResult : class
{
using var _ = Diagnostic.PerformingHitTest();
@@ -102,7 +111,7 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
yield break;
rootVisual = root.CompositionVisual;
}
-
+
Func? f = null;
if (filter != null)
f = v =>
@@ -112,22 +121,37 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
return true;
};
- using var res = CompositionTarget.TryHitTest(p, rootVisual, f);
- if(res == null)
+ using var res = CompositionTarget.TryHitTest(input, rootVisual, f);
+ if (res == null)
yield break;
- foreach(var v in res)
+
+ foreach (var v in res)
{
- if (v is CompositionDrawListVisual dv)
+ if (v.Item2 is CompositionDrawListVisual dv)
{
if (filter == null || filter(dv.Visual))
- yield return dv.Visual;
+ yield return resultSelector(dv.Visual, v.Item1);
}
}
}
///
public Visual? HitTestFirst(Point p, Visual root, Func? filter)
+ => HitTestFirst(p, root, filter, out _);
+
+ ///
+ public GeometryHitTestResult? HitTestFirst(Geometry geometry, Visual root, Func? filter)
{
+ var visual = HitTestFirst(geometry, root, filter, out var intersectionResult);
+
+ return visual != null ? new GeometryHitTestResult(visual, intersectionResult) : null;
+ }
+
+ private Visual? HitTestFirst(T input, Visual root, Func? filter, out IntersectionResult intersectionResult)
+ where THitTester : struct, ICompositionHitTester
+ {
+ intersectionResult = IntersectionResult.NotCalculated;
+
using var _ = Diagnostic.PerformingHitTest();
if (root.CompositionVisual == null)
@@ -137,7 +161,8 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
? null :
v => v is not CompositionDrawListVisual dlv || filter(dlv.Visual);
- return CompositionTarget.TryHitTestFirst(p, root.CompositionVisual, f, static v => v is CompositionDrawListVisual) is CompositionDrawListVisual dv ? dv.Visual : null;
+ return CompositionTarget.TryHitTestFirst(input, root.CompositionVisual, f, static v => v is CompositionDrawListVisual, out intersectionResult)
+ is CompositionDrawListVisual dv ? dv.Visual : null;
}
///
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
index 6f4bb3b882..c7bf13e6ff 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
@@ -1,3 +1,4 @@
+using Avalonia.Media;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Rendering.Composition.Transport;
@@ -65,4 +66,17 @@ internal class CompositionDrawListVisual : CompositionContainerVisual
return DrawList?.HitTest(pt) ?? false;
}
+
+ internal override IntersectionResult HitTest(Geometry geometry)
+ {
+ var custom = Visual as ICustomHitTest;
+ if (DrawList == null && custom == null)
+ return IntersectionResult.Empty;
+ if (custom != null)
+ {
+ return custom.HitTest(geometry);
+ }
+
+ return DrawList?.HitTest(geometry) ?? IntersectionResult.Empty;
+ }
}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionGradientStop.cs b/src/Avalonia.Base/Rendering/Composition/CompositionGradientStop.cs
new file mode 100644
index 0000000000..cc14b6d176
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionGradientStop.cs
@@ -0,0 +1,24 @@
+using Avalonia.Media;
+using Avalonia.Rendering.Composition.Server;
+using Avalonia.Utilities;
+
+namespace Avalonia.Rendering.Composition;
+
+public partial class CompositionGradientStop : IGradientStop
+{
+ internal CompositionGradientStop(Compositor compositor, ServerCompositionGradientStop server, double offset, Color color) : base(compositor, server)
+ {
+ Server = server;
+ if (MathUtilities.IsZero(offset))
+ {
+ offset = 0;
+ }
+ Offset = (offset < 0) ? 0 : (offset > 1) ? 1 : offset;
+ Color = color;
+ InitializeDefaults();
+ }
+ partial void InitializeDefaultsExtra()
+ {
+ Server.Activate();
+ }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionInterop.cs b/src/Avalonia.Base/Rendering/Composition/CompositionInterop.cs
index a66ab701a1..939eb1fddd 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositionInterop.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionInterop.cs
@@ -56,7 +56,7 @@ internal class CompositionInterop : ICompositionGpuInterop
throw new System.NotSupportedException();
}
- public bool IsLost { get; }
+ public bool IsLost => _context.IsLost;
public byte[]? DeviceLuid { get; set; }
public byte[]? DeviceUuid { get; set; }
}
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
index 8b418211b8..0e9607dafa 100644
--- a/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
@@ -1,7 +1,9 @@
using System;
-using System.Numerics;
+using System.Diagnostics.CodeAnalysis;
using Avalonia.Collections.Pooled;
-using Avalonia.VisualTree;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Rendering.Composition.HitTesting;
namespace Avalonia.Rendering.Composition
{
@@ -24,12 +26,16 @@ namespace Avalonia.Rendering.Composition
if (Root != null)
Root.Root = null;
}
-
+
///
- /// Attempts to perform a hit-tst
+ /// Attempts to perform a hit-test.
///
- ///
- public PooledList? TryHitTest(Point point, CompositionVisual? root, Func? filter)
+ /// A list of visuals hit during the test.
+ public PooledList<(IntersectionResult, CompositionVisual)>? TryHitTest(
+ T input,
+ CompositionVisual? root,
+ Func? filter)
+ where THitTester : struct, ICompositionHitTester
{
Server.Compositor.Readback.NextRead();
@@ -43,10 +49,11 @@ namespace Avalonia.Rendering.Composition
var readback = root.TryGetValidReadback();
if (readback == null)
return null;
- point = point.Transform(readback.Matrix);
- var res = new PooledList();
- HitTestCore(root, point, res, filter);
+ var parentInput = THitTester.Transform(input, in readback.Matrix);
+
+ var res = new PooledList<(IntersectionResult, CompositionVisual)>();
+ HitTestCore(root, parentInput, res, filter);
return res;
}
@@ -77,31 +84,37 @@ namespace Avalonia.Rendering.Composition
}
}
- private void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList result, Func? filter)
+ private void HitTestCore(
+ CompositionVisual visual,
+ T parentInput,
+ PooledList<(IntersectionResult, CompositionVisual)> result,
+ Func? filter)
+ where THitTester : struct, ICompositionHitTester
{
- if (!HitTestVisual(visual, parentPoint, filter, out var point))
+ if (!HitTestVisual(visual, parentInput, filter, out var input))
return;
// Inspect children
if (visual is CompositionContainerVisual cv)
- HitTestChildren(cv, point, result, filter);
-
+ HitTestChildren(cv, input, result, filter);
+
// Hit-test the current node
- if (visual.HitTest(point))
- result.Add(visual);
+ if (THitTester.HitTest(visual, input) is IntersectionResult intersectionResult && intersectionResult > IntersectionResult.Empty)
+ result.Add((intersectionResult, visual));
}
- private void HitTestChildren(CompositionContainerVisual visual, Point point, PooledList result, Func? filter)
+ private void HitTestChildren(CompositionContainerVisual visual, T input, PooledList<(IntersectionResult, CompositionVisual)> result, Func? filter)
+ where THitTester : struct, ICompositionHitTester
{
if (visual.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
var candidates = RentHitTestChildCandidates(out var releaseToField);
try
{
- if (visual.TryQueryHitTestChildren(point, candidates))
+ if (visual.TryQueryHitTestChildren(input, candidates))
{
foreach (var child in candidates)
- HitTestCore(child, point, result, filter);
+ HitTestCore(child, input, result, filter);
return;
}
}
@@ -112,47 +125,58 @@ namespace Avalonia.Rendering.Composition
}
for (var c = visual.Children.Count - 1; c >= 0; c--)
- HitTestCore(visual.Children[c], point, result, filter);
+ HitTestCore(visual.Children[c], input, result, filter);
}
- private static bool HitTestVisual(CompositionVisual visual, Point parentPoint, Func? filter, out Point point)
+ private static bool HitTestVisual(
+ CompositionVisual visual,
+ T parentInput,
+ Func? filter,
+ [MaybeNullWhen(false)] out T input)
+ where THitTester : struct, ICompositionHitTester
{
- point = default;
+ input = default;
- if (visual.Visible == false)
+ if (!visual.Visible)
return false;
if (filter != null && !filter(visual))
return false;
var readback = visual.TryGetValidReadback();
- if(readback == null)
+ if (readback == null)
return false;
if (!visual.DisableSubTreeBoundsHitTestOptimization &&
- (readback.TransformedSubtreeBounds == null ||
- !readback.TransformedSubtreeBounds.Value.Contains(parentPoint)))
+ (readback.TransformedSubtreeBounds is not { } transformedSubtreeBounds ||
+ !THitTester.TransformedSubTreeBoundsMatch(transformedSubtreeBounds, parentInput)))
return false;
- if(!readback.Matrix.TryInvert(out var invMatrix))
+ if (!readback.Matrix.TryInvert(out var invMatrix))
return false;
- point = parentPoint.Transform(invMatrix);
+ input = THitTester.Transform(parentInput, in invMatrix);
- if (visual.ClipToBounds
- && (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y))
+ if (visual.ClipToBounds && !THitTester.ClippedBoundsMatch(visual, input))
return false;
- if (visual.Clip?.FillContains(point) == false)
+ if (visual.Clip is { } clip && !THitTester.ClipMatches(clip, input))
return false;
return true;
}
- public CompositionVisual? TryHitTestFirst(Point point, CompositionVisual? root, Func? filter, Func? resultFilter)
+ public CompositionVisual? TryHitTestFirst(
+ T input,
+ CompositionVisual? root,
+ Func? filter,
+ Func? resultFilter, out IntersectionResult intersectionResult)
+ where THitTester : struct, ICompositionHitTester
{
Server.Compositor.Readback.NextRead();
+ intersectionResult = IntersectionResult.NotCalculated;
+
root ??= Root;
if (root == null)
return null;
@@ -164,12 +188,21 @@ namespace Avalonia.Rendering.Composition
if (readback == null)
return null;
- return HitTestFirstCore(root, point.Transform(readback.Matrix), filter, resultFilter);
+ var parentInput = THitTester.Transform(input, in readback.Matrix);
+
+ return HitTestFirstCore(root, parentInput, filter, resultFilter, out intersectionResult);
}
- internal CompositionVisual? HitTestFirstCore(CompositionVisual visual, Point parentPoint, Func? filter, Func? resultFilter)
+ internal CompositionVisual? HitTestFirstCore(
+ CompositionVisual visual,
+ T parentInput,
+ Func? filter,
+ Func? resultFilter, out IntersectionResult intersectionResult)
+ where THitTester : struct, ICompositionHitTester
{
- if (!HitTestVisual(visual, parentPoint, filter, out var point))
+ intersectionResult = IntersectionResult.NotCalculated;
+
+ if (!HitTestVisual(visual, parentInput, filter, out var input))
return null;
if (visual is CompositionContainerVisual cv)
@@ -177,7 +210,7 @@ namespace Avalonia.Rendering.Composition
var queriedIndexedChildren = false;
if (cv.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
- if (cv.TryQueryFirstHitTestChild(this, point, filter, resultFilter, out var hit))
+ if (cv.TryQueryFirstHitTestChild(this, input, filter, resultFilter, out var hit, out intersectionResult))
{
queriedIndexedChildren = true;
if (hit != null)
@@ -189,14 +222,16 @@ namespace Avalonia.Rendering.Composition
{
for (var c = cv.Children.Count - 1; c >= 0; c--)
{
- var hit = HitTestFirstCore(cv.Children[c], point, filter, resultFilter);
+ var hit = HitTestFirstCore(cv.Children[c], input, filter, resultFilter, out intersectionResult);
if (hit != null)
return hit;
}
}
}
- return visual.HitTest(point) && (resultFilter == null || resultFilter(visual)) ? visual : null;
+ intersectionResult = THitTester.HitTest(visual, input);
+
+ return intersectionResult > IntersectionResult.Empty && (resultFilter == null || resultFilter(visual)) ? visual : null;
}
///
diff --git a/src/Avalonia.Base/Rendering/Composition/CompositionTransparencyLevel.cs b/src/Avalonia.Base/Rendering/Composition/CompositionTransparencyLevel.cs
new file mode 100644
index 0000000000..fd2abd75ee
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/CompositionTransparencyLevel.cs
@@ -0,0 +1,10 @@
+namespace Avalonia.Rendering.Composition;
+
+public enum CompositionTransparencyLevel : byte
+{
+ None = 0,
+ Transparent = 1,
+ Blur = 2,
+ AcrylicBlur = 3,
+ Mica = 4
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs b/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs
index b1ff53e5c8..917fa8b842 100644
--- a/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Compositor.Factories.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Rendering.Composition.Animations;
@@ -18,9 +19,9 @@ public partial class Compositor
{
return new CompositionTarget(this, new ServerCompositionTarget(_server, surfaces));
}
-
+
public CompositionContainerVisual CreateContainerVisual() => new(this, new ServerCompositionContainerVisual(_server));
-
+
public ExpressionAnimation CreateExpressionAnimation() => new ExpressionAnimation(this);
public ExpressionAnimation CreateExpressionAnimation(string expression) => new ExpressionAnimation(this)
@@ -40,4 +41,18 @@ public partial class Compositor
public CompositionSurfaceVisual CreateSurfaceVisual() => new(this, new ServerCompositionSurfaceVisual(_server));
public CompositionDrawingSurface CreateDrawingSurface() => new(this);
+
+ public CompositionSolidColorBrush CreateSolidColorBrush() => new(this, new ServerCompositionSolidColorBrush(Server));
+
+ public CompositionSolidColorBrush CreateSolidColorBrush(Color color) => new(this, new ServerCompositionSolidColorBrush(Server), color);
+
+ public CompositionLinearGradientBrush CreateLinearGradientBrush() => new(this, new ServerCompositionLinearGradientBrush(Server));
+
+ public CompositionConicGradientBrush CreateConicGradientBrush() => new(this, new ServerCompositionConicGradientBrush(Server));
+
+ public CompositionRadialGradientBrush CreateRadialGradientBrush() => new(this, new ServerCompositionRadialGradientBrush(Server));
+
+ public CompositionGradientStop CreateGradientStop(double offset, Color color) => new(this, new ServerCompositionGradientStop(Server), offset, color);
+
+ public CompositionGradientStop CreateGradientStop() => new(this, new ServerCompositionGradientStop(Server));
}
diff --git a/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs b/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
index ceaf18ee0c..04dedb041d 100644
--- a/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
+++ b/src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
@@ -1,6 +1,7 @@
using System;
using Avalonia.Collections.Pooled;
-using Avalonia.Rendering.Composition.Server;
+using Avalonia.Media;
+using Avalonia.Rendering.Composition.HitTesting;
namespace Avalonia.Rendering.Composition
{
@@ -53,7 +54,8 @@ namespace Avalonia.Rendering.Composition
_hitTestChildren?.Clear();
}
- internal bool TryQueryHitTestChildren(Point point, PooledList results)
+ internal bool TryQueryHitTestChildren(T input, PooledList results)
+ where THitTester : struct, ICompositionHitTester
{
if (Children.Count < HitTestAabbTreeThreshold)
{
@@ -63,22 +65,30 @@ namespace Avalonia.Rendering.Composition
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
- _hitTestChildren.Query(point, results, Server.Compositor.Readback.ReadRevision);
+ _hitTestChildren.Query(input, results, Server.Compositor.Readback.ReadRevision);
return true;
}
- internal bool TryQueryFirstHitTestChild(CompositionTarget target, Point point, Func? filter, Func? resultFilter, out CompositionVisual? hit)
+ internal bool TryQueryFirstHitTestChild(
+ CompositionTarget target,
+ T input,
+ Func? filter,
+ Func? resultFilter,
+ out CompositionVisual? hit,
+ out IntersectionResult intersectionResult)
+ where THitTester : struct, ICompositionHitTester
{
if (Children.Count < HitTestAabbTreeThreshold)
{
_hitTestChildren = null;
hit = null;
+ intersectionResult = IntersectionResult.Empty;
return false;
}
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
- hit = _hitTestChildren.QueryFirst(target, point, filter, resultFilter, Server.Compositor.Readback.ReadRevision);
+ hit = _hitTestChildren.QueryFirst(target, input, filter, resultFilter, Server.Compositor.Readback.ReadRevision, out intersectionResult);
return true;
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs
index 0ad70d7102..79a41f6452 100644
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs
@@ -1,8 +1,5 @@
using System;
-using System.Collections.Generic;
using Avalonia.Media;
-using Avalonia.Media.Immutable;
-using Avalonia.Rendering.Composition.Drawing.Nodes;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Rendering.Composition.Transport;
using Avalonia.Utilities;
@@ -12,36 +9,33 @@ namespace Avalonia.Rendering.Composition.Drawing;
internal class CompositionRenderData : ICompositorSerializable, IDisposable
{
private readonly Compositor _compositor;
+ private readonly RenderDataStream _stream;
+ private PooledInlineList _resources;
+ private bool _itemsSent;
- public CompositionRenderData(Compositor compositor)
+ public CompositionRenderData(Compositor compositor, RenderDataStream stream)
{
_compositor = compositor;
+ _stream = stream;
Server = new ServerCompositionRenderData(compositor.Server);
}
public ServerCompositionRenderData Server { get; }
- private PooledInlineList _resources;
- private PooledInlineList _items;
- private bool _itemsSent;
+
public void AddResource(ICompositionRenderResource resource) => _resources.Add(resource);
- public void Add(IRenderDataItem item) => _items.Add(item);
-
public void Dispose()
{
if (!_itemsSent)
- {
- foreach(var i in _items)
- if (i is IDisposable disp)
- disp.Dispose();
- }
-
- _items.Dispose();
- _itemsSent = false;
- foreach(var r in _resources)
+ _stream.DisposeResources();
+
+ foreach (var r in _resources)
r.ReleaseOnCompositor(_compositor);
_resources.Dispose();
-
+
+ _stream.Dispose();
+ _itemsSent = false;
+
_compositor.DisposeOnNextBatch(Server);
}
@@ -49,20 +43,11 @@ internal class CompositionRenderData : ICompositorSerializable, IDisposable
public void SerializeChanges(Compositor c, BatchStreamWriter writer)
{
- writer.Write(_items.Count);
- foreach (var item in _items)
- writer.WriteObject(item);
+ _stream.SerializeTo(writer);
_itemsSent = true;
}
- public bool HitTest(Point pt)
- {
- foreach (var op in _items)
- {
- if (op.HitTest(pt))
- return true;
- }
+ public bool HitTest(Point pt) => _stream.HitTest(pt);
- return false;
- }
-}
\ No newline at end of file
+ public IntersectionResult HitTest(Geometry geometry) => _stream.HitTest(geometry);
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs
new file mode 100644
index 0000000000..a7c7a6543e
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataGeometry.cs
@@ -0,0 +1,20 @@
+using Avalonia.Metadata;
+using Avalonia.Platform;
+
+namespace Avalonia.Rendering.Composition.Drawing;
+
+///
+/// Common abstraction for a geometry to render.
+/// It is implemented by both the immutable platform (which returns itself)
+/// and the compositor-aware server resource
+/// (which returns its current, mutable backing geometry). This lets the render data stay strongly typed
+/// while still resolving to the concrete that platform drawing contexts require.
+///
+[PrivateApi]
+public interface IRenderDataGeometry
+{
+ ///
+ /// The underlying immutable platform geometry, or null when there's nothing to draw.
+ ///
+ IGeometryImpl? GeometryImpl { get; }
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataVisitor.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataVisitor.cs
new file mode 100644
index 0000000000..3f5d4bc5e4
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/IRenderDataVisitor.cs
@@ -0,0 +1,30 @@
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Rendering.SceneGraph;
+using Avalonia.Utilities;
+
+namespace Avalonia.Rendering.Composition.Drawing;
+
+internal interface IRenderDataVisitor where TScope : unmanaged
+{
+ bool StopVisiting { get; }
+
+ void OnDrawLine(IPen? serverPen, IPen? clientPen, Point p1, Point p2);
+ void OnDrawRectangle(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, RoundedRect rect, BoxShadows boxShadows);
+ void OnDrawEllipse(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, Rect rect);
+ void OnDrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IGeometryImpl? geometry);
+ void OnDrawGlyphRun(IBrush? serverBrush, IRef? glyphRun);
+ void OnDrawBitmap(IRef? bitmap, double opacity, Rect sourceRect, Rect destRect);
+ void OnDrawCustom(ICustomDrawOperation? operation);
+
+ TScope OnPushClip(RoundedRect clip);
+ TScope OnPushGeometryClip(IGeometryImpl? geometry);
+ TScope OnPushOpacity(double opacity);
+ TScope OnPushOpacityMask(IBrush? brush, Rect bounds);
+ TScope OnPushTransform(Matrix matrix);
+ TScope OnPushRenderOptions(RenderOptions options);
+ TScope OnPushTextOptions(TextOptions options);
+ TScope OnPushEffect(IEffect? effect, Rect bounds);
+ void OnPop(in TScope scope);
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs
index 00f75ec8f8..e3b43655bc 100644
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs
@@ -1,32 +1,19 @@
-using System;
-using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Platform;
-using Avalonia.Rendering.Composition.Drawing.Nodes;
-using Avalonia.Threading;
namespace Avalonia.Rendering.Composition.Drawing;
internal class ImmediateRenderDataSceneBrushContent : ISceneBrushContent
{
- private List? _items;
- private readonly ThreadSafeObjectPool> _pool;
+ private RenderDataStream? _stream;
- public ImmediateRenderDataSceneBrushContent(ITileBrush brush, List items, Rect? rect,
- bool useScalableRasterization, ThreadSafeObjectPool> pool)
+ public ImmediateRenderDataSceneBrushContent(ITileBrush brush, RenderDataStream stream, Rect? rect,
+ bool useScalableRasterization)
{
Brush = brush;
- _items = items;
- _pool = pool;
+ _stream = stream;
UseScalableRasterization = useScalableRasterization;
- if (rect == null)
- {
- foreach (var i in _items)
- rect = Rect.Union(rect, i.Bounds);
- rect = ServerCompositionRenderData.ApplyRenderBoundsRounding(rect);
- }
-
- Rect = rect ?? default;
+ Rect = rect ?? ServerCompositionRenderData.ApplyRenderBoundsRounding(stream.CalculateBounds()) ?? default;
}
public ITileBrush Brush { get; }
@@ -38,31 +25,15 @@ internal class ImmediateRenderDataSceneBrushContent : ISceneBrushContent
public void Dispose()
{
- if(_items == null)
+ if (_stream == null)
return;
- foreach (var i in _items)
- (i as IDisposable)?.Dispose();
- _items.Clear();
- _pool.ReturnAndSetNull(ref _items);
+ _stream.DisposeResources();
+ _stream.Dispose();
+ _stream = null;
}
- void Render(IDrawingContextImpl context)
- {
- if (_items == null)
- return;
-
- var ctx = new RenderDataNodeRenderContext(context);
- try
- {
- foreach (var i in _items)
- i.Invoke(ref ctx);
- }
- finally
- {
- ctx.Dispose();
- }
- }
-
+ private void Render(IDrawingContextImpl context) => _stream?.Replay(context);
+
public void Render(IDrawingContextImpl context, Matrix? transform)
{
if (transform.HasValue)
@@ -77,5 +48,4 @@ internal class ImmediateRenderDataSceneBrushContent : ISceneBrushContent
}
public bool UseScalableRasterization { get; }
-
-}
\ No newline at end of file
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataBitmapNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataBitmapNode.cs
deleted file mode 100644
index 11a1b87fc9..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataBitmapNode.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using System;
-using Avalonia.Platform;
-using Avalonia.Utilities;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataBitmapNode : IRenderDataItem, IDisposable
-{
- public IRef? Bitmap { get; set; }
- public double Opacity { get; set; }
- public Rect SourceRect { get; set; }
- public Rect DestRect { get; set; }
-
- public bool HitTest(Point p) => DestRect.Contains(p);
-
- public void Invoke(ref RenderDataNodeRenderContext context)
- {
- if (Bitmap != null)
- context.Context.DrawBitmap(Bitmap.Item, Opacity, SourceRect, DestRect);
- }
-
- public Rect? Bounds => DestRect;
- public void Dispose()
- {
- Bitmap?.Dispose();
- Bitmap = null;
- }
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataEllipseNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataEllipseNode.cs
deleted file mode 100644
index 4e77281cc5..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataEllipseNode.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System;
-using Avalonia.Media;
-using Avalonia.Platform;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataEllipseNode :RenderDataBrushAndPenNode
-{
- public Rect Rect { get; set; }
-
- bool Contains(double dx, double dy, double radiusX, double radiusY)
- {
- var rx2 = radiusX * radiusX;
- var ry2 = radiusY * radiusY;
-
- var distance = ry2 * dx * dx + rx2 * dy * dy;
-
- return distance <= rx2 * ry2;
- }
-
- public override bool HitTest(Point p)
- {
- var center = Rect.Center;
-
- var strokeThickness = ClientPen?.Thickness ?? 0;
-
- var rx = Rect.Width / 2 + strokeThickness / 2;
- var ry = Rect.Height / 2 + strokeThickness / 2;
-
- var dx = p.X - center.X;
- var dy = p.Y - center.Y;
-
- if (Math.Abs(dx) > rx || Math.Abs(dy) > ry)
- {
- return false;
- }
-
- if (ServerBrush != null)
- {
- return Contains(dx, dy, rx, ry);
- }
- else if (strokeThickness > 0)
- {
- bool inStroke = Contains(dx, dy, rx, ry);
-
- rx = Rect.Width / 2 - strokeThickness / 2;
- ry = Rect.Height / 2 - strokeThickness / 2;
-
- bool inInner = Contains(dx, dy, rx, ry);
-
- return inStroke && !inInner;
- }
-
- return false;
- }
-
- public override void Invoke(ref RenderDataNodeRenderContext context) =>
- context.Context.DrawEllipse(ServerBrush, ServerPen, Rect);
-
- public override Rect? Bounds => Rect.Inflate(ServerPen?.Thickness ?? 0);
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGeometryNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGeometryNode.cs
deleted file mode 100644
index 5a40a6b279..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGeometryNode.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Diagnostics;
-using Avalonia.Media;
-using Avalonia.Platform;
-using Avalonia.Rendering.SceneGraph;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataGeometryNode : RenderDataBrushAndPenNode
-{
- public IGeometryImpl? Geometry { get; set; }
-
- public override bool HitTest(Point p)
- {
- if (Geometry == null)
- return false;
-
- return (ServerBrush != null // null check is safe
- && Geometry.FillContains(p)) ||
- (ClientPen != null && Geometry.StrokeContains(ClientPen, p));
- }
-
- public override void Invoke(ref RenderDataNodeRenderContext context)
- {
- Debug.Assert(Geometry != null);
- context.Context.DrawGeometry(ServerBrush, ServerPen, Geometry!);
- }
-
- public override Rect? Bounds => Geometry?.GetRenderBounds(ServerPen) ?? default;
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGlyphRunNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGlyphRunNode.cs
deleted file mode 100644
index ca2748cd55..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataGlyphRunNode.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using System.Diagnostics;
-using Avalonia.Media;
-using Avalonia.Platform;
-using Avalonia.Utilities;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataGlyphRunNode : IRenderDataItemWithServerResources, IDisposable
-{
- public IBrush? ServerBrush { get; set; }
- // Dispose only happens once, so it's safe to have one reference
- public IRef? GlyphRun { get; set; }
-
- public bool HitTest(Point p) => GlyphRun?.Item.Bounds.ContainsExclusive(p) ?? false;
-
- public void Invoke(ref RenderDataNodeRenderContext context)
- {
- Debug.Assert(GlyphRun!.Item != null);
- context.Context.DrawGlyphRun(ServerBrush, GlyphRun.Item);
- }
-
- public Rect? Bounds => GlyphRun?.Item?.Bounds ?? default;
-
- public void Collect(IRenderDataServerResourcesCollector collector)
- {
- collector.AddRenderDataServerResource(ServerBrush);
- }
-
- public void Dispose()
- {
- GlyphRun?.Dispose();
- GlyphRun = null;
- }
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataLineNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataLineNode.cs
deleted file mode 100644
index 6b6b9bdb01..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataLineNode.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using Avalonia.Media;
-using Avalonia.Platform;
-using Avalonia.Rendering.SceneGraph;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataLineNode : IRenderDataItemWithServerResources
-{
- public IPen? ServerPen { get; set; }
- public IPen? ClientPen { get; set; }
- public Point P1 { get; set; }
- public Point P2 { get; set; }
-
- public bool HitTest(Point p)
- {
- if (ClientPen == null)
- return false;
- var halfThickness = ClientPen.Thickness / 2;
- var minX = Math.Min(P1.X, P2.X) - halfThickness;
- var maxX = Math.Max(P1.X, P2.X) + halfThickness;
- var minY = Math.Min(P1.Y, P2.Y) - halfThickness;
- var maxY = Math.Max(P1.Y, P2.Y) + halfThickness;
-
- if (p.X < minX || p.X > maxX || p.Y < minY || p.Y > maxY)
- return false;
-
- var a = P1;
- var b = P2;
-
- //If dot1 or dot2 is negative, then the angle between the perpendicular and the segment is obtuse.
- //The distance from a point to a straight line is defined as the
- //length of the vector formed by the point and the closest point of the segment
-
- Vector ap = p - a;
- var dot1 = Vector.Dot(b - a, ap);
-
- if (dot1 < 0)
- return ap.Length <= ClientPen.Thickness / 2;
-
- Vector bp = p - b;
- var dot2 = Vector.Dot(a - b, bp);
-
- if (dot2 < 0)
- return bp.Length <= halfThickness;
-
- var bXaX = b.X - a.X;
- var bYaY = b.Y - a.Y;
-
- var distance = (bXaX * (p.Y - a.Y) - bYaY * (p.X - a.X)) /
- (Math.Sqrt(bXaX * bXaX + bYaY * bYaY));
-
- return Math.Abs(distance) <= halfThickness;
- }
-
-
- public void Invoke(ref RenderDataNodeRenderContext context)
- => context.Context.DrawLine(ServerPen, P1, P2);
-
- public Rect? Bounds => LineBoundsHelper.CalculateBounds(P1, P2, ServerPen!);
- public void Collect(IRenderDataServerResourcesCollector collector)
- {
- collector.AddRenderDataServerResource(ServerPen);
- }
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs
deleted file mode 100644
index 52098411cd..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataNodes.cs
+++ /dev/null
@@ -1,294 +0,0 @@
-using System;
-using System.Collections.Generic;
-using Avalonia.Media;
-using Avalonia.Platform;
-using Avalonia.Rendering.SceneGraph;
-using Avalonia.Threading;
-using Avalonia.Utilities;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-enum RenderDataPopNodeType
-{
- Transform,
- Clip,
- GeometryClip,
- Opacity,
- OpacityMask,
- Effect
-}
-
-interface IRenderDataServerResourcesCollector
-{
- void AddRenderDataServerResource(object? obj);
-}
-
-interface IRenderDataItemWithServerResources : IRenderDataItem
-{
- void Collect(IRenderDataServerResourcesCollector collector);
-}
-
-struct RenderDataNodeRenderContext : IDisposable
-{
- private Stack? _stack;
- private static readonly ThreadSafeObjectPool> s_matrixStackPool = new();
-
- public RenderDataNodeRenderContext(IDrawingContextImpl context)
- {
- Context = context;
- }
- public IDrawingContextImpl Context { get; }
-
- public Stack MatrixStack
- {
- get => _stack ??= s_matrixStackPool.Get();
- }
-
- public void Dispose()
- {
- if (_stack != null)
- {
- _stack.Clear();
- s_matrixStackPool.ReturnAndSetNull(ref _stack);
- }
- }
-}
-
-interface IRenderDataItem
-{
- ///
- /// Renders the node to a drawing context.
- ///
- /// The drawing context.
- void Invoke(ref RenderDataNodeRenderContext context);
-
- ///
- /// Gets the bounds of the visible content in the node in global coordinates.
- ///
- Rect? Bounds { get; }
-
- ///
- /// Hit test the geometry in this node.
- ///
- /// The point in global coordinates.
- /// True if the point hits the node's geometry; otherwise false.
- ///
- /// This method does not recurse to childs, if you want
- /// to hit test children they must be hit tested manually.
- ///
- bool HitTest(Point p);
-}
-
-class RenderDataCustomNode : IRenderDataItem, IDisposable
-{
- public ICustomDrawOperation? Operation { get; set; }
- public bool HitTest(Point p) => Operation?.HitTest(p) ?? false;
- public void Invoke(ref RenderDataNodeRenderContext context) => Operation?.Render(new(context.Context, false));
-
- public Rect? Bounds => Operation?.Bounds;
-
- public void Dispose()
- {
- Operation?.Dispose();
- Operation = null;
- }
-}
-
-abstract class RenderDataPushNode : IRenderDataItem, IDisposable
-{
- public PooledInlineList Children;
- public abstract void Push(ref RenderDataNodeRenderContext context);
- public abstract void Pop(ref RenderDataNodeRenderContext context);
- public void Invoke(ref RenderDataNodeRenderContext context)
- {
- if (Children.Count == 0)
- return;
- Push(ref context);
- foreach (var ch in Children)
- ch.Invoke(ref context);
- Pop(ref context);
- }
-
- public virtual Rect? Bounds
- {
- get
- {
- if (Children.Count == 0)
- return null;
- Rect? union = null;
- foreach (var i in Children)
- union = Rect.Union(union, i.Bounds);
- return union;
- }
- }
-
- public virtual bool HitTest(Point p)
- {
- if (Children.Count == 0)
- return false;
- foreach(var ch in Children)
- if (ch.HitTest(p))
- return true;
- return false;
- }
-
- public void Dispose()
- {
- if (Children.Count > 0)
- {
- foreach(var ch in Children)
- if (ch is IDisposable disposable)
- disposable.Dispose();
- Children.Dispose();
- }
- }
-}
-
-class RenderDataClipNode : RenderDataPushNode
-{
- public RoundedRect Rect { get; set; }
- public override void Push(ref RenderDataNodeRenderContext context) =>
- context.Context.PushClip(Rect);
-
- public override void Pop(ref RenderDataNodeRenderContext context) =>
- context.Context.PopClip();
-
- public override bool HitTest(Point p)
- {
- if (!Rect.Rect.Contains(p))
- return false;
- return base.HitTest(p);
- }
-}
-
-class RenderDataGeometryClipNode : RenderDataPushNode
-{
- public IGeometryImpl? Geometry { get; set; }
- public bool Contains(Point p) => Geometry?.FillContains(p) ?? false;
-
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- if (Geometry != null)
- context.Context.PushGeometryClip(Geometry);
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- if (Geometry != null)
- context.Context.PopGeometryClip();
- }
-
- public override bool HitTest(Point p)
- {
- if (Geometry != null && !Geometry.FillContains(p))
- return false;
- return base.HitTest(p);
- }
-}
-
-class RenderDataOpacityNode : RenderDataPushNode
-{
- public double Opacity { get; set; }
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- if (Opacity != 1)
- context.Context.PushOpacity(Opacity, null);
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- if (Opacity != 1)
- context.Context.PopOpacity();
- }
-}
-
-abstract class RenderDataBrushAndPenNode : IRenderDataItemWithServerResources
-{
- public IBrush? ServerBrush { get; set; }
- public IPen? ServerPen { get; set; }
- public IPen? ClientPen { get; set; }
-
- public void Collect(IRenderDataServerResourcesCollector collector)
- {
- collector.AddRenderDataServerResource(ServerBrush);
- collector.AddRenderDataServerResource(ServerPen);
- }
-
- public abstract void Invoke(ref RenderDataNodeRenderContext context);
- public abstract Rect? Bounds { get; }
- public abstract bool HitTest(Point p);
-}
-
-class RenderDataRenderOptionsNode : RenderDataPushNode
-{
- public RenderOptions RenderOptions { get; set; }
-
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- context.Context.PushRenderOptions(RenderOptions);
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- context.Context.PopRenderOptions();
- }
-}
-
-class RenderDataTextOptionsNode : RenderDataPushNode
-{
- public TextOptions TextOptions { get; set; }
-
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- context.Context.PushTextOptions(TextOptions);
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- context.Context.PopTextOptions();
- }
-}
-
-///
-/// A render data node that pushes an effect.
-///
-class RenderDataEffectNode : RenderDataPushNode
-{
- ///
- /// Gets or sets the effect to push.
- ///
- public IEffect? Effect { get; set; }
-
- ///
- /// Gets or sets the bounds of the effect.
- ///
- public Rect BoundsRect { get; set; }
-
- ///
- public override Rect? Bounds
- {
- get
- {
- var bounds = base.Bounds;
- if (bounds is null)
- return null;
- return Effect is { } effect
- ? bounds.Value.Inflate(effect.GetEffectOutputPadding())
- : bounds;
- }
- }
-
- ///
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl)
- effectImpl.PushEffect(BoundsRect, Effect);
- }
-
- ///
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- if (Effect != null && context.Context is IDrawingContextImplWithEffects effectImpl)
- effectImpl.PopEffect();
- }
-}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushMatrixNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushMatrixNode.cs
deleted file mode 100644
index 2ccfa0e7a5..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushMatrixNode.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataPushMatrixNode : RenderDataPushNode
-{
- public Matrix Matrix { get; set; }
-
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- var current = context.Context.Transform;
- context.MatrixStack.Push(current);
- context.Context.Transform = Matrix * current;
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context)
- {
- context.Context.Transform = context.MatrixStack.Pop();
- }
-
- public override bool HitTest(Point p)
- {
- if (Matrix.TryInvert(out var inverted))
- return base.HitTest(p.Transform(inverted));
- return false;
- }
-
- public override Rect? Bounds => base.Bounds?.TransformToAABB(Matrix);
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushOpacityMaskNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushOpacityMaskNode.cs
deleted file mode 100644
index 14607282da..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataPushOpacityMaskNode.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Avalonia.Media;
-using Avalonia.Platform;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataOpacityMaskNode : RenderDataPushNode, IRenderDataItemWithServerResources
-{
- public IBrush? ServerBrush { get; set; }
-
- public Rect BoundsRect { get; set; }
-
- public void Collect(IRenderDataServerResourcesCollector collector)
- {
- collector.AddRenderDataServerResource(ServerBrush);
- }
-
- public override void Push(ref RenderDataNodeRenderContext context)
- {
- if (ServerBrush != null)
- context.Context.PushOpacityMask(ServerBrush, BoundsRect);
- }
-
- public override void Pop(ref RenderDataNodeRenderContext context) =>
- context.Context.PopOpacityMask();
-}
\ No newline at end of file
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs
deleted file mode 100644
index c6a3859e3d..0000000000
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/Nodes/RenderDataRectangleNode.cs
+++ /dev/null
@@ -1,46 +0,0 @@
-using Avalonia.Media;
-
-namespace Avalonia.Rendering.Composition.Drawing.Nodes;
-
-class RenderDataRectangleNode : RenderDataBrushAndPenNode
-{
- public RoundedRect Rect { get; set; }
- public BoxShadows BoxShadows { get; set; }
-
- public override bool HitTest(Point p)
- {
- var strokeThicknessAdjustment = (ClientPen?.Thickness / 2) ?? 0;
-
- if (Rect.IsRounded)
- {
- var outerRoundedRect = Rect.Inflate(strokeThicknessAdjustment, strokeThicknessAdjustment);
- if (outerRoundedRect.ContainsExclusive(p))
- {
- if (ServerBrush != null) // it's safe to check for null
- return true;
-
- var innerRoundedRect = Rect.Deflate(strokeThicknessAdjustment, strokeThicknessAdjustment);
- return !innerRoundedRect.ContainsExclusive(p);
- }
- }
- else
- {
- var outerRect = Rect.Rect.Inflate(strokeThicknessAdjustment);
- if (outerRect.ContainsExclusive(p))
- {
- if (ServerBrush != null) // it's safe to check for null
- return true;
-
- var innerRect = Rect.Rect.Deflate(strokeThicknessAdjustment);
- return !innerRect.ContainsExclusive(p);
- }
- }
-
- return false;
- }
-
- public override void Invoke(ref RenderDataNodeRenderContext context) =>
- context.Context.DrawRectangle(ServerBrush, ServerPen, Rect, BoxShadows);
-
- public override Rect? Bounds => BoxShadows.TransformBounds(Rect.Rect).Inflate((ServerPen?.Thickness ?? 0) / 2);
-}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs
index 28370d71e1..cc4799b439 100644
--- a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataDrawingContext.cs
@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-using System.Runtime.CompilerServices;
using Avalonia.Media;
using Avalonia.Media.Immutable;
using Avalonia.Platform;
-using Avalonia.Rendering.Composition.Drawing.Nodes;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Threading;
using Avalonia.Utilities;
@@ -16,101 +13,48 @@ namespace Avalonia.Rendering.Composition.Drawing;
internal class RenderDataDrawingContext : DrawingContext
{
private readonly Compositor? _compositor;
+ private RenderDataStream? _stream;
private CompositionRenderData? _renderData;
private HashSet? _resourcesHashSet;
+ private Stack? _pushStack;
private static readonly ThreadSafeObjectPool> s_hashSetPool = new();
- private CompositionRenderData RenderData
- {
- get
- {
- Debug.Assert(_compositor != null);
- return _renderData ??= new(_compositor);
- }
- }
-
- struct ParentStackItem
+ private static readonly ThreadSafeObjectPool> s_pushStackPool = new();
+
+ private struct PushEntry
{
- public RenderDataPushNode? Node;
- public List Items;
+ public bool Emitted;
+ public int PositionBefore;
+ public int PositionAfter;
+ public int DepthBefore;
}
-
- private List? _currentItemList;
- private static readonly ThreadSafeObjectPool> s_listPool = new();
-
- private Stack? _parentNodeStack;
- private static readonly ThreadSafeObjectPool> s_parentStackPool = new();
public RenderDataDrawingContext(Compositor? compositor)
{
_compositor = compositor;
}
- void Add(IRenderDataItem item)
- {
- _currentItemList ??= s_listPool.Get();
- _currentItemList.Add(item);
- }
-
- void Push(RenderDataPushNode? node = null)
- {
- // Push a fake no-op node so something could be popped by the corresponding Pop call
- // Since there is no nesting, we don't update the item list
- if (node == null)
- {
- (_parentNodeStack ??= s_parentStackPool.Get()).Push(default);
- return;
- }
- Add(node);
- (_parentNodeStack ??= s_parentStackPool.Get()).Push(new ParentStackItem
- {
- Node = node,
- Items = _currentItemList!
- });
- _currentItemList = null;
- }
-
- void Pop() where T : IRenderDataItem
- {
- var parent = _parentNodeStack!.Pop();
-
- // No-op node
- if (parent.Node == null)
- return;
+ private RenderDataStream Stream => _stream ??= new RenderDataStream();
- if (!(parent.Node is T))
- throw new InvalidOperationException("Invalid Pop operation");
-
- var removeLastPush = true;
- if (_currentItemList != null)
- {
- removeLastPush = _currentItemList.Count == 0;
- foreach (var item in _currentItemList)
- parent.Node.Children.Add(item);
- _currentItemList.Clear();
- s_listPool.ReturnAndSetNull(ref _currentItemList);
- }
- _currentItemList = parent.Items;
- if (removeLastPush)
- _currentItemList.RemoveAt(_currentItemList.Count - 1);
- }
+ private CompositionRenderData RenderData => _renderData ??= new CompositionRenderData(_compositor!, Stream);
- void AddResource(object? resource)
+ private void AddResource(object? resource)
{
if (_compositor == null)
return;
-
+
if (resource == null
|| resource is IImmutableBrush
|| resource is ImmutablePen
- || resource is ImmutableTransform)
+ || resource is ImmutableTransform
+ || resource is CompositionBrush)
return;
-
+
if (resource is ICompositionRenderResource renderResource)
{
_resourcesHashSet ??= s_hashSetPool.Get();
if (!_resourcesHashSet.Add(renderResource))
return;
-
+
renderResource.AddRefOnCompositor(_compositor);
RenderData.AddResource(renderResource);
return;
@@ -118,19 +62,37 @@ internal class RenderDataDrawingContext : DrawingContext
throw new InvalidOperationException(resource.GetType().FullName + " can not be used with this DrawingContext");
}
-
+
+ private void PushedScope(int positionBefore) =>
+ (_pushStack ??= s_pushStackPool.Get()).Push(new PushEntry
+ {
+ Emitted = true,
+ PositionBefore = positionBefore,
+ PositionAfter = Stream.OpcodeLength,
+ DepthBefore = Stream.Depth - 1
+ });
+
+ private void PushedNoOpScope() =>
+ (_pushStack ??= s_pushStackPool.Get()).Push(new PushEntry { Emitted = false });
+
+ private void PopCore()
+ {
+ var entry = _pushStack!.Pop();
+ if (!entry.Emitted)
+ return;
+
+ if (Stream.OpcodeLength == entry.PositionAfter)
+ Stream.Rewind(entry.PositionBefore, entry.DepthBefore);
+ else
+ Stream.Pop();
+ }
+
protected override void DrawLineCore(IPen? pen, Point p1, Point p2)
{
- if(pen == null)
+ if (pen == null)
return;
AddResource(pen);
- Add(new RenderDataLineNode
- {
- ClientPen = pen,
- ServerPen = pen.GetServer(_compositor),
- P1 = p1,
- P2 = p2
- });
+ Stream.DrawLine(pen.GetServer(_compositor), pen, p1, p2);
}
protected override void DrawGeometryCore(IBrush? brush, IPen? pen, IGeometryImpl geometry)
@@ -139,244 +101,236 @@ internal class RenderDataDrawingContext : DrawingContext
return;
AddResource(brush);
AddResource(pen);
- Add(new RenderDataGeometryNode
- {
- ServerBrush = brush.GetServer(_compositor),
- ServerPen = pen.GetServer(_compositor),
- ClientPen = pen,
- Geometry = geometry
- });
+ Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry);
+ }
+
+ protected override void DrawGeometryCore(IBrush? brush, IPen? pen, Geometry geometry)
+ {
+ if (brush is null && pen is null)
+ return;
+
+ AddResource(brush);
+ AddResource(pen);
+ AddResource(geometry);
+
+ Stream.DrawGeometry(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, geometry.GetServer(_compositor));
}
protected override void DrawRectangleCore(IBrush? brush, IPen? pen, RoundedRect rrect, BoxShadows boxShadows = default)
{
if (rrect.IsEmpty())
return;
- if(brush == null && pen == null && boxShadows == default)
+ if (brush == null && pen == null && boxShadows == default)
return;
AddResource(brush);
AddResource(pen);
- Add(new RenderDataRectangleNode
- {
- ServerBrush = brush.GetServer(_compositor),
- ServerPen = pen.GetServer(_compositor),
- ClientPen = pen,
- Rect = rrect,
- BoxShadows = boxShadows
- });
+ Stream.DrawRectangle(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, rrect, boxShadows);
}
protected override void DrawEllipseCore(IBrush? brush, IPen? pen, Rect rect)
{
if (rect.IsEmpty())
return;
-
- if(brush == null && pen == null)
+ if (brush == null && pen == null)
return;
AddResource(brush);
AddResource(pen);
- Add(new RenderDataEllipseNode
- {
- ServerBrush = brush.GetServer(_compositor),
- ServerPen = pen.GetServer(_compositor),
- ClientPen = pen,
- Rect = rect,
- });
+ Stream.DrawEllipse(brush.GetServer(_compositor), pen.GetServer(_compositor), pen, rect);
}
- public override void Custom(ICustomDrawOperation custom) => Add(new RenderDataCustomNode
- {
- Operation = custom
- });
+ public override void Custom(ICustomDrawOperation custom) => Stream.DrawCustom(custom);
public override void DrawGlyphRun(IBrush? foreground, GlyphRun? glyphRun)
{
if (foreground == null || glyphRun == null)
return;
AddResource(foreground);
- Add(new RenderDataGlyphRunNode
- {
- ServerBrush = foreground.GetServer(_compositor),
- GlyphRun = glyphRun.PlatformImpl.Clone()
- });
+ Stream.DrawGlyphRun(foreground.GetServer(_compositor), glyphRun.PlatformImpl.Clone());
}
- protected override void PushClipCore(RoundedRect rect) => Push(new RenderDataClipNode
+ internal override void DrawBitmap(IRef? source, double opacity, Rect sourceRect, Rect destRect)
{
- Rect = rect
- });
+ if (source == null || sourceRect.IsEmpty() || destRect.IsEmpty())
+ return;
+ Stream.DrawBitmap(source.Clone(), opacity, sourceRect, destRect);
+ }
+
+ protected override void PushClipCore(RoundedRect rect)
+ {
+ var before = Stream.OpcodeLength;
+ Stream.PushClip(rect);
+ PushedScope(before);
+ }
- protected override void PushClipCore(Rect rect) => Push(new RenderDataClipNode
+ protected override void PushClipCore(Rect rect)
{
- Rect = rect
- });
+ var before = Stream.OpcodeLength;
+ Stream.PushClip(new RoundedRect(rect));
+ PushedScope(before);
+ }
protected override void PushGeometryClipCore(Geometry? clip)
{
if (clip == null)
- Push();
- else
- Push(new RenderDataGeometryClipNode
- {
- Geometry = clip?.PlatformImpl
- });
+ {
+ PushedNoOpScope();
+ return;
+ }
+
+ AddResource(clip);
+ var before = Stream.OpcodeLength;
+ Stream.PushGeometryClip(clip.GetServer(_compositor));
+ PushedScope(before);
}
protected override void PushOpacityCore(double opacity)
{
if (opacity == 1)
- Push();
- else
- Push(new RenderDataOpacityNode
- {
- Opacity = opacity
- });
+ {
+ PushedNoOpScope();
+ return;
+ }
+
+ var before = Stream.OpcodeLength;
+ Stream.PushOpacity(opacity);
+ PushedScope(before);
}
protected override void PushOpacityMaskCore(IBrush? mask, Rect bounds)
{
- if(mask == null)
- Push();
- else
+ if (mask == null)
{
- AddResource(mask);
- Push(new RenderDataOpacityMaskNode
- {
- ServerBrush = mask.GetServer(_compositor),
- BoundsRect = bounds
- });
+ PushedNoOpScope();
+ return;
}
+
+ AddResource(mask);
+ var before = Stream.OpcodeLength;
+ Stream.PushOpacityMask(mask.GetServer(_compositor), bounds);
+ PushedScope(before);
}
protected override void PushTransformCore(Matrix matrix)
{
if (matrix.IsIdentity)
- Push();
- else
- Push(new RenderDataPushMatrixNode()
- {
- Matrix = matrix
- });
+ {
+ PushedNoOpScope();
+ return;
+ }
+
+ var before = Stream.OpcodeLength;
+ Stream.PushTransform(matrix);
+ PushedScope(before);
}
- protected override void PushRenderOptionsCore(RenderOptions renderOptions) => Push(new RenderDataRenderOptionsNode()
+ protected override void PushRenderOptionsCore(RenderOptions renderOptions)
{
- RenderOptions = renderOptions
- });
+ var before = Stream.OpcodeLength;
+ Stream.PushRenderOptions(renderOptions);
+ PushedScope(before);
+ }
- protected override void PushTextOptionsCore(TextOptions textOptions) => Push(new RenderDataTextOptionsNode()
+ protected override void PushTextOptionsCore(TextOptions textOptions)
{
- TextOptions = textOptions
- });
+ var before = Stream.OpcodeLength;
+ Stream.PushTextOptions(textOptions);
+ PushedScope(before);
+ }
- ///
- protected override void PushEffectCore(IEffect effect, Rect bounds) => Push(new RenderDataEffectNode()
+ protected override void PushEffectCore(IEffect effect, Rect bounds)
{
- Effect = effect.ToImmutable(),
- BoundsRect = bounds.Inflate(effect.GetEffectOutputPadding())
- });
+ var before = Stream.OpcodeLength;
+ Stream.PushEffect(effect.ToImmutable(), bounds.Inflate(effect.GetEffectOutputPadding()));
+ PushedScope(before);
+ }
- protected override void PopClipCore() => Pop();
+ protected override void PopClipCore() => PopCore();
- protected override void PopGeometryClipCore() => Pop();
+ protected override void PopGeometryClipCore() => PopCore();
- protected override void PopOpacityCore() => Pop();
+ protected override void PopOpacityCore() => PopCore();
- protected override void PopOpacityMaskCore() => Pop();
+ protected override void PopOpacityMaskCore() => PopCore();
- protected override void PopTransformCore() => Pop();
+ protected override void PopTransformCore() => PopCore();
- protected override void PopRenderOptionsCore() => Pop();
+ protected override void PopRenderOptionsCore() => PopCore();
- protected override void PopTextOptionsCore() => Pop();
+ protected override void PopTextOptionsCore() => PopCore();
- ///
- protected override void PopEffectCore() => Pop();
+ protected override void PopEffectCore() => PopCore();
- internal override void DrawBitmap(IRef? source, double opacity, Rect sourceRect, Rect destRect)
+ private void FlushStack()
{
- if (source == null || sourceRect.IsEmpty() || destRect.IsEmpty())
- return;
- Add(new RenderDataBitmapNode
- {
- Bitmap = source.Clone(),
- Opacity = opacity,
- SourceRect = sourceRect,
- DestRect = destRect
- });
+ while (_pushStack is { Count: > 0 })
+ PopCore();
}
-
- void FlushStack()
- {
- // Flush stack
- if (_parentNodeStack != null)
- {
- // TODO: throw error, unbalanced stack
- while (_parentNodeStack.Count > 0)
- Pop();
- }
-
-
- }
-
public CompositionRenderData? GetRenderResults()
{
Debug.Assert(_compositor != null);
-
FlushStack();
-
- // Transfer items to RenderData
- if (_currentItemList is { Count: > 0 })
+
+ var rv = _renderData;
+ if (rv == null)
{
- foreach (var i in _currentItemList)
- RenderData.Add(i);
- _currentItemList.Clear();
+ if (_stream is { OpcodeLength: > 0 })
+ rv = new CompositionRenderData(_compositor!, _stream);
+ else
+ {
+ _stream?.Dispose();
+ _stream = null;
+ return null;
+ }
}
- var rv = _renderData;
_renderData = null;
+ _stream = null;
_resourcesHashSet?.Clear();
-
- if (rv != null)
- _compositor.RegisterForSerialization(rv);
+
+ _compositor!.RegisterForSerialization(rv);
return rv;
}
public ImmediateRenderDataSceneBrushContent? GetImmediateSceneBrushContent(ITileBrush brush, Rect? rect, bool useScalableRasterization)
{
Debug.Assert(_compositor == null);
- Debug.Assert(_resourcesHashSet == null);
Debug.Assert(_renderData == null);
-
FlushStack();
- if (_currentItemList == null || _currentItemList.Count == 0)
- return null;
- var itemList = _currentItemList;
- _currentItemList = null;
+ if (_stream is not { OpcodeLength: > 0 })
+ {
+ _stream?.Dispose();
+ _stream = null;
+ return null;
+ }
- return new ImmediateRenderDataSceneBrushContent(brush, itemList, rect, useScalableRasterization, s_listPool);
+ var stream = _stream;
+ _stream = null;
+ return new ImmediateRenderDataSceneBrushContent(brush, stream, rect, useScalableRasterization);
}
public void Reset()
{
- // This means that render data should be discarded
if (_renderData != null)
{
_renderData.Dispose();
_renderData = null;
}
+ else
+ _stream?.Dispose();
- _currentItemList?.Clear();
- _parentNodeStack?.Clear();
+ _stream = null;
+ _pushStack?.Clear();
_resourcesHashSet?.Clear();
}
-
+
protected override void DisposeCore()
{
Reset();
- if (_resourcesHashSet != null)
+ if (_resourcesHashSet != null)
s_hashSetPool.ReturnAndSetNull(ref _resourcesHashSet);
+ if (_pushStack != null)
+ s_pushStackPool.ReturnAndSetNull(ref _pushStack);
}
}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataOpcode.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataOpcode.cs
new file mode 100644
index 0000000000..888ee0cd12
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataOpcode.cs
@@ -0,0 +1,22 @@
+namespace Avalonia.Rendering.Composition.Drawing;
+
+internal enum RenderDataOpcode : byte
+{
+ Invalid = 0,
+ DrawLine,
+ DrawRectangle,
+ DrawEllipse,
+ DrawGeometry,
+ DrawGlyphRun,
+ DrawBitmap,
+ DrawCustom,
+ PushClip,
+ PushGeometryClip,
+ PushOpacity,
+ PushOpacityMask,
+ PushTransform,
+ PushRenderOptions,
+ PushTextOptions,
+ PushEffect,
+ Pop
+}
diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataPayloads.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataPayloads.cs
new file mode 100644
index 0000000000..6bb0c8b08c
--- /dev/null
+++ b/src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataPayloads.cs
@@ -0,0 +1,132 @@
+using Avalonia.Media;
+
+namespace Avalonia.Rendering.Composition.Drawing;
+
+internal interface IRenderDataPayload where TSelf : unmanaged, IRenderDataPayload
+{
+ static abstract RenderDataOpcode Opcode { get; }
+}
+
+internal struct DrawLinePayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawLine;
+
+ public int ServerPen;
+ public int ClientPen;
+ public Point P1;
+ public Point P2;
+}
+
+internal struct DrawRectanglePayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawRectangle;
+
+ public int ServerBrush;
+ public int ServerPen;
+ public int ClientPen;
+ public RoundedRect Rect;
+ public int BoxShadowCount;
+}
+
+internal struct DrawEllipsePayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawEllipse;
+
+ public int ServerBrush;
+ public int ServerPen;
+ public int ClientPen;
+ public Rect Rect;
+}
+
+internal struct DrawGeometryPayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawGeometry;
+
+ public int ServerBrush;
+ public int ServerPen;
+ public int ClientPen;
+ public int Geometry;
+}
+
+internal struct DrawGlyphRunPayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawGlyphRun;
+
+ public int ServerBrush;
+ public int GlyphRun;
+}
+
+internal struct DrawBitmapPayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawBitmap;
+
+ public int Bitmap;
+ public double Opacity;
+ public Rect SourceRect;
+ public Rect DestRect;
+}
+
+internal struct DrawCustomPayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.DrawCustom;
+
+ public int Operation;
+}
+
+internal struct PushClipPayload : IRenderDataPayload
+{
+ public static RenderDataOpcode Opcode => RenderDataOpcode.PushClip;
+
+ public RoundedRect Clip;
+}
+
+internal struct PushGeometryClipPayload : IRenderDataPayload