From fdf5d23ba794ed87a3977d2102cf224b8047bbf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 14:43:29 +0200 Subject: [PATCH 01/14] Add statically linked Metal WebGPU backend support --- eng/build-wgpu-native-ios.sh | 103 +++++++ src/ProGPU.Backend/GpuBuffer.cs | 13 +- .../GpuTextureReadbackBuffer.cs | 27 +- .../Native/progpu-static-resolver-stub.c | 9 + src/ProGPU.Backend/ProGPU.Backend.csproj | 8 + src/ProGPU.Backend/WgpuContext.cs | 262 ++++++++++++++---- .../buildTransitive/ProGPU.Backend.targets | 7 + 7 files changed, 338 insertions(+), 91 deletions(-) create mode 100755 eng/build-wgpu-native-ios.sh create mode 100644 src/ProGPU.Backend/Native/progpu-static-resolver-stub.c create mode 100644 src/ProGPU.Backend/buildTransitive/ProGPU.Backend.targets diff --git a/eng/build-wgpu-native-ios.sh b/eng/build-wgpu-native-ios.sh new file mode 100755 index 00000000..ec87850a --- /dev/null +++ b/eng/build-wgpu-native-ios.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the exact wgpu-native ABI consumed by Silk.NET.WebGPU 2.23.0. +# Source remains an external build input under artifacts/ and is never vendored into ProGPU. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="${WGPU_NATIVE_SOURCE:-${repo_root}/artifacts/wgpu-native-src}" +output_dir="${WGPU_NATIVE_OUTPUT:-${repo_root}/artifacts/wgpu-native-ios}" +# Silk.NET 2.23 still exposes the WebGPU C ABI used by wgpu-native's +# May 2024 header update (with wgpu-core 0.19.4). +# Do not replace this with Silk's newer wgpu-native submodule pointer: its callback-info +# and surface-source ABI is incompatible with the generated Silk.NET.WebGPU assembly. +expected_commit="33133da4ec5a0174cb21539ef2d3346f75200411" +upstream_url="https://github.com/gfx-rs/wgpu-native.git" + +if [[ ! -d "${source_dir}/.git" ]]; then + git clone --filter=blob:none "${upstream_url}" "${source_dir}" +fi + +git -C "${source_dir}" fetch --depth 1 origin "${expected_commit}" +git -C "${source_dir}" checkout --detach "${expected_commit}" +git -C "${source_dir}" submodule update --init --depth 1 ffi/webgpu-headers + +actual_commit="$(git -C "${source_dir}" rev-parse HEAD)" +if [[ "${actual_commit}" != "${expected_commit}" ]]; then + echo "Expected wgpu-native ${expected_commit}, found ${actual_commit}." >&2 + exit 1 +fi + +rustup target add aarch64-apple-ios aarch64-apple-ios-sim + +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-15.0}" +export CARGO_PROFILE_RELEASE_LTO="thin" +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="1" + +features="wgsl,metal" +cargo build \ + --manifest-path "${source_dir}/Cargo.toml" \ + --target aarch64-apple-ios \ + --release \ + --locked \ + --no-default-features \ + --features "${features}" +cargo build \ + --manifest-path "${source_dir}/Cargo.toml" \ + --target aarch64-apple-ios-sim \ + --release \ + --locked \ + --no-default-features \ + --features "${features}" + +mkdir -p "${output_dir}/include" +cp "${source_dir}/ffi/wgpu.h" "${output_dir}/include/wgpu.h" +cp "${source_dir}/ffi/webgpu-headers/webgpu.h" "${output_dir}/include/webgpu.h" + +# Silk resolves entry points lazily. iOS static libraries have no dlopen-able image, so +# retain the C API and expose a single resolver that returns direct function pointers. +resolver_dir="${output_dir}/resolver" +mkdir -p "${resolver_dir}" +resolver_source="${resolver_dir}/progpu_wgpu_resolver.c" +{ + echo '#include ' + echo '#include ' + echo '#include "wgpu.h"' + echo 'void* progpu_wgpu_get_proc_address(const char* proc) {' + echo ' if (proc == NULL) return NULL;' + grep -hEo 'wgpu[A-Z][A-Za-z0-9_]+[[:space:]]*\(' \ + "${source_dir}/ffi/webgpu-headers/webgpu.h" \ + "${source_dir}/ffi/wgpu.h" | sed -E 's/[[:space:]]*\($//' | sort -u | while IFS= read -r symbol; do + echo " if (strcmp(proc, \"${symbol}\") == 0) return (void*)&${symbol};" + done + echo ' return NULL;' + echo '}' +} > "${resolver_source}" + +device_sdk="$(xcrun --sdk iphoneos --show-sdk-path)" +simulator_sdk="$(xcrun --sdk iphonesimulator --show-sdk-path)" +clang -arch arm64 -isysroot "${device_sdk}" -miphoneos-version-min="${IPHONEOS_DEPLOYMENT_TARGET}" \ + -I"${output_dir}/include" -c "${resolver_source}" -o "${resolver_dir}/resolver-ios-arm64.o" +clang -arch arm64 -isysroot "${simulator_sdk}" -mios-simulator-version-min="${IPHONEOS_DEPLOYMENT_TARGET}" \ + -I"${output_dir}/include" -c "${resolver_source}" -o "${resolver_dir}/resolver-iossimulator-arm64.o" + +libtool -static -o "${resolver_dir}/libwgpu_native-ios-arm64.a" \ + "${resolver_dir}/resolver-ios-arm64.o" \ + "${source_dir}/target/aarch64-apple-ios/release/libwgpu_native.a" +libtool -static -o "${resolver_dir}/libwgpu_native-iossimulator-arm64.a" \ + "${resolver_dir}/resolver-iossimulator-arm64.o" \ + "${source_dir}/target/aarch64-apple-ios-sim/release/libwgpu_native.a" + +xcframework="${output_dir}/wgpu_native.xcframework" +if [[ -e "${xcframework}" ]]; then + rm -rf "${xcframework}" +fi + +xcodebuild -create-xcframework \ + -library "${resolver_dir}/libwgpu_native-ios-arm64.a" \ + -headers "${output_dir}/include" \ + -library "${resolver_dir}/libwgpu_native-iossimulator-arm64.a" \ + -headers "${output_dir}/include" \ + -output "${xcframework}" + +echo "Created ${xcframework} from wgpu-native ${expected_commit}." diff --git a/src/ProGPU.Backend/GpuBuffer.cs b/src/ProGPU.Backend/GpuBuffer.cs index c4dd714e..10ed3b13 100644 --- a/src/ProGPU.Backend/GpuBuffer.cs +++ b/src/ProGPU.Backend/GpuBuffer.cs @@ -247,18 +247,10 @@ private void MapReadBuffer( Span destination, bool destroyAfterRead) { - var mapSignal = new System.Threading.ManualResetEventSlim(false); - var mapStatus = BufferMapAsyncStatus.ValidationError; - var onMapped = PfnBufferMapCallback.From((status, userData) => - { - mapStatus = status; - mapSignal.Set(); - }); - - _context.Api.BufferMapAsync(buffer, MapMode.Read, offsetBytes, (nuint)sizeBytes, onMapped, null); + var mapTask = _context.Api.BufferMapAsyncTask(buffer, MapMode.Read, offsetBytes, (nuint)sizeBytes); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); - while (!mapSignal.IsSet) + while (!mapTask.IsCompleted) { _context.PollDevice(wait: false); System.Threading.Thread.Sleep(1); @@ -269,6 +261,7 @@ private void MapReadBuffer( } } + var mapStatus = mapTask.GetAwaiter().GetResult(); if (mapStatus != BufferMapAsyncStatus.Success) { CleanupMappedReadBuffer(buffer, destroyAfterRead); diff --git a/src/ProGPU.Backend/GpuTextureReadbackBuffer.cs b/src/ProGPU.Backend/GpuTextureReadbackBuffer.cs index 2572a879..a56e521c 100644 --- a/src/ProGPU.Backend/GpuTextureReadbackBuffer.cs +++ b/src/ProGPU.Backend/GpuTextureReadbackBuffer.cs @@ -11,15 +11,12 @@ public unsafe sealed class GpuTextureReadbackBuffer : IDisposable public const int DefaultMapTimeoutMilliseconds = 30000; private readonly WgpuContext _context; - private readonly PfnBufferMapCallback _mapCallback; private WgpuBuffer* _buffer; - private ManualResetEventSlim? _mapSignal; private bool _isMapActive; public GpuTextureReadbackBuffer(WgpuContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); - _mapCallback = PfnBufferMapCallback.From(OnBufferMapped); } public uint Width { get; private set; } @@ -227,7 +224,6 @@ public bool TryReadTextureRows( public void Dispose() { QueueBufferDisposal(); - _mapSignal = null; GC.SuppressFinalize(this); } @@ -304,33 +300,22 @@ private bool TryMapAndCopyRows( LastMapStatus = BufferMapAsyncStatus.ValidationError; LastMapTimedOut = false; - using var signal = new ManualResetEventSlim(false); - _mapSignal = signal; - _context.Api.BufferMapAsync(_buffer, MapMode.Read, 0, (nuint)BufferSize, _mapCallback, null); + var mapTask = _context.Api.BufferMapAsyncTask(_buffer, MapMode.Read, 0, (nuint)BufferSize); var stopwatch = Stopwatch.StartNew(); - while (!signal.IsSet) + while (!mapTask.IsCompleted) { _context.PollDevice(wait: false); Thread.Sleep(1); if (stopwatch.ElapsedMilliseconds > timeoutMilliseconds) { LastMapTimedOut = true; - if (ReferenceEquals(_mapSignal, signal)) - { - _mapSignal = null; - } - QueueBufferDisposal(); return false; } } - if (ReferenceEquals(_mapSignal, signal)) - { - _mapSignal = null; - } - + LastMapStatus = mapTask.GetAwaiter().GetResult(); if (LastMapStatus != BufferMapAsyncStatus.Success) { QueueBufferDisposal(); @@ -369,12 +354,6 @@ private bool TryMapAndCopyRows( } } - private void OnBufferMapped(BufferMapAsyncStatus status, void* userData) - { - LastMapStatus = status; - _mapSignal?.Set(); - } - private void QueueBufferDisposal() { if (_buffer == null) diff --git a/src/ProGPU.Backend/Native/progpu-static-resolver-stub.c b/src/ProGPU.Backend/Native/progpu-static-resolver-stub.c new file mode 100644 index 00000000..fce08bd9 --- /dev/null +++ b/src/ProGPU.Backend/Native/progpu-static-resolver-stub.c @@ -0,0 +1,9 @@ +#include + +// Link-only counterpart for WgpuContext's iOS static-library resolver import. +// Browser rendering obtains WebGPU from JavaScript and never calls this function. +void *progpu_wgpu_get_proc_address(const char *symbol) +{ + (void)symbol; + return NULL; +} diff --git a/src/ProGPU.Backend/ProGPU.Backend.csproj b/src/ProGPU.Backend/ProGPU.Backend.csproj index 835ba374..2b475bd3 100644 --- a/src/ProGPU.Backend/ProGPU.Backend.csproj +++ b/src/ProGPU.Backend/ProGPU.Backend.csproj @@ -11,4 +11,12 @@ + + + + diff --git a/src/ProGPU.Backend/WgpuContext.cs b/src/ProGPU.Backend/WgpuContext.cs index 77152e22..264012bd 100644 --- a/src/ProGPU.Backend/WgpuContext.cs +++ b/src/ProGPU.Backend/WgpuContext.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Silk.NET.Core.Contexts; using Silk.NET.Core.Native; @@ -41,6 +42,7 @@ public unsafe class WgpuContext : IDisposable public bool SupportsReadOnlyAndReadWriteStorageTextures { get; private set; } public static event Action? OnWebGpuError; + public static event Action? OnWebGpuDeviceLost; public static void RaiseWebGpuError(ErrorType type, string message) { @@ -48,6 +50,7 @@ public static void RaiseWebGpuError(ErrorType type, string message) } private PfnErrorCallback _errorCallback; + private nint _devicePollAddress; public readonly object RenderLock = new(); public readonly object DisposalLock = new(); @@ -473,6 +476,27 @@ public void Dispose() public void Initialize(IWindow? window) + { + InitializeNative(window, null, 0, 0); + } + + /// + /// Initializes WebGPU directly against an Apple CAMetalLayer. + /// The layer is borrowed for the lifetime of the context and remains owned by UIKit. + /// + public void InitializeMetalLayer(nint metalLayer, uint framebufferWidth, uint framebufferHeight) + { + if (metalLayer == 0) + throw new ArgumentException("A valid CAMetalLayer handle is required.", nameof(metalLayer)); + + InitializeNative( + window: null, + metalLayer: (void*)metalLayer, + framebufferWidth: Math.Max(1u, framebufferWidth), + framebufferHeight: Math.Max(1u, framebufferHeight)); + } + + private void InitializeNative(IWindow? window, void* metalLayer, uint framebufferWidth, uint framebufferHeight) { string logPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ProGPU_test_run.log"); void SafeLog(string msg) @@ -489,7 +513,7 @@ void SafeLog(string msg) SafeLog($"[WGPUCONTEXT] Initialize started, window exists={window != null}\n"); _window = window; - Wgpu = WebGPU.GetApi(); + Wgpu = CreateNativeWebGpuApi(); Api = new SilkWebGpuApi(Wgpu); // 1. Create WebGPU Instance (isolated per context) @@ -501,7 +525,7 @@ void SafeLog(string msg) throw new InvalidOperationException("Failed to create WebGPU Instance."); } - // 2. Create Surface if window is provided + // 2. Create a native presentation surface when requested. if (window != null) { if (!CanCreateNativeSurface(window)) @@ -517,11 +541,33 @@ void SafeLog(string msg) throw new InvalidOperationException("Failed to create WebGPU Surface from window."); } } + else if (metalLayer != null) + { + SafeLog("[WGPUCONTEXT] Creating WebGPU Surface from CAMetalLayer\n"); + var metalDescriptor = new SurfaceDescriptorFromMetalLayer + { + Chain = new ChainedStruct + { + SType = SType.SurfaceDescriptorFromMetalLayer + }, + Layer = metalLayer + }; + var surfaceDescriptor = new SurfaceDescriptor + { + NextInChain = &metalDescriptor.Chain + }; + Surface = Wgpu.InstanceCreateSurface(Instance, &surfaceDescriptor); + if (Surface == null) + { + throw new InvalidOperationException("Failed to create a WebGPU Surface from CAMetalLayer."); + } + } // 3. Request Adapter (synchronously) SafeLog("[WGPUCONTEXT] Requesting Adapter\n"); - var adapterSignal = new ManualResetEventSlim(false); - Adapter* requestedAdapter = null; + using var adapterSignal = new ManualResetEventSlim(false); + var adapterState = new AdapterRequestState(adapterSignal); + var adapterStateHandle = GCHandle.Alloc(adapterState); var requestAdapterOptions = new RequestAdapterOptions { @@ -529,34 +575,33 @@ void SafeLog(string msg) PowerPreference = PowerPreference.HighPerformance }; - var onAdapterReceived = PfnRequestAdapterCallback.From((status, adapter, message, userData) => + try { - if (status == RequestAdapterStatus.Success) - { - requestedAdapter = adapter; - } - else - { - string msg = (message != null ? SilkMarshal.PtrToString((nint)message) : null) ?? "Unknown error"; - Console.WriteLine($"[WebGPU] RequestAdapter failed: {msg}"); - } - adapterSignal.Set(); - }); - - Wgpu.InstanceRequestAdapter(Instance, &requestAdapterOptions, onAdapterReceived, null); - adapterSignal.Wait(); + var onAdapterReceived = new PfnRequestAdapterCallback(&OnAdapterRequested); + Wgpu.InstanceRequestAdapter( + Instance, + &requestAdapterOptions, + onAdapterReceived, + (void*)GCHandle.ToIntPtr(adapterStateHandle)); + adapterSignal.Wait(); + } + finally + { + adapterStateHandle.Free(); + } - SafeLog($"[WGPUCONTEXT] RequestAdapter finished, adapter={(nint)requestedAdapter:X}\n"); - if (requestedAdapter == null) + SafeLog($"[WGPUCONTEXT] RequestAdapter finished, adapter={adapterState.Result:X}\n"); + if (adapterState.Result == 0) { - throw new InvalidOperationException("Failed to obtain WebGPU Adapter."); + throw new InvalidOperationException($"Failed to obtain WebGPU Adapter. {adapterState.Error}"); } - Adapter = requestedAdapter; + Adapter = (Adapter*)adapterState.Result; // 4. Request Device (synchronously) SafeLog("[WGPUCONTEXT] Requesting Device\n"); - var deviceSignal = new ManualResetEventSlim(false); - Device* requestedDevice = null; + using var deviceSignal = new ManualResetEventSlim(false); + var deviceState = new DeviceRequestState(deviceSignal); + var deviceStateHandle = GCHandle.Alloc(deviceState); var adapterLimits = new SupportedLimits(); Wgpu.AdapterGetLimits(Adapter, &adapterLimits); @@ -569,35 +614,34 @@ void SafeLog(string msg) Label = (byte*)SilkMarshal.StringToPtr("ProGPU Primary Device"), RequiredLimits = &requiredLimits, RequiredFeatureCount = 1, - RequiredFeatures = requiredFeatures + RequiredFeatures = requiredFeatures, + DeviceLostCallback = new PfnDeviceLostCallback(&OnDeviceLost) }; - var onDeviceReceived = PfnRequestDeviceCallback.From((status, device, message, userData) => + try { - if (status == RequestDeviceStatus.Success) - { - requestedDevice = device; - } - else - { - string msg = (message != null ? SilkMarshal.PtrToString((nint)message) : null) ?? "Unknown error"; - Console.WriteLine($"[WebGPU] RequestDevice failed: {msg}"); - } - deviceSignal.Set(); - }); - - Wgpu.AdapterRequestDevice(Adapter, &deviceDesc, onDeviceReceived, null); - deviceSignal.Wait(); + var onDeviceReceived = new PfnRequestDeviceCallback(&OnDeviceRequested); + Wgpu.AdapterRequestDevice( + Adapter, + &deviceDesc, + onDeviceReceived, + (void*)GCHandle.ToIntPtr(deviceStateHandle)); + deviceSignal.Wait(); + } + finally + { + deviceStateHandle.Free(); + } // Free labeled string SilkMarshal.Free((nint)deviceDesc.Label); - SafeLog($"[WGPUCONTEXT] RequestDevice finished, device={(nint)requestedDevice:X}\n"); - if (requestedDevice == null) + SafeLog($"[WGPUCONTEXT] RequestDevice finished, device={deviceState.Result:X}\n"); + if (deviceState.Result == 0) { - throw new InvalidOperationException("Failed to obtain WebGPU Device."); + throw new InvalidOperationException($"Failed to obtain WebGPU Device. {deviceState.Error}"); } - Device = requestedDevice; + Device = (Device*)deviceState.Result; var deviceLimits = new SupportedLimits(); Wgpu.DeviceGetLimits(Device, &deviceLimits); @@ -612,19 +656,16 @@ void SafeLog(string msg) _sharedDeviceLifetime = new SharedDeviceLifetime(Wgpu, Instance, Adapter, Device, Queue); // 6. Hook up validation error callback - _errorCallback = PfnErrorCallback.From((type, msg, _) => - { - string errorMsg = (msg != null ? SilkMarshal.PtrToString((nint)msg) : null) ?? "Unknown error"; - Console.WriteLine($"[WebGPU Error] Type: {type}, Message: {errorMsg}"); - OnWebGpuError?.Invoke(type, errorMsg); - }); + _errorCallback = new PfnErrorCallback(&OnUncapturedError); Wgpu.DeviceSetUncapturedErrorCallback(Device, _errorCallback, null); // 7. Configure Surface if window exists - if (window != null && Surface != null) + if (Surface != null) { SafeLog("[WGPUCONTEXT] Configuring SwapChain\n"); - ConfigureSwapChain((uint)window.FramebufferSize.X, (uint)window.FramebufferSize.Y); + uint width = window != null ? (uint)Math.Max(1, window.FramebufferSize.X) : framebufferWidth; + uint height = window != null ? (uint)Math.Max(1, window.FramebufferSize.Y) : framebufferHeight; + ConfigureSwapChain(width, height); SafeLog("[WGPUCONTEXT] Configuring SwapChain finished\n"); } @@ -639,6 +680,110 @@ void SafeLog(string msg) Current = this; } + private static WebGPU CreateNativeWebGpuApi() + { + if (OperatingSystem.IsIOS()) + { + return new WebGPU(new LamdaNativeContext(ResolveAppleStaticWebGpuSymbol)); + } + + return WebGPU.GetApi(); + } + + private static nint ResolveAppleStaticWebGpuSymbol(string symbol) + { + nint utf8 = SilkMarshal.StringToPtr(symbol); + try + { + return ResolveStaticallyLinkedWebGpuSymbol((byte*)utf8); + } + finally + { + SilkMarshal.Free(utf8); + } + } + + // A direct import keeps the resolver object, and therefore wgpu-native, rooted by + // Apple's static linker. Browser applications provide a link-only no-op definition; + // this method is unreachable there because OperatingSystem.IsIOS() is false. + [DllImport("__Internal", EntryPoint = "progpu_wgpu_get_proc_address")] + private static extern nint ResolveStaticallyLinkedWebGpuSymbol(byte* symbol); + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnAdapterRequested( + RequestAdapterStatus status, + Adapter* adapter, + byte* message, + void* userData) + { + var state = (AdapterRequestState)GCHandle.FromIntPtr((nint)userData).Target!; + if (status == RequestAdapterStatus.Success) + { + state.Result = (nint)adapter; + } + else + { + state.Error = ReadNativeMessage(message); + Console.WriteLine($"[WebGPU] RequestAdapter failed: {state.Error}"); + } + + state.Signal.Set(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnDeviceRequested( + RequestDeviceStatus status, + Device* device, + byte* message, + void* userData) + { + var state = (DeviceRequestState)GCHandle.FromIntPtr((nint)userData).Target!; + if (status == RequestDeviceStatus.Success) + { + state.Result = (nint)device; + } + else + { + state.Error = ReadNativeMessage(message); + Console.WriteLine($"[WebGPU] RequestDevice failed: {state.Error}"); + } + + state.Signal.Set(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnUncapturedError(ErrorType type, byte* message, void* _) + { + string errorMessage = ReadNativeMessage(message); + Console.WriteLine($"[WebGPU Error] Type: {type}, Message: {errorMessage}"); + OnWebGpuError?.Invoke(type, errorMessage); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void OnDeviceLost(DeviceLostReason reason, byte* message, void* _) + { + string errorMessage = ReadNativeMessage(message); + Console.Error.WriteLine($"[WebGPU Device Lost] Reason: {reason}, Message: {errorMessage}"); + OnWebGpuDeviceLost?.Invoke(reason, errorMessage); + } + + private static string ReadNativeMessage(byte* message) => + (message != null ? SilkMarshal.PtrToString((nint)message) : null) ?? "Unknown error"; + + private sealed class AdapterRequestState(ManualResetEventSlim signal) + { + public ManualResetEventSlim Signal { get; } = signal; + public nint Result { get; set; } + public string? Error { get; set; } + } + + private sealed class DeviceRequestState(ManualResetEventSlim signal) + { + public ManualResetEventSlim Signal { get; } = signal; + public nint Result { get; set; } + public string? Error { get; set; } + } + public Task InitializeAsync(IWindow? window, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -997,14 +1142,17 @@ public bool TryReconfigureIfNeeded(uint width, uint height) return _isSurfaceConfigured; } - [DllImport("wgpu_native", EntryPoint = "wgpuDevicePoll")] - private static extern unsafe bool wgpuDevicePoll(Device* device, bool wait, void* wrappedSubmissionIndex); - public void PollDevice(bool wait) { if (BackendKind == WgpuBackendKind.SilkNative && Device != null && !_isDisposed) { - wgpuDevicePoll(Device, wait, null); + if (_devicePollAddress == 0) + { + _devicePollAddress = Wgpu.Context.GetProcAddress("wgpuDevicePoll"); + } + + var poll = (delegate* unmanaged[Cdecl])_devicePollAddress; + _ = poll(Device, wait ? 1u : 0u, null); } } diff --git a/src/ProGPU.Backend/buildTransitive/ProGPU.Backend.targets b/src/ProGPU.Backend/buildTransitive/ProGPU.Backend.targets new file mode 100644 index 00000000..a51f512a --- /dev/null +++ b/src/ProGPU.Backend/buildTransitive/ProGPU.Backend.targets @@ -0,0 +1,7 @@ + + + + + + From f06cb93e1fc70b487c94d002ce664e80b3f676f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 14:43:50 +0200 Subject: [PATCH 02/14] Add WinUI mobile window and input pane APIs --- src/ProGPU.Tests/MobileWindowApiTests.cs | 117 ++++++++ src/ProGPU.WinUI/Core/Application.cs | 41 ++- src/ProGPU.WinUI/Core/WinUI.Xaml.cs | 7 +- src/ProGPU.WinUI/Core/Window.cs | 305 ++++++++++++++++++++- src/ProGPU.WinUI/Core/WindowEventArgs.cs | 50 ++++ src/ProGPU.WinUI/Core/WindowInsets.Xaml.cs | 23 ++ src/ProGPU.WinUI/Core/WindowInsets.cs | 72 +++++ 7 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 src/ProGPU.Tests/MobileWindowApiTests.cs create mode 100644 src/ProGPU.WinUI/Core/WindowEventArgs.cs create mode 100644 src/ProGPU.WinUI/Core/WindowInsets.Xaml.cs create mode 100644 src/ProGPU.WinUI/Core/WindowInsets.cs diff --git a/src/ProGPU.Tests/MobileWindowApiTests.cs b/src/ProGPU.Tests/MobileWindowApiTests.cs new file mode 100644 index 00000000..8a76ad56 --- /dev/null +++ b/src/ProGPU.Tests/MobileWindowApiTests.cs @@ -0,0 +1,117 @@ +using Microsoft.UI.Xaml; +using Windows.UI.ViewManagement; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class MobileWindowApiTests +{ + [Fact] + public void ExternalHostEventsUseWinUiWindowArgumentShapes() + { + var window = new Window(); + var activationStates = new List(); + var visibilityStates = new List(); + var closedCount = 0; + + window.Activated += (_, args) => + { + args.Handled = true; + activationStates.Add(args.WindowActivationState); + }; + window.VisibilityChanged += (_, args) => + { + args.Handled = true; + visibilityStates.Add(args.Visible); + }; + window.Closed += (_, args) => + { + args.Handled = true; + closedCount++; + }; + + window.NotifyHostVisibilityChanged(true); + window.NotifyHostActivationChanged(WindowActivationState.PointerActivated); + window.NotifyHostVisibilityChanged(false); + window.ShutdownExternalRenderer(); + window.ShutdownExternalRenderer(); + + Assert.IsAssignableFrom(window); + Assert.Equal([WindowActivationState.PointerActivated, WindowActivationState.Deactivated], activationStates); + Assert.Equal([true, false], visibilityStates); + Assert.Equal(1, closedCount); + } + + [Fact] + public void SetTitleBarAcceptsNullToRestoreTheSystemTitleBar() + { + var window = new Window(); + + window.SetTitleBar(null); + window.ShutdownExternalRenderer(); + } + + [Fact] + public void LaunchArgumentsMatchTheWinUiStringContract() + { + var args = new LaunchActivatedEventArgs(["--page", "Text"]); + + Assert.Equal("--page Text", args.Arguments); + } + + [Fact] + public void ApplicationStartInvokesInitializationOnTheCallingThread() + { + int callingThread = Environment.CurrentManagedThreadId; + int callbackThread = -1; + + Application.Start(_ => callbackThread = Environment.CurrentManagedThreadId); + + Assert.Equal(callingThread, callbackThread); + } + + [Fact] + public void SafeAreaAndDockedInputPaneProduceLogicalVisibleBounds() + { + var window = new Window { Width = 390, Height = 844 }; + var changes = new List(); + window.InsetsChanged += (_, args) => changes.Add(args.Insets); + InputPane pane = InputPane.GetForWindow(window); + int showing = 0; + int hiding = 0; + pane.Showing += (_, args) => + { + args.EnsuredFocusedElementInView = true; + showing++; + }; + pane.Hiding += (_, _) => hiding++; + + window.NotifyHostInsetsChanged( + new Thickness(0f, 59f, 0f, 34f), + new Windows.Foundation.Rect(0d, 544d, 390d, 300d)); + + Assert.Equal(new Thickness(0f, 59f, 0f, 34f), window.Insets.SafeArea); + Assert.Equal(new Windows.Foundation.Rect(0d, 59d, 390d, 485d), window.Insets.VisibleBounds); + Assert.True(pane.Visible); + Assert.Equal(1, showing); + + window.NotifyHostInsetsChanged(new Thickness(0f, 59f, 0f, 34f), default); + + Assert.Equal(new Windows.Foundation.Rect(0d, 59d, 390d, 751d), window.Insets.VisibleBounds); + Assert.False(pane.Visible); + Assert.Equal(1, hiding); + Assert.Equal(2, changes.Count); + } + + [Fact] + public void FloatingInputPaneReportsOcclusionWithoutShrinkingWholeWindow() + { + var window = new Window { Width = 390, Height = 844 }; + var floating = new Windows.Foundation.Rect(90d, 360d, 210d, 240d); + + window.NotifyHostInsetsChanged(new Thickness(0f, 59f, 0f, 34f), floating); + + Assert.Equal(floating, window.InputPane.OccludedRect); + Assert.Equal(new Windows.Foundation.Rect(0d, 59d, 390d, 751d), window.Insets.VisibleBounds); + } +} diff --git a/src/ProGPU.WinUI/Core/Application.cs b/src/ProGPU.WinUI/Core/Application.cs index 365bfe2e..bb5e621d 100644 --- a/src/ProGPU.WinUI/Core/Application.cs +++ b/src/ProGPU.WinUI/Core/Application.cs @@ -1,14 +1,28 @@ using System; +using System.Runtime.ExceptionServices; namespace Microsoft.UI.Xaml; +#pragma warning disable CS0618 +public delegate void ApplicationInitializationCallback(ApplicationInitializationCallbackParams parameters); +#pragma warning restore CS0618 + +[Obsolete("ApplicationInitializationCallbackParams is retained for WinUI API compatibility.")] +public sealed class ApplicationInitializationCallbackParams +{ + internal ApplicationInitializationCallbackParams() + { + } +} + public class LaunchActivatedEventArgs : EventArgs { - public string[] Arguments { get; } + public string Arguments { get; } public LaunchActivatedEventArgs(string[] args) { - Arguments = args; + ArgumentNullException.ThrowIfNull(args); + Arguments = string.Join(' ', args); } } @@ -18,7 +32,19 @@ public class Application public ResourceDictionary Resources { get; } = new(); - public event EventHandler? UnhandledException; + public event UnhandledExceptionEventHandler? UnhandledException; + + /// + /// Invokes the framework initialization callback on the current UI thread. + /// Platform hosts remain responsible for installing their dispatcher before calling this API. + /// + public static void Start(ApplicationInitializationCallback callback) + { + ArgumentNullException.ThrowIfNull(callback); +#pragma warning disable CS0618 + callback(new ApplicationInitializationCallbackParams()); +#pragma warning restore CS0618 + } protected virtual void OnLaunched(LaunchActivatedEventArgs args) { @@ -32,8 +58,13 @@ internal void Launch(LaunchActivatedEventArgs args) } catch (Exception ex) { - UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs { Exception = ex }); - Console.WriteLine($"[Application] Unhandled exception during launch: {ex.Message}"); + var eventArgs = new UnhandledExceptionEventArgs { Exception = ex }; + UnhandledException?.Invoke(this, eventArgs); + if (!eventArgs.Handled) + { + Console.Error.WriteLine($"[Application] Unhandled exception during launch: {ex}"); + ExceptionDispatchInfo.Capture(ex).Throw(); + } } } } diff --git a/src/ProGPU.WinUI/Core/WinUI.Xaml.cs b/src/ProGPU.WinUI/Core/WinUI.Xaml.cs index 4f2add34..991bfd59 100644 --- a/src/ProGPU.WinUI/Core/WinUI.Xaml.cs +++ b/src/ProGPU.WinUI/Core/WinUI.Xaml.cs @@ -73,10 +73,13 @@ public Visibility Visibility } public delegate void RoutedEventHandler(object sender, RoutedEventArgs e); + public delegate void UnhandledExceptionEventHandler(object sender, UnhandledExceptionEventArgs e); public class UnhandledExceptionEventArgs : EventArgs { - public Exception Exception { get; set; } = new Exception("Unhandled XAML exception"); + public Exception Exception { get; internal set; } = new Exception("Unhandled XAML exception"); + public bool Handled { get; set; } + public string Message => Exception.Message; } public partial class FrameworkElement : UIElement, System.ComponentModel.INotifyPropertyChanged @@ -116,5 +119,7 @@ internal sealed class CompletedAsyncOperation : IAsyncOperation _silkWindow; public WgpuContext? WgpuContext => _wgpuContext; @@ -69,6 +79,37 @@ public class Window _windowController?.FrameInsets ?? NativeWindowFrameInsets.Empty; public bool IsUsingSystemBackdropFallback { get; private set; } public WindowFrameMetrics FrameMetrics { get; private set; } + public Windows.Foundation.Rect Bounds => _bounds; + public bool Visible => _visible; + public WindowInsets Insets => _insets; + public Windows.UI.ViewManagement.InputPane InputPane { get; } + + /// + /// When false (the default), content is arranged inside platform safe areas. + /// Backdrops and compositor overlays continue to cover the complete surface. + /// + public bool ExtendsContentIntoSystemInsets + { + get => _extendsContentIntoSystemInsets; + set + { + if (_extendsContentIntoSystemInsets == value) return; + _extendsContentIntoSystemInsets = value; + ApplyContentInsets(); + } + } + + /// Automatically keeps ordinary window content above a docked input pane. + public bool AvoidInputPane + { + get => _avoidInputPane; + set + { + if (_avoidInputPane == value) return; + _avoidInputPane = value; + ApplyContentInsets(); + } + } /// /// Configures the bounded glyph cache before activation. Font-inspection tools @@ -211,12 +252,12 @@ public FrameworkElement? Content } if (_content != null) { - _renderRoot.RemoveChild(_content); + _contentRoot.RemoveChild(_content); } _content = value; if (value != null) { - _renderRoot.AddChild(value); + _contentRoot.AddChild(value); } if (_inputState != null) { @@ -241,6 +282,7 @@ public int Width set { _width = value; + _bounds = _bounds with { Width = value }; if (_silkWindow != null) _silkWindow.Size = new Vector2D(_width, _silkWindow.Size.Y); } } @@ -251,12 +293,16 @@ public int Height set { _height = value; + _bounds = _bounds with { Height = value }; if (_silkWindow != null) _silkWindow.Size = new Vector2D(_silkWindow.Size.X, _height); } } - public event EventHandler? Closed; - public event EventHandler? Activated; + public event Windows.Foundation.TypedEventHandler? Activated; + public event Windows.Foundation.TypedEventHandler? Closed; + public event Windows.Foundation.TypedEventHandler? SizeChanged; + public event Windows.Foundation.TypedEventHandler? VisibilityChanged; + public event Windows.Foundation.TypedEventHandler? InsetsChanged; public event EventHandler? Rendering; public Window() @@ -272,11 +318,18 @@ public Window() VerticalAlignment = VerticalAlignment.Stretch, IsHitTestVisible = false }; + _contentRoot = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch, + VerticalAlignment = VerticalAlignment.Stretch + }; + InputPane = new Windows.UI.ViewManagement.InputPane(this); _renderRoot.AddChild(_backdropLayer); - ThemeManager.ThemeChanged += OnThemeChanged; + _renderRoot.AddChild(_contentRoot); + ThemeManager.ThemeChanged += HandleThemeChanged; } - private void OnThemeChanged() + private void HandleThemeChanged() { _windowController?.SetTheme( ThemeManager.CurrentTheme == ElementTheme.Dark @@ -297,13 +350,15 @@ private void OnSystemBackdropChanged() public void Activate() { + ObjectDisposedException.ThrowIf(_isClosed, this); if (WindowHostServices.Current is { } externalHost) { if (_isExternalHostActive) return; externalHost.Activate(this); _isExternalHostActive = true; WindowManager.Register(this); - Activated?.Invoke(this, EventArgs.Empty); + NotifyHostVisibilityChanged(true); + NotifyHostActivationChanged(WindowActivationState.CodeActivated); return; } @@ -338,7 +393,9 @@ public void Activate() _silkWindow.Initialize(); WindowManager.Register(this); - Activated?.Invoke(this, EventArgs.Empty); + UpdateBounds(_silkWindow.Size.X, _silkWindow.Size.Y); + NotifyHostVisibilityChanged(true); + NotifyHostActivationChanged(WindowActivationState.CodeActivated); } public Task ActivateAsync(CancellationToken cancellationToken = default) @@ -370,8 +427,21 @@ public void Hide() { _silkWindow.IsVisible = false; } + NotifyHostVisibilityChanged(false); + NotifyHostActivationChanged(WindowActivationState.Deactivated); + } + + /// + /// Selects the element used as the app-defined title bar. This mirrors the WinUI API; + /// native hosts may use the value to initiate platform window dragging. + /// + public void SetTitleBar(UIElement? titleBar) + { + _titleBar = titleBar; } + internal UIElement? AppTitleBar => _titleBar; + private void OnLoad() { if (_silkWindow == null) return; @@ -452,6 +522,7 @@ public void RenderExternalFrame(double delta, uint framebufferWidth, uint frameb var scale = float.IsFinite(dpiScale) && dpiScale > 0f ? dpiScale : 1f; var framebufferSize = new Vector2D(checked((int)Math.Max(1u, framebufferWidth)), checked((int)Math.Max(1u, framebufferHeight))); var logicalSize = ResolveLogicalClientSize(framebufferSize, scale); + UpdateBounds(logicalSize.X, logicalSize.Y); _isRendering = true; try { @@ -470,8 +541,173 @@ public void ShutdownExternalRenderer() _wgpuContext = null; _inputState = null; _isExternalHostActive = false; + NotifyHostVisibilityChanged(false); + NotifyHostActivationChanged(WindowActivationState.Deactivated); WindowManager.Unregister(this); - Closed?.Invoke(this, EventArgs.Empty); + RaiseClosed(); + DetachWindowServices(); + } + + /// + /// Updates activation state from an external platform host. + /// + public void NotifyHostActivationChanged(WindowActivationState state) + { + if (_isClosed) return; + Activated?.Invoke(this, new WindowActivatedEventArgs(state)); + } + + /// + /// Updates visibility from an external platform host. + /// + public void NotifyHostVisibilityChanged(bool visible) + { + if (_isClosed || _visible == visible) return; + _visible = visible; + VisibilityChanged?.Invoke(this, new WindowVisibilityChangedEventArgs(visible)); + } + + /// Updates safe-area and input-pane geometry from an external native host. + public void NotifyHostInsetsChanged( + Thickness safeArea, + Windows.Foundation.Rect inputPaneOccludedRect) + { + safeArea = NormalizeInsets(safeArea); + inputPaneOccludedRect = NormalizeOccludedRect(inputPaneOccludedRect); + if (_safeAreaInsets.Equals(safeArea) && _inputPaneOccludedRect.Equals(inputPaneOccludedRect)) return; + + _safeAreaInsets = safeArea; + _inputPaneOccludedRect = inputPaneOccludedRect; + ApplyContentInsets(); + bool ensuredFocusedElement = InputPaneVisible(inputPaneOccludedRect) && EnsureFocusedElementInView(); + InputPane.UpdateOccludedRect(inputPaneOccludedRect, ensuredFocusedElement); + } + + /// Installs best-effort platform software-keyboard show/hide callbacks. + public void ConfigureInputPane(Func? tryShow, Func? tryHide) => + InputPane.SetPlatformCallbacks(tryShow, tryHide); + + private void ApplyContentInsets() + { + double width = Math.Max(0d, _bounds.Width); + double height = Math.Max(0d, _bounds.Height); + Thickness safe = _extendsContentIntoSystemInsets ? default : _safeAreaInsets; + float keyboardBottom = 0f; + + if (_avoidInputPane && IsDockedBottomOcclusion(_inputPaneOccludedRect, width, height)) + { + keyboardBottom = (float)Math.Clamp(height - _inputPaneOccludedRect.Y, 0d, height); + } + + var effective = new Thickness( + safe.Left, + safe.Top, + safe.Right, + Math.Max(safe.Bottom, keyboardBottom)); + _contentRoot.Margin = effective; + + double visibleX = Math.Clamp(effective.Left, 0f, (float)width); + double visibleY = Math.Clamp(effective.Top, 0f, (float)height); + double visibleWidth = Math.Max(0d, width - effective.Left - effective.Right); + double visibleHeight = Math.Max(0d, height - effective.Top - effective.Bottom); + var next = new WindowInsets( + _safeAreaInsets, + _inputPaneOccludedRect, + new Windows.Foundation.Rect(visibleX, visibleY, visibleWidth, visibleHeight)); + if (_insets.Equals(next)) return; + _insets = next; + _contentRoot.InvalidateMeasure(); + _contentRoot.Invalidate(); + InsetsChanged?.Invoke(this, new WindowInsetsChangedEventArgs(next)); + } + + private static bool IsDockedBottomOcclusion(Windows.Foundation.Rect rect, double width, double height) => + rect.Width > 0d && rect.Height > 0d && + rect.Width >= width * 0.9d && + rect.Y + rect.Height >= height - 1d; + + private static bool InputPaneVisible(Windows.Foundation.Rect rect) => + rect.Width > 0d && rect.Height > 0d; + + private bool EnsureFocusedElementInView() + { + FrameworkElement? focused = InputSystem.FocusedElement; + if (focused == null || focused.Size.X <= 0f || focused.Size.Y <= 0f) return false; + + bool belongsToWindow = false; + for (Visual? ancestor = focused; ancestor != null; ancestor = ancestor.Parent) + { + if (ReferenceEquals(ancestor, _renderRoot)) + { + belongsToWindow = true; + break; + } + } + if (!belongsToWindow) return false; + + Rect focusedBounds = focused.TransformToVisual(_renderRoot) + .TransformBounds(new Rect(0f, 0f, focused.Size.X, focused.Size.Y)); + const float revealPadding = 12f; + float visibleLeft = (float)_insets.VisibleBounds.X + revealPadding; + float visibleTop = (float)_insets.VisibleBounds.Y + revealPadding; + float visibleRight = (float)(_insets.VisibleBounds.X + _insets.VisibleBounds.Width) - revealPadding; + float visibleBottom = (float)(_insets.VisibleBounds.Y + _insets.VisibleBounds.Height) - revealPadding; + float deltaX = focusedBounds.Right > visibleRight + ? focusedBounds.Right - visibleRight + : focusedBounds.X < visibleLeft ? focusedBounds.X - visibleLeft : 0f; + float deltaY = focusedBounds.Bottom > visibleBottom + ? focusedBounds.Bottom - visibleBottom + : focusedBounds.Y < visibleTop ? focusedBounds.Y - visibleTop : 0f; + + // Floating/undocked keyboards create a hole rather than reducing the whole + // viewport. Prefer revealing the editor immediately above that occlusion. + var occluded = _inputPaneOccludedRect; + bool overlapsOcclusion = occluded.Width > 0d && occluded.Height > 0d && + focusedBounds.Right > (float)occluded.X && + focusedBounds.X < (float)(occluded.X + occluded.Width) && + focusedBounds.Bottom > (float)occluded.Y && + focusedBounds.Y < (float)(occluded.Y + occluded.Height); + if (overlapsOcclusion) + { + deltaY = focusedBounds.Bottom - ((float)occluded.Y - revealPadding); + } + if (deltaX == 0f && deltaY == 0f) return true; + + for (Visual? ancestor = focused.Parent; ancestor != null; ancestor = ancestor.Parent) + { + if (ancestor is ScrollViewer viewer) + { + bool changed = viewer.ChangeView( + deltaX == 0f ? null : viewer.HorizontalOffset + deltaX, + deltaY == 0f ? null : viewer.VerticalOffset + deltaY, + null); + if (changed) return true; + } + else if (ancestor is DataGrid dataGrid && deltaY != 0f) + { + float previous = dataGrid.ScrollOffset; + dataGrid.ScrollOffset += deltaY; + if (dataGrid.ScrollOffset != previous) return true; + } + if (ReferenceEquals(ancestor, _renderRoot)) break; + } + + return false; + } + + private static Thickness NormalizeInsets(Thickness value) => new( + float.IsFinite(value.Left) ? Math.Max(0f, value.Left) : 0f, + float.IsFinite(value.Top) ? Math.Max(0f, value.Top) : 0f, + float.IsFinite(value.Right) ? Math.Max(0f, value.Right) : 0f, + float.IsFinite(value.Bottom) ? Math.Max(0f, value.Bottom) : 0f); + + private Windows.Foundation.Rect NormalizeOccludedRect(Windows.Foundation.Rect value) + { + double x = double.IsFinite(value.X) ? Math.Clamp(value.X, 0d, _bounds.Width) : 0d; + double y = double.IsFinite(value.Y) ? Math.Clamp(value.Y, 0d, _bounds.Height) : 0d; + double width = double.IsFinite(value.Width) ? Math.Clamp(value.Width, 0d, _bounds.Width - x) : 0d; + double height = double.IsFinite(value.Height) ? Math.Clamp(value.Height, 0d, _bounds.Height - y) : 0d; + return new Windows.Foundation.Rect(x, y, width, height); } private void OnRender(double delta) @@ -568,6 +804,17 @@ private unsafe void RenderFrameCore(double delta, Vector2D framebufferSize, }; targetView = wgpuContext.Api.TextureCreateView(surfaceTexture.Texture, &viewDesc); } + else if (surfaceTexture.Status is SurfaceGetCurrentTextureStatus.Outdated or SurfaceGetCurrentTextureStatus.Lost) + { + // Resize, display migration, and foreground restoration can invalidate the + // platform surface without changing its dimensions. Reconfigure now and let + // the next display-link tick acquire the replacement drawable. + wgpuContext.TryConfigureSwapChain((uint)framebufferSize.X, (uint)framebufferSize.Y); + } + else if (surfaceTexture.Status is SurfaceGetCurrentTextureStatus.OutOfMemory or SurfaceGetCurrentTextureStatus.DeviceLost) + { + throw new InvalidOperationException($"WebGPU surface acquisition failed: {surfaceTexture.Status}."); + } } double surfaceAcquireTimeMs = System.Diagnostics.Stopwatch.GetElapsedTime(phaseStart).TotalMilliseconds; double compositorTimeMs = 0d; @@ -617,8 +864,9 @@ private unsafe void RenderFrameCore(double delta, Vector2D framebufferSize, System.Diagnostics.Stopwatch.GetElapsedTime(frameStart).TotalMilliseconds); } - private void OnResize(Vector2D _) + private void OnResize(Vector2D newSize) { + UpdateBounds(newSize.X, newSize.Y); _content?.Invalidate(); _renderRoot.Invalidate(); } @@ -680,19 +928,48 @@ private Vector2 NormalizePointerPosition(Vector2 pointerPosition) private void OnClosing() { - Closed?.Invoke(this, EventArgs.Empty); + NotifyHostVisibilityChanged(false); + NotifyHostActivationChanged(WindowActivationState.Deactivated); + RaiseClosed(); WindowManager.Unregister(this); _compositor?.Dispose(); _wgpuContext?.Dispose(); _windowController?.Dispose(); _windowController = null; + DetachWindowServices(); + _silkWindow = null; + } + + private void UpdateBounds(double width, double height) + { + double normalizedWidth = double.IsFinite(width) ? Math.Max(0d, width) : 0d; + double normalizedHeight = double.IsFinite(height) ? Math.Max(0d, height) : 0d; + if (_bounds.Width.Equals(normalizedWidth) && _bounds.Height.Equals(normalizedHeight)) return; + + _bounds = new Windows.Foundation.Rect(_bounds.X, _bounds.Y, normalizedWidth, normalizedHeight); + _width = checked((int)Math.Round(normalizedWidth)); + _height = checked((int)Math.Round(normalizedHeight)); + ApplyContentInsets(); + SizeChanged?.Invoke( + this, + new WindowSizeChangedEventArgs(new Windows.Foundation.Size(normalizedWidth, normalizedHeight))); + } + + private void RaiseClosed() + { + if (_isClosed) return; + _isClosed = true; + Closed?.Invoke(this, new WindowEventArgs()); + } + + private void DetachWindowServices() + { if (_systemBackdrop != null) { _systemBackdrop.Changed -= OnSystemBackdropChanged; } - ThemeManager.ThemeChanged -= OnThemeChanged; - _silkWindow = null; + ThemeManager.ThemeChanged -= HandleThemeChanged; } public void SetSizeConstraints(NativeWindowSize minimum, NativeWindowSize maximum) diff --git a/src/ProGPU.WinUI/Core/WindowEventArgs.cs b/src/ProGPU.WinUI/Core/WindowEventArgs.cs new file mode 100644 index 00000000..ae8d6191 --- /dev/null +++ b/src/ProGPU.WinUI/Core/WindowEventArgs.cs @@ -0,0 +1,50 @@ +namespace Microsoft.UI.Xaml; + +/// +/// Describes how a window entered or left the activated state. +/// Values match Microsoft.UI.Xaml.WindowActivationState. +/// +public enum WindowActivationState +{ + CodeActivated = 0, + Deactivated = 1, + PointerActivated = 2 +} + +public sealed class WindowActivatedEventArgs : EventArgs +{ + internal WindowActivatedEventArgs(WindowActivationState windowActivationState) + { + WindowActivationState = windowActivationState; + } + + public bool Handled { get; set; } + public WindowActivationState WindowActivationState { get; } +} + +public sealed class WindowEventArgs : EventArgs +{ + public bool Handled { get; set; } +} + +public sealed class WindowSizeChangedEventArgs : EventArgs +{ + internal WindowSizeChangedEventArgs(Windows.Foundation.Size size) + { + Size = size; + } + + public bool Handled { get; set; } + public Windows.Foundation.Size Size { get; } +} + +public sealed class WindowVisibilityChangedEventArgs : EventArgs +{ + internal WindowVisibilityChangedEventArgs(bool visible) + { + Visible = visible; + } + + public bool Handled { get; set; } + public bool Visible { get; } +} diff --git a/src/ProGPU.WinUI/Core/WindowInsets.Xaml.cs b/src/ProGPU.WinUI/Core/WindowInsets.Xaml.cs new file mode 100644 index 00000000..83bafad3 --- /dev/null +++ b/src/ProGPU.WinUI/Core/WindowInsets.Xaml.cs @@ -0,0 +1,23 @@ +namespace Microsoft.UI.Xaml; + +/// +/// Host-neutral system inset state. WinUI has an InputPane occlusion API but no +/// cross-platform safe-area contract, so ProGPU adds this immutable companion. +/// +public readonly record struct WindowInsets( + Thickness SafeArea, + Windows.Foundation.Rect InputPaneOccludedRect, + Windows.Foundation.Rect VisibleBounds) +{ + public static WindowInsets Empty => default; +} + +public sealed class WindowInsetsChangedEventArgs : EventArgs +{ + internal WindowInsetsChangedEventArgs(WindowInsets insets) + { + Insets = insets; + } + + public WindowInsets Insets { get; } +} diff --git a/src/ProGPU.WinUI/Core/WindowInsets.cs b/src/ProGPU.WinUI/Core/WindowInsets.cs new file mode 100644 index 00000000..d00370ff --- /dev/null +++ b/src/ProGPU.WinUI/Core/WindowInsets.cs @@ -0,0 +1,72 @@ +using Microsoft.UI.Xaml; + +namespace Windows.UI.ViewManagement; + +/// +/// Provides the official UWP/WinUI input-pane contract for ProGPU hosts. +/// Rectangles use logical client coordinates (DIPs). +/// +public sealed class InputPaneVisibilityEventArgs : EventArgs +{ + internal InputPaneVisibilityEventArgs(Windows.Foundation.Rect occludedRect) + { + OccludedRect = occludedRect; + } + + public bool EnsuredFocusedElementInView { get; set; } + public Windows.Foundation.Rect OccludedRect { get; } +} + +public sealed class InputPane +{ + private readonly Window _window; + private Func? _tryShow; + private Func? _tryHide; + + internal InputPane(Window window) + { + _window = window; + } + + public Windows.Foundation.Rect OccludedRect { get; private set; } + public bool Visible => OccludedRect.Width > 0d && OccludedRect.Height > 0d; + + public event Windows.Foundation.TypedEventHandler? Showing; + public event Windows.Foundation.TypedEventHandler? Hiding; + + public static InputPane GetForCurrentView() + { + IReadOnlyList windows = WindowManager.ActiveWindows; + if (windows.Count == 0) + throw new InvalidOperationException("No active Window is associated with the current view."); + return windows[^1].InputPane; + } + + /// ProGPU host-neutral overload for applications that own more than one window. + public static InputPane GetForWindow(Window window) => + (window ?? throw new ArgumentNullException(nameof(window))).InputPane; + + public bool TryShow() => _tryShow?.Invoke() == true; + public bool TryHide() => _tryHide?.Invoke() == true; + + internal void SetPlatformCallbacks(Func? tryShow, Func? tryHide) + { + _tryShow = tryShow; + _tryHide = tryHide; + } + + internal void UpdateOccludedRect(Windows.Foundation.Rect value, bool ensuredFocusedElementInView = false) + { + if (OccludedRect.Equals(value)) return; + bool wasVisible = Visible; + OccludedRect = value; + bool isVisible = Visible; + var args = new InputPaneVisibilityEventArgs(value) + { + EnsuredFocusedElementInView = ensuredFocusedElementInView + }; + if (!wasVisible && isVisible) Showing?.Invoke(this, args); + else if (wasVisible && !isVisible) Hiding?.Invoke(this, args); + else if (isVisible) Showing?.Invoke(this, args); + } +} From 5d7009da61b69991577d7f99ae469bc023314bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 14:44:14 +0200 Subject: [PATCH 03/14] Support precise trackpad and native text input --- .../BrowserAssets/progpu-browser.js | 2 +- src/ProGPU.Browser/BrowserInputDispatcher.cs | 1 + src/ProGPU.Samples/Pages/DxfViewerPage.cs | 22 ++++++-- src/ProGPU.WinUI/Controls/RichEditBox.cs | 11 ++++ src/ProGPU.WinUI/Controls/ScrollViewer.cs | 30 ++++++++--- src/ProGPU.WinUI/Controls/TextBox.cs | 15 ++++++ src/ProGPU.WinUI/Core/FrameworkElement.cs | 2 + src/ProGPU.WinUI/Input/InputSystem.cs | 52 ++++++++++++++++++- src/ProGPU.WinUI/Input/PointerGestureTypes.cs | 7 +++ 9 files changed, 131 insertions(+), 11 deletions(-) diff --git a/src/ProGPU.Browser/BrowserAssets/progpu-browser.js b/src/ProGPU.Browser/BrowserAssets/progpu-browser.js index 4aa760e8..db9f8192 100644 --- a/src/ProGPU.Browser/BrowserAssets/progpu-browser.js +++ b/src/ProGPU.Browser/BrowserAssets/progpu-browser.js @@ -509,7 +509,7 @@ function installBrowserInput() { const point = pointerPosition(event); const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? state.canvas.clientHeight : 1; - queueInputEvent(4, point.x, point.y, -event.deltaX * unit / 100, -event.deltaY * unit / 100, + queueInputEvent(4, point.x, point.y, -event.deltaX * unit, -event.deltaY * unit, 1, eventModifiers(event), 0, event.timeStamp, 0, 0, 0, 0, -1); event.preventDefault(); }, { passive: false }); diff --git a/src/ProGPU.Browser/BrowserInputDispatcher.cs b/src/ProGPU.Browser/BrowserInputDispatcher.cs index fd6bd76f..6a058891 100644 --- a/src/ProGPU.Browser/BrowserInputDispatcher.cs +++ b/src/ProGPU.Browser/BrowserInputDispatcher.cs @@ -142,6 +142,7 @@ private static void Dispatch(ReadOnlySpan record) ToMicroseconds(ReadDouble(record, 32)), WheelDeltaX: ReadSingle(record, 12), WheelDeltaY: ReadSingle(record, 16), + IsPreciseWheel: true, Modifiers: ReadModifiers(ReadUInt32(record, 24)))); break; case BrowserInputKind.KeyDown: diff --git a/src/ProGPU.Samples/Pages/DxfViewerPage.cs b/src/ProGPU.Samples/Pages/DxfViewerPage.cs index e7a0f4a9..ad10f1b9 100644 --- a/src/ProGPU.Samples/Pages/DxfViewerPage.cs +++ b/src/ProGPU.Samples/Pages/DxfViewerPage.cs @@ -445,9 +445,25 @@ private void OnPointerWheelChanged(object? sender, PointerRoutedEventArgs e) { if (Document == null) return; - // Standard scroll zoom mapping (Scroll up = Zoom In, Scroll down = Zoom Out) - float factor = e.WheelDelta > 0 ? 1.15f : 0.85f; - ZoomToPoint(e.Position, factor); + if (e.IsPreciseScrolling && !e.KeyModifiers.HasFlag(VirtualKeyModifiers.Control)) + { + // UIKit scroll events report the content-following two-axis translation in + // logical points. Preserve it exactly for trackpad panning. + Context.Pan += new Vector2(e.WheelDeltaX, e.WheelDelta); + Invalidate(); + } + else if (e.IsPreciseScrolling) + { + // The iOS host maps the relative UIPinch scale to 120 * ln(scale). + // This restores the exact multiplicative scale without quantized steps. + ZoomToPoint(e.Position, MathF.Exp(e.WheelDelta / 120f)); + } + else + { + // Preserve conventional stepped mouse-wheel zoom on desktop. + float factor = e.WheelDelta > 0 ? 1.15f : 0.85f; + ZoomToPoint(e.Position, factor); + } e.Handled = true; } } diff --git a/src/ProGPU.WinUI/Controls/RichEditBox.cs b/src/ProGPU.WinUI/Controls/RichEditBox.cs index c192158c..2ac0b0ad 100644 --- a/src/ProGPU.WinUI/Controls/RichEditBox.cs +++ b/src/ProGPU.WinUI/Controls/RichEditBox.cs @@ -1081,6 +1081,17 @@ void ITextInputClient.OnTextInput(TextInputRoutedEventArgs args) case TextInputEventKind.CompositionCanceled: CancelComposition(); break; + case TextInputEventKind.ReplaceText: + SelectionStart = Math.Clamp(args.ReplacementStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.ReplacementLength, 0, Text.Length - SelectionStart); + InsertText(args.Text); + SelectionStart = Math.Clamp(args.SelectionStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.SelectionLength, 0, Text.Length - SelectionStart); + break; + case TextInputEventKind.SelectionChanged: + SelectionStart = Math.Clamp(args.SelectionStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.SelectionLength, 0, Text.Length - SelectionStart); + break; case TextInputEventKind.Paste: PasteFromClipboard(); break; diff --git a/src/ProGPU.WinUI/Controls/ScrollViewer.cs b/src/ProGPU.WinUI/Controls/ScrollViewer.cs index 443dc5c8..15da1790 100644 --- a/src/ProGPU.WinUI/Controls/ScrollViewer.cs +++ b/src/ProGPU.WinUI/Controls/ScrollViewer.cs @@ -285,18 +285,36 @@ public override void OnPointerWheelChanged(PointerRoutedEventArgs e) { if (IsEnabled) { - float maxScroll = Math.Max(0f, ContentHeight - Size.Y); - if (maxScroll > 0f) + bool changed = false; + float verticalMaximum = Math.Max(0f, ContentHeight - Size.Y); + if (VerticalScrollMode != ScrollMode.Disabled && verticalMaximum > 0f && e.WheelDelta != 0f) { - float delta = -e.WheelDelta * 30f; - float targetOffset = Math.Clamp(_verticalOffset + delta, 0f, maxScroll); + float delta = e.IsPreciseScrolling ? -e.WheelDelta : -e.WheelDelta * 30f; + float targetOffset = Math.Clamp(_verticalOffset + delta, 0f, verticalMaximum); if (targetOffset != _verticalOffset) { VerticalOffset = targetOffset; - e.Handled = true; - return; + changed = true; } } + + float horizontalMaximum = Math.Max(0f, ContentWidth - Size.X); + if (HorizontalScrollMode != ScrollMode.Disabled && horizontalMaximum > 0f && e.WheelDeltaX != 0f) + { + float delta = e.IsPreciseScrolling ? -e.WheelDeltaX : -e.WheelDeltaX * 30f; + float targetOffset = Math.Clamp(_horizontalOffset + delta, 0f, horizontalMaximum); + if (targetOffset != _horizontalOffset) + { + HorizontalOffset = targetOffset; + changed = true; + } + } + + if (changed) + { + e.Handled = true; + return; + } } base.OnPointerWheelChanged(e); } diff --git a/src/ProGPU.WinUI/Controls/TextBox.cs b/src/ProGPU.WinUI/Controls/TextBox.cs index a8a548b7..9b1b5755 100644 --- a/src/ProGPU.WinUI/Controls/TextBox.cs +++ b/src/ProGPU.WinUI/Controls/TextBox.cs @@ -389,6 +389,21 @@ void ITextInputClient.OnTextInput(TextInputRoutedEventArgs args) _compositionOriginalText = string.Empty; } break; + case TextInputEventKind.ReplaceText: + SaveUndoState(); + SelectionStart = Math.Clamp(args.ReplacementStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.ReplacementLength, 0, Text.Length - SelectionStart); + CaretIndex = SelectionStart + SelectionLength; + InsertText(args.Text); + SelectionStart = Math.Clamp(args.SelectionStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.SelectionLength, 0, Text.Length - SelectionStart); + CaretIndex = SelectionStart + SelectionLength; + break; + case TextInputEventKind.SelectionChanged: + SelectionStart = Math.Clamp(args.SelectionStart, 0, Text.Length); + SelectionLength = Math.Clamp(args.SelectionLength, 0, Text.Length - SelectionStart); + CaretIndex = SelectionStart + SelectionLength; + break; case TextInputEventKind.Paste: string pasteText = ClipboardHelper.GetText(); if (!string.IsNullOrEmpty(pasteText)) diff --git a/src/ProGPU.WinUI/Core/FrameworkElement.cs b/src/ProGPU.WinUI/Core/FrameworkElement.cs index a410a2ce..b89b3374 100644 --- a/src/ProGPU.WinUI/Core/FrameworkElement.cs +++ b/src/ProGPU.WinUI/Core/FrameworkElement.cs @@ -40,6 +40,8 @@ public class PointerRoutedEventArgs : RoutedEventArgs public bool IsMiddleButtonPressed { get; set; } public bool IsRightButtonPressed { get; set; } public float WheelDelta { get; set; } + public float WheelDeltaX { get; set; } + public bool IsPreciseScrolling { get; set; } public Pointer Pointer { get; set; } = new(1, PointerDeviceType.Mouse, false); public ulong Timestamp { get; set; } public bool IsPrimary { get; set; } = true; diff --git a/src/ProGPU.WinUI/Input/InputSystem.cs b/src/ProGPU.WinUI/Input/InputSystem.cs index 0641fd1f..c2573304 100644 --- a/src/ProGPU.WinUI/Input/InputSystem.cs +++ b/src/ProGPU.WinUI/Input/InputSystem.cs @@ -34,6 +34,7 @@ public class WindowInputState public bool IsMiddleButtonPressed; public bool IsRightButtonPressed; public Action? CursorChanged; + internal FrameworkElement? ComposingElement; internal Dictionary PointerContacts { get; } = new(); internal Dictionary CapturedElements { get; } = new(); internal Dictionary Manipulations { get; } = new(); @@ -110,12 +111,51 @@ public static void InjectMouseUp(MouseButton button) public static void InjectTextInput(TextInputEventKind kind, string? text = null, bool isComposing = false) { if (_focusedElement == null) return; - _focusedElement.OnTextInput(new TextInputRoutedEventArgs + FrameworkElement target = _focusedElement; + if (kind == TextInputEventKind.CompositionStarted) + { + Current.ComposingElement = target; + } + target.OnTextInput(new TextInputRoutedEventArgs { Kind = kind, Text = text ?? string.Empty, IsComposing = isComposing }); + if (kind is TextInputEventKind.CompositionCompleted or TextInputEventKind.CompositionCanceled) + { + Current.ComposingElement = null; + } + } + + public static void InjectTextReplacement( + string text, + int replacementStart, + int replacementLength, + int selectionStart, + int selectionLength) + { + if (_focusedElement == null) return; + _focusedElement.OnTextInput(new TextInputRoutedEventArgs + { + Kind = TextInputEventKind.ReplaceText, + Text = text ?? string.Empty, + ReplacementStart = replacementStart, + ReplacementLength = replacementLength, + SelectionStart = selectionStart, + SelectionLength = selectionLength + }); + } + + public static void InjectTextSelection(int selectionStart, int selectionLength) + { + if (_focusedElement == null) return; + _focusedElement.OnTextInput(new TextInputRoutedEventArgs + { + Kind = TextInputEventKind.SelectionChanged, + SelectionStart = selectionStart, + SelectionLength = selectionLength + }); } public static Action? DispatcherQueue { get; set; } @@ -667,6 +707,8 @@ private static PointerRoutedEventArgs CreatePointerArgs( Pressure = input.Pressure, ContactRect = input.ContactRect, WheelDelta = input.WheelDeltaY, + WheelDeltaX = input.WheelDeltaX, + IsPreciseScrolling = input.IsPreciseWheel, KeyModifiers = input.Modifiers, IsCanceled = canceled }; @@ -1032,6 +1074,14 @@ public static void SetFocus(FrameworkElement? element) if (_focusedElement == element) return; var oldFocus = _focusedElement; + if (oldFocus != null && ReferenceEquals(Current.ComposingElement, oldFocus)) + { + oldFocus.OnTextInput(new TextInputRoutedEventArgs + { + Kind = TextInputEventKind.CompositionCanceled + }); + Current.ComposingElement = null; + } _focusedElement = element; if (oldFocus is Control oldControl) diff --git a/src/ProGPU.WinUI/Input/PointerGestureTypes.cs b/src/ProGPU.WinUI/Input/PointerGestureTypes.cs index 3035a2c6..5d9a7faa 100644 --- a/src/ProGPU.WinUI/Input/PointerGestureTypes.cs +++ b/src/ProGPU.WinUI/Input/PointerGestureTypes.cs @@ -230,6 +230,8 @@ public enum TextInputEventKind CompositionUpdated, CompositionCompleted, CompositionCanceled, + ReplaceText, + SelectionChanged, Paste } @@ -238,6 +240,10 @@ public sealed class TextInputRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs public TextInputEventKind Kind { get; internal init; } public string Text { get; internal init; } = string.Empty; public bool IsComposing { get; internal init; } + public int ReplacementStart { get; internal init; } = -1; + public int ReplacementLength { get; internal init; } + public int SelectionStart { get; internal init; } = -1; + public int SelectionLength { get; internal init; } } public readonly record struct TextInputOptions( @@ -282,5 +288,6 @@ public readonly record struct PointerInputEvent( Rect ContactRect = default, float WheelDeltaX = 0f, float WheelDeltaY = 0f, + bool IsPreciseWheel = false, VirtualKeyModifiers Modifiers = VirtualKeyModifiers.None); } From 06f644029a37542e5f760e22291c28fb70ff5978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 14:44:23 +0200 Subject: [PATCH 04/14] Add variable-height virtualization and wrapped grid cells --- .../Pages/DataVirtualizationPage.cs | 5 +- .../DataGridValueProviderTests.cs | 27 +++ src/ProGPU.Tests/BidiAndFlowDirectionTests.cs | 12 + .../TouchGestureResponsiveTests.cs | 135 +++++++++++ src/ProGPU.Tests/VariableSizeIndexTests.cs | 44 ++++ .../VariableSizeIndex.cs | 190 ++++++++++++++++ src/ProGPU.WinUI/Controls/DataGrid.cs | 212 ++++++++++++++++-- .../Controls/VirtualizingPanel.cs | 5 +- .../Controls/VirtualizingStackPanel.cs | 148 ++++++++++-- 9 files changed, 738 insertions(+), 40 deletions(-) create mode 100644 src/ProGPU.Tests/VariableSizeIndexTests.cs create mode 100644 src/ProGPU.Virtualization/VariableSizeIndex.cs diff --git a/src/ProGPU.Samples/Pages/DataVirtualizationPage.cs b/src/ProGPU.Samples/Pages/DataVirtualizationPage.cs index 7992db88..ea2642f4 100644 --- a/src/ProGPU.Samples/Pages/DataVirtualizationPage.cs +++ b/src/ProGPU.Samples/Pages/DataVirtualizationPage.cs @@ -81,7 +81,10 @@ public static FrameworkElement Create() var dataGrid = new Microsoft.UI.Xaml.Controls.DataGrid { Font = AppState._font, - RowHeight = 28f, + RowHeight = float.NaN, + MinRowHeight = 28f, + EstimatedRowHeight = 48f, + CellTextWrapping = TextWrapping.WrapWholeWords, HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Stretch, Margin = new Thickness(4) diff --git a/src/ProGPU.Tests.Headless/DataGridValueProviderTests.cs b/src/ProGPU.Tests.Headless/DataGridValueProviderTests.cs index 64d94740..798b5589 100644 --- a/src/ProGPU.Tests.Headless/DataGridValueProviderTests.cs +++ b/src/ProGPU.Tests.Headless/DataGridValueProviderTests.cs @@ -1,11 +1,38 @@ using System.IO; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using ProGPU.Fonts.Inter; using Xunit; namespace ProGPU.Tests.Headless; +[Collection("HeadlessTests")] public class DataGridValueProviderTests { + [Fact] + public void DataGrid_WrappedCellMeasuresVariableRowHeight() + { + InterFontFamily.RegisterFonts(); + var dataGrid = new DataGrid + { + Width = 220f, + Height = 140f, + Font = InterFontFamily.Regular, + RowHeight = float.NaN, + MinRowHeight = 28f, + EstimatedRowHeight = 28f, + CellTextWrapping = TextWrapping.WrapWholeWords + }; + dataGrid.Columns.Add(new DataGridColumn("Name", 120f, "Name")); + dataGrid.AddItem(new ProviderRow("A long activity name that must wrap across several visual lines")); + + using var window = new HeadlessWindow(220, 140) { Content = dataGrid }; + window.Render(); + window.Render(); + + Assert.True(dataGrid.TotalBodyHeight > dataGrid.MinRowHeight); + } + [Fact] public void DataGrid_UsesValueProviderForSortingWithoutPocoProperty() { diff --git a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs index 4709973b..0f54db90 100644 --- a/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs +++ b/src/ProGPU.Tests/BidiAndFlowDirectionTests.cs @@ -1369,6 +1369,18 @@ public void RichDocumentFormatsRoundTripThroughTypedRegistry() Assert.Equal("Hello world link", PlainTextDocumentExporter.Default.Export(htmlImported)); } + [Fact] + public void PortableRichDocumentRegistryOmitsOpenXmlButKeepsEditableFormats() + { + RichDocumentFormatRegistry registry = RichDocumentFormatRegistry.CreatePortableDefault(); + + Assert.False(registry.TryGetFileExtension(".docx", out _)); + Assert.True(registry.TryGetFileExtension(".txt", out _)); + Assert.True(registry.TryGetFileExtension(".md", out _)); + Assert.True(registry.TryGetFileExtension(".rtf", out _)); + Assert.True(registry.TryGetFileExtension(".html", out _)); + } + [Fact] public void HtmlDocumentCodecPreservesSemanticBlocksDirectionAndCssFormatting() { diff --git a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs index 8b60bfa0..156c2765 100644 --- a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs +++ b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs @@ -98,6 +98,13 @@ public void MobileTextOperationsDoNotDependOnPhysicalKeyEvents() InputSystem.InjectTextInput(TextInputEventKind.CompositionUpdated, "に", true); InputSystem.InjectTextInput(TextInputEventKind.CompositionCanceled); Assert.Equal("A日本", textBox.Text); + + textBox.SelectionStart = 1; + textBox.SelectionLength = 2; + InputSystem.InjectTextInput(TextInputEventKind.CompositionStarted, isComposing: true); + InputSystem.InjectTextInput(TextInputEventKind.CompositionUpdated, "語", true); + InputSystem.SetFocus(new Button()); + Assert.Equal("A日本", textBox.Text); } [Fact] @@ -153,6 +160,73 @@ public void ScrollViewerConsumesTouchManipulationAndSupportsChangeView() Assert.Equal(200f, viewer.VerticalOffset); } + [Fact] + public void PreciseTrackpadWheelPreservesPixelDeltasOnBothAxes() + { + var content = new Border { Width = 900, Height = 900 }; + var viewer = new ScrollViewer { Content = content, Width = 220, Height = 180 }; + ArrangeRoot(viewer, new Vector2(220, 180)); + + var wheel = new PointerRoutedEventArgs + { + WheelDelta = -17f, + WheelDeltaX = -13f, + IsPreciseScrolling = true + }; + viewer.OnPointerWheelChanged(wheel); + + Assert.True(wheel.Handled); + Assert.Equal(17f, viewer.VerticalOffset); + Assert.Equal(13f, viewer.HorizontalOffset); + } + + [Fact] + public void PreciseTrackpadWheelScrollsVirtualizedDataByLogicalPixels() + { + var dataGrid = new DataGrid { Width = 320, Height = 180 }; + dataGrid.Columns.Add(new DataGridColumn("Value", 280f, "Value")); + for (int index = 0; index < 100; index++) dataGrid.ItemsSource.Add($"Row {index}"); + ArrangeRoot(dataGrid, new Vector2(320, 180)); + + var wheel = new PointerRoutedEventArgs + { + WheelDelta = -11.5f, + IsPreciseScrolling = true + }; + dataGrid.OnPointerWheelChanged(wheel); + + Assert.True(wheel.Handled); + Assert.Equal(11.5f, dataGrid.ScrollOffset); + } + + [Fact] + public void VariablePanelFillsViewportAndPreservesTailMeasurements() + { + var panel = new VirtualizingStackPanel + { + Width = 200, + Height = 100, + ItemsCount = 100, + ItemHeight = float.NaN, + EstimatedItemHeight = 100f, + CacheLength = 0f, + CreateVisualFactory = static () => new Border { Height = 10f }, + BindVisualCallback = static (_, _) => { } + }; + ArrangeRoot(panel, new Vector2(200, 100)); + + var activeField = typeof(VirtualizingStackPanel).GetField( + "_activeVisuals", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(activeField); + var active = Assert.IsAssignableFrom(activeField.GetValue(panel)); + Assert.True(active.Count >= 10); + + float measuredExtent = panel.TotalVirtualHeight; + panel.ItemsCount = 101; + Assert.Equal(measuredExtent + 100f, panel.TotalVirtualHeight); + } + [Fact] public void TouchCanDragAndPageVerticalAndHorizontalScrollBars() { @@ -539,6 +613,67 @@ public void TouchDragDropTracksAndCompletesTheOwningPointer() Assert.Equal(0u, DragDropManager.ActivePointerId); } + [Fact] + public void IndirectMouseDragDropTracksAndCompletesItsStablePointer() + { + const uint indirectPointerId = uint.MaxValue; + var source = new TouchDragSource + { + Width = 100, + Height = 80, + Background = new ProGPU.Vector.ThemeResourceBrush("ControlBackground") + }; + var target = new Border + { + Width = 100, + Height = 80, + AllowDrop = true, + Background = new ProGPU.Vector.ThemeResourceBrush("ControlBackground") + }; + var root = new StackPanel { Orientation = Orientation.Horizontal }; + root.Children.Add(source); + root.Children.Add(target); + ArrangeRoot(root, new Vector2(200, 80)); + UseInputRoot(root); + var drops = 0; + target.Drop += (_, _) => drops++; + + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Moved, + indirectPointerId, + PointerDeviceType.Mouse, + new Vector2(20, 30), + 500)); + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Pressed, + indirectPointerId, + PointerDeviceType.Mouse, + new Vector2(20, 30), + 1_000, + IsInContact: true, + IsLeftButtonPressed: true)); + Assert.True(DragDropManager.IsDragging); + Assert.Equal(indirectPointerId, DragDropManager.ActivePointerId); + + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Moved, + indirectPointerId, + PointerDeviceType.Mouse, + new Vector2(150, 30), + 20_000, + IsInContact: true, + IsLeftButtonPressed: true)); + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Released, + indirectPointerId, + PointerDeviceType.Mouse, + new Vector2(150, 30), + 30_000)); + + Assert.Equal(1, drops); + Assert.False(DragDropManager.IsDragging); + } + [Fact] public void VisualStatesRestoreSettersAndNavigationViewUsesWinUiBreakpoints() { diff --git a/src/ProGPU.Tests/VariableSizeIndexTests.cs b/src/ProGPU.Tests/VariableSizeIndexTests.cs new file mode 100644 index 00000000..08db3739 --- /dev/null +++ b/src/ProGPU.Tests/VariableSizeIndexTests.cs @@ -0,0 +1,44 @@ +using ProGPU.Virtualization; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class VariableSizeIndexTests +{ + [Fact] + public void PrefixLookupAndMeasurementUpdatesRemainLogarithmicIndexSemantics() + { + var index = new VariableSizeIndex(5, 10f); + + index.SetMeasuredSize(1, 25f); + index.SetMeasuredSize(3, 5f); + + Assert.Equal(0f, index.GetOffset(0)); + Assert.Equal(10f, index.GetOffset(1)); + Assert.Equal(35f, index.GetOffset(2)); + Assert.Equal(45f, index.GetOffset(3)); + Assert.Equal(50f, index.GetOffset(4)); + Assert.Equal(60f, index.TotalSize); + Assert.Equal(0, index.GetIndexAtOffset(0f)); + Assert.Equal(1, index.GetIndexAtOffset(10f)); + Assert.Equal(1, index.GetIndexAtOffset(34.99f)); + Assert.Equal(2, index.GetIndexAtOffset(35f)); + Assert.Equal(4, index.GetIndexAtOffset(100f)); + } + + [Fact] + public void InsertRemoveAndAnchorPreserveMeasuredGeometry() + { + var index = new VariableSizeIndex(4, 20f); + index.SetMeasuredSize(1, 40f); + VirtualizationAnchor anchor = index.CaptureAnchor(65f); + + index.InsertRange(1, 2); + Assert.Equal(6, index.Count); + Assert.Equal(40f, index.GetSize(3)); + + index.RemoveRange(1, 2); + Assert.Equal(4, index.Count); + Assert.Equal(65f, index.RestoreAnchor(anchor)); + } +} diff --git a/src/ProGPU.Virtualization/VariableSizeIndex.cs b/src/ProGPU.Virtualization/VariableSizeIndex.cs new file mode 100644 index 00000000..9e195583 --- /dev/null +++ b/src/ProGPU.Virtualization/VariableSizeIndex.cs @@ -0,0 +1,190 @@ +namespace ProGPU.Virtualization; + +/// +/// Prefix-sum index for variable-size virtualized items. A Fenwick tree keeps +/// offset/index lookup and individual measurement updates logarithmic while +/// retaining only bounded O(N) numeric state and no element references. +/// +public sealed class VariableSizeIndex +{ + private float[] _sizes = []; + private float[] _tree = [0f]; + private bool[] _measured = []; + + public VariableSizeIndex(int count = 0, float estimatedSize = 40f) + { + Reset(count, estimatedSize); + } + + public int Count => _sizes.Length; + public float EstimatedSize { get; private set; } + public float TotalSize => PrefixSum(Count); + + /// Reinitializes N items in O(N) time and O(N) storage. + public void Reset(int count, float estimatedSize) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (!float.IsFinite(estimatedSize) || estimatedSize <= 0f) + throw new ArgumentOutOfRangeException(nameof(estimatedSize)); + + EstimatedSize = estimatedSize; + _sizes = new float[count]; + _measured = new bool[count]; + Array.Fill(_sizes, estimatedSize); + RebuildTree(); + } + + /// Changes one measured size in O(log N), returning its signed delta. + public float SetMeasuredSize(int index, float size) + { + ValidateIndex(index); + if (!float.IsFinite(size) || size <= 0f) + throw new ArgumentOutOfRangeException(nameof(size)); + float delta = size - _sizes[index]; + _measured[index] = true; + if (Math.Abs(delta) <= 0.01f) return 0f; + _sizes[index] = size; + Add(index + 1, delta); + return delta; + } + + public bool IsMeasured(int index) + { + ValidateIndex(index); + return _measured[index]; + } + + public float GetSize(int index) + { + ValidateIndex(index); + return _sizes[index]; + } + + /// Returns the leading offset of an item in O(log N). + public float GetOffset(int index) + { + if (index < 0 || index > Count) throw new ArgumentOutOfRangeException(nameof(index)); + return PrefixSum(index); + } + + /// Returns the item containing an offset in O(log N). + public int GetIndexAtOffset(float offset) + { + if (Count == 0) return -1; + if (!float.IsFinite(offset) || offset <= 0f) return 0; + if (offset >= TotalSize) return Count - 1; + + int index = 0; + float sum = 0f; + int bit = HighestPowerOfTwoAtMost(Count); + while (bit != 0) + { + int next = index + bit; + if (next <= Count && sum + _tree[next] <= offset) + { + index = next; + sum += _tree[next]; + } + bit >>= 1; + } + return Math.Min(index, Count - 1); + } + + public VirtualizationAnchor CaptureAnchor(float scrollOffset) + { + int index = GetIndexAtOffset(scrollOffset); + return index < 0 + ? default + : new VirtualizationAnchor(index, Math.Max(0f, scrollOffset - GetOffset(index))); + } + + public float RestoreAnchor(VirtualizationAnchor anchor) + { + if (Count == 0) return 0f; + int index = Math.Clamp(anchor.Index, 0, Count - 1); + return GetOffset(index) + Math.Clamp(anchor.OffsetWithinItem, 0f, GetSize(index)); + } + + /// Inserts estimated items in O(N) time, preserving known measurements. + public void InsertRange(int index, int count) + { + if (index < 0 || index > Count) throw new ArgumentOutOfRangeException(nameof(index)); + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (count == 0) return; + var sizes = new float[Count + count]; + var measured = new bool[sizes.Length]; + Array.Copy(_sizes, 0, sizes, 0, index); + Array.Fill(sizes, EstimatedSize, index, count); + Array.Copy(_sizes, index, sizes, index + count, Count - index); + Array.Copy(_measured, 0, measured, 0, index); + Array.Copy(_measured, index, measured, index + count, Count - index); + _sizes = sizes; + _measured = measured; + RebuildTree(); + } + + /// Removes items in O(N) time, preserving remaining measurements. + public void RemoveRange(int index, int count) + { + if (index < 0 || count < 0 || index + count > Count) + throw new ArgumentOutOfRangeException(nameof(index)); + if (count == 0) return; + var sizes = new float[Count - count]; + var measured = new bool[sizes.Length]; + Array.Copy(_sizes, 0, sizes, 0, index); + Array.Copy(_sizes, index + count, sizes, index, Count - index - count); + Array.Copy(_measured, 0, measured, 0, index); + Array.Copy(_measured, index + count, measured, index, Count - index - count); + _sizes = sizes; + _measured = measured; + RebuildTree(); + } + + public void InvalidateMeasurement(int index) + { + ValidateIndex(index); + float delta = EstimatedSize - _sizes[index]; + _sizes[index] = EstimatedSize; + _measured[index] = false; + if (Math.Abs(delta) > 0.01f) Add(index + 1, delta); + } + + public void InvalidateAllMeasurements() => Reset(Count, EstimatedSize); + + private float PrefixSum(int count) + { + float sum = 0f; + for (int i = count; i > 0; i -= i & -i) sum += _tree[i]; + return sum; + } + + private void Add(int treeIndex, float delta) + { + for (int i = treeIndex; i <= Count; i += i & -i) _tree[i] += delta; + } + + private void RebuildTree() + { + _tree = new float[Count + 1]; + for (int i = 1; i <= Count; i++) + { + _tree[i] += _sizes[i - 1]; + int parent = i + (i & -i); + if (parent <= Count) _tree[parent] += _tree[i]; + } + } + + private void ValidateIndex(int index) + { + if ((uint)index >= (uint)Count) throw new ArgumentOutOfRangeException(nameof(index)); + } + + private static int HighestPowerOfTwoAtMost(int value) + { + int result = 1; + while (result <= value >> 1) result <<= 1; + return result; + } +} + +public readonly record struct VirtualizationAnchor(int Index, float OffsetWithinItem); diff --git a/src/ProGPU.WinUI/Controls/DataGrid.cs b/src/ProGPU.WinUI/Controls/DataGrid.cs index 5818fe29..db6559d1 100644 --- a/src/ProGPU.WinUI/Controls/DataGrid.cs +++ b/src/ProGPU.WinUI/Controls/DataGrid.cs @@ -17,6 +17,7 @@ using System.Collections.Concurrent; using System.Globalization; using Windows.Devices.Input; +using ProGPU.Virtualization; namespace Microsoft.UI.Xaml.Controls; @@ -27,6 +28,7 @@ public class DataGridColumn public float ActualWidth { get; internal set; } public string PropertyName { get; set; } = string.Empty; public bool IsAscending { get; set; } = true; + public TextWrapping? TextWrapping { get; set; } public DataGridColumn(string header, DataGridLength width, string propName) { @@ -78,6 +80,13 @@ public class DataGrid : Control private int _pendingTouchColumn = -1; private int _pendingTouchSortColumn = -1; private float _touchInertiaVelocity; + private readonly VariableSizeIndex _rowSizes = new(); + private int _indexedRowCount = -1; + private int _rowLayoutSignature; + private int _indexedRowLayoutSignature = -1; + private float _minRowHeight = 28f; + private float _estimatedRowHeight = 40f; + private TextWrapping _cellTextWrapping = TextWrapping.NoWrap; public List Columns { get; } = new(); @@ -88,6 +97,7 @@ protected override void OnPropertyChanged(Microsoft.UI.Xaml.DependencyProperty d base.OnPropertyChanged(dp, oldValue, newValue); if (dp == FontProperty) { + InvalidateRowMeasurements(); Invalidate(); } } @@ -95,13 +105,58 @@ protected override void OnPropertyChanged(Microsoft.UI.Xaml.DependencyProperty d public float FontSize { get => _fontSize; - set { _fontSize = value; Invalidate(); } + set { _fontSize = value; InvalidateRowMeasurements(); Invalidate(); } } public float RowHeight { get => _rowHeight; - set { _rowHeight = value; Invalidate(); } + set + { + if (!float.IsNaN(value) && (!float.IsFinite(value) || value <= 0f)) + throw new ArgumentOutOfRangeException(nameof(value)); + _rowHeight = value; + InvalidateRowMeasurements(); + Invalidate(); + } + } + + public float MinRowHeight + { + get => _minRowHeight; + set + { + if (!float.IsFinite(value) || value <= 0f) throw new ArgumentOutOfRangeException(nameof(value)); + _minRowHeight = value; + InvalidateRowMeasurements(); + Invalidate(); + } + } + + /// Estimated auto-row height used until a row enters the realization window. + public float EstimatedRowHeight + { + get => _estimatedRowHeight; + set + { + if (!float.IsFinite(value) || value <= 0f) throw new ArgumentOutOfRangeException(nameof(value)); + if (_estimatedRowHeight == value) return; + _estimatedRowHeight = value; + InvalidateRowMeasurements(); + Invalidate(); + } + } + + public TextWrapping CellTextWrapping + { + get => _cellTextWrapping; + set + { + if (_cellTextWrapping == value) return; + _cellTextWrapping = value; + InvalidateRowMeasurements(); + Invalidate(); + } } public int SelectedIndex @@ -144,7 +199,9 @@ public float ScrollOffset } } - public float TotalBodyHeight => _itemsSource.Count * _rowHeight; + public float TotalBodyHeight => IsVariableRowHeight + ? EnsureRowSizeIndex().TotalSize + : _itemsSource.Count * _rowHeight; public float ViewportHeight => Size.Y - _headerHeight; public int EditingRow => _editingRow; public int EditingCol => _editingCol; @@ -240,6 +297,7 @@ private static TValue CastRegisteredValue(object? value) public void AddItem(object item) { _itemsSource.Add(item); + InvalidateRowMeasurements(); Invalidate(); } @@ -249,6 +307,7 @@ public void ClearItems() _itemsSource.Clear(); _selectedIndex = -1; _scrollOffset = 0f; + InvalidateRowMeasurements(); Invalidate(); } @@ -499,6 +558,7 @@ public void SortItems(DataGridColumn column) }); SortingColumn = column; + InvalidateRowMeasurements(); Invalidate(); } @@ -509,7 +569,8 @@ public override void OnPointerWheelChanged(PointerRoutedEventArgs e) float maxScroll = Math.Max(0f, TotalBodyHeight - ViewportHeight); if (maxScroll > 0f) { - float delta = -e.WheelDelta * _rowHeight; + float line = IsVariableRowHeight ? MinRowHeight : _rowHeight; + float delta = e.IsPreciseScrolling ? -e.WheelDelta : -e.WheelDelta * line; float targetOffset = Math.Clamp(_scrollOffset + delta, 0f, maxScroll); if (targetOffset != _scrollOffset) { @@ -598,7 +659,7 @@ public override void OnPointerPressed(PointerRoutedEventArgs e) // Click in Body rows else { - int r = (int)((e.Position.Y - _headerHeight + ScrollOffset) / _rowHeight); + int r = GetRowIndexAt(e.Position.Y); if (r >= 0 && r < _itemsSource.Count) { SelectedIndex = r; @@ -834,7 +895,7 @@ public override void OnPointerMoved(PointerRoutedEventArgs e) if (position.Y > _headerHeight && !ScrollBarInteraction.IsVerticalTrackHit(position.X, Size.X, e.Pointer.PointerDeviceType)) { - int r = (int)((position.Y - _headerHeight + ScrollOffset) / _rowHeight); + int r = GetRowIndexAt(position.Y); if (r >= 0 && r < _itemsSource.Count) { if (_hoveredRowIndex != r) @@ -879,7 +940,10 @@ public override void OnPointerMoved(PointerRoutedEventArgs e) private int GetRowIndexAt(float y) { if (y <= _headerHeight) return -1; - var row = (int)((y - _headerHeight + ScrollOffset) / _rowHeight); + float offset = y - _headerHeight + ScrollOffset; + int row = IsVariableRowHeight + ? EnsureRowSizeIndex().GetIndexAtOffset(offset) + : (int)(offset / _rowHeight); return row >= 0 && row < _itemsSource.Count ? row : -1; } @@ -1047,6 +1111,7 @@ protected override void ArrangeOverride(Rect arrangeRect) { Size = new Vector2(arrangeRect.Width, arrangeRect.Height); ResolveColumnWidths(arrangeRect.Width); + UpdateRowLayoutSignature(); UpdateCellEditorLayout(); } @@ -1125,8 +1190,11 @@ public override void OnRender(DrawingContext context) // 3. Draw Body Row Cells (Virtualized recycling viewport loop) if (_itemsSource.Count > 0) { - int startRow = (int)Math.Floor(ScrollOffset / _rowHeight); - int endRow = (int)Math.Ceiling((ScrollOffset + ViewportHeight) / _rowHeight); + VariableSizeIndex? rowSizes = IsVariableRowHeight ? EnsureRowSizeIndex() : null; + int startRow = rowSizes?.GetIndexAtOffset(ScrollOffset) ?? + (int)Math.Floor(ScrollOffset / _rowHeight); + int endRow = rowSizes?.GetIndexAtOffset(ScrollOffset + ViewportHeight) ?? + (int)Math.Ceiling((ScrollOffset + ViewportHeight) / _rowHeight); startRow = Math.Clamp(startRow, 0, _itemsSource.Count - 1); endRow = Math.Clamp(endRow, 0, _itemsSource.Count - 1); @@ -1135,7 +1203,8 @@ public override void OnRender(DrawingContext context) for (int r = startRow; r <= endRow; r++) { - float rowY = _headerHeight + r * _rowHeight - ScrollOffset; + float currentRowHeight = ResolveRowHeight(r, activeFont); + float rowY = _headerHeight + (rowSizes?.GetOffset(r) ?? r * _rowHeight) - ScrollOffset; var item = _itemsSource[r]; // Alternate, Hover & Selection backgrounds @@ -1153,7 +1222,7 @@ public override void OnRender(DrawingContext context) rowBg = ThemeManager.GetBrush("ControlBackground"); // Subtle alternate rows } - Rect rowRect = new Rect(0, rowY, Size.X, _rowHeight); + Rect rowRect = new Rect(0, rowY, Size.X, currentRowHeight); if (rowBg != null) { context.DrawRectangle(rowBg, null, rowRect); @@ -1162,7 +1231,7 @@ public override void OnRender(DrawingContext context) // Draw active selection vertical indicator stripe on far-left if (r == SelectedIndex) { - Rect selectionStripe = LogicalToPhysical(new Rect(0f, rowY + 2f, 3f, _rowHeight - 4f)); + Rect selectionStripe = LogicalToPhysical(new Rect(0f, rowY + 2f, 3f, currentRowHeight - 4f)); context.DrawRectangle(ThemeManager.GetBrush("SystemAccentColor"), null, selectionStripe); } @@ -1180,12 +1249,16 @@ public override void OnRender(DrawingContext context) else { string val = GetCellValue(item, col.PropertyName); - float cellTextY = rowY + (_rowHeight - FontSize) / 2f; + TextWrapping wrapping = col.TextWrapping ?? CellTextWrapping; + float cellTextY = wrapping == TextWrapping.NoWrap + ? rowY + Math.Max(4f, (currentRowHeight - FontSize) * 0.5f) + : rowY + 4f; Rect cellTextBounds = LogicalToPhysical(new Rect( colX + 8f, cellTextY, - Math.Max(0f, colWidth - 16f), - FontSize)); + wrapping == TextWrapping.NoWrap ? 10000f : Math.Max(0f, colWidth - 16f), + Math.Max(FontSize, currentRowHeight - 8f))); + context.PushClip(LogicalToPhysical(new Rect(colX, rowY, colWidth, currentRowHeight))); context.DrawText( val, activeFont, @@ -1195,15 +1268,22 @@ public override void OnRender(DrawingContext context) Matrix4x4.Identity, cellTextBounds, textShapingOptions: GetTextShapingOptions(), - textAlignment: FlowDirection == FlowDirection.RightToLeft + textAlignment: wrapping != TextWrapping.NoWrap && FlowDirection == FlowDirection.RightToLeft ? ProGPU.Text.TextAlignment.Right : ProGPU.Text.TextAlignment.Left); + context.PopClip(); } colX += colWidth; } // Draw thin grid lines - context.DrawRectangle(null, new Pen(ThemeManager.GetBrush("ControlBorder"), 0.5f), new Rect(0, rowY, Size.X, _rowHeight)); + context.DrawRectangle(null, new Pen(ThemeManager.GetBrush("ControlBorder"), 0.5f), new Rect(0, rowY, Size.X, currentRowHeight)); + + if (rowSizes != null && r == endRow && r + 1 < _itemsSource.Count && + rowY + currentRowHeight < _headerHeight + ViewportHeight) + { + endRow++; + } } context.PopClip(); @@ -1433,6 +1513,13 @@ public void CommitEdit() Console.WriteLine($"Error committing edit: {ex.Message}"); } + if (IsVariableRowHeight && + _indexedRowCount == _itemsSource.Count && + _indexedRowLayoutSignature == _rowLayoutSignature) + { + _rowSizes.InvalidateMeasurement(row); + } + Invalidate(); } } @@ -1468,7 +1555,12 @@ private void UpdateCellEditorLayout() if (_editingRow != -1) { - float rowY = _headerHeight + _editingRow * _rowHeight - _scrollOffset; + float editorRowHeight = IsVariableRowHeight + ? ResolveRowHeight(_editingRow, GetActiveFont()!) + : _rowHeight; + float rowY = _headerHeight + + (IsVariableRowHeight ? EnsureRowSizeIndex().GetOffset(_editingRow) : _editingRow * _rowHeight) - + _scrollOffset; float colX = Padding.Left; for (int i = 0; i < _editingCol; i++) { @@ -1476,7 +1568,7 @@ private void UpdateCellEditorLayout() } float colWidth = Columns[_editingCol].ActualWidth; - if (rowY + _rowHeight <= _headerHeight || rowY >= Size.Y) + if (rowY + editorRowHeight <= _headerHeight || rowY >= Size.Y) { _cellEditor.WidthConstraint = 0f; _cellEditor.HeightConstraint = 0f; @@ -1487,16 +1579,16 @@ private void UpdateCellEditorLayout() else { _cellEditor.WidthConstraint = colWidth; - _cellEditor.HeightConstraint = _rowHeight; - _cellEditor.Measure(new Vector2(colWidth, _rowHeight)); - _cellEditor.Arrange(new Rect(colX, rowY, colWidth, _rowHeight)); + _cellEditor.HeightConstraint = editorRowHeight; + _cellEditor.Measure(new Vector2(colWidth, editorRowHeight)); + _cellEditor.Arrange(new Rect(colX, rowY, colWidth, editorRowHeight)); if (rowY < _headerHeight) { float clipY = _headerHeight - rowY; - _cellEditor.ClipBounds = new Rect(0f, clipY, colWidth, _rowHeight - clipY); + _cellEditor.ClipBounds = new Rect(0f, clipY, colWidth, editorRowHeight - clipY); } - else if (rowY + _rowHeight > Size.Y) + else if (rowY + editorRowHeight > Size.Y) { float clipH = Size.Y - rowY; _cellEditor.ClipBounds = new Rect(0f, 0f, colWidth, clipH); @@ -1517,6 +1609,78 @@ private void UpdateCellEditorLayout() } } + private bool IsVariableRowHeight => float.IsNaN(_rowHeight); + + private void UpdateRowLayoutSignature() + { + var hash = new HashCode(); + hash.Add(FontSize); + hash.Add(MinRowHeight); + hash.Add(CellTextWrapping); + hash.Add(GetActiveFont()); + hash.Add(Columns.Count); + foreach (DataGridColumn column in Columns) + { + hash.Add(column.ActualWidth); + hash.Add(column.TextWrapping); + } + + int signature = hash.ToHashCode(); + if (_rowLayoutSignature == signature) return; + _rowLayoutSignature = signature; + InvalidateRowMeasurements(); + } + + private void InvalidateRowMeasurements() + { + _indexedRowCount = -1; + _indexedRowLayoutSignature = -1; + } + + private VariableSizeIndex EnsureRowSizeIndex() + { + if (_indexedRowCount != _itemsSource.Count || + _indexedRowLayoutSignature != _rowLayoutSignature) + { + _rowSizes.Reset(_itemsSource.Count, Math.Max(MinRowHeight, EstimatedRowHeight)); + _indexedRowCount = _itemsSource.Count; + _indexedRowLayoutSignature = _rowLayoutSignature; + } + return _rowSizes; + } + + private float ResolveRowHeight(int row, TtfFont activeFont) + { + if (!IsVariableRowHeight) return _rowHeight; + VariableSizeIndex sizes = EnsureRowSizeIndex(); + if (sizes.IsMeasured(row)) return sizes.GetSize(row); + + float height = MinRowHeight; + object item = _itemsSource[row]; + foreach (DataGridColumn column in Columns) + { + string value = GetCellValue(item, column.PropertyName); + if (string.IsNullOrEmpty(value)) continue; + TextWrapping wrapping = column.TextWrapping ?? CellTextWrapping; + float layoutWidth = wrapping == TextWrapping.NoWrap + ? float.PositiveInfinity + : Math.Max(1f, column.ActualWidth - 16f); + var layout = new TextLayout( + value, + activeFont, + FontSize, + layoutWidth, + FlowDirection == FlowDirection.RightToLeft + ? ProGPU.Text.TextAlignment.Right + : ProGPU.Text.TextAlignment.Left, + shapingOptions: GetTextShapingOptions()); + height = Math.Max(height, layout.ContentSize.Y + 8f); + } + + sizes.SetMeasuredSize(row, height); + return height; + } + private class CellEditorTextBox : TextBox { private readonly DataGrid _owner; diff --git a/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs b/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs index 70fc5917..7067eb04 100644 --- a/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs +++ b/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs @@ -216,7 +216,10 @@ public override void OnPointerWheelChanged(PointerRoutedEventArgs e) float maxScroll = Math.Max(0f, contentSize - viewportSize); if (maxScroll > 0f) { - float delta = -e.WheelDelta * 40f; // Scroll by 40px per wheel tick + float wheelDelta = IsHorizontal && e.WheelDeltaX != 0f + ? e.WheelDeltaX + : e.WheelDelta; + float delta = e.IsPreciseScrolling ? -wheelDelta : -wheelDelta * 40f; float targetOffset = Math.Clamp(ScrollOffset + delta, 0f, maxScroll); if (targetOffset != ScrollOffset) { diff --git a/src/ProGPU.WinUI/Controls/VirtualizingStackPanel.cs b/src/ProGPU.WinUI/Controls/VirtualizingStackPanel.cs index 4f9512af..65e0d141 100644 --- a/src/ProGPU.WinUI/Controls/VirtualizingStackPanel.cs +++ b/src/ProGPU.WinUI/Controls/VirtualizingStackPanel.cs @@ -4,6 +4,7 @@ using ProGPU.Layout; using ProGPU.Scene; using Microsoft.UI.Xaml; +using ProGPU.Virtualization; namespace Microsoft.UI.Xaml.Controls; @@ -13,6 +14,14 @@ public class VirtualizingStackPanel : VirtualizingPanel private float _itemWidth = 300f; private float _itemHeight = 40f; private Orientation _orientation = Orientation.Vertical; + private float _estimatedItemHeight = 40f; + private float _cacheLength = 1f; + private readonly VariableSizeIndex _verticalSizeIndex = new(); + private readonly List _indicesToRecycle = new(); + private int _indexedItemCount = -1; + private float _indexedEstimate = -1f; + private float _indexedWidth = -1f; + private bool _isUpdatingViewport; public VirtualizingStackPanel() { @@ -102,6 +111,37 @@ public float ItemHeight } } + /// + /// Estimated height used until an auto-sized item is realized and measured. + /// Set to to enable variable sizes. + /// + public float EstimatedItemHeight + { + get => _estimatedItemHeight; + set + { + if (!float.IsFinite(value) || value <= 0f) throw new ArgumentOutOfRangeException(nameof(value)); + if (_estimatedItemHeight == value) return; + _estimatedItemHeight = value; + ResetSizeIndex(); + UpdateViewport(); + Invalidate(); + } + } + + /// Realization cache measured in viewport lengths on each scroll side. + public float CacheLength + { + get => _cacheLength; + set + { + if (!float.IsFinite(value) || value < 0f) throw new ArgumentOutOfRangeException(nameof(value)); + if (_cacheLength == value) return; + _cacheLength = value; + UpdateViewport(); + } + } + public Orientation Orientation { get => _orientation; @@ -116,7 +156,9 @@ public Orientation Orientation } } - public override float TotalVirtualHeight => Orientation == Orientation.Vertical ? ItemsCount * ItemHeight : ViewportHeight; + public override float TotalVirtualHeight => Orientation == Orientation.Vertical + ? IsVariableHeight ? EnsureSizeIndex().TotalSize : ItemsCount * ItemHeight + : ViewportHeight; public override float TotalVirtualWidth => Orientation == Orientation.Horizontal ? ItemsCount * ItemWidth : ViewportWidth; public override bool IsHorizontal => Orientation == Orientation.Horizontal; @@ -148,6 +190,10 @@ protected override void ArrangeOverride(Rect arrangeRect) private void UpdateViewport() { + if (_isUpdatingViewport) return; + _isUpdatingViewport = true; + try + { int itemsCount = ItemsCount; var createVisual = CreateVisualFactory; var bindVisual = BindVisualCallback; @@ -163,24 +209,30 @@ private void UpdateViewport() if (Orientation == Orientation.Vertical) { - // 1. Calculate visible item range (Vertical) - int startIdx = (int)Math.Floor(ScrollOffset / ItemHeight); - int endIdx = (int)Math.Ceiling((ScrollOffset + viewportHeight) / ItemHeight); + VariableSizeIndex? sizeIndex = IsVariableHeight ? EnsureSizeIndex(viewportWidth) : null; + float realizationPadding = viewportHeight * CacheLength; + float realizationStart = Math.Max(0f, ScrollOffset - realizationPadding); + float realizationEnd = ScrollOffset + viewportHeight + realizationPadding; + int startIdx = sizeIndex?.GetIndexAtOffset(realizationStart) ?? + (int)Math.Floor(realizationStart / ItemHeight); + int endIdx = sizeIndex?.GetIndexAtOffset(realizationEnd) ?? + (int)Math.Ceiling(realizationEnd / ItemHeight); + int anchorIndex = sizeIndex?.GetIndexAtOffset(ScrollOffset) ?? startIdx; startIdx = Math.Clamp(startIdx, 0, itemsCount - 1); endIdx = Math.Clamp(endIdx, 0, itemsCount - 1); // 2. Recycle items scrolled out of view - var indicesToRecycle = new List(); + _indicesToRecycle.Clear(); foreach (var key in _activeVisuals.Keys) { if (key < startIdx || key > endIdx) { - indicesToRecycle.Add(key); + _indicesToRecycle.Add(key); } } - foreach (var idx in indicesToRecycle) + foreach (var idx in _indicesToRecycle) { var vis = _activeVisuals[idx]; _activeVisuals.Remove(idx); @@ -199,7 +251,19 @@ private void UpdateViewport() AddChild(visual); } - float posY = i * ItemHeight; + float itemHeight = sizeIndex?.GetSize(i) ?? ItemHeight; + if (sizeIndex != null && !sizeIndex.IsMeasured(i) && visual is LayoutNode measuredNode) + { + measuredNode.Measure(new Vector2(viewportWidth, float.PositiveInfinity)); + itemHeight = Math.Max(1f, measuredNode.DesiredSize.Y); + float delta = sizeIndex.SetMeasuredSize(i, itemHeight); + if (delta != 0f && i < anchorIndex) + { + ScrollOffset += delta; + } + } + + float posY = sizeIndex?.GetOffset(i) ?? i * ItemHeight; if (ScrollViewerOwner == null) { posY = MathF.Round(posY - ScrollOffset); @@ -207,12 +271,18 @@ private void UpdateViewport() float itemWidth = viewportWidth; visual.Offset = new Vector2(0f, posY); - visual.Size = new Vector2(itemWidth, ItemHeight); + visual.Size = new Vector2(itemWidth, itemHeight); if (visual is LayoutNode childNode) { - childNode.Measure(new Vector2(itemWidth, ItemHeight)); - childNode.Arrange(new Rect(0f, posY, itemWidth, ItemHeight)); + if (sizeIndex == null) childNode.Measure(new Vector2(itemWidth, itemHeight)); + childNode.Arrange(new Rect(0f, posY, itemWidth, itemHeight)); + } + + if (sizeIndex != null && i == endIdx && i + 1 < itemsCount && + sizeIndex.GetOffset(i + 1) < realizationEnd) + { + endIdx++; } } } @@ -225,16 +295,16 @@ private void UpdateViewport() startIdx = Math.Clamp(startIdx, 0, itemsCount - 1); endIdx = Math.Clamp(endIdx, 0, itemsCount - 1); - var indicesToRecycle = new List(); + _indicesToRecycle.Clear(); foreach (var key in _activeVisuals.Keys) { if (key < startIdx || key > endIdx) { - indicesToRecycle.Add(key); + _indicesToRecycle.Add(key); } } - foreach (var idx in indicesToRecycle) + foreach (var idx in _indicesToRecycle) { var vis = _activeVisuals[idx]; _activeVisuals.Remove(idx); @@ -269,6 +339,45 @@ private void UpdateViewport() } } } + } + finally + { + _isUpdatingViewport = false; + } + } + + private bool IsVariableHeight => float.IsNaN(ItemHeight); + + private VariableSizeIndex EnsureSizeIndex(float viewportWidth = float.NaN) + { + int count = ItemsCount; + if (_indexedEstimate != EstimatedItemHeight || _indexedItemCount < 0) + { + _verticalSizeIndex.Reset(count, EstimatedItemHeight); + _indexedEstimate = EstimatedItemHeight; + } + else if (_indexedItemCount < count) + { + _verticalSizeIndex.InsertRange(_indexedItemCount, count - _indexedItemCount); + } + else if (_indexedItemCount > count) + { + _verticalSizeIndex.RemoveRange(count, _indexedItemCount - count); + } + if (float.IsFinite(viewportWidth) && viewportWidth > 0f && _indexedWidth != viewportWidth) + { + _verticalSizeIndex.InvalidateAllMeasurements(); + _indexedWidth = viewportWidth; + } + _indexedItemCount = count; + return _verticalSizeIndex; + } + + private void ResetSizeIndex() + { + _indexedItemCount = -1; + _indexedEstimate = -1f; + _indexedWidth = -1f; } private void ClearActiveToRecycler() @@ -284,6 +393,17 @@ private void ClearActiveToRecycler() public override void ForceRebind() { ClearActiveToRecycler(); + ResetSizeIndex(); base.ForceRebind(); } + + public override void RebindVisibleItems() + { + var bindVisual = BindVisualCallback; + if (bindVisual == null) return; + foreach (var pair in _activeVisuals) bindVisual(pair.Value, pair.Key); + if (IsVariableHeight) _verticalSizeIndex.InvalidateAllMeasurements(); + UpdateViewport(); + Invalidate(); + } } From 2591db274780d1b24928c3ba5f7e666067a86c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 14:44:41 +0200 Subject: [PATCH 05/14] Add native iPhone host and portable picker services --- src/ProGPU.Browser/BrowserWindowHost.cs | 5 +- .../ProGPU.Samples.Browser.csproj | 3 + src/ProGPU.Samples.iOS/Info.plist | 37 ++ .../ProGPU.Samples.iOS.csproj | 32 ++ src/ProGPU.Samples.iOS/Program.cs | 3 + .../Pages/RichTextEditorPage.cs | 6 +- .../Pages/SamplePagePresenter.cs | 14 +- .../StoragePickerPlatformServiceTests.cs | 111 ++++++ .../Documents/RichDocumentFormatRegistry.cs | 12 +- src/ProGPU.iOS.slnx | 4 + src/ProGPU.iOS/IosApplication.cs | 134 +++++++ src/ProGPU.iOS/IosStoragePickerService.cs | 301 +++++++++++++++ src/ProGPU.iOS/IosTextInputBridge.cs | 304 ++++++++++++++++ src/ProGPU.iOS/IosWindowHost.cs | 195 ++++++++++ src/ProGPU.iOS/MetalRenderView.cs | 342 ++++++++++++++++++ src/ProGPU.iOS/ProGPU.iOS.csproj | 30 ++ 16 files changed, 1525 insertions(+), 8 deletions(-) create mode 100644 src/ProGPU.Samples.iOS/Info.plist create mode 100644 src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj create mode 100644 src/ProGPU.Samples.iOS/Program.cs create mode 100644 src/ProGPU.Tests/StoragePickerPlatformServiceTests.cs create mode 100644 src/ProGPU.iOS.slnx create mode 100644 src/ProGPU.iOS/IosApplication.cs create mode 100644 src/ProGPU.iOS/IosStoragePickerService.cs create mode 100644 src/ProGPU.iOS/IosTextInputBridge.cs create mode 100644 src/ProGPU.iOS/IosWindowHost.cs create mode 100644 src/ProGPU.iOS/MetalRenderView.cs create mode 100644 src/ProGPU.iOS/ProGPU.iOS.csproj diff --git a/src/ProGPU.Browser/BrowserWindowHost.cs b/src/ProGPU.Browser/BrowserWindowHost.cs index 6c2495ba..a6f962f7 100644 --- a/src/ProGPU.Browser/BrowserWindowHost.cs +++ b/src/ProGPU.Browser/BrowserWindowHost.cs @@ -71,7 +71,10 @@ public void Close(Window window) public void Hide(Window window) { var hosted = _windows.FirstOrDefault(item => ReferenceEquals(item.Window, window)); - if (hosted != null) hosted.IsVisible = false; + if (hosted == null) return; + hosted.IsVisible = false; + window.NotifyHostVisibilityChanged(false); + window.NotifyHostActivationChanged(WindowActivationState.Deactivated); } public async Task RunAsync(CancellationToken cancellationToken = default) diff --git a/src/ProGPU.Samples.Browser/ProGPU.Samples.Browser.csproj b/src/ProGPU.Samples.Browser/ProGPU.Samples.Browser.csproj index e3f38c15..67c957d5 100644 --- a/src/ProGPU.Samples.Browser/ProGPU.Samples.Browser.csproj +++ b/src/ProGPU.Samples.Browser/ProGPU.Samples.Browser.csproj @@ -24,6 +24,9 @@ CopyToOutputDirectory="Always" CopyToPublishDirectory="Always" /> + + Window["WinUI-shaped Window"] + Window --> Renderer["Retained renderer and IWebGpuApi"] + Renderer --> Desktop["Desktop: Silk window and wgpu-native"] + Renderer --> Browser["Browser: command packets and navigator.gpu"] + Renderer --> IOS["iPhone: ProGPU.iOS host"] + IOS --> Silk["Silk.NET.WebGPU managed ABI"] + Silk --> Resolver["Static C symbol resolver"] + Resolver --> Wgpu["wgpu-native: WGSL and Metal only"] + Wgpu --> Layer["CAMetalLayer at native drawable size"] +``` + +The useful browser architecture is the platform-neutral boundary, not the +browser transport itself. Both hosts implement `IWindowHost`, install native +input/clipboard services, and drive `Window.InitializeExternalRenderer` and +`Window.RenderExternalFrame`. The browser must serialize commands across the +.NET/JavaScript boundary. iPhone has no such boundary, so reusing the command +arena or JavaScript decoder would add CPU work and latency without adding +portability. + +`MetalRenderView` owns one `CAMetalLayer`. Its drawable size is the view bounds +multiplied by the current scene screen's `NativeScale`, while ProGPU continues +to arrange content in logical coordinates. A `CADisplayLink` drives frames at +the screen's maximum refresh rate and is paused while the scene is inactive. +The layer allows three outstanding drawables. Surface loss or an outdated +surface triggers configuration with the current physical dimensions before +the frame is retried. + +The initial host supports one UIKit scene and one top-level ProGPU `Window`. +Popups, menus, flyouts, tooltips, and dialogs remain compositor layers. Touch +and Apple Pencil input include stable pointer IDs, pressure, contact bounds, +and microsecond timestamps. Indirect input uses separate UIKit recognizers for +hover, scroll, and transform events. A gesture delegate admits only the native +event family owned by each recognizer and rejects direct touches, which remain +on the normal multi-touch path. Scroll translation is preserved as precise 2D +logical-pixel deltas; pinch scale is carried as `120 * ln(scale)` so zoom +consumers can reconstruct the exact multiplicative scale. Indirect-pointer +touches become WinUI mouse contacts with primary, secondary, and middle button +masks, enabling capture and drag/drop without a compatibility touch adapter. +The sample explicitly enables +`UIApplicationSupportsIndirectInputEvents`; Apple's current compatibility +guidance requires that opt-in for reliable indirect input when the deployment +target predates iOS 17. A UIKit text-input bridge supplies software and +hardware keyboard input, exact replacement ranges, selection, marked-text composition, +deletion, return keys, password mode, capitalization, spell-checking, +dictation/autocorrection edits, and input-scope keyboard selection. Clipboard +text uses `UIPasteboard`. + +File-open, file-save, and folder selection use UIKit's native document picker. +Open requests copy the selected document into the application container so the +portable `StorageFile` read APIs remain safe. Save and folder requests retain +their security-scoped URLs for the host lifetime; writes are performed through +`NSFileCoordinator` before the scope is released at shutdown. + +`FramebufferOnly`, three drawable slots, native physical drawable dimensions, +the screen's maximum refresh range, a single shared `WgpuContext`, and direct +static C ABI dispatch keep the native route equivalent to the desktop renderer +where the platforms permit. There is no JavaScript command serialization, +readback, intermediate canvas, or second rendering framework on iPhone. + +## Dependencies and ABI contract + +The managed host uses the platform assemblies from the .NET iOS workload and +the repository's existing Silk.NET WebGPU binding. The only additional native +component is `wgpu-native`, built from source as an XCFramework. Its Cargo +features are restricted to `wgsl,metal`; the default GLSL, SPIR-V, and +non-Apple graphics backends are excluded. + +Silk.NET.WebGPU 2.23.0 was generated for a May 2024 WebGPU C ABI. The iOS build +therefore pins `wgpu-native` commit +`33133da4ec5a0174cb21539ef2d3346f75200411`. A newer native library is not a +drop-in replacement: callback-info layouts, surface source tags, device error +callbacks, and render-pass attachment layouts changed. Upgrade Silk's managed +binding and the native commit as one reviewed change. + +iOS cannot dynamically load an ordinary native WebGPU library. The build +generates a small C resolver whose table is derived from the pinned public +headers, compiles it for device and simulator, and combines it with the Rust +static archive. This both gives Silk function pointers and roots the symbols +against native-linker dead stripping. The generated source and all cloned +third-party source remain under ignored `artifacts/`; no upstream +implementation is copied into ProGPU. + +## Build and run + +Prerequisites: + +- macOS with a current Xcode and an installed iOS Simulator runtime. +- .NET 10 SDK with the iOS workload: `dotnet workload install ios`. +- Rust and the Apple ARM64 targets (the native build script installs the + targets when missing). + +Build the ABI-compatible Metal-only native library: + +```bash +./eng/build-wgpu-native-ios.sh +``` + +Build and launch an Apple Silicon simulator application: + +```bash +progpu_simulator_id="$(xcrun simctl create \ + "ProGPU iPhone 17 Pro" \ + com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro \ + com.apple.CoreSimulator.SimRuntime.iOS-26-4)" +xcrun simctl boot "${progpu_simulator_id}" +xcrun simctl bootstatus "${progpu_simulator_id}" -b +dotnet build src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj \ + -c Debug -r iossimulator-arm64 +xcrun simctl install "${progpu_simulator_id}" \ + src/ProGPU.Samples.iOS/bin/Debug/net10.0-ios/iossimulator-arm64/ProGPU.Samples.iOS.app +xcrun simctl launch "${progpu_simulator_id}" com.progpu.samples +``` + +Use an explicit dedicated simulator identifier as above; `booted` can target an +unrelated simulator that another application or test run is already using. + +The native input design follows Apple's [trackpad and mouse input guidance](https://developer.apple.com/videos/play/wwdc2020/10094/): +`UIPanGestureRecognizer.allowedScrollTypesMask` opts into scroll events, +`UIPinchGestureRecognizer` consumes transform events, `UIHoverGestureRecognizer` +tracks an unpressed pointer, and `UIEvent.buttonMask` identifies mouse buttons. +The event delegate intentionally reads `UIEvent.Type` because non-touch scroll +and transform events have zero touches after the indirect-input opt-in. + +### Responsive offscreen effects + +The Compute FX preview sizes its reusable offscreen gear scene from the actual +arranged preview control. Compact `ResponsiveSplitView` panes are overlays and +therefore reserve no width; the old desktop-only `window width - pane width` +estimate produced a narrow texture that was stretched across the iPhone preview. +The hot path remains fixed work outside the existing scene compilation, texture +resize, and compute dispatches: reading the retained preview size is `O(1)` with +no per-frame discovery or allocation. + +This follows the explicit target-size model used by [Skia surfaces](https://api.skia.org/classSkSurface.html), +[Direct2D render targets](https://learn.microsoft.com/en-us/windows/win32/direct2d/render-targets-overview), +[Win2D offscreen targets](https://microsoft.github.io/Win2D/WinUI3/html/DPI.htm), +[WebRender render tasks](https://github.com/mozilla/gecko-dev/blob/master/gfx/wr/webrender/src/render_task.rs), +and [Vello `RenderParams`](https://github.com/linebender/vello): the scene viewport +and target dimensions must describe the destination that will consume the image. +ProGPU adopts that sizing contract while retaining its existing texture objects, +compute pipelines, and demand-driven resize behavior. SkParagraph and HarfBuzz +were also reviewed as required; their reusable shaping/layout separation remains +unchanged because this defect is confined to a non-text offscreen target. +The fixed fully trimmed simulator build sustained the 60 FPS display cadence on +the Compute FX page; simulator timing is functional evidence only, with physical +iPhone frame-time, thermal, and power measurements still required for release. + +Use `-c Release` to validate the fully AOT-compiled path. Debug enables the +.NET interpreter and SDK-only trimming to keep edit/build cycles practical on +the simulator. Release uses full application trimming and does not enable the +interpreter, reducing the shipped managed graph before native AOT. + +On iOS and browser, the shared Rich Document Editor selects the portable +text/Markdown/RTF/HTML registry. Desktop also registers DOCX. This makes the +OpenXML package unreachable and removes it entirely from the fully linked iOS +application and AOT graph without removing the editor from the shared sample. + +For a physical iPhone, select a signing identity and provisioning profile in +the usual .NET iOS/MSBuild way, then build or publish `-r ios-arm64`. The +XCFramework already contains both `ios-arm64` and +`iossimulator-arm64` slices. A physical device is the required source of final +frame-time, thermal, memory, and power measurements; simulator FPS is only a +functional signal. + +## Shared sample and WinUI-shaped app model + +`ProGPU.Samples.iOS` contains only native startup metadata and a call to +`IosApplication.Run()`. Pages, retained scene content, +shaders, and application startup remain in `ProGPU.Samples`, exactly as they do +for desktop and browser. + +The platform work also aligns the portable application/window surface with the +official WinUI contract used by mobile hosting: + +- `Window` derives from `DependencyObject`. +- activation, close, size, and visibility events use WinRT-style + `TypedEventHandler` signatures; +- `WindowActivationState`, `WindowEventArgs`, `WindowSizeChangedEventArgs`, and + `WindowVisibilityChangedEventArgs.Handled` are available; +- `Bounds`, `Visible`, and nullable `SetTitleBar` are exposed; +- `Application.Start(ApplicationInitializationCallback)` and the official + string-shaped `LaunchActivatedEventArgs.Arguments` are supported; +- unhandled launch exceptions can be marked handled, otherwise their original + stack is preserved. + +These APIs are host-neutral. Browser and iOS hosts report activation, +visibility, logical size, and DPI through the same internal notifications. + +### Insets and the software keyboard + +The portable layer exposes the official `Windows.UI.ViewManagement.InputPane` +shape, including `OccludedRect`, `Showing`, `Hiding`, `TryShow`, `TryHide`, and +`InputPaneVisibilityEventArgs.EnsuredFocusedElementInView`. WinUI does not +publish a cross-platform safe-area contract, so ProGPU adds the deliberately +small `Window.Insets` value with `SafeArea`, `InputPaneOccludedRect`, and +`VisibleBounds`, plus `InsetsChanged` and `ExtendsContentIntoSystemInsets`. + +UIKit safe-area insets remain in logical points. Ordinary content is arranged +inside those insets, while the backdrop covers the full drawable. A docked, +full-width keyboard reduces the content's bottom visible bound; an undocked or +floating keyboard is reported through `InputPane` without incorrectly +shrinking the whole window. The focused editor is revealed through the nearest +`ScrollViewer` or `DataGrid` when possible. + +### Variable-size virtualization and DataGrid wrapping + +`VariableSizeIndex` is shared by `VirtualizingStackPanel` and `DataGrid`. It +uses a Fenwick prefix-sum tree: measured-size updates, item-to-offset lookup, +and offset-to-item lookup are `O(log N)`; retained numeric state is `O(N)`; +insert/remove operations preserve existing measurements in `O(N)`. A viewport +anchor prevents newly measured rows above the viewport from moving visible +content. `VirtualizingStackPanel.CacheLength` defaults to one viewport before +and after the visible viewport, making a three-viewport realization window. + +Set `VirtualizingStackPanel.ItemHeight` or `DataGrid.RowHeight` to `float.NaN` +for variable sizing. `EstimatedItemHeight`/`EstimatedRowHeight` stabilize the +unmeasured extent. `DataGrid.CellTextWrapping` follows the WinUI +`TextWrapping` values, and each `DataGridColumn.TextWrapping` can override it; +wrapped cell layout determines the realized row height and remains clipped to +its cell. + +## Research record and decisions + +This implementation is clean-room work based on public contracts and primary +sources. No implementation was copied or translated from another engine. + +| Source | Relevant production behavior | ProGPU decision | +| --- | --- | --- | +| [wgpu](https://github.com/gfx-rs/wgpu) and [wgpu-native](https://github.com/gfx-rs/wgpu-native) | WebGPU implementation with Metal support and a C API | Adopt direct WebGPU-to-Metal; pin the exact C ABI consumed by Silk. | +| [Apple native screen scale guidance](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/NativeScreenScale.html), [`CAMetalLayer.drawableSize`](https://developer.apple.com/documentation/quartzcore/cametallayer/drawablesize), and [drawable guidance](https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/MTLBestPracticesGuide/Drawables.html) | Separate logical view coordinates from physical render targets and acquire drawables only when rendering | Adopt native-scale framebuffer sizing, lazy per-frame acquisition, and a bounded drawable queue. | +| [`CADisplayLink`](https://learn.microsoft.com/en-us/dotnet/api/coreanimation.cadisplaylink) and [`CAMetalDisplayLink`](https://developer.apple.com/documentation/quartzcore/cametaldisplaylink) | Display-synchronized frame callbacks and lifecycle-aware presentation | Use the broadly available `CADisplayLink` for iOS 15+, with a host boundary that can move to `CAMetalDisplayLink` when the deployment target permits. | +| [.NET for iOS](https://learn.microsoft.com/en-us/dotnet/ios/) and [`NativeReference`](https://learn.microsoft.com/en-us/dotnet/maui/migration/ios-binding-projects) | Native Apple app lifecycle, AOT, and static framework linking | Adopt a thin UIKit scene host and XCFramework reference; reject a second UI framework dependency. | +| [WebGPU specification](https://gpuweb.github.io/gpuweb/) and [explainer](https://gpuweb.github.io/gpuweb/explainer/) | Portable command model, explicit surface/resource lifetime, asynchronous device behavior | Preserve ProGPU's typed WebGPU boundary and device-loss reporting. | +| [Skia GPU surfaces](https://skia.org/docs/user/api/skcanvas_creation/) and [Skia API](https://skia.org/docs/user/api/) | Host-owned GPU contexts and reusable drawing state | Adapt the single host-owned GPU context and retained-resource lifetime model; do not introduce a second canvas scene. | +| [Direct2D resource domains](https://learn.microsoft.com/en-us/windows/win32/direct2d/resources-and-resource-domains) and [Win2D device-loss handling](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/handling-device-lost) | Device-dependent resources and explicit recovery | Preserve surface/device generation invalidation and make terminal device loss observable. | +| [WebRender](https://doc.servo.org/webrender/) and [Vello](https://github.com/linebender/vello) | Retained scenes, batching/caching, and GPU parallelism | Keep ProGPU's retained compiled scene and GPU raster/composition; reject a platform-specific renderer fork. | +| [HarfBuzz shaping plans and caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) | Reusable CPU shaping plans/results | Keep shaping and line layout on the CPU and reusable across frames; only visibility, upload, rasterization, and composition move through the GPU. | +| [WinUI `Window`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.window) and the [Microsoft UI XAML model](https://github.com/microsoft/microsoft-ui-xaml/blob/main/src/dxaml/xcp/tools/XCPTypesAutoGen/XamlOM/Model/Microsoft.UI.Xaml.cs) | Official app/window event shapes and state | Match the public API shapes needed by native lifecycle hosts without adding UIKit types to portable assemblies. | +| [WinUI/UWP `InputPane`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpane), [`OccludedRect`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpane.occludedrect), and [`InputPaneVisibilityEventArgs`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpanevisibilityeventargs) | Portable keyboard visibility, occlusion, and focused-element reporting | Match the official API shape and keep UIKit geometry out of application code. | +| [`UIView.safeAreaInsets`](https://developer.apple.com/documentation/uikit/uiview/safeareainsets), [`safeAreaInsetsDidChange`](https://developer.apple.com/documentation/uikit/uiview/safeareainsetsdidchange()), and [`keyboardLayoutGuide`](https://developer.apple.com/documentation/uikit/uiview/keyboardlayoutguide) | System-bar/cutout geometry and keyboard-aware layout | Report logical inset/occlusion values; inset app content without reducing the Metal drawable. | +| [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) and [`setMarkedText`](https://developer.apple.com/documentation/uikit/uitextinput/setmarkedtext(_:selectedrange:)) | Exact document replacements, selection, and IME marked-text lifecycle | Let UIKit own its native editing document and mirror exact changes into typed ProGPU text operations. | +| [`UIPanGestureRecognizer.allowedScrollTypesMask`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer/allowedscrolltypesmask) and [`allowedTouchTypes`](https://developer.apple.com/documentation/uikit/uigesturerecognizer/allowedtouchtypes) | Indirect continuous scrolling from trackpads and other scrolling devices, independently from direct touches | Accept all scroll types, exclude direct touches from the scroll recognizer, and preserve pixel deltas through routed input. | +| [Apple TN3210: Optimizing your app for iPhone Mirroring](https://developer.apple.com/documentation/technotes/tn3210-optimizing-your-app-for-iphone-mirroring) | Compatibility requirements for indirect scroll events in apps with older deployment targets | Set `UIApplicationSupportsIndirectInputEvents` to `YES` because the sample supports iOS 15, then use UIKit's built-in pan recognizer for both continuous and discrete scroll events. | +| [`UIDocumentPickerViewController`](https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller), [`UTType`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttypereference), and [WinUI file-management guidance](https://learn.microsoft.com/en-us/windows/apps/develop/files/) | Native file/folder selection, extension filtering, save destinations, and sandbox-safe access | Preserve the WinUI-shaped asynchronous picker contract while presenting Files UI, copying opened documents locally, and retaining/coordinating security-scoped save and folder URLs. | +| [WinUI `ItemsRepeater`](https://learn.microsoft.com/en-us/windows/apps/design/controls/items-repeater), [`VirtualizingLayoutContext`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.virtualizinglayoutcontext), and [attached-layout guidance](https://learn.microsoft.com/en-us/windows/apps/design/controls/items-repeater#attached-layouts) | Viewport-driven realization, recycling, and variable-sized layouts | Share a prefix-sum geometry index, use bounded overscan, recycle containers, and preserve scroll anchors. | + +Startup remains lazy: no WebGPU instance, adapter, device, or surface is created +until the shared application activates its window. Shaping/layout results, +retained commands, atlases, and pipeline caches remain owned by the existing +renderer. Visibility and lifecycle changes pause production instead of +invalidating stable content. No GPU shaping rewrite or iPhone-only scene +compiler was introduced. + +## Current validation and limits + +The fully trimmed Release gallery has been built, installed, launched, and +rendered through native WebGPU/Metal on a dedicated iPhone 17 Pro simulator at +its physical drawable size. The linked Release app contains the static WebGPU +resolver and no OpenXML assembly. The portable API compatibility tests cover +event signatures/state, nullable title +bars, launch arguments, and `Application.Start` thread behavior. The complete +portable suite currently passes 2,196 tests, and the complete headless GPU +suite passes 195 tests. Focused coverage includes safe-area and floating/docked +keyboard geometry, exact replacement/selection and IME cancellation, precise +two-axis scrolling, variable-size prefix lookup and anchoring, wrapped DataGrid +row measurement, and an actual offscreen GPU render of the virtualized sample. + +Still required before calling the host production-ready: + +- run sustained cold-start, first-interaction, scrolling percentile, memory, + thermal, and image-comparison measurements on physical iPhone hardware; +- add native external drag/drop, accessibility-tree, and other platform + services as applications require them; +- add multi-scene/multi-window policy if ProGPU applications need more than one + top-level UIKit scene; +- implement automatic application-level reconstruction after terminal WebGPU + device loss. Surface loss already reconfigures in place. From 8755ce9913a69ac9407fb49f20f57c6480db2cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 20:20:21 +0200 Subject: [PATCH 08/14] Add direct Android Vulkan surface support --- Directory.Packages.props | 3 + src/ProGPU.Backend/ProGPU.Backend.csproj | 3 +- src/ProGPU.Backend/WgpuContext.cs | 274 ++++++++++++++++++++--- src/ProGPU.Tests/WgpuContextTests.cs | 27 +++ 4 files changed, 280 insertions(+), 27 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 08cd4167..efede4cd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,10 +15,13 @@ + + + diff --git a/src/ProGPU.Backend/ProGPU.Backend.csproj b/src/ProGPU.Backend/ProGPU.Backend.csproj index 2b475bd3..9c50622e 100644 --- a/src/ProGPU.Backend/ProGPU.Backend.csproj +++ b/src/ProGPU.Backend/ProGPU.Backend.csproj @@ -8,7 +8,8 @@ - + + diff --git a/src/ProGPU.Backend/WgpuContext.cs b/src/ProGPU.Backend/WgpuContext.cs index 264012bd..e2d66cfd 100644 --- a/src/ProGPU.Backend/WgpuContext.cs +++ b/src/ProGPU.Backend/WgpuContext.cs @@ -8,6 +8,7 @@ using Silk.NET.Core.Native; using Silk.NET.WebGPU; using Silk.NET.Windowing; +using WgpuAdapter = Silk.NET.WebGPU.Adapter; namespace ProGPU.Backend; @@ -31,7 +32,7 @@ public unsafe class WgpuContext : IDisposable public IWebGpuApi Api { get; private set; } = null!; public WgpuBackendKind BackendKind { get; private set; } = WgpuBackendKind.SilkNative; public Instance* Instance { get; private set; } = null; - public Adapter* Adapter { get; private set; } = null; + public WgpuAdapter* Adapter { get; private set; } = null; public Device* Device { get; private set; } = null; public Queue* Queue { get; private set; } = null; public Surface* Surface { get; private set; } = null; @@ -40,6 +41,8 @@ public unsafe class WgpuContext : IDisposable public uint MaxSamplersPerShaderStage { get; private set; } = 16; public uint MaxBindGroups { get; private set; } = 4; public bool SupportsReadOnlyAndReadWriteStorageTextures { get; private set; } + public BackendType AdapterBackendType { get; private set; } = BackendType.Undefined; + public string AdapterName { get; private set; } = string.Empty; public static event Action? OnWebGpuError; public static event Action? OnWebGpuDeviceLost; @@ -477,7 +480,7 @@ public void Dispose() public void Initialize(IWindow? window) { - InitializeNative(window, null, 0, 0); + InitializeNative(window, null, null, 0, 0); } /// @@ -492,11 +495,35 @@ public void InitializeMetalLayer(nint metalLayer, uint framebufferWidth, uint fr InitializeNative( window: null, metalLayer: (void*)metalLayer, + androidNativeWindow: null, framebufferWidth: Math.Max(1u, framebufferWidth), framebufferHeight: Math.Max(1u, framebufferHeight)); } - private void InitializeNative(IWindow? window, void* metalLayer, uint framebufferWidth, uint framebufferHeight) + /// + /// Initializes WebGPU directly against an Android ANativeWindow. + /// The caller retains ownership and must keep the native window acquired until this + /// context has been disposed. + /// + public void InitializeAndroidNativeWindow(nint nativeWindow, uint framebufferWidth, uint framebufferHeight) + { + if (nativeWindow == 0) + throw new ArgumentException("A valid ANativeWindow pointer is required.", nameof(nativeWindow)); + + InitializeNative( + window: null, + metalLayer: null, + androidNativeWindow: (void*)nativeWindow, + framebufferWidth: Math.Max(1u, framebufferWidth), + framebufferHeight: Math.Max(1u, framebufferHeight)); + } + + private void InitializeNative( + IWindow? window, + void* metalLayer, + void* androidNativeWindow, + uint framebufferWidth, + uint framebufferHeight) { string logPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ProGPU_test_run.log"); void SafeLog(string msg) @@ -518,7 +545,11 @@ void SafeLog(string msg) // 1. Create WebGPU Instance (isolated per context) SafeLog("[WGPUCONTEXT] Creating WebGPU Instance\n"); - var instanceDesc = new InstanceDescriptor(); + var instanceExtras = CreateNativeInstanceExtras(); + var instanceDesc = new InstanceDescriptor + { + NextInChain = instanceExtras.Chain.SType == 0 ? null : &instanceExtras.Chain + }; Instance = Wgpu.CreateInstance(&instanceDesc); if (Instance == null) { @@ -562,6 +593,15 @@ void SafeLog(string msg) throw new InvalidOperationException("Failed to create a WebGPU Surface from CAMetalLayer."); } } + else if (androidNativeWindow != null) + { + SafeLog("[WGPUCONTEXT] Creating WebGPU Surface from ANativeWindow\n"); + Surface = CreateAndroidSurface(androidNativeWindow); + if (Surface == null) + { + throw new InvalidOperationException("Failed to create a WebGPU Surface from ANativeWindow."); + } + } // 3. Request Adapter (synchronously) SafeLog("[WGPUCONTEXT] Requesting Adapter\n"); @@ -595,7 +635,25 @@ void SafeLog(string msg) { throw new InvalidOperationException($"Failed to obtain WebGPU Adapter. {adapterState.Error}"); } - Adapter = (Adapter*)adapterState.Result; + Adapter = (WgpuAdapter*)adapterState.Result; + + var adapterProperties = new AdapterProperties(); + Wgpu.AdapterGetProperties(Adapter, &adapterProperties); + AdapterBackendType = adapterProperties.BackendType; + AdapterName = adapterProperties.Name == null ? string.Empty : ReadNativeMessage(adapterProperties.Name); + SafeLog($"[WGPUCONTEXT] Adapter '{AdapterName}', backend={AdapterBackendType}\n"); + if (OperatingSystem.IsAndroid() && AdapterBackendType != BackendType.Vulkan) + { + ReleaseAdapterInitializationResources(); + throw new InvalidOperationException( + $"Android requires the direct Vulkan WebGPU backend, but wgpu-native selected {AdapterBackendType}."); + } + if (OperatingSystem.IsIOS() && AdapterBackendType != BackendType.Metal) + { + ReleaseAdapterInitializationResources(); + throw new InvalidOperationException( + $"iOS requires the direct Metal WebGPU backend, but wgpu-native selected {AdapterBackendType}."); + } // 4. Request Device (synchronously) SafeLog("[WGPUCONTEXT] Requesting Device\n"); @@ -607,14 +665,18 @@ void SafeLog(string msg) Wgpu.AdapterGetLimits(Adapter, &adapterLimits); var requiredLimits = CreateRequiredLimits(adapterLimits); var requiredFeatures = stackalloc FeatureName[1]; - requiredFeatures[0] = FeatureName.Bgra8UnormStorage; + uint requiredFeatureCount = 0; + if (Wgpu.AdapterHasFeature(Adapter, FeatureName.Bgra8UnormStorage)) + { + requiredFeatures[requiredFeatureCount++] = FeatureName.Bgra8UnormStorage; + } var deviceDesc = new DeviceDescriptor { Label = (byte*)SilkMarshal.StringToPtr("ProGPU Primary Device"), RequiredLimits = &requiredLimits, - RequiredFeatureCount = 1, - RequiredFeatures = requiredFeatures, + RequiredFeatureCount = requiredFeatureCount, + RequiredFeatures = requiredFeatureCount == 0 ? null : requiredFeatures, DeviceLostCallback = new PfnDeviceLostCallback(&OnDeviceLost) }; @@ -680,6 +742,125 @@ void SafeLog(string msg) Current = this; } + /// + /// Replaces a temporarily lost Android presentation surface without rebuilding the + /// adapter, device, queue, pipelines, atlases, or retained compositor resources. + /// + public void AttachAndroidNativeWindow(nint nativeWindow, uint framebufferWidth, uint framebufferHeight) + { + if (nativeWindow == 0) + throw new ArgumentException("A valid ANativeWindow pointer is required.", nameof(nativeWindow)); + + lock (RenderLock) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + if (Instance == null || Adapter == null || Device == null) + throw new InvalidOperationException("The WebGPU device must be initialized before attaching a replacement surface."); + + ReleasePresentationSurfaceCore(waitForDevice: true); + Surface = CreateAndroidSurface((void*)nativeWindow); + if (Surface == null) + throw new InvalidOperationException("Failed to create a replacement WebGPU Surface from ANativeWindow."); + + if (!TryConfigureSwapChain(Math.Max(1u, framebufferWidth), Math.Max(1u, framebufferHeight))) + throw new InvalidOperationException("The replacement Android WebGPU surface did not expose usable capabilities."); + } + } + + /// + /// Releases only the Android presentation surface. The device and all reusable GPU + /// resources remain alive for a later call. + /// + public void DetachAndroidNativeWindow() + { + lock (RenderLock) + { + if (_isDisposed) return; + ReleasePresentationSurfaceCore(waitForDevice: true); + } + } + + private Surface* CreateAndroidSurface(void* nativeWindow) + { + var androidDescriptor = new SurfaceDescriptorFromAndroidNativeWindow + { + Chain = new ChainedStruct + { + SType = SType.SurfaceDescriptorFromAndroidNativeWindow + }, + Window = nativeWindow + }; + var surfaceDescriptor = new SurfaceDescriptor + { + NextInChain = &androidDescriptor.Chain + }; + return Wgpu.InstanceCreateSurface(Instance, &surfaceDescriptor); + } + + private void ReleaseAdapterInitializationResources() + { + ReleasePresentationSurfaceCore(waitForDevice: false); + if (Adapter != null) + { + Wgpu.AdapterRelease(Adapter); + Adapter = null; + } + if (Instance != null) + { + Wgpu.InstanceRelease(Instance); + Instance = null; + } + } + + private void ReleasePresentationSurfaceCore(bool waitForDevice) + { + if (Surface == null) return; + if (waitForDevice && Device != null) WaitIdle(); + // Externally owned browser surfaces are opaque command-stream handles and + // have no native WebGPU instance to unconfigure. Only the Silk-native path + // owns a wgpu-native surface configuration. + if (BackendKind == WgpuBackendKind.SilkNative && _isSurfaceConfigured) + Wgpu.SurfaceUnconfigure(Surface); + Api.SurfaceRelease(Surface); + Surface = null; + _isSurfaceConfigured = false; + } + + private static NativeInstanceExtras CreateNativeInstanceExtras() + { + uint backends = OperatingSystem.IsAndroid() + ? NativeInstanceExtras.VulkanBackend + : OperatingSystem.IsIOS() + ? NativeInstanceExtras.MetalBackend + : 0u; + return backends == 0u + ? default + : new NativeInstanceExtras + { + Chain = new ChainedStruct { SType = (SType)NativeInstanceExtras.STypeValue }, + Backends = backends + }; + } + + // wgpu-native 0.19 extension ABI from its public wgpu.h. Silk exposes the + // standard WebGPU descriptor chain but intentionally does not generate native-only + // extensions, so this private sequential representation keeps that boundary explicit. + [StructLayout(LayoutKind.Sequential)] + private struct NativeInstanceExtras + { + public const uint STypeValue = 0x00030006; + public const uint VulkanBackend = 1u << 0; + public const uint MetalBackend = 1u << 2; + + public ChainedStruct Chain; + public uint Backends; + public uint Flags; + public int Dx12ShaderCompiler; + public int Gles3MinorVersion; + public byte* DxilPath; + public byte* DxcPath; + } + private static WebGPU CreateNativeWebGpuApi() { if (OperatingSystem.IsIOS()) @@ -687,9 +868,37 @@ private static WebGPU CreateNativeWebGpuApi() return new WebGPU(new LamdaNativeContext(ResolveAppleStaticWebGpuSymbol)); } + if (OperatingSystem.IsAndroid()) + { + return new WebGPU(new LamdaNativeContext(ResolveAndroidWebGpuSymbol)); + } + return WebGPU.GetApi(); } + private static readonly object s_androidWebGpuLibraryLock = new(); + private static nint s_androidWebGpuLibrary; + + private static nint ResolveAndroidWebGpuSymbol(string symbol) + { + if (s_androidWebGpuLibrary == 0) + { + lock (s_androidWebGpuLibraryLock) + { + if (s_androidWebGpuLibrary == 0 && + !NativeLibrary.TryLoad("libwgpu_native.so", out s_androidWebGpuLibrary)) + { + throw new DllNotFoundException( + "Unable to load libwgpu_native.so. Package the matching Android ABI native library with the application."); + } + } + } + + return NativeLibrary.TryGetExport(s_androidWebGpuLibrary, symbol, out nint address) + ? address + : 0; + } + private static nint ResolveAppleStaticWebGpuSymbol(string symbol) { nint utf8 = SilkMarshal.StringToPtr(symbol); @@ -712,7 +921,7 @@ private static nint ResolveAppleStaticWebGpuSymbol(string symbol) [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] private static void OnAdapterRequested( RequestAdapterStatus status, - Adapter* adapter, + WgpuAdapter* adapter, byte* message, void* userData) { @@ -958,15 +1167,7 @@ public bool TryConfigureSwapChain(uint width, uint height) return false; } - TextureFormat swapChainFormat = formats[0]; - for (int i = 0; i < formats.Length; i++) - { - if (formats[i] == TextureFormat.Bgra8Unorm) - { - swapChainFormat = TextureFormat.Bgra8Unorm; - break; - } - } + TextureFormat swapChainFormat = ChooseSurfaceFormat(formats); var alphaMode = ChooseCompositeAlphaMode( _window?.TransparentFramebuffer == true, @@ -1005,6 +1206,31 @@ public static bool CanConfigureSurface( return !formats.IsEmpty && !alphaModes.IsEmpty && !presentModes.IsEmpty; } + /// + /// Chooses an encoded-color presentation target. ProGPU theme, vector, and text + /// colors are stored as sRGB channel values and the compositor writes those values + /// directly. A non-sRGB attachment preserves the established desktop and Metal + /// output; selecting an *Srgb format would encode the channels a second time. + /// + public static TextureFormat ChooseSurfaceFormat(ReadOnlySpan formats) + { + if (formats.IsEmpty) + throw new ArgumentException("At least one surface format is required.", nameof(formats)); + + ReadOnlySpan preferred = + [TextureFormat.Bgra8Unorm, TextureFormat.Rgba8Unorm]; + for (int preferredIndex = 0; preferredIndex < preferred.Length; preferredIndex++) + { + for (int availableIndex = 0; availableIndex < formats.Length; availableIndex++) + { + if (formats[availableIndex] == preferred[preferredIndex]) + return preferred[preferredIndex]; + } + } + + return formats[0]; + } + public static CompositeAlphaMode ChooseCompositeAlphaMode( bool transparentFramebuffer, ReadOnlySpan alphaModes) @@ -1224,11 +1450,7 @@ public void Dispose() _activeContexts.Remove(this); } - if (Surface != null) - { - Api.SurfaceRelease(Surface); - Surface = null; - } + ReleasePresentationSurfaceCore(waitForDevice: false); _sharedDeviceLifetime?.Release(); _sharedDeviceLifetime = null; @@ -1253,7 +1475,7 @@ private sealed class SharedDeviceLifetime private readonly object _sync = new(); private WebGPU? _wgpu; private Instance* _instance; - private Adapter* _adapter; + private WgpuAdapter* _adapter; private Device* _device; private Queue* _queue; private int _referenceCount = 1; @@ -1261,7 +1483,7 @@ private sealed class SharedDeviceLifetime public SharedDeviceLifetime( WebGPU wgpu, Instance* instance, - Adapter* adapter, + WgpuAdapter* adapter, Device* device, Queue* queue) { @@ -1286,7 +1508,7 @@ public void Release() { WebGPU? wgpu; Instance* instance; - Adapter* adapter; + WgpuAdapter* adapter; Device* device; Queue* queue; lock (_sync) diff --git a/src/ProGPU.Tests/WgpuContextTests.cs b/src/ProGPU.Tests/WgpuContextTests.cs index d14a74a4..3cedbf24 100644 --- a/src/ProGPU.Tests/WgpuContextTests.cs +++ b/src/ProGPU.Tests/WgpuContextTests.cs @@ -9,6 +9,33 @@ namespace ProGPU.Tests; public sealed class WgpuContextTests { + [Fact] + public void ChooseSurfaceFormat_PrefersNonSrgbRgbaOverSrgbFirstEntry() + { + TextureFormat selected = WgpuContext.ChooseSurfaceFormat( + [TextureFormat.Rgba8UnormSrgb, TextureFormat.Rgba8Unorm]); + + Assert.Equal(TextureFormat.Rgba8Unorm, selected); + } + + [Fact] + public void ChooseSurfaceFormat_PrefersBgraNonSrgbWhenBothEncodedFormatsExist() + { + TextureFormat selected = WgpuContext.ChooseSurfaceFormat( + [TextureFormat.Rgba8Unorm, TextureFormat.Bgra8Unorm]); + + Assert.Equal(TextureFormat.Bgra8Unorm, selected); + } + + [Fact] + public void ChooseSurfaceFormat_FallsBackToFirstAdvertisedFormat() + { + TextureFormat selected = WgpuContext.ChooseSurfaceFormat( + [TextureFormat.Rgba16float, TextureFormat.Rgba8UnormSrgb]); + + Assert.Equal(TextureFormat.Rgba16float, selected); + } + [Fact] public unsafe void SharedSurfaceRejectsUninitializedDeviceOwnerWithoutMutatingContext() { From b81a80725885029aef479702e7a2a4d4508f7b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 20:20:42 +0200 Subject: [PATCH 09/14] Add mobile WinUI controls and platform APIs --- src/ProGPU.Layout/LayoutContainers.cs | 4 +- src/ProGPU.Samples/Pages/ImageEffectsPage.cs | 1 + .../Pages/ImageRepeatShowcasePage.cs | 1 + .../MobileWinUiControlApiTests.cs | 233 ++++++++++++++ .../MobileWinUiPlatformApiTests.cs | 187 +++++++++++ .../StoragePickerPlatformServiceTests.cs | 77 +++++ src/ProGPU.WinUI/Controls/Frame.cs | 222 +++++++++++++ src/ProGPU.WinUI/Controls/Image.cs | 9 +- src/ProGPU.WinUI/Controls/ItemsPanels.cs | 215 +++++++++++++ src/ProGPU.WinUI/Controls/MarkdownParser.cs | 1 + src/ProGPU.WinUI/Controls/Page.cs | 74 +++++ .../Primitives/GroupHeaderPlacement.cs | 7 + src/ProGPU.WinUI/Controls/RelativePanel.cs | 294 ++++++++++++++++++ src/ProGPU.WinUI/Controls/RichEditBox.cs | 1 + .../Controls/UniformVirtualizingGridPanel.cs | 189 ++++++++--- .../Controls/VariableSizedWrapGrid.cs | 278 +++++++++++++++++ src/ProGPU.WinUI/Controls/Viewbox.cs | 184 +++++++++++ src/ProGPU.WinUI/Core/Application.cs | 60 ++++ src/ProGPU.WinUI/Core/Storage.cs | 57 +++- src/ProGPU.WinUI/Core/Window.cs | 18 +- src/ProGPU.WinUI/Media/Stretch.cs | 10 + src/ProGPU.WinUI/Navigation/Navigation.cs | 87 ++++++ .../ApplicationLifecycleEventArgs.cs | 112 +++++++ .../Windows/Foundation/Deferral.cs | 28 ++ .../Graphics/Display/DisplayInformation.cs | 172 ++++++++++ .../UI/Core/SystemNavigationManager.cs | 46 +++ 26 files changed, 2503 insertions(+), 64 deletions(-) create mode 100644 src/ProGPU.Tests/MobileWinUiControlApiTests.cs create mode 100644 src/ProGPU.Tests/MobileWinUiPlatformApiTests.cs create mode 100644 src/ProGPU.WinUI/Controls/Frame.cs create mode 100644 src/ProGPU.WinUI/Controls/ItemsPanels.cs create mode 100644 src/ProGPU.WinUI/Controls/Page.cs create mode 100644 src/ProGPU.WinUI/Controls/Primitives/GroupHeaderPlacement.cs create mode 100644 src/ProGPU.WinUI/Controls/RelativePanel.cs create mode 100644 src/ProGPU.WinUI/Controls/VariableSizedWrapGrid.cs create mode 100644 src/ProGPU.WinUI/Controls/Viewbox.cs create mode 100644 src/ProGPU.WinUI/Media/Stretch.cs create mode 100644 src/ProGPU.WinUI/Navigation/Navigation.cs create mode 100644 src/ProGPU.WinUI/Windows/ApplicationModel/ApplicationLifecycleEventArgs.cs create mode 100644 src/ProGPU.WinUI/Windows/Foundation/Deferral.cs create mode 100644 src/ProGPU.WinUI/Windows/Graphics/Display/DisplayInformation.cs create mode 100644 src/ProGPU.WinUI/Windows/UI/Core/SystemNavigationManager.cs diff --git a/src/ProGPU.Layout/LayoutContainers.cs b/src/ProGPU.Layout/LayoutContainers.cs index 1414c34b..0f127681 100644 --- a/src/ProGPU.Layout/LayoutContainers.cs +++ b/src/ProGPU.Layout/LayoutContainers.cs @@ -7,8 +7,8 @@ namespace Microsoft.UI.Xaml.Controls { public enum Orientation { - Horizontal, - Vertical + Vertical = 0, + Horizontal = 1 } } diff --git a/src/ProGPU.Samples/Pages/ImageEffectsPage.cs b/src/ProGPU.Samples/Pages/ImageEffectsPage.cs index 7de7d248..4615d9ff 100644 --- a/src/ProGPU.Samples/Pages/ImageEffectsPage.cs +++ b/src/ProGPU.Samples/Pages/ImageEffectsPage.cs @@ -8,6 +8,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; using StackPanel = Microsoft.UI.Xaml.Controls.StackPanel; using Image = Microsoft.UI.Xaml.Controls.Image; diff --git a/src/ProGPU.Samples/Pages/ImageRepeatShowcasePage.cs b/src/ProGPU.Samples/Pages/ImageRepeatShowcasePage.cs index 2b7ee84c..3c3df73a 100644 --- a/src/ProGPU.Samples/Pages/ImageRepeatShowcasePage.cs +++ b/src/ProGPU.Samples/Pages/ImageRepeatShowcasePage.cs @@ -16,6 +16,7 @@ using Microsoft.UI.Xaml.Markup; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; using Button = Microsoft.UI.Xaml.Controls.Button; using StackPanel = Microsoft.UI.Xaml.Controls.StackPanel; diff --git a/src/ProGPU.Tests/MobileWinUiControlApiTests.cs b/src/ProGPU.Tests/MobileWinUiControlApiTests.cs new file mode 100644 index 00000000..94ef0dea --- /dev/null +++ b/src/ProGPU.Tests/MobileWinUiControlApiTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using ProGPU.Scene; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class MobileWinUiControlApiTests +{ + [Fact] + public void FrameUsesTypedFactoriesAndMaintainsNavigationStacks() + { + Frame.RegisterPageFactory(static () => new FirstPage()); + Frame.RegisterPageFactory(static () => new SecondPage()); + var frame = new Frame(); + var modes = new List(); + frame.Navigated += (_, args) => modes.Add(args.NavigationMode); + + Assert.True(frame.Navigate(typeof(FirstPage), "first")); + FirstPage first = Assert.IsType(frame.Content); + Assert.Same(frame, first.Frame); + Assert.Equal("first", first.LastParameter); + + Assert.True(frame.Navigate(typeof(SecondPage), "second")); + Assert.True(frame.CanGoBack); + Assert.Equal(1, frame.BackStackDepth); + Assert.Equal(typeof(SecondPage), frame.CurrentSourcePageType); + + frame.GoBack(); + Assert.Same(first, frame.Content); + Assert.True(frame.CanGoForward); + Assert.Equal(new[] { NavigationMode.New, NavigationMode.New, NavigationMode.Back }, modes); + Assert.Equal(typeof(SecondPage), first.LastNavigatedFromTarget); + } + + [Fact] + public void FrameHonorsPageNavigationCancellation() + { + Frame.RegisterPageFactory(static () => new CancelingPage()); + Frame.RegisterPageFactory(static () => new SecondPage()); + var frame = new Frame(); + + Assert.True(frame.Navigate(typeof(CancelingPage))); + Assert.False(frame.Navigate(typeof(SecondPage))); + Assert.IsType(frame.Content); + Assert.False(frame.CanGoBack); + } + + [Fact] + public void RelativePanelSolvesSiblingAndPanelEdgeRelationships() + { + var first = FixedElement(40f, 20f); + var second = FixedElement(20f, 10f); + var stretched = new Border { HeightConstraint = 5f }; + RelativePanel.SetRightOf(second, first); + RelativePanel.SetBelow(second, first); + RelativePanel.SetAlignLeftWithPanel(stretched, true); + RelativePanel.SetAlignRightWithPanel(stretched, true); + RelativePanel.SetAlignBottomWithPanel(stretched, true); + + var panel = new RelativePanel(); + panel.Children.Add(first); + panel.Children.Add(second); + panel.Children.Add(stretched); + panel.Measure(new Vector2(200f, 100f)); + panel.Arrange(new Rect(0f, 0f, 200f, 100f)); + + Assert.Equal(new Vector2(0f, 0f), first.Offset); + Assert.Equal(new Vector2(40f, 20f), second.Offset); + Assert.Equal(new Vector2(0f, 95f), stretched.Offset); + Assert.Equal(200f, stretched.Size.X); + } + + [Fact] + public void RelativePanelRejectsConstraintCycles() + { + var first = FixedElement(10f, 10f); + var second = FixedElement(10f, 10f); + RelativePanel.SetRightOf(first, second); + RelativePanel.SetRightOf(second, first); + var panel = new RelativePanel(); + panel.Children.Add(first); + panel.Children.Add(second); + + Assert.Throws(() => panel.Measure(new Vector2(100f, 100f))); + } + + [Fact] + public void VariableSizedWrapGridUsesFirstFitSpansAndWraps() + { + var first = FixedElement(50f, 20f); + var second = FixedElement(50f, 20f); + var third = FixedElement(50f, 20f); + VariableSizedWrapGrid.SetColumnSpan(first, 2); + var panel = new VariableSizedWrapGrid + { + Orientation = Orientation.Horizontal, + ItemWidth = 50f, + ItemHeight = 20f, + MaximumRowsOrColumns = 3 + }; + panel.Children.Add(first); + panel.Children.Add(second); + panel.Children.Add(third); + + panel.Measure(new Vector2(150f, 100f)); + panel.Arrange(new Rect(0f, 0f, 150f, 40f)); + + Assert.Equal(new Vector2(150f, 40f), panel.DesiredSize); + Assert.Equal(new Vector2(0f, 0f), first.Offset); + Assert.Equal(new Vector2(100f, 0f), second.Offset); + Assert.Equal(new Vector2(0f, 20f), third.Offset); + Assert.Throws(() => VariableSizedWrapGrid.SetRowSpan(first, 0)); + } + + [Fact] + public void ViewboxScalesThroughPresenterAndPreservesChildTransform() + { + var child = FixedElement(200f, 100f); + child.Scale = new Vector3(2f, 2f, 1f); + var viewbox = new Viewbox { Child = child, Stretch = Stretch.Uniform }; + + viewbox.Measure(new Vector2(100f, 100f)); + viewbox.Arrange(new Rect(0f, 0f, 100f, 100f)); + + Matrix4x4 global = child.GetGlobalTransformMatrix(); + Assert.Equal(new Vector2(100f, 50f), viewbox.DesiredSize); + Assert.Equal(new Vector3(2f, 2f, 1f), child.Scale); + Assert.Equal(new Vector2(0f, 25f), child.Parent!.Offset); + Assert.Equal(1f, global.M11, 4); + Assert.Equal(1f, global.M22, 4); + } + + [Fact] + public void ViewboxStretchDirectionPreventsDownscaling() + { + var child = FixedElement(200f, 100f); + var viewbox = new Viewbox + { + Child = child, + Stretch = Stretch.Uniform, + StretchDirection = StretchDirection.UpOnly + }; + + viewbox.Measure(new Vector2(100f, 100f)); + viewbox.Arrange(new Rect(0f, 0f, 100f, 100f)); + + Matrix4x4 global = child.GetGlobalTransformMatrix(); + Assert.Equal(1f, global.M11, 4); + Assert.Equal(-50f, global.M41, 4); + } + + [Fact] + public void OfficialItemsPanelPropertiesMapToSharedVirtualizers() + { + var stack = new ItemsStackPanel(); + Assert.Equal(Orientation.Vertical, stack.Orientation); + Assert.Equal(4d, stack.CacheLength); + Assert.True(stack.AreStickyGroupHeadersEnabled); + Assert.Equal(GroupHeaderPlacement.Top, stack.GroupHeaderPlacement); + Assert.Equal(2f, ((VirtualizingStackPanel)stack).CacheLength); + + stack.Orientation = Orientation.Horizontal; + stack.CacheLength = 6d; + stack.ItemsUpdatingScrollMode = ItemsUpdatingScrollMode.KeepScrollOffset; + Assert.Equal(Orientation.Horizontal, ((VirtualizingStackPanel)stack).Orientation); + Assert.Equal(3f, ((VirtualizingStackPanel)stack).CacheLength); + + var wrap = new ItemsWrapGrid(); + Assert.Equal(Orientation.Vertical, wrap.Orientation); + Assert.True(double.IsNaN(wrap.ItemWidth)); + Assert.True(double.IsNaN(wrap.ItemHeight)); + Assert.Equal(4d, wrap.CacheLength); + Assert.True(wrap.AreStickyGroupHeadersEnabled); + Assert.True(((UniformVirtualizingGridPanel)wrap).IsHorizontal); + } + + [Fact] + public void ItemsWrapGridVirtualizesInOfficialVerticalFillOrder() + { + var wrap = new ItemsWrapGrid + { + ItemsCount = 20, + ItemWidth = 20d, + ItemHeight = 10d, + MaximumRowsOrColumns = 2, + CacheLength = 0d, + CreateVisualFactory = static () => new Border(), + BindVisualCallback = static (visual, index) => visual.HitTestId = index + 1 + }; + + wrap.Measure(new Vector2(100f, 100f)); + wrap.Arrange(new Rect(0f, 0f, 100f, 100f)); + + Visual first = Assert.Single(wrap.Children, visual => visual.HitTestId == 1); + Visual second = Assert.Single(wrap.Children, visual => visual.HitTestId == 2); + Visual third = Assert.Single(wrap.Children, visual => visual.HitTestId == 3); + Assert.Equal(new Vector2(0f, 0f), first.Offset); + Assert.Equal(new Vector2(0f, 10f), second.Offset); + Assert.Equal(new Vector2(20f, 0f), third.Offset); + Assert.Equal(200f, wrap.TotalVirtualWidth); + } + + private static Border FixedElement(float width, float height) => new() + { + WidthConstraint = width, + HeightConstraint = height + }; + + private sealed class FirstPage : Page + { + public object? LastParameter { get; private set; } + public Type? LastNavigatedFromTarget { get; private set; } + + protected override void OnNavigatedTo(NavigationEventArgs e) => LastParameter = e.Parameter; + protected override void OnNavigatedFrom(NavigationEventArgs e) => LastNavigatedFromTarget = e.SourcePageType; + } + + private sealed class SecondPage : Page + { + } + + private sealed class CancelingPage : Page + { + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) => e.Cancel = true; + } +} diff --git a/src/ProGPU.Tests/MobileWinUiPlatformApiTests.cs b/src/ProGPU.Tests/MobileWinUiPlatformApiTests.cs new file mode 100644 index 00000000..bcc84cd4 --- /dev/null +++ b/src/ProGPU.Tests/MobileWinUiPlatformApiTests.cs @@ -0,0 +1,187 @@ +using Microsoft.UI.Xaml; +using Windows.ApplicationModel; +using Windows.Graphics.Display; +using Windows.UI.Core; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class MobileWinUiPlatformApiTests +{ + [Fact] + public async Task ApplicationLifecycleWaitsForOfficialDeferralsAndPreservesEventOrder() + { + var application = new Application(); + var events = new List(); + Windows.Foundation.Deferral? enteredDeferral = null; + SuspendingDeferral? suspendingDeferral = null; + Windows.Foundation.Deferral? leavingDeferral = null; + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddMinutes(1); + + application.EnteredBackground += (_, args) => + { + events.Add("entered"); + enteredDeferral = args.GetDeferral(); + }; + application.Suspending += (_, args) => + { + events.Add("suspending"); + Assert.Equal(deadline, args.SuspendingOperation.Deadline); + suspendingDeferral = args.SuspendingOperation.GetDeferral(); + }; + application.Resuming += (_, _) => events.Add("resuming"); + application.LeavingBackground += (_, args) => + { + events.Add("leaving"); + leavingDeferral = args.GetDeferral(); + }; + + Task entered = application.NotifyHostEnteredBackgroundAsync(); + Assert.False(entered.IsCompleted); + enteredDeferral!.Complete(); + enteredDeferral.Complete(); + await entered; + + Task suspending = application.NotifyHostSuspendingAsync(deadline); + Assert.False(suspending.IsCompleted); + suspendingDeferral!.Complete(); + suspendingDeferral.Complete(); + await suspending; + + application.NotifyHostResuming(); + + Task leaving = application.NotifyHostLeavingBackgroundAsync(); + Assert.False(leaving.IsCompleted); + leavingDeferral!.Complete(); + await leaving; + + Assert.Equal(["entered", "suspending", "resuming", "leaving"], events); + } + + [Fact] + public async Task LifecycleWithoutSubscribersCompletesSynchronously() + { + var application = new Application(); + + Assert.True(application.NotifyHostEnteredBackgroundAsync().IsCompletedSuccessfully); + Assert.True(application.NotifyHostLeavingBackgroundAsync().IsCompletedSuccessfully); + await application.NotifyHostSuspendingAsync(DateTimeOffset.UtcNow.AddSeconds(1)); + } + + [Fact] + public void SystemNavigationManagerUsesOfficialDefaultsAndReportsHandledBackRequests() + { + SystemNavigationManager first = SystemNavigationManager.GetForCurrentView(); + SystemNavigationManager second = SystemNavigationManager.GetForCurrentView(); + Assert.Same(first, second); + first.AppViewBackButtonVisibility = AppViewBackButtonVisibility.Collapsed; + Assert.Equal(AppViewBackButtonVisibility.Collapsed, first.AppViewBackButtonVisibility); + + int requests = 0; + void Handler(object? sender, BackRequestedEventArgs args) + { + Assert.Same(first, sender); + requests++; + args.Handled = true; + } + + first.BackRequested += Handler; + try + { + Assert.True(first.NotifyBackRequested()); + Assert.Equal(1, requests); + } + finally + { + first.BackRequested -= Handler; + } + + Assert.False(first.NotifyBackRequested()); + } + + [Fact] + public void DisplayInformationPublishesAtomicMetricsAndOnlyRelevantEvents() + { + DisplayInformation display = DisplayInformation.GetForCurrentView(); + var original = new DisplayInformationMetrics( + display.CurrentOrientation, + display.NativeOrientation, + display.LogicalDpi, + display.RawPixelsPerViewPixel, + display.ScreenWidthInRawPixels, + display.ScreenHeightInRawPixels, + display.DiagonalSizeInInches); + int orientationChanges = 0; + int dpiChanges = 0; + void OnOrientation(DisplayInformation sender, object args) + { + Assert.Same(display, sender); + orientationChanges++; + } + void OnDpi(DisplayInformation sender, object args) + { + Assert.Same(display, sender); + dpiChanges++; + } + + display.OrientationChanged += OnOrientation; + display.DpiChanged += OnDpi; + try + { + var portrait = new DisplayInformationMetrics( + DisplayOrientations.Portrait, + DisplayOrientations.Portrait, + 264f, + 2.75d, + 1179, + 2556, + 6.1d); + + DisplayInformation.NotifyHostMetricsChanged(portrait); + DisplayInformation.NotifyHostMetricsChanged(portrait); + + Assert.Equal(DisplayOrientations.Portrait, display.CurrentOrientation); + Assert.Equal(DisplayOrientations.Portrait, display.NativeOrientation); + Assert.Equal(264f, display.LogicalDpi); + Assert.Equal(2.75d, display.RawPixelsPerViewPixel); + Assert.Equal(ResolutionScale.Scale250Percent, display.ResolutionScale); + Assert.Equal((uint)1179, display.ScreenWidthInRawPixels); + Assert.Equal((uint)2556, display.ScreenHeightInRawPixels); + Assert.Equal(6.1d, display.DiagonalSizeInInches); + Assert.Equal(1, orientationChanges); + Assert.Equal(1, dpiChanges); + } + finally + { + display.OrientationChanged -= OnOrientation; + display.DpiChanged -= OnDpi; + DisplayInformation.NotifyHostMetricsChanged(original); + } + } + + [Fact] + public void DisplayInformationRejectsInvalidHostMetricsTransactionally() + { + DisplayInformation display = DisplayInformation.GetForCurrentView(); + double oldScale = display.RawPixelsPerViewPixel; + + Assert.Throws(() => + DisplayInformation.NotifyHostMetricsChanged(new DisplayInformationMetrics( + DisplayOrientations.None, + DisplayOrientations.Landscape, + 96f, + 1d, + 100, + 100))); + Assert.Throws(() => + DisplayInformation.NotifyHostMetricsChanged(new DisplayInformationMetrics( + DisplayOrientations.Landscape, + DisplayOrientations.Landscape, + 96f, + double.NaN, + 100, + 100))); + + Assert.Equal(oldScale, display.RawPixelsPerViewPixel); + } +} diff --git a/src/ProGPU.Tests/StoragePickerPlatformServiceTests.cs b/src/ProGPU.Tests/StoragePickerPlatformServiceTests.cs index 4cd3752f..24f946a7 100644 --- a/src/ProGPU.Tests/StoragePickerPlatformServiceTests.cs +++ b/src/ProGPU.Tests/StoragePickerPlatformServiceTests.cs @@ -108,4 +108,81 @@ public async Task StorageFileUsesHostCoordinatedWriteWhenAvailable() StoragePlatformServices.WriteBytesAsync = previousByteWriter; } } + + [Fact] + public async Task StorageObjectsUseHostBackedReadEnumerationAndCreationWhenAvailable() + { + var previousTextReader = StoragePlatformServices.ReadTextAsync; + var previousByteReader = StoragePlatformServices.ReadBytesAsync; + var previousFileEnumerator = StoragePlatformServices.EnumerateFilesAsync; + var previousFolderEnumerator = StoragePlatformServices.EnumerateFoldersAsync; + var previousFileCreator = StoragePlatformServices.CreateFileAsync; + var previousFolderCreator = StoragePlatformServices.CreateFolderAsync; + var requests = new List(); + try + { + StoragePlatformServices.ReadTextAsync = path => + { + requests.Add($"read-text:{path}"); + return Task.FromResult("host text"); + }; + StoragePlatformServices.ReadBytesAsync = path => + { + requests.Add($"read-bytes:{path}"); + return Task.FromResult([4, 5, 6]); + }; + StoragePlatformServices.EnumerateFilesAsync = path => + { + requests.Add($"files:{path}"); + return Task.FromResult>(["content://tree/root/document/one.txt"]); + }; + StoragePlatformServices.EnumerateFoldersAsync = path => + { + requests.Add($"folders:{path}"); + return Task.FromResult>(["content://tree/root/document/child"]); + }; + StoragePlatformServices.CreateFileAsync = (path, name) => + { + requests.Add($"create-file:{path}:{name}"); + return Task.FromResult($"{path}/document/{name}"); + }; + StoragePlatformServices.CreateFolderAsync = (path, name) => + { + requests.Add($"create-folder:{path}:{name}"); + return Task.FromResult($"{path}/tree/{name}"); + }; + + const string filePath = "content://documents/document/source.txt"; + var file = new StorageFile(filePath); + Assert.Equal("host text", await file.ReadTextAsync()); + Assert.Equal([4, 5, 6], await file.ReadBytesAsync()); + + const string folderPath = "content://documents/tree/root"; + var folder = new StorageFolder(folderPath); + Assert.Equal("one.txt", Assert.Single(await folder.GetFilesAsync()).Name); + Assert.Equal("child", Assert.Single(await folder.GetFoldersAsync()).Name); + Assert.Equal("created.txt", (await folder.CreateFileAsync("created.txt")).Name); + Assert.Equal("created", (await folder.CreateFolderAsync("created")).Name); + + Assert.Equal( + [ + $"read-text:{filePath}", + $"read-bytes:{filePath}", + $"files:{folderPath}", + $"folders:{folderPath}", + $"create-file:{folderPath}:created.txt", + $"create-folder:{folderPath}:created" + ], + requests); + } + finally + { + StoragePlatformServices.ReadTextAsync = previousTextReader; + StoragePlatformServices.ReadBytesAsync = previousByteReader; + StoragePlatformServices.EnumerateFilesAsync = previousFileEnumerator; + StoragePlatformServices.EnumerateFoldersAsync = previousFolderEnumerator; + StoragePlatformServices.CreateFileAsync = previousFileCreator; + StoragePlatformServices.CreateFolderAsync = previousFolderCreator; + } + } } diff --git a/src/ProGPU.WinUI/Controls/Frame.cs b/src/ProGPU.WinUI/Controls/Frame.cs new file mode 100644 index 00000000..801828b0 --- /dev/null +++ b/src/ProGPU.WinUI/Controls/Frame.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.UI.Xaml.Controls; + +/// +/// Typed page-navigation host with reusable page instances and deterministic back/forward +/// stacks. Registered factories are reflection-free and are the supported NativeAOT path. +/// +public class Frame : ContentControl +{ + private sealed record Entry(Type SourcePageType, object? Parameter, Page Page); + + private static readonly object FactoryGate = new(); + private static readonly Dictionary> PageFactories = new(); + private readonly List _backEntries = new(); + private readonly List _forwardEntries = new(); + private readonly Dictionary _pageCache = new(); + private Entry? _current; + private int _cacheSize = 10; + + public int CacheSize + { + get => _cacheSize; + set + { + if (value < 0) throw new ArgumentOutOfRangeException(nameof(value)); + _cacheSize = value; + TrimEnabledCache(); + } + } + + public bool CanGoBack => _backEntries.Count != 0; + public bool CanGoForward => _forwardEntries.Count != 0; + public Type? CurrentSourcePageType => _current?.SourcePageType; + public int BackStackDepth => _backEntries.Count; + public bool IsNavigationStackEnabled { get; set; } = true; + + public Type? SourcePageType + { + get => CurrentSourcePageType; + set + { + if (value is not null) + Navigate(value); + } + } + + public ObservableCollection BackStack { get; } = new(); + public ObservableCollection ForwardStack { get; } = new(); + + public event NavigatedEventHandler? Navigated; + public event NavigatingCancelEventHandler? Navigating; + public event NavigationFailedEventHandler? NavigationFailed; + public event NavigationStoppedEventHandler? NavigationStopped; + + public static void RegisterPageFactory(Func factory) + where TPage : Page + { + ArgumentNullException.ThrowIfNull(factory); + lock (FactoryGate) + PageFactories[typeof(TPage)] = () => factory(); + } + + public static void RegisterPageFactory() + where TPage : Page, new() => RegisterPageFactory(static () => new TPage()); + + public bool Navigate( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type sourcePageType) => + Navigate(sourcePageType, null); + + public bool Navigate( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type sourcePageType, + object? parameter) + { + ArgumentNullException.ThrowIfNull(sourcePageType); + return NavigateNew(sourcePageType, parameter); + } + + public void GoBack() + { + if (!CanGoBack) throw new InvalidOperationException("The frame has no back-stack entry."); + Entry target = _backEntries[^1]; + if (!TryNavigateExisting(target, NavigationMode.Back)) return; + + _backEntries.RemoveAt(_backEntries.Count - 1); + BackStack.RemoveAt(BackStack.Count - 1); + if (_currentBeforeTransition is { } previous && IsNavigationStackEnabled) + { + _forwardEntries.Add(previous); + ForwardStack.Add(new PageStackEntry(previous.SourcePageType, previous.Parameter)); + } + } + + public void GoForward() + { + if (!CanGoForward) throw new InvalidOperationException("The frame has no forward-stack entry."); + Entry target = _forwardEntries[^1]; + if (!TryNavigateExisting(target, NavigationMode.Forward)) return; + + _forwardEntries.RemoveAt(_forwardEntries.Count - 1); + ForwardStack.RemoveAt(ForwardStack.Count - 1); + if (_currentBeforeTransition is { } previous && IsNavigationStackEnabled) + { + _backEntries.Add(previous); + BackStack.Add(new PageStackEntry(previous.SourcePageType, previous.Parameter)); + } + } + + private Entry? _currentBeforeTransition; + + private bool NavigateNew(Type sourcePageType, object? parameter) + { + try + { + Page page = ResolvePage(sourcePageType); + var target = new Entry(sourcePageType, parameter, page); + if (!TryNavigateExisting(target, NavigationMode.New)) return false; + + if (_currentBeforeTransition is { } previous && IsNavigationStackEnabled) + { + _backEntries.Add(previous); + BackStack.Add(new PageStackEntry(previous.SourcePageType, previous.Parameter)); + } + _forwardEntries.Clear(); + ForwardStack.Clear(); + TrimEnabledCache(); + return true; + } + catch (Exception exception) + { + var args = new NavigationFailedEventArgs(exception, sourcePageType); + NavigationFailed?.Invoke(this, args); + if (args.Handled) return false; + throw; + } + } + + private bool TryNavigateExisting(Entry target, NavigationMode mode) + { + var navigating = new NavigatingCancelEventArgs(target.Parameter, target.SourcePageType, mode); + _current?.Page.RaiseNavigatingFrom(navigating); + Navigating?.Invoke(this, navigating); + if (navigating.Cancel) + { + NavigationStopped?.Invoke( + this, + new NavigationEventArgs(_current?.Page, target.Parameter, target.SourcePageType, mode)); + return false; + } + + Entry? previous = _current; + _currentBeforeTransition = previous; + _current = target; + target.Page.SetFrame(this); + Content = target.Page; + + if (previous is not null) + { + previous.Page.RaiseNavigatedFrom( + new NavigationEventArgs(target.Page, target.Parameter, target.SourcePageType, mode)); + } + + var navigated = new NavigationEventArgs(target.Page, target.Parameter, target.SourcePageType, mode); + target.Page.RaiseNavigatedTo(navigated); + Navigated?.Invoke(this, navigated); + return true; + } + + private static Page CreatePage( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type sourcePageType) + { + if (!typeof(Page).IsAssignableFrom(sourcePageType)) + throw new ArgumentException("The source page type must derive from Page.", nameof(sourcePageType)); + + Func? factory; + lock (FactoryGate) + PageFactories.TryGetValue(sourcePageType, out factory); + if (factory is not null) + return factory(); + + // Compatibility fallback for existing desktop code. NativeAOT applications should + // register a typed factory so trimming and activation stay reflection-free. + return Activator.CreateInstance(sourcePageType) as Page ?? + throw new InvalidOperationException($"Unable to construct page type '{sourcePageType.FullName}'."); + } + + private Page ResolvePage( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type sourcePageType) + { + if (_pageCache.TryGetValue(sourcePageType, out Page? cached)) + return cached; + + Page page = CreatePage(sourcePageType); + if (page.NavigationCacheMode != NavigationCacheMode.Disabled) + _pageCache[sourcePageType] = page; + return page; + } + + private void TrimEnabledCache() + { + if (_pageCache.Count <= CacheSize) return; + + var retained = new HashSet(); + if (_current is not null) retained.Add(_current.Page); + foreach (Entry entry in _backEntries) retained.Add(entry.Page); + foreach (Entry entry in _forwardEntries) retained.Add(entry.Page); + + var removable = new List(); + foreach ((Type type, Page page) in _pageCache) + { + if (_pageCache.Count - removable.Count <= CacheSize) break; + if (page.NavigationCacheMode == NavigationCacheMode.Enabled && !retained.Contains(page)) + removable.Add(type); + } + foreach (Type type in removable) + _pageCache.Remove(type); + } +} diff --git a/src/ProGPU.WinUI/Controls/Image.cs b/src/ProGPU.WinUI/Controls/Image.cs index b0ed5951..99eba781 100644 --- a/src/ProGPU.WinUI/Controls/Image.cs +++ b/src/ProGPU.WinUI/Controls/Image.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Xaml.Markup; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; using System; using System.IO; using System.Numerics; @@ -14,14 +15,6 @@ namespace Microsoft.UI.Xaml.Controls; -public enum Stretch -{ - None, - Fill, - Uniform, - UniformToFill -} - /// /// Immutable encoded image payload. Decoding is cached on first render and GPU upload /// remains demand-driven, so constructing or measuring the source does not initialize WebGPU. diff --git a/src/ProGPU.WinUI/Controls/ItemsPanels.cs b/src/ProGPU.WinUI/Controls/ItemsPanels.cs new file mode 100644 index 00000000..0d6ba527 --- /dev/null +++ b/src/ProGPU.WinUI/Controls/ItemsPanels.cs @@ -0,0 +1,215 @@ +using System; +using Microsoft.UI.Xaml.Controls.Primitives; + +namespace Microsoft.UI.Xaml.Controls; + +public enum PanelScrollingDirection +{ + None = 0, + Forward = 1, + Backward = 2 +} + +public enum ItemsUpdatingScrollMode +{ + KeepItemsInView = 0, + KeepScrollOffset = 1, + KeepLastItemInView = 2 +} + +/// +/// WinUI-compatible items stack panel backed by ProGPU's variable-size indexed +/// virtualization engine. Group-specific properties are retained for grouped hosts. +/// +public sealed class ItemsStackPanel : VirtualizingStackPanel +{ + public ItemsStackPanel() + { + base.CacheLength = 2f; + base.Orientation = Orientation.Vertical; + } + + public static readonly DependencyProperty GroupPaddingProperty = Register(nameof(GroupPadding), typeof(Thickness), default(Thickness)); + public static readonly DependencyProperty OrientationProperty = Register(nameof(Orientation), typeof(Orientation), Orientation.Vertical, OnOrientationChanged); + public static readonly DependencyProperty GroupHeaderPlacementProperty = Register(nameof(GroupHeaderPlacement), typeof(GroupHeaderPlacement), GroupHeaderPlacement.Top); + public static readonly DependencyProperty ItemsUpdatingScrollModeProperty = Register(nameof(ItemsUpdatingScrollMode), typeof(ItemsUpdatingScrollMode), ItemsUpdatingScrollMode.KeepItemsInView); + public static readonly DependencyProperty CacheLengthProperty = Register(nameof(CacheLength), typeof(double), 4d, OnCacheLengthChanged); + public static readonly DependencyProperty AreStickyGroupHeadersEnabledProperty = Register(nameof(AreStickyGroupHeadersEnabled), typeof(bool), true); + + public Thickness GroupPadding + { + get => (Thickness)(GetValue(GroupPaddingProperty) ?? default(Thickness)); + set => SetValue(GroupPaddingProperty, value); + } + + public new Orientation Orientation + { + get => (Orientation)(GetValue(OrientationProperty) ?? Orientation.Vertical); + set => SetValue(OrientationProperty, value); + } + + public GroupHeaderPlacement GroupHeaderPlacement + { + get => (GroupHeaderPlacement)(GetValue(GroupHeaderPlacementProperty) ?? GroupHeaderPlacement.Top); + set => SetValue(GroupHeaderPlacementProperty, value); + } + + public ItemsUpdatingScrollMode ItemsUpdatingScrollMode + { + get => (ItemsUpdatingScrollMode)(GetValue(ItemsUpdatingScrollModeProperty) ?? ItemsUpdatingScrollMode.KeepItemsInView); + set => SetValue(ItemsUpdatingScrollModeProperty, value); + } + + public new double CacheLength + { + get => (double)(GetValue(CacheLengthProperty) ?? 4d); + set + { + if (!double.IsFinite(value) || value < 0d) throw new ArgumentOutOfRangeException(nameof(value)); + SetValue(CacheLengthProperty, value); + } + } + + public bool AreStickyGroupHeadersEnabled + { + get => (bool)(GetValue(AreStickyGroupHeadersEnabledProperty) ?? true); + set => SetValue(AreStickyGroupHeadersEnabledProperty, value); + } + + private static void OnOrientationChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((VirtualizingStackPanel)(ItemsStackPanel)sender).Orientation = (Orientation)(args.NewValue ?? Orientation.Vertical); + + private static void OnCacheLengthChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((VirtualizingStackPanel)(ItemsStackPanel)sender).CacheLength = (float)((double)(args.NewValue ?? 4d) / 2d); + + private static DependencyProperty Register( + string name, + Type type, + object defaultValue, + PropertyChangedCallback? callback = null) => + DependencyProperty.Register( + name, + type, + typeof(ItemsStackPanel), + new PropertyMetadata(defaultValue, callback) { AffectsMeasure = true, AffectsArrange = true }); +} + +/// +/// WinUI-compatible wrapping items panel backed by bounded uniform-cell virtualization. +/// Only visible and cache-range containers are materialized, for O(V + B) active storage. +/// +public sealed class ItemsWrapGrid : UniformVirtualizingGridPanel +{ + public ItemsWrapGrid() + { + base.Orientation = Orientation.Vertical; + base.ItemWidth = float.NaN; + base.ItemHeight = float.NaN; + base.CacheLength = 2f; + } + + public static readonly DependencyProperty GroupPaddingProperty = Register(nameof(GroupPadding), typeof(Thickness), default(Thickness)); + public static readonly DependencyProperty OrientationProperty = Register(nameof(Orientation), typeof(Orientation), Orientation.Vertical, OnOrientationChanged); + public static readonly DependencyProperty MaximumRowsOrColumnsProperty = Register(nameof(MaximumRowsOrColumns), typeof(int), -1, OnMaximumChanged); + public static readonly DependencyProperty ItemWidthProperty = Register(nameof(ItemWidth), typeof(double), double.NaN, OnItemWidthChanged); + public static readonly DependencyProperty ItemHeightProperty = Register(nameof(ItemHeight), typeof(double), double.NaN, OnItemHeightChanged); + public static readonly DependencyProperty GroupHeaderPlacementProperty = Register(nameof(GroupHeaderPlacement), typeof(GroupHeaderPlacement), GroupHeaderPlacement.Top); + public static readonly DependencyProperty CacheLengthProperty = Register(nameof(CacheLength), typeof(double), 4d, OnCacheLengthChanged); + public static readonly DependencyProperty AreStickyGroupHeadersEnabledProperty = Register(nameof(AreStickyGroupHeadersEnabled), typeof(bool), true); + + public Thickness GroupPadding + { + get => (Thickness)(GetValue(GroupPaddingProperty) ?? default(Thickness)); + set => SetValue(GroupPaddingProperty, value); + } + + public new Orientation Orientation + { + get => (Orientation)(GetValue(OrientationProperty) ?? Orientation.Vertical); + set => SetValue(OrientationProperty, value); + } + + public new int MaximumRowsOrColumns + { + get => (int)(GetValue(MaximumRowsOrColumnsProperty) ?? -1); + set + { + if (value == 0 || value < -1) throw new ArgumentOutOfRangeException(nameof(value)); + SetValue(MaximumRowsOrColumnsProperty, value); + } + } + + public new double ItemWidth + { + get => (double)(GetValue(ItemWidthProperty) ?? double.NaN); + set + { + ValidateItemSize(value, nameof(value)); + SetValue(ItemWidthProperty, value); + } + } + + public new double ItemHeight + { + get => (double)(GetValue(ItemHeightProperty) ?? double.NaN); + set + { + ValidateItemSize(value, nameof(value)); + SetValue(ItemHeightProperty, value); + } + } + + public GroupHeaderPlacement GroupHeaderPlacement + { + get => (GroupHeaderPlacement)(GetValue(GroupHeaderPlacementProperty) ?? GroupHeaderPlacement.Top); + set => SetValue(GroupHeaderPlacementProperty, value); + } + + public new double CacheLength + { + get => (double)(GetValue(CacheLengthProperty) ?? 4d); + set + { + if (!double.IsFinite(value) || value < 0d) throw new ArgumentOutOfRangeException(nameof(value)); + SetValue(CacheLengthProperty, value); + } + } + + public bool AreStickyGroupHeadersEnabled + { + get => (bool)(GetValue(AreStickyGroupHeadersEnabledProperty) ?? true); + set => SetValue(AreStickyGroupHeadersEnabledProperty, value); + } + + private static void OnOrientationChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((UniformVirtualizingGridPanel)(ItemsWrapGrid)sender).Orientation = (Orientation)(args.NewValue ?? Orientation.Vertical); + + private static void OnMaximumChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((UniformVirtualizingGridPanel)(ItemsWrapGrid)sender).MaximumRowsOrColumns = (int)(args.NewValue ?? -1); + + private static void OnItemWidthChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((UniformVirtualizingGridPanel)(ItemsWrapGrid)sender).ItemWidth = (float)(double)(args.NewValue ?? double.NaN); + + private static void OnItemHeightChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((UniformVirtualizingGridPanel)(ItemsWrapGrid)sender).ItemHeight = (float)(double)(args.NewValue ?? double.NaN); + + private static void OnCacheLengthChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) => + ((UniformVirtualizingGridPanel)(ItemsWrapGrid)sender).CacheLength = (float)((double)(args.NewValue ?? 4d) / 2d); + + private static DependencyProperty Register( + string name, + Type type, + object defaultValue, + PropertyChangedCallback? callback = null) => + DependencyProperty.Register( + name, + type, + typeof(ItemsWrapGrid), + new PropertyMetadata(defaultValue, callback) { AffectsMeasure = true, AffectsArrange = true }); + + private static void ValidateItemSize(double value, string parameterName) + { + if (!double.IsNaN(value) && (!double.IsFinite(value) || value <= 0d)) + throw new ArgumentOutOfRangeException(parameterName); + } +} diff --git a/src/ProGPU.WinUI/Controls/MarkdownParser.cs b/src/ProGPU.WinUI/Controls/MarkdownParser.cs index 0d379a06..f195812f 100644 --- a/src/ProGPU.WinUI/Controls/MarkdownParser.cs +++ b/src/ProGPU.WinUI/Controls/MarkdownParser.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; using System; using System.Collections.Generic; using System.Text; diff --git a/src/ProGPU.WinUI/Controls/Page.cs b/src/ProGPU.WinUI/Controls/Page.cs new file mode 100644 index 00000000..4baa7b3e --- /dev/null +++ b/src/ProGPU.WinUI/Controls/Page.cs @@ -0,0 +1,74 @@ +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.UI.Xaml.Controls; + +public class AppBar : ContentControl +{ + public bool IsOpen { get; set; } + public bool IsSticky { get; set; } +} + +public class Page : UserControl +{ + private Frame? _frame; + + public static readonly DependencyProperty FrameProperty = + DependencyProperty.Register( + nameof(Frame), + typeof(Frame), + typeof(Page), + new PropertyMetadata(null)); + + public static readonly DependencyProperty TopAppBarProperty = + DependencyProperty.Register( + nameof(TopAppBar), + typeof(AppBar), + typeof(Page), + new PropertyMetadata(null)); + + public static readonly DependencyProperty BottomAppBarProperty = + DependencyProperty.Register( + nameof(BottomAppBar), + typeof(AppBar), + typeof(Page), + new PropertyMetadata(null)); + + public Frame? Frame => _frame; + + public NavigationCacheMode NavigationCacheMode { get; set; } = NavigationCacheMode.Disabled; + + public AppBar? TopAppBar + { + get => GetValue(TopAppBarProperty) as AppBar; + set => SetValue(TopAppBarProperty, value); + } + + public AppBar? BottomAppBar + { + get => GetValue(BottomAppBarProperty) as AppBar; + set => SetValue(BottomAppBarProperty, value); + } + + protected virtual void OnNavigatedFrom(NavigationEventArgs e) + { + } + + protected virtual void OnNavigatedTo(NavigationEventArgs e) + { + } + + protected virtual void OnNavigatingFrom(NavigatingCancelEventArgs e) + { + } + + internal void SetFrame(Frame? frame) + { + if (ReferenceEquals(_frame, frame)) return; + _frame = frame; + SetValue(FrameProperty, frame); + } + + internal void RaiseNavigatedFrom(NavigationEventArgs e) => OnNavigatedFrom(e); + internal void RaiseNavigatedTo(NavigationEventArgs e) => OnNavigatedTo(e); + internal void RaiseNavigatingFrom(NavigatingCancelEventArgs e) => OnNavigatingFrom(e); +} diff --git a/src/ProGPU.WinUI/Controls/Primitives/GroupHeaderPlacement.cs b/src/ProGPU.WinUI/Controls/Primitives/GroupHeaderPlacement.cs new file mode 100644 index 00000000..1a33f9b9 --- /dev/null +++ b/src/ProGPU.WinUI/Controls/Primitives/GroupHeaderPlacement.cs @@ -0,0 +1,7 @@ +namespace Microsoft.UI.Xaml.Controls.Primitives; + +public enum GroupHeaderPlacement +{ + Top = 0, + Left = 1 +} diff --git a/src/ProGPU.WinUI/Controls/RelativePanel.cs b/src/ProGPU.WinUI/Controls/RelativePanel.cs new file mode 100644 index 00000000..336c96f6 --- /dev/null +++ b/src/ProGPU.WinUI/Controls/RelativePanel.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using ProGPU.Layout; +using ProGPU.Scene; + +namespace Microsoft.UI.Xaml.Controls; + +/// +/// Constraint panel implementing WinUI's sibling and panel-edge relationships. +/// The dependency graph is solved once per layout pass in O(N + E) graph work, +/// where N is the child count and E is the number of active relationships. +/// +public class RelativePanel : Panel +{ + public static readonly DependencyProperty LeftOfProperty = RegisterObject(nameof(LeftOfProperty)); + public static readonly DependencyProperty AboveProperty = RegisterObject(nameof(AboveProperty)); + public static readonly DependencyProperty RightOfProperty = RegisterObject(nameof(RightOfProperty)); + public static readonly DependencyProperty BelowProperty = RegisterObject(nameof(BelowProperty)); + public static readonly DependencyProperty AlignHorizontalCenterWithProperty = RegisterObject(nameof(AlignHorizontalCenterWithProperty)); + public static readonly DependencyProperty AlignVerticalCenterWithProperty = RegisterObject(nameof(AlignVerticalCenterWithProperty)); + public static readonly DependencyProperty AlignLeftWithProperty = RegisterObject(nameof(AlignLeftWithProperty)); + public static readonly DependencyProperty AlignTopWithProperty = RegisterObject(nameof(AlignTopWithProperty)); + public static readonly DependencyProperty AlignRightWithProperty = RegisterObject(nameof(AlignRightWithProperty)); + public static readonly DependencyProperty AlignBottomWithProperty = RegisterObject(nameof(AlignBottomWithProperty)); + public static readonly DependencyProperty AlignLeftWithPanelProperty = RegisterBoolean(nameof(AlignLeftWithPanelProperty)); + public static readonly DependencyProperty AlignTopWithPanelProperty = RegisterBoolean(nameof(AlignTopWithPanelProperty)); + public static readonly DependencyProperty AlignRightWithPanelProperty = RegisterBoolean(nameof(AlignRightWithPanelProperty)); + public static readonly DependencyProperty AlignBottomWithPanelProperty = RegisterBoolean(nameof(AlignBottomWithPanelProperty)); + public static readonly DependencyProperty AlignHorizontalCenterWithPanelProperty = RegisterBoolean(nameof(AlignHorizontalCenterWithPanelProperty)); + public static readonly DependencyProperty AlignVerticalCenterWithPanelProperty = RegisterBoolean(nameof(AlignVerticalCenterWithPanelProperty)); + + private static DependencyProperty RegisterObject(string identifierName) => + DependencyProperty.RegisterAttached( + identifierName[..^"Property".Length], + typeof(object), + typeof(RelativePanel), + new PropertyMetadata(null, OnConstraintChanged)); + + private static DependencyProperty RegisterBoolean(string identifierName) => + DependencyProperty.RegisterAttached( + identifierName[..^"Property".Length], + typeof(bool), + typeof(RelativePanel), + new PropertyMetadata(false, OnConstraintChanged)); + + private static void OnConstraintChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + if (sender is Visual { Parent: RelativePanel panel }) + { + panel.InvalidateMeasure(); + panel.InvalidateArrange(); + panel.Invalidate(); + } + } + + public static object? GetLeftOf(UIElement element) => GetObject(element, LeftOfProperty); + public static void SetLeftOf(UIElement element, object? value) => SetObject(element, LeftOfProperty, value); + public static object? GetAbove(UIElement element) => GetObject(element, AboveProperty); + public static void SetAbove(UIElement element, object? value) => SetObject(element, AboveProperty, value); + public static object? GetRightOf(UIElement element) => GetObject(element, RightOfProperty); + public static void SetRightOf(UIElement element, object? value) => SetObject(element, RightOfProperty, value); + public static object? GetBelow(UIElement element) => GetObject(element, BelowProperty); + public static void SetBelow(UIElement element, object? value) => SetObject(element, BelowProperty, value); + public static object? GetAlignHorizontalCenterWith(UIElement element) => GetObject(element, AlignHorizontalCenterWithProperty); + public static void SetAlignHorizontalCenterWith(UIElement element, object? value) => SetObject(element, AlignHorizontalCenterWithProperty, value); + public static object? GetAlignVerticalCenterWith(UIElement element) => GetObject(element, AlignVerticalCenterWithProperty); + public static void SetAlignVerticalCenterWith(UIElement element, object? value) => SetObject(element, AlignVerticalCenterWithProperty, value); + public static object? GetAlignLeftWith(UIElement element) => GetObject(element, AlignLeftWithProperty); + public static void SetAlignLeftWith(UIElement element, object? value) => SetObject(element, AlignLeftWithProperty, value); + public static object? GetAlignTopWith(UIElement element) => GetObject(element, AlignTopWithProperty); + public static void SetAlignTopWith(UIElement element, object? value) => SetObject(element, AlignTopWithProperty, value); + public static object? GetAlignRightWith(UIElement element) => GetObject(element, AlignRightWithProperty); + public static void SetAlignRightWith(UIElement element, object? value) => SetObject(element, AlignRightWithProperty, value); + public static object? GetAlignBottomWith(UIElement element) => GetObject(element, AlignBottomWithProperty); + public static void SetAlignBottomWith(UIElement element, object? value) => SetObject(element, AlignBottomWithProperty, value); + public static bool GetAlignLeftWithPanel(UIElement element) => GetBoolean(element, AlignLeftWithPanelProperty); + public static void SetAlignLeftWithPanel(UIElement element, bool value) => SetBoolean(element, AlignLeftWithPanelProperty, value); + public static bool GetAlignTopWithPanel(UIElement element) => GetBoolean(element, AlignTopWithPanelProperty); + public static void SetAlignTopWithPanel(UIElement element, bool value) => SetBoolean(element, AlignTopWithPanelProperty, value); + public static bool GetAlignRightWithPanel(UIElement element) => GetBoolean(element, AlignRightWithPanelProperty); + public static void SetAlignRightWithPanel(UIElement element, bool value) => SetBoolean(element, AlignRightWithPanelProperty, value); + public static bool GetAlignBottomWithPanel(UIElement element) => GetBoolean(element, AlignBottomWithPanelProperty); + public static void SetAlignBottomWithPanel(UIElement element, bool value) => SetBoolean(element, AlignBottomWithPanelProperty, value); + public static bool GetAlignHorizontalCenterWithPanel(UIElement element) => GetBoolean(element, AlignHorizontalCenterWithPanelProperty); + public static void SetAlignHorizontalCenterWithPanel(UIElement element, bool value) => SetBoolean(element, AlignHorizontalCenterWithPanelProperty, value); + public static bool GetAlignVerticalCenterWithPanel(UIElement element) => GetBoolean(element, AlignVerticalCenterWithPanelProperty); + public static void SetAlignVerticalCenterWithPanel(UIElement element, bool value) => SetBoolean(element, AlignVerticalCenterWithPanelProperty, value); + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + foreach (Visual child in VisualChildren) + { + if (child is LayoutNode node) + node.Measure(availableSize); + } + + float width = float.IsFinite(availableSize.X) ? availableSize.X : 0f; + float height = float.IsFinite(availableSize.Y) ? availableSize.Y : 0f; + Dictionary rects = Solve(new Rect(0f, 0f, width, height)); + if (rects.Count == 0) return Vector2.Zero; + + float minX = 0f; + float minY = 0f; + float maxX = 0f; + float maxY = 0f; + foreach (Rect rect in rects.Values) + { + minX = Math.Min(minX, rect.X); + minY = Math.Min(minY, rect.Y); + maxX = Math.Max(maxX, rect.X + rect.Width); + maxY = Math.Max(maxY, rect.Y + rect.Height); + } + + return new Vector2(maxX - minX, maxY - minY); + } + + protected override void ArrangeOverride(Rect arrangeRect) + { + Dictionary rects = Solve(arrangeRect); + foreach ((LayoutNode child, Rect rect) in rects) + child.Arrange(rect); + } + + private Dictionary Solve(Rect panelRect) + { + var nodes = new List(VisualChildren.Count); + var elements = new Dictionary(); + var names = new Dictionary(StringComparer.Ordinal); + foreach (Visual child in VisualChildren) + { + if (child is not LayoutNode node) continue; + nodes.Add(node); + if (child is UIElement element) + { + elements[node] = element; + if (element is FrameworkElement { Name.Length: > 0 } named) + names[named.Name] = node; + } + } + + var nodeSet = new HashSet(nodes); + var result = new Dictionary(nodes.Count); + var visiting = new HashSet(); + + Rect Resolve(LayoutNode node) + { + if (result.TryGetValue(node, out Rect resolved)) return resolved; + if (!visiting.Add(node)) + throw new InvalidOperationException("RelativePanel constraints contain a dependency cycle."); + + float width = Math.Max(0f, node.DesiredSize.X); + float height = Math.Max(0f, node.DesiredSize.Y); + float? left = null; + float? right = null; + float? horizontalCenter = null; + float? top = null; + float? bottom = null; + float? verticalCenter = null; + + if (elements.TryGetValue(node, out UIElement? element)) + { + if (GetAlignLeftWithPanel(element)) left = panelRect.X; + if (GetAlignRightWithPanel(element)) right = panelRect.X + panelRect.Width; + if (GetAlignHorizontalCenterWithPanel(element)) horizontalCenter = panelRect.X + panelRect.Width / 2f; + if (GetAlignTopWithPanel(element)) top = panelRect.Y; + if (GetAlignBottomWithPanel(element)) bottom = panelRect.Y + panelRect.Height; + if (GetAlignVerticalCenterWithPanel(element)) verticalCenter = panelRect.Y + panelRect.Height / 2f; + + if (TryResolveTarget(element, LeftOfProperty, out LayoutNode? target)) + right = Resolve(target).X; + if (TryResolveTarget(element, RightOfProperty, out target)) + { + Rect targetRect = Resolve(target); + left = targetRect.X + targetRect.Width; + } + if (TryResolveTarget(element, AlignLeftWithProperty, out target)) + left = Resolve(target).X; + if (TryResolveTarget(element, AlignRightWithProperty, out target)) + { + Rect targetRect = Resolve(target); + right = targetRect.X + targetRect.Width; + } + if (TryResolveTarget(element, AlignHorizontalCenterWithProperty, out target)) + { + Rect targetRect = Resolve(target); + horizontalCenter = targetRect.X + targetRect.Width / 2f; + } + + if (TryResolveTarget(element, AboveProperty, out target)) + bottom = Resolve(target).Y; + if (TryResolveTarget(element, BelowProperty, out target)) + { + Rect targetRect = Resolve(target); + top = targetRect.Y + targetRect.Height; + } + if (TryResolveTarget(element, AlignTopWithProperty, out target)) + top = Resolve(target).Y; + if (TryResolveTarget(element, AlignBottomWithProperty, out target)) + { + Rect targetRect = Resolve(target); + bottom = targetRect.Y + targetRect.Height; + } + if (TryResolveTarget(element, AlignVerticalCenterWithProperty, out target)) + { + Rect targetRect = Resolve(target); + verticalCenter = targetRect.Y + targetRect.Height / 2f; + } + } + + float x; + if (left.HasValue && right.HasValue) + { + x = left.Value; + width = Math.Max(0f, right.Value - left.Value); + } + else if (horizontalCenter.HasValue) + x = horizontalCenter.Value - width / 2f; + else if (left.HasValue) + x = left.Value; + else if (right.HasValue) + x = right.Value - width; + else + x = panelRect.X; + + float y; + if (top.HasValue && bottom.HasValue) + { + y = top.Value; + height = Math.Max(0f, bottom.Value - top.Value); + } + else if (verticalCenter.HasValue) + y = verticalCenter.Value - height / 2f; + else if (top.HasValue) + y = top.Value; + else if (bottom.HasValue) + y = bottom.Value - height; + else + y = panelRect.Y; + + visiting.Remove(node); + resolved = new Rect(x, y, width, height); + result[node] = resolved; + return resolved; + } + + bool TryResolveTarget(UIElement element, DependencyProperty property, out LayoutNode target) + { + object? value = element.GetValue(property); + if (value is LayoutNode node && nodeSet.Contains(node)) + { + target = node; + return true; + } + if (value is string name && names.TryGetValue(name, out LayoutNode? namedNode)) + { + target = namedNode; + return true; + } + if (value is not null) + throw new InvalidOperationException("RelativePanel relationship targets must be sibling elements or sibling names."); + target = null!; + return false; + } + + foreach (LayoutNode node in nodes) + Resolve(node); + return result; + } + + private static object? GetObject(UIElement element, DependencyProperty property) + { + ArgumentNullException.ThrowIfNull(element); + return element.GetValue(property); + } + + private static void SetObject(UIElement element, DependencyProperty property, object? value) + { + ArgumentNullException.ThrowIfNull(element); + element.SetValue(property, value); + } + + private static bool GetBoolean(UIElement element, DependencyProperty property) + { + ArgumentNullException.ThrowIfNull(element); + return (bool)(element.GetValue(property) ?? false); + } + + private static void SetBoolean(UIElement element, DependencyProperty property, bool value) + { + ArgumentNullException.ThrowIfNull(element); + element.SetValue(property, value); + } +} diff --git a/src/ProGPU.WinUI/Controls/RichEditBox.cs b/src/ProGPU.WinUI/Controls/RichEditBox.cs index 2ac0b0ad..d7b413cc 100644 --- a/src/ProGPU.WinUI/Controls/RichEditBox.cs +++ b/src/ProGPU.WinUI/Controls/RichEditBox.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Xaml.Markup; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; using Microsoft.UI.Text; using System; using System.Collections.Generic; diff --git a/src/ProGPU.WinUI/Controls/UniformVirtualizingGridPanel.cs b/src/ProGPU.WinUI/Controls/UniformVirtualizingGridPanel.cs index e01e82e6..ce37383a 100644 --- a/src/ProGPU.WinUI/Controls/UniformVirtualizingGridPanel.cs +++ b/src/ProGPU.WinUI/Controls/UniformVirtualizingGridPanel.cs @@ -12,6 +12,11 @@ public class UniformVirtualizingGridPanel : VirtualizingPanel private int _itemsCount = 0; private float _itemWidth = 80f; private float _itemHeight = 80f; + private float _measuredItemWidth = 80f; + private float _measuredItemHeight = 80f; + private Orientation _orientation = Orientation.Horizontal; + private int _maximumRowsOrColumns = -1; + private float _cacheLength; public UniformVirtualizingGridPanel() { @@ -80,9 +85,12 @@ public float ItemWidth get => _itemWidth; set { + if (!float.IsNaN(value) && (!float.IsFinite(value) || value <= 0f)) + throw new ArgumentOutOfRangeException(nameof(value)); if (_itemWidth != value) { _itemWidth = value; + _measuredItemWidth = float.IsNaN(value) ? 80f : value; UpdateViewport(); Invalidate(); } @@ -94,20 +102,76 @@ public float ItemHeight get => _itemHeight; set { + if (!float.IsNaN(value) && (!float.IsFinite(value) || value <= 0f)) + throw new ArgumentOutOfRangeException(nameof(value)); if (_itemHeight != value) { _itemHeight = value; + _measuredItemHeight = float.IsNaN(value) ? 80f : value; UpdateViewport(); Invalidate(); } } } - public int ColumnsCount => Math.Max(1, (int)Math.Floor(Math.Max(1f, ViewportWidth) / ItemWidth)); - - public int RowsCount => (int)Math.Ceiling((double)ItemsCount / ColumnsCount); + public Orientation Orientation + { + get => _orientation; + set + { + if (_orientation == value) return; + _orientation = value; + UpdateViewport(); + InvalidateMeasure(); + Invalidate(); + } + } + + public int MaximumRowsOrColumns + { + get => _maximumRowsOrColumns; + set + { + if (value == 0 || value < -1) throw new ArgumentOutOfRangeException(nameof(value)); + if (_maximumRowsOrColumns == value) return; + _maximumRowsOrColumns = value; + UpdateViewport(); + InvalidateMeasure(); + } + } + + public float CacheLength + { + get => _cacheLength; + set + { + if (!float.IsFinite(value) || value < 0f) throw new ArgumentOutOfRangeException(nameof(value)); + if (_cacheLength == value) return; + _cacheLength = value; + UpdateViewport(); + } + } + + private float EffectiveItemWidth => float.IsNaN(_itemWidth) ? _measuredItemWidth : _itemWidth; + private float EffectiveItemHeight => float.IsNaN(_itemHeight) ? _measuredItemHeight : _itemHeight; + + public int ColumnsCount => Orientation == Orientation.Horizontal + ? GetPrimaryCount(ViewportWidth, EffectiveItemWidth) + : (int)Math.Ceiling((double)ItemsCount / GetPrimaryCount(ViewportHeight, EffectiveItemHeight)); + + public int RowsCount => Orientation == Orientation.Vertical + ? GetPrimaryCount(ViewportHeight, EffectiveItemHeight) + : (int)Math.Ceiling((double)ItemsCount / GetPrimaryCount(ViewportWidth, EffectiveItemWidth)); + + public override float TotalVirtualHeight => Orientation == Orientation.Horizontal + ? RowsCount * EffectiveItemHeight + : Math.Max(ViewportHeight, RowsCount * EffectiveItemHeight); + + public override float TotalVirtualWidth => Orientation == Orientation.Vertical + ? ColumnsCount * EffectiveItemWidth + : Math.Max(ViewportWidth, ColumnsCount * EffectiveItemWidth); - public override float TotalVirtualHeight => RowsCount * ItemHeight; + public override bool IsHorizontal => Orientation == Orientation.Vertical; protected override void OnScrollOffsetChanged(float newOffset) { @@ -116,8 +180,12 @@ protected override void OnScrollOffsetChanged(float newOffset) protected override Vector2 MeasureOverride(Vector2 availableSize) { - float width = float.IsInfinity(availableSize.X) ? 400f : availableSize.X; - float height = float.IsInfinity(availableSize.Y) ? TotalVirtualHeight : availableSize.Y; + float width = float.IsInfinity(availableSize.X) + ? Orientation == Orientation.Vertical ? TotalVirtualWidth : 400f + : availableSize.X; + float height = float.IsInfinity(availableSize.Y) + ? Orientation == Orientation.Horizontal ? TotalVirtualHeight : 400f + : availableSize.Y; // Perform active viewport computation during layout pass UpdateViewport(); @@ -150,18 +218,36 @@ private void UpdateViewport() return; } - int cols = ColumnsCount; - int rows = RowsCount; + if (float.IsNaN(_itemWidth) || float.IsNaN(_itemHeight)) + { + Visual first = GetOrCreateVisual(0, createVisual, itemsControl, ownerBindVisual, directBindVisual); + if (first is LayoutNode firstNode) + { + firstNode.Measure(new Vector2( + float.IsNaN(_itemWidth) ? float.PositiveInfinity : _itemWidth, + float.IsNaN(_itemHeight) ? float.PositiveInfinity : _itemHeight)); + if (float.IsNaN(_itemWidth)) _measuredItemWidth = Math.Max(1f, firstNode.DesiredSize.X); + if (float.IsNaN(_itemHeight)) _measuredItemHeight = Math.Max(1f, firstNode.DesiredSize.Y); + } + } - // 1. Calculate visible item range - int startRow = (int)Math.Floor(ScrollOffset / ItemHeight); - int endRow = (int)Math.Ceiling((ScrollOffset + viewportHeight) / ItemHeight); + float itemWidth = EffectiveItemWidth; + float itemHeight = EffectiveItemHeight; + int primaryCount = Orientation == Orientation.Horizontal + ? GetPrimaryCount(viewportWidth, itemWidth) + : GetPrimaryCount(viewportHeight, itemHeight); + int groupCount = (int)Math.Ceiling((double)itemsCount / primaryCount); + float viewportLength = Orientation == Orientation.Horizontal ? viewportHeight : viewportWidth; + float itemLength = Orientation == Orientation.Horizontal ? itemHeight : itemWidth; + float realizationPadding = viewportLength * CacheLength; + float realizationStart = Math.Max(0f, ScrollOffset - realizationPadding); + float realizationEnd = ScrollOffset + viewportLength + realizationPadding; - startRow = Math.Clamp(startRow, 0, rows - 1); - endRow = Math.Clamp(endRow, 0, rows - 1); + int startGroup = Math.Clamp((int)Math.Floor(realizationStart / itemLength), 0, groupCount - 1); + int endGroup = Math.Clamp((int)Math.Ceiling(realizationEnd / itemLength), 0, groupCount - 1); - int startIdx = startRow * cols; - int endIdx = Math.Min(itemsCount - 1, (endRow + 1) * cols - 1); + int startIdx = startGroup * primaryCount; + int endIdx = Math.Min(itemsCount - 1, (endGroup + 1) * primaryCount - 1); startIdx = Math.Clamp(startIdx, 0, itemsCount - 1); endIdx = Math.Clamp(endIdx, 0, itemsCount - 1); @@ -189,53 +275,62 @@ private void UpdateViewport() // 3. Position and Bind newly visible items for (int i = startIdx; i <= endIdx; i++) { - int row = i / cols; - int col = i % cols; - - if (!_activeVisuals.TryGetValue(i, out var visual)) - { - // Grab from pool or allocate new - visual = _recycledVisuals.Count > 0 ? _recycledVisuals.Pop() : createVisual(); - - // Bind dataset properties - if (itemsControl != null) - { - var item = itemsControl.GetItemAt(i); - if (item != null) - { - ownerBindVisual!(visual, item, i); - } - } - else - { - directBindVisual!(visual, i); - } - - _activeVisuals[i] = visual; - AddChild(visual); - } + int row = Orientation == Orientation.Horizontal ? i / primaryCount : i % primaryCount; + int col = Orientation == Orientation.Horizontal ? i % primaryCount : i / primaryCount; + Visual visual = GetOrCreateVisual(i, createVisual, itemsControl, ownerBindVisual, directBindVisual); // Calculate screen position relative to viewport - float posX = col * ItemWidth; - float posY = row * ItemHeight; + float posX = col * itemWidth; + float posY = row * itemHeight; if (ScrollViewerOwner == null) { - posY = MathF.Round(posY - ScrollOffset); + if (Orientation == Orientation.Horizontal) posY = MathF.Round(posY - ScrollOffset); + else posX = MathF.Round(posX - ScrollOffset); } // Position child visual node visual.Offset = new Vector2(posX, posY); - visual.Size = new Vector2(ItemWidth, ItemHeight); + visual.Size = new Vector2(itemWidth, itemHeight); // If child is a LayoutNode, arrange it! if (visual is LayoutNode childNode) { - childNode.Measure(new Vector2(ItemWidth, ItemHeight)); - childNode.Arrange(new Rect(posX, posY, ItemWidth, ItemHeight)); + childNode.Measure(new Vector2(itemWidth, itemHeight)); + childNode.Arrange(new Rect(posX, posY, itemWidth, itemHeight)); } } } + private int GetPrimaryCount(float viewportLength, float itemLength) + { + if (MaximumRowsOrColumns > 0) return MaximumRowsOrColumns; + return Math.Max(1, (int)Math.Floor(Math.Max(1f, viewportLength) / itemLength)); + } + + private Visual GetOrCreateVisual( + int index, + Func createVisual, + ItemsControl? itemsControl, + Action? ownerBindVisual, + Action? directBindVisual) + { + if (_activeVisuals.TryGetValue(index, out Visual? visual)) return visual; + + visual = _recycledVisuals.Count > 0 ? _recycledVisuals.Pop() : createVisual(); + if (itemsControl is not null) + { + object? item = itemsControl.GetItemAt(index); + if (item is not null) ownerBindVisual!(visual, item, index); + } + else + { + directBindVisual!(visual, index); + } + _activeVisuals[index] = visual; + AddChild(visual); + return visual; + } + private void ClearActiveToRecycler() { foreach (var vis in _activeVisuals.Values) @@ -249,6 +344,8 @@ private void ClearActiveToRecycler() public override void ForceRebind() { ClearActiveToRecycler(); + if (float.IsNaN(_itemWidth)) _measuredItemWidth = 80f; + if (float.IsNaN(_itemHeight)) _measuredItemHeight = 80f; base.ForceRebind(); } diff --git a/src/ProGPU.WinUI/Controls/VariableSizedWrapGrid.cs b/src/ProGPU.WinUI/Controls/VariableSizedWrapGrid.cs new file mode 100644 index 00000000..8f7b53f2 --- /dev/null +++ b/src/ProGPU.WinUI/Controls/VariableSizedWrapGrid.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using ProGPU.Layout; +using ProGPU.Scene; + +namespace Microsoft.UI.Xaml.Controls; + +/// +/// Places children into a uniform cell lattice while honoring per-child row and column +/// spans. Placement is deterministic first-fit in orientation order. For N children and +/// C occupied cells, storage is O(C); ordinary dense layout is O(C), while fragmented +/// span patterns can require O(N*C) candidate probes in the worst case. +/// +public sealed class VariableSizedWrapGrid : Panel +{ + private readonly List _placements = new(); + private float _measuredCellWidth = 1f; + private float _measuredCellHeight = 1f; + + private readonly record struct Placement(LayoutNode Child, int Row, int Column, int RowSpan, int ColumnSpan); + + public static readonly DependencyProperty ItemWidthProperty = + RegisterLayoutProperty(nameof(ItemWidth), typeof(float), float.NaN); + + public static readonly DependencyProperty ItemHeightProperty = + RegisterLayoutProperty(nameof(ItemHeight), typeof(float), float.NaN); + + public static readonly DependencyProperty OrientationProperty = + RegisterLayoutProperty(nameof(Orientation), typeof(Orientation), Orientation.Vertical); + + public static readonly DependencyProperty HorizontalChildrenAlignmentProperty = + RegisterLayoutProperty(nameof(HorizontalChildrenAlignment), typeof(HorizontalAlignment), HorizontalAlignment.Left); + + public static readonly DependencyProperty VerticalChildrenAlignmentProperty = + RegisterLayoutProperty(nameof(VerticalChildrenAlignment), typeof(VerticalAlignment), VerticalAlignment.Top); + + public static readonly DependencyProperty MaximumRowsOrColumnsProperty = + RegisterLayoutProperty(nameof(MaximumRowsOrColumns), typeof(int), -1); + + public static readonly DependencyProperty RowSpanProperty = + DependencyProperty.RegisterAttached( + "RowSpan", + typeof(int), + typeof(VariableSizedWrapGrid), + new PropertyMetadata(1, OnSpanChanged)); + + public static readonly DependencyProperty ColumnSpanProperty = + DependencyProperty.RegisterAttached( + "ColumnSpan", + typeof(int), + typeof(VariableSizedWrapGrid), + new PropertyMetadata(1, OnSpanChanged)); + + public float ItemWidth + { + get => (float)(GetValue(ItemWidthProperty) ?? float.NaN); + set + { + ValidateCellSize(value, nameof(value)); + SetValue(ItemWidthProperty, value); + } + } + + public float ItemHeight + { + get => (float)(GetValue(ItemHeightProperty) ?? float.NaN); + set + { + ValidateCellSize(value, nameof(value)); + SetValue(ItemHeightProperty, value); + } + } + + public Orientation Orientation + { + get => (Orientation)(GetValue(OrientationProperty) ?? Orientation.Vertical); + set => SetValue(OrientationProperty, value); + } + + public HorizontalAlignment HorizontalChildrenAlignment + { + get => (HorizontalAlignment)(GetValue(HorizontalChildrenAlignmentProperty) ?? HorizontalAlignment.Left); + set => SetValue(HorizontalChildrenAlignmentProperty, value); + } + + public VerticalAlignment VerticalChildrenAlignment + { + get => (VerticalAlignment)(GetValue(VerticalChildrenAlignmentProperty) ?? VerticalAlignment.Top); + set => SetValue(VerticalChildrenAlignmentProperty, value); + } + + public int MaximumRowsOrColumns + { + get => (int)(GetValue(MaximumRowsOrColumnsProperty) ?? -1); + set + { + if (value == 0 || value < -1) throw new ArgumentOutOfRangeException(nameof(value)); + SetValue(MaximumRowsOrColumnsProperty, value); + } + } + + public static int GetRowSpan(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + return (int)(element.GetValue(RowSpanProperty) ?? 1); + } + + public static void SetRowSpan(UIElement element, int value) + { + ArgumentNullException.ThrowIfNull(element); + if (value <= 0) throw new ArgumentOutOfRangeException(nameof(value)); + element.SetValue(RowSpanProperty, value); + } + + public static int GetColumnSpan(UIElement element) + { + ArgumentNullException.ThrowIfNull(element); + return (int)(element.GetValue(ColumnSpanProperty) ?? 1); + } + + public static void SetColumnSpan(UIElement element, int value) + { + ArgumentNullException.ThrowIfNull(element); + if (value <= 0) throw new ArgumentOutOfRangeException(nameof(value)); + element.SetValue(ColumnSpanProperty, value); + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + _placements.Clear(); + var children = new List(VisualChildren.Count); + foreach (Visual visual in VisualChildren) + { + if (visual is LayoutNode child && !child.IsCollapsed) + children.Add(child); + } + if (children.Count == 0) return Vector2.Zero; + + float cellWidth = ItemWidth; + float cellHeight = ItemHeight; + if (float.IsNaN(cellWidth) || float.IsNaN(cellHeight)) + { + children[0].Measure(new Vector2( + float.IsNaN(cellWidth) ? float.PositiveInfinity : cellWidth, + float.IsNaN(cellHeight) ? float.PositiveInfinity : cellHeight)); + if (float.IsNaN(cellWidth)) cellWidth = Math.Max(1f, children[0].DesiredSize.X); + if (float.IsNaN(cellHeight)) cellHeight = Math.Max(1f, children[0].DesiredSize.Y); + } + _measuredCellWidth = cellWidth; + _measuredCellHeight = cellHeight; + + int primaryLimit = GetPrimaryLimit(availableSize, children, cellWidth, cellHeight); + var occupied = new HashSet(); + int maxRow = 0; + int maxColumn = 0; + + foreach (LayoutNode child in children) + { + int requestedRows = child is UIElement element ? GetRowSpan(element) : 1; + int requestedColumns = child is UIElement uiElement ? GetColumnSpan(uiElement) : 1; + int rows = Orientation == Orientation.Vertical ? Math.Min(requestedRows, primaryLimit) : requestedRows; + int columns = Orientation == Orientation.Horizontal ? Math.Min(requestedColumns, primaryLimit) : requestedColumns; + + (int row, int column) = FindFirstFit(occupied, rows, columns, primaryLimit); + MarkOccupied(occupied, row, column, rows, columns); + _placements.Add(new Placement(child, row, column, rows, columns)); + + child.Measure(new Vector2(columns * cellWidth, rows * cellHeight)); + maxRow = Math.Max(maxRow, row + rows); + maxColumn = Math.Max(maxColumn, column + columns); + } + + return new Vector2(maxColumn * cellWidth, maxRow * cellHeight); + } + + protected override void ArrangeOverride(Rect arrangeRect) + { + foreach (Placement placement in _placements) + { + float x = arrangeRect.X + placement.Column * _measuredCellWidth; + float y = arrangeRect.Y + placement.Row * _measuredCellHeight; + float width = placement.ColumnSpan * _measuredCellWidth; + float height = placement.RowSpan * _measuredCellHeight; + Vector2 desired = placement.Child.DesiredSize; + + if (HorizontalChildrenAlignment != HorizontalAlignment.Stretch) + { + float alignedWidth = Math.Min(width, desired.X); + if (HorizontalChildrenAlignment == HorizontalAlignment.Center) x += (width - alignedWidth) / 2f; + else if (HorizontalChildrenAlignment == HorizontalAlignment.Right) x += width - alignedWidth; + width = alignedWidth; + } + if (VerticalChildrenAlignment != VerticalAlignment.Stretch) + { + float alignedHeight = Math.Min(height, desired.Y); + if (VerticalChildrenAlignment == VerticalAlignment.Center) y += (height - alignedHeight) / 2f; + else if (VerticalChildrenAlignment == VerticalAlignment.Bottom) y += height - alignedHeight; + height = alignedHeight; + } + + placement.Child.Arrange(new Rect(x, y, width, height)); + } + } + + private int GetPrimaryLimit(Vector2 availableSize, List children, float cellWidth, float cellHeight) + { + if (MaximumRowsOrColumns > 0) return MaximumRowsOrColumns; + + float available = Orientation == Orientation.Horizontal ? availableSize.X : availableSize.Y; + float cell = Orientation == Orientation.Horizontal ? cellWidth : cellHeight; + if (float.IsFinite(available)) return Math.Max(1, (int)Math.Floor(Math.Max(0f, available) / cell)); + + long totalSpans = 0; + foreach (LayoutNode child in children) + { + int span = child is UIElement element + ? Orientation == Orientation.Horizontal ? GetColumnSpan(element) : GetRowSpan(element) + : 1; + totalSpans += span; + } + return (int)Math.Clamp(totalSpans, 1L, int.MaxValue); + } + + private (int Row, int Column) FindFirstFit(HashSet occupied, int rows, int columns, int primaryLimit) + { + for (int secondary = 0; ; secondary++) + { + for (int primary = 0; primary < primaryLimit; primary++) + { + int row = Orientation == Orientation.Horizontal ? secondary : primary; + int column = Orientation == Orientation.Horizontal ? primary : secondary; + if (Orientation == Orientation.Horizontal && primary + columns > primaryLimit) break; + if (Orientation == Orientation.Vertical && primary + rows > primaryLimit) break; + if (IsFree(occupied, row, column, rows, columns)) return (row, column); + } + } + } + + private static bool IsFree(HashSet occupied, int row, int column, int rows, int columns) + { + for (int y = row; y < row + rows; y++) + for (int x = column; x < column + columns; x++) + if (occupied.Contains(CellKey(y, x))) return false; + return true; + } + + private static void MarkOccupied(HashSet occupied, int row, int column, int rows, int columns) + { + for (int y = row; y < row + rows; y++) + for (int x = column; x < column + columns; x++) + occupied.Add(CellKey(y, x)); + } + + private static long CellKey(int row, int column) => ((long)row << 32) | (uint)column; + + private static DependencyProperty RegisterLayoutProperty(string name, Type type, object defaultValue) => + DependencyProperty.Register( + name, + type, + typeof(VariableSizedWrapGrid), + new PropertyMetadata(defaultValue) { AffectsMeasure = true, AffectsArrange = true }); + + private static void OnSpanChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args) + { + if (sender is Visual { Parent: VariableSizedWrapGrid panel }) + { + panel.InvalidateMeasure(); + panel.InvalidateArrange(); + } + } + + private static void ValidateCellSize(float value, string parameterName) + { + if (!float.IsNaN(value) && (!float.IsFinite(value) || value <= 0f)) + throw new ArgumentOutOfRangeException(parameterName); + } +} diff --git a/src/ProGPU.WinUI/Controls/Viewbox.cs b/src/ProGPU.WinUI/Controls/Viewbox.cs new file mode 100644 index 00000000..6b3f6b2c --- /dev/null +++ b/src/ProGPU.WinUI/Controls/Viewbox.cs @@ -0,0 +1,184 @@ +using System; +using System.Numerics; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; +using ProGPU.Layout; +using ProGPU.Scene; + +namespace Microsoft.UI.Xaml.Controls; + +public enum StretchDirection +{ + UpOnly = 0, + DownOnly = 1, + Both = 2 +} + +/// +/// Scales one child without overwriting that child's own composition transform. +/// Measure and arrange perform fixed O(1) work in addition to the child layout pass. +/// +[ContentProperty(Name = nameof(Child))] +public sealed class Viewbox : FrameworkElement +{ + private readonly ViewboxPresenter _presenter = new(); + private Vector2 _naturalSize; + + public Viewbox() + { + AddChild(_presenter); + } + + public static readonly DependencyProperty ChildProperty = + DependencyProperty.Register( + nameof(Child), + typeof(UIElement), + typeof(Viewbox), + new PropertyMetadata(null, static (sender, args) => + { + var viewbox = (Viewbox)sender; + viewbox._presenter.Child = args.NewValue as UIElement; + viewbox.InvalidateMeasure(); + viewbox.InvalidateArrange(); + })); + + public static readonly DependencyProperty StretchProperty = + DependencyProperty.Register( + nameof(Stretch), + typeof(Stretch), + typeof(Viewbox), + new PropertyMetadata(Stretch.Uniform) { AffectsMeasure = true, AffectsArrange = true }); + + public static readonly DependencyProperty StretchDirectionProperty = + DependencyProperty.Register( + nameof(StretchDirection), + typeof(StretchDirection), + typeof(Viewbox), + new PropertyMetadata(StretchDirection.Both) { AffectsMeasure = true, AffectsArrange = true }); + + public UIElement? Child + { + get => GetValue(ChildProperty) as UIElement; + set => SetValue(ChildProperty, value); + } + + public Stretch Stretch + { + get => (Stretch)(GetValue(StretchProperty) ?? Stretch.Uniform); + set => SetValue(StretchProperty, value); + } + + public StretchDirection StretchDirection + { + get => (StretchDirection)(GetValue(StretchDirectionProperty) ?? StretchDirection.Both); + set => SetValue(StretchDirectionProperty, value); + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + UIElement? child = Child; + if (child is null) + { + _naturalSize = Vector2.Zero; + return Vector2.Zero; + } + + child.Measure(new Vector2(float.PositiveInfinity, float.PositiveInfinity)); + _naturalSize = child.DesiredSize; + Vector2 scale = CalculateScale(availableSize, _naturalSize); + Vector2 desired = _naturalSize * scale; + if (float.IsFinite(availableSize.X)) desired.X = Math.Min(desired.X, availableSize.X); + if (float.IsFinite(availableSize.Y)) desired.Y = Math.Min(desired.Y, availableSize.Y); + return desired; + } + + protected override void ArrangeOverride(Rect arrangeRect) + { + UIElement? child = Child; + if (child is null || _naturalSize.X <= 0f || _naturalSize.Y <= 0f) + { + _presenter.Scale = Vector3.One; + _presenter.Arrange(new Rect(arrangeRect.X, arrangeRect.Y, 0f, 0f)); + ClipBounds = null; + return; + } + + Vector2 scale = CalculateScale(new Vector2(arrangeRect.Width, arrangeRect.Height), _naturalSize); + float renderedWidth = _naturalSize.X * scale.X; + float renderedHeight = _naturalSize.Y * scale.Y; + float x = arrangeRect.X + (arrangeRect.Width - renderedWidth) / 2f; + float y = arrangeRect.Y + (arrangeRect.Height - renderedHeight) / 2f; + + _presenter.Scale = new Vector3(scale, 1f); + _presenter.RenderTransformOrigin = Vector2.Zero; + _presenter.Arrange(new Rect(x, y, _naturalSize.X, _naturalSize.Y)); + ClipBounds = new Rect(0f, 0f, Size.X, Size.Y); + } + + private Vector2 CalculateScale(Vector2 available, Vector2 natural) + { + if (natural.X <= 0f || natural.Y <= 0f || Stretch == Stretch.None) + return Vector2.One; + + float? ratioX = float.IsFinite(available.X) ? Math.Max(0f, available.X) / natural.X : null; + float? ratioY = float.IsFinite(available.Y) ? Math.Max(0f, available.Y) / natural.Y : null; + float sx; + float sy; + + if (Stretch == Stretch.Fill) + { + sx = ratioX ?? 1f; + sy = ratioY ?? 1f; + } + else + { + float uniform = ratioX.HasValue && ratioY.HasValue + ? Stretch == Stretch.Uniform ? Math.Min(ratioX.Value, ratioY.Value) : Math.Max(ratioX.Value, ratioY.Value) + : ratioX ?? ratioY ?? 1f; + sx = uniform; + sy = uniform; + } + + if (StretchDirection == StretchDirection.UpOnly) + { + sx = Math.Max(1f, sx); + sy = Math.Max(1f, sy); + } + else if (StretchDirection == StretchDirection.DownOnly) + { + sx = Math.Min(1f, sx); + sy = Math.Min(1f, sy); + } + return new Vector2(sx, sy); + } + + private sealed class ViewboxPresenter : FrameworkElement + { + private UIElement? _child; + + public UIElement? Child + { + get => _child; + set + { + if (ReferenceEquals(_child, value)) return; + if (_child is not null) RemoveChild(_child); + _child = value; + if (_child is not null) AddChild(_child); + InvalidateMeasure(); + } + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + if (_child is null) return Vector2.Zero; + _child.Measure(availableSize); + return _child.DesiredSize; + } + + protected override void ArrangeOverride(Rect arrangeRect) + { + _child?.Arrange(new Rect(arrangeRect.X, arrangeRect.Y, arrangeRect.Width, arrangeRect.Height)); + } + } +} diff --git a/src/ProGPU.WinUI/Core/Application.cs b/src/ProGPU.WinUI/Core/Application.cs index bb5e621d..c26aae27 100644 --- a/src/ProGPU.WinUI/Core/Application.cs +++ b/src/ProGPU.WinUI/Core/Application.cs @@ -1,10 +1,15 @@ using System; using System.Runtime.ExceptionServices; +using System.Threading.Tasks; +using Windows.ApplicationModel; namespace Microsoft.UI.Xaml; #pragma warning disable CS0618 public delegate void ApplicationInitializationCallback(ApplicationInitializationCallbackParams parameters); +public delegate void SuspendingEventHandler(object sender, SuspendingEventArgs e); +public delegate void EnteredBackgroundEventHandler(object sender, EnteredBackgroundEventArgs e); +public delegate void LeavingBackgroundEventHandler(object sender, LeavingBackgroundEventArgs e); #pragma warning restore CS0618 [Obsolete("ApplicationInitializationCallbackParams is retained for WinUI API compatibility.")] @@ -33,6 +38,10 @@ public class Application public ResourceDictionary Resources { get; } = new(); public event UnhandledExceptionEventHandler? UnhandledException; + public event SuspendingEventHandler? Suspending; + public event EventHandler? Resuming; + public event EnteredBackgroundEventHandler? EnteredBackground; + public event LeavingBackgroundEventHandler? LeavingBackground; /// /// Invokes the framework initialization callback on the current UI thread. @@ -67,4 +76,55 @@ internal void Launch(LaunchActivatedEventArgs args) } } } + + /// + /// Raises the WinUI entered-background event and waits for all event deferrals. + /// This is a ProGPU platform-host extension. + /// + public Task NotifyHostEnteredBackgroundAsync() + { + var deferrals = new DeferralTracker(); + var args = new EnteredBackgroundEventArgs(deferrals); + EnteredBackground?.Invoke(this, args); + return deferrals.SealAndWaitAsync(); + } + + /// + /// Raises the WinUI suspension event and waits for event deferrals until its + /// platform-supplied deadline. This is a ProGPU platform-host extension. + /// + public async Task NotifyHostSuspendingAsync(DateTimeOffset? deadline = null) + { + DateTimeOffset effectiveDeadline = deadline ?? DateTimeOffset.UtcNow.AddSeconds(5); + var deferrals = new DeferralTracker(); + var operation = new SuspendingOperation(effectiveDeadline, deferrals); + Suspending?.Invoke(this, new SuspendingEventArgs(operation)); + Task completion = deferrals.SealAndWaitAsync(); + TimeSpan remaining = effectiveDeadline - DateTimeOffset.UtcNow; + if (completion.IsCompleted) + { + await completion.ConfigureAwait(false); + return; + } + + if (remaining <= TimeSpan.Zero) + return; + + await Task.WhenAny(completion, Task.Delay(remaining)).ConfigureAwait(false); + } + + /// Raises the WinUI resumed event. This is a ProGPU platform-host extension. + public void NotifyHostResuming() => Resuming?.Invoke(this, new object()); + + /// + /// Raises the WinUI leaving-background event and waits for all event deferrals. + /// This is a ProGPU platform-host extension. + /// + public Task NotifyHostLeavingBackgroundAsync() + { + var deferrals = new DeferralTracker(); + var args = new LeavingBackgroundEventArgs(deferrals); + LeavingBackground?.Invoke(this, args); + return deferrals.SealAndWaitAsync(); + } } diff --git a/src/ProGPU.WinUI/Core/Storage.cs b/src/ProGPU.WinUI/Core/Storage.cs index e05ee01b..1e73c8cf 100644 --- a/src/ProGPU.WinUI/Core/Storage.cs +++ b/src/ProGPU.WinUI/Core/Storage.cs @@ -26,11 +26,15 @@ public StorageFile(string path) public async Task ReadTextAsync() { + if (StoragePlatformServices.ReadTextAsync is { } platformRead) + return await platformRead(Path).ConfigureAwait(false); return await File.ReadAllTextAsync(Path); } public async Task ReadBytesAsync() { + if (StoragePlatformServices.ReadBytesAsync is { } platformRead) + return await platformRead(Path).ConfigureAwait(false); return await File.ReadAllBytesAsync(Path); } @@ -65,8 +69,14 @@ public static Task GetFileFromPathAsync(string path) public static class StoragePlatformServices { public static Func?, string?, Task>? PickPathAsync { get; set; } + public static Func>? ReadTextAsync { get; set; } + public static Func>? ReadBytesAsync { get; set; } public static Func>? WriteTextAsync { get; set; } public static Func>? WriteBytesAsync { get; set; } + public static Func>>? EnumerateFilesAsync { get; set; } + public static Func>>? EnumerateFoldersAsync { get; set; } + public static Func>? CreateFileAsync { get; set; } + public static Func>? CreateFolderAsync { get; set; } } public class StorageFolder @@ -79,8 +89,17 @@ public StorageFolder(string path) Path = path; } - public Task> GetFilesAsync() + public async Task> GetFilesAsync() { + if (StoragePlatformServices.EnumerateFilesAsync is { } platformEnumerate) + { + IReadOnlyList paths = await platformEnumerate(Path).ConfigureAwait(false); + var platformFiles = new List(paths.Count); + foreach (string path in paths) + platformFiles.Add(new StorageFile(path)); + return platformFiles; + } + var files = new List(); if (Directory.Exists(Path)) { @@ -89,11 +108,20 @@ public Task> GetFilesAsync() files.Add(new StorageFile(file)); } } - return Task.FromResult>(files); + return files; } - public Task> GetFoldersAsync() + public async Task> GetFoldersAsync() { + if (StoragePlatformServices.EnumerateFoldersAsync is { } platformEnumerate) + { + IReadOnlyList paths = await platformEnumerate(Path).ConfigureAwait(false); + var platformFolders = new List(paths.Count); + foreach (string path in paths) + platformFolders.Add(new StorageFolder(path)); + return platformFolders; + } + var folders = new List(); if (Directory.Exists(Path)) { @@ -102,16 +130,37 @@ public Task> GetFoldersAsync() folders.Add(new StorageFolder(dir)); } } - return Task.FromResult>(folders); + return folders; } public async Task CreateFileAsync(string desiredName) { + ArgumentException.ThrowIfNullOrWhiteSpace(desiredName); + if (StoragePlatformServices.CreateFileAsync is { } platformCreate) + { + string path = await platformCreate(Path, desiredName).ConfigureAwait(false); + return new StorageFile(path); + } + var fullPath = System.IO.Path.Combine(Path, desiredName); await File.WriteAllTextAsync(fullPath, string.Empty); return new StorageFile(fullPath); } + public async Task CreateFolderAsync(string desiredName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(desiredName); + if (StoragePlatformServices.CreateFolderAsync is { } platformCreate) + { + string path = await platformCreate(Path, desiredName).ConfigureAwait(false); + return new StorageFolder(path); + } + + string fullPath = System.IO.Path.Combine(Path, desiredName); + Directory.CreateDirectory(fullPath); + return new StorageFolder(fullPath); + } + public static Task GetFolderFromPathAsync(string path) { return Task.FromResult(new StorageFolder(path)); diff --git a/src/ProGPU.WinUI/Core/Window.cs b/src/ProGPU.WinUI/Core/Window.cs index f2b22e75..9d2cd076 100644 --- a/src/ProGPU.WinUI/Core/Window.cs +++ b/src/ProGPU.WinUI/Core/Window.cs @@ -536,10 +536,7 @@ public void RenderExternalFrame(double delta, uint framebufferWidth, uint frameb public void ShutdownExternalRenderer() { - _compositor?.Dispose(); - _compositor = null; - _wgpuContext = null; - _inputState = null; + SuspendExternalRenderer(); _isExternalHostActive = false; NotifyHostVisibilityChanged(false); NotifyHostActivationChanged(WindowActivationState.Deactivated); @@ -548,6 +545,19 @@ public void ShutdownExternalRenderer() DetachWindowServices(); } + /// + /// Releases renderer-owned resources while preserving the WinUI window and its + /// application state. Mobile hosts use this when a native presentation surface is + /// temporarily destroyed and initialize a replacement renderer when it returns. + /// + public void SuspendExternalRenderer() + { + _compositor?.Dispose(); + _compositor = null; + _wgpuContext = null; + _inputState = null; + } + /// /// Updates activation state from an external platform host. /// diff --git a/src/ProGPU.WinUI/Media/Stretch.cs b/src/ProGPU.WinUI/Media/Stretch.cs new file mode 100644 index 00000000..de38142c --- /dev/null +++ b/src/ProGPU.WinUI/Media/Stretch.cs @@ -0,0 +1,10 @@ +namespace Microsoft.UI.Xaml.Media; + +/// Describes how content is resized to fill its allocated layout space. +public enum Stretch +{ + None = 0, + Fill = 1, + Uniform = 2, + UniformToFill = 3 +} diff --git a/src/ProGPU.WinUI/Navigation/Navigation.cs b/src/ProGPU.WinUI/Navigation/Navigation.cs new file mode 100644 index 00000000..44d16a86 --- /dev/null +++ b/src/ProGPU.WinUI/Navigation/Navigation.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.UI.Xaml; + +namespace Microsoft.UI.Xaml.Navigation; + +public enum NavigationMode +{ + New = 0, + Back = 1, + Forward = 2, + Refresh = 3 +} + +public enum NavigationCacheMode +{ + Disabled = 0, + Required = 1, + Enabled = 2 +} + +public sealed class NavigationEventArgs : EventArgs +{ + internal NavigationEventArgs( + object? content, + object? parameter, + Type sourcePageType, + NavigationMode navigationMode) + { + Content = content; + Parameter = parameter; + SourcePageType = sourcePageType; + NavigationMode = navigationMode; + } + + public object? Content { get; } + public object? Parameter { get; } + public Type SourcePageType { get; } + public NavigationMode NavigationMode { get; } +} + +public sealed class NavigatingCancelEventArgs : EventArgs +{ + internal NavigatingCancelEventArgs( + object? parameter, + Type sourcePageType, + NavigationMode navigationMode) + { + Parameter = parameter; + SourcePageType = sourcePageType; + NavigationMode = navigationMode; + } + + public bool Cancel { get; set; } + public NavigationMode NavigationMode { get; } + public Type SourcePageType { get; } + public object? Parameter { get; } +} + +public sealed class NavigationFailedEventArgs : EventArgs +{ + internal NavigationFailedEventArgs(Exception exception, Type sourcePageType) + { + Exception = exception; + SourcePageType = sourcePageType; + } + + public Exception Exception { get; } + public bool Handled { get; set; } + public Type SourcePageType { get; } +} + +public sealed class PageStackEntry : DependencyObject +{ + public PageStackEntry(Type sourcePageType, object? parameter = null) + { + SourcePageType = sourcePageType ?? throw new ArgumentNullException(nameof(sourcePageType)); + Parameter = parameter; + } + + public Type SourcePageType { get; } + public object? Parameter { get; } +} + +public delegate void NavigatedEventHandler(object sender, NavigationEventArgs e); +public delegate void NavigatingCancelEventHandler(object sender, NavigatingCancelEventArgs e); +public delegate void NavigationFailedEventHandler(object sender, NavigationFailedEventArgs e); +public delegate void NavigationStoppedEventHandler(object sender, NavigationEventArgs e); diff --git a/src/ProGPU.WinUI/Windows/ApplicationModel/ApplicationLifecycleEventArgs.cs b/src/ProGPU.WinUI/Windows/ApplicationModel/ApplicationLifecycleEventArgs.cs new file mode 100644 index 00000000..8084bdfa --- /dev/null +++ b/src/ProGPU.WinUI/Windows/ApplicationModel/ApplicationLifecycleEventArgs.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Windows.Foundation; + +namespace Windows.ApplicationModel; + +public sealed class SuspendingEventArgs : EventArgs +{ + internal SuspendingEventArgs(SuspendingOperation operation) + { + SuspendingOperation = operation; + } + + public SuspendingOperation SuspendingOperation { get; } +} + +public sealed class SuspendingOperation +{ + private readonly DeferralTracker _deferrals; + + internal SuspendingOperation(DateTimeOffset deadline, DeferralTracker deferrals) + { + Deadline = deadline; + _deferrals = deferrals; + } + + public DateTimeOffset Deadline { get; } + + public SuspendingDeferral GetDeferral() => new(_deferrals.GetDeferral()); +} + +public sealed class SuspendingDeferral : IDisposable +{ + private Deferral? _deferral; + + internal SuspendingDeferral(Deferral deferral) + { + _deferral = deferral; + } + + public void Complete() => + Interlocked.Exchange(ref _deferral, null)?.Complete(); + + public void Close() => Complete(); + + public void Dispose() => Complete(); +} + +public sealed class EnteredBackgroundEventArgs : EventArgs +{ + private readonly DeferralTracker _deferrals; + + internal EnteredBackgroundEventArgs(DeferralTracker deferrals) + { + _deferrals = deferrals; + } + + public Deferral GetDeferral() => _deferrals.GetDeferral(); +} + +public sealed class LeavingBackgroundEventArgs : EventArgs +{ + private readonly DeferralTracker _deferrals; + + internal LeavingBackgroundEventArgs(DeferralTracker deferrals) + { + _deferrals = deferrals; + } + + public Deferral GetDeferral() => _deferrals.GetDeferral(); +} + +internal sealed class DeferralTracker +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _remaining = 1; + private int _sealed; + + public Deferral GetDeferral() + { + if (Volatile.Read(ref _sealed) != 0) + throw new InvalidOperationException("A deferral cannot be requested after the event handler has returned."); + + Interlocked.Increment(ref _remaining); + if (Volatile.Read(ref _sealed) != 0) + { + CompleteOne(); + throw new InvalidOperationException("A deferral cannot be requested after the event handler has returned."); + } + + return new Deferral(CompleteOne); + } + + public Task SealAndWaitAsync() + { + if (Interlocked.Exchange(ref _sealed, 1) == 0) + CompleteOne(); + + return _completion.Task; + } + + private void CompleteOne() + { + int remaining = Interlocked.Decrement(ref _remaining); + if (remaining == 0) + _completion.TrySetResult(); + else if (remaining < 0) + throw new InvalidOperationException("A lifecycle deferral was completed more than once."); + } +} diff --git a/src/ProGPU.WinUI/Windows/Foundation/Deferral.cs b/src/ProGPU.WinUI/Windows/Foundation/Deferral.cs new file mode 100644 index 00000000..5fde4a55 --- /dev/null +++ b/src/ProGPU.WinUI/Windows/Foundation/Deferral.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; + +namespace Windows.Foundation; + +public delegate void DeferralCompletedHandler(); + +/// +/// Represents work that must complete before a platform operation may continue. +/// Completion is idempotent, matching the WinRT Deferral contract. +/// +public sealed class Deferral : IDisposable +{ + private DeferralCompletedHandler? _completedHandler; + + public Deferral(DeferralCompletedHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + _completedHandler = handler; + } + + public void Complete() => + Interlocked.Exchange(ref _completedHandler, null)?.Invoke(); + + public void Close() => Complete(); + + public void Dispose() => Complete(); +} diff --git a/src/ProGPU.WinUI/Windows/Graphics/Display/DisplayInformation.cs b/src/ProGPU.WinUI/Windows/Graphics/Display/DisplayInformation.cs new file mode 100644 index 00000000..dd751b20 --- /dev/null +++ b/src/ProGPU.WinUI/Windows/Graphics/Display/DisplayInformation.cs @@ -0,0 +1,172 @@ +using System; + +namespace Windows.Graphics.Display; + +[Flags] +public enum DisplayOrientations +{ + None = 0, + Landscape = 1, + Portrait = 2, + LandscapeFlipped = 4, + PortraitFlipped = 8 +} + +public enum ResolutionScale +{ + Invalid = 0, + Scale100Percent = 100, + Scale120Percent = 120, + Scale125Percent = 125, + Scale140Percent = 140, + Scale150Percent = 150, + Scale160Percent = 160, + Scale175Percent = 175, + Scale180Percent = 180, + Scale200Percent = 200, + Scale225Percent = 225, + Scale250Percent = 250, + Scale300Percent = 300, + Scale350Percent = 350, + Scale400Percent = 400, + Scale450Percent = 450, + Scale500Percent = 500 +} + +/// +/// Immutable host input for . Physical dimensions +/// are raw pixels and scale is raw pixels per logical layout pixel. +/// +public readonly record struct DisplayInformationMetrics( + DisplayOrientations CurrentOrientation, + DisplayOrientations NativeOrientation, + float LogicalDpi, + double RawPixelsPerViewPixel, + uint ScreenWidthInRawPixels, + uint ScreenHeightInRawPixels, + double? DiagonalSizeInInches = null); + +public sealed class DisplayInformation +{ + private static readonly object EventArgs = new(); + private static readonly DisplayInformation CurrentView = new(); + private static DisplayOrientations _autoRotationPreferences; + + private DisplayInformation() + { + } + + public static DisplayOrientations AutoRotationPreferences + { + get => _autoRotationPreferences; + set => _autoRotationPreferences = value; + } + + public DisplayOrientations CurrentOrientation { get; private set; } = DisplayOrientations.Landscape; + public DisplayOrientations NativeOrientation { get; private set; } = DisplayOrientations.Landscape; + public float LogicalDpi { get; private set; } = 96f; + public double RawPixelsPerViewPixel { get; private set; } = 1d; + public ResolutionScale ResolutionScale { get; private set; } = ResolutionScale.Scale100Percent; + public uint ScreenWidthInRawPixels { get; private set; } + public uint ScreenHeightInRawPixels { get; private set; } + public double? DiagonalSizeInInches { get; private set; } + + public event Windows.Foundation.TypedEventHandler? OrientationChanged; + public event Windows.Foundation.TypedEventHandler? DpiChanged; + + public static DisplayInformation GetForCurrentView() => CurrentView; + + /// + /// Publishes native display state to the current WinUI view. This is a ProGPU + /// platform-host extension; application-facing properties and events retain the + /// official DisplayInformation shapes. + /// + public static void NotifyHostMetricsChanged(DisplayInformationMetrics metrics) => + CurrentView.Update(metrics); + + private void Update(DisplayInformationMetrics metrics) + { + Validate(metrics); + + bool orientationChanged = + CurrentOrientation != metrics.CurrentOrientation || + NativeOrientation != metrics.NativeOrientation; + ResolutionScale resolutionScale = ResolveScale(metrics.RawPixelsPerViewPixel); + bool dpiChanged = + LogicalDpi != metrics.LogicalDpi || + RawPixelsPerViewPixel != metrics.RawPixelsPerViewPixel || + ResolutionScale != resolutionScale; + + CurrentOrientation = metrics.CurrentOrientation; + NativeOrientation = metrics.NativeOrientation; + LogicalDpi = metrics.LogicalDpi; + RawPixelsPerViewPixel = metrics.RawPixelsPerViewPixel; + ResolutionScale = resolutionScale; + ScreenWidthInRawPixels = metrics.ScreenWidthInRawPixels; + ScreenHeightInRawPixels = metrics.ScreenHeightInRawPixels; + DiagonalSizeInInches = metrics.DiagonalSizeInInches; + + if (orientationChanged) + OrientationChanged?.Invoke(this, EventArgs); + if (dpiChanged) + DpiChanged?.Invoke(this, EventArgs); + } + + private static void Validate(DisplayInformationMetrics metrics) + { + if (!IsSingleOrientation(metrics.CurrentOrientation)) + throw new ArgumentOutOfRangeException(nameof(metrics), "Current orientation must identify one display orientation."); + if (metrics.NativeOrientation is not (DisplayOrientations.Landscape or DisplayOrientations.Portrait)) + throw new ArgumentOutOfRangeException(nameof(metrics), "Native orientation must be Landscape or Portrait."); + if (!float.IsFinite(metrics.LogicalDpi) || metrics.LogicalDpi <= 0f) + throw new ArgumentOutOfRangeException(nameof(metrics), "Logical DPI must be finite and positive."); + if (!double.IsFinite(metrics.RawPixelsPerViewPixel) || metrics.RawPixelsPerViewPixel <= 0d) + throw new ArgumentOutOfRangeException(nameof(metrics), "Raw-pixel scale must be finite and positive."); + if (metrics.DiagonalSizeInInches is { } diagonal && (!double.IsFinite(diagonal) || diagonal <= 0d)) + throw new ArgumentOutOfRangeException(nameof(metrics), "Display diagonal must be finite and positive when supplied."); + } + + private static bool IsSingleOrientation(DisplayOrientations orientation) => + orientation is DisplayOrientations.Landscape or + DisplayOrientations.Portrait or + DisplayOrientations.LandscapeFlipped or + DisplayOrientations.PortraitFlipped; + + private static ResolutionScale ResolveScale(double rawPixelsPerViewPixel) + { + int percentage = checked((int)Math.Round(rawPixelsPerViewPixel * 100d)); + ReadOnlySpan scales = + [ + ResolutionScale.Scale100Percent, + ResolutionScale.Scale120Percent, + ResolutionScale.Scale125Percent, + ResolutionScale.Scale140Percent, + ResolutionScale.Scale150Percent, + ResolutionScale.Scale160Percent, + ResolutionScale.Scale175Percent, + ResolutionScale.Scale180Percent, + ResolutionScale.Scale200Percent, + ResolutionScale.Scale225Percent, + ResolutionScale.Scale250Percent, + ResolutionScale.Scale300Percent, + ResolutionScale.Scale350Percent, + ResolutionScale.Scale400Percent, + ResolutionScale.Scale450Percent, + ResolutionScale.Scale500Percent + ]; + + ResolutionScale nearest = scales[0]; + int nearestDistance = Math.Abs(percentage - (int)nearest); + for (int i = 1; i < scales.Length; i++) + { + int distance = Math.Abs(percentage - (int)scales[i]); + if (distance < nearestDistance) + { + nearest = scales[i]; + nearestDistance = distance; + } + } + + return nearest; + } +} diff --git a/src/ProGPU.WinUI/Windows/UI/Core/SystemNavigationManager.cs b/src/ProGPU.WinUI/Windows/UI/Core/SystemNavigationManager.cs new file mode 100644 index 00000000..83aa5ff9 --- /dev/null +++ b/src/ProGPU.WinUI/Windows/UI/Core/SystemNavigationManager.cs @@ -0,0 +1,46 @@ +using System; + +namespace Windows.UI.Core; + +public enum AppViewBackButtonVisibility +{ + Visible = 0, + Collapsed = 1, + Disabled = 2 +} + +public sealed class BackRequestedEventArgs : EventArgs +{ + public bool Handled { get; set; } +} + +/// +/// Provides the WinUI system-back contract. Platform hosts call +/// for their native back command or gesture. +/// +public sealed class SystemNavigationManager +{ + private static readonly SystemNavigationManager CurrentView = new(); + + private SystemNavigationManager() + { + } + + public AppViewBackButtonVisibility AppViewBackButtonVisibility { get; set; } = + AppViewBackButtonVisibility.Collapsed; + + public event EventHandler? BackRequested; + + public static SystemNavigationManager GetForCurrentView() => CurrentView; + + /// + /// Raises the current view's system-back event and returns whether application + /// code handled it. This is a ProGPU platform-host extension. + /// + public bool NotifyBackRequested() + { + var args = new BackRequestedEventArgs(); + BackRequested?.Invoke(this, args); + return args.Handled; + } +} From a9096cfe2eddd644fadd1a38335693aa17a0c499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 20:20:54 +0200 Subject: [PATCH 10/14] Add native Android WebGPU gallery host --- README.md | 20 +- docs/android.md | 406 +++++++++++++ eng/build-wgpu-native-android.sh | 348 ++++++++++++ src/ProGPU.Android.slnx | 4 + src/ProGPU.Android/AndroidApplication.cs | 185 ++++++ src/ProGPU.Android/AndroidRenderView.cs | 507 +++++++++++++++++ .../AndroidScrollDeltaPolicy.cs | 37 ++ .../AndroidStorageNamePolicy.cs | 13 + .../AndroidStoragePickerService.cs | 524 +++++++++++++++++ src/ProGPU.Android/AndroidTextInputBridge.cs | 311 ++++++++++ src/ProGPU.Android/AndroidWindowHost.cs | 532 ++++++++++++++++++ src/ProGPU.Android/ProGPU.Android.csproj | 26 + .../AndroidManifest.xml | 21 + .../ProGPU.Samples.Android.csproj | 31 + src/ProGPU.Samples.Android/Program.cs | 31 + src/ProGPU.Samples/ProGPU.Samples.csproj | 2 +- .../AndroidScrollDeltaPolicyTests.cs | 74 +++ .../AndroidStorageNamePolicyTests.cs | 36 ++ src/ProGPU.Tests/ProGPU.Tests.csproj | 4 + src/ProGPU.WinUI/ProGPU.WinUI.csproj | 2 +- src/ProGPU.slnx | 2 + 21 files changed, 3113 insertions(+), 3 deletions(-) create mode 100644 docs/android.md create mode 100755 eng/build-wgpu-native-android.sh create mode 100644 src/ProGPU.Android.slnx create mode 100644 src/ProGPU.Android/AndroidApplication.cs create mode 100644 src/ProGPU.Android/AndroidRenderView.cs create mode 100644 src/ProGPU.Android/AndroidScrollDeltaPolicy.cs create mode 100644 src/ProGPU.Android/AndroidStorageNamePolicy.cs create mode 100644 src/ProGPU.Android/AndroidStoragePickerService.cs create mode 100644 src/ProGPU.Android/AndroidTextInputBridge.cs create mode 100644 src/ProGPU.Android/AndroidWindowHost.cs create mode 100644 src/ProGPU.Android/ProGPU.Android.csproj create mode 100644 src/ProGPU.Samples.Android/AndroidManifest.xml create mode 100644 src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj create mode 100644 src/ProGPU.Samples.Android/Program.cs create mode 100644 src/ProGPU.Tests/AndroidScrollDeltaPolicyTests.cs create mode 100644 src/ProGPU.Tests/AndroidStorageNamePolicyTests.cs diff --git a/README.md b/README.md index 8ef88864..745d06ca 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ProGPU release packages are built from `eng/progpu-package-list.sh` by the `Rele | `ProGPU.Backend` | WebGPU device, swapchain, Silk.NET windowing, and platform backend services. | [![NuGet](https://img.shields.io/nuget/vpre/ProGPU.Backend.svg)](https://www.nuget.org/packages/ProGPU.Backend/) | | `ProGPU.Browser` | Batched .NET WebAssembly dispatcher and `navigator.gpu` browser host services. | [![NuGet](https://img.shields.io/nuget/vpre/ProGPU.Browser.svg)](https://www.nuget.org/packages/ProGPU.Browser/) | | `ProGPU.iOS` | Native UIKit/`CAMetalLayer` host using statically linked WebGPU over Metal. | Experimental source project | +| `ProGPU.Android` | Native `SurfaceView` host using WebGPU and wgpu-native directly over Vulkan. | Experimental source project | | `ProGPU.DirectX` | DirectX-compatible facade and shader-oriented API surface implemented on ProGPU/WebGPU. | [![NuGet](https://img.shields.io/nuget/vpre/ProGPU.DirectX.svg)](https://www.nuget.org/packages/ProGPU.DirectX/) | | `ProGPU.Transpiler` | Shader/source transformation helpers used by generated GPU pipelines. | [![NuGet](https://img.shields.io/nuget/vpre/ProGPU.Transpiler.svg)](https://www.nuget.org/packages/ProGPU.Transpiler/) | | `ProGPU.Compute` | Compute pipeline helpers for GPU-side effects, acceleration, and future hit-test indexes. | [![NuGet](https://img.shields.io/nuget/vpre/ProGPU.Compute.svg)](https://www.nuget.org/packages/ProGPU.Compute/) | @@ -47,9 +48,26 @@ dotnet build src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj \ The iOS-specific solution, native build details, ABI pin, architecture research, simulator commands, physical-device guidance, and current limitations are in [`docs/ios.md`](docs/ios.md). +## Native Android WebGPU sample + +`ProGPU.Samples.Android` is the corresponding thin .NET Android host for the +shared gallery. It renders directly to an `ANativeWindow`-backed WebGPU surface +through Vulkan, with no browser, Android `Canvas`, MAUI, Uno, or readback path. + +```bash +export ANDROID_NDK_ROOT=/absolute/path/to/android-sdk/ndk/your-version +./eng/build-wgpu-native-android.sh arm64 +dotnet build src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj \ + -c Debug -f net10.0-android +``` + +The Android architecture, exact native ABI pin, ARM64/x64 build lane, AOT +publishing guidance, input/IME/inset/storage contracts, clean-room research, +and device validation gates are in [`docs/android.md`](docs/android.md). + ## Browser WebGPU sample -The gallery is split into a shared `ProGPU.Samples` library and thin `ProGPU.Samples.Desktop`, `ProGPU.Samples.Browser`, and `ProGPU.Samples.iOS` hosts. The browser host publishes with the .NET WebAssembly SDK, negotiates WebGPU capabilities, sends aligned binary command packets directly from WASM memory, and passes embedded WGSL unchanged to `GPUDevice.createShaderModule`. +The gallery is split into a shared `ProGPU.Samples` library and thin `ProGPU.Samples.Desktop`, `ProGPU.Samples.Browser`, `ProGPU.Samples.iOS`, and `ProGPU.Samples.Android` hosts. The browser host publishes with the .NET WebAssembly SDK, negotiates WebGPU capabilities, sends aligned binary command packets directly from WASM memory, and passes embedded WGSL unchanged to `GPUDevice.createShaderModule`. ### Prerequisites diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 00000000..0e1926fd --- /dev/null +++ b/docs/android.md @@ -0,0 +1,406 @@ +# Native Android WebGPU host + +ProGPU's Android host runs the same shared application, controls, retained +scene, shaders, and renderer used by the desktop, browser, and iPhone samples. +It presents directly to an Android `SurfaceView` through WebGPU and +`wgpu-native`'s Vulkan backend. It does not embed a browser, render through an +Android `Canvas`, read pixels back through Skia, or depend on MAUI, Uno, or +AndroidX. + +## Architecture + +```mermaid +flowchart LR + App["Shared ProGPU application and views"] --> Window["WinUI-shaped Window"] + Window --> Scene["Retained scene, text, vector, and compute"] + Scene --> Api["IWebGpuApi"] + Api --> Desktop["Desktop: Silk and wgpu-native"] + Api --> Browser["Browser: command packets and navigator.gpu"] + Api --> Android["Android: ProGPU.Android host"] + Android --> Holder["SurfaceView and SurfaceHolder"] + Holder --> NativeWindow["ANativeWindow"] + NativeWindow --> Silk["Silk.NET.WebGPU managed ABI"] + Silk --> Native["wgpu-native: WGSL and Vulkan"] + Native --> Swapchain["Vulkan Android surface at physical size"] +``` + +The useful part of the browser architecture is the typed, platform-neutral +`IWebGpuApi` and external-window boundary. Android reuses that boundary but not +the browser command encoder, JavaScript decoder, worker transport, or canvas. +The managed renderer calls the same Silk.NET WebGPU C ABI as desktop directly +inside the application process. + +The native host has four ownership layers: + +1. A .NET Android activity owns lifecycle, native platform services, and one + shared ProGPU `Window`. +2. A `SurfaceView` owns Android surface production. `SurfaceHolder.Callback` + announces when the surface is safe to acquire, resize, or release. +3. The host converts the Java `Surface` to a reference-counted + `ANativeWindow*`, creates the WebGPU Android surface descriptor, and gives + physical buffer dimensions to `WgpuContext`. +4. The existing compositor owns the adapter, device, queue, swapchain + configuration, retained resources, and presentation. A display-synchronized + `Choreographer.FrameCallback` asks the ordinary external-renderer path to + render only while the activity and surface are active. + +The framebuffer is always the surface's physical pixel width and height. +Layout and input remain in view-independent logical units, derived from the +current display density. This preserves ProGPU's physical-pixel glyph atlas and +quarter-physical-pixel text snapping contract without scaling a completed +frame in the platform compositor. + +`ANativeWindow_fromSurface` acquires a native-window reference. The host +releases that exact reference after the WebGPU surface and compositor stop +using it. Activity pause and surface destruction are separate events: the +frame callback is paused immediately, surface-dependent GPU objects are +detached in submission order, and host-neutral application/window state stays +alive for a later surface. A terminal WebGPU device loss requires a new +instance, adapter, device, and generation of device-dependent resources; +retained CPU scene and text layout results remain reusable. + +## Direct Vulkan performance contract + +Android selects a hardware Vulkan adapter and uses WebGPU's Android native +window surface. There is no JavaScript serialization, intermediate bitmap, +`TextureView`, OpenGL presentation bridge, or second scene graph. Shader +modules remain the same embedded WGSL resources used by desktop. Pipelines, +bind groups, atlases, texture uploads, and compiled scenes retain their normal +lazy caches and invalidation generations. + +ProGPU theme, vector, image, and text channels are retained as encoded sRGB +values and written directly by the established desktop compositor. Surface +selection therefore prefers `Bgra8Unorm`, then `Rgba8Unorm`, before accepting +an advertised fallback. Selecting an `*Srgb` attachment would apply the sRGB +transfer curve a second time and wash out dark colors. The Android view also +disables the platform's full-view default focus highlight: ProGPU owns focus +visuals, and a translucent Android highlight over the edge-to-edge +`SurfaceView` would modify the entire presented frame after mouse or trackpad +focus. + +`Choreographer` supplies the stable frame timestamp for animation and aligns +submission with the display cadence. The host does not run a free-running +timer or advance animations twice. It stops scheduling while invisible, +paused, or surface-less and resumes without unconditionally invalidating the +shared scene. Android may change refresh rate, density, rotation, and surface +extent independently; each is reported through the portable window/display +contract before the next frame. + +The pinned wgpu-native release enables Vulkan and GLES together for Unix +targets in its own Cargo metadata; that backend union cannot be narrowed with +a public feature in this ABI revision. ProGPU builds with only the `wgsl` +shader-input feature and requests Vulkan at runtime. GLSL and SPIR-V shader +input remain disabled. GLES is therefore dormant compatibility code, not a +selected ProGPU presentation path. A future Silk/wgpu-native ABI upgrade may +remove it if upstream exposes an Android Vulkan-only feature boundary. + +## Native dependency and ABI contract + +The Android host uses the .NET Android workload and ProGPU's existing +Silk.NET.WebGPU binding. Its only added native component is `libwgpu_native.so`. +Silk.NET.WebGPU 2.23.0 was generated for the WebGPU C ABI represented by +wgpu-native commit +`33133da4ec5a0174cb21539ef2d3346f75200411`. Newer wgpu-native callback-info, +surface-chain, device-callback, and render-pass layouts are not drop-in +compatible. Upgrade the managed binding and native commit in one reviewed +change. + +[`eng/build-wgpu-native-android.sh`](../eng/build-wgpu-native-android.sh) +checks out that exact commit into ignored `artifacts/`, uses its locked Cargo +graph, enables only `wgsl`, and cross-links with the selected Android NDK. The +default output is: + +```text +artifacts/wgpu-native-android/ +├── BUILD-MANIFEST.txt +├── SHA256SUMS +├── include/ +│ ├── webgpu.h +│ └── wgpu.h +├── lib/ +│ ├── arm64-v8a/libwgpu_native.so +│ └── x86_64/libwgpu_native.so +└── licenses/ +``` + +ARM64 is built by default. The x64 library is optional and intended for x64 +emulators. Release builds use thin LTO, one codegen unit, disabled incremental +compilation, remapped source paths, a content-derived ELF build ID, stripped +unneeded symbols, and a manifest/checksum record. These inputs make repeated +builds deterministic when the Rust and NDK toolchain versions recorded in the +manifest are also held constant. Third-party source and build output remain +ignored external artifacts; no upstream implementation is copied into ProGPU. + +## Build, AOT, and deployment + +Prerequisites: + +- .NET 10 SDK with the Android workload (`dotnet workload install android`); +- an Android SDK, platform tools, API 24 or newer, and an installed NDK; +- the JDK version required by the installed .NET Android workload; +- Rust and Cargo (the build script installs requested Rust target components); +- a Vulkan-capable physical Android device, or an emulator configured with a + Vulkan-capable GPU backend. + +Point the build at the NDK and create the ARM64 native library: + +```bash +export ANDROID_NDK_ROOT=/absolute/path/to/android-sdk/ndk/your-version +./eng/build-wgpu-native-android.sh arm64 +``` + +Build both device and x64-emulator libraries when needed: + +```bash +./eng/build-wgpu-native-android.sh all +``` + +Build the shared gallery host: + +```bash +dotnet build src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj \ + -c Debug -f net10.0-android +``` + +For a physical ARM64 device, validate the fully trimmed/AOT Release path, not +only the Debug interpreter path: + +```bash +dotnet publish src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj \ + -c Release -f net10.0-android -r android-arm64 \ + -p:RunAOTCompilation=true +``` + +Use the .NET Android `Install` target or `adb install -r` on the signed APK +produced by that publish. A physical device is required for authoritative GPU +frame-time, thermal, power, memory-pressure, refresh-rate, stylus, mouse, and +IME results. Emulator results are useful functional evidence only. + +API 24 is the native-library baseline because Android exposes Vulkan there, +but API level alone does not prove hardware Vulkan support. Adapter discovery +must fail explicitly with a useful diagnostic when a device exposes no Vulkan +physical device; ProGPU does not silently switch to a bitmap renderer. + +## Lifecycle and portable WinUI-shaped APIs + +Android lifecycle events are translated into portable state before any frame +is produced: + +- activity start/resume activates the `Window`, updates display information, + and resumes frame scheduling once a surface exists; +- pause deactivates the window and stops scheduling before Android can revoke + the surface; +- stop/start raises background/leaving-background application events; +- a configuration or surface-size change reports logical bounds, physical + framebuffer dimensions, DPI, and orientation atomically; +- destroy closes the window, cancels pending platform requests, releases the + native surface, and disposes GPU state once; +- Android back dispatches the WinUI/UWP-compatible + `SystemNavigationManager.BackRequested` contract before default activity + navigation. + +The host-neutral compatibility surface includes the official API shapes needed +by a native mobile host: application suspension/resume/background events and +deferrals, `Window` activation/visibility/size/close state, `InputPane`, +`DisplayInformation` density/orientation notifications, system back +navigation, clipboard, and asynchronous file/folder pickers. Platform classes +and JNI handles never escape into the portable assemblies. + +## Insets, IME, and text input + +Android edge-to-edge layout separates the full render target from the region +safe for ordinary content. System-bar and display-cutout insets become +`Window.Insets.SafeArea`; IME intersection becomes +`InputPane.OccludedRect`/`Window.Insets.InputPaneOccludedRect`; the resulting +content area becomes `VisibleBounds`. Values are converted from physical +pixels to logical units exactly once. The framebuffer always covers the full +surface, including behind system UI. + +`Window.ExtendsContentIntoSystemInsets` controls whether the application lays +out ordinary content in the safe area or explicitly opts into the full edge. +A docked IME reduces the unobscured visible bottom. Floating or split keyboards +report their actual occlusion rectangle without incorrectly shrinking the +entire viewport. The nearest scrollable ancestor can reveal a focused editor +after `InputPane.Showing` while preserving the official +`EnsuredFocusedElementInView` result. + +A native editable text bridge owns Android's editing session. It maps ProGPU +input scopes, password state, capitalization, return-key intent, prediction, +spell checking, and multiline behavior to `EditorInfo`. Exact replacement +ranges, selection changes, composing spans, deletion, committed text, and +editor actions are mirrored into `InputSystem`; composition is never reduced +to synthetic key presses. This preserves CJK, Korean, Indic, Arabic, emoji, +autocorrect, dictation, hardware-keyboard, and accessibility-IME behavior. +Keyboard visibility and occlusion come from `WindowInsets.Type.ime()`, not a +guessed keyboard height. + +## Pointer, trackpad, stylus, drag/drop, and back input + +`MotionEvent` pointer IDs remain stable for the lifetime of each contact. +Coordinates, contact rectangles, pressure, tilt/orientation when supplied, +tool kind, button masks, modifiers, and event timestamps are converted into +the existing routed pointer model. All pointers in a multi-touch event are +processed; a compatibility single-touch adapter is not used. + +`ACTION_SCROLL` is handled in `onGenericMotionEvent`. On Android 14 and newer, +the dedicated gesture-distance axes are accumulated across every batched +historical sample and consumed in their documented display-pixel units. Older +or wheel-style `HSCROLL`/`VSCROLL` axes are normalized device values, so the +host applies `ViewConfiguration`'s scroll factors exactly once. Both paths are +then divided by display density and marked as logical-pixel deltas; WinUI +controls must not multiply them by a row or line height again. This preserves +precise two-dimensional input for `ScrollViewer`, virtualized controls, and +DXF pan/zoom surfaces. Hover and mouse button events use the same pointer +identity and capture path as pressed input. Batched pinch scale remains +multiplicative, while translation remains in logical pixels. + +Native view drag events map enter/over/leave/drop actions and offered MIME +types into the portable drag/drop contract. Internal ProGPU drags continue to +use routed pointer capture; external Android payloads remain URI/clip-data +services rather than exposing Java objects. The Android back gesture/button is +first offered through `BackRequested`; an unhandled request follows normal +activity behavior. + +## Clipboard and Storage Access Framework + +Text clipboard operations use Android's `ClipboardManager`. Open, save, and +folder pickers use Storage Access Framework intents: + +- `ACTION_OPEN_DOCUMENT` for one or multiple documents; +- `ACTION_CREATE_DOCUMENT` for a save destination; +- `ACTION_OPEN_DOCUMENT_TREE` for a folder. + +MIME types and extension filters are translated at the host boundary. The host +persists URI grants when the provider allows it. Opened content can be copied +to an application-owned cache file for portable path-based reads; save and +folder handles route writes, enumeration, and creation through +`ContentResolver`/document-provider operations. No broad external-storage +permission or raw-path assumption is required. Activity recreation either +reconnects a pending request or completes it as canceled exactly once. + +## Clean-room cross-engine research record + +This design is an original ProGPU implementation based on specifications, +public API contracts, primary documentation, and independently observable +behavior. No source implementation was copied, translated, or structurally +reproduced from another engine. + +| Primary source | Behavior examined | Adopted, adapted, or rejected | +| --- | --- | --- | +| [WebGPU specification](https://gpuweb.github.io/gpuweb/) and [wgpu-native at the pinned ABI](https://github.com/gfx-rs/wgpu-native/tree/33133da4ec5a0174cb21539ef2d3346f75200411) | Explicit instance/adapter/device/queue/surface ownership, asynchronous failure, resource and presentation rules | Adopt the typed WebGPU model and exact ABI pin. Adapt the surface source to `ANativeWindow`; reject a platform renderer fork. | +| [Android Vulkan guide](https://developer.android.com/ndk/guides/graphics), [NDK stable APIs](https://developer.android.com/ndk/guides/stable_apis), and [`ANativeWindow`](https://developer.android.com/ndk/reference/group/a-native-window) | Vulkan availability, Android surface lifetime, acquire/release ownership, low-overhead native presentation | Adopt direct Vulkan presentation and explicit native-window lifetime. Reject Canvas, readback, and OpenGL presentation bridges. | +| [`SurfaceHolder`](https://developer.android.com/reference/android/view/SurfaceHolder), [`SurfaceView`](https://developer.android.com/reference/android/view/SurfaceView), and [`Choreographer`](https://developer.android.com/reference/android/view/Choreographer) | Surface creation can differ from activity lifetime; stable display-synchronized frame time | Adapt them to ProGPU's external renderer and retained state. Reject timer-driven rendering and work while surface-less. | +| [Android window-inset guidance](https://developer.android.com/develop/ui/views/layout/insets), [edge-to-edge guidance](https://developer.android.com/develop/ui/views/layout/edge-to-edge), and [`WindowInsets.Type`](https://developer.android.com/reference/android/view/WindowInsets.Type) | System bars, cutouts, gestures, and IME are distinct physical occlusions | Convert once to ProGPU logical safe-area/input-pane values; reject hard-coded status/navigation/keyboard sizes. | +| [`MotionEvent`](https://developer.android.com/reference/android/view/MotionEvent), [`ViewConfiguration`](https://developer.android.com/reference/android/view/ViewConfiguration), [`InputConnection`](https://developer.android.com/reference/android/view/inputmethod/InputConnection), and [`BaseInputConnection`](https://developer.android.com/reference/android/view/inputmethod/BaseInputConnection) | Multi-pointer/tool/button/axis events, display-pixel gesture distances, normalized wheel axes, batched history, and transactional IME composition/selection edits | Preserve native pointer identity, convert each scroll representation once, accumulate batched samples, and retain exact IME transactions. Reject synthetic mouse-from-touch and text-from-key-only adapters. | +| [Android drag/drop](https://developer.android.com/develop/ui/views/touch-and-input/drag-drop/view) and [Storage Access Framework](https://developer.android.com/training/data-storage/shared/documents-files) | MIME/URI payloads, permissions, provider-owned documents, persistable access | Adapt data to typed portable services and URI-backed operations; reject Java-object leakage and broad storage permissions. | +| [Skia GPU context/resource APIs](https://api.skia.org/classGrDirectContext.html), [Skia surface creation](https://skia.org/docs/user/api/skcanvas_creation/), and [SkParagraph](https://skia.googlesource.com/skia/+/refs/heads/main/modules/skparagraph/) | Host-owned device contexts, bounded GPU caches, device loss, reusable paragraph shaping/layout | Retain one host-owned device and cache generations; preserve reusable CPU text layout. Reject an Android Skia scene or per-frame context creation. | +| [Direct2D resource domains](https://learn.microsoft.com/en-us/windows/win32/direct2d/resources-and-resource-domains), [DirectWrite text layout](https://learn.microsoft.com/en-us/windows/win32/directwrite/text-formatting-and-layout), and [Win2D device-loss guidance](https://learn.microsoft.com/en-us/windows/apps/develop/win2d/handling-device-lost) | Separation of device-independent geometry/layout from device-dependent resources and explicit device reconstruction | Preserve retained CPU geometry/text and rebuild only generation-bound GPU resources. Reject hiding device loss behind stale handles. | +| [WebRender render tasks](https://github.com/mozilla/gecko-dev/blob/master/gfx/wr/webrender/src/render_task.rs), [texture cache](https://github.com/mozilla/gecko-dev/blob/master/gfx/wr/webrender/src/texture_cache.rs), and [glyph rasterizer](https://github.com/mozilla/gecko-dev/blob/master/gfx/wr/webrender/src/glyph_rasterizer/mod.rs) | Retained display data, visibility-driven work, render-task organization, texture/glyph cache residency | Keep ProGPU compiled-scene reuse, culling, demand upload, and atlas generations. Reject rebuilding or uploading the whole scene after resume. | +| [Vello architecture and renderer](https://github.com/linebender/vello) | GPU compute organization, scene encoding, explicit render target parameters, wgpu integration | Preserve GPU path/glyph/effect work and explicit physical target sizes. Reject replacing the mature ProGPU renderer or duplicating scenes for Android. | +| [Parley layout documentation](https://docs.rs/parley/latest/parley/) | Coarse-grained font/layout contexts, reusable scratch storage, re-line-breaking without reshaping unchanged text, fallback and variation state | Preserve ProGPU's shared font discovery and shaped-layout reuse. Reject platform-only layout results that cannot survive surface loss. | +| [HarfBuzz shaping plans and caching](https://harfbuzz.github.io/shaping-plans-and-caching.html) and [shaping model](https://harfbuzz.github.io/shaping-and-shape-plans.html) | Cached plans keyed by face/segment properties/features and cluster-preserving Unicode/OpenType shaping | Keep shaping on the CPU with typed cache keys, fallback faces, and variable-font state; reject GPU or JNI text shaping in the frame path. | +| [.NET for Android build properties](https://learn.microsoft.com/en-us/dotnet/android/building-apps/build-properties) and [native-library interop](https://learn.microsoft.com/en-us/dotnet/android/binding-libs/advanced-concepts/native-library-interop) | Full managed AOT/trimming, Android native library packaging, Java/native interop | Use the .NET Android runtime's supported full-AOT lane and package the ABI-specific `.so`; reject experimental NativeAOT where it cannot supply the required Java host interop. | +| [WinUI `Window`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.window), [UWP `InputPane`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpane), [`DisplayInformation`](https://learn.microsoft.com/en-us/uwp/api/windows.graphics.display.displayinformation), and [`SystemNavigationManager`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.core.systemnavigationmanager) | Portable activation, bounds, keyboard occlusion, DPI/orientation, and back-navigation API shapes | Match official public shapes where they exist. Add only a small host-neutral safe-area value where WinUI publishes no cross-platform contract. | + +The comparison led to these cross-engine decisions: + +- **Startup and lazy initialization:** no WebGPU instance or font enumeration is + created by static initialization. Surface/device creation starts only after + an active native surface exists; expensive shared discovery can warm outside + first interaction. +- **Shaping and layout reuse:** Unicode/OpenType shaping, fallback selection, + variable-font coordinates, bidi, and line layout remain reusable CPU results. + Width-only relayout does not imply a new platform editing document or GPU + device. +- **Retained scene and visibility:** unchanged display commands survive pause, + resume, and surface recreation. Culling and virtualization bound realized + work to the visible/overscan regions. +- **Cache keys and eviction:** glyph, texture, path, pipeline, and compiled-scene + keys include the existing font/style/variation/DPI/subpixel and device/atlas + generations. Memory pressure purges only resources that can be regenerated. +- **Demand-driven upload and batching:** visible missing resources upload in + bounded batches; no Android bridge allocates one JNI call or object per draw, + glyph, row, or pointer sample. +- **Worker preparation:** host-neutral shaping/layout and scene preparation may + use the existing worker boundaries, but Android UI, JNI, surface, and IME + state stay on their required looper thread. GPU queue ordering remains + explicit. +- **DPI and text quality:** logical layout maps to physical targets at the + current density. Glyph raster size and four-way subpixel snapping remain in + physical coordinates. Android does not substitute platform text rendering + for retained ProGPU glyphs. +- **Device and atlas invalidation:** surface extent changes reconfigure the + surface; terminal device loss creates a new generation. Atlas generation + changes only when UV contents move or clear, not merely because the activity + paused. + +## Current emulator evidence + +The final ARM64 Release build produced on 2026-07-21 used full managed AOT and +full trimming and was installed standalone on the dedicated Android 16/API 36 +`ProGPU_API_36` emulator. This is functional evidence, not a physical-device +performance claim: + +- the native adapter was `Apple M3 Pro`, the selected backend was Vulkan, the + presentation format was `Rgba8Unorm`, and the physical target was + 1280×2856 at density scale 3; +- the first frame reported 65 draws, 732 vector vertices, and 293 text + vertices; cold first-frame total was 599.117 ms, including 190.107 ms in the + compositor and 0.619 ms in presentation; +- the steady gallery HUD reported 56 FPS on the emulator after warmup; +- a normalized Android wheel event moved the variable-height DataGrid by one + logical 64-pixel increment instead of the former roughly 80-row jump, while + text wrapping, recycling, and the non-sRGB dark palette remained intact; +- backgrounding and resuming retained the same application process and GPU + context, with no second WebGPU initialization or fatal runtime event. + +Android shell's `input touchpad scroll` command emits a zero-axis event on this +API 36 image, so it cannot validate the API 34 gesture-distance path. That path +is covered by conversion tests and the official axis contract, but final +two-finger scroll, pinch, and inertia validation still requires real touchpad +input on representative hardware. + +## Validation and release gate + +The host is release-ready only after all of the following evidence is captured +from the same final Release/AOT binaries before and after material rendering +changes: + +1. **Build provenance:** `bash -n` and `shellcheck` pass for the native build + script; Cargo uses `--locked`; `BUILD-MANIFEST.txt` has the expected commit, + API, ABI, NDK, and Rust versions; `SHA256SUMS` verifies; ELF inspection shows + the requested architecture and exported `wgpu*` dynamic symbols. +2. **Lifecycle correctness:** cold launch, rotate, resize/multi-window, lock and + unlock, home/resume, repeated surface destruction/recreation, activity + recreation, trim-memory pressure, and terminal device-loss injection never + use a stale surface or present the wrong physical extent. +3. **Input correctness:** multi-touch, stylus pressure/tilt, mouse hover and all + buttons, precise horizontal/vertical trackpad scroll, wheel scroll, pinch, + DXF pan/zoom, capture, internal and external drag/drop, hardware keyboard, + back gesture, picker cancellation, and clipboard round trips are covered on + emulator and representative devices. +4. **IME correctness:** replacement ranges, selection, composing start/update/ + commit/cancel, grapheme deletion, prediction, autocorrect, dictation, + password mode, multiline actions, CJK, Korean, Arabic/RTL, Indic, and emoji + work with both docked and floating keyboards. Insets keep focused content + visible without resizing the physical framebuffer. +5. **Rendering quality:** physical-pixel screenshots cover 1x and high-density + displays, text subpixel phases, fallback/color/variable fonts, paths, + clipping, effects, Compute FX, virtualized DataGrid wrapping, and DXF. Image + comparisons use the desktop/iPhone/browser reference scenes with documented + platform-appropriate tolerances. +6. **Performance:** capture cold-start and first-interaction time, frame + p50/p95/p99/worst, CPU and GPU duration, allocation rate, upload bytes, + draw/dispatch count, cache residency/eviction, RSS, thermal state, and power + during sustained gallery navigation, the 10,000-row variable-size grid, + DXF pan/zoom, text editing, and Compute FX. Use Perfetto/Android GPU Inspector + or vendor counters where available, and investigate every repeatable frame, + memory, or quality regression. +7. **AOT and parity:** the signed `android-arm64` Release publishes with full + managed AOT and trimming, launches without dynamic-code/reflection failures, + contains only intended native ABIs, and runs the same shared samples and + shader resources as desktop, browser, and iPhone. + +Simulator/emulator FPS alone does not satisfy this gate. Performance gains do +not count if they bypass invalidation, reduce DPI/text quality, skip dynamic +content, or silently fall back from the direct Vulkan path. diff --git a/eng/build-wgpu-native-android.sh b/eng/build-wgpu-native-android.sh new file mode 100755 index 00000000..d25be187 --- /dev/null +++ b/eng/build-wgpu-native-android.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the exact wgpu-native ABI consumed by Silk.NET.WebGPU 2.23.0. +# Source remains an external build input under artifacts/ and is never vendored into ProGPU. + +usage() { + cat <<'EOF' +Usage: ./eng/build-wgpu-native-android.sh [arm64|x64|all] [--api LEVEL] + +Builds arm64-v8a by default. Pass x64 for an Android emulator library or all +for both ABIs. ANDROID_NDK_ROOT (or ANDROID_NDK_HOME) must identify an Android +NDK. If neither is set, the script searches the ndk/ directory below +ANDROID_SDK_ROOT or ANDROID_HOME. + +Environment overrides: + ANDROID_API_LEVEL Minimum Android API level (default: 24) + WGPU_NATIVE_SOURCE External wgpu-native clone + WGPU_NATIVE_ANDROID_OUTPUT Packaged headers, libraries, and build manifest + WGPU_NATIVE_ANDROID_TARGET Cargo build cache (outside the packaged directory) +EOF +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="${WGPU_NATIVE_SOURCE:-${repo_root}/artifacts/wgpu-native-src}" +output_dir="${WGPU_NATIVE_ANDROID_OUTPUT:-${repo_root}/artifacts/wgpu-native-android}" +target_dir="${WGPU_NATIVE_ANDROID_TARGET:-${repo_root}/artifacts/wgpu-native-android-build}" +android_api_level="${ANDROID_API_LEVEL:-24}" + +# Silk.NET 2.23 still exposes the WebGPU C ABI used by wgpu-native's +# May 2024 header update (with wgpu-core 0.19.4). A newer native library is not +# ABI-compatible with the generated Silk.NET.WebGPU assembly. +expected_commit="33133da4ec5a0174cb21539ef2d3346f75200411" +upstream_url="https://github.com/gfx-rs/wgpu-native.git" + +requested_architectures=() +while (($# > 0)); do + case "$1" in + arm64|--arm64) + requested_architectures+=("arm64") + ;; + x64|--x64) + requested_architectures+=("x64") + ;; + all|--all) + requested_architectures+=("arm64" "x64") + ;; + --api) + if (($# < 2)); then + echo "--api requires a numeric Android API level." >&2 + exit 2 + fi + android_api_level="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +if ((${#requested_architectures[@]} == 0)); then + requested_architectures=("arm64") +fi + +if [[ ! "${android_api_level}" =~ ^[0-9]+$ ]] || ((android_api_level < 24)); then + echo "Android API level must be an integer greater than or equal to 24 (Vulkan baseline)." >&2 + exit 2 +fi + +case "${output_dir}" in + ""|/|"${repo_root}"|"${source_dir}"|"${target_dir}") + echo "Unsafe WGPU_NATIVE_ANDROID_OUTPUT: ${output_dir}" >&2 + exit 2 + ;; +esac + +resolve_ndk_root() { + local candidate="" + local sdk_root="" + local best="" + + for candidate in "${ANDROID_NDK_ROOT:-}" "${ANDROID_NDK_HOME:-}"; do + if [[ -n "${candidate}" && -d "${candidate}/toolchains/llvm/prebuilt" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done + + for sdk_root in "${ANDROID_SDK_ROOT:-}" "${ANDROID_HOME:-}"; do + [[ -n "${sdk_root}" ]] || continue + + best="" + for candidate in "${sdk_root}"/ndk/*; do + [[ -d "${candidate}/toolchains/llvm/prebuilt" ]] || continue + if [[ -z "${best}" || "${candidate##*/}" > "${best##*/}" ]]; then + best="${candidate}" + fi + done + if [[ -n "${best}" ]]; then + printf '%s\n' "${best}" + return 0 + fi + + candidate="${sdk_root}/ndk-bundle" + if [[ -d "${candidate}/toolchains/llvm/prebuilt" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done + + return 1 +} + +if ! ndk_root="$(resolve_ndk_root)"; then + echo "Android NDK not found. Set ANDROID_NDK_ROOT to an installed NDK." >&2 + exit 1 +fi + +host_prebuilt="" +case "$(uname -s)" in + Darwin) + for candidate in darwin-arm64 darwin-x86_64; do + if [[ -d "${ndk_root}/toolchains/llvm/prebuilt/${candidate}" ]]; then + host_prebuilt="${candidate}" + break + fi + done + ;; + Linux) + for candidate in linux-x86_64 linux-aarch64; do + if [[ -d "${ndk_root}/toolchains/llvm/prebuilt/${candidate}" ]]; then + host_prebuilt="${candidate}" + break + fi + done + ;; +esac + +if [[ -z "${host_prebuilt}" ]]; then + echo "No compatible LLVM toolchain was found in ${ndk_root}." >&2 + exit 1 +fi + +toolchain="${ndk_root}/toolchains/llvm/prebuilt/${host_prebuilt}" +sysroot="${toolchain}/sysroot" + +for required_tool in cargo git rustup; do + if ! command -v "${required_tool}" >/dev/null 2>&1; then + echo "Required tool not found: ${required_tool}" >&2 + exit 1 + fi +done + +if [[ ! -d "${source_dir}/.git" ]]; then + git clone --filter=blob:none "${upstream_url}" "${source_dir}" +fi + +if [[ -n "$(git -C "${source_dir}" status --porcelain --untracked-files=no)" ]]; then + echo "Refusing to change a modified external wgpu-native checkout: ${source_dir}" >&2 + exit 1 +fi + +git -C "${source_dir}" fetch --depth 1 origin "${expected_commit}" +if [[ "$(git -C "${source_dir}" rev-parse HEAD)" != "${expected_commit}" ]]; then + git -C "${source_dir}" checkout --detach "${expected_commit}" +fi +git -C "${source_dir}" submodule update --init --depth 1 ffi/webgpu-headers + +actual_commit="$(git -C "${source_dir}" rev-parse HEAD)" +if [[ "${actual_commit}" != "${expected_commit}" ]]; then + echo "Expected wgpu-native ${expected_commit}, found ${actual_commit}." >&2 + exit 1 +fi + +# Each invocation describes exactly the ABIs it was asked to package. Remove +# only the two known generated ABI directories so an earlier optional-emulator +# build cannot leak into a later device-only APK. +rm -rf "${output_dir}/lib/arm64-v8a" "${output_dir}/lib/x86_64" +mkdir -p "${output_dir}/include" "${output_dir}/licenses" "${output_dir}/lib" +install -m 0644 "${source_dir}/ffi/wgpu.h" "${output_dir}/include/wgpu.h" +install -m 0644 "${source_dir}/ffi/webgpu-headers/webgpu.h" "${output_dir}/include/webgpu.h" +install -m 0644 "${source_dir}/LICENSE.APACHE" "${output_dir}/licenses/LICENSE.APACHE" +install -m 0644 "${source_dir}/LICENSE.MIT" "${output_dir}/licenses/LICENSE.MIT" + +export CARGO_INCREMENTAL=0 +export CARGO_NET_GIT_FETCH_WITH_CLI=true +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 +export CARGO_PROFILE_RELEASE_LTO=thin +export CARGO_TARGET_DIR="${target_dir}" +export LC_ALL=C +source_date_epoch="$(git -C "${source_dir}" show -s --format=%ct "${expected_commit}")" +export SOURCE_DATE_EPOCH="${source_date_epoch}" +export TZ=UTC + +# Cargo's locked dependency graph plus remapped source paths avoids embedding +# machine-specific checkout locations. lld derives the GNU build-id from the +# linked image instead of using a random identifier. +base_rustflags="--remap-path-prefix=${source_dir}=wgpu-native --remap-path-prefix=${repo_root}=ProGPU -C link-arg=-Wl,--build-id=sha1 -C link-arg=-Wl,-soname,libwgpu_native.so -C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384" +if [[ -n "${RUSTFLAGS:-}" ]]; then + export RUSTFLAGS="${RUSTFLAGS} ${base_rustflags}" +else + export RUSTFLAGS="${base_rustflags}" +fi + +built_abis=() +seen_architectures=" " +for architecture in "${requested_architectures[@]}"; do + if [[ "${seen_architectures}" == *" ${architecture} "* ]]; then + continue + fi + seen_architectures+="${architecture} " + + case "${architecture}" in + arm64) + rust_target="aarch64-linux-android" + clang_target="aarch64-linux-android" + android_abi="arm64-v8a" + ;; + x64) + rust_target="x86_64-linux-android" + clang_target="x86_64-linux-android" + android_abi="x86_64" + ;; + *) + echo "Unsupported architecture: ${architecture}" >&2 + exit 2 + ;; + esac + + clang="${toolchain}/bin/${clang_target}${android_api_level}-clang" + llvm_ar="${toolchain}/bin/llvm-ar" + llvm_nm="${toolchain}/bin/llvm-nm" + llvm_readelf="${toolchain}/bin/llvm-readelf" + llvm_strip="${toolchain}/bin/llvm-strip" + if [[ ! -x "${clang}" || ! -x "${llvm_ar}" || ! -x "${llvm_nm}" || + ! -x "${llvm_readelf}" || ! -x "${llvm_strip}" ]]; then + echo "The selected NDK is missing tools for ${rust_target} at API ${android_api_level}." >&2 + exit 1 + fi + + rustup target add "${rust_target}" + + target_key="$(printf '%s' "${rust_target}" | tr '[:lower:]-' '[:upper:]_')" + target_env_key="$(printf '%s' "${rust_target}" | tr '-' '_')" + export "CARGO_TARGET_${target_key}_LINKER=${clang}" + export "CC_${target_env_key}=${clang}" + export "AR_${target_env_key}=${llvm_ar}" + export "CFLAGS_${target_env_key}=--sysroot=${sysroot} -fPIC" + export "BINDGEN_EXTRA_CLANG_ARGS_${target_env_key}=--target=${clang_target}${android_api_level} --sysroot=${sysroot}" + + # The pinned upstream enables its Vulkan (and, unavoidably for this release, + # GLES) wgpu-core backend on Android. Only WGSL shader ingestion is enabled; + # GLSL and SPIR-V input features remain excluded. + cargo build \ + --manifest-path "${source_dir}/Cargo.toml" \ + --target "${rust_target}" \ + --release \ + --locked \ + --no-default-features \ + --features wgsl + + source_library="${target_dir}/${rust_target}/release/libwgpu_native.so" + destination_dir="${output_dir}/lib/${android_abi}" + destination_library="${destination_dir}/libwgpu_native.so" + if [[ ! -f "${source_library}" ]]; then + echo "Cargo completed without producing ${source_library}." >&2 + exit 1 + fi + + mkdir -p "${destination_dir}" + install -m 0755 "${source_library}" "${destination_library}" + "${llvm_strip}" --strip-unneeded "${destination_library}" + + case "${architecture}" in + arm64) expected_machine="AArch64" ;; + x64) expected_machine="Advanced Micro Devices X86-64" ;; + esac + if ! "${llvm_readelf}" -h "${destination_library}" | + awk -v expected="${expected_machine}" ' + /^[[:space:]]*Machine:/ { + sub(/^[[:space:]]*Machine:[[:space:]]*/, "", $0) + found = ($0 == expected) + } + END { exit !found } + '; then + echo "Packaged ${android_abi} library has the wrong ELF machine type." >&2 + exit 1 + fi + if ! "${llvm_readelf}" -d "${destination_library}" | + awk '/\(SONAME\)/ && /\[libwgpu_native\.so\]/ { found = 1 } END { exit !found }'; then + echo "Packaged ${android_abi} library has no libwgpu_native.so SONAME." >&2 + exit 1 + fi + if ! "${llvm_nm}" -D --defined-only "${destination_library}" | + awk '$NF == "wgpuCreateInstance" { found = 1 } END { exit !found }'; then + echo "Packaged ${android_abi} library does not export the WebGPU C ABI." >&2 + exit 1 + fi + if ! "${llvm_readelf}" -l "${destination_library}" | + awk '$1 == "LOAD" { found = 1; if ($NF != "0x4000") bad = 1 } END { exit !found || bad }'; then + echo "Packaged ${android_abi} library is not aligned for Android 16 KiB pages." >&2 + exit 1 + fi + built_abis+=("${android_abi}") +done + +ndk_revision="unknown" +if [[ -f "${ndk_root}/source.properties" ]]; then + ndk_revision="$(sed -n 's/^Pkg\.Revision[[:space:]]*=[[:space:]]*//p' "${ndk_root}/source.properties" | head -n 1)" +fi +rust_version="$(rustc --version)" +{ + printf 'wgpu-native-commit=%s\n' "${expected_commit}" + printf 'silk-net-webgpu-abi=2.23.0\n' + printf 'android-api-level=%s\n' "${android_api_level}" + printf 'android-abis=%s\n' "$(IFS=,; printf '%s' "${built_abis[*]}")" + printf 'cargo-features=wgsl\n' + printf 'runtime-backend=vulkan\n' + printf 'ndk-revision=%s\n' "${ndk_revision}" + printf 'rust-version=%s\n' "${rust_version}" +} > "${output_dir}/BUILD-MANIFEST.txt" + +checksum_file="${output_dir}/SHA256SUMS" +checksum_temp="${checksum_file}.tmp" +: > "${checksum_temp}" +while IFS= read -r relative_path; do + if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "${output_dir}/${relative_path}" | awk '{print $1}')" + else + digest="$(shasum -a 256 "${output_dir}/${relative_path}" | awk '{print $1}')" + fi + printf '%s %s\n' "${digest}" "${relative_path}" >> "${checksum_temp}" +done < <( + cd "${output_dir}" + find BUILD-MANIFEST.txt include licenses lib -type f -print | LC_ALL=C sort +) +mv "${checksum_temp}" "${checksum_file}" + +echo "Created Android wgpu-native package at ${output_dir} from ${expected_commit}." +echo "ABIs: $(IFS=,; printf '%s' "${built_abis[*]}") (API ${android_api_level})" diff --git a/src/ProGPU.Android.slnx b/src/ProGPU.Android.slnx new file mode 100644 index 00000000..74567162 --- /dev/null +++ b/src/ProGPU.Android.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/ProGPU.Android/AndroidApplication.cs b/src/ProGPU.Android/AndroidApplication.cs new file mode 100644 index 00000000..b474cad6 --- /dev/null +++ b/src/ProGPU.Android/AndroidApplication.cs @@ -0,0 +1,185 @@ +using Android.App; +using Android.Content; +using Android.Content.PM; +using Android.Content.Res; +using Android.Graphics; +using Android.OS; +using Android.Views; +using Android.Widget; +using Microsoft.UI.Xaml; +using XamlApplication = Microsoft.UI.Xaml.Application; + +namespace ProGPU.Android; + +/// +/// Android activity base for a ProGPU application. Applications provide only the shared +/// WinUI launch callback; this base owns the native Android surface and lifecycle. +/// +public abstract class ProGpuActivity : Activity +{ + private AndroidRenderView? _renderView; + private AndroidTextInputBridge? _textInput; + private AndroidWindowHost? _host; + private bool _isResumed; + private bool _launchStarted; + private bool _hasPaused; + private bool _hasStopped; + + /// Launches the shared application after the Android window host is installed. + protected abstract Task LaunchProGpuApplicationAsync(); + + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + if (Window is { } nativeWindow) + { + nativeWindow.SetSoftInputMode(SoftInput.AdjustNothing); + nativeWindow.SetStatusBarColor(Color.Transparent); + nativeWindow.SetNavigationBarColor(Color.Transparent); + nativeWindow.StatusBarContrastEnforced = false; + nativeWindow.NavigationBarContrastEnforced = false; + nativeWindow.SetDecorFitsSystemWindows(false); + if (nativeWindow.Attributes is { } attributes) + { + attributes.LayoutInDisplayCutoutMode = LayoutInDisplayCutoutMode.Always; + nativeWindow.Attributes = attributes; + } + } + + var root = new FrameLayout(this) + { + LayoutParameters = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent) + }; + _renderView = new AndroidRenderView(this); + root.AddView(_renderView, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MatchParent, + ViewGroup.LayoutParams.MatchParent)); + + _textInput = new AndroidTextInputBridge(this); + root.AddView(_textInput.NativeView, new FrameLayout.LayoutParams(1, 1)); + SetContentView(root); + + _host = new AndroidWindowHost(this, _renderView, _textInput); + WindowHostServices.Current = _host; + _renderView.SurfaceAvailable += OnInitialSurfaceAvailable; + } + + protected override void OnStart() + { + base.OnStart(); + if (_hasStopped && XamlApplication.Current is { } application) + { + _hasStopped = false; + ObserveLifecycleTask(application.NotifyHostLeavingBackgroundAsync(), "leaving background"); + } + } + + protected override void OnResume() + { + base.OnResume(); + _isResumed = true; + if (_hasPaused && XamlApplication.Current is { } application) + { + _hasPaused = false; + application.NotifyHostResuming(); + } + _host?.Resume(); + TryLaunchApplication(); + } + + protected override void OnPause() + { + _isResumed = false; + _host?.Pause(); + _hasPaused = true; + if (XamlApplication.Current is { } application) + ObserveLifecycleTask(application.NotifyHostSuspendingAsync(), "suspending"); + base.OnPause(); + } + + protected override void OnStop() + { + _hasStopped = true; + if (XamlApplication.Current is { } application) + ObserveLifecycleTask(application.NotifyHostEnteredBackgroundAsync(), "entering background"); + base.OnStop(); + } + + protected override void OnDestroy() + { + if (_renderView != null) + _renderView.SurfaceAvailable -= OnInitialSurfaceAvailable; + AndroidWindowHost? host = _host; + _host = null; + if (ReferenceEquals(WindowHostServices.Current, host)) WindowHostServices.Current = null; + host?.Dispose(); + _textInput = null; + _renderView = null; + base.OnDestroy(); + } + + private void OnInitialSurfaceAvailable(Surface surface, int width, int height) => + TryLaunchApplication(); + + private void TryLaunchApplication() + { + if (_launchStarted || !_isResumed || _renderView?.HasValidSurface != true) return; + _launchStarted = true; + _renderView.SurfaceAvailable -= OnInitialSurfaceAvailable; + _ = ObserveLaunchAsync(); + } + + protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data) + { + if (_host?.HandleActivityResult(requestCode, resultCode, data) == true) return; + base.OnActivityResult(requestCode, resultCode, data); + } + + public override bool DispatchKeyEvent(KeyEvent? e) + { + if (e != null && _host?.HandleKeyEvent(e) == true) return true; + return base.DispatchKeyEvent(e); + } + + public override void OnBackPressed() + { + if (_host?.HandleBackRequested() == true) return; +#pragma warning disable CS0618 + base.OnBackPressed(); +#pragma warning restore CS0618 + } + + public override void OnConfigurationChanged(Configuration newConfig) + { + base.OnConfigurationChanged(newConfig); + _renderView?.RefreshMetrics(); + } + + private async Task ObserveLaunchAsync() + { + try + { + await LaunchProGpuApplicationAsync(); + } + catch (Exception exception) + { + global::Android.Util.Log.Error("ProGPU.Android", exception.ToString()); + throw; + } + } + + private static async void ObserveLifecycleTask(Task task, string transition) + { + try + { + await task; + } + catch (Exception exception) + { + global::Android.Util.Log.Error("ProGPU.Android", $"Application failed while {transition}: {exception}"); + } + } +} diff --git a/src/ProGPU.Android/AndroidRenderView.cs b/src/ProGPU.Android/AndroidRenderView.cs new file mode 100644 index 00000000..c8eb0cb4 --- /dev/null +++ b/src/ProGPU.Android/AndroidRenderView.cs @@ -0,0 +1,507 @@ +using Android.App; +using Android.Content; +using Android.Graphics; +using Android.OS; +using Android.Views; +using Microsoft.UI.Xaml.Input; +using System.Numerics; +using Windows.Devices.Input; + +namespace ProGPU.Android; + +internal readonly record struct AndroidRenderMetrics( + uint Width, + uint Height, + float DpiScale, + Microsoft.UI.Xaml.Thickness SafeAreaInsets, + Windows.Foundation.Rect InputPaneOccludedRect); + +internal sealed class AndroidRenderView : SurfaceView, ISurfaceHolderCallback +{ + private const uint IndirectPointerId = uint.MaxValue; + private const float PinchWheelUnitsPerNaturalLog = 120f; + private readonly float _horizontalScrollFactor; + private readonly float _verticalScrollFactor; + private Microsoft.UI.Xaml.Thickness _safeAreaInsets; + private Windows.Foundation.Rect _inputPaneOccludedRect; + private int _surfaceWidth; + private int _surfaceHeight; + private Vector2 _lastIndirectPosition; + private Microsoft.UI.Xaml.FrameworkElement? _externalDragTarget; + private Microsoft.UI.Xaml.DataPackage? _externalDragData; + private DragAndDropPermissions? _externalDragPermissions; + private readonly Activity _activity; + + public AndroidRenderView(Activity activity) : base(activity) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + Holder?.AddCallback(this); + Focusable = true; + FocusableInTouchMode = true; + // Android's default non-touch focus drawable is applied to the entire + // edge-to-edge SurfaceView after the first mouse or trackpad event. That + // translucent overlay would alter every presented color, including the + // content rendered beneath the system bars. ProGPU draws its own focus + // visuals, so the platform-wide highlight must remain disabled. + DefaultFocusHighlightEnabled = false; + // SurfaceView presents through a separate SurfaceFlinger layer below the + // activity's view layer. An opaque View background here would cover that + // Vulkan layer even though WebGPU continues to acquire and present frames. + SetBackgroundColor(Color.Transparent); + var configuration = ViewConfiguration.Get(activity); + _horizontalScrollFactor = configuration?.ScaledHorizontalScrollFactor ?? 48f; + _verticalScrollFactor = configuration?.ScaledVerticalScrollFactor ?? 48f; + } + + public event Action? SurfaceAvailable; + public event Action? SurfaceUnavailable; + public event Action? MetricsChanged; + + public Func? InputStateProvider { get; set; } + + public bool HasValidSurface => Holder?.Surface is { IsValid: true }; + + public AndroidRenderMetrics Metrics => new( + checked((uint)Math.Max(1, _surfaceWidth > 0 ? _surfaceWidth : Width)), + checked((uint)Math.Max(1, _surfaceHeight > 0 ? _surfaceHeight : Height)), + ResolveDensity(), + _safeAreaInsets, + _inputPaneOccludedRect); + + public void SurfaceCreated(ISurfaceHolder holder) + { + if (holder.Surface is { IsValid: true } surface) + SurfaceAvailable?.Invoke(surface, Math.Max(1, _surfaceWidth), Math.Max(1, _surfaceHeight)); + } + + public void SurfaceChanged(ISurfaceHolder holder, Format format, int width, int height) + { + _surfaceWidth = Math.Max(1, width); + _surfaceHeight = Math.Max(1, height); + if (holder.Surface is { IsValid: true } surface) + SurfaceAvailable?.Invoke(surface, _surfaceWidth, _surfaceHeight); + NotifyMetricsChanged(); + } + + public void SurfaceDestroyed(ISurfaceHolder holder) + { + _surfaceWidth = 0; + _surfaceHeight = 0; + SurfaceUnavailable?.Invoke(); + } + + public void RefreshMetrics() + { + RequestApplyInsets(); + NotifyMetricsChanged(); + } + + public override WindowInsets? OnApplyWindowInsets(WindowInsets? insets) + { + if (insets == null) return null; + float density = ResolveDensity(); + int safeTypes = WindowInsets.Type.SystemBars() | WindowInsets.Type.DisplayCutout(); + Insets safe = insets.GetInsets(safeTypes); + _safeAreaInsets = new Microsoft.UI.Xaml.Thickness( + safe.Left / density, + safe.Top / density, + safe.Right / density, + safe.Bottom / density); + + Insets ime = insets.GetInsets(WindowInsets.Type.Ime()); + bool imeVisible = insets.IsVisible(WindowInsets.Type.Ime()); + float logicalWidth = Math.Max(1, Width) / density; + float logicalHeight = Math.Max(1, Height) / density; + float imeHeight = imeVisible ? Math.Clamp(ime.Bottom / density, 0f, logicalHeight) : 0f; + _inputPaneOccludedRect = imeHeight > 0f + ? new Windows.Foundation.Rect(0f, logicalHeight - imeHeight, logicalWidth, imeHeight) + : default; + NotifyMetricsChanged(); + return insets; + } + + public override bool OnTouchEvent(MotionEvent? e) + { + if (e == null || !TrySelectInputState()) return false; + switch (e.ActionMasked) + { + case MotionEventActions.Down: + case MotionEventActions.PointerDown: + DispatchPointer(e, e.ActionIndex, PointerInputKind.Pressed, inContact: true); + return true; + case MotionEventActions.Move: + for (int index = 0; index < e.PointerCount; index++) + DispatchPointer(e, index, PointerInputKind.Moved, inContact: true); + return true; + case MotionEventActions.Up: + case MotionEventActions.PointerUp: + DispatchPointer(e, e.ActionIndex, PointerInputKind.Released, inContact: false); + return true; + case MotionEventActions.Cancel: + for (int index = 0; index < e.PointerCount; index++) + DispatchPointer(e, index, PointerInputKind.Canceled, inContact: false); + return true; + case MotionEventActions.ButtonPress: + DispatchPointer(e, Math.Max(0, e.ActionIndex), PointerInputKind.Pressed, inContact: true); + return true; + case MotionEventActions.ButtonRelease: + DispatchPointer(e, Math.Max(0, e.ActionIndex), PointerInputKind.Released, inContact: false); + return true; + default: + return base.OnTouchEvent(e); + } + } + + public override bool OnHoverEvent(MotionEvent? e) + { + if (e == null || !TrySelectInputState()) return false; + if (e.ActionMasked is not (MotionEventActions.HoverEnter or MotionEventActions.HoverMove or MotionEventActions.HoverExit)) + return base.OnHoverEvent(e); + DispatchPointer(e, 0, PointerInputKind.Moved, inContact: false); + return true; + } + + public override bool OnGenericMotionEvent(MotionEvent? e) + { + if (e == null || !TrySelectInputState()) return false; + if (e.ActionMasked == MotionEventActions.Scroll) + { + DispatchScroll(e); + return true; + } + if (e.ActionMasked is MotionEventActions.HoverEnter or MotionEventActions.HoverMove or MotionEventActions.HoverExit) + { + DispatchPointer(e, 0, PointerInputKind.Moved, inContact: false); + return true; + } + if (e.ActionMasked is MotionEventActions.ButtonPress or MotionEventActions.ButtonRelease) + { + DispatchPointer( + e, + Math.Max(0, e.ActionIndex), + e.ActionMasked == MotionEventActions.ButtonPress ? PointerInputKind.Pressed : PointerInputKind.Released, + e.ActionMasked == MotionEventActions.ButtonPress); + return true; + } + return base.OnGenericMotionEvent(e); + } + + public override bool OnDragEvent(global::Android.Views.DragEvent? e) + { + if (e == null || !TrySelectInputState()) return false; + switch (e.Action) + { + case DragAction.Started: + ReleaseExternalDragPermissions(); + _externalDragData = CreateDataPackage(e.ClipData); + LeaveExternalDragTarget(_lastIndirectPosition); + return InputSystem.Root != null; + case DragAction.Entered: + return true; + case DragAction.Location: + UpdateExternalDragTarget(GetDragPosition(e), isDrop: false); + return true; + case DragAction.Exited: + LeaveExternalDragTarget(_lastIndirectPosition); + return true; + case DragAction.Drop: + AcquireExternalDragPermissions(e); + _externalDragData = CreateDataPackage(e.ClipData) ?? _externalDragData; + try + { + UpdateExternalDragTarget(GetDragPosition(e), isDrop: true); + return true; + } + catch + { + ReleaseExternalDragPermissions(); + throw; + } + finally + { + _externalDragData = null; + } + case DragAction.Ended: + LeaveExternalDragTarget(_lastIndirectPosition); + _externalDragData = null; + ReleaseExternalDragPermissions(); + return true; + default: + return base.OnDragEvent(e); + } + } + + private void DispatchPointer(MotionEvent e, int index, PointerInputKind kind, bool inContact) + { + if (index < 0 || index >= e.PointerCount) return; + float density = ResolveDensity(); + var deviceType = MapDeviceType(e.GetToolType(index)); + bool isMouse = deviceType == PointerDeviceType.Mouse; + uint pointerId = isMouse ? IndirectPointerId : checked((uint)e.GetPointerId(index) + 1u); + var position = new Vector2(e.GetX(index) / density, e.GetY(index) / density); + if (isMouse) _lastIndirectPosition = position; + MotionEventButtonState buttons = e.ButtonState; + if (!isMouse && inContact) buttons |= MotionEventButtonState.Primary; + bool effectiveContact = inContact && (!isMouse || buttons != 0); + float diameter = Math.Max(1f / density, e.GetTouchMajor(index) / density); + + InputSystem.InjectPointer(new PointerInputEvent( + kind, + pointerId, + deviceType, + position, + checked((ulong)Math.Max(0L, e.EventTime) * 1_000UL), + IsPrimary: isMouse || index == 0, + IsInContact: effectiveContact, + IsLeftButtonPressed: buttons.HasFlag(MotionEventButtonState.Primary), + IsMiddleButtonPressed: buttons.HasFlag(MotionEventButtonState.Tertiary), + IsRightButtonPressed: buttons.HasFlag(MotionEventButtonState.Secondary), + Pressure: effectiveContact ? Math.Clamp(e.GetPressure(index), 0f, 1f) : 0f, + ContactRect: new ProGPU.Scene.Rect( + position.X - diameter * 0.5f, + position.Y - diameter * 0.5f, + diameter, + diameter), + Modifiers: ReadModifiers(e.MetaState))); + } + + private void DispatchScroll(MotionEvent e) + { + float density = ResolveDensity(); + var position = new Vector2(e.GetX() / density, e.GetY() / density); + if (float.IsFinite(position.X) && float.IsFinite(position.Y)) _lastIndirectPosition = position; + else position = _lastIndirectPosition; + + float gestureX = 0f; + float gestureY = 0f; + float pinchScale = 1f; + float rawHorizontal = ReadAccumulatedAxis(e, Axis.Hscroll); + float rawVertical = ReadAccumulatedAxis(e, Axis.Vscroll); + if (OperatingSystem.IsAndroidVersionAtLeast(34)) + { + gestureX = ReadAccumulatedAxis(e, Axis.GestureScrollXDistance); + gestureY = ReadAccumulatedAxis(e, Axis.GestureScrollYDistance); + pinchScale = ReadAccumulatedPinchScale(e); + } + + AndroidScrollDelta scroll = AndroidScrollDeltaPolicy.Convert( + density, + _horizontalScrollFactor, + _verticalScrollFactor, + rawHorizontal, + rawVertical, + gestureX, + gestureY); + float deltaX = scroll.X; + float deltaY = scroll.Y; + bool precise = scroll.IsPrecise; + + VirtualKeyModifiers modifiers = ReadModifiers(e.MetaState); + if (float.IsFinite(pinchScale) && pinchScale > 0f && MathF.Abs(pinchScale - 1f) > 0.0001f) + { + deltaX = 0f; + deltaY = MathF.Log(pinchScale) * PinchWheelUnitsPerNaturalLog; + modifiers |= VirtualKeyModifiers.Control; + } + if (deltaX == 0f && deltaY == 0f) return; + + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Wheel, + IndirectPointerId, + PointerDeviceType.Mouse, + position, + checked((ulong)Math.Max(0L, e.EventTime) * 1_000UL), + WheelDeltaX: deltaX, + WheelDeltaY: deltaY, + IsPreciseWheel: precise, + Modifiers: modifiers)); + } + + private static float ReadAccumulatedAxis(MotionEvent e, Axis axis) + { + float total = 0f; + for (int historyIndex = 0; historyIndex < e.HistorySize; historyIndex++) + { + float historical = e.GetHistoricalAxisValue(axis, historyIndex); + if (float.IsFinite(historical)) total += historical; + } + + float current = e.GetAxisValue(axis); + if (float.IsFinite(current)) total += current; + return total; + } + + private static float ReadAccumulatedPinchScale(MotionEvent e) + { + float scale = 1f; + for (int historyIndex = 0; historyIndex < e.HistorySize; historyIndex++) + { + float historical = e.GetHistoricalAxisValue(Axis.GesturePinchScaleFactor, historyIndex); + if (float.IsFinite(historical) && historical > 0f) scale *= historical; + } + + float current = e.GetAxisValue(Axis.GesturePinchScaleFactor); + if (float.IsFinite(current) && current > 0f) scale *= current; + return scale; + } + + private Vector2 GetDragPosition(global::Android.Views.DragEvent e) + { + float density = ResolveDensity(); + var position = new Vector2(e.GetX() / density, e.GetY() / density); + if (float.IsFinite(position.X) && float.IsFinite(position.Y)) _lastIndirectPosition = position; + return _lastIndirectPosition; + } + + private void UpdateExternalDragTarget(Vector2 screenPosition, bool isDrop) + { + Microsoft.UI.Xaml.FrameworkElement? target = FindDropTarget(InputSystem.HitTest(screenPosition)); + Microsoft.UI.Xaml.DataPackage data = _externalDragData ?? new Microsoft.UI.Xaml.DataPackage(); + if (!ReferenceEquals(target, _externalDragTarget)) + { + LeaveExternalDragTarget(screenPosition); + _externalDragTarget = target; + if (target != null) + { + target.OnDragEnter(CreateExternalDragArgs(target, screenPosition, data)); + } + } + if (target == null) return; + if (isDrop) + { + target.OnDrop(CreateExternalDragArgs(target, screenPosition, data)); + _externalDragTarget = null; + } + else + { + target.OnDragOver(CreateExternalDragArgs(target, screenPosition, data)); + } + InputSystem.Root?.Invalidate(); + } + + private void LeaveExternalDragTarget(Vector2 screenPosition) + { + if (_externalDragTarget is not { } target) return; + target.OnDragLeave(CreateExternalDragArgs( + target, + screenPosition, + _externalDragData ?? new Microsoft.UI.Xaml.DataPackage())); + _externalDragTarget = null; + InputSystem.Root?.Invalidate(); + } + + private static Microsoft.UI.Xaml.FrameworkElement? FindDropTarget(Microsoft.UI.Xaml.FrameworkElement? element) + { + for (Microsoft.UI.Xaml.FrameworkElement? current = element; + current != null; + current = current.Parent as Microsoft.UI.Xaml.FrameworkElement) + { + if (current.AllowDrop) return current; + } + return null; + } + + private static Microsoft.UI.Xaml.DragEventArgs CreateExternalDragArgs( + Microsoft.UI.Xaml.FrameworkElement target, + Vector2 screenPosition, + Microsoft.UI.Xaml.DataPackage data) => + new( + InputSystem.GetLocalPosition(target, screenPosition), + screenPosition, + data, + Microsoft.UI.Xaml.DragDropEffects.Copy, + Microsoft.UI.Xaml.DragDropModifiers.None); + + private Microsoft.UI.Xaml.DataPackage? CreateDataPackage(ClipData? clip) + { + if (clip == null) return null; + var data = new Microsoft.UI.Xaml.DataPackage(); + var text = new List(); + var uris = new List(); + for (int index = 0; index < clip.ItemCount; index++) + { + ClipData.Item? item = clip.GetItemAt(index); + if (item == null) continue; + string? uri = item.Uri?.ToString(); + if (!string.IsNullOrWhiteSpace(uri)) uris.Add(uri); + string? itemText = item.Text?.ToString() ?? item.CoerceToText(Context)?.ToString(); + if (!string.IsNullOrEmpty(itemText)) text.Add(itemText); + } + if (text.Count > 0) data.SetText(string.Join(System.Environment.NewLine, text)); + if (uris.Count > 0) + { + string[] values = [.. uris]; + data.SetData(Microsoft.UI.Xaml.StandardDataFormats.StorageItems, values); + data.SetData(Microsoft.UI.Xaml.StandardDataFormats.FileNames, values); + } + return data; + } + + private void AcquireExternalDragPermissions(global::Android.Views.DragEvent dragEvent) + { + ReleaseExternalDragPermissions(); + try + { + // Android grants transient URI access only from ACTION_DROP. Retain the + // token through ACTION_DRAG_ENDED so synchronous framework drop handlers + // can consume ClipData, then release it at the native session boundary. + _externalDragPermissions = _activity.RequestDragAndDropPermissions(dragEvent); + } + catch (Java.Lang.SecurityException) + { + // Plain-text drags and providers that do not offer URI grants must still + // reach the framework drop contract. + } + } + + private void ReleaseExternalDragPermissions() + { + DragAndDropPermissions? permissions = _externalDragPermissions; + _externalDragPermissions = null; + if (permissions == null) return; + permissions.Release(); + permissions.Dispose(); + } + + private bool TrySelectInputState() + { + if (InputStateProvider?.Invoke() is not { } inputState) return false; + InputSystem.Current = inputState; + return true; + } + + private void NotifyMetricsChanged() => MetricsChanged?.Invoke(Metrics); + + private float ResolveDensity() + { + float density = Resources?.DisplayMetrics?.Density ?? 1f; + return float.IsFinite(density) && density > 0f ? density : 1f; + } + + private static PointerDeviceType MapDeviceType(MotionEventToolType toolType) => toolType switch + { + MotionEventToolType.Stylus or MotionEventToolType.Eraser => PointerDeviceType.Pen, + MotionEventToolType.Mouse => PointerDeviceType.Mouse, + _ => PointerDeviceType.Touch + }; + + internal static VirtualKeyModifiers ReadModifiers(MetaKeyStates state) + { + var result = VirtualKeyModifiers.None; + if ((state & MetaKeyStates.ShiftMask) != 0) result |= VirtualKeyModifiers.Shift; + if ((state & MetaKeyStates.CtrlMask) != 0) result |= VirtualKeyModifiers.Control; + if ((state & MetaKeyStates.AltMask) != 0) result |= VirtualKeyModifiers.Menu; + if ((state & MetaKeyStates.MetaMask) != 0) result |= VirtualKeyModifiers.Windows; + return result; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + ReleaseExternalDragPermissions(); + Holder?.RemoveCallback(this); + } + base.Dispose(disposing); + } +} diff --git a/src/ProGPU.Android/AndroidScrollDeltaPolicy.cs b/src/ProGPU.Android/AndroidScrollDeltaPolicy.cs new file mode 100644 index 00000000..e2d4f735 --- /dev/null +++ b/src/ProGPU.Android/AndroidScrollDeltaPolicy.cs @@ -0,0 +1,37 @@ +namespace ProGPU.Android; + +internal readonly record struct AndroidScrollDelta(float X, float Y, bool IsPrecise); + +internal static class AndroidScrollDeltaPolicy +{ + /// + /// Converts Android scroll axes to ProGPU logical-pixel deltas. Android 14+ + /// gesture-distance axes are already expressed in display pixels. Legacy mouse + /// wheel axes are normalized device units and therefore use ViewConfiguration's + /// pixel factors. Both paths produce logical pixels, so consumers must not apply + /// an additional line-height multiplier. + /// + public static AndroidScrollDelta Convert( + float density, + float horizontalScrollFactor, + float verticalScrollFactor, + float rawHorizontal, + float rawVertical, + float gestureHorizontalPixels, + float gestureVerticalPixels) + { + if (!float.IsFinite(density) || density <= 0f) + density = 1f; + + float horizontalPixels = gestureHorizontalPixels != 0f + ? gestureHorizontalPixels + : rawHorizontal * horizontalScrollFactor; + float verticalPixels = gestureVerticalPixels != 0f + ? gestureVerticalPixels + : rawVertical * verticalScrollFactor; + + float x = float.IsFinite(horizontalPixels) ? horizontalPixels / density : 0f; + float y = float.IsFinite(verticalPixels) ? verticalPixels / density : 0f; + return new AndroidScrollDelta(x, y, IsPrecise: x != 0f || y != 0f); + } +} diff --git a/src/ProGPU.Android/AndroidStorageNamePolicy.cs b/src/ProGPU.Android/AndroidStorageNamePolicy.cs new file mode 100644 index 00000000..bdb3be11 --- /dev/null +++ b/src/ProGPU.Android/AndroidStorageNamePolicy.cs @@ -0,0 +1,13 @@ +namespace ProGPU.Android; + +internal static class AndroidStorageNamePolicy +{ + public static string SanitizeFileName(string? value, string fallback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fallback); + string fileName = string.IsNullOrWhiteSpace(value) ? fallback : value; + foreach (char invalid in Path.GetInvalidFileNameChars()) fileName = fileName.Replace(invalid, '_'); + fileName = fileName.Replace('\0', '_'); + return fileName is "." or ".." ? fallback : fileName; + } +} diff --git a/src/ProGPU.Android/AndroidStoragePickerService.cs b/src/ProGPU.Android/AndroidStoragePickerService.cs new file mode 100644 index 00000000..28a72af2 --- /dev/null +++ b/src/ProGPU.Android/AndroidStoragePickerService.cs @@ -0,0 +1,524 @@ +using Android.App; +using Android.Content; +using Android.Database; +using Android.Provider; +using Android.Webkit; +using Microsoft.UI.Xaml; +using AndroidUri = Android.Net.Uri; + +namespace ProGPU.Android; + +/// +/// Adapts Android's Storage Access Framework to the path-shaped WinUI storage seam. +/// Open documents are materialized into the app cache. Save documents retain their +/// content URI and synchronize every framework write through ContentResolver. +/// +internal sealed class AndroidStoragePickerService : IDisposable +{ + private const int OpenMode = 0; + private const int SaveMode = 1; + private const int FolderMode = 2; + private const int FirstRequestCode = 0x5A20; + + private readonly Activity _activity; + private readonly string _sessionDirectory; + private readonly SemaphoreSlim _pickerGate = new(1, 1); + private readonly object _stateLock = new(); + private readonly Dictionary _documentUris = new(StringComparer.Ordinal); + private readonly Dictionary _writableDocuments = new(StringComparer.Ordinal); + private readonly Dictionary _folderTrees = new(StringComparer.Ordinal); + private TaskCompletionSource? _activeCompletion; + private int _activeRequestCode; + private int _activeMode = -1; + private int _nextRequestCode = FirstRequestCode; + private bool _disposed; + + public AndroidStoragePickerService(Activity activity) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + string cache = activity.CacheDir?.AbsolutePath ?? Path.GetTempPath(); + _sessionDirectory = Path.Combine(cache, "ProGPU.Pickers", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_sessionDirectory); + } + + public async Task PickPathAsync( + int mode, + IReadOnlyList? fileTypes, + string? suggestedFileName) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (mode is not (OpenMode or SaveMode or FolderMode)) + throw new ArgumentOutOfRangeException(nameof(mode)); + + await _pickerGate.WaitAsync().ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(_disposed, this); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int requestCode; + lock (_stateLock) + { + requestCode = _nextRequestCode++; + if (_nextRequestCode > FirstRequestCode + 1024) _nextRequestCode = FirstRequestCode; + _activeRequestCode = requestCode; + _activeMode = mode; + _activeCompletion = completion; + } + + var intent = CreatePickerIntent(mode, fileTypes, suggestedFileName); + _activity.RunOnUiThread(() => + { + try + { + _activity.StartActivityForResult(intent, requestCode); + } + catch (Exception exception) + { + CompleteActivePicker(requestCode, exception: exception); + } + }); + return await completion.Task.ConfigureAwait(false); + } + finally + { + _pickerGate.Release(); + } + } + + public bool HandleActivityResult(int requestCode, Result resultCode, Intent? data) + { + int mode; + lock (_stateLock) + { + if (_activeCompletion == null || requestCode != _activeRequestCode) return false; + mode = _activeMode; + } + + if (resultCode != Result.Ok || data?.Data is not { } uri) + { + CompleteActivePicker(requestCode, result: null); + return true; + } + + TryPersistAccess(uri, data.Flags, mode); + _ = MaterializeSelectionAsync(requestCode, mode, uri); + return true; + } + + public async Task WriteTextAsync(string path, string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(text); + return await WriteExternalAsync(path, async stream => + { + using var writer = new StreamWriter(stream, leaveOpen: true); + await writer.WriteAsync(text).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + } + + public Task WriteBytesAsync(string path, byte[] bytes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(bytes); + return WriteExternalAsync(path, stream => stream.WriteAsync(bytes).AsTask()); + } + + public async Task ReadTextAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string? uriText = GetDocumentUri(path); + if (uriText == null) return await File.ReadAllTextAsync(path).ConfigureAwait(false); + await using Stream stream = OpenDocumentForRead(uriText); + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + + public async Task ReadBytesAsync(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string? uriText = GetDocumentUri(path); + if (uriText == null) return await File.ReadAllBytesAsync(path).ConfigureAwait(false); + await using Stream stream = OpenDocumentForRead(uriText); + using var output = new MemoryStream(); + await stream.CopyToAsync(output).ConfigureAwait(false); + return output.ToArray(); + } + + public Task> EnumerateFilesAsync(string folderPath) => + EnumerateChildrenAsync(folderPath, folders: false); + + public Task> EnumerateFoldersAsync(string folderPath) => + EnumerateChildrenAsync(folderPath, folders: true); + + public Task CreateFileAsync(string folderPath, string desiredName) => + CreateDocumentAsync(folderPath, desiredName, ResolveMimeType(desiredName)); + + public Task CreateFolderAsync(string folderPath, string desiredName) => + CreateDocumentAsync(folderPath, desiredName, DocumentsContract.Document.MimeTypeDir); + + private Intent CreatePickerIntent( + int mode, + IReadOnlyList? fileTypes, + string? suggestedFileName) + { + var intent = new Intent(mode switch + { + OpenMode => Intent.ActionOpenDocument, + SaveMode => Intent.ActionCreateDocument, + FolderMode => Intent.ActionOpenDocumentTree, + _ => throw new ArgumentOutOfRangeException(nameof(mode)) + }); + intent.AddFlags( + ActivityFlags.GrantReadUriPermission | + ActivityFlags.GrantWriteUriPermission | + ActivityFlags.GrantPersistableUriPermission | + ActivityFlags.GrantPrefixUriPermission); + + if (mode != FolderMode) + { + intent.AddCategory(Intent.CategoryOpenable); + string[] mimeTypes = ResolveMimeTypes(fileTypes); + intent.SetType(mimeTypes.Length == 1 ? mimeTypes[0] : "*/*"); + if (mimeTypes.Length > 1) intent.PutExtra(Intent.ExtraMimeTypes, mimeTypes); + } + if (mode == SaveMode) + intent.PutExtra(Intent.ExtraTitle, ResolveSuggestedFileName(suggestedFileName, fileTypes)); + return intent; + } + + private async Task MaterializeSelectionAsync(int requestCode, int mode, AndroidUri uri) + { + try + { + string displayName = QueryDisplayName(uri) ?? (mode == FolderMode ? "Folder" : "document"); + string result; + if (mode == FolderMode) + { + result = CreateUniqueLocalDirectory("Folders", displayName); + string uriText = uri.ToString() ?? string.Empty; + lock (_stateLock) + { + _folderTrees[result] = uriText; + _documentUris[result] = uriText; + } + } + else + { + string directory = CreateUniqueLocalDirectory(mode == OpenMode ? "Open" : "Save", null); + result = Path.Combine( + directory, + AndroidStorageNamePolicy.SanitizeFileName(displayName, "document")); + if (mode == OpenMode) + { + await using Stream? input = _activity.ContentResolver?.OpenInputStream(uri); + if (input == null) throw new IOException($"The Android document provider did not open '{uri}'."); + await using var output = new FileStream( + result, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + FileOptions.Asynchronous); + await input.CopyToAsync(output).ConfigureAwait(false); + } + else + { + await File.WriteAllBytesAsync(result, []).ConfigureAwait(false); + string uriText = uri.ToString() ?? string.Empty; + lock (_stateLock) + { + _writableDocuments[result] = uriText; + _documentUris[result] = uriText; + } + } + } + CompleteActivePicker(requestCode, result: result); + } + catch (Exception exception) + { + CompleteActivePicker(requestCode, exception: exception); + } + } + + private async Task WriteExternalAsync(string path, Func write) + { + string? uriText; + lock (_stateLock) _writableDocuments.TryGetValue(path, out uriText); + if (uriText == null) return false; + + AndroidUri? uri = AndroidUri.Parse(uriText); + if (uri == null) return false; + await using Stream? stream = _activity.ContentResolver?.OpenOutputStream(uri, "wt"); + if (stream == null) throw new IOException($"The Android document provider did not open '{uri}' for writing."); + await write(stream).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + return true; + } + + private Stream OpenDocumentForRead(string uriText) + { + AndroidUri? uri = AndroidUri.Parse(uriText); + Stream? stream = uri == null ? null : _activity.ContentResolver?.OpenInputStream(uri); + return stream ?? throw new IOException($"The Android document provider did not open '{uriText}' for reading."); + } + + private string? GetDocumentUri(string path) + { + lock (_stateLock) + return _documentUris.TryGetValue(path, out string? value) ? value : null; + } + + private Task> EnumerateChildrenAsync(string folderPath, bool folders) + { + ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); + string? treeText; + lock (_stateLock) _folderTrees.TryGetValue(folderPath, out treeText); + if (treeText == null) + { + IReadOnlyList local = folders + ? Directory.Exists(folderPath) ? Directory.GetDirectories(folderPath) : [] + : Directory.Exists(folderPath) ? Directory.GetFiles(folderPath) : []; + return Task.FromResult(local); + } + + AndroidUri treeUri = AndroidUri.Parse(treeText) + ?? throw new IOException($"The Android tree URI for '{folderPath}' is invalid."); + string parentId = TryGetDocumentId(treeUri) ?? + throw new IOException($"The Android provider did not expose a document id for '{treeUri}'."); + AndroidUri childrenUri = DocumentsContract.BuildChildDocumentsUriUsingTree(treeUri, parentId) + ?? throw new IOException($"The Android provider did not expose children for '{treeUri}'."); + string[] projection = + [ + DocumentsContract.Document.ColumnDocumentId, + DocumentsContract.Document.ColumnDisplayName, + DocumentsContract.Document.ColumnMimeType + ]; + var results = new List(); + using ICursor? cursor = _activity.ContentResolver?.Query(childrenUri, projection, null, null, null); + if (cursor == null) return Task.FromResult>(results); + int idColumn = cursor.GetColumnIndex(DocumentsContract.Document.ColumnDocumentId); + int nameColumn = cursor.GetColumnIndex(DocumentsContract.Document.ColumnDisplayName); + int mimeColumn = cursor.GetColumnIndex(DocumentsContract.Document.ColumnMimeType); + while (cursor.MoveToNext()) + { + string? documentId = idColumn >= 0 ? cursor.GetString(idColumn) : null; + string? displayName = nameColumn >= 0 ? cursor.GetString(nameColumn) : null; + string? mimeType = mimeColumn >= 0 ? cursor.GetString(mimeColumn) : null; + bool isFolder = string.Equals(mimeType, DocumentsContract.Document.MimeTypeDir, StringComparison.Ordinal); + if (isFolder != folders || string.IsNullOrEmpty(documentId)) continue; + AndroidUri? documentUri = DocumentsContract.BuildDocumentUriUsingTree(treeUri, documentId); + if (documentUri == null) continue; + string uriText = documentUri.ToString() ?? string.Empty; + string childPath = ResolveProxyChildPath(folderPath, displayName, uriText, isFolder); + lock (_stateLock) + { + _documentUris[childPath] = uriText; + if (isFolder) _folderTrees[childPath] = uriText; + else _writableDocuments[childPath] = uriText; + } + if (isFolder) Directory.CreateDirectory(childPath); + results.Add(childPath); + } + return Task.FromResult>(results); + } + + private Task CreateDocumentAsync(string folderPath, string desiredName, string mimeType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(folderPath); + desiredName = AndroidStorageNamePolicy.SanitizeFileName(desiredName, "untitled"); + string? treeText; + lock (_stateLock) _folderTrees.TryGetValue(folderPath, out treeText); + if (treeText == null) + { + string localPath = Path.Combine(folderPath, desiredName); + if (mimeType == DocumentsContract.Document.MimeTypeDir) Directory.CreateDirectory(localPath); + else File.WriteAllBytes(localPath, []); + return Task.FromResult(localPath); + } + + AndroidUri treeUri = AndroidUri.Parse(treeText) + ?? throw new IOException($"The Android tree URI for '{folderPath}' is invalid."); + string parentId = TryGetDocumentId(treeUri) ?? + throw new IOException($"The Android provider did not expose a document id for '{treeUri}'."); + AndroidUri parentUri = DocumentsContract.BuildDocumentUriUsingTree(treeUri, parentId) + ?? throw new IOException($"The Android provider did not expose '{parentId}'."); + AndroidUri createdUri = DocumentsContract.CreateDocument( + _activity.ContentResolver!, + parentUri, + mimeType, + desiredName) ?? throw new IOException($"The Android provider could not create '{desiredName}'."); + bool isFolder = mimeType == DocumentsContract.Document.MimeTypeDir; + string uriText = createdUri.ToString() ?? string.Empty; + string childPath = ResolveProxyChildPath(folderPath, desiredName, uriText, isFolder); + lock (_stateLock) + { + _documentUris[childPath] = uriText; + if (isFolder) _folderTrees[childPath] = uriText; + else _writableDocuments[childPath] = uriText; + } + if (isFolder) Directory.CreateDirectory(childPath); + else File.WriteAllBytes(childPath, []); + return Task.FromResult(childPath); + } + + private string ResolveProxyChildPath(string folderPath, string? displayName, string uriText, bool isFolder) + { + lock (_stateLock) + { + foreach ((string existingPath, string existingUri) in _documentUris) + { + if (string.Equals(existingUri, uriText, StringComparison.Ordinal)) return existingPath; + } + } + + string name = AndroidStorageNamePolicy.SanitizeFileName(displayName, isFolder ? "Folder" : "document"); + string candidate = Path.Combine(folderPath, name); + int suffix = 2; + while (File.Exists(candidate) || Directory.Exists(candidate) || GetDocumentUri(candidate) != null) + { + string stem = Path.GetFileNameWithoutExtension(name); + string extension = Path.GetExtension(name); + candidate = Path.Combine(folderPath, $"{stem}-{suffix++}{extension}"); + } + return candidate; + } + + private static string? TryGetDocumentId(AndroidUri uri) + { + try + { + string? documentId = DocumentsContract.GetDocumentId(uri); + if (!string.IsNullOrEmpty(documentId)) return documentId; + } + catch (Java.Lang.IllegalArgumentException) + { + } + return DocumentsContract.GetTreeDocumentId(uri); + } + + private static string ResolveMimeType(string fileName) + { + string extension = NormalizeExtension(Path.GetExtension(fileName)); + return MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.ToLowerInvariant()) + ?? "application/octet-stream"; + } + + private void TryPersistAccess(AndroidUri uri, ActivityFlags returnedFlags, int mode) + { + ActivityFlags access = returnedFlags & + (ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantWriteUriPermission); + if (mode == OpenMode) access &= ActivityFlags.GrantReadUriPermission; + if (access == 0) return; + try + { + _activity.ContentResolver?.TakePersistableUriPermission(uri, access); + } + catch (Java.Lang.SecurityException) + { + // Some providers grant access only for the current activity. Materializing + // open files immediately still honors that contract; save writes may later fail. + } + } + + private string? QueryDisplayName(AndroidUri uri) + { + using ICursor? cursor = _activity.ContentResolver?.Query( + uri, + [IOpenableColumns.DisplayName], + null, + null, + null); + if (cursor == null || !cursor.MoveToFirst()) return null; + int column = cursor.GetColumnIndex(IOpenableColumns.DisplayName); + return column >= 0 ? cursor.GetString(column) : null; + } + + private string CreateUniqueLocalDirectory(string category, string? displayName) + { + string directory = Path.Combine( + _sessionDirectory, + category, + Guid.NewGuid().ToString("N") + + (string.IsNullOrWhiteSpace(displayName) + ? string.Empty + : "-" + AndroidStorageNamePolicy.SanitizeFileName(displayName, category))); + Directory.CreateDirectory(directory); + return directory; + } + + private static string[] ResolveMimeTypes(IReadOnlyList? fileTypes) + { + if (fileTypes == null || fileTypes.Count == 0) return ["*/*"]; + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string raw in fileTypes) + { + string extension = NormalizeExtension(raw); + if (extension.Length == 0 || extension == "*") return ["*/*"]; + string? mime = MimeTypeMap.Singleton?.GetMimeTypeFromExtension(extension.ToLowerInvariant()); + result.Add(string.IsNullOrWhiteSpace(mime) ? "application/octet-stream" : mime); + } + return result.Count == 0 ? ["*/*"] : [.. result]; + } + + private static string ResolveSuggestedFileName( + string? suggestedFileName, + IReadOnlyList? fileTypes) + { + string fileName = AndroidStorageNamePolicy.SanitizeFileName( + Path.GetFileName(suggestedFileName ?? string.Empty), + "untitled"); + if (!Path.HasExtension(fileName) && fileTypes != null) + { + foreach (string type in fileTypes) + { + string extension = NormalizeExtension(type); + if (extension.Length == 0 || extension == "*") continue; + fileName += "." + extension; + break; + } + } + return fileName; + } + + private static string NormalizeExtension(string? fileType) + { + string extension = fileType?.Trim() ?? string.Empty; + if (extension is "*" or "*.*") return "*"; + if (extension.StartsWith("*.", StringComparison.Ordinal)) extension = extension[2..]; + else if (extension.StartsWith(".", StringComparison.Ordinal)) extension = extension[1..]; + return extension.Trim(); + } + + private void CompleteActivePicker(int requestCode, string? result = null, Exception? exception = null) + { + TaskCompletionSource? completion; + lock (_stateLock) + { + if (_activeCompletion == null || _activeRequestCode != requestCode) return; + completion = _activeCompletion; + _activeCompletion = null; + _activeMode = -1; + _activeRequestCode = 0; + } + if (exception != null) completion.TrySetException(exception); + else completion.TrySetResult(result); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + TaskCompletionSource? completion; + lock (_stateLock) + { + completion = _activeCompletion; + _activeCompletion = null; + _writableDocuments.Clear(); + _documentUris.Clear(); + _folderTrees.Clear(); + } + completion?.TrySetResult(null); + _pickerGate.Dispose(); + } +} diff --git a/src/ProGPU.Android/AndroidTextInputBridge.cs b/src/ProGPU.Android/AndroidTextInputBridge.cs new file mode 100644 index 00000000..2d56c29b --- /dev/null +++ b/src/ProGPU.Android/AndroidTextInputBridge.cs @@ -0,0 +1,311 @@ +using Android.App; +using Android.Content; +using Android.Graphics; +using Android.Text; +using Android.Views; +using Android.Views.InputMethods; +using Android.Widget; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Input; + +namespace ProGPU.Android; + +internal sealed class AndroidTextInputBridge : IDisposable +{ + private readonly Activity _activity; + private readonly BridgeEditText _editText; + private WindowInputState? _inputState; + private bool _acceptsReturn; + private bool _synchronizing; + private bool _compositionActive; + private string _lastText = string.Empty; + private string _compositionBaselineText = string.Empty; + private int _compositionStart; + private int _compositionOriginalLength; + private bool _disposed; + + public AndroidTextInputBridge(Activity activity) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + _editText = new BridgeEditText(activity) + { + Background = null, + Alpha = 0.01f, + ImportantForAccessibility = ImportantForAccessibility.NoHideDescendants + }; + _editText.SetCursorVisible(false); + _editText.SetIncludeFontPadding(false); + _editText.SetSingleLine(true); + _editText.SetPadding(0, 0, 0, 0); + _editText.SetTextColor(Color.Transparent); + _editText.SetHintTextColor(Color.Transparent); + _editText.SelectionChanged += OnNativeSelectionChanged; + _editText.AfterTextChanged += OnAfterTextChanged; + _editText.EditorAction += OnEditorAction; + } + + public View NativeView => _editText; + + public bool IsActive => _editText.HasFocus && _inputState != null; + + public void Attach(WindowInputState inputState) + { + ArgumentNullException.ThrowIfNull(inputState); + if (_inputState != null && !ReferenceEquals(_inputState, inputState)) Detach(_inputState); + _inputState = inputState; + inputState.FocusChanged = OnFocusChanged; + } + + public void Detach(WindowInputState inputState) + { + if (inputState.FocusChanged == OnFocusChanged) inputState.FocusChanged = null; + if (ReferenceEquals(_inputState, inputState)) _inputState = null; + TryHide(); + } + + public bool TryShow() + { + if (_inputState == null || InputSystem.FocusedElement is not ITextInputClient) return false; + _editText.RequestFocus(); + var manager = (InputMethodManager?)_activity.GetSystemService(Context.InputMethodService); + return manager?.ShowSoftInput(_editText, ShowFlags.Implicit) == true; + } + + public bool TryHide() + { + var manager = (InputMethodManager?)_activity.GetSystemService(Context.InputMethodService); + bool hidden = manager?.HideSoftInputFromWindow(_editText.WindowToken, HideSoftInputFlags.None) == true; + _editText.ClearFocus(); + return hidden; + } + + private WindowInputState FindInputState() => + _inputState ?? throw new InvalidOperationException("The Android text bridge is not attached."); + + private void OnFocusChanged(FrameworkElement? focusedElement) + { + if (focusedElement is not ITextInputClient client) + { + TryHide(); + return; + } + + InputSystem.Current = FindInputState(); + TextInputOptions options = client.GetTextInputOptions(); + _acceptsReturn = options.AcceptsReturn; + _editText.InputType = MapInputType(options); + _editText.ImeOptions = MapImeAction(options.EnterKeyHint, options.AcceptsReturn); + _editText.SetSingleLine(!options.AcceptsReturn); + PositionNativeEditor(options); + SynchronizeNativeDocument(options); + _editText.RequestFocus(); + _editText.Post(() => TryShow()); + } + + private void PositionNativeEditor(TextInputOptions options) + { + float density = ResolveDensity(); + if (_editText.LayoutParameters is not FrameLayout.LayoutParams layout) return; + layout.Width = Math.Max(1, checked((int)MathF.Ceiling(options.Bounds.Width * density))); + layout.Height = Math.Max(1, checked((int)MathF.Ceiling(options.Bounds.Height * density))); + layout.LeftMargin = checked((int)MathF.Round(options.Bounds.X * density)); + layout.TopMargin = checked((int)MathF.Round(options.Bounds.Y * density)); + _editText.LayoutParameters = layout; + } + + private void SynchronizeNativeDocument(TextInputOptions options) + { + _synchronizing = true; + try + { + _compositionActive = false; + _lastText = options.Text ?? string.Empty; + _editText.SetText(_lastText, TextView.BufferType.Editable); + int start = Math.Clamp(options.SelectionStart, 0, _lastText.Length); + int end = Math.Clamp(start + options.SelectionLength, start, _lastText.Length); + _editText.SetSelection(start, end); + } + finally + { + _synchronizing = false; + } + } + + private void OnAfterTextChanged(object? sender, AfterTextChangedEventArgs args) + { + if (_synchronizing || _inputState == null) return; + InputSystem.Current = FindInputState(); + string current = _editText.Text ?? string.Empty; + IEditable? editable = _editText.EditableText; + int markedStart = editable == null ? -1 : BaseInputConnection.GetComposingSpanStart(editable); + int markedEnd = editable == null ? -1 : BaseInputConnection.GetComposingSpanEnd(editable); + if (markedStart >= 0 && markedEnd >= markedStart) + { + string markedText = current.Substring( + Math.Clamp(markedStart, 0, current.Length), + Math.Clamp(markedEnd - markedStart, 0, current.Length - Math.Clamp(markedStart, 0, current.Length))); + if (!_compositionActive) + { + _compositionActive = true; + _compositionBaselineText = _lastText; + _compositionStart = Math.Clamp(markedStart, 0, _compositionBaselineText.Length); + _compositionOriginalLength = Math.Clamp( + _compositionBaselineText.Length + markedText.Length - current.Length, + 0, + _compositionBaselineText.Length - _compositionStart); + InputSystem.InjectTextInput(TextInputEventKind.CompositionStarted, isComposing: true); + } + InputSystem.InjectTextInput(TextInputEventKind.CompositionUpdated, markedText, isComposing: true); + _lastText = current; + return; + } + + if (_compositionActive) + { + if (string.Equals(current, _compositionBaselineText, StringComparison.Ordinal)) + { + InputSystem.InjectTextInput(TextInputEventKind.CompositionCanceled); + } + else + { + int finalLength = Math.Max( + 0, + current.Length - (_compositionBaselineText.Length - _compositionOriginalLength)); + finalLength = Math.Min(finalLength, Math.Max(0, current.Length - _compositionStart)); + string committed = current.Substring(_compositionStart, finalLength); + InputSystem.InjectTextInput(TextInputEventKind.CompositionCompleted, committed); + } + _compositionActive = false; + _lastText = current; + return; + } + + FindSingleReplacement(_lastText, current, out int start, out int removedLength, out string inserted); + int selectionStart = Math.Max(0, _editText.SelectionStart); + int selectionEnd = Math.Max(selectionStart, _editText.SelectionEnd); + InputSystem.InjectTextReplacement( + inserted, + start, + removedLength, + selectionStart, + selectionEnd - selectionStart); + _lastText = current; + } + + private void OnNativeSelectionChanged(int start, int end) + { + if (_synchronizing || _compositionActive || _inputState == null) return; + InputSystem.Current = FindInputState(); + start = Math.Max(0, start); + end = Math.Max(start, end); + InputSystem.InjectTextSelection(start, end - start); + } + + private void OnEditorAction(object? sender, TextView.EditorActionEventArgs args) + { + if (_inputState == null) return; + InputSystem.Current = FindInputState(); + InputSystem.InjectTextInput(TextInputEventKind.InsertLineBreak, "\n"); + if (!_acceptsReturn) TryHide(); + args.Handled = true; + } + + private static void FindSingleReplacement( + string oldText, + string newText, + out int start, + out int removedLength, + out string inserted) + { + int prefix = 0; + int commonLimit = Math.Min(oldText.Length, newText.Length); + while (prefix < commonLimit && oldText[prefix] == newText[prefix]) prefix++; + + int suffix = 0; + while (suffix < oldText.Length - prefix && + suffix < newText.Length - prefix && + oldText[oldText.Length - 1 - suffix] == newText[newText.Length - 1 - suffix]) + { + suffix++; + } + + start = prefix; + removedLength = oldText.Length - prefix - suffix; + inserted = newText.Substring(prefix, newText.Length - prefix - suffix); + } + + private static InputTypes MapInputType(TextInputOptions options) + { + InputTypes type = options.InputScope switch + { + InputScopeNameValue.Number => InputTypes.ClassNumber | InputTypes.NumberFlagDecimal | InputTypes.NumberFlagSigned, + InputScopeNameValue.NumericPin => InputTypes.ClassNumber | InputTypes.NumberVariationPassword, + InputScopeNameValue.TelephoneNumber => InputTypes.ClassPhone, + InputScopeNameValue.Url => InputTypes.ClassText | InputTypes.TextVariationUri, + InputScopeNameValue.EmailSmtpAddress => InputTypes.ClassText | InputTypes.TextVariationEmailAddress, + InputScopeNameValue.Password => InputTypes.ClassText | InputTypes.TextVariationPassword, + _ => InputTypes.ClassText | InputTypes.TextVariationNormal + }; + if (options.IsPassword) type = InputTypes.ClassText | InputTypes.TextVariationPassword; + if (options.AcceptsReturn) type |= InputTypes.TextFlagMultiLine | InputTypes.TextFlagCapSentences; + if (!options.IsSpellCheckEnabled) type |= InputTypes.TextFlagNoSuggestions; + type |= options.AutoCapitalize.ToLowerInvariant() switch + { + "allcharacters" or "characters" => InputTypes.TextFlagCapCharacters, + "words" => InputTypes.TextFlagCapWords, + "sentences" => InputTypes.TextFlagCapSentences, + _ => 0 + }; + return type; + } + + private static ImeAction MapImeAction(string hint, bool acceptsReturn) + { + if (acceptsReturn) return ImeAction.None; + return hint.ToLowerInvariant() switch + { + "done" => ImeAction.Done, + "go" => ImeAction.Go, + "next" => ImeAction.Next, + "search" => ImeAction.Search, + "send" => ImeAction.Send, + _ => ImeAction.Done + }; + } + + private float ResolveDensity() + { + float density = _activity.Resources?.DisplayMetrics?.Density ?? 1f; + return float.IsFinite(density) && density > 0f ? density : 1f; + } + + public void Dispose() + { + if (_disposed) return; + if (_inputState != null) Detach(_inputState); + _editText.SelectionChanged -= OnNativeSelectionChanged; + _editText.AfterTextChanged -= OnAfterTextChanged; + _editText.EditorAction -= OnEditorAction; + _editText.Dispose(); + _disposed = true; + } + + private sealed class BridgeEditText(Context context) : EditText(context) + { + public event Action? SelectionChanged; + + // This view exists only to supply Android's InputConnection. ProGPU owns hit + // testing and selection, so pointer streams must continue to the SurfaceView. + public override bool OnTouchEvent(MotionEvent? e) => false; + + public override bool OnGenericMotionEvent(MotionEvent? e) => false; + + public override bool OnHoverEvent(MotionEvent? e) => false; + + protected override void OnSelectionChanged(int selStart, int selEnd) + { + base.OnSelectionChanged(selStart, selEnd); + SelectionChanged?.Invoke(selStart, selEnd); + } + } +} diff --git a/src/ProGPU.Android/AndroidWindowHost.cs b/src/ProGPU.Android/AndroidWindowHost.cs new file mode 100644 index 00000000..8e01d9dc --- /dev/null +++ b/src/ProGPU.Android/AndroidWindowHost.cs @@ -0,0 +1,532 @@ +using Android.App; +using Android.Content; +using Android.Runtime; +using Android.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using ProGPU.Backend; +using ProGPU.Fonts.Inter; +using ProGPU.Fonts.Noto; +using ProGPU.Text; +using Silk.NET.Input; +using System.Runtime.InteropServices; +using Windows.Graphics.Display; +using Windows.UI.Core; +using XamlWindow = Microsoft.UI.Xaml.Window; + +namespace ProGPU.Android; + +internal sealed class AndroidWindowHost : Java.Lang.Object, IWindowHost, Choreographer.IFrameCallback +{ + private readonly Activity _activity; + private readonly AndroidRenderView _renderView; + private readonly AndroidTextInputBridge _textInput; + private readonly AndroidStoragePickerService _storagePicker; + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Action _setClipboard; + private readonly Func _getClipboard; + private HostedWindow? _hosted; + private Choreographer? _choreographer; + private nint _nativeWindow; + private nint _javaSurfaceHandle; + private long _previousFrameNanos; + private bool _framePending; + private bool _resumed; + private bool _reportedFirstFrame; + private bool _disposed; + + public AndroidWindowHost( + Activity activity, + AndroidRenderView renderView, + AndroidTextInputBridge textInput) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + _renderView = renderView ?? throw new ArgumentNullException(nameof(renderView)); + _textInput = textInput ?? throw new ArgumentNullException(nameof(textInput)); + _storagePicker = new AndroidStoragePickerService(activity); + + InterFontFamily.RegisterFonts(); + NotoFontFamily.RegisterFallbacks(); + FontApi.RegisterPlatformFallbackFont(InterFontFamily.Regular); + PopupService.DefaultFont ??= InterFontFamily.Regular; + + _setClipboard = SetClipboardText; + _getClipboard = GetClipboardText; + ClipboardHelper.PlatformSetText = _setClipboard; + ClipboardHelper.PlatformGetText = _getClipboard; + StoragePlatformServices.PickPathAsync = _storagePicker.PickPathAsync; + StoragePlatformServices.ReadTextAsync = _storagePicker.ReadTextAsync; + StoragePlatformServices.ReadBytesAsync = _storagePicker.ReadBytesAsync; + StoragePlatformServices.WriteTextAsync = _storagePicker.WriteTextAsync; + StoragePlatformServices.WriteBytesAsync = _storagePicker.WriteBytesAsync; + StoragePlatformServices.EnumerateFilesAsync = _storagePicker.EnumerateFilesAsync; + StoragePlatformServices.EnumerateFoldersAsync = _storagePicker.EnumerateFoldersAsync; + StoragePlatformServices.CreateFileAsync = _storagePicker.CreateFileAsync; + StoragePlatformServices.CreateFolderAsync = _storagePicker.CreateFolderAsync; + + _renderView.InputStateProvider = GetInputState; + _renderView.SurfaceAvailable += OnSurfaceAvailable; + _renderView.SurfaceUnavailable += OnSurfaceUnavailable; + _renderView.MetricsChanged += OnMetricsChanged; + _choreographer = Choreographer.Instance; + } + + public void Activate(XamlWindow window) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(window); + if (_hosted != null) + { + if (ReferenceEquals(_hosted.Window, window)) return; + throw new NotSupportedException("The Android host currently supports one top-level Window; popups remain compositor layers."); + } + + _hosted = new HostedWindow(window); + window.ConfigureInputPane(_textInput.TryShow, _textInput.TryHide); + TryInitializeRenderer(); + if (_hosted.Context == null) + throw new InvalidOperationException( + "The Android Window can only activate while its SurfaceView surface is available."); + AndroidRenderMetrics metrics = _renderView.Metrics; + window.NotifyHostInsetsChanged(metrics.SafeAreaInsets, metrics.InputPaneOccludedRect); + StartFrameLoop(); + } + + public void Close(XamlWindow window) + { + if (_hosted is not { } hosted || !ReferenceEquals(hosted.Window, window)) return; + StopFrameLoop(); + DetachTextInput(hosted.Window); + hosted.Window.ConfigureInputPane(null, null); + hosted.Window.ShutdownExternalRenderer(); + DisposeRendererContext(hosted); + ReleaseNativeWindow(); + _hosted = null; + _completion.TrySetResult(); + } + + public void Hide(XamlWindow window) + { + if (_hosted is not { } hosted || !ReferenceEquals(hosted.Window, window)) return; + hosted.IsVisible = false; + StopFrameLoop(); + window.NotifyHostVisibilityChanged(false); + window.NotifyHostActivationChanged(WindowActivationState.Deactivated); + } + + public Task RunAsync(CancellationToken cancellationToken = default) => + _completion.Task.WaitAsync(cancellationToken); + + public void Resume() + { + _resumed = true; + if (_hosted is not { } hosted) return; + hosted.IsVisible = true; + _previousFrameNanos = 0; + hosted.Window.NotifyHostVisibilityChanged(true); + hosted.Window.NotifyHostActivationChanged(WindowActivationState.CodeActivated); + StartFrameLoop(); + } + + public void Pause() + { + _resumed = false; + StopFrameLoop(); + if (_hosted is not { } hosted) return; + hosted.Window.NotifyHostActivationChanged(WindowActivationState.Deactivated); + } + + public bool HandleActivityResult(int requestCode, Result resultCode, Intent? data) => + _storagePicker.HandleActivityResult(requestCode, resultCode, data); + + public bool HandleKeyEvent(KeyEvent keyEvent) + { + ArgumentNullException.ThrowIfNull(keyEvent); + if (_hosted?.Window.InputState is not { } inputState) return false; + if (keyEvent.KeyCode == Keycode.Back) + { + // Let Activity observe the down event so its back dispatcher can track the + // gesture. On release, a WinUI handler gets first refusal; an unhandled + // release returns to Activity and preserves Android's normal navigation. + if (keyEvent.Action == KeyEventActions.Down) return false; + return keyEvent.Action == KeyEventActions.Up && HandleBackRequested(); + } + + Key key = MapKey(keyEvent.KeyCode); + if (key == Key.Unknown) return false; + bool textOwnsKey = _textInput.IsActive && + key is not (Key.Tab or Key.Escape or Key.F1 or Key.F2 or Key.F3 or Key.F4 or Key.F5 or Key.F6 or + Key.F7 or Key.F8 or Key.F9 or Key.F10 or Key.F11 or Key.F12); + if (textOwnsKey) return false; + + InputSystem.Current = inputState; + if (keyEvent.Action == KeyEventActions.Down) + { + InputSystem.InjectKeyDown(key); + return true; + } + if (keyEvent.Action == KeyEventActions.Up) + { + InputSystem.InjectKeyUp(key); + return true; + } + return false; + } + + public bool HandleBackRequested() + { + if (_hosted is null) return false; + return SystemNavigationManager.GetForCurrentView().NotifyBackRequested(); + } + + public void DoFrame(long frameTimeNanos) + { + _framePending = false; + if (_disposed || !_resumed || _nativeWindow == 0 || + _hosted is not { IsVisible: true, Context: not null } hosted) return; + + double delta = _previousFrameNanos == 0 + ? 0d + : Math.Clamp((frameTimeNanos - _previousFrameNanos) / 1_000_000_000d, 0d, 0.25d); + _previousFrameNanos = frameTimeNanos; + AndroidRenderMetrics metrics = _renderView.Metrics; + hosted.Window.NotifyHostInsetsChanged(metrics.SafeAreaInsets, metrics.InputPaneOccludedRect); + if (hosted.Window.InputState is { } inputState) InputSystem.Current = inputState; + hosted.Window.RenderExternalFrame(delta, metrics.Width, metrics.Height, metrics.DpiScale); + if (!_reportedFirstFrame) + { + _reportedFirstFrame = true; + WindowFrameMetrics frame = hosted.Window.FrameMetrics; + ProGPU.Scene.CompositorMetrics compositor = hosted.Window.Compositor?.Metrics ?? default; + FrameworkElement? content = hosted.Window.Content; + global::Android.Util.Log.Info( + "ProGPU.Android", + $"First frame: adapter='{hosted.Context.AdapterName}', backend={hosted.Context.AdapterBackendType}, " + + $"format={hosted.Context.SwapChainFormat}, " + + $"physical={metrics.Width}x{metrics.Height}, scale={metrics.DpiScale:F3}, " + + $"surface={frame.SurfaceAcquireTimeMs:F3}ms, compositor={frame.CompositorTimeMs:F3}ms, " + + $"present={frame.PresentTimeMs:F3}ms, total={frame.TotalTimeMs:F3}ms, " + + $"draws={compositor.DrawCallsCount}, vectors={compositor.VectorVerticesCount}, " + + $"text={compositor.TextVerticesCount}, content={content?.Size.X:F1}x{content?.Size.Y:F1}."); + } + StartFrameLoop(); + } + + private void OnSurfaceAvailable(Surface surface, int width, int height) + { + if (_disposed || !surface.IsValid) return; + nint surfaceHandle = surface.Handle; + if (_nativeWindow != 0 && _javaSurfaceHandle == surfaceHandle) + { + StartFrameLoop(); + return; + } + + if (_nativeWindow != 0 && _hosted is { Context: not null } hosted) + { + StopFrameLoop(); + hosted.Context.DetachAndroidNativeWindow(); + ReleaseNativeWindow(); + } + + _javaSurfaceHandle = surfaceHandle; + TryInitializeRenderer(); + StartFrameLoop(); + } + + private void OnSurfaceUnavailable() + { + StopFrameLoop(); + if (_hosted is { Context: not null } hosted) + { + hosted.Context.DetachAndroidNativeWindow(); + } + ReleaseNativeWindow(); + } + + private void TryInitializeRenderer() + { + if (_disposed || _hosted is not { } hosted || _nativeWindow != 0) return; + Surface? surface = _renderView.Holder?.Surface; + if (surface is not { IsValid: true }) return; + + nint nativeWindow = AndroidNativeWindow.Acquire(surface); + if (hosted.Context is { } existingContext) + { + try + { + AndroidRenderMetrics replacementMetrics = _renderView.Metrics; + existingContext.AttachAndroidNativeWindow( + nativeWindow, + replacementMetrics.Width, + replacementMetrics.Height); + _nativeWindow = nativeWindow; + _javaSurfaceHandle = surface.Handle; + PublishDisplayInformation(replacementMetrics); + hosted.Window.NotifyHostInsetsChanged( + replacementMetrics.SafeAreaInsets, + replacementMetrics.InputPaneOccludedRect); + return; + } + catch + { + AndroidNativeWindow.Release(nativeWindow); + throw; + } + } + + var context = new WgpuContext { VSync = true }; + try + { + AndroidRenderMetrics metrics = _renderView.Metrics; + context.InitializeAndroidNativeWindow(nativeWindow, metrics.Width, metrics.Height); + hosted.Window.InitializeExternalRenderer(context, metrics.DpiScale); + hosted.Context = context; + _nativeWindow = nativeWindow; + _javaSurfaceHandle = surface.Handle; + if (hosted.Window.InputState is { } inputState) + { + _textInput.Attach(inputState); + InputSystem.Current = inputState; + } + PublishDisplayInformation(metrics); + hosted.Window.NotifyHostInsetsChanged(metrics.SafeAreaInsets, metrics.InputPaneOccludedRect); + } + catch + { + context.Dispose(); + AndroidNativeWindow.Release(nativeWindow); + throw; + } + } + + private void OnMetricsChanged(AndroidRenderMetrics metrics) + { + if (_hosted is not { } hosted) return; + PublishDisplayInformation(metrics); + hosted.Window.NotifyHostInsetsChanged(metrics.SafeAreaInsets, metrics.InputPaneOccludedRect); + } + + private void PublishDisplayInformation(AndroidRenderMetrics renderMetrics) + { + global::Android.Util.DisplayMetrics? displayMetrics = _activity.Resources?.DisplayMetrics; + var configuration = _activity.Resources?.Configuration; + bool currentPortrait = configuration?.Orientation == global::Android.Content.Res.Orientation.Portrait; + SurfaceOrientation rotation = _activity.Display.Rotation; + bool nativePortrait = rotation is SurfaceOrientation.Rotation0 or SurfaceOrientation.Rotation180 + ? currentPortrait + : !currentPortrait; + bool flipped = rotation is SurfaceOrientation.Rotation180 or SurfaceOrientation.Rotation270; + DisplayOrientations currentOrientation = currentPortrait + ? flipped ? DisplayOrientations.PortraitFlipped : DisplayOrientations.Portrait + : flipped ? DisplayOrientations.LandscapeFlipped : DisplayOrientations.Landscape; + + global::Android.Views.Display.Mode? displayMode = _activity.Display.GetMode(); + int physicalWidth = displayMode?.PhysicalWidth ?? + displayMetrics?.WidthPixels ?? (int)renderMetrics.Width; + int physicalHeight = displayMode?.PhysicalHeight ?? + displayMetrics?.HeightPixels ?? (int)renderMetrics.Height; + uint screenWidth = checked((uint)Math.Max(1, physicalWidth)); + uint screenHeight = checked((uint)Math.Max(1, physicalHeight)); + double? diagonal = null; + float xdpi = displayMetrics?.Xdpi ?? 0f; + float ydpi = displayMetrics?.Ydpi ?? 0f; + if (float.IsFinite(xdpi) && xdpi > 0f && float.IsFinite(ydpi) && ydpi > 0f) + { + double widthInches = screenWidth / xdpi; + double heightInches = screenHeight / ydpi; + double candidate = Math.Sqrt(widthInches * widthInches + heightInches * heightInches); + if (double.IsFinite(candidate) && candidate > 0d) diagonal = candidate; + } + + DisplayInformation.NotifyHostMetricsChanged(new DisplayInformationMetrics( + currentOrientation, + nativePortrait ? DisplayOrientations.Portrait : DisplayOrientations.Landscape, + 96f * renderMetrics.DpiScale, + renderMetrics.DpiScale, + screenWidth, + screenHeight, + diagonal)); + } + + private void StartFrameLoop() + { + if (_disposed || _framePending || !_resumed || _nativeWindow == 0 || + _hosted is not { IsVisible: true, Context: not null } || _choreographer == null) return; + _framePending = true; + _choreographer.PostFrameCallback(this); + } + + private void StopFrameLoop() + { + if (_framePending) _choreographer?.RemoveFrameCallback(this); + _framePending = false; + _previousFrameNanos = 0; + } + + private WindowInputState? GetInputState() => _hosted?.Window.InputState; + + private void DetachTextInput(XamlWindow window) + { + if (window.InputState is { } inputState) _textInput.Detach(inputState); + } + + private static void DisposeRendererContext(HostedWindow hosted) + { + WgpuContext? context = hosted.Context; + hosted.Context = null; + context?.Dispose(); + } + + private void ReleaseNativeWindow() + { + nint nativeWindow = _nativeWindow; + _nativeWindow = 0; + _javaSurfaceHandle = 0; + if (nativeWindow != 0) AndroidNativeWindow.Release(nativeWindow); + } + + private void SetClipboardText(string text) + { + var clipboard = (ClipboardManager?)_activity.GetSystemService(Context.ClipboardService); + if (clipboard != null) clipboard.PrimaryClip = ClipData.NewPlainText("ProGPU", text ?? string.Empty); + } + + private string GetClipboardText() + { + var clipboard = (ClipboardManager?)_activity.GetSystemService(Context.ClipboardService); + ClipData? clip = clipboard?.PrimaryClip; + if (clip == null || clip.ItemCount == 0) return string.Empty; + return clip.GetItemAt(0)?.CoerceToText(_activity)?.ToString() ?? string.Empty; + } + + private static Key MapKey(Keycode code) + { + if (code is >= Keycode.A and <= Keycode.Z) + return Key.A + (code - Keycode.A); + if (code is >= Keycode.Num0 and <= Keycode.Num9) + return Key.Number0 + (code - Keycode.Num0); + if (code is >= Keycode.Numpad0 and <= Keycode.Numpad9) + return Key.Keypad0 + (code - Keycode.Numpad0); + if (code is >= Keycode.F1 and <= Keycode.F12) + return Key.F1 + (code - Keycode.F1); + + return code switch + { + Keycode.Space => Key.Space, + Keycode.Apostrophe => Key.Apostrophe, + Keycode.Comma => Key.Comma, + Keycode.Minus => Key.Minus, + Keycode.Period => Key.Period, + Keycode.Slash => Key.Slash, + Keycode.Semicolon => Key.Semicolon, + Keycode.Equals or Keycode.Plus => Key.Equal, + Keycode.LeftBracket => Key.LeftBracket, + Keycode.Backslash => Key.BackSlash, + Keycode.RightBracket => Key.RightBracket, + Keycode.Grave => Key.GraveAccent, + Keycode.Escape or Keycode.Back => Key.Escape, + Keycode.Enter => Key.Enter, + Keycode.Tab => Key.Tab, + Keycode.Del => Key.Backspace, + Keycode.Insert => Key.Insert, + Keycode.ForwardDel => Key.Delete, + Keycode.DpadRight => Key.Right, + Keycode.DpadLeft => Key.Left, + Keycode.DpadDown => Key.Down, + Keycode.DpadUp => Key.Up, + Keycode.PageUp => Key.PageUp, + Keycode.PageDown => Key.PageDown, + Keycode.MoveHome => Key.Home, + Keycode.MoveEnd => Key.End, + Keycode.CapsLock => Key.CapsLock, + Keycode.ScrollLock => Key.ScrollLock, + Keycode.NumLock => Key.NumLock, + Keycode.Print => Key.PrintScreen, + Keycode.Break => Key.Pause, + Keycode.NumpadDot => Key.KeypadDecimal, + Keycode.NumpadDivide => Key.KeypadDivide, + Keycode.NumpadMultiply => Key.KeypadMultiply, + Keycode.NumpadSubtract => Key.KeypadSubtract, + Keycode.NumpadAdd => Key.KeypadAdd, + Keycode.NumpadEnter => Key.KeypadEnter, + Keycode.NumpadEquals => Key.KeypadEqual, + Keycode.ShiftLeft => Key.ShiftLeft, + Keycode.CtrlLeft => Key.ControlLeft, + Keycode.AltLeft => Key.AltLeft, + Keycode.MetaLeft => Key.SuperLeft, + Keycode.ShiftRight => Key.ShiftRight, + Keycode.CtrlRight => Key.ControlRight, + Keycode.AltRight => Key.AltRight, + Keycode.MetaRight => Key.SuperRight, + Keycode.Menu => Key.Menu, + _ => Key.Unknown + }; + } + + public new void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_hosted is { } hosted) Close(hosted.Window); + StopFrameLoop(); + _renderView.SurfaceAvailable -= OnSurfaceAvailable; + _renderView.SurfaceUnavailable -= OnSurfaceUnavailable; + _renderView.MetricsChanged -= OnMetricsChanged; + _renderView.InputStateProvider = null; + ReleaseNativeWindow(); + if (ReferenceEquals(ClipboardHelper.PlatformSetText, _setClipboard)) ClipboardHelper.PlatformSetText = null; + if (ReferenceEquals(ClipboardHelper.PlatformGetText, _getClipboard)) ClipboardHelper.PlatformGetText = null; + if (StoragePlatformServices.PickPathAsync?.Target == _storagePicker) + StoragePlatformServices.PickPathAsync = null; + if (StoragePlatformServices.ReadTextAsync?.Target == _storagePicker) + StoragePlatformServices.ReadTextAsync = null; + if (StoragePlatformServices.ReadBytesAsync?.Target == _storagePicker) + StoragePlatformServices.ReadBytesAsync = null; + if (StoragePlatformServices.WriteTextAsync?.Target == _storagePicker) + StoragePlatformServices.WriteTextAsync = null; + if (StoragePlatformServices.WriteBytesAsync?.Target == _storagePicker) + StoragePlatformServices.WriteBytesAsync = null; + if (StoragePlatformServices.EnumerateFilesAsync?.Target == _storagePicker) + StoragePlatformServices.EnumerateFilesAsync = null; + if (StoragePlatformServices.EnumerateFoldersAsync?.Target == _storagePicker) + StoragePlatformServices.EnumerateFoldersAsync = null; + if (StoragePlatformServices.CreateFileAsync?.Target == _storagePicker) + StoragePlatformServices.CreateFileAsync = null; + if (StoragePlatformServices.CreateFolderAsync?.Target == _storagePicker) + StoragePlatformServices.CreateFolderAsync = null; + _storagePicker.Dispose(); + _textInput.Dispose(); + _completion.TrySetResult(); + base.Dispose(); + } + + private sealed class HostedWindow(XamlWindow window) + { + public XamlWindow Window { get; } = window; + public WgpuContext? Context { get; set; } + public bool IsVisible { get; set; } = true; + } + + private static class AndroidNativeWindow + { + public static nint Acquire(Surface surface) + { + nint result = ANativeWindowFromSurface(JNIEnv.Handle, surface.Handle); + if (result == 0) throw new InvalidOperationException("ANativeWindow_fromSurface returned a null Android native window."); + return result; + } + + public static void Release(nint nativeWindow) + { + if (nativeWindow != 0) ANativeWindowRelease(nativeWindow); + } + + [DllImport("android", EntryPoint = "ANativeWindow_fromSurface")] + private static extern nint ANativeWindowFromSurface(nint environment, nint surface); + + [DllImport("android", EntryPoint = "ANativeWindow_release")] + private static extern void ANativeWindowRelease(nint nativeWindow); + } +} diff --git a/src/ProGPU.Android/ProGPU.Android.csproj b/src/ProGPU.Android/ProGPU.Android.csproj new file mode 100644 index 00000000..def150de --- /dev/null +++ b/src/ProGPU.Android/ProGPU.Android.csproj @@ -0,0 +1,26 @@ + + + net10.0-android + enable + enable + true + 30.0 + true + true + true + $(MSBuildThisFileDirectory)../../artifacts/wgpu-native-android + + + + + + + + + + + + + + diff --git a/src/ProGPU.Samples.Android/AndroidManifest.xml b/src/ProGPU.Samples.Android/AndroidManifest.xml new file mode 100644 index 00000000..a22b1cb5 --- /dev/null +++ b/src/ProGPU.Samples.Android/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj b/src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj new file mode 100644 index 00000000..af4e4041 --- /dev/null +++ b/src/ProGPU.Samples.Android/ProGPU.Samples.Android.csproj @@ -0,0 +1,31 @@ + + + Exe + net10.0-android + android-arm64;android-x64 + 30.0 + enable + enable + true + com.progpu.samples + ProGPU Samples + 1.0 + 1 + apk + true + full + true + false + true + + + + + + + + diff --git a/src/ProGPU.Samples.Android/Program.cs b/src/ProGPU.Samples.Android/Program.cs new file mode 100644 index 00000000..74e7e390 --- /dev/null +++ b/src/ProGPU.Samples.Android/Program.cs @@ -0,0 +1,31 @@ +using Android.App; +using Android.Content.PM; +using Microsoft.UI.Xaml; +using ProGPU.Android; + +namespace ProGPU.Samples.Android; + +[Activity( + Label = "ProGPU Samples", + Theme = "@android:style/Theme.Material.NoActionBar", + MainLauncher = true, + Exported = true, + LaunchMode = LaunchMode.SingleTask, + ConfigurationChanges = + ConfigChanges.Orientation | + ConfigChanges.ScreenSize | + ConfigChanges.ScreenLayout | + ConfigChanges.SmallestScreenSize | + ConfigChanges.UiMode | + ConfigChanges.Density | + ConfigChanges.Keyboard | + ConfigChanges.KeyboardHidden)] +public sealed class MainActivity : ProGpuActivity +{ + protected override Task LaunchProGpuApplicationAsync() => + AppBuilder + .Configure() + .WithTitle("ProGPU Samples") + .Build() + .RunAsync([]); +} diff --git a/src/ProGPU.Samples/ProGPU.Samples.csproj b/src/ProGPU.Samples/ProGPU.Samples.csproj index aae94f7d..dc960311 100644 --- a/src/ProGPU.Samples/ProGPU.Samples.csproj +++ b/src/ProGPU.Samples/ProGPU.Samples.csproj @@ -21,7 +21,7 @@ - + diff --git a/src/ProGPU.Tests/AndroidScrollDeltaPolicyTests.cs b/src/ProGPU.Tests/AndroidScrollDeltaPolicyTests.cs new file mode 100644 index 00000000..6b6e7d5a --- /dev/null +++ b/src/ProGPU.Tests/AndroidScrollDeltaPolicyTests.cs @@ -0,0 +1,74 @@ +using ProGPU.Android; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class AndroidScrollDeltaPolicyTests +{ + [Fact] + public void NormalizedWheelAxesBecomeLogicalPixelsAndRemainPrecise() + { + AndroidScrollDelta result = AndroidScrollDeltaPolicy.Convert( + density: 3f, + horizontalScrollFactor: 150f, + verticalScrollFactor: 192f, + rawHorizontal: 1f, + rawVertical: -2f, + gestureHorizontalPixels: 0f, + gestureVerticalPixels: 0f); + + Assert.Equal(50f, result.X); + Assert.Equal(-128f, result.Y); + Assert.True(result.IsPrecise); + } + + [Fact] + public void AndroidFourteenGestureDistanceOverridesLegacyAxisPerDimension() + { + AndroidScrollDelta result = AndroidScrollDeltaPolicy.Convert( + density: 2f, + horizontalScrollFactor: 100f, + verticalScrollFactor: 100f, + rawHorizontal: 2f, + rawVertical: -3f, + gestureHorizontalPixels: 24f, + gestureVerticalPixels: -18f); + + Assert.Equal(12f, result.X); + Assert.Equal(-9f, result.Y); + Assert.True(result.IsPrecise); + } + + [Fact] + public void MissingGestureAxisFallsBackIndependently() + { + AndroidScrollDelta result = AndroidScrollDeltaPolicy.Convert( + density: 2f, + horizontalScrollFactor: 80f, + verticalScrollFactor: 120f, + rawHorizontal: 0.5f, + rawVertical: -1f, + gestureHorizontalPixels: 0f, + gestureVerticalPixels: -20f); + + Assert.Equal(20f, result.X); + Assert.Equal(-10f, result.Y); + } + + [Fact] + public void InvalidDensityAndAxisValuesAreContained() + { + AndroidScrollDelta result = AndroidScrollDeltaPolicy.Convert( + density: float.NaN, + horizontalScrollFactor: 80f, + verticalScrollFactor: 120f, + rawHorizontal: float.PositiveInfinity, + rawVertical: 0f, + gestureHorizontalPixels: 0f, + gestureVerticalPixels: 0f); + + Assert.Equal(0f, result.X); + Assert.Equal(0f, result.Y); + Assert.False(result.IsPrecise); + } +} diff --git a/src/ProGPU.Tests/AndroidStorageNamePolicyTests.cs b/src/ProGPU.Tests/AndroidStorageNamePolicyTests.cs new file mode 100644 index 00000000..88ee466d --- /dev/null +++ b/src/ProGPU.Tests/AndroidStorageNamePolicyTests.cs @@ -0,0 +1,36 @@ +using ProGPU.Android; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class AndroidStorageNamePolicyTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + public void UnsafeOrEmptySegmentsUseFallback(string? value) + { + Assert.Equal("document", AndroidStorageNamePolicy.SanitizeFileName(value, "document")); + } + + [Fact] + public void TraversalSeparatorIsReplaced() + { + string result = AndroidStorageNamePolicy.SanitizeFileName("../secret.txt", "document"); + + Assert.DoesNotContain(Path.DirectorySeparatorChar, result); + Assert.DoesNotContain(Path.AltDirectorySeparatorChar, result); + Assert.NotEqual("..", result); + } + + [Fact] + public void OrdinaryDisplayNameIsPreserved() + { + Assert.Equal( + "drawing.dxf", + AndroidStorageNamePolicy.SanitizeFileName("drawing.dxf", "document")); + } +} diff --git a/src/ProGPU.Tests/ProGPU.Tests.csproj b/src/ProGPU.Tests/ProGPU.Tests.csproj index c5cd5493..a52fd812 100644 --- a/src/ProGPU.Tests/ProGPU.Tests.csproj +++ b/src/ProGPU.Tests/ProGPU.Tests.csproj @@ -22,6 +22,10 @@ + + diff --git a/src/ProGPU.WinUI/ProGPU.WinUI.csproj b/src/ProGPU.WinUI/ProGPU.WinUI.csproj index 40228014..893e73ef 100644 --- a/src/ProGPU.WinUI/ProGPU.WinUI.csproj +++ b/src/ProGPU.WinUI/ProGPU.WinUI.csproj @@ -6,7 +6,7 @@ true - + diff --git a/src/ProGPU.slnx b/src/ProGPU.slnx index 1ba3762e..bfa352a3 100644 --- a/src/ProGPU.slnx +++ b/src/ProGPU.slnx @@ -3,6 +3,7 @@ + @@ -24,6 +25,7 @@ + From 9b208cad7ed1dc2086508e824a027d4915deb34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 23:01:39 +0200 Subject: [PATCH 11/14] Link Metal framework for iOS AOT builds --- src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj | 1 + src/ProGPU.iOS/ProGPU.iOS.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj b/src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj index 171488bb..a3300c8e 100644 --- a/src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj +++ b/src/ProGPU.Samples.iOS/ProGPU.Samples.iOS.csproj @@ -27,6 +27,7 @@ Framework true true + Metal diff --git a/src/ProGPU.iOS/ProGPU.iOS.csproj b/src/ProGPU.iOS/ProGPU.iOS.csproj index 48a7e8af..b0ff21a7 100644 --- a/src/ProGPU.iOS/ProGPU.iOS.csproj +++ b/src/ProGPU.iOS/ProGPU.iOS.csproj @@ -21,6 +21,7 @@ Framework true true + Metal Date: Tue, 21 Jul 2026 23:47:50 +0200 Subject: [PATCH 12/14] Implement WinUI gesture recognition APIs --- src/ProGPU.Tests/GestureRecognizerTests.cs | 295 +++++++ .../TouchGestureResponsiveTests.cs | 41 +- src/ProGPU.WinUI.Designer/Toolbox.cs | 2 +- src/ProGPU.WinUI/Controls/ComboBox.cs | 2 +- src/ProGPU.WinUI/Controls/DataGrid.cs | 12 +- .../Controls/NavigationViewItem.cs | 2 +- .../Controls/ScrollBarInteraction.cs | 2 +- src/ProGPU.WinUI/Controls/ScrollViewer.cs | 12 +- src/ProGPU.WinUI/Controls/TreeView.cs | 2 +- .../Controls/VirtualizingPanel.cs | 2 +- src/ProGPU.WinUI/Core/FrameworkElement.cs | 99 ++- src/ProGPU.WinUI/Core/WinUI.Xaml.cs | 6 +- src/ProGPU.WinUI/Core/Window.cs | 1 + src/ProGPU.WinUI/Input/GestureRecognizer.cs | 827 ++++++++++++++++++ src/ProGPU.WinUI/Input/InputSystem.cs | 219 ++++- src/ProGPU.WinUI/Input/PointerGestureTypes.cs | 299 +++++-- src/ProGPU.WinUI/Properties/AssemblyInfo.cs | 7 + src/ProGPU.WinUI/Text/RichEditTextRange.cs | 2 +- 18 files changed, 1693 insertions(+), 139 deletions(-) create mode 100644 src/ProGPU.Tests/GestureRecognizerTests.cs create mode 100644 src/ProGPU.WinUI/Input/GestureRecognizer.cs diff --git a/src/ProGPU.Tests/GestureRecognizerTests.cs b/src/ProGPU.Tests/GestureRecognizerTests.cs new file mode 100644 index 00000000..a3034cff --- /dev/null +++ b/src/ProGPU.Tests/GestureRecognizerTests.cs @@ -0,0 +1,295 @@ +using Microsoft.UI.Input; +using Windows.Devices.Input; +using Windows.Foundation; +using Xunit; +using InputPointerDeviceType = Microsoft.UI.Input.PointerDeviceType; +using NativePointerDeviceType = Windows.Devices.Input.PointerDeviceType; + +namespace ProGPU.Tests; + +public sealed class GestureRecognizerTests +{ + [Fact] + public void PublicContractDefaultsAndEnumValuesMatchWindowsAppSdk() + { + var recognizer = new GestureRecognizer(); + + Assert.True(recognizer.AutoProcessInertia); + Assert.False(recognizer.CrossSlideExact); + Assert.False(recognizer.CrossSlideHorizontally); + Assert.Equal(GestureSettings.None, recognizer.GestureSettings); + Assert.False(recognizer.IsActive); + Assert.False(recognizer.IsInertial); + Assert.False(recognizer.ManipulationExact); + Assert.True(recognizer.ShowGestureFeedback); + Assert.Equal(2048u, (uint)GestureSettings.ManipulationScale); + Assert.Equal(65536u, (uint)GestureSettings.ManipulationMultipleFingerPanning); + Assert.Equal(3, (int)InputPointerDeviceType.Touchpad); + } + + [Fact] + public void TapAndDoubleTapReportOfficialTapCounts() + { + var recognizer = new GestureRecognizer + { + GestureSettings = GestureSettings.Tap | GestureSettings.DoubleTap + }; + var counts = new List(); + recognizer.Tapped += (_, args) => counts.Add(args.TapCount); + + recognizer.ProcessDownEvent(Point(1, 20, 30, 1_000, true)); + recognizer.ProcessUpEvent(Point(1, 20, 30, 50_000, false)); + recognizer.ProcessDownEvent(Point(2, 22, 30, 180_000, true)); + recognizer.ProcessUpEvent(Point(2, 22, 30, 220_000, false)); + + Assert.Equal(new uint[] { 1, 2 }, counts); + Assert.False(recognizer.IsActive); + } + + [Fact] + public void MultiPointerManipulationReportsScaleRotationTranslationAndCumulativeValues() + { + var recognizer = new GestureRecognizer + { + GestureSettings = GestureSettings.ManipulationTranslateX | + GestureSettings.ManipulationTranslateY | + GestureSettings.ManipulationScale | + GestureSettings.ManipulationRotate, + ManipulationExact = true + }; + ManipulationUpdatedEventArgs? updated = null; + var started = 0; + var completed = 0; + recognizer.ManipulationStarted += (_, _) => started++; + recognizer.ManipulationUpdated += (_, args) => updated = args; + recognizer.ManipulationCompleted += (_, _) => completed++; + + recognizer.ProcessDownEvent(Point(1, 0, 0, 1_000, true)); + recognizer.ProcessDownEvent(Point(2, 10, 0, 2_000, true)); + recognizer.ProcessMoveEvents([Point(2, 20, 10, 12_000, true)]); + + Assert.Equal(1, started); + Assert.NotNull(updated); + Assert.True(updated!.Delta.Scale > 2f); + Assert.True(updated.Delta.Rotation > 20f); + Assert.Equal(5f, updated.Delta.Translation.X, 3); + Assert.Equal(5f, updated.Delta.Translation.Y, 3); + Assert.Equal(updated.Delta.Scale, updated.Cumulative.Scale, 3); + + recognizer.ProcessUpEvent(Point(1, 0, 0, 20_000, false)); + recognizer.ProcessUpEvent(Point(2, 20, 10, 21_000, false)); + Assert.Equal(1, completed); + } + + [Fact] + public void MouseDragAndTouchCrossSlideRaiseCompleteStateSequences() + { + var drag = new GestureRecognizer { GestureSettings = GestureSettings.Drag }; + var dragStates = new List(); + drag.Dragging += (_, args) => dragStates.Add(args.DraggingState); + drag.ProcessDownEvent(Point(4, 10, 10, 1_000, true, NativePointerDeviceType.Mouse, left: true)); + drag.ProcessMoveEvents([Point(4, 30, 10, 20_000, true, NativePointerDeviceType.Mouse, left: true)]); + drag.ProcessMoveEvents([Point(4, 40, 10, 30_000, true, NativePointerDeviceType.Mouse, left: true)]); + drag.ProcessUpEvent(Point(4, 40, 10, 40_000, false, NativePointerDeviceType.Mouse)); + Assert.Equal(new[] { DraggingState.Started, DraggingState.Continuing, DraggingState.Completed }, dragStates); + + var cross = new GestureRecognizer + { + GestureSettings = GestureSettings.CrossSlide, + CrossSlideHorizontally = true, + CrossSlideExact = true, + CrossSlideThresholds = new CrossSlideThresholds(5, 10, 20, 30) + }; + var crossStates = new List(); + cross.CrossSliding += (_, args) => crossStates.Add(args.CrossSlidingState); + cross.ProcessDownEvent(Point(5, 0, 0, 1_000, true)); + cross.ProcessMoveEvents([Point(5, 35, 1, 20_000, true)]); + cross.ProcessUpEvent(Point(5, 35, 1, 30_000, false)); + Assert.Equal(CrossSlidingState.Started, crossStates[0]); + Assert.Contains(CrossSlidingState.Rearranging, crossStates); + Assert.Equal(CrossSlidingState.Completed, crossStates[^1]); + } + + [Fact] + public void MouseWheelSupportsTranslationAndControlScale() + { + var recognizer = new GestureRecognizer + { + GestureSettings = GestureSettings.ManipulationTranslateY | GestureSettings.ManipulationScale + }; + var deltas = new List(); + recognizer.ManipulationUpdated += (_, args) => deltas.Add(args.Delta); + var wheel = Point(9, 50, 50, 1_000, false, NativePointerDeviceType.Mouse, wheel: 120); + + recognizer.ProcessMouseWheelEvent(wheel, isShiftKeyDown: false, isControlKeyDown: false); + recognizer.ProcessMouseWheelEvent(wheel, isShiftKeyDown: false, isControlKeyDown: true); + + Assert.Equal(48f, deltas[0].Translation.Y, 3); + Assert.Equal(1f, deltas[0].Scale, 3); + Assert.Equal(1.1f, deltas[1].Scale, 3); + } + + [Fact] + public void RightMouseTapAndSinglePointerPivotRotationAreRecognized() + { + var rightTap = new GestureRecognizer { GestureSettings = GestureSettings.RightTap }; + RightTappedEventArgs? rightArgs = null; + rightTap.RightTapped += (_, args) => rightArgs = args; + rightTap.ProcessDownEvent(Point(10, 5, 5, 1_000, true, NativePointerDeviceType.Mouse, right: true)); + rightTap.ProcessUpEvent(Point(10, 5, 5, 20_000, false, NativePointerDeviceType.Mouse)); + Assert.NotNull(rightArgs); + Assert.Equal(InputPointerDeviceType.Mouse, rightArgs!.PointerDeviceType); + + var rotate = new GestureRecognizer + { + GestureSettings = GestureSettings.ManipulationRotate, + ManipulationExact = true, + PivotCenter = new Point(0, 0), + PivotRadius = 10 + }; + ManipulationUpdatedEventArgs? update = null; + rotate.ManipulationUpdated += (_, args) => update = args; + rotate.ProcessDownEvent(Point(11, 10, 0, 1_000, true)); + rotate.ProcessMoveEvents([Point(11, 0, 10, 11_000, true)]); + Assert.NotNull(update); + Assert.Equal(90f, update!.Delta.Rotation, 3); + } + + [Fact] + public void ManualInertiaRunsUntilCompletion() + { + var recognizer = new GestureRecognizer + { + AutoProcessInertia = false, + ManipulationExact = true, + GestureSettings = GestureSettings.ManipulationTranslateX | + GestureSettings.ManipulationTranslateInertia, + InertiaTranslationDeceleration = 0.1f + }; + var completed = 0; + recognizer.ManipulationCompleted += (_, _) => completed++; + recognizer.ProcessDownEvent(Point(8, 0, 0, 1_000, true)); + recognizer.ProcessMoveEvents([Point(8, 20, 0, 11_000, true)]); + recognizer.ProcessUpEvent(Point(8, 20, 0, 12_000, false)); + + Assert.True(recognizer.IsInertial); + for (var index = 0; index < 20 && recognizer.IsInertial; index++) recognizer.ProcessInertia(); + Assert.False(recognizer.IsInertial); + Assert.Equal(1, completed); + } + + [Fact] + public void FinalUpSampleContributesItsMovementToTheManipulation() + { + var recognizer = new GestureRecognizer + { + GestureSettings = GestureSettings.ManipulationTranslateX, + ManipulationExact = true + }; + ManipulationCompletedEventArgs? completed = null; + recognizer.ManipulationCompleted += (_, args) => completed = args; + + recognizer.ProcessDownEvent(Point(12, 0, 0, 1_000, true)); + recognizer.ProcessUpEvent(Point(12, 20, 0, 11_000, false)); + + Assert.NotNull(completed); + Assert.Equal(20d, completed!.Cumulative.Translation.X, 3); + } + + [Fact] + public void XamlManipulationArgumentsExposeWinUiCompletionAndInertiaContracts() + { + var pivot = new Microsoft.UI.Xaml.Input.ManipulationPivot(new Point(12, 18), 24); + var starting = new Microsoft.UI.Xaml.Input.ManipulationStartingRoutedEventArgs + { + Mode = Microsoft.UI.Xaml.Input.ManipulationModes.Scale, + Pivot = pivot + }; + var delta = new Microsoft.UI.Xaml.Input.ManipulationDeltaRoutedEventArgs(); + delta.Complete(); + var inertia = new Microsoft.UI.Xaml.Input.ManipulationInertiaStartingRoutedEventArgs(); + inertia.TranslationBehavior.DesiredDeceleration = 0.25; + + Assert.Equal(12d, starting.Pivot!.Center.X); + Assert.Equal(24d, starting.Pivot.Radius); + Assert.True(delta.IsCompleteRequested); + Assert.Equal(0.25, inertia.TranslationBehavior.DesiredDeceleration); + } + + [Fact] + public void XamlGestureContractsUseCurrentMicrosoftUiInputTypes() + { + Assert.Equal(typeof(Microsoft.UI.Xaml.RoutedEventArgs), typeof(Microsoft.UI.Xaml.Input.TappedRoutedEventArgs).BaseType); + Assert.Equal(typeof(InputPointerDeviceType), + typeof(Microsoft.UI.Xaml.Input.TappedRoutedEventArgs).GetProperty("PointerDeviceType")!.PropertyType); + Assert.Equal(typeof(ManipulationDelta), + typeof(Microsoft.UI.Xaml.Input.ManipulationDeltaRoutedEventArgs).GetProperty("Delta")!.PropertyType); + Assert.Equal(typeof(ManipulationVelocities), + typeof(Microsoft.UI.Xaml.Input.ManipulationDeltaRoutedEventArgs).GetProperty("Velocities")!.PropertyType); + Assert.Null(typeof(Microsoft.UI.Xaml.Input.ManipulationDeltaRoutedEventArgs).Assembly.GetType( + "Microsoft.UI.Xaml.Input.ManipulationDelta")); + Assert.False(typeof(Microsoft.UI.Xaml.Input.ManipulationStartedRoutedEventArgs).IsSealed); + Assert.Equal(Microsoft.UI.Xaml.Input.ManipulationModes.All, + new Microsoft.UI.Xaml.Input.ManipulationStartingRoutedEventArgs().Mode); + Assert.Null(typeof(Microsoft.UI.Xaml.Input.ManipulationInertiaStartingRoutedEventArgs).GetProperty("Position")); + } + + [Fact] + public void PointerPointTransformPreservesMetadataAndTransformsContactBounds() + { + var point = Point(13, 4, 6, 100, true); + var transformed = point.GetTransformedPoint(new OffsetTransform(10, 20)); + + Assert.NotNull(transformed); + Assert.Equal(14d, transformed!.Position.X); + Assert.Equal(26d, transformed.Position.Y); + Assert.Equal(13d, transformed.Properties.ContactRect.X); + Assert.Equal(25d, transformed.Properties.ContactRect.Y); + Assert.Equal(point.PointerId, transformed.PointerId); + Assert.Equal(point.PointerDeviceType, transformed.PointerDeviceType); + } + + private static PointerPoint Point( + uint id, + float x, + float y, + ulong timestamp, + bool contact, + NativePointerDeviceType device = NativePointerDeviceType.Touch, + bool left = false, + bool right = false, + int wheel = 0) => + new( + id, + timestamp, + new System.Numerics.Vector2(x, y), + new System.Numerics.Vector2(x, y), + device, + contact, + new PointerPointProperties + { + IsPrimary = true, + IsInRange = true, + IsLeftButtonPressed = left, + IsRightButtonPressed = right, + MouseWheelDelta = wheel, + ContactRect = new Rect(x - 1, y - 1, 2, 2) + }); + + private sealed class OffsetTransform(double x, double y) : IPointerPointTransform + { + public IPointerPointTransform Inverse => new OffsetTransform(-x, -y); + + public bool TryTransform(Point inPoint, out Point outPoint) + { + outPoint = new Point(inPoint.X + x, inPoint.Y + y); + return true; + } + + public bool TryTransformBounds(Rect inRect, out Rect outRect) + { + outRect = new Rect(inRect.X + x, inRect.Y + y, inRect.Width, inRect.Height); + return true; + } + } +} diff --git a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs index 156c2765..cc979859 100644 --- a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs +++ b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs @@ -20,7 +20,7 @@ public void TouchPointerPreservesIdentityCaptureAndCancellation() InputSystem.InjectPointer(Touch(PointerInputKind.Pressed, 17, 20, 20, 1_000, true)); Assert.Equal((uint)17, target.LastPointerId); - Assert.Equal(PointerDeviceType.Touch, target.LastDeviceType); + Assert.Equal(Microsoft.UI.Input.PointerDeviceType.Touch, target.LastDeviceType); Assert.True(target.CaptureSucceeded); InputSystem.InjectPointer(Touch(PointerInputKind.Moved, 17, 210, 210, 20_000, true)); @@ -60,6 +60,31 @@ public void TouchRecognizesTapDoubleTapAndTwoPointerScale() Assert.Equal(1, target.ManipulationCompletedCount); } + [Fact] + public void RoutedManipulationProducesInertialDeltasBeforeCompletion() + { + var target = new TrackingControl + { + Width = 220, + Height = 220, + ManipulationMode = ManipulationModes.TranslateY | ManipulationModes.TranslateInertia + }; + ArrangeRoot(target, new Vector2(220, 220)); + UseInputRoot(target); + + InputSystem.InjectPointer(Touch(PointerInputKind.Pressed, 90, 100, 160, 1_000, true)); + InputSystem.InjectPointer(Touch(PointerInputKind.Moved, 90, 100, 60, 31_000, true)); + InputSystem.InjectPointer(Touch(PointerInputKind.Released, 90, 100, 60, 32_000, false)); + + Assert.Equal(1, target.ManipulationInertiaStartingCount); + Assert.Equal(0, target.ManipulationCompletedCount); + for (var index = 0; index < 100 && target.ManipulationCompletedCount == 0; index++) + InputSystem.UpdateManipulationInertia(0.016f); + Assert.True(target.InertialDeltaCount > 0); + Assert.Equal(1, target.ManipulationCompletedCount); + Assert.True(target.LastManipulationCompletedWasInertial); + } + [Fact] public void TouchReleaseBeyondTapThresholdWithoutMoveDoesNotTap() { @@ -957,7 +982,7 @@ private static IEnumerable DescendantsAndSelf(Visual visual) private sealed class TrackingControl : Control { public uint LastPointerId { get; private set; } - public PointerDeviceType LastDeviceType { get; private set; } + public Microsoft.UI.Input.PointerDeviceType LastDeviceType { get; private set; } public Vector2 LastScreenPosition { get; private set; } public bool CaptureSucceeded { get; private set; } public bool CaptureOnPress { get; init; } @@ -967,6 +992,9 @@ private sealed class TrackingControl : Control public int DoubleTappedCount { get; private set; } public int ManipulationStartedCount { get; private set; } public int ManipulationCompletedCount { get; private set; } + public int ManipulationInertiaStartingCount { get; private set; } + public int InertialDeltaCount { get; private set; } + public bool LastManipulationCompletedWasInertial { get; private set; } public float LastManipulationScale { get; private set; } = 1f; public override void OnPointerPressed(PointerRoutedEventArgs e) @@ -1016,12 +1044,21 @@ public override void OnManipulationStarted(ManipulationStartedRoutedEventArgs e) public override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) { LastManipulationScale = e.Delta.Scale; + if (e.IsInertial) InertialDeltaCount++; base.OnManipulationDelta(e); } + public override void OnManipulationInertiaStarting(ManipulationInertiaStartingRoutedEventArgs e) + { + ManipulationInertiaStartingCount++; + e.TranslationBehavior.DesiredDeceleration = 0.01; + base.OnManipulationInertiaStarting(e); + } + public override void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) { ManipulationCompletedCount++; + LastManipulationCompletedWasInertial = e.IsInertial; base.OnManipulationCompleted(e); } } diff --git a/src/ProGPU.WinUI.Designer/Toolbox.cs b/src/ProGPU.WinUI.Designer/Toolbox.cs index 6dbc6137..38f522df 100644 --- a/src/ProGPU.WinUI.Designer/Toolbox.cs +++ b/src/ProGPU.WinUI.Designer/Toolbox.cs @@ -7,7 +7,7 @@ namespace ProGPU.WinUI.Designer; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Documents; using ProGPU.Vector; -using Windows.Devices.Input; +using Microsoft.UI.Input; public class ToolboxItem : Border { diff --git a/src/ProGPU.WinUI/Controls/ComboBox.cs b/src/ProGPU.WinUI/Controls/ComboBox.cs index 63efd01f..9f6788b5 100644 --- a/src/ProGPU.WinUI/Controls/ComboBox.cs +++ b/src/ProGPU.WinUI/Controls/ComboBox.cs @@ -11,7 +11,7 @@ using ProGPU.Layout; using ProGPU.Vector; using ProGPU.Scene; -using Windows.Devices.Input; +using Microsoft.UI.Input; namespace Microsoft.UI.Xaml.Controls; diff --git a/src/ProGPU.WinUI/Controls/DataGrid.cs b/src/ProGPU.WinUI/Controls/DataGrid.cs index db6559d1..59ca86fc 100644 --- a/src/ProGPU.WinUI/Controls/DataGrid.cs +++ b/src/ProGPU.WinUI/Controls/DataGrid.cs @@ -16,7 +16,7 @@ using ProGPU.Text; using System.Collections.Concurrent; using System.Globalization; -using Windows.Devices.Input; +using Microsoft.UI.Input; using ProGPU.Virtualization; namespace Microsoft.UI.Xaml.Controls; @@ -585,7 +585,7 @@ public override void OnPointerWheelChanged(PointerRoutedEventArgs e) public override void OnPointerPressed(PointerRoutedEventArgs e) { - var position = e.GetCurrentPoint(this).Position; + Vector2 position = e.GetCurrentPoint(this).Position; if (IsEnabled && TryStartScrollbarInteraction(e, position)) { return; @@ -712,7 +712,7 @@ public override void OnPointerReleased(PointerRoutedEventArgs e) if (_pendingTouchPointerId == e.Pointer.PointerId) { - var position = e.GetCurrentPoint(this).Position; + Vector2 position = e.GetCurrentPoint(this).Position; if (IsEnabled && IsPointerPressed && IsPointerOver) { if (_pendingTouchSortColumn >= 0 && position.Y <= _headerHeight && @@ -804,14 +804,14 @@ public override void OnManipulationStarted(ManipulationStartedRoutedEventArgs e) public override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) { var oldOffset = ScrollOffset; - ScrollOffset -= e.Delta.Translation.Y; + ScrollOffset -= (float)e.Delta.Translation.Y; if (oldOffset != ScrollOffset) e.Handled = true; base.OnManipulationDelta(e); } public override void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) { - _touchInertiaVelocity = e.IsInertial ? -e.Velocities.Linear.Y : 0f; + _touchInertiaVelocity = e.IsInertial ? -(float)e.Velocities.Linear.Y : 0f; base.OnManipulationCompleted(e); } @@ -844,7 +844,7 @@ public override void OnPointerExited(PointerRoutedEventArgs e) public override void OnPointerMoved(PointerRoutedEventArgs e) { - var position = e.ScreenPosition == Vector2.Zero && e.Position != Vector2.Zero + Vector2 position = e.ScreenPosition == Vector2.Zero && e.Position != Vector2.Zero ? e.Position : e.GetCurrentPoint(this).Position; if (IsEnabled) diff --git a/src/ProGPU.WinUI/Controls/NavigationViewItem.cs b/src/ProGPU.WinUI/Controls/NavigationViewItem.cs index 6cee1811..cf8390db 100644 --- a/src/ProGPU.WinUI/Controls/NavigationViewItem.cs +++ b/src/ProGPU.WinUI/Controls/NavigationViewItem.cs @@ -11,7 +11,7 @@ using ProGPU.Scene; using ProGPU.Vector; using ProGPU.Text; -using Windows.Devices.Input; +using Microsoft.UI.Input; using static System.FormattableString; namespace Microsoft.UI.Xaml.Controls; diff --git a/src/ProGPU.WinUI/Controls/ScrollBarInteraction.cs b/src/ProGPU.WinUI/Controls/ScrollBarInteraction.cs index 5b98ff7c..41d4a851 100644 --- a/src/ProGPU.WinUI/Controls/ScrollBarInteraction.cs +++ b/src/ProGPU.WinUI/Controls/ScrollBarInteraction.cs @@ -1,7 +1,7 @@ using System; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Input; -using Windows.Devices.Input; +using Microsoft.UI.Input; namespace Microsoft.UI.Xaml.Controls; diff --git a/src/ProGPU.WinUI/Controls/ScrollViewer.cs b/src/ProGPU.WinUI/Controls/ScrollViewer.cs index 15da1790..40bc613c 100644 --- a/src/ProGPU.WinUI/Controls/ScrollViewer.cs +++ b/src/ProGPU.WinUI/Controls/ScrollViewer.cs @@ -223,8 +223,8 @@ public override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) var oldHorizontal = HorizontalOffset; var oldVertical = VerticalOffset; var oldZoom = ZoomFactor; - if (HorizontalScrollMode != ScrollMode.Disabled) HorizontalOffset -= e.Delta.Translation.X; - if (VerticalScrollMode != ScrollMode.Disabled) VerticalOffset -= e.Delta.Translation.Y; + if (HorizontalScrollMode != ScrollMode.Disabled) HorizontalOffset -= (float)e.Delta.Translation.X; + if (VerticalScrollMode != ScrollMode.Disabled) VerticalOffset -= (float)e.Delta.Translation.Y; if (ZoomMode == ZoomMode.Enabled && float.IsFinite(e.Delta.Scale)) ZoomFactor *= e.Delta.Scale; if (oldHorizontal != HorizontalOffset || oldVertical != VerticalOffset || oldZoom != ZoomFactor) { @@ -243,7 +243,7 @@ public override void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) public override void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) { - if (e.IsInertial) _inertiaVelocity = -e.Velocities.Linear; + if (e.IsInertial) _inertiaVelocity = -(Vector2)e.Velocities.Linear; RaiseViewChanged(isIntermediate: false); base.OnManipulationCompleted(e); } @@ -323,7 +323,7 @@ public override void OnPointerPressed(PointerRoutedEventArgs e) { if (IsEnabled) { - var localPos = e.GetCurrentPoint(this).Position; + Vector2 localPos = e.GetCurrentPoint(this).Position; var deviceType = e.Pointer.PointerDeviceType; if (ScrollBarInteraction.TryCreateMetrics(0f, Size.Y, ContentHeight, VerticalOffset, out var verticalMetrics) && ScrollBarInteraction.IsVerticalTrackHit(localPos.X, Size.X, deviceType)) @@ -446,7 +446,7 @@ public override void OnPointerMoved(PointerRoutedEventArgs e) if (_scrollbarPointerId == e.Pointer.PointerId && _draggingScrollBar.HasValue && IsEnabled) { - var localPos = e.GetCurrentPoint(this).Position; + Vector2 localPos = e.GetCurrentPoint(this).Position; if (_draggingScrollBar == Orientation.Vertical && ScrollBarInteraction.TryCreateMetrics(0f, Size.Y, ContentHeight, _dragStartOffset, out var verticalMetrics)) { @@ -467,7 +467,7 @@ public override void OnPointerMoved(PointerRoutedEventArgs e) private void UpdateScrollbarPointerOver(PointerRoutedEventArgs e) { - var localPos = e.GetCurrentPoint(this).Position; + Vector2 localPos = e.GetCurrentPoint(this).Position; var deviceType = e.Pointer.PointerDeviceType; _isPointerOverVerticalScrollbar = ContentHeight > Size.Y && ScrollBarInteraction.IsVerticalTrackHit(localPos.X, Size.X, deviceType); diff --git a/src/ProGPU.WinUI/Controls/TreeView.cs b/src/ProGPU.WinUI/Controls/TreeView.cs index 003482cd..b7ad5f67 100644 --- a/src/ProGPU.WinUI/Controls/TreeView.cs +++ b/src/ProGPU.WinUI/Controls/TreeView.cs @@ -11,7 +11,7 @@ using ProGPU.Scene; using ProGPU.Vector; using ProGPU.Text; -using Windows.Devices.Input; +using Microsoft.UI.Input; using static System.FormattableString; namespace Microsoft.UI.Xaml.Controls; diff --git a/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs b/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs index 7067eb04..056c04fc 100644 --- a/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs +++ b/src/ProGPU.WinUI/Controls/VirtualizingPanel.cs @@ -250,7 +250,7 @@ public override void OnPointerPressed(PointerRoutedEventArgs e) { if (IsEnabled) { - var localPos = e.GetCurrentPoint(this).Position; + Vector2 localPos = e.GetCurrentPoint(this).Position; float contentSize = _panel.IsHorizontal ? _panel.TotalVirtualWidth : _panel.TotalVirtualHeight; float viewportSize = _panel.IsHorizontal ? Size.X : Size.Y; diff --git a/src/ProGPU.WinUI/Core/FrameworkElement.cs b/src/ProGPU.WinUI/Core/FrameworkElement.cs index b89b3374..0425668d 100644 --- a/src/ProGPU.WinUI/Core/FrameworkElement.cs +++ b/src/ProGPU.WinUI/Core/FrameworkElement.cs @@ -42,7 +42,7 @@ public class PointerRoutedEventArgs : RoutedEventArgs public float WheelDelta { get; set; } public float WheelDeltaX { get; set; } public bool IsPreciseScrolling { get; set; } - public Pointer Pointer { get; set; } = new(1, PointerDeviceType.Mouse, false); + public Pointer Pointer { get; set; } = new(1, Windows.Devices.Input.PointerDeviceType.Mouse, false); public ulong Timestamp { get; set; } public bool IsPrimary { get; set; } = true; public float Pressure { get; set; } @@ -85,7 +85,7 @@ public IReadOnlyList GetIntermediatePoints(FrameworkElement? relat Timestamp, position, ScreenPosition, - Pointer.PointerDeviceType, + Pointer.LegacyPointerDeviceType, Pointer.IsInContact, new PointerPointProperties { @@ -95,7 +95,11 @@ public IReadOnlyList GetIntermediatePoints(FrameworkElement? relat IsPrimary = IsPrimary, IsCanceled = IsCanceled, Pressure = Pressure, - ContactRect = ContactRect, + ContactRect = new Windows.Foundation.Rect( + ContactRect.X, + ContactRect.Y, + ContactRect.Width, + ContactRect.Height), MouseWheelDelta = (int)WheelDelta }); } @@ -746,15 +750,15 @@ public float Height public event EventHandler? PointerCanceled; public event EventHandler? PointerCaptureLost; - public event EventHandler? Tapped; - public event EventHandler? DoubleTapped; - public event EventHandler? RightTapped; - public event EventHandler? Holding; - public event EventHandler? ManipulationStarting; - public event EventHandler? ManipulationStarted; - public event EventHandler? ManipulationDelta; - public event EventHandler? ManipulationInertiaStarting; - public event EventHandler? ManipulationCompleted; + public event TappedEventHandler? Tapped; + public event DoubleTappedEventHandler? DoubleTapped; + public event RightTappedEventHandler? RightTapped; + public event HoldingEventHandler? Holding; + public event ManipulationStartingEventHandler? ManipulationStarting; + public event ManipulationStartedEventHandler? ManipulationStarted; + public event ManipulationDeltaEventHandler? ManipulationDelta; + public event ManipulationInertiaStartingEventHandler? ManipulationInertiaStarting; + public event ManipulationCompletedEventHandler? ManipulationCompleted; public event EventHandler? KeyDown; public event EventHandler? KeyUp; @@ -875,16 +879,61 @@ public virtual void OnPointerCaptureLost(PointerRoutedEventArgs e) if (!e.Handled && Parent is FrameworkElement parentFe) parentFe.OnPointerCaptureLost(e); } - public virtual void OnTapped(TappedRoutedEventArgs e) => RaiseGesture(e, Tapped, static (p, a) => p.OnTapped(a)); - public virtual void OnDoubleTapped(DoubleTappedRoutedEventArgs e) => RaiseGesture(e, DoubleTapped, static (p, a) => p.OnDoubleTapped(a)); - public virtual void OnRightTapped(RightTappedRoutedEventArgs e) => RaiseGesture(e, RightTapped, static (p, a) => p.OnRightTapped(a)); - public virtual void OnHolding(HoldingRoutedEventArgs e) => RaiseGesture(e, Holding, static (p, a) => p.OnHolding(a)); + public virtual void OnTapped(TappedRoutedEventArgs e) + { + e.OriginalSource ??= this; + Tapped?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnTapped(e); + } + public virtual void OnDoubleTapped(DoubleTappedRoutedEventArgs e) + { + e.OriginalSource ??= this; + DoubleTapped?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnDoubleTapped(e); + } + public virtual void OnRightTapped(RightTappedRoutedEventArgs e) + { + e.OriginalSource ??= this; + RightTapped?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnRightTapped(e); + } + public virtual void OnHolding(HoldingRoutedEventArgs e) + { + e.OriginalSource ??= this; + Holding?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnHolding(e); + } - public virtual void OnManipulationStarting(ManipulationStartingRoutedEventArgs e) => RaiseGesture(e, ManipulationStarting, static (p, a) => p.OnManipulationStarting(a)); - public virtual void OnManipulationStarted(ManipulationStartedRoutedEventArgs e) => RaiseGesture(e, ManipulationStarted, static (p, a) => p.OnManipulationStarted(a)); - public virtual void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) => RaiseGesture(e, ManipulationDelta, static (p, a) => p.OnManipulationDelta(a)); - public virtual void OnManipulationInertiaStarting(ManipulationInertiaStartingRoutedEventArgs e) => RaiseGesture(e, ManipulationInertiaStarting, static (p, a) => p.OnManipulationInertiaStarting(a)); - public virtual void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) => RaiseGesture(e, ManipulationCompleted, static (p, a) => p.OnManipulationCompleted(a)); + public virtual void OnManipulationStarting(ManipulationStartingRoutedEventArgs e) + { + e.OriginalSource ??= this; + ManipulationStarting?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnManipulationStarting(e); + } + public virtual void OnManipulationStarted(ManipulationStartedRoutedEventArgs e) + { + e.OriginalSource ??= this; + ManipulationStarted?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnManipulationStarted(e); + } + public virtual void OnManipulationDelta(ManipulationDeltaRoutedEventArgs e) + { + e.OriginalSource ??= this; + ManipulationDelta?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnManipulationDelta(e); + } + public virtual void OnManipulationInertiaStarting(ManipulationInertiaStartingRoutedEventArgs e) + { + e.OriginalSource ??= this; + ManipulationInertiaStarting?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnManipulationInertiaStarting(e); + } + public virtual void OnManipulationCompleted(ManipulationCompletedRoutedEventArgs e) + { + e.OriginalSource ??= this; + ManipulationCompleted?.Invoke(this, e); + if (!e.Handled && Parent is FrameworkElement parent) parent.OnManipulationCompleted(e); + } public virtual void OnTextInput(TextInputRoutedEventArgs e) { @@ -894,14 +943,6 @@ public virtual void OnTextInput(TextInputRoutedEventArgs e) if (!e.Handled && Parent is FrameworkElement parentFe) parentFe.OnTextInput(e); } - private void RaiseGesture(T e, EventHandler? handler, Action bubble) - where T : RoutedEventArgs - { - e.OriginalSource ??= this; - handler?.Invoke(this, e); - if (!e.Handled && Parent is FrameworkElement parentFe) bubble(parentFe, e); - } - public virtual void OnKeyDown(KeyRoutedEventArgs e) { e.OriginalSource ??= this; diff --git a/src/ProGPU.WinUI/Core/WinUI.Xaml.cs b/src/ProGPU.WinUI/Core/WinUI.Xaml.cs index 991bfd59..01c6857b 100644 --- a/src/ProGPU.WinUI/Core/WinUI.Xaml.cs +++ b/src/ProGPU.WinUI/Core/WinUI.Xaml.cs @@ -117,7 +117,11 @@ internal sealed class CompletedAsyncOperation : IAsyncOperation GetAwaiter() => _task.GetAwaiter(); } - public readonly record struct Point(double X, double Y); + public readonly record struct Point(double X, double Y) + { + public static implicit operator System.Numerics.Vector2(Point value) => new((float)value.X, (float)value.Y); + public static implicit operator Point(System.Numerics.Vector2 value) => new(value.X, value.Y); + } public readonly record struct Size(double Width, double Height); diff --git a/src/ProGPU.WinUI/Core/Window.cs b/src/ProGPU.WinUI/Core/Window.cs index 9d2cd076..c16dab8c 100644 --- a/src/ProGPU.WinUI/Core/Window.cs +++ b/src/ProGPU.WinUI/Core/Window.cs @@ -784,6 +784,7 @@ private unsafe void RenderFrameCore(double delta, Vector2D framebufferSize, // Core animation updates phaseStart = System.Diagnostics.Stopwatch.GetTimestamp(); + InputSystem.UpdateManipulationInertia((float)delta); content.UpdateAnimations((float)delta); double animationTimeMs = System.Diagnostics.Stopwatch.GetElapsedTime(phaseStart).TotalMilliseconds; diff --git a/src/ProGPU.WinUI/Input/GestureRecognizer.cs b/src/ProGPU.WinUI/Input/GestureRecognizer.cs new file mode 100644 index 00000000..fed745e6 --- /dev/null +++ b/src/ProGPU.WinUI/Input/GestureRecognizer.cs @@ -0,0 +1,827 @@ +using System.Numerics; +using Windows.Foundation; + +namespace Microsoft.UI.Input; + +[Flags] +public enum GestureSettings : uint +{ + None = 0, + Tap = 1, + DoubleTap = 2, + Hold = 4, + HoldWithMouse = 8, + RightTap = 16, + Drag = 32, + ManipulationTranslateX = 64, + ManipulationTranslateY = 128, + ManipulationTranslateRailsX = 256, + ManipulationTranslateRailsY = 512, + ManipulationRotate = 1024, + ManipulationScale = 2048, + ManipulationTranslateInertia = 4096, + ManipulationRotateInertia = 8192, + ManipulationScaleInertia = 16384, + CrossSlide = 32768, + ManipulationMultipleFingerPanning = 65536 +} + +public enum DraggingState +{ + Started, + Continuing, + Completed +} + +public enum CrossSlidingState +{ + Started, + Dragging, + Selecting, + SelectSpeedBumping, + SpeedBumping, + Rearranging, + Completed +} + +public enum HoldingState +{ + Started, + Completed, + Canceled +} + +public struct ManipulationDelta : IEquatable +{ + public ManipulationDelta(Point translation, float scale, float rotation, float expansion) + { + Translation = translation; + Scale = scale; + Rotation = rotation; + Expansion = expansion; + } + + public Point Translation; + public float Scale; + public float Rotation; + public float Expansion; + + internal static ManipulationDelta Identity => new(new Point(0, 0), 1f, 0f, 0f); + public readonly bool Equals(ManipulationDelta other) => + Translation.Equals(other.Translation) && Scale == other.Scale && + Rotation == other.Rotation && Expansion == other.Expansion; + public override readonly bool Equals(object? obj) => obj is ManipulationDelta other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(Translation, Scale, Rotation, Expansion); + public static bool operator ==(ManipulationDelta left, ManipulationDelta right) => left.Equals(right); + public static bool operator !=(ManipulationDelta left, ManipulationDelta right) => !left.Equals(right); +} + +public struct ManipulationVelocities : IEquatable +{ + public ManipulationVelocities(Point linear, float angular, float expansion) + { + Linear = linear; + Angular = angular; + Expansion = expansion; + } + + public Point Linear; + public float Angular; + public float Expansion; + + public readonly bool Equals(ManipulationVelocities other) => + Linear.Equals(other.Linear) && Angular == other.Angular && Expansion == other.Expansion; + public override readonly bool Equals(object? obj) => obj is ManipulationVelocities other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(Linear, Angular, Expansion); + public static bool operator ==(ManipulationVelocities left, ManipulationVelocities right) => left.Equals(right); + public static bool operator !=(ManipulationVelocities left, ManipulationVelocities right) => !left.Equals(right); +} + +public struct CrossSlideThresholds : IEquatable +{ + public CrossSlideThresholds(float selectionStart, float speedBumpStart, float speedBumpEnd, float rearrangeStart) + { + SelectionStart = selectionStart; + SpeedBumpStart = speedBumpStart; + SpeedBumpEnd = speedBumpEnd; + RearrangeStart = rearrangeStart; + } + + public float SelectionStart; + public float SpeedBumpStart; + public float SpeedBumpEnd; + public float RearrangeStart; + + public readonly bool Equals(CrossSlideThresholds other) => + SelectionStart == other.SelectionStart && SpeedBumpStart == other.SpeedBumpStart && + SpeedBumpEnd == other.SpeedBumpEnd && RearrangeStart == other.RearrangeStart; + public override readonly bool Equals(object? obj) => obj is CrossSlideThresholds other && Equals(other); + public override readonly int GetHashCode() => HashCode.Combine(SelectionStart, SpeedBumpStart, SpeedBumpEnd, RearrangeStart); + public static bool operator ==(CrossSlideThresholds left, CrossSlideThresholds right) => left.Equals(right); + public static bool operator !=(CrossSlideThresholds left, CrossSlideThresholds right) => !left.Equals(right); +} + +public sealed class MouseWheelParameters +{ + internal MouseWheelParameters() + { + } + + public Point CharTranslation { get; set; } = new(8, 16); + public float DeltaRotationAngle { get; set; } = 15f; + public float DeltaScale { get; set; } = 1.1f; + public Point PageTranslation { get; set; } = new(80, 240); +} + +public sealed class TappedEventArgs +{ + internal TappedEventArgs(PointerDeviceType pointerDeviceType, Point position, uint tapCount) => + (PointerDeviceType, Position, TapCount) = (pointerDeviceType, position, tapCount); + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } + public uint TapCount { get; } +} + +public sealed class RightTappedEventArgs +{ + internal RightTappedEventArgs(PointerDeviceType pointerDeviceType, Point position) => + (PointerDeviceType, Position) = (pointerDeviceType, position); + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } +} + +public sealed class HoldingEventArgs +{ + internal HoldingEventArgs(PointerDeviceType pointerDeviceType, Point position, HoldingState state) => + (PointerDeviceType, Position, HoldingState) = (pointerDeviceType, position, state); + public HoldingState HoldingState { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } +} + +public sealed class DraggingEventArgs +{ + internal DraggingEventArgs(PointerDeviceType pointerDeviceType, Point position, DraggingState state) => + (PointerDeviceType, Position, DraggingState) = (pointerDeviceType, position, state); + public DraggingState DraggingState { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } +} + +public sealed class CrossSlidingEventArgs +{ + internal CrossSlidingEventArgs(PointerDeviceType pointerDeviceType, Point position, CrossSlidingState state) => + (PointerDeviceType, Position, CrossSlidingState) = (pointerDeviceType, position, state); + public CrossSlidingState CrossSlidingState { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } +} + +public sealed class ManipulationStartedEventArgs +{ + internal ManipulationStartedEventArgs(ManipulationDelta cumulative, PointerDeviceType type, Point position) => + (Cumulative, PointerDeviceType, Position) = (cumulative, type, position); + public ManipulationDelta Cumulative { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } +} + +public sealed class ManipulationUpdatedEventArgs +{ + internal ManipulationUpdatedEventArgs(ManipulationDelta cumulative, ManipulationDelta delta, + PointerDeviceType type, Point position, ManipulationVelocities velocities) => + (Cumulative, Delta, PointerDeviceType, Position, Velocities) = + (cumulative, delta, type, position, velocities); + public ManipulationDelta Cumulative { get; } + public ManipulationDelta Delta { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } + public ManipulationVelocities Velocities { get; } +} + +public sealed class ManipulationInertiaStartingEventArgs +{ + internal ManipulationInertiaStartingEventArgs(ManipulationDelta cumulative, ManipulationDelta delta, + PointerDeviceType type, Point position, ManipulationVelocities velocities) => + (Cumulative, Delta, PointerDeviceType, Position, Velocities) = + (cumulative, delta, type, position, velocities); + public ManipulationDelta Cumulative { get; } + public ManipulationDelta Delta { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } + public ManipulationVelocities Velocities { get; } +} + +public sealed class ManipulationCompletedEventArgs +{ + internal ManipulationCompletedEventArgs(ManipulationDelta cumulative, PointerDeviceType type, + Point position, ManipulationVelocities velocities) => + (Cumulative, PointerDeviceType, Position, Velocities) = (cumulative, type, position, velocities); + public ManipulationDelta Cumulative { get; } + public PointerDeviceType PointerDeviceType { get; } + public Point Position { get; } + public ManipulationVelocities Velocities { get; } +} + +/// +/// Clean-room implementation of the Windows App SDK gesture recognizer contract. +/// Pointer processing is O(P) per sample for P active contacts and stores O(P) state. +/// +public sealed class GestureRecognizer +{ + private const float StartThreshold = 4f; + private const float TapRadius = 12f; + private const ulong DoubleTapMicroseconds = 500_000; + private const int HoldMilliseconds = 800; + private readonly Dictionary _contacts = []; + private readonly SynchronizationContext? _synchronizationContext = SynchronizationContext.Current; + private CancellationTokenSource? _holdCancellation; + private CancellationTokenSource? _inertiaCancellation; + private bool _dragging; + private bool _crossSliding; + private CrossSlidingState _crossSlidingState; + private bool _manipulating; + private bool _holding; + private bool _tapCandidate; + private Point _lastTapPosition; + private PointerDeviceType _lastTapDevice; + private ulong _lastTapTimestamp; + private ManipulationDelta _cumulative = ManipulationDelta.Identity; + private ManipulationDelta _lastDelta = ManipulationDelta.Identity; + private ManipulationVelocities _velocities; + private Point _position; + private PointerDeviceType _deviceType; + + public bool AutoProcessInertia { get; set; } = true; + public bool CrossSlideExact { get; set; } + public bool CrossSlideHorizontally { get; set; } + public CrossSlideThresholds CrossSlideThresholds { get; set; } + public GestureSettings GestureSettings { get; set; } + public float InertiaExpansion { get; set; } + public float InertiaExpansionDeceleration { get; set; } + public float InertiaRotationAngle { get; set; } + public float InertiaRotationDeceleration { get; set; } + public float InertiaTranslationDeceleration { get; set; } + public float InertiaTranslationDisplacement { get; set; } + public bool IsActive => _contacts.Count != 0 || _dragging || _crossSliding || _manipulating || IsInertial; + public bool IsInertial { get; private set; } + public bool ManipulationExact { get; set; } + public MouseWheelParameters MouseWheelParameters { get; } = new(); + public Point PivotCenter { get; set; } + public float PivotRadius { get; set; } + public bool ShowGestureFeedback { get; set; } = true; + + public event TypedEventHandler? CrossSliding; + public event TypedEventHandler? Dragging; + public event TypedEventHandler? Holding; + public event TypedEventHandler? ManipulationCompleted; + public event TypedEventHandler? ManipulationInertiaStarting; + public event TypedEventHandler? ManipulationStarted; + public event TypedEventHandler? ManipulationUpdated; + public event TypedEventHandler? RightTapped; + public event TypedEventHandler? Tapped; + + public bool CanBeDoubleTap(PointerPoint value) + { + ArgumentNullException.ThrowIfNull(value); + if (_lastTapTimestamp == 0 || value.Timestamp < _lastTapTimestamp || + value.Timestamp - _lastTapTimestamp > DoubleTapMicroseconds) return false; + return value.PointerDeviceType == _lastTapDevice && + Distance(value.Position, _lastTapPosition) <= TapRadius; + } + + public void ProcessDownEvent(PointerPoint value) + { + ArgumentNullException.ThrowIfNull(value); + if (IsInertial) StopInertia(raiseCompleted: true); + var contact = new Contact(value); + _contacts[value.PointerId] = contact; + _position = value.Position; + _deviceType = value.PointerDeviceType; + _tapCandidate = _contacts.Count == 1; + if (_contacts.Count == 1) + { + _cumulative = ManipulationDelta.Identity; + _lastDelta = ManipulationDelta.Identity; + _velocities = default; + StartHolding(contact); + } + else + { + CancelHolding(raiseCanceled: true); + _tapCandidate = false; + } + } + + public void ProcessMoveEvents(IList value) + { + ArgumentNullException.ThrowIfNull(value); + for (var index = 0; index < value.Count; index++) ProcessMove(value[index]); + } + + public void ProcessUpEvent(PointerPoint value) + { + ArgumentNullException.ThrowIfNull(value); + if (!_contacts.TryGetValue(value.PointerId, out var contact)) return; + if (Distance(contact.Current.Position, value.Position) > 0f) + { + ProcessMove(value); + if (!_contacts.TryGetValue(value.PointerId, out contact)) return; + } + else + { + UpdateContact(contact, value); + } + _position = value.Position; + if (Distance(contact.Down.Position, value.Position) > StartThreshold) _tapCandidate = false; + + if (_holding) + { + if (HoldingEnabledFor(_deviceType)) + Holding?.Invoke(this, new HoldingEventArgs(_deviceType, _position, HoldingState.Completed)); + if (Has(GestureSettings.RightTap)) + RightTapped?.Invoke(this, new RightTappedEventArgs(_deviceType, _position)); + _holding = false; + } + CancelHolding(raiseCanceled: false); + + if (_dragging) + { + Dragging?.Invoke(this, new DraggingEventArgs(_deviceType, _position, DraggingState.Completed)); + _dragging = false; + } + if (_crossSliding) + { + CrossSliding?.Invoke(this, new CrossSlidingEventArgs(_deviceType, _position, CrossSlidingState.Completed)); + _crossSliding = false; + } + + _contacts.Remove(value.PointerId); + if (_manipulating && _contacts.Count == 0) + { + if (ShouldStartInertia()) StartInertia(); + else CompleteManipulation(); + } + else if (_contacts.Count > 0) + { + ResetContactBaselines(); + } + + if (_contacts.Count == 0 && !_manipulating && !_holding && !_dragging && _tapCandidate) + { + CompleteTap(value, contact.Down.Properties.IsRightButtonPressed || + contact.Down.Properties.IsBarrelButtonPressed); + } + _tapCandidate = false; + } + + public void ProcessMouseWheelEvent(PointerPoint value, bool isShiftKeyDown, bool isControlKeyDown) + { + ArgumentNullException.ThrowIfNull(value); + int wheel = value.Properties.MouseWheelDelta; + if (wheel == 0) return; + float steps = wheel / 120f; + var delta = ManipulationDelta.Identity; + if (isControlKeyDown && Has(GestureSettings.ManipulationScale)) + { + delta.Scale = MathF.Pow(Math.Max(0.001f, MouseWheelParameters.DeltaScale), steps); + } + else if (HasTranslation) + { + Point configured = MouseWheelParameters.CharTranslation; + const float systemLinesPerDetent = 3f; + bool horizontal = isShiftKeyDown || value.Properties.IsHorizontalMouseWheel; + delta.Translation = horizontal + ? new Point(configured.X * steps * systemLinesPerDetent, 0) + : new Point(0, configured.Y * steps * systemLinesPerDetent); + delta = FilterDelta(delta, 1); + } + else if (Has(GestureSettings.ManipulationRotate)) + { + delta.Rotation = MouseWheelParameters.DeltaRotationAngle * steps; + } + else return; + + _deviceType = value.PointerDeviceType; + _position = value.Position; + _cumulative = ManipulationDelta.Identity; + _manipulating = true; + ManipulationStarted?.Invoke(this, new ManipulationStartedEventArgs(_cumulative, _deviceType, _position)); + Accumulate(delta); + var velocities = default(ManipulationVelocities); + ManipulationUpdated?.Invoke(this, new ManipulationUpdatedEventArgs(_cumulative, delta, _deviceType, _position, velocities)); + CompleteManipulation(); + } + + public void ProcessInertia() + { + if (!IsInertial) return; + const float elapsedMilliseconds = 16f; + Vector2 linear = ToVector(_velocities.Linear); + float angular = _velocities.Angular; + float expansion = _velocities.Expansion; + + linear = Decelerate(linear, ResolveTranslationDeceleration() * elapsedMilliseconds); + angular = Decelerate(angular, ResolveRotationDeceleration() * elapsedMilliseconds); + expansion = Decelerate(expansion, ResolveExpansionDeceleration() * elapsedMilliseconds); + _velocities = new ManipulationVelocities(ToPoint(linear), angular, expansion); + + var delta = FilterDelta(new ManipulationDelta( + ToPoint(linear * elapsedMilliseconds), + ScaleFromExpansion(expansion * elapsedMilliseconds), + angular * elapsedMilliseconds, + expansion * elapsedMilliseconds), 0); + Accumulate(delta); + _lastDelta = delta; + ManipulationUpdated?.Invoke(this, + new ManipulationUpdatedEventArgs(_cumulative, delta, _deviceType, _position, _velocities)); + + if (linear.LengthSquared() < 0.0001f && MathF.Abs(angular) < 0.001f && MathF.Abs(expansion) < 0.001f) + { + StopInertia(raiseCompleted: true); + } + } + + public void CompleteGesture() + { + CancelHolding(raiseCanceled: true); + if (_dragging) + Dragging?.Invoke(this, new DraggingEventArgs(_deviceType, _position, DraggingState.Completed)); + if (_crossSliding) + CrossSliding?.Invoke(this, new CrossSlidingEventArgs(_deviceType, _position, CrossSlidingState.Completed)); + _dragging = false; + _crossSliding = false; + _contacts.Clear(); + StopInertia(raiseCompleted: _manipulating || IsInertial); + if (_manipulating) CompleteManipulation(); + _tapCandidate = false; + } + + private void ProcessMove(PointerPoint value) + { + ArgumentNullException.ThrowIfNull(value); + if (!_contacts.TryGetValue(value.PointerId, out var contact)) return; + var before = Snapshot(); + UpdateContact(contact, value); + var after = Snapshot(); + _position = value.Position; + _deviceType = value.PointerDeviceType; + + if (Distance(contact.Down.Position, value.Position) > StartThreshold) + { + _tapCandidate = false; + CancelHolding(raiseCanceled: true); + } + + ProcessDrag(contact, value); + ProcessCrossSlide(contact, value); + ProcessManipulation(before, after, value.Timestamp); + } + + private void ProcessDrag(Contact contact, PointerPoint value) + { + if (!Has(GestureSettings.Drag) || _contacts.Count != 1 || + value.PointerDeviceType is not (PointerDeviceType.Mouse or PointerDeviceType.Pen)) return; + if (!_dragging && Distance(contact.Down.Position, value.Position) > StartThreshold) + { + _dragging = true; + Dragging?.Invoke(this, new DraggingEventArgs(_deviceType, _position, DraggingState.Started)); + } + else if (_dragging) + { + Dragging?.Invoke(this, new DraggingEventArgs(_deviceType, _position, DraggingState.Continuing)); + } + } + + private void ProcessCrossSlide(Contact contact, PointerPoint value) + { + if (!Has(GestureSettings.CrossSlide) || _contacts.Count != 1 || + value.PointerDeviceType != PointerDeviceType.Touch) return; + Vector2 movement = ToVector(value.Position) - ToVector(contact.Down.Position); + float cross = MathF.Abs(CrossSlideHorizontally ? movement.X : movement.Y); + float along = MathF.Abs(CrossSlideHorizontally ? movement.Y : movement.X); + if (cross <= along || cross < (CrossSlideExact ? 0f : StartThreshold)) return; + + CrossSlideThresholds thresholds = EffectiveCrossSlideThresholds(); + if (thresholds.SelectionStart <= 0f && thresholds.SpeedBumpStart <= 0f && + thresholds.SpeedBumpEnd <= 0f && thresholds.RearrangeStart <= 0f) return; + var state = cross >= thresholds.RearrangeStart ? CrossSlidingState.Rearranging + : cross >= thresholds.SpeedBumpEnd ? CrossSlidingState.SpeedBumping + : cross >= thresholds.SpeedBumpStart ? CrossSlidingState.SelectSpeedBumping + : cross >= thresholds.SelectionStart ? CrossSlidingState.Selecting + : CrossSlidingState.Dragging; + if (!_crossSliding) + { + _crossSliding = true; + _crossSlidingState = CrossSlidingState.Started; + CrossSliding?.Invoke(this, new CrossSlidingEventArgs(_deviceType, _position, CrossSlidingState.Started)); + } + if (_crossSlidingState != state) + { + _crossSlidingState = state; + CrossSliding?.Invoke(this, new CrossSlidingEventArgs(_deviceType, _position, state)); + } + } + + private void ProcessManipulation(ContactSnapshot before, ContactSnapshot after, ulong timestamp) + { + if (!HasManipulation || after.Count == 0 || _dragging) return; + Vector2 translation = after.Center - before.Center; + float scale = after.Count >= 2 && before.Radius > 0.001f ? after.Radius / before.Radius : 1f; + float rotation = NormalizeDegrees(after.Angle - before.Angle); + float expansion = after.Count >= 2 ? (after.Radius - before.Radius) * 2f : 0f; + var delta = FilterDelta(new ManipulationDelta(ToPoint(translation), scale, rotation, expansion), after.Count); + Vector2 filteredTranslation = ToVector(delta.Translation); + bool changed = filteredTranslation.LengthSquared() > 0f || MathF.Abs(delta.Scale - 1f) > 0.00001f || + MathF.Abs(delta.Rotation) > 0.00001f || MathF.Abs(delta.Expansion) > 0.00001f; + if (!changed) return; + + if (!_manipulating) + { + float threshold = ManipulationExact ? 0f : StartThreshold; + if (filteredTranslation.Length() < threshold && MathF.Abs(delta.Expansion) < threshold && + MathF.Abs(delta.Rotation) < 1f && MathF.Abs(delta.Scale - 1f) < 0.01f) return; + _manipulating = true; + CancelHolding(raiseCanceled: true); + _tapCandidate = false; + ManipulationStarted?.Invoke(this, + new ManipulationStartedEventArgs(_cumulative, _deviceType, ToPoint(after.Center))); + } + + float elapsedMs = Math.Max(0.001f, (timestamp - before.Timestamp) / 1000f); + _velocities = new ManipulationVelocities( + ToPoint(ToVector(delta.Translation) / elapsedMs), + delta.Rotation / elapsedMs, + delta.Expansion / elapsedMs); + _lastDelta = delta; + Accumulate(delta); + _position = ToPoint(after.Center); + ManipulationUpdated?.Invoke(this, + new ManipulationUpdatedEventArgs(_cumulative, delta, _deviceType, _position, _velocities)); + } + + private ManipulationDelta FilterDelta(ManipulationDelta delta, int contactCount) + { + bool translateX = Has(GestureSettings.ManipulationTranslateX) || Has(GestureSettings.ManipulationTranslateRailsX); + bool translateY = Has(GestureSettings.ManipulationTranslateY) || Has(GestureSettings.ManipulationTranslateRailsY); + var translation = new Vector2( + translateX ? (float)delta.Translation.X : 0f, + translateY ? (float)delta.Translation.Y : 0f); + if (Has(GestureSettings.ManipulationTranslateRailsX) && MathF.Abs(translation.X) >= MathF.Abs(translation.Y)) + translation.Y = 0f; + if (Has(GestureSettings.ManipulationTranslateRailsY) && MathF.Abs(translation.Y) >= MathF.Abs(translation.X)) + translation.X = 0f; + bool panOnly = contactCount >= 2 && Has(GestureSettings.ManipulationMultipleFingerPanning); + return new ManipulationDelta( + ToPoint(translation), + !panOnly && Has(GestureSettings.ManipulationScale) ? delta.Scale : 1f, + !panOnly && Has(GestureSettings.ManipulationRotate) ? delta.Rotation : 0f, + !panOnly && Has(GestureSettings.ManipulationScale) ? delta.Expansion : 0f); + } + + private void CompleteTap(PointerPoint value, bool startedAsRightTap) + { + bool rightButton = startedAsRightTap || value.Properties.IsRightButtonPressed || + value.Properties.PointerUpdateKind == PointerUpdateKind.RightButtonReleased; + if (rightButton && Has(GestureSettings.RightTap)) + { + RightTapped?.Invoke(this, new RightTappedEventArgs(value.PointerDeviceType, value.Position)); + return; + } + + bool doubleTap = Has(GestureSettings.DoubleTap) && CanBeDoubleTap(value); + if (doubleTap) + { + Tapped?.Invoke(this, new TappedEventArgs(value.PointerDeviceType, value.Position, 2)); + _lastTapTimestamp = 0; + } + else + { + if (Has(GestureSettings.Tap)) + Tapped?.Invoke(this, new TappedEventArgs(value.PointerDeviceType, value.Position, 1)); + if (Has(GestureSettings.DoubleTap)) + { + _lastTapPosition = value.Position; + _lastTapDevice = value.PointerDeviceType; + _lastTapTimestamp = value.Timestamp; + } + } + } + + private void StartHolding(Contact contact) + { + bool enabled = HoldingEnabledFor(contact.Down.PointerDeviceType) || Has(GestureSettings.RightTap); + if (!enabled) return; + CancelHolding(raiseCanceled: false); + var cancellation = _holdCancellation = new CancellationTokenSource(); + _ = WaitForHoldAsync(contact.Down.PointerId, cancellation); + } + + private async Task WaitForHoldAsync(uint pointerId, CancellationTokenSource cancellation) + { + try + { + await Task.Delay(HoldMilliseconds, cancellation.Token).ConfigureAwait(false); + Post(() => + { + if (cancellation.IsCancellationRequested || !_contacts.TryGetValue(pointerId, out var contact)) return; + _holding = true; + _tapCandidate = false; + _position = contact.Current.Position; + if (HoldingEnabledFor(_deviceType)) + Holding?.Invoke(this, new HoldingEventArgs(_deviceType, _position, HoldingState.Started)); + }); + } + catch (OperationCanceledException) + { + } + } + + private void CancelHolding(bool raiseCanceled) + { + _holdCancellation?.Cancel(); + _holdCancellation?.Dispose(); + _holdCancellation = null; + if (!_holding) return; + if (raiseCanceled && HoldingEnabledFor(_deviceType)) + Holding?.Invoke(this, new HoldingEventArgs(_deviceType, _position, HoldingState.Canceled)); + _holding = false; + } + + private bool ShouldStartInertia() + { + Vector2 linear = ToVector(_velocities.Linear); + return (Has(GestureSettings.ManipulationTranslateInertia) && linear.LengthSquared() > 0.0025f) || + (Has(GestureSettings.ManipulationRotateInertia) && MathF.Abs(_velocities.Angular) > 0.01f) || + (Has(GestureSettings.ManipulationScaleInertia) && MathF.Abs(_velocities.Expansion) > 0.01f); + } + + private void StartInertia() + { + IsInertial = true; + ManipulationInertiaStarting?.Invoke(this, + new ManipulationInertiaStartingEventArgs(_cumulative, _lastDelta, _deviceType, _position, _velocities)); + if (!AutoProcessInertia) return; + var cancellation = _inertiaCancellation = new CancellationTokenSource(); + _ = RunInertiaAsync(cancellation); + } + + private async Task RunInertiaAsync(CancellationTokenSource cancellation) + { + try + { + while (!cancellation.IsCancellationRequested && IsInertial) + { + await Task.Delay(16, cancellation.Token).ConfigureAwait(false); + Post(ProcessInertia); + } + } + catch (OperationCanceledException) + { + } + } + + private void StopInertia(bool raiseCompleted) + { + _inertiaCancellation?.Cancel(); + _inertiaCancellation?.Dispose(); + _inertiaCancellation = null; + bool wasInertial = IsInertial; + IsInertial = false; + if (raiseCompleted && (wasInertial || _manipulating)) CompleteManipulation(); + } + + private void CompleteManipulation() + { + if (!_manipulating) return; + _manipulating = false; + ManipulationCompleted?.Invoke(this, + new ManipulationCompletedEventArgs(_cumulative, _deviceType, _position, _velocities)); + } + + private void Accumulate(ManipulationDelta delta) + { + Vector2 translation = ToVector(_cumulative.Translation) + ToVector(delta.Translation); + _cumulative = new ManipulationDelta( + ToPoint(translation), + _cumulative.Scale * delta.Scale, + _cumulative.Rotation + delta.Rotation, + _cumulative.Expansion + delta.Expansion); + } + + private ContactSnapshot Snapshot() + { + if (_contacts.Count == 0) return default; + Vector2 center = Vector2.Zero; + ulong timestamp = 0; + foreach (var contact in _contacts.Values) + { + center += ToVector(contact.Current.Position); + timestamp = Math.Max(timestamp, contact.Current.Timestamp); + } + center /= _contacts.Count; + float radius = 0f; + float angle = 0f; + if (_contacts.Count >= 2) + { + using var enumerator = _contacts.Values.GetEnumerator(); + enumerator.MoveNext(); + Vector2 first = ToVector(enumerator.Current.Current.Position); + enumerator.MoveNext(); + Vector2 second = ToVector(enumerator.Current.Current.Position); + radius = Vector2.Distance(first, second) * 0.5f; + Vector2 axis = second - first; + angle = MathF.Atan2(axis.Y, axis.X) * (180f / MathF.PI); + } + else if (_contacts.Count == 1 && PivotRadius > 0f && Has(GestureSettings.ManipulationRotate)) + { + foreach (var contact in _contacts.Values) + { + Vector2 axis = ToVector(contact.Current.Position) - ToVector(PivotCenter); + angle = MathF.Atan2(axis.Y, axis.X) * (180f / MathF.PI); + break; + } + radius = PivotRadius; + } + return new ContactSnapshot(_contacts.Count, center, radius, angle, timestamp); + } + + private void ResetContactBaselines() + { + foreach (var contact in _contacts.Values) contact.Previous = contact.Current; + } + + private static void UpdateContact(Contact contact, PointerPoint value) + { + contact.Previous = contact.Current; + contact.Current = value; + } + + private CrossSlideThresholds EffectiveCrossSlideThresholds() + { + return CrossSlideThresholds; + } + + private float ResolveTranslationDeceleration() + { + if (InertiaTranslationDeceleration > 0f) return InertiaTranslationDeceleration; + float speed = ToVector(_velocities.Linear).Length(); + if (InertiaTranslationDisplacement > 0f) return speed * speed / (2f * InertiaTranslationDisplacement); + return 0.0025f; + } + + private float ResolveRotationDeceleration() + { + if (InertiaRotationDeceleration > 0f) return InertiaRotationDeceleration; + if (InertiaRotationAngle > 0f) return _velocities.Angular * _velocities.Angular / (2f * InertiaRotationAngle); + return 0.00015f; + } + + private float ResolveExpansionDeceleration() + { + if (InertiaExpansionDeceleration > 0f) return InertiaExpansionDeceleration; + if (InertiaExpansion > 0f) return _velocities.Expansion * _velocities.Expansion / (2f * InertiaExpansion); + return 0.00015f; + } + + private bool Has(GestureSettings setting) => (GestureSettings & setting) != 0; + private bool HoldingEnabledFor(PointerDeviceType type) => type == PointerDeviceType.Mouse + ? Has(GestureSettings.HoldWithMouse) + : Has(GestureSettings.Hold); + private bool HasTranslation => Has(GestureSettings.ManipulationTranslateX) || + Has(GestureSettings.ManipulationTranslateY) || Has(GestureSettings.ManipulationTranslateRailsX) || + Has(GestureSettings.ManipulationTranslateRailsY); + private bool HasManipulation => HasTranslation || Has(GestureSettings.ManipulationRotate) || + Has(GestureSettings.ManipulationScale); + private static Vector2 ToVector(Point point) => new((float)point.X, (float)point.Y); + private static Point ToPoint(Vector2 vector) => new(vector.X, vector.Y); + private static float Distance(Point left, Point right) => Vector2.Distance(ToVector(left), ToVector(right)); + private static float NormalizeDegrees(float value) => MathF.IEEERemainder(value, 360f); + private static float ScaleFromExpansion(float expansion) => Math.Max(0.001f, 1f + expansion / 100f); + private static Vector2 Decelerate(Vector2 value, float amount) + { + float length = value.Length(); + return length <= amount || length == 0f ? Vector2.Zero : value * ((length - amount) / length); + } + private static float Decelerate(float value, float amount) => + MathF.Abs(value) <= amount ? 0f : value - MathF.CopySign(amount, value); + private void Post(Action action) + { + if (_synchronizationContext == null) action(); + else _synchronizationContext.Post(static state => ((Action)state!).Invoke(), action); + } + + private sealed class Contact(PointerPoint down) + { + public PointerPoint Down { get; } = down; + public PointerPoint Previous { get; set; } = down; + public PointerPoint Current { get; set; } = down; + } + + private readonly record struct ContactSnapshot( + int Count, + Vector2 Center, + float Radius, + float Angle, + ulong Timestamp); +} diff --git a/src/ProGPU.WinUI/Input/InputSystem.cs b/src/ProGPU.WinUI/Input/InputSystem.cs index c2573304..dc149e59 100644 --- a/src/ProGPU.WinUI/Input/InputSystem.cs +++ b/src/ProGPU.WinUI/Input/InputSystem.cs @@ -66,6 +66,7 @@ internal sealed class ManipulationSession { public required FrameworkElement Target { get; init; } public required ManipulationModes Mode { get; set; } + public Microsoft.UI.Input.PointerDeviceType PointerDeviceType { get; set; } public HashSet PointerIds { get; } = new(); public Vector2 PreviousCentroid { get; set; } public float PreviousDistance { get; set; } @@ -74,9 +75,14 @@ internal sealed class ManipulationSession public float CumulativeScale { get; set; } = 1f; public float CumulativeRotation { get; set; } public float CumulativeExpansion { get; set; } - public ManipulationVelocities Velocities { get; set; } + public Microsoft.UI.Input.ManipulationVelocities Velocities { get; set; } public ulong PreviousTimestamp { get; set; } public bool Started { get; set; } + public bool IsInertial { get; set; } + public float InertiaTranslationDeceleration { get; set; } + public float InertiaRotationDeceleration { get; set; } + public float InertiaExpansionDeceleration { get; set; } + public float InertiaDistance { get; set; } = 1f; } public static class InputSystem @@ -771,7 +777,8 @@ private static bool IsDescendantOf(FrameworkElement? element, string ancestorNam private static void BeginHolding(PointerContactState contact) { - if (contact.Target == null || !contact.Target.IsHoldingEnabled || contact.Pointer.PointerDeviceType == PointerDeviceType.Mouse) return; + if (contact.Target == null || !contact.Target.IsHoldingEnabled || + contact.Pointer.PointerDeviceType == Microsoft.UI.Input.PointerDeviceType.Mouse) return; var cancellation = new CancellationTokenSource(); contact.HoldingCancellation = cancellation; var state = Current; @@ -787,7 +794,7 @@ void Raise() { PointerDeviceType = active.Pointer.PointerDeviceType, ScreenPosition = active.LastEvent.Position, - HoldingState = HoldingState.Started + HoldingState = Microsoft.UI.Input.HoldingState.Started }); } if (DispatcherQueue != null) DispatcherQueue(Raise); else Raise(); @@ -803,7 +810,7 @@ private static void CancelHolding(PointerContactState contact, bool raiseCancele { PointerDeviceType = contact.Pointer.PointerDeviceType, ScreenPosition = contact.LastEvent.Position, - HoldingState = HoldingState.Canceled + HoldingState = Microsoft.UI.Input.HoldingState.Canceled }); } @@ -817,7 +824,7 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve { PointerDeviceType = contact.Pointer.PointerDeviceType, ScreenPosition = input.Position, - HoldingState = HoldingState.Completed + HoldingState = Microsoft.UI.Input.HoldingState.Completed }); return; } @@ -835,8 +842,7 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve { contact.Target.OnRightTapped(new RightTappedRoutedEventArgs { - PointerId = input.PointerId, - PointerDeviceType = input.DeviceType, + PointerDeviceType = contact.Pointer.PointerDeviceType, ScreenPosition = input.Position }); } @@ -846,8 +852,7 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve if (!contact.Target.IsTapEnabled) return; var tapped = new TappedRoutedEventArgs { - PointerId = input.PointerId, - PointerDeviceType = input.DeviceType, + PointerDeviceType = contact.Pointer.PointerDeviceType, ScreenPosition = input.Position }; contact.Target.OnTapped(tapped); @@ -861,8 +866,7 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve { contact.Target.OnDoubleTapped(new DoubleTappedRoutedEventArgs { - PointerId = input.PointerId, - PointerDeviceType = input.DeviceType, + PointerDeviceType = contact.Pointer.PointerDeviceType, ScreenPosition = input.Position }); Current.LastTappedElement = null; @@ -892,13 +896,26 @@ private static float GetTapThreshold(PointerDeviceType deviceType) => private static void BeginManipulation(PointerContactState contact) { - if (contact.Pointer.PointerDeviceType == PointerDeviceType.Mouse) return; + if (contact.Pointer.PointerDeviceType == Microsoft.UI.Input.PointerDeviceType.Mouse) return; if (DragDropManager.IsDragging && DragDropManager.IsPointerOwner(contact.Pointer.PointerId)) return; var target = FindManipulationTarget(contact.Target); if (target == null) return; if (Current.CapturedElements.ContainsKey(contact.Pointer.PointerId)) return; contact.ManipulationTarget = target; - if (!Current.Manipulations.TryGetValue(target, out var session)) + Current.Manipulations.TryGetValue(target, out var session); + if (session?.IsInertial == true) + { + session.IsInertial = false; + session.Velocities = default; + RaiseManipulationCompleted(session, new Microsoft.UI.Input.ManipulationDelta( + session.CumulativeTranslation, + session.CumulativeScale, + session.CumulativeRotation, + session.CumulativeExpansion), isInertial: true); + Current.Manipulations.Remove(target); + session = null; + } + if (session == null) { var starting = new ManipulationStartingRoutedEventArgs { @@ -913,7 +930,8 @@ private static void BeginManipulation(PointerContactState contact) { Target = target, Mode = starting.Mode, - PreviousTimestamp = contact.LastEvent.Timestamp + PreviousTimestamp = contact.LastEvent.Timestamp, + PointerDeviceType = contact.Pointer.PointerDeviceType }; Current.Manipulations[target] = session; } @@ -994,11 +1012,19 @@ private static void UpdateManipulation(PointerContactState contact) if (!Current.PointerContacts.TryGetValue(id, out var state)) continue; CancelContactForManipulation(state); } - session.Target.OnManipulationStarted(new ManipulationStartedRoutedEventArgs + var started = new ManipulationStartedRoutedEventArgs { OriginalSource = session.Target, - Position = centroid - }); + Container = session.Target, + Position = centroid, + PointerDeviceType = contact.Pointer.PointerDeviceType + }; + session.Target.OnManipulationStarted(started); + if (started.IsCompleteRequested) + { + CompleteManipulation(session, inertial: false); + return; + } } session.CumulativeTranslation += translation; @@ -1009,20 +1035,23 @@ private static void UpdateManipulation(PointerContactState contact) ? contact.LastEvent.Timestamp - session.PreviousTimestamp : 1UL; var seconds = elapsedMicros / 1_000_000f; - session.Velocities = new ManipulationVelocities(translation / seconds, rotation / seconds, expansion / seconds); + session.Velocities = new Microsoft.UI.Input.ManipulationVelocities(translation / seconds, rotation / seconds, expansion / seconds); session.PreviousTimestamp = contact.LastEvent.Timestamp; session.PreviousCentroid = centroid; - var delta = new ManipulationDelta(translation, scale, rotation, expansion); - var cumulative = new ManipulationDelta(session.CumulativeTranslation, session.CumulativeScale, session.CumulativeRotation, session.CumulativeExpansion); + var delta = new Microsoft.UI.Input.ManipulationDelta(translation, scale, rotation, expansion); + var cumulative = new Microsoft.UI.Input.ManipulationDelta(session.CumulativeTranslation, session.CumulativeScale, session.CumulativeRotation, session.CumulativeExpansion); var args = new ManipulationDeltaRoutedEventArgs { OriginalSource = session.Target, + Container = session.Target, Delta = delta, Cumulative = cumulative, - Velocities = session.Velocities + Velocities = session.Velocities, + PointerDeviceType = contact.Pointer.PointerDeviceType, + Position = centroid }; session.Target.OnManipulationDelta(args); - if (args.Complete) CompleteManipulation(session, inertial: false); + if (args.IsCompleteRequested) CompleteManipulation(session, inertial: false); } private static void EndManipulation(PointerContactState contact, bool canceled) @@ -1040,27 +1069,153 @@ private static void EndManipulation(PointerContactState contact, bool canceled) private static void CompleteManipulation(ManipulationSession session, bool inertial) { - var cumulative = new ManipulationDelta(session.CumulativeTranslation, session.CumulativeScale, session.CumulativeRotation, session.CumulativeExpansion); - if (session.Started && inertial) + var cumulative = new Microsoft.UI.Input.ManipulationDelta(session.CumulativeTranslation, session.CumulativeScale, session.CumulativeRotation, session.CumulativeExpansion); + if (session.Started && inertial && TryStartManipulationInertia(session, cumulative)) return; + RaiseManipulationCompleted(session, cumulative, isInertial: false); + Current.Manipulations.Remove(session.Target); + } + + private static bool TryStartManipulationInertia( + ManipulationSession session, + Microsoft.UI.Input.ManipulationDelta cumulative) + { + var linear = (Vector2)session.Velocities.Linear; + var angular = session.Velocities.Angular; + var expansion = session.Velocities.Expansion; + if (!session.Mode.HasFlag(ManipulationModes.TranslateInertia)) linear = Vector2.Zero; + if (!session.Mode.HasFlag(ManipulationModes.RotateInertia)) angular = 0f; + if (!session.Mode.HasFlag(ManipulationModes.ScaleInertia)) expansion = 0f; + if (linear.LengthSquared() < 4f && MathF.Abs(angular) < 1f && MathF.Abs(expansion) < 1f) return false; + + session.Velocities = new Microsoft.UI.Input.ManipulationVelocities(linear, angular, expansion); + var args = new ManipulationInertiaStartingRoutedEventArgs + { + OriginalSource = session.Target, + Container = session.Target, + Cumulative = cumulative, + Delta = new Microsoft.UI.Input.ManipulationDelta( + session.Velocities.Linear, + 1f, + session.Velocities.Angular, + session.Velocities.Expansion), + Velocities = session.Velocities, + PointerDeviceType = session.PointerDeviceType + }; + session.Target.OnManipulationInertiaStarting(args); + session.InertiaTranslationDeceleration = ResolveInertiaDeceleration( + linear.Length(), + args.TranslationBehavior.DesiredDeceleration, + args.TranslationBehavior.DesiredDisplacement, + defaultValue: 2500f); + session.InertiaRotationDeceleration = ResolveInertiaDeceleration( + MathF.Abs(angular), + args.RotationBehavior.DesiredDeceleration, + args.RotationBehavior.DesiredRotation, + defaultValue: 180f); + session.InertiaExpansionDeceleration = ResolveInertiaDeceleration( + MathF.Abs(expansion), + args.ExpansionBehavior.DesiredDeceleration, + args.ExpansionBehavior.DesiredExpansion, + defaultValue: 2500f); + session.InertiaDistance = Math.Max(1f, session.PreviousDistance); + session.IsInertial = true; + return true; + } + + private static float ResolveInertiaDeceleration(float speed, double desiredDeceleration, + double desiredDisplacement, float defaultValue) + { + if (double.IsFinite(desiredDeceleration) && desiredDeceleration > 0d) + return (float)Math.Min(float.MaxValue, desiredDeceleration * 1_000_000d); + if (double.IsFinite(desiredDisplacement) && desiredDisplacement > 0d) + return speed * speed / (2f * (float)desiredDisplacement); + return defaultValue; + } + + private static void RaiseManipulationCompleted(ManipulationSession session, + Microsoft.UI.Input.ManipulationDelta cumulative, bool isInertial) + { + if (session.Started) { - session.Target.OnManipulationInertiaStarting(new ManipulationInertiaStartingRoutedEventArgs + session.Target.OnManipulationCompleted(new ManipulationCompletedRoutedEventArgs { OriginalSource = session.Target, + Container = session.Target, Cumulative = cumulative, - Velocities = session.Velocities + Velocities = session.Velocities, + IsInertial = isInertial, + PointerDeviceType = session.PointerDeviceType, + Position = session.PreviousCentroid }); } - if (session.Started) - { - session.Target.OnManipulationCompleted(new ManipulationCompletedRoutedEventArgs + } + + internal static void UpdateManipulationInertia(float elapsedSeconds) + { + if (elapsedSeconds <= 0f || Current.Manipulations.Count == 0) return; + List? completed = null; + foreach (var pair in Current.Manipulations) + { + var session = pair.Value; + if (!session.IsInertial) continue; + + var linear = DecelerateInertia((Vector2)session.Velocities.Linear, + session.InertiaTranslationDeceleration * elapsedSeconds); + var angular = DecelerateInertia(session.Velocities.Angular, + session.InertiaRotationDeceleration * elapsedSeconds); + var expansion = DecelerateInertia(session.Velocities.Expansion, + session.InertiaExpansionDeceleration * elapsedSeconds); + var translation = linear * elapsedSeconds; + var rotation = angular * elapsedSeconds; + var expansionDelta = expansion * elapsedSeconds; + var scale = Math.Max(0.001f, 1f + expansionDelta / Math.Max(1f, session.InertiaDistance)); + session.InertiaDistance = Math.Max(1f, session.InertiaDistance + expansionDelta); + session.Velocities = new Microsoft.UI.Input.ManipulationVelocities(linear, angular, expansion); + session.CumulativeTranslation += translation; + session.CumulativeScale *= scale; + session.CumulativeRotation += rotation; + session.CumulativeExpansion += expansionDelta; + var delta = new Microsoft.UI.Input.ManipulationDelta(translation, scale, rotation, expansionDelta); + var cumulative = new Microsoft.UI.Input.ManipulationDelta( + session.CumulativeTranslation, + session.CumulativeScale, + session.CumulativeRotation, + session.CumulativeExpansion); + var args = new ManipulationDeltaRoutedEventArgs { OriginalSource = session.Target, + Container = session.Target, + Delta = delta, Cumulative = cumulative, Velocities = session.Velocities, - IsInertial = inertial - }); + IsInertial = true, + PointerDeviceType = session.PointerDeviceType, + Position = session.PreviousCentroid + }; + session.Target.OnManipulationDelta(args); + + if (!args.IsCompleteRequested && + (linear.LengthSquared() >= 4f || MathF.Abs(angular) >= 1f || MathF.Abs(expansion) >= 1f)) continue; + session.IsInertial = false; + session.Velocities = default; + RaiseManipulationCompleted(session, cumulative, isInertial: true); + (completed ??= []).Add(pair.Key); } - Current.Manipulations.Remove(session.Target); + if (completed == null) return; + foreach (var target in completed) Current.Manipulations.Remove(target); + } + + private static Vector2 DecelerateInertia(Vector2 velocity, float amount) + { + var speed = velocity.Length(); + if (speed <= amount || speed <= 0f) return Vector2.Zero; + return velocity * ((speed - amount) / speed); + } + + private static float DecelerateInertia(float velocity, float amount) + { + if (MathF.Abs(velocity) <= amount) return 0f; + return velocity - MathF.CopySign(amount, velocity); } public static void SetFocus(FrameworkElement? element) diff --git a/src/ProGPU.WinUI/Input/PointerGestureTypes.cs b/src/ProGPU.WinUI/Input/PointerGestureTypes.cs index 5d9a7faa..f2ddae81 100644 --- a/src/ProGPU.WinUI/Input/PointerGestureTypes.cs +++ b/src/ProGPU.WinUI/Input/PointerGestureTypes.cs @@ -29,14 +29,30 @@ namespace Microsoft.UI.Input public sealed class PointerPointProperties { + internal PointerPointProperties() + { + } + + public Windows.Foundation.Rect ContactRect { get; internal set; } + public bool IsBarrelButtonPressed { get; internal set; } + public bool IsHorizontalMouseWheel { get; internal set; } + public bool IsInRange { get; internal set; } = true; + public bool IsInverted { get; internal set; } public bool IsLeftButtonPressed { get; internal set; } public bool IsMiddleButtonPressed { get; internal set; } public bool IsRightButtonPressed { get; internal set; } + public bool IsXButton1Pressed { get; internal set; } + public bool IsXButton2Pressed { get; internal set; } public bool IsPrimary { get; internal set; } public bool IsCanceled { get; internal set; } public bool IsEraser { get; internal set; } + public float Orientation { get; internal set; } + public PointerUpdateKind PointerUpdateKind { get; internal set; } public float Pressure { get; internal set; } - public Rect ContactRect { get; internal set; } + public bool TouchConfidence { get; internal set; } = true; + public float Twist { get; internal set; } + public float XTilt { get; internal set; } + public float YTilt { get; internal set; } public int MouseWheelDelta { get; internal set; } } @@ -47,45 +63,140 @@ internal PointerPoint( ulong timestamp, Vector2 position, Vector2 rawPosition, - PointerDeviceType deviceType, + Windows.Devices.Input.PointerDeviceType deviceType, + bool isInContact, + PointerPointProperties properties) + : this(pointerId, timestamp, position, rawPosition, deviceType, deviceType switch + { + Windows.Devices.Input.PointerDeviceType.Touch => Microsoft.UI.Input.PointerDeviceType.Touch, + Windows.Devices.Input.PointerDeviceType.Pen => Microsoft.UI.Input.PointerDeviceType.Pen, + _ => Microsoft.UI.Input.PointerDeviceType.Mouse + }, isInContact, properties) + { + } + + private PointerPoint( + uint pointerId, + ulong timestamp, + Vector2 position, + Vector2 rawPosition, + Windows.Devices.Input.PointerDeviceType legacyDeviceType, + Microsoft.UI.Input.PointerDeviceType deviceType, bool isInContact, PointerPointProperties properties) { PointerId = pointerId; Timestamp = timestamp; - Position = position; + FrameId = unchecked((uint)timestamp); + Position = new Windows.Foundation.Point(position.X, position.Y); RawPosition = rawPosition; - PointerDevice = PointerDevice.GetPointerDevice(deviceType); + PointerDevice = Windows.Devices.Input.PointerDevice.GetPointerDevice(legacyDeviceType); + PointerDeviceType = deviceType; IsInContact = isInContact; Properties = properties; } + public uint FrameId { get; } public uint PointerId { get; } public ulong Timestamp { get; } - public Vector2 Position { get; } - public Vector2 RawPosition { get; } - public PointerDevice PointerDevice { get; } + public Windows.Foundation.Point Position { get; } + internal Vector2 RawPosition { get; } + public Microsoft.UI.Input.PointerDeviceType PointerDeviceType { get; } + // Kept as a source-compatibility extension for existing ProGPU XAML code. + public Windows.Devices.Input.PointerDevice PointerDevice { get; } public bool IsInContact { get; } public PointerPointProperties Properties { get; } + + public PointerPoint? GetTransformedPoint(IPointerPointTransform transform) + { + ArgumentNullException.ThrowIfNull(transform); + if (!transform.TryTransform(Position, out var transformedPosition) || + !transform.TryTransformBounds(Properties.ContactRect, out var transformedContactRect)) return null; + var transformedProperties = new PointerPointProperties + { + ContactRect = transformedContactRect, + IsBarrelButtonPressed = Properties.IsBarrelButtonPressed, + IsHorizontalMouseWheel = Properties.IsHorizontalMouseWheel, + IsInRange = Properties.IsInRange, + IsInverted = Properties.IsInverted, + IsLeftButtonPressed = Properties.IsLeftButtonPressed, + IsMiddleButtonPressed = Properties.IsMiddleButtonPressed, + IsRightButtonPressed = Properties.IsRightButtonPressed, + IsXButton1Pressed = Properties.IsXButton1Pressed, + IsXButton2Pressed = Properties.IsXButton2Pressed, + IsPrimary = Properties.IsPrimary, + IsCanceled = Properties.IsCanceled, + IsEraser = Properties.IsEraser, + Orientation = Properties.Orientation, + PointerUpdateKind = Properties.PointerUpdateKind, + Pressure = Properties.Pressure, + TouchConfidence = Properties.TouchConfidence, + Twist = Properties.Twist, + XTilt = Properties.XTilt, + YTilt = Properties.YTilt, + MouseWheelDelta = Properties.MouseWheelDelta + }; + var transformed = new Vector2((float)transformedPosition.X, (float)transformedPosition.Y); + return new PointerPoint(PointerId, Timestamp, transformed, transformed, + PointerDevice.PointerDeviceType, PointerDeviceType, IsInContact, transformedProperties); + } +} + +public interface IPointerPointTransform +{ + IPointerPointTransform Inverse { get; } + bool TryTransform(Windows.Foundation.Point inPoint, out Windows.Foundation.Point outPoint); + bool TryTransformBounds(Windows.Foundation.Rect inRect, out Windows.Foundation.Rect outRect); +} + +public enum PointerDeviceType +{ + Touch = 0, + Pen = 1, + Mouse = 2, + Touchpad = 3 +} + +public enum PointerUpdateKind +{ + Other = 0, + LeftButtonPressed = 1, + LeftButtonReleased = 2, + RightButtonPressed = 3, + RightButtonReleased = 4, + MiddleButtonPressed = 5, + MiddleButtonReleased = 6, + XButton1Pressed = 7, + XButton1Released = 8, + XButton2Pressed = 9, + XButton2Released = 10 } } namespace Microsoft.UI.Xaml.Input { -using Windows.Devices.Input; +using InputPointerDeviceType = Microsoft.UI.Input.PointerDeviceType; +using LegacyPointerDeviceType = Windows.Devices.Input.PointerDeviceType; public sealed class Pointer { - internal Pointer(uint pointerId, PointerDeviceType pointerDeviceType, bool isInContact, bool isInRange = true) + internal Pointer(uint pointerId, LegacyPointerDeviceType pointerDeviceType, bool isInContact, bool isInRange = true) { PointerId = pointerId; - PointerDeviceType = pointerDeviceType; + LegacyPointerDeviceType = pointerDeviceType; + PointerDeviceType = pointerDeviceType switch + { + LegacyPointerDeviceType.Touch => InputPointerDeviceType.Touch, + LegacyPointerDeviceType.Pen => InputPointerDeviceType.Pen, + _ => InputPointerDeviceType.Mouse + }; IsInContact = isInContact; IsInRange = isInRange; } public uint PointerId { get; } - public PointerDeviceType PointerDeviceType { get; } + public InputPointerDeviceType PointerDeviceType { get; } + internal LegacyPointerDeviceType LegacyPointerDeviceType { get; } public bool IsInContact { get; internal set; } public bool IsInRange { get; internal set; } } @@ -107,95 +218,171 @@ public enum ManipulationModes : uint System = 65536 } -public enum HoldingState +internal static class GesturePosition { - Started = 0, - Completed = 1, - Canceled = 2 + public static Windows.Foundation.Point Get( + Microsoft.UI.Xaml.UIElement? relativeTo, + Vector2 screenPosition) => + InputSystem.GetLocalPosition(relativeTo as Microsoft.UI.Xaml.FrameworkElement, screenPosition); } -public readonly record struct ManipulationDelta( - Vector2 Translation, - float Scale, - float Rotation, - float Expansion) +public sealed class TappedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public static ManipulationDelta Identity => new(Vector2.Zero, 1f, 0f, 0f); + internal Vector2 ScreenPosition { get; init; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point GetPosition(Microsoft.UI.Xaml.UIElement? relativeTo) => + GesturePosition.Get(relativeTo, ScreenPosition); } -public readonly record struct ManipulationVelocities( - Vector2 Linear, - float Angular, - float Expansion); +public sealed class DoubleTappedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs +{ + internal Vector2 ScreenPosition { get; init; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point GetPosition(Microsoft.UI.Xaml.UIElement? relativeTo) => + GesturePosition.Get(relativeTo, ScreenPosition); +} -public abstract class GestureRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs +public sealed class RightTappedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { internal Vector2 ScreenPosition { get; init; } - public PointerDeviceType PointerDeviceType { get; internal init; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point GetPosition(Microsoft.UI.Xaml.UIElement? relativeTo) => + GesturePosition.Get(relativeTo, ScreenPosition); +} - public Vector2 GetPosition(Microsoft.UI.Xaml.FrameworkElement? relativeTo) => - InputSystem.GetLocalPosition(relativeTo, ScreenPosition); +public sealed class HoldingRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs +{ + internal Vector2 ScreenPosition { get; init; } + public Microsoft.UI.Input.HoldingState HoldingState { get; internal init; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point GetPosition(Microsoft.UI.Xaml.UIElement? relativeTo) => + GesturePosition.Get(relativeTo, ScreenPosition); } -public sealed class TappedRoutedEventArgs : GestureRoutedEventArgs +public sealed class ManipulationPivot { - public uint PointerId { get; internal init; } + public ManipulationPivot() + { + } + + public ManipulationPivot(Windows.Foundation.Point center, double radius) + { + Center = center; + Radius = radius; + } + + public Windows.Foundation.Point Center { get; set; } + public double Radius { get; set; } } -public sealed class DoubleTappedRoutedEventArgs : GestureRoutedEventArgs +public sealed class InertiaExpansionBehavior { - public uint PointerId { get; internal init; } + public double DesiredDeceleration { get; set; } = float.NaN; + public double DesiredExpansion { get; set; } = float.NaN; } -public sealed class RightTappedRoutedEventArgs : GestureRoutedEventArgs +public sealed class InertiaRotationBehavior { - public uint PointerId { get; internal init; } + public double DesiredDeceleration { get; set; } = float.NaN; + public double DesiredRotation { get; set; } = float.NaN; } -public sealed class HoldingRoutedEventArgs : GestureRoutedEventArgs +public sealed class InertiaTranslationBehavior { - public HoldingState HoldingState { get; internal init; } + public double DesiredDeceleration { get; set; } = float.NaN; + public double DesiredDisplacement { get; set; } = float.NaN; } public sealed class ManipulationStartingRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public ManipulationModes Mode { get; set; } - public Microsoft.UI.Xaml.FrameworkElement? Container { get; set; } - public Vector2 PivotCenter { get; set; } - public float PivotRadius { get; set; } + public ManipulationModes Mode { get; set; } = ManipulationModes.All; + public Microsoft.UI.Xaml.UIElement? Container { get; set; } + public ManipulationPivot? Pivot { get; set; } + + // Source-compatible aliases retained for early ProGPU callers. + public Vector2 PivotCenter + { + get => Pivot?.Center ?? default; + set => (Pivot ??= new ManipulationPivot()).Center = value; + } + public float PivotRadius + { + get => (float)(Pivot?.Radius ?? 0d); + set => (Pivot ??= new ManipulationPivot()).Radius = value; + } } -public sealed class ManipulationStartedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs +public class ManipulationStartedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public Vector2 Position { get; internal init; } - public ManipulationDelta Cumulative { get; internal init; } = ManipulationDelta.Identity; + public Microsoft.UI.Xaml.UIElement? Container { get; internal init; } + public Microsoft.UI.Input.ManipulationDelta Cumulative { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point Position { get; internal init; } + internal bool IsCompleteRequested { get; private set; } + public void Complete() => IsCompleteRequested = true; } public sealed class ManipulationDeltaRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public ManipulationDelta Delta { get; internal init; } = ManipulationDelta.Identity; - public ManipulationDelta Cumulative { get; internal init; } = ManipulationDelta.Identity; - public ManipulationVelocities Velocities { get; internal init; } + public Microsoft.UI.Xaml.UIElement? Container { get; internal init; } + public Microsoft.UI.Input.ManipulationDelta Delta { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public Microsoft.UI.Input.ManipulationDelta Cumulative { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public Microsoft.UI.Input.ManipulationVelocities Velocities { get; internal init; } public bool IsInertial { get; internal init; } - public bool Complete { get; set; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point Position { get; internal init; } + internal bool IsCompleteRequested { get; private set; } + public void Complete() => IsCompleteRequested = true; } public sealed class ManipulationInertiaStartingRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public ManipulationDelta Cumulative { get; internal init; } = ManipulationDelta.Identity; - public ManipulationVelocities Velocities { get; internal init; } - public float TranslationDeceleration { get; set; } = 0.001f; - public float RotationDeceleration { get; set; } = 0.0001f; - public float ExpansionDeceleration { get; set; } = 0.001f; + public Microsoft.UI.Xaml.UIElement? Container { get; internal init; } + public Microsoft.UI.Input.ManipulationDelta Cumulative { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public Microsoft.UI.Input.ManipulationDelta Delta { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public Microsoft.UI.Input.ManipulationVelocities Velocities { get; internal init; } + public InertiaExpansionBehavior ExpansionBehavior { get; set; } = new(); + public InertiaRotationBehavior RotationBehavior { get; set; } = new(); + public InertiaTranslationBehavior TranslationBehavior { get; set; } = new(); + public InputPointerDeviceType PointerDeviceType { get; internal init; } + + public float TranslationDeceleration + { + get => double.IsNaN(TranslationBehavior.DesiredDeceleration) ? 0f : (float)TranslationBehavior.DesiredDeceleration; + set => TranslationBehavior.DesiredDeceleration = value; + } + public float RotationDeceleration + { + get => double.IsNaN(RotationBehavior.DesiredDeceleration) ? 0f : (float)RotationBehavior.DesiredDeceleration; + set => RotationBehavior.DesiredDeceleration = value; + } + public float ExpansionDeceleration + { + get => double.IsNaN(ExpansionBehavior.DesiredDeceleration) ? 0f : (float)ExpansionBehavior.DesiredDeceleration; + set => ExpansionBehavior.DesiredDeceleration = value; + } } public sealed class ManipulationCompletedRoutedEventArgs : Microsoft.UI.Xaml.RoutedEventArgs { - public ManipulationDelta Cumulative { get; internal init; } = ManipulationDelta.Identity; - public ManipulationVelocities Velocities { get; internal init; } + public Microsoft.UI.Xaml.UIElement? Container { get; internal init; } + public Microsoft.UI.Input.ManipulationDelta Cumulative { get; internal init; } = Microsoft.UI.Input.ManipulationDelta.Identity; + public Microsoft.UI.Input.ManipulationVelocities Velocities { get; internal init; } public bool IsInertial { get; internal init; } + public InputPointerDeviceType PointerDeviceType { get; internal init; } + public Windows.Foundation.Point Position { get; internal init; } } +public delegate void TappedEventHandler(object sender, TappedRoutedEventArgs e); +public delegate void DoubleTappedEventHandler(object sender, DoubleTappedRoutedEventArgs e); +public delegate void RightTappedEventHandler(object sender, RightTappedRoutedEventArgs e); +public delegate void HoldingEventHandler(object sender, HoldingRoutedEventArgs e); +public delegate void ManipulationStartingEventHandler(object sender, ManipulationStartingRoutedEventArgs e); +public delegate void ManipulationStartedEventHandler(object sender, ManipulationStartedRoutedEventArgs e); +public delegate void ManipulationDeltaEventHandler(object sender, ManipulationDeltaRoutedEventArgs e); +public delegate void ManipulationInertiaStartingEventHandler(object sender, ManipulationInertiaStartingRoutedEventArgs e); +public delegate void ManipulationCompletedEventHandler(object sender, ManipulationCompletedRoutedEventArgs e); + public enum InputScopeNameValue { Default = 0, @@ -276,7 +463,7 @@ public enum PointerInputKind public readonly record struct PointerInputEvent( PointerInputKind Kind, uint PointerId, - PointerDeviceType DeviceType, + LegacyPointerDeviceType DeviceType, Vector2 Position, ulong Timestamp, bool IsPrimary = true, diff --git a/src/ProGPU.WinUI/Properties/AssemblyInfo.cs b/src/ProGPU.WinUI/Properties/AssemblyInfo.cs index 24ea979b..53a7b937 100644 --- a/src/ProGPU.WinUI/Properties/AssemblyInfo.cs +++ b/src/ProGPU.WinUI/Properties/AssemblyInfo.cs @@ -9,3 +9,10 @@ "a38b05e02998b776d1ae9133d2bf7197e5eb39e9ba6bee9ed837236a90c151c6d6e154dc8debd4" + "fef44d6597f795ee9e33bf0b2a959915729166099862717525ec4bab3002d4d8f8a81d656b5c25" + "c891cb91")] +[assembly: InternalsVisibleTo( + "ProGPU.Tests, PublicKey=" + + "002400000480000094000000060200000024000052534131000400000100010045aa5f537c126e" + + "38b67a4e22a49d30ad6424dc66d3e82890c03de2caa8f421736dbe5170b054610320e4a436e808" + + "a38b05e02998b776d1ae9133d2bf7197e5eb39e9ba6bee9ed837236a90c151c6d6e154dc8debd4" + + "fef44d6597f795ee9e33bf0b2a959915729166099862717525ec4bab3002d4d8f8a81d656b5c25" + + "c891cb91")] diff --git a/src/ProGPU.WinUI/Text/RichEditTextRange.cs b/src/ProGPU.WinUI/Text/RichEditTextRange.cs index bddfaec1..0ea9ca42 100644 --- a/src/ProGPU.WinUI/Text/RichEditTextRange.cs +++ b/src/ProGPU.WinUI/Text/RichEditTextRange.cs @@ -409,7 +409,7 @@ public void GetPoint( VerticalCharacterAlignment.Baseline => bounds.Y + bounds.Height * 0.8f, _ => bounds.Y }; - point = new Windows.Foundation.Point(x, y); + point = new Windows.Foundation.Point((float)x, (float)y); } public void GetRect(PointOptions options, out Windows.Foundation.Rect rect, out int hit) From 29bdd0a5d3fa79e663d76b1fef0c5692f2491c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 23:48:41 +0200 Subject: [PATCH 13/14] Fix iOS indirect scrolling and pinch input --- docs/ios.md | 25 ++++++++- .../IosIndirectScrollPolicyTests.cs | 31 +++++++++++ src/ProGPU.Tests/ProGPU.Tests.csproj | 2 + src/ProGPU.iOS/IosIndirectScrollPolicy.cs | 22 ++++++++ src/ProGPU.iOS/MetalRenderView.cs | 53 ++++++++++++++----- 5 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 src/ProGPU.Tests/IosIndirectScrollPolicyTests.cs create mode 100644 src/ProGPU.iOS/IosIndirectScrollPolicy.cs diff --git a/docs/ios.md b/docs/ios.md index b4c9650d..4d1bab1c 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -130,7 +130,27 @@ The native input design follows Apple's [trackpad and mouse input guidance](http `UIPinchGestureRecognizer` consumes transform events, `UIHoverGestureRecognizer` tracks an unpressed pointer, and `UIEvent.buttonMask` identifies mouse buttons. The event delegate intentionally reads `UIEvent.Type` because non-touch scroll -and transform events have zero touches after the indirect-input opt-in. +and transform events have zero touches after the indirect-input opt-in. Disjoint +continuous and discrete pan recognizers retain logical-pixel trackpad deltas while +normalizing mouse-wheel detents to the same line units used by desktop hosts. +Pinch scale is transported without step quantization as `120 * ln(relativeScale)` and +reconstructed by zooming controls with `exp(delta / 120)`. Pointer location falls back to the last +hover/click position (or the view center for the first event), because a touchless +scroll or transform event may report an empty recognizer location. + +The portable input layer also implements the current +[`Microsoft.UI.Input.GestureRecognizer`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.input.gesturerecognizer) +contract: official gesture-setting values, pointer-point metadata, tap/double-tap, +right-tap, holding, mouse/pen dragging, touch cross-slide, one- and multi-pointer +translation/rails/scale/rotation, wheel manipulation, configurable manual or +automatic inertia, and completion. XAML routed gesture arguments expose the WinUI +container, pivot, position, device, cumulative/delta/velocity, completion, and +inertia-behavior surfaces using the current `Microsoft.UI.Input` value and device +types. Routed inertia produces timed `ManipulationDelta` events, honors desired +deceleration or displacement/angle/expansion, supports interruption by a new contact, +and completes only after motion stops. Recognition is `O(P)` time per sample and `O(P)` retained +state for `P` active contacts; wheel, tap, drag, and inertia steps are `O(1)` and +allocation-free after event construction. ### Responsive offscreen effects @@ -250,8 +270,9 @@ sources. No implementation was copied or translated from another engine. | [WinUI/UWP `InputPane`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpane), [`OccludedRect`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpane.occludedrect), and [`InputPaneVisibilityEventArgs`](https://learn.microsoft.com/en-us/uwp/api/windows.ui.viewmanagement.inputpanevisibilityeventargs) | Portable keyboard visibility, occlusion, and focused-element reporting | Match the official API shape and keep UIKit geometry out of application code. | | [`UIView.safeAreaInsets`](https://developer.apple.com/documentation/uikit/uiview/safeareainsets), [`safeAreaInsetsDidChange`](https://developer.apple.com/documentation/uikit/uiview/safeareainsetsdidchange()), and [`keyboardLayoutGuide`](https://developer.apple.com/documentation/uikit/uiview/keyboardlayoutguide) | System-bar/cutout geometry and keyboard-aware layout | Report logical inset/occlusion values; inset app content without reducing the Metal drawable. | | [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) and [`setMarkedText`](https://developer.apple.com/documentation/uikit/uitextinput/setmarkedtext(_:selectedrange:)) | Exact document replacements, selection, and IME marked-text lifecycle | Let UIKit own its native editing document and mirror exact changes into typed ProGPU text operations. | -| [`UIPanGestureRecognizer.allowedScrollTypesMask`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer/allowedscrolltypesmask) and [`allowedTouchTypes`](https://developer.apple.com/documentation/uikit/uigesturerecognizer/allowedtouchtypes) | Indirect continuous scrolling from trackpads and other scrolling devices, independently from direct touches | Accept all scroll types, exclude direct touches from the scroll recognizer, and preserve pixel deltas through routed input. | +| [`UIPanGestureRecognizer.allowedScrollTypesMask`](https://developer.apple.com/documentation/uikit/uipangesturerecognizer/allowedscrolltypesmask), [`UIScrollTypeMask`](https://developer.apple.com/documentation/uikit/uiscrolltypemask), and [`allowedTouchTypes`](https://developer.apple.com/documentation/uikit/uigesturerecognizer/allowedtouchtypes) | Indirect scrolling distinguishes continuous trackpads from discrete mouse wheels independently from direct touches | Use disjoint continuous/discrete recognizers, exclude direct touches, preserve trackpad pixels, and normalize discrete detents. | | [Apple TN3210: Optimizing your app for iPhone Mirroring](https://developer.apple.com/documentation/technotes/tn3210-optimizing-your-app-for-iphone-mirroring) | Compatibility requirements for indirect scroll events in apps with older deployment targets | Set `UIApplicationSupportsIndirectInputEvents` to `YES` because the sample supports iOS 15, then use UIKit's built-in pan recognizer for both continuous and discrete scroll events. | +| [Windows App SDK `GestureRecognizer`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.input.gesturerecognizer), [`GestureSettings`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.input.gesturesettings), and [XAML manipulation events](https://learn.microsoft.com/en-us/windows/apps/design/input/touch-interactions) | Public gesture ingestion, settings, typed event payloads, completion, and inertia contracts | Match the Microsoft API surface and implement an original typed recognizer shared by every host; UIKit remains an input adapter rather than a second gesture model. | | [`UIDocumentPickerViewController`](https://developer.apple.com/documentation/uikit/uidocumentpickerviewcontroller), [`UTType`](https://developer.apple.com/documentation/uniformtypeidentifiers/uttypereference), and [WinUI file-management guidance](https://learn.microsoft.com/en-us/windows/apps/develop/files/) | Native file/folder selection, extension filtering, save destinations, and sandbox-safe access | Preserve the WinUI-shaped asynchronous picker contract while presenting Files UI, copying opened documents locally, and retaining/coordinating security-scoped save and folder URLs. | | [WinUI `ItemsRepeater`](https://learn.microsoft.com/en-us/windows/apps/design/controls/items-repeater), [`VirtualizingLayoutContext`](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.virtualizinglayoutcontext), and [attached-layout guidance](https://learn.microsoft.com/en-us/windows/apps/design/controls/items-repeater#attached-layouts) | Viewport-driven realization, recycling, and variable-sized layouts | Share a prefix-sum geometry index, use bounded overscan, recycle containers, and preserve scroll anchors. | diff --git a/src/ProGPU.Tests/IosIndirectScrollPolicyTests.cs b/src/ProGPU.Tests/IosIndirectScrollPolicyTests.cs new file mode 100644 index 00000000..65b96a1a --- /dev/null +++ b/src/ProGPU.Tests/IosIndirectScrollPolicyTests.cs @@ -0,0 +1,31 @@ +using ProGPU.iOS; +using Xunit; + +namespace ProGPU.Tests; + +public sealed class IosIndirectScrollPolicyTests +{ + [Theory] + [InlineData(18f, true, 18f)] + [InlineData(-3.5f, true, -3.5f)] + [InlineData(18f, false, 1f)] + [InlineData(-18f, false, -1f)] + public void PreservesTrackpadPixelsAndNormalizesMouseNotches( + float input, + bool precise, + float expected) + { + Assert.Equal(expected, IosIndirectScrollPolicy.NormalizeScrollDelta(input, precise)); + } + + [Theory] + [InlineData(0.5f)] + [InlineData(0.9f)] + [InlineData(1.1f)] + [InlineData(2f)] + public void PinchWheelMappingRoundTripsRelativeScale(float scale) + { + float wheel = IosIndirectScrollPolicy.PinchScaleToWheelDelta(scale); + Assert.Equal(scale, IosIndirectScrollPolicy.WheelDeltaToScale(wheel), 5); + } +} diff --git a/src/ProGPU.Tests/ProGPU.Tests.csproj b/src/ProGPU.Tests/ProGPU.Tests.csproj index a52fd812..f4190638 100644 --- a/src/ProGPU.Tests/ProGPU.Tests.csproj +++ b/src/ProGPU.Tests/ProGPU.Tests.csproj @@ -26,6 +26,8 @@ Link="Android\AndroidStorageNamePolicy.cs" /> + diff --git a/src/ProGPU.iOS/IosIndirectScrollPolicy.cs b/src/ProGPU.iOS/IosIndirectScrollPolicy.cs new file mode 100644 index 00000000..78a855d7 --- /dev/null +++ b/src/ProGPU.iOS/IosIndirectScrollPolicy.cs @@ -0,0 +1,22 @@ +namespace ProGPU.iOS; + +/// +/// Converts UIKit indirect-input units to ProGPU wheel units without platform types. +/// Each conversion is fixed O(1) work and allocation-free. +/// +public static class IosIndirectScrollPolicy +{ + public const float PinchWheelUnitsPerNaturalLog = 120f; + + public static float NormalizeScrollDelta(float value, bool precise) => + precise || value == 0f ? value : MathF.CopySign(1f, value); + + public static float PinchScaleToWheelDelta(double relativeScale) + { + if (!double.IsFinite(relativeScale) || relativeScale <= 0d) return 0f; + return (float)(Math.Log(relativeScale) * PinchWheelUnitsPerNaturalLog); + } + + public static float WheelDeltaToScale(float wheelDelta) => + MathF.Exp(wheelDelta / PinchWheelUnitsPerNaturalLog); +} diff --git a/src/ProGPU.iOS/MetalRenderView.cs b/src/ProGPU.iOS/MetalRenderView.cs index 2aa6de60..541623a6 100644 --- a/src/ProGPU.iOS/MetalRenderView.cs +++ b/src/ProGPU.iOS/MetalRenderView.cs @@ -19,11 +19,11 @@ internal sealed class MetalRenderView : UIView // Keep the framework's conventional low IDs available to simultaneous direct // touches while presenting every hover/click/scroll phase as one mouse pointer. private const uint IndirectPointerId = uint.MaxValue; - private const float PinchWheelUnitsPerNaturalLog = 120f; private readonly CAMetalLayer _metalLayer; private readonly UIScreen _screen; private readonly Dictionary _pointerIds = []; - private readonly UIPanGestureRecognizer _indirectScrollRecognizer; + private readonly UIPanGestureRecognizer _continuousScrollRecognizer; + private readonly UIPanGestureRecognizer _discreteScrollRecognizer; private readonly UIPinchGestureRecognizer _indirectPinchRecognizer; private readonly UIHoverGestureRecognizer _hoverRecognizer; private readonly IndirectGestureDelegate _scrollGestureDelegate; @@ -52,9 +52,18 @@ public MetalRenderView(CGRect frame, UIScreen screen) : base(frame) Layer.AddSublayer(_metalLayer); _scrollGestureDelegate = new IndirectGestureDelegate(UIEventType.Scroll); _transformGestureDelegate = new IndirectGestureDelegate(UIEventType.Transform); - _indirectScrollRecognizer = new UIPanGestureRecognizer(HandleIndirectScroll) + // UIKit exposes device precision through disjoint masks rather than on the + // callback event. Keeping two recognizers preserves pixel trackpad deltas + // while normalizing a notched mouse wheel to framework line units. + _continuousScrollRecognizer = new UIPanGestureRecognizer(HandleContinuousScroll) { - AllowedScrollTypesMask = UIScrollTypeMask.All, + AllowedScrollTypesMask = UIScrollTypeMask.Continuous, + CancelsTouchesInView = false, + Delegate = _scrollGestureDelegate + }; + _discreteScrollRecognizer = new UIPanGestureRecognizer(HandleDiscreteScroll) + { + AllowedScrollTypesMask = UIScrollTypeMask.Discrete, CancelsTouchesInView = false, Delegate = _scrollGestureDelegate }; @@ -67,7 +76,8 @@ public MetalRenderView(CGRect frame, UIScreen screen) : base(frame) { CancelsTouchesInView = false }; - AddGestureRecognizer(_indirectScrollRecognizer); + AddGestureRecognizer(_continuousScrollRecognizer); + AddGestureRecognizer(_discreteScrollRecognizer); AddGestureRecognizer(_indirectPinchRecognizer); AddGestureRecognizer(_hoverRecognizer); UpdateDrawableSize(); @@ -211,7 +221,13 @@ private void UpdateDrawableSize() } } - private void HandleIndirectScroll(UIPanGestureRecognizer recognizer) + private void HandleContinuousScroll(UIPanGestureRecognizer recognizer) => + HandleIndirectScroll(recognizer, precise: true); + + private void HandleDiscreteScroll(UIPanGestureRecognizer recognizer) => + HandleIndirectScroll(recognizer, precise: false); + + private void HandleIndirectScroll(UIPanGestureRecognizer recognizer, bool precise) { if (recognizer.State is not (UIGestureRecognizerState.Began or UIGestureRecognizerState.Changed or @@ -225,9 +241,9 @@ UIGestureRecognizerState.Changed or PointerDeviceType.Mouse, location, checked((ulong)Math.Max(0d, NSProcessInfo.ProcessInfo.SystemUptime * 1_000_000d)), - WheelDeltaX: (float)translation.X, - WheelDeltaY: (float)translation.Y, - IsPreciseWheel: true, + WheelDeltaX: IosIndirectScrollPolicy.NormalizeScrollDelta((float)translation.X, precise), + WheelDeltaY: IosIndirectScrollPolicy.NormalizeScrollDelta((float)translation.Y, precise), + IsPreciseWheel: precise, Modifiers: ReadModifiers(recognizer.ModifierFlags))); recognizer.SetTranslation(CGPoint.Empty, this); } @@ -241,7 +257,7 @@ UIGestureRecognizerState.Changed or double scale = (double)recognizer.Scale; if (!double.IsFinite(scale) || scale <= 0d) return; recognizer.Scale = 1; - float zoomDelta = (float)(Math.Log(scale) * PinchWheelUnitsPerNaturalLog); + float zoomDelta = IosIndirectScrollPolicy.PinchScaleToWheelDelta(scale); if (MathF.Abs(zoomDelta) <= float.Epsilon) return; InputSystem.InjectPointer(new PointerInputEvent( @@ -275,6 +291,14 @@ private Vector2 ResolveIndirectLocation(UIGestureRecognizer recognizer) { CGPoint point = recognizer.LocationInView(this); var position = new Vector2((float)point.X, (float)point.Y); + // Native scroll/transform events have no touches. UIKit can return CGPoint.Empty + // until hover has supplied a pointer location, so retain the latest hover/click + // position and fall back to the view center for the first such event. + if (recognizer.NumberOfTouches == 0 && position == Vector2.Zero) + { + if (_lastIndirectPointerPosition != Vector2.Zero) return _lastIndirectPointerPosition; + return new Vector2((float)Bounds.GetMidX(), (float)Bounds.GetMidY()); + } if (float.IsFinite(position.X) && float.IsFinite(position.Y)) { _lastIndirectPointerPosition = position; @@ -317,9 +341,12 @@ protected override void Dispose(bool disposing) RemoveGestureRecognizer(_indirectPinchRecognizer); _indirectPinchRecognizer.Delegate = null!; _indirectPinchRecognizer.Dispose(); - RemoveGestureRecognizer(_indirectScrollRecognizer); - _indirectScrollRecognizer.Delegate = null!; - _indirectScrollRecognizer.Dispose(); + RemoveGestureRecognizer(_discreteScrollRecognizer); + _discreteScrollRecognizer.Delegate = null!; + _discreteScrollRecognizer.Dispose(); + RemoveGestureRecognizer(_continuousScrollRecognizer); + _continuousScrollRecognizer.Delegate = null!; + _continuousScrollRecognizer.Dispose(); _transformGestureDelegate.Dispose(); _scrollGestureDelegate.Dispose(); _metalLayer.RemoveFromSuperLayer(); From ab9804120481dd0ab5b22a0c5edd1ecaf61f1295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Jul 2026 23:51:00 +0200 Subject: [PATCH 14/14] Complete routed right-tap recognition --- .../TouchGestureResponsiveTests.cs | 23 +++++++++++++++++++ src/ProGPU.WinUI/Input/InputSystem.cs | 11 ++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs index cc979859..8860cf86 100644 --- a/src/ProGPU.Tests/TouchGestureResponsiveTests.cs +++ b/src/ProGPU.Tests/TouchGestureResponsiveTests.cs @@ -98,6 +98,22 @@ public void TouchReleaseBeyondTapThresholdWithoutMoveDoesNotTap() Assert.Equal(0, target.TappedCount); } + [Fact] + public void PenBarrelButtonProducesRightTap() + { + var target = new TrackingControl { Width = 120, Height = 120 }; + ArrangeRoot(target, new Vector2(120, 120)); + UseInputRoot(target); + + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Pressed, 91, PointerDeviceType.Pen, new Vector2(40, 40), 1_000, + IsInContact: true, IsRightButtonPressed: true)); + InputSystem.InjectPointer(new PointerInputEvent( + PointerInputKind.Released, 91, PointerDeviceType.Pen, new Vector2(40, 40), 20_000)); + + Assert.Equal(1, target.RightTappedCount); + } + [Fact] public void MobileTextOperationsDoNotDependOnPhysicalKeyEvents() { @@ -990,6 +1006,7 @@ private sealed class TrackingControl : Control public int CaptureLostCount { get; private set; } public int TappedCount { get; private set; } public int DoubleTappedCount { get; private set; } + public int RightTappedCount { get; private set; } public int ManipulationStartedCount { get; private set; } public int ManipulationCompletedCount { get; private set; } public int ManipulationInertiaStartingCount { get; private set; } @@ -1035,6 +1052,12 @@ public override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) base.OnDoubleTapped(e); } + public override void OnRightTapped(RightTappedRoutedEventArgs e) + { + RightTappedCount++; + base.OnRightTapped(e); + } + public override void OnManipulationStarted(ManipulationStartedRoutedEventArgs e) { ManipulationStartedCount++; diff --git a/src/ProGPU.WinUI/Input/InputSystem.cs b/src/ProGPU.WinUI/Input/InputSystem.cs index dc149e59..6601a424 100644 --- a/src/ProGPU.WinUI/Input/InputSystem.cs +++ b/src/ProGPU.WinUI/Input/InputSystem.cs @@ -826,6 +826,14 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve ScreenPosition = input.Position, HoldingState = Microsoft.UI.Input.HoldingState.Completed }); + if (contact.Target.IsRightTapEnabled) + { + contact.Target.OnRightTapped(new RightTappedRoutedEventArgs + { + PointerDeviceType = contact.Pointer.PointerDeviceType, + ScreenPosition = input.Position + }); + } return; } @@ -836,7 +844,8 @@ private static void CompleteGesture(PointerContactState contact, PointerInputEve var releaseExceededTapThreshold = Vector2.DistanceSquared(contact.DownPosition, input.Position) > tapThreshold * tapThreshold; if (contact.ExceededTapThreshold || releaseExceededTapThreshold || contactDuration > 800_000UL) return; - if (input.DeviceType == PointerDeviceType.Mouse && contact.StartedWithRightButton) + if (input.DeviceType is PointerDeviceType.Mouse or PointerDeviceType.Pen && + contact.StartedWithRightButton) { if (contact.Target.IsRightTapEnabled) {