diff --git a/samples/ControlCatalog.Browser.Blazor/App.razor.cs b/samples/ControlCatalog.Browser.Blazor/App.razor.cs index f38db2b055..c331625664 100644 --- a/samples/ControlCatalog.Browser.Blazor/App.razor.cs +++ b/samples/ControlCatalog.Browser.Blazor/App.razor.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading.Tasks; using Avalonia; using Avalonia.Browser.Blazor; @@ -5,13 +7,4 @@ namespace ControlCatalog.Browser.Blazor; public partial class App { - protected override void OnParametersSet() - { - AppBuilder.Configure() - .UseBlazor() - // .With(new SkiaOptions { CustomGpuFactory = null }) // uncomment to disable GPU/GL rendering - .SetupWithSingleViewLifetime(); - - base.OnParametersSet(); - } } diff --git a/samples/ControlCatalog.Browser.Blazor/Program.cs b/samples/ControlCatalog.Browser.Blazor/Program.cs index eb99ca518e..e68e9b14d9 100644 --- a/samples/ControlCatalog.Browser.Blazor/Program.cs +++ b/samples/ControlCatalog.Browser.Blazor/Program.cs @@ -1,6 +1,8 @@ using System; using System.Net.Http; using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser.Blazor; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection; using ControlCatalog.Browser.Blazor; @@ -9,9 +11,17 @@ public class Program { public static async Task Main(string[] args) { - await CreateHostBuilder(args).Build().RunAsync(); + var host = CreateHostBuilder(args).Build(); + await StartAvaloniaApp(); + await host.RunAsync(); } + public static async Task StartAvaloniaApp() + { + await AppBuilder.Configure() + .StartBlazorAppAsync(); + } + public static WebAssemblyHostBuilder CreateHostBuilder(string[] args) { var builder = WebAssemblyHostBuilder.CreateDefault(args); diff --git a/samples/ControlCatalog.Browser/Program.cs b/samples/ControlCatalog.Browser/Program.cs index 53b7c60a6f..e1a4500173 100644 --- a/samples/ControlCatalog.Browser/Program.cs +++ b/samples/ControlCatalog.Browser/Program.cs @@ -1,6 +1,8 @@ using System.Runtime.Versioning; +using System.Threading.Tasks; using Avalonia; using Avalonia.Browser; +using Avalonia.Controls; using ControlCatalog; using ControlCatalog.Browser; @@ -8,15 +10,27 @@ using ControlCatalog.Browser; internal partial class Program { - private static void Main(string[] args) + public static async Task Main(string[] args) { - BuildAvaloniaApp() + await BuildAvaloniaApp() .AfterSetup(_ => { ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb(); - }).SetupBrowserApp("out"); + }) + .StartBrowserAppAsync("out"); } + // Example without a ISingleViewApplicationLifetime + // private static AvaloniaView _avaloniaView; + // public static async Task Main(string[] args) + // { + // await BuildAvaloniaApp() + // .SetupBrowserApp(); + // + // _avaloniaView = new AvaloniaView("out"); + // _avaloniaView.Content = new TextBlock { Text = "Hello world" }; + // } + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure(); } diff --git a/samples/ControlCatalog.Browser/main.js b/samples/ControlCatalog.Browser/main.js index 87f8a4f943..9d90db8bd2 100644 --- a/samples/ControlCatalog.Browser/main.js +++ b/samples/ControlCatalog.Browser/main.js @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. import { dotnet } from './dotnet.js' -import { registerAvaloniaModule } from './avalonia.js'; const is_browser = typeof window != "undefined"; if (!is_browser) throw new Error(`Expected to be running in a browser`); @@ -12,8 +11,6 @@ const dotnetRuntime = await dotnet .withApplicationArgumentsFromQuery() .create(); -await registerAvaloniaModule(dotnetRuntime); - const config = dotnetRuntime.getConfig(); await dotnetRuntime.runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]); diff --git a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs index f7b020678d..e24860e3e1 100644 --- a/samples/ControlCatalog/Pages/DialogsPage.xaml.cs +++ b/samples/ControlCatalog/Pages/DialogsPage.xaml.cs @@ -40,7 +40,7 @@ namespace ControlCatalog.Pages if (Enum.TryParse(currentFolderBox.Text, true, out var folderEnum)) { - lastSelectedDirectory = await GetStorageProvider().TryGetWellKnownFolder(folderEnum); + lastSelectedDirectory = await GetStorageProvider().TryGetWellKnownFolderAsync(folderEnum); } else { @@ -51,7 +51,7 @@ namespace ControlCatalog.Pages if (folderLink is not null) { - lastSelectedDirectory = await GetStorageProvider().TryGetFolderFromPath(folderLink); + lastSelectedDirectory = await GetStorageProvider().TryGetFolderFromPathAsync(folderLink); } } }; @@ -82,7 +82,13 @@ namespace ControlCatalog.Pages return new List { FilePickerFileTypes.All, - FilePickerFileTypes.TextPlain + FilePickerFileTypes.TextPlain, + new("Binary Log") + { + Patterns = new[] { "*.binlog", "*.buildlog" }, + MimeTypes = new[] { "application/binlog", "application/buildlog" }, + AppleUniformTypeIdentifiers = new []{ "public.data" } + } }; } @@ -142,7 +148,7 @@ namespace ControlCatalog.Pages } else { - SetFolder(await GetStorageProvider().TryGetFolderFromPath(result)); + SetFolder(await GetStorageProvider().TryGetFolderFromPathAsync(result)); results.Items = new[] { result }; resultsVisible.IsVisible = true; } @@ -223,7 +229,7 @@ namespace ControlCatalog.Pages ShowOverwritePrompt = false }); - if (file is not null && file.CanOpenWrite) + if (file is not null) { // Sync disposal of StreamWriter is not supported on WASM #if NET6_0_OR_GREATER @@ -275,7 +281,7 @@ namespace ControlCatalog.Pages { ignoreTextChanged = true; lastSelectedDirectory = folder; - currentFolderBox.Text = folder?.Path.LocalPath; + currentFolderBox.Text = folder?.Path is { IsAbsoluteUri: true } abs ? abs.LocalPath : folder?.Path?.ToString(); ignoreTextChanged = false; } async Task SetPickerResult(IReadOnlyCollection? items) @@ -298,31 +304,26 @@ namespace ControlCatalog.Pages if (item is IStorageFile file) { resultText += @$" - CanOpenRead: {file.CanOpenRead} - CanOpenWrite: {file.CanOpenWrite} Content: "; - if (file.CanOpenRead) - { #if NET6_0_OR_GREATER - await using var stream = await file.OpenReadAsync(); + await using var stream = await file.OpenReadAsync(); #else - using var stream = await file.OpenReadAsync(); + using var stream = await file.OpenReadAsync(); #endif - using var reader = new System.IO.StreamReader(stream); + using var reader = new System.IO.StreamReader(stream); - // 4GB file test, shouldn't load more than 10000 chars into a memory. - const int length = 10000; - var buffer = ArrayPool.Shared.Rent(length); - try - { - var charsRead = await reader.ReadAsync(buffer, 0, length); - resultText += new string(buffer, 0, charsRead); - } - finally - { - ArrayPool.Shared.Return(buffer); - } + // 4GB file test, shouldn't load more than 10000 chars into a memory. + const int length = 10000; + var buffer = ArrayPool.Shared.Rent(length); + try + { + var charsRead = await reader.ReadAsync(buffer, 0, length); + resultText += new string(buffer, 0, charsRead); + } + finally + { + ArrayPool.Shared.Return(buffer); } } diff --git a/samples/IntegrationTestApp/MainWindow.axaml b/samples/IntegrationTestApp/MainWindow.axaml index a6f463fdcb..353e01dca7 100644 --- a/samples/IntegrationTestApp/MainWindow.axaml +++ b/samples/IntegrationTestApp/MainWindow.axaml @@ -153,6 +153,9 @@ + + + diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs index 9838bb06c8..9d6dd46d0e 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageItem.cs @@ -177,11 +177,7 @@ internal sealed class AndroidStorageFile : AndroidStorageItem, IStorageBookmarkF public AndroidStorageFile(Activity activity, AndroidUri uri) : base(activity, uri, false) { } - - public bool CanOpenRead => true; - - public bool CanOpenWrite => true; - + public Task OpenReadAsync() => Task.FromResult(OpenContentStream(Activity, Uri, false) ?? throw new InvalidOperationException("Failed to open content stream")); diff --git a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs index f611f50164..e35bde0acd 100644 --- a/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs +++ b/src/Android/Avalonia.Android/Platform/Storage/AndroidStorageProvider.cs @@ -37,7 +37,7 @@ internal class AndroidStorageProvider : IStorageProvider return Task.FromResult(new AndroidStorageFolder(_activity, uri, false)); } - public async Task TryGetFileFromPath(Uri filePath) + public async Task TryGetFileFromPathAsync(Uri filePath) { if (filePath is null) { @@ -70,7 +70,7 @@ internal class AndroidStorageProvider : IStorageProvider return new AndroidStorageFile(_activity, androidUri); } - public async Task TryGetFolderFromPath(Uri folderPath) + public async Task TryGetFolderFromPathAsync(Uri folderPath) { if (folderPath is null) { @@ -103,7 +103,7 @@ internal class AndroidStorageProvider : IStorageProvider return new AndroidStorageFolder(_activity, androidUri, false); } - public Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder) + public Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { var dirCode = wellKnownFolder switch { diff --git a/src/Avalonia.Base/AvaloniaObject.cs b/src/Avalonia.Base/AvaloniaObject.cs index 2c9efc7767..d89d6f3690 100644 --- a/src/Avalonia.Base/AvaloniaObject.cs +++ b/src/Avalonia.Base/AvaloniaObject.cs @@ -664,14 +664,12 @@ namespace Avalonia /// The property that has changed. /// The old property value. /// The new property value. - /// The priority of the binding that produced the value. protected void RaisePropertyChanged( DirectPropertyBase property, - Optional oldValue, - BindingValue newValue, - BindingPriority priority = BindingPriority.LocalValue) + T oldValue, + T newValue) { - RaisePropertyChanged(property, oldValue, newValue, priority, true); + RaisePropertyChanged(property, oldValue, newValue, BindingPriority.LocalValue, true); } /// @@ -720,7 +718,7 @@ namespace Avalonia /// /// True if the value changed, otherwise false. /// - protected bool SetAndRaise(AvaloniaProperty property, ref T field, T value) + protected bool SetAndRaise(DirectPropertyBase property, ref T field, T value) { VerifyAccess(); diff --git a/src/Avalonia.Base/Layout/LayoutInformation.cs b/src/Avalonia.Base/Layout/LayoutInformation.cs new file mode 100644 index 0000000000..9b821053a2 --- /dev/null +++ b/src/Avalonia.Base/Layout/LayoutInformation.cs @@ -0,0 +1,27 @@ +namespace Avalonia.Layout; + +/// +/// Provides access to layout information of a control. +/// +public static class LayoutInformation +{ + /// + /// Gets the available size constraint passed in the previous layout pass. + /// + /// The control. + /// Previous control measure constraint, if any. + public static Size? GetPreviousMeasureConstraint(Layoutable control) + { + return control.PreviousMeasure; + } + + /// + /// Gets the control bounds used in the previous layout arrange pass. + /// + /// The control. + /// Previous control arrange bounds, if any. + public static Rect? GetPreviousArrangeBounds(Layoutable control) + { + return control.PreviousArrange; + } +} diff --git a/src/Avalonia.Base/Layout/Layoutable.cs b/src/Avalonia.Base/Layout/Layoutable.cs index 775b8adddd..4a273b0291 100644 --- a/src/Avalonia.Base/Layout/Layoutable.cs +++ b/src/Avalonia.Base/Layout/Layoutable.cs @@ -323,6 +323,9 @@ namespace Avalonia.Layout set { SetValue(UseLayoutRoundingProperty, value); } } + /// + /// Gets the available size passed in the previous layout pass, if any. + /// internal Size? PreviousMeasure => _previousMeasure; /// diff --git a/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs b/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs index b5e7298b7e..08fcdb50aa 100644 --- a/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs +++ b/src/Avalonia.Base/Platform/DefaultPlatformSettings.cs @@ -37,7 +37,7 @@ namespace Avalonia.Platform }; } - public event EventHandler? ColorValuesChanged; + public virtual event EventHandler? ColorValuesChanged; protected void OnColorValuesChanged(PlatformColorValues colorValues) { diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs index a4005d4f5f..5bf9ff9d9a 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageFile.cs @@ -18,11 +18,7 @@ internal class BclStorageFile : IStorageBookmarkFile } public FileInfo FileInfo { get; } - - public bool CanOpenRead => true; - - public bool CanOpenWrite => true; - + public string Name => FileInfo.Name; public virtual bool CanBookmark => true; diff --git a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs index ee169d62a5..34409f5fda 100644 --- a/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs +++ b/src/Avalonia.Base/Platform/Storage/FileIO/BclStorageProvider.cs @@ -34,7 +34,7 @@ internal abstract class BclStorageProvider : IStorageProvider : Task.FromResult(null); } - public virtual Task TryGetFileFromPath(Uri filePath) + public virtual Task TryGetFileFromPathAsync(Uri filePath) { if (filePath.IsAbsoluteUri) { @@ -48,7 +48,7 @@ internal abstract class BclStorageProvider : IStorageProvider return Task.FromResult(null); } - public virtual Task TryGetFolderFromPath(Uri folderPath) + public virtual Task TryGetFolderFromPathAsync(Uri folderPath) { if (folderPath.IsAbsoluteUri) { @@ -62,7 +62,7 @@ internal abstract class BclStorageProvider : IStorageProvider return Task.FromResult(null); } - public virtual Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder) + public virtual Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { // Note, this BCL API returns different values depending on the .NET version. // We should also document it. diff --git a/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs index f9c7f9685d..7b0446e224 100644 --- a/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs +++ b/src/Avalonia.Base/Platform/Storage/FilePickerFileType.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.IO; +using System.Linq; namespace Avalonia.Platform.Storage; @@ -21,7 +23,7 @@ public sealed class FilePickerFileType /// List of extensions in GLOB format. I.e. "*.png" or "*.*". /// /// - /// Used on Windows and Linux systems. + /// Used on Windows, Linux and Browser platforms. /// public IReadOnlyList? Patterns { get; set; } @@ -29,7 +31,7 @@ public sealed class FilePickerFileType /// List of extensions in MIME format. /// /// - /// Used on Android, Browser and Linux systems. + /// Used on Android, Linux and Browser platforms. /// public IReadOnlyList? MimeTypes { get; set; } @@ -41,4 +43,14 @@ public sealed class FilePickerFileType /// See https://developer.apple.com/documentation/uniformtypeidentifiers/system_declared_uniform_type_identifiers. /// public IReadOnlyList? AppleUniformTypeIdentifiers { get; set; } + + internal IReadOnlyList? TryGetExtensions() + { + // Converts random glob pattern to a simple extension name. + // GetExtension should be sufficient here. + // Only exception is "*.*proj" patterns that should be filtered as well. + return Patterns?.Select(Path.GetExtension) + .Where(e => !string.IsNullOrEmpty(e) && !e.Contains('*') && e.StartsWith(".")) + .ToArray()!; + } } diff --git a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs index 4aa84e3ec4..2a0ce15279 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageFile.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageFile.cs @@ -10,22 +10,12 @@ namespace Avalonia.Platform.Storage; [NotClientImplementable] public interface IStorageFile : IStorageItem { - /// - /// Returns true, if file is readable. - /// - bool CanOpenRead { get; } - /// /// Opens a stream for read access. /// /// Task OpenReadAsync(); - - /// - /// Returns true, if file is writeable. - /// - bool CanOpenWrite { get; } - + /// /// Opens stream for writing to the file. /// diff --git a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs index 6922151e02..9d3c961e51 100644 --- a/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs +++ b/src/Avalonia.Base/Platform/Storage/IStorageProvider.cs @@ -66,7 +66,7 @@ public interface IStorageProvider /// It also might ask user for the permission, and throw an exception if it was denied. /// /// File or null if it doesn't exist. - Task TryGetFileFromPath(Uri filePath); + Task TryGetFileFromPathAsync(Uri filePath); /// /// Attempts to read folder from the file-system by its path. @@ -78,12 +78,12 @@ public interface IStorageProvider /// It also might ask user for the permission, and throw an exception if it was denied. /// /// Folder or null if it doesn't exist. - Task TryGetFolderFromPath(Uri folderPath); + Task TryGetFolderFromPathAsync(Uri folderPath); /// /// Attempts to read folder from the file-system by its path /// /// Well known folder identifier. /// Folder or null if it doesn't exist. - Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder); + Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder); } diff --git a/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs b/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs index c7772d1196..6f8b945cd6 100644 --- a/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs +++ b/src/Avalonia.Base/Platform/Storage/StorageProviderExtensions.cs @@ -8,48 +8,47 @@ namespace Avalonia.Platform.Storage; /// public static class StorageProviderExtensions { - /// - public static Task TryGetFileFromPath(this IStorageProvider provider, string filePath) + /// + public static Task TryGetFileFromPathAsync(this IStorageProvider provider, string filePath) { - return provider.TryGetFileFromPath(StorageProviderHelpers.FilePathToUri(filePath)); + return provider.TryGetFileFromPathAsync(StorageProviderHelpers.FilePathToUri(filePath)); } - /// - public static Task TryGetFolderFromPath(this IStorageProvider provider, string folderPath) + /// + public static Task TryGetFolderFromPathAsync(this IStorageProvider provider, string folderPath) { - return provider.TryGetFolderFromPath(StorageProviderHelpers.FilePathToUri(folderPath)); + return provider.TryGetFolderFromPathAsync(StorageProviderHelpers.FilePathToUri(folderPath)); } - internal static string? TryGetFullPath(this IStorageFolder folder) + /// + /// Gets the local file system path of the item as a string. + /// + /// Storage folder or file. + /// Full local path to the folder or file if possible, otherwise null. + /// + /// Android platform usually uses "content:" virtual file paths + /// and Browser platform has isolated access without full paths, + /// so on these platforms this method will return null. + /// + public static string? TryGetLocalPath(this IStorageItem item) { // We can avoid double escaping of the path by checking for BclStorageFolder. // Ideally, `folder.Path.LocalPath` should also work, as that's only available way for the users. - if (folder is BclStorageFolder storageFolder) + if (item is BclStorageFolder storageFolder) { return storageFolder.DirectoryInfo.FullName; } - - if (folder.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath) - { - return absolutePath.LocalPath; - } - - // android "content:", browser and ios relative links go here. - return null; - } - - internal static string? TryGetFullPath(this IStorageFile file) - { - if (file is BclStorageFile storageFolder) + if (item is BclStorageFile storageFile) { - return storageFolder.FileInfo.FullName; + return storageFile.FileInfo.FullName; } - if (file.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath) + if (item.Path is { IsAbsoluteUri: true, Scheme: "file" } absolutePath) { return absolutePath.LocalPath; } + // android "content:", browser and ios relative links go here. return null; } } diff --git a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs index e33dc999dc..98be861afa 100644 --- a/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs +++ b/src/Avalonia.Base/Rendering/Composition/Server/ServerCompositionVisual.cs @@ -48,7 +48,8 @@ namespace Avalonia.Rendering.Composition.Server { canvas.PostTransform = Matrix.Identity; canvas.Transform = Matrix.Identity; - canvas.PushClip(AdornedVisual._combinedTransformedClipBounds); + if (AdornerIsClipped) + canvas.PushClip(AdornedVisual._combinedTransformedClipBounds); } var transform = GlobalTransformMatrix; canvas.PostTransform = MatrixUtils.ToMatrix(transform); @@ -74,7 +75,7 @@ namespace Avalonia.Rendering.Composition.Server canvas.PopGeometryClip(); if (ClipToBounds && !HandlesClipToBounds) canvas.PopClip(); - if (AdornedVisual != null) + if (AdornedVisual != null && AdornerIsClipped) canvas.PopClip(); if(Opacity != 1) canvas.PopOpacity(); diff --git a/src/Avalonia.Base/StyledElement.cs b/src/Avalonia.Base/StyledElement.cs index 2cdb973174..5b8dac2f53 100644 --- a/src/Avalonia.Base/StyledElement.cs +++ b/src/Avalonia.Base/StyledElement.cs @@ -524,13 +524,7 @@ namespace Avalonia NotifyResourcesChanged(); } -#nullable disable - RaisePropertyChanged( - ParentProperty, - new Optional(old), - new BindingValue(Parent), - BindingPriority.LocalValue); -#nullable enable + RaisePropertyChanged(ParentProperty, old, Parent); } } diff --git a/src/Avalonia.Base/Visual.cs b/src/Avalonia.Base/Visual.cs index 87bb1d3790..8b0cc06136 100644 --- a/src/Avalonia.Base/Visual.cs +++ b/src/Avalonia.Base/Visual.cs @@ -573,7 +573,7 @@ namespace Avalonia /// The new visual parent. protected virtual void OnVisualParentChanged(Visual? oldParent, Visual? newParent) { - RaisePropertyChanged(VisualParentProperty, oldParent, newParent, BindingPriority.LocalValue); + RaisePropertyChanged(VisualParentProperty, oldParent, newParent); } internal override ParametrizedLogger? GetBindingWarningLogger( diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index 36fd9fe709..31722974ee 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -26,6 +26,7 @@ + diff --git a/src/Avalonia.Controls/Automation/Peers/SliderAutomationPeer.cs b/src/Avalonia.Controls/Automation/Peers/SliderAutomationPeer.cs new file mode 100644 index 0000000000..42b15eec96 --- /dev/null +++ b/src/Avalonia.Controls/Automation/Peers/SliderAutomationPeer.cs @@ -0,0 +1,22 @@ +using Avalonia.Automation.Peers; + +namespace Avalonia.Controls.Automation.Peers +{ + public class SliderAutomationPeer : RangeBaseAutomationPeer + { + public SliderAutomationPeer(Slider owner) : base(owner) + { + } + + override protected string GetClassNameCore() + { + return "Slider"; + } + + override protected AutomationControlType GetAutomationControlTypeCore() + { + return AutomationControlType.Slider; + } + + } +} diff --git a/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs b/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs index a8a266e378..20bfb440e3 100644 --- a/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs +++ b/src/Avalonia.Controls/Platform/Dialogs/SystemDialogImpl.cs @@ -27,7 +27,7 @@ namespace Avalonia.Controls.Platform var files = await filePicker.OpenFilePickerAsync(options); return files - .Select(file => file.TryGetFullPath() ?? file.Name) + .Select(file => file.TryGetLocalPath() ?? file.Name) .ToArray(); } else if (dialog is SaveFileDialog saveDialog) @@ -46,7 +46,7 @@ namespace Avalonia.Controls.Platform return null; } - var filePath = file.TryGetFullPath() ?? file.Name; + var filePath = file.TryGetLocalPath() ?? file.Name; return new[] { filePath }; } return null; @@ -64,7 +64,7 @@ namespace Avalonia.Controls.Platform var folders = await filePicker.OpenFolderPickerAsync(options); return folders - .Select(folder => folder.TryGetFullPath() ?? folder.Name) + .Select(folder => folder.TryGetLocalPath() ?? folder.Name) .FirstOrDefault(u => u is not null); } } diff --git a/src/Avalonia.Controls/Primitives/AdornerLayer.cs b/src/Avalonia.Controls/Primitives/AdornerLayer.cs index 79719912ea..611d57a980 100644 --- a/src/Avalonia.Controls/Primitives/AdornerLayer.cs +++ b/src/Avalonia.Controls/Primitives/AdornerLayer.cs @@ -279,8 +279,11 @@ namespace Avalonia.Controls.Primitives private void UpdateAdornedElement(Visual adorner, Visual? adorned) { if (adorner.CompositionVisual != null) + { adorner.CompositionVisual.AdornedVisual = adorned?.CompositionVisual; - + adorner.CompositionVisual.AdornerIsClipped = GetIsClipEnabled(adorner); + } + var info = adorner.GetValue(s_adornedElementInfoProperty); if (info != null) diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 065c4ff2e5..2ee32b0dda 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -345,10 +345,7 @@ namespace Avalonia.Controls.Primitives if (_oldSelectedItems != SelectedItems) { - RaisePropertyChanged( - SelectedItemsProperty, - new Optional(_oldSelectedItems), - new BindingValue(SelectedItems)); + RaisePropertyChanged(SelectedItemsProperty, _oldSelectedItems, SelectedItems); _oldSelectedItems = SelectedItems; } } @@ -909,10 +906,7 @@ namespace Avalonia.Controls.Primitives else if (e.PropertyName == nameof(InternalSelectionModel.WritableSelectedItems) && _oldSelectedItems != (Selection as InternalSelectionModel)?.SelectedItems) { - RaisePropertyChanged( - SelectedItemsProperty, - new Optional(_oldSelectedItems), - new BindingValue(SelectedItems)); + RaisePropertyChanged(SelectedItemsProperty, _oldSelectedItems, SelectedItems); _oldSelectedItems = SelectedItems; } else if (e.PropertyName == nameof(ISelectionModel.Source)) diff --git a/src/Avalonia.Controls/RelativePanel.AttachedProperties.cs b/src/Avalonia.Controls/RelativePanel.AttachedProperties.cs index 18cf96ddca..d2b91def7d 100644 --- a/src/Avalonia.Controls/RelativePanel.AttachedProperties.cs +++ b/src/Avalonia.Controls/RelativePanel.AttachedProperties.cs @@ -1,37 +1,30 @@ using Avalonia.Layout; +using Avalonia.Threading; namespace Avalonia.Controls { public partial class RelativePanel { - private static void OnAlignPropertiesChanged(AvaloniaObject d, AvaloniaPropertyChangedEventArgs e) - { - if (d is Layoutable layoutable && layoutable.Parent is Layoutable layoutableParent) - { - layoutableParent.InvalidateArrange(); - } - } static RelativePanel() { ClipToBoundsProperty.OverrideDefaultValue(true); - AboveProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignBottomWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignBottomWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignHorizontalCenterWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignHorizontalCenterWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignLeftWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignLeftWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignRightWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignRightWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignTopWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignTopWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignVerticalCenterWithPanelProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - AlignVerticalCenterWithProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - BelowProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - LeftOfProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); - RightOfProperty.Changed.AddClassHandler(OnAlignPropertiesChanged); + AffectsParentArrange( + AlignLeftWithPanelProperty, AlignLeftWithProperty, LeftOfProperty, + AlignRightWithPanelProperty, AlignRightWithProperty, RightOfProperty, + AlignTopWithPanelProperty, AlignTopWithProperty, AboveProperty, + AlignBottomWithPanelProperty, AlignBottomWithProperty, BelowProperty, + AlignHorizontalCenterWithPanelProperty, AlignHorizontalCenterWithProperty, + AlignVerticalCenterWithPanelProperty, AlignVerticalCenterWithProperty); + + AffectsParentMeasure( + AlignLeftWithPanelProperty, AlignLeftWithProperty, LeftOfProperty, + AlignRightWithPanelProperty, AlignRightWithProperty, RightOfProperty, + AlignTopWithPanelProperty, AlignTopWithProperty, AboveProperty, + AlignBottomWithPanelProperty, AlignBottomWithProperty, BelowProperty, + AlignHorizontalCenterWithPanelProperty, AlignHorizontalCenterWithProperty, + AlignVerticalCenterWithPanelProperty, AlignVerticalCenterWithProperty); } /// diff --git a/src/Avalonia.Controls/Slider.cs b/src/Avalonia.Controls/Slider.cs index 828bf2a1fb..7de726a932 100644 --- a/src/Avalonia.Controls/Slider.cs +++ b/src/Avalonia.Controls/Slider.cs @@ -10,6 +10,7 @@ using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Utilities; using Avalonia.Automation; +using Avalonia.Controls.Automation.Peers; namespace Avalonia.Controls { @@ -380,6 +381,11 @@ namespace Avalonia.Controls } } + protected override AutomationPeer OnCreateAutomationPeer() + { + return new SliderAutomationPeer(this); + } + /// protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { diff --git a/src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs b/src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs index a7d279741e..6ea46b6d54 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Screenshots/FilePickerHandler.cs @@ -54,8 +54,8 @@ namespace Avalonia.Diagnostics.Screenshots protected override async Task GetStream(Control control) { var storageProvider = GetTopLevel(control).StorageProvider; - var defaultFolder = await storageProvider.TryGetFolderFromPath(_screenshotRoot) - ?? await storageProvider.TryGetWellKnownFolder(WellKnownFolder.Pictures); + var defaultFolder = await storageProvider.TryGetFolderFromPathAsync(_screenshotRoot) + ?? await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Pictures); var result = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { @@ -68,10 +68,6 @@ namespace Avalonia.Diagnostics.Screenshots { return null; } - if (!result.CanOpenWrite) - { - throw new InvalidOperationException("Read-only file was selected."); - } return await result.OpenWriteAsync(); } diff --git a/src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs b/src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs index 46de460b1a..c929cb9ac9 100644 --- a/src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs +++ b/src/Avalonia.Dialogs/Internal/ManagedFileChooserViewModel.cs @@ -260,7 +260,7 @@ namespace Avalonia.Dialogs.Internal public void Navigate(IStorageFolder path, string initialSelectionName = null) { - var fullDirectoryPath = path?.TryGetFullPath() ?? Directory.GetCurrentDirectory(); + var fullDirectoryPath = path?.TryGetLocalPath() ?? Directory.GetCurrentDirectory(); Navigate(fullDirectoryPath, initialSelectionName); } diff --git a/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs b/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs index cad938ac35..e9a75ab46a 100644 --- a/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs +++ b/src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs @@ -51,7 +51,7 @@ namespace Avalonia.Dialogs var files = await impl.OpenFilePickerAsync(dialog.ToFilePickerOpenOptions()); return files - .Select(file => file.TryGetFullPath() ?? file.Name) + .Select(file => file.TryGetLocalPath() ?? file.Name) .ToArray(); } } diff --git a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs index 6f647215be..905ce1f272 100644 --- a/src/Avalonia.FreeDesktop/DBusSystemDialog.cs +++ b/src/Avalonia.FreeDesktop/DBusSystemDialog.cs @@ -88,7 +88,7 @@ namespace Avalonia.FreeDesktop if (options.SuggestedFileName is { } currentName) chooserOptions.Add("current_name", currentName); - if (options.SuggestedStartLocation?.TryGetFullPath() is { } folderPath) + if (options.SuggestedStartLocation?.TryGetLocalPath() is { } folderPath) chooserOptions.Add("current_folder", Encoding.UTF8.GetBytes(folderPath)); objectPath = await _fileChooser.SaveFileAsync(parentWindow, options.Title ?? string.Empty, chooserOptions); diff --git a/src/Avalonia.Native/SystemDialogs.cs b/src/Avalonia.Native/SystemDialogs.cs index 60724507f3..1d23ad9997 100644 --- a/src/Avalonia.Native/SystemDialogs.cs +++ b/src/Avalonia.Native/SystemDialogs.cs @@ -33,7 +33,7 @@ namespace Avalonia.Native { using var events = new SystemDialogEvents(); - var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty; + var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; _native.OpenFileDialog((IAvnWindow)_window.Native, events, @@ -53,7 +53,7 @@ namespace Avalonia.Native { using var events = new SystemDialogEvents(); - var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty; + var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; _native.SaveFileDialog((IAvnWindow)_window.Native, events, @@ -72,7 +72,7 @@ namespace Avalonia.Native { using var events = new SystemDialogEvents(); - var suggestedDirectory = options.SuggestedStartLocation?.TryGetFullPath() ?? string.Empty; + var suggestedDirectory = options.SuggestedStartLocation?.TryGetLocalPath() ?? string.Empty; _native.SelectFolderDialog((IAvnWindow)_window.Native, events, options.AllowMultiple.AsComBool(), options.Title ?? "", suggestedDirectory); diff --git a/src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs b/src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs index 07a11ff2ec..16416c7e56 100644 --- a/src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs +++ b/src/Avalonia.X11/NativeDialogs/CompositeStorageProvider.cs @@ -62,21 +62,21 @@ internal class CompositeStorageProvider : IStorageProvider return await provider.OpenFolderBookmarkAsync(bookmark).ConfigureAwait(false); } - public async Task TryGetFileFromPath(Uri filePath) + public async Task TryGetFileFromPathAsync(Uri filePath) { var provider = await EnsureStorageProvider().ConfigureAwait(false); - return await provider.TryGetFileFromPath(filePath).ConfigureAwait(false); + return await provider.TryGetFileFromPathAsync(filePath).ConfigureAwait(false); } - public async Task TryGetFolderFromPath(Uri folderPath) + public async Task TryGetFolderFromPathAsync(Uri folderPath) { var provider = await EnsureStorageProvider().ConfigureAwait(false); - return await provider.TryGetFolderFromPath(folderPath).ConfigureAwait(false); + return await provider.TryGetFolderFromPathAsync(folderPath).ConfigureAwait(false); } - public async Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder) + public async Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { var provider = await EnsureStorageProvider().ConfigureAwait(false); - return await provider.TryGetWellKnownFolder(wellKnownFolder).ConfigureAwait(false); + return await provider.TryGetWellKnownFolderAsync(wellKnownFolder).ConfigureAwait(false); } } diff --git a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs index ceb32e52e9..16e1a1bce8 100644 --- a/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs +++ b/src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs @@ -196,7 +196,7 @@ namespace Avalonia.X11.NativeDialogs gtk_dialog_add_button(dlg, open, GtkResponseType.Cancel); } - var folderLocalPath = initialFolder?.TryGetFullPath(); + var folderLocalPath = initialFolder?.TryGetLocalPath(); if (folderLocalPath is not null) { using var dir = new Utf8Buffer(folderLocalPath); diff --git a/src/Browser/Avalonia.Browser.Blazor/AvaloniaView.cs b/src/Browser/Avalonia.Browser.Blazor/AvaloniaView.cs index 68efea31d6..1fc87fed2f 100644 --- a/src/Browser/Avalonia.Browser.Blazor/AvaloniaView.cs +++ b/src/Browser/Avalonia.Browser.Blazor/AvaloniaView.cs @@ -30,12 +30,10 @@ public class AvaloniaView : ComponentBase builder.CloseElement(); } - protected override async Task OnInitializedAsync() + protected override void OnAfterRender(bool firstRender) { - if (OperatingSystem.IsBrowser()) + if (firstRender) { - await AvaloniaModule.ImportMain(); - _browserView = new Browser.AvaloniaView(_containerId); if (Application.Current?.ApplicationLifetime is ISingleViewApplicationLifetime lifetime) { @@ -43,4 +41,12 @@ public class AvaloniaView : ComponentBase } } } + + protected override void OnInitialized() + { + if (!OperatingSystem.IsBrowser()) + { + throw new NotSupportedException("Avalonia doesn't support server-side Blazor"); + } + } } diff --git a/src/Browser/Avalonia.Browser.Blazor/BlazorSingleViewLifetime.cs b/src/Browser/Avalonia.Browser.Blazor/BlazorSingleViewLifetime.cs index a7bb5a62df..7c5ee27c66 100644 --- a/src/Browser/Avalonia.Browser.Blazor/BlazorSingleViewLifetime.cs +++ b/src/Browser/Avalonia.Browser.Blazor/BlazorSingleViewLifetime.cs @@ -1,33 +1,28 @@ -using System.Runtime.Versioning; - +using System; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser.Interop; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; namespace Avalonia.Browser.Blazor; -public static class WebAppBuilder +public static class BlazorAppBuilder { - public static AppBuilder SetupWithSingleViewLifetime( - this AppBuilder builder) + /// + /// Configures blazor backend, loads avalonia javascript modules and creates a single view lifetime. + /// + /// Application builder. + /// Browser backend specific options. + public static async Task StartBlazorAppAsync(this AppBuilder builder, BrowserPlatformOptions? options = null) { - return builder.SetupWithLifetime(new BlazorSingleViewLifetime()); - } + options ??= new BrowserPlatformOptions(); + options.FrameworkAssetPathResolver ??= filePath => $"/_content/Avalonia.Browser.Blazor/{filePath}"; - public static AppBuilder UseBlazor(this AppBuilder builder) - { - return builder - .UseBrowser() - .With(new BrowserPlatformOptions - { - FrameworkAssetPathResolver = new(filePath => $"/_content/Avalonia.Browser.Blazor/{filePath}") - }); - } + builder = await BrowserAppBuilder.PreSetupBrowser(builder, options); - public static AppBuilder Configure() - where TApp : Application, new() - { - return AppBuilder.Configure() - .UseBlazor(); + builder.SetupWithLifetime(new BlazorSingleViewLifetime()); } internal class BlazorSingleViewLifetime : ISingleViewApplicationLifetime diff --git a/src/Browser/Avalonia.Browser/AvaloniaView.cs b/src/Browser/Avalonia.Browser/AvaloniaView.cs index 3f4aa0d0ba..3bb7260e55 100644 --- a/src/Browser/Avalonia.Browser/AvaloniaView.cs +++ b/src/Browser/Avalonia.Browser/AvaloniaView.cs @@ -20,7 +20,7 @@ using static System.Runtime.CompilerServices.RuntimeHelpers; namespace Avalonia.Browser { - public partial class AvaloniaView : ITextInputMethodImpl + public class AvaloniaView : ITextInputMethodImpl { private static readonly PooledList s_intermediatePointsPooledList = new(ClearMode.Never); private readonly BrowserTopLevelImpl _topLevelImpl; @@ -43,8 +43,9 @@ namespace Avalonia.Browser private bool _useGL; private ITextInputMethodClient? _client; + /// ID of the html element where avalonia content should be rendered. public AvaloniaView(string divId) - : this(DomHelper.GetElementById(divId) ?? throw new Exception($"Element with id {divId} was not found in the html document.")) + : this(DomHelper.GetElementById(divId) ?? throw new Exception($"Element with id '{divId}' was not found in the html document.")) { } @@ -380,12 +381,10 @@ namespace Avalonia.Browser { if (_useGL && (_jsGlInfo == null)) { - Console.WriteLine("nothing to render"); return; } if (_canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0) { - Console.WriteLine("nothing to render"); return; } @@ -458,7 +457,6 @@ namespace Avalonia.Browser void ITextInputMethodImpl.SetClient(ITextInputMethodClient? client) { - Console.WriteLine("Set Client"); if (_client != null) { _client.SurroundingTextChanged -= SurroundingTextChanged; @@ -481,8 +479,6 @@ namespace Avalonia.Browser var surroundingText = _client.SurroundingText; InputHelper.SetSurroundingText(_inputElement, surroundingText.Text, surroundingText.AnchorOffset, surroundingText.CursorOffset); - - Console.WriteLine("Shown, focused and surrounded."); } else { diff --git a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs new file mode 100644 index 0000000000..32637b6d1b --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Browser.Interop; + +namespace Avalonia.Browser; + +public class BrowserPlatformOptions +{ + /// + /// Defines paths where avalonia modules and service locator should be resolved. + /// If null, default path resolved depending on the backend (browser or blazor) is used. + /// + public Func? FrameworkAssetPathResolver { get; set; } +} + +public static class BrowserAppBuilder +{ + /// + /// Configures browser backend, loads avalonia javascript modules and creates a single view lifetime from the passed parameter. + /// + /// Application builder. + /// ID of the html element where avalonia content should be rendered. + /// Browser backend specific options. + public static async Task StartBrowserAppAsync(this AppBuilder builder, string mainDivId, BrowserPlatformOptions? options = null) + { + if (mainDivId is null) + { + throw new ArgumentNullException(nameof(mainDivId)); + } + + builder = await PreSetupBrowser(builder, options); + + var lifetime = new BrowserSingleViewLifetime(); + builder + .AfterSetup(_ => + { + lifetime.View = new AvaloniaView(mainDivId); + }) + .SetupWithLifetime(lifetime); + } + + /// + /// Loads avalonia javascript modules and configures browser backend. + /// + /// Application builder. + /// Browser backend specific options. + /// + /// This method doesn't creates any avalonia views to be rendered. To do so create an object. + /// Alternatively, you can call method instead of . + /// + public static async Task SetupBrowserAppAsync(this AppBuilder builder, BrowserPlatformOptions? options = null) + { + builder = await PreSetupBrowser(builder, options); + + builder + .SetupWithoutStarting(); + } + + internal static async Task PreSetupBrowser(AppBuilder builder, BrowserPlatformOptions? options) + { + options ??= new BrowserPlatformOptions(); + options.FrameworkAssetPathResolver ??= fileName => $"./{fileName}"; + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(options); + + await AvaloniaModule.ImportMain(); + + if (builder.WindowingSubsystemInitializer is null) + { + builder = builder.UseBrowser(); + } + + return builder; + } + + public static AppBuilder UseBrowser( + this AppBuilder builder) + { + return builder + .UseWindowingSubsystem(BrowserWindowingPlatform.Register) + .UseSkia(); + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserPlatformSettings.cs b/src/Browser/Avalonia.Browser/BrowserPlatformSettings.cs index 6084c5c7de..fa647d31b7 100644 --- a/src/Browser/Avalonia.Browser/BrowserPlatformSettings.cs +++ b/src/Browser/Avalonia.Browser/BrowserPlatformSettings.cs @@ -1,4 +1,5 @@ -using Avalonia.Browser.Interop; +using System; +using Avalonia.Browser.Interop; using Avalonia.Platform; namespace Avalonia.Browser; @@ -7,25 +8,44 @@ internal class BrowserPlatformSettings : DefaultPlatformSettings { private bool _isDarkMode; private bool _isHighContrast; - - public BrowserPlatformSettings() + private bool _isInitialized; + + public override event EventHandler? ColorValuesChanged { - var obj = DomHelper.ObserveDarkMode((isDarkMode, isHighContrast) => + add { - _isDarkMode = isDarkMode; - _isHighContrast = isHighContrast; - OnColorValuesChanged(GetColorValues()); - }); - _isDarkMode = obj.GetPropertyAsBoolean("isDarkMode"); - _isHighContrast = obj.GetPropertyAsBoolean("isHighContrast"); + EnsureBackend(); + base.ColorValuesChanged += value; + } + remove => base.ColorValuesChanged -= value; } public override PlatformColorValues GetColorValues() { + EnsureBackend(); + return base.GetColorValues() with { ThemeVariant = _isDarkMode ? PlatformThemeVariant.Dark : PlatformThemeVariant.Light, ContrastPreference = _isHighContrast ? ColorContrastPreference.High : ColorContrastPreference.NoPreference }; } + + private void EnsureBackend() + { + if (!_isInitialized) + { + // WASM module has async nature of initialization. We can't native code right away during components registration. + _isInitialized = true; + + var obj = DomHelper.ObserveDarkMode((isDarkMode, isHighContrast) => + { + _isDarkMode = isDarkMode; + _isHighContrast = isHighContrast; + OnColorValuesChanged(GetColorValues()); + }); + _isDarkMode = obj.GetPropertyAsBoolean("isDarkMode"); + _isHighContrast = obj.GetPropertyAsBoolean("isHighContrast"); + } + } } diff --git a/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs b/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs index add69760ee..6fa79f6f54 100644 --- a/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs +++ b/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs @@ -1,47 +1,36 @@ using System; +using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using System.Runtime.Versioning; +using Avalonia.Browser; -namespace Avalonia.Browser; +namespace Avalonia; -public class BrowserSingleViewLifetime : ISingleViewApplicationLifetime +internal class BrowserSingleViewLifetime : ISingleViewApplicationLifetime { public AvaloniaView? View; public Control? MainView { - get => View!.Content; - set => View!.Content = value; - } -} - -public class BrowserPlatformOptions -{ - public Func FrameworkAssetPathResolver { get; set; } = new(fileName => $"./{fileName}"); -} - -public static class WebAppBuilder -{ - public static AppBuilder SetupBrowserApp( - this AppBuilder builder, string mainDivId) - { - var lifetime = new BrowserSingleViewLifetime(); - - return builder - .UseBrowser() - .AfterSetup(b => - { - lifetime.View = new AvaloniaView(mainDivId); - }) - .SetupWithLifetime(lifetime); + get + { + EnsureView(); + return View.Content; + } + set + { + EnsureView(); + View.Content = value; + } } - public static AppBuilder UseBrowser( - this AppBuilder builder) + [MemberNotNull(nameof(View))] + private void EnsureView() { - return builder - .UseWindowingSubsystem(BrowserWindowingPlatform.Register) - .UseSkia(); + if (View is null) + { + throw new InvalidOperationException("Browser lifetime was not initialized. Make sure AppBuilder.StartBrowserApp was called."); + } } } diff --git a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs index b283fbaa56..f1936a8d97 100644 --- a/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs +++ b/src/Browser/Avalonia.Browser/Interop/AvaloniaModule.cs @@ -11,13 +11,13 @@ internal static partial class AvaloniaModule public static Task ImportMain() { var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); - return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver("avalonia.js")); + return JSHost.ImportAsync(MainModuleName, options.FrameworkAssetPathResolver!("avalonia.js")); } public static Task ImportStorage() { var options = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); - return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver("storage.js")); + return JSHost.ImportAsync(StorageModuleName, options.FrameworkAssetPathResolver!("storage.js")); } [JSImport("Caniuse.isMobile", AvaloniaModule.MainModuleName)] diff --git a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs index a4d7bcdb87..11beba6f2c 100644 --- a/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/StorageHelper.cs @@ -5,14 +5,8 @@ namespace Avalonia.Browser.Interop; internal static partial class StorageHelper { - [JSImport("Caniuse.canShowOpenFilePicker", AvaloniaModule.MainModuleName)] - public static partial bool CanShowOpenFilePicker(); - - [JSImport("Caniuse.canShowSaveFilePicker", AvaloniaModule.MainModuleName)] - public static partial bool CanShowSaveFilePicker(); - - [JSImport("Caniuse.canShowDirectoryPicker", AvaloniaModule.MainModuleName)] - public static partial bool CanShowDirectoryPicker(); + [JSImport("Caniuse.hasNativeFilePicker", AvaloniaModule.MainModuleName)] + public static partial bool HasNativeFilePicker(); [JSImport("StorageProvider.selectFolderDialog", AvaloniaModule.StorageModuleName)] public static partial Task SelectFolderDialog(JSObject? startIn); @@ -54,5 +48,5 @@ internal static partial class StorageHelper public static partial JSObject[] ItemsArray(JSObject item); [JSImport("StorageProvider.createAcceptType", AvaloniaModule.StorageModuleName)] - public static partial JSObject CreateAcceptType(string description, string[] mimeTypes); + public static partial JSObject CreateAcceptType(string description, string[] mimeTypes, string[]? extensions); } diff --git a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs index 7e8e2e0990..5b76d53a9d 100644 --- a/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs +++ b/src/Browser/Avalonia.Browser/Storage/BrowserStorageProvider.cs @@ -20,9 +20,9 @@ internal class BrowserStorageProvider : IStorageProvider private readonly Lazy _lazyModule = new(() => AvaloniaModule.ImportStorage()); - public bool CanOpen => StorageHelper.CanShowOpenFilePicker(); - public bool CanSave => StorageHelper.CanShowSaveFilePicker(); - public bool CanPickFolder => StorageHelper.CanShowDirectoryPicker(); + public bool CanOpen => true; + public bool CanSave => StorageHelper.HasNativeFilePicker(); + public bool CanPickFolder => true; public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) { @@ -116,17 +116,17 @@ internal class BrowserStorageProvider : IStorageProvider return item is not null ? new JSStorageFolder(item) : null; } - public Task TryGetFileFromPath(Uri filePath) + public Task TryGetFileFromPathAsync(Uri filePath) { return Task.FromResult(null); } - public Task TryGetFolderFromPath(Uri folderPath) + public Task TryGetFolderFromPathAsync(Uri folderPath) { return Task.FromResult(null); } - public async Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder) + public async Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { await _lazyModule.Value; var directory = StorageHelper.CreateWellKnownDirectory(wellKnownFolder switch @@ -147,7 +147,7 @@ internal class BrowserStorageProvider : IStorageProvider { var types = input? .Where(t => t.MimeTypes?.Any() == true && t != FilePickerFileTypes.All) - .Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray())) + .Select(t => StorageHelper.CreateAcceptType(t.Name, t.MimeTypes!.ToArray(), t.TryGetExtensions()?.ToArray())) .ToArray(); if (types?.Length == 0) { @@ -186,10 +186,15 @@ internal abstract class JSStorageItem : IStorageBookmarkItem dateModified: lastModified > 0 ? DateTimeOffset.FromUnixTimeMilliseconds(lastModified.Value) : null); } - public bool CanBookmark => true; + public bool CanBookmark => StorageHelper.HasNativeFilePicker(); public Task SaveBookmarkAsync() { + if (!CanBookmark) + { + return Task.FromResult(null); + } + return StorageHelper.SaveBookmark(FileHandle); } @@ -200,6 +205,11 @@ internal abstract class JSStorageItem : IStorageBookmarkItem public Task ReleaseBookmarkAsync() { + if (!CanBookmark) + { + return Task.CompletedTask; + } + return StorageHelper.DeleteBookmark(FileHandle); } @@ -216,7 +226,6 @@ internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile { } - public bool CanOpenRead => true; public async Task OpenReadAsync() { try @@ -230,7 +239,6 @@ internal class JSStorageFile : JSStorageItem, IStorageBookmarkFile } } - public bool CanOpenWrite => true; public async Task OpenWriteAsync() { try diff --git a/src/Browser/Avalonia.Browser/webapp/.eslintrc.json b/src/Browser/Avalonia.Browser/webapp/.eslintrc.json index 4b7e24987f..f4fb8e37bf 100644 --- a/src/Browser/Avalonia.Browser/webapp/.eslintrc.json +++ b/src/Browser/Avalonia.Browser/webapp/.eslintrc.json @@ -43,5 +43,5 @@ } ] }, - "ignorePatterns": ["types/*"] + "ignorePatterns": ["types/*","node_modules/*"] } diff --git a/src/Browser/Avalonia.Browser/webapp/build.js b/src/Browser/Avalonia.Browser/webapp/build.js index 81f863cac7..c1cbc84709 100644 --- a/src/Browser/Avalonia.Browser/webapp/build.js +++ b/src/Browser/Avalonia.Browser/webapp/build.js @@ -7,7 +7,7 @@ require("esbuild").build({ bundle: true, minify: true, format: "esm", - target: "es2016", + target: "es2018", platform: "browser", sourcemap: "linked", loader: { ".ts": "ts" } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts index ab0c85eaa2..3fb4124c96 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts @@ -1,4 +1,3 @@ -import { RuntimeAPI } from "../types/dotnet"; import { SizeWatcher, DpiWatcher, Canvas } from "./avalonia/canvas"; import { InputHelper } from "./avalonia/input"; import { AvaloniaDOM } from "./avalonia/dom"; @@ -7,19 +6,6 @@ import { StreamHelper } from "./avalonia/stream"; import { NativeControlHost } from "./avalonia/nativeControlHost"; import { NavigationHelper } from "./avalonia/navigationHelper"; -async function registerAvaloniaModule(api: RuntimeAPI): Promise { - api.setModuleImports("avalonia", { - Caniuse, - Canvas, - InputHelper, - SizeWatcher, - DpiWatcher, - AvaloniaDOM, - StreamHelper, - NativeControlHost, - NavigationHelper - }); -} export { Caniuse, Canvas, @@ -29,7 +15,5 @@ export { AvaloniaDOM, StreamHelper, NativeControlHost, - NavigationHelper, - - registerAvaloniaModule + NavigationHelper }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts index e019f92113..8fdc3a5c01 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/caniuse.ts @@ -1,14 +1,6 @@ export class Caniuse { - public static canShowOpenFilePicker(): boolean { - return typeof globalThis.showOpenFilePicker !== "undefined"; - } - - public static canShowSaveFilePicker(): boolean { - return typeof globalThis.showSaveFilePicker !== "undefined"; - } - - public static canShowDirectoryPicker(): boolean { - return typeof globalThis.showDirectoryPicker !== "undefined"; + public static hasNativeFilePicker(): boolean { + return "showSaveFilePicker" in globalThis; } public static isMobile(): boolean { diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts index 1f2c181edc..7c7769ea36 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/stream.ts @@ -1,3 +1,4 @@ +import FileSystemWritableFileStream from "native-file-system-adapter/types/src/FileSystemWritableFileStream"; import { IMemoryView } from "../../types/dotnet"; export class StreamHelper { @@ -17,7 +18,7 @@ export class StreamHelper { const array = new Uint8Array(span.byteLength); span.copyTo(array); - const data: WriteParams = { + const data = { type: "write", data: array }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts index c6e5254329..8f47e61100 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageItem.ts @@ -1,8 +1,10 @@ import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; +import { FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFileStream } from "native-file-system-adapter"; +import { Caniuse } from "../avalonia"; export class StorageItem { constructor( - public handle?: FileSystemHandle, + public handle?: FileSystemFileHandle | FileSystemDirectoryHandle, private readonly bookmarkId?: string, public wellKnownType?: WellKnownDirectory ) { @@ -27,39 +29,44 @@ export class StorageItem { } public static async openRead(item: StorageItem): Promise { - if (!(item.handle instanceof FileSystemFileHandle)) { + if (!item.handle || item.kind !== "file") { throw new Error("StorageItem is not a file"); } await item.verityPermissions("read"); - const file = await item.handle.getFile(); + const file = await (item.handle as FileSystemFileHandle).getFile(); return file; } public static async openWrite(item: StorageItem): Promise { - if (!(item.handle instanceof FileSystemFileHandle)) { + if (!item.handle || item.kind !== "file") { throw new Error("StorageItem is not a file"); } await item.verityPermissions("readwrite"); - return await item.handle.createWritable({ keepExistingData: true }); + return await (item.handle as FileSystemFileHandle).createWritable({ keepExistingData: true }); } public static async getProperties(item: StorageItem): Promise<{ Size: number; LastModified: number; Type: string } | null> { - const file = item.handle instanceof FileSystemFileHandle && - await item.handle.getFile(); - - if (!file) { + // getFile can fail with an exception depending if we use polyfill with a save file dialog or not. + try { + const file = item.handle instanceof FileSystemFileHandle && + await item.handle.getFile(); + + if (!file) { + return null; + } + + return { + Size: file.size, + LastModified: file.lastModified, + Type: file.type + }; + } catch { return null; } - - return { - Size: file.size, - LastModified: file.lastModified, - Type: file.type - }; } public static async getItems(item: StorageItem): Promise { @@ -74,11 +81,16 @@ export class StorageItem { return new StorageItems(items); } - private async verityPermissions(mode: FileSystemPermissionMode): Promise { + private async verityPermissions(mode: "read" | "readwrite"): Promise { if (!this.handle) { return; } + // If we are using polyfill, let it decide permissions by itself, we can't request anything in this case. + if (!Caniuse.hasNativeFilePicker()) { + return; + } + if (await this.handle.queryPermission({ mode }) === "granted") { return; } @@ -93,7 +105,9 @@ export class StorageItem { if (item.bookmarkId) { return item.bookmarkId; } - if (!item.handle) { + + // Bookmarks are not supported with polyfill. + if (!item.handle || !Caniuse.hasNativeFilePicker()) { return null; } @@ -107,7 +121,7 @@ export class StorageItem { } public static async deleteBookmark(item: StorageItem): Promise { - if (!item.bookmarkId) { + if (!item.bookmarkId || !Caniuse.hasNativeFilePicker()) { return; } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts index e621a1ed30..750c38b8ea 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/storage/storageProvider.ts @@ -1,14 +1,12 @@ import { avaloniaDb, fileBookmarksStore } from "./indexedDb"; import { StorageItem, StorageItems } from "./storageItem"; +import { showOpenFilePicker, showDirectoryPicker, FileSystemFileHandle } from "native-file-system-adapter"; declare global { type WellKnownDirectory = "desktop" | "documents" | "downloads" | "music" | "pictures" | "videos"; - type StartInDirectory = WellKnownDirectory | FileSystemHandle; - interface OpenFilePickerOptions { - startIn?: StartInDirectory; - } - interface SaveFilePickerOptions { - startIn?: StartInDirectory; + interface FilePickerAcceptType { + description?: string | undefined; + accept: Record; } } @@ -16,39 +14,40 @@ export class StorageProvider { public static async selectFolderDialog( startIn: StorageItem | null): Promise { // 'Picker' API doesn't accept "null" as a parameter, so it should be set to undefined. - const options: DirectoryPickerOptions = { + const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined) }; - const handle = await window.showDirectoryPicker(options); + const handle = await showDirectoryPicker(options as any); return new StorageItem(handle); } public static async openFileDialog( startIn: StorageItem | null, multiple: boolean, types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { - const options: OpenFilePickerOptions = { + const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), multiple, excludeAcceptAllOption, types: (types ?? undefined) }; - const handles = await window.showOpenFilePicker(options); - return new StorageItems(handles.map((handle: FileSystemHandle) => new StorageItem(handle))); + const handles = await showOpenFilePicker(options); + return new StorageItems(handles.map((handle: FileSystemFileHandle) => new StorageItem(handle))); } public static async saveFileDialog( startIn: StorageItem | null, suggestedName: string | null, types: FilePickerAcceptType[] | null, excludeAcceptAllOption: boolean): Promise { - const options: SaveFilePickerOptions = { + const options = { startIn: (startIn?.wellKnownType ?? startIn?.handle ?? undefined), suggestedName: (suggestedName ?? undefined), excludeAcceptAllOption, types: (types ?? undefined) }; - const handle = await window.showSaveFilePicker(options); + // Always prefer native save file picker, as polyfill solutions are not reliable. + const handle = await (globalThis as any).showSaveFilePicker(options); return new StorageItem(handle); } @@ -62,9 +61,9 @@ export class StorageProvider { } } - public static createAcceptType(description: string, mimeTypes: string[]): FilePickerAcceptType { + public static createAcceptType(description: string, mimeTypes: string[], extensions: string[] | undefined): FilePickerAcceptType { const accept: Record = {}; - mimeTypes.forEach(a => { accept[a] = []; }); + mimeTypes.forEach(a => { accept[a] = extensions ?? []; }); return { description, accept }; } } diff --git a/src/Browser/Avalonia.Browser/webapp/package-lock.json b/src/Browser/Avalonia.Browser/webapp/package-lock.json index 2d875e84db..12757fd7a0 100644 --- a/src/Browser/Avalonia.Browser/webapp/package-lock.json +++ b/src/Browser/Avalonia.Browser/webapp/package-lock.json @@ -5,9 +5,11 @@ "packages": { "": { "name": "avalonia.browser", + "dependencies": { + "native-file-system-adapter": "github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027" + }, "devDependencies": { "@types/emscripten": "^1.39.6", - "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.38.1", "esbuild": "^0.15.7", "eslint": "^8.24.0", @@ -170,12 +172,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/wicg-file-system-access": { - "version": "2020.9.5", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", - "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", - "dev": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.38.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz", @@ -1573,6 +1569,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2289,6 +2308,27 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/native-file-system-adapter": { + "version": "3.0.0", + "resolved": "git+ssh://git@github.com/jimmywarting/native-file-system-adapter.git#d43ad841581c2cc3ce47bbd1e8f11950ebdff027", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.8.0" + }, + "optionalDependencies": { + "fetch-blob": "^3.2.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2301,6 +2341,25 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -3196,6 +3255,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "optional": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3366,12 +3434,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "@types/wicg-file-system-access": { - "version": "2020.9.5", - "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", - "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", - "dev": true - }, "@typescript-eslint/eslint-plugin": { "version": "5.38.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz", @@ -4275,6 +4337,16 @@ "reusify": "^1.0.4" } }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "optional": true, + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4796,6 +4868,13 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "native-file-system-adapter": { + "version": "git+ssh://git@github.com/jimmywarting/native-file-system-adapter.git#d43ad841581c2cc3ce47bbd1e8f11950ebdff027", + "from": "native-file-system-adapter@github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027", + "requires": { + "fetch-blob": "^3.2.0" + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4808,6 +4887,12 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "optional": true + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -5446,6 +5531,12 @@ "spdx-expression-parse": "^3.0.0" } }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "optional": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/src/Browser/Avalonia.Browser/webapp/package.json b/src/Browser/Avalonia.Browser/webapp/package.json index 05a3976ccc..20dd8d4e6a 100644 --- a/src/Browser/Avalonia.Browser/webapp/package.json +++ b/src/Browser/Avalonia.Browser/webapp/package.json @@ -8,7 +8,6 @@ }, "devDependencies": { "@types/emscripten": "^1.39.6", - "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.38.1", "esbuild": "^0.15.7", "eslint": "^8.24.0", @@ -18,5 +17,8 @@ "eslint-plugin-promise": "^6.0.1", "npm-run-all": "^4.1.5", "typescript": "^4.8.3" + }, + "dependencies": { + "native-file-system-adapter": "github:jimmywarting/native-file-system-adapter#d43ad841581c2cc3ce47bbd1e8f11950ebdff027" } } diff --git a/src/Browser/Avalonia.Browser/webapp/tsconfig.json b/src/Browser/Avalonia.Browser/webapp/tsconfig.json index ad0e727150..1450ce4c57 100644 --- a/src/Browser/Avalonia.Browser/webapp/tsconfig.json +++ b/src/Browser/Avalonia.Browser/webapp/tsconfig.json @@ -1,14 +1,16 @@ { "compilerOptions": { - "target": "es2016", + "target": "es2018", "module": "es2020", "strict": true, "sourceMap": true, "noEmitOnError": true, + "moduleResolution": "node", + "skipLibCheck": true, "isolatedModules": true, // we need it for esbuild "lib": [ "dom", - "es2016", + "es2018", "esnext.asynciterable" ] }, diff --git a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs index 5ca4ef63bf..48ebd4068e 100644 --- a/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs +++ b/src/Windows/Avalonia.Win32/Automation/AutomationNode.cs @@ -20,7 +20,6 @@ namespace Avalonia.Win32.Automation IRawElementProviderSimple, IRawElementProviderSimple2, IRawElementProviderFragment, - IRawElementProviderAdviseEvents, IInvokeProvider { private static Dictionary s_propertyMap = new() @@ -47,14 +46,31 @@ namespace Avalonia.Win32.Automation private static ConditionalWeakTable s_nodes = new(); private readonly int[] _runtimeId; - private int _raiseFocusChanged; - private int _raisePropertyChanged; public AutomationNode(AutomationPeer peer) { _runtimeId = new int[] { 3, GetHashCode() }; Peer = peer; s_nodes.Add(peer, this); + peer.ChildrenChanged += Peer_ChildrenChanged; + peer.PropertyChanged += Peer_PropertyChanged; + } + + private void Peer_ChildrenChanged(object? sender, EventArgs e) + { + ChildrenChanged(); + } + + private void Peer_PropertyChanged(object? sender, AutomationPropertyChangedEventArgs e) + { + if (s_propertyMap.TryGetValue(e.Property, out var id)) + { + UiaCoreProviderApi.UiaRaiseAutomationPropertyChangedEvent( + this, + (int)id, + e.OldValue as IConvertible, + e.NewValue as IConvertible); + } } public AutomationPeer Peer { get; protected set; } @@ -86,14 +102,6 @@ namespace Avalonia.Win32.Automation 0); } - public void PropertyChanged(AutomationProperty property, object? oldValue, object? newValue) - { - if (_raisePropertyChanged > 0 && s_propertyMap.TryGetValue(property, out var id)) - { - UiaCoreProviderApi.UiaRaiseAutomationPropertyChangedEvent(this, (int)id, oldValue, newValue); - } - } - [return: MarshalAs(UnmanagedType.IUnknown)] public virtual object? GetPatternProvider(int patternId) { @@ -188,32 +196,6 @@ namespace Avalonia.Win32.Automation void IRawElementProviderSimple2.ShowContextMenu() => InvokeSync(() => Peer.ShowContextMenu()); void IInvokeProvider.Invoke() => InvokeSync((AAP.IInvokeProvider x) => x.Invoke()); - void IRawElementProviderAdviseEvents.AdviseEventAdded(int eventId, int[] properties) - { - switch ((UiaEventId)eventId) - { - case UiaEventId.AutomationPropertyChanged: - ++_raisePropertyChanged; - break; - case UiaEventId.AutomationFocusChanged: - ++_raiseFocusChanged; - break; - } - } - - void IRawElementProviderAdviseEvents.AdviseEventRemoved(int eventId, int[] properties) - { - switch ((UiaEventId)eventId) - { - case UiaEventId.AutomationPropertyChanged: - --_raisePropertyChanged; - break; - case UiaEventId.AutomationFocusChanged: - --_raiseFocusChanged; - break; - } - } - protected void InvokeSync(Action action) { if (Dispatcher.UIThread.CheckAccess()) @@ -266,15 +248,6 @@ namespace Avalonia.Win32.Automation throw new NotSupportedException(); } - protected void RaiseFocusChanged(AutomationNode? focused) - { - if (_raiseFocusChanged > 0) - { - UiaCoreProviderApi.UiaRaiseAutomationEvent( - focused, - (int)UiaEventId.AutomationFocusChanged); - } - } private AutomationNode? GetRoot() { diff --git a/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs b/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs index 3d8b4995ad..ff8ff69d5e 100644 --- a/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs +++ b/src/Windows/Avalonia.Win32/Automation/RootAutomationNode.cs @@ -10,8 +10,11 @@ namespace Avalonia.Win32.Automation { [RequiresUnreferencedCode("Requires .NET COM interop")] internal class RootAutomationNode : AutomationNode, - IRawElementProviderFragmentRoot + IRawElementProviderFragmentRoot, + IRawElementProviderAdviseEvents { + private int _raiseFocusChanged; + public RootAutomationNode(AutomationPeer peer) : base(peer) { @@ -42,6 +45,36 @@ namespace Avalonia.Win32.Automation return GetOrCreate(focus); } + void IRawElementProviderAdviseEvents.AdviseEventAdded(int eventId, int[] properties) + { + switch ((UiaEventId)eventId) + { + case UiaEventId.AutomationFocusChanged: + ++_raiseFocusChanged; + break; + } + } + + void IRawElementProviderAdviseEvents.AdviseEventRemoved(int eventId, int[] properties) + { + switch ((UiaEventId)eventId) + { + case UiaEventId.AutomationFocusChanged: + --_raiseFocusChanged; + break; + } + } + + protected void RaiseFocusChanged(AutomationNode? focused) + { + if (_raiseFocusChanged > 0) + { + UiaCoreProviderApi.UiaRaiseAutomationEvent( + focused, + (int)UiaEventId.AutomationFocusChanged); + } + } + public void FocusChanged(object? sender, EventArgs e) { RaiseFocusChanged(GetOrCreate(Peer.GetFocus())); diff --git a/src/Windows/Avalonia.Win32/Win32StorageProvider.cs b/src/Windows/Avalonia.Win32/Win32StorageProvider.cs index 2fd49c8b09..86574f2fa3 100644 --- a/src/Windows/Avalonia.Win32/Win32StorageProvider.cs +++ b/src/Windows/Avalonia.Win32/Win32StorageProvider.cs @@ -131,7 +131,7 @@ namespace Avalonia.Win32 } } - if (folder?.TryGetFullPath() is { } folderPath) + if (folder?.TryGetLocalPath() is { } folderPath) { var riid = UnmanagedMethods.ShellIds.IShellItem; if (UnmanagedMethods.SHCreateItemFromParsingName(folderPath, IntPtr.Zero, ref riid, out var directoryShellItem) diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs index ef0e2467dc..6fa65f1265 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageItem.cs @@ -94,11 +94,7 @@ internal sealed class IOSStorageFile : IOSStorageItem, IStorageBookmarkFile public IOSStorageFile(NSUrl url) : base(url) { } - - public bool CanOpenRead => true; - - public bool CanOpenWrite => true; - + public Task OpenReadAsync() { return Task.FromResult(new IOSSecurityScopedStream(Url, FileAccess.Read)); diff --git a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs index 376e988e74..9f27bfbbf5 100644 --- a/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs +++ b/src/iOS/Avalonia.iOS/Storage/IOSStorageProvider.cs @@ -43,6 +43,10 @@ internal class IOSStorageProvider : IStorageProvider { return f.AppleUniformTypeIdentifiers.Select(id => UTType.CreateFromIdentifier(id)); } + if (f.TryGetExtensions() is { } extensions && extensions.Any()) + { + return extensions.Select(id => UTType.CreateFromExtension(id.TrimStart('.'))); + } if (f.MimeTypes?.Any() == true) { return f.MimeTypes.Select(id => UTType.CreateFromMimeType(id)); @@ -100,19 +104,19 @@ internal class IOSStorageProvider : IStorageProvider ? new IOSStorageFolder(url) : null); } - public Task TryGetFileFromPath(Uri filePath) + public Task TryGetFileFromPathAsync(Uri filePath) { // TODO: research if it's possible, maybe with additional permissions. return Task.FromResult(null); } - public Task TryGetFolderFromPath(Uri folderPath) + public Task TryGetFolderFromPathAsync(Uri folderPath) { // TODO: research if it's possible, maybe with additional permissions. return Task.FromResult(null); } - public Task TryGetWellKnownFolder(WellKnownFolder wellKnownFolder) + public Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) { var directoryType = wellKnownFolder switch { diff --git a/tests/Avalonia.IntegrationTests.Appium/SliderTests.cs b/tests/Avalonia.IntegrationTests.Appium/SliderTests.cs new file mode 100644 index 0000000000..7fa5eb83ee --- /dev/null +++ b/tests/Avalonia.IntegrationTests.Appium/SliderTests.cs @@ -0,0 +1,35 @@ +using System; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Interactions; +using Xunit; + +namespace Avalonia.IntegrationTests.Appium +{ + [Collection("Default")] + public class SliderTests + { + private readonly AppiumDriver _session; + + public SliderTests(TestAppFixture fixture) + { + _session = fixture.Session; + + var tabs = _session.FindElementByAccessibilityId("MainTabs"); + var tab = tabs.FindElementByName("SliderTab"); + tab.Click(); + } + + [Fact] + public void Changes_Value_When_Clicking_Increase_Button() + { + var slider = _session.FindElementByAccessibilityId("Slider"); + + // slider.Text gets the Slider value + Assert.True(double.Parse(slider.Text) == 30); + + new Actions(_session).Click(slider).Perform(); + + Assert.Equal(50, Math.Round(double.Parse(slider.Text))); + } + } +} diff --git a/tests/Avalonia.RenderTests/Controls/AdornerTests.cs b/tests/Avalonia.RenderTests/Controls/AdornerTests.cs index c0159aecff..b158bf798d 100644 --- a/tests/Avalonia.RenderTests/Controls/AdornerTests.cs +++ b/tests/Avalonia.RenderTests/Controls/AdornerTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -18,56 +19,70 @@ public class AdornerTests : TestBase { } - [Fact] - public async Task Focus_Adorner_Is_Properly_Clipped() + async Task CheckAdornedContent(Control content, Control adorned, Control adorner, int width = 200, int height = 200, + [CallerMemberName] string testName = "") { - Border adorned; var tree = new Decorator { Child = new VisualLayerManager { - Child = new Border - { - Background = Brushes.Red, - Padding = new Thickness(10, 50, 10,10), - Child = new Border() - { - Background = Brushes.White, - ClipToBounds = true, - Padding = new Thickness(0, -30, 0, 0), - Child = adorned = new Border - { - Background = Brushes.Green, - VerticalAlignment = VerticalAlignment.Top, - Height = 100, - Width = 50 - } - } - } + Child = content }, - Width = 200, - Height = 200 - }; - var adorner = new Border - { - BorderThickness = new Thickness(2), - BorderBrush = Brushes.Black + Width = width, + Height = height }; - + var size = new Size(tree.Width, tree.Height); tree.Measure(size); tree.Arrange(new Rect(size)); - - + adorned.AttachedToVisualTree += delegate { AdornerLayer.SetAdornedElement(adorner, adorned); AdornerLayer.GetAdornerLayer(adorned)!.Children.Add(adorner); }; + tree.Measure(size); tree.Arrange(new Rect(size)); - await RenderToFile(tree); - CompareImages(skipImmediate: true); + await RenderToFile(tree, testName: testName); + CompareImages(skipImmediate: true, testName: testName); + } + + [Theory, + InlineData(true), + InlineData(false) + ] + public async Task Focus_Adorner_Is_Properly_Clipped(bool clip) + { + Border adorned; + var content = new Border + { + Background = Brushes.Red, + Padding = new Thickness(10, 50, 10, 10), + Child = new Border() + { + Background = Brushes.White, + ClipToBounds = true, + Padding = new Thickness(0, -30, 0, 0), + Child = adorned = new Border + { + Background = Brushes.Green, + VerticalAlignment = VerticalAlignment.Top, + Height = 100, + Width = 50 + } + } + }; + var adorner = new Border + { + BorderThickness = new Thickness(2), + BorderBrush = Brushes.Black + }; + if (!clip) + AdornerLayer.SetIsClipEnabled(adorner, false); + await CheckAdornedContent(content, adorned, adorner, + testName: "Focus_Adorner_Is_Properly_Clipped_Clip_" + clip); } + } \ No newline at end of file diff --git a/tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png b/tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png new file mode 100644 index 0000000000..4821c22c39 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png differ diff --git a/tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped.expected.png b/tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_True.expected.png similarity index 100% rename from tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped.expected.png rename to tests/TestFiles/Direct2D1/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_True.expected.png diff --git a/tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png b/tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png new file mode 100644 index 0000000000..4821c22c39 Binary files /dev/null and b/tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_False.expected.png differ diff --git a/tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped.expected.png b/tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_True.expected.png similarity index 100% rename from tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped.expected.png rename to tests/TestFiles/Skia/Controls/Adorner/Focus_Adorner_Is_Properly_Clipped_Clip_True.expected.png