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 API # Conflicts: # samples/MobileSandbox.Browser/app.css # samples/MobileSandbox/Platforms/Browser/wwwroot/index.htmlrelease/11.1.0-beta2
52 changed files with 1883 additions and 1402 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.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<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.
|
|||
// 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]); |
|||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 708 B |
@ -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; |
|||
} |
|||
|
|||
@ -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