Browse Source
* 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 APIpull/15258/head
committed by
GitHub
53 changed files with 1887 additions and 1426 deletions
@ -0,0 +1,22 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<!-- https://learn.microsoft.com/en-us/dotnet/fundamentals/package-validation/diagnostic-ids --> |
||||
|
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema"> |
||||
|
<Suppression> |
||||
|
<DiagnosticId>CP0002</DiagnosticId> |
||||
|
<Target>M:Avalonia.Browser.AvaloniaView.get_IsComposing</Target> |
||||
|
<Left>baseline/net7.0/Avalonia.Browser.dll</Left> |
||||
|
<Right>target/net8.0-browser1.0/Avalonia.Browser.dll</Right> |
||||
|
</Suppression> |
||||
|
<Suppression> |
||||
|
<DiagnosticId>CP0002</DiagnosticId> |
||||
|
<Target>M:Avalonia.Browser.AvaloniaView.OnDragEvent(System.Runtime.InteropServices.JavaScript.JSObject)</Target> |
||||
|
<Left>baseline/net7.0/Avalonia.Browser.dll</Left> |
||||
|
<Right>target/net8.0-browser1.0/Avalonia.Browser.dll</Right> |
||||
|
</Suppression> |
||||
|
<Suppression> |
||||
|
<DiagnosticId>CP0008</DiagnosticId> |
||||
|
<Target>T:Avalonia.Browser.AvaloniaView</Target> |
||||
|
<Left>baseline/net7.0/Avalonia.Browser.dll</Left> |
||||
|
<Right>target/net8.0-browser1.0/Avalonia.Browser.dll</Right> |
||||
|
</Suppression> |
||||
|
</Suppressions> |
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@ -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; |
|
||||
} |
|
||||
} |
|
||||
@ -1,28 +0,0 @@ |
|||||
<!DOCTYPE html> |
|
||||
<!-- Licensed to the .NET Foundation under one or more agreements. --> |
|
||||
<!-- The .NET Foundation licenses this file to you under the MIT license. --> |
|
||||
<html> |
|
||||
|
|
||||
<head> |
|
||||
<title>AvaloniaUI - ControlCatalog</title> |
|
||||
<meta charset="UTF-8"> |
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||
<link rel="stylesheet" href="./app.css" /> |
|
||||
</head> |
|
||||
|
|
||||
<body style="margin: 0px; overflow: hidden"> |
|
||||
<div id="out"> |
|
||||
<div id="avalonia-splash"> |
|
||||
<div class="center"> |
|
||||
<h2 class="purple"> |
|
||||
Powered by |
|
||||
<a class="highlight" href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a> |
|
||||
</h2> |
|
||||
</div> |
|
||||
<img class="icon" src="Logo.svg" alt="Avalonia Logo" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
<script type='module' src="./main.js"></script> |
|
||||
</body> |
|
||||
|
|
||||
</html> |
|
||||
@ -1,40 +1,108 @@ |
|||||
|
using System; |
||||
using System.Diagnostics; |
using System.Diagnostics; |
||||
|
using System.Linq; |
||||
using System.Runtime.Versioning; |
using System.Runtime.Versioning; |
||||
using System.Threading.Tasks; |
using System.Threading.Tasks; |
||||
|
using System.Web; |
||||
using Avalonia; |
using Avalonia; |
||||
using Avalonia.Browser; |
using Avalonia.Browser; |
||||
|
using Avalonia.Controls; |
||||
|
using Avalonia.Controls.ApplicationLifetimes; |
||||
using Avalonia.Logging; |
using Avalonia.Logging; |
||||
|
using Avalonia.Rendering; |
||||
|
using Avalonia.Threading; |
||||
using ControlCatalog; |
using ControlCatalog; |
||||
using ControlCatalog.Browser; |
using ControlCatalog.Browser; |
||||
|
|
||||
[assembly:SupportedOSPlatform("browser")] |
[assembly:SupportedOSPlatform("browser")] |
||||
|
#nullable enable |
||||
|
|
||||
internal partial class Program |
internal partial class Program |
||||
{ |
{ |
||||
public static async Task Main(string[] args) |
public static async Task Main(string[] args) |
||||
{ |
{ |
||||
Trace.Listeners.Add(new ConsoleTraceListener()); |
Trace.Listeners.Add(new ConsoleTraceListener()); |
||||
|
|
||||
|
var options = ParseArgs(args) ?? new BrowserPlatformOptions(); |
||||
|
|
||||
await BuildAvaloniaApp() |
await BuildAvaloniaApp() |
||||
.LogToTrace(LogEventLevel.Warning) |
.LogToTrace() |
||||
.AfterSetup(_ => |
.AfterSetup(_ => |
||||
{ |
{ |
||||
ControlCatalog.Pages.EmbedSample.Implementation = new EmbedSampleWeb(); |
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
|
// Test with multiple AvaloniaView at once.
|
||||
// private static AvaloniaView _avaloniaView;
|
// private static AvaloniaView _avaloniaView1;
|
||||
|
// private static AvaloniaView _avaloniaView2;
|
||||
// public static async Task Main(string[] args)
|
// public static async Task Main(string[] args)
|
||||
// {
|
// {
|
||||
|
// Trace.Listeners.Add(new ConsoleTraceListener());
|
||||
|
//
|
||||
|
// var options = ParseArgs(args) ?? new BrowserPlatformOptions();
|
||||
|
//
|
||||
// await BuildAvaloniaApp()
|
// await BuildAvaloniaApp()
|
||||
// .SetupBrowserApp();
|
// .LogToTrace()
|
||||
|
// .SetupBrowserAppAsync(options);
|
||||
|
//
|
||||
|
// _avaloniaView1 = new AvaloniaView("out1");
|
||||
|
// _avaloniaView1.Content = new TextBlock { Text = "Hello" };
|
||||
//
|
//
|
||||
// _avaloniaView = new AvaloniaView("out");
|
// _avaloniaView2 = new AvaloniaView("out2");
|
||||
// _avaloniaView.Content = new TextBlock { Text = "Hello world" };
|
// _avaloniaView2.Content = new TextBlock { Text = "World" };
|
||||
|
//
|
||||
|
// Dispatcher.UIThread.Invoke(() =>
|
||||
|
// {
|
||||
|
// var topLevel = TopLevel.GetTopLevel(_avaloniaView1.Content);
|
||||
|
// topLevel!.RendererDiagnostics.DebugOverlays = RendererDebugOverlays.Fps;
|
||||
|
// });
|
||||
// }
|
// }
|
||||
|
|
||||
public static AppBuilder BuildAvaloniaApp() |
public static AppBuilder BuildAvaloniaApp() |
||||
=> AppBuilder.Configure<App>(); |
=> AppBuilder.Configure<App>(); |
||||
|
|
||||
|
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<BrowserRenderingMode>(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; |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
|
|||||
|
After Width: | Height: | Size: 701 B |
@ -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; |
||||
|
} |
||||
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |
@ -0,0 +1,46 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<!-- Licensed to the .NET Foundation under one or more agreements. --> |
||||
|
<!-- The .NET Foundation licenses this file to you under the MIT license. --> |
||||
|
<html> |
||||
|
|
||||
|
<head> |
||||
|
<title>AvaloniaUI - ControlCatalog</title> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<link rel="stylesheet" href="./app.css" /> |
||||
|
</head> |
||||
|
|
||||
|
<body style="margin: 0; overflow: hidden"> |
||||
|
<div id="out"> |
||||
|
<div class="avalonia-splash"> |
||||
|
<h2> |
||||
|
Powered by |
||||
|
<a href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a> |
||||
|
</h2> |
||||
|
<img src="Logo.svg" alt="Avalonia Logo" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<!--<div id="common-root" style="display: grid; grid-template-columns: 50% 50%; position: absolute; width:100%; height: 100%">--> |
||||
|
<!-- <div id="out1" style="position:relative">--> |
||||
|
<!-- <div class="avalonia-splash">--> |
||||
|
<!-- <h2>--> |
||||
|
<!-- Powered by--> |
||||
|
<!-- <a href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a>--> |
||||
|
<!-- </h2>--> |
||||
|
<!-- <img src="Logo.svg" alt="Avalonia Logo" />--> |
||||
|
<!-- </div>--> |
||||
|
<!-- </div>--> |
||||
|
<!-- <div id="out2" style="position:relative">--> |
||||
|
<!-- <div class="avalonia-splash">--> |
||||
|
<!-- <h2>--> |
||||
|
<!-- Powered by--> |
||||
|
<!-- <a href="https://www.avaloniaui.net/" target="_blank">Avalonia UI</a>--> |
||||
|
<!-- </h2>--> |
||||
|
<!-- <img src="Logo.svg" alt="Avalonia Logo" />--> |
||||
|
<!-- </div>--> |
||||
|
<!-- </div>--> |
||||
|
<!--</div>--> |
||||
|
<script type='module' src="./main.js"></script> |
||||
|
</body> |
||||
|
|
||||
|
</html> |
||||
@ -1,17 +1,15 @@ |
|||||
// Licensed to the .NET Foundation under one or more agreements.
|
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
// 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' |
||||
//import { dotnet } from './_framework/dotnet.js' // NET 8+
|
|
||||
|
|
||||
const is_browser = typeof window != "undefined"; |
const is_browser = typeof window != "undefined"; |
||||
if (!is_browser) throw new Error(`Expected to be running in a browser`); |
if (!is_browser) throw new Error(`Expected to be running in a browser`); |
||||
|
|
||||
const dotnetRuntime = await dotnet |
const dotnetRuntime = await dotnet |
||||
.withDiagnosticTracing(false) |
|
||||
.withApplicationArgumentsFromQuery() |
.withApplicationArgumentsFromQuery() |
||||
.create(); |
.create(); |
||||
|
|
||||
const config = dotnetRuntime.getConfig(); |
const config = dotnetRuntime.getConfig(); |
||||
|
|
||||
await dotnetRuntime.runMainAndExit(config.mainAssemblyName, [globalThis.location.href]); |
await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]); |
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 708 B |
@ -1,74 +1,38 @@ |
|||||
:root { |
/* HTML styles for the splash screen */ |
||||
--sat: env(safe-area-inset-top); |
.avalonia-splash { |
||||
--sar: env(safe-area-inset-right); |
position: absolute; |
||||
--sab: env(safe-area-inset-bottom); |
height: 100%; |
||||
--sal: env(safe-area-inset-left); |
width: 100%; |
||||
|
background: #1b2a4e; |
||||
|
font-family: 'Nunito', sans-serif; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
pointer-events: none; |
||||
} |
} |
||||
|
|
||||
/* HTML styles for the splash screen */ |
.avalonia-splash h2 { |
||||
|
font-size: 1.5rem; |
||||
|
color: #8b44ac; |
||||
|
} |
||||
|
|
||||
.highlight { |
.avalonia-splash a { |
||||
color: white; |
color: white; |
||||
|
text-decoration: none; |
||||
font-size: 2.5rem; |
font-size: 2.5rem; |
||||
display: block; |
display: block; |
||||
} |
} |
||||
|
|
||||
.purple { |
.avalonia-splash img { |
||||
color: #8b44ac; |
|
||||
} |
|
||||
|
|
||||
.icon { |
|
||||
opacity: 0.05; |
opacity: 0.05; |
||||
height: 35%; |
height: 35%; |
||||
width: 35%; |
|
||||
position: absolute; |
position: absolute; |
||||
background-repeat: no-repeat; |
right: 3%; |
||||
right: 0px; |
bottom: 3%; |
||||
bottom: 0px; |
|
||||
margin-right: 3%; |
|
||||
margin-bottom: 5%; |
|
||||
z-index: 5000; |
|
||||
background-position: right bottom; |
|
||||
pointer-events: none; |
|
||||
} |
} |
||||
|
|
||||
#avalonia-splash a { |
.avalonia-splash.splash-close { |
||||
color: whitesmoke; |
transition: opacity 200ms, display 200ms; |
||||
text-decoration: none; |
display: none; |
||||
} |
opacity: 0; |
||||
|
|
||||
.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; |
|
||||
} |
|
||||
} |
} |
||||
|
|||||
@ -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<BrowserMouseDevice> _mouseDevices; |
||||
|
private IInputRoot? _inputRoot; |
||||
|
|
||||
|
private static readonly PooledList<RawPointerPoint> 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<IReadOnlyList<RawPointerPoint>?>(() => |
||||
|
{ |
||||
|
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<IReadOnlyList<RawPointerPoint>?>? 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<IDragDropDevice>(); |
||||
|
var eventArgs = new RawDragEvent(device, eventType, _inputRoot!, position, dataObject, dropEffect, modifiers); |
||||
|
_topLevelImpl.Input?.Invoke(eventArgs); |
||||
|
return eventArgs.Effects; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<JSType.Function<JSType.Number, JSType.Boolean>>] Func<double, bool> renderFrameCallback); |
||||
|
|
||||
|
[JSImport("globalThis.setTimeout")] |
||||
|
public static partial int SetTimeout([JSMarshalAs<JSType.Function>] Action callback, int intervalMs); |
||||
|
|
||||
|
[JSImport("globalThis.clearTimeout")] |
||||
|
public static partial int ClearTimeout(int id); |
||||
|
|
||||
|
[JSImport("globalThis.setInterval")] |
||||
|
public static partial int SetInterval([JSMarshalAs<JSType.Function>] Action callback, int intervalMs); |
||||
|
|
||||
|
[JSImport("globalThis.clearInterval")] |
||||
|
public static partial int ClearInterval(int id); |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
using Avalonia.Platform; |
||||
|
using Avalonia.Rendering; |
||||
|
using Avalonia.Rendering.Composition; |
||||
|
|
||||
|
namespace Avalonia.Browser.Rendering; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 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.
|
||||
|
/// </summary>
|
||||
|
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<IPlatformGraphics>()); |
||||
|
|
||||
|
internal static Compositor SoftwareUiCompositor => s_softwareUiCompositor ??= new Compositor( |
||||
|
new RenderLoop(BrowserUiRenderTimer), null); |
||||
|
} |
||||
@ -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<SkiaOptions>(); |
||||
|
_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); |
||||
|
} |
||||
|
} |
||||
@ -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<byte> s_pool = ArrayPool<byte>.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<byte>(_array, 0, length); |
||||
|
} |
||||
|
|
||||
|
public PixelSize Size { get; } |
||||
|
|
||||
|
public int RowBytes { get; } |
||||
|
|
||||
|
public IntPtr Address { get; } |
||||
|
|
||||
|
public ArraySegment<byte> AsSegment { get; } |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
_handle.Free(); |
||||
|
s_pool.Return(_array); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public IFramebufferRenderTarget CreateFramebufferRenderTarget() => new FuncFramebufferRenderTarget(Lock); |
||||
|
} |
||||
@ -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<TimeSpan>? _tick; |
||||
|
|
||||
|
public BrowserRenderTimer(bool isBackground) |
||||
|
{ |
||||
|
RunsInBackground = isBackground; |
||||
|
} |
||||
|
|
||||
|
public bool RunsInBackground { get; } |
||||
|
|
||||
|
public event Action<TimeSpan>? 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; |
||||
|
} |
||||
|
} |
||||
@ -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<BrowserPlatformOptions>() ?? 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(); |
||||
|
} |
||||
|
} |
||||
@ -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<IntPtr, SKSizeI> _blitCallback; |
|
||||
private readonly Action _onDisposeAction; |
|
||||
|
|
||||
public BrowserSkiaRasterSurface( |
|
||||
SKColorType colorType, PixelSize size, double scaling, Action<IntPtr, SKSizeI> 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); |
|
||||
} |
|
||||
} |
|
||||
@ -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; } |
|
||||
} |
|
||||
} |
|
||||
@ -1,9 +0,0 @@ |
|||||
namespace Avalonia.Browser.Skia |
|
||||
{ |
|
||||
internal interface IBrowserSkiaSurface |
|
||||
{ |
|
||||
public PixelSize Size { get; set; } |
|
||||
|
|
||||
public double Scaling { get; set; } |
|
||||
} |
|
||||
} |
|
||||
@ -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<string, HTMLCanvasElement>; |
|
||||
|
|
||||
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<string, HTMLCanvasElement>(); |
|
||||
} |
|
||||
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<Element, ResizeHandlerCallback>; |
|
||||
} & 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<Element, ResizeHandlerCallback>(); |
|
||||
} |
|
||||
|
|
||||
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); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
type ResizeObserverWithCallbacks = { |
||||
|
callbacks: Map<Element, ((width: number, height: number, dpr: number) => 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<Element, ((width: number, height: number, dpr: number) => 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue