feat(interop): add safe handle wrappers and configuration options
Implement SafeHandle wrappers for native resources (SafeGtkWidgetHandle, SafeGObjectHandle, SafeX11DisplayHandle, SafeX11CursorHandle, SafeDlopenHandle) to prevent memory leaks. Move gesture and rendering configuration from static properties to LinuxApplicationOptions for better testability and DI compatibility. Add THREADING.md and DI-MIGRATION.md documentation. Include LayoutIntegrationTests for Measure/Arrange pipeline and SkiaViewTheoryTests with parameterized test cases using [Theory] attributes.
This commit is contained in:
@@ -238,6 +238,17 @@ public partial class LinuxApplication : IDisposable
|
||||
/// </summary>
|
||||
public void Initialize(LinuxApplicationOptions options)
|
||||
{
|
||||
// Apply gesture configuration
|
||||
Handlers.GestureManager.SwipeMinDistance = options.SwipeMinDistance;
|
||||
Handlers.GestureManager.SwipeMaxTime = options.SwipeMaxTime;
|
||||
Handlers.GestureManager.SwipeDirectionThreshold = options.SwipeDirectionThreshold;
|
||||
Handlers.GestureManager.PanMinDistance = options.PanMinDistance;
|
||||
Handlers.GestureManager.PinchScrollScale = options.PinchScrollScale;
|
||||
|
||||
// Apply rendering configuration
|
||||
SkiaRenderingEngine.MaxDirtyRegions = options.MaxDirtyRegions;
|
||||
SkiaRenderingEngine.RegionMergeThreshold = options.RegionMergeThreshold;
|
||||
|
||||
_useGtk = options.UseGtk;
|
||||
if (_useGtk)
|
||||
{
|
||||
|
||||
@@ -20,4 +20,15 @@ public class LinuxApplicationOptions
|
||||
public string? IconPath { get; set; }
|
||||
|
||||
public bool UseGtk { get; set; }
|
||||
|
||||
// Gesture configuration
|
||||
public double SwipeMinDistance { get; set; } = 50.0;
|
||||
public double SwipeMaxTime { get; set; } = 500.0;
|
||||
public double SwipeDirectionThreshold { get; set; } = 0.5;
|
||||
public double PanMinDistance { get; set; } = 10.0;
|
||||
public double PinchScrollScale { get; set; } = 0.1;
|
||||
|
||||
// Rendering configuration
|
||||
public int MaxDirtyRegions { get; set; } = 32;
|
||||
public float RegionMergeThreshold { get; set; } = 0.3f;
|
||||
}
|
||||
|
||||
196
Native/SafeHandles.cs
Normal file
196
Native/SafeHandles.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Microsoft.Maui.Platform.Linux.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Safe handle wrapper for GTK widget pointers.
|
||||
/// Releases the widget via <c>gtk_widget_destroy</c> when disposed.
|
||||
/// </summary>
|
||||
internal class SafeGtkWidgetHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
[DllImport("libgtk-3.so.0")]
|
||||
private static extern void gtk_widget_destroy(IntPtr widget);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeGtkWidgetHandle"/> that owns the handle.
|
||||
/// </summary>
|
||||
public SafeGtkWidgetHandle() : base(ownsHandle: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeGtkWidgetHandle"/> wrapping an existing pointer.
|
||||
/// </summary>
|
||||
/// <param name="existingHandle">The existing GTK widget pointer.</param>
|
||||
/// <param name="ownsHandle">Whether this safe handle is responsible for releasing the resource.</param>
|
||||
public SafeGtkWidgetHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
|
||||
{
|
||||
SetHandle(existingHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
gtk_widget_destroy(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe handle wrapper for GObject pointers.
|
||||
/// Releases the object via <c>g_object_unref</c> when disposed.
|
||||
/// Suitable for any GObject-derived type including GtkCssProvider, GdkPixbuf, etc.
|
||||
/// </summary>
|
||||
internal class SafeGObjectHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
[DllImport("libgobject-2.0.so.0")]
|
||||
private static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeGObjectHandle"/> that owns the handle.
|
||||
/// </summary>
|
||||
public SafeGObjectHandle() : base(ownsHandle: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeGObjectHandle"/> wrapping an existing pointer.
|
||||
/// </summary>
|
||||
/// <param name="existingHandle">The existing GObject pointer.</param>
|
||||
/// <param name="ownsHandle">Whether this safe handle is responsible for releasing the resource.</param>
|
||||
public SafeGObjectHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
|
||||
{
|
||||
SetHandle(existingHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
g_object_unref(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe handle wrapper for X11 <c>Display*</c> pointers.
|
||||
/// Releases the display connection via <c>XCloseDisplay</c> when disposed.
|
||||
/// </summary>
|
||||
internal class SafeX11DisplayHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
[DllImport("libX11.so.6")]
|
||||
private static extern int XCloseDisplay(IntPtr display);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeX11DisplayHandle"/> that owns the handle.
|
||||
/// </summary>
|
||||
public SafeX11DisplayHandle() : base(ownsHandle: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeX11DisplayHandle"/> wrapping an existing pointer.
|
||||
/// </summary>
|
||||
/// <param name="existingHandle">The existing X11 Display pointer.</param>
|
||||
/// <param name="ownsHandle">Whether this safe handle is responsible for releasing the resource.</param>
|
||||
public SafeX11DisplayHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
|
||||
{
|
||||
SetHandle(existingHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
XCloseDisplay(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe handle wrapper for X11 Cursor resources.
|
||||
/// Releases the cursor via <c>XFreeCursor</c> when disposed.
|
||||
/// Requires the associated <c>Display*</c> to be provided at construction time,
|
||||
/// as X11 cursor cleanup requires both the display and cursor handles.
|
||||
/// </summary>
|
||||
internal class SafeX11CursorHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
[DllImport("libX11.so.6")]
|
||||
private static extern int XFreeCursor(IntPtr display, IntPtr cursor);
|
||||
|
||||
private readonly IntPtr _display;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeX11CursorHandle"/> that owns the handle.
|
||||
/// </summary>
|
||||
/// <param name="display">
|
||||
/// The X11 Display pointer required for releasing the cursor.
|
||||
/// The caller must ensure the display remains valid for the lifetime of this handle.
|
||||
/// </param>
|
||||
public SafeX11CursorHandle(IntPtr display) : base(ownsHandle: true)
|
||||
{
|
||||
_display = display;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeX11CursorHandle"/> wrapping an existing cursor.
|
||||
/// </summary>
|
||||
/// <param name="display">
|
||||
/// The X11 Display pointer required for releasing the cursor.
|
||||
/// The caller must ensure the display remains valid for the lifetime of this handle.
|
||||
/// </param>
|
||||
/// <param name="existingHandle">The existing X11 Cursor handle.</param>
|
||||
/// <param name="ownsHandle">Whether this safe handle is responsible for releasing the resource.</param>
|
||||
public SafeX11CursorHandle(IntPtr display, IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
|
||||
{
|
||||
_display = display;
|
||||
SetHandle(existingHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
if (_display != IntPtr.Zero)
|
||||
{
|
||||
XFreeCursor(_display, handle);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safe handle wrapper for <c>GtkCssProvider*</c> pointers.
|
||||
/// Since GtkCssProvider is a GObject, this releases it via <c>g_object_unref</c> when disposed.
|
||||
/// </summary>
|
||||
internal class SafeCssProviderHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
[DllImport("libgobject-2.0.so.0")]
|
||||
private static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeCssProviderHandle"/> that owns the handle.
|
||||
/// </summary>
|
||||
public SafeCssProviderHandle() : base(ownsHandle: true)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SafeCssProviderHandle"/> wrapping an existing pointer.
|
||||
/// </summary>
|
||||
/// <param name="existingHandle">The existing GtkCssProvider pointer.</param>
|
||||
/// <param name="ownsHandle">Whether this safe handle is responsible for releasing the resource.</param>
|
||||
public SafeCssProviderHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
|
||||
{
|
||||
SetHandle(existingHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
g_object_unref(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
408
docs/DI-MIGRATION.md
Normal file
408
docs/DI-MIGRATION.md
Normal file
@@ -0,0 +1,408 @@
|
||||
<!-- Licensed to the .NET Foundation under one or more agreements.
|
||||
The .NET Foundation licenses this file to you under the MIT license. -->
|
||||
|
||||
# Singleton-to-DI Migration Plan
|
||||
|
||||
This document catalogs all singleton service classes in the maui-linux project that use a
|
||||
static `Instance` property pattern instead of proper dependency injection, and provides a
|
||||
migration plan for each.
|
||||
|
||||
---
|
||||
|
||||
## Identified Singleton Services
|
||||
|
||||
### 1. GtkHostService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/GtkHostService.cs` |
|
||||
| **Pattern** | `_instance ??= new GtkHostService()` (null-coalescing assignment) |
|
||||
| **Interface** | None (concrete class) |
|
||||
| **Static references** | 8 across 6 files |
|
||||
| **Init/Cleanup** | `Initialize(title, width, height)` must be called before use; `Shutdown()` must be called on exit |
|
||||
| **DI Registration** | `AddSingleton<GtkHostService>` |
|
||||
| **Difficulty** | **Hard** |
|
||||
|
||||
**Files referencing `GtkHostService.Instance`:**
|
||||
- `LinuxApplication.cs` (2) -- creates/configures host window
|
||||
- `LinuxApplication.Lifecycle.cs` (1) -- calls `Shutdown()`
|
||||
- `Hosting/LinuxProgramHost.cs` (1) -- calls `Initialize()`
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration (already wrapping Instance)
|
||||
- `Handlers/GtkWebViewPlatformView.cs` (1) -- reads `HostWindow`
|
||||
- `Handlers/GtkWebViewHandler.cs` (2) -- reads host service
|
||||
|
||||
**Notes:** This service is initialized very early in the startup path (`LinuxProgramHost`) before the
|
||||
DI container is fully built, and is also accessed by `LinuxApplication` which itself is constructed
|
||||
before DI resolution. Migration requires refactoring the startup pipeline so that `GtkHostService`
|
||||
is constructed by the container and then explicitly initialized via a hosted-service or startup hook.
|
||||
|
||||
---
|
||||
|
||||
### 2. SystemThemeService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/SystemThemeService.cs` |
|
||||
| **Pattern** | Double-checked locking with `lock (_lock)` |
|
||||
| **Interface** | None (concrete class) |
|
||||
| **Static references** | 3 across 2 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor auto-detects theme and starts a `FileSystemWatcher` + polling `Timer` |
|
||||
| **DI Registration** | `AddSingleton<SystemThemeService>` |
|
||||
| **Difficulty** | **Medium** |
|
||||
|
||||
**Files referencing `SystemThemeService.Instance`:**
|
||||
- `LinuxApplication.Lifecycle.cs` (2) -- reads `CurrentTheme`, subscribes to `ThemeChanged`
|
||||
- `Services/AppInfoService.cs` (1) -- reads `CurrentTheme` for `RequestedTheme`
|
||||
|
||||
**Notes:** Already registered in DI (`TryAddSingleton<SystemThemeService>()`), but the registration
|
||||
does not use the static Instance -- it lets the container construct it. However, call sites still
|
||||
use `SystemThemeService.Instance` directly instead of resolving from the container. `AppInfoService`
|
||||
itself is a singleton and would need the `SystemThemeService` injected via constructor.
|
||||
|
||||
---
|
||||
|
||||
### 3. FontFallbackManager
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/FontFallbackManager.cs` |
|
||||
| **Pattern** | Double-checked locking with `lock (_lock)` |
|
||||
| **Interface** | None (concrete class) |
|
||||
| **Static references** | 4 across 3 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor pre-caches top 10 fallback font typefaces |
|
||||
| **DI Registration** | `AddSingleton<FontFallbackManager>` |
|
||||
| **Difficulty** | **Medium** |
|
||||
|
||||
**Files referencing `FontFallbackManager.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration (wrapping Instance)
|
||||
- `Views/SkiaLabel.cs` (2) -- calls `ShapeTextWithFallback`
|
||||
- `Rendering/TextRenderingHelper.cs` (1) -- calls `ShapeTextWithFallback`
|
||||
|
||||
**Notes:** The view and rendering classes currently access the static Instance directly rather than
|
||||
accepting it via constructor injection. These classes would need to receive `FontFallbackManager`
|
||||
through the handler or view constructor.
|
||||
|
||||
---
|
||||
|
||||
### 4. InputMethodServiceFactory
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/InputMethodServiceFactory.cs` |
|
||||
| **Pattern** | Static factory class with double-checked locking |
|
||||
| **Interface** | Returns `IInputMethodService` |
|
||||
| **Static references** | 3 across 3 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | Factory auto-detects IBus/Fcitx5/XIM; `Reset()` calls `Shutdown()` on existing instance |
|
||||
| **DI Registration** | `AddSingleton<IInputMethodService>` (via factory delegate) |
|
||||
| **Difficulty** | **Medium** |
|
||||
|
||||
**Files referencing `InputMethodServiceFactory.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration (wrapping Instance)
|
||||
- `Views/SkiaEditor.cs` (1) -- assigns to `_inputMethodService` field
|
||||
- `Views/SkiaEntry.cs` (1) -- assigns to `_inputMethodService` field
|
||||
|
||||
**Notes:** The factory pattern can be replaced with a DI factory registration:
|
||||
`AddSingleton<IInputMethodService>(sp => InputMethodServiceFactory.CreateService())`.
|
||||
View classes should receive `IInputMethodService` via constructor injection.
|
||||
|
||||
---
|
||||
|
||||
### 5. AccessibilityServiceFactory
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/AccessibilityServiceFactory.cs` |
|
||||
| **Pattern** | Static factory class with double-checked locking |
|
||||
| **Interface** | Returns `IAccessibilityService` |
|
||||
| **Static references** | 2 across 2 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | Factory creates `AtSpi2AccessibilityService` and calls `Initialize()`; `Reset()` calls `Shutdown()` |
|
||||
| **DI Registration** | `AddSingleton<IAccessibilityService>` (via factory delegate) |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `AccessibilityServiceFactory.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration (wrapping Instance)
|
||||
- `Views/SkiaView.Accessibility.cs` (1) -- accesses the service
|
||||
|
||||
**Notes:** Similar to `InputMethodServiceFactory`. Replace with a factory delegate in DI
|
||||
registration. Only one non-registration call site to update.
|
||||
|
||||
---
|
||||
|
||||
### 6. ConnectivityService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/ConnectivityService.cs` |
|
||||
| **Pattern** | `Lazy<ConnectivityService>` |
|
||||
| **Interface** | `IConnectivity`, `IDisposable` |
|
||||
| **Static references** | 1 across 1 file (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor subscribes to `NetworkChange` events; `Dispose()` unsubscribes |
|
||||
| **DI Registration** | `AddSingleton<IConnectivity>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `ConnectivityService.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration
|
||||
|
||||
**Notes:** Already only referenced via the DI registration line. Migration is trivial: change to
|
||||
`AddSingleton<IConnectivity, ConnectivityService>()`. The constructor is already `public`.
|
||||
|
||||
---
|
||||
|
||||
### 7. DeviceDisplayService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/DeviceDisplayService.cs` |
|
||||
| **Pattern** | `Lazy<DeviceDisplayService>` |
|
||||
| **Interface** | `IDeviceDisplay` |
|
||||
| **Static references** | 1 across 1 file (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor calls `RefreshDisplayInfo()`; internally references `MonitorService.Instance` |
|
||||
| **DI Registration** | `AddSingleton<IDeviceDisplay>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `DeviceDisplayService.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration
|
||||
|
||||
**Notes:** Only referenced at the DI registration point. The dependency on `MonitorService.Instance`
|
||||
inside `RefreshDisplayInfo()` means `MonitorService` should be migrated first (or simultaneously)
|
||||
and injected via constructor.
|
||||
|
||||
---
|
||||
|
||||
### 8. AppInfoService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/AppInfoService.cs` |
|
||||
| **Pattern** | `Lazy<AppInfoService>` |
|
||||
| **Interface** | `IAppInfo` |
|
||||
| **Static references** | 1 across 1 file (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor reads assembly metadata; `RequestedTheme` accesses `SystemThemeService.Instance` |
|
||||
| **DI Registration** | `AddSingleton<IAppInfo>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `AppInfoService.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration
|
||||
|
||||
**Notes:** Only referenced at the DI registration point. Has an internal dependency on
|
||||
`SystemThemeService.Instance` that should be replaced with constructor injection of
|
||||
`SystemThemeService`.
|
||||
|
||||
---
|
||||
|
||||
### 9. DeviceInfoService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/DeviceInfoService.cs` |
|
||||
| **Pattern** | `Lazy<DeviceInfoService>` |
|
||||
| **Interface** | `IDeviceInfo` |
|
||||
| **Static references** | 1 across 1 file (excluding its own definition) |
|
||||
| **Init/Cleanup** | Constructor reads `/sys/class/dmi/id/` files |
|
||||
| **DI Registration** | `AddSingleton<IDeviceInfo>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `DeviceInfoService.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration
|
||||
|
||||
**Notes:** No dependencies on other singletons. Simplest migration candidate.
|
||||
|
||||
---
|
||||
|
||||
### 10. MonitorService
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/MonitorService.cs` |
|
||||
| **Pattern** | Double-checked locking with `lock (_lock)` |
|
||||
| **Interface** | `IDisposable` |
|
||||
| **Static references** | 2 across 2 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | Lazy initialization via `EnsureInitialized()`; opens X11 display; `Dispose()` closes display |
|
||||
| **DI Registration** | `AddSingleton<MonitorService>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `MonitorService.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration (wrapping Instance)
|
||||
- `Services/DeviceDisplayService.cs` (1) -- accesses `PrimaryMonitor`
|
||||
|
||||
**Notes:** Referenced by `DeviceDisplayService` internally. Both should be migrated together, with
|
||||
`MonitorService` injected into `DeviceDisplayService` via constructor.
|
||||
|
||||
---
|
||||
|
||||
### 11. LinuxDispatcherProvider
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Dispatching/LinuxDispatcherProvider.cs` |
|
||||
| **Pattern** | `_instance ?? (_instance = new ...)` (not thread-safe) |
|
||||
| **Interface** | `IDispatcherProvider` |
|
||||
| **Static references** | 2 across 2 files (excluding its own definition) |
|
||||
| **Init/Cleanup** | None |
|
||||
| **DI Registration** | `AddSingleton<IDispatcherProvider>` |
|
||||
| **Difficulty** | **Easy** |
|
||||
|
||||
**Files referencing `LinuxDispatcherProvider.Instance`:**
|
||||
- `Hosting/LinuxMauiAppBuilderExtensions.cs` (1) -- DI registration
|
||||
- `LinuxApplication.Lifecycle.cs` (1) -- used during lifecycle setup
|
||||
|
||||
**Notes:** Minimal state, no initialization. Trivial to migrate.
|
||||
|
||||
---
|
||||
|
||||
## Services That SHOULD Remain Static
|
||||
|
||||
### DiagnosticLog
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| **File** | `Services/DiagnosticLog.cs` |
|
||||
| **Pattern** | Pure static class (not a singleton Instance pattern) |
|
||||
| **References** | 518 occurrences across 74 files |
|
||||
|
||||
**Rationale for keeping static:** `DiagnosticLog` is used pervasively throughout the entire codebase,
|
||||
including inside constructors of the singleton services themselves, during startup before the DI
|
||||
container is built, in native interop code, in static factory methods, and in exception handlers.
|
||||
Converting it to an injected dependency would require threading a logger parameter through every
|
||||
class in the project and would create circular dependency issues (services log during their own
|
||||
construction). It is a cross-cutting concern that is appropriately static.
|
||||
|
||||
---
|
||||
|
||||
## Recommended DI Registrations
|
||||
|
||||
Replace the current registration block in `Hosting/LinuxMauiAppBuilderExtensions.cs` with:
|
||||
|
||||
```csharp
|
||||
// Dispatcher
|
||||
builder.Services.TryAddSingleton<IDispatcherProvider, LinuxDispatcherProvider>();
|
||||
|
||||
// Device services (no inter-dependencies)
|
||||
builder.Services.TryAddSingleton<IDeviceInfo, DeviceInfoService>();
|
||||
builder.Services.TryAddSingleton<IConnectivity, ConnectivityService>();
|
||||
|
||||
// Theme and monitor services
|
||||
builder.Services.TryAddSingleton<SystemThemeService>();
|
||||
builder.Services.TryAddSingleton<MonitorService>();
|
||||
|
||||
// Services with dependencies on theme/monitor
|
||||
builder.Services.TryAddSingleton<IAppInfo, AppInfoService>(); // depends on SystemThemeService
|
||||
builder.Services.TryAddSingleton<IDeviceDisplay, DeviceDisplayService>(); // depends on MonitorService
|
||||
|
||||
// Factory-created services
|
||||
builder.Services.TryAddSingleton<IAccessibilityService>(sp =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = new AtSpi2AccessibilityService();
|
||||
service.Initialize();
|
||||
return service;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new NullAccessibilityService();
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.TryAddSingleton<IInputMethodService>(sp =>
|
||||
InputMethodServiceFactory.CreateService());
|
||||
|
||||
// Infrastructure services
|
||||
builder.Services.TryAddSingleton<FontFallbackManager>();
|
||||
builder.Services.TryAddSingleton<GtkHostService>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Priority by Difficulty
|
||||
|
||||
### Easy (change registration + remove static Instance; few or no call-site changes)
|
||||
|
||||
1. **DeviceInfoService** -- zero non-registration references; no dependencies
|
||||
2. **ConnectivityService** -- zero non-registration references; implements `IDisposable`
|
||||
3. **LinuxDispatcherProvider** -- one non-registration reference in `LinuxApplication.Lifecycle.cs`
|
||||
4. **DeviceDisplayService** -- zero non-registration references; depends on `MonitorService`
|
||||
5. **MonitorService** -- one non-registration reference (in `DeviceDisplayService`); migrate together with #4
|
||||
6. **AppInfoService** -- zero non-registration references; depends on `SystemThemeService`
|
||||
7. **AccessibilityServiceFactory** -- one non-registration reference; factory pattern
|
||||
|
||||
### Medium (requires updating view/rendering classes to accept injected dependencies)
|
||||
|
||||
8. **SystemThemeService** -- two non-registration references in `LinuxApplication.Lifecycle.cs`; also depended on by `AppInfoService`
|
||||
9. **FontFallbackManager** -- three non-registration references in view/rendering code
|
||||
10. **InputMethodServiceFactory** -- two non-registration references in view code (`SkiaEditor`, `SkiaEntry`)
|
||||
|
||||
### Hard (requires refactoring the application startup pipeline)
|
||||
|
||||
11. **GtkHostService** -- seven non-registration references across application core, startup host, and handler code; initialized before DI container is built
|
||||
|
||||
---
|
||||
|
||||
## Migration Example: DeviceInfoService (Before/After)
|
||||
|
||||
### Before
|
||||
|
||||
**Services/DeviceInfoService.cs:**
|
||||
```csharp
|
||||
public class DeviceInfoService : IDeviceInfo
|
||||
{
|
||||
private static readonly Lazy<DeviceInfoService> _instance =
|
||||
new Lazy<DeviceInfoService>(() => new DeviceInfoService());
|
||||
|
||||
public static DeviceInfoService Instance => _instance.Value;
|
||||
|
||||
public DeviceInfoService()
|
||||
{
|
||||
LoadDeviceInfo();
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Hosting/LinuxMauiAppBuilderExtensions.cs:**
|
||||
```csharp
|
||||
builder.Services.TryAddSingleton<IDeviceInfo>(DeviceInfoService.Instance);
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
**Services/DeviceInfoService.cs:**
|
||||
```csharp
|
||||
public class DeviceInfoService : IDeviceInfo
|
||||
{
|
||||
// Remove: private static readonly Lazy<DeviceInfoService> _instance = ...
|
||||
// Remove: public static DeviceInfoService Instance => ...
|
||||
|
||||
public DeviceInfoService()
|
||||
{
|
||||
LoadDeviceInfo();
|
||||
}
|
||||
// ... (rest unchanged)
|
||||
}
|
||||
```
|
||||
|
||||
**Hosting/LinuxMauiAppBuilderExtensions.cs:**
|
||||
```csharp
|
||||
builder.Services.TryAddSingleton<IDeviceInfo, DeviceInfoService>();
|
||||
```
|
||||
|
||||
No other files reference `DeviceInfoService.Instance`, so no further changes are needed. The DI
|
||||
container will construct the instance lazily on first resolution and manage its lifetime.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph for Migration Order
|
||||
|
||||
```
|
||||
DeviceInfoService (no deps) -- migrate first
|
||||
ConnectivityService (no deps)
|
||||
LinuxDispatcherProvider (no deps)
|
||||
MonitorService (no deps)
|
||||
\--> DeviceDisplayService (depends on MonitorService)
|
||||
SystemThemeService (no deps)
|
||||
\--> AppInfoService (depends on SystemThemeService)
|
||||
FontFallbackManager (no deps, but used by views)
|
||||
InputMethodServiceFactory (no deps, but used by views)
|
||||
AccessibilityServiceFactory (no deps)
|
||||
GtkHostService (no deps, but used before DI is built) -- migrate last
|
||||
```
|
||||
248
docs/THREADING.md
Normal file
248
docs/THREADING.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Threading Model and Native Handle Ownership
|
||||
|
||||
This document describes the threading rules, event loop architecture, and native
|
||||
resource management patterns used in the maui-linux project.
|
||||
|
||||
## Overview
|
||||
|
||||
maui-linux uses a **single-threaded UI model** inherited from GTK. All UI
|
||||
operations -- rendering, layout, input handling, and widget mutation -- must
|
||||
execute on a single designated thread called the *GTK thread*. Background work
|
||||
is permitted, but its results must be marshaled back to the GTK thread before
|
||||
touching any UI state.
|
||||
|
||||
## The GTK Thread
|
||||
|
||||
At startup, `LinuxApplication.Run` initializes the dispatcher and records the
|
||||
current managed thread ID:
|
||||
|
||||
```csharp
|
||||
// LinuxApplication.cs
|
||||
private static int _gtkThreadId;
|
||||
|
||||
private static void StartHeartbeat()
|
||||
{
|
||||
_gtkThreadId = Environment.CurrentManagedThreadId;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
// LinuxDispatcher.cs
|
||||
public static void Initialize()
|
||||
{
|
||||
_mainThreadId = Environment.CurrentManagedThreadId;
|
||||
_mainDispatcher = new LinuxDispatcher();
|
||||
}
|
||||
```
|
||||
|
||||
Both `_gtkThreadId` (in `LinuxApplication`) and `_mainThreadId` (in
|
||||
`LinuxDispatcher`) capture the same thread -- the one that will run the event
|
||||
loop. Diagnostic logging in `LogInvalidate` and `LogRequestRedraw` warns when
|
||||
UI work is attempted from any other thread.
|
||||
|
||||
## Thread Marshaling
|
||||
|
||||
There are two mechanisms to marshal work onto the GTK thread.
|
||||
|
||||
### 1. `GLibNative.IdleAdd` / `GLibNative.TimeoutAdd` (low-level)
|
||||
|
||||
These wrap `g_idle_add` and `g_timeout_add` from GLib. The callback runs on the
|
||||
next iteration of the GTK main loop. Returning `true` reschedules the callback;
|
||||
returning `false` removes it.
|
||||
|
||||
```csharp
|
||||
// Marshal a one-shot action to the GTK thread
|
||||
GLibNative.IdleAdd(() =>
|
||||
{
|
||||
// Runs on the GTK thread
|
||||
RequestRedrawInternal();
|
||||
return false; // do not repeat
|
||||
});
|
||||
|
||||
// Schedule a recurring action every 250 ms
|
||||
GLibNative.TimeoutAdd(250, () =>
|
||||
{
|
||||
// heartbeat logic
|
||||
return true; // keep repeating
|
||||
});
|
||||
```
|
||||
|
||||
`GLibNative` prevents the delegate from being garbage-collected by storing it
|
||||
in a static `_callbacks` list (protected by `_callbackLock`). The wrapper is
|
||||
removed from the list when the callback returns `false`.
|
||||
|
||||
`GtkNative` has its own `IdleAdd` overload with the same prevent-GC pattern
|
||||
(via `_idleCallbacks`). Prefer the `GLibNative` versions for new code -- they
|
||||
have proper locking and error logging.
|
||||
|
||||
### 2. `LinuxDispatcher` (high-level, MAUI-compatible)
|
||||
|
||||
`LinuxDispatcher` implements `IDispatcher` so that MAUI's `Dispatcher.Dispatch`
|
||||
works correctly on Linux.
|
||||
|
||||
```csharp
|
||||
// From any thread:
|
||||
dispatcher.Dispatch(() =>
|
||||
{
|
||||
// Runs on the GTK thread via GLibNative.IdleAdd
|
||||
});
|
||||
|
||||
dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(500), () =>
|
||||
{
|
||||
// Runs on the GTK thread after a 500 ms delay via GLibNative.TimeoutAdd
|
||||
});
|
||||
```
|
||||
|
||||
If `Dispatch` is called from the GTK thread, the action runs **synchronously**
|
||||
(no round-trip through the event loop). `DispatchDelayed` always goes through
|
||||
`TimeoutAdd`, regardless of calling thread.
|
||||
|
||||
`LinuxDispatcherTimer` wraps `GLibNative.TimeoutAdd` to implement
|
||||
`IDispatcherTimer`. Stopping a timer calls `GLibNative.SourceRemove` to cancel
|
||||
the GLib source.
|
||||
|
||||
## Event Loop
|
||||
|
||||
The application supports two event-loop modes, selected by
|
||||
`LinuxApplicationOptions.UseGtk`.
|
||||
|
||||
### X11 Mode (`RunX11`)
|
||||
|
||||
A manual polling loop on the main thread:
|
||||
|
||||
```
|
||||
while (_mainWindow.IsRunning)
|
||||
{
|
||||
_mainWindow.ProcessEvents(); // drain X11 event queue
|
||||
SkiaWebView.ProcessGtkEvents(); // pump GTK for WebView support
|
||||
UpdateAnimations();
|
||||
Render();
|
||||
Thread.Sleep(1); // yield CPU
|
||||
}
|
||||
```
|
||||
|
||||
X11 events are read with `XNextEvent` / `XPending`. GTK is pre-initialized
|
||||
(`gtk_init_check`) so that WebView (WebKitGTK) works, but GTK does not own the
|
||||
main loop. GTK events are drained cooperatively via `ProcessGtkEvents`.
|
||||
|
||||
### GTK Mode (`RunGtk`)
|
||||
|
||||
GTK owns the main loop:
|
||||
|
||||
```csharp
|
||||
StartHeartbeat(); // records _gtkThreadId, starts 250 ms heartbeat
|
||||
PerformGtkLayout(w, h);
|
||||
_gtkWindow.RequestRedraw();
|
||||
_gtkWindow.Run(); // calls gtk_main() -- blocks until quit
|
||||
GtkHostService.Instance.Shutdown();
|
||||
```
|
||||
|
||||
All rendering is driven by GTK draw callbacks. `GLibNative.IdleAdd` and
|
||||
`TimeoutAdd` integrate naturally because GTK's main loop processes GLib
|
||||
sources.
|
||||
|
||||
## Native Handle Ownership
|
||||
|
||||
### Raw `IntPtr` Handles (current codebase)
|
||||
|
||||
Most native resources are currently held as raw `IntPtr` fields. The owning
|
||||
class implements `IDisposable` and frees resources in `Dispose(bool)`.
|
||||
|
||||
**X11 resources** (`X11Window`):
|
||||
|
||||
| Resource | Acquire | Release |
|
||||
|---|---|---|
|
||||
| `Display*` | `XOpenDisplay` | `XCloseDisplay` |
|
||||
| `Window` | `XCreateSimpleWindow` | `XDestroyWindow` |
|
||||
| `Cursor` | `XCreateFontCursor` | `XFreeCursor` |
|
||||
| `XImage*` | `XCreateImage` | `XDestroyImage` (also frees pixel buffer) |
|
||||
|
||||
Release order matters: cursors must be freed **before** the display is closed,
|
||||
and the window must be destroyed before the display is closed.
|
||||
|
||||
**GObject resources** (`GtkHostWindow` and others):
|
||||
|
||||
| Resource | Acquire | Release |
|
||||
|---|---|---|
|
||||
| GTK widget | `gtk_window_new`, `gtk_drawing_area_new`, etc. | `gtk_widget_destroy` |
|
||||
| GdkPixbuf | `gdk_pixbuf_new_from_file` | `g_object_unref` |
|
||||
| GtkCssProvider | `gtk_css_provider_new` | `g_object_unref` |
|
||||
|
||||
GObject uses **reference counting**. `g_object_unref` decrements the ref
|
||||
count; when it reaches zero the object is freed. If you receive an object with
|
||||
a "floating" reference (common for newly created GTK widgets), adding it to a
|
||||
container sinks the reference -- the container then owns it. Only call
|
||||
`g_object_unref` on objects you have explicitly ref'd or that you own.
|
||||
|
||||
### Signal Connection / Disconnection
|
||||
|
||||
GTK signals are connected with `g_signal_connect_data` and return a `ulong`
|
||||
handler ID. Signals **must** be disconnected before the widget is destroyed,
|
||||
or the pointers backing the managed delegates become dangling.
|
||||
|
||||
```csharp
|
||||
// Connect (store the handler ID)
|
||||
_deleteSignalId = GtkNative.g_signal_connect_data(
|
||||
_window, "delete-event",
|
||||
Marshal.GetFunctionPointerForDelegate(_deleteEventHandler),
|
||||
IntPtr.Zero, IntPtr.Zero, 0);
|
||||
|
||||
// Disconnect (in Dispose, before gtk_widget_destroy)
|
||||
if (_deleteSignalId != 0)
|
||||
GtkNative.g_signal_handler_disconnect(_window, _deleteSignalId);
|
||||
```
|
||||
|
||||
Keep the delegate instance alive for as long as the signal is connected (store
|
||||
it as a field). If the GC collects the delegate while the signal is still
|
||||
connected, GTK will call into freed memory.
|
||||
|
||||
### SafeHandle Wrappers (for new code)
|
||||
|
||||
`Native/SafeHandles.cs` provides `SafeHandle`-derived wrappers that automate
|
||||
release:
|
||||
|
||||
| Type | Releases via |
|
||||
|---|---|
|
||||
| `SafeGtkWidgetHandle` | `gtk_widget_destroy` |
|
||||
| `SafeGObjectHandle` | `g_object_unref` |
|
||||
| `SafeX11DisplayHandle` | `XCloseDisplay` |
|
||||
| `SafeX11CursorHandle` | `XFreeCursor` (requires display ptr at construction) |
|
||||
| `SafeCssProviderHandle` | `g_object_unref` |
|
||||
|
||||
New code should prefer these over raw `IntPtr` where practical. They guarantee
|
||||
release even if `Dispose` is not called (via the CLR release mechanism),
|
||||
and they prevent double-free by tracking validity.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **UI work off the GTK thread.** Any call to GTK or X11 APIs, or mutation of
|
||||
`SkiaView` state, from a background thread is undefined behavior. Always
|
||||
marshal with `GLibNative.IdleAdd` or `LinuxDispatcher.Dispatch`. The
|
||||
diagnostic warnings from `LogInvalidate` / `LogRequestRedraw` exist to catch
|
||||
this -- do not ignore them.
|
||||
|
||||
2. **Delegate collection during signal connection.** A `GSourceFunc` or signal
|
||||
callback delegate passed to native code must be stored in a field or a static
|
||||
list for its entire connected lifetime. `GLibNative._callbacks` and
|
||||
`GtkNative._idleCallbacks` exist for this purpose.
|
||||
|
||||
3. **Reentrancy in `RequestRedraw`.** The `_isRedrawing` flag guards against
|
||||
recursive invalidation. Calling `RequestRedraw` from within a render
|
||||
callback is safe (it will be a no-op), but scheduling further idle callbacks
|
||||
from within an idle callback can lead to unbounded queue growth if not gated.
|
||||
|
||||
4. **X11 resource release ordering.** Cursors and windows depend on the display
|
||||
connection. Always free them before calling `XCloseDisplay`. The
|
||||
`X11Window.Dispose` method shows the correct order:
|
||||
cursors first, then the window, then the display.
|
||||
|
||||
5. **`XDestroyImage` frees the pixel buffer.** When you call `XCreateImage`
|
||||
with a pointer to pixel data, `XDestroyImage` will free that memory. If you
|
||||
allocated the buffer with `Marshal.AllocHGlobal`, do **not** free it yourself
|
||||
after `XDestroyImage`. If `XCreateImage` fails, you **must** free it
|
||||
yourself.
|
||||
|
||||
6. **`Thread.Sleep(1)` in X11 mode.** The X11 event loop uses a 1 ms sleep to
|
||||
yield CPU. This limits frame rate and increases input latency compared to
|
||||
GTK mode. Be aware of this when profiling rendering performance.
|
||||
430
tests/Views/LayoutIntegrationTests.cs
Normal file
430
tests/Views/LayoutIntegrationTests.cs
Normal file
@@ -0,0 +1,430 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Microsoft.Maui.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.Maui.Controls.Linux.Tests.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal concrete SkiaView that uses base MeasureOverride (respects WidthRequest/HeightRequest).
|
||||
/// SkiaLabel overrides MeasureOverride to use font metrics, so we need this for layout tests.
|
||||
/// </summary>
|
||||
internal class TestView : SkiaView
|
||||
{
|
||||
protected override void OnDraw(SkiaSharp.SKCanvas canvas, SkiaSharp.SKRect bounds) { }
|
||||
}
|
||||
|
||||
#region StackLayoutIntegrationTests
|
||||
|
||||
public class StackLayoutIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void VerticalStack_MeasuresChildrenSequentially()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var child1 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
var child2 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
var child3 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
// Act
|
||||
var size = stack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - 3 children each 30px tall => total height >= 90
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalStack_MeasuresChildrenSideToSide()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Horizontal
|
||||
};
|
||||
|
||||
var child1 = new TestView { WidthRequest = 50, HeightRequest = 30 };
|
||||
var child2 = new TestView { WidthRequest = 50, HeightRequest = 30 };
|
||||
var child3 = new TestView { WidthRequest = 50, HeightRequest = 30 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
// Act
|
||||
var size = stack.Measure(new Size(600, 400));
|
||||
|
||||
// Assert - 3 children each 50px wide => total width >= 150
|
||||
size.Width.Should().BeGreaterThanOrEqualTo(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalStack_ArrangePositionsChildrenVertically()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var child1 = new TestView { HeightRequest = 40, WidthRequest = 100 };
|
||||
var child2 = new TestView { HeightRequest = 40, WidthRequest = 100 };
|
||||
var child3 = new TestView { HeightRequest = 40, WidthRequest = 100 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
stack.Measure(new Size(400, 600));
|
||||
|
||||
// Act
|
||||
stack.Arrange(new Rect(0, 0, 400, 600));
|
||||
|
||||
// Assert - children should have increasing Y positions
|
||||
child2.Bounds.Top.Should().BeGreaterThan(child1.Bounds.Top);
|
||||
child3.Bounds.Top.Should().BeGreaterThan(child2.Bounds.Top);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalStack_ArrangePositionsChildrenHorizontally()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Horizontal
|
||||
};
|
||||
|
||||
var child1 = new TestView { WidthRequest = 60, HeightRequest = 30 };
|
||||
var child2 = new TestView { WidthRequest = 60, HeightRequest = 30 };
|
||||
var child3 = new TestView { WidthRequest = 60, HeightRequest = 30 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
stack.Measure(new Size(600, 400));
|
||||
|
||||
// Act
|
||||
stack.Arrange(new Rect(0, 0, 600, 400));
|
||||
|
||||
// Assert - children should have increasing X positions
|
||||
child2.Bounds.Left.Should().BeGreaterThan(child1.Bounds.Left);
|
||||
child3.Bounds.Left.Should().BeGreaterThan(child2.Bounds.Left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedStacks_MeasureCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var outerStack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var innerStack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Horizontal
|
||||
};
|
||||
|
||||
var innerChild1 = new TestView { WidthRequest = 50, HeightRequest = 30 };
|
||||
var innerChild2 = new TestView { WidthRequest = 50, HeightRequest = 30 };
|
||||
|
||||
innerStack.AddChild(innerChild1);
|
||||
innerStack.AddChild(innerChild2);
|
||||
|
||||
var outerChild = new TestView { WidthRequest = 100, HeightRequest = 40 };
|
||||
|
||||
outerStack.AddChild(innerStack);
|
||||
outerStack.AddChild(outerChild);
|
||||
|
||||
// Act
|
||||
var size = outerStack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - should measure without error and produce positive size
|
||||
size.Width.Should().BeGreaterThan(0);
|
||||
size.Height.Should().BeGreaterThan(0);
|
||||
// Height should include both the inner stack's height and the outer child
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(30 + 40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvisibleChildren_ExcludedFromLayout()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var child1 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
var child2 = new TestView { HeightRequest = 30, WidthRequest = 100, IsVisible = false };
|
||||
var child3 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
// Act
|
||||
var size = stack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - only 2 visible children, so height should reflect only 2 x 30 = 60
|
||||
// The invisible child should not contribute to measured height
|
||||
size.Height.Should().BeLessThan(90);
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Spacing_AppliedBetweenVisibleChildrenOnly()
|
||||
{
|
||||
// Arrange - 3 children with spacing=10, middle one invisible
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical,
|
||||
Spacing = 10
|
||||
};
|
||||
|
||||
var child1 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
var child2 = new TestView { HeightRequest = 30, WidthRequest = 100, IsVisible = false };
|
||||
var child3 = new TestView { HeightRequest = 30, WidthRequest = 100 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
stack.AddChild(child3);
|
||||
|
||||
// Act
|
||||
var size = stack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - 2 visible children with 1 gap of spacing=10
|
||||
// Expected: 30 + 10 + 30 = 70
|
||||
size.Height.Should().Be(70);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GridIntegrationTests
|
||||
|
||||
public class GridIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SingleCell_MeasuresChild()
|
||||
{
|
||||
// Arrange
|
||||
var grid = new SkiaGrid();
|
||||
grid.RowDefinitions.Add(Microsoft.Maui.Platform.GridLength.Auto);
|
||||
grid.ColumnDefinitions.Add(Microsoft.Maui.Platform.GridLength.Auto);
|
||||
|
||||
var button = new TestView { WidthRequest = 80, HeightRequest = 40 };
|
||||
grid.AddChild(button, row: 0, column: 0);
|
||||
|
||||
// Act
|
||||
var size = grid.Measure(new Size(400, 300));
|
||||
|
||||
// Assert - grid should measure to at least the child's requested size
|
||||
size.Width.Should().BeGreaterThanOrEqualTo(80);
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoRows_ArrangePositionsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var grid = new SkiaGrid();
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(50));
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(50));
|
||||
grid.ColumnDefinitions.Add(Microsoft.Maui.Platform.GridLength.Star);
|
||||
|
||||
var child1 = new TestView { WidthRequest = 100, HeightRequest = 30 };
|
||||
var child2 = new TestView { WidthRequest = 100, HeightRequest = 30 };
|
||||
|
||||
grid.AddChild(child1, row: 0, column: 0);
|
||||
grid.AddChild(child2, row: 1, column: 0);
|
||||
|
||||
grid.Measure(new Size(400, 300));
|
||||
|
||||
// Act
|
||||
grid.Arrange(new Rect(0, 0, 400, 300));
|
||||
|
||||
// Assert - second row child should be at Y >= 50
|
||||
child2.Bounds.Top.Should().BeGreaterThanOrEqualTo(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoColumns_ArrangePositionsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var grid = new SkiaGrid();
|
||||
grid.RowDefinitions.Add(Microsoft.Maui.Platform.GridLength.Star);
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength(100));
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength(100));
|
||||
|
||||
var child1 = new TestView { WidthRequest = 80, HeightRequest = 30 };
|
||||
var child2 = new TestView { WidthRequest = 80, HeightRequest = 30 };
|
||||
|
||||
grid.AddChild(child1, row: 0, column: 0);
|
||||
grid.AddChild(child2, row: 0, column: 1);
|
||||
|
||||
grid.Measure(new Size(400, 300));
|
||||
|
||||
// Act
|
||||
grid.Arrange(new Rect(0, 0, 400, 300));
|
||||
|
||||
// Assert - second column child should be at X >= 100
|
||||
child2.Bounds.Left.Should().BeGreaterThanOrEqualTo(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyGrid_MeasuresWithDefinitions()
|
||||
{
|
||||
// Arrange
|
||||
var grid = new SkiaGrid();
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(60));
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(40));
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength(120));
|
||||
grid.ColumnDefinitions.Add(new Microsoft.Maui.Platform.GridLength(80));
|
||||
|
||||
// Act - no children added
|
||||
var size = grid.Measure(new Size(400, 300));
|
||||
|
||||
// Assert - should still measure based on definitions
|
||||
// Width = 120 + 80 = 200, Height = 60 + 40 = 100
|
||||
size.Width.Should().BeGreaterThanOrEqualTo(200);
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowSpacing_IncludedInMeasure()
|
||||
{
|
||||
// Arrange
|
||||
var grid = new SkiaGrid { RowSpacing = 20 };
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(50));
|
||||
grid.RowDefinitions.Add(new Microsoft.Maui.Platform.GridLength(50));
|
||||
grid.ColumnDefinitions.Add(Microsoft.Maui.Platform.GridLength.Star);
|
||||
|
||||
var child1 = new TestView { WidthRequest = 100, HeightRequest = 30 };
|
||||
var child2 = new TestView { WidthRequest = 100, HeightRequest = 30 };
|
||||
|
||||
grid.AddChild(child1, row: 0, column: 0);
|
||||
grid.AddChild(child2, row: 1, column: 0);
|
||||
|
||||
// Act
|
||||
var size = grid.Measure(new Size(400, 300));
|
||||
|
||||
// Assert - height should include row spacing: 50 + 20 + 50 = 120
|
||||
size.Height.Should().BeGreaterThanOrEqualTo(120);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MeasureArrangePipelineTests
|
||||
|
||||
public class MeasureArrangePipelineTests
|
||||
{
|
||||
[Fact]
|
||||
public void Measure_PropagatesConstraints()
|
||||
{
|
||||
// Arrange - parent with padding subtracts from available space
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical,
|
||||
Padding = new Thickness(20)
|
||||
};
|
||||
|
||||
var child = new TestView { WidthRequest = 500, HeightRequest = 50 };
|
||||
stack.AddChild(child);
|
||||
|
||||
// Act
|
||||
stack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - child's DesiredSize should reflect WidthRequest/HeightRequest
|
||||
child.DesiredSize.Width.Should().Be(500);
|
||||
child.DesiredSize.Height.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arrange_SetsBoundsOnChildren()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var child1 = new TestView { WidthRequest = 100, HeightRequest = 40 };
|
||||
var child2 = new TestView { WidthRequest = 100, HeightRequest = 40 };
|
||||
|
||||
stack.AddChild(child1);
|
||||
stack.AddChild(child2);
|
||||
|
||||
stack.Measure(new Size(400, 600));
|
||||
|
||||
// Act
|
||||
stack.Arrange(new Rect(0, 0, 400, 600));
|
||||
|
||||
// Assert - children should have non-zero bounds after arrange
|
||||
child1.Bounds.Width.Should().BeGreaterThan(0);
|
||||
child1.Bounds.Height.Should().BeGreaterThan(0);
|
||||
child2.Bounds.Width.Should().BeGreaterThan(0);
|
||||
child2.Bounds.Height.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateMeasure_CausesMeasureRecalculation()
|
||||
{
|
||||
// Arrange
|
||||
var stack = new SkiaStackLayout
|
||||
{
|
||||
Orientation = Microsoft.Maui.Platform.StackOrientation.Vertical
|
||||
};
|
||||
|
||||
var child = new TestView { WidthRequest = 100, HeightRequest = 30 };
|
||||
stack.AddChild(child);
|
||||
|
||||
// First measure
|
||||
var size1 = stack.Measure(new Size(400, 600));
|
||||
|
||||
// Act - change child property and invalidate
|
||||
child.HeightRequest = 60;
|
||||
child.InvalidateMeasure();
|
||||
|
||||
// Re-measure
|
||||
var size2 = stack.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - new measured size should reflect the change
|
||||
size2.Height.Should().BeGreaterThan(size1.Height);
|
||||
size2.Height.Should().BeGreaterThanOrEqualTo(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesiredSize_SetAfterMeasure()
|
||||
{
|
||||
// Arrange
|
||||
var view = new TestView { WidthRequest = 120, HeightRequest = 45 };
|
||||
|
||||
// Before measure, DesiredSize should be zero
|
||||
view.DesiredSize.Should().Be(Size.Zero);
|
||||
|
||||
// Act
|
||||
var measured = view.Measure(new Size(400, 600));
|
||||
|
||||
// Assert - DesiredSize should reflect the measured size
|
||||
view.DesiredSize.Width.Should().Be(measured.Width);
|
||||
view.DesiredSize.Height.Should().Be(measured.Height);
|
||||
view.DesiredSize.Width.Should().BeGreaterThan(0);
|
||||
view.DesiredSize.Height.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
201
tests/Views/SkiaViewTheoryTests.cs
Normal file
201
tests/Views/SkiaViewTheoryTests.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Maui.Graphics;
|
||||
using Microsoft.Maui.Platform;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.Maui.Controls.Linux.Tests.Views;
|
||||
|
||||
public class SkiaLabelTheoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(FontAttributes.None)]
|
||||
[InlineData(FontAttributes.Bold)]
|
||||
[InlineData(FontAttributes.Italic)]
|
||||
[InlineData(FontAttributes.Bold | FontAttributes.Italic)]
|
||||
public void FontAttributes_AllValues_CanBeSet(FontAttributes attributes)
|
||||
{
|
||||
// Arrange
|
||||
var label = new SkiaLabel();
|
||||
|
||||
// Act
|
||||
label.FontAttributes = attributes;
|
||||
|
||||
// Assert
|
||||
label.FontAttributes.Should().Be(attributes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TextAlignment.Start)]
|
||||
[InlineData(TextAlignment.Center)]
|
||||
[InlineData(TextAlignment.End)]
|
||||
public void HorizontalTextAlignment_AllValues_CanBeSet(TextAlignment alignment)
|
||||
{
|
||||
// Arrange
|
||||
var label = new SkiaLabel();
|
||||
|
||||
// Act
|
||||
label.HorizontalTextAlignment = alignment;
|
||||
|
||||
// Assert
|
||||
label.HorizontalTextAlignment.Should().Be(alignment);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TextAlignment.Start)]
|
||||
[InlineData(TextAlignment.Center)]
|
||||
[InlineData(TextAlignment.End)]
|
||||
public void VerticalTextAlignment_AllValues_CanBeSet(TextAlignment alignment)
|
||||
{
|
||||
// Arrange
|
||||
var label = new SkiaLabel();
|
||||
|
||||
// Act
|
||||
label.VerticalTextAlignment = alignment;
|
||||
|
||||
// Assert
|
||||
label.VerticalTextAlignment.Should().Be(alignment);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TextDecorations.None)]
|
||||
[InlineData(TextDecorations.Underline)]
|
||||
[InlineData(TextDecorations.Strikethrough)]
|
||||
public void TextDecorations_AllValues_CanBeSet(TextDecorations decorations)
|
||||
{
|
||||
// Arrange
|
||||
var label = new SkiaLabel();
|
||||
|
||||
// Act
|
||||
label.TextDecorations = decorations;
|
||||
|
||||
// Assert
|
||||
label.TextDecorations.Should().Be(decorations);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LineBreakMode.NoWrap)]
|
||||
[InlineData(LineBreakMode.WordWrap)]
|
||||
[InlineData(LineBreakMode.CharacterWrap)]
|
||||
[InlineData(LineBreakMode.HeadTruncation)]
|
||||
[InlineData(LineBreakMode.TailTruncation)]
|
||||
[InlineData(LineBreakMode.MiddleTruncation)]
|
||||
public void LineBreakMode_AllValues_CanBeSet(LineBreakMode mode)
|
||||
{
|
||||
// Arrange
|
||||
var label = new SkiaLabel();
|
||||
|
||||
// Act
|
||||
label.LineBreakMode = mode;
|
||||
|
||||
// Assert
|
||||
label.LineBreakMode.Should().Be(mode);
|
||||
}
|
||||
}
|
||||
|
||||
public class SkiaViewVisibilityTheoryTests
|
||||
{
|
||||
public static IEnumerable<object[]> ViewInstances()
|
||||
{
|
||||
yield return new object[] { new SkiaLabel() };
|
||||
yield return new object[] { new SkiaButton() };
|
||||
yield return new object[] { new SkiaCheckBox() };
|
||||
yield return new object[] { new SkiaEntry() };
|
||||
yield return new object[] { new SkiaProgressBar() };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ViewInstances))]
|
||||
public void AllViews_DefaultVisible(SkiaView view)
|
||||
{
|
||||
// Assert
|
||||
view.IsVisible.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ViewInstances))]
|
||||
public void AllViews_DefaultEnabled(SkiaView view)
|
||||
{
|
||||
// Assert
|
||||
view.IsEnabled.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ViewInstances))]
|
||||
public void AllViews_CanBeDisabled(SkiaView view)
|
||||
{
|
||||
// Act
|
||||
view.IsEnabled = false;
|
||||
|
||||
// Assert
|
||||
view.IsEnabled.Should().BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
public class SkiaSliderTheoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, 100, 50, 50)]
|
||||
[InlineData(0, 100, -10, 0)]
|
||||
[InlineData(0, 100, 200, 100)]
|
||||
[InlineData(0, 1, 0.5, 0.5)]
|
||||
[InlineData(10, 20, 15, 15)]
|
||||
public void Value_ClampsToRange(double min, double max, double setValue, double expectedValue)
|
||||
{
|
||||
// Arrange
|
||||
var slider = new SkiaSlider();
|
||||
|
||||
// Always set Maximum before Minimum when Minimum > default (1.0)
|
||||
slider.Maximum = max;
|
||||
slider.Minimum = min;
|
||||
|
||||
// Act
|
||||
slider.Value = setValue;
|
||||
|
||||
// Assert
|
||||
slider.Value.Should().Be(expectedValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class SkiaEntryTextTheoryTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Hello")]
|
||||
[InlineData("Hello World with spaces")]
|
||||
[InlineData("Special chars: !@#$%^&*()")]
|
||||
[InlineData("Unicode: \u3053\u3093\u306B\u3061\u306F")]
|
||||
[InlineData("This is a very long string that exceeds one hundred characters in length to test how the SkiaEntry control handles lengthy text input values properly")]
|
||||
public void Text_VariousStrings_SetCorrectly(string text)
|
||||
{
|
||||
// Arrange
|
||||
var entry = new SkiaEntry();
|
||||
|
||||
// Act
|
||||
entry.Text = text;
|
||||
|
||||
// Assert
|
||||
entry.Text.Should().Be(text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Hello")]
|
||||
[InlineData("Hello World with spaces")]
|
||||
[InlineData("Special chars: !@#$%^&*()")]
|
||||
[InlineData("Unicode: \u3053\u3093\u306B\u3061\u306F")]
|
||||
[InlineData("This is a very long string that exceeds one hundred characters in length to test how the SkiaEntry control handles lengthy placeholder text values properly")]
|
||||
public void Placeholder_VariousStrings_SetCorrectly(string placeholder)
|
||||
{
|
||||
// Arrange
|
||||
var entry = new SkiaEntry();
|
||||
|
||||
// Act
|
||||
entry.Placeholder = placeholder;
|
||||
|
||||
// Assert
|
||||
entry.Placeholder.Should().Be(placeholder);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user