diff --git a/LinuxApplication.cs b/LinuxApplication.cs
index 7a485cf..d8dea04 100644
--- a/LinuxApplication.cs
+++ b/LinuxApplication.cs
@@ -238,6 +238,17 @@ public partial class LinuxApplication : IDisposable
///
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)
{
diff --git a/LinuxApplicationOptions.cs b/LinuxApplicationOptions.cs
index b44bb6c..f12e0b2 100644
--- a/LinuxApplicationOptions.cs
+++ b/LinuxApplicationOptions.cs
@@ -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;
}
diff --git a/Native/SafeHandles.cs b/Native/SafeHandles.cs
new file mode 100644
index 0000000..09571ad
--- /dev/null
+++ b/Native/SafeHandles.cs
@@ -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;
+
+///
+/// Safe handle wrapper for GTK widget pointers.
+/// Releases the widget via gtk_widget_destroy when disposed.
+///
+internal class SafeGtkWidgetHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ [DllImport("libgtk-3.so.0")]
+ private static extern void gtk_widget_destroy(IntPtr widget);
+
+ ///
+ /// Initializes a new that owns the handle.
+ ///
+ public SafeGtkWidgetHandle() : base(ownsHandle: true)
+ {
+ }
+
+ ///
+ /// Initializes a new wrapping an existing pointer.
+ ///
+ /// The existing GTK widget pointer.
+ /// Whether this safe handle is responsible for releasing the resource.
+ public SafeGtkWidgetHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
+ {
+ SetHandle(existingHandle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ gtk_widget_destroy(handle);
+ return true;
+ }
+}
+
+///
+/// Safe handle wrapper for GObject pointers.
+/// Releases the object via g_object_unref when disposed.
+/// Suitable for any GObject-derived type including GtkCssProvider, GdkPixbuf, etc.
+///
+internal class SafeGObjectHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ [DllImport("libgobject-2.0.so.0")]
+ private static extern void g_object_unref(IntPtr obj);
+
+ ///
+ /// Initializes a new that owns the handle.
+ ///
+ public SafeGObjectHandle() : base(ownsHandle: true)
+ {
+ }
+
+ ///
+ /// Initializes a new wrapping an existing pointer.
+ ///
+ /// The existing GObject pointer.
+ /// Whether this safe handle is responsible for releasing the resource.
+ public SafeGObjectHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
+ {
+ SetHandle(existingHandle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ g_object_unref(handle);
+ return true;
+ }
+}
+
+///
+/// Safe handle wrapper for X11 Display* pointers.
+/// Releases the display connection via XCloseDisplay when disposed.
+///
+internal class SafeX11DisplayHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ [DllImport("libX11.so.6")]
+ private static extern int XCloseDisplay(IntPtr display);
+
+ ///
+ /// Initializes a new that owns the handle.
+ ///
+ public SafeX11DisplayHandle() : base(ownsHandle: true)
+ {
+ }
+
+ ///
+ /// Initializes a new wrapping an existing pointer.
+ ///
+ /// The existing X11 Display pointer.
+ /// Whether this safe handle is responsible for releasing the resource.
+ public SafeX11DisplayHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
+ {
+ SetHandle(existingHandle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ XCloseDisplay(handle);
+ return true;
+ }
+}
+
+///
+/// Safe handle wrapper for X11 Cursor resources.
+/// Releases the cursor via XFreeCursor when disposed.
+/// Requires the associated Display* to be provided at construction time,
+/// as X11 cursor cleanup requires both the display and cursor handles.
+///
+internal class SafeX11CursorHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ [DllImport("libX11.so.6")]
+ private static extern int XFreeCursor(IntPtr display, IntPtr cursor);
+
+ private readonly IntPtr _display;
+
+ ///
+ /// Initializes a new that owns the handle.
+ ///
+ ///
+ /// The X11 Display pointer required for releasing the cursor.
+ /// The caller must ensure the display remains valid for the lifetime of this handle.
+ ///
+ public SafeX11CursorHandle(IntPtr display) : base(ownsHandle: true)
+ {
+ _display = display;
+ }
+
+ ///
+ /// Initializes a new wrapping an existing cursor.
+ ///
+ ///
+ /// The X11 Display pointer required for releasing the cursor.
+ /// The caller must ensure the display remains valid for the lifetime of this handle.
+ ///
+ /// The existing X11 Cursor handle.
+ /// Whether this safe handle is responsible for releasing the resource.
+ public SafeX11CursorHandle(IntPtr display, IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
+ {
+ _display = display;
+ SetHandle(existingHandle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ if (_display != IntPtr.Zero)
+ {
+ XFreeCursor(_display, handle);
+ }
+ return true;
+ }
+}
+
+///
+/// Safe handle wrapper for GtkCssProvider* pointers.
+/// Since GtkCssProvider is a GObject, this releases it via g_object_unref when disposed.
+///
+internal class SafeCssProviderHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ [DllImport("libgobject-2.0.so.0")]
+ private static extern void g_object_unref(IntPtr obj);
+
+ ///
+ /// Initializes a new that owns the handle.
+ ///
+ public SafeCssProviderHandle() : base(ownsHandle: true)
+ {
+ }
+
+ ///
+ /// Initializes a new wrapping an existing pointer.
+ ///
+ /// The existing GtkCssProvider pointer.
+ /// Whether this safe handle is responsible for releasing the resource.
+ public SafeCssProviderHandle(IntPtr existingHandle, bool ownsHandle = true) : base(ownsHandle)
+ {
+ SetHandle(existingHandle);
+ }
+
+ ///
+ protected override bool ReleaseHandle()
+ {
+ g_object_unref(handle);
+ return true;
+ }
+}
diff --git a/docs/DI-MIGRATION.md b/docs/DI-MIGRATION.md
new file mode 100644
index 0000000..71bbeb1
--- /dev/null
+++ b/docs/DI-MIGRATION.md
@@ -0,0 +1,408 @@
+
+
+# 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` |
+| **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` |
+| **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()`), 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` |
+| **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` (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(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` (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` |
+| **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` |
+| **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()`. The constructor is already `public`.
+
+---
+
+### 7. DeviceDisplayService
+
+| Property | Value |
+|---|---|
+| **File** | `Services/DeviceDisplayService.cs` |
+| **Pattern** | `Lazy` |
+| **Interface** | `IDeviceDisplay` |
+| **Static references** | 1 across 1 file (excluding its own definition) |
+| **Init/Cleanup** | Constructor calls `RefreshDisplayInfo()`; internally references `MonitorService.Instance` |
+| **DI Registration** | `AddSingleton` |
+| **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` |
+| **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` |
+| **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` |
+| **Interface** | `IDeviceInfo` |
+| **Static references** | 1 across 1 file (excluding its own definition) |
+| **Init/Cleanup** | Constructor reads `/sys/class/dmi/id/` files |
+| **DI Registration** | `AddSingleton` |
+| **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` |
+| **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` |
+| **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();
+
+// Device services (no inter-dependencies)
+builder.Services.TryAddSingleton();
+builder.Services.TryAddSingleton();
+
+// Theme and monitor services
+builder.Services.TryAddSingleton();
+builder.Services.TryAddSingleton();
+
+// Services with dependencies on theme/monitor
+builder.Services.TryAddSingleton(); // depends on SystemThemeService
+builder.Services.TryAddSingleton(); // depends on MonitorService
+
+// Factory-created services
+builder.Services.TryAddSingleton(sp =>
+{
+ try
+ {
+ var service = new AtSpi2AccessibilityService();
+ service.Initialize();
+ return service;
+ }
+ catch
+ {
+ return new NullAccessibilityService();
+ }
+});
+
+builder.Services.TryAddSingleton(sp =>
+ InputMethodServiceFactory.CreateService());
+
+// Infrastructure services
+builder.Services.TryAddSingleton();
+builder.Services.TryAddSingleton();
+```
+
+---
+
+## 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 _instance =
+ new Lazy(() => new DeviceInfoService());
+
+ public static DeviceInfoService Instance => _instance.Value;
+
+ public DeviceInfoService()
+ {
+ LoadDeviceInfo();
+ }
+ // ...
+}
+```
+
+**Hosting/LinuxMauiAppBuilderExtensions.cs:**
+```csharp
+builder.Services.TryAddSingleton(DeviceInfoService.Instance);
+```
+
+### After
+
+**Services/DeviceInfoService.cs:**
+```csharp
+public class DeviceInfoService : IDeviceInfo
+{
+ // Remove: private static readonly Lazy _instance = ...
+ // Remove: public static DeviceInfoService Instance => ...
+
+ public DeviceInfoService()
+ {
+ LoadDeviceInfo();
+ }
+ // ... (rest unchanged)
+}
+```
+
+**Hosting/LinuxMauiAppBuilderExtensions.cs:**
+```csharp
+builder.Services.TryAddSingleton();
+```
+
+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
+```
diff --git a/docs/THREADING.md b/docs/THREADING.md
new file mode 100644
index 0000000..d7dc62f
--- /dev/null
+++ b/docs/THREADING.md
@@ -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.
diff --git a/tests/Views/LayoutIntegrationTests.cs b/tests/Views/LayoutIntegrationTests.cs
new file mode 100644
index 0000000..d30e2a5
--- /dev/null
+++ b/tests/Views/LayoutIntegrationTests.cs
@@ -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;
+
+///
+/// Minimal concrete SkiaView that uses base MeasureOverride (respects WidthRequest/HeightRequest).
+/// SkiaLabel overrides MeasureOverride to use font metrics, so we need this for layout tests.
+///
+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
diff --git a/tests/Views/SkiaViewTheoryTests.cs b/tests/Views/SkiaViewTheoryTests.cs
new file mode 100644
index 0000000..4433502
--- /dev/null
+++ b/tests/Views/SkiaViewTheoryTests.cs
@@ -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