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 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); + } +}