From 16eab47b27f2fd65c9e8fce87d254e2b0cee923d Mon Sep 17 00:00:00 2001 From: Max Katz Date: Fri, 5 Apr 2024 21:01:32 -0700 Subject: [PATCH] Browser software renderer and some refactoring allowing multiple AvaloniaView instances (#15207) * Update .NET runtime TypeScript definitions * Get splash screen by classname instead of ID * Minimize css usage * Move insets css properties to JS file * Refactor browser surface handling, re-enable software renderer, allow fallback render modes * Extract render timer from the surface, try to reuse compositor instance between avalonia views * ControlCatalog: Make it possible to set some browser properties (like render mode) from URI arguments * Rename AppBundle folder to wwwroot * Extract ITextInputMethodImpl into a BrowserTopLevelImpl * Extract input into BrowserInputHandler * Make default surface size 1,1 to match other backends * Reformat code that I touched (for the most part) * Why this method even was in public API # Conflicts: # samples/MobileSandbox.Browser/app.css # samples/MobileSandbox/Platforms/Browser/wwwroot/index.html --- api/Avalonia.Browser.nupkg.xml | 22 + .../ControlCatalog.Browser/AppBundle/Logo.svg | 5 - .../ControlCatalog.Browser/AppBundle/app.css | 74 --- .../AppBundle/index.html | 28 - .../ControlCatalog.Browser.csproj | 5 +- samples/ControlCatalog.Browser/Program.cs | 84 ++- .../ControlCatalog.Browser/wwwroot/Logo.svg | 5 + .../ControlCatalog.Browser/wwwroot/app.css | 38 ++ .../{AppBundle => wwwroot}/embed.js | 0 .../{AppBundle => wwwroot}/favicon.ico | Bin .../ControlCatalog.Browser/wwwroot/index.html | 46 ++ .../{AppBundle => wwwroot}/main.js | 6 +- samples/MobileSandbox.Browser/Logo.svg | 8 +- samples/MobileSandbox.Browser/app.css | 70 +-- .../Data/Core/TargetTypeConverter.cs | 1 + src/Browser/Avalonia.Browser/AvaloniaView.cs | 536 +----------------- .../Avalonia.Browser/BrowserAppBuilder.cs | 31 +- .../Avalonia.Browser/BrowserDispatcherImpl.cs | 6 +- .../Avalonia.Browser/BrowserInputHandler.cs | 383 +++++++++++++ .../Avalonia.Browser/BrowserInsetsManager.cs | 13 +- .../Avalonia.Browser/BrowserMouseDevice.cs | 2 +- .../BrowserNativeControlHost.cs | 4 +- .../BrowserRuntimePlatform.cs | 3 - .../BrowserSingleViewLifetime.cs | 5 +- .../BrowserTextInputMethod.cs | 162 ++++++ .../Avalonia.Browser/BrowserTopLevelImpl.cs | 203 ++----- .../Avalonia.Browser/Interop/CanvasHelper.cs | 64 ++- .../Avalonia.Browser/Interop/DomHelper.cs | 14 +- .../Avalonia.Browser/Interop/TimerHelper.cs | 23 + .../Rendering/BrowserCompositor.cs | 24 + .../Rendering/BrowserGlSurface.cs | 51 ++ .../Rendering/BrowserRasterSurface.cs | 98 ++++ .../Rendering/BrowserRenderTimer.cs | 46 ++ .../Rendering/BrowserSurface.cs | 102 ++++ .../Avalonia.Browser/Skia/BrowserSkiaGpu.cs | 12 +- .../Skia/BrowserSkiaGpuRenderSession.cs | 17 +- .../Skia/BrowserSkiaGpuRenderTarget.cs | 23 +- .../Skia/BrowserSkiaRasterSurface.cs | 90 --- .../Skia/BrowserSkiaSurface.cs | 33 -- .../Skia/IBrowserSkiaSurface.cs | 9 - .../Avalonia.Browser/webapp/.eslintrc.json | 1 + .../webapp/modules/avalonia.ts | 7 +- .../webapp/modules/avalonia/canvas.ts | 263 --------- .../webapp/modules/avalonia/dom.ts | 60 +- .../avalonia/surfaces/htmlSurfaceBase.ts | 40 ++ .../avalonia/surfaces/resizeHandler.ts | 58 ++ .../avalonia/surfaces/softwareSurface.ts | 48 ++ .../modules/avalonia/surfaces/surfaceBase.ts | 18 + .../avalonia/surfaces/surfaceFactory.ts | 44 ++ .../modules/avalonia/surfaces/webGlSurface.ts | 58 ++ .../webapp/modules/avalonia/timer.ts | 12 + .../Avalonia.Browser/webapp/types/dotnet.d.ts | 330 +++++++++-- 52 files changed, 1883 insertions(+), 1402 deletions(-) create mode 100644 api/Avalonia.Browser.nupkg.xml delete mode 100644 samples/ControlCatalog.Browser/AppBundle/Logo.svg delete mode 100644 samples/ControlCatalog.Browser/AppBundle/app.css delete mode 100644 samples/ControlCatalog.Browser/AppBundle/index.html create mode 100644 samples/ControlCatalog.Browser/wwwroot/Logo.svg create mode 100644 samples/ControlCatalog.Browser/wwwroot/app.css rename samples/ControlCatalog.Browser/{AppBundle => wwwroot}/embed.js (100%) rename samples/ControlCatalog.Browser/{AppBundle => wwwroot}/favicon.ico (100%) create mode 100644 samples/ControlCatalog.Browser/wwwroot/index.html rename samples/ControlCatalog.Browser/{AppBundle => wwwroot}/main.js (63%) create mode 100644 src/Browser/Avalonia.Browser/BrowserInputHandler.cs create mode 100644 src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs create mode 100644 src/Browser/Avalonia.Browser/Interop/TimerHelper.cs create mode 100644 src/Browser/Avalonia.Browser/Rendering/BrowserCompositor.cs create mode 100644 src/Browser/Avalonia.Browser/Rendering/BrowserGlSurface.cs create mode 100644 src/Browser/Avalonia.Browser/Rendering/BrowserRasterSurface.cs create mode 100644 src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs create mode 100644 src/Browser/Avalonia.Browser/Rendering/BrowserSurface.cs delete mode 100644 src/Browser/Avalonia.Browser/Skia/BrowserSkiaRasterSurface.cs delete mode 100644 src/Browser/Avalonia.Browser/Skia/BrowserSkiaSurface.cs delete mode 100644 src/Browser/Avalonia.Browser/Skia/IBrowserSkiaSurface.cs delete mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/canvas.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/htmlSurfaceBase.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/resizeHandler.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/softwareSurface.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceBase.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceFactory.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/webGlSurface.ts create mode 100644 src/Browser/Avalonia.Browser/webapp/modules/avalonia/timer.ts diff --git a/api/Avalonia.Browser.nupkg.xml b/api/Avalonia.Browser.nupkg.xml new file mode 100644 index 0000000000..16bdbfe25d --- /dev/null +++ b/api/Avalonia.Browser.nupkg.xml @@ -0,0 +1,22 @@ + + + + + CP0002 + M:Avalonia.Browser.AvaloniaView.get_IsComposing + baseline/net7.0/Avalonia.Browser.dll + target/net8.0-browser1.0/Avalonia.Browser.dll + + + CP0002 + M:Avalonia.Browser.AvaloniaView.OnDragEvent(System.Runtime.InteropServices.JavaScript.JSObject) + baseline/net7.0/Avalonia.Browser.dll + target/net8.0-browser1.0/Avalonia.Browser.dll + + + CP0008 + T:Avalonia.Browser.AvaloniaView + baseline/net7.0/Avalonia.Browser.dll + target/net8.0-browser1.0/Avalonia.Browser.dll + + \ No newline at end of file diff --git a/samples/ControlCatalog.Browser/AppBundle/Logo.svg b/samples/ControlCatalog.Browser/AppBundle/Logo.svg deleted file mode 100644 index 9685a23af1..0000000000 --- a/samples/ControlCatalog.Browser/AppBundle/Logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/samples/ControlCatalog.Browser/AppBundle/app.css b/samples/ControlCatalog.Browser/AppBundle/app.css deleted file mode 100644 index e14dfe4487..0000000000 --- a/samples/ControlCatalog.Browser/AppBundle/app.css +++ /dev/null @@ -1,74 +0,0 @@ -:root { - --sat: env(safe-area-inset-top); - --sar: env(safe-area-inset-right); - --sab: env(safe-area-inset-bottom); - --sal: env(safe-area-inset-left); -} - -/* HTML styles for the splash screen */ - -.highlight { - color: white; - font-size: 2.5rem; - display: block; -} - -.purple { - color: #8b44ac; -} - -.icon { - opacity: 0.05; - height: 35%; - width: 35%; - position: absolute; - background-repeat: no-repeat; - right: 0px; - bottom: 0px; - margin-right: 3%; - margin-bottom: 5%; - z-index: 5000; - background-position: right bottom; - pointer-events: none; -} - -#avalonia-splash a { - color: whitesmoke; - text-decoration: none; -} - -.center { - display: flex; - justify-content: center; - align-items: center; - height: 100vh; -} - -#avalonia-splash { - position: relative; - height: 100%; - width: 100%; - color: whitesmoke; - background: #1b2a4e; - font-family: 'Nunito', sans-serif; - background-position: center; - background-size: cover; - background-repeat: no-repeat; - justify-content: center; - align-items: center; -} - -.splash-close { - animation: fadeout 0.25s linear forwards; -} - -@keyframes fadeout { - 0% { - opacity: 100%; - } - - 100% { - opacity: 0; - visibility: collapse; - } -} diff --git a/samples/ControlCatalog.Browser/AppBundle/index.html b/samples/ControlCatalog.Browser/AppBundle/index.html deleted file mode 100644 index b35acaed5c..0000000000 --- a/samples/ControlCatalog.Browser/AppBundle/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - AvaloniaUI - ControlCatalog - - - - - - -
-
-
-

- Powered by - Avalonia UI -

-
- Avalonia Logo -
-
- - - - diff --git a/samples/ControlCatalog.Browser/ControlCatalog.Browser.csproj b/samples/ControlCatalog.Browser/ControlCatalog.Browser.csproj index cc4087d2f0..f223eb0725 100644 --- a/samples/ControlCatalog.Browser/ControlCatalog.Browser.csproj +++ b/samples/ControlCatalog.Browser/ControlCatalog.Browser.csproj @@ -2,10 +2,9 @@ $(AvsCurrentBrowserTargetFramework) browser-wasm - AppBundle/main.js + wwwroot/main.js Exe true - ./ @@ -15,7 +14,7 @@ - + diff --git a/samples/ControlCatalog.Browser/Program.cs b/samples/ControlCatalog.Browser/Program.cs index 919df5103c..c50f1dcbdd 100644 --- a/samples/ControlCatalog.Browser/Program.cs +++ b/samples/ControlCatalog.Browser/Program.cs @@ -1,40 +1,108 @@ +using System; using System.Diagnostics; +using System.Linq; using System.Runtime.Versioning; using System.Threading.Tasks; +using System.Web; using Avalonia; using Avalonia.Browser; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Logging; +using Avalonia.Rendering; +using Avalonia.Threading; using ControlCatalog; using ControlCatalog.Browser; [assembly:SupportedOSPlatform("browser")] +#nullable enable internal partial class Program { public static async Task Main(string[] args) { Trace.Listeners.Add(new ConsoleTraceListener()); - + + var options = ParseArgs(args) ?? new BrowserPlatformOptions(); + await BuildAvaloniaApp() - .LogToTrace(LogEventLevel.Warning) + .LogToTrace() .AfterSetup(_ => { ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb(); }) - .StartBrowserAppAsync("out"); + .StartBrowserAppAsync("out", options); + + if (Application.Current!.ApplicationLifetime is ISingleTopLevelApplicationLifetime lifetime) + { + lifetime.TopLevel!.RendererDiagnostics.DebugOverlays = RendererDebugOverlays.Fps; + } } - // Example without a ISingleViewApplicationLifetime - // private static AvaloniaView _avaloniaView; + // Test with multiple AvaloniaView at once. + // private static AvaloniaView _avaloniaView1; + // private static AvaloniaView _avaloniaView2; // public static async Task Main(string[] args) // { + // Trace.Listeners.Add(new ConsoleTraceListener()); + // + // var options = ParseArgs(args) ?? new BrowserPlatformOptions(); + // // await BuildAvaloniaApp() - // .SetupBrowserApp(); + // .LogToTrace() + // .SetupBrowserAppAsync(options); + // + // _avaloniaView1 = new AvaloniaView("out1"); + // _avaloniaView1.Content = new TextBlock { Text = "Hello" }; // - // _avaloniaView = new AvaloniaView("out"); - // _avaloniaView.Content = new TextBlock { Text = "Hello world" }; + // _avaloniaView2 = new AvaloniaView("out2"); + // _avaloniaView2.Content = new TextBlock { Text = "World" }; + // + // Dispatcher.UIThread.Invoke(() => + // { + // var topLevel = TopLevel.GetTopLevel(_avaloniaView1.Content); + // topLevel!.RendererDiagnostics.DebugOverlays = RendererDebugOverlays.Fps; + // }); // } public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure(); + + private static BrowserPlatformOptions? ParseArgs(string[] args) + { + try + { + if (args.Length == 0 + || !Uri.TryCreate(args[0], UriKind.Absolute, out var uri) + || uri.Query.Length <= 1) + { + uri = new Uri("http://localhost"); + } + + var queryParams = HttpUtility.ParseQueryString(uri.Query); + var options = new BrowserPlatformOptions(); + + if (bool.TryParse(queryParams[nameof(options.PreferFileDialogPolyfill)], out var preferDialogsPolyfill)) + { + options.PreferFileDialogPolyfill = preferDialogsPolyfill; + } + + if (queryParams[nameof(options.RenderingMode)] is { } renderingModePairs) + { + options.RenderingMode = renderingModePairs + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(entry => Enum.Parse(entry, true)) + .ToArray(); + } + + Console.WriteLine("DemoBrowserPlatformOptions.PreferFileDialogPolyfill: " + options.PreferFileDialogPolyfill); + Console.WriteLine("DemoBrowserPlatformOptions.RenderingMode: " + string.Join(";", options.RenderingMode)); + return options; + } + catch (Exception ex) + { + Console.WriteLine("ParseArgs of DemoBrowserPlatformOptions failed: " + ex); + return null; + } + } } diff --git a/samples/ControlCatalog.Browser/wwwroot/Logo.svg b/samples/ControlCatalog.Browser/wwwroot/Logo.svg new file mode 100644 index 0000000000..3e18ea1958 --- /dev/null +++ b/samples/ControlCatalog.Browser/wwwroot/Logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog.Browser/wwwroot/app.css b/samples/ControlCatalog.Browser/wwwroot/app.css new file mode 100644 index 0000000000..77f2051221 --- /dev/null +++ b/samples/ControlCatalog.Browser/wwwroot/app.css @@ -0,0 +1,38 @@ +/* HTML styles for the splash screen */ +.avalonia-splash { + position: absolute; + height: 100%; + width: 100%; + background: #1b2a4e; + font-family: 'Nunito', sans-serif; + justify-content: center; + align-items: center; + display: flex; + pointer-events: none; +} + +.avalonia-splash h2 { + font-size: 1.5rem; + color: #8b44ac; +} + +.avalonia-splash a { + color: white; + text-decoration: none; + font-size: 2.5rem; + display: block; +} + +.avalonia-splash img { + opacity: 0.05; + height: 35%; + position: absolute; + right: 3%; + bottom: 3%; +} + +.avalonia-splash.splash-close { + transition: opacity 200ms, display 200ms; + display: none; + opacity: 0; +} diff --git a/samples/ControlCatalog.Browser/AppBundle/embed.js b/samples/ControlCatalog.Browser/wwwroot/embed.js similarity index 100% rename from samples/ControlCatalog.Browser/AppBundle/embed.js rename to samples/ControlCatalog.Browser/wwwroot/embed.js diff --git a/samples/ControlCatalog.Browser/AppBundle/favicon.ico b/samples/ControlCatalog.Browser/wwwroot/favicon.ico similarity index 100% rename from samples/ControlCatalog.Browser/AppBundle/favicon.ico rename to samples/ControlCatalog.Browser/wwwroot/favicon.ico diff --git a/samples/ControlCatalog.Browser/wwwroot/index.html b/samples/ControlCatalog.Browser/wwwroot/index.html new file mode 100644 index 0000000000..d8bf05fe3c --- /dev/null +++ b/samples/ControlCatalog.Browser/wwwroot/index.html @@ -0,0 +1,46 @@ + + + + + + + AvaloniaUI - ControlCatalog + + + + + + +
+
+

+ Powered by + Avalonia UI +

+ Avalonia Logo +
+
+ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/ControlCatalog.Browser/AppBundle/main.js b/samples/ControlCatalog.Browser/wwwroot/main.js similarity index 63% rename from samples/ControlCatalog.Browser/AppBundle/main.js rename to samples/ControlCatalog.Browser/wwwroot/main.js index 9eae9fd740..35c8245b01 100644 --- a/samples/ControlCatalog.Browser/AppBundle/main.js +++ b/samples/ControlCatalog.Browser/wwwroot/main.js @@ -1,17 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import { dotnet } from './dotnet.js' // NET 7 -//import { dotnet } from './_framework/dotnet.js' // NET 8+ +import { dotnet } from './_framework/dotnet.js' const is_browser = typeof window != "undefined"; if (!is_browser) throw new Error(`Expected to be running in a browser`); const dotnetRuntime = await dotnet - .withDiagnosticTracing(false) .withApplicationArgumentsFromQuery() .create(); const config = dotnetRuntime.getConfig(); -await dotnetRuntime.runMainAndExit(config.mainAssemblyName, [globalThis.location.href]); +await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]); diff --git a/samples/MobileSandbox.Browser/Logo.svg b/samples/MobileSandbox.Browser/Logo.svg index 9685a23af1..2560af46cf 100644 --- a/samples/MobileSandbox.Browser/Logo.svg +++ b/samples/MobileSandbox.Browser/Logo.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/samples/MobileSandbox.Browser/app.css b/samples/MobileSandbox.Browser/app.css index 0e6ab12461..77f2051221 100644 --- a/samples/MobileSandbox.Browser/app.css +++ b/samples/MobileSandbox.Browser/app.css @@ -1,56 +1,38 @@ -:root { - --sat: env(safe-area-inset-top); - --sar: env(safe-area-inset-right); - --sab: env(safe-area-inset-bottom); - --sal: env(safe-area-inset-left); -} - -#out { - height: 100vh; - width: 100vw -} - -#avalonia-splash { - position: relative; +/* HTML styles for the splash screen */ +.avalonia-splash { + position: absolute; height: 100%; width: 100%; - color: whitesmoke; - background: #171C2C; + background: #1b2a4e; font-family: 'Nunito', sans-serif; - background-position: center; - background-size: cover; - background-repeat: no-repeat; + justify-content: center; + align-items: center; + display: flex; + pointer-events: none; } -#avalonia-splash a{ - color: whitesmoke; - text-decoration: none; +.avalonia-splash h2 { + font-size: 1.5rem; + color: #8b44ac; } -.center { - display: flex; - justify-content: center; - height: 250px; +.avalonia-splash a { + color: white; + text-decoration: none; + font-size: 2.5rem; + display: block; } -.splash-close { - animation: slide 0.5s linear 1s forwards; +.avalonia-splash img { + opacity: 0.05; + height: 35%; + position: absolute; + right: 3%; + bottom: 3%; } -@keyframes slide { - 0% { - top: 0%; - } - - 50% { - opacity: 80%; - } - - 100% { - top: 100%; - overflow: hidden; - opacity: 0; - display: none; - visibility: collapse; - } +.avalonia-splash.splash-close { + transition: opacity 200ms, display 200ms; + display: none; + opacity: 0; } diff --git a/src/Avalonia.Base/Data/Core/TargetTypeConverter.cs b/src/Avalonia.Base/Data/Core/TargetTypeConverter.cs index 7a4b0544bd..2efc5b42bd 100644 --- a/src/Avalonia.Base/Data/Core/TargetTypeConverter.cs +++ b/src/Avalonia.Base/Data/Core/TargetTypeConverter.cs @@ -68,6 +68,7 @@ internal abstract class TargetTypeConverter #pragma warning disable IL2026 #pragma warning disable IL2067 +#pragma warning disable IL2072 // TODO: TypeConverters are not trimming friendly in some edge cases, we probably need // to make compiled bindings emit conversion code at compile-time. var toTypeConverter = TypeDescriptor.GetConverter(t); diff --git a/src/Browser/Avalonia.Browser/AvaloniaView.cs b/src/Browser/Avalonia.Browser/AvaloniaView.cs index e2a6212e13..ff397a14b8 100644 --- a/src/Browser/Avalonia.Browser/AvaloniaView.cs +++ b/src/Browser/Avalonia.Browser/AvaloniaView.cs @@ -1,143 +1,59 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; using System.Runtime.InteropServices.JavaScript; using Avalonia.Browser.Interop; -using Avalonia.Browser.Skia; -using Avalonia.Collections.Pooled; using Avalonia.Controls; using Avalonia.Controls.Embedding; -using Avalonia.Controls.Platform; -using Avalonia.Input; -using Avalonia.Input.Raw; -using Avalonia.Input.TextInput; -using Avalonia.Logging; -using Avalonia.Media; -using Avalonia.Platform; -using Avalonia.Rendering.Composition; -using Avalonia.Threading; -using SkiaSharp; -using static System.Runtime.CompilerServices.RuntimeHelpers; namespace Avalonia.Browser { - public class AvaloniaView : ITextInputMethodImpl + public class AvaloniaView { - private static readonly PooledList s_intermediatePointsPooledList = new(ClearMode.Never); - private readonly BrowserTopLevelImpl _topLevelImpl; - private EmbeddableControlRoot _topLevel; - - private readonly JSObject _containerElement; - private readonly JSObject _canvas; - private readonly JSObject _nativeControlsContainer; - private readonly JSObject _inputElement; - private readonly JSObject? _splash; - - private GLInfo? _jsGlInfo = null; - private double _dpi = 1; - private Size _canvasSize = new(100.0, 100.0); - - private GRContext? _context; - private GRGlInterface? _glInterface; - private const SKColorType ColorType = SKColorType.Rgba8888; - - private bool _useGL; - private TextInputMethodClient? _client; + private readonly EmbeddableControlRoot _topLevel; /// 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.")) { } + /// JSObject holding a div element where avalonia content should be rendered. public AvaloniaView(JSObject host) { + if (host is null) + { + throw new ArgumentNullException(nameof(host)); + } + var hostContent = DomHelper.CreateAvaloniaHost(host); if (hostContent == null) { throw new InvalidOperationException("Avalonia WASM host wasn't initialized."); } - _containerElement = hostContent.GetPropertyAsJSObject("host") - ?? throw new InvalidOperationException("Host cannot be null"); - _canvas = hostContent.GetPropertyAsJSObject("canvas") - ?? throw new InvalidOperationException("Canvas cannot be null"); - _nativeControlsContainer = hostContent.GetPropertyAsJSObject("nativeHost") - ?? throw new InvalidOperationException("NativeHost cannot be null"); - _inputElement = hostContent.GetPropertyAsJSObject("inputElement") - ?? throw new InvalidOperationException("InputElement cannot be null"); + var nativeControlsContainer = hostContent.GetPropertyAsJSObject("nativeHost") + ?? throw new InvalidOperationException("NativeHost cannot be null"); + var inputElement = hostContent.GetPropertyAsJSObject("inputElement") + ?? throw new InvalidOperationException("InputElement cannot be null"); - _splash = DomHelper.GetElementById("avalonia-splash"); + var topLevelImpl = new BrowserTopLevelImpl(host, nativeControlsContainer, inputElement); + _topLevel = new EmbeddableControlRoot(topLevelImpl); - _topLevelImpl = new BrowserTopLevelImpl(this, _containerElement); - _topLevelImpl.SetCssCursor = (cursor) => - { - InputHelper.SetCursor(_containerElement, cursor); - }; - - _topLevel = new EmbeddableControlRoot(_topLevelImpl); _topLevel.Prepare(); - _topLevel.Renderer.Start(); - if (_splash != null) - { - _topLevel.RequestAnimationFrame(_ => DomHelper.AddCssClass(_splash, "splash-close")); - } - - InputHelper.InitializeBackgroundHandlers(); - - InputHelper.SubscribeKeyEvents( - _containerElement, - OnKeyDown, - OnKeyUp); - - InputHelper.SubscribeTextEvents( - _inputElement, - OnBeforeInput, - OnCompositionStart, - OnCompositionUpdate, - OnCompositionEnd); - - InputHelper.SubscribePointerEvents(_containerElement, OnPointerMove, OnPointerDown, OnPointerUp, - OnPointerCancel, OnWheel); - - InputHelper.SubscribeDropEvents(_containerElement, OnDragEvent); - - var skiaOptions = AvaloniaLocator.Current.GetService(); - - _useGL = AvaloniaLocator.Current.GetService() != null; - - if (_useGL) - { - _jsGlInfo = CanvasHelper.InitialiseGL(_canvas, OnRenderFrame); - // create the SkiaSharp context - if (_context == null) + _topLevel.GotFocus += (_, _) => InputHelper.FocusElement(host); + _topLevel.Renderer.Start(); // TODO: use Start+StopRenderer() instead. + _topLevel.RequestAnimationFrame(_ => + { + // Try to get local splash-screen of the specific host. + // If couldn't find - get global one by ID for compatibility. + var splash = DomHelper.GetElementsByClassName("avalonia-splash", host) + ?? DomHelper.GetElementById("avalonia-splash"); + if (splash is not null) { - _glInterface = GRGlInterface.Create(); - _context = GRContext.CreateGl(_glInterface); - - // bump the default resource cache limit - _context.SetResourceCacheLimit(skiaOptions?.MaxGpuResourceSizeBytes ?? 32 * 1024 * 1024); + DomHelper.AddCssClass(splash, "splash-close"); + splash.Dispose(); } - - _topLevelImpl.Surfaces = new[] - { - new BrowserSkiaSurface(_context, _jsGlInfo, ColorType, - new PixelSize((int)_canvasSize.Width, (int)_canvasSize.Height), _canvasSize,_dpi, - GRSurfaceOrigin.BottomLeft) - }; - } - else - { - Logger.TryGet(LogEventLevel.Error, LogArea.BrowserPlatform)? - .Log(this, "[Avalonia]: Unable to initialize Canvas surface."); - } - - DomHelper.ObserveSize(host, OnSizeOrDpiChanged); - - CanvasHelper.RequestAnimationFrame(_canvas, true); - - InputHelper.FocusElement(_containerElement); + }); } public Control? Content @@ -146,402 +62,6 @@ namespace Avalonia.Browser set => _topLevel.Content = value; } - public bool IsComposing { get; private set; } - internal TopLevel TopLevel => _topLevel; - - private static RawPointerPoint ExtractRawPointerFromJSArgs(JSObject args) - { - var point = new RawPointerPoint - { - Position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")), - Pressure = (float)args.GetPropertyAsDouble("pressure"), - XTilt = (float)args.GetPropertyAsDouble("tiltX"), - YTilt = (float)args.GetPropertyAsDouble("tiltY"), - Twist = (float)args.GetPropertyAsDouble("twist") - }; - - return point; - } - - private bool OnPointerMove(JSObject args) - { - var pointerType = args.GetPropertyAsString("pointerType"); - var point = ExtractRawPointerFromJSArgs(args); - var type = pointerType switch - { - "touch" => RawPointerEventType.TouchUpdate, - _ => RawPointerEventType.Move - }; - - var coalescedEvents = new Lazy?>(() => - { - var points = InputHelper.GetCoalescedEvents(args); - s_intermediatePointsPooledList.Clear(); - s_intermediatePointsPooledList.Capacity = points.Length - 1; - - // Skip the last one, as it is already processed point. - for (var i = 0; i < points.Length - 1; i++) - { - var point = points[i]; - s_intermediatePointsPooledList.Add(ExtractRawPointerFromJSArgs(point)); - } - - return s_intermediatePointsPooledList; - }); - - return _topLevelImpl.RawPointerEvent(type, pointerType!, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId"), coalescedEvents); - } - - private bool OnPointerDown(JSObject args) - { - var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; - var type = pointerType switch - { - "touch" => RawPointerEventType.TouchBegin, - _ => args.GetPropertyAsInt32("button") switch - { - 0 => RawPointerEventType.LeftButtonDown, - 1 => RawPointerEventType.MiddleButtonDown, - 2 => RawPointerEventType.RightButtonDown, - 3 => RawPointerEventType.XButton1Down, - 4 => RawPointerEventType.XButton2Down, - 5 => RawPointerEventType.XButton1Down, // should be pen eraser button, - _ => RawPointerEventType.Move - } - }; - - var point = ExtractRawPointerFromJSArgs(args); - return _topLevelImpl.RawPointerEvent(type, pointerType, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); - } - - private bool OnPointerUp(JSObject args) - { - var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; - var type = pointerType switch - { - "touch" => RawPointerEventType.TouchEnd, - _ => args.GetPropertyAsInt32("button") switch - { - 0 => RawPointerEventType.LeftButtonUp, - 1 => RawPointerEventType.MiddleButtonUp, - 2 => RawPointerEventType.RightButtonUp, - 3 => RawPointerEventType.XButton1Up, - 4 => RawPointerEventType.XButton2Up, - 5 => RawPointerEventType.XButton1Up, // should be pen eraser button, - _ => RawPointerEventType.Move - } - }; - - var point = ExtractRawPointerFromJSArgs(args); - return _topLevelImpl.RawPointerEvent(type, pointerType, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); - } - - private bool OnPointerCancel(JSObject args) - { - var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; - if (pointerType == "touch") - { - var point = ExtractRawPointerFromJSArgs(args); - _topLevelImpl.RawPointerEvent(RawPointerEventType.TouchCancel, pointerType, point, - GetModifiers(args), args.GetPropertyAsInt32("pointerId")); - } - - return false; - } - - private bool OnWheel(JSObject args) - { - return _topLevelImpl.RawMouseWheelEvent(new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")), - new Vector(-(args.GetPropertyAsDouble("deltaX") / 50), -(args.GetPropertyAsDouble("deltaY") / 50)), GetModifiers(args)); - } - - private static RawInputModifiers GetModifiers(JSObject e) - { - var modifiers = RawInputModifiers.None; - - if (e.GetPropertyAsBoolean("ctrlKey")) - modifiers |= RawInputModifiers.Control; - if (e.GetPropertyAsBoolean("altKey")) - modifiers |= RawInputModifiers.Alt; - if (e.GetPropertyAsBoolean("shiftKey")) - modifiers |= RawInputModifiers.Shift; - if (e.GetPropertyAsBoolean("metaKey")) - modifiers |= RawInputModifiers.Meta; - - var buttons = e.GetPropertyAsInt32("buttons"); - if ((buttons & 1L) == 1) - modifiers |= RawInputModifiers.LeftMouseButton; - - if ((buttons & 2L) == 2) - modifiers |= e.GetPropertyAsString("type") == "pen" ? RawInputModifiers.PenBarrelButton : RawInputModifiers.RightMouseButton; - - if ((buttons & 4L) == 4) - modifiers |= RawInputModifiers.MiddleMouseButton; - - if ((buttons & 8L) == 8) - modifiers |= RawInputModifiers.XButton1MouseButton; - - if ((buttons & 16L) == 16) - modifiers |= RawInputModifiers.XButton2MouseButton; - - if ((buttons & 32L) == 32) - modifiers |= RawInputModifiers.PenEraser; - - return modifiers; - } - - public bool OnDragEvent(JSObject args) - { - var eventType = args?.GetPropertyAsString("type") switch - { - "dragenter" => RawDragEventType.DragEnter, - "dragover" => RawDragEventType.DragOver, - "dragleave" => RawDragEventType.DragLeave, - "drop" => RawDragEventType.Drop, - _ => (RawDragEventType)(int)-1 - }; - var dataObject = args?.GetPropertyAsJSObject("dataTransfer"); - if (args is null || eventType < 0 || dataObject is null) - { - return false; - } - - // If file is dropped, we need storage js to be referenced. - // TODO: restructure JS files, so it's not needed. - _ = AvaloniaModule.ImportStorage(); - - var position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")); - var modifiers = GetModifiers(args); - - var effectAllowedStr = dataObject.GetPropertyAsString("effectAllowed") ?? "none"; - var effectAllowed = DragDropEffects.None; - if (effectAllowedStr.Contains("copy", StringComparison.OrdinalIgnoreCase)) - { - effectAllowed |= DragDropEffects.Copy; - } - if (effectAllowedStr.Contains("link", StringComparison.OrdinalIgnoreCase)) - { - effectAllowed |= DragDropEffects.Link; - } - if (effectAllowedStr.Contains("move", StringComparison.OrdinalIgnoreCase)) - { - effectAllowed |= DragDropEffects.Move; - } - if (effectAllowedStr.Equals("all", StringComparison.OrdinalIgnoreCase)) - { - effectAllowed |= DragDropEffects.Move | DragDropEffects.Copy | DragDropEffects.Link; - } - if (effectAllowed == DragDropEffects.None) - { - return false; - } - - var dropEffect = _topLevelImpl.RawDragEvent(eventType, position, modifiers, new BrowserDataObject(dataObject), effectAllowed); - dataObject.SetProperty("dropEffect", dropEffect.ToString().ToLowerInvariant()); - - return eventType is RawDragEventType.Drop or RawDragEventType.DragOver - && dropEffect != DragDropEffects.None; - } - - private bool OnKeyDown (string code, string key, int modifier) - { - var handled = _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier); - - if (!handled && key.Length == 1) - { - handled = _topLevelImpl.RawTextEvent(key); - } - - return handled; - } - - private bool OnKeyUp(string code, string key, int modifier) - { - return _topLevelImpl.RawKeyboardEvent(RawKeyEventType.KeyUp, code, key, (RawInputModifiers)modifier); - } - - private bool OnBeforeInput(JSObject arg, int start, int end) - { - var type = arg.GetPropertyAsString("inputType"); - if (type != "deleteByComposition") - { - if (type == "deleteContentBackward") - { - start = _inputElement.GetPropertyAsInt32("selectionStart"); - end = _inputElement.GetPropertyAsInt32("selectionEnd"); - } - else - { - start = -1; - end = -1; - } - } - - if(start != -1 && end != -1 && _client != null) - { - _client.Selection = new TextSelection(start, end); - } - return false; - } - - private bool OnCompositionStart (JSObject args) - { - if (_client == null) - return false; - - _client.SetPreeditText(null); - IsComposing = true; - - return false; - } - - private bool OnCompositionUpdate(JSObject args) - { - if (_client == null) - return false; - - _client.SetPreeditText(args.GetPropertyAsString("data")); - - return false; - } - - private bool OnCompositionEnd(JSObject args) - { - if (_client == null) - return false; - - IsComposing = false; - - _client.SetPreeditText(null); - - var text = args.GetPropertyAsString("data"); - - if(text != null) - { - return _topLevelImpl.RawTextEvent(text); - } - - return false; - } - - private void OnRenderFrame() - { - if (_useGL && (_jsGlInfo == null)) - { - return; - } - if (_canvasSize.Width <= 0 || _canvasSize.Height <= 0 || _dpi <= 0) - { - return; - } - - Dispatcher.UIThread.RunJobs(DispatcherPriority.UiThreadRender); - ManualTriggerRenderTimer.Instance.RaiseTick(); - } - - internal INativeControlHostImpl GetNativeControlHostImpl() - { - return new BrowserNativeControlHost(_nativeControlsContainer); - } - - private void ForceBlit() - { - // Note: this is technically a hack, but it's a kinda unique use case when - // we want to blit the previous frame - // renderer doesn't have much control over the render target - // we render on the UI thread - // We also don't want to have it as a meaningful public API. - // Therefore we have InternalsVisibleTo hack here. - - if (_topLevel.Renderer is CompositingRenderer dr) - { - MediaContext.Instance.ImmediateRenderRequested(dr.CompositionTarget, true); - } - } - - private void OnSizeOrDpiChanged(double displayWidth, double displayHeight, double dpi) - { - var newSize = new Size(displayWidth, displayHeight); - - if (_canvasSize != newSize || _dpi != dpi) - { - _dpi = dpi; - - _canvasSize = newSize; - - CanvasHelper.SetCanvasSize(_canvas, (int)_canvasSize.Width, (int)_canvasSize.Height); - - _topLevelImpl.SetClientSize(new(displayWidth / dpi, displayHeight / dpi), dpi); - - ForceBlit(); - } - } - - private void HideIme() - { - InputHelper.HideElement(_inputElement); - InputHelper.FocusElement(_containerElement); - } - - void ITextInputMethodImpl.SetClient(TextInputMethodClient? client) - { - if (_client != null) - { - _client.SurroundingTextChanged -= SurroundingTextChanged; - } - - if (client != null) - { - client.SurroundingTextChanged += SurroundingTextChanged; - } - - InputHelper.ClearInputElement(_inputElement); - - _client = client; - - if (_client != null) - { - InputHelper.ShowElement(_inputElement); - InputHelper.FocusElement(_inputElement); - - var surroundingText = _client.SurroundingText ?? ""; - var selection = _client.Selection; - - InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); - } - else - { - HideIme(); - } - } - - private void SurroundingTextChanged(object? sender, EventArgs e) - { - if (_client != null) - { - var surroundingText = _client.SurroundingText ?? ""; - var selection = _client.Selection; - - InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); - } - } - - void ITextInputMethodImpl.SetCursorRect(Rect rect) - { - InputHelper.FocusElement(_inputElement); - InputHelper.SetBounds(_inputElement, (int)rect.X, (int)rect.Y, (int)rect.Width, (int)rect.Height, _client?.Selection.End ?? 0); - InputHelper.FocusElement(_inputElement); - } - - void ITextInputMethodImpl.SetOptions(TextInputOptions options) - { - } - - void ITextInputMethodImpl.Reset() - { - InputHelper.ClearInputElement(_inputElement); - InputHelper.SetSurroundingText(_inputElement, "", 0, 0); - } } } diff --git a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs index 9c41fff43f..950d07f207 100644 --- a/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs +++ b/src/Browser/Avalonia.Browser/BrowserAppBuilder.cs @@ -1,12 +1,30 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Avalonia.Browser.Interop; using Avalonia.Metadata; namespace Avalonia.Browser; -public class BrowserPlatformOptions +public enum BrowserRenderingMode +{ + Software2D = 1, + WebGL1, + WebGL2 +} + +public record BrowserPlatformOptions { + /// + /// Gets or sets Avalonia rendering modes with fallbacks. + /// The first element in the array has the highest priority. + /// + /// Thrown if no values were matched. + public IReadOnlyList RenderingMode { get; set; } = new[] + { + BrowserRenderingMode.WebGL2, BrowserRenderingMode.WebGL1, BrowserRenderingMode.Software2D + }; + /// /// 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. @@ -26,7 +44,7 @@ public class BrowserPlatformOptions /// By default, current domain root is used as a scope. /// public string? AvaloniaServiceWorkerScope { get; set; } - + /// /// Avalonia uses "native-file-system-adapter" polyfill for the file dialogs. /// If native implementation is available, by default it is used. @@ -44,13 +62,14 @@ public static class BrowserAppBuilder /// 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) + 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(); @@ -86,7 +105,7 @@ public static class BrowserAppBuilder options.FrameworkAssetPathResolver ??= fileName => $"./{fileName}"; AvaloniaLocator.CurrentMutable.Bind().ToConstant(options); - + await AvaloniaModule.ImportMain(); if (builder.WindowingSubsystemInitializer is null) @@ -96,7 +115,7 @@ public static class BrowserAppBuilder return builder; } - + public static AppBuilder UseBrowser( this AppBuilder builder) { diff --git a/src/Browser/Avalonia.Browser/BrowserDispatcherImpl.cs b/src/Browser/Avalonia.Browser/BrowserDispatcherImpl.cs index 6ee6c719a7..d7eaa81abf 100644 --- a/src/Browser/Avalonia.Browser/BrowserDispatcherImpl.cs +++ b/src/Browser/Avalonia.Browser/BrowserDispatcherImpl.cs @@ -44,7 +44,7 @@ internal class BrowserDispatcherImpl : IDispatcherImpl // NOTE: by HTML5 spec minimal timeout is 4ms, but Chrome seems to work well with 1ms as well. var interval = 1; - CanvasHelper.SetTimeout(_signalCallback, interval); + TimerHelper.SetTimeout(_signalCallback, interval); } public void UpdateTimer(long? dueTimeInMs) @@ -52,13 +52,13 @@ internal class BrowserDispatcherImpl : IDispatcherImpl if (_timerId is { } timerId) { _timerId = null; - CanvasHelper.ClearInterval(timerId); + TimerHelper.ClearInterval(timerId); } if (dueTimeInMs.HasValue) { var interval = Math.Max(1, dueTimeInMs.Value - _clock.ElapsedMilliseconds); - _timerId = CanvasHelper.SetInterval(_timerCallback, (int)interval); + _timerId = TimerHelper.SetInterval(_timerCallback, (int)interval); } } } diff --git a/src/Browser/Avalonia.Browser/BrowserInputHandler.cs b/src/Browser/Avalonia.Browser/BrowserInputHandler.cs new file mode 100644 index 0000000000..edd66b5a06 --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserInputHandler.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Collections.Pooled; +using Avalonia.Input; +using Avalonia.Input.Raw; + +namespace Avalonia.Browser; + +internal class BrowserInputHandler +{ + private readonly BrowserTopLevelImpl _topLevelImpl; + private readonly JSObject _container; + private readonly Stopwatch _sw = Stopwatch.StartNew(); + private readonly TouchDevice _touchDevice; + private readonly PenDevice _penDevice; + private readonly MouseDevice _wheelMouseDevice; + private readonly List _mouseDevices; + private IInputRoot? _inputRoot; + + private static readonly PooledList s_intermediatePointsPooledList = new(ClearMode.Never); + + public BrowserInputHandler(BrowserTopLevelImpl topLevelImpl, JSObject container) + { + _topLevelImpl = topLevelImpl; + _container = container ?? throw new ArgumentNullException(nameof(container)); + + _touchDevice = new TouchDevice(); + _penDevice = new PenDevice(); + _wheelMouseDevice = new MouseDevice(); + _mouseDevices = new(); + + InputHelper.SubscribeKeyEvents( + container, + OnKeyDown, + OnKeyUp); + InputHelper.SubscribePointerEvents(container, OnPointerMove, OnPointerDown, OnPointerUp, + OnPointerCancel, OnWheel); + InputHelper.SubscribeDropEvents(container, OnDragEvent); + } + + public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds; + + internal void SetInputRoot(IInputRoot inputRoot) + { + _inputRoot = inputRoot; + } + + private static RawPointerPoint ExtractRawPointerFromJsArgs(JSObject args) + { + var point = new RawPointerPoint + { + Position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")), + Pressure = (float)args.GetPropertyAsDouble("pressure"), + XTilt = (float)args.GetPropertyAsDouble("tiltX"), + YTilt = (float)args.GetPropertyAsDouble("tiltY"), + Twist = (float)args.GetPropertyAsDouble("twist") + }; + + return point; + } + + private bool OnPointerMove(JSObject args) + { + var pointerType = args.GetPropertyAsString("pointerType"); + var point = ExtractRawPointerFromJsArgs(args); + var type = pointerType switch + { + "touch" => RawPointerEventType.TouchUpdate, + _ => RawPointerEventType.Move + }; + + var coalescedEvents = new Lazy?>(() => + { + var points = InputHelper.GetCoalescedEvents(args); + s_intermediatePointsPooledList.Clear(); + s_intermediatePointsPooledList.Capacity = points.Length - 1; + + // Skip the last one, as it is already processed point. + for (var i = 0; i < points.Length - 1; i++) + { + var point = points[i]; + s_intermediatePointsPooledList.Add(ExtractRawPointerFromJsArgs(point)); + } + + return s_intermediatePointsPooledList; + }); + + return RawPointerEvent(type, pointerType!, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId"), + coalescedEvents); + } + + private bool OnPointerDown(JSObject args) + { + var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; + var type = pointerType switch + { + "touch" => RawPointerEventType.TouchBegin, + _ => args.GetPropertyAsInt32("button") switch + { + 0 => RawPointerEventType.LeftButtonDown, + 1 => RawPointerEventType.MiddleButtonDown, + 2 => RawPointerEventType.RightButtonDown, + 3 => RawPointerEventType.XButton1Down, + 4 => RawPointerEventType.XButton2Down, + 5 => RawPointerEventType.XButton1Down, // should be pen eraser button, + _ => RawPointerEventType.Move + } + }; + + var point = ExtractRawPointerFromJsArgs(args); + return RawPointerEvent(type, pointerType, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); + } + + private bool OnPointerUp(JSObject args) + { + var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; + var type = pointerType switch + { + "touch" => RawPointerEventType.TouchEnd, + _ => args.GetPropertyAsInt32("button") switch + { + 0 => RawPointerEventType.LeftButtonUp, + 1 => RawPointerEventType.MiddleButtonUp, + 2 => RawPointerEventType.RightButtonUp, + 3 => RawPointerEventType.XButton1Up, + 4 => RawPointerEventType.XButton2Up, + 5 => RawPointerEventType.XButton1Up, // should be pen eraser button, + _ => RawPointerEventType.Move + } + }; + + var point = ExtractRawPointerFromJsArgs(args); + return RawPointerEvent(type, pointerType, point, GetModifiers(args), args.GetPropertyAsInt32("pointerId")); + } + + private bool OnPointerCancel(JSObject args) + { + var pointerType = args.GetPropertyAsString("pointerType") ?? "mouse"; + if (pointerType == "touch") + { + var point = ExtractRawPointerFromJsArgs(args); + RawPointerEvent(RawPointerEventType.TouchCancel, pointerType, point, + GetModifiers(args), args.GetPropertyAsInt32("pointerId")); + } + + return false; + } + + private bool OnWheel(JSObject args) + { + return RawMouseWheelEvent(new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")), + new Vector(-(args.GetPropertyAsDouble("deltaX") / 50), -(args.GetPropertyAsDouble("deltaY") / 50)), + GetModifiers(args)); + } + + private static RawInputModifiers GetModifiers(JSObject e) + { + var modifiers = RawInputModifiers.None; + + if (e.GetPropertyAsBoolean("ctrlKey")) + modifiers |= RawInputModifiers.Control; + if (e.GetPropertyAsBoolean("altKey")) + modifiers |= RawInputModifiers.Alt; + if (e.GetPropertyAsBoolean("shiftKey")) + modifiers |= RawInputModifiers.Shift; + if (e.GetPropertyAsBoolean("metaKey")) + modifiers |= RawInputModifiers.Meta; + + var buttons = e.GetPropertyAsInt32("buttons"); + if ((buttons & 1L) == 1) + modifiers |= RawInputModifiers.LeftMouseButton; + + if ((buttons & 2L) == 2) + modifiers |= e.GetPropertyAsString("type") == "pen" ? + RawInputModifiers.PenBarrelButton : + RawInputModifiers.RightMouseButton; + + if ((buttons & 4L) == 4) + modifiers |= RawInputModifiers.MiddleMouseButton; + + if ((buttons & 8L) == 8) + modifiers |= RawInputModifiers.XButton1MouseButton; + + if ((buttons & 16L) == 16) + modifiers |= RawInputModifiers.XButton2MouseButton; + + if ((buttons & 32L) == 32) + modifiers |= RawInputModifiers.PenEraser; + + return modifiers; + } + + public bool OnDragEvent(JSObject args) + { + var eventType = args?.GetPropertyAsString("type") switch + { + "dragenter" => RawDragEventType.DragEnter, + "dragover" => RawDragEventType.DragOver, + "dragleave" => RawDragEventType.DragLeave, + "drop" => RawDragEventType.Drop, + _ => (RawDragEventType)(int)-1 + }; + var dataObject = args?.GetPropertyAsJSObject("dataTransfer"); + if (args is null || eventType < 0 || dataObject is null) + { + return false; + } + + // If file is dropped, we need storage js to be referenced. + // TODO: restructure JS files, so it's not needed. + _ = AvaloniaModule.ImportStorage(); + + var position = new Point(args.GetPropertyAsDouble("offsetX"), args.GetPropertyAsDouble("offsetY")); + var modifiers = GetModifiers(args); + + var effectAllowedStr = dataObject.GetPropertyAsString("effectAllowed") ?? "none"; + var effectAllowed = DragDropEffects.None; + if (effectAllowedStr.Contains("copy", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Copy; + } + + if (effectAllowedStr.Contains("link", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Link; + } + + if (effectAllowedStr.Contains("move", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Move; + } + + if (effectAllowedStr.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + effectAllowed |= DragDropEffects.Move | DragDropEffects.Copy | DragDropEffects.Link; + } + + if (effectAllowed == DragDropEffects.None) + { + return false; + } + + var dropEffect = RawDragEvent(eventType, position, modifiers, new BrowserDataObject(dataObject), effectAllowed); + dataObject.SetProperty("dropEffect", dropEffect.ToString().ToLowerInvariant()); + + return eventType is RawDragEventType.Drop or RawDragEventType.DragOver + && dropEffect != DragDropEffects.None; + } + + private bool OnKeyDown(string code, string key, int modifier) + { + var handled = RawKeyboardEvent(RawKeyEventType.KeyDown, code, key, (RawInputModifiers)modifier); + + if (!handled && key.Length == 1) + { + handled = RawTextEvent(key); + } + + return handled; + } + + private bool OnKeyUp(string code, string key, int modifier) + { + return RawKeyboardEvent(RawKeyEventType.KeyUp, code, key, (RawInputModifiers)modifier); + } + + private bool RawPointerEvent( + RawPointerEventType eventType, string pointerType, + RawPointerPoint p, RawInputModifiers modifiers, long touchPointId, + Lazy?>? intermediatePoints = null) + { + if (_inputRoot is { } + && _topLevelImpl.Input is { } input) + { + var device = GetPointerDevice(pointerType, touchPointId); + var args = device is TouchDevice ? + new RawTouchEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers, touchPointId) + { + IntermediatePoints = intermediatePoints + } : + new RawPointerEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers) + { + RawPointerId = touchPointId, IntermediatePoints = intermediatePoints + }; + + input.Invoke(args); + + return args.Handled; + } + + return false; + } + + private IPointerDevice GetPointerDevice(string pointerType, long pointerId) + { + if (pointerType == "touch") + return _touchDevice; + else if (pointerType == "pen") + return _penDevice; + + // TODO: refactor pointer devices, so we can reuse single instance here. + foreach (var mouseDevice in _mouseDevices) + { + if (mouseDevice.PointerId == pointerId) + return mouseDevice; + } + + var newMouseDevice = new BrowserMouseDevice(pointerId, _container); + _mouseDevices.Add(newMouseDevice); + return newMouseDevice; + } + + private bool RawMouseWheelEvent(Point p, Vector v, RawInputModifiers modifiers) + { + if (_inputRoot is { }) + { + var args = new RawMouseWheelEventArgs(_wheelMouseDevice, Timestamp, _inputRoot, p, v, modifiers); + + _topLevelImpl.Input?.Invoke(args); + + return args.Handled; + } + + return false; + } + + private bool RawKeyboardEvent(RawKeyEventType type, string domCode, string domKey, RawInputModifiers modifiers) + { + if (_inputRoot is null) + return false; + + var physicalKey = KeyInterop.PhysicalKeyFromDomCode(domCode); + var key = KeyInterop.KeyFromDomKey(domKey, physicalKey); + var keySymbol = KeyInterop.KeySymbolFromDomKey(domKey); + + var args = new RawKeyEventArgs( + BrowserWindowingPlatform.Keyboard, + Timestamp, + _inputRoot, + type, + key, + modifiers, + physicalKey, + keySymbol + ); + + try + { + _topLevelImpl.Input?.Invoke(args); + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + + return args.Handled; + } + + internal bool RawTextEvent(string text) + { + if (_inputRoot is { }) + { + var args = new RawTextInputEventArgs(BrowserWindowingPlatform.Keyboard, Timestamp, _inputRoot, text); + _topLevelImpl.Input?.Invoke(args); + + return args.Handled; + } + + return false; + } + + private DragDropEffects RawDragEvent(RawDragEventType eventType, Point position, RawInputModifiers modifiers, + BrowserDataObject dataObject, DragDropEffects dropEffect) + { + var device = AvaloniaLocator.Current.GetRequiredService(); + var eventArgs = new RawDragEvent(device, eventType, _inputRoot!, position, dataObject, dropEffect, modifiers); + _topLevelImpl.Input?.Invoke(eventArgs); + return eventArgs.Effects; + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs b/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs index dac5a3f14a..2c8eb03c72 100644 --- a/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs +++ b/src/Browser/Avalonia.Browser/BrowserInsetsManager.cs @@ -1,17 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Avalonia.Browser.Interop; +using Avalonia.Browser.Interop; using Avalonia.Controls.Platform; using Avalonia.Media; -using static Avalonia.Controls.Platform.IInsetsManager; namespace Avalonia.Browser { internal class BrowserInsetsManager : InsetsManagerBase { + public BrowserInsetsManager() + { + DomHelper.InitSafeAreaPadding(); + } + public override bool? IsSystemBarVisible { get diff --git a/src/Browser/Avalonia.Browser/BrowserMouseDevice.cs b/src/Browser/Avalonia.Browser/BrowserMouseDevice.cs index c8c23b6000..9835e3a15b 100644 --- a/src/Browser/Avalonia.Browser/BrowserMouseDevice.cs +++ b/src/Browser/Avalonia.Browser/BrowserMouseDevice.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices.JavaScript; -using Avalonia.Input; using Avalonia.Browser.Interop; +using Avalonia.Input; namespace Avalonia.Browser { diff --git a/src/Browser/Avalonia.Browser/BrowserNativeControlHost.cs b/src/Browser/Avalonia.Browser/BrowserNativeControlHost.cs index c2e54c7ed7..75a5444ab2 100644 --- a/src/Browser/Avalonia.Browser/BrowserNativeControlHost.cs +++ b/src/Browser/Avalonia.Browser/BrowserNativeControlHost.cs @@ -11,9 +11,9 @@ namespace Avalonia.Browser { private readonly JSObject _hostElement; - public BrowserNativeControlHost(JSObject element) + public BrowserNativeControlHost(JSObject nativeControlHost) { - _hostElement = element; + _hostElement = nativeControlHost ?? throw new ArgumentNullException(nameof(nativeControlHost)); } public INativeControlHostDestroyableControlHandle CreateDefaultChild(IPlatformHandle parent) diff --git a/src/Browser/Avalonia.Browser/BrowserRuntimePlatform.cs b/src/Browser/Avalonia.Browser/BrowserRuntimePlatform.cs index 8709fd79fd..ca3eb7bd94 100644 --- a/src/Browser/Avalonia.Browser/BrowserRuntimePlatform.cs +++ b/src/Browser/Avalonia.Browser/BrowserRuntimePlatform.cs @@ -1,8 +1,5 @@ using System; using System.Reflection; -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.JavaScript; -using System.Text.RegularExpressions; using Avalonia.Browser.Interop; using Avalonia.Platform; diff --git a/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs b/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs index a28bb469f3..d352b92c0d 100644 --- a/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs +++ b/src/Browser/Avalonia.Browser/BrowserSingleViewLifetime.cs @@ -1,8 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; +using Avalonia.Browser; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Browser; namespace Avalonia; @@ -29,7 +29,8 @@ internal class BrowserSingleViewLifetime : ISingleViewApplicationLifetime, ISing { if (View is null) { - throw new InvalidOperationException("Browser lifetime was not initialized. Make sure AppBuilder.StartBrowserApp was called."); + throw new InvalidOperationException( + "Browser lifetime was not initialized. Make sure AppBuilder.StartBrowserAppAsync was called."); } } diff --git a/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs new file mode 100644 index 0000000000..8cb4296c48 --- /dev/null +++ b/src/Browser/Avalonia.Browser/BrowserTextInputMethod.cs @@ -0,0 +1,162 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Input.TextInput; + +namespace Avalonia.Browser; + +internal class BrowserTextInputMethod : ITextInputMethodImpl +{ + private readonly JSObject _inputElement; + private readonly JSObject _containerElement; + private readonly BrowserInputHandler _inputHandler; + private TextInputMethodClient? _client; + + public BrowserTextInputMethod(BrowserInputHandler inputHandler, JSObject containerElement, JSObject inputElement) + { + _inputHandler = inputHandler ?? throw new ArgumentNullException(nameof(inputHandler)); + _containerElement = containerElement ?? throw new ArgumentNullException(nameof(containerElement)); + _inputElement = inputElement ?? throw new ArgumentNullException(nameof(inputElement)); + + InputHelper.SubscribeTextEvents( + _inputElement, + OnBeforeInput, + OnCompositionStart, + OnCompositionUpdate, + OnCompositionEnd); + } + + public bool IsComposing { get; private set; } + + private void HideIme() + { + InputHelper.HideElement(_inputElement); + InputHelper.FocusElement(_containerElement); + } + + public void SetClient(TextInputMethodClient? client) + { + if (_client != null) + { + _client.SurroundingTextChanged -= SurroundingTextChanged; + } + + if (client != null) + { + client.SurroundingTextChanged += SurroundingTextChanged; + } + + InputHelper.ClearInputElement(_inputElement); + + _client = client; + + if (_client != null) + { + InputHelper.ShowElement(_inputElement); + InputHelper.FocusElement(_inputElement); + + var surroundingText = _client.SurroundingText ?? ""; + var selection = _client.Selection; + + InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); + } + else + { + HideIme(); + } + } + + private void SurroundingTextChanged(object? sender, EventArgs e) + { + if (_client != null) + { + var surroundingText = _client.SurroundingText ?? ""; + var selection = _client.Selection; + + InputHelper.SetSurroundingText(_inputElement, surroundingText, selection.Start, selection.End); + } + } + + public void SetCursorRect(Rect rect) + { + InputHelper.FocusElement(_inputElement); + InputHelper.SetBounds(_inputElement, (int)rect.X, (int)rect.Y, (int)rect.Width, (int)rect.Height, + _client?.Selection.End ?? 0); + InputHelper.FocusElement(_inputElement); + } + + public void SetOptions(TextInputOptions options) + { + } + + public void Reset() + { + InputHelper.ClearInputElement(_inputElement); + InputHelper.SetSurroundingText(_inputElement, "", 0, 0); + } + + private bool OnBeforeInput(JSObject arg, int start, int end) + { + var type = arg.GetPropertyAsString("inputType"); + if (type != "deleteByComposition") + { + if (type == "deleteContentBackward") + { + start = _inputElement.GetPropertyAsInt32("selectionStart"); + end = _inputElement.GetPropertyAsInt32("selectionEnd"); + } + else + { + start = -1; + end = -1; + } + } + + if (start != -1 && end != -1 && _client != null) + { + _client.Selection = new TextSelection(start, end); + } + + return false; + } + + private bool OnCompositionStart(JSObject args) + { + if (_client == null) + return false; + + _client.SetPreeditText(null); + IsComposing = true; + + return false; + } + + private bool OnCompositionUpdate(JSObject args) + { + if (_client == null) + return false; + + _client.SetPreeditText(args.GetPropertyAsString("data")); + + return false; + } + + private bool OnCompositionEnd(JSObject args) + { + if (_client == null) + return false; + + IsComposing = false; + + _client.SetPreeditText(null); + + var text = args.GetPropertyAsString("data"); + + if (text != null) + { + return _inputHandler.RawTextEvent(text); + } + + return false; + } +} diff --git a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs index 629c42af9b..9e5742954b 100644 --- a/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs +++ b/src/Browser/Avalonia.Browser/BrowserTopLevelImpl.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Runtime.InteropServices.JavaScript; using System.Runtime.Versioning; +using Avalonia.Browser.Interop; +using Avalonia.Browser.Rendering; using Avalonia.Browser.Skia; using Avalonia.Browser.Storage; using Avalonia.Controls; @@ -22,205 +23,86 @@ namespace Avalonia.Browser { internal class BrowserTopLevelImpl : ITopLevelImpl { - private Size _clientSize; - private IInputRoot? _inputRoot; - private readonly Stopwatch _sw = Stopwatch.StartNew(); - private readonly AvaloniaView _avaloniaView; - private readonly TouchDevice _touchDevice; - private readonly PenDevice _penDevice; - private string _currentCursor = CssCursor.Default; private readonly INativeControlHostImpl _nativeControlHost; private readonly IStorageProvider _storageProvider; private readonly ISystemNavigationManagerImpl _systemNavigationManager; + private readonly ITextInputMethodImpl _textInputMethodImpl; private readonly ClipboardImpl _clipboard; - private readonly IInsetsManager? _insetsManager; + private readonly IInsetsManager _insetsManager; private readonly IInputPane _inputPane; - private readonly List _mouseDevices; private readonly JSObject _container; + private readonly BrowserInputHandler _inputHandler; + private string _currentCursor = CssCursor.Default; + private BrowserSurface? _surface; + static BrowserTopLevelImpl() + { + InputHelper.InitializeBackgroundHandlers(); + } - public BrowserTopLevelImpl(AvaloniaView avaloniaView, JSObject container) + public BrowserTopLevelImpl(JSObject container, JSObject nativeControlHost, JSObject inputElement) { Surfaces = Enumerable.Empty(); - _avaloniaView = avaloniaView; AcrylicCompensationLevels = new AcrylicPlatformCompensationLevels(1, 1, 1); - _touchDevice = new TouchDevice(); - _penDevice = new PenDevice(); + _inputHandler = new BrowserInputHandler(this, container); + _textInputMethodImpl = new BrowserTextInputMethod(_inputHandler, container, inputElement); _insetsManager = new BrowserInsetsManager(); - _nativeControlHost = _avaloniaView.GetNativeControlHostImpl(); + _nativeControlHost = new BrowserNativeControlHost(nativeControlHost); _storageProvider = new BrowserStorageProvider(); _systemNavigationManager = new BrowserSystemNavigationManagerImpl(); _clipboard = new ClipboardImpl(); _inputPane = new BrowserInputPane(container); - _mouseDevices = new(); _container = container; - } - - public ulong Timestamp => (ulong)_sw.ElapsedMilliseconds; - - public void SetClientSize(Size newSize, double dpi) - { - if (Math.Abs(RenderScaling - dpi) > 0.0001) - { - if (Surfaces.FirstOrDefault() is BrowserSkiaSurface surface) - { - surface.Scaling = dpi; - } - - ScalingChanged?.Invoke(dpi); - } - - if (newSize != _clientSize) - { - _clientSize = newSize; - - if (Surfaces.FirstOrDefault() is BrowserSkiaSurface surface) - { - surface.Size = new PixelSize((int)newSize.Width, (int)newSize.Height); - surface.DisplaySize = newSize; - } - - Resized?.Invoke(newSize, WindowResizeReason.User); - - (_insetsManager as BrowserInsetsManager)?.NotifySafeAreaPaddingChanged(); - } - } - - public bool RawPointerEvent( - RawPointerEventType eventType, string pointerType, - RawPointerPoint p, RawInputModifiers modifiers, long touchPointId, - Lazy?>? intermediatePoints = null) - { - if (_inputRoot is { } - && Input is { } input) - { - var device = GetPointerDevice(pointerType, touchPointId); - var args = device is TouchDevice ? - new RawTouchEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers, touchPointId) - { - IntermediatePoints = intermediatePoints - } : - new RawPointerEventArgs(device, Timestamp, _inputRoot, eventType, p, modifiers) - { - RawPointerId = touchPointId, - IntermediatePoints = intermediatePoints - }; - - input.Invoke(args); - - return args.Handled; - } - - return false; - } - private IPointerDevice GetPointerDevice(string pointerType, long pointerId) - { - if (pointerType == "touch") - return _touchDevice; - else if (pointerType == "pen") - return _penDevice; + _surface = BrowserSurface.Create(container, PixelFormats.Rgba8888); + _surface.SizeChanged += OnSizeChanged; + _surface.ScalingChanged += OnScalingChanged; - foreach (var mouseDevice in _mouseDevices) - { - if (mouseDevice.PointerId == pointerId) - return mouseDevice; - } - var newMouseDevice = new BrowserMouseDevice(pointerId, _container); - _mouseDevices.Add(newMouseDevice); - return newMouseDevice; + Surfaces = new[] { _surface }; + Compositor = _surface.IsWebGl ? + BrowserCompositor.WebGlUiCompositor : + BrowserCompositor.SoftwareUiCompositor; } - public bool RawMouseWheelEvent(Point p, Vector v, RawInputModifiers modifiers) + private void OnScalingChanged() { - if (_inputRoot is { }) + if (_surface is not null) { - var args = new RawMouseWheelEventArgs(WheelMouseDevice, Timestamp, _inputRoot, p, v, modifiers); - - Input?.Invoke(args); - - return args.Handled; + ScalingChanged?.Invoke(_surface.Scaling); } - - return false; } - public bool RawKeyboardEvent(RawKeyEventType type, string domCode, string domKey, RawInputModifiers modifiers) + private void OnSizeChanged() { - if (_inputRoot is null) - return false; - - var physicalKey = KeyInterop.PhysicalKeyFromDomCode(domCode); - var key = KeyInterop.KeyFromDomKey(domKey, physicalKey); - var keySymbol = KeyInterop.KeySymbolFromDomKey(domKey); - - var args = new RawKeyEventArgs( - KeyboardDevice, - Timestamp, - _inputRoot, - type, - key, - modifiers, - physicalKey, - keySymbol - ); - - Input?.Invoke(args); - - return args.Handled; - } - - public bool RawTextEvent(string text) - { - if (_inputRoot is { }) + if (_surface is not null) { - var args = new RawTextInputEventArgs(KeyboardDevice, Timestamp, _inputRoot, text); - Input?.Invoke(args); - - return args.Handled; + Resized?.Invoke(_surface.ClientSize, WindowResizeReason.User); + (_insetsManager as BrowserInsetsManager)?.NotifySafeAreaPaddingChanged(); } - - return false; - } - - public DragDropEffects RawDragEvent(RawDragEventType eventType, Point position, RawInputModifiers modifiers, BrowserDataObject dataObject, DragDropEffects dropEffect) - { - var device = AvaloniaLocator.Current.GetRequiredService(); - var eventArgs = new RawDragEvent(device, eventType, _inputRoot!, position, dataObject, dropEffect, modifiers); - Console.WriteLine($"{eventArgs.Location} {eventArgs.Effects} {eventArgs.Type} {eventArgs.KeyModifiers}"); - Input?.Invoke(eventArgs); - return eventArgs.Effects; } public void Dispose() { - + _surface?.Dispose(); + _surface = null; } - public Compositor Compositor { get; } = new(AvaloniaLocator.Current.GetRequiredService()); + public Compositor Compositor { get; } - public void Invalidate(Rect rect) - { - //Console.WriteLine("invalidate rect called"); - } + public void SetInputRoot(IInputRoot inputRoot) => _inputHandler.SetInputRoot(inputRoot); - public void SetInputRoot(IInputRoot inputRoot) - { - _inputRoot = inputRoot; - } - - public Point PointToClient(PixelPoint point) => new Point(point.X, point.Y); + public Point PointToClient(PixelPoint point) => new(point.X, point.Y); - public PixelPoint PointToScreen(Point point) => new PixelPoint((int)point.X, (int)point.Y); + public PixelPoint PointToScreen(Point point) => new((int)point.X, (int)point.Y); public void SetCursor(ICursorImpl? cursor) { var val = (cursor as CssCursor)?.Value ?? CssCursor.Default; if (_currentCursor != val) { - SetCssCursor?.Invoke(val); + InputHelper.SetCursor(_container, val); _currentCursor = val; } } @@ -234,13 +116,12 @@ namespace Avalonia.Browser { } - public Size ClientSize => _clientSize; + public Size ClientSize => _surface?.ClientSize ?? new Size(1, 1); public Size? FrameSize => null; - public double RenderScaling => (Surfaces.FirstOrDefault() as BrowserSkiaSurface)?.Scaling ?? 1; + public double RenderScaling => _surface?.Scaling ?? 1; public IEnumerable Surfaces { get; set; } - public Action? SetCssCursor { get; set; } public Action? Input { get; set; } public Action? Paint { get; set; } public Action? Resized { get; set; } @@ -248,10 +129,8 @@ namespace Avalonia.Browser public Action? TransparencyLevelChanged { get; set; } public Action? Closed { get; set; } public Action? LostFocus { get; set; } - public IMouseDevice WheelMouseDevice { get; } = new MouseDevice(); - - public IKeyboardDevice KeyboardDevice { get; } = BrowserWindowingPlatform.Keyboard; public WindowTransparencyLevel TransparencyLevel => WindowTransparencyLevel.None; + public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) { // not in the standard, but we potentially can use "apple-mobile-web-app-status-bar-style" for iOS and "theme-color" for android. @@ -268,7 +147,7 @@ namespace Avalonia.Browser if (featureType == typeof(ITextInputMethodImpl)) { - return _avaloniaView; + return _textInputMethodImpl; } if (featureType == typeof(ISystemNavigationManagerImpl)) @@ -290,7 +169,7 @@ namespace Avalonia.Browser { return _clipboard; } - + if (featureType == typeof(IInputPane)) { return _inputPane; diff --git a/src/Browser/Avalonia.Browser/Interop/CanvasHelper.cs b/src/Browser/Avalonia.Browser/Interop/CanvasHelper.cs index cdcda6a320..e725c2997a 100644 --- a/src/Browser/Avalonia.Browser/Interop/CanvasHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/CanvasHelper.cs @@ -1,4 +1,7 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.InteropServices.JavaScript; @@ -8,41 +11,42 @@ internal record GLInfo(int ContextId, uint FboId, int Stencils, int Samples, int internal static partial class CanvasHelper { - public static GLInfo InitialiseGL(JSObject canvas, Action renderFrameCallback) + public static (JSObject CanvasView, GLInfo? GLInfo) CreateSurface( + JSObject container, BrowserRenderingMode renderingMode) { - var info = InitGL(canvas, canvas.GetPropertyAsString("id")!, renderFrameCallback); - - var glInfo = new GLInfo( - info.GetPropertyAsInt32("context"), - (uint)info.GetPropertyAsInt32("fboId"), - info.GetPropertyAsInt32("stencil"), - info.GetPropertyAsInt32("sample"), - info.GetPropertyAsInt32("depth")); - - return glInfo; + var isGlMode = renderingMode is BrowserRenderingMode.WebGL1 or BrowserRenderingMode.WebGL2; + + var canvasView = Create(container, (int)renderingMode); + + GLInfo? glInfo = null; + if (isGlMode) + { + glInfo = new GLInfo( + canvasView.GetPropertyAsInt32("contextHandle")!, + (uint)canvasView.GetPropertyAsInt32("fboId"), + canvasView.GetPropertyAsInt32("stencil"), + canvasView.GetPropertyAsInt32("sample"), + canvasView.GetPropertyAsInt32("depth")); + } + + return (canvasView, glInfo); } - [JSImport("Canvas.requestAnimationFrame", AvaloniaModule.MainModuleName)] - public static partial void RequestAnimationFrame(JSObject canvas, bool renderLoop); - - [JSImport("Canvas.setCanvasSize", AvaloniaModule.MainModuleName)] - public static partial void SetCanvasSize(JSObject canvas, int width, int height); - - [JSImport("Canvas.initGL", AvaloniaModule.MainModuleName)] - private static partial JSObject InitGL( - JSObject canvas, - string canvasId, - [JSMarshalAs] Action renderFrameCallback); + [JSImport("CanvasFactory.onSizeChanged", AvaloniaModule.MainModuleName)] + public static partial void OnSizeChanged( + JSObject canvasSurface, + [JSMarshalAs>] + Action onSizeChanged); - [JSImport("globalThis.setTimeout")] - public static partial int SetTimeout([JSMarshalAs] Action callback, int intervalMs); + [JSImport("CanvasFactory.create", AvaloniaModule.MainModuleName)] + private static partial JSObject Create(JSObject canvasSurface, int mode); - [JSImport("globalThis.clearTimeout")] - public static partial int ClearTimeout(int id); + [JSImport("CanvasFactory.destroy", AvaloniaModule.MainModuleName)] + public static partial void Destroy(JSObject canvasSurface); - [JSImport("globalThis.setInterval")] - public static partial int SetInterval([JSMarshalAs] Action callback, int intervalMs); + [JSImport("CanvasFactory.ensureSize", AvaloniaModule.MainModuleName)] + public static partial void EnsureSize(JSObject canvasSurface); - [JSImport("globalThis.clearInterval")] - public static partial int ClearInterval(int id); + [JSImport("CanvasFactory.putPixelData", AvaloniaModule.MainModuleName)] + public static partial void PutPixelData(JSObject canvasSurface, [JSMarshalAs] ArraySegment data, int width, int height); } diff --git a/src/Browser/Avalonia.Browser/Interop/DomHelper.cs b/src/Browser/Avalonia.Browser/Interop/DomHelper.cs index 9cd7261994..a567973131 100644 --- a/src/Browser/Avalonia.Browser/Interop/DomHelper.cs +++ b/src/Browser/Avalonia.Browser/Interop/DomHelper.cs @@ -8,6 +8,9 @@ internal static partial class DomHelper [JSImport("globalThis.document.getElementById")] internal static partial JSObject? GetElementById(string id); + [JSImport("AvaloniaDOM.getFirstElementByClassName", AvaloniaModule.MainModuleName)] + internal static partial JSObject? GetElementsByClassName(string className, JSObject? parent); + [JSImport("AvaloniaDOM.createAvaloniaHost", AvaloniaModule.MainModuleName)] public static partial JSObject CreateAvaloniaHost(JSObject element); @@ -18,17 +21,14 @@ internal static partial class DomHelper public static partial JSObject SetFullscreen(bool isFullscreen); [JSImport("AvaloniaDOM.getSafeAreaPadding", AvaloniaModule.MainModuleName)] - public static partial byte[] GetSafeAreaPadding(); + public static partial double[] GetSafeAreaPadding(); + + [JSImport("AvaloniaDOM.initSafeAreaPadding", AvaloniaModule.MainModuleName)] + public static partial void InitSafeAreaPadding(); [JSImport("AvaloniaDOM.addClass", AvaloniaModule.MainModuleName)] public static partial void AddCssClass(JSObject element, string className); - [JSImport("ResizeHandler.observeSize", AvaloniaModule.MainModuleName)] - public static partial void ObserveSize( - JSObject canvas, - [JSMarshalAs>] - Action onSizeOrDpiChanged); - [JSImport("AvaloniaDOM.observeDarkMode", AvaloniaModule.MainModuleName)] public static partial JSObject ObserveDarkMode( [JSMarshalAs>] diff --git a/src/Browser/Avalonia.Browser/Interop/TimerHelper.cs b/src/Browser/Avalonia.Browser/Interop/TimerHelper.cs new file mode 100644 index 0000000000..32c2f66565 --- /dev/null +++ b/src/Browser/Avalonia.Browser/Interop/TimerHelper.cs @@ -0,0 +1,23 @@ +using System; +using System.Runtime.InteropServices.JavaScript; + +namespace Avalonia.Browser.Interop; + +internal static partial class TimerHelper +{ + [JSImport("TimerHelper.runAnimationFrames", AvaloniaModule.MainModuleName)] + public static partial void RunAnimationFrames( + [JSMarshalAs>] Func renderFrameCallback); + + [JSImport("globalThis.setTimeout")] + public static partial int SetTimeout([JSMarshalAs] Action callback, int intervalMs); + + [JSImport("globalThis.clearTimeout")] + public static partial int ClearTimeout(int id); + + [JSImport("globalThis.setInterval")] + public static partial int SetInterval([JSMarshalAs] Action callback, int intervalMs); + + [JSImport("globalThis.clearInterval")] + public static partial int ClearInterval(int id); +} diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserCompositor.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserCompositor.cs new file mode 100644 index 0000000000..a16b659a6e --- /dev/null +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserCompositor.cs @@ -0,0 +1,24 @@ +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.Composition; + +namespace Avalonia.Browser.Rendering; + +/// +/// We want to reuse timer/compositor instances per each AvaloniaView. +/// But at the same time, we want to keep possiblity of having different rendering modes (both software and webgl) at the same time. +/// For example, WebGL contexts number might exceed maximum allowed, or we might want to keep popups in software renderer. +/// +internal static class BrowserCompositor +{ + private static BrowserRenderTimer? s_browserUiRenderTimer; + private static BrowserRenderTimer BrowserUiRenderTimer => s_browserUiRenderTimer ??= new BrowserRenderTimer(false); + + private static Compositor? s_webGlUiCompositor, s_softwareUiCompositor; + + internal static Compositor WebGlUiCompositor => s_webGlUiCompositor ??= new Compositor( + new RenderLoop(BrowserUiRenderTimer), AvaloniaLocator.Current.GetRequiredService()); + + internal static Compositor SoftwareUiCompositor => s_softwareUiCompositor ??= new Compositor( + new RenderLoop(BrowserUiRenderTimer), null); +} diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserGlSurface.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserGlSurface.cs new file mode 100644 index 0000000000..5cb65bafd8 --- /dev/null +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserGlSurface.cs @@ -0,0 +1,51 @@ +using System; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Browser.Skia; +using Avalonia.Platform; +using SkiaSharp; + +namespace Avalonia.Browser.Rendering; + +internal sealed class BrowserGlSurface : BrowserSurface +{ + private readonly GRGlInterface _glInterface; + + public BrowserGlSurface(JSObject canvasSurface, GLInfo glInfo, PixelFormat pixelFormat, + BrowserRenderingMode renderingMode) + : base(canvasSurface, renderingMode) + { + var skiaOptions = AvaloniaLocator.Current.GetService(); + _glInterface = GRGlInterface.Create() ?? throw new InvalidOperationException("Unable to create GRGlInterface."); + Context = GRContext.CreateGl(_glInterface) ?? + throw new InvalidOperationException("Unable to create GRContext."); + if (skiaOptions?.MaxGpuResourceSizeBytes is { } resourceSizeBytes) + { + Context.SetResourceCacheLimit(resourceSizeBytes); + } + + GlInfo = glInfo ?? throw new ArgumentNullException(nameof(glInfo)); + PixelFormat = pixelFormat; + } + + public PixelFormat PixelFormat { get; } + + public GRContext Context { get; private set; } + + public GLInfo GlInfo { get; } + + public override void Dispose() + { + base.Dispose(); + + Context.Dispose(); + Context = null!; + + _glInterface.Dispose(); + } + + public void EnsureResize() + { + CanvasHelper.EnsureSize(JsSurface); + } +} diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserRasterSurface.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserRasterSurface.cs new file mode 100644 index 0000000000..725d6241ac --- /dev/null +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserRasterSurface.cs @@ -0,0 +1,98 @@ +using System; +using System.Buffers; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Controls.Platform.Surfaces; +using Avalonia.Platform; + +namespace Avalonia.Browser.Skia; + +internal sealed class BrowserRasterSurface : BrowserSurface, IFramebufferPlatformSurface +{ + public PixelFormat PixelFormat { get; set; } + + private FramebufferData? _fbData; + private readonly Action _onDisposeAction; + private readonly int _bytesPerPixel; + + public BrowserRasterSurface(JSObject canvasSurface, PixelFormat pixelFormat, BrowserRenderingMode renderingMode) + : base(canvasSurface, renderingMode) + { + PixelFormat = pixelFormat; + _onDisposeAction = Blit; + _bytesPerPixel = pixelFormat.BitsPerPixel / 8; + } + + public override void Dispose() + { + _fbData?.Dispose(); + _fbData = null; + + base.Dispose(); + } + + public ILockedFramebuffer Lock() + { + var bytesPerPixel = _bytesPerPixel; + var dpi = Scaling * 96.0; + var size = RenderSize; + + if (_fbData is null || _fbData?.Size != size) + { + _fbData?.Dispose(); + _fbData = new FramebufferData(size.Width, size.Height, bytesPerPixel); + } + + var data = _fbData; + return new LockedFramebuffer( + data.Address, data.Size, data.RowBytes, + new Vector(dpi, dpi), PixelFormat, _onDisposeAction); + } + + private void Blit() + { + if (_fbData is { } data) + { + CanvasHelper.PutPixelData(JsSurface, data.AsSegment, data.Size.Width, data.Size.Height); + } + } + + private class FramebufferData + { + private static ArrayPool s_pool = ArrayPool.Create(); + + private readonly byte[] _array; + private GCHandle _handle; + + public FramebufferData(int width, int height, int bytesPerPixel) + { + Size = new PixelSize(width, height); + RowBytes = width * bytesPerPixel; + + var length = width * height * bytesPerPixel; + _array = s_pool.Rent(length); + + _handle = GCHandle.Alloc(_array, GCHandleType.Pinned); + Address = _handle.AddrOfPinnedObject(); + + AsSegment = new ArraySegment(_array, 0, length); + } + + public PixelSize Size { get; } + + public int RowBytes { get; } + + public IntPtr Address { get; } + + public ArraySegment AsSegment { get; } + + public void Dispose() + { + _handle.Free(); + s_pool.Return(_array); + } + } + + public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncFramebufferRenderTarget(Lock); +} diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs new file mode 100644 index 0000000000..15f74e6585 --- /dev/null +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserRenderTimer.cs @@ -0,0 +1,46 @@ +using System; +using System.Diagnostics; +using Avalonia.Browser.Interop; +using Avalonia.Rendering; + +namespace Avalonia.Browser.Rendering; + +internal class BrowserRenderTimer : IRenderTimer +{ + private Action? _tick; + + public BrowserRenderTimer(bool isBackground) + { + RunsInBackground = isBackground; + } + + public bool RunsInBackground { get; } + + public event Action? Tick + { + add + { + if (_tick is null) + { + TimerHelper.RunAnimationFrames(RenderFrameCallback); + } + + _tick += value; + } + remove + { + _tick -= value; + } + } + + private bool RenderFrameCallback(double timestamp) + { + if (_tick is { } tick) + { + tick.Invoke(TimeSpan.FromMilliseconds(timestamp)); + return true; + } + + return false; + } +} diff --git a/src/Browser/Avalonia.Browser/Rendering/BrowserSurface.cs b/src/Browser/Avalonia.Browser/Rendering/BrowserSurface.cs new file mode 100644 index 0000000000..aa2b4cf014 --- /dev/null +++ b/src/Browser/Avalonia.Browser/Rendering/BrowserSurface.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.JavaScript; +using Avalonia.Browser.Interop; +using Avalonia.Browser.Rendering; +using Avalonia.Logging; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Threading; + +namespace Avalonia.Browser.Skia; + +internal abstract class BrowserSurface : IDisposable +{ + private readonly BrowserRenderingMode _renderingMode; + + protected BrowserSurface(JSObject jsSurface, BrowserRenderingMode renderingMode) + { + _renderingMode = renderingMode; + JsSurface = jsSurface; + + Scaling = 1; + ClientSize = new Size(1, 1); + RenderSize = new PixelSize(1, 1); + } + + public bool IsWebGl => _renderingMode is BrowserRenderingMode.WebGL1 or BrowserRenderingMode.WebGL2; + + public JSObject JsSurface { get; private set; } + public double Scaling { get; private set; } + public Size ClientSize { get; private set; } + public PixelSize RenderSize { get; private set; } + + public bool IsValid => RenderSize.Width > 0 && RenderSize.Height > 0 && Scaling > 0; + + public event Action? SizeChanged; + public event Action? ScalingChanged; + + public static BrowserSurface Create(JSObject container, PixelFormat pixelFormat) + { + var opts = AvaloniaLocator.Current.GetService() ?? new BrowserPlatformOptions(); + if (opts.RenderingMode is null || !opts.RenderingMode.Any()) + { + throw new InvalidOperationException( + $"{nameof(BrowserPlatformOptions)}.{nameof(BrowserPlatformOptions.RenderingMode)} must not be empty or null"); + } + + BrowserSurface? surface = null; + foreach (var mode in opts.RenderingMode) + { + try + { + var (jsSurface, jsGlInfo) = CanvasHelper.CreateSurface(container, mode); + surface = jsGlInfo != null + ? new BrowserGlSurface(jsSurface, jsGlInfo, pixelFormat, mode) + : new BrowserRasterSurface(jsSurface, pixelFormat, mode); + break; + } + catch (Exception ex) + { + Logger.TryGet(LogEventLevel.Error, LogArea.BrowserPlatform)? + .Log(null, + "Creation of BrowserSurface with mode {Mode} failed with an error:\r\n{Exception}", + mode, ex); + } + } + + if (surface is null) + { + throw new InvalidOperationException( + $"{nameof(BrowserPlatformOptions)}.{nameof(BrowserPlatformOptions.RenderingMode)} has a value of \"{string.Join(", ", opts.RenderingMode)}\", but no options were applied."); + } + + CanvasHelper.OnSizeChanged(surface.JsSurface, surface.OnSizeChanged); + return surface; + } + + public virtual void Dispose() + { + CanvasHelper.Destroy(JsSurface); + JsSurface.Dispose(); + JsSurface = null!; + RenderSize = default; + ClientSize = default; + } + + private void OnSizeChanged(int pixelWidth, int pixelHeight, double dpr) + { + var oldScaling = Scaling; + var oldClientSize = ClientSize; + RenderSize = new PixelSize(pixelWidth, pixelHeight); + ClientSize = RenderSize.ToSize(dpr); + Scaling = dpr; + if (oldClientSize != ClientSize) + SizeChanged?.Invoke(); + if (Math.Abs(oldScaling - dpr) > 0.0001) + ScalingChanged?.Invoke(); + } +} diff --git a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpu.cs b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpu.cs index a169966188..cb8a912b8c 100644 --- a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpu.cs +++ b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpu.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Avalonia.Browser.Rendering; using Avalonia.Platform; using Avalonia.Skia; using Avalonia.Reactive; @@ -12,7 +13,7 @@ namespace Avalonia.Browser.Skia { foreach (var surface in surfaces) { - if (surface is BrowserSkiaSurface browserSkiaSurface) + if (surface is BrowserGlSurface browserSkiaSurface) { return new BrowserSkiaGpuRenderTarget(browserSkiaSurface); } @@ -28,20 +29,19 @@ namespace Avalonia.Browser.Skia public void Dispose() { - } - public object? TryGetFeature(Type t) => null; - + public object? TryGetFeature(Type t) => null; + public bool IsLost => false; - + public IDisposable EnsureCurrent() { return Disposable.Empty; } } - class BrowserSkiaGraphics : IPlatformGraphics + internal class BrowserSkiaGraphics : IPlatformGraphics { private BrowserSkiaGpu _skia = new(); public bool UsesSharedContext => true; diff --git a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderSession.cs b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderSession.cs index 3e4cf31dda..b8fa21342c 100644 --- a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderSession.cs +++ b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderSession.cs @@ -1,3 +1,5 @@ +using System; +using Avalonia.Browser.Rendering; using Avalonia.Skia; using SkiaSharp; @@ -7,16 +9,17 @@ namespace Avalonia.Browser.Skia { private readonly SKSurface _surface; - public BrowserSkiaGpuRenderSession(BrowserSkiaSurface browserSkiaSurface, GRBackendRenderTarget renderTarget) + public BrowserSkiaGpuRenderSession(BrowserGlSurface browserGlSurface, GRBackendRenderTarget renderTarget) { - _surface = SKSurface.Create(browserSkiaSurface.Context, renderTarget, browserSkiaSurface.Origin, - browserSkiaSurface.ColorType, new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal)); + _surface = SKSurface.Create(browserGlSurface.Context, renderTarget, GRSurfaceOrigin.BottomLeft, + browserGlSurface.PixelFormat.ToSkColorType(), new SKSurfaceProperties(SKPixelGeometry.RgbHorizontal)) + ?? throw new InvalidOperationException("Unable to create SKSurface."); - GrContext = browserSkiaSurface.Context; + GrContext = browserGlSurface.Context; + ScaleFactor = browserGlSurface.Scaling; + SurfaceOrigin = GRSurfaceOrigin.BottomLeft; - ScaleFactor = browserSkiaSurface.Scaling; - - SurfaceOrigin = browserSkiaSurface.Origin; + browserGlSurface.EnsureResize(); } public void Dispose() diff --git a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderTarget.cs b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderTarget.cs index 9ede45a3eb..db4c5f2f8b 100644 --- a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderTarget.cs +++ b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaGpuRenderTarget.cs @@ -1,4 +1,5 @@ using System; +using Avalonia.Browser.Rendering; using Avalonia.Skia; using SkiaSharp; @@ -7,20 +8,20 @@ namespace Avalonia.Browser.Skia internal class BrowserSkiaGpuRenderTarget : ISkiaGpuRenderTarget { private readonly GRBackendRenderTarget _renderTarget; - private readonly BrowserSkiaSurface _browserSkiaSurface; + private readonly BrowserGlSurface _browserGlSurface; private readonly PixelSize _size; - public BrowserSkiaGpuRenderTarget(BrowserSkiaSurface browserSkiaSurface) + public BrowserSkiaGpuRenderTarget(BrowserGlSurface browserGlSurface) { - _size = browserSkiaSurface.Size; + _size = browserGlSurface.RenderSize; - var glFbInfo = new GRGlFramebufferInfo(browserSkiaSurface.GlInfo.FboId, browserSkiaSurface.ColorType.ToGlSizedFormat()); - _browserSkiaSurface = browserSkiaSurface; + var glFbInfo = new GRGlFramebufferInfo(browserGlSurface.GlInfo.FboId, browserGlSurface.PixelFormat.ToSkColorType().ToGlSizedFormat()); + _browserGlSurface = browserGlSurface; _renderTarget = new GRBackendRenderTarget( - (int)Math.Round(browserSkiaSurface.DisplaySize.Width * browserSkiaSurface.Scaling), - (int)Math.Round(browserSkiaSurface.DisplaySize.Height * browserSkiaSurface.Scaling), - browserSkiaSurface.GlInfo.Samples, - browserSkiaSurface.GlInfo.Stencils, glFbInfo); + _size.Width, + _size.Height, + browserGlSurface.GlInfo.Samples, + browserGlSurface.GlInfo.Stencils, glFbInfo); } public void Dispose() @@ -30,9 +31,9 @@ namespace Avalonia.Browser.Skia public ISkiaGpuRenderSession BeginRenderingSession() { - return new BrowserSkiaGpuRenderSession(_browserSkiaSurface, _renderTarget); + return new BrowserSkiaGpuRenderSession(_browserGlSurface, _renderTarget); } - public bool IsCorrupted => _browserSkiaSurface.Size != _size; + public bool IsCorrupted => _browserGlSurface.RenderSize != _size; } } diff --git a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaRasterSurface.cs b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaRasterSurface.cs deleted file mode 100644 index 777d44ea66..0000000000 --- a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaRasterSurface.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Avalonia.Controls.Platform.Surfaces; -using Avalonia.Platform; -using Avalonia.Skia; -using SkiaSharp; - -namespace Avalonia.Browser.Skia -{ - internal class BrowserSkiaRasterSurface : IBrowserSkiaSurface, IFramebufferPlatformSurface, IDisposable - { - public SKColorType ColorType { get; set; } - - public PixelSize Size { get; set; } - - public double Scaling { get; set; } - - private FramebufferData? _fbData; - private readonly Action _blitCallback; - private readonly Action _onDisposeAction; - - public BrowserSkiaRasterSurface( - SKColorType colorType, PixelSize size, double scaling, Action blitCallback) - { - ColorType = colorType; - Size = size; - Scaling = scaling; - _blitCallback = blitCallback; - _onDisposeAction = Blit; - } - - public void Dispose() - { - _fbData?.Dispose(); - _fbData = null; - } - - public ILockedFramebuffer Lock() - { - var bytesPerPixel = 4; // TODO: derive from ColorType - var dpi = Scaling * 96.0; - var width = (int)(Size.Width * Scaling); - var height = (int)(Size.Height * Scaling); - - if (_fbData is null || _fbData?.Size.Width != width || _fbData?.Size.Height != height) - { - _fbData?.Dispose(); - _fbData = new FramebufferData(width, height, bytesPerPixel); - } - - var pixelFormat = ColorType.ToPixelFormat(); - var data = _fbData.Value; - return new LockedFramebuffer( - data.Address, data.Size, data.RowBytes, - new Vector(dpi, dpi), pixelFormat, _onDisposeAction); - } - - private void Blit() - { - if (_fbData != null) - { - var data = _fbData.Value; - _blitCallback(data.Address, new SKSizeI(data.Size.Width, data.Size.Height)); - } - } - - private readonly struct FramebufferData - { - public PixelSize Size { get; } - - public int RowBytes { get; } - - public IntPtr Address { get; } - - public FramebufferData(int width, int height, int bytesPerPixel) - { - Size = new PixelSize(width, height); - RowBytes = width * bytesPerPixel; - Address = Marshal.AllocHGlobal(width * height * bytesPerPixel); - } - - public void Dispose() - { - Marshal.FreeHGlobal(Address); - } - } - - public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncFramebufferRenderTarget(Lock); - } -} diff --git a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaSurface.cs b/src/Browser/Avalonia.Browser/Skia/BrowserSkiaSurface.cs deleted file mode 100644 index 9170aab782..0000000000 --- a/src/Browser/Avalonia.Browser/Skia/BrowserSkiaSurface.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Avalonia.Browser.Interop; -using SkiaSharp; - -namespace Avalonia.Browser.Skia -{ - internal class BrowserSkiaSurface : IBrowserSkiaSurface - { - public BrowserSkiaSurface(GRContext context, GLInfo glInfo, SKColorType colorType, PixelSize size, Size displaySize, double scaling, GRSurfaceOrigin origin) - { - Context = context; - GlInfo = glInfo; - ColorType = colorType; - Size = size; - DisplaySize = displaySize; - Scaling = scaling; - Origin = origin; - } - - public SKColorType ColorType { get; set; } - - public PixelSize Size { get; set; } - - public Size DisplaySize { get; set; } - - public GRContext Context { get; set; } - - public GRSurfaceOrigin Origin { get; set; } - - public double Scaling { get; set; } - - public GLInfo GlInfo { get; set; } - } -} diff --git a/src/Browser/Avalonia.Browser/Skia/IBrowserSkiaSurface.cs b/src/Browser/Avalonia.Browser/Skia/IBrowserSkiaSurface.cs deleted file mode 100644 index 1585d7f283..0000000000 --- a/src/Browser/Avalonia.Browser/Skia/IBrowserSkiaSurface.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Avalonia.Browser.Skia -{ - internal interface IBrowserSkiaSurface - { - public PixelSize Size { get; set; } - - public double Scaling { get; set; } - } -} diff --git a/src/Browser/Avalonia.Browser/webapp/.eslintrc.json b/src/Browser/Avalonia.Browser/webapp/.eslintrc.json index f4fb8e37bf..b8b5d87e76 100644 --- a/src/Browser/Avalonia.Browser/webapp/.eslintrc.json +++ b/src/Browser/Avalonia.Browser/webapp/.eslintrc.json @@ -23,6 +23,7 @@ ], "quotes": ["warn", "double"], "semi": ["error", "always"], + "@typescript-eslint/no-this-alias": "off", "@typescript-eslint/quotes": ["warn", "double"], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-extraneous-class": "off", diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts index 212d67211c..7d7b64656c 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia.ts @@ -1,4 +1,3 @@ -import { ResizeHandler, Canvas } from "./avalonia/canvas"; import { InputHelper } from "./avalonia/input"; import { AvaloniaDOM } from "./avalonia/dom"; import { Caniuse } from "./avalonia/caniuse"; @@ -6,6 +5,8 @@ import { StreamHelper } from "./avalonia/stream"; import { NativeControlHost } from "./avalonia/nativeControlHost"; import { NavigationHelper } from "./avalonia/navigationHelper"; import { GeneralHelpers } from "./avalonia/generalHelpers"; +import { TimerHelper } from "./avalonia/timer"; +import { CanvasFactory } from "./avalonia/surfaces/surfaceFactory"; async function registerServiceWorker(path: string, scope: string | undefined) { if ("serviceWorker" in navigator) { @@ -15,13 +16,13 @@ async function registerServiceWorker(path: string, scope: string | undefined) { export { Caniuse, - Canvas, + CanvasFactory, InputHelper, - ResizeHandler, AvaloniaDOM, StreamHelper, NativeControlHost, NavigationHelper, GeneralHelpers, + TimerHelper, registerServiceWorker }; diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/canvas.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/canvas.ts deleted file mode 100644 index a653c5518a..0000000000 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/canvas.ts +++ /dev/null @@ -1,263 +0,0 @@ -interface SKGLViewInfo { - context: WebGLRenderingContext | WebGL2RenderingContext | undefined; - fboId: number; - stencil: number; - sample: number; - depth: number; -} - -type CanvasElement = { - Canvas: Canvas | undefined; -} & HTMLCanvasElement; - -function getGL(): any { - const self = globalThis as any; - const module = self.Module ?? self.getDotnetRuntime(0)?.Module; - return module?.GL ?? self.AvaloniaGL ?? self.SkiaSharpGL; -} - -export class Canvas { - static elements: Map; - - htmlCanvas: HTMLCanvasElement; - glInfo?: SKGLViewInfo; - renderFrameCallback: () => void; - renderLoopEnabled: boolean = false; - renderLoopRequest: number = 0; - newWidth?: number; - newHeight?: number; - - public static initGL(element: HTMLCanvasElement, elementId: string, renderFrameCallback: () => void): SKGLViewInfo | null { - const view = Canvas.init(true, element, elementId, renderFrameCallback); - if (!view || !view.glInfo) { - return null; - } - - return view.glInfo; - } - - static init(useGL: boolean, element: HTMLCanvasElement, elementId: string, renderFrameCallback: () => void): Canvas | null { - const htmlCanvas = element as CanvasElement; - if (!htmlCanvas) { - console.error("No canvas element was provided."); - return null; - } - - if (!Canvas.elements) { - Canvas.elements = new Map(); - } - Canvas.elements.set(elementId, element); - - const view = new Canvas(useGL, element, renderFrameCallback); - - htmlCanvas.Canvas = view; - - return view; - } - - public constructor(useGL: boolean, element: HTMLCanvasElement, renderFrameCallback: () => void) { - this.htmlCanvas = element; - this.renderFrameCallback = renderFrameCallback; - - if (useGL) { - const ctx = Canvas.createWebGLContext(element); - if (!ctx) { - console.error("Failed to create WebGL context"); - return; - } - - const GL = getGL(); - - // make current - GL.makeContextCurrent(ctx); - - const GLctx = GL.currentContext.GLctx as WebGLRenderingContext; - - // read values - const fbo = GLctx.getParameter(GLctx.FRAMEBUFFER_BINDING); - - this.glInfo = { - context: ctx, - fboId: fbo ? fbo.id : 0, - stencil: GLctx.getParameter(GLctx.STENCIL_BITS), - sample: 0, // TODO: GLctx.getParameter(GLctx.SAMPLES) - depth: GLctx.getParameter(GLctx.DEPTH_BITS) - }; - } - } - - public setEnableRenderLoop(enable: boolean): void { - this.renderLoopEnabled = enable; - - // either start the new frame or cancel the existing one - if (enable) { - // console.info(`Enabling render loop with callback ${this.renderFrameCallback._id}...`); - this.requestAnimationFrame(); - } else if (this.renderLoopRequest !== 0) { - window.cancelAnimationFrame(this.renderLoopRequest); - this.renderLoopRequest = 0; - } - } - - public requestAnimationFrame(renderLoop?: boolean): void { - // optionally update the render loop - if (renderLoop !== undefined && this.renderLoopEnabled !== renderLoop) { - this.setEnableRenderLoop(renderLoop); - } - - // skip because we have a render loop - if (this.renderLoopRequest !== 0) { - return; - } - - // add the draw to the next frame - this.renderLoopRequest = window.requestAnimationFrame(() => { - if (this.htmlCanvas.width !== this.newWidth) { - this.htmlCanvas.width = this.newWidth ?? 0; - } - - if (this.htmlCanvas.height !== this.newHeight) { - this.htmlCanvas.height = this.newHeight ?? 0; - } - - this.renderFrameCallback(); - this.renderLoopRequest = 0; - - // we may want to draw the next frame - if (this.renderLoopEnabled) { - this.requestAnimationFrame(); - } - }); - } - - public setCanvasSize(width: number, height: number): void { - if (this.renderLoopRequest !== 0) { - window.cancelAnimationFrame(this.renderLoopRequest); - this.renderLoopRequest = 0; - } - - this.newWidth = width; - this.newHeight = height; - - if (this.htmlCanvas.width !== this.newWidth) { - this.htmlCanvas.width = this.newWidth; - } - - if (this.htmlCanvas.height !== this.newHeight) { - this.htmlCanvas.height = this.newHeight; - } - - this.requestAnimationFrame(); - } - - public static setCanvasSize(element: HTMLCanvasElement, width: number, height: number): void { - const htmlCanvas = element as CanvasElement; - if (!htmlCanvas || !htmlCanvas.Canvas) { - return; - } - - htmlCanvas.Canvas.setCanvasSize(width, height); - } - - public static requestAnimationFrame(element: HTMLCanvasElement, renderLoop?: boolean): void { - const htmlCanvas = element as CanvasElement; - if (!htmlCanvas || !htmlCanvas.Canvas) { - return; - } - - htmlCanvas.Canvas.requestAnimationFrame(renderLoop); - } - - static createWebGLContext(htmlCanvas: HTMLCanvasElement): WebGLRenderingContext | WebGL2RenderingContext { - const contextAttributes = { - alpha: 1, - depth: 1, - stencil: 8, - antialias: 0, - premultipliedAlpha: 1, - preserveDrawingBuffer: 0, - preferLowPowerToHighPerformance: 0, - failIfMajorPerformanceCaveat: 0, - majorVersion: 2, - minorVersion: 0, - enableExtensionsByDefault: 1, - explicitSwapControl: 0, - renderViaOffscreenBackBuffer: 1 - }; - - const GL = getGL(); - - let ctx: WebGLRenderingContext = GL.createContext(htmlCanvas, contextAttributes); - - if (!ctx && contextAttributes.majorVersion > 1) { - console.warn("Falling back to WebGL 1.0"); - contextAttributes.majorVersion = 1; - contextAttributes.minorVersion = 0; - ctx = GL.createContext(htmlCanvas, contextAttributes); - } - - return ctx; - } -} - -type ResizeHandlerCallback = (displayWidth: number, displayHeight: number, dpi: number) => void; - -type ResizeObserverWithCallbacks = { - callbacks: Map; -} & ResizeObserver; - -export class ResizeHandler { - private static resizeObserver?: ResizeObserverWithCallbacks; - - public static observeSize(element: HTMLElement, callback: ResizeHandlerCallback): (() => void) { - if (!this.resizeObserver) { - this.resizeObserver = new ResizeObserver(this.onResize) as ResizeObserverWithCallbacks; - this.resizeObserver.callbacks = new Map(); - } - - this.resizeObserver.callbacks.set(element, callback); - this.resizeObserver.observe(element, { box: "content-box" }); - - return () => { - this.resizeObserver?.callbacks.delete(element); - this.resizeObserver?.unobserve(element); - }; - } - - private static onResize(entries: ResizeObserverEntry[], observer: ResizeObserver) { - for (const entry of entries) { - const callback = (observer as ResizeObserverWithCallbacks).callbacks.get(entry.target); - if (!callback) { - continue; - } - - const trueDpr = window.devicePixelRatio; - let width; - let height; - let dpr = trueDpr; - if (entry.devicePixelContentBoxSize) { - // NOTE: Only this path gives the correct answer - // The other paths are imperfect fallbacks - // for browsers that don't provide anyway to do this - width = entry.devicePixelContentBoxSize[0].inlineSize; - height = entry.devicePixelContentBoxSize[0].blockSize; - dpr = 1; // it's already in width and height - } else if (entry.contentBoxSize) { - if (entry.contentBoxSize[0]) { - width = entry.contentBoxSize[0].inlineSize; - height = entry.contentBoxSize[0].blockSize; - } else { - width = (entry.contentBoxSize as any).inlineSize; - height = (entry.contentBoxSize as any).blockSize; - } - } else { - width = entry.contentRect.width; - height = entry.contentRect.height; - } - const displayWidth = Math.round(width * dpr); - const displayHeight = Math.round(height * dpr); - - callback(displayWidth, displayHeight, trueDpr); - } - } -} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts index 36119545eb..d51ee4b184 100644 --- a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/dom.ts @@ -1,3 +1,4 @@ + export class AvaloniaDOM { public static addClass(element: HTMLElement, className: string): void { element.classList.add(className); @@ -24,27 +25,48 @@ export class AvaloniaDOM { }; } + static getFirstElementByClassName(className: string, parent?: HTMLElement): Element | null { + const elements = (parent ?? globalThis.document).getElementsByClassName(className); + return elements ? elements[0] : null; + } + + static createAvaloniaCanvas(host: HTMLElement): HTMLCanvasElement { + const containerId = host.getAttribute("data-containerId") ?? "0000"; + + const canvas = document.createElement("canvas"); + canvas.id = `canvas${containerId}`; + canvas.classList.add("avalonia-canvas"); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + canvas.style.position = "absolute"; + + return canvas; + } + + static attachCanvas(host: HTMLElement, canvas: HTMLCanvasElement): void { + host.prepend(canvas); + } + + static detachCanvas(host: HTMLElement, canvas: HTMLCanvasElement): void { + host.removeChild(canvas); + } + static createAvaloniaHost(host: HTMLElement) { - const randomIdPart = Math.random().toString(36).replace(/[^a-z]+/g, "").substr(2, 10); + const containerId = Math.random().toString(36).replace(/[^a-z]+/g, "").substr(2, 10); // Root element host.classList.add("avalonia-container"); host.tabIndex = 0; + host.setAttribute("data-containerId", containerId); host.oncontextmenu = function () { return false; }; host.style.overflow = "hidden"; host.style.touchAction = "none"; - // Rendering target canvas - const canvas = document.createElement("canvas"); - canvas.id = `canvas${randomIdPart}`; - canvas.classList.add("avalonia-canvas"); - canvas.style.width = "100%"; - canvas.style.height = "100%"; - canvas.style.position = "absolute"; + // Canvas is lazily created depending on the rendering mode. See createAvaloniaCanvas usage. // Native controls host const nativeHost = document.createElement("div"); - nativeHost.id = `nativeHost${randomIdPart}`; + nativeHost.id = `nativeHost${containerId}`; nativeHost.classList.add("avalonia-native-host"); nativeHost.style.left = "0px"; nativeHost.style.top = "0px"; @@ -54,13 +76,14 @@ export class AvaloniaDOM { // IME const inputElement = document.createElement("input"); - inputElement.id = `inputElement${randomIdPart}`; + inputElement.id = `inputElement${containerId}`; inputElement.classList.add("avalonia-input-element"); inputElement.autocapitalize = "none"; inputElement.type = "text"; inputElement.spellcheck = false; inputElement.style.padding = "0"; inputElement.style.margin = "0"; + inputElement.style.borderWidth = "0"; inputElement.style.position = "absolute"; inputElement.style.overflow = "hidden"; inputElement.style.borderStyle = "hidden"; @@ -76,11 +99,9 @@ export class AvaloniaDOM { host.prepend(inputElement); host.prepend(nativeHost); - host.prepend(canvas); return { host, - canvas, nativeHost, inputElement }; @@ -99,11 +120,18 @@ export class AvaloniaDOM { } } + public static initSafeAreaPadding(): void { + document.documentElement.style.setProperty("--av-sat", "env(safe-area-inset-top)"); + document.documentElement.style.setProperty("--av-sar", "env(safe-area-inset-right)"); + document.documentElement.style.setProperty("--av-sab", "env(safe-area-inset-bottom)"); + document.documentElement.style.setProperty("--av-sal", "env(safe-area-inset-left)"); + } + public static getSafeAreaPadding(): number[] { - const top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sat")); - const bottom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sab")); - const left = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sal")); - const right = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--sar")); + const top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--av-sat")); + const bottom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--av-sab")); + const left = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--av-sal")); + const right = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--av-sar")); return [left, top, bottom, right]; } diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/htmlSurfaceBase.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/htmlSurfaceBase.ts new file mode 100644 index 0000000000..30a6bbac03 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/htmlSurfaceBase.ts @@ -0,0 +1,40 @@ +import { ResizeHandler } from "./resizeHandler"; +import { CanvasSurface, AvaloniaRenderingContext, BrowserRenderingMode } from "./surfaceBase"; + +export abstract class HtmlCanvasSurfaceBase extends CanvasSurface { + private sizeParams?: [number, number, number]; + private sizeChangedCallback?: (width: number, height: number, dpr: number) => void; + + constructor( + public canvas: HTMLCanvasElement, + public context: AvaloniaRenderingContext, + public mode: BrowserRenderingMode) { + super(context, mode); + + // No need to ubsubsribe, canvas never leaves JS world, it should be GC'ed with all callbacks. + ResizeHandler.observeSize(canvas, (width, height, dpr) => { + this.sizeParams = [width, height, dpr]; + + if (this.sizeChangedCallback) { + this.sizeChangedCallback(width, height, dpr); + } + }); + } + + public destroy(): void { + delete this.sizeChangedCallback; + } + + public onSizeChanged(sizeChangedCallback: (width: number, height: number, dpr: number) => void) { + if (this.sizeChangedCallback) { throw new Error("For simplicity, we don't support multiple size changed callbacks per surface, not needed yet."); } + this.sizeChangedCallback = sizeChangedCallback; + } + + public ensureSize() { + if (this.sizeParams) { + this.canvas.width = this.sizeParams[0]; + this.canvas.height = this.sizeParams[1]; + delete this.sizeParams; + } + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/resizeHandler.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/resizeHandler.ts new file mode 100644 index 0000000000..03f1f23b96 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/resizeHandler.ts @@ -0,0 +1,58 @@ +type ResizeObserverWithCallbacks = { + callbacks: Map void)>; +} & ResizeObserver; + +export class ResizeHandler { + private static resizeObserver?: ResizeObserverWithCallbacks; + + public static observeSize(element: HTMLElement, callback: (width: number, height: number, dpr: number) => void) : (() => void) { + if (!this.resizeObserver) { + this.resizeObserver = new ResizeObserver(this.onResize) as ResizeObserverWithCallbacks; + this.resizeObserver.callbacks = new Map void)>(); + } + + this.resizeObserver.callbacks.set(element, callback); + this.resizeObserver.observe(element, { box: "content-box" }); + + return () => { + this.resizeObserver?.callbacks.delete(element); + this.resizeObserver?.unobserve(element); + }; + } + + private static onResize(entries: ResizeObserverEntry[], observer: ResizeObserver) { + for (const entry of entries) { + const callback = (observer as ResizeObserverWithCallbacks).callbacks.get(entry.target); + if (!callback) { + continue; + } + + const trueDpr = window.devicePixelRatio; + let width; + let height; + let dpr = trueDpr; + if (entry.devicePixelContentBoxSize) { + // NOTE: Only this path gives the correct answer + // The other paths are imperfect fallbacks + // for browsers that don't provide anyway to do this + width = entry.devicePixelContentBoxSize[0].inlineSize; + height = entry.devicePixelContentBoxSize[0].blockSize; + dpr = 1; // it's already in width and height + } else if (entry.contentBoxSize) { + if (entry.contentBoxSize[0]) { + width = entry.contentBoxSize[0].inlineSize; + height = entry.contentBoxSize[0].blockSize; + } else { + width = (entry.contentBoxSize as any).inlineSize; + height = (entry.contentBoxSize as any).blockSize; + } + } else { + width = entry.contentRect.width; + height = entry.contentRect.height; + } + const displayWidth = Math.round(width * dpr); + const displayHeight = Math.round(height * dpr); + callback(displayWidth, displayHeight, trueDpr); + } + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/softwareSurface.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/softwareSurface.ts new file mode 100644 index 0000000000..df32711a37 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/softwareSurface.ts @@ -0,0 +1,48 @@ +import { BrowserRenderingMode } from "./surfaceBase"; +import { HtmlCanvasSurfaceBase } from "./htmlSurfaceBase"; +import { RuntimeAPI } from "../../../types/dotnet"; + +const sharedArrayBufferDefined = typeof SharedArrayBuffer !== "undefined"; +function isSharedArrayBuffer(buffer: any): buffer is SharedArrayBuffer { + // BEWARE: In some cases, `instanceof SharedArrayBuffer` returns false even though buffer is an SAB. + // Patch adapted from https://github.com/emscripten-core/emscripten/pull/16994 + // See also https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toStringTag + return sharedArrayBufferDefined && buffer[Symbol.toStringTag] === "SharedArrayBuffer"; +} + +export class SoftwareSurface extends HtmlCanvasSurfaceBase { + private readonly runtime: RuntimeAPI | undefined; + + constructor(public canvas: HTMLCanvasElement) { + const context = canvas.getContext("2d", { + alpha: true + }); + if (!context) { + throw new Error("HTMLCanvasElement.getContext(2d) returned null."); + } + super(canvas, context, BrowserRenderingMode.Software2D); + + this.runtime = globalThis.getDotnetRuntime(0); + } + + public putPixelData(span: any /* IMemoryView */, width: number, height: number): void { + this.ensureSize(); + + const heap8 = this.runtime?.localHeapViewU8(); + + let clampedBuffer: Uint8ClampedArray; + if (span._pointer > 0 && span._length > 0 && heap8 && !isSharedArrayBuffer(heap8.buffer)) { + // Attempt to use undocumented access to the HEAP8 directly + // Note, SharedArrayBuffer cannot be used with ImageData (when WasmEnableThreads = true). + clampedBuffer = new Uint8ClampedArray(heap8.buffer, span._pointer, span._length); + } else { + // Or fallback to the normal API that does multiple array copies. + const copy = new Uint8Array(span.byteLength); + span.copyTo(copy); + clampedBuffer = new Uint8ClampedArray(copy.buffer); + } + + const imageData = new ImageData(clampedBuffer, width, height); + (this.context as CanvasRenderingContext2D).putImageData(imageData, 0, 0); + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceBase.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceBase.ts new file mode 100644 index 0000000000..88cde86f37 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceBase.ts @@ -0,0 +1,18 @@ +export type AvaloniaRenderingContext = RenderingContext; + +export enum BrowserRenderingMode { + Software2D = 1, + WebGL1, + WebGL2 +} + +export abstract class CanvasSurface { + constructor( + public context: AvaloniaRenderingContext, + public mode: BrowserRenderingMode) { + } + + abstract destroy(): void; + abstract ensureSize(): void; + abstract onSizeChanged(sizeChangedCallback: (width: number, height: number, dpr: number) => void): void; +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceFactory.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceFactory.ts new file mode 100644 index 0000000000..cb5810f5a4 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/surfaceFactory.ts @@ -0,0 +1,44 @@ +import { AvaloniaDOM } from "../dom"; +import { SoftwareSurface } from "./softwareSurface"; +import { BrowserRenderingMode, CanvasSurface } from "./surfaceBase"; +import { WebGlSurface } from "./webGlSurface"; + +export class CanvasFactory { + public static create(container: HTMLElement, mode: BrowserRenderingMode): CanvasSurface { + if (!container) { + throw new Error("No html container was provided."); + } + + const canvas = AvaloniaDOM.createAvaloniaCanvas(container); + AvaloniaDOM.attachCanvas(container, canvas); + + try { + if (mode === BrowserRenderingMode.Software2D) { + return new SoftwareSurface(canvas); + } else if (mode === BrowserRenderingMode.WebGL1 || mode === BrowserRenderingMode.WebGL2) { + return new WebGlSurface(canvas, mode); + } else { + throw new Error(`Unsupported rendering mode: ${BrowserRenderingMode[mode]}`); + } + } catch (ex) { + AvaloniaDOM.detachCanvas(container, canvas); + throw ex; + } + } + + public static destroy(surface: CanvasSurface) { + surface.destroy(); + } + + public static onSizeChanged(surface: CanvasSurface, sizeChangedCallback: (width: number, height: number, dpr: number) => void) { + surface.onSizeChanged(sizeChangedCallback); + } + + public static ensureSize(surface: CanvasSurface): void { + surface.ensureSize(); + } + + public static putPixelData(surface: SoftwareSurface, span: any /* IMemoryView */, width: number, height: number): void { + surface.putPixelData(span, width, height); + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/webGlSurface.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/webGlSurface.ts new file mode 100644 index 0000000000..9da7f5bf8a --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/surfaces/webGlSurface.ts @@ -0,0 +1,58 @@ +import { BrowserRenderingMode } from "./surfaceBase"; +import { HtmlCanvasSurfaceBase } from "./htmlSurfaceBase"; + +function getGL(): any { + const self = globalThis as any; + const module = self.Module ?? self.getDotnetRuntime(0)?.Module; + return module?.GL ?? self.AvaloniaGL ?? self.SkiaSharpGL; +} + +export class WebGlSurface extends HtmlCanvasSurfaceBase { + public contextHandle?: number; + public fboId?: number; + public stencil?: number; + public sample?: number; + public depth?: number; + + constructor(public canvas: HTMLCanvasElement, mode: BrowserRenderingMode.WebGL1 | BrowserRenderingMode.WebGL2) { + // Skia only understands WebGL context wrapped in Emscripten. + const gl = getGL(); + if (!gl) { + throw new Error("Module.GL object wasn't initialized, WebGL can't be used."); + } + + const modeStr = mode === BrowserRenderingMode.WebGL1 ? "webgl" : "webgl2"; + const attrs: WebGLContextAttributes | any = + { + alpha: true, + depth: true, + stencil: true, + antialias: false, + premultipliedAlpha: true, + preserveDrawingBuffer: false, + // only supported on older browsers, which is perfect as we want to fallback to 2d there. + failIfMajorPerformanceCaveat: true, + // attrs used by Emscripten: + majorVersion: mode === BrowserRenderingMode.WebGL1 ? 1 : 2, + minorVersion: 0, + enableExtensionsByDefault: 1, + explicitSwapControl: 0 + }; + const context = canvas.getContext(modeStr, attrs) as WebGLRenderingContext; + if (!context) { + throw new Error(`HTMLCanvasElement.getContext(${modeStr}) returned null.`); + } + + const handle = gl.registerContext(context, attrs); + gl.makeContextCurrent(handle); + (context as any).gl_handle = handle; + + super(canvas, context, BrowserRenderingMode.Software2D); + + this.contextHandle = handle; + this.fboId = context.getParameter(context.FRAMEBUFFER_BINDING)?.id ?? 0; + this.stencil = context.getParameter(context.STENCIL_BITS); + this.sample = context.getParameter(context.SAMPLES); + this.depth = context.getParameter(context.DEPTH_BITS); + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/modules/avalonia/timer.ts b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/timer.ts new file mode 100644 index 0000000000..7e52092056 --- /dev/null +++ b/src/Browser/Avalonia.Browser/webapp/modules/avalonia/timer.ts @@ -0,0 +1,12 @@ +export class TimerHelper { + public static runAnimationFrames(renderFrameCallback: (timestamp: number) => boolean): void { + function render(time: number) { + const next = renderFrameCallback(time); + if (next) { + window.requestAnimationFrame(render); + } + } + + window.requestAnimationFrame(render); + } +} diff --git a/src/Browser/Avalonia.Browser/webapp/types/dotnet.d.ts b/src/Browser/Avalonia.Browser/webapp/types/dotnet.d.ts index 0067ee3e0e..81eb0bd748 100644 --- a/src/Browser/Avalonia.Browser/webapp/types/dotnet.d.ts +++ b/src/Browser/Avalonia.Browser/webapp/types/dotnet.d.ts @@ -1,4 +1,4 @@ -// See https://raw.githubusercontent.com/dotnet/runtime/main/src/mono/wasm/runtime/dotnet.d.ts +// See https://github.com/dotnet/runtime/blob/v8.0.3/src/mono/wasm/runtime/dotnet.d.ts //! Licensed to the .NET Foundation under one or more agreements. //! The .NET Foundation licenses this file to you under the MIT license. @@ -7,23 +7,6 @@ //! This is not considered public API with backward compatibility guarantees. -interface DotnetHostBuilder { - withConfig(config: MonoConfig): DotnetHostBuilder; - withConfigSrc(configSrc: string): DotnetHostBuilder; - withApplicationArguments(...args: string[]): DotnetHostBuilder; - withEnvironmentVariable(name: string, value: string): DotnetHostBuilder; - withEnvironmentVariables(variables: { - [i: string]: string; - }): DotnetHostBuilder; - withVirtualWorkingDirectory(vfsPath: string): DotnetHostBuilder; - withDiagnosticTracing(enabled: boolean): DotnetHostBuilder; - withDebugging(level: number): DotnetHostBuilder; - withMainAssembly(mainAssemblyName: string): DotnetHostBuilder; - withApplicationArgumentsFromQuery(): DotnetHostBuilder; - create(): Promise; - run(): Promise; -} - declare interface NativePointer { __brandNativePointer: "NativePointer"; } @@ -37,18 +20,28 @@ declare interface Int32Ptr extends NativePointer { __brand: "Int32Ptr"; } declare interface EmscriptenModule { + /** @deprecated Please use growableHeapI8() instead.*/ HEAP8: Int8Array; + /** @deprecated Please use growableHeapI16() instead.*/ HEAP16: Int16Array; + /** @deprecated Please use growableHeapI32() instead. */ HEAP32: Int32Array; + /** @deprecated Please use growableHeapI64() instead. */ + HEAP64: BigInt64Array; + /** @deprecated Please use growableHeapU8() instead. */ HEAPU8: Uint8Array; + /** @deprecated Please use growableHeapU16() instead. */ HEAPU16: Uint16Array; + /** @deprecated Please use growableHeapU32() instead */ HEAPU32: Uint32Array; + /** @deprecated Please use growableHeapF32() instead */ HEAPF32: Float32Array; + /** @deprecated Please use growableHeapF64() instead. */ HEAPF64: Float64Array; _malloc(size: number): VoidPtr; _free(ptr: VoidPtr): void; - print(message: string): void; - printErr(message: string): void; + out(message: string): void; + err(message: string): void; ccall(ident: string, returnType?: string | null, argTypes?: string[], args?: any[], opts?: any): T; cwrap(ident: string, returnType: string, argTypes?: string[], opts?: any): T; cwrap(ident: string, ...args: any[]): T; @@ -57,15 +50,13 @@ declare interface EmscriptenModule { getValue(ptr: number, type: string, noSafe?: number | boolean): number; UTF8ToString(ptr: CharPtr, maxBytesToRead?: number): string; UTF8ArrayToString(u8Array: Uint8Array, idx?: number, maxBytesToRead?: number): string; + stringToUTF8Array(str: string, heap: Uint8Array, outIdx: number, maxBytesToWrite: number): void; FS_createPath(parent: string, path: string, canRead?: boolean, canWrite?: boolean): string; FS_createDataFile(parent: string, name: string, data: TypedArray, canRead: boolean, canWrite: boolean, canOwn?: boolean): string; - FS_readFile(filename: string, opts: any): any; - removeRunDependency(id: string): void; - addRunDependency(id: string): void; + addFunction(fn: Function, signature: string): number; stackSave(): VoidPtr; stackRestore(stack: VoidPtr): void; stackAlloc(size: number): VoidPtr; - ready: Promise; instantiateWasm?: InstantiateWasmCallBack; preInit?: (() => any)[] | (() => any); preRun?: (() => any)[] | (() => any); @@ -74,20 +65,38 @@ declare interface EmscriptenModule { onAbort?: { (error: any): void; }; + onExit?: { + (code: number): void; + }; } -declare type InstantiateWasmSuccessCallback = (instance: WebAssembly.Instance, module: WebAssembly.Module) => void; -declare type InstantiateWasmCallBack = (imports: WebAssembly.Imports, successCallback: InstantiateWasmSuccessCallback) => any; +type InstantiateWasmSuccessCallback = (instance: WebAssembly.Instance, module: WebAssembly.Module | undefined) => void; +type InstantiateWasmCallBack = (imports: WebAssembly.Imports, successCallback: InstantiateWasmSuccessCallback) => any; declare type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array; -declare type MonoConfig = { - /** - * The subfolder containing managed assemblies and pdbs. This is relative to dotnet.js script. - */ - assemblyRootFolder?: string; +interface DotnetHostBuilder { + withConfig(config: MonoConfig): DotnetHostBuilder; + withConfigSrc(configSrc: string): DotnetHostBuilder; + withApplicationArguments(...args: string[]): DotnetHostBuilder; + withEnvironmentVariable(name: string, value: string): DotnetHostBuilder; + withEnvironmentVariables(variables: { + [i: string]: string; + }): DotnetHostBuilder; + withVirtualWorkingDirectory(vfsPath: string): DotnetHostBuilder; + withDiagnosticTracing(enabled: boolean): DotnetHostBuilder; + withDebugging(level: number): DotnetHostBuilder; + withMainAssembly(mainAssemblyName: string): DotnetHostBuilder; + withApplicationArgumentsFromQuery(): DotnetHostBuilder; + withApplicationEnvironment(applicationEnvironment?: string): DotnetHostBuilder; + withApplicationCulture(applicationCulture?: string): DotnetHostBuilder; /** - * A list of assets to load along with the runtime. + * Overrides the built-in boot resource loading mechanism so that boot resources can be fetched + * from a custom source, such as an external CDN. */ - assets?: AssetEntry[]; + withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder; + create(): Promise; + run(): Promise; +} +type MonoConfig = { /** * Additional search locations for assets. */ @@ -100,6 +109,10 @@ declare type MonoConfig = { * We are throttling parallel downloads in order to avoid net::ERR_INSUFFICIENT_RESOURCES on chrome. The default value is 16. */ maxParallelDownloads?: number; + /** + * We are making up to 2 more delayed attempts to download same asset. Default true. + */ + enableDownloadRetry?: boolean; /** * Name of the assembly with main entrypoint */ @@ -111,12 +124,28 @@ declare type MonoConfig = { /** * debugLevel > 0 enables debugging and sets the debug log level to debugLevel * debugLevel == 0 disables debugging and enables interpreter optimizations - * debugLevel < 0 enabled debugging and disables debug logging. + * debugLevel < 0 enables debugging and disables debug logging. */ debugLevel?: number; /** - * Enables diagnostic log messages during startup - */ + * Gets a value that determines whether to enable caching of the 'resources' inside a CacheStorage instance within the browser. + */ + cacheBootResources?: boolean; + /** + * Delay of the purge of the cached resources in milliseconds. Default is 10000 (10 seconds). + */ + cachedResourcesPurgeDelay?: number; + /** + * Configures use of the `integrity` directive for fetching assets + */ + disableIntegrityCheck?: boolean; + /** + * Configures use of the `no-cache` directive for fetching assets + */ + disableNoCacheFetch?: boolean; + /** + * Enables diagnostic log messages during startup + */ diagnosticTracing?: boolean; /** * Dictionary-style Object containing environment variables @@ -128,19 +157,97 @@ declare type MonoConfig = { * initial number of workers to add to the emscripten pthread pool */ pthreadPoolSize?: number; + /** + * If true, the snapshot of runtime's memory will be stored in the browser and used for faster startup next time. Default is false. + */ + startupMemoryCache?: boolean; + /** + * application environment + */ + applicationEnvironment?: string; + /** + * Gets the application culture. This is a name specified in the BCP 47 format. See https://tools.ietf.org/html/bcp47 + */ + applicationCulture?: string; + /** + * definition of assets to load along with the runtime. + */ + resources?: ResourceGroups; + /** + * appsettings files to load to VFS + */ + appsettings?: string[]; + /** + * config extensions declared in MSBuild items @(WasmBootConfigExtension) + */ + extensions?: { + [name: string]: any; + }; }; -interface ResourceRequest { - name: string; - behavior: AssetBehaviours; - resolvedUrl?: string; +type ResourceExtensions = { + [extensionName: string]: ResourceList; +}; +interface ResourceGroups { hash?: string; + assembly?: ResourceList; + lazyAssembly?: ResourceList; + pdb?: ResourceList; + jsModuleWorker?: ResourceList; + jsModuleNative: ResourceList; + jsModuleRuntime: ResourceList; + wasmSymbols?: ResourceList; + wasmNative: ResourceList; + icu?: ResourceList; + satelliteResources?: { + [cultureName: string]: ResourceList; + }; + modulesAfterConfigLoaded?: ResourceList; + modulesAfterRuntimeReady?: ResourceList; + extensions?: ResourceExtensions; + vfs?: { + [virtualPath: string]: ResourceList; + }; } +/** + * A "key" is name of the file, a "value" is optional hash for integrity check. + */ +type ResourceList = { + [name: string]: string | null | ""; +}; +/** + * Overrides the built-in boot resource loading mechanism so that boot resources can be fetched + * from a custom source, such as an external CDN. + * @param type The type of the resource to be loaded. + * @param name The name of the resource to be loaded. + * @param defaultUri The URI from which the framework would fetch the resource by default. The URI may be relative or absolute. + * @param integrity The integrity string representing the expected content in the response. + * @param behavior The detailed behavior/type of the resource to be loaded. + * @returns A URI string or a Response promise to override the loading process, or null/undefined to allow the default loading behavior. + * When returned string is not qualified with `./` or absolute URL, it will be resolved against the application base URI. + */ +type LoadBootResourceCallback = (type: WebAssemblyBootResourceType, name: string, defaultUri: string, integrity: string, behavior: AssetBehaviors) => string | Promise | null | undefined; interface LoadingResource { name: string; url: string; response: Promise; } -interface AssetEntry extends ResourceRequest { +interface AssetEntry { + /** + * the name of the asset, including extension. + */ + name: string; + /** + * determines how the asset will be handled once loaded + */ + behavior: AssetBehaviors; + /** + * this should be absolute url to the asset + */ + resolvedUrl?: string; + /** + * the integrity hash of the asset (if any) + */ + hash?: string | null | ""; /** * If specified, overrides the path of the asset in the virtual filesystem and similar data structures once downloaded. */ @@ -161,34 +268,116 @@ interface AssetEntry extends ResourceRequest { * If provided, runtime doesn't have to fetch the data. * Runtime would set the buffer to null after instantiation to free the memory. */ - buffer?: ArrayBuffer; + buffer?: ArrayBuffer | Promise; + /** + * If provided, runtime doesn't have to import it's JavaScript modules. + * This will not work for multi-threaded runtime. + */ + moduleExports?: any | Promise; /** * It's metadata + fetch-like Promise * If provided, the runtime doesn't have to initiate the download. It would just await the response. */ pendingDownload?: LoadingResource; } -declare type AssetBehaviours = "resource" | "assembly" | "pdb" | "heap" | "icu" | "vfs" | "dotnetwasm" | "js-module-threads"; -declare type GlobalizationMode = "icu" | // load ICU globalization data from any runtime assets with behavior "icu". -"invariant" | // operate in invariant globalization mode. -"auto"; -declare type DotnetModuleConfig = { +type SingleAssetBehaviors = +/** + * The binary of the dotnet runtime. + */ + "dotnetwasm" + /** + * The javascript module for loader. + */ + | "js-module-dotnet" + /** + * The javascript module for threads. + */ + | "js-module-threads" + /** + * The javascript module for runtime. + */ + | "js-module-runtime" + /** + * The javascript module for emscripten. + */ + | "js-module-native" + /** + * Typically blazor.boot.json + */ + | "manifest"; +type AssetBehaviors = SingleAssetBehaviors | + /** + * Load asset as a managed resource assembly. + */ + "resource" + /** + * Load asset as a managed assembly. + */ + | "assembly" + /** + * Load asset as a managed debugging information. + */ + | "pdb" + /** + * Store asset into the native heap. + */ + | "heap" + /** + * Load asset as an ICU data archive. + */ + | "icu" + /** + * Load asset into the virtual filesystem (for fopen, File.Open, etc). + */ + | "vfs" + /** + * The javascript module that came from nuget package . + */ + | "js-module-library-initializer" + /** + * The javascript module for threads. + */ + | "symbols"; +declare const enum GlobalizationMode { + /** + * Load sharded ICU data. + */ + Sharded = "sharded", + /** + * Load all ICU data. + */ + All = "all", + /** + * Operate in invariant globalization mode. + */ + Invariant = "invariant", + /** + * Use user defined icu file. + */ + Custom = "custom", + /** + * Operate in hybrid globalization mode with small ICU files, using native platform functions. + */ + Hybrid = "hybrid" +} +type DotnetModuleConfig = { disableDotnet6Compatibility?: boolean; config?: MonoConfig; configSrc?: string; onConfigLoaded?: (config: MonoConfig) => void | Promise; onDotnetReady?: () => void | Promise; + onDownloadResourceProgress?: (resourcesLoaded: number, totalResources: number) => void; imports?: any; exports?: string[]; - downloadResource?: (request: ResourceRequest) => LoadingResource | undefined; } & Partial; -declare type APIType = { +type APIType = { runMain: (mainAssemblyName: string, args: string[]) => Promise; runMainAndExit: (mainAssemblyName: string, args: string[]) => Promise; setEnvironmentVariable: (name: string, value: string) => void; getAssemblyExports(assemblyName: string): Promise; setModuleImports(moduleName: string, moduleImports: any): void; getConfig: () => MonoConfig; + invokeLibraryInitializers: (functionName: string, args: any[]) => Promise; setHeapB32: (offset: NativePointer, value: number | boolean) => void; setHeapU8: (offset: NativePointer, value: number) => void; setHeapU16: (offset: NativePointer, value: number) => void; @@ -213,8 +402,17 @@ declare type APIType = { getHeapI64Big: (offset: NativePointer) => bigint; getHeapF32: (offset: NativePointer) => number; getHeapF64: (offset: NativePointer) => number; + localHeapViewI8: () => Int8Array; + localHeapViewI16: () => Int16Array; + localHeapViewI32: () => Int32Array; + localHeapViewI64Big: () => BigInt64Array; + localHeapViewU8: () => Uint8Array; + localHeapViewU16: () => Uint16Array; + localHeapViewU32: () => Uint32Array; + localHeapViewF32: () => Float32Array; + localHeapViewF64: () => Float64Array; }; -declare type RuntimeAPI = { +type RuntimeAPI = { /** * @deprecated Please use API object instead. See also MONOType in dotnet-legacy.d.ts */ @@ -232,23 +430,18 @@ declare type RuntimeAPI = { buildConfiguration: string; }; } & APIType; -declare type ModuleAPI = { +type ModuleAPI = { dotnet: DotnetHostBuilder; exit: (code: number, reason?: any) => void; }; -declare function createDotnetRuntime(moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)): Promise; -declare type CreateDotnetRuntimeType = typeof createDotnetRuntime; +type CreateDotnetRuntimeType = (moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)) => Promise; +type WebAssemblyBootResourceType = "assembly" | "pdb" | "dotnetjs" | "dotnetwasm" | "globalization" | "manifest" | "configuration"; -declare global { - function getDotnetRuntime(runtimeId: number): RuntimeAPI | undefined; +interface IDisposable { + dispose(): void; + get isDisposed(): boolean; } - -declare const dotnet: ModuleAPI["dotnet"]; -declare const exit: ModuleAPI["exit"]; - -export { CreateDotnetRuntimeType, DotnetModuleConfig, EmscriptenModule, ModuleAPI, MonoConfig, RuntimeAPI, createDotnetRuntime as default, dotnet, exit }; - -export interface IMemoryView { +interface IMemoryView extends IDisposable { /** * copies elements from provided source to the wasm memory. * target has to have the elements of the same type as the underlying C# array. @@ -264,7 +457,18 @@ export interface IMemoryView { * same as TypedArray.slice() */ slice(start?: number, end?: number): TypedArray; - get length(): number; get byteLength(): number; } + +declare function mono_exit(exit_code: number, reason?: any): void; + +declare const dotnet: DotnetHostBuilder; +declare const exit: typeof mono_exit; + +declare global { + function getDotnetRuntime(runtimeId: number): RuntimeAPI | undefined; +} +declare const createDotnetRuntime: CreateDotnetRuntimeType; + +export { AssetBehaviors, AssetEntry, CreateDotnetRuntimeType, DotnetHostBuilder, DotnetModuleConfig, EmscriptenModule, GlobalizationMode, IMemoryView, ModuleAPI, MonoConfig, RuntimeAPI, createDotnetRuntime as default, dotnet, exit };