diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index 0cddbdf9fd..b021c9f4a5 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -8,6 +8,7 @@ "samples\\ControlCatalog\\ControlCatalog.csproj", "samples\\GpuInterop\\GpuInterop.csproj", "samples\\IntegrationTestApp\\IntegrationTestApp.csproj", + "samples\\TextTestApp\\TextTestApp.csproj", "samples\\MiniMvvm\\MiniMvvm.csproj", "samples\\ReactiveUIDemo\\ReactiveUIDemo.csproj", "samples\\RenderDemo\\RenderDemo.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 8c857389dc..b4decb7dcc 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -120,6 +121,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Props", "Props", "{F3AC8BC1 build\UnitTests.NetFX.props = build\UnitTests.NetFX.props build\WarnAsErrors.props = build\WarnAsErrors.props build\XUnit.props = build\XUnit.props + build\AnalyzerProject.targets = build\AnalyzerProject.targets EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Targets", "Targets", "{4D6FAF79-58B4-482F-9122-0668C346364C}" @@ -190,6 +192,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniMvvm", "samples\MiniMvv EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApp", "samples\IntegrationTestApp\IntegrationTestApp.csproj", "{676D6BFD-029D-4E43-BFC7-3892265CE251}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TextTestApp", "samples\TextTestApp\TextTestApp.csproj", "{CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.IntegrationTests.Appium", "tests\Avalonia.IntegrationTests.Appium\Avalonia.IntegrationTests.Appium.csproj", "{F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Browser", "Browser", "{86A3F706-DC3C-43C6-BE1B-B98F5BAAA268}" @@ -299,6 +303,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Win32.Automation", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XEmbedSample", "samples\XEmbedSample\XEmbedSample.csproj", "{255614F5-CB64-4ECA-A026-E0B1AF6A2EF4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.MacCatalyst", "samples\ControlCatalog.iOS\ControlCatalog.MacCatalyst.csproj", "{DE3C28DD-B602-4750-831D-345102A54CA0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.tvOS", "samples\ControlCatalog.iOS\ControlCatalog.tvOS.csproj", "{14342787-B4EF-4076-8C91-BA6C523DE8DF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -533,6 +541,10 @@ Global {676D6BFD-029D-4E43-BFC7-3892265CE251}.Debug|Any CPU.Build.0 = Debug|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.ActiveCfg = Release|Any CPU {676D6BFD-029D-4E43-BFC7-3892265CE251}.Release|Any CPU.Build.0 = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F}.Release|Any CPU.Build.0 = Release|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -699,6 +711,14 @@ Global {255614F5-CB64-4ECA-A026-E0B1AF6A2EF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {255614F5-CB64-4ECA-A026-E0B1AF6A2EF4}.Release|Any CPU.ActiveCfg = Release|Any CPU {255614F5-CB64-4ECA-A026-E0B1AF6A2EF4}.Release|Any CPU.Build.0 = Release|Any CPU + {DE3C28DD-B602-4750-831D-345102A54CA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE3C28DD-B602-4750-831D-345102A54CA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE3C28DD-B602-4750-831D-345102A54CA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE3C28DD-B602-4750-831D-345102A54CA0}.Release|Any CPU.Build.0 = Release|Any CPU + {14342787-B4EF-4076-8C91-BA6C523DE8DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14342787-B4EF-4076-8C91-BA6C523DE8DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14342787-B4EF-4076-8C91-BA6C523DE8DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14342787-B4EF-4076-8C91-BA6C523DE8DF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -748,6 +768,7 @@ Global {11BE52AF-E2DD-4CF0-B19A-05285ACAF571} = {9B9E3891-2366-4253-A952-D08BCEB71098} {BC594FD5-4AF2-409E-A1E6-04123F54D7C5} = {9B9E3891-2366-4253-A952-D08BCEB71098} {676D6BFD-029D-4E43-BFC7-3892265CE251} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {CE728F96-A593-462C-B8D4-1D5AFFDB5B4F} = {9B9E3891-2366-4253-A952-D08BCEB71098} {F2CE566B-E7F6-447A-AB1A-3F574A6FE43A} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {26A98DA1-D89D-4A95-8152-349F404DA2E2} = {A0CC0258-D18C-4AB3-854F-7101680FC3F9} {A0D0A6A4-5C72-4ADA-9B27-621C7D94F270} = {9B9E3891-2366-4253-A952-D08BCEB71098} @@ -787,6 +808,8 @@ Global {9AE1B827-21AC-4063-AB22-C8804B7F931E} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {0097673D-DBCE-476E-82FE-E78A56E58AA2} = {B39A8919-9F95-48FE-AD7B-76E08B509888} {255614F5-CB64-4ECA-A026-E0B1AF6A2EF4} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {DE3C28DD-B602-4750-831D-345102A54CA0} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {14342787-B4EF-4076-8C91-BA6C523DE8DF} = {9B9E3891-2366-4253-A952-D08BCEB71098} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 02fdc4116f..a3ab4977f2 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -114,6 +114,40 @@ M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant) baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + M:Avalonia.Threading.DispatcherPriorityAwaitable.get_IsCompleted + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable.OnCompleted(System.Action) + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetAwaiter + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0002 + M:Avalonia.Threading.DispatcherPriorityAwaitable`1.GetResult + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll CP0002 @@ -193,6 +227,30 @@ baseline/netstandard2.0/Avalonia.Controls.dll target/netstandard2.0/Avalonia.Controls.dll + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0007 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + + + CP0008 + T:Avalonia.Threading.DispatcherPriorityAwaitable`1 + baseline/netstandard2.0/Avalonia.Base.dll + target/netstandard2.0/Avalonia.Base.dll + CP0009 T:Avalonia.Diagnostics.StyleDiagnostics diff --git a/azure-pipelines.yml b/azure-pipelines.yml index dc23500374..d9a4c80fdf 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -80,7 +80,7 @@ jobs: displayName: 'Install Workloads' inputs: script: | - dotnet workload install android ios macos wasm-tools + dotnet workload install android ios maccatalyst macos wasm-tools - task: CmdLine@2 displayName: 'Generate avalonia-native' @@ -154,7 +154,7 @@ jobs: displayName: 'Install Workloads' inputs: script: | - dotnet workload install android ios tvos wasm-tools + dotnet workload install android maccatalyst ios tvos wasm-tools - task: CmdLine@2 displayName: 'Install Nuke' diff --git a/build/AnalyzerProject.targets b/build/AnalyzerProject.targets new file mode 100644 index 0000000000..f067ec0418 --- /dev/null +++ b/build/AnalyzerProject.targets @@ -0,0 +1,14 @@ + + + + true + true + + + + + + + + + diff --git a/build/TargetFrameworks.props b/build/TargetFrameworks.props index 0b04810436..e48c9933f3 100644 --- a/build/TargetFrameworks.props +++ b/build/TargetFrameworks.props @@ -4,6 +4,7 @@ $(AvsCurrentTargetFramework)-windows $(AvsCurrentTargetFramework)-macos $(AvsCurrentTargetFramework)-android34.0 + $(AvsCurrentTargetFramework)-maccatalyst17.0 $(AvsCurrentTargetFramework)-ios17.0 $(AvsCurrentTargetFramework)-tvos17.0 $(AvsCurrentTargetFramework)-browser diff --git a/docs/build.md b/docs/build.md index 065d2ee960..d54bdeee20 100644 --- a/docs/build.md +++ b/docs/build.md @@ -16,7 +16,7 @@ Go to https://dotnet.microsoft.com/en-us/download/visual-studio-sdks and install Since Avalonia targets pretty much every supported .NET platform, you need to install these workloads as well. Running it from the command line: ```bash -dotnet workload install android ios wasm-tools +dotnet workload install android ios maccatalyst wasm-tools ``` macOS workloads are not required to build Avalonia. @@ -97,25 +97,6 @@ On macOS it is necessary to build and manually install the respective native lib ./build.sh CompileNative ``` -# Building Avalonia into a local NuGet cache - -It is possible to build Avalonia locally and generate NuGet packages that can be used locally to test local changes. - -First, install Nuke's dotnet global tool like so: - -```bash -dotnet tool install Nuke.GlobalTool --global -``` - -Then you need to run: -```bash -nuke --target BuildToNuGetCache --configuration Release -``` - -This command will generate nuget packages and push them into a local NuGet automatically. -To use these packages use `9999.0.0-localbuild` package version. -Each time local changes are made to Avalonia, running this command again will replace old packages and reset cache for the same version. - ## Browser To build and run browser/wasm projects, it's necessary to install NodeJS. @@ -124,3 +105,7 @@ You can find latest LTS on https://nodejs.org/. ## Windows It is possible to run some .NET Framework samples and tests using .NET Framework SDK. You need to install at least 4.7 SDK. + +## Building Avalonia into a local NuGet cache + +See [Building Local NuGet Packages](nuget.md) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 6adc4fd450..c8355c0f74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ This documentation covers Avalonia framework development. For user documentation - [Debugging the XAML Compiler](debug-xaml-compiler.md) - [Porting Code from 3rd Party Sources](porting-code-from-3rd-party-sources.md) +- [Building Local NuGet Packages](nuget.md) ## Releases diff --git a/docs/nuget.md b/docs/nuget.md new file mode 100644 index 0000000000..3ccb571bab --- /dev/null +++ b/docs/nuget.md @@ -0,0 +1,56 @@ +# Building Local NuGet Packages + +To build NuGet packages, one can use the `CreateNugetPackages` target: + +Windows + +``` +.\build.ps1 CreateNugetPackages +``` + +Linux/macOS + +``` +./build.sh CreateNugetPackages +``` + +Or if you have Nuke's [dotnet global tool](https://nuke.build/docs/getting-started/installation/) installed: + +``` +nuke CreateNugetPackages +``` + +The produced NuGet packages will be placed in the `artifacts\nuget` directory. + +> [!NOTE] +> The rest of this document will assume that you have the Nuke global tool installed, as the invocation is the same on all platforms. You can always replace `nuke` in the instructions below with the `build` script relvant to your platform. + +By default the packages will be built in debug configuration. To build in relase configuration add the `--configuration` parameter, e.g.: + +``` +nuke CreateNugetPackages --configuration Release +``` + +To configure the version of the built packages, add the `--force-nuget-version` parameter, e.g.: + +``` +nuke CreateNugetPackages --force-nuget-version 11.4.0 +``` + +## Building to the Local Cache + +Building packages with the `CreateNugetPackages` target has a few gotchas: + +- One needs to set up a local nuget feed to consume the packages +- When building on an operating system other than macOS, the Avalonia.Native package will not be built, resulting in a NuGet error when trying to use Avalonia.Desktop +- It's easy to introduce versioning problems + +For these reasons, it is possible to build Avalonia directly to your machine's NuGet cache using the `BuildToNuGetCache` target: + +```bash +nuke --target BuildToNuGetCache --configuration Release +``` + +This command will generate nuget packages and push them into your local NuGet cache (usually `~/.nuget/packages`) with a version of `9999.0.0-localbuild`. + +Each time local changes are made to Avalonia, running this command again will replace the old packages and reset the cache, meaning that the changes should be picked up automatically by msbuild. diff --git a/docs/release.md b/docs/release.md index 00c83cc47c..42f8927f42 100644 --- a/docs/release.md +++ b/docs/release.md @@ -16,8 +16,10 @@ This document describes the process for creating a new Avalonia release - Create a branch named e.g. `release/11.0.9` for the specific minor version - Update the version number in the file [SharedVersion.props](../build/SharedVersion.props), e.g. `11.0.9` -- Add a tag for this version, e.g. `git tag 11.0.9` -- Push the release branch and the tag. +- Commit the file. +- Add a tag for this version, e.g. `git tag 11.0.9`. +- Update the `release/latest` branch to point to the same commit. +- Push the release branches and the tag. - Wait for azure pipelines to finish the build. Nightly build with 11.0.9 version should be released soon after. - Using the nightly build run a due diligence test to make sure you're happy with the package. - On azure pipelines, on the release for your release branch `release/11.0.9` click on the badge for "Nuget Release" @@ -27,4 +29,4 @@ This document describes the process for creating a new Avalonia release - Replace changelog with one generated by avalonia-backport tool. Enable discussion for the specific release - Review the release information and publish. - Update the dotnet templates, visual studio templates. -- Announce on telegram (RU and EN), twitter, etc \ No newline at end of file +- Announce on telegram (RU and EN), twitter, etc diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.h b/native/Avalonia.Native/src/OSX/WindowImpl.h index 37699082ed..7b911ef945 100644 --- a/native/Avalonia.Native/src/OSX/WindowImpl.h +++ b/native/Avalonia.Native/src/OSX/WindowImpl.h @@ -45,6 +45,10 @@ BEGIN_INTERFACE_MAP() void DoZoom(); virtual HRESULT SetCanResize(bool value) override; + + virtual HRESULT SetCanMinimize(bool value) override; + + virtual HRESULT SetCanMaximize(bool value) override; virtual HRESULT SetDecorations(SystemDecorations value) override; @@ -82,7 +86,7 @@ BEGIN_INTERFACE_MAP() bool CanBecomeKeyWindow (); - bool CanZoom() override { return _isEnabled && _canResize; } + bool CanZoom() override { return _isEnabled && _canMaximize; } protected: virtual NSWindowStyleMask CalculateStyleMask() override; @@ -94,6 +98,8 @@ private: NSString *_lastTitle; bool _isEnabled; bool _canResize; + bool _canMinimize; + bool _canMaximize; bool _fullScreenActive; SystemDecorations _decorations; AvnWindowState _lastWindowState; diff --git a/native/Avalonia.Native/src/OSX/WindowImpl.mm b/native/Avalonia.Native/src/OSX/WindowImpl.mm index 341085ec08..5a57715b55 100644 --- a/native/Avalonia.Native/src/OSX/WindowImpl.mm +++ b/native/Avalonia.Native/src/OSX/WindowImpl.mm @@ -16,6 +16,8 @@ WindowImpl::WindowImpl(IAvnWindowEvents *events) : TopLevelImpl(events), WindowB _extendClientHints = AvnDefaultChrome; _fullScreenActive = false; _canResize = true; + _canMinimize = true; + _canMaximize = true; _decorations = SystemDecorationsFull; _transitioningWindowState = false; _inSetWindowState = false; @@ -191,7 +193,8 @@ bool WindowImpl::IsZoomed() { void WindowImpl::DoZoom() { if (_decorations == SystemDecorationsNone || _decorations == SystemDecorationsBorderOnly || - _canResize == false) { + _canResize == false || + _canMaximize == false) { [Window setFrame:[Window screen].visibleFrame display:true]; } else { [Window performZoom:Window]; @@ -208,6 +211,22 @@ HRESULT WindowImpl::SetCanResize(bool value) { } } +HRESULT WindowImpl::SetCanMinimize(bool value) { + START_COM_ARP_CALL; + + _canMinimize = value; + UpdateAppearance(); + return S_OK; +} + +HRESULT WindowImpl::SetCanMaximize(bool value) { + START_COM_ARP_CALL; + + _canMaximize = value; + UpdateAppearance(); + return S_OK; +} + HRESULT WindowImpl::SetDecorations(SystemDecorations value) { START_COM_CALL; @@ -583,7 +602,7 @@ NSWindowStyleMask WindowImpl::CalculateStyleMask() { break; } - if (!IsOwned()) { + if (_canMinimize && !IsOwned()) { s |= NSWindowStyleMaskMiniaturizable; } @@ -611,9 +630,9 @@ void WindowImpl::UpdateAppearance() { [closeButton setHidden:!hasTrafficLights]; [closeButton setEnabled:_isEnabled]; [miniaturizeButton setHidden:!hasTrafficLights]; - [miniaturizeButton setEnabled:_isEnabled]; + [miniaturizeButton setEnabled:_isEnabled && _canMinimize]; [zoomButton setHidden:!hasTrafficLights]; - [zoomButton setEnabled:CanZoom()]; + [zoomButton setEnabled:CanZoom() || (([Window styleMask] & NSWindowStyleMaskFullScreen) != 0 && _isEnabled)]; } extern IAvnWindow* CreateAvnWindow(IAvnWindowEvents*events) diff --git a/samples/ControlCatalog.iOS/ControlCatalog.MacCatalyst.csproj b/samples/ControlCatalog.iOS/ControlCatalog.MacCatalyst.csproj new file mode 100644 index 0000000000..f7cd8eebf4 --- /dev/null +++ b/samples/ControlCatalog.iOS/ControlCatalog.MacCatalyst.csproj @@ -0,0 +1,19 @@ + + + Exe + manual + $(AvsCurrentMacCatalystTargetFramework) + + 14.0 + + + + + + Info.plist + + + + true + + diff --git a/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj b/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj index 75c244711b..1ab3df1b63 100644 --- a/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj +++ b/samples/ControlCatalog.iOS/ControlCatalog.iOS.csproj @@ -3,11 +3,16 @@ Exe manual $(AvsCurrentIOSTargetFramework) - $(AvsMinSupportedIOSVersion) + + Info.plist + - + + true + + \ No newline at end of file diff --git a/samples/ControlCatalog.iOS/ControlCatalog.tvOS.csproj b/samples/ControlCatalog.iOS/ControlCatalog.tvOS.csproj new file mode 100644 index 0000000000..35e964abab --- /dev/null +++ b/samples/ControlCatalog.iOS/ControlCatalog.tvOS.csproj @@ -0,0 +1,21 @@ + + + Exe + manual + $(AvsCurrentTvOSTargetFramework) + $(AvsMinSupportedTvOSVersion) + + tvossimulator-x64 + + + + + + Info.plist + + + + true + + diff --git a/samples/ControlCatalog.iOS/Info.Catalyst.plist b/samples/ControlCatalog.iOS/Info.Catalyst.plist new file mode 100644 index 0000000000..ad5aecb10a --- /dev/null +++ b/samples/ControlCatalog.iOS/Info.Catalyst.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDisplayName + ControlCatalog.Catalyst + CFBundleIdentifier + Avalonia.ControlCatalog + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + UIDeviceFamily + + 6 + + UIRequiredDeviceCapabilities + + arm64 + + UILaunchStoryboardName + LaunchScreen + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + com.apple.security.files.user-selected.read-write + + + diff --git a/samples/ControlCatalog.iOS/Info.plist b/samples/ControlCatalog.iOS/Info.iOS.plist similarity index 94% rename from samples/ControlCatalog.iOS/Info.plist rename to samples/ControlCatalog.iOS/Info.iOS.plist index b4c7c07eb6..a1aa23e506 100644 --- a/samples/ControlCatalog.iOS/Info.plist +++ b/samples/ControlCatalog.iOS/Info.iOS.plist @@ -16,7 +16,6 @@ 1 2 - 3 UIRequiredDeviceCapabilities @@ -38,5 +37,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + com.apple.security.files.user-selected.read-write + diff --git a/samples/ControlCatalog.iOS/Info.tvOS.plist b/samples/ControlCatalog.iOS/Info.tvOS.plist new file mode 100644 index 0000000000..779324b970 --- /dev/null +++ b/samples/ControlCatalog.iOS/Info.tvOS.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDisplayName + ControlCatalog.tvOS + CFBundleIdentifier + Avalonia.ControlCatalog + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0 + LSRequiresIPhoneOS + + UIDeviceFamily + + 3 + + UIRequiredDeviceCapabilities + + arm64 + + UILaunchStoryboardName + LaunchScreen + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/samples/ControlCatalog/MainWindow.xaml b/samples/ControlCatalog/MainWindow.xaml index adcfc285cd..53f41672a9 100644 --- a/samples/ControlCatalog/MainWindow.xaml +++ b/samples/ControlCatalog/MainWindow.xaml @@ -10,6 +10,9 @@ ExtendClientAreaToDecorationsHint="{Binding ExtendClientAreaEnabled}" ExtendClientAreaChromeHints="{Binding ChromeHints}" ExtendClientAreaTitleBarHeightHint="{Binding TitleBarHeight}" + CanResize="{Binding CanResize}" + CanMinimize="{Binding CanMinimize}" + CanMaximize="{Binding CanMaximize}" x:Name="MainWindow" Background="Transparent" x:Class="ControlCatalog.MainWindow" WindowState="{Binding WindowState, Mode=TwoWay}" diff --git a/samples/ControlCatalog/Pages/CalendarPage.xaml b/samples/ControlCatalog/Pages/CalendarPage.xaml index cfae7140b0..e142c4da72 100644 --- a/samples/ControlCatalog/Pages/CalendarPage.xaml +++ b/samples/ControlCatalog/Pages/CalendarPage.xaml @@ -32,6 +32,11 @@ + + + + - + @@ -134,6 +134,18 @@ + + + + + + + + diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index 7694845009..e3a706bfed 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -20,6 +20,9 @@ + Hosts a collection of ListBoxItem. diff --git a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml index bcc1a71243..a647d34356 100644 --- a/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml +++ b/samples/ControlCatalog/Pages/WindowCustomizationsPage.xaml @@ -8,18 +8,55 @@ x:DataType="viewModels:MainWindowViewModel" x:CompileBindings="True"> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs index e98d9f61f1..7d415e5d6d 100644 --- a/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs +++ b/samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs @@ -10,6 +10,8 @@ namespace ControlCatalog.ViewModels public class ComboBoxPageViewModel : ViewModelBase { private bool _wrapSelection; + private string _textValue = string.Empty; + private IdAndName? _selectedItem = null; public bool WrapSelection { @@ -17,6 +19,18 @@ namespace ControlCatalog.ViewModels set => this.RaiseAndSetIfChanged(ref _wrapSelection, value); } + public string TextValue + { + get => _textValue; + set => this.RaiseAndSetIfChanged(ref _textValue, value); + } + + public IdAndName? SelectedItem + { + get => _selectedItem; + set => this.RaiseAndSetIfChanged(ref _selectedItem, value); + } + public ObservableCollection Values { get; set; } = new ObservableCollection { new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" }, diff --git a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs index 1050beedf2..716675f2c6 100644 --- a/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs +++ b/samples/ControlCatalog/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,5 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Controls.Notifications; using Avalonia.Dialogs; using Avalonia.Platform; using System; @@ -22,6 +21,9 @@ namespace ControlCatalog.ViewModels private bool _isSystemBarVisible; private bool _displayEdgeToEdge; private Thickness _safeAreaPadding; + private bool _canResize; + private bool _canMinimize; + private bool _canMaximize; public MainWindowViewModel() { @@ -49,7 +51,7 @@ namespace ControlCatalog.ViewModels WindowState.FullScreen, }; - this.PropertyChanged += (s, e) => + PropertyChanged += (s, e) => { if (e.PropertyName is nameof(SystemTitleBarEnabled) or nameof(PreferSystemChromeEnabled)) { @@ -67,70 +69,104 @@ namespace ControlCatalog.ViewModels } }; - SystemTitleBarEnabled = true; + SystemTitleBarEnabled = true; TitleBarHeight = -1; + CanResize = true; + CanMinimize = true; + CanMaximize = true; } public ExtendClientAreaChromeHints ChromeHints { get { return _chromeHints; } - set { this.RaiseAndSetIfChanged(ref _chromeHints, value); } + set { RaiseAndSetIfChanged(ref _chromeHints, value); } } public bool ExtendClientAreaEnabled { get { return _extendClientAreaEnabled; } - set { this.RaiseAndSetIfChanged(ref _extendClientAreaEnabled, value); } + set + { + if (RaiseAndSetIfChanged(ref _extendClientAreaEnabled, value) && !value) + { + SystemTitleBarEnabled = true; + } + } } public bool SystemTitleBarEnabled { get { return _systemTitleBarEnabled; } - set { this.RaiseAndSetIfChanged(ref _systemTitleBarEnabled, value); } + set + { + if (RaiseAndSetIfChanged(ref _systemTitleBarEnabled, value) && !value) + { + TitleBarHeight = -1; + } + } } public bool PreferSystemChromeEnabled { get { return _preferSystemChromeEnabled; } - set { this.RaiseAndSetIfChanged(ref _preferSystemChromeEnabled, value); } + set { RaiseAndSetIfChanged(ref _preferSystemChromeEnabled, value); } } public double TitleBarHeight { get { return _titleBarHeight; } - set { this.RaiseAndSetIfChanged(ref _titleBarHeight, value); } + set { RaiseAndSetIfChanged(ref _titleBarHeight, value); } } public WindowState WindowState { get { return _windowState; } - set { this.RaiseAndSetIfChanged(ref _windowState, value); } + set { RaiseAndSetIfChanged(ref _windowState, value); } } public WindowState[] WindowStates { get { return _windowStates; } - set { this.RaiseAndSetIfChanged(ref _windowStates, value); } + set { RaiseAndSetIfChanged(ref _windowStates, value); } } public bool IsSystemBarVisible { get { return _isSystemBarVisible; } - set { this.RaiseAndSetIfChanged(ref _isSystemBarVisible, value); } + set { RaiseAndSetIfChanged(ref _isSystemBarVisible, value); } } public bool DisplayEdgeToEdge { get { return _displayEdgeToEdge; } - set { this.RaiseAndSetIfChanged(ref _displayEdgeToEdge, value); } + set { RaiseAndSetIfChanged(ref _displayEdgeToEdge, value); } } public Thickness SafeAreaPadding { get { return _safeAreaPadding; } - set { this.RaiseAndSetIfChanged(ref _safeAreaPadding, value); } + set { RaiseAndSetIfChanged(ref _safeAreaPadding, value); } + } + + public bool CanResize + { + get { return _canResize; } + set { RaiseAndSetIfChanged(ref _canResize, value); } + } + + public bool CanMinimize + { + get { return _canMinimize; } + set { RaiseAndSetIfChanged(ref _canMinimize, value); } } + public bool CanMaximize + { + get { return _canMaximize; } + set { RaiseAndSetIfChanged(ref _canMaximize, value); } + } + + public MiniCommand AboutCommand { get; } public MiniCommand ExitCommand { get; } @@ -144,7 +180,7 @@ namespace ControlCatalog.ViewModels public DateTime? ValidatedDateExample { get => _validatedDateExample; - set => this.RaiseAndSetIfChanged(ref _validatedDateExample, value); + set => RaiseAndSetIfChanged(ref _validatedDateExample, value); } } } diff --git a/samples/Generators.Sandbox/Controls/SignUpView.xaml b/samples/Generators.Sandbox/Controls/SignUpView.xaml index c126f36f53..1cfb581cf9 100644 --- a/samples/Generators.Sandbox/Controls/SignUpView.xaml +++ b/samples/Generators.Sandbox/Controls/SignUpView.xaml @@ -8,7 +8,7 @@ Watermark="Please, enter user name..." UseFloatingWatermark="True" /> ExtendClientAreaToDecorationsHint Can Resize + Can Minimize + Can Maximize + + - - diff --git a/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs b/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs index 5549e537d3..b7f505a7b2 100644 --- a/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs +++ b/samples/IntegrationTestApp/Pages/WindowPage.axaml.cs @@ -23,10 +23,13 @@ public partial class WindowPage : UserControl private void ShowWindow_Click(object? sender, RoutedEventArgs e) { var size = !string.IsNullOrWhiteSpace(ShowWindowSize.Text) ? Size.Parse(ShowWindowSize.Text) : (Size?)null; + var canResize = ShowWindowCanResize.IsChecked ?? false; var window = new ShowWindowTest { WindowStartupLocation = (WindowStartupLocation)ShowWindowLocation.SelectedIndex, - CanResize = ShowWindowCanResize.IsChecked ?? false, + CanResize = canResize, + CanMinimize = ShowWindowCanMinimize.IsChecked ?? false, + CanMaximize = canResize && (ShowWindowCanMaximize.IsChecked ?? false) }; if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) diff --git a/samples/TextTestApp/App.axaml b/samples/TextTestApp/App.axaml new file mode 100644 index 0000000000..ff984071c1 --- /dev/null +++ b/samples/TextTestApp/App.axaml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/TextTestApp/App.axaml.cs b/samples/TextTestApp/App.axaml.cs new file mode 100644 index 0000000000..ef6cc15ddf --- /dev/null +++ b/samples/TextTestApp/App.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace TextTestApp +{ + public partial class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/TextTestApp/GridRow.cs b/samples/TextTestApp/GridRow.cs new file mode 100644 index 0000000000..d486e186db --- /dev/null +++ b/samples/TextTestApp/GridRow.cs @@ -0,0 +1,24 @@ +using System.Collections.Specialized; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace TextTestApp +{ + public class GridRow : Grid + { + protected override void ChildrenChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + base.ChildrenChanged(sender, e); + + while (Children.Count > ColumnDefinitions.Count) + ColumnDefinitions.Add(new ColumnDefinition { SharedSizeGroup = "c" + ColumnDefinitions.Count }); + + for (int i = 0; i < Children.Count; i++) + { + SetColumn(Children[i], i); + if (Children[i] is Layoutable l) + l.VerticalAlignment = VerticalAlignment.Center; + } + } + } +} diff --git a/samples/TextTestApp/InteractiveLineControl.cs b/samples/TextTestApp/InteractiveLineControl.cs new file mode 100644 index 0000000000..67db521ef4 --- /dev/null +++ b/samples/TextTestApp/InteractiveLineControl.cs @@ -0,0 +1,705 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public class InteractiveLineControl : Control + { + /// + /// Defines the property. + /// + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty BackgroundProperty = + Border.BackgroundProperty.AddOwner(); + + public static readonly StyledProperty ExtentStrokeProperty = + AvaloniaProperty.Register(nameof(ExtentStroke)); + + public static readonly StyledProperty BaselineStrokeProperty = + AvaloniaProperty.Register(nameof(BaselineStroke)); + + public static readonly StyledProperty TextBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(TextBoundsStroke)); + + public static readonly StyledProperty RunBoundsStrokeProperty = + AvaloniaProperty.Register(nameof(RunBoundsStroke)); + + public static readonly StyledProperty NextHitStrokeProperty = + AvaloniaProperty.Register(nameof(NextHitStroke)); + + public static readonly StyledProperty BackspaceHitStrokeProperty = + AvaloniaProperty.Register(nameof(BackspaceHitStroke)); + + public static readonly StyledProperty PreviousHitStrokeProperty = + AvaloniaProperty.Register(nameof(PreviousHitStroke)); + + public static readonly StyledProperty DistanceStrokeProperty = + AvaloniaProperty.Register(nameof(DistanceStroke)); + + public IBrush? ExtentStroke + { + get => GetValue(ExtentStrokeProperty); + set => SetValue(ExtentStrokeProperty, value); + } + public IBrush? BaselineStroke + { + get => GetValue(BaselineStrokeProperty); + set => SetValue(BaselineStrokeProperty, value); + } + + public IBrush? TextBoundsStroke + { + get => GetValue(TextBoundsStrokeProperty); + set => SetValue(TextBoundsStrokeProperty, value); + } + + public IBrush? RunBoundsStroke + { + get => GetValue(RunBoundsStrokeProperty); + set => SetValue(RunBoundsStrokeProperty, value); + } + + public IBrush? NextHitStroke + { + get => GetValue(NextHitStrokeProperty); + set => SetValue(NextHitStrokeProperty, value); + } + + public IBrush? BackspaceHitStroke + { + get => GetValue(BackspaceHitStrokeProperty); + set => SetValue(BackspaceHitStrokeProperty, value); + } + + public IBrush? PreviousHitStroke + { + get => GetValue(PreviousHitStrokeProperty); + set => SetValue(PreviousHitStrokeProperty, value); + } + + public IBrush? DistanceStroke + { + get => GetValue(DistanceStrokeProperty); + set => SetValue(DistanceStrokeProperty, value); + } + + private IPen? _extentPen; + protected IPen ExtentPen => _extentPen ??= new Pen(ExtentStroke, dashStyle: DashStyle.Dash); + + private IPen? _baselinePen; + protected IPen BaselinePen => _baselinePen ??= new Pen(BaselineStroke); + + private IPen? _textBoundsPen; + protected IPen TextBoundsPen => _textBoundsPen ??= new Pen(TextBoundsStroke); + + private IPen? _runBoundsPen; + protected IPen RunBoundsPen => _runBoundsPen ??= new Pen(RunBoundsStroke, dashStyle: DashStyle.Dash); + + private IPen? _nextHitPen; + protected IPen NextHitPen => _nextHitPen ??= new Pen(NextHitStroke); + + private IPen? _previousHitPen; + protected IPen PreviousHitPen => _previousHitPen ??= new Pen(PreviousHitStroke); + + private IPen? _backspaceHitPen; + protected IPen BackspaceHitPen => _backspaceHitPen ??= new Pen(BackspaceHitStroke); + + private IPen? _distancePen; + protected IPen DistancePen => _distancePen ??= new Pen(DistanceStroke); + + /// + /// Gets or sets the text to draw. + /// + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + /// + /// Gets or sets a brush used to paint the control's background. + /// + public IBrush? Background + { + get => GetValue(BackgroundProperty); + set => SetValue(BackgroundProperty, value); + } + + // TextRunProperties + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFamilyProperty = + TextElement.FontFamilyProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontFeaturesProperty = + TextElement.FontFeaturesProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontSizeProperty = + TextElement.FontSizeProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStyleProperty = + TextElement.FontStyleProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontWeightProperty = + TextElement.FontWeightProperty.AddOwner(); + + /// + /// Defines the property. + /// + public static readonly StyledProperty FontStretchProperty = + TextElement.FontStretchProperty.AddOwner(); + + /// + /// Gets or sets the font family used to draw the control's text. + /// + public FontFamily FontFamily + { + get => GetValue(FontFamilyProperty); + set => SetValue(FontFamilyProperty, value); + } + + /// + /// Gets or sets the font features turned on/off. + /// + public FontFeatureCollection? FontFeatures + { + get => GetValue(FontFeaturesProperty); + set => SetValue(FontFeaturesProperty, value); + } + + /// + /// Gets or sets the size of the control's text in points. + /// + public double FontSize + { + get => GetValue(FontSizeProperty); + set => SetValue(FontSizeProperty, value); + } + + /// + /// Gets or sets the font style used to draw the control's text. + /// + public FontStyle FontStyle + { + get => GetValue(FontStyleProperty); + set => SetValue(FontStyleProperty, value); + } + + /// + /// Gets or sets the font weight used to draw the control's text. + /// + public FontWeight FontWeight + { + get => GetValue(FontWeightProperty); + set => SetValue(FontWeightProperty, value); + } + + /// + /// Gets or sets the font stretch used to draw the control's text. + /// + public FontStretch FontStretch + { + get => GetValue(FontStretchProperty); + set => SetValue(FontStretchProperty, value); + } + + private GenericTextRunProperties? _textRunProperties; + public GenericTextRunProperties TextRunProperties + { + get + { + return _textRunProperties ??= CreateTextRunProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textRunProperties = value; + SetCurrentValue(FontFamilyProperty, value.Typeface.FontFamily); + SetCurrentValue(FontFeaturesProperty, value.FontFeatures); + SetCurrentValue(FontSizeProperty, value.FontRenderingEmSize); + SetCurrentValue(FontStyleProperty, value.Typeface.Style); + SetCurrentValue(FontWeightProperty, value.Typeface.Weight); + SetCurrentValue(FontStretchProperty, value.Typeface.Stretch); + } + } + + private GenericTextRunProperties CreateTextRunProperties() + { + Typeface typeface = new Typeface(FontFamily, FontStyle, FontWeight, FontStretch); + return new GenericTextRunProperties(typeface, FontFeatures, FontSize, + textDecorations: null, + foregroundBrush: Brushes.Black, + backgroundBrush: null, + baselineAlignment: BaselineAlignment.Baseline, + cultureInfo: null); + } + + // TextParagraphProperties + + private GenericTextParagraphProperties? _textParagraphProperties; + public GenericTextParagraphProperties TextParagraphProperties + { + get + { + return _textParagraphProperties ??= CreateTextParagraphProperties(); + } + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + _textParagraphProperties = null; + SetCurrentValue(FlowDirectionProperty, value.FlowDirection); + } + } + + private GenericTextParagraphProperties CreateTextParagraphProperties() + { + return new GenericTextParagraphProperties( + FlowDirection, + TextAlignment.Start, + firstLineInParagraph: false, + alwaysCollapsible: false, + TextRunProperties, + textWrapping: TextWrapping.NoWrap, + lineHeight: 0, + indent: 0, + letterSpacing: 0); + } + + private readonly ITextSource _textSource; + private class TextSource : ITextSource + { + private readonly InteractiveLineControl _owner; + + public TextSource(InteractiveLineControl owner) + { + _owner = owner; + } + + public TextRun? GetTextRun(int textSourceIndex) + { + string text = _owner.Text ?? string.Empty; + + if (textSourceIndex < 0 || textSourceIndex >= text.Length) + return null; + + return new TextCharacters(text, _owner.TextRunProperties); + } + } + + private TextLine? _textLine; + public TextLine? TextLine => _textLine ??= TextFormatter.Current.FormatLine(_textSource, 0, Bounds.Size.Width, TextParagraphProperties); + + private TextLayout? _textLayout; + public TextLayout TextLayout => _textLayout ??= new TextLayout(_textSource, TextParagraphProperties); + + private Size? _textLineSize; + protected Size TextLineSize => _textLineSize ??= TextLine is { } textLine ? new Size(textLine.WidthIncludingTrailingWhitespace, textLine.Height) : default; + + private Size? _inkSize; + protected Size InkSize => _inkSize ??= TextLine is { } textLine ? new Size(textLine.OverhangLeading + textLine.WidthIncludingTrailingWhitespace + textLine.OverhangTrailing, textLine.Extent) : default; + + public event EventHandler? TextLineChanged; + + public InteractiveLineControl() + { + _textSource = new TextSource(this); + + RenderOptions.SetEdgeMode(this, EdgeMode.Aliased); + RenderOptions.SetTextRenderingMode(this, TextRenderingMode.SubpixelAntialias); + } + + private void InvalidateTextRunProperties() + { + _textRunProperties = null; + InvalidateTextParagraphProperties(); + } + + private void InvalidateTextParagraphProperties() + { + _textParagraphProperties = null; + InvalidateTextLine(); + } + + private void InvalidateTextLine() + { + _textLayout = null; + _textLine = null; + _textLineSize = null; + _inkSize = null; + InvalidateMeasure(); + InvalidateVisual(); + + TextLineChanged?.Invoke(this, EventArgs.Empty); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + switch (change.Property.Name) + { + case nameof(FontFamily): + case nameof(FontSize): + InvalidateTextRunProperties(); + break; + + case nameof(FontStyle): + case nameof(FontWeight): + case nameof(FontStretch): + InvalidateTextRunProperties(); + break; + + case nameof(FlowDirection): + InvalidateTextParagraphProperties(); + break; + + case nameof(Text): + InvalidateTextLine(); + break; + + case nameof(BaselineStroke): + _baselinePen = null; + InvalidateVisual(); + break; + + case nameof(TextBoundsStroke): + _textBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(RunBoundsStroke): + _runBoundsPen = null; + InvalidateVisual(); + break; + + case nameof(NextHitStroke): + _nextHitPen = null; + InvalidateVisual(); + break; + + case nameof(PreviousHitStroke): + _previousHitPen = null; + InvalidateVisual(); + break; + + case nameof(BackspaceHitStroke): + _backspaceHitPen = null; + InvalidateVisual(); + break; + } + + base.OnPropertyChanged(change); + } + + protected override Size MeasureOverride(Size availableSize) + { + if (TextLine == null) + return default; + + return new Size(Math.Max(TextLineSize.Width, InkSize.Width), Math.Max(TextLineSize.Height, InkSize.Height)); + } + + private const double VerticalSpacing = 5; + private const double HorizontalSpacing = 5; + private const double ArrowSize = 5; + + private Dictionary _labelsCache = new(); + protected FormattedText GetOrCreateLabel(string label, IBrush brush, bool disableCache = false) + { + if (_labelsCache.TryGetValue(label, out var text)) + return text; + + text = new FormattedText(label, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, Typeface.Default, 8, brush); + + if (!disableCache) + _labelsCache[label] = text; + + return text; + } + + private Rect _inkRenderBounds; + private Rect _lineRenderBounds; + + public Rect InkRenderBounds => _inkRenderBounds; + public Rect LineRenderBounds => _lineRenderBounds; + + public override void Render(DrawingContext context) + { + TextLine? textLine = TextLine; + if (textLine == null) + return; + + // overhang leading should be negative when extending (e.g. for j) WPF: "When the leading alignment point comes before the leading drawn pixel, the value is negative." - docs wrong but values correct + // overhang trailing should be negative when extending (e.g. for f) WPF: "The OverhangTrailing value will be positive when the trailing drawn pixel comes before the trailing alignment point." + // overhang after should be negative when inside (e.g. for x) WPF: "The value is positive if the bottommost drawn pixel goes below the line bottom, and is negative if it is within (on or above) the line." + // => we want overhang before to be negative when inside (e.g. for x) + + double overhangBefore = textLine.Extent - textLine.OverhangAfter - textLine.Height; + Rect inkBounds = new Rect(new Point(textLine.OverhangLeading, -overhangBefore), InkSize); + Rect lineBounds = new Rect(new Point(0, 0), TextLineSize); + + if (inkBounds.Left < 0) + lineBounds = lineBounds.Translate(new Vector(-inkBounds.Left, 0)); + + if (inkBounds.Top < 0) + lineBounds = lineBounds.Translate(new Vector(0, -inkBounds.Top)); + + _inkRenderBounds = inkBounds; + _lineRenderBounds = lineBounds; + + Rect bounds = new Rect(0, 0, Math.Max(inkBounds.Right, lineBounds.Right), Math.Max(inkBounds.Bottom, lineBounds.Bottom)); + double labelX = bounds.Right + HorizontalSpacing; + + if (Background is IBrush background) + context.FillRectangle(background, lineBounds); + + if (ExtentStroke != null) + { + context.DrawRectangle(ExtentPen, inkBounds); + RenderLabel(context, nameof(textLine.Extent), ExtentStroke, labelX, inkBounds.Top); + } + + using (context.PushTransform(Matrix.CreateTranslation(lineBounds.Left, lineBounds.Top))) + { + labelX -= lineBounds.Left; // labels to ignore horizontal transform + + if (BaselineStroke != null) + { + RenderFontLine(context, textLine.Baseline, lineBounds.Width, BaselinePen); // no other lines currently available in Avalonia + RenderLabel(context, nameof(textLine.Baseline), BaselineStroke, labelX, textLine.Baseline); + } + + textLine.Draw(context, lineOrigin: default); + + var runBoundsStroke = RunBoundsStroke; + if (TextBoundsStroke != null || runBoundsStroke != null) + { + IReadOnlyList textBounds = textLine.GetTextBounds(textLine.FirstTextSourceIndex, textLine.Length); + foreach (var textBound in textBounds) + { + if (runBoundsStroke != null) + { + var runBounds = textBound.TextRunBounds; + foreach (var runBound in runBounds) + context.DrawRectangle(RunBoundsPen, runBound.Rectangle); + } + + context.DrawRectangle(TextBoundsPen, textBound.Rectangle); + } + } + + double y = inkBounds.Bottom - lineBounds.Top + VerticalSpacing * 2; + + if (NextHitStroke != null) + { + RenderHits(context, NextHitPen, textLine, textLine.GetNextCaretCharacterHit, new CharacterHit(0), ref y); + RenderLabel(context, nameof(textLine.GetNextCaretCharacterHit), NextHitStroke, labelX, y); + y += VerticalSpacing * 2; + } + + if (PreviousHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetPreviousCaretCharacterHit), PreviousHitStroke, labelX, y); + RenderHits(context, PreviousHitPen, textLine, textLine.GetPreviousCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (BackspaceHitStroke != null) + { + RenderLabel(context, nameof(textLine.GetBackspaceCaretCharacterHit), BackspaceHitStroke, labelX, y); + RenderHits(context, BackspaceHitPen, textLine, textLine.GetBackspaceCaretCharacterHit, new CharacterHit(textLine.Length), ref y); + y += VerticalSpacing * 2; + } + + if (DistanceStroke != null) + { + y += VerticalSpacing; + + var label = RenderLabel(context, nameof(textLine.GetDistanceFromCharacterHit), DistanceStroke, 0, y); + y += label.Height; + + for (int i = 0; i < textLine.Length; i++) + { + var hit = new CharacterHit(i); + CharacterHit prevHit = default, nextHit = default; + + double leftLabelX = -HorizontalSpacing; + + // we want z-order to be previous, next, distance + // but labels need to be ordered next, distance, previous + if (NextHitStroke != null) + { + nextHit = textLine.GetNextCaretCharacterHit(hit); + var nextLabel = RenderLabel(context, $" > {nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}", NextHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + leftLabelX -= nextLabel.WidthIncludingTrailingWhitespace; + } + + if (PreviousHitStroke != null) + { + prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(prevHit.FirstCharacterIndex + prevHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, PreviousHitPen, ArrowSize); + } + + if (NextHitStroke != null) + { + var x1 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex, 0)); + var x2 = textLine.GetDistanceFromCharacterHit(new CharacterHit(nextHit.FirstCharacterIndex + nextHit.TrailingLength, 0)); + RenderHorizontalPoint(context, x1, x2, y, NextHitPen, ArrowSize); + } + + label = RenderLabel(context, $"[{i}]", DistanceStroke, leftLabelX, y, TextAlignment.Right); + leftLabelX -= label.WidthIncludingTrailingWhitespace; + + if (PreviousHitStroke != null) + RenderLabel(context, $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength} < ", PreviousHitStroke, leftLabelX, y, TextAlignment.Right, disableCache: true); + + double distance = textLine.GetDistanceFromCharacterHit(new CharacterHit(i)); + RenderHorizontalBar(context, 0, distance, y, DistancePen, ArrowSize); + //RenderLabel(context, distance.ToString("F2"), DistanceStroke, distance + HorizontalSpacing, y, disableCache: true); + + y += label.Height; + } + } + } + } + + [return: NotNullIfNotNull("brush")] + private FormattedText? RenderLabel(DrawingContext context, string label, IBrush? brush, double x, double y, TextAlignment alignment = TextAlignment.Left, bool disableCache = false) + { + if (brush == null) + return null; + + var text = GetOrCreateLabel(label, brush, disableCache); + + if (alignment == TextAlignment.Right) + context.DrawText(text, new Point(x - text.WidthIncludingTrailingWhitespace, y - text.Height / 2)); + else + context.DrawText(text, new Point(x, y - text.Height / 2)); + + return text; + } + + private void RenderHits(DrawingContext context, IPen hitPen, TextLine textLine, Func nextHit, CharacterHit startingHit, ref double y) + { + CharacterHit lastHit = startingHit; + double lastX = textLine.GetDistanceFromCharacterHit(lastHit); + double lastDirection = 0; + y -= VerticalSpacing; // we always start with adding one below + + while (true) + { + CharacterHit hit = nextHit(lastHit); + if (hit == lastHit) + break; + + double x = textLine.GetDistanceFromCharacterHit(hit); + double direction = Math.Sign(x - lastX); + + if (direction == 0 || lastDirection != direction) + y += VerticalSpacing; + + if (direction == 0) + RenderPoint(context, x, y, hitPen, ArrowSize); + else + RenderHorizontalArrow(context, lastX, x, y, hitPen, ArrowSize); + + lastX = x; + lastHit = hit; + lastDirection = direction; + } + } + + private void RenderPoint(DrawingContext context, double x, double y, IPen pen, double arrowHeight) + { + context.DrawEllipse(pen.Brush, pen, new Point(x, y), ArrowSize / 2, ArrowSize / 2); + } + + private void RenderHorizontalPoint(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + PathGeometry startCap = new PathGeometry(); + PathFigure startFigure = new PathFigure(); + startFigure.StartPoint = new Point(xStart, y - size / 2); + startFigure.IsClosed = true; + startFigure.IsFilled = true; + startFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xStart, y + size / 2), SweepDirection = SweepDirection.CounterClockwise }); + startCap.Figures!.Add(startFigure); + + context.DrawGeometry(pen.Brush, pen, startCap); + + PathGeometry endCap = new PathGeometry(); + PathFigure endFigure = new PathFigure(); + endFigure.StartPoint = new Point(xEnd, y - size / 2); + endFigure.IsClosed = true; + endFigure.IsFilled = false; + endFigure.Segments!.Add(new ArcSegment { Size = new Size(size / 2, size / 2), Point = new Point(xEnd, y + size / 2), SweepDirection = SweepDirection.Clockwise }); + endCap.Figures!.Add(endFigure); + + context.DrawGeometry(pen.Brush, pen, endCap); + } + + private void RenderHorizontalArrow(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + + if (xEnd >= xStart) + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd - size, y - size / 2), + new Point(xEnd - size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + else + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(xEnd + size, y - size / 2), + new Point(xEnd + size, y + size/2), + new Point(xEnd, y) + ], isFilled: true)); + } + private void RenderHorizontalBar(DrawingContext context, double xStart, double xEnd, double y, IPen pen, double size) + { + context.DrawLine(pen, new Point(xStart, y), new Point(xEnd, y)); + context.DrawLine(pen, new Point(xStart, y - size / 2), new Point(xStart, y + size / 2)); // start cap + context.DrawLine(pen, new Point(xEnd, y - size / 2), new Point(xEnd, y + size / 2)); // end cap + } + + private void RenderFontLine(DrawingContext context, double y, double width, IPen pen) + { + context.DrawLine(pen, new Point(0, y), new Point(width, y)); + } + } +} diff --git a/samples/TextTestApp/MainWindow.axaml b/samples/TextTestApp/MainWindow.axaml new file mode 100644 index 0000000000..6dd6670124 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/MainWindow.axaml.cs b/samples/TextTestApp/MainWindow.axaml.cs new file mode 100644 index 0000000000..493bc3e9d4 --- /dev/null +++ b/samples/TextTestApp/MainWindow.axaml.cs @@ -0,0 +1,340 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.TextFormatting; + +namespace TextTestApp +{ + public partial class MainWindow : Window + { + private SelectionAdorner? _selectionAdorner; + + public MainWindow() + { + InitializeComponent(); + + _selectionAdorner = new(); + _selectionAdorner.Stroke = Brushes.Red; + _selectionAdorner.Fill = new SolidColorBrush(Colors.LightSkyBlue, 0.25); + _selectionAdorner.IsHitTestVisible = false; + AdornerLayer.SetIsClipEnabled(_selectionAdorner, false); + AdornerLayer.SetAdorner(_rendering, _selectionAdorner); + + _rendering.TextLineChanged += OnShapeBufferChanged; + OnShapeBufferChanged(); + } + + private void OnNewWindowClick(object? sender, RoutedEventArgs e) + { + MainWindow win = new MainWindow(); + win.Show(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.F5) + { + _rendering.InvalidateVisual(); + OnShapeBufferChanged(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + if (_hits.IsKeyboardFocusWithin && _hits.SelectedIndex != -1) + { + _hits.SelectedIndex = -1; + e.Handled = true; + } + else if (_buffer.IsKeyboardFocusWithin && _buffer.SelectedIndex != -1) + { + _buffer.SelectedIndex = -1; + e.Handled = true; + } + } + + base.OnKeyDown(e); + } + + private void OnShapeBufferChanged(object? sender, EventArgs e) => OnShapeBufferChanged(); + private void OnShapeBufferChanged() + { + if (_selectionAdorner == null) + return; + + ListBuffers(); + ListHits(); + + Rect bounds = _rendering.LineRenderBounds; + _selectionAdorner!.Transform = Matrix.CreateTranslation(bounds.X, bounds.Y); + } + + private void ListBuffers() + { + for (int i = _buffer.ItemCount - 1; i >= 1; i--) + _buffer.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + double currentX = _rendering.LineRenderBounds.Left; + foreach (TextRun run in textLine.TextRuns) + { + if (run is ShapedTextRun shapedRun) + { + _buffer.Items.Add(new TextBlock + { + Text = $"{run.GetType().Name}: Bidi = {shapedRun.BidiLevel}, Font = {shapedRun.ShapedBuffer.GlyphTypeface.FamilyName}", + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run, + }); + + ListBuffer(textLine, shapedRun, ref currentX); + } + else + _buffer.Items.Add(new TextBlock + { + Text = run.GetType().Name, + FontWeight = FontWeight.Bold, + Padding = new Thickness(10, 0), + Tag = run + }); + } + } + + private void ListHits() + { + for (int i = _hits.ItemCount - 1; i >= 1; i--) + _hits.Items.RemoveAt(i); + + TextLine? textLine = _rendering.TextLine; + if (textLine == null) + return; + + for (int i = 0; i < textLine.Length; i++) + { + string? clusterText = _rendering.Text!.Substring(i, 1); + string? clusterHex = ToHex(clusterText); + + var hit = new CharacterHit(i); + var prevHit = textLine.GetPreviousCaretCharacterHit(hit); + var nextHit = textLine.GetNextCaretCharacterHit(hit); + var bkspHit = textLine.GetBackspaceCaretCharacterHit(hit); + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = $"{bkspHit.FirstCharacterIndex}+{bkspHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = $"{prevHit.FirstCharacterIndex}+{prevHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = i.ToString(), FontWeight = FontWeight.Bold }); + row.Children.Add(new TextBlock { Text = $"{nextHit.FirstCharacterIndex}+{nextHit.TrailingLength}" }); + row.Children.Add(new TextBlock { Text = clusterHex }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = textLine.GetDistanceFromCharacterHit(hit).ToString() }); + row.Tag = i; + + _hits.Items.Add(row); + } + } + + private static readonly IBrush TransparentAliceBlue = new SolidColorBrush(0x0F0188FF); + private static readonly IBrush TransparentAntiqueWhite = new SolidColorBrush(0x28DF8000); + private void ListBuffer(TextLine textLine, ShapedTextRun shapedRun, ref double currentX) + { + ShapedBuffer buffer = shapedRun.ShapedBuffer; + + int lastClusterStart = -1; + bool oddCluster = false; + + IReadOnlyList glyphInfos = buffer; + + currentX += shapedRun.GlyphRun.BaselineOrigin.X; + for (var i = 0; i < glyphInfos.Count; i++) + { + GlyphInfo info = glyphInfos[i]; + int clusterStart = info.GlyphCluster; + int clusterLength = FindClusterLenghtAt(i); + string? clusterText = _rendering.Text!.Substring(clusterStart, clusterLength); + string? clusterHex = ToHex(clusterText); + + Border border = new Border(); + if (clusterStart == lastClusterStart) + { + clusterText = clusterHex = null; + } + else + { + oddCluster = !oddCluster; + lastClusterStart = clusterStart; + } + border.Background = oddCluster ? TransparentAliceBlue : TransparentAntiqueWhite; + + + GridRow row = new GridRow { ColumnSpacing = 10 }; + row.Children.Add(new Control()); + row.Children.Add(new TextBlock { Text = clusterStart.ToString() }); + row.Children.Add(new TextBlock { Text = clusterText }); + row.Children.Add(new TextBlock { Text = clusterHex, TextWrapping = TextWrapping.Wrap }); + row.Children.Add(new Image { Source = CreateGlyphDrawing(shapedRun.GlyphRun.GlyphTypeface, FontSize, info), Margin = new Thickness(2) }); + row.Children.Add(new TextBlock { Text = info.GlyphIndex.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphAdvance.ToString() }); + row.Children.Add(new TextBlock { Text = info.GlyphOffset.ToString() }); + + Geometry glyph = GetGlyphOutline(shapedRun.GlyphRun.GlyphTypeface, shapedRun.GlyphRun.FontRenderingEmSize, info); + Rect glyphBounds = glyph.Bounds; + Rect offsetBounds = glyphBounds.Translate(new Vector(currentX + info.GlyphOffset.X, info.GlyphOffset.Y)); + + TextBlock boundsBlock = new TextBlock { Text = offsetBounds.ToString() }; + ToolTip.SetTip(boundsBlock, "Origin bounds: " + glyphBounds); + row.Children.Add(boundsBlock); + + border.Child = row; + border.Tag = offsetBounds; + _buffer.Items.Add(border); + + currentX += glyphInfos[i].GlyphAdvance; + } + + int FindClusterLenghtAt(int index) + { + int cluster = glyphInfos[index].GlyphCluster; + if (shapedRun.BidiLevel % 2 == 0) + { + while (++index < glyphInfos.Count) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[0].GlyphCluster - cluster; + } + else + { + while (--index >= 0) + if (glyphInfos[index].GlyphCluster != cluster) + return glyphInfos[index].GlyphCluster - cluster; + + return shapedRun.Length + glyphInfos[glyphInfos.Count - 1].GlyphCluster - cluster; + } + } + } + + private IImage CreateGlyphDrawing(IGlyphTypeface glyphTypeface, double emSize, GlyphInfo info) + { + return new DrawingImage { Drawing = new GeometryDrawing { Brush = Brushes.Black, Geometry = GetGlyphOutline(glyphTypeface, emSize, info) } }; + } + + private Geometry GetGlyphOutline(IGlyphTypeface typeface, double emSize, GlyphInfo info) + { + // substitute for GlyphTypeface.GetGlyphOutline + return new GlyphRun(typeface, emSize, new[] { '\0' }, [info]).BuildGeometry(); + } + + private void OnPointerMoved(object sender, PointerEventArgs e) + { + InteractiveLineControl lineControl = (InteractiveLineControl)sender; + TextLayout textLayout = lineControl.TextLayout; + Rect lineBounds = lineControl.LineRenderBounds; + + PointerPoint pointerPoint = e.GetCurrentPoint(lineControl); + Point point = new Point(pointerPoint.Position.X - lineBounds.Left, pointerPoint.Position.Y - lineBounds.Top); + _coordinates.Text = $"{pointerPoint.Position.X:F4}, {pointerPoint.Position.Y:F4}"; + + TextHitTestResult textHit = textLayout.HitTestPoint(point); + _hit.Text = $"{textHit.TextPosition} ({textHit.CharacterHit.FirstCharacterIndex}+{textHit.CharacterHit.TrailingLength})"; + if (textHit.IsTrailing) + _hit.Text += " T"; + + if (textHit.IsInside) + { + _hits.SelectedIndex = textHit.TextPosition + 1; // header + } + else + _hits.SelectedIndex = -1; + } + + private void OnHitTestMethodChanged(object? sender, RoutedEventArgs e) + { + _hits.SelectionMode = _hitRangeToggle.IsChecked == true ? SelectionMode.Multiple : SelectionMode.Single; + } + + private void OnHitsSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_selectionAdorner == null) + return; + + List rectangles = new List(); + TextLayout textLayout = _rendering.TextLayout; + + if (_hitRangeToggle.IsChecked == true) + { + // collect continuous selected indices + List<(int start, int length)> selections = new(1); + + int[] indices = _hits.Selection.SelectedIndexes.ToArray(); + Array.Sort(indices); + + int currentIndex = -1; + int currentLength = 0; + for (int i = 0; i < indices.Length; i++) + if (_hits.Items[indices[i]] is Control { Tag: int index }) + { + if (index == currentIndex + currentLength) + { + currentLength++; + } + else + { + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + currentIndex = index; + currentLength = 1; + } + } + + if (currentLength > 0) + selections.Add((currentIndex, currentLength)); + + foreach (var selection in selections) + { + var selectionRectangles = textLayout.HitTestTextRange(selection.start, selection.length); + rectangles.AddRange(selectionRectangles); + } + } + else + { + if (_hits.SelectedItem is Control { Tag: int index }) + { + Rect rect = textLayout.HitTestTextPosition(index); + rectangles.Add(rect); + } + } + + _selectionAdorner.Rectangles = rectangles; + } + + private void OnBufferSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + List rectangles = new List(_buffer.Selection.Count); + + foreach (var row in _buffer.SelectedItems) + if (row is Control { Tag: Rect rect }) + rectangles.Add(rect); + + _selectionAdorner.Rectangles = rectangles; + } + + private static string ToHex(string s) + { + if (string.IsNullOrEmpty(s)) + return s; + + return string.Join(" ", s.Select(c => ((int)c).ToString("X4"))); + } + } +} diff --git a/samples/TextTestApp/Program.cs b/samples/TextTestApp/Program.cs new file mode 100644 index 0000000000..cb953f8ba5 --- /dev/null +++ b/samples/TextTestApp/Program.cs @@ -0,0 +1,25 @@ +using System; +using Avalonia; + +namespace TextTestApp +{ + static class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + { + return AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } + } +} diff --git a/samples/TextTestApp/SelectionAdorner.cs b/samples/TextTestApp/SelectionAdorner.cs new file mode 100644 index 0000000000..bfaa030fc8 --- /dev/null +++ b/samples/TextTestApp/SelectionAdorner.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; + +namespace TextTestApp +{ + public class SelectionAdorner : Control + { + public static readonly StyledProperty FillProperty = + AvaloniaProperty.Register(nameof(Fill)); + + public static readonly StyledProperty StrokeProperty = + AvaloniaProperty.Register(nameof(Stroke)); + + public static readonly StyledProperty TransformProperty = + AvaloniaProperty.Register(nameof(Transform), Matrix.Identity); + + public Matrix Transform + { + get => this.GetValue(TransformProperty); + set => SetValue(TransformProperty, value); + } + + public IBrush? Stroke + { + get => GetValue(StrokeProperty); + set => SetValue(StrokeProperty, value); + } + + public IBrush? Fill + { + get => GetValue(FillProperty); + set => SetValue(FillProperty, value); + } + + private IList? _rectangles; + public IList? Rectangles + { + get => _rectangles; + set + { + _rectangles = value; + InvalidateVisual(); + } + } + + public SelectionAdorner() + { + AffectsRender(FillProperty, StrokeProperty, TransformProperty); + } + + public override void Render(DrawingContext context) + { + var rectangles = Rectangles; + if (rectangles == null) + return; + + using (context.PushTransform(Transform)) + { + Pen pen = new Pen(Stroke, 1); + for (int i = 0; i < rectangles.Count; i++) + { + Rect rectangle = rectangles[i]; + Rect normalized = rectangle.Width < 0 ? new Rect(rectangle.TopRight, rectangle.BottomLeft) : rectangle; + + if (rectangles[i].Width == 0) + context.DrawLine(pen, rectangle.TopLeft, rectangle.BottomRight); + else + context.DrawRectangle(Fill, pen, normalized); + + RenderCue(context, pen, rectangle.TopLeft, 5, isFilled: true); + RenderCue(context, pen, rectangle.TopRight, 5, isFilled: false); + } + } + } + + private void RenderCue(DrawingContext context, IPen pen, Point p, double size, bool isFilled) + { + context.DrawGeometry(pen.Brush, pen, new PolylineGeometry( + [ + new Point(p.X - size / 2, p.Y - size), + new Point(p.X + size / 2, p.Y - size), + new Point(p.X, p.Y), + new Point(p.X - size / 2, p.Y - size), + ], isFilled)); + } + } +} diff --git a/samples/TextTestApp/TextTestApp.csproj b/samples/TextTestApp/TextTestApp.csproj new file mode 100644 index 0000000000..50dc52c768 --- /dev/null +++ b/samples/TextTestApp/TextTestApp.csproj @@ -0,0 +1,23 @@ + + + + WinExe + $(AvsCurrentTargetFramework) + true + app.manifest + true + enable + + + + + + + + + + + + + + diff --git a/samples/TextTestApp/app.manifest b/samples/TextTestApp/app.manifest new file mode 100644 index 0000000000..db90057191 --- /dev/null +++ b/samples/TextTestApp/app.manifest @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Android/Avalonia.Android/AvaloniaActivity.cs b/src/Android/Avalonia.Android/AvaloniaActivity.cs index fa3484f058..cf425d279e 100644 --- a/src/Android/Avalonia.Android/AvaloniaActivity.cs +++ b/src/Android/Avalonia.Android/AvaloniaActivity.cs @@ -8,10 +8,10 @@ using Android.OS; using Android.Runtime; using Android.Views; using AndroidX.AppCompat.App; -using Avalonia.Platform; using Avalonia.Android.Platform; using Avalonia.Android.Platform.Storage; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform; namespace Avalonia.Android; @@ -48,6 +48,9 @@ public class AvaloniaActivity : AppCompatActivity, IAvaloniaActivity SetContentView(_view); + // By default, the view isn't focused if the activity is created anew, so we force focus. + _view.RequestFocus(); + _listener = new GlobalLayoutListener(_view); _view.ViewTreeObserver?.AddOnGlobalLayoutListener(_listener); diff --git a/src/Android/Avalonia.Android/AvaloniaView.Input.cs b/src/Android/Avalonia.Android/AvaloniaView.Input.cs new file mode 100644 index 0000000000..0d069e1d6c --- /dev/null +++ b/src/Android/Avalonia.Android/AvaloniaView.Input.cs @@ -0,0 +1,67 @@ +using System; +using Android.Views; +using Android.Views.InputMethods; +using Avalonia.Android.Platform.SkiaPlatform; + +namespace Avalonia.Android +{ + public partial class AvaloniaView : IInitEditorInfo + { + private Func? _initEditorInfo; + + public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) + { + return _initEditorInfo?.Invoke(_view, outAttrs!)!; + } + + void IInitEditorInfo.InitEditorInfo(Func init) + { + _initEditorInfo = init; + } + + protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) + { + base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); + _accessHelper?.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); + } + + protected override bool DispatchHoverEvent(MotionEvent? e) + { + return _accessHelper?.DispatchHoverEvent(e!) == true || base.DispatchHoverEvent(e); + } + + protected override bool DispatchGenericPointerEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + + var baseResult = callBase && base.DispatchGenericPointerEvent(e); + + return result ?? baseResult; + } + + public override bool DispatchTouchEvent(MotionEvent? e) + { + var result = _view.PointerHelper.DispatchMotionEvent(e, out var callBase); + var baseResult = callBase && base.DispatchTouchEvent(e); + + if(result == true) + { + // Request focus for this view + RequestFocus(); + } + + return result ?? baseResult; + } + + public override bool DispatchKeyEvent(KeyEvent? e) + { + var res = _view.KeyboardHelper.DispatchKeyEvent(e, out var callBase); + if (res == false) + callBase = _accessHelper?.DispatchKeyEvent(e!) == false && callBase; + + var baseResult = callBase && base.DispatchKeyEvent(e); + + return res ?? baseResult; + } + } +} diff --git a/src/Android/Avalonia.Android/AvaloniaView.cs b/src/Android/Avalonia.Android/AvaloniaView.cs index a97d0425e6..ad7d95b147 100644 --- a/src/Android/Avalonia.Android/AvaloniaView.cs +++ b/src/Android/Avalonia.Android/AvaloniaView.cs @@ -17,7 +17,7 @@ using Avalonia.Rendering; namespace Avalonia.Android { - public class AvaloniaView : FrameLayout + public partial class AvaloniaView : FrameLayout { private EmbeddableControlRoot? _root; private ExploreByTouchHelper? _accessHelper; @@ -102,24 +102,6 @@ namespace Avalonia.Android base.OnAttachedToWindow(); } - protected override void OnFocusChanged(bool gainFocus, FocusSearchDirection direction, global::Android.Graphics.Rect? previouslyFocusedRect) - { - base.OnFocusChanged(gainFocus, direction, previouslyFocusedRect); - _accessHelper?.OnFocusChanged(gainFocus, (int)direction, previouslyFocusedRect); - } - - protected override bool DispatchHoverEvent(MotionEvent? e) - { - return _accessHelper?.DispatchHoverEvent(e!) == true || base.DispatchHoverEvent(e); - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - if (!_view.View.DispatchKeyEvent(e)) - return _accessHelper?.DispatchKeyEvent(e!) == true || base.DispatchKeyEvent(e); - return true; - } - [SupportedOSPlatform("android24.0")] public override void OnVisibilityAggregated(bool isVisible) { @@ -182,7 +164,6 @@ namespace Avalonia.Android { public ViewImpl(AvaloniaView avaloniaView) : base(avaloniaView) { - View.Focusable = true; View.FocusChange += ViewImpl_FocusChange; } diff --git a/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs b/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs index 8ca921c03c..5da78439e2 100644 --- a/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs +++ b/src/Android/Avalonia.Android/Platform/AndroidPlatformSettings.cs @@ -2,6 +2,8 @@ using Android.Content; using Android.Content.Res; using Android.Provider; +using Android.Views; +using Avalonia.Input; using Avalonia.Platform; using Color = Avalonia.Media.Color; @@ -11,10 +13,18 @@ namespace Avalonia.Android.Platform; internal class AndroidPlatformSettings : DefaultPlatformSettings { private PlatformColorValues _latestValues; + 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); public AndroidPlatformSettings() { _latestValues = base.GetColorValues(); + if (global::Android.App.Application.Context is { } context) + { + GetInputConfigValues(context); + } } public override PlatformColorValues GetColorValues() @@ -22,6 +32,23 @@ internal class AndroidPlatformSettings : DefaultPlatformSettings return _latestValues; } + public override TimeSpan GetDoubleTapTime(PointerType type) + { + return type == PointerType.Mouse ? base.GetDoubleTapTime(type) : _doubleTapTime; + } + + public override Size GetDoubleTapSize(PointerType type) + { + return type == PointerType.Mouse ? base.GetDoubleTapSize(type) : _doubleTapSize; + } + + public override Size GetTapSize(PointerType type) + { + return type == PointerType.Mouse ? base.GetTapSize(type) : _tapSize; + } + + public override TimeSpan HoldWaitDuration => _holdWaitDuration; + internal void OnViewConfigurationChanged(Context context, Configuration configuration) { if (context.Resources is null) @@ -80,9 +107,30 @@ internal class AndroidPlatformSettings : DefaultPlatformSettings _latestValues = _latestValues with { ThemeVariant = systemTheme }; } + GetInputConfigValues(context); + OnColorValuesChanged(_latestValues); } + private void GetInputConfigValues(Context context) + { + _holdWaitDuration = TimeSpan.FromMilliseconds(ViewConfiguration.LongPressTimeout); + + if (OperatingSystem.IsAndroidVersionAtLeast(31)) + { + _doubleTapTime = TimeSpan.FromMilliseconds(ViewConfiguration.MultiPressTimeout); + } + var config = ViewConfiguration.Get(context); + var scaling = context.Resources?.DisplayMetrics?.Density ?? 1; + if (config != null) + { + var size = config.ScaledDoubleTapSlop * 2 / scaling; + _doubleTapSize = new Size(size, size); + size = config.ScaledTouchSlop * 2 / scaling; + _tapSize = new Size(size, size); + } + } + private static ColorContrastPreference IsHighContrast(Context context) { try diff --git a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs index 8003db6607..2e8e145ef8 100644 --- a/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs +++ b/src/Android/Avalonia.Android/Platform/Input/AndroidInputMethod.cs @@ -44,9 +44,6 @@ namespace Avalonia.Android.Platform.Input public AndroidInputMethod(TView host) { - if (host.OnCheckIsTextEditor() == false) - throw new InvalidOperationException("Host should return true from OnCheckIsTextEditor()"); - _host = host; _imm = host.Context?.GetSystemService(Context.InputMethodService).JavaCast() ?? throw new InvalidOperationException("Context.InputMethodService is expected to be not null."); diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 337f20c160..0f84c1aa27 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -5,9 +5,7 @@ using Android.Content; using Android.Graphics; using Android.Graphics.Drawables; using Android.Runtime; -using Android.Text; using Android.Views; -using Android.Views.InputMethods; using AndroidX.AppCompat.App; using AndroidX.Core.View; using Avalonia.Android.Platform.Input; @@ -16,13 +14,11 @@ using Avalonia.Android.Platform.Specific.Helpers; using Avalonia.Android.Platform.Storage; using Avalonia.Controls; using Avalonia.Controls.Platform; -using Avalonia.Controls.Platform.Surfaces; using Avalonia.Input; using Avalonia.Input.Platform; using Avalonia.Input.Raw; using Avalonia.Input.TextInput; using Avalonia.OpenGL.Egl; -using Avalonia.OpenGL.Surfaces; using Avalonia.Platform; using Avalonia.Platform.Storage; using Avalonia.Rendering.Composition; @@ -35,7 +31,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { private readonly AndroidKeyboardEventsHelper _keyboardHelper; private readonly AndroidMotionEventsHelper _pointerHelper; - private readonly AndroidInputMethod _textInputMethod; + private readonly AndroidInputMethod _textInputMethod; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider? _storageProvider; private readonly AndroidSystemNavigationManagerImpl _systemNavigationManager; @@ -43,7 +39,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly ClipboardImpl _clipboard; private readonly AndroidLauncher? _launcher; private readonly AndroidScreens? _screens; - private ViewImpl _view; + private SurfaceViewImpl _view; private WindowTransparencyLevel _transparencyLevel; public TopLevelImpl(AvaloniaView avaloniaView, bool placeOnTop = false) @@ -53,8 +49,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform throw new ArgumentException("AvaloniaView.Context must not be null"); } - _view = new ViewImpl(avaloniaView.Context, this, placeOnTop); - _textInputMethod = new AndroidInputMethod(_view); + _view = new SurfaceViewImpl(avaloniaView.Context, this, placeOnTop); + _textInputMethod = new AndroidInputMethod(avaloniaView); _keyboardHelper = new AndroidKeyboardEventsHelper(this); _pointerHelper = new AndroidMotionEventsHelper(this); _clipboard = new ClipboardImpl(avaloniaView.Context.GetSystemService(Context.ClipboardService).JavaCast()); @@ -142,13 +138,13 @@ namespace Avalonia.Android.Platform.SkiaPlatform Resized?.Invoke(size, WindowResizeReason.Layout); } - sealed class ViewImpl : InvalidationAwareSurfaceView, IInitEditorInfo + sealed class SurfaceViewImpl : InvalidationAwareSurfaceView { private readonly TopLevelImpl _tl; private Size _oldSize; private double _oldScaling; - public ViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) + public SurfaceViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) { _tl = tl; if (placeOnTop) @@ -177,30 +173,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform base.DispatchDraw(canvas); } - protected override bool DispatchGenericPointerEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchGenericPointerEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchTouchEvent(MotionEvent? e) - { - var result = _tl._pointerHelper.DispatchMotionEvent(e, out var callBase); - var baseResult = callBase && base.DispatchTouchEvent(e); - - return result ?? baseResult; - } - - public override bool DispatchKeyEvent(KeyEvent? e) - { - var res = _tl._keyboardHelper.DispatchKeyEvent(e, out var callBase); - var baseResult = callBase && base.DispatchKeyEvent(e); - - return res ?? baseResult; - } - public override void SurfaceChanged(ISurfaceHolder holder, Format format, int width, int height) { base.SurfaceChanged(holder, format, width, height); @@ -233,23 +205,6 @@ namespace Avalonia.Android.Platform.SkiaPlatform _tl.Compositor.RequestCompositionUpdate(drawingFinished.Run); base.SurfaceRedrawNeededAsync(holder, drawingFinished); } - - public override bool OnCheckIsTextEditor() - { - return true; - } - - private Func? _initEditorInfo; - - public void InitEditorInfo(Func init) - { - _initEditorInfo = init; - } - - public override IInputConnection OnCreateInputConnection(EditorInfo? outAttrs) - { - return _initEditorInfo?.Invoke(_tl, outAttrs!)!; - } } public IPopupImpl? CreatePopup() => null; @@ -309,6 +264,8 @@ namespace Avalonia.Android.Platform.SkiaPlatform double EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Scaling => _view.Scaling; internal AndroidInsetsManager? InsetsManager => _insetsManager; + internal AndroidKeyboardEventsHelper KeyboardHelper => _keyboardHelper; + internal AndroidMotionEventsHelper PointerHelper => _pointerHelper; public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels) { diff --git a/src/Avalonia.Base/Input/InputElement.cs b/src/Avalonia.Base/Input/InputElement.cs index 0e0b6eab5a..f00166d6b0 100644 --- a/src/Avalonia.Base/Input/InputElement.cs +++ b/src/Avalonia.Base/Input/InputElement.cs @@ -566,6 +566,8 @@ namespace Avalonia.Input { FocusManager.GetFocusManager(this)?.ClearFocusOnElementRemoved(this, e.Parent); } + + IsKeyboardFocusWithin = false; } /// diff --git a/src/Avalonia.Base/Media/CharacterHit.cs b/src/Avalonia.Base/Media/CharacterHit.cs index 27cf3a42dc..48b89c0543 100644 --- a/src/Avalonia.Base/Media/CharacterHit.cs +++ b/src/Avalonia.Base/Media/CharacterHit.cs @@ -35,6 +35,10 @@ namespace Avalonia.Media /// /// Gets the trailing length value for the character that got hit. /// + /// + /// In the case of a leading edge, this value is 0. In the case of a trailing edge, + /// this value is the number of code points until the next valid caret position. + /// public int TrailingLength { get; } public bool Equals(CharacterHit other) diff --git a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs index b9ed31523e..15b3d5a9b4 100644 --- a/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/GenericTextParagraphProperties.cs @@ -1,7 +1,7 @@ namespace Avalonia.Media.TextFormatting { /// - /// Generic implementation of TextParagraphProperties + /// Generic implementation of . /// public sealed class GenericTextParagraphProperties : TextParagraphProperties { @@ -11,45 +11,45 @@ private double _lineHeight; /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// default paragraph's default run properties - /// logical horizontal alignment - /// text wrap option - /// Paragraph line height - /// letter spacing + /// Default text run properties, such as typeface or foreground brush. + /// The alignment of inline content in a block. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of letter spacing. public GenericTextParagraphProperties(TextRunProperties defaultTextRunProperties, TextAlignment textAlignment = TextAlignment.Left, - TextWrapping textWrap = TextWrapping.NoWrap, + TextWrapping textWrapping = TextWrapping.NoWrap, double lineHeight = 0, double letterSpacing = 0) { DefaultTextRunProperties = defaultTextRunProperties; _textAlignment = textAlignment; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; } /// - /// Constructing TextParagraphProperties + /// Initializes a new instance of the . /// - /// text flow direction - /// logical horizontal alignment - /// true if the paragraph is the first line in the paragraph - /// true if the line is always collapsible - /// default paragraph's default run properties - /// text wrap option - /// Paragraph line height - /// line indentation - /// letter spacing + /// The primary text advance direction. + /// The alignment of inline content in a block. + /// if the paragraph is the first line in the paragraph + /// if the formatted line may always be collapsed. If (the default), only lines that overflow the paragraph width are collapsed. + /// Default text run properties, such as typeface or foreground brush. + /// A value that controls whether text wraps when it reaches the flow edge of its containing block box. + /// Paragraph's line spacing. + /// The amount of line indentation. + /// The amount of letter spacing. public GenericTextParagraphProperties( FlowDirection flowDirection, TextAlignment textAlignment, bool firstLineInParagraph, bool alwaysCollapsible, TextRunProperties defaultTextRunProperties, - TextWrapping textWrap, + TextWrapping textWrapping, double lineHeight, double indent, double letterSpacing) @@ -59,16 +59,16 @@ FirstLineInParagraph = firstLineInParagraph; AlwaysCollapsible = alwaysCollapsible; DefaultTextRunProperties = defaultTextRunProperties; - _textWrap = textWrap; + _textWrap = textWrapping; _lineHeight = lineHeight; LetterSpacing = letterSpacing; Indent = indent; } /// - /// Constructing TextParagraphProperties from another one + /// Initializes a new instance of the with values copied from the specified . /// - /// source line props + /// The to copy values from. public GenericTextParagraphProperties(TextParagraphProperties textParagraphProperties) : this(textParagraphProperties.FlowDirection, textParagraphProperties.TextAlignment, @@ -82,64 +82,43 @@ { } - /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left, or top-to-bottom. - /// + /// public override FlowDirection FlowDirection { get { return _flowDirection; } } - /// - /// This property describes how inline content of a block is aligned. - /// + /// public override TextAlignment TextAlignment { get { return _textAlignment; } } - /// - /// Paragraph's line height - /// + /// public override double LineHeight { get { return _lineHeight; } } - /// - /// Indicates the first line of the paragraph. - /// + /// public override bool FirstLineInParagraph { get; } - /// - /// If true, the formatted line may always be collapsed. If false (the default), - /// only lines that overflow the paragraph width are collapsed. - /// + /// public override bool AlwaysCollapsible { get; } - /// - /// Paragraph's default run properties - /// + /// public override TextRunProperties DefaultTextRunProperties { get; } - /// - /// This property controls whether or not text wraps when it reaches the flow edge - /// of its containing block box - /// + /// public override TextWrapping TextWrapping { get { return _textWrap; } } - /// - /// Line indentation - /// + /// public override double Indent { get; } - /// - /// The letter spacing - /// + /// public override double LetterSpacing { get; } /// diff --git a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs index c41d9552ca..cde63c02a6 100644 --- a/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs +++ b/src/Avalonia.Base/Media/TextFormatting/TextParagraphProperties.cs @@ -6,63 +6,68 @@ public abstract class TextParagraphProperties { /// - /// This property specifies whether the primary text advance - /// direction shall be left-to-right, right-to-left. + /// Gets a value that specifies whether the primary text advance direction shall be left-to-right, or right-to-left. /// public abstract FlowDirection FlowDirection { get; } /// - /// Gets the text alignment. + /// Gets a value that describes how an inline content of a block is aligned. /// public abstract TextAlignment TextAlignment { get; } /// - /// Paragraph's line height + /// Gets the height of a line of text. /// public abstract double LineHeight { get; } /// - /// Paragraph's line spacing + /// Gets or sets paragraph's line spacing. /// internal double LineSpacing { get; set; } /// - /// Indicates the first line of the paragraph. + /// Gets a value that indicates whether the text run is the first line of the paragraph. /// public abstract bool FirstLineInParagraph { get; } /// + /// Gets a value that indicates whether a formatted line can always be collapsed. + /// + /// /// If true, the formatted line may always be collapsed. If false (the default), /// only lines that overflow the paragraph width are collapsed. - /// + /// public virtual bool AlwaysCollapsible { get { return false; } } /// - /// Gets the default text style. + /// Gets the default text run properties, such as typeface or foreground brush. /// public abstract TextRunProperties DefaultTextRunProperties { get; } /// + /// Gets the collection of TextDecoration objects. + /// + /// /// If not null, text decorations to apply to all runs in the line. This is in addition /// to any text decorations specified by the TextRunProperties for individual text runs. /// public virtual TextDecorationCollection? TextDecorations => null; /// - /// Gets the text wrapping. + /// Gets a value that controls whether text wraps when it reaches the flow edge of its containing block box. /// public abstract TextWrapping TextWrapping { get; } /// - /// Line indentation + /// Gets the amount of line indentation. /// public abstract double Indent { get; } /// - /// Get the paragraph indentation. + /// Gets the paragraph indentation. /// public virtual double ParagraphIndent { @@ -75,7 +80,7 @@ public virtual double DefaultIncrementalTab => 0; /// - /// Gets the letter spacing. + /// Gets the amount of letter spacing. /// public virtual double LetterSpacing { get; } } diff --git a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs index 324a50e4b4..afee481252 100644 --- a/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs +++ b/src/Avalonia.Base/Threading/Dispatcher.Invoke.cs @@ -672,4 +672,70 @@ public partial class Dispatcher /// public DispatcherPriorityAwaitable AwaitWithPriority(Task task, DispatcherPriority priority) => new(this, task, priority); + + /// + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + public DispatcherPriorityAwaitable Resume() => + Resume(DispatcherPriority.Background); + + /// `` + /// Creates an awaitable object that asynchronously resumes execution on the dispatcher. The work that occurs + /// when control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously resumes execution on the dispatcher. + /// + public DispatcherPriorityAwaitable Resume(DispatcherPriority priority) + { + DispatcherPriority.Validate(priority, nameof(priority)); + return new(this, null, priority); + } + + /// + /// Creates an awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// This method is equivalent to calling the method + /// and passing in . + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield() => + Yield(DispatcherPriority.Background); + + /// + /// Creates an cawaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. The work that occurs when + /// control returns to the code awaiting the result of this method is scheduled with the specified priority. + /// + /// The priority at which to schedule the continuation. + /// + /// An awaitable object that asynchronously yields control back to the current dispatcher + /// and provides an opportunity for the dispatcher to process other events. + /// + /// + /// The current thread is not the UI thread. + /// + public static DispatcherPriorityAwaitable Yield(DispatcherPriority priority) + { + // TODO12: Update to use Dispatcher.CurrentDispatcher once multi-dispatcher support is merged + var current = UIThread; + current.VerifyAccess(); + return UIThread.Resume(priority); + } } diff --git a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs index 456e2d7551..ab4fb38b5a 100644 --- a/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs +++ b/src/Avalonia.Base/Threading/DispatcherPriorityAwaitable.cs @@ -1,40 +1,130 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; namespace Avalonia.Threading; -public class DispatcherPriorityAwaitable : INotifyCompletion +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter. +/// +public struct DispatcherPriorityAwaitable { private readonly Dispatcher _dispatcher; - private protected readonly Task Task; + private readonly Task? _task; private readonly DispatcherPriority _priority; - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task? task, DispatcherPriority priority) { _dispatcher = dispatcher; - Task = task; + _task = task; _priority = priority; } - - public void OnCompleted(Action continuation) => - Task.ContinueWith(_ => _dispatcher.Post(continuation, _priority)); - public bool IsCompleted => Task.IsCompleted; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); +} + +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion +{ + private readonly Dispatcher _dispatcher; + private readonly Task? _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task? task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } + + public void OnCompleted(Action continuation) + { + if(_task == null || _task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; + + public void GetResult() + { + if (_task != null) + _task.GetAwaiter().GetResult(); + } +} + +/// +/// A simple awaitable type that will return a DispatcherPriorityAwaiter<T>. +/// +public struct DispatcherPriorityAwaitable +{ + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; - public void GetResult() => Task.GetAwaiter().GetResult(); + internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) + { + _dispatcher = dispatcher; + _task = task; + _priority = priority; + } - public DispatcherPriorityAwaitable GetAwaiter() => this; + public DispatcherPriorityAwaiter GetAwaiter() => new(_dispatcher, _task, _priority); } -public sealed class DispatcherPriorityAwaitable : DispatcherPriorityAwaitable +/// +/// A simple awaiter type that will queue the continuation to a dispatcher at a specific priority. +/// +/// +/// This is returned from DispatcherPriorityAwaitable<T>.GetAwaiter() +/// +public struct DispatcherPriorityAwaiter : INotifyCompletion { - internal DispatcherPriorityAwaitable(Dispatcher dispatcher, Task task, DispatcherPriority priority) : base( - dispatcher, task, priority) + private readonly Dispatcher _dispatcher; + private readonly Task _task; + private readonly DispatcherPriority _priority; + + internal DispatcherPriorityAwaiter(Dispatcher dispatcher, Task task, DispatcherPriority priority) { + _dispatcher = dispatcher; + _task = task; + _priority = priority; } - public new T GetResult() => ((Task)Task).GetAwaiter().GetResult(); + public void OnCompleted(Action continuation) + { + if(_task.IsCompleted) + _dispatcher.Post(continuation, _priority); + else + { + var self = this; + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(() => + { + self._dispatcher.Post(continuation, self._priority); + }); + } + } + + /// + /// This always returns false since continuation is requested to be queued to a dispatcher queue + /// + public bool IsCompleted => false; - public new DispatcherPriorityAwaitable GetAwaiter() => this; -} + public void GetResult() => _task.GetAwaiter().GetResult(); +} \ No newline at end of file diff --git a/src/Avalonia.Base/Utilities/WeakEvent.cs b/src/Avalonia.Base/Utilities/WeakEvent.cs index bc7cd04568..a237143c6c 100644 --- a/src/Avalonia.Base/Utilities/WeakEvent.cs +++ b/src/Avalonia.Base/Utilities/WeakEvent.cs @@ -1,5 +1,7 @@ using System; using System.Runtime.CompilerServices; +using System.Threading; +using Avalonia.Collections.Pooled; using Avalonia.Threading; namespace Avalonia.Utilities; @@ -10,8 +12,8 @@ namespace Avalonia.Utilities; public sealed class WeakEvent : WeakEvent where TSender : class { private readonly Func, Action> _subscribe; - - private readonly ConditionalWeakTable _subscriptions = new(); + private readonly ConditionalWeakTable _subscriptions = new(); + private readonly ConditionalWeakTable.CreateValueCallback _createSubscription; internal WeakEvent( Action> subscribe, @@ -22,33 +24,43 @@ public sealed class WeakEvent : WeakEvent where TSender : c subscribe(t, s); return () => unsubscribe(t, s); }; + _createSubscription = CreateSubscription; } - + internal WeakEvent(Func, Action> subscribe) { _subscribe = subscribe; + _createSubscription = CreateSubscription; } - + public void Subscribe(TSender target, IWeakEventSubscriber subscriber) { - if (!_subscriptions.TryGetValue(target, out var subscription)) - _subscriptions.Add(target, subscription = new Subscription(this, target)); - subscription.Add(subscriber); + var spinWait = default(SpinWait); + while (true) + { + var subscription = _subscriptions.GetValue(target, _createSubscription); + if (subscription.Add(subscriber)) + break; + spinWait.SpinOnce(); + } } public void Unsubscribe(TSender target, IWeakEventSubscriber subscriber) { - if (_subscriptions.TryGetValue(target, out var subscription)) + if (_subscriptions.TryGetValue(target, out var subscription)) subscription.Remove(subscriber); } + private Subscription CreateSubscription(TSender key) => new(this, key); + private sealed class Subscription { private readonly WeakEvent _ev; private readonly TSender _target; private readonly Action _compact; - private readonly Action _unsubscribe; private readonly WeakHashList> _list = new(); + private readonly object _lock = new(); + private Action? _unsubscribe; private bool _compactScheduled; private bool _destroyed; @@ -57,7 +69,6 @@ public sealed class WeakEvent : WeakEvent where TSender : c _ev = ev; _target = target; _compact = Compact; - _unsubscribe = ev._subscribe(target, OnEvent); } private void Destroy() @@ -65,19 +76,42 @@ public sealed class WeakEvent : WeakEvent where TSender : c if(_destroyed) return; _destroyed = true; - _unsubscribe(); + _unsubscribe?.Invoke(); _ev._subscriptions.Remove(_target); } - public void Add(IWeakEventSubscriber s) => _list.Add(s); + public bool Add(IWeakEventSubscriber s) + { + if (_destroyed) + return false; + + lock (_lock) + { + if (_destroyed) + return false; + + _unsubscribe ??= _ev._subscribe(_target, OnEvent); + _list.Add(s); + return true; + } + } public void Remove(IWeakEventSubscriber s) { - _list.Remove(s); - if(_list.IsEmpty) - Destroy(); - else if(_list.NeedCompact && _compactScheduled) - ScheduleCompact(); + if (_destroyed) + return; + + lock (_lock) + { + if (_destroyed) + return; + + _list.Remove(s); + if(_list.IsEmpty) + Destroy(); + else if(_list.NeedCompact && _compactScheduled) + ScheduleCompact(); + } } private void ScheduleCompact() @@ -90,23 +124,40 @@ public sealed class WeakEvent : WeakEvent where TSender : c private void Compact() { - if(!_compactScheduled) + if (_destroyed) return; - _compactScheduled = false; - _list.Compact(); - if (_list.IsEmpty) - Destroy(); + + lock (_lock) + { + if (_destroyed) + return; + if(!_compactScheduled) + return; + _compactScheduled = false; + _list.Compact(); + if (_list.IsEmpty) + Destroy(); + } } private void OnEvent(object? sender, TEventArgs eventArgs) { - var alive = _list.GetAlive(); - if(alive == null) - Destroy(); - else + PooledList>? alive; + lock (_lock) + { + alive = _list.GetAlive(); + if (alive == null) + { + Destroy(); + return; + } + } + + foreach(var item in alive.Span) + item.OnEvent(_target, _ev, eventArgs); + + lock (_lock) { - foreach(var item in alive.Span) - item.OnEvent(_target, _ev, eventArgs); WeakHashList>.ReturnToSharedPool(alive); if(_list.NeedCompact && !_compactScheduled) ScheduleCompact(); @@ -124,13 +175,13 @@ public class WeakEvent { return new WeakEvent(subscribe, unsubscribe); } - + public static WeakEvent Register( Func, Action> subscribe) where TSender : class where TEventArgs : EventArgs { return new WeakEvent(subscribe); } - + public static WeakEvent Register( Action subscribe, Action unsubscribe) where TSender : class diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs index 70be01e316..a667087c37 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.Properties.cs @@ -22,7 +22,7 @@ namespace Avalonia.Controls public static readonly StyledProperty CaretIndexProperty = TextBox.CaretIndexProperty.AddOwner(new( defaultValue: 0, - defaultBindingMode:BindingMode.TwoWay)); + defaultBindingMode: BindingMode.TwoWay)); public static readonly StyledProperty WatermarkProperty = TextBox.WatermarkProperty.AddOwner(); @@ -72,6 +72,12 @@ namespace Avalonia.Controls AvaloniaProperty.Register( nameof(ItemTemplate)); + /// + /// Defines the property + /// + public static readonly StyledProperty ClearSelectionOnLostFocusProperty = + TextBox.ClearSelectionOnLostFocusProperty.AddOwner(); + /// /// Identifies the property. /// @@ -295,6 +301,15 @@ namespace Avalonia.Controls set => SetValue(IsDropDownOpenProperty, value); } + /// + /// Gets or sets a value that determines whether the clears its selection after it loses focus. + /// + public bool ClearSelectionOnLostFocus + { + get => GetValue(ClearSelectionOnLostFocusProperty); + set => SetValue(ClearSelectionOnLostFocusProperty, value); + } + /// /// Gets or sets the that /// is used to get the values for display in the text portion of @@ -484,7 +499,7 @@ namespace Avalonia.Controls get => GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } - + /// /// Gets or sets the maximum number of characters that the can accept. /// This constraint only applies for manually entered (user-inputted) text. @@ -494,7 +509,7 @@ namespace Avalonia.Controls get => GetValue(MaxLengthProperty); set => SetValue(MaxLengthProperty, value); } - + /// /// Gets or sets custom content that is positioned on the left side of the text layout box /// @@ -511,6 +526,6 @@ namespace Avalonia.Controls { get => GetValue(InnerRightContentProperty); set => SetValue(InnerRightContentProperty, value); - } + } } } diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs index 4241f6315f..f61f6ff2a1 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -813,7 +813,10 @@ namespace Avalonia.Controls _userCalledPopulate = false; - if (ContextMenu is not { IsOpen: true }) + var textBoxContextMenuIsOpen = TextBox?.ContextFlyout?.IsOpen == true || TextBox?.ContextMenu?.IsOpen == true; + var contextMenuIsOpen = ContextFlyout?.IsOpen == true || ContextMenu?.IsOpen == true; + + if (!textBoxContextMenuIsOpen && !contextMenuIsOpen && ClearSelectionOnLostFocus) { ClearTextBoxSelection(); } diff --git a/src/Avalonia.Controls/Calendar/Calendar.cs b/src/Avalonia.Controls/Calendar/Calendar.cs index 58b4f4ee09..d0a5e2e890 100644 --- a/src/Avalonia.Controls/Calendar/Calendar.cs +++ b/src/Avalonia.Controls/Calendar/Calendar.cs @@ -237,6 +237,8 @@ namespace Avalonia.Controls private bool _isShiftPressed; private bool _displayDateIsChanging; + private bool _isTapRangeSelectionActive; + private DateTime? _tapRangeStart; internal CalendarDayButton? FocusButton { get; set; } internal CalendarButton? FocusCalendarButton { get; set; } @@ -437,6 +439,11 @@ namespace Avalonia.Controls nameof(SelectionMode), defaultValue: CalendarSelectionMode.SingleDate); + public static readonly StyledProperty AllowTapRangeSelectionProperty = + AvaloniaProperty.Register( + nameof(AllowTapRangeSelection), + defaultValue: true); + /// /// Gets or sets a value that indicates what kind of selections are /// allowed. @@ -462,6 +469,24 @@ namespace Avalonia.Controls set => SetValue(SelectionModeProperty, value); } + /// + /// Gets or sets a value indicating whether tap-to-select range mode is enabled. + /// When enabled, users can tap a start date and then tap an end date to select a range. + /// + /// + /// True to enable tap range selection; otherwise, false. The default is false. + /// + /// + /// This feature only works when SelectionMode is set to SingleRange. + /// When enabled, the first tap selects the start date, and the second tap selects + /// the end date to complete the range. Tapping a third date starts a new range. + /// + public bool AllowTapRangeSelection + { + get => GetValue(AllowTapRangeSelectionProperty); + set => SetValue(AllowTapRangeSelectionProperty, value); + } + private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs e) { if (IsValidSelectionMode(e.NewValue!)) @@ -470,6 +495,10 @@ namespace Avalonia.Controls SetCurrentValue(SelectedDateProperty, null); _displayDateIsChanging = false; SelectedDates.Clear(); + + // Reset tap range selection state when mode changes + _isTapRangeSelectionActive = false; + _tapRangeStart = null; } else { @@ -477,6 +506,12 @@ namespace Avalonia.Controls } } + private void OnAllowTapRangeSelectionChanged(AvaloniaPropertyChangedEventArgs e) + { + _isTapRangeSelectionActive = false; + _tapRangeStart = null; + } + /// /// Inherited code: Requires comment. /// @@ -1450,6 +1485,94 @@ namespace Avalonia.Controls SelectedDates.AddRange(HoverStart.Value, HoverEnd.Value); } } + + /// + /// Handles tap range selection logic for date range selection. + /// + /// The date that was tapped. + /// True if the tap was handled as part of range selection; otherwise, false. + internal bool ProcessTapRangeSelection(DateTime selectedDate) + { + if (!AllowTapRangeSelection || + (SelectionMode != CalendarSelectionMode.SingleRange && SelectionMode != CalendarSelectionMode.MultipleRange)) + { + return false; + } + + if (!IsValidDateSelection(this, selectedDate)) + { + return false; + } + + if (!_isTapRangeSelectionActive || !_tapRangeStart.HasValue) + { + _isTapRangeSelectionActive = true; + _tapRangeStart = selectedDate; + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + if (!SelectedDates.Contains(selectedDate)) + { + SelectedDates.Add(selectedDate); + } + + return true; + } + else + { + DateTime startDate = _tapRangeStart.Value; + DateTime endDate = selectedDate; + + if (DateTime.Compare(startDate, endDate) > 0) + { + (startDate, endDate) = (endDate, startDate); + } + + CalendarDateRange range = new CalendarDateRange(startDate, endDate); + if (BlackoutDates.ContainsAny(range)) + { + _tapRangeStart = selectedDate; + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + if (!SelectedDates.Contains(selectedDate)) + { + SelectedDates.Add(selectedDate); + } + return true; + } + + if (SelectionMode == CalendarSelectionMode.SingleRange) + { + foreach (DateTime item in SelectedDates) + { + RemovedItems.Add(item); + } + SelectedDates.ClearInternal(); + } + + SelectedDates.AddRange(startDate, endDate); + + _isTapRangeSelectionActive = false; + _tapRangeStart = null; + + return true; + } + } private void ProcessSelection(bool shift, DateTime? lastSelectedDate, int? index) { if (SelectionMode == CalendarSelectionMode.None && lastSelectedDate != null) @@ -1457,6 +1580,17 @@ namespace Avalonia.Controls OnDayClick(lastSelectedDate.Value); return; } + + // Handle tap range selection. + if (lastSelectedDate != null && index == null && !shift) + { + if (ProcessTapRangeSelection(lastSelectedDate.Value)) + { + OnDayClick(lastSelectedDate.Value); + return; + } + } + if (lastSelectedDate != null && IsValidKeyboardSelection(this, lastSelectedDate.Value)) { if (SelectionMode == CalendarSelectionMode.SingleRange || SelectionMode == CalendarSelectionMode.MultipleRange) @@ -2069,6 +2203,7 @@ namespace Avalonia.Controls IsTodayHighlightedProperty.Changed.AddClassHandler((x, e) => x.OnIsTodayHighlightedChanged(e)); DisplayModeProperty.Changed.AddClassHandler((x, e) => x.OnDisplayModePropertyChanged(e)); SelectionModeProperty.Changed.AddClassHandler((x, e) => x.OnSelectionModeChanged(e)); + AllowTapRangeSelectionProperty.Changed.AddClassHandler((x, e) => x.OnAllowTapRangeSelectionChanged(e)); SelectedDateProperty.Changed.AddClassHandler((x, e) => x.OnSelectedDateChanged(e)); DisplayDateProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateChanged(e)); DisplayDateStartProperty.Changed.AddClassHandler((x, e) => x.OnDisplayDateStartChanged(e)); diff --git a/src/Avalonia.Controls/Calendar/CalendarItem.cs b/src/Avalonia.Controls/Calendar/CalendarItem.cs index c2801e6e64..2bfe3c9be2 100644 --- a/src/Avalonia.Controls/Calendar/CalendarItem.cs +++ b/src/Avalonia.Controls/Calendar/CalendarItem.cs @@ -1030,6 +1030,15 @@ namespace Avalonia.Controls.Primitives Owner.OnDayClick(selectedDate); return; } + if (Owner.AllowTapRangeSelection && + (Owner.SelectionMode == CalendarSelectionMode.SingleRange || Owner.SelectionMode == CalendarSelectionMode.MultipleRange)) + { + if (Owner.ProcessTapRangeSelection(selectedDate)) + { + Owner.OnDayClick(selectedDate); + return; + } + } if (Owner.HoverStart.HasValue) { switch (Owner.SelectionMode) diff --git a/src/Avalonia.Controls/Chrome/CaptionButtons.cs b/src/Avalonia.Controls/Chrome/CaptionButtons.cs index 07ecbb188e..32bdd8fa96 100644 --- a/src/Avalonia.Controls/Chrome/CaptionButtons.cs +++ b/src/Avalonia.Controls/Chrome/CaptionButtons.cs @@ -21,6 +21,8 @@ namespace Avalonia.Controls.Chrome internal const string PART_FullScreenButton = "PART_FullScreenButton"; private Button? _restoreButton; + private Button? _minimizeButton; + private Button? _fullScreenButton; private IDisposable? _disposables; /// @@ -36,11 +38,16 @@ namespace Avalonia.Controls.Chrome _disposables = new CompositeDisposable { - HostWindow.GetObservable(Window.CanResizeProperty) - .Subscribe(x => + HostWindow.GetObservable(Window.CanMaximizeProperty) + .Subscribe(_ => + { + UpdateRestoreButtonState(); + UpdateFullScreenButtonState(); + }), + HostWindow.GetObservable(Window.CanMinimizeProperty) + .Subscribe(_ => { - if (_restoreButton is not null) - _restoreButton.IsEnabled = x; + UpdateMinimizeButtonState(); }), HostWindow.GetObservable(Window.WindowStateProperty) .Subscribe(x => @@ -49,6 +56,9 @@ namespace Avalonia.Controls.Chrome PseudoClasses.Set(":normal", x == WindowState.Normal); PseudoClasses.Set(":maximized", x == WindowState.Maximized); PseudoClasses.Set(":fullscreen", x == WindowState.FullScreen); + UpdateRestoreButtonState(); + UpdateMinimizeButtonState(); + UpdateFullScreenButtonState(); }), }; } @@ -116,8 +126,8 @@ namespace Avalonia.Controls.Chrome OnRestore(); args.Handled = true; }; - restoreButton.IsEnabled = HostWindow?.CanResize ?? true; _restoreButton = restoreButton; + UpdateRestoreButtonState(); } if (e.NameScope.Find [TemplatePart("PART_Popup", typeof(Popup), IsRequired = true)] + [TemplatePart("PART_EditableTextBox", typeof(TextBox), IsRequired = false)] [PseudoClasses(pcDropdownOpen, pcPressed)] public class ComboBox : SelectingItemsControl { @@ -38,6 +39,12 @@ namespace Avalonia.Controls public static readonly StyledProperty IsDropDownOpenProperty = AvaloniaProperty.Register(nameof(IsDropDownOpen)); + /// + /// Defines the property. + /// + public static readonly StyledProperty IsEditableProperty = + AvaloniaProperty.Register(nameof(IsEditable)); + /// /// Defines the property. /// @@ -73,7 +80,13 @@ namespace Avalonia.Controls /// public static readonly StyledProperty VerticalContentAlignmentProperty = ContentControl.VerticalContentAlignmentProperty.AddOwner(); - + + /// + /// Defines the property + /// + public static readonly StyledProperty TextProperty = + TextBlock.TextProperty.AddOwner(new(string.Empty, BindingMode.TwoWay)); + /// /// Defines the property. /// @@ -95,6 +108,10 @@ namespace Avalonia.Controls private object? _selectionBoxItem; private readonly CompositeDisposable _subscriptionsOnOpen = new CompositeDisposable(); + private TextBox? _inputTextBox; + private BindingEvaluator? _textValueBindingEvaluator = null; + private bool _skipNextTextChanged = false; + /// /// Initializes static members of the class. /// @@ -124,6 +141,15 @@ namespace Avalonia.Controls set => SetValue(IsDropDownOpenProperty, value); } + /// + /// Gets or sets a value indicating whether the control is editable + /// + public bool IsEditable + { + get => GetValue(IsEditableProperty); + set => SetValue(IsEditableProperty, value); + } + /// /// Gets or sets the maximum height for the dropdown list. /// @@ -188,6 +214,16 @@ namespace Avalonia.Controls set => SetValue(SelectionBoxItemTemplateProperty, value); } + /// + /// Gets or sets the text used when is true. + /// Does nothing if not . + /// + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); @@ -229,7 +265,7 @@ namespace Avalonia.Controls SetCurrentValue(IsDropDownOpenProperty, false); e.Handled = true; } - else if (!IsDropDownOpen && (e.Key == Key.Enter || e.Key == Key.Space)) + else if (!IsDropDownOpen && !IsEditable && (e.Key == Key.Enter || e.Key == Key.Space)) { SetCurrentValue(IsDropDownOpenProperty, true); e.Handled = true; @@ -315,6 +351,15 @@ namespace Avalonia.Controls /// protected override void OnPointerReleased(PointerReleasedEventArgs e) { + //if the user clicked in the input text we don't want to open the dropdown + if (_inputTextBox != null + && !e.Handled + && e.Source is StyledElement styledSource + && styledSource.TemplatedParent == _inputTextBox) + { + return; + } + if (!e.Handled && e.Source is Visual source) { if (_popup?.IsInsidePopup(source) == true) @@ -348,6 +393,8 @@ namespace Avalonia.Controls _popup = e.NameScope.Get("PART_Popup"); _popup.Opened += PopupOpened; _popup.Closed += PopupClosed; + + _inputTextBox = e.NameScope.Find("PART_EditableTextBox"); } /// @@ -357,6 +404,7 @@ namespace Avalonia.Controls { UpdateSelectionBoxItem(change.NewValue); TryFocusSelectedItem(); + UpdateInputTextFromSelection(change.NewValue); } else if (change.Property == IsDropDownOpenProperty) { @@ -366,6 +414,30 @@ namespace Avalonia.Controls { CoerceValue(SelectionBoxItemTemplateProperty); } + else if (change.Property == IsEditableProperty && change.GetNewValue()) + { + UpdateInputTextFromSelection(SelectedItem); + } + else if (change.Property == TextProperty) + { + TextChanged(change.GetNewValue()); + } + else if (change.Property == ItemsSourceProperty) + { + //the base handler deselects the current item (and resets Text) so we want to run the base first, then try match by text + string? text = Text; + base.OnPropertyChanged(change); + SetCurrentValue(TextProperty, text); + return; + } + else if (change.Property == DisplayMemberBindingProperty) + { + HandleTextValueBindingValueChanged(null, change); + } + else if (change.Property == TextSearch.TextBindingProperty) + { + HandleTextValueBindingValueChanged(change, null); + } base.OnPropertyChanged(change); } @@ -374,6 +446,17 @@ namespace Avalonia.Controls return new ComboBoxAutomationPeer(this); } + protected override void OnGotFocus(GotFocusEventArgs e) + { + if (IsEditable && _inputTextBox != null) + { + _inputTextBox.Focus(); + _inputTextBox.SelectAll(); + } + + base.OnGotFocus(e); + } + internal void ItemFocused(ComboBoxItem dropDownItem) { if (IsDropDownOpen && dropDownItem.IsFocused && dropDownItem.IsArrangeValid) @@ -386,6 +469,11 @@ namespace Avalonia.Controls { _subscriptionsOnOpen.Clear(); + if(IsEditable && CanFocus(this)) + { + Focus(); + } + DropDownClosed?.Invoke(this, EventArgs.Empty); } @@ -502,6 +590,14 @@ namespace Avalonia.Controls } } + private void UpdateInputTextFromSelection(object? item) + { + //if we are modifying the text box which has deselected a value we don't want to update the textbox value + if (_skipNextTextChanged) + return; + SetCurrentValue(TextProperty, GetItemTextValue(item)); + } + private void SelectFocusedItem() { foreach (var dropdownItem in GetRealizedContainers()) @@ -561,5 +657,69 @@ namespace Avalonia.Controls SelectedItem = null; SelectedIndex = -1; } + + private void HandleTextValueBindingValueChanged(AvaloniaPropertyChangedEventArgs? textSearchPropChange, + AvaloniaPropertyChangedEventArgs? displayMemberPropChange) + { + IBinding? textValueBinding; + //prioritise using the TextSearch.TextBindingProperty if possible + if (textSearchPropChange == null && TextSearch.GetTextBinding(this) is IBinding textSearchBinding) + textValueBinding = textSearchBinding; + + else if (textSearchPropChange != null && textSearchPropChange.NewValue is IBinding eventTextSearchBinding) + textValueBinding = eventTextSearchBinding; + + else if (displayMemberPropChange != null && displayMemberPropChange.NewValue is IBinding eventDisplayMemberBinding) + textValueBinding = eventDisplayMemberBinding; + + else + textValueBinding = null; + + if (_textValueBindingEvaluator == null) + _textValueBindingEvaluator = BindingEvaluator.TryCreate(textValueBinding); + else if (textValueBinding == null) + _textValueBindingEvaluator = null; + else + _textValueBindingEvaluator.UpdateBinding(textValueBinding); + + //if the binding is set we want to set the initial value for the selected item so the text box has the correct value + if (_textValueBindingEvaluator != null) + _textValueBindingEvaluator.Value = GetItemTextValue(SelectedValue); + } + + private void TextChanged(string? newValue) + { + if (!IsEditable || _skipNextTextChanged) + return; + + int selectedIdx = -1; + object? selectedItem = null; + int i = -1; + foreach (object? item in Items) + { + i++; + string itemText = GetItemTextValue(item); + if (string.Equals(newValue, itemText, StringComparison.CurrentCultureIgnoreCase)) + { + selectedIdx = i; + selectedItem = item; + break; + } + } + + _skipNextTextChanged = true; + try + { + SelectedIndex = selectedIdx; + SelectedItem = selectedItem; + } + finally + { + _skipNextTextChanged = false; + } + } + + private string GetItemTextValue(object? item) + => TextSearch.GetEffectiveText(item, _textValueBindingEvaluator); } } diff --git a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs index b745ed7779..bfe928309f 100644 --- a/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs +++ b/src/Avalonia.Controls/DateTimePickers/DatePickerPresenter.cs @@ -1,6 +1,5 @@ using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; -using Avalonia.Controls.Shapes; using Avalonia.Input; using Avalonia.Interactivity; using System; @@ -13,23 +12,23 @@ namespace Avalonia.Controls /// Defines the presenter used for selecting a date for a /// /// - [TemplatePart("PART_AcceptButton", typeof(Button), IsRequired = true)] - [TemplatePart("PART_DayDownButton", typeof(RepeatButton))] - [TemplatePart("PART_DayHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_DaySelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_DayUpButton", typeof(RepeatButton))] - [TemplatePart("PART_DismissButton", typeof(Button))] - [TemplatePart("PART_FirstSpacer", typeof(Rectangle))] - [TemplatePart("PART_MonthDownButton", typeof(RepeatButton))] - [TemplatePart("PART_MonthHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_MonthSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_MonthUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PickerContainer", typeof(Grid), IsRequired = true)] - [TemplatePart("PART_SecondSpacer", typeof(Rectangle))] - [TemplatePart("PART_YearDownButton", typeof(RepeatButton))] - [TemplatePart("PART_YearHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_YearSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_YearUpButton", typeof(RepeatButton))] + [TemplatePart(TemplateItems.AcceptButtonName, typeof(Button), IsRequired = true)] + [TemplatePart(TemplateItems.DayDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.DayHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.DaySelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.DayUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.DismissButtonName, typeof(Button))] + [TemplatePart(TemplateItems.FirstSpacerName, typeof(Control))] + [TemplatePart(TemplateItems.MonthDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MonthHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.MonthSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.MonthUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PickerContainerName, typeof(Grid), IsRequired = true)] + [TemplatePart(TemplateItems.SecondSpacerName, typeof(Control))] + [TemplatePart(TemplateItems.YearDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.YearHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.YearSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.YearUpButtonName, typeof(Button))] public class DatePickerPresenter : PickerPresenterBase { /// @@ -102,24 +101,61 @@ namespace Avalonia.Controls public static readonly StyledProperty YearVisibleProperty = DatePicker.YearVisibleProperty.AddOwner(); - // Template Items - private Grid? _pickerContainer; - private Button? _acceptButton; - private Button? _dismissButton; - private Rectangle? _spacer1; - private Rectangle? _spacer2; - private Panel? _monthHost; - private Panel? _yearHost; - private Panel? _dayHost; - private DateTimePickerPanel? _monthSelector; - private DateTimePickerPanel? _yearSelector; - private DateTimePickerPanel? _daySelector; - private Button? _monthUpButton; - private Button? _dayUpButton; - private Button? _yearUpButton; - private Button? _monthDownButton; - private Button? _dayDownButton; - private Button? _yearDownButton; + private struct TemplateItems + { + public Grid _pickerContainer; + public const string PickerContainerName = "PART_PickerContainer"; + + public Button _acceptButton; + public const string AcceptButtonName = "PART_AcceptButton"; + + public Button? _dismissButton; + public const string DismissButtonName = "PART_DismissButton"; + + public Control? _firstSpacer; + public const string FirstSpacerName = "PART_FirstSpacer"; + + public Control? _secondSpacer; + public const string SecondSpacerName = "PART_SecondSpacer"; + + public Panel _monthHost; + public const string MonthHostName = "PART_MonthHost"; + + public Panel _yearHost; + public const string YearHostName = "PART_YearHost"; + + public Panel _dayHost; + public const string DayHostName = "PART_DayHost"; + + public DateTimePickerPanel _monthSelector; + public const string MonthSelectorName = "PART_MonthSelector"; + + public DateTimePickerPanel _yearSelector; + public const string YearSelectorName = "PART_YearSelector"; + + public DateTimePickerPanel _daySelector; + public const string DaySelectorName = "PART_DaySelector"; + + public Button? _monthUpButton; + public const string MonthUpButtonName = "PART_MonthUpButton"; + + public Button? _dayUpButton; + public const string DayUpButtonName = "PART_DayUpButton"; + + public Button? _yearUpButton; + public const string YearUpButtonName = "PART_YearUpButton"; + + public Button? _monthDownButton; + public const string MonthDownButtonName = "PART_MonthDownButton"; + + public Button? _dayDownButton; + public const string DayDownButtonName = "PART_DayDownButton"; + + public Button? _yearDownButton; + public const string YearDownButtonName = "PART_YearDownButton"; + } + + private TemplateItems? _templateItems; private DateTimeOffset _syncDate; @@ -235,69 +271,54 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - // These are requirements, so throw if not found - _pickerContainer = e.NameScope.Get("PART_PickerContainer"); - _monthHost = e.NameScope.Get("PART_MonthHost"); - _dayHost = e.NameScope.Get("PART_DayHost"); - _yearHost = e.NameScope.Get("PART_YearHost"); - _monthSelector = e.NameScope.Get("PART_MonthSelector"); - _monthSelector.SelectionChanged += OnMonthChanged; - - _daySelector = e.NameScope.Get("PART_DaySelector"); - _daySelector.SelectionChanged += OnDayChanged; - - _yearSelector = e.NameScope.Get("PART_YearSelector"); - _yearSelector.SelectionChanged += OnYearChanged; - - _acceptButton = e.NameScope.Get - [TemplatePart("PART_AcceptButton", typeof(Button), IsRequired = true)] - [TemplatePart("PART_DismissButton", typeof(Button))] - [TemplatePart("PART_HourDownButton", typeof(RepeatButton))] - [TemplatePart("PART_HourSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_HourUpButton", typeof(RepeatButton))] - [TemplatePart("PART_MinuteDownButton", typeof(RepeatButton))] - [TemplatePart("PART_MinuteSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_MinuteUpButton", typeof(RepeatButton))] - [TemplatePart("PART_SecondDownButton", typeof(RepeatButton))] - [TemplatePart("PART_SecondHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_SecondSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_SecondUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PeriodDownButton", typeof(RepeatButton))] - [TemplatePart("PART_PeriodHost", typeof(Panel), IsRequired = true)] - [TemplatePart("PART_PeriodSelector", typeof(DateTimePickerPanel), IsRequired = true)] - [TemplatePart("PART_PeriodUpButton", typeof(RepeatButton))] - [TemplatePart("PART_PickerContainer", typeof(Grid), IsRequired = true)] - [TemplatePart("PART_SecondSpacer", typeof(Rectangle), IsRequired = true)] - [TemplatePart("PART_ThirdSpacer", typeof(Rectangle), IsRequired = true)] + [TemplatePart(TemplateItems.AcceptButtonName, typeof(Button), IsRequired = true)] + [TemplatePart(TemplateItems.DismissButtonName, typeof(Button))] + [TemplatePart(TemplateItems.HourDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.HourSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.HourUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MinuteDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.MinuteSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.MinuteUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.SecondDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.SecondHostName, typeof(Panel))] + [TemplatePart(TemplateItems.SecondSelectorName, typeof(DateTimePickerPanel))] + [TemplatePart(TemplateItems.SecondUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PeriodDownButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PeriodHostName, typeof(Panel), IsRequired = true)] + [TemplatePart(TemplateItems.PeriodSelectorName, typeof(DateTimePickerPanel), IsRequired = true)] + [TemplatePart(TemplateItems.PeriodUpButtonName, typeof(Button))] + [TemplatePart(TemplateItems.PickerContainerName, typeof(Grid), IsRequired = true)] + [TemplatePart(TemplateItems.SecondSpacerName, typeof(Control), IsRequired = true)] + [TemplatePart(TemplateItems.ThirdSpacerName, typeof(Control))] public class TimePickerPresenter : PickerPresenterBase { /// @@ -37,7 +36,7 @@ namespace Avalonia.Controls /// public static readonly StyledProperty MinuteIncrementProperty = TimePicker.MinuteIncrementProperty.AddOwner(); - + /// /// Defines the property /// @@ -49,7 +48,7 @@ namespace Avalonia.Controls /// public static readonly StyledProperty ClockIdentifierProperty = TimePicker.ClockIdentifierProperty.AddOwner(); - + /// /// Defines the property /// @@ -72,26 +71,54 @@ namespace Avalonia.Controls KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); } - // TemplateItems - private Grid? _pickerContainer; - private Button? _acceptButton; - private Button? _dismissButton; - private Rectangle? _spacer2; - private Rectangle? _spacer3; - private Panel? _secondHost; - private Panel? _periodHost; - private DateTimePickerPanel? _hourSelector; - private DateTimePickerPanel? _minuteSelector; - private DateTimePickerPanel? _secondSelector; - private DateTimePickerPanel? _periodSelector; - private Button? _hourUpButton; - private Button? _minuteUpButton; - private Button? _secondUpButton; - private Button? _periodUpButton; - private Button? _hourDownButton; - private Button? _minuteDownButton; - private Button? _secondDownButton; - private Button? _periodDownButton; + private struct TemplateItems + { + public Grid _pickerContainer; + public const string PickerContainerName = "PART_PickerContainer"; + + public Button _acceptButton; + public const string AcceptButtonName = "PART_AcceptButton"; + public Button? _dismissButton; + public const string DismissButtonName = "PART_DismissButton"; + + public Control _secondSpacer; // the 2nd spacer, not seconds of time + public const string SecondSpacerName = "PART_SecondSpacer"; + public Control? _thirdSpacer; + public const string ThirdSpacerName = "PART_ThirdSpacer"; + + public Panel? _secondHost; + public const string SecondHostName = "PART_SecondHost"; + public Panel _periodHost; + public const string PeriodHostName = "PART_PeriodHost"; + + public DateTimePickerPanel _hourSelector; + public const string HourSelectorName = "PART_HourSelector"; + public DateTimePickerPanel _minuteSelector; + public const string MinuteSelectorName = "PART_MinuteSelector"; + public DateTimePickerPanel? _secondSelector; + public const string SecondSelectorName = "PART_SecondSelector"; + public DateTimePickerPanel _periodSelector; + public const string PeriodSelectorName = "PART_PeriodSelector"; + + public Button? _hourUpButton; + public const string HourUpButtonName = "PART_HourUpButton"; + public Button? _minuteUpButton; + public const string MinuteUpButtonName = "PART_MinuteUpButton"; + public Button? _secondUpButton; + public const string SecondUpButtonName = "PART_SecondUpButton"; + public Button? _periodUpButton; + public const string PeriodUpButtonName = "PART_PeriodUpButton"; + public Button? _hourDownButton; + public const string HourDownButtonName = "PART_HourDownButton"; + public Button? _minuteDownButton; + public const string MinuteDownButtonName = "PART_MinuteDownButton"; + public Button? _secondDownButton; + public const string SecondDownButtonName = "PART_SecondDownButton"; + public Button? _periodDownButton; + public const string PeriodDownButtonName = "PART_PeriodDownButton"; + } + + private TemplateItems? _templateItems; /// /// Gets or sets the minute increment in the selector @@ -101,7 +128,7 @@ namespace Avalonia.Controls get => GetValue(MinuteIncrementProperty); set => SetValue(MinuteIncrementProperty, value); } - + /// /// Gets or sets the second increment in the selector /// @@ -119,7 +146,7 @@ namespace Avalonia.Controls get => GetValue(ClockIdentifierProperty); set => SetValue(ClockIdentifierProperty, value); } - + /// /// Gets or sets the current clock identifier, either 12HourClock or 24HourClock /// @@ -142,54 +169,54 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); - _pickerContainer = e.NameScope.Get("PART_PickerContainer"); - _periodHost = e.NameScope.Get("PART_PeriodHost"); - _secondHost = e.NameScope.Find("PART_SecondHost"); - - _hourSelector = e.NameScope.Get("PART_HourSelector"); - _minuteSelector = e.NameScope.Get("PART_MinuteSelector"); - _secondSelector = e.NameScope.Find("PART_SecondSelector"); - _periodSelector = e.NameScope.Get("PART_PeriodSelector"); - - _spacer2 = e.NameScope.Get("PART_SecondSpacer"); - _spacer3 = e.NameScope.Find("PART_ThirdSpacer"); - - _acceptButton = e.NameScope.Get private bool SetupDefinitionsToResize() { - int gridSpan = GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ? + // Get properties values from ContentPresenter if Grid it's used in ItemsControl as ItemsPanel otherwise directly from GridSplitter. + var sourceControl = GetPropertiesValueSource(); + int gridSpan = sourceControl.GetValue(_resizeData!.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnSpanProperty : Grid.RowSpanProperty); if (gridSpan == 1) { - var splitterIndex = GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ? + var splitterIndex = sourceControl.GetValue(_resizeData.ResizeDirection == GridResizeDirection.Columns ? Grid.ColumnProperty : Grid.RowProperty); @@ -351,6 +356,81 @@ namespace Avalonia.Controls } } + /// + /// Retrieves the that ultimately hosts this + /// in the visual/logical tree. + /// + /// + /// A splitter can be placed directly inside a or + /// indirectly inside an that uses a + /// as its . + /// In the latter case the first logical parent is usually an + /// (or the items control itself), + /// so the method walks these intermediate containers to locate the + /// underlying grid. + /// + /// + /// The containing if one is found; otherwise + /// null. + /// + protected virtual Grid? GetParentGrid() + { + // When GridSplitter is used inside an ItemsControl with Grid as + // its ItemsPanel, its immediate parent is usually a ItemsControl or ContentPresenter. + switch (Parent) + { + case Grid grid: + { + return grid; + } + case ItemsControl itemsControl: + { + if (itemsControl.ItemsPanelRoot is Grid grid) + { + return grid; + } + + break; + } + case ContentPresenter { Parent: ItemsControl presenterItemsControl }: + { + if (presenterItemsControl.ItemsPanelRoot is Grid grid) + { + return grid; + } + + break; + } + } + + return null; + } + + /// + /// Returns the element that carries the grid-attached properties + /// (, , etc.) relevant + /// to this . + /// + /// + /// When the splitter is generated as part of an + /// template, the attached properties are set on the surrounding + /// rather than on the splitter itself. + /// This helper selects that presenter when appropriate so subsequent + /// property look-ups read the correct values; otherwise it simply + /// returns this. + /// + /// + /// The from which grid-attached properties + /// should be read—either the parent or + /// the splitter instance. + /// + protected virtual StyledElement GetPropertiesValueSource() + { + return Parent is ContentPresenter + ? Parent + : this; + } + protected override void OnPointerEntered(PointerEventArgs e) { base.OnPointerEntered(e); diff --git a/src/Avalonia.Controls/Platform/IWindowImpl.cs b/src/Avalonia.Controls/Platform/IWindowImpl.cs index 91386c93fe..50cb8c4f2d 100644 --- a/src/Avalonia.Controls/Platform/IWindowImpl.cs +++ b/src/Avalonia.Controls/Platform/IWindowImpl.cs @@ -64,6 +64,16 @@ namespace Avalonia.Platform /// void CanResize(bool value); + /// + /// Enables or disables minimizing the window. + /// + void SetCanMinimize(bool value); + + /// + /// Enables or disables maximizing the window. + /// + void SetCanMaximize(bool value); + /// /// Gets or sets a method called before the underlying implementation is destroyed. /// Return true to prevent the underlying implementation from closing. @@ -124,7 +134,6 @@ namespace Avalonia.Platform /// /// Minimum width of the window. /// - /// void SetMinMaxSize(Size minSize, Size maxSize); /// diff --git a/src/Avalonia.Controls/SplitView/SplitView.cs b/src/Avalonia.Controls/SplitView/SplitView.cs index fd6fd98de6..f78ac6e0ee 100644 --- a/src/Avalonia.Controls/SplitView/SplitView.cs +++ b/src/Avalonia.Controls/SplitView/SplitView.cs @@ -314,6 +314,7 @@ namespace Avalonia.Controls { base.OnDetachedFromVisualTree(e); _pointerDisposable?.Dispose(); + _pointerDisposable = null; } /// @@ -423,7 +424,7 @@ namespace Avalonia.Controls protected virtual void OnPaneOpened(RoutedEventArgs args) { - EnableLightDismiss(); + InvalidateLightDismissSubscription(); RaiseEvent(args); } @@ -528,6 +529,8 @@ namespace Avalonia.Controls }; TemplateSettings.ClosedPaneWidth = closedPaneWidth; TemplateSettings.PaneColumnGridLength = paneColumnGridLength; + + InvalidateLightDismissSubscription(); } private void UpdateVisualStateForPanePlacementProperty(SplitViewPanePlacement newValue) @@ -541,7 +544,7 @@ namespace Avalonia.Controls PseudoClasses.Add(_lastPlacementPseudoclass); } - private void EnableLightDismiss() + private void InvalidateLightDismissSubscription() { if (_pane == null) return; @@ -549,19 +552,26 @@ namespace Avalonia.Controls // If this returns false, we're not in Overlay or CompactOverlay DisplayMode // and don't need the light dismiss behavior if (!IsInOverlayMode()) + { + _pointerDisposable?.Dispose(); + _pointerDisposable = null; return; + } - var topLevel = TopLevel.GetTopLevel(this); - if (topLevel != null) + if (_pointerDisposable == null) { - _pointerDisposable = Disposable.Create(() => + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel != null) { - topLevel.PointerReleased -= PointerReleasedOutside; - topLevel.BackRequested -= TopLevelBackRequested; - }); - - topLevel.PointerReleased += PointerReleasedOutside; - topLevel.BackRequested += TopLevelBackRequested; + _pointerDisposable = Disposable.Create(() => + { + topLevel.PointerReleased -= PointerReleasedOutside; + topLevel.BackRequested -= TopLevelBackRequested; + }); + + topLevel.PointerReleased += PointerReleasedOutside; + topLevel.BackRequested += TopLevelBackRequested; + } } } diff --git a/src/Avalonia.Controls/Utils/BindingEvaluator.cs b/src/Avalonia.Controls/Utils/BindingEvaluator.cs index 00d9a71513..4f1c4f500c 100644 --- a/src/Avalonia.Controls/Utils/BindingEvaluator.cs +++ b/src/Avalonia.Controls/Utils/BindingEvaluator.cs @@ -19,6 +19,15 @@ internal sealed class BindingEvaluator : StyledElement, IDisposable public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register, T>("Value"); + /// + /// Gets or sets the data item value. + /// + public T Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + public T Evaluate(object? dataContext) { // Only update the DataContext if necessary @@ -49,6 +58,7 @@ internal sealed class BindingEvaluator : StyledElement, IDisposable DataContext = null; } + [return: NotNullIfNotNull(nameof(binding))] public static BindingEvaluator? TryCreate(IBinding? binding) { if (binding is null) diff --git a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs index 675e9e13e5..d9fe30b72b 100644 --- a/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs +++ b/src/Avalonia.Controls/Utils/CollectionChangedEventManager.cs @@ -132,18 +132,22 @@ namespace Avalonia.Controls.Utils } } - var l = Listeners.ToArray(); - if (Dispatcher.UIThread.CheckAccess()) { + var l = Listeners.ToArray(); Notify(_collection, e, l); } else { var eCapture = e; - Dispatcher.UIThread.Post(() => Notify(_collection, eCapture, l), DispatcherPriority.Send); + Dispatcher.UIThread.Post(() => + { + var l = Listeners.ToArray(); + Notify(_collection, eCapture, l); + }, + DispatcherPriority.Send); } } } } -} +} \ No newline at end of file diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index adeebf97d9..e883bb533b 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.Utils; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +using Avalonia.Reactive; using Avalonia.Utilities; using Avalonia.VisualTree; @@ -51,6 +52,12 @@ namespace Avalonia.Controls RoutedEvent.Register( nameof(VerticalSnapPointsChanged), RoutingStrategies.Bubble); + /// + /// Defines the property. + /// + public static readonly StyledProperty CacheLengthProperty = + AvaloniaProperty.Register(nameof(CacheLength), 0.0, + validate: v => v is >= 0 and <= 2); private static readonly AttachedProperty RecycleKeyProperty = AvaloniaProperty.RegisterAttached("RecycleKey"); @@ -73,12 +80,24 @@ namespace Avalonia.Controls private int _focusedIndex = -1; private Control? _realizingElement; private int _realizingIndex = -1; + private double _bufferFactor; + + private bool _hasReachedStart = false; + private bool _hasReachedEnd = false; + private Rect _extendedViewport; + + static VirtualizingStackPanel() + { + CacheLengthProperty.Changed.AddClassHandler((x, e) => x.OnCacheLengthChanged(e)); + } public VirtualizingStackPanel() { _recycleElement = RecycleElement; _recycleElementOnItemRemoved = RecycleElementOnItemRemoved; _updateElementIndex = UpdateElementIndex; + + _bufferFactor = Math.Max(0, CacheLength); EffectiveViewportChanged += OnEffectiveViewportChanged; } @@ -131,6 +150,20 @@ namespace Avalonia.Controls set => SetValue(AreVerticalSnapPointsRegularProperty, value); } + /// + /// Gets or sets the CacheLength. + /// + /// The factor determines how much additional space to maintain above and below the viewport. + /// A value of 0.5 means half the viewport size will be buffered on each side (up-down or left-right) + /// This uses more memory as more UI elements are realized, but greatly reduces the number of Measure-Arrange + /// cycles which can cause heavy GC pressure depending on the complexity of the item layouts. + /// + public double CacheLength + { + get => GetValue(CacheLengthProperty); + set => SetValue(CacheLengthProperty, value); + } + /// /// Gets the index of the first realized element, or -1 if no elements are realized. /// @@ -141,6 +174,16 @@ namespace Avalonia.Controls /// public int LastRealizedIndex => _realizedElements?.LastIndex ?? -1; + /// + /// Returns the viewport that contains any visible elements + /// + internal Rect ViewPort => _viewport; + + /// + /// Returns the extended viewport that contains any visible elements and the additional elements for fast scrolling (viewport * CacheLength * 2) + /// + internal Rect ExtendedViewPort => _extendedViewport; + protected override Size MeasureOverride(Size availableSize) { var items = Items; @@ -217,8 +260,12 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, sizeU, finalSize.Height) : new Rect(0, u, finalSize.Width, sizeU); + e.Arrange(rect); - _scrollAnchorProvider?.RegisterAnchorCandidate(e); + + if (_viewport.Intersects(rect)) + _scrollAnchorProvider?.RegisterAnchorCandidate(e); + u += orientation == Orientation.Horizontal ? rect.Width : rect.Height; } } @@ -230,6 +277,7 @@ namespace Avalonia.Controls var rect = orientation == Orientation.Horizontal ? new Rect(u, 0, _focusedElement.DesiredSize.Width, finalSize.Height) : new Rect(0, u, finalSize.Width, _focusedElement.DesiredSize.Height); + _focusedElement.Arrange(rect); } @@ -416,6 +464,7 @@ namespace Avalonia.Controls // Create and measure the element to be brought into view. Store it in a field so that // it can be re-used in the layout pass. var scrollToElement = GetOrCreateElement(items, index); + scrollToElement.Measure(Size.Infinity); // Get the expected position of the element and put it in place. @@ -483,7 +532,8 @@ namespace Avalonia.Controls { Debug.Assert(_realizedElements is not null); - var viewport = _viewport; + // Use the extended viewport for calculations + var viewport = _extendedViewport; // Get the viewport in the orientation direction. var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y; @@ -653,7 +703,6 @@ namespace Avalonia.Controls return index * estimatedSize; } - private void RealizeElements( IReadOnlyList items, Size availableSize, @@ -666,6 +715,10 @@ namespace Avalonia.Controls var index = viewport.anchorIndex; var horizontal = Orientation == Orientation.Horizontal; var u = viewport.anchorU; + + // Reset boundary flags + _hasReachedStart = false; + _hasReachedEnd = false; // If the anchor element is at the beginning of, or before, the start of the viewport // then we can recycle all elements before it. @@ -678,8 +731,9 @@ namespace Avalonia.Controls _realizingIndex = index; var e = GetOrCreateElement(items, index); _realizingElement = e; + e.Measure(availableSize); - + var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; @@ -691,7 +745,10 @@ namespace Avalonia.Controls _realizingIndex = -1; _realizingElement = null; } while (u < viewport.viewportUEnd && index < items.Count); - + + // Check if we reached the end of the collection + _hasReachedEnd = index >= items.Count; + // Store the last index and end U position for the desired size calculation. viewport.lastIndex = index - 1; viewport.realizedEndU = u; @@ -706,8 +763,8 @@ namespace Avalonia.Controls while (u > viewport.viewportUStart && index >= 0) { var e = GetOrCreateElement(items, index); + e.Measure(availableSize); - var sizeU = horizontal ? e.DesiredSize.Width : e.DesiredSize.Height; var sizeV = horizontal ? e.DesiredSize.Height : e.DesiredSize.Width; u -= sizeU; @@ -716,6 +773,9 @@ namespace Avalonia.Controls viewport.measuredV = Math.Max(viewport.measuredV, sizeV); --index; } + + // Check if we reached the start of the collection + _hasReachedStart = index < 0; // We can now recycle elements before the first element. _realizedElements.RecycleElementsBefore(index + 1, _recycleElement); @@ -748,7 +808,7 @@ namespace Avalonia.Controls { return _realizedElements?.GetElement(index); } - + private static Control? GetRealizedElement( int index, ref int specialIndex, @@ -891,22 +951,146 @@ namespace Avalonia.Controls ItemContainerGenerator.ItemContainerIndexChanged(element, oldIndex, newIndex); } - + private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e) { var vertical = Orientation == Orientation.Vertical; var oldViewportStart = vertical ? _viewport.Top : _viewport.Left; var oldViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var oldExtendedViewportStart = vertical ? _extendedViewport.Top : _extendedViewport.Left; + var oldExtendedViewportEnd = vertical ? _extendedViewport.Bottom : _extendedViewport.Right; + // Update current viewport _viewport = e.EffectiveViewport.Intersect(new(Bounds.Size)); _isWaitingForViewportUpdate = false; + // Calculate buffer sizes based on viewport dimensions + var viewportSize = vertical ? _viewport.Height : _viewport.Width; + var bufferSize = viewportSize * _bufferFactor; + + // Calculate extended viewport with relative buffers + var extendedViewportStart = vertical ? + Math.Max(0, _viewport.Top - bufferSize) : + Math.Max(0, _viewport.Left - bufferSize); + + var extendedViewportEnd = vertical ? + Math.Min(Bounds.Height, _viewport.Bottom + bufferSize) : + Math.Min(Bounds.Width, _viewport.Right + bufferSize); + + // special case: + // If we are at the start of the list, append 2 * CacheLength additional items + // If we are at the end of the list, prepend 2 * CacheLength additional items + // - this way we always maintain "2 * CacheLength * element" items. + if (vertical) + { + var spaceAbove = _viewport.Top - bufferSize; + var spaceBelow = Bounds.Height - (_viewport.Bottom + bufferSize); + + if (spaceAbove < 0 && spaceBelow >= 0) + extendedViewportEnd = Math.Min(Bounds.Height, extendedViewportEnd + Math.Abs(spaceAbove)); + if (spaceAbove >= 0 && spaceBelow < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceBelow)); + } + else + { + var spaceLeft = _viewport.Left - bufferSize; + var spaceRight = Bounds.Width - (_viewport.Right + bufferSize); + + if (spaceLeft < 0 && spaceRight >= 0) + extendedViewportEnd = Math.Min(Bounds.Width, extendedViewportEnd + Math.Abs(spaceLeft)); + if(spaceLeft >= 0 && spaceRight < 0) + extendedViewportStart = Math.Max(0, extendedViewportStart - Math.Abs(spaceRight)); + } + + Rect extendedViewPort; + if (vertical) + { + extendedViewPort = new Rect( + _viewport.X, + extendedViewportStart, + _viewport.Width, + extendedViewportEnd - extendedViewportStart); + } + else + { + extendedViewPort = new Rect( + extendedViewportStart, + _viewport.Y, + extendedViewportEnd - extendedViewportStart, + _viewport.Height); + } + + // Determine if we need a new measure var newViewportStart = vertical ? _viewport.Top : _viewport.Left; var newViewportEnd = vertical ? _viewport.Bottom : _viewport.Right; + var newExtendedViewportStart = vertical ? extendedViewPort.Top : extendedViewPort.Left; + var newExtendedViewportEnd = vertical ? extendedViewPort.Bottom : extendedViewPort.Right; + var needsMeasure = false; + + + // Case 1: Viewport has changed significantly if (!MathUtilities.AreClose(oldViewportStart, newViewportStart) || !MathUtilities.AreClose(oldViewportEnd, newViewportEnd)) { + // Case 1a: The new viewport exceeds the old extended viewport + if (newViewportStart < oldExtendedViewportStart || + newViewportEnd > oldExtendedViewportEnd) + { + needsMeasure = true; + } + // Case 1b: The extended viewport has changed significantly + else if (!MathUtilities.AreClose(oldExtendedViewportStart, newExtendedViewportStart) || + !MathUtilities.AreClose(oldExtendedViewportEnd, newExtendedViewportEnd)) + { + // Check if we're about to scroll into an area where we don't have realized elements + // This would be the case if we're near the edge of our current extended viewport + var nearingEdge = false; + + if (_realizedElements != null) + { + var firstRealizedElementU = _realizedElements.StartU; + var lastRealizedElementU = _realizedElements.StartU; + + for (var i = 0; i < _realizedElements.Count; i++) + { + lastRealizedElementU += _realizedElements.SizeU[i]; + } + + // If scrolling up/left and nearing the top/left edge of realized elements + if (newViewportStart < oldViewportStart && + newViewportStart - newExtendedViewportStart < bufferSize) + { + // Edge case: We're at item 0 with excess measurement space. + // Skip re-measuring since we're at the list start and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedStart; + } + + // If scrolling down/right and nearing the bottom/right edge of realized elements + if (newViewportEnd > oldViewportEnd && + newExtendedViewportEnd - newViewportEnd < bufferSize) + { + // Edge case: We're at the last item with excess measurement space. + // Skip re-measuring since we're at the list end and it won't change the result. + // This prevents redundant Measure-Arrange cycles when at list beginning. + nearingEdge = !_hasReachedEnd; + } + } + else + { + nearingEdge = true; + } + + needsMeasure = nearingEdge; + } + } + + if (needsMeasure) + { + // only store the new "old" extended viewport if we _did_ actually measure + _extendedViewport = extendedViewPort; + InvalidateMeasure(); } } @@ -924,6 +1108,15 @@ namespace Avalonia.Controls } } + private void OnCacheLengthChanged(AvaloniaPropertyChangedEventArgs e) + { + var newValue = e.GetNewValue(); + _bufferFactor = newValue; + + // Force a recalculation of the extended viewport on the next layout pass + InvalidateMeasure(); + } + /// public IReadOnlyList GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment snapPointsAlignment) { diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 6fb203b035..ee9671edda 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -181,9 +181,24 @@ namespace Avalonia.Controls public static readonly StyledProperty WindowStartupLocationProperty = AvaloniaProperty.Register(nameof(WindowStartupLocation)); + /// + /// Defines the property. + /// public static readonly StyledProperty CanResizeProperty = AvaloniaProperty.Register(nameof(CanResize), true); + /// + /// Defines the property. + /// + public static readonly StyledProperty CanMinimizeProperty = + AvaloniaProperty.Register(nameof(CanMinimize), true); + + /// + /// Defines the property. + /// + public static readonly StyledProperty CanMaximizeProperty = + AvaloniaProperty.Register(nameof(CanMaximize), true, coerce: CoerceCanMaximize); + /// /// Routed event that can be used for global tracking of window destruction /// @@ -236,6 +251,8 @@ namespace Avalonia.Controls CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title)); CreatePlatformImplBinding(IconProperty, icon => PlatformImpl!.SetIcon((icon ?? s_defaultIcon.Value)?.PlatformImpl)); CreatePlatformImplBinding(CanResizeProperty, canResize => PlatformImpl!.CanResize(canResize)); + CreatePlatformImplBinding(CanMinimizeProperty, canMinimize => PlatformImpl!.SetCanMinimize(canMinimize)); + CreatePlatformImplBinding(CanMaximizeProperty, canMaximize => PlatformImpl!.SetCanMaximize(canMaximize)); CreatePlatformImplBinding(ShowInTaskbarProperty, show => PlatformImpl!.ShowTaskbarIcon(show)); CreatePlatformImplBinding(WindowStateProperty, state => PlatformImpl!.WindowState = state); @@ -407,6 +424,32 @@ namespace Avalonia.Controls set => SetValue(CanResizeProperty, value); } + /// + /// Enables or disables minimizing the window. + /// + /// + /// This property might be ignored by some window managers on Linux. + /// + public bool CanMinimize + { + get => GetValue(CanMinimizeProperty); + set => SetValue(CanMinimizeProperty, value); + } + + /// + /// Enables or disables maximizing the window. + /// + /// + /// When is false, this property is always false. + /// On macOS, setting this property to false also disables the full screen mode. + /// This property might be ignored by some window managers on Linux. + /// + public bool CanMaximize + { + get => GetValue(CanMaximizeProperty); + set => SetValue(CanMaximizeProperty, value); + } + /// /// Gets or sets the icon of the window. /// @@ -438,6 +481,11 @@ namespace Avalonia.Controls } } + /// + /// Gets whether this window was opened as a dialog + /// + public bool IsDialog => _showingAsDialog; + /// /// Starts moving a window with left button being held. Should be called from left mouse button press event handler /// @@ -718,12 +766,12 @@ namespace Avalonia.Controls return null; } + _showingAsDialog = modal; RaiseEvent(new RoutedEventArgs(WindowOpenedEvent)); EnsureInitialized(); ApplyStyling(); _shown = true; - _showingAsDialog = modal; IsVisible = true; // If window position was not set before then platform may provide incorrect scaling at this time, @@ -941,7 +989,7 @@ namespace Avalonia.Controls { return; } - + var location = GetEffectiveWindowStartupLocation(owner); switch (location) @@ -974,7 +1022,7 @@ namespace Avalonia.Controls return startupLocation; } - + private void SetWindowStartupLocation(Window? owner = null) { if (_wasShownBefore) @@ -1009,7 +1057,7 @@ namespace Avalonia.Controls screen ??= Screens.ScreenFromPoint(Position); screen ??= Screens.Primary; - + if (screen is not null) { var childRect = screen.WorkingArea.CenterRect(rect); @@ -1029,7 +1077,7 @@ namespace Avalonia.Controls var childRect = ownerRect.CenterRect(rect); var screen = Screens.ScreenFromWindow(owner); - + childRect = ApplyScreenConstraint(screen, childRect); Position = childRect.Position; @@ -1037,7 +1085,7 @@ namespace Avalonia.Controls if (!_positionWasSet && DesktopScaling != PlatformImpl?.DesktopScaling) // Platform returns incorrect scaling, forcing setting position may fix it PlatformImpl?.Move(Position); - + PixelRect ApplyScreenConstraint(Screen? screen, PixelRect childRect) { if (screen?.WorkingArea is { } constraint) @@ -1184,7 +1232,7 @@ namespace Avalonia.Controls PlatformImpl?.SetSystemDecorations(typedNewValue); } - if (change.Property == OwnerProperty) + else if (change.Property == OwnerProperty) { var oldParent = change.OldValue as Window; var newParent = change.NewValue as Window; @@ -1197,6 +1245,11 @@ namespace Avalonia.Controls impl.SetParent(_showingAsDialog ? newParent?.PlatformImpl! : (newParent?.PlatformImpl ?? null)); } } + + else if (change.Property == CanResizeProperty) + { + CoerceValue(CanMaximizeProperty); + } } protected override AutomationPeer OnCreateAutomationPeer() @@ -1217,5 +1270,8 @@ namespace Avalonia.Controls } return null; } + + private static bool CoerceCanMaximize(AvaloniaObject target, bool value) + => value && target is not Window { CanResize: false }; } } diff --git a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs index 84c1d7db19..61924f53ab 100644 --- a/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs +++ b/src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs @@ -133,6 +133,14 @@ namespace Avalonia.DesignerSupport.Remote { } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public void SetTopmost(bool value) { } diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index a68517a7ab..675b831dcb 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/src/Avalonia.DesignerSupport/Remote/Stubs.cs @@ -152,6 +152,14 @@ namespace Avalonia.DesignerSupport.Remote { } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public void SetTopmost(bool value) { } diff --git a/src/Avalonia.Native/WindowImpl.cs b/src/Avalonia.Native/WindowImpl.cs index a278bda67e..08aaae669f 100644 --- a/src/Avalonia.Native/WindowImpl.cs +++ b/src/Avalonia.Native/WindowImpl.cs @@ -18,6 +18,7 @@ namespace Avalonia.Native private DoubleClickHelper _doubleClickHelper; private readonly ITopLevelNativeMenuExporter _nativeMenuExporter; private bool _canResize = true; + private bool _canMaximize = true; internal WindowImpl(IAvaloniaNativeFactory factory, AvaloniaNativePlatformOptions opts) : base(factory) { @@ -77,6 +78,17 @@ namespace Avalonia.Native _native.SetCanResize(value.AsComBool()); } + public void SetCanMinimize(bool value) + { + _native.SetCanMinimize(value.AsComBool()); + } + + public void SetCanMaximize(bool value) + { + _canMaximize = value; + _native.SetCanMaximize(value.AsComBool()); + } + public void SetSystemDecorations(Controls.SystemDecorations enabled) { _native.SetDecorations((Interop.SystemDecorations)enabled); @@ -138,10 +150,17 @@ namespace Avalonia.Native { if (_doubleClickHelper.IsDoubleClick(e.Timestamp, e.Position)) { - if (_canResize) + switch (WindowState) { - WindowState = WindowState is WindowState.Maximized or WindowState.FullScreen ? - WindowState.Normal : WindowState.Maximized; + case WindowState.Maximized or WindowState.FullScreen + when _canResize: + WindowState = WindowState.Normal; + break; + + case WindowState.Normal + when _canMaximize: + WindowState = WindowState.Maximized; + break; } } else diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index bc2d6ddd1a..97afb8667e 100644 --- a/src/Avalonia.Native/avn.idl +++ b/src/Avalonia.Native/avn.idl @@ -772,6 +772,8 @@ interface IAvnWindow : IAvnWindowBase { HRESULT SetEnabled(bool enable); HRESULT SetCanResize(bool value); + HRESULT SetCanMinimize(bool value); + HRESULT SetCanMaximize(bool value); HRESULT SetDecorations(SystemDecorations value); HRESULT SetTitle(char* utf8Title); HRESULT SetTitleBarColor(AvnColor color); diff --git a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml index d1b1acf2a2..b11ea00898 100644 --- a/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml +++ b/src/Avalonia.Themes.Fluent/Accents/FluentControlResources.xaml @@ -102,6 +102,15 @@ + + 4 + 16 + 0,4,0,12 + 1 + + + + #FF681DA8 diff --git a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml index 3d9db1de3b..3196d262a1 100644 --- a/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/AutoCompleteBox.xaml @@ -42,6 +42,7 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" CaretIndex="{TemplateBinding CaretIndex, Mode=TwoWay}" + ClearSelectionOnLostFocus="{TemplateBinding ClearSelectionOnLostFocus}" Padding="{TemplateBinding Padding}" Margin="0" DataValidationErrors.Errors="{TemplateBinding (DataValidationErrors.Errors)}" diff --git a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml index bcaeac8012..d9f9343925 100644 --- a/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml @@ -112,7 +112,7 @@ - diff --git a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml index 116529d095..255cf29133 100644 --- a/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/ComboBox.xaml @@ -16,6 +16,7 @@ Item 1 Item 2 + @@ -25,6 +26,25 @@ + + + Item A + Item b + Item c + + + + + + + + + + + Item A + Item b + Item c + @@ -80,17 +100,42 @@ VerticalAlignment="{TemplateBinding VerticalContentAlignment}" Margin="{TemplateBinding Padding}" Text="{TemplateBinding PlaceholderText}" - Foreground="{TemplateBinding PlaceholderForeground}" - IsVisible="{TemplateBinding SelectionBoxItem, Converter={x:Static ObjectConverters.IsNull}}" /> + Foreground="{TemplateBinding PlaceholderForeground}"> + + + + + + + + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}"> + + + Transparent + Transparent + 0 + + + + + diff --git a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml index cd0610944b..1fee84269d 100644 --- a/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/GroupBox.xaml @@ -33,16 +33,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml b/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml index 23b756cdc9..10b48a12f6 100644 --- a/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/AutoCompleteBox.xaml @@ -17,6 +17,7 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" CaretIndex="{TemplateBinding CaretIndex, Mode=TwoWay}" + ClearSelectionOnLostFocus="{TemplateBinding ClearSelectionOnLostFocus}" DataValidationErrors.Errors="{TemplateBinding (DataValidationErrors.Errors)}" Watermark="{TemplateBinding Watermark}" MaxLength="{TemplateBinding MaxLength}" diff --git a/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml b/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml index cbea6bf79f..54e75db0a2 100644 --- a/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/ComboBox.xaml @@ -27,15 +27,37 @@ HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}" Foreground="{TemplateBinding PlaceholderForeground}" - IsVisible="{TemplateBinding SelectionBoxItem, - Converter={x:Static ObjectConverters.IsNull}}" - Text="{TemplateBinding PlaceholderText}" /> + Text="{TemplateBinding PlaceholderText}"> + + + + + + + + ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}" + IsVisible="{TemplateBinding IsEditable, Converter={x:Static BoolConverters.Not}}"> + + + Transparent + Transparent + 0 + + + + + + diff --git a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml index 2285128c12..6267e3731c 100644 --- a/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml +++ b/src/Avalonia.Themes.Simple/Controls/GroupBox.xaml @@ -31,16 +31,19 @@ CornerRadius="{TemplateBinding CornerRadius}" BorderThickness="{TemplateBinding BorderThickness}" BorderBrush="{TemplateBinding BorderBrush}"> - - - - - - - - - - + + + + + + + + + diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 7a95d6bfa0..f18dfe601b 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -316,22 +316,28 @@ namespace Avalonia.X11 || _systemDecorations == SystemDecorations.None) decorations = 0; - if (!_canResize || !IsEnabled) + var isDisabled = !IsEnabled; + + if (!_canResize || isDisabled) { - functions &= ~(MotifFunctions.Resize | MotifFunctions.Maximize); - decorations &= ~(MotifDecorations.Maximize | MotifDecorations.ResizeH); + functions &= ~MotifFunctions.Resize; + decorations &= ~MotifDecorations.ResizeH; } - if (!IsEnabled) - { - functions &= ~(MotifFunctions.Resize | MotifFunctions.Minimize); - UpdateSizeHints(null, true); + if (!_canMinimize || isDisabled) + { + functions &= ~MotifFunctions.Minimize; + decorations &= ~MotifDecorations.Minimize; } - else + + if (!_canMaximize || isDisabled) { - UpdateSizeHints(null); + functions &= ~MotifFunctions.Maximize; + decorations &= ~MotifDecorations.Maximize; } + UpdateSizeHints(null, isDisabled); + var hints = new MotifWmHints { flags = new IntPtr((int)(MotifFlags.Decorations | MotifFlags.Functions)), @@ -857,6 +863,8 @@ namespace Avalonia.X11 private SystemDecorations _systemDecorations = SystemDecorations.Full; private bool _canResize = true; + private bool _canMinimize = true; + private bool _canMaximize = true; private const int MaxWindowDimension = 100000; private (Size minSize, Size maxSize) _scaledMinMaxSize = @@ -1162,6 +1170,18 @@ namespace Avalonia.X11 UpdateSizeHints(null); } + public void SetCanMinimize(bool value) + { + _canMinimize = value; + UpdateMotifHints(); + } + + public void SetCanMaximize(bool value) + { + _canMaximize = value; + UpdateMotifHints(); + } + public void SetCursor(ICursorImpl? cursor) { if (cursor == null) @@ -1245,10 +1265,10 @@ namespace Avalonia.X11 ptr1 = l0, ptr2 = l1 ?? IntPtr.Zero, ptr3 = l2 ?? IntPtr.Zero, - ptr4 = l3 ?? IntPtr.Zero + ptr4 = l3 ?? IntPtr.Zero, + ptr5 = l4 ?? IntPtr.Zero } }; - xev.ClientMessageEvent.ptr4 = l4 ?? IntPtr.Zero; XSendEvent(_x11.Display, _x11.RootWindow, false, new IntPtr((int)(EventMask.SubstructureRedirectMask | EventMask.SubstructureNotifyMask)), ref xev); diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index ebc6d1edc6..279700df4a 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -165,6 +165,14 @@ namespace Avalonia.Headless } + public void SetCanMinimize(bool value) + { + } + + public void SetCanMaximize(bool value) + { + } + public Func? Closing { get; set; } private class FramebufferProxy : ILockedFramebuffer diff --git a/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs b/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs index 509be65784..a039d568c6 100644 --- a/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs +++ b/src/Windows/Avalonia.Win32/EmbeddedWindowImpl.cs @@ -12,6 +12,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = false, + IsMinimizable = false, + IsMaximizable = false, Decorations = SystemDecorations.None }; } diff --git a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs index ed5c7ba85c..1177334680 100644 --- a/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs +++ b/src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs @@ -1502,6 +1502,21 @@ namespace Avalonia.Win32.Interop [DllImport("shell32", CharSet = CharSet.Auto)] public static extern int Shell_NotifyIcon(NIM dwMessage, NOTIFYICONDATA lpData); + [DllImport("user32.dll", SetLastError = true)] + public static extern bool ChangeWindowMessageFilterEx( + IntPtr hWnd, + uint message, + MessageFilterFlag action, + IntPtr pChangeFilterStruct + ); + + public enum MessageFilterFlag + { + MSGFLT_RESET = 0, + MSGFLT_ALLOW = 1, + MSGFLT_DISALLOW = 2, + } + [DllImport("shell32", CharSet = CharSet.Auto)] public static extern nint SHAppBarMessage(AppBarMessage dwMessage, ref APPBARDATA lpData); diff --git a/src/Windows/Avalonia.Win32/PopupImpl.cs b/src/Windows/Avalonia.Win32/PopupImpl.cs index 95c8178adc..8a8450443f 100644 --- a/src/Windows/Avalonia.Win32/PopupImpl.cs +++ b/src/Windows/Avalonia.Win32/PopupImpl.cs @@ -111,6 +111,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = false, + IsMinimizable = false, + IsMaximizable = false, Decorations = SystemDecorations.None, }; diff --git a/src/Windows/Avalonia.Win32/TrayIconImpl.cs b/src/Windows/Avalonia.Win32/TrayIconImpl.cs index 0199e1bc74..fdb6327a85 100644 --- a/src/Windows/Avalonia.Win32/TrayIconImpl.cs +++ b/src/Windows/Avalonia.Win32/TrayIconImpl.cs @@ -37,7 +37,12 @@ namespace Avalonia.Win32 new PixelSize(32, 32), new Vector(96, 96), PixelFormats.Bgra8888, AlphaFormat.Unpremul); s_emptyIcon = new Win32Icon(bitmap); } - + + internal static void ChangeWindowMessageFilter(IntPtr hWnd) + { + ChangeWindowMessageFilterEx(hWnd, WM_TASKBARCREATED, MessageFilterFlag.MSGFLT_ALLOW, IntPtr.Zero); + } + public TrayIconImpl() { FindTaskBarMonitor(); diff --git a/src/Windows/Avalonia.Win32/Win32Platform.cs b/src/Windows/Avalonia.Win32/Win32Platform.cs index 79a03bbe23..1cf30137d2 100644 --- a/src/Windows/Avalonia.Win32/Win32Platform.cs +++ b/src/Windows/Avalonia.Win32/Win32Platform.cs @@ -218,6 +218,8 @@ namespace Avalonia.Win32 { throw new Win32Exception(); } + + TrayIconImpl.ChangeWindowMessageFilter(_hwnd); } public ITrayIconImpl CreateTrayIcon() diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 3898f3b624..1b52ce4cbf 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -136,6 +136,8 @@ namespace Avalonia.Win32 { ShowInTaskbar = false, IsResizable = true, + IsMinimizable = true, + IsMaximizable = true, Decorations = SystemDecorations.Full }; @@ -858,6 +860,24 @@ namespace Avalonia.Win32 UpdateWindowProperties(newWindowProperties); } + public void SetCanMinimize(bool value) + { + var newWindowProperties = _windowProperties; + + newWindowProperties.IsMinimizable = value; + + UpdateWindowProperties(newWindowProperties); + } + + public void SetCanMaximize(bool value) + { + var newWindowProperties = _windowProperties; + + newWindowProperties.IsMaximizable = value; + + UpdateWindowProperties(newWindowProperties); + } + public void SetSystemDecorations(SystemDecorations value) { var newWindowProperties = _windowProperties; @@ -1425,15 +1445,19 @@ namespace Avalonia.Win32 style |= WindowStyles.WS_VISIBLE; if (newProperties.IsResizable || newProperties.WindowState == WindowState.Maximized) - { style |= WindowStyles.WS_THICKFRAME; - style |= WindowStyles.WS_MAXIMIZEBOX; - } else - { style &= ~WindowStyles.WS_THICKFRAME; + + if (newProperties.IsMinimizable) + style |= WindowStyles.WS_MINIMIZEBOX; + else + style &= ~WindowStyles.WS_MINIMIZEBOX; + + if (newProperties.IsMaximizable || (newProperties.WindowState == WindowState.Maximized && newProperties.IsResizable)) + style |= WindowStyles.WS_MAXIMIZEBOX; + else style &= ~WindowStyles.WS_MAXIMIZEBOX; - } const WindowStyles fullDecorationFlags = WindowStyles.WS_CAPTION | WindowStyles.WS_SYSMENU | WindowStyles.WS_BORDER; @@ -1656,6 +1680,8 @@ namespace Avalonia.Win32 { public bool ShowInTaskbar; public bool IsResizable; + public bool IsMinimizable; + public bool IsMaximizable; public SystemDecorations Decorations; public bool IsFullScreen; public WindowState WindowState; diff --git a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj index 071c6c5b9a..f284d2d0a2 100644 --- a/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj +++ b/src/iOS/Avalonia.iOS/Avalonia.iOS.csproj @@ -1,9 +1,8 @@  - $(AvsCurrentIOSTargetFramework);$(AvsCurrentTvOSTargetFramework) + $(AvsCurrentMacCatalystTargetFramework);$(AvsCurrentIOSTargetFramework);$(AvsCurrentTvOSTargetFramework) $(AvsMinSupportedIOSVersion) $(AvsMinSupportedTvOSVersion) - $(AvsMinSupportedMacCatalystVersion) true diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs index 4c1bf97c6f..79d88c13b0 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -26,7 +26,7 @@ internal class IOSStorageProvider : IStorageProvider public bool CanOpen => true; - public bool CanSave => false; + public bool CanSave => true; public bool CanPickFolder => true; @@ -161,10 +161,72 @@ internal class IOSStorageProvider : IStorageProvider return Task.FromResult(new IOSStorageFolder(uri, wellKnownFolder)); } - public Task SaveFilePickerAsync(FilePickerSaveOptions options) + public async Task SaveFilePickerAsync(FilePickerSaveOptions options) { - return Task.FromException( - new PlatformNotSupportedException("Save file picker is not supported by iOS")); + /* + This requires a bit of dialog here... + To save a file, we need to present the user with a document picker + This requires a temp file to be created and used to "export" the file to. + When the user picks the file location and name, UIDocumentPickerViewController + will give back the URI to the real file location, which we can then use + to give back as an IStorageFile. + https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller + Yes, it is weird, but without the temp file it will explode. + */ + + // Create a temporary file to use with the document picker + var tempFileName = StorageProviderHelpers.NameWithExtension( + options.SuggestedFileName ?? "document", + options.DefaultExtension, + options.FileTypeChoices?.FirstOrDefault()); + + var tempDir = NSFileManager.DefaultManager.GetTemporaryDirectory().Append(Guid.NewGuid().ToString(), true); + if (tempDir == null) + { + throw new InvalidOperationException("Failed to get temporary directory for save file picker"); + } + + var isDirectoryCreated = NSFileManager.DefaultManager.CreateDirectory(tempDir, true, null, out var error); + if (!isDirectoryCreated) + { + throw new InvalidOperationException("Failed to create temporary directory for save file picker"); + } + + var tempFileUrl = tempDir.Append(tempFileName, false); + + // Create an empty file at the temp location + NSData.FromBytes(0, 0).Save(tempFileUrl, false); + + UIDocumentPickerViewController documentPicker; + if (OperatingSystem.IsIOSVersionAtLeast(14)) + { + documentPicker = new UIDocumentPickerViewController(new[] { tempFileUrl }, asCopy: true); + } + else + { +#pragma warning disable CA1422 + documentPicker = new UIDocumentPickerViewController(tempFileUrl, UIDocumentPickerMode.ExportToService); +#pragma warning restore CA1422 + } + + using (documentPicker) + { + if (OperatingSystem.IsIOSVersionAtLeast(13)) + { + documentPicker.DirectoryUrl = GetUrlFromFolder(options.SuggestedStartLocation); + } + + documentPicker.Title = options.Title; + + var tcs = new TaskCompletionSource(); + documentPicker.Delegate = new PickerDelegate(urls => tcs.TrySetResult(urls)); + var urls = await ShowPicker(documentPicker, tcs); + + // Clean up the temporary directory + NSFileManager.DefaultManager.Remove(tempDir, out _); + + return urls.FirstOrDefault() is { } url ? new IOSStorageFile(url) : null; + } } public async Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) diff --git a/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs b/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs index 133f20cb34..6c4dd1074f 100644 --- a/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs +++ b/src/iOS/Avalonia.iOS/TextInputResponder.Properties.cs @@ -81,7 +81,7 @@ partial class AvaloniaView [Export("smartInsertDeleteType")] public UITextSmartInsertDeleteType SmartInsertDeleteType { get; set; } = UITextSmartInsertDeleteType.Default; - [Export("passwordRules")] public UITextInputPasswordRules PasswordRules { get; set; } = null!; + [Export("passwordRules")] public UITextInputPasswordRules? PasswordRules { get; set; } = null!; public NSObject? WeakInputDelegate { diff --git a/src/tools/Avalonia.Analyzers/Avalonia.Analyzers.csproj b/src/tools/Avalonia.Analyzers/Avalonia.Analyzers.csproj index 23f1062e68..2e21089c8c 100644 --- a/src/tools/Avalonia.Analyzers/Avalonia.Analyzers.csproj +++ b/src/tools/Avalonia.Analyzers/Avalonia.Analyzers.csproj @@ -2,23 +2,16 @@ netstandard2.0 false - true embedded true false - true - - - - - - - - + + + diff --git a/src/tools/Avalonia.Generators/Avalonia.Generators.csproj b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj index 7945839563..8b8ac0db39 100644 --- a/src/tools/Avalonia.Generators/Avalonia.Generators.csproj +++ b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj @@ -6,17 +6,10 @@ embedded true false - true - true enable ../../../external/XamlX/src/XamlX - - - - - + diff --git a/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs b/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs index 4b426172f8..12dcbaf857 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/ICodeGenerator.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; internal interface ICodeGenerator { - string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names); + string GenerateCode(string className, string nameSpace, IEnumerable names); } diff --git a/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs b/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs index 04dbf9cbb9..09279d6986 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/IGlobPattern.cs @@ -1,6 +1,8 @@ +using System; + namespace Avalonia.Generators.Common.Domain; -internal interface IGlobPattern +internal interface IGlobPattern : IEquatable { bool Matches(string str); } diff --git a/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs b/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs index cb5488d8a3..5943d73fa7 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/INameResolver.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; using XamlX.Ast; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; @@ -13,7 +15,11 @@ internal enum NamedFieldModifier internal interface INameResolver { - IReadOnlyList ResolveNames(XamlDocument xaml); + EquatableList ResolveXmlNames(XamlDocument xaml, CancellationToken cancellationToken); + ResolvedName ResolveName(IXamlType xamlType, string name, string? fieldModifier); } -internal record ResolvedName(string TypeName, string Name, string FieldModifier); +internal record XamlXmlType(string Name, string? XmlNamespace, EquatableList GenericArguments); + +internal record ResolvedXmlName(XamlXmlType XmlType, string Name, string? FieldModifier); +internal record ResolvedName(string TypeName, string Name, string? FieldModifier); diff --git a/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs b/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs index 49ceb6f69e..689aa25970 100644 --- a/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs +++ b/src/tools/Avalonia.Generators/Common/Domain/IViewResolver.cs @@ -1,11 +1,46 @@ +using System.Collections.Immutable; +using System.Threading; using XamlX.Ast; -using XamlX.TypeSystem; namespace Avalonia.Generators.Common.Domain; internal interface IViewResolver { - ResolvedView? ResolveView(string xaml); + ResolvedViewDocument? ResolveView(string xaml, CancellationToken cancellationToken); } -internal record ResolvedView(string ClassName, IXamlType XamlType, string Namespace, XamlDocument Xaml); +internal record ResolvedViewInfo(string ClassName, string Namespace) +{ + public string FullName => $"{Namespace}.{ClassName}"; + public override string ToString() => FullName; +} + +internal record ResolvedViewDocument(string ClassName, string Namespace, XamlDocument Xaml) + : ResolvedViewInfo(ClassName, Namespace); + +internal record ResolvedXmlView( + string ClassName, + string Namespace, + EquatableList XmlNames) + : ResolvedViewInfo(ClassName, Namespace) +{ + public ResolvedXmlView(ResolvedViewInfo info, EquatableList xmlNames) + : this(info.ClassName, info.Namespace, xmlNames) + { + + } +} + +internal record ResolvedView( + string ClassName, + string Namespace, + bool IsWindow, + EquatableList Names) + : ResolvedViewInfo(ClassName, Namespace) +{ + public ResolvedView(ResolvedViewInfo info, bool isWindow, EquatableList names) + : this(info.ClassName, info.Namespace, isWindow, names) + { + + } +} diff --git a/src/tools/Avalonia.Generators/Common/EquatableList.cs b/src/tools/Avalonia.Generators/Common/EquatableList.cs new file mode 100644 index 0000000000..2b4c8a184d --- /dev/null +++ b/src/tools/Avalonia.Generators/Common/EquatableList.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Avalonia.Generators.Common; + +// https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md#pipeline-model-design +// With minor modification to use ReadOnlyCollection instead of List +internal class EquatableList(IList collection) + : ReadOnlyCollection(collection), IEquatable> +{ + public bool Equals(EquatableList? other) + { + // If the other list is null or a different size, they're not equal + if (other is null || Count != other.Count) + { + return false; + } + + // Compare each pair of elements for equality + for (int i = 0; i < Count; i++) + { + if (!EqualityComparer.Default.Equals(this[i], other[i])) + { + return false; + } + } + + // If we got this far, the lists are equal + return true; + } + + public override bool Equals(object? obj) + { + return Equals(obj as EquatableList); + } + + public override int GetHashCode() + { + var hash = 0; + for (var i = 0; i < Count; i++) + { + hash ^= this[i]?.GetHashCode() ?? 0; + } + return hash; + } + + public static bool operator ==(EquatableList? list1, EquatableList? list2) + { + return ReferenceEquals(list1, list2) + || list1 is not null && list2 is not null && list1.Equals(list2); + } + + public static bool operator !=(EquatableList? list1, EquatableList? list2) + { + return !(list1 == list2); + } +} diff --git a/src/tools/Avalonia.Generators/Common/GlobPattern.cs b/src/tools/Avalonia.Generators/Common/GlobPattern.cs index 484e17d787..b76f4b2566 100644 --- a/src/tools/Avalonia.Generators/Common/GlobPattern.cs +++ b/src/tools/Avalonia.Generators/Common/GlobPattern.cs @@ -7,12 +7,19 @@ internal class GlobPattern : IGlobPattern { private const RegexOptions GlobOptions = RegexOptions.IgnoreCase | RegexOptions.Singleline; private readonly Regex _regex; + private readonly string _pattern; public GlobPattern(string pattern) { + _pattern = pattern; var expression = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$"; _regex = new Regex(expression, GlobOptions); } public bool Matches(string str) => _regex.IsMatch(str); + + public bool Equals(IGlobPattern other) => other is GlobPattern pattern && pattern._pattern == _pattern; + public override int GetHashCode() => _pattern.GetHashCode(); + public override bool Equals(object? obj) => obj is GlobPattern pattern && Equals(pattern); + public override string ToString() => _pattern; } diff --git a/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs b/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs index 1358ee7920..f32f7c9a02 100644 --- a/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs +++ b/src/tools/Avalonia.Generators/Common/GlobPatternGroup.cs @@ -4,14 +4,20 @@ using Avalonia.Generators.Common.Domain; namespace Avalonia.Generators.Common; -internal class GlobPatternGroup : IGlobPattern +internal class GlobPatternGroup(IEnumerable patterns) + : EquatableList(patterns.Select(p => new GlobPattern(p)).ToArray()), IGlobPattern { - private readonly GlobPattern[] _patterns; + public bool Matches(string str) + { + for (var i = 0; i < Count; i++) + { + if (this[i].Matches(str)) + return true; + } + return false; + } - public GlobPatternGroup(IEnumerable patterns) => - _patterns = patterns - .Select(pattern => new GlobPattern(pattern)) - .ToArray(); - - public bool Matches(string str) => _patterns.Any(pattern => pattern.Matches(str)); + public bool Equals(IGlobPattern other) => other is GlobPatternGroup group && base.Equals(group); + public override string ToString() => $"[{string.Join(", ", this.Select(p => p.ToString()))}]"; } + diff --git a/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs b/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs index 04352298c8..092eee6e2e 100644 --- a/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs +++ b/src/tools/Avalonia.Generators/Common/ResolverExtensions.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using XamlX.TypeSystem; namespace Avalonia.Generators.Common; @@ -6,20 +6,14 @@ namespace Avalonia.Generators.Common; internal static class ResolverExtensions { public static bool IsAvaloniaStyledElement(this IXamlType clrType) => - clrType.HasStyledElementBaseType() || - clrType.HasIStyledElementInterface(); + Inherits(clrType, "Avalonia.StyledElement"); + public static bool IsAvaloniaWindow(this IXamlType clrType) => + Inherits(clrType, "Avalonia.Controls.Window"); - private static bool HasStyledElementBaseType(this IXamlType clrType) + private static bool Inherits(IXamlType clrType, string metadataName) { - // Check for the base type since IStyledElement interface is removed. - // https://github.com/AvaloniaUI/Avalonia/pull/9553 - if (clrType.FullName == "Avalonia.StyledElement") + if (string.Equals(clrType.FullName, metadataName, StringComparison.Ordinal)) return true; - return clrType.BaseType != null && IsAvaloniaStyledElement(clrType.BaseType); + return clrType.BaseType is { } baseType && Inherits(baseType, metadataName); } - - private static bool HasIStyledElementInterface(this IXamlType clrType) => - clrType.Interfaces.Any(abstraction => - abstraction.IsInterface && - abstraction.FullName == "Avalonia.IStyledElement"); } diff --git a/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs b/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs index 955df90ddd..0081d76196 100644 --- a/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs +++ b/src/tools/Avalonia.Generators/Common/XamlXNameResolver.cs @@ -1,38 +1,56 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; using Avalonia.Generators.Common.Domain; using XamlX; using XamlX.Ast; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common; -internal class XamlXNameResolver : INameResolver, IXamlAstVisitor +internal class XamlXNameResolver + : INameResolver, IXamlAstVisitor { - private readonly List _items = new(); - private readonly string _defaultFieldModifier; + private readonly Dictionary _items = new(); + private CancellationToken _cancellationToken; - public XamlXNameResolver(NamedFieldModifier namedFieldModifier = NamedFieldModifier.Internal) + public EquatableList ResolveXmlNames(XamlDocument xaml, CancellationToken cancellationToken) { - _defaultFieldModifier = namedFieldModifier.ToString().ToLowerInvariant(); + _items.Clear(); + try + { + _cancellationToken = cancellationToken; + xaml.Root.Visit(this); + xaml.Root.VisitChildren(this); + } + finally + { + _cancellationToken = CancellationToken.None; + } + + return new EquatableList(_items.Values.ToArray()); } - public IReadOnlyList ResolveNames(XamlDocument xaml) + public ResolvedName ResolveName(IXamlType clrType, string name, string? fieldModifier) { - _items.Clear(); - xaml.Root.Visit(this); - xaml.Root.VisitChildren(this); - return _items; + var typeName = $"{clrType.Namespace}.{clrType.Name}"; + var typeAgs = clrType.GenericArguments.Select(arg => arg.FullName).ToImmutableList(); + var genericTypeName = typeAgs.Count == 0 + ? $"global::{typeName}" + : $"global::{typeName}<{string.Join(", ", typeAgs.Select(arg => $"global::{arg}"))}>"; + return new ResolvedName(genericTypeName, name, fieldModifier); } IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) { + _cancellationToken.ThrowIfCancellationRequested(); + if (node is not XamlAstObjectNode objectNode) return node; - var clrType = objectNode.Type.GetClrType(); - if (!clrType.IsAvaloniaStyledElement()) - return node; + var xamlType = (XamlAstXmlTypeReference)objectNode.Type; foreach (var child in objectNode.Children) { @@ -44,27 +62,24 @@ internal class XamlXNameResolver : INameResolver, IXamlAstVisitor propertyValueNode.Values[0] is XamlAstTextNode text) { var fieldModifier = TryGetFieldModifier(objectNode); - var typeName = $@"{clrType.Namespace}.{clrType.Name}"; - var typeAgs = clrType.GenericArguments.Select(arg => arg.FullName).ToImmutableList(); - var genericTypeName = typeAgs.Count == 0 - ? $"global::{typeName}" - : $@"global::{typeName}<{string.Join(", ", typeAgs.Select(arg => $"global::{arg}"))}>"; - - var resolvedName = new ResolvedName(genericTypeName, text.Text, fieldModifier); - if (_items.Contains(resolvedName)) + var resolvedName = new ResolvedXmlName(ConvertType(xamlType), text.Text, fieldModifier); + if (_items.ContainsKey(text.Text)) continue; - _items.Add(resolvedName); + _items.Add(text.Text, resolvedName); } } return node; + + static XamlXmlType ConvertType(XamlAstXmlTypeReference type) => new(type.Name, type.XmlNamespace, + new EquatableList(type.GenericArguments.Select(ConvertType).ToArray())); } void IXamlAstVisitor.Push(IXamlAstNode node) { } void IXamlAstVisitor.Pop() { } - private string TryGetFieldModifier(XamlAstObjectNode objectNode) + private string? TryGetFieldModifier(XamlAstObjectNode objectNode) { // We follow Xamarin.Forms API behavior in terms of x:FieldModifier here: // https://docs.microsoft.com/en-us/xamarin/xamarin-forms/xaml/field-modifiers @@ -87,7 +102,7 @@ internal class XamlXNameResolver : INameResolver, IXamlAstVisitor "protected" => "protected", "internal" => "internal", "notpublic" => "internal", - _ => _defaultFieldModifier + _ => null }; } diff --git a/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs b/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs index b0495b2840..35880dcc44 100644 --- a/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs +++ b/src/tools/Avalonia.Generators/Common/XamlXViewResolver.cs @@ -1,92 +1,61 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using Avalonia.Generators.Common.Domain; using Avalonia.Generators.Compiler; using XamlX; using XamlX.Ast; using XamlX.Parsers; +using XamlX.TypeSystem; namespace Avalonia.Generators.Common; -internal class XamlXViewResolver : IViewResolver, IXamlAstVisitor +internal class XamlXViewResolver(MiniCompiler compiler) : IViewResolver, IXamlAstVisitor { - private readonly RoslynTypeSystem _typeSystem; - private readonly MiniCompiler _compiler; - private readonly bool _checkTypeValidity; - private readonly Action? _onTypeInvalid; - private readonly Action? _onUnhandledError; - - private ResolvedView? _resolvedClass; + private ResolvedViewDocument? _resolvedClass; private XamlDocument? _xaml; + private CancellationToken _cancellationToken; - public XamlXViewResolver( - RoslynTypeSystem typeSystem, - MiniCompiler compiler, - bool checkTypeValidity = false, - Action? onTypeInvalid = null, - Action? onUnhandledError = null) + public ResolvedViewDocument? ResolveView(string xaml, CancellationToken cancellationToken) { - _checkTypeValidity = checkTypeValidity; - _onTypeInvalid = onTypeInvalid; - _onUnhandledError = onUnhandledError; - _typeSystem = typeSystem; - _compiler = compiler; - } + _resolvedClass = null; + _xaml = XDocumentXamlParser.Parse(xaml, new Dictionary + { + {XamlNamespaces.Blend2008, XamlNamespaces.Blend2008} + }); - public ResolvedView? ResolveView(string xaml) - { try { - _resolvedClass = null; - _xaml = XDocumentXamlParser.Parse(xaml, new Dictionary - { - {XamlNamespaces.Blend2008, XamlNamespaces.Blend2008} - }); - - _compiler.Transform(_xaml); + _cancellationToken = cancellationToken; + compiler.TransformWithCancellation(_xaml, cancellationToken); _xaml.Root.Visit(this); _xaml.Root.VisitChildren(this); - return _resolvedClass; } - catch (Exception exception) + finally { - _onUnhandledError?.Invoke(exception); - return null; + _cancellationToken = CancellationToken.None; } + return _resolvedClass; } - + IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) { - if (node is not XamlAstObjectNode objectNode) - return node; + _cancellationToken.ThrowIfCancellationRequested(); - var clrType = objectNode.Type.GetClrType(); - if (!clrType.IsAvaloniaStyledElement()) + if (node is not XamlAstObjectNode objectNode) return node; foreach (var child in objectNode.Children) { - if (child is XamlAstXmlDirective directive && - directive.Name == "Class" && - directive.Namespace == XamlNamespaces.Xaml2006 && - directive.Values[0] is XamlAstTextNode text) + if (child is XamlAstXmlDirective { Name: "Class", Namespace: XamlNamespaces.Xaml2006 } directive + && directive.Values[0] is XamlAstTextNode text) { - if (_checkTypeValidity) - { - var existingType = _typeSystem.FindType(text.Text); - if (existingType == null) - { - _onTypeInvalid?.Invoke(text.Text); - return node; - } - } - var split = text.Text.Split('.'); var nameSpace = string.Join(".", split.Take(split.Length - 1)); var className = split.Last(); - _resolvedClass = new ResolvedView(className, clrType, nameSpace, _xaml!); + _resolvedClass = new ResolvedViewDocument(className, nameSpace, _xaml!); return node; } } diff --git a/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs index b0421cd245..0c7805bb38 100644 --- a/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs +++ b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs @@ -1,6 +1,9 @@ using System; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; +using Avalonia.Generators.Common.Domain; +using XamlX.Ast; using XamlX.Compiler; using XamlX.Emit; using XamlX.Transform; @@ -14,7 +17,22 @@ internal sealed class MiniCompiler : XamlCompiler public const string AvaloniaXmlnsDefinitionAttribute = "Avalonia.Metadata.XmlnsDefinitionAttribute"; [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = TrimmingMessages.Roslyn)] - public static MiniCompiler CreateDefault(RoslynTypeSystem typeSystem, params string[] additionalTypes) + public static MiniCompiler CreateNoop() + { + var typeSystem = new NoopTypeSystem(); + var mappings = new XamlLanguageTypeMappings(typeSystem); + var diagnosticsHandler = new XamlDiagnosticsHandler(); + + var configuration = new TransformerConfiguration( + typeSystem, + typeSystem.Assemblies.First(), + mappings, + diagnosticsHandler: diagnosticsHandler); + return new MiniCompiler(configuration); + } + + [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = TrimmingMessages.Roslyn)] + public static MiniCompiler CreateRoslyn(RoslynTypeSystem typeSystem, params string[] additionalTypes) { var mappings = new XamlLanguageTypeMappings(typeSystem); foreach (var additionalType in additionalTypes) @@ -29,7 +47,7 @@ internal sealed class MiniCompiler : XamlCompiler diagnosticsHandler: diagnosticsHandler); return new MiniCompiler(configuration); } - + private MiniCompiler(TransformerConfiguration configuration) : base(configuration, new XamlLanguageEmitMappings(), false) { @@ -38,9 +56,42 @@ internal sealed class MiniCompiler : XamlCompiler Transformers.Add(new KnownDirectivesTransformer()); Transformers.Add(new XamlIntrinsicsTransformer()); Transformers.Add(new XArgumentsTransformer()); - Transformers.Add(new TypeReferenceResolver()); } + public IXamlTypeSystem TypeSystem => _configuration.TypeSystem; + + public void TransformWithCancellation(XamlDocument doc, CancellationToken cancellationToken) + { + var ctx = CreateTransformationContext(doc); + + var root = doc.Root; + ctx.RootObject = new XamlRootObjectNode((XamlAstObjectNode)root); + foreach (var transformer in Transformers) + { + cancellationToken.ThrowIfCancellationRequested(); + ctx.VisitChildren(ctx.RootObject, transformer); + root = ctx.Visit(root, transformer); + } + + foreach (var simplifier in SimplificationTransformers) + { + cancellationToken.ThrowIfCancellationRequested(); + root = ctx.Visit(root, simplifier); + } + + doc.Root = root; + } + + public IXamlType ResolveXamlType(XamlXmlType type) + { + var clrTypeRef = TypeReferenceResolver.ResolveType( + new AstTransformationContext(_configuration, null), ToTypeRef(type)); + return clrTypeRef.Type; + + static XamlAstXmlTypeReference ToTypeRef(XamlXmlType type) => new(EmptyLineInfo.Instance, + type.XmlNamespace, type.Name, type.GenericArguments.Select(ToTypeRef)); + } + protected override XamlEmitContext InitCodeGen( IFileSource file, IXamlTypeBuilder declaringType, @@ -48,4 +99,11 @@ internal sealed class MiniCompiler : XamlCompiler XamlRuntimeContext context, bool needContextLocal) => throw new NotSupportedException(); + + private class EmptyLineInfo : IXamlLineInfo + { + public static IXamlLineInfo Instance { get; } = new EmptyLineInfo(); + public int Line { get => 0; set { } } + public int Position { get => 0; set { } } + } } diff --git a/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs b/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs new file mode 100644 index 0000000000..b9bc801a52 --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/NoopTypeSystem.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Compiler; + +internal class NoopTypeSystem : IXamlTypeSystem +{ + public IEnumerable Assemblies => [NoopAssembly.Instance]; + public IXamlAssembly? FindAssembly(string substring) => null; + public IXamlType? FindType(string name) => XamlPseudoType.Unresolved(name); + public IXamlType? FindType(string name, string assembly) => XamlPseudoType.Unresolved(name); + + internal class NoopAssembly : IXamlAssembly + { + public static NoopAssembly Instance { get; } = new(); + public bool Equals(IXamlAssembly other) => ReferenceEquals(this, other); + public string Name { get; } = "Noop"; + public IReadOnlyList CustomAttributes { get; } = []; + public IXamlType? FindType(string fullName) => XamlPseudoType.Unresolved(fullName); + } +} + diff --git a/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs index 7e0ab123f4..04e0e594c4 100644 --- a/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs +++ b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -11,8 +12,9 @@ namespace Avalonia.Generators.Compiler; internal class RoslynTypeSystem : IXamlTypeSystem { private readonly List _assemblies = new(); + private readonly ConcurrentDictionary _typeCache = new(); - public RoslynTypeSystem(CSharpCompilation compilation) + public RoslynTypeSystem(Compilation compilation) { _assemblies.Add(new RoslynAssembly(compilation.Assembly)); @@ -34,9 +36,9 @@ internal class RoslynTypeSystem : IXamlTypeSystem [UnconditionalSuppressMessage("Trimming", "IL2092", Justification = TrimmingMessages.Roslyn)] public IXamlType? FindType(string name) => - _assemblies + _typeCache.GetOrAdd(name, _ => _assemblies .Select(assembly => assembly.FindType(name)) - .FirstOrDefault(type => type != null); + .FirstOrDefault(type => type != null)); [UnconditionalSuppressMessage("Trimming", "IL2092", Justification = TrimmingMessages.Roslyn)] public IXamlType? FindType(string name, string assembly) => diff --git a/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs b/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs deleted file mode 100644 index b1f7738a8a..0000000000 --- a/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using Microsoft.CodeAnalysis; - -namespace Avalonia.Generators; - -internal static class GeneratorContextExtensions -{ - private const string UnhandledErrorDescriptorId = "AXN0002"; - private const string InvalidTypeDescriptorId = "AXN0001"; - - public static string GetMsBuildProperty( - this GeneratorExecutionContext context, - string name, - string defaultValue = "") - { - context.AnalyzerConfigOptions.GlobalOptions.TryGetValue($"build_property.{name}", out var value); - return value ?? defaultValue; - } - - public static void ReportNameGeneratorUnhandledError(this GeneratorExecutionContext context, Exception error) => - context.Report(UnhandledErrorDescriptorId, - "Unhandled exception occurred while generating typed Name references. " + - "Please file an issue: https://github.com/avaloniaui/Avalonia", - error.Message, - error.ToString()); - - public static void ReportNameGeneratorInvalidType(this GeneratorExecutionContext context, string typeName) => - context.Report(InvalidTypeDescriptorId, - $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + - $"The type '{typeName}' does not exist in the assembly."); - - private static void Report(this GeneratorExecutionContext context, string id, string title, string? message = null, string? description = null) => - context.ReportDiagnostic( - Diagnostic.Create( - new DiagnosticDescriptor( - id: id, - title: title, - messageFormat: message ?? title, - category: "Usage", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true, - description), - Location.None)); -} diff --git a/src/tools/Avalonia.Generators/GeneratorExtensions.cs b/src/tools/Avalonia.Generators/GeneratorExtensions.cs new file mode 100644 index 0000000000..9553dddc46 --- /dev/null +++ b/src/tools/Avalonia.Generators/GeneratorExtensions.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Avalonia.Generators; + +internal static class GeneratorExtensions +{ + private const string UnhandledErrorDescriptorId = "AXN0002"; + private const string InvalidTypeDescriptorId = "AXN0001"; + + public static string GetMsBuildProperty( + this AnalyzerConfigOptions options, + string name, + string defaultValue = "") + { + options.TryGetValue($"build_property.{name}", out var value); + return value ?? defaultValue; + } + + public static DiagnosticDescriptor NameGeneratorUnhandledError(Exception error) => new( + UnhandledErrorDescriptorId, + title: "Unhandled exception occurred while generating typed Name references. " + + "Please file an issue: https://github.com/avaloniaui/Avalonia", + messageFormat: error.Message, + description: error.ToString(), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static DiagnosticDescriptor NameGeneratorInvalidType(string typeName) => new( + InvalidTypeDescriptorId, + title: $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + + $"The type '{typeName}' does not exist in the assembly.", + messageFormat: $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + + $"The type '{typeName}' does not exist in the assembly.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static void Report(this SourceProductionContext context, DiagnosticDescriptor diagnostics) => + context.ReportDiagnostic(Diagnostic.Create(diagnostics, Location.None)); +} diff --git a/src/tools/Avalonia.Generators/GeneratorOptions.cs b/src/tools/Avalonia.Generators/GeneratorOptions.cs index 9dcf5062f4..b9066aa3ca 100644 --- a/src/tools/Avalonia.Generators/GeneratorOptions.cs +++ b/src/tools/Avalonia.Generators/GeneratorOptions.cs @@ -1,7 +1,8 @@ using System; +using Avalonia.Generators.Common; using Avalonia.Generators.Common.Domain; using Avalonia.Generators.NameGenerator; -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; namespace Avalonia.Generators; @@ -18,58 +19,72 @@ internal enum BuildProperties // TODO add other generators properties here. } -internal class GeneratorOptions +internal record GeneratorOptions { - private readonly GeneratorExecutionContext _context; - - public GeneratorOptions(GeneratorExecutionContext context) => _context = context; + public GeneratorOptions(AnalyzerConfigOptions options) + { + AvaloniaNameGeneratorIsEnabled = GetBoolProperty( + options, + BuildProperties.AvaloniaNameGeneratorIsEnabled, + true); + AvaloniaNameGeneratorBehavior = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorBehavior, + Behavior.InitializeComponent); + AvaloniaNameGeneratorClassFieldModifier = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorDefaultFieldModifier, + NamedFieldModifier.Internal); + AvaloniaNameGeneratorViewFileNamingStrategy = GetEnumProperty( + options, + BuildProperties.AvaloniaNameGeneratorViewFileNamingStrategy, + ViewFileNamingStrategy.NamespaceAndClassName); + AvaloniaNameGeneratorFilterByPath = new GlobPatternGroup(GetStringArrayProperty( + options, + BuildProperties.AvaloniaNameGeneratorFilterByPath, + "*")); + AvaloniaNameGeneratorFilterByNamespace = new GlobPatternGroup(GetStringArrayProperty( + options, + BuildProperties.AvaloniaNameGeneratorFilterByNamespace, + "*")); + AvaloniaNameGeneratorAttachDevTools = GetBoolProperty( + options, + BuildProperties.AvaloniaNameGeneratorAttachDevTools, + true); + } - public bool AvaloniaNameGeneratorIsEnabled => GetBoolProperty( - BuildProperties.AvaloniaNameGeneratorIsEnabled, - true); + public bool AvaloniaNameGeneratorIsEnabled { get; } - public Behavior AvaloniaNameGeneratorBehavior => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorBehavior, - Behavior.InitializeComponent); + public Behavior AvaloniaNameGeneratorBehavior { get; } - public NamedFieldModifier AvaloniaNameGeneratorClassFieldModifier => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorDefaultFieldModifier, - NamedFieldModifier.Internal); + public NamedFieldModifier AvaloniaNameGeneratorClassFieldModifier { get; } - public ViewFileNamingStrategy AvaloniaNameGeneratorViewFileNamingStrategy => GetEnumProperty( - BuildProperties.AvaloniaNameGeneratorViewFileNamingStrategy, - ViewFileNamingStrategy.NamespaceAndClassName); + public ViewFileNamingStrategy AvaloniaNameGeneratorViewFileNamingStrategy { get; } - public string[] AvaloniaNameGeneratorFilterByPath => GetStringArrayProperty( - BuildProperties.AvaloniaNameGeneratorFilterByPath, - "*"); + public IGlobPattern AvaloniaNameGeneratorFilterByPath { get; } - public string[] AvaloniaNameGeneratorFilterByNamespace => GetStringArrayProperty( - BuildProperties.AvaloniaNameGeneratorFilterByNamespace, - "*"); + public IGlobPattern AvaloniaNameGeneratorFilterByNamespace { get; } - public bool AvaloniaNameGeneratorAttachDevTools => GetBoolProperty( - BuildProperties.AvaloniaNameGeneratorAttachDevTools, - true); + public bool AvaloniaNameGeneratorAttachDevTools { get; } - private string[] GetStringArrayProperty(BuildProperties name, string defaultValue) + private static string[] GetStringArrayProperty(AnalyzerConfigOptions options, BuildProperties name, string defaultValue) { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue); - return value.Contains(";") ? value.Split(';') : new[] {value}; + var value = options.GetMsBuildProperty(key, defaultValue); + return value.Contains(";") ? value.Split(';') : [value]; } - private TEnum GetEnumProperty(BuildProperties name, TEnum defaultValue) where TEnum : struct + private static TEnum GetEnumProperty(AnalyzerConfigOptions options, BuildProperties name, TEnum defaultValue) where TEnum : struct { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue.ToString()); + var value = options.GetMsBuildProperty(key, defaultValue.ToString()); return Enum.TryParse(value, true, out TEnum behavior) ? behavior : defaultValue; } - - private bool GetBoolProperty(BuildProperties name, bool defaultValue) + + private static bool GetBoolProperty(AnalyzerConfigOptions options, BuildProperties name, bool defaultValue) { var key = name.ToString(); - var value = _context.GetMsBuildProperty(key, defaultValue.ToString()); + var value = options.GetMsBuildProperty(key, defaultValue.ToString()); return bool.TryParse(value, out var result) ? result : defaultValue; } } diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs deleted file mode 100644 index 67389ef826..0000000000 --- a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameGenerator.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using Avalonia.Generators.Common.Domain; -using Microsoft.CodeAnalysis; - -namespace Avalonia.Generators.NameGenerator; - -internal class AvaloniaNameGenerator : INameGenerator -{ - private readonly ViewFileNamingStrategy _naming; - private readonly IGlobPattern _pathPattern; - private readonly IGlobPattern _namespacePattern; - private readonly IViewResolver _classes; - private readonly INameResolver _names; - private readonly ICodeGenerator _code; - - public AvaloniaNameGenerator( - ViewFileNamingStrategy naming, - IGlobPattern pathPattern, - IGlobPattern namespacePattern, - IViewResolver classes, - INameResolver names, - ICodeGenerator code) - { - _naming = naming; - _pathPattern = pathPattern; - _namespacePattern = namespacePattern; - _classes = classes; - _names = names; - _code = code; - } - - public IEnumerable GenerateNameReferences(IEnumerable additionalFiles, CancellationToken cancellationToken) - { - var resolveViews = - from file in additionalFiles - let filePath = file.Path - where (filePath.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase) || - filePath.EndsWith(".paml", StringComparison.OrdinalIgnoreCase) || - filePath.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase)) && - _pathPattern.Matches(filePath) - let xaml = file.GetText(cancellationToken)?.ToString() - where xaml != null - let view = _classes.ResolveView(xaml) - where view != null && _namespacePattern.Matches(view.Namespace) - select view; - - var query = - from view in resolveViews - let names = _names.ResolveNames(view.Xaml) - let code = _code.GenerateCode(view.ClassName, view.Namespace, view.XamlType, names) - let fileName = ResolveViewFileName(view, _naming) - select new GeneratedPartialClass(fileName, code); - - return query; - } - - private static string ResolveViewFileName(ResolvedView view, ViewFileNamingStrategy strategy) => strategy switch - { - ViewFileNamingStrategy.ClassName => $"{view.ClassName}.g.cs", - ViewFileNamingStrategy.NamespaceAndClassName => $"{view.Namespace}.{view.ClassName}.g.cs", - _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown naming strategy!") - }; -} diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs new file mode 100644 index 0000000000..ba0d0d7579 --- /dev/null +++ b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameIncrementalGenerator.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; +using Avalonia.Generators.Compiler; +using Microsoft.CodeAnalysis; +using XamlX.Transform; + +namespace Avalonia.Generators.NameGenerator; + +[Generator(LanguageNames.CSharp)] +public class AvaloniaNameIncrementalGenerator : IIncrementalGenerator +{ + private const string SourceItemGroupMetadata = "build_metadata.AdditionalFiles.SourceItemGroup"; + private static readonly MiniCompiler s_noopCompiler = MiniCompiler.CreateNoop(); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Map MSBuild properties onto readonly GeneratorOptions. + var options = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => new GeneratorOptions(options.GlobalOptions)) + .WithTrackingName(TrackingNames.XamlGeneratorOptionsProvider); + + // Filter additional texts, we only need Avalonia XAML files. + var xamlFiles = context.AdditionalTextsProvider + .Combine(options.Combine(context.AnalyzerConfigOptionsProvider)) + .Where(static pair => + { + var text = pair.Left; + var (options, optionsProvider) = pair.Right; + var filePath = text.Path; + + if (!(filePath.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".paml", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + if (!options.AvaloniaNameGeneratorFilterByPath.Matches(filePath)) + { + return false; + } + + if (!optionsProvider.GetOptions(pair.Left).TryGetValue(SourceItemGroupMetadata, out var itemGroup) + || itemGroup != "AvaloniaXaml") + { + return false; + } + + return true; + }) + .Select(static (pair, _) => pair.Left) + .WithTrackingName(TrackingNames.InputXamlFilesProvider); + + // Actual parsing step. We input XAML files one by one, but don't resolve any types. + // That's why we use NoOp type system here, allowing parsing to run detached from C# compilation. + // Otherwise we would need to re-parse XAML on any C# file changed. + var parsedXamlClasses = xamlFiles + .Select(static (file, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + var text = file.GetText(cancellationToken); + var diagnostics = new List(); + if (text is not null) + { + try + { + var xaml = text.ToString(); + var viewResolver = new XamlXViewResolver(s_noopCompiler); + var view = viewResolver.ResolveView(xaml, cancellationToken); + if (view is null) + { + return null; + } + + var nameResolver = new XamlXNameResolver(); + var xmlNames = nameResolver.ResolveXmlNames(view.Xaml, cancellationToken); + + return new XmlClassInfo( + new ResolvedXmlView(view, xmlNames), + new EquatableList(diagnostics)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorUnhandledError(ex)); + return new XmlClassInfo(null, new EquatableList(diagnostics)); + } + } + + return null; + }) + .Where(request => request is not null) + .WithTrackingName(TrackingNames.ParsedXamlClasses); + + // IMPORTANT: we shouldn't cache CompilationProvider as a whole, + // But we also should keep in mind that CompilationProvider can frequently re-trigger generator. + var compiler = context.CompilationProvider + .Select(static (compilation, _) => + { + var roslynTypeSystem = new RoslynTypeSystem(compilation); + return MiniCompiler.CreateRoslyn(roslynTypeSystem, MiniCompiler.AvaloniaXmlnsDefinitionAttribute); + }) + .WithTrackingName(TrackingNames.XamlTypeSystem); + + // Note: this step will be re-executed on any C# file changes. + // As much as possible heavy tasks should be moved outside of this step, like XAML parsing. + var resolvedNames = parsedXamlClasses + .Combine(compiler) + .Select(static (pair, ct) => + { + var (classInfo, compiler) = pair; + var hasDevToolsReference = compiler.TypeSystem.FindAssembly("Avalonia.Diagnostics") is not null; + var nameResolver = new XamlXNameResolver(); + + var diagnostics = new List(classInfo!.Diagnostics); + ResolvedView? view = null; + if (classInfo.XmlView is { } xmlView) + { + var type = compiler.TypeSystem.FindType(xmlView.FullName); + + if (type is null) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorInvalidType(xmlView.FullName)); + } + else if (type.IsAvaloniaStyledElement()) + { + var resolvedNames = new List(); + foreach (var xmlName in xmlView.XmlNames) + { + ct.ThrowIfCancellationRequested(); + + try + { + var clrType = compiler.ResolveXamlType(xmlName.XmlType); + if (!clrType.IsAvaloniaStyledElement()) + { + continue; + } + + resolvedNames.Add(nameResolver + .ResolveName(clrType, xmlName.Name, xmlName.FieldModifier)); + } + catch (Exception ex) + { + diagnostics.Add(GeneratorExtensions.NameGeneratorUnhandledError(ex)); + } + } + + view = new ResolvedView(xmlView, type.IsAvaloniaWindow(), new EquatableList(resolvedNames)); + } + } + + return new ResolvedClassInfo(view, hasDevToolsReference, new EquatableList(diagnostics)); + }) + .WithTrackingName(TrackingNames.ResolvedNamesProvider); + + context.RegisterSourceOutput(resolvedNames.Combine(options), static (context, pair) => + { + var (info, options) = pair; + + foreach (var diagnostic in info!.Diagnostics) + { + context.Report(diagnostic); + } + + if (info.View is { } view && options.AvaloniaNameGeneratorFilterByNamespace.Matches(view.Namespace)) + { + ICodeGenerator codeGenerator = options.AvaloniaNameGeneratorBehavior switch + { + Behavior.OnlyProperties => new OnlyPropertiesCodeGenerator( + options.AvaloniaNameGeneratorClassFieldModifier), + Behavior.InitializeComponent => new InitializeComponentCodeGenerator( + options.AvaloniaNameGeneratorAttachDevTools && info.CanAttachDevTools && view.IsWindow, + options.AvaloniaNameGeneratorClassFieldModifier), + _ => throw new ArgumentOutOfRangeException() + }; + var fileName = options.AvaloniaNameGeneratorViewFileNamingStrategy switch + { + ViewFileNamingStrategy.ClassName => $"{view.ClassName}.g.cs", + ViewFileNamingStrategy.NamespaceAndClassName => $"{view.Namespace}.{view.ClassName}.g.cs", + _ => throw new ArgumentOutOfRangeException( + nameof(ViewFileNamingStrategy), options.AvaloniaNameGeneratorViewFileNamingStrategy, + "Unknown naming strategy!") + }; + + var generatedPartialClass = codeGenerator.GenerateCode( + info.View.ClassName, + info.View.Namespace, + info.View.Names); + + context.AddSource(fileName, generatedPartialClass); + } + }); + } + + internal record XmlClassInfo( + ResolvedXmlView? XmlView, + EquatableList Diagnostics); + + internal record ResolvedClassInfo( + ResolvedView? View, + bool CanAttachDevTools, + EquatableList Diagnostics); +} diff --git a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs deleted file mode 100644 index e93895db2e..0000000000 --- a/src/tools/Avalonia.Generators/NameGenerator/AvaloniaNameSourceGenerator.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -using Avalonia.Generators.Common; -using Avalonia.Generators.Common.Domain; -using Avalonia.Generators.Compiler; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -namespace Avalonia.Generators.NameGenerator; - -[Generator] -public class AvaloniaNameSourceGenerator : ISourceGenerator -{ - private const string SourceItemGroupMetadata = "build_metadata.AdditionalFiles.SourceItemGroup"; - - public void Initialize(GeneratorInitializationContext context) { } - - public void Execute(GeneratorExecutionContext context) - { - try - { - var generator = CreateNameGenerator(context); - if (generator is null) - { - return; - } - - var partials = generator.GenerateNameReferences(ResolveAdditionalFiles(context), context.CancellationToken); - foreach (var (fileName, content) in partials) - { - if(context.CancellationToken.IsCancellationRequested) - { - break; - } - - context.AddSource(fileName, content); - } - } - catch (OperationCanceledException) - { - } - catch (Exception exception) - { - context.ReportNameGeneratorUnhandledError(exception); - } - } - - private static IEnumerable ResolveAdditionalFiles(GeneratorExecutionContext context) - { - return context - .AdditionalFiles - .Where(f => context.AnalyzerConfigOptions - .GetOptions(f) - .TryGetValue(SourceItemGroupMetadata, out var sourceItemGroup) - && sourceItemGroup == "AvaloniaXaml"); - } - - private static INameGenerator? CreateNameGenerator(GeneratorExecutionContext context) - { - var options = new GeneratorOptions(context); - if (!options.AvaloniaNameGeneratorIsEnabled) - { - return null; - } - - var types = new RoslynTypeSystem((CSharpCompilation)context.Compilation); - ICodeGenerator generator = options.AvaloniaNameGeneratorBehavior switch { - Behavior.OnlyProperties => new OnlyPropertiesCodeGenerator(), - Behavior.InitializeComponent => new InitializeComponentCodeGenerator(types, options.AvaloniaNameGeneratorAttachDevTools), - _ => throw new ArgumentOutOfRangeException() - }; - - var compiler = MiniCompiler.CreateDefault(types, MiniCompiler.AvaloniaXmlnsDefinitionAttribute); - return new AvaloniaNameGenerator( - options.AvaloniaNameGeneratorViewFileNamingStrategy, - new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByPath), - new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByNamespace), - new XamlXViewResolver(types, compiler, true, - type => context.ReportNameGeneratorInvalidType(type), - error => context.ReportNameGeneratorUnhandledError(error)), - new XamlXNameResolver(options.AvaloniaNameGeneratorClassFieldModifier), - generator); - } -} diff --git a/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs index 5b44de43c1..10414c7959 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/INameGenerator.cs @@ -1,12 +1,6 @@ -using System.Collections.Generic; -using System.Threading; -using Microsoft.CodeAnalysis; +using Avalonia.Generators.Common; +using Avalonia.Generators.Common.Domain; +using Microsoft.CodeAnalysis.Text; namespace Avalonia.Generators.NameGenerator; -internal interface INameGenerator -{ - IEnumerable GenerateNameReferences(IEnumerable additionalFiles, CancellationToken cancellationToken); -} - -internal record GeneratedPartialClass(string FileName, string Content); diff --git a/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs index 3dd058af0b..30b9d870aa 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/InitializeComponentCodeGenerator.cs @@ -1,14 +1,13 @@ using System.Collections.Generic; using Avalonia.Generators.Common.Domain; -using XamlX.TypeSystem; namespace Avalonia.Generators.NameGenerator; -internal class InitializeComponentCodeGenerator : ICodeGenerator +internal class InitializeComponentCodeGenerator(bool avaloniaNameGeneratorAttachDevTools, NamedFieldModifier defaultNamedFieldModifier = NamedFieldModifier.Internal) : ICodeGenerator { private string _generatorName = typeof(InitializeComponentCodeGenerator).FullName; private string _generatorVersion = typeof(InitializeComponentCodeGenerator).Assembly.GetName().Version.ToString(); - private readonly bool _diagnosticsAreConnected; + private const string AttachDevToolsCodeBlock = @" #if DEBUG if (attachDevTools) @@ -22,12 +21,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator "; - public InitializeComponentCodeGenerator(IXamlTypeSystem types, bool avaloniaNameGeneratorAttachDevTools) - { - _diagnosticsAreConnected = avaloniaNameGeneratorAttachDevTools && types.FindAssembly("Avalonia.Diagnostics") != null; - } - - public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + public string GenerateCode(string className, string nameSpace, IEnumerable names) { var properties = new List(); var initializations = new List(); @@ -45,7 +39,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator var propertySource = $""" [global::System.CodeDom.Compiler.GeneratedCode("{_generatorName}", "{_generatorVersion}")] - {fieldModifier} {typeName} {name}; + {fieldModifier ?? defaultNamedFieldModifier.ToString().ToLowerInvariant()} {typeName} {name}; """; properties.Add(propertySource); initializations.Add($" {name} = __thisNameScope__?.Find<{typeName}>(\"{name}\");"); @@ -53,7 +47,7 @@ internal class InitializeComponentCodeGenerator : ICodeGenerator hasNames = true; } - var attachDevTools = _diagnosticsAreConnected && IsWindow(xamlType); + var attachDevTools = avaloniaNameGeneratorAttachDevTools; return $@"// @@ -87,17 +81,4 @@ namespace {nameSpace} }} "; } - - private static bool IsWindow(IXamlType xamlType) - { - var type = xamlType; - bool isWindow; - do - { - isWindow = type.FullName == "Avalonia.Controls.Window"; - type = type.BaseType; - } while (!isWindow && type != null); - - return isWindow; - } } diff --git a/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs b/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs index 8b295acd6b..128af004a5 100644 --- a/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs +++ b/src/tools/Avalonia.Generators/NameGenerator/OnlyPropertiesCodeGenerator.cs @@ -5,18 +5,18 @@ using XamlX.TypeSystem; namespace Avalonia.Generators.NameGenerator; -internal class OnlyPropertiesCodeGenerator : ICodeGenerator +internal class OnlyPropertiesCodeGenerator(NamedFieldModifier defaultNamedFieldModifier = NamedFieldModifier.Internal) : ICodeGenerator { private string _generatorName = typeof(OnlyPropertiesCodeGenerator).FullName; private string _generatorVersion = typeof(OnlyPropertiesCodeGenerator).Assembly.GetName().Version.ToString(); - public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + public string GenerateCode(string className, string nameSpace, IEnumerable names) { var namedControls = names .Select(info => " " + $"[global::System.CodeDom.Compiler.GeneratedCode(\"{_generatorName}\", \"{_generatorVersion}\")]\n" + " " + - $"{info.FieldModifier} {info.TypeName} {info.Name} => " + + $"{info.FieldModifier ?? defaultNamedFieldModifier.ToString().ToLowerInvariant()} {info.TypeName} {info.Name} => " + $"this.FindNameScope()?.Find<{info.TypeName}>(\"{info.Name}\");") .ToList(); var lines = string.Join("\n", namedControls); diff --git a/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs b/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs new file mode 100644 index 0000000000..09d894cbba --- /dev/null +++ b/src/tools/Avalonia.Generators/NameGenerator/TrackingNames.cs @@ -0,0 +1,10 @@ +namespace Avalonia.Generators.NameGenerator; + +internal static class TrackingNames +{ + public const string ResolvedNamesProvider = nameof(ResolvedNamesProvider); + public const string XamlGeneratorOptionsProvider = nameof(XamlGeneratorOptionsProvider); + public const string InputXamlFilesProvider = nameof(InputXamlFilesProvider); + public const string ParsedXamlClasses = nameof(ParsedXamlClasses); + public const string XamlTypeSystem = nameof(XamlTypeSystem); +} diff --git a/src/tools/Avalonia.Generators/README.md b/src/tools/Avalonia.Generators/README.md index 73e9e71196..63ec7e8580 100644 --- a/src/tools/Avalonia.Generators/README.md +++ b/src/tools/Avalonia.Generators/README.md @@ -1,29 +1,10 @@ -[![NuGet Stats](https://img.shields.io/nuget/v/XamlNameReferenceGenerator.svg)](https://www.nuget.org/packages/XamlNameReferenceGenerator) [![downloads](https://img.shields.io/nuget/dt/XamlNameReferenceGenerator)](https://www.nuget.org/packages/XamlNameReferenceGenerator) ![Build](https://github.com/avaloniaui/Avalonia.NameGenerator/workflows/Build/badge.svg) ![License](https://img.shields.io/github/license/avaloniaui/Avalonia.NameGenerator.svg) ![Size](https://img.shields.io/github/repo-size/avaloniaui/Avalonia.NameGenerator.svg) - ### C# `SourceGenerator` for Typed Avalonia `x:Name` References This is a [C# `SourceGenerator`](https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/) built for generating strongly-typed references to controls with `x:Name` (or just `Name`) attributes declared in XAML (or, in `.axaml`). The source generator will look for the `xaml` (or `axaml`) file with the same name as your partial C# class that is a subclass of `Avalonia.INamed` and parses the XAML markup, finds all XAML tags with `x:Name` attributes and generates the C# code. ### Getting Started -In order to get started, just install the NuGet package: - -``` -dotnet add package XamlNameReferenceGenerator -``` - -Or, if you are using [submodules](https://git-scm.com/docs/git-submodule), you can reference the generator as such: - -```xml - - - - - -``` +In order to get started, just create project with Avalonia NuGet package: ### Usage diff --git a/src/tools/DevAnalyzers/DevAnalyzers.csproj b/src/tools/DevAnalyzers/DevAnalyzers.csproj index 6044eb6d2d..5f6f6ef62c 100644 --- a/src/tools/DevAnalyzers/DevAnalyzers.csproj +++ b/src/tools/DevAnalyzers/DevAnalyzers.csproj @@ -3,15 +3,7 @@ netstandard2.0 enable - True - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + diff --git a/src/tools/DevGenerators/DevGenerators.csproj b/src/tools/DevGenerators/DevGenerators.csproj index 8d856d5fc5..f1af2d8bba 100644 --- a/src/tools/DevGenerators/DevGenerators.csproj +++ b/src/tools/DevGenerators/DevGenerators.csproj @@ -4,18 +4,12 @@ netstandard2.0 enable false - True - true - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + diff --git a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs index a667057708..1884a1ab65 100644 --- a/tests/Avalonia.Base.UnitTests/DispatcherTests.cs +++ b/tests/Avalonia.Base.UnitTests/DispatcherTests.cs @@ -505,4 +505,104 @@ public partial class DispatcherTests t.GetAwaiter().GetResult(); } } + + + [Fact] + public async Task DispatcherResumeContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.UIThread.Resume(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldContinuesOnUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Dispatcher.Yield(); + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task DispatcherYieldThrowsOnNonUIThread() + { + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + await Assert.ThrowsAsync(async () => await Dispatcher.Yield()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } + + [Fact] + public async Task AwaitWithPriorityRunsOnUIThread() + { + static async Task Workload() + { + await Task.Delay(1).ConfigureAwait(false); + Assert.False(Dispatcher.UIThread.CheckAccess()); + + return Thread.CurrentThread.ManagedThreadId; + } + + using var services = new DispatcherServices(new SimpleControlledDispatcherImpl()); + + var tokenSource = new CancellationTokenSource(); + var workload = Dispatcher.UIThread.InvokeAsync( + async () => + { + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithoutResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithoutResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + Task taskWithResult = Workload(); + + await Dispatcher.UIThread.AwaitWithPriority(taskWithResult, DispatcherPriority.Default); + + Assert.True(Dispatcher.UIThread.CheckAccess()); + + tokenSource.Cancel(); + }); + + Dispatcher.UIThread.MainLoop(tokenSource.Token); + } } diff --git a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs b/tests/Avalonia.Controls.UnitTests/CalendarTests.cs index 7f464827d7..864f92f511 100644 --- a/tests/Avalonia.Controls.UnitTests/CalendarTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CalendarTests.cs @@ -271,5 +271,97 @@ namespace Avalonia.Controls.UnitTests Assert.True(CompareDates(calendar.SelectedDate.Value, DateTime.Today.AddDays(100))); Assert.True(calendar.SelectedDates.Count == 1); } + + [Fact] + public void AllowTapRangeSelection_Should_Disable_TapToSelectRange() + { + var calendar = new Calendar(); + Assert.True(calendar.AllowTapRangeSelection); // Default should be true + + calendar.AllowTapRangeSelection = false; + Assert.False(calendar.AllowTapRangeSelection); + } + + [Fact] + public void TapRangeSelection_Should_Work_In_SingleRange_Mode() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var startDate = new DateTime(2023, 10, 10); + var endDate = new DateTime(2023, 10, 15); + + // First tap should select start date + var firstTapResult = calendar.ProcessTapRangeSelection(startDate); + Assert.True(firstTapResult); + Assert.Equal(1, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(startDate)); + + // Second tap should complete the range + var secondTapResult = calendar.ProcessTapRangeSelection(endDate); + Assert.True(secondTapResult); + Assert.Equal(6, calendar.SelectedDates.Count); // 5 days inclusive + Assert.True(calendar.SelectedDates.Contains(startDate)); + Assert.True(calendar.SelectedDates.Contains(endDate)); + } + + [Fact] + public void TapRangeSelection_Should_Not_Work_In_SingleDate_Mode() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleDate; + calendar.AllowTapRangeSelection = true; + + var date = new DateTime(2023, 10, 10); + var result = calendar.ProcessTapRangeSelection(date); + Assert.False(result); // Should not handle tap range selection + } + + [Fact] + public void TapRangeSelection_Should_Handle_Blackout_Dates() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var startDate = new DateTime(2023, 10, 10); + var blackoutDate = new DateTime(2023, 10, 12); + var endDate = new DateTime(2023, 10, 15); + + // Add blackout date in the middle + calendar.BlackoutDates.Add(new CalendarDateRange(blackoutDate, blackoutDate)); + + // First tap + calendar.ProcessTapRangeSelection(startDate); + Assert.Equal(1, calendar.SelectedDates.Count); + + // Second tap should restart selection due to blackout date + calendar.ProcessTapRangeSelection(endDate); + Assert.Equal(1, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(endDate)); + Assert.False(calendar.SelectedDates.Contains(startDate)); + } + + [Fact] + public void TapRangeSelection_Should_Handle_Reverse_Order_Dates() + { + var calendar = new Calendar(); + calendar.SelectionMode = CalendarSelectionMode.SingleRange; + calendar.AllowTapRangeSelection = true; + + var laterDate = new DateTime(2023, 10, 15); + var earlierDate = new DateTime(2023, 10, 10); + + // First tap on later date + calendar.ProcessTapRangeSelection(laterDate); + Assert.Equal(1, calendar.SelectedDates.Count); + + // Second tap on earlier date should still create correct range + calendar.ProcessTapRangeSelection(earlierDate); + Assert.Equal(6, calendar.SelectedDates.Count); + Assert.True(calendar.SelectedDates.Contains(earlierDate)); + Assert.True(calendar.SelectedDates.Contains(laterDate)); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index 96ff5ff786..8c7710971b 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -209,6 +209,10 @@ namespace Avalonia.Controls.UnitTests ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), }.RegisterInNameScope(scope) }.RegisterInNameScope(scope) + }.RegisterInNameScope(scope), + new TextBox + { + Name = "PART_InputText" }.RegisterInNameScope(scope) } }; @@ -635,5 +639,81 @@ namespace Avalonia.Controls.UnitTests } private sealed record Item(string Value, string Display); + + [Fact] + public void When_Editable_Input_Text_Matches_An_Item_It_Is_Selected() + { + var target = new ComboBox + { + DisplayMemberBinding = new Binding(), + IsEditable = true, + ItemsSource = new[] { "foo", "bar" } + }; + + target.SelectedItem = null; + Assert.Null(target.SelectedItem); + + target.Text = "foo"; + Assert.NotNull(target.SelectedItem); + Assert.Equal(target.SelectedItem, "foo"); + } + + [Fact] + public void When_Editable_TextSearch_TextBinding_Is_Prioritised_Over_DisplayMember() + { + var items = new[] + { + new Item("Value 1", "Display 1"), + new Item("Value 2", "Display 2") + }; + var target = new ComboBox + { + DisplayMemberBinding = new Binding("Display"), + IsEditable = true, + ItemsSource = items + }; + TextSearch.SetTextBinding(target, new Binding("Value")); + + target.SelectedItem = null; + Assert.Null(target.SelectedItem); + + target.Text = "Value 1"; + Assert.NotNull(target.SelectedItem); + Assert.Equal(target.SelectedItem, items[0]); + } + + [Fact] + public void When_Items_Source_Changes_It_Selects_An_Item_By_Text() + { + var items = new[] + { + new Item("Value 1", "Display 1"), + new Item("Value 2", "Display 2") + }; + var items2 = new[] + { + new Item("Value 1", "Display 3"), + new Item("Value 2", "Display 4") + }; + var target = new ComboBox + { + DisplayMemberBinding = new Binding("Display"), + IsEditable = true, + ItemsSource = items + }; + TextSearch.SetTextBinding(target, new Binding("Value")); + + target.SelectedItem = null; + Assert.Null(target.SelectedItem); + + target.Text = "Value 1"; + Assert.NotNull(target.SelectedItem); + Assert.Equal(target.SelectedItem, items[0]); + + target.ItemsSource = items2; + Assert.NotNull(target.SelectedItem); + Assert.Equal(target.SelectedItem, items2[0]); + Assert.Equal(target.Text, "Value 1"); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs b/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs index cdb8fb21fe..c0974ea97b 100644 --- a/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridSplitterTests.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; +using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Input; +using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia.UnitTests; using Moq; @@ -380,5 +383,164 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(columnDefinitions[0].Width, new GridLength(1, GridUnitType.Star)); Assert.Equal(columnDefinitions[2].Width, new GridLength(1, GridUnitType.Star)); } + + [Fact] + public void Works_In_ItemsControl_ItemsSource() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + +"; + + var itemsControl = AvaloniaRuntimeXamlLoader.Parse(xaml); + itemsControl.ItemsSource = new List + { + new TextItem { Column = 0, Text = "A" }, + new SplitterItem { Column = 1 }, + new TextItem { Column = 2, Text = "B" }, + }; + + var root = new TestRoot { Child = itemsControl }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var panel = Assert.IsType(itemsControl.ItemsPanelRoot); + var cp = Assert.IsType(panel.Children[1]); + cp.UpdateChild(); + var splitter = Assert.IsType(cp.Child); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width); + } + + [Fact] + public void Works_In_ItemsControl_Items() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + + + + + + + + + + + + + + + + + + + +"; + + var itemsControl = AvaloniaRuntimeXamlLoader.Parse(xaml); + var root = new TestRoot { Child = itemsControl }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var panel = Assert.IsType(itemsControl.ItemsPanelRoot); + var splitter = Assert.IsType(panel.Children[1]); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(panel.ColumnDefinitions[0].Width, panel.ColumnDefinitions[2].Width); + } + + [Fact] + public void Works_In_Grid() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow); + + var xaml = @" + + + +"; + + var grid = AvaloniaRuntimeXamlLoader.Parse(xaml); + var root = new TestRoot { Child = grid }; + root.Measure(new Size(200, 100)); + root.Arrange(new Rect(0, 0, 200, 100)); + + var splitter = Assert.IsType(grid.Children[1]); + + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragStartedEvent }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragDeltaEvent, Vector = new Vector(-20, 0) }); + splitter.RaiseEvent(new VectorEventArgs { RoutedEvent = Thumb.DragCompletedEvent }); + + Assert.NotEqual(grid.ColumnDefinitions[0].Width, grid.ColumnDefinitions[2].Width); + } + } + + public interface IGridItem + { + int Column { get; set; } + } + + public class TextItem : IGridItem + { + public int Column { get; set; } + public string? Text { get; set; } + } + + public class SplitterItem : IGridItem + { + public int Column { get; set; } } } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 139c4656a1..1a9ce7c655 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -669,6 +669,51 @@ namespace Avalonia.Controls.UnitTests.Primitives } } + [Fact] + public void Popup_Should_Clear_Keyboard_Focus_From_Children_When_Closed() + { + using (CreateServicesWithFocus()) + { + var winButton = new Button(); + var window = PreparedWindow(new Panel { Children = { winButton }}); + + var border1 = new Border(); + var border2 = new Border(); + var button = new Button(); + border1.Child = border2; + border2.Child = button; + var popup = new Popup + { + PlacementTarget = window, + Child = new StackPanel + { + Children = + { + border1 + } + } + }; + + ((ISetLogicalParent)popup).SetParent(popup.PlacementTarget); + window.Show(); + winButton.Focus(); + popup.Open(); + + button.Focus(); + + var inputRoot = Assert.IsAssignableFrom(popup.Host); + + var focusManager = inputRoot.FocusManager!; + Assert.Same(button, focusManager.GetFocusedElement()); + + border1.Child = null; + + winButton.Focus(); + + Assert.False(border2.IsKeyboardFocusWithin); + } + } + [Fact] public void Closing_Popup_Sets_Focus_On_PlacementTarget() { diff --git a/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs index 26d572d416..5dc4661a5f 100644 --- a/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/SplitViewTests.cs @@ -303,5 +303,45 @@ namespace Avalonia.Controls.UnitTests Assert.Contains(splitView.Classes, ":closed".Equals); } + + [Fact] + public void SplitView_Shouldnt_Close_Panel_When_IsPaneOpen_True_Then_Display_Mode_Changed() + { + using var app = UnitTestApplication.Start(TestServices.StyledWindow + .With(globalClock: new MockGlobalClock())); + var wnd = new Window + { + Width = 1280, + Height = 720 + }; + var splitView = new SplitView(); + splitView.DisplayMode = SplitViewDisplayMode.CompactOverlay; + wnd.Content = splitView; + wnd.Show(); + + splitView.IsPaneOpen = true; + + splitView.RaiseEvent(new PointerReleasedEventArgs(splitView, + null, wnd, new Point(1270, 30), 0, + new PointerPointProperties(), + KeyModifiers.None, + MouseButton.Left)); + + Assert.False(splitView.IsPaneOpen); + + // Inline shouldn't close the pane + splitView.IsPaneOpen = true; + + // Change the display mode once the pane is already open. + splitView.DisplayMode = SplitViewDisplayMode.Inline; + + splitView.RaiseEvent(new PointerReleasedEventArgs(splitView, + null, wnd, new Point(1270, 30), 0, + new PointerPointProperties(), + KeyModifiers.None, + MouseButton.Left)); + + Assert.True(splitView.IsPaneOpen); + } } } diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs index 0b33239687..6c6252d836 100644 --- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs +++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs @@ -3,6 +3,7 @@ using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; @@ -24,137 +25,167 @@ namespace Avalonia.Controls.UnitTests public class VirtualizingStackPanelTests : ScopedTestBase { private static FuncDataTemplate CanvasWithHeightTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Width = 100, [!Layoutable.HeightProperty] = new Binding("Height"), }); private static FuncDataTemplate CanvasWithWidthTemplate = new((_, _) => - new Canvas + new CanvasCountingMeasureArrangeCalls { Height = 100, [!Layoutable.WidthProperty] = new Binding("Width"), }); - [Fact] - public void Creates_Initial_Items() + [Theory] + [InlineData(0d , 10)] + [InlineData(0.5d, 20)] + public void Creates_Initial_Items(double bufferFactor, int expectedCount) { using var app = App(); - var (target, scroll, itemsControl) = CreateTarget(); + var (target, scroll, itemsControl) = CreateTarget(bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedItems(target, itemsControl, 0, 10); + AssertRealizedItems(target, itemsControl, 0, expectedCount); } - [Fact] - public void Initializes_Initial_Control_Items() + [Theory] + [InlineData(0d, 10)] + [InlineData(0.5d, 20)] // Buffer factor of 0.5. Since at start there is no room, the 10 additional items are just appended + public void Initializes_Initial_Control_Items(double bufferFactor, int expectedCount) { using var app = App(); var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 }); - var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null); + var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null, bufferFactor:bufferFactor); Assert.Equal(1000, scroll.Extent.Height); - AssertRealizedControlItems