diff --git a/LinuxApplication.Input.cs b/LinuxApplication.Input.cs
new file mode 100644
index 0000000..ad016b6
--- /dev/null
+++ b/LinuxApplication.Input.cs
@@ -0,0 +1,534 @@
+// 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 Microsoft.Maui.Controls;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using Microsoft.Maui.Platform.Linux.Services;
+using Microsoft.Maui.Platform.Linux.Window;
+using Microsoft.Maui.Platform;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform.Linux;
+
+public partial class LinuxApplication
+{
+ private void UpdateAnimations()
+ {
+ // Update cursor blink for text input controls
+ if (_focusedView is SkiaEntry entry)
+ {
+ entry.UpdateCursorBlink();
+ }
+ else if (_focusedView is SkiaEditor editor)
+ {
+ editor.UpdateCursorBlink();
+ }
+ }
+
+ private void OnWindowResized(object? sender, (int Width, int Height) size)
+ {
+ if (_rootView != null)
+ {
+ // Re-measure with new available size, then arrange
+ var availableSize = new Microsoft.Maui.Graphics.Size(size.Width, size.Height);
+ _rootView.Measure(availableSize);
+ _rootView.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, size.Width, size.Height));
+ }
+ _renderingEngine?.InvalidateAll();
+ }
+
+ private void OnWindowExposed(object? sender, EventArgs e)
+ {
+ Render();
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnKeyDown(e);
+ return;
+ }
+
+ if (_focusedView != null)
+ {
+ _focusedView.OnKeyDown(e);
+ }
+ }
+
+ private void OnKeyUp(object? sender, KeyEventArgs e)
+ {
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnKeyUp(e);
+ return;
+ }
+
+ if (_focusedView != null)
+ {
+ _focusedView.OnKeyUp(e);
+ }
+ }
+
+ private void OnTextInput(object? sender, TextInputEventArgs e)
+ {
+ if (_focusedView != null)
+ {
+ _focusedView.OnTextInput(e);
+ }
+ }
+
+ private void OnPointerMoved(object? sender, PointerEventArgs e)
+ {
+ // Route to context menu if one is active
+ if (LinuxDialogService.HasContextMenu)
+ {
+ LinuxDialogService.ActiveContextMenu?.OnPointerMoved(e);
+ return;
+ }
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnPointerMoved(e);
+ return;
+ }
+
+ if (_rootView != null)
+ {
+ // If a view has captured the pointer, send all events to it
+ if (_capturedView != null)
+ {
+ _capturedView.OnPointerMoved(e);
+ return;
+ }
+
+ // Check for popup overlay first
+ var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
+ var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
+
+ // Track hover state changes
+ if (hitView != _hoveredView)
+ {
+ _hoveredView?.OnPointerExited(e);
+ _hoveredView = hitView;
+ _hoveredView?.OnPointerEntered(e);
+
+ // Update cursor based on view's cursor type
+ CursorType cursor = hitView?.CursorType ?? CursorType.Arrow;
+ _mainWindow?.SetCursor(cursor);
+ }
+
+ hitView?.OnPointerMoved(e);
+ }
+ }
+
+ private void OnPointerPressed(object? sender, PointerEventArgs e)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"OnPointerPressed at ({e.X}, {e.Y}), Button={e.Button}");
+
+ // Route to context menu if one is active
+ if (LinuxDialogService.HasContextMenu)
+ {
+ LinuxDialogService.ActiveContextMenu?.OnPointerPressed(e);
+ return;
+ }
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnPointerPressed(e);
+ return;
+ }
+
+ if (_rootView != null)
+ {
+ // Check for popup overlay first
+ var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
+ var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
+ DiagnosticLog.Debug("LinuxApplication", $"HitView: {hitView?.GetType().Name ?? "null"}, rootView: {_rootView.GetType().Name}");
+
+ if (hitView != null)
+ {
+ // Capture pointer to this view for drag operations
+ _capturedView = hitView;
+
+ // Update focus
+ if (hitView.IsFocusable)
+ {
+ FocusedView = hitView;
+ }
+
+ DiagnosticLog.Debug("LinuxApplication", $"Calling OnPointerPressed on {hitView.GetType().Name}");
+ hitView.OnPointerPressed(e);
+ }
+ else
+ {
+ // Close any open popups when clicking outside
+ if (SkiaView.HasActivePopup && _focusedView != null)
+ {
+ _focusedView.OnFocusLost();
+ }
+ FocusedView = null;
+ }
+ }
+ }
+
+ private void OnPointerReleased(object? sender, PointerEventArgs e)
+ {
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnPointerReleased(e);
+ return;
+ }
+
+ if (_rootView != null)
+ {
+ // If a view has captured the pointer, send release to it
+ if (_capturedView != null)
+ {
+ _capturedView.OnPointerReleased(e);
+ _capturedView = null; // Release capture
+ return;
+ }
+
+ // Check for popup overlay first
+ var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
+ var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
+ hitView?.OnPointerReleased(e);
+ }
+ }
+
+ private void OnScroll(object? sender, ScrollEventArgs e)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"OnScroll - X={e.X}, Y={e.Y}, DeltaX={e.DeltaX}, DeltaY={e.DeltaY}");
+ if (_rootView != null)
+ {
+ var hitView = _rootView.HitTest(e.X, e.Y);
+ DiagnosticLog.Debug("LinuxApplication", $"HitView: {hitView?.GetType().Name ?? "null"}");
+ // Bubble scroll events up to find a ScrollView
+ var view = hitView;
+ while (view != null)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Bubbling to: {view.GetType().Name}");
+ if (view is SkiaScrollView scrollView)
+ {
+ scrollView.OnScroll(e);
+ return;
+ }
+ view.OnScroll(e);
+ if (e.Handled) return;
+ view = view.Parent;
+ }
+ }
+ }
+
+ private void OnCloseRequested(object? sender, EventArgs e)
+ {
+ _mainWindow?.Stop();
+ }
+
+ // GTK Event Handlers
+ private void OnGtkDrawRequested(object? sender, EventArgs e)
+ {
+ DiagnosticLog.Debug("LinuxApplication", ">>> OnGtkDrawRequested ENTER");
+ LogDraw();
+ var surface = _gtkWindow?.SkiaSurface;
+ if (surface?.Canvas != null && _rootView != null)
+ {
+ var bgColor = Application.Current?.UserAppTheme == AppTheme.Dark
+ ? new SKColor(32, 33, 36)
+ : SKColors.White;
+ surface.Canvas.Clear(bgColor);
+ DiagnosticLog.Debug("LinuxApplication", "Drawing rootView...");
+ _rootView.Draw(surface.Canvas);
+ DiagnosticLog.Debug("LinuxApplication", "Drawing dialogs...");
+ var bounds = new SKRect(0, 0, surface.Width, surface.Height);
+ LinuxDialogService.DrawDialogs(surface.Canvas, bounds);
+ DiagnosticLog.Debug("LinuxApplication", "<<< OnGtkDrawRequested EXIT");
+ }
+ }
+
+ private void OnGtkResized(object? sender, (int Width, int Height) size)
+ {
+ PerformGtkLayout(size.Width, size.Height);
+ _gtkWindow?.RequestRedraw();
+ }
+
+ private void OnGtkPointerPressed(object? sender, (double X, double Y, int Button) e)
+ {
+ string buttonName = e.Button == 1 ? "Left" : e.Button == 2 ? "Middle" : e.Button == 3 ? "Right" : $"Unknown({e.Button})";
+ DiagnosticLog.Debug("LinuxApplication", $"GTK PointerPressed at ({e.X:F1}, {e.Y:F1}), Button={e.Button} ({buttonName})");
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ LinuxDialogService.TopDialog?.OnPointerPressed(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (LinuxDialogService.HasContextMenu)
+ {
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ LinuxDialogService.ActiveContextMenu?.OnPointerPressed(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (_rootView == null)
+ {
+ DiagnosticLog.Warn("LinuxApplication", "GTK _rootView is null!");
+ return;
+ }
+
+ var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
+ DiagnosticLog.Debug("LinuxApplication", $"GTK HitView: {hitView?.GetType().Name ?? "null"}");
+
+ if (hitView != null)
+ {
+ if (hitView.IsFocusable && _focusedView != hitView)
+ {
+ _focusedView?.OnFocusLost();
+ _focusedView = hitView;
+ _focusedView.OnFocusGained();
+ }
+ _capturedView = hitView;
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ DiagnosticLog.Debug("LinuxApplication", ">>> Before OnPointerPressed");
+ hitView.OnPointerPressed(args);
+ DiagnosticLog.Debug("LinuxApplication", "<<< After OnPointerPressed, calling RequestRedraw");
+ _gtkWindow?.RequestRedraw();
+ DiagnosticLog.Debug("LinuxApplication", "<<< After RequestRedraw, returning from handler");
+ }
+ }
+
+ private void OnGtkPointerReleased(object? sender, (double X, double Y, int Button) e)
+ {
+ DiagnosticLog.Debug("LinuxApplication", ">>> OnGtkPointerReleased ENTER");
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ LinuxDialogService.TopDialog?.OnPointerReleased(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (_rootView == null) return;
+
+ if (_capturedView != null)
+ {
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ DiagnosticLog.Debug("LinuxApplication", $"Calling OnPointerReleased on {_capturedView.GetType().Name}");
+ _capturedView.OnPointerReleased(args);
+ DiagnosticLog.Debug("LinuxApplication", "OnPointerReleased returned");
+ _capturedView = null;
+ _gtkWindow?.RequestRedraw();
+ DiagnosticLog.Debug("LinuxApplication", "<<< OnGtkPointerReleased EXIT (captured path)");
+ }
+ else
+ {
+ var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
+ if (hitView != null)
+ {
+ var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
+ var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
+ hitView.OnPointerReleased(args);
+ _gtkWindow?.RequestRedraw();
+ }
+ }
+ }
+
+ private void OnGtkPointerMoved(object? sender, (double X, double Y) e)
+ {
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ var args = new PointerEventArgs((float)e.X, (float)e.Y);
+ LinuxDialogService.TopDialog?.OnPointerMoved(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (LinuxDialogService.HasContextMenu)
+ {
+ var args = new PointerEventArgs((float)e.X, (float)e.Y);
+ LinuxDialogService.ActiveContextMenu?.OnPointerMoved(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (_rootView == null) return;
+
+ if (_capturedView != null)
+ {
+ var args = new PointerEventArgs((float)e.X, (float)e.Y);
+ _capturedView.OnPointerMoved(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
+ if (hitView != _hoveredView)
+ {
+ var args = new PointerEventArgs((float)e.X, (float)e.Y);
+ _hoveredView?.OnPointerExited(args);
+ _hoveredView = hitView;
+ _hoveredView?.OnPointerEntered(args);
+ _gtkWindow?.RequestRedraw();
+ }
+
+ if (hitView != null)
+ {
+ var args = new PointerEventArgs((float)e.X, (float)e.Y);
+ hitView.OnPointerMoved(args);
+ }
+ }
+
+ private void OnGtkKeyPressed(object? sender, (uint KeyVal, uint KeyCode, uint State) e)
+ {
+ var key = ConvertGdkKey(e.KeyVal);
+ var modifiers = ConvertGdkModifiers(e.State);
+ var args = new KeyEventArgs(key, modifiers);
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnKeyDown(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (_focusedView != null)
+ {
+ _focusedView.OnKeyDown(args);
+ _gtkWindow?.RequestRedraw();
+ }
+ }
+
+ private void OnGtkKeyReleased(object? sender, (uint KeyVal, uint KeyCode, uint State) e)
+ {
+ var key = ConvertGdkKey(e.KeyVal);
+ var modifiers = ConvertGdkModifiers(e.State);
+ var args = new KeyEventArgs(key, modifiers);
+
+ // Route to dialog if one is active
+ if (LinuxDialogService.HasActiveDialog)
+ {
+ LinuxDialogService.TopDialog?.OnKeyUp(args);
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+
+ if (_focusedView != null)
+ {
+ _focusedView.OnKeyUp(args);
+ _gtkWindow?.RequestRedraw();
+ }
+ }
+
+ private void OnGtkScrolled(object? sender, (double X, double Y, double DeltaX, double DeltaY, uint State) e)
+ {
+ if (_rootView == null) return;
+
+ // Convert GDK state to KeyModifiers
+ var modifiers = ConvertGdkStateToModifiers(e.State);
+ bool isCtrlPressed = (modifiers & KeyModifiers.Control) != 0;
+
+ var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
+
+ // Check for pinch gesture (Ctrl+Scroll) first
+ if (isCtrlPressed && hitView?.MauiView != null)
+ {
+ if (Handlers.GestureManager.ProcessScrollAsPinch(hitView.MauiView, e.X, e.Y, e.DeltaY, true))
+ {
+ _gtkWindow?.RequestRedraw();
+ return;
+ }
+ }
+
+ while (hitView != null)
+ {
+ if (hitView is SkiaScrollView scrollView)
+ {
+ var args = new ScrollEventArgs((float)e.X, (float)e.Y, (float)e.DeltaX, (float)e.DeltaY, modifiers);
+ scrollView.OnScroll(args);
+ _gtkWindow?.RequestRedraw();
+ break;
+ }
+ hitView = hitView.Parent;
+ }
+ }
+
+ private static KeyModifiers ConvertGdkStateToModifiers(uint state)
+ {
+ var modifiers = KeyModifiers.None;
+ // GDK modifier masks
+ const uint GDK_SHIFT_MASK = 1 << 0;
+ const uint GDK_CONTROL_MASK = 1 << 2;
+ const uint GDK_MOD1_MASK = 1 << 3; // Alt
+ const uint GDK_SUPER_MASK = 1 << 26;
+ const uint GDK_LOCK_MASK = 1 << 1; // Caps Lock
+
+ if ((state & GDK_SHIFT_MASK) != 0) modifiers |= KeyModifiers.Shift;
+ if ((state & GDK_CONTROL_MASK) != 0) modifiers |= KeyModifiers.Control;
+ if ((state & GDK_MOD1_MASK) != 0) modifiers |= KeyModifiers.Alt;
+ if ((state & GDK_SUPER_MASK) != 0) modifiers |= KeyModifiers.Super;
+ if ((state & GDK_LOCK_MASK) != 0) modifiers |= KeyModifiers.CapsLock;
+
+ return modifiers;
+ }
+
+ private void OnGtkTextInput(object? sender, string text)
+ {
+ if (_focusedView != null)
+ {
+ var args = new TextInputEventArgs(text);
+ _focusedView.OnTextInput(args);
+ _gtkWindow?.RequestRedraw();
+ }
+ }
+
+ private static Key ConvertGdkKey(uint keyval)
+ {
+ return keyval switch
+ {
+ 65288 => Key.Backspace,
+ 65289 => Key.Tab,
+ 65293 => Key.Enter,
+ 65307 => Key.Escape,
+ 65360 => Key.Home,
+ 65361 => Key.Left,
+ 65362 => Key.Up,
+ 65363 => Key.Right,
+ 65364 => Key.Down,
+ 65365 => Key.PageUp,
+ 65366 => Key.PageDown,
+ 65367 => Key.End,
+ 65535 => Key.Delete,
+ >= 32 and <= 126 => (Key)keyval,
+ _ => Key.Unknown
+ };
+ }
+
+ private static KeyModifiers ConvertGdkModifiers(uint state)
+ {
+ var modifiers = KeyModifiers.None;
+ if ((state & 1) != 0) modifiers |= KeyModifiers.Shift;
+ if ((state & 4) != 0) modifiers |= KeyModifiers.Control;
+ if ((state & 8) != 0) modifiers |= KeyModifiers.Alt;
+ return modifiers;
+ }
+}
diff --git a/LinuxApplication.Lifecycle.cs b/LinuxApplication.Lifecycle.cs
new file mode 100644
index 0000000..d5bf0fd
--- /dev/null
+++ b/LinuxApplication.Lifecycle.cs
@@ -0,0 +1,510 @@
+// 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.Collections.Generic;
+using System.ComponentModel;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Dispatching;
+using Microsoft.Maui.Hosting;
+using Microsoft.Maui.Platform.Linux.Dispatching;
+using Microsoft.Maui.Platform.Linux.Hosting;
+using Microsoft.Maui.Platform.Linux.Native;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using Microsoft.Maui.Platform.Linux.Services;
+using Microsoft.Maui.Platform.Linux.Window;
+using Microsoft.Maui.Platform;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform.Linux;
+
+public partial class LinuxApplication
+{
+ ///
+ /// Runs a MAUI application on Linux.
+ /// This is the main entry point for Linux apps.
+ ///
+ /// The MauiApp to run.
+ /// Command line arguments.
+ public static void Run(MauiApp app, string[] args)
+ {
+ Run(app, args, null);
+ }
+
+ ///
+ /// Runs a MAUI application on Linux with options.
+ ///
+ /// The MauiApp to run.
+ /// Command line arguments.
+ /// Optional configuration action.
+ public static void Run(MauiApp app, string[] args, Action? configure)
+ {
+ // Force X11 backend for GTK/WebKitGTK - MUST be set before any GTK code runs
+ Environment.SetEnvironmentVariable("GDK_BACKEND", "x11");
+
+ // Pre-initialize GTK for WebView compatibility (even when using X11 mode)
+ int argc = 0;
+ IntPtr argv = IntPtr.Zero;
+ if (!GtkNative.gtk_init_check(ref argc, ref argv))
+ {
+ DiagnosticLog.Warn("LinuxApplication", "GTK initialization failed - WebView may not work");
+ }
+ else
+ {
+ DiagnosticLog.Debug("LinuxApplication", "GTK pre-initialized for WebView support");
+ }
+
+ // Set application name for desktop integration (taskbar, etc.)
+ // Try to get the name from environment or use executable name
+ string? appName = Environment.GetEnvironmentVariable("APPIMAGE_NAME");
+ if (string.IsNullOrEmpty(appName))
+ {
+ appName = Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "MauiApp");
+ }
+ string prgName = appName.Replace(" ", "");
+ GtkNative.g_set_prgname(prgName);
+ GtkNative.g_set_application_name(appName);
+ DiagnosticLog.Debug("LinuxApplication", $"Set application name: {appName} (prgname: {prgName})");
+
+ // Initialize dispatcher
+ LinuxDispatcher.Initialize();
+ DispatcherProvider.SetCurrent(LinuxDispatcherProvider.Instance);
+ DiagnosticLog.Debug("LinuxApplication", "Dispatcher initialized");
+
+ var options = app.Services.GetService()
+ ?? new LinuxApplicationOptions();
+ configure?.Invoke(options);
+ ParseCommandLineOptions(args, options);
+
+ var linuxApp = new LinuxApplication();
+ try
+ {
+ linuxApp.Initialize(options);
+
+ // Create MAUI context
+ var mauiContext = new LinuxMauiContext(app.Services, linuxApp);
+
+ // Get the application and render it
+ var application = app.Services.GetService();
+ SkiaView? rootView = null;
+
+ if (application is Application mauiApplication)
+ {
+ // Force Application.Current to be this instance
+ var currentProperty = typeof(Application).GetProperty("Current");
+ if (currentProperty != null && currentProperty.CanWrite)
+ {
+ currentProperty.SetValue(null, mauiApplication);
+ }
+
+ // Set initial theme based on system theme
+ var systemTheme = SystemThemeService.Instance.CurrentTheme;
+ DiagnosticLog.Debug("LinuxApplication", $"System theme detected at startup: {systemTheme}");
+ if (systemTheme == SystemTheme.Dark)
+ {
+ mauiApplication.UserAppTheme = AppTheme.Dark;
+ DiagnosticLog.Debug("LinuxApplication", "Set initial UserAppTheme to Dark based on system theme");
+ }
+ else
+ {
+ mauiApplication.UserAppTheme = AppTheme.Light;
+ DiagnosticLog.Debug("LinuxApplication", "Set initial UserAppTheme to Light based on system theme");
+ }
+
+ // Initialize GTK theme service and apply initial CSS
+ GtkThemeService.ApplyTheme();
+
+ // Handle user-initiated theme changes
+ ((BindableObject)mauiApplication).PropertyChanged += (s, e) =>
+ {
+ if (e.PropertyName == "UserAppTheme")
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"User theme changed to: {mauiApplication.UserAppTheme}");
+
+ // Apply GTK CSS for dialogs, menus, and window decorations
+ GtkThemeService.ApplyTheme();
+
+ LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme();
+
+ // Force re-render the entire page to pick up theme changes
+ linuxApp.RefreshPageForThemeChange();
+
+ // Invalidate to redraw - use correct method based on mode
+ if (linuxApp._useGtk)
+ {
+ linuxApp._gtkWindow?.RequestRedraw();
+ }
+ else
+ {
+ linuxApp._renderingEngine?.InvalidateAll();
+ }
+ }
+ };
+
+ // Handle system theme changes (e.g., GNOME/KDE dark mode toggle)
+ SystemThemeService.Instance.ThemeChanged += (s, e) =>
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"System theme changed to: {e.NewTheme}");
+
+ // Update MAUI's UserAppTheme to match system theme
+ // This will trigger the PropertyChanged handler which does the refresh
+ var newAppTheme = e.NewTheme == SystemTheme.Dark ? AppTheme.Dark : AppTheme.Light;
+ if (mauiApplication.UserAppTheme != newAppTheme)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Setting UserAppTheme to {newAppTheme} to match system");
+ mauiApplication.UserAppTheme = newAppTheme;
+ }
+ else
+ {
+ // If UserAppTheme didn't change (user manually set it), still refresh
+ LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme();
+ linuxApp.RefreshPageForThemeChange();
+ if (linuxApp._useGtk)
+ {
+ linuxApp._gtkWindow?.RequestRedraw();
+ }
+ else
+ {
+ linuxApp._renderingEngine?.InvalidateAll();
+ }
+ }
+ };
+
+ // Get the main page - prefer CreateWindow() over deprecated MainPage
+ Page? mainPage = null;
+
+ // Try CreateWindow() first (the modern MAUI pattern)
+ try
+ {
+ // CreateWindow is protected, use reflection
+ var createWindowMethod = typeof(Application).GetMethod("CreateWindow",
+ BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public,
+ null, new[] { typeof(IActivationState) }, null);
+
+ if (createWindowMethod != null)
+ {
+ var mauiWindow = createWindowMethod.Invoke(mauiApplication, new object?[] { null }) as Microsoft.Maui.Controls.Window;
+ if (mauiWindow != null)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Got Window from CreateWindow: {mauiWindow.GetType().Name}");
+ mainPage = mauiWindow.Page;
+ DiagnosticLog.Debug("LinuxApplication", $"Window.Page: {mainPage?.GetType().Name}");
+
+ // Add to windows list
+ var windowsField = typeof(Application).GetField("_windows",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ var windowsList = windowsField?.GetValue(mauiApplication) as List;
+ if (windowsList != null && !windowsList.Contains(mauiWindow))
+ {
+ windowsList.Add(mauiWindow);
+ mauiWindow.Parent = mauiApplication;
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ DiagnosticLog.Error("LinuxApplication", $"CreateWindow failed: {ex.Message}");
+ }
+
+ // Fall back to deprecated MainPage if CreateWindow didn't work
+ if (mainPage == null && mauiApplication.MainPage != null)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Falling back to MainPage: {mauiApplication.MainPage.GetType().Name}");
+ mainPage = mauiApplication.MainPage;
+
+ var windowsField = typeof(Application).GetField("_windows",
+ BindingFlags.NonPublic | BindingFlags.Instance);
+ var windowsList = windowsField?.GetValue(mauiApplication) as List;
+
+ if (windowsList != null && windowsList.Count == 0)
+ {
+ var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage);
+ windowsList.Add(mauiWindow);
+ mauiWindow.Parent = mauiApplication;
+ }
+ else if (windowsList != null && windowsList.Count > 0 && windowsList[0].Page == null)
+ {
+ windowsList[0].Page = mainPage;
+ }
+ }
+
+ if (mainPage != null)
+ {
+ var renderer = new LinuxViewRenderer(mauiContext);
+ rootView = renderer.RenderPage(mainPage);
+
+ string windowTitle = "OpenMaui App";
+ if (mainPage is NavigationPage navPage)
+ {
+ windowTitle = navPage.Title ?? windowTitle;
+ }
+ else if (mainPage is Shell shell)
+ {
+ windowTitle = shell.Title ?? windowTitle;
+ }
+ else
+ {
+ windowTitle = mainPage.Title ?? windowTitle;
+ }
+ linuxApp.SetWindowTitle(windowTitle);
+ }
+ }
+
+ if (rootView == null)
+ {
+ rootView = LinuxProgramHost.CreateDemoView();
+ }
+
+ linuxApp.RootView = rootView;
+ linuxApp.Run();
+ }
+ finally
+ {
+ linuxApp?.Dispose();
+ }
+ }
+
+ private static void ParseCommandLineOptions(string[] args, LinuxApplicationOptions options)
+ {
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i].ToLowerInvariant())
+ {
+ case "--title" when i + 1 < args.Length:
+ options.Title = args[++i];
+ break;
+ case "--width" when i + 1 < args.Length && int.TryParse(args[i + 1], out var w):
+ options.Width = w;
+ i++;
+ break;
+ case "--height" when i + 1 < args.Length && int.TryParse(args[i + 1], out var h):
+ options.Height = h;
+ i++;
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Shows the main window and runs the event loop.
+ ///
+ public void Run()
+ {
+ if (_useGtk)
+ {
+ RunGtk();
+ }
+ else
+ {
+ RunX11();
+ }
+ }
+
+ private void RunX11()
+ {
+ if (_mainWindow == null)
+ throw new InvalidOperationException("Application not initialized");
+
+ _mainWindow.Show();
+ Render();
+
+ DiagnosticLog.Debug("LinuxApplication", "Starting event loop");
+ while (_mainWindow.IsRunning)
+ {
+ _loopCounter++;
+ if (_loopCounter % 1000 == 0)
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Loop iteration {_loopCounter}");
+ }
+
+ _mainWindow.ProcessEvents();
+ SkiaWebView.ProcessGtkEvents();
+ UpdateAnimations();
+ Render();
+ Thread.Sleep(1);
+ }
+ DiagnosticLog.Debug("LinuxApplication", "Event loop ended");
+ }
+
+ private void RunGtk()
+ {
+ if (_gtkWindow == null)
+ throw new InvalidOperationException("Application not initialized");
+
+ StartHeartbeat();
+ PerformGtkLayout(_gtkWindow.Width, _gtkWindow.Height);
+ _gtkWindow.RequestRedraw();
+ _gtkWindow.Run();
+ GtkHostService.Instance.Shutdown();
+ }
+
+ private void PerformGtkLayout(int width, int height)
+ {
+ if (_rootView != null)
+ {
+ _rootView.Measure(new Microsoft.Maui.Graphics.Size(width, height));
+ _rootView.Arrange(new Microsoft.Maui.Graphics.Rect(0, 0, width, height));
+ }
+ }
+
+ ///
+ /// Forces all views to refresh their theme-dependent properties.
+ /// This is needed because AppThemeBinding may not automatically trigger
+ /// property mappers on all platforms.
+ ///
+ private void RefreshPageForThemeChange()
+ {
+ DiagnosticLog.Debug("LinuxApplication", "RefreshPageForThemeChange - forcing property updates");
+
+ // First, try to trigger MAUI's RequestedThemeChanged event using reflection
+ // This ensures AppThemeBinding bindings re-evaluate
+ TriggerMauiThemeChanged();
+
+ if (_rootView == null) return;
+
+ // Traverse the visual tree and force theme-dependent properties to update
+ RefreshViewTheme(_rootView);
+ }
+
+ ///
+ /// Called after theme change to refresh views.
+ /// Note: MAUI's Application.UserAppTheme setter automatically triggers RequestedThemeChanged
+ /// via WeakEventManager, which AppThemeBinding subscribes to. This method handles
+ /// any additional platform-specific refresh needed.
+ ///
+ private void TriggerMauiThemeChanged()
+ {
+ var app = Application.Current;
+ if (app == null) return;
+
+ DiagnosticLog.Debug("LinuxApplication", $"Theme is now: {app.UserAppTheme}, RequestedTheme: {app.RequestedTheme}");
+ }
+
+ private void RefreshViewTheme(SkiaView view)
+ {
+ // Get the associated MAUI view and handler
+ var mauiView = view.MauiView;
+ var handler = mauiView?.Handler;
+
+ if (handler != null && mauiView != null)
+ {
+ // Force key properties to be re-mapped
+ // This ensures theme-dependent bindings are re-evaluated
+ try
+ {
+ // Background/BackgroundColor - both need updating for AppThemeBinding
+ handler.UpdateValue(nameof(IView.Background));
+ handler.UpdateValue("BackgroundColor");
+
+ // For ImageButton, force Source to be re-mapped
+ if (mauiView is Microsoft.Maui.Controls.ImageButton)
+ {
+ handler.UpdateValue(nameof(IImageSourcePart.Source));
+ }
+
+ // For Image, force Source to be re-mapped
+ if (mauiView is Microsoft.Maui.Controls.Image)
+ {
+ handler.UpdateValue(nameof(IImageSourcePart.Source));
+ }
+
+ // For views with text colors
+ if (mauiView is ITextStyle)
+ {
+ handler.UpdateValue(nameof(ITextStyle.TextColor));
+ }
+
+ // For Entry/Editor placeholder colors
+ if (mauiView is IPlaceholder)
+ {
+ handler.UpdateValue(nameof(IPlaceholder.PlaceholderColor));
+ }
+
+ // For Border stroke
+ if (mauiView is IBorderStroke)
+ {
+ handler.UpdateValue(nameof(IBorderStroke.Stroke));
+ }
+ }
+ catch (Exception ex)
+ {
+ DiagnosticLog.Error("LinuxApplication", $"Error refreshing theme for {mauiView.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ // Special handling for ItemsViews (CollectionView, ListView)
+ // Their item views are cached separately and need to be refreshed
+ if (view is SkiaItemsView itemsView)
+ {
+ itemsView.RefreshTheme();
+ }
+
+ // Special handling for NavigationPage - it stores content in _currentPage
+ if (view is SkiaNavigationPage navPage && navPage.CurrentPage != null)
+ {
+ RefreshViewTheme(navPage.CurrentPage);
+ navPage.Invalidate(); // Force redraw of navigation page
+ }
+
+ // Special handling for SkiaPage - refresh via MauiPage handler and process Content
+ if (view is SkiaPage page)
+ {
+ // Refresh page properties via handler if MauiPage is set
+ var pageHandler = page.MauiPage?.Handler;
+ if (pageHandler != null)
+ {
+ try
+ {
+ DiagnosticLog.Debug("LinuxApplication", $"Refreshing page theme: {page.MauiPage?.GetType().Name}");
+ pageHandler.UpdateValue(nameof(IView.Background));
+ pageHandler.UpdateValue("BackgroundColor");
+ }
+ catch (Exception ex)
+ {
+ DiagnosticLog.Error("LinuxApplication", $"Error refreshing page theme: {ex.Message}");
+ }
+ }
+
+ page.Invalidate(); // Force redraw to pick up theme-aware background
+ if (page.Content != null)
+ {
+ RefreshViewTheme(page.Content);
+ }
+ }
+
+ // Recursively process children
+ // Note: SkiaLayoutView hides SkiaView.Children with 'new', so we need to cast
+ IReadOnlyList children = view is SkiaLayoutView layout ? layout.Children : view.Children;
+ foreach (var child in children)
+ {
+ RefreshViewTheme(child);
+ }
+ }
+
+ private void Render()
+ {
+ if (_renderingEngine != null && _rootView != null)
+ {
+ _renderingEngine.Render(_rootView);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _renderingEngine?.Dispose();
+ _mainWindow?.Dispose();
+
+ if (Current == this)
+ Current = null;
+
+ _disposed = true;
+ }
+ }
+}
diff --git a/LinuxApplication.cs b/LinuxApplication.cs
index 2630c01..7a485cf 100644
--- a/LinuxApplication.cs
+++ b/LinuxApplication.cs
@@ -25,7 +25,7 @@ namespace Microsoft.Maui.Platform.Linux;
///
/// Main Linux application class that bootstraps the MAUI application.
///
-public class LinuxApplication : IDisposable
+public partial class LinuxApplication : IDisposable
{
private static int _invalidateCount;
private static int _requestRedrawCount;
@@ -233,272 +233,6 @@ public class LinuxApplication : IDisposable
});
}
- ///
- /// Runs a MAUI application on Linux.
- /// This is the main entry point for Linux apps.
- ///
- /// The MauiApp to run.
- /// Command line arguments.
- public static void Run(MauiApp app, string[] args)
- {
- Run(app, args, null);
- }
-
- ///
- /// Runs a MAUI application on Linux with options.
- ///
- /// The MauiApp to run.
- /// Command line arguments.
- /// Optional configuration action.
- public static void Run(MauiApp app, string[] args, Action? configure)
- {
- // Force X11 backend for GTK/WebKitGTK - MUST be set before any GTK code runs
- Environment.SetEnvironmentVariable("GDK_BACKEND", "x11");
-
- // Pre-initialize GTK for WebView compatibility (even when using X11 mode)
- int argc = 0;
- IntPtr argv = IntPtr.Zero;
- if (!GtkNative.gtk_init_check(ref argc, ref argv))
- {
- DiagnosticLog.Warn("LinuxApplication", "GTK initialization failed - WebView may not work");
- }
- else
- {
- DiagnosticLog.Debug("LinuxApplication", "GTK pre-initialized for WebView support");
- }
-
- // Set application name for desktop integration (taskbar, etc.)
- // Try to get the name from environment or use executable name
- string? appName = Environment.GetEnvironmentVariable("APPIMAGE_NAME");
- if (string.IsNullOrEmpty(appName))
- {
- appName = Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "MauiApp");
- }
- string prgName = appName.Replace(" ", "");
- GtkNative.g_set_prgname(prgName);
- GtkNative.g_set_application_name(appName);
- DiagnosticLog.Debug("LinuxApplication", $"Set application name: {appName} (prgname: {prgName})");
-
- // Initialize dispatcher
- LinuxDispatcher.Initialize();
- DispatcherProvider.SetCurrent(LinuxDispatcherProvider.Instance);
- DiagnosticLog.Debug("LinuxApplication", "Dispatcher initialized");
-
- var options = app.Services.GetService()
- ?? new LinuxApplicationOptions();
- configure?.Invoke(options);
- ParseCommandLineOptions(args, options);
-
- var linuxApp = new LinuxApplication();
- try
- {
- linuxApp.Initialize(options);
-
- // Create MAUI context
- var mauiContext = new LinuxMauiContext(app.Services, linuxApp);
-
- // Get the application and render it
- var application = app.Services.GetService();
- SkiaView? rootView = null;
-
- if (application is Application mauiApplication)
- {
- // Force Application.Current to be this instance
- var currentProperty = typeof(Application).GetProperty("Current");
- if (currentProperty != null && currentProperty.CanWrite)
- {
- currentProperty.SetValue(null, mauiApplication);
- }
-
- // Set initial theme based on system theme
- var systemTheme = SystemThemeService.Instance.CurrentTheme;
- DiagnosticLog.Debug("LinuxApplication", $"System theme detected at startup: {systemTheme}");
- if (systemTheme == SystemTheme.Dark)
- {
- mauiApplication.UserAppTheme = AppTheme.Dark;
- DiagnosticLog.Debug("LinuxApplication", "Set initial UserAppTheme to Dark based on system theme");
- }
- else
- {
- mauiApplication.UserAppTheme = AppTheme.Light;
- DiagnosticLog.Debug("LinuxApplication", "Set initial UserAppTheme to Light based on system theme");
- }
-
- // Initialize GTK theme service and apply initial CSS
- GtkThemeService.ApplyTheme();
-
- // Handle user-initiated theme changes
- ((BindableObject)mauiApplication).PropertyChanged += (s, e) =>
- {
- if (e.PropertyName == "UserAppTheme")
- {
- DiagnosticLog.Debug("LinuxApplication", $"User theme changed to: {mauiApplication.UserAppTheme}");
-
- // Apply GTK CSS for dialogs, menus, and window decorations
- GtkThemeService.ApplyTheme();
-
- LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme();
-
- // Force re-render the entire page to pick up theme changes
- linuxApp.RefreshPageForThemeChange();
-
- // Invalidate to redraw - use correct method based on mode
- if (linuxApp._useGtk)
- {
- linuxApp._gtkWindow?.RequestRedraw();
- }
- else
- {
- linuxApp._renderingEngine?.InvalidateAll();
- }
- }
- };
-
- // Handle system theme changes (e.g., GNOME/KDE dark mode toggle)
- SystemThemeService.Instance.ThemeChanged += (s, e) =>
- {
- DiagnosticLog.Debug("LinuxApplication", $"System theme changed to: {e.NewTheme}");
-
- // Update MAUI's UserAppTheme to match system theme
- // This will trigger the PropertyChanged handler which does the refresh
- var newAppTheme = e.NewTheme == SystemTheme.Dark ? AppTheme.Dark : AppTheme.Light;
- if (mauiApplication.UserAppTheme != newAppTheme)
- {
- DiagnosticLog.Debug("LinuxApplication", $"Setting UserAppTheme to {newAppTheme} to match system");
- mauiApplication.UserAppTheme = newAppTheme;
- }
- else
- {
- // If UserAppTheme didn't change (user manually set it), still refresh
- LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme();
- linuxApp.RefreshPageForThemeChange();
- if (linuxApp._useGtk)
- {
- linuxApp._gtkWindow?.RequestRedraw();
- }
- else
- {
- linuxApp._renderingEngine?.InvalidateAll();
- }
- }
- };
-
- // Get the main page - prefer CreateWindow() over deprecated MainPage
- Page? mainPage = null;
-
- // Try CreateWindow() first (the modern MAUI pattern)
- try
- {
- // CreateWindow is protected, use reflection
- var createWindowMethod = typeof(Application).GetMethod("CreateWindow",
- BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public,
- null, new[] { typeof(IActivationState) }, null);
-
- if (createWindowMethod != null)
- {
- var mauiWindow = createWindowMethod.Invoke(mauiApplication, new object?[] { null }) as Microsoft.Maui.Controls.Window;
- if (mauiWindow != null)
- {
- DiagnosticLog.Debug("LinuxApplication", $"Got Window from CreateWindow: {mauiWindow.GetType().Name}");
- mainPage = mauiWindow.Page;
- DiagnosticLog.Debug("LinuxApplication", $"Window.Page: {mainPage?.GetType().Name}");
-
- // Add to windows list
- var windowsField = typeof(Application).GetField("_windows",
- BindingFlags.NonPublic | BindingFlags.Instance);
- var windowsList = windowsField?.GetValue(mauiApplication) as List;
- if (windowsList != null && !windowsList.Contains(mauiWindow))
- {
- windowsList.Add(mauiWindow);
- mauiWindow.Parent = mauiApplication;
- }
- }
- }
- }
- catch (Exception ex)
- {
- DiagnosticLog.Error("LinuxApplication", $"CreateWindow failed: {ex.Message}");
- }
-
- // Fall back to deprecated MainPage if CreateWindow didn't work
- if (mainPage == null && mauiApplication.MainPage != null)
- {
- DiagnosticLog.Debug("LinuxApplication", $"Falling back to MainPage: {mauiApplication.MainPage.GetType().Name}");
- mainPage = mauiApplication.MainPage;
-
- var windowsField = typeof(Application).GetField("_windows",
- BindingFlags.NonPublic | BindingFlags.Instance);
- var windowsList = windowsField?.GetValue(mauiApplication) as List;
-
- if (windowsList != null && windowsList.Count == 0)
- {
- var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage);
- windowsList.Add(mauiWindow);
- mauiWindow.Parent = mauiApplication;
- }
- else if (windowsList != null && windowsList.Count > 0 && windowsList[0].Page == null)
- {
- windowsList[0].Page = mainPage;
- }
- }
-
- if (mainPage != null)
- {
- var renderer = new LinuxViewRenderer(mauiContext);
- rootView = renderer.RenderPage(mainPage);
-
- string windowTitle = "OpenMaui App";
- if (mainPage is NavigationPage navPage)
- {
- windowTitle = navPage.Title ?? windowTitle;
- }
- else if (mainPage is Shell shell)
- {
- windowTitle = shell.Title ?? windowTitle;
- }
- else
- {
- windowTitle = mainPage.Title ?? windowTitle;
- }
- linuxApp.SetWindowTitle(windowTitle);
- }
- }
-
- if (rootView == null)
- {
- rootView = LinuxProgramHost.CreateDemoView();
- }
-
- linuxApp.RootView = rootView;
- linuxApp.Run();
- }
- finally
- {
- linuxApp?.Dispose();
- }
- }
-
- private static void ParseCommandLineOptions(string[] args, LinuxApplicationOptions options)
- {
- for (int i = 0; i < args.Length; i++)
- {
- switch (args[i].ToLowerInvariant())
- {
- case "--title" when i + 1 < args.Length:
- options.Title = args[++i];
- break;
- case "--width" when i + 1 < args.Length && int.TryParse(args[i + 1], out var w):
- options.Width = w;
- i++;
- break;
- case "--height" when i + 1 < args.Length && int.TryParse(args[i + 1], out var h):
- options.Height = h;
- i++;
- break;
- }
- }
- }
-
///
/// Initializes the application with the specified options.
///
@@ -623,741 +357,4 @@ public class LinuxApplication : IDisposable
{
_mainWindow?.SetTitle(title);
}
-
- ///
- /// Shows the main window and runs the event loop.
- ///
- public void Run()
- {
- if (_useGtk)
- {
- RunGtk();
- }
- else
- {
- RunX11();
- }
- }
-
- private void RunX11()
- {
- if (_mainWindow == null)
- throw new InvalidOperationException("Application not initialized");
-
- _mainWindow.Show();
- Render();
-
- DiagnosticLog.Debug("LinuxApplication", "Starting event loop");
- while (_mainWindow.IsRunning)
- {
- _loopCounter++;
- if (_loopCounter % 1000 == 0)
- {
- DiagnosticLog.Debug("LinuxApplication", $"Loop iteration {_loopCounter}");
- }
-
- _mainWindow.ProcessEvents();
- SkiaWebView.ProcessGtkEvents();
- UpdateAnimations();
- Render();
- Thread.Sleep(1);
- }
- DiagnosticLog.Debug("LinuxApplication", "Event loop ended");
- }
-
- private void RunGtk()
- {
- if (_gtkWindow == null)
- throw new InvalidOperationException("Application not initialized");
-
- StartHeartbeat();
- PerformGtkLayout(_gtkWindow.Width, _gtkWindow.Height);
- _gtkWindow.RequestRedraw();
- _gtkWindow.Run();
- GtkHostService.Instance.Shutdown();
- }
-
- private void PerformGtkLayout(int width, int height)
- {
- if (_rootView != null)
- {
- _rootView.Measure(new Size(width, height));
- _rootView.Arrange(new Rect(0, 0, width, height));
- }
- }
-
- ///
- /// Forces all views to refresh their theme-dependent properties.
- /// This is needed because AppThemeBinding may not automatically trigger
- /// property mappers on all platforms.
- ///
- private void RefreshPageForThemeChange()
- {
- DiagnosticLog.Debug("LinuxApplication", "RefreshPageForThemeChange - forcing property updates");
-
- // First, try to trigger MAUI's RequestedThemeChanged event using reflection
- // This ensures AppThemeBinding bindings re-evaluate
- TriggerMauiThemeChanged();
-
- if (_rootView == null) return;
-
- // Traverse the visual tree and force theme-dependent properties to update
- RefreshViewTheme(_rootView);
- }
-
- ///
- /// Called after theme change to refresh views.
- /// Note: MAUI's Application.UserAppTheme setter automatically triggers RequestedThemeChanged
- /// via WeakEventManager, which AppThemeBinding subscribes to. This method handles
- /// any additional platform-specific refresh needed.
- ///
- private void TriggerMauiThemeChanged()
- {
- var app = Application.Current;
- if (app == null) return;
-
- DiagnosticLog.Debug("LinuxApplication", $"Theme is now: {app.UserAppTheme}, RequestedTheme: {app.RequestedTheme}");
- }
-
- private void RefreshViewTheme(SkiaView view)
- {
- // Get the associated MAUI view and handler
- var mauiView = view.MauiView;
- var handler = mauiView?.Handler;
-
- if (handler != null && mauiView != null)
- {
- // Force key properties to be re-mapped
- // This ensures theme-dependent bindings are re-evaluated
- try
- {
- // Background/BackgroundColor - both need updating for AppThemeBinding
- handler.UpdateValue(nameof(IView.Background));
- handler.UpdateValue("BackgroundColor");
-
- // For ImageButton, force Source to be re-mapped
- if (mauiView is Microsoft.Maui.Controls.ImageButton)
- {
- handler.UpdateValue(nameof(IImageSourcePart.Source));
- }
-
- // For Image, force Source to be re-mapped
- if (mauiView is Microsoft.Maui.Controls.Image)
- {
- handler.UpdateValue(nameof(IImageSourcePart.Source));
- }
-
- // For views with text colors
- if (mauiView is ITextStyle)
- {
- handler.UpdateValue(nameof(ITextStyle.TextColor));
- }
-
- // For Entry/Editor placeholder colors
- if (mauiView is IPlaceholder)
- {
- handler.UpdateValue(nameof(IPlaceholder.PlaceholderColor));
- }
-
- // For Border stroke
- if (mauiView is IBorderStroke)
- {
- handler.UpdateValue(nameof(IBorderStroke.Stroke));
- }
- }
- catch (Exception ex)
- {
- DiagnosticLog.Error("LinuxApplication", $"Error refreshing theme for {mauiView.GetType().Name}: {ex.Message}");
- }
- }
-
- // Special handling for ItemsViews (CollectionView, ListView)
- // Their item views are cached separately and need to be refreshed
- if (view is SkiaItemsView itemsView)
- {
- itemsView.RefreshTheme();
- }
-
- // Special handling for NavigationPage - it stores content in _currentPage
- if (view is SkiaNavigationPage navPage && navPage.CurrentPage != null)
- {
- RefreshViewTheme(navPage.CurrentPage);
- navPage.Invalidate(); // Force redraw of navigation page
- }
-
- // Special handling for SkiaPage - refresh via MauiPage handler and process Content
- if (view is SkiaPage page)
- {
- // Refresh page properties via handler if MauiPage is set
- var pageHandler = page.MauiPage?.Handler;
- if (pageHandler != null)
- {
- try
- {
- DiagnosticLog.Debug("LinuxApplication", $"Refreshing page theme: {page.MauiPage?.GetType().Name}");
- pageHandler.UpdateValue(nameof(IView.Background));
- pageHandler.UpdateValue("BackgroundColor");
- }
- catch (Exception ex)
- {
- DiagnosticLog.Error("LinuxApplication", $"Error refreshing page theme: {ex.Message}");
- }
- }
-
- page.Invalidate(); // Force redraw to pick up theme-aware background
- if (page.Content != null)
- {
- RefreshViewTheme(page.Content);
- }
- }
-
- // Recursively process children
- // Note: SkiaLayoutView hides SkiaView.Children with 'new', so we need to cast
- IReadOnlyList children = view is SkiaLayoutView layout ? layout.Children : view.Children;
- foreach (var child in children)
- {
- RefreshViewTheme(child);
- }
- }
-
- private void UpdateAnimations()
- {
- // Update cursor blink for text input controls
- if (_focusedView is SkiaEntry entry)
- {
- entry.UpdateCursorBlink();
- }
- else if (_focusedView is SkiaEditor editor)
- {
- editor.UpdateCursorBlink();
- }
- }
-
- private void Render()
- {
- if (_renderingEngine != null && _rootView != null)
- {
- _renderingEngine.Render(_rootView);
- }
- }
-
- private void OnWindowResized(object? sender, (int Width, int Height) size)
- {
- if (_rootView != null)
- {
- // Re-measure with new available size, then arrange
- var availableSize = new Size(size.Width, size.Height);
- _rootView.Measure(availableSize);
- _rootView.Arrange(new Rect(0, 0, size.Width, size.Height));
- }
- _renderingEngine?.InvalidateAll();
- }
-
- private void OnWindowExposed(object? sender, EventArgs e)
- {
- Render();
- }
-
- private void OnKeyDown(object? sender, KeyEventArgs e)
- {
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnKeyDown(e);
- return;
- }
-
- if (_focusedView != null)
- {
- _focusedView.OnKeyDown(e);
- }
- }
-
- private void OnKeyUp(object? sender, KeyEventArgs e)
- {
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnKeyUp(e);
- return;
- }
-
- if (_focusedView != null)
- {
- _focusedView.OnKeyUp(e);
- }
- }
-
- private void OnTextInput(object? sender, TextInputEventArgs e)
- {
- if (_focusedView != null)
- {
- _focusedView.OnTextInput(e);
- }
- }
-
- private void OnPointerMoved(object? sender, PointerEventArgs e)
- {
- // Route to context menu if one is active
- if (LinuxDialogService.HasContextMenu)
- {
- LinuxDialogService.ActiveContextMenu?.OnPointerMoved(e);
- return;
- }
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnPointerMoved(e);
- return;
- }
-
- if (_rootView != null)
- {
- // If a view has captured the pointer, send all events to it
- if (_capturedView != null)
- {
- _capturedView.OnPointerMoved(e);
- return;
- }
-
- // Check for popup overlay first
- var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
- var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
-
- // Track hover state changes
- if (hitView != _hoveredView)
- {
- _hoveredView?.OnPointerExited(e);
- _hoveredView = hitView;
- _hoveredView?.OnPointerEntered(e);
-
- // Update cursor based on view's cursor type
- CursorType cursor = hitView?.CursorType ?? CursorType.Arrow;
- _mainWindow?.SetCursor(cursor);
- }
-
- hitView?.OnPointerMoved(e);
- }
- }
-
- private void OnPointerPressed(object? sender, PointerEventArgs e)
- {
- DiagnosticLog.Debug("LinuxApplication", $"OnPointerPressed at ({e.X}, {e.Y}), Button={e.Button}");
-
- // Route to context menu if one is active
- if (LinuxDialogService.HasContextMenu)
- {
- LinuxDialogService.ActiveContextMenu?.OnPointerPressed(e);
- return;
- }
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnPointerPressed(e);
- return;
- }
-
- if (_rootView != null)
- {
- // Check for popup overlay first
- var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
- var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
- DiagnosticLog.Debug("LinuxApplication", $"HitView: {hitView?.GetType().Name ?? "null"}, rootView: {_rootView.GetType().Name}");
-
- if (hitView != null)
- {
- // Capture pointer to this view for drag operations
- _capturedView = hitView;
-
- // Update focus
- if (hitView.IsFocusable)
- {
- FocusedView = hitView;
- }
-
- DiagnosticLog.Debug("LinuxApplication", $"Calling OnPointerPressed on {hitView.GetType().Name}");
- hitView.OnPointerPressed(e);
- }
- else
- {
- // Close any open popups when clicking outside
- if (SkiaView.HasActivePopup && _focusedView != null)
- {
- _focusedView.OnFocusLost();
- }
- FocusedView = null;
- }
- }
- }
-
- private void OnPointerReleased(object? sender, PointerEventArgs e)
- {
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnPointerReleased(e);
- return;
- }
-
- if (_rootView != null)
- {
- // If a view has captured the pointer, send release to it
- if (_capturedView != null)
- {
- _capturedView.OnPointerReleased(e);
- _capturedView = null; // Release capture
- return;
- }
-
- // Check for popup overlay first
- var popupOwner = SkiaView.GetPopupOwnerAt(e.X, e.Y);
- var hitView = popupOwner ?? _rootView.HitTest(e.X, e.Y);
- hitView?.OnPointerReleased(e);
- }
- }
-
- private void OnScroll(object? sender, ScrollEventArgs e)
- {
- DiagnosticLog.Debug("LinuxApplication", $"OnScroll - X={e.X}, Y={e.Y}, DeltaX={e.DeltaX}, DeltaY={e.DeltaY}");
- if (_rootView != null)
- {
- var hitView = _rootView.HitTest(e.X, e.Y);
- DiagnosticLog.Debug("LinuxApplication", $"HitView: {hitView?.GetType().Name ?? "null"}");
- // Bubble scroll events up to find a ScrollView
- var view = hitView;
- while (view != null)
- {
- DiagnosticLog.Debug("LinuxApplication", $"Bubbling to: {view.GetType().Name}");
- if (view is SkiaScrollView scrollView)
- {
- scrollView.OnScroll(e);
- return;
- }
- view.OnScroll(e);
- if (e.Handled) return;
- view = view.Parent;
- }
- }
- }
-
- private void OnCloseRequested(object? sender, EventArgs e)
- {
- _mainWindow?.Stop();
- }
-
- // GTK Event Handlers
- private void OnGtkDrawRequested(object? sender, EventArgs e)
- {
- DiagnosticLog.Debug("LinuxApplication", ">>> OnGtkDrawRequested ENTER");
- LogDraw();
- var surface = _gtkWindow?.SkiaSurface;
- if (surface?.Canvas != null && _rootView != null)
- {
- var bgColor = Application.Current?.UserAppTheme == AppTheme.Dark
- ? new SKColor(32, 33, 36)
- : SKColors.White;
- surface.Canvas.Clear(bgColor);
- DiagnosticLog.Debug("LinuxApplication", "Drawing rootView...");
- _rootView.Draw(surface.Canvas);
- DiagnosticLog.Debug("LinuxApplication", "Drawing dialogs...");
- var bounds = new SKRect(0, 0, surface.Width, surface.Height);
- LinuxDialogService.DrawDialogs(surface.Canvas, bounds);
- DiagnosticLog.Debug("LinuxApplication", "<<< OnGtkDrawRequested EXIT");
- }
- }
-
- private void OnGtkResized(object? sender, (int Width, int Height) size)
- {
- PerformGtkLayout(size.Width, size.Height);
- _gtkWindow?.RequestRedraw();
- }
-
- private void OnGtkPointerPressed(object? sender, (double X, double Y, int Button) e)
- {
- string buttonName = e.Button == 1 ? "Left" : e.Button == 2 ? "Middle" : e.Button == 3 ? "Right" : $"Unknown({e.Button})";
- DiagnosticLog.Debug("LinuxApplication", $"GTK PointerPressed at ({e.X:F1}, {e.Y:F1}), Button={e.Button} ({buttonName})");
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- LinuxDialogService.TopDialog?.OnPointerPressed(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (LinuxDialogService.HasContextMenu)
- {
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- LinuxDialogService.ActiveContextMenu?.OnPointerPressed(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (_rootView == null)
- {
- DiagnosticLog.Warn("LinuxApplication", "GTK _rootView is null!");
- return;
- }
-
- var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
- DiagnosticLog.Debug("LinuxApplication", $"GTK HitView: {hitView?.GetType().Name ?? "null"}");
-
- if (hitView != null)
- {
- if (hitView.IsFocusable && _focusedView != hitView)
- {
- _focusedView?.OnFocusLost();
- _focusedView = hitView;
- _focusedView.OnFocusGained();
- }
- _capturedView = hitView;
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- DiagnosticLog.Debug("LinuxApplication", ">>> Before OnPointerPressed");
- hitView.OnPointerPressed(args);
- DiagnosticLog.Debug("LinuxApplication", "<<< After OnPointerPressed, calling RequestRedraw");
- _gtkWindow?.RequestRedraw();
- DiagnosticLog.Debug("LinuxApplication", "<<< After RequestRedraw, returning from handler");
- }
- }
-
- private void OnGtkPointerReleased(object? sender, (double X, double Y, int Button) e)
- {
- DiagnosticLog.Debug("LinuxApplication", ">>> OnGtkPointerReleased ENTER");
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- LinuxDialogService.TopDialog?.OnPointerReleased(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (_rootView == null) return;
-
- if (_capturedView != null)
- {
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- DiagnosticLog.Debug("LinuxApplication", $"Calling OnPointerReleased on {_capturedView.GetType().Name}");
- _capturedView.OnPointerReleased(args);
- DiagnosticLog.Debug("LinuxApplication", "OnPointerReleased returned");
- _capturedView = null;
- _gtkWindow?.RequestRedraw();
- DiagnosticLog.Debug("LinuxApplication", "<<< OnGtkPointerReleased EXIT (captured path)");
- }
- else
- {
- var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
- if (hitView != null)
- {
- var button = e.Button == 1 ? PointerButton.Left : e.Button == 2 ? PointerButton.Middle : PointerButton.Right;
- var args = new PointerEventArgs((float)e.X, (float)e.Y, button);
- hitView.OnPointerReleased(args);
- _gtkWindow?.RequestRedraw();
- }
- }
- }
-
- private void OnGtkPointerMoved(object? sender, (double X, double Y) e)
- {
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- var args = new PointerEventArgs((float)e.X, (float)e.Y);
- LinuxDialogService.TopDialog?.OnPointerMoved(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (LinuxDialogService.HasContextMenu)
- {
- var args = new PointerEventArgs((float)e.X, (float)e.Y);
- LinuxDialogService.ActiveContextMenu?.OnPointerMoved(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (_rootView == null) return;
-
- if (_capturedView != null)
- {
- var args = new PointerEventArgs((float)e.X, (float)e.Y);
- _capturedView.OnPointerMoved(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
- if (hitView != _hoveredView)
- {
- var args = new PointerEventArgs((float)e.X, (float)e.Y);
- _hoveredView?.OnPointerExited(args);
- _hoveredView = hitView;
- _hoveredView?.OnPointerEntered(args);
- _gtkWindow?.RequestRedraw();
- }
-
- if (hitView != null)
- {
- var args = new PointerEventArgs((float)e.X, (float)e.Y);
- hitView.OnPointerMoved(args);
- }
- }
-
- private void OnGtkKeyPressed(object? sender, (uint KeyVal, uint KeyCode, uint State) e)
- {
- var key = ConvertGdkKey(e.KeyVal);
- var modifiers = ConvertGdkModifiers(e.State);
- var args = new KeyEventArgs(key, modifiers);
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnKeyDown(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (_focusedView != null)
- {
- _focusedView.OnKeyDown(args);
- _gtkWindow?.RequestRedraw();
- }
- }
-
- private void OnGtkKeyReleased(object? sender, (uint KeyVal, uint KeyCode, uint State) e)
- {
- var key = ConvertGdkKey(e.KeyVal);
- var modifiers = ConvertGdkModifiers(e.State);
- var args = new KeyEventArgs(key, modifiers);
-
- // Route to dialog if one is active
- if (LinuxDialogService.HasActiveDialog)
- {
- LinuxDialogService.TopDialog?.OnKeyUp(args);
- _gtkWindow?.RequestRedraw();
- return;
- }
-
- if (_focusedView != null)
- {
- _focusedView.OnKeyUp(args);
- _gtkWindow?.RequestRedraw();
- }
- }
-
- private void OnGtkScrolled(object? sender, (double X, double Y, double DeltaX, double DeltaY, uint State) e)
- {
- if (_rootView == null) return;
-
- // Convert GDK state to KeyModifiers
- var modifiers = ConvertGdkStateToModifiers(e.State);
- bool isCtrlPressed = (modifiers & KeyModifiers.Control) != 0;
-
- var hitView = _rootView.HitTest((float)e.X, (float)e.Y);
-
- // Check for pinch gesture (Ctrl+Scroll) first
- if (isCtrlPressed && hitView?.MauiView != null)
- {
- if (Handlers.GestureManager.ProcessScrollAsPinch(hitView.MauiView, e.X, e.Y, e.DeltaY, true))
- {
- _gtkWindow?.RequestRedraw();
- return;
- }
- }
-
- while (hitView != null)
- {
- if (hitView is SkiaScrollView scrollView)
- {
- var args = new ScrollEventArgs((float)e.X, (float)e.Y, (float)e.DeltaX, (float)e.DeltaY, modifiers);
- scrollView.OnScroll(args);
- _gtkWindow?.RequestRedraw();
- break;
- }
- hitView = hitView.Parent;
- }
- }
-
- private static KeyModifiers ConvertGdkStateToModifiers(uint state)
- {
- var modifiers = KeyModifiers.None;
- // GDK modifier masks
- const uint GDK_SHIFT_MASK = 1 << 0;
- const uint GDK_CONTROL_MASK = 1 << 2;
- const uint GDK_MOD1_MASK = 1 << 3; // Alt
- const uint GDK_SUPER_MASK = 1 << 26;
- const uint GDK_LOCK_MASK = 1 << 1; // Caps Lock
-
- if ((state & GDK_SHIFT_MASK) != 0) modifiers |= KeyModifiers.Shift;
- if ((state & GDK_CONTROL_MASK) != 0) modifiers |= KeyModifiers.Control;
- if ((state & GDK_MOD1_MASK) != 0) modifiers |= KeyModifiers.Alt;
- if ((state & GDK_SUPER_MASK) != 0) modifiers |= KeyModifiers.Super;
- if ((state & GDK_LOCK_MASK) != 0) modifiers |= KeyModifiers.CapsLock;
-
- return modifiers;
- }
-
- private void OnGtkTextInput(object? sender, string text)
- {
- if (_focusedView != null)
- {
- var args = new TextInputEventArgs(text);
- _focusedView.OnTextInput(args);
- _gtkWindow?.RequestRedraw();
- }
- }
-
- private static Key ConvertGdkKey(uint keyval)
- {
- return keyval switch
- {
- 65288 => Key.Backspace,
- 65289 => Key.Tab,
- 65293 => Key.Enter,
- 65307 => Key.Escape,
- 65360 => Key.Home,
- 65361 => Key.Left,
- 65362 => Key.Up,
- 65363 => Key.Right,
- 65364 => Key.Down,
- 65365 => Key.PageUp,
- 65366 => Key.PageDown,
- 65367 => Key.End,
- 65535 => Key.Delete,
- >= 32 and <= 126 => (Key)keyval,
- _ => Key.Unknown
- };
- }
-
- private static KeyModifiers ConvertGdkModifiers(uint state)
- {
- var modifiers = KeyModifiers.None;
- if ((state & 1) != 0) modifiers |= KeyModifiers.Shift;
- if ((state & 4) != 0) modifiers |= KeyModifiers.Control;
- if ((state & 8) != 0) modifiers |= KeyModifiers.Alt;
- return modifiers;
- }
-
- public void Dispose()
- {
- if (!_disposed)
- {
- _renderingEngine?.Dispose();
- _mainWindow?.Dispose();
-
- if (Current == this)
- Current = null;
-
- _disposed = true;
- }
- }
}
diff --git a/Rendering/SkiaRenderingEngine.cs b/Rendering/SkiaRenderingEngine.cs
index d6d24b1..b307a6f 100644
--- a/Rendering/SkiaRenderingEngine.cs
+++ b/Rendering/SkiaRenderingEngine.cs
@@ -26,8 +26,15 @@ public class SkiaRenderingEngine : IDisposable
// Dirty region tracking for optimized rendering
private readonly List _dirtyRegions = new();
private readonly object _dirtyLock = new();
- private const int MaxDirtyRegions = 32;
- private const float RegionMergeThreshold = 0.3f; // Merge if overlap > 30%
+ ///
+ /// Maximum number of dirty regions to track before falling back to a full redraw.
+ ///
+ public static int MaxDirtyRegions { get; set; } = 32;
+
+ ///
+ /// Overlap ratio threshold (0.0-1.0) at which adjacent dirty regions are merged.
+ ///
+ public static float RegionMergeThreshold { get; set; } = 0.3f;
public static SkiaRenderingEngine? Current { get; private set; }
public ResourceCache ResourceCache { get; }
diff --git a/Rendering/TextRenderingHelper.cs b/Rendering/TextRenderingHelper.cs
new file mode 100644
index 0000000..3808377
--- /dev/null
+++ b/Rendering/TextRenderingHelper.cs
@@ -0,0 +1,112 @@
+// 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 Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform.Linux.Rendering;
+
+///
+/// Shared text rendering utilities extracted from SkiaEntry, SkiaEditor, and SkiaLabel
+/// to eliminate code duplication for common text rendering operations.
+///
+public static class TextRenderingHelper
+{
+ ///
+ /// Draws text with font fallback for emoji, CJK, and other scripts.
+ /// Uses FontFallbackManager to shape text across multiple typefaces when needed.
+ ///
+ public static void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface, float fontSize)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return;
+ }
+
+ // Use FontFallbackManager for mixed-script text
+ var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface);
+
+ if (runs.Count <= 1)
+ {
+ // Single run or no fallback needed - draw directly
+ canvas.DrawText(text, x, y, paint);
+ return;
+ }
+
+ // Multiple runs with different fonts
+ float currentX = x;
+ foreach (var run in runs)
+ {
+ using var runFont = new SKFont(run.Typeface, fontSize);
+ using var runPaint = new SKPaint(runFont)
+ {
+ Color = paint.Color,
+ IsAntialias = true
+ };
+
+ canvas.DrawText(run.Text, currentX, y, runPaint);
+ currentX += runPaint.MeasureText(run.Text);
+ }
+ }
+
+ ///
+ /// Draws underline for IME pre-edit (composition) text.
+ /// Renders a dashed underline beneath the pre-edit text region.
+ ///
+ public static void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, int cursorPosition, string preEditText, float x, float y)
+ {
+ // Calculate pre-edit text position
+ var textToCursor = displayText.Substring(0, Math.Min(cursorPosition, displayText.Length));
+ var preEditStartX = x + paint.MeasureText(textToCursor);
+ var preEditEndX = preEditStartX + paint.MeasureText(preEditText);
+
+ // Draw dotted underline to indicate composition
+ using var underlinePaint = new SKPaint
+ {
+ Color = paint.Color,
+ StrokeWidth = 1,
+ IsAntialias = true,
+ PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0)
+ };
+
+ var underlineY = y + 2;
+ canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint);
+ }
+
+ ///
+ /// Converts a MAUI Color to SkiaSharp SKColor for rendering.
+ /// Returns the specified default color when the input color is null.
+ ///
+ public static SKColor ToSKColor(Color? color, SKColor defaultColor = default)
+ {
+ if (color == null) return defaultColor;
+ return color.ToSKColor();
+ }
+
+ ///
+ /// Converts FontAttributes to the corresponding SKFontStyle.
+ ///
+ public static SKFontStyle GetFontStyle(FontAttributes attributes)
+ {
+ bool isBold = attributes.HasFlag(FontAttributes.Bold);
+ bool isItalic = attributes.HasFlag(FontAttributes.Italic);
+
+ if (isBold && isItalic)
+ return SKFontStyle.BoldItalic;
+ if (isBold)
+ return SKFontStyle.Bold;
+ if (isItalic)
+ return SKFontStyle.Italic;
+ return SKFontStyle.Normal;
+ }
+
+ ///
+ /// Gets the effective font family, returning "Sans" as the platform default when empty.
+ ///
+ public static string GetEffectiveFontFamily(string? fontFamily)
+ {
+ return string.IsNullOrEmpty(fontFamily) ? "Sans" : fontFamily;
+ }
+}
diff --git a/Views/SkiaAbsoluteLayout.cs b/Views/SkiaAbsoluteLayout.cs
new file mode 100644
index 0000000..5f6d030
--- /dev/null
+++ b/Views/SkiaAbsoluteLayout.cs
@@ -0,0 +1,160 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+using Microsoft.Maui;
+
+namespace Microsoft.Maui.Platform;
+
+///
+/// Absolute layout that positions children at exact coordinates.
+///
+public class SkiaAbsoluteLayout : SkiaLayoutView
+{
+ private readonly Dictionary _childBounds = new();
+
+ ///
+ /// Adds a child at the specified position and size.
+ ///
+ public void AddChild(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
+ {
+ base.AddChild(child);
+ _childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
+ }
+
+ public override void RemoveChild(SkiaView child)
+ {
+ base.RemoveChild(child);
+ _childBounds.Remove(child);
+ }
+
+ ///
+ /// Gets the layout bounds for a child.
+ ///
+ public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
+ {
+ return _childBounds.TryGetValue(child, out var bounds)
+ ? bounds
+ : new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
+ }
+
+ ///
+ /// Sets the layout bounds for a child.
+ ///
+ public void SetLayoutBounds(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
+ {
+ _childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
+ InvalidateMeasure();
+ Invalidate();
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ float maxRight = 0;
+ float maxBottom = 0;
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var layout = GetLayoutBounds(child);
+ var bounds = layout.Bounds;
+
+ child.Measure(new Size(bounds.Width, bounds.Height));
+
+ maxRight = Math.Max(maxRight, bounds.Right);
+ maxBottom = Math.Max(maxBottom, bounds.Bottom);
+ }
+
+ return new Size(
+ maxRight + Padding.Left + Padding.Right,
+ maxBottom + Padding.Top + Padding.Bottom);
+ }
+
+ protected override Rect ArrangeOverride(Rect bounds)
+ {
+ var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var layout = GetLayoutBounds(child);
+ var childBounds = layout.Bounds;
+ var flags = layout.Flags;
+
+ float x, y, width, height;
+
+ // X position
+ if (flags.HasFlag(AbsoluteLayoutFlags.XProportional))
+ x = content.Left + childBounds.Left * content.Width;
+ else
+ x = content.Left + childBounds.Left;
+
+ // Y position
+ if (flags.HasFlag(AbsoluteLayoutFlags.YProportional))
+ y = content.Top + childBounds.Top * content.Height;
+ else
+ y = content.Top + childBounds.Top;
+
+ // Width
+ if (flags.HasFlag(AbsoluteLayoutFlags.WidthProportional))
+ width = childBounds.Width * content.Width;
+ else if (childBounds.Width < 0)
+ width = (float)child.DesiredSize.Width;
+ else
+ width = childBounds.Width;
+
+ // Height
+ if (flags.HasFlag(AbsoluteLayoutFlags.HeightProportional))
+ height = childBounds.Height * content.Height;
+ else if (childBounds.Height < 0)
+ height = (float)child.DesiredSize.Height;
+ else
+ height = childBounds.Height;
+
+ // Apply child's margin
+ var margin = child.Margin;
+ var marginedBounds = new Rect(
+ x + (float)margin.Left,
+ y + (float)margin.Top,
+ width - (float)margin.Left - (float)margin.Right,
+ height - (float)margin.Top - (float)margin.Bottom);
+ child.Arrange(marginedBounds);
+ }
+ return bounds;
+ }
+}
+
+///
+/// Absolute layout bounds for a child.
+///
+public readonly struct AbsoluteLayoutBounds
+{
+ public SKRect Bounds { get; }
+ public AbsoluteLayoutFlags Flags { get; }
+
+ public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
+ {
+ Bounds = bounds;
+ Flags = flags;
+ }
+}
+
+///
+/// Flags for absolute layout positioning.
+///
+[Flags]
+public enum AbsoluteLayoutFlags
+{
+ None = 0,
+ XProportional = 1,
+ YProportional = 2,
+ WidthProportional = 4,
+ HeightProportional = 8,
+ PositionProportional = XProportional | YProportional,
+ SizeProportional = WidthProportional | HeightProportional,
+ All = XProportional | YProportional | WidthProportional | HeightProportional
+}
diff --git a/Views/SkiaEditor.Drawing.cs b/Views/SkiaEditor.Drawing.cs
new file mode 100644
index 0000000..36a8e08
--- /dev/null
+++ b/Views/SkiaEditor.Drawing.cs
@@ -0,0 +1,255 @@
+// 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.Collections.Generic;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public partial class SkiaEditor
+{
+ protected override void OnDraw(SKCanvas canvas, SKRect bounds)
+ {
+ var paddingLeft = (float)Padding.Left;
+ var paddingTop = (float)Padding.Top;
+ var paddingRight = (float)Padding.Right;
+ var paddingBottom = (float)Padding.Bottom;
+ var fontSize = (float)FontSize;
+ var lineHeight = (float)LineHeight;
+ var cornerRadius = (float)CornerRadius;
+
+ // Update wrap width if bounds changed and re-wrap text
+ var newWrapWidth = bounds.Width - paddingLeft - paddingRight;
+ if (Math.Abs(newWrapWidth - _wrapWidth) > 1)
+ {
+ _wrapWidth = newWrapWidth;
+ UpdateLines();
+ }
+
+ // Draw background
+ var bgColor = EditorBackgroundColor != null ? ToSKColor(EditorBackgroundColor) :
+ (IsEnabled ? SkiaTheme.BackgroundWhiteSK : SkiaTheme.Gray100SK);
+ using var bgPaint = new SKPaint
+ {
+ Color = bgColor,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ };
+ canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), bgPaint);
+
+ // Draw border only if BorderColor is not transparent
+ if (BorderColor != null && BorderColor != Colors.Transparent && BorderColor.Alpha > 0)
+ {
+ using var borderPaint = new SKPaint
+ {
+ Color = IsFocused ? ToSKColor(CursorColor) : ToSKColor(BorderColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = IsFocused ? 2 : 1,
+ IsAntialias = true
+ };
+ canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), borderPaint);
+ }
+
+ // Setup text rendering
+ using var font = new SKFont(SKTypeface.Default, fontSize);
+ var lineSpacing = fontSize * lineHeight;
+
+ // Clip to content area
+ var contentRect = new SKRect(
+ bounds.Left + paddingLeft,
+ bounds.Top + paddingTop,
+ bounds.Right - paddingRight,
+ bounds.Bottom - paddingBottom);
+
+ canvas.Save();
+ canvas.ClipRect(contentRect);
+ // Don't translate - let the text draw at absolute positions
+ // canvas.Translate(0, -_scrollOffsetY);
+
+ if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder))
+ {
+ using var placeholderPaint = new SKPaint(font)
+ {
+ Color = GetEffectivePlaceholderColor(),
+ IsAntialias = true
+ };
+ // Handle multiline placeholder text by splitting on newlines
+ var placeholderLines = Placeholder.Split('\n');
+ var y = contentRect.Top + fontSize;
+ foreach (var line in placeholderLines)
+ {
+ canvas.DrawText(line, contentRect.Left, y, placeholderPaint);
+ y += lineSpacing;
+ }
+ }
+ else
+ {
+ var textColor = GetEffectiveTextColor();
+ using var textPaint = new SKPaint(font)
+ {
+ Color = IsEnabled ? textColor : textColor.WithAlpha(128),
+ IsAntialias = true
+ };
+ using var selectionPaint = new SKPaint
+ {
+ Color = ToSKColor(SelectionColor),
+ Style = SKPaintStyle.Fill
+ };
+
+ var y = contentRect.Top + fontSize;
+ var charIndex = 0;
+
+ for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++)
+ {
+ var line = _lines[lineIndex];
+ var x = contentRect.Left;
+
+ // Draw selection for this line if applicable
+ if (_selectionStart >= 0 && _selectionLength != 0)
+ {
+ // Handle both positive and negative selection lengths
+ var selStart = _selectionLength > 0 ? _selectionStart : _selectionStart + _selectionLength;
+ var selEnd = _selectionLength > 0 ? _selectionStart + _selectionLength : _selectionStart;
+ var lineStart = charIndex;
+ var lineEnd = charIndex + line.Length;
+
+ if (selEnd > lineStart && selStart < lineEnd)
+ {
+ var selStartInLine = Math.Max(0, selStart - lineStart);
+ var selEndInLine = Math.Min(line.Length, selEnd - lineStart);
+
+ var startX = x + MeasureText(line.Substring(0, selStartInLine), font);
+ var endX = x + MeasureText(line.Substring(0, selEndInLine), font);
+
+ canvas.DrawRect(new SKRect(startX, y - fontSize, endX, y + lineSpacing - fontSize), selectionPaint);
+ }
+ }
+
+ // Determine if pre-edit text should be displayed on this line
+ var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition);
+ var displayLine = line;
+ var hasPreEditOnThisLine = !string.IsNullOrEmpty(_preEditText) && cursorLine == lineIndex;
+
+ if (hasPreEditOnThisLine)
+ {
+ // Insert pre-edit text at cursor position within this line
+ var insertPos = Math.Min(cursorCol, line.Length);
+ displayLine = line.Insert(insertPos, _preEditText);
+ }
+
+ // Draw the text with font fallback for emoji/CJK support
+ DrawTextWithFallback(canvas, displayLine, x, y, textPaint, SKTypeface.Default);
+
+ // Draw underline for pre-edit (composition) text
+ if (hasPreEditOnThisLine)
+ {
+ DrawPreEditUnderline(canvas, textPaint, line, x, y, contentRect);
+ }
+
+ // Draw cursor if on this line
+ if (IsFocused && _cursorVisible)
+ {
+ if (cursorLine == lineIndex)
+ {
+ // Account for pre-edit text when calculating cursor position
+ var textToCursor = line.Substring(0, Math.Min(cursorCol, line.Length));
+ var cursorX = x + MeasureText(textToCursor, font);
+
+ // If there's pre-edit text, cursor goes after it
+ if (hasPreEditOnThisLine && _preEditText.Length > 0)
+ {
+ cursorX += MeasureText(_preEditText, font);
+ }
+
+ using var cursorPaint = new SKPaint
+ {
+ Color = ToSKColor(CursorColor),
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2,
+ IsAntialias = true
+ };
+ canvas.DrawLine(cursorX, y - fontSize + 2, cursorX, y + 2, cursorPaint);
+ }
+ }
+
+ y += lineSpacing;
+ charIndex += line.Length + 1;
+ }
+ }
+
+ canvas.Restore();
+
+ // Draw scrollbar if needed
+ var totalHeight = _lines.Count * fontSize * lineHeight;
+ if (totalHeight > contentRect.Height)
+ {
+ DrawScrollbar(canvas, bounds, contentRect.Height, totalHeight);
+ }
+ }
+
+ private float MeasureText(string text, SKFont font)
+ {
+ if (string.IsNullOrEmpty(text)) return 0;
+ using var paint = new SKPaint(font);
+ return paint.MeasureText(text);
+ }
+
+ private void DrawScrollbar(SKCanvas canvas, SKRect bounds, float viewHeight, float contentHeight)
+ {
+ var scrollbarWidth = 6f;
+ var scrollbarMargin = 2f;
+ var paddingTop = (float)Padding.Top;
+ var scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight));
+ var scrollbarY = bounds.Top + paddingTop + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight);
+
+ using var paint = new SKPaint
+ {
+ Color = SkiaTheme.Shadow25SK,
+ Style = SKPaintStyle.Fill,
+ IsAntialias = true
+ };
+
+ canvas.DrawRoundRect(new SKRoundRect(
+ new SKRect(
+ bounds.Right - scrollbarWidth - scrollbarMargin,
+ scrollbarY,
+ bounds.Right - scrollbarMargin,
+ scrollbarY + scrollbarHeight),
+ scrollbarWidth / 2), paint);
+ }
+
+ ///
+ /// Draws text with font fallback for emoji, CJK, and other scripts.
+ ///
+ private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
+ => TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, (float)FontSize);
+
+ ///
+ /// Draws underline for IME pre-edit (composition) text.
+ ///
+ private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
+ => TextRenderingHelper.DrawPreEditUnderline(canvas, paint, displayText, _cursorPosition, _preEditText, x, y);
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (AutoSize)
+ {
+ var fontSize = (float)FontSize;
+ var lineHeight = (float)LineHeight;
+ var lineSpacing = fontSize * lineHeight;
+ var verticalPadding = Padding.Top + Padding.Bottom;
+ var height = Math.Max(lineSpacing + verticalPadding, _lines.Count * lineSpacing + verticalPadding);
+ return new Size(
+ availableSize.Width < double.MaxValue ? availableSize.Width : 200,
+ Math.Min(height, availableSize.Height < double.MaxValue ? availableSize.Height : 200));
+ }
+
+ return new Size(
+ availableSize.Width < double.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
+ availableSize.Height < double.MaxValue ? Math.Min(availableSize.Height, 150) : 150);
+ }
+}
diff --git a/Views/SkiaEditor.Input.cs b/Views/SkiaEditor.Input.cs
new file mode 100644
index 0000000..8abb81f
--- /dev/null
+++ b/Views/SkiaEditor.Input.cs
@@ -0,0 +1,756 @@
+// 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.Collections.Generic;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public partial class SkiaEditor
+{
+ #region IInputContext Implementation
+
+ ///
+ /// Gets or sets the text for IME context.
+ ///
+ string IInputContext.Text
+ {
+ get => Text;
+ set => Text = value;
+ }
+
+ ///
+ /// Gets or sets the cursor position for IME context.
+ ///
+ int IInputContext.CursorPosition
+ {
+ get => _cursorPosition;
+ set => CursorPosition = value;
+ }
+
+ ///
+ /// Gets the selection start for IME context.
+ ///
+ int IInputContext.SelectionStart => _selectionStart;
+
+ ///
+ /// Gets the selection length for IME context.
+ ///
+ int IInputContext.SelectionLength => _selectionLength;
+
+ ///
+ /// Called when IME commits text.
+ ///
+ public void OnTextCommitted(string text)
+ {
+ if (IsReadOnly) return;
+
+ // Delete selection if any
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+
+ // Clear pre-edit text
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+
+ // Check max length
+ if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
+ {
+ text = text.Substring(0, MaxLength - Text.Length);
+ }
+
+ // Insert committed text at cursor
+ var newText = Text.Insert(_cursorPosition, text);
+ var newPos = _cursorPosition + text.Length;
+ Text = newText;
+ _cursorPosition = newPos;
+
+ EnsureCursorVisible();
+ Invalidate();
+ }
+
+ ///
+ /// Called when IME pre-edit (composition) text changes.
+ ///
+ public void OnPreEditChanged(string preEditText, int cursorPosition)
+ {
+ _preEditText = preEditText ?? string.Empty;
+ _preEditCursorPosition = cursorPosition;
+ Invalidate();
+ }
+
+ ///
+ /// Called when IME pre-edit ends (cancelled or committed).
+ ///
+ public void OnPreEditEnded()
+ {
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+ Invalidate();
+ }
+
+ #endregion
+
+ private void UpdateLines()
+ {
+ _lines.Clear();
+ var text = Text ?? "";
+ if (string.IsNullOrEmpty(text))
+ {
+ _lines.Add("");
+ return;
+ }
+
+ using var font = new SKFont(SKTypeface.Default, (float)FontSize);
+
+ // Split by actual newlines first
+ var paragraphs = text.Split('\n');
+
+ foreach (var paragraph in paragraphs)
+ {
+ if (string.IsNullOrEmpty(paragraph))
+ {
+ _lines.Add("");
+ continue;
+ }
+
+ // Word wrap this paragraph if we have a known width
+ if (_wrapWidth > 0)
+ {
+ WrapParagraph(paragraph, font, _wrapWidth);
+ }
+ else
+ {
+ _lines.Add(paragraph);
+ }
+ }
+
+ if (_lines.Count == 0)
+ {
+ _lines.Add("");
+ }
+ }
+
+ private void WrapParagraph(string paragraph, SKFont font, float maxWidth)
+ {
+ var words = paragraph.Split(' ');
+ var currentLine = "";
+
+ foreach (var word in words)
+ {
+ var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
+ var lineWidth = MeasureText(testLine, font);
+
+ if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
+ {
+ // Line too long, save current and start new
+ _lines.Add(currentLine);
+ currentLine = word;
+ }
+ else
+ {
+ currentLine = testLine;
+ }
+ }
+
+ // Add remaining text
+ if (!string.IsNullOrEmpty(currentLine))
+ {
+ _lines.Add(currentLine);
+ }
+ }
+
+ private (int line, int column) GetLineColumn(int position)
+ {
+ var pos = 0;
+ for (int i = 0; i < _lines.Count; i++)
+ {
+ var lineLength = _lines[i].Length;
+ if (pos + lineLength >= position || i == _lines.Count - 1)
+ {
+ return (i, position - pos);
+ }
+ pos += lineLength + 1;
+ }
+ return (_lines.Count - 1, _lines[^1].Length);
+ }
+
+ private int GetPosition(int line, int column)
+ {
+ var pos = 0;
+ for (int i = 0; i < line && i < _lines.Count; i++)
+ {
+ pos += _lines[i].Length + 1;
+ }
+ if (line < _lines.Count)
+ {
+ pos += Math.Min(column, _lines[line].Length);
+ }
+ return Math.Min(pos, Text.Length);
+ }
+
+ private void EnsureCursorVisible()
+ {
+ var (line, col) = GetLineColumn(_cursorPosition);
+ var fontSize = (float)FontSize;
+ var lineHeight = (float)LineHeight;
+ var lineSpacing = fontSize * lineHeight;
+ var cursorY = line * lineSpacing;
+ var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom);
+
+ if (cursorY < _scrollOffsetY)
+ {
+ _scrollOffsetY = cursorY;
+ }
+ else if (cursorY + lineSpacing > _scrollOffsetY + (float)viewHeight)
+ {
+ _scrollOffsetY = cursorY + lineSpacing - (float)viewHeight;
+ }
+ }
+
+ public override void OnPointerPressed(PointerEventArgs e)
+ {
+ DiagnosticLog.Debug("SkiaEditor", $"OnPointerPressed: Button={e.Button}, IsEnabled={IsEnabled}");
+ if (!IsEnabled) return;
+
+ // Handle right-click context menu
+ if (e.Button == PointerButton.Right)
+ {
+ DiagnosticLog.Debug("SkiaEditor", "Right-click detected, showing context menu");
+ ShowContextMenu(e.X, e.Y);
+ return;
+ }
+
+ IsFocused = true;
+
+ // Use screen coordinates for proper hit detection
+ var screenBounds = ScreenBounds;
+ var paddingLeft = (float)Padding.Left;
+ var paddingTop = (float)Padding.Top;
+ var contentX = e.X - screenBounds.Left - paddingLeft;
+ var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
+
+ var fontSize = (float)FontSize;
+ var lineSpacing = fontSize * (float)LineHeight;
+ var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
+
+ using var font = new SKFont(SKTypeface.Default, fontSize);
+ var line = _lines[clickedLine];
+ var clickedCol = 0;
+
+ for (int i = 0; i <= line.Length; i++)
+ {
+ var charX = MeasureText(line.Substring(0, i), font);
+ if (charX > contentX)
+ {
+ clickedCol = i > 0 ? i - 1 : 0;
+ break;
+ }
+ clickedCol = i;
+ }
+
+ _cursorPosition = GetPosition(clickedLine, clickedCol);
+
+ // Check for double-click (select word)
+ var now = DateTime.UtcNow;
+ var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
+ var distanceFromLastClick = Math.Sqrt(Math.Pow(e.X - _lastClickX, 2) + Math.Pow(e.Y - _lastClickY, 2));
+
+ if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
+ {
+ // Double-click: select the word at cursor
+ SelectWordAtCursor();
+ _lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
+ _isSelecting = false;
+ }
+ else
+ {
+ // Single click: start selection
+ _selectionStart = _cursorPosition;
+ _selectionLength = 0;
+ _isSelecting = true;
+ _lastClickTime = now;
+ _lastClickX = e.X;
+ _lastClickY = e.Y;
+ }
+
+ _cursorVisible = true;
+ _lastCursorBlink = DateTime.Now;
+
+ Invalidate();
+ }
+
+ public override void OnPointerMoved(PointerEventArgs e)
+ {
+ if (!IsEnabled || !_isSelecting) return;
+
+ // Calculate position from mouse coordinates
+ var screenBounds = ScreenBounds;
+ var paddingLeft = (float)Padding.Left;
+ var paddingTop = (float)Padding.Top;
+ var contentX = e.X - screenBounds.Left - paddingLeft;
+ var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
+
+ var fontSize = (float)FontSize;
+ var lineSpacing = fontSize * (float)LineHeight;
+ var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
+
+ using var font = new SKFont(SKTypeface.Default, fontSize);
+ var line = _lines[clickedLine];
+ var clickedCol = 0;
+
+ for (int i = 0; i <= line.Length; i++)
+ {
+ var charX = MeasureText(line.Substring(0, i), font);
+ if (charX > contentX)
+ {
+ clickedCol = i > 0 ? i - 1 : 0;
+ break;
+ }
+ clickedCol = i;
+ }
+
+ var newPosition = GetPosition(clickedLine, clickedCol);
+ if (newPosition != _cursorPosition)
+ {
+ _cursorPosition = newPosition;
+ _selectionLength = _cursorPosition - _selectionStart;
+ _cursorVisible = true;
+ _lastCursorBlink = DateTime.Now;
+ Invalidate();
+ }
+ }
+
+ public override void OnPointerReleased(PointerEventArgs e)
+ {
+ _isSelecting = false;
+ }
+
+ public override void OnKeyDown(KeyEventArgs e)
+ {
+ if (!IsEnabled) return;
+
+ var (line, col) = GetLineColumn(_cursorPosition);
+ _cursorVisible = true;
+ _lastCursorBlink = DateTime.Now;
+
+ switch (e.Key)
+ {
+ case Key.Left:
+ if (_cursorPosition > 0)
+ {
+ _cursorPosition--;
+ EnsureCursorVisible();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Right:
+ if (_cursorPosition < Text.Length)
+ {
+ _cursorPosition++;
+ EnsureCursorVisible();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Up:
+ if (line > 0)
+ {
+ _cursorPosition = GetPosition(line - 1, col);
+ EnsureCursorVisible();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Down:
+ if (line < _lines.Count - 1)
+ {
+ _cursorPosition = GetPosition(line + 1, col);
+ EnsureCursorVisible();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Home:
+ _cursorPosition = GetPosition(line, 0);
+ EnsureCursorVisible();
+ e.Handled = true;
+ break;
+
+ case Key.End:
+ _cursorPosition = GetPosition(line, _lines[line].Length);
+ EnsureCursorVisible();
+ e.Handled = true;
+ break;
+
+ case Key.Enter:
+ if (!IsReadOnly)
+ {
+ InsertText("\n");
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Backspace:
+ if (!IsReadOnly)
+ {
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+ else if (_cursorPosition > 0)
+ {
+ Text = Text.Remove(_cursorPosition - 1, 1);
+ _cursorPosition--;
+ }
+ EnsureCursorVisible();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Delete:
+ if (!IsReadOnly)
+ {
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+ else if (_cursorPosition < Text.Length)
+ {
+ Text = Text.Remove(_cursorPosition, 1);
+ }
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Tab:
+ if (!IsReadOnly)
+ {
+ InsertText(" ");
+ }
+ e.Handled = true;
+ break;
+
+ case Key.A:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control))
+ {
+ SelectAll();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.C:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control))
+ {
+ CopyToClipboard();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.V:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
+ {
+ PasteFromClipboard();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.X:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
+ {
+ CutToClipboard();
+ e.Handled = true;
+ }
+ break;
+ }
+
+ Invalidate();
+ }
+
+ public override void OnTextInput(TextInputEventArgs e)
+ {
+ if (!IsEnabled || IsReadOnly) return;
+
+ // Ignore control characters (Ctrl+key combinations send ASCII control codes)
+ if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
+ return;
+
+ if (!string.IsNullOrEmpty(e.Text))
+ {
+ InsertText(e.Text);
+ e.Handled = true;
+ }
+ }
+
+ private void InsertText(string text)
+ {
+ if (_selectionLength > 0)
+ {
+ var currentText = Text;
+ Text = currentText.Remove(_selectionStart, _selectionLength);
+ _cursorPosition = _selectionStart;
+ _selectionStart = -1;
+ _selectionLength = 0;
+ }
+
+ if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
+ {
+ text = text.Substring(0, Math.Max(0, MaxLength - Text.Length));
+ }
+
+ if (!string.IsNullOrEmpty(text))
+ {
+ Text = Text.Insert(_cursorPosition, text);
+ _cursorPosition += text.Length;
+ EnsureCursorVisible();
+ }
+ }
+
+ public override void OnScroll(ScrollEventArgs e)
+ {
+ var fontSize = (float)FontSize;
+ var lineHeight = (float)LineHeight;
+ var lineSpacing = fontSize * lineHeight;
+ var totalHeight = _lines.Count * lineSpacing;
+ var viewHeight = (float)Bounds.Height - (float)(Padding.Top + Padding.Bottom);
+ var maxScroll = Math.Max(0, totalHeight - viewHeight);
+
+ _scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll);
+ Invalidate();
+ }
+
+ public override void OnFocusGained()
+ {
+ base.OnFocusGained();
+ SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
+
+ // Connect to IME service
+ _inputMethodService?.SetFocus(this);
+
+ // Update cursor location for IME candidate window positioning
+ UpdateImeCursorLocation();
+ }
+
+ public override void OnFocusLost()
+ {
+ base.OnFocusLost();
+ SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
+
+ // Disconnect from IME service and reset any composition
+ _inputMethodService?.SetFocus(null);
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+
+ Completed?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Resets the cursor blink timer (shows cursor immediately).
+ ///
+ private void ResetCursorBlink()
+ {
+ _lastCursorBlink = DateTime.Now;
+ _cursorVisible = true;
+ }
+
+ ///
+ /// Updates cursor blink animation. Called by the application's animation loop.
+ ///
+ public void UpdateCursorBlink()
+ {
+ if (!IsFocused) return;
+
+ var elapsed = (DateTime.Now - _lastCursorBlink).TotalMilliseconds;
+ var newVisible = ((int)(elapsed / 500) % 2) == 0;
+
+ if (newVisible != _cursorVisible)
+ {
+ _cursorVisible = newVisible;
+ Invalidate();
+ }
+ }
+
+ #region Selection and Clipboard
+
+ public void SelectAll()
+ {
+ _selectionStart = 0;
+ _cursorPosition = Text.Length;
+ _selectionLength = Text.Length;
+ Invalidate();
+ }
+
+ private void SelectWordAtCursor()
+ {
+ if (string.IsNullOrEmpty(Text)) return;
+
+ // Find word boundaries
+ int start = _cursorPosition;
+ int end = _cursorPosition;
+
+ // Move start backwards to beginning of word
+ while (start > 0 && IsWordChar(Text[start - 1]))
+ start--;
+
+ // Move end forwards to end of word
+ while (end < Text.Length && IsWordChar(Text[end]))
+ end++;
+
+ _selectionStart = start;
+ _cursorPosition = end;
+ _selectionLength = end - start;
+ }
+
+ private static bool IsWordChar(char c)
+ {
+ return char.IsLetterOrDigit(c) || c == '_';
+ }
+
+ private void CopyToClipboard()
+ {
+ if (_selectionLength == 0) return;
+
+ var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
+ var length = Math.Abs(_selectionLength);
+ var selectedText = Text.Substring(start, length);
+
+ // Use system clipboard via xclip/xsel
+ SystemClipboard.SetText(selectedText);
+ }
+
+ private void CutToClipboard()
+ {
+ CopyToClipboard();
+ DeleteSelection();
+ Invalidate();
+ }
+
+ private void PasteFromClipboard()
+ {
+ // Get from system clipboard
+ var text = SystemClipboard.GetText();
+ if (string.IsNullOrEmpty(text)) return;
+
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+
+ InsertText(text);
+ }
+
+ private void DeleteSelection()
+ {
+ if (_selectionLength == 0) return;
+
+ var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
+ var length = Math.Abs(_selectionLength);
+
+ Text = Text.Remove(start, length);
+ _cursorPosition = start;
+ _selectionStart = -1;
+ _selectionLength = 0;
+ }
+
+ private void ShowContextMenu(float x, float y)
+ {
+ DiagnosticLog.Debug("SkiaEditor", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
+ bool hasSelection = _selectionLength != 0;
+ bool hasText = !string.IsNullOrEmpty(Text);
+ bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
+ bool isEditable = !IsReadOnly;
+
+ if (LinuxApplication.IsGtkMode)
+ {
+ // Use GTK context menu when running in GTK mode (e.g., with WebView)
+ GtkContextMenuService.ShowContextMenu(new List
+ {
+ new GtkMenuItem("Cut", () =>
+ {
+ CutToClipboard();
+ Invalidate();
+ }, hasSelection && isEditable),
+ new GtkMenuItem("Copy", () =>
+ {
+ CopyToClipboard();
+ }, hasSelection),
+ new GtkMenuItem("Paste", () =>
+ {
+ PasteFromClipboard();
+ Invalidate();
+ }, hasClipboard && isEditable),
+ GtkMenuItem.Separator,
+ new GtkMenuItem("Select All", () =>
+ {
+ SelectAll();
+ Invalidate();
+ }, hasText)
+ });
+ }
+ else
+ {
+ // Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
+ bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
+ var items = new List
+ {
+ new ContextMenuItem("Cut", () =>
+ {
+ CutToClipboard();
+ Invalidate();
+ }, hasSelection && isEditable),
+ new ContextMenuItem("Copy", () =>
+ {
+ CopyToClipboard();
+ }, hasSelection),
+ new ContextMenuItem("Paste", () =>
+ {
+ PasteFromClipboard();
+ Invalidate();
+ }, hasClipboard && isEditable),
+ ContextMenuItem.Separator,
+ new ContextMenuItem("Select All", () =>
+ {
+ SelectAll();
+ Invalidate();
+ }, hasText)
+ };
+ var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
+ LinuxDialogService.ShowContextMenu(menu);
+ }
+ }
+
+ #endregion
+
+ ///
+ /// Updates the IME cursor location for candidate window positioning.
+ ///
+ private void UpdateImeCursorLocation()
+ {
+ if (_inputMethodService == null) return;
+
+ var screenBounds = ScreenBounds;
+ var fontSize = (float)FontSize;
+ var lineSpacing = fontSize * (float)LineHeight;
+ var (line, col) = GetLineColumn(_cursorPosition);
+
+ using var font = new SKFont(SKTypeface.Default, fontSize);
+ var lineText = line < _lines.Count ? _lines[line] : "";
+ var textToCursor = lineText.Substring(0, Math.Min(col, lineText.Length));
+ var cursorX = MeasureText(textToCursor, font);
+
+ int x = (int)(screenBounds.Left + Padding.Left + cursorX);
+ int y = (int)(screenBounds.Top + Padding.Top + line * lineSpacing - _scrollOffsetY);
+ int height = (int)fontSize;
+
+ _inputMethodService.SetCursorLocation(x, y, 2, height);
+ }
+}
diff --git a/Views/SkiaEditor.cs b/Views/SkiaEditor.cs
index ff7b5cb..fde1bba 100644
--- a/Views/SkiaEditor.cs
+++ b/Views/SkiaEditor.cs
@@ -16,7 +16,7 @@ namespace Microsoft.Maui.Platform;
/// Skia-rendered multiline text editor control with full XAML styling support.
/// Implements IInputContext for IME (Input Method Editor) support.
///
-public class SkiaEditor : SkiaView, IInputContext
+public partial class SkiaEditor : SkiaView, IInputContext
{
#region BindableProperties
@@ -313,11 +313,7 @@ public class SkiaEditor : SkiaView, IInputContext
///
/// Converts a MAUI Color to SkiaSharp SKColor.
///
- private static SKColor ToSKColor(Color? color)
- {
- if (color == null) return SKColors.Transparent;
- return color.ToSKColor();
- }
+ private static SKColor ToSKColor(Color? color) => TextRenderingHelper.ToSKColor(color, SKColors.Transparent);
///
/// Gets the effective text color (platform default black if null).
@@ -338,10 +334,7 @@ public class SkiaEditor : SkiaView, IInputContext
///
/// Gets the effective font family (platform default "Sans" if empty).
///
- private string GetEffectiveFontFamily()
- {
- return string.IsNullOrEmpty(FontFamily) ? "Sans" : FontFamily;
- }
+ private string GetEffectiveFontFamily() => TextRenderingHelper.GetEffectiveFontFamily(FontFamily);
///
/// Determines if text should be rendered right-to-left based on FlowDirection.
@@ -637,91 +630,6 @@ public class SkiaEditor : SkiaView, IInputContext
private int _preEditCursorPosition;
private IInputMethodService? _inputMethodService;
- #region IInputContext Implementation
-
- ///
- /// Gets or sets the text for IME context.
- ///
- string IInputContext.Text
- {
- get => Text;
- set => Text = value;
- }
-
- ///
- /// Gets or sets the cursor position for IME context.
- ///
- int IInputContext.CursorPosition
- {
- get => _cursorPosition;
- set => CursorPosition = value;
- }
-
- ///
- /// Gets the selection start for IME context.
- ///
- int IInputContext.SelectionStart => _selectionStart;
-
- ///
- /// Gets the selection length for IME context.
- ///
- int IInputContext.SelectionLength => _selectionLength;
-
- ///
- /// Called when IME commits text.
- ///
- public void OnTextCommitted(string text)
- {
- if (IsReadOnly) return;
-
- // Delete selection if any
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
-
- // Clear pre-edit text
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
-
- // Check max length
- if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
- {
- text = text.Substring(0, MaxLength - Text.Length);
- }
-
- // Insert committed text at cursor
- var newText = Text.Insert(_cursorPosition, text);
- var newPos = _cursorPosition + text.Length;
- Text = newText;
- _cursorPosition = newPos;
-
- EnsureCursorVisible();
- Invalidate();
- }
-
- ///
- /// Called when IME pre-edit (composition) text changes.
- ///
- public void OnPreEditChanged(string preEditText, int cursorPosition)
- {
- _preEditText = preEditText ?? string.Empty;
- _preEditCursorPosition = cursorPosition;
- Invalidate();
- }
-
- ///
- /// Called when IME pre-edit ends (cancelled or committed).
- ///
- public void OnPreEditEnded()
- {
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
- Invalidate();
- }
-
- #endregion
-
///
/// Event raised when text changes.
///
@@ -757,949 +665,4 @@ public class SkiaEditor : SkiaView, IInputContext
TextChanged?.Invoke(this, EventArgs.Empty);
Invalidate();
}
-
- private void UpdateLines()
- {
- _lines.Clear();
- var text = Text ?? "";
- if (string.IsNullOrEmpty(text))
- {
- _lines.Add("");
- return;
- }
-
- using var font = new SKFont(SKTypeface.Default, (float)FontSize);
-
- // Split by actual newlines first
- var paragraphs = text.Split('\n');
-
- foreach (var paragraph in paragraphs)
- {
- if (string.IsNullOrEmpty(paragraph))
- {
- _lines.Add("");
- continue;
- }
-
- // Word wrap this paragraph if we have a known width
- if (_wrapWidth > 0)
- {
- WrapParagraph(paragraph, font, _wrapWidth);
- }
- else
- {
- _lines.Add(paragraph);
- }
- }
-
- if (_lines.Count == 0)
- {
- _lines.Add("");
- }
- }
-
- private void WrapParagraph(string paragraph, SKFont font, float maxWidth)
- {
- var words = paragraph.Split(' ');
- var currentLine = "";
-
- foreach (var word in words)
- {
- var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
- var lineWidth = MeasureText(testLine, font);
-
- if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
- {
- // Line too long, save current and start new
- _lines.Add(currentLine);
- currentLine = word;
- }
- else
- {
- currentLine = testLine;
- }
- }
-
- // Add remaining text
- if (!string.IsNullOrEmpty(currentLine))
- {
- _lines.Add(currentLine);
- }
- }
-
- private (int line, int column) GetLineColumn(int position)
- {
- var pos = 0;
- for (int i = 0; i < _lines.Count; i++)
- {
- var lineLength = _lines[i].Length;
- if (pos + lineLength >= position || i == _lines.Count - 1)
- {
- return (i, position - pos);
- }
- pos += lineLength + 1;
- }
- return (_lines.Count - 1, _lines[^1].Length);
- }
-
- private int GetPosition(int line, int column)
- {
- var pos = 0;
- for (int i = 0; i < line && i < _lines.Count; i++)
- {
- pos += _lines[i].Length + 1;
- }
- if (line < _lines.Count)
- {
- pos += Math.Min(column, _lines[line].Length);
- }
- return Math.Min(pos, Text.Length);
- }
-
- protected override void OnDraw(SKCanvas canvas, SKRect bounds)
- {
- var paddingLeft = (float)Padding.Left;
- var paddingTop = (float)Padding.Top;
- var paddingRight = (float)Padding.Right;
- var paddingBottom = (float)Padding.Bottom;
- var fontSize = (float)FontSize;
- var lineHeight = (float)LineHeight;
- var cornerRadius = (float)CornerRadius;
-
- // Update wrap width if bounds changed and re-wrap text
- var newWrapWidth = bounds.Width - paddingLeft - paddingRight;
- if (Math.Abs(newWrapWidth - _wrapWidth) > 1)
- {
- _wrapWidth = newWrapWidth;
- UpdateLines();
- }
-
- // Draw background
- var bgColor = EditorBackgroundColor != null ? ToSKColor(EditorBackgroundColor) :
- (IsEnabled ? SkiaTheme.BackgroundWhiteSK : SkiaTheme.Gray100SK);
- using var bgPaint = new SKPaint
- {
- Color = bgColor,
- Style = SKPaintStyle.Fill,
- IsAntialias = true
- };
- canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), bgPaint);
-
- // Draw border only if BorderColor is not transparent
- if (BorderColor != null && BorderColor != Colors.Transparent && BorderColor.Alpha > 0)
- {
- using var borderPaint = new SKPaint
- {
- Color = IsFocused ? ToSKColor(CursorColor) : ToSKColor(BorderColor),
- Style = SKPaintStyle.Stroke,
- StrokeWidth = IsFocused ? 2 : 1,
- IsAntialias = true
- };
- canvas.DrawRoundRect(new SKRoundRect(bounds, cornerRadius), borderPaint);
- }
-
- // Setup text rendering
- using var font = new SKFont(SKTypeface.Default, fontSize);
- var lineSpacing = fontSize * lineHeight;
-
- // Clip to content area
- var contentRect = new SKRect(
- bounds.Left + paddingLeft,
- bounds.Top + paddingTop,
- bounds.Right - paddingRight,
- bounds.Bottom - paddingBottom);
-
- canvas.Save();
- canvas.ClipRect(contentRect);
- // Don't translate - let the text draw at absolute positions
- // canvas.Translate(0, -_scrollOffsetY);
-
- if (string.IsNullOrEmpty(Text) && !string.IsNullOrEmpty(Placeholder))
- {
- using var placeholderPaint = new SKPaint(font)
- {
- Color = GetEffectivePlaceholderColor(),
- IsAntialias = true
- };
- // Handle multiline placeholder text by splitting on newlines
- var placeholderLines = Placeholder.Split('\n');
- var y = contentRect.Top + fontSize;
- foreach (var line in placeholderLines)
- {
- canvas.DrawText(line, contentRect.Left, y, placeholderPaint);
- y += lineSpacing;
- }
- }
- else
- {
- var textColor = GetEffectiveTextColor();
- using var textPaint = new SKPaint(font)
- {
- Color = IsEnabled ? textColor : textColor.WithAlpha(128),
- IsAntialias = true
- };
- using var selectionPaint = new SKPaint
- {
- Color = ToSKColor(SelectionColor),
- Style = SKPaintStyle.Fill
- };
-
- var y = contentRect.Top + fontSize;
- var charIndex = 0;
-
- for (int lineIndex = 0; lineIndex < _lines.Count; lineIndex++)
- {
- var line = _lines[lineIndex];
- var x = contentRect.Left;
-
- // Draw selection for this line if applicable
- if (_selectionStart >= 0 && _selectionLength != 0)
- {
- // Handle both positive and negative selection lengths
- var selStart = _selectionLength > 0 ? _selectionStart : _selectionStart + _selectionLength;
- var selEnd = _selectionLength > 0 ? _selectionStart + _selectionLength : _selectionStart;
- var lineStart = charIndex;
- var lineEnd = charIndex + line.Length;
-
- if (selEnd > lineStart && selStart < lineEnd)
- {
- var selStartInLine = Math.Max(0, selStart - lineStart);
- var selEndInLine = Math.Min(line.Length, selEnd - lineStart);
-
- var startX = x + MeasureText(line.Substring(0, selStartInLine), font);
- var endX = x + MeasureText(line.Substring(0, selEndInLine), font);
-
- canvas.DrawRect(new SKRect(startX, y - fontSize, endX, y + lineSpacing - fontSize), selectionPaint);
- }
- }
-
- // Determine if pre-edit text should be displayed on this line
- var (cursorLine, cursorCol) = GetLineColumn(_cursorPosition);
- var displayLine = line;
- var hasPreEditOnThisLine = !string.IsNullOrEmpty(_preEditText) && cursorLine == lineIndex;
-
- if (hasPreEditOnThisLine)
- {
- // Insert pre-edit text at cursor position within this line
- var insertPos = Math.Min(cursorCol, line.Length);
- displayLine = line.Insert(insertPos, _preEditText);
- }
-
- // Draw the text with font fallback for emoji/CJK support
- DrawTextWithFallback(canvas, displayLine, x, y, textPaint, SKTypeface.Default);
-
- // Draw underline for pre-edit (composition) text
- if (hasPreEditOnThisLine)
- {
- DrawPreEditUnderline(canvas, textPaint, line, x, y, contentRect);
- }
-
- // Draw cursor if on this line
- if (IsFocused && _cursorVisible)
- {
- if (cursorLine == lineIndex)
- {
- // Account for pre-edit text when calculating cursor position
- var textToCursor = line.Substring(0, Math.Min(cursorCol, line.Length));
- var cursorX = x + MeasureText(textToCursor, font);
-
- // If there's pre-edit text, cursor goes after it
- if (hasPreEditOnThisLine && _preEditText.Length > 0)
- {
- cursorX += MeasureText(_preEditText, font);
- }
-
- using var cursorPaint = new SKPaint
- {
- Color = ToSKColor(CursorColor),
- Style = SKPaintStyle.Stroke,
- StrokeWidth = 2,
- IsAntialias = true
- };
- canvas.DrawLine(cursorX, y - fontSize + 2, cursorX, y + 2, cursorPaint);
- }
- }
-
- y += lineSpacing;
- charIndex += line.Length + 1;
- }
- }
-
- canvas.Restore();
-
- // Draw scrollbar if needed
- var totalHeight = _lines.Count * fontSize * lineHeight;
- if (totalHeight > contentRect.Height)
- {
- DrawScrollbar(canvas, bounds, contentRect.Height, totalHeight);
- }
- }
-
- private float MeasureText(string text, SKFont font)
- {
- if (string.IsNullOrEmpty(text)) return 0;
- using var paint = new SKPaint(font);
- return paint.MeasureText(text);
- }
-
- private void DrawScrollbar(SKCanvas canvas, SKRect bounds, float viewHeight, float contentHeight)
- {
- var scrollbarWidth = 6f;
- var scrollbarMargin = 2f;
- var paddingTop = (float)Padding.Top;
- var scrollbarHeight = Math.Max(20, viewHeight * (viewHeight / contentHeight));
- var scrollbarY = bounds.Top + paddingTop + (_scrollOffsetY / contentHeight) * (viewHeight - scrollbarHeight);
-
- using var paint = new SKPaint
- {
- Color = SkiaTheme.Shadow25SK,
- Style = SKPaintStyle.Fill,
- IsAntialias = true
- };
-
- canvas.DrawRoundRect(new SKRoundRect(
- new SKRect(
- bounds.Right - scrollbarWidth - scrollbarMargin,
- scrollbarY,
- bounds.Right - scrollbarMargin,
- scrollbarY + scrollbarHeight),
- scrollbarWidth / 2), paint);
- }
-
- private void EnsureCursorVisible()
- {
- var (line, col) = GetLineColumn(_cursorPosition);
- var fontSize = (float)FontSize;
- var lineHeight = (float)LineHeight;
- var lineSpacing = fontSize * lineHeight;
- var cursorY = line * lineSpacing;
- var viewHeight = Bounds.Height - (float)(Padding.Top + Padding.Bottom);
-
- if (cursorY < _scrollOffsetY)
- {
- _scrollOffsetY = cursorY;
- }
- else if (cursorY + lineSpacing > _scrollOffsetY + (float)viewHeight)
- {
- _scrollOffsetY = cursorY + lineSpacing - (float)viewHeight;
- }
- }
-
- public override void OnPointerPressed(PointerEventArgs e)
- {
- DiagnosticLog.Debug("SkiaEditor", $"OnPointerPressed: Button={e.Button}, IsEnabled={IsEnabled}");
- if (!IsEnabled) return;
-
- // Handle right-click context menu
- if (e.Button == PointerButton.Right)
- {
- DiagnosticLog.Debug("SkiaEditor", "Right-click detected, showing context menu");
- ShowContextMenu(e.X, e.Y);
- return;
- }
-
- IsFocused = true;
-
- // Use screen coordinates for proper hit detection
- var screenBounds = ScreenBounds;
- var paddingLeft = (float)Padding.Left;
- var paddingTop = (float)Padding.Top;
- var contentX = e.X - screenBounds.Left - paddingLeft;
- var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
-
- var fontSize = (float)FontSize;
- var lineSpacing = fontSize * (float)LineHeight;
- var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
-
- using var font = new SKFont(SKTypeface.Default, fontSize);
- var line = _lines[clickedLine];
- var clickedCol = 0;
-
- for (int i = 0; i <= line.Length; i++)
- {
- var charX = MeasureText(line.Substring(0, i), font);
- if (charX > contentX)
- {
- clickedCol = i > 0 ? i - 1 : 0;
- break;
- }
- clickedCol = i;
- }
-
- _cursorPosition = GetPosition(clickedLine, clickedCol);
-
- // Check for double-click (select word)
- var now = DateTime.UtcNow;
- var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
- var distanceFromLastClick = Math.Sqrt(Math.Pow(e.X - _lastClickX, 2) + Math.Pow(e.Y - _lastClickY, 2));
-
- if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
- {
- // Double-click: select the word at cursor
- SelectWordAtCursor();
- _lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
- _isSelecting = false;
- }
- else
- {
- // Single click: start selection
- _selectionStart = _cursorPosition;
- _selectionLength = 0;
- _isSelecting = true;
- _lastClickTime = now;
- _lastClickX = e.X;
- _lastClickY = e.Y;
- }
-
- _cursorVisible = true;
- _lastCursorBlink = DateTime.Now;
-
- Invalidate();
- }
-
- public override void OnPointerMoved(PointerEventArgs e)
- {
- if (!IsEnabled || !_isSelecting) return;
-
- // Calculate position from mouse coordinates
- var screenBounds = ScreenBounds;
- var paddingLeft = (float)Padding.Left;
- var paddingTop = (float)Padding.Top;
- var contentX = e.X - screenBounds.Left - paddingLeft;
- var contentY = e.Y - screenBounds.Top - paddingTop + _scrollOffsetY;
-
- var fontSize = (float)FontSize;
- var lineSpacing = fontSize * (float)LineHeight;
- var clickedLine = Math.Clamp((int)(contentY / lineSpacing), 0, _lines.Count - 1);
-
- using var font = new SKFont(SKTypeface.Default, fontSize);
- var line = _lines[clickedLine];
- var clickedCol = 0;
-
- for (int i = 0; i <= line.Length; i++)
- {
- var charX = MeasureText(line.Substring(0, i), font);
- if (charX > contentX)
- {
- clickedCol = i > 0 ? i - 1 : 0;
- break;
- }
- clickedCol = i;
- }
-
- var newPosition = GetPosition(clickedLine, clickedCol);
- if (newPosition != _cursorPosition)
- {
- _cursorPosition = newPosition;
- _selectionLength = _cursorPosition - _selectionStart;
- _cursorVisible = true;
- _lastCursorBlink = DateTime.Now;
- Invalidate();
- }
- }
-
- public override void OnPointerReleased(PointerEventArgs e)
- {
- _isSelecting = false;
- }
-
- public override void OnKeyDown(KeyEventArgs e)
- {
- if (!IsEnabled) return;
-
- var (line, col) = GetLineColumn(_cursorPosition);
- _cursorVisible = true;
- _lastCursorBlink = DateTime.Now;
-
- switch (e.Key)
- {
- case Key.Left:
- if (_cursorPosition > 0)
- {
- _cursorPosition--;
- EnsureCursorVisible();
- }
- e.Handled = true;
- break;
-
- case Key.Right:
- if (_cursorPosition < Text.Length)
- {
- _cursorPosition++;
- EnsureCursorVisible();
- }
- e.Handled = true;
- break;
-
- case Key.Up:
- if (line > 0)
- {
- _cursorPosition = GetPosition(line - 1, col);
- EnsureCursorVisible();
- }
- e.Handled = true;
- break;
-
- case Key.Down:
- if (line < _lines.Count - 1)
- {
- _cursorPosition = GetPosition(line + 1, col);
- EnsureCursorVisible();
- }
- e.Handled = true;
- break;
-
- case Key.Home:
- _cursorPosition = GetPosition(line, 0);
- EnsureCursorVisible();
- e.Handled = true;
- break;
-
- case Key.End:
- _cursorPosition = GetPosition(line, _lines[line].Length);
- EnsureCursorVisible();
- e.Handled = true;
- break;
-
- case Key.Enter:
- if (!IsReadOnly)
- {
- InsertText("\n");
- }
- e.Handled = true;
- break;
-
- case Key.Backspace:
- if (!IsReadOnly)
- {
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
- else if (_cursorPosition > 0)
- {
- Text = Text.Remove(_cursorPosition - 1, 1);
- _cursorPosition--;
- }
- EnsureCursorVisible();
- }
- e.Handled = true;
- break;
-
- case Key.Delete:
- if (!IsReadOnly)
- {
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
- else if (_cursorPosition < Text.Length)
- {
- Text = Text.Remove(_cursorPosition, 1);
- }
- }
- e.Handled = true;
- break;
-
- case Key.Tab:
- if (!IsReadOnly)
- {
- InsertText(" ");
- }
- e.Handled = true;
- break;
-
- case Key.A:
- if (e.Modifiers.HasFlag(KeyModifiers.Control))
- {
- SelectAll();
- e.Handled = true;
- }
- break;
-
- case Key.C:
- if (e.Modifiers.HasFlag(KeyModifiers.Control))
- {
- CopyToClipboard();
- e.Handled = true;
- }
- break;
-
- case Key.V:
- if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
- {
- PasteFromClipboard();
- e.Handled = true;
- }
- break;
-
- case Key.X:
- if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
- {
- CutToClipboard();
- e.Handled = true;
- }
- break;
- }
-
- Invalidate();
- }
-
- public override void OnTextInput(TextInputEventArgs e)
- {
- if (!IsEnabled || IsReadOnly) return;
-
- // Ignore control characters (Ctrl+key combinations send ASCII control codes)
- if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
- return;
-
- if (!string.IsNullOrEmpty(e.Text))
- {
- InsertText(e.Text);
- e.Handled = true;
- }
- }
-
- private void InsertText(string text)
- {
- if (_selectionLength > 0)
- {
- var currentText = Text;
- Text = currentText.Remove(_selectionStart, _selectionLength);
- _cursorPosition = _selectionStart;
- _selectionStart = -1;
- _selectionLength = 0;
- }
-
- if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
- {
- text = text.Substring(0, Math.Max(0, MaxLength - Text.Length));
- }
-
- if (!string.IsNullOrEmpty(text))
- {
- Text = Text.Insert(_cursorPosition, text);
- _cursorPosition += text.Length;
- EnsureCursorVisible();
- }
- }
-
- public override void OnScroll(ScrollEventArgs e)
- {
- var fontSize = (float)FontSize;
- var lineHeight = (float)LineHeight;
- var lineSpacing = fontSize * lineHeight;
- var totalHeight = _lines.Count * lineSpacing;
- var viewHeight = (float)Bounds.Height - (float)(Padding.Top + Padding.Bottom);
- var maxScroll = Math.Max(0, totalHeight - viewHeight);
-
- _scrollOffsetY = Math.Clamp(_scrollOffsetY - e.DeltaY * 3, 0, maxScroll);
- Invalidate();
- }
-
- public override void OnFocusGained()
- {
- base.OnFocusGained();
- SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
-
- // Connect to IME service
- _inputMethodService?.SetFocus(this);
-
- // Update cursor location for IME candidate window positioning
- UpdateImeCursorLocation();
- }
-
- public override void OnFocusLost()
- {
- base.OnFocusLost();
- SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
-
- // Disconnect from IME service and reset any composition
- _inputMethodService?.SetFocus(null);
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
-
- Completed?.Invoke(this, EventArgs.Empty);
- }
-
- ///
- /// Resets the cursor blink timer (shows cursor immediately).
- ///
- private void ResetCursorBlink()
- {
- _lastCursorBlink = DateTime.Now;
- _cursorVisible = true;
- }
-
- ///
- /// Updates cursor blink animation. Called by the application's animation loop.
- ///
- public void UpdateCursorBlink()
- {
- if (!IsFocused) return;
-
- var elapsed = (DateTime.Now - _lastCursorBlink).TotalMilliseconds;
- var newVisible = ((int)(elapsed / 500) % 2) == 0;
-
- if (newVisible != _cursorVisible)
- {
- _cursorVisible = newVisible;
- Invalidate();
- }
- }
-
- #region Selection and Clipboard
-
- public void SelectAll()
- {
- _selectionStart = 0;
- _cursorPosition = Text.Length;
- _selectionLength = Text.Length;
- Invalidate();
- }
-
- private void SelectWordAtCursor()
- {
- if (string.IsNullOrEmpty(Text)) return;
-
- // Find word boundaries
- int start = _cursorPosition;
- int end = _cursorPosition;
-
- // Move start backwards to beginning of word
- while (start > 0 && IsWordChar(Text[start - 1]))
- start--;
-
- // Move end forwards to end of word
- while (end < Text.Length && IsWordChar(Text[end]))
- end++;
-
- _selectionStart = start;
- _cursorPosition = end;
- _selectionLength = end - start;
- }
-
- private static bool IsWordChar(char c)
- {
- return char.IsLetterOrDigit(c) || c == '_';
- }
-
- private void CopyToClipboard()
- {
- if (_selectionLength == 0) return;
-
- var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
- var length = Math.Abs(_selectionLength);
- var selectedText = Text.Substring(start, length);
-
- // Use system clipboard via xclip/xsel
- SystemClipboard.SetText(selectedText);
- }
-
- private void CutToClipboard()
- {
- CopyToClipboard();
- DeleteSelection();
- Invalidate();
- }
-
- private void PasteFromClipboard()
- {
- // Get from system clipboard
- var text = SystemClipboard.GetText();
- if (string.IsNullOrEmpty(text)) return;
-
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
-
- InsertText(text);
- }
-
- private void DeleteSelection()
- {
- if (_selectionLength == 0) return;
-
- var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
- var length = Math.Abs(_selectionLength);
-
- Text = Text.Remove(start, length);
- _cursorPosition = start;
- _selectionStart = -1;
- _selectionLength = 0;
- }
-
- private void ShowContextMenu(float x, float y)
- {
- DiagnosticLog.Debug("SkiaEditor", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
- bool hasSelection = _selectionLength != 0;
- bool hasText = !string.IsNullOrEmpty(Text);
- bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
- bool isEditable = !IsReadOnly;
-
- if (LinuxApplication.IsGtkMode)
- {
- // Use GTK context menu when running in GTK mode (e.g., with WebView)
- GtkContextMenuService.ShowContextMenu(new List
- {
- new GtkMenuItem("Cut", () =>
- {
- CutToClipboard();
- Invalidate();
- }, hasSelection && isEditable),
- new GtkMenuItem("Copy", () =>
- {
- CopyToClipboard();
- }, hasSelection),
- new GtkMenuItem("Paste", () =>
- {
- PasteFromClipboard();
- Invalidate();
- }, hasClipboard && isEditable),
- GtkMenuItem.Separator,
- new GtkMenuItem("Select All", () =>
- {
- SelectAll();
- Invalidate();
- }, hasText)
- });
- }
- else
- {
- // Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
- bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
- var items = new List
- {
- new ContextMenuItem("Cut", () =>
- {
- CutToClipboard();
- Invalidate();
- }, hasSelection && isEditable),
- new ContextMenuItem("Copy", () =>
- {
- CopyToClipboard();
- }, hasSelection),
- new ContextMenuItem("Paste", () =>
- {
- PasteFromClipboard();
- Invalidate();
- }, hasClipboard && isEditable),
- ContextMenuItem.Separator,
- new ContextMenuItem("Select All", () =>
- {
- SelectAll();
- Invalidate();
- }, hasText)
- };
- var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
- LinuxDialogService.ShowContextMenu(menu);
- }
- }
-
- #endregion
-
- ///
- /// Draws text with font fallback for emoji, CJK, and other scripts.
- ///
- private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
- {
- if (string.IsNullOrEmpty(text))
- {
- return;
- }
-
- // Use FontFallbackManager for mixed-script text
- var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface);
-
- if (runs.Count <= 1)
- {
- // Single run or no fallback needed - draw directly
- canvas.DrawText(text, x, y, paint);
- return;
- }
-
- // Multiple runs with different fonts
- float currentX = x;
- foreach (var run in runs)
- {
- using var runFont = new SKFont(run.Typeface, (float)FontSize);
- using var runPaint = new SKPaint(runFont)
- {
- Color = paint.Color,
- IsAntialias = true
- };
-
- canvas.DrawText(run.Text, currentX, y, runPaint);
- currentX += runPaint.MeasureText(run.Text);
- }
- }
-
- ///
- /// Draws underline for IME pre-edit (composition) text.
- ///
- private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
- {
- // Calculate pre-edit text position
- var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
- var preEditStartX = x + paint.MeasureText(textToCursor);
- var preEditEndX = preEditStartX + paint.MeasureText(_preEditText);
-
- // Draw dotted underline to indicate composition
- using var underlinePaint = new SKPaint
- {
- Color = paint.Color,
- StrokeWidth = 1,
- IsAntialias = true,
- PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0)
- };
-
- var underlineY = y + 2;
- canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint);
- }
-
- ///
- /// Updates the IME cursor location for candidate window positioning.
- ///
- private void UpdateImeCursorLocation()
- {
- if (_inputMethodService == null) return;
-
- var screenBounds = ScreenBounds;
- var (line, col) = GetLineColumn(_cursorPosition);
- var fontSize = (float)FontSize;
- var lineSpacing = fontSize * (float)LineHeight;
-
- using var font = new SKFont(SKTypeface.Default, fontSize);
- using var paint = new SKPaint(font);
-
- var lineText = line < _lines.Count ? _lines[line] : "";
- var textToCursor = lineText.Substring(0, Math.Min(col, lineText.Length));
- var cursorX = paint.MeasureText(textToCursor);
-
- int x = (int)(screenBounds.Left + Padding.Left + cursorX);
- int y = (int)(screenBounds.Top + Padding.Top + line * lineSpacing - _scrollOffsetY);
- int height = (int)fontSize;
-
- _inputMethodService.SetCursorLocation(x, y, 2, height);
- }
-
- protected override Size MeasureOverride(Size availableSize)
- {
- if (AutoSize)
- {
- var fontSize = (float)FontSize;
- var lineHeight = (float)LineHeight;
- var lineSpacing = fontSize * lineHeight;
- var verticalPadding = Padding.Top + Padding.Bottom;
- var height = Math.Max(lineSpacing + verticalPadding, _lines.Count * lineSpacing + verticalPadding);
- return new Size(
- availableSize.Width < double.MaxValue ? availableSize.Width : 200,
- Math.Min(height, availableSize.Height < double.MaxValue ? availableSize.Height : 200));
- }
-
- return new Size(
- availableSize.Width < double.MaxValue ? Math.Min(availableSize.Width, 200) : 200,
- availableSize.Height < double.MaxValue ? Math.Min(availableSize.Height, 150) : 150);
- }
}
diff --git a/Views/SkiaEntry.Drawing.cs b/Views/SkiaEntry.Drawing.cs
new file mode 100644
index 0000000..9d67af0
--- /dev/null
+++ b/Views/SkiaEntry.Drawing.cs
@@ -0,0 +1,303 @@
+// 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 Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public partial class SkiaEntry
+{
+ protected override void DrawBackground(SKCanvas canvas, SKRect bounds)
+ {
+ // Skip base background drawing if Entry is transparent
+ // (transparent Entry is likely inside a Border that handles appearance)
+ var bgColor = ToSKColor(EntryBackgroundColor);
+ var baseBgColor = GetEffectiveBackgroundColor();
+ if (bgColor.Alpha < 10 && baseBgColor.Alpha < 10)
+ return;
+
+ // Otherwise let base class draw
+ base.DrawBackground(canvas, bounds);
+ }
+
+ protected override void OnDraw(SKCanvas canvas, SKRect bounds)
+ {
+ var bgColor = ToSKColor(EntryBackgroundColor);
+ var isTransparent = bgColor.Alpha < 10; // Consider nearly transparent as transparent
+
+ if (!isTransparent)
+ {
+ // Draw background
+ using var bgPaint = new SKPaint
+ {
+ Color = bgColor,
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill
+ };
+
+ var rect = new SKRoundRect(bounds, (float)CornerRadius);
+ canvas.DrawRoundRect(rect, bgPaint);
+
+ // Draw border
+ var borderColor = IsFocused ? ToSKColor(FocusedBorderColor) : ToSKColor(BorderColor);
+ var borderWidth = IsFocused ? (float)BorderWidth + 1 : (float)BorderWidth;
+
+ using var borderPaint = new SKPaint
+ {
+ Color = borderColor,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = borderWidth
+ };
+ canvas.DrawRoundRect(rect, borderPaint);
+ }
+
+ // Calculate content bounds
+ var contentBounds = new SKRect(
+ bounds.Left + (float)Padding.Left,
+ bounds.Top + (float)Padding.Top,
+ bounds.Right - (float)Padding.Right,
+ bounds.Bottom - (float)Padding.Bottom);
+
+ // Reserve space for clear button if shown
+ var clearButtonSize = 20f;
+ var clearButtonMargin = 8f;
+ var showClear = ShouldShowClearButton();
+ if (showClear)
+ {
+ contentBounds.Right -= clearButtonSize + clearButtonMargin;
+ }
+
+ // Set up clipping for text area
+ canvas.Save();
+ canvas.ClipRect(contentBounds);
+
+ var fontStyle = GetFontStyle();
+ var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
+ ?? SKTypeface.Default;
+
+ using var font = new SKFont(typeface, (float)FontSize);
+ using var paint = new SKPaint(font) { IsAntialias = true };
+
+ var displayText = GetDisplayText();
+ // Append pre-edit text at cursor position for IME composition display
+ var preEditInsertPos = Math.Min(_cursorPosition, displayText.Length);
+ var displayTextWithPreEdit = string.IsNullOrEmpty(_preEditText)
+ ? displayText
+ : displayText.Insert(preEditInsertPos, _preEditText);
+ var hasText = !string.IsNullOrEmpty(displayTextWithPreEdit);
+
+ if (hasText)
+ {
+ paint.Color = GetEffectiveTextColor();
+
+ // Measure text to cursor position for scrolling
+ var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
+ var cursorX = paint.MeasureText(textToCursor);
+
+ // Auto-scroll to keep cursor visible
+ if (cursorX - _scrollOffset > contentBounds.Width - 10)
+ {
+ _scrollOffset = cursorX - contentBounds.Width + 10;
+ }
+ else if (cursorX - _scrollOffset < 0)
+ {
+ _scrollOffset = cursorX;
+ }
+
+ // Draw selection (check != 0 to handle both forward and backward selection)
+ if (IsFocused && _selectionLength != 0)
+ {
+ DrawSelection(canvas, paint, displayText, contentBounds);
+ }
+
+ // Calculate text position based on vertical alignment
+ var textBounds = new SKRect();
+ paint.MeasureText(displayText, ref textBounds);
+
+ float x = contentBounds.Left - _scrollOffset;
+ float y = VerticalTextAlignment switch
+ {
+ TextAlignment.Start => contentBounds.Top - textBounds.Top,
+ TextAlignment.End => contentBounds.Bottom - textBounds.Bottom,
+ _ => contentBounds.MidY - textBounds.MidY // Center
+ };
+
+ // Draw the text with font fallback for emoji/CJK support
+ DrawTextWithFallback(canvas, displayTextWithPreEdit, x, y, paint, typeface);
+
+ // Draw underline for pre-edit (composition) text
+ if (!string.IsNullOrEmpty(_preEditText))
+ {
+ DrawPreEditUnderline(canvas, paint, displayText, x, y, contentBounds);
+ }
+
+ // Draw cursor
+ if (IsFocused && !IsReadOnly && _cursorVisible)
+ {
+ DrawCursor(canvas, paint, displayText, contentBounds);
+ }
+ }
+ else if (!string.IsNullOrEmpty(Placeholder))
+ {
+ // Draw placeholder
+ paint.Color = GetEffectivePlaceholderColor();
+
+ var textBounds = new SKRect();
+ paint.MeasureText(Placeholder, ref textBounds);
+
+ float x = contentBounds.Left;
+ float y = contentBounds.MidY - textBounds.MidY;
+
+ canvas.DrawText(Placeholder, x, y, paint);
+ }
+ else if (IsFocused && !IsReadOnly && _cursorVisible)
+ {
+ // Draw cursor even with no text
+ DrawCursor(canvas, paint, "", contentBounds);
+ }
+
+ canvas.Restore();
+
+ // Draw clear button if applicable
+ if (showClear)
+ {
+ DrawClearButton(canvas, bounds, clearButtonSize, clearButtonMargin);
+ }
+ }
+
+ private bool ShouldShowClearButton()
+ {
+ if (string.IsNullOrEmpty(Text)) return false;
+
+ // Check both legacy ShowClearButton and MAUI ClearButtonVisibility
+ if (ShowClearButton && IsFocused) return true;
+
+ return ClearButtonVisibility switch
+ {
+ ClearButtonVisibility.WhileEditing => IsFocused,
+ ClearButtonVisibility.Never => false,
+ _ => false
+ };
+ }
+
+ private void DrawClearButton(SKCanvas canvas, SKRect bounds, float size, float margin)
+ {
+ var centerX = bounds.Right - margin - size / 2;
+ var centerY = bounds.MidY;
+
+ // Draw circle background
+ using var circlePaint = new SKPaint
+ {
+ Color = SkiaTheme.Gray400SK,
+ IsAntialias = true,
+ Style = SKPaintStyle.Fill
+ };
+ canvas.DrawCircle(centerX, centerY, size / 2 - 2, circlePaint);
+
+ // Draw X
+ using var xPaint = new SKPaint
+ {
+ Color = SkiaTheme.BackgroundWhiteSK,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = 2,
+ StrokeCap = SKStrokeCap.Round
+ };
+
+ var offset = size / 4 - 1;
+ canvas.DrawLine(centerX - offset, centerY - offset, centerX + offset, centerY + offset, xPaint);
+ canvas.DrawLine(centerX - offset, centerY + offset, centerX + offset, centerY - offset, xPaint);
+ }
+
+ private void DrawSelection(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
+ {
+ var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength);
+ var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength);
+
+ var textToStart = displayText.Substring(0, selStart);
+ var textToEnd = displayText.Substring(0, selEnd);
+
+ var startX = bounds.Left - _scrollOffset + paint.MeasureText(textToStart);
+ var endX = bounds.Left - _scrollOffset + paint.MeasureText(textToEnd);
+
+ using var selPaint = new SKPaint
+ {
+ Color = ToSKColor(SelectionColor),
+ Style = SKPaintStyle.Fill
+ };
+
+ canvas.DrawRect(startX, bounds.Top, endX - startX, bounds.Height, selPaint);
+ }
+
+ private void DrawCursor(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
+ {
+ var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
+ var cursorX = bounds.Left - _scrollOffset + paint.MeasureText(textToCursor);
+
+ using var cursorPaint = new SKPaint
+ {
+ Color = ToSKColor(CursorColor),
+ StrokeWidth = 2,
+ IsAntialias = true
+ };
+
+ canvas.DrawLine(cursorX, bounds.Top + 2, cursorX, bounds.Bottom - 2, cursorPaint);
+ }
+
+ ///
+ /// Draws text with font fallback for emoji, CJK, and other scripts.
+ ///
+ private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
+ => TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, (float)FontSize);
+
+ ///
+ /// Draws underline for IME pre-edit (composition) text.
+ ///
+ private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
+ => TextRenderingHelper.DrawPreEditUnderline(canvas, paint, displayText, _cursorPosition, _preEditText, x, y);
+
+ private void ResetCursorBlink()
+ {
+ _cursorBlinkTime = DateTime.UtcNow;
+ _cursorVisible = true;
+ }
+
+ ///
+ /// Updates cursor blink animation.
+ ///
+ public void UpdateCursorBlink()
+ {
+ if (!IsFocused) return;
+
+ var elapsed = (DateTime.UtcNow - _cursorBlinkTime).TotalMilliseconds;
+ var newVisible = ((int)(elapsed / 500) % 2) == 0;
+
+ if (newVisible != _cursorVisible)
+ {
+ _cursorVisible = newVisible;
+ Invalidate();
+ }
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var fontStyle = GetFontStyle();
+ var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
+ ?? SKTypeface.Default;
+
+ using var font = new SKFont(typeface, (float)FontSize);
+
+ // Use font metrics for consistent height regardless of text content
+ // This prevents size changes when placeholder disappears or text changes
+ var metrics = font.Metrics;
+ var textHeight = metrics.Descent - metrics.Ascent + metrics.Leading;
+
+ return new Size(
+ 200, // Default width, will be overridden by layout
+ textHeight + Padding.Top + Padding.Bottom + BorderWidth * 2);
+ }
+}
diff --git a/Views/SkiaEntry.Input.cs b/Views/SkiaEntry.Input.cs
new file mode 100644
index 0000000..9741409
--- /dev/null
+++ b/Views/SkiaEntry.Input.cs
@@ -0,0 +1,654 @@
+// 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.Collections.Generic;
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux;
+using Microsoft.Maui.Platform.Linux.Rendering;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public partial class SkiaEntry
+{
+ #region IInputContext Implementation
+
+ ///
+ /// Gets or sets the text for IME context.
+ ///
+ string IInputContext.Text
+ {
+ get => Text;
+ set => Text = value;
+ }
+
+ ///
+ /// Gets or sets the cursor position for IME context.
+ ///
+ int IInputContext.CursorPosition
+ {
+ get => _cursorPosition;
+ set => CursorPosition = value;
+ }
+
+ ///
+ /// Gets the selection start for IME context.
+ ///
+ int IInputContext.SelectionStart => _selectionStart;
+
+ ///
+ /// Gets the selection length for IME context.
+ ///
+ int IInputContext.SelectionLength => _selectionLength;
+
+ ///
+ /// Called when IME commits text.
+ ///
+ public void OnTextCommitted(string text)
+ {
+ if (IsReadOnly) return;
+
+ // Delete selection if any
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+
+ // Clear pre-edit text
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+
+ // Check max length
+ if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
+ {
+ text = text.Substring(0, MaxLength - Text.Length);
+ }
+
+ // Insert committed text at cursor
+ var newText = Text.Insert(_cursorPosition, text);
+ var newPos = _cursorPosition + text.Length;
+ Text = newText;
+ _cursorPosition = newPos;
+
+ ResetCursorBlink();
+ Invalidate();
+ }
+
+ ///
+ /// Called when IME pre-edit (composition) text changes.
+ ///
+ public void OnPreEditChanged(string preEditText, int cursorPosition)
+ {
+ _preEditText = preEditText ?? string.Empty;
+ _preEditCursorPosition = cursorPosition;
+ Invalidate();
+ }
+
+ ///
+ /// Called when IME pre-edit ends (cancelled or committed).
+ ///
+ public void OnPreEditEnded()
+ {
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+ Invalidate();
+ }
+
+ #endregion
+
+ public override void OnTextInput(TextInputEventArgs e)
+ {
+ if (!IsEnabled || IsReadOnly) return;
+
+ // Ignore control characters (Ctrl+key combinations send ASCII control codes)
+ if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
+ return;
+
+ // Delete selection if any
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+
+ // Check max length
+ if (MaxLength > 0 && Text.Length >= MaxLength)
+ return;
+
+ // Insert text at cursor
+ var insertText = e.Text;
+ if (MaxLength > 0)
+ {
+ var remaining = MaxLength - Text.Length;
+ insertText = insertText.Substring(0, Math.Min(insertText.Length, remaining));
+ }
+
+ var newText = Text.Insert(_cursorPosition, insertText);
+ var oldPos = _cursorPosition;
+ Text = newText;
+ _cursorPosition = oldPos + insertText.Length;
+
+ ResetCursorBlink();
+ Invalidate();
+ }
+
+ public override void OnKeyDown(KeyEventArgs e)
+ {
+ if (!IsEnabled) return;
+
+ switch (e.Key)
+ {
+ case Key.Backspace:
+ if (!IsReadOnly)
+ {
+ if (_selectionLength > 0)
+ {
+ DeleteSelection();
+ }
+ else if (_cursorPosition > 0)
+ {
+ var newText = Text.Remove(_cursorPosition - 1, 1);
+ var newPos = _cursorPosition - 1;
+ Text = newText;
+ _cursorPosition = newPos;
+ }
+ ResetCursorBlink();
+ Invalidate();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Delete:
+ if (!IsReadOnly)
+ {
+ if (_selectionLength > 0)
+ {
+ DeleteSelection();
+ }
+ else if (_cursorPosition < Text.Length)
+ {
+ Text = Text.Remove(_cursorPosition, 1);
+ }
+ ResetCursorBlink();
+ Invalidate();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Left:
+ if (_cursorPosition > 0)
+ {
+ if (e.Modifiers.HasFlag(KeyModifiers.Shift))
+ {
+ ExtendSelection(-1);
+ }
+ else
+ {
+ ClearSelection();
+ _cursorPosition--;
+ }
+ ResetCursorBlink();
+ Invalidate();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Right:
+ if (_cursorPosition < Text.Length)
+ {
+ if (e.Modifiers.HasFlag(KeyModifiers.Shift))
+ {
+ ExtendSelection(1);
+ }
+ else
+ {
+ ClearSelection();
+ _cursorPosition++;
+ }
+ ResetCursorBlink();
+ Invalidate();
+ }
+ e.Handled = true;
+ break;
+
+ case Key.Home:
+ if (e.Modifiers.HasFlag(KeyModifiers.Shift))
+ {
+ ExtendSelectionTo(0);
+ }
+ else
+ {
+ ClearSelection();
+ _cursorPosition = 0;
+ }
+ ResetCursorBlink();
+ Invalidate();
+ e.Handled = true;
+ break;
+
+ case Key.End:
+ if (e.Modifiers.HasFlag(KeyModifiers.Shift))
+ {
+ ExtendSelectionTo(Text.Length);
+ }
+ else
+ {
+ ClearSelection();
+ _cursorPosition = Text.Length;
+ }
+ ResetCursorBlink();
+ Invalidate();
+ e.Handled = true;
+ break;
+
+ case Key.A:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control))
+ {
+ SelectAll();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.C:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control))
+ {
+ CopyToClipboard();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.V:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
+ {
+ PasteFromClipboard();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.X:
+ if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
+ {
+ CutToClipboard();
+ e.Handled = true;
+ }
+ break;
+
+ case Key.Enter:
+ Completed?.Invoke(this, EventArgs.Empty);
+ // Execute ReturnCommand if set and can execute
+ if (ReturnCommand?.CanExecute(ReturnCommandParameter) == true)
+ {
+ ReturnCommand.Execute(ReturnCommandParameter);
+ }
+ e.Handled = true;
+ break;
+ }
+ }
+
+ public override void OnPointerPressed(PointerEventArgs e)
+ {
+ if (!IsEnabled) return;
+
+ // Handle right-click context menu
+ if (e.Button == PointerButton.Right)
+ {
+ ShowContextMenu(e.X, e.Y);
+ return;
+ }
+
+ // Check if clicked on clear button
+ if (ShouldShowClearButton())
+ {
+ var clearButtonSize = 20f;
+ var clearButtonMargin = 8f;
+ var clearCenterX = (float)(Bounds.Left + Bounds.Width) - clearButtonMargin - clearButtonSize / 2;
+ var clearCenterY = (float)(Bounds.Top + Bounds.Height / 2);
+
+ var dx = e.X - clearCenterX;
+ var dy = e.Y - clearCenterY;
+ if (dx * dx + dy * dy < (clearButtonSize / 2) * (clearButtonSize / 2))
+ {
+ // Clear button clicked
+ Text = "";
+ _cursorPosition = 0;
+ _selectionLength = 0;
+ Invalidate();
+ return;
+ }
+ }
+
+ // Calculate cursor position from click using screen coordinates
+ var screenBounds = ScreenBounds;
+ var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
+ _cursorPosition = GetCharacterIndexAtX(clickX);
+
+ // Check for double-click (select word or select all)
+ var now = DateTime.UtcNow;
+ var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
+ var distanceFromLastClick = Math.Abs(e.X - _lastClickX);
+
+ if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
+ {
+ // Double-click: select all or select word based on property
+ if (SelectAllOnDoubleClick)
+ {
+ SelectAll();
+ }
+ else
+ {
+ SelectWordAtCursor();
+ }
+ _lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
+ _isSelecting = false;
+ }
+ else
+ {
+ // Single click: start selection
+ _selectionStart = _cursorPosition;
+ _selectionLength = 0;
+ _isSelecting = true;
+ _lastClickTime = now;
+ _lastClickX = e.X;
+ }
+
+ ResetCursorBlink();
+ Invalidate();
+ }
+
+ private void SelectWordAtCursor()
+ {
+ if (string.IsNullOrEmpty(Text)) return;
+
+ // Find word boundaries
+ int start = _cursorPosition;
+ int end = _cursorPosition;
+
+ // Move start backwards to beginning of word
+ while (start > 0 && IsWordChar(Text[start - 1]))
+ start--;
+
+ // Move end forwards to end of word
+ while (end < Text.Length && IsWordChar(Text[end]))
+ end++;
+
+ _selectionStart = start;
+ _cursorPosition = end;
+ _selectionLength = end - start;
+ }
+
+ private static bool IsWordChar(char c)
+ {
+ return char.IsLetterOrDigit(c) || c == '_';
+ }
+
+ public override void OnPointerMoved(PointerEventArgs e)
+ {
+ if (!IsEnabled || !_isSelecting) return;
+
+ // Extend selection to current mouse position
+ var screenBounds = ScreenBounds;
+ var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
+ var newPosition = GetCharacterIndexAtX(clickX);
+
+ if (newPosition != _cursorPosition)
+ {
+ _cursorPosition = newPosition;
+ _selectionLength = _cursorPosition - _selectionStart;
+ ResetCursorBlink();
+ Invalidate();
+ }
+ }
+
+ public override void OnPointerReleased(PointerEventArgs e)
+ {
+ _isSelecting = false;
+ }
+
+ private int GetCharacterIndexAtX(float x)
+ {
+ if (string.IsNullOrEmpty(Text)) return 0;
+
+ var fontStyle = GetFontStyle();
+ var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
+ ?? SKTypeface.Default;
+
+ using var font = new SKFont(typeface, (float)FontSize);
+ using var paint = new SKPaint(font);
+
+ var displayText = GetDisplayText();
+
+ for (int i = 0; i <= displayText.Length; i++)
+ {
+ var substring = displayText.Substring(0, i);
+ var width = paint.MeasureText(substring);
+
+ if (width >= x)
+ {
+ // Check if closer to current or previous character
+ if (i > 0)
+ {
+ var prevWidth = paint.MeasureText(displayText.Substring(0, i - 1));
+ if (x - prevWidth < width - x)
+ return i - 1;
+ }
+ return i;
+ }
+ }
+
+ return displayText.Length;
+ }
+
+ private void DeleteSelection()
+ {
+ var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
+ var length = Math.Abs(_selectionLength);
+
+ Text = Text.Remove(start, length);
+ _cursorPosition = start;
+ _selectionLength = 0;
+ }
+
+ private void ClearSelection()
+ {
+ _selectionLength = 0;
+ }
+
+ private void ExtendSelection(int delta)
+ {
+ if (_selectionLength == 0)
+ {
+ _selectionStart = _cursorPosition;
+ }
+
+ _cursorPosition += delta;
+ _selectionLength = _cursorPosition - _selectionStart;
+ }
+
+ private void ExtendSelectionTo(int position)
+ {
+ if (_selectionLength == 0)
+ {
+ _selectionStart = _cursorPosition;
+ }
+
+ _cursorPosition = position;
+ _selectionLength = _cursorPosition - _selectionStart;
+ }
+
+ ///
+ /// Selects all text.
+ ///
+ public void SelectAll()
+ {
+ _selectionStart = 0;
+ _cursorPosition = Text.Length;
+ _selectionLength = Text.Length;
+ Invalidate();
+ }
+
+ private void CopyToClipboard()
+ {
+ // Password fields should not allow copying
+ if (IsPassword) return;
+ if (_selectionLength == 0) return;
+
+ var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
+ var length = Math.Abs(_selectionLength);
+ var selectedText = Text.Substring(start, length);
+
+ // Use system clipboard via xclip/xsel
+ SystemClipboard.SetText(selectedText);
+ }
+
+ private void CutToClipboard()
+ {
+ // Password fields should not allow cutting
+ if (IsPassword) return;
+
+ CopyToClipboard();
+ DeleteSelection();
+ Invalidate();
+ }
+
+ private void PasteFromClipboard()
+ {
+ // Get from system clipboard
+ var text = SystemClipboard.GetText();
+ if (string.IsNullOrEmpty(text)) return;
+
+ if (_selectionLength != 0)
+ {
+ DeleteSelection();
+ }
+
+ // Check max length
+ if (MaxLength > 0)
+ {
+ var remaining = MaxLength - Text.Length;
+ text = text.Substring(0, Math.Min(text.Length, remaining));
+ }
+
+ var newText = Text.Insert(_cursorPosition, text);
+ var newPos = _cursorPosition + text.Length;
+ Text = newText;
+ _cursorPosition = newPos;
+ Invalidate();
+ }
+
+ private void ShowContextMenu(float x, float y)
+ {
+ DiagnosticLog.Debug("SkiaEntry", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
+ bool hasSelection = _selectionLength != 0;
+ bool hasText = !string.IsNullOrEmpty(Text);
+ bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
+
+ if (LinuxApplication.IsGtkMode)
+ {
+ // Use GTK context menu when running in GTK mode (e.g., with WebView)
+ GtkContextMenuService.ShowContextMenu(new List
+ {
+ new GtkMenuItem("Cut", () =>
+ {
+ CutToClipboard();
+ Invalidate();
+ }, hasSelection),
+ new GtkMenuItem("Copy", () =>
+ {
+ CopyToClipboard();
+ }, hasSelection),
+ new GtkMenuItem("Paste", () =>
+ {
+ PasteFromClipboard();
+ Invalidate();
+ }, hasClipboard),
+ GtkMenuItem.Separator,
+ new GtkMenuItem("Select All", () =>
+ {
+ SelectAll();
+ Invalidate();
+ }, hasText)
+ });
+ }
+ else
+ {
+ // Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
+ bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
+ var items = new List
+ {
+ new ContextMenuItem("Cut", () =>
+ {
+ CutToClipboard();
+ Invalidate();
+ }, hasSelection),
+ new ContextMenuItem("Copy", () =>
+ {
+ CopyToClipboard();
+ }, hasSelection),
+ new ContextMenuItem("Paste", () =>
+ {
+ PasteFromClipboard();
+ Invalidate();
+ }, hasClipboard),
+ ContextMenuItem.Separator,
+ new ContextMenuItem("Select All", () =>
+ {
+ SelectAll();
+ Invalidate();
+ }, hasText)
+ };
+ var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
+ LinuxDialogService.ShowContextMenu(menu);
+ }
+ }
+
+ public override void OnFocusGained()
+ {
+ base.OnFocusGained();
+ SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
+
+ // Connect to IME service
+ _inputMethodService?.SetFocus(this);
+
+ // Update cursor location for IME candidate window positioning
+ UpdateImeCursorLocation();
+ }
+
+ public override void OnFocusLost()
+ {
+ base.OnFocusLost();
+ SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
+
+ // Disconnect from IME service and reset any composition
+ _inputMethodService?.SetFocus(null);
+ _preEditText = string.Empty;
+ _preEditCursorPosition = 0;
+ }
+
+ ///
+ /// Updates the IME cursor location for candidate window positioning.
+ ///
+ private void UpdateImeCursorLocation()
+ {
+ if (_inputMethodService == null) return;
+
+ var screenBounds = ScreenBounds;
+ var fontStyle = GetFontStyle();
+ var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
+ ?? SKTypeface.Default;
+
+ using var font = new SKFont(typeface, (float)FontSize);
+ using var paint = new SKPaint(font);
+
+ var displayText = GetDisplayText();
+ var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
+ var cursorX = paint.MeasureText(textToCursor);
+
+ int x = (int)(screenBounds.Left + Padding.Left - _scrollOffset + cursorX);
+ int y = (int)(screenBounds.Top + Padding.Top);
+ int height = (int)FontSize;
+
+ _inputMethodService.SetCursorLocation(x, y, 2, height);
+ }
+}
diff --git a/Views/SkiaEntry.cs b/Views/SkiaEntry.cs
index 9eb1110..a8e4b87 100644
--- a/Views/SkiaEntry.cs
+++ b/Views/SkiaEntry.cs
@@ -16,7 +16,7 @@ namespace Microsoft.Maui.Platform;
/// Skia-rendered text entry control with full XAML styling and data binding support.
/// Implements IInputContext for IME (Input Method Editor) support.
///
-public class SkiaEntry : SkiaView, IInputContext
+public partial class SkiaEntry : SkiaView, IInputContext
{
#region BindableProperties
@@ -707,92 +707,6 @@ public class SkiaEntry : SkiaView, IInputContext
_inputMethodService = InputMethodServiceFactory.Instance;
}
- #region IInputContext Implementation
-
- ///
- /// Gets or sets the text for IME context.
- ///
- string IInputContext.Text
- {
- get => Text;
- set => Text = value;
- }
-
- ///
- /// Gets or sets the cursor position for IME context.
- ///
- int IInputContext.CursorPosition
- {
- get => _cursorPosition;
- set => CursorPosition = value;
- }
-
- ///
- /// Gets the selection start for IME context.
- ///
- int IInputContext.SelectionStart => _selectionStart;
-
- ///
- /// Gets the selection length for IME context.
- ///
- int IInputContext.SelectionLength => _selectionLength;
-
- ///
- /// Called when IME commits text.
- ///
- public void OnTextCommitted(string text)
- {
- if (IsReadOnly) return;
-
- // Delete selection if any
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
-
- // Clear pre-edit text
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
-
- // Check max length
- if (MaxLength > 0 && Text.Length + text.Length > MaxLength)
- {
- text = text.Substring(0, MaxLength - Text.Length);
- }
-
- // Insert committed text at cursor
- var newText = Text.Insert(_cursorPosition, text);
- var newPos = _cursorPosition + text.Length;
- Text = newText;
- _cursorPosition = newPos;
-
- ResetCursorBlink();
- Invalidate();
- }
-
- ///
- /// Called when IME pre-edit (composition) text changes.
- ///
- public void OnPreEditChanged(string preEditText, int cursorPosition)
- {
- _preEditText = preEditText ?? string.Empty;
- _preEditCursorPosition = cursorPosition;
- ResetCursorBlink();
- Invalidate();
- }
-
- ///
- /// Called when IME pre-edit ends (cancelled or committed).
- ///
- public void OnPreEditEnded()
- {
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
- Invalidate();
- }
-
- #endregion
-
///
/// Converts a MAUI Color to SkiaSharp SKColor for rendering.
///
@@ -859,225 +773,6 @@ public class SkiaEntry : SkiaView, IInputContext
Invalidate();
}
- protected override void DrawBackground(SKCanvas canvas, SKRect bounds)
- {
- // Skip base background drawing if Entry is transparent
- // (transparent Entry is likely inside a Border that handles appearance)
- var bgColor = ToSKColor(EntryBackgroundColor);
- var baseBgColor = GetEffectiveBackgroundColor();
- if (bgColor.Alpha < 10 && baseBgColor.Alpha < 10)
- return;
-
- // Otherwise let base class draw
- base.DrawBackground(canvas, bounds);
- }
-
- protected override void OnDraw(SKCanvas canvas, SKRect bounds)
- {
- var bgColor = ToSKColor(EntryBackgroundColor);
- var isTransparent = bgColor.Alpha < 10; // Consider nearly transparent as transparent
-
- // Only draw background and border if not transparent
- // (transparent means the Entry is likely inside a Border that handles appearance)
- if (!isTransparent)
- {
- // Draw background
- using var bgPaint = new SKPaint
- {
- Color = bgColor,
- IsAntialias = true,
- Style = SKPaintStyle.Fill
- };
-
- var rect = new SKRoundRect(bounds, (float)CornerRadius);
- canvas.DrawRoundRect(rect, bgPaint);
-
- // Draw border
- var borderColor = IsFocused ? ToSKColor(FocusedBorderColor) : ToSKColor(BorderColor);
- var borderWidth = IsFocused ? (float)BorderWidth + 1 : (float)BorderWidth;
-
- using var borderPaint = new SKPaint
- {
- Color = borderColor,
- IsAntialias = true,
- Style = SKPaintStyle.Stroke,
- StrokeWidth = borderWidth
- };
- canvas.DrawRoundRect(rect, borderPaint);
- }
-
- // Calculate content bounds
- var contentBounds = new SKRect(
- bounds.Left + (float)Padding.Left,
- bounds.Top + (float)Padding.Top,
- bounds.Right - (float)Padding.Right,
- bounds.Bottom - (float)Padding.Bottom);
-
- // Reserve space for clear button if shown
- var clearButtonSize = 20f;
- var clearButtonMargin = 8f;
- var showClear = ShouldShowClearButton();
- if (showClear)
- {
- contentBounds.Right -= clearButtonSize + clearButtonMargin;
- }
-
- // Set up clipping for text area
- canvas.Save();
- canvas.ClipRect(contentBounds);
-
- var fontStyle = GetFontStyle();
- var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
- ?? SKTypeface.Default;
-
- using var font = new SKFont(typeface, (float)FontSize);
- using var paint = new SKPaint(font) { IsAntialias = true };
-
- var displayText = GetDisplayText();
- // Append pre-edit text at cursor position for IME composition display
- var preEditInsertPos = Math.Min(_cursorPosition, displayText.Length);
- var displayTextWithPreEdit = string.IsNullOrEmpty(_preEditText)
- ? displayText
- : displayText.Insert(preEditInsertPos, _preEditText);
- var hasText = !string.IsNullOrEmpty(displayTextWithPreEdit);
-
- if (hasText)
- {
- paint.Color = GetEffectiveTextColor();
-
- // Measure text to cursor position for scrolling
- var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
- var cursorX = paint.MeasureText(textToCursor);
-
- // Auto-scroll to keep cursor visible
- if (cursorX - _scrollOffset > contentBounds.Width - 10)
- {
- _scrollOffset = cursorX - contentBounds.Width + 10;
- }
- else if (cursorX - _scrollOffset < 0)
- {
- _scrollOffset = cursorX;
- }
-
- // Draw selection (check != 0 to handle both forward and backward selection)
- if (IsFocused && _selectionLength != 0)
- {
- DrawSelection(canvas, paint, displayText, contentBounds);
- }
-
- // Calculate text position based on vertical alignment
- var textBounds = new SKRect();
- paint.MeasureText(displayText, ref textBounds);
-
- float x = contentBounds.Left - _scrollOffset;
- float y = VerticalTextAlignment switch
- {
- TextAlignment.Start => contentBounds.Top - textBounds.Top,
- TextAlignment.End => contentBounds.Bottom - textBounds.Bottom,
- _ => contentBounds.MidY - textBounds.MidY // Center
- };
-
- // Draw the text with font fallback for emoji/CJK support
- DrawTextWithFallback(canvas, displayTextWithPreEdit, x, y, paint, typeface);
-
- // Draw underline for pre-edit (composition) text
- if (!string.IsNullOrEmpty(_preEditText))
- {
- DrawPreEditUnderline(canvas, paint, displayText, x, y, contentBounds);
- }
-
- // Draw cursor
- if (IsFocused && !IsReadOnly && _cursorVisible)
- {
- DrawCursor(canvas, paint, displayText, contentBounds);
- }
- }
- else if (!string.IsNullOrEmpty(Placeholder))
- {
- // Draw placeholder
- paint.Color = GetEffectivePlaceholderColor();
-
- var textBounds = new SKRect();
- paint.MeasureText(Placeholder, ref textBounds);
-
- float x = contentBounds.Left;
- float y = contentBounds.MidY - textBounds.MidY;
-
- canvas.DrawText(Placeholder, x, y, paint);
- }
- else if (IsFocused && !IsReadOnly && _cursorVisible)
- {
- // Draw cursor even with no text
- DrawCursor(canvas, paint, "", contentBounds);
- }
-
- canvas.Restore();
-
- // Draw clear button if applicable
- if (showClear)
- {
- DrawClearButton(canvas, bounds, clearButtonSize, clearButtonMargin);
- }
- }
-
- private bool ShouldShowClearButton()
- {
- if (string.IsNullOrEmpty(Text)) return false;
-
- // Check both legacy ShowClearButton and MAUI ClearButtonVisibility
- if (ShowClearButton && IsFocused) return true;
-
- return ClearButtonVisibility switch
- {
- ClearButtonVisibility.WhileEditing => IsFocused,
- ClearButtonVisibility.Never => false,
- _ => false
- };
- }
-
- private SKFontStyle GetFontStyle()
- {
- bool isBold = FontAttributes.HasFlag(FontAttributes.Bold);
- bool isItalic = FontAttributes.HasFlag(FontAttributes.Italic);
-
- if (isBold && isItalic)
- return SKFontStyle.BoldItalic;
- if (isBold)
- return SKFontStyle.Bold;
- if (isItalic)
- return SKFontStyle.Italic;
- return SKFontStyle.Normal;
- }
-
- private void DrawClearButton(SKCanvas canvas, SKRect bounds, float size, float margin)
- {
- var centerX = bounds.Right - margin - size / 2;
- var centerY = bounds.MidY;
-
- // Draw circle background
- using var circlePaint = new SKPaint
- {
- Color = SkiaTheme.Gray400SK,
- IsAntialias = true,
- Style = SKPaintStyle.Fill
- };
- canvas.DrawCircle(centerX, centerY, size / 2 - 2, circlePaint);
-
- // Draw X
- using var xPaint = new SKPaint
- {
- Color = SkiaTheme.BackgroundWhiteSK,
- IsAntialias = true,
- Style = SKPaintStyle.Stroke,
- StrokeWidth = 2,
- StrokeCap = SKStrokeCap.Round
- };
-
- var offset = size / 4 - 1;
- canvas.DrawLine(centerX - offset, centerY - offset, centerX + offset, centerY + offset, xPaint);
- canvas.DrawLine(centerX - offset, centerY + offset, centerX + offset, centerY - offset, xPaint);
- }
-
private string GetDisplayText()
{
if (IsPassword && !string.IsNullOrEmpty(Text))
@@ -1087,693 +782,7 @@ public class SkiaEntry : SkiaView, IInputContext
return Text;
}
- private void DrawSelection(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
- {
- var selStart = Math.Min(_selectionStart, _selectionStart + _selectionLength);
- var selEnd = Math.Max(_selectionStart, _selectionStart + _selectionLength);
-
- var textToStart = displayText.Substring(0, selStart);
- var textToEnd = displayText.Substring(0, selEnd);
-
- var startX = bounds.Left - _scrollOffset + paint.MeasureText(textToStart);
- var endX = bounds.Left - _scrollOffset + paint.MeasureText(textToEnd);
-
- using var selPaint = new SKPaint
- {
- Color = ToSKColor(SelectionColor),
- Style = SKPaintStyle.Fill
- };
-
- canvas.DrawRect(startX, bounds.Top, endX - startX, bounds.Height, selPaint);
- }
-
- private void DrawCursor(SKCanvas canvas, SKPaint paint, string displayText, SKRect bounds)
- {
- var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
- var cursorX = bounds.Left - _scrollOffset + paint.MeasureText(textToCursor);
-
- using var cursorPaint = new SKPaint
- {
- Color = ToSKColor(CursorColor),
- StrokeWidth = 2,
- IsAntialias = true
- };
-
- canvas.DrawLine(cursorX, bounds.Top + 2, cursorX, bounds.Bottom - 2, cursorPaint);
- }
-
- ///
- /// Draws text with font fallback for emoji, CJK, and other scripts.
- ///
- private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
- {
- if (string.IsNullOrEmpty(text))
- {
- return;
- }
-
- // Use FontFallbackManager for mixed-script text
- var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface);
-
- if (runs.Count <= 1)
- {
- // Single run or no fallback needed - draw directly
- canvas.DrawText(text, x, y, paint);
- return;
- }
-
- // Multiple runs with different fonts
- float currentX = x;
- foreach (var run in runs)
- {
- using var runFont = new SKFont(run.Typeface, (float)FontSize);
- using var runPaint = new SKPaint(runFont)
- {
- Color = paint.Color,
- IsAntialias = true
- };
-
- canvas.DrawText(run.Text, currentX, y, runPaint);
- currentX += runPaint.MeasureText(run.Text);
- }
- }
-
- ///
- /// Draws underline for IME pre-edit (composition) text.
- ///
- private void DrawPreEditUnderline(SKCanvas canvas, SKPaint paint, string displayText, float x, float y, SKRect bounds)
- {
- // Calculate pre-edit text position
- var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
- var preEditStartX = x + paint.MeasureText(textToCursor);
- var preEditEndX = preEditStartX + paint.MeasureText(_preEditText);
-
- // Draw dotted underline to indicate composition
- using var underlinePaint = new SKPaint
- {
- Color = paint.Color,
- StrokeWidth = 1,
- IsAntialias = true,
- PathEffect = SKPathEffect.CreateDash(new float[] { 3, 2 }, 0)
- };
-
- var underlineY = y + 2;
- canvas.DrawLine(preEditStartX, underlineY, preEditEndX, underlineY, underlinePaint);
- }
-
- private void ResetCursorBlink()
- {
- _cursorBlinkTime = DateTime.UtcNow;
- _cursorVisible = true;
- }
-
- ///
- /// Updates cursor blink animation.
- ///
- public void UpdateCursorBlink()
- {
- if (!IsFocused) return;
-
- var elapsed = (DateTime.UtcNow - _cursorBlinkTime).TotalMilliseconds;
- var newVisible = ((int)(elapsed / 500) % 2) == 0;
-
- if (newVisible != _cursorVisible)
- {
- _cursorVisible = newVisible;
- Invalidate();
- }
- }
-
- public override void OnTextInput(TextInputEventArgs e)
- {
- if (!IsEnabled || IsReadOnly) return;
-
- // Ignore control characters (Ctrl+key combinations send ASCII control codes)
- if (!string.IsNullOrEmpty(e.Text) && e.Text.Length == 1 && e.Text[0] < 32)
- return;
-
- // Delete selection if any
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
-
- // Check max length
- if (MaxLength > 0 && Text.Length >= MaxLength)
- return;
-
- // Insert text at cursor
- var insertText = e.Text;
- if (MaxLength > 0)
- {
- var remaining = MaxLength - Text.Length;
- insertText = insertText.Substring(0, Math.Min(insertText.Length, remaining));
- }
-
- var newText = Text.Insert(_cursorPosition, insertText);
- var oldPos = _cursorPosition;
- Text = newText;
- _cursorPosition = oldPos + insertText.Length;
-
- ResetCursorBlink();
- Invalidate();
- }
-
- public override void OnKeyDown(KeyEventArgs e)
- {
- if (!IsEnabled) return;
-
- switch (e.Key)
- {
- case Key.Backspace:
- if (!IsReadOnly)
- {
- if (_selectionLength > 0)
- {
- DeleteSelection();
- }
- else if (_cursorPosition > 0)
- {
- var newText = Text.Remove(_cursorPosition - 1, 1);
- var newPos = _cursorPosition - 1;
- Text = newText;
- _cursorPosition = newPos;
- }
- ResetCursorBlink();
- Invalidate();
- }
- e.Handled = true;
- break;
-
- case Key.Delete:
- if (!IsReadOnly)
- {
- if (_selectionLength > 0)
- {
- DeleteSelection();
- }
- else if (_cursorPosition < Text.Length)
- {
- Text = Text.Remove(_cursorPosition, 1);
- }
- ResetCursorBlink();
- Invalidate();
- }
- e.Handled = true;
- break;
-
- case Key.Left:
- if (_cursorPosition > 0)
- {
- if (e.Modifiers.HasFlag(KeyModifiers.Shift))
- {
- ExtendSelection(-1);
- }
- else
- {
- ClearSelection();
- _cursorPosition--;
- }
- ResetCursorBlink();
- Invalidate();
- }
- e.Handled = true;
- break;
-
- case Key.Right:
- if (_cursorPosition < Text.Length)
- {
- if (e.Modifiers.HasFlag(KeyModifiers.Shift))
- {
- ExtendSelection(1);
- }
- else
- {
- ClearSelection();
- _cursorPosition++;
- }
- ResetCursorBlink();
- Invalidate();
- }
- e.Handled = true;
- break;
-
- case Key.Home:
- if (e.Modifiers.HasFlag(KeyModifiers.Shift))
- {
- ExtendSelectionTo(0);
- }
- else
- {
- ClearSelection();
- _cursorPosition = 0;
- }
- ResetCursorBlink();
- Invalidate();
- e.Handled = true;
- break;
-
- case Key.End:
- if (e.Modifiers.HasFlag(KeyModifiers.Shift))
- {
- ExtendSelectionTo(Text.Length);
- }
- else
- {
- ClearSelection();
- _cursorPosition = Text.Length;
- }
- ResetCursorBlink();
- Invalidate();
- e.Handled = true;
- break;
-
- case Key.A:
- if (e.Modifiers.HasFlag(KeyModifiers.Control))
- {
- SelectAll();
- e.Handled = true;
- }
- break;
-
- case Key.C:
- if (e.Modifiers.HasFlag(KeyModifiers.Control))
- {
- CopyToClipboard();
- e.Handled = true;
- }
- break;
-
- case Key.V:
- if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
- {
- PasteFromClipboard();
- e.Handled = true;
- }
- break;
-
- case Key.X:
- if (e.Modifiers.HasFlag(KeyModifiers.Control) && !IsReadOnly)
- {
- CutToClipboard();
- e.Handled = true;
- }
- break;
-
- case Key.Enter:
- Completed?.Invoke(this, EventArgs.Empty);
- // Execute ReturnCommand if set and can execute
- if (ReturnCommand?.CanExecute(ReturnCommandParameter) == true)
- {
- ReturnCommand.Execute(ReturnCommandParameter);
- }
- e.Handled = true;
- break;
- }
- }
-
- public override void OnPointerPressed(PointerEventArgs e)
- {
- if (!IsEnabled) return;
-
- // Handle right-click context menu
- if (e.Button == PointerButton.Right)
- {
- ShowContextMenu(e.X, e.Y);
- return;
- }
-
- // Check if clicked on clear button
- if (ShouldShowClearButton())
- {
- var clearButtonSize = 20f;
- var clearButtonMargin = 8f;
- var clearCenterX = (float)(Bounds.Left + Bounds.Width) - clearButtonMargin - clearButtonSize / 2;
- var clearCenterY = (float)(Bounds.Top + Bounds.Height / 2);
-
- var dx = e.X - clearCenterX;
- var dy = e.Y - clearCenterY;
- if (dx * dx + dy * dy < (clearButtonSize / 2) * (clearButtonSize / 2))
- {
- // Clear button clicked
- Text = "";
- _cursorPosition = 0;
- _selectionLength = 0;
- Invalidate();
- return;
- }
- }
-
- // Calculate cursor position from click using screen coordinates
- var screenBounds = ScreenBounds;
- var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
- _cursorPosition = GetCharacterIndexAtX(clickX);
-
- // Check for double-click (select word or select all)
- var now = DateTime.UtcNow;
- var timeSinceLastClick = (now - _lastClickTime).TotalMilliseconds;
- var distanceFromLastClick = Math.Abs(e.X - _lastClickX);
-
- if (timeSinceLastClick < DoubleClickThresholdMs && distanceFromLastClick < 10)
- {
- // Double-click: select all or select word based on property
- if (SelectAllOnDoubleClick)
- {
- SelectAll();
- }
- else
- {
- SelectWordAtCursor();
- }
- _lastClickTime = DateTime.MinValue; // Reset to prevent triple-click issues
- _isSelecting = false;
- }
- else
- {
- // Single click: start selection
- _selectionStart = _cursorPosition;
- _selectionLength = 0;
- _isSelecting = true;
- _lastClickTime = now;
- _lastClickX = e.X;
- }
-
- ResetCursorBlink();
- Invalidate();
- }
-
- private void SelectWordAtCursor()
- {
- if (string.IsNullOrEmpty(Text)) return;
-
- // Find word boundaries
- int start = _cursorPosition;
- int end = _cursorPosition;
-
- // Move start backwards to beginning of word
- while (start > 0 && IsWordChar(Text[start - 1]))
- start--;
-
- // Move end forwards to end of word
- while (end < Text.Length && IsWordChar(Text[end]))
- end++;
-
- _selectionStart = start;
- _cursorPosition = end;
- _selectionLength = end - start;
- }
-
- private static bool IsWordChar(char c)
- {
- return char.IsLetterOrDigit(c) || c == '_';
- }
-
- public override void OnPointerMoved(PointerEventArgs e)
- {
- if (!IsEnabled || !_isSelecting) return;
-
- // Extend selection to current mouse position
- var screenBounds = ScreenBounds;
- var clickX = e.X - (float)screenBounds.Left - (float)Padding.Left + _scrollOffset;
- var newPosition = GetCharacterIndexAtX(clickX);
-
- if (newPosition != _cursorPosition)
- {
- _cursorPosition = newPosition;
- _selectionLength = _cursorPosition - _selectionStart;
- ResetCursorBlink();
- Invalidate();
- }
- }
-
- public override void OnPointerReleased(PointerEventArgs e)
- {
- _isSelecting = false;
- }
-
- private int GetCharacterIndexAtX(float x)
- {
- if (string.IsNullOrEmpty(Text)) return 0;
-
- var fontStyle = GetFontStyle();
- var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
- ?? SKTypeface.Default;
-
- using var font = new SKFont(typeface, (float)FontSize);
- using var paint = new SKPaint(font);
-
- var displayText = GetDisplayText();
-
- for (int i = 0; i <= displayText.Length; i++)
- {
- var substring = displayText.Substring(0, i);
- var width = paint.MeasureText(substring);
-
- if (width >= x)
- {
- // Check if closer to current or previous character
- if (i > 0)
- {
- var prevWidth = paint.MeasureText(displayText.Substring(0, i - 1));
- if (x - prevWidth < width - x)
- return i - 1;
- }
- return i;
- }
- }
-
- return displayText.Length;
- }
-
- private void DeleteSelection()
- {
- var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
- var length = Math.Abs(_selectionLength);
-
- Text = Text.Remove(start, length);
- _cursorPosition = start;
- _selectionLength = 0;
- }
-
- private void ClearSelection()
- {
- _selectionLength = 0;
- }
-
- private void ExtendSelection(int delta)
- {
- if (_selectionLength == 0)
- {
- _selectionStart = _cursorPosition;
- }
-
- _cursorPosition += delta;
- _selectionLength = _cursorPosition - _selectionStart;
- }
-
- private void ExtendSelectionTo(int position)
- {
- if (_selectionLength == 0)
- {
- _selectionStart = _cursorPosition;
- }
-
- _cursorPosition = position;
- _selectionLength = _cursorPosition - _selectionStart;
- }
-
- ///
- /// Selects all text.
- ///
- public void SelectAll()
- {
- _selectionStart = 0;
- _cursorPosition = Text.Length;
- _selectionLength = Text.Length;
- Invalidate();
- }
-
- private void CopyToClipboard()
- {
- // Password fields should not allow copying
- if (IsPassword) return;
- if (_selectionLength == 0) return;
-
- var start = Math.Min(_selectionStart, _selectionStart + _selectionLength);
- var length = Math.Abs(_selectionLength);
- var selectedText = Text.Substring(start, length);
-
- // Use system clipboard via xclip/xsel
- SystemClipboard.SetText(selectedText);
- }
-
- private void CutToClipboard()
- {
- // Password fields should not allow cutting
- if (IsPassword) return;
-
- CopyToClipboard();
- DeleteSelection();
- Invalidate();
- }
-
- private void PasteFromClipboard()
- {
- // Get from system clipboard
- var text = SystemClipboard.GetText();
- if (string.IsNullOrEmpty(text)) return;
-
- if (_selectionLength != 0)
- {
- DeleteSelection();
- }
-
- // Check max length
- if (MaxLength > 0)
- {
- var remaining = MaxLength - Text.Length;
- text = text.Substring(0, Math.Min(text.Length, remaining));
- }
-
- var newText = Text.Insert(_cursorPosition, text);
- var newPos = _cursorPosition + text.Length;
- Text = newText;
- _cursorPosition = newPos;
- Invalidate();
- }
-
- private void ShowContextMenu(float x, float y)
- {
- DiagnosticLog.Debug("SkiaEntry", $"ShowContextMenu at ({x}, {y}), IsGtkMode={LinuxApplication.IsGtkMode}");
- bool hasSelection = _selectionLength != 0;
- bool hasText = !string.IsNullOrEmpty(Text);
- bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText());
-
- if (LinuxApplication.IsGtkMode)
- {
- // Use GTK context menu when running in GTK mode (e.g., with WebView)
- GtkContextMenuService.ShowContextMenu(new List
- {
- new GtkMenuItem("Cut", () =>
- {
- CutToClipboard();
- Invalidate();
- }, hasSelection),
- new GtkMenuItem("Copy", () =>
- {
- CopyToClipboard();
- }, hasSelection),
- new GtkMenuItem("Paste", () =>
- {
- PasteFromClipboard();
- Invalidate();
- }, hasClipboard),
- GtkMenuItem.Separator,
- new GtkMenuItem("Select All", () =>
- {
- SelectAll();
- Invalidate();
- }, hasText)
- });
- }
- else
- {
- // Use Skia-rendered context menu for pure Skia mode (Wayland/X11)
- bool isDarkTheme = Application.Current?.RequestedTheme == AppTheme.Dark;
- var items = new List
- {
- new ContextMenuItem("Cut", () =>
- {
- CutToClipboard();
- Invalidate();
- }, hasSelection),
- new ContextMenuItem("Copy", () =>
- {
- CopyToClipboard();
- }, hasSelection),
- new ContextMenuItem("Paste", () =>
- {
- PasteFromClipboard();
- Invalidate();
- }, hasClipboard),
- ContextMenuItem.Separator,
- new ContextMenuItem("Select All", () =>
- {
- SelectAll();
- Invalidate();
- }, hasText)
- };
- var menu = new SkiaContextMenu(x, y, items, isDarkTheme);
- LinuxDialogService.ShowContextMenu(menu);
- }
- }
-
- public override void OnFocusGained()
- {
- base.OnFocusGained();
- SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Focused);
-
- // Connect to IME service
- _inputMethodService?.SetFocus(this);
-
- // Update cursor location for IME candidate window positioning
- UpdateImeCursorLocation();
- }
-
- public override void OnFocusLost()
- {
- base.OnFocusLost();
- SkiaVisualStateManager.GoToState(this, SkiaVisualStateManager.CommonStates.Normal);
-
- // Disconnect from IME service and reset any composition
- _inputMethodService?.SetFocus(null);
- _preEditText = string.Empty;
- _preEditCursorPosition = 0;
- }
-
- ///
- /// Updates the IME cursor location for candidate window positioning.
- ///
- private void UpdateImeCursorLocation()
- {
- if (_inputMethodService == null) return;
-
- var screenBounds = ScreenBounds;
- var fontStyle = GetFontStyle();
- var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
- ?? SKTypeface.Default;
-
- using var font = new SKFont(typeface, (float)FontSize);
- using var paint = new SKPaint(font);
-
- var displayText = GetDisplayText();
- var textToCursor = displayText.Substring(0, Math.Min(_cursorPosition, displayText.Length));
- var cursorX = paint.MeasureText(textToCursor);
-
- int x = (int)(screenBounds.Left + Padding.Left - _scrollOffset + cursorX);
- int y = (int)(screenBounds.Top + Padding.Top);
- int height = (int)FontSize;
-
- _inputMethodService.SetCursorLocation(x, y, 2, height);
- }
-
- protected override Size MeasureOverride(Size availableSize)
- {
- var fontStyle = GetFontStyle();
- var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(GetEffectiveFontFamily(), fontStyle)
- ?? SKTypeface.Default;
-
- using var font = new SKFont(typeface, (float)FontSize);
-
- // Use font metrics for consistent height regardless of text content
- // This prevents size changes when placeholder disappears or text changes
- var metrics = font.Metrics;
- var textHeight = metrics.Descent - metrics.Ascent + metrics.Leading;
-
- return new Size(
- 200, // Default width, will be overridden by layout
- textHeight + Padding.Top + Padding.Bottom + BorderWidth * 2);
- }
+ private SKFontStyle GetFontStyle() => TextRenderingHelper.GetFontStyle(FontAttributes);
}
///
diff --git a/Views/SkiaGrid.cs b/Views/SkiaGrid.cs
new file mode 100644
index 0000000..3ba6458
--- /dev/null
+++ b/Views/SkiaGrid.cs
@@ -0,0 +1,500 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+using Microsoft.Maui;
+
+namespace Microsoft.Maui.Platform;
+
+///
+/// Grid layout that arranges children in rows and columns.
+///
+public class SkiaGrid : SkiaLayoutView
+{
+ #region BindableProperties
+
+ ///
+ /// Bindable property for RowSpacing.
+ ///
+ public static readonly BindableProperty RowSpacingProperty =
+ BindableProperty.Create(
+ nameof(RowSpacing),
+ typeof(float),
+ typeof(SkiaGrid),
+ 0f,
+ BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
+
+ ///
+ /// Bindable property for ColumnSpacing.
+ ///
+ public static readonly BindableProperty ColumnSpacingProperty =
+ BindableProperty.Create(
+ nameof(ColumnSpacing),
+ typeof(float),
+ typeof(SkiaGrid),
+ 0f,
+ BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
+
+ #endregion
+
+ private readonly List _rowDefinitions = new();
+ private readonly List _columnDefinitions = new();
+ private readonly Dictionary _childPositions = new();
+
+ private float[] _rowHeights = Array.Empty();
+ private float[] _columnWidths = Array.Empty();
+
+ ///
+ /// Gets the row definitions.
+ ///
+ public IList RowDefinitions => _rowDefinitions;
+
+ ///
+ /// Gets the column definitions.
+ ///
+ public IList ColumnDefinitions => _columnDefinitions;
+
+ ///
+ /// Spacing between rows.
+ ///
+ public float RowSpacing
+ {
+ get => (float)GetValue(RowSpacingProperty);
+ set => SetValue(RowSpacingProperty, value);
+ }
+
+ ///
+ /// Spacing between columns.
+ ///
+ public float ColumnSpacing
+ {
+ get => (float)GetValue(ColumnSpacingProperty);
+ set => SetValue(ColumnSpacingProperty, value);
+ }
+
+ ///
+ /// Adds a child at the specified grid position.
+ ///
+ public void AddChild(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
+ {
+ base.AddChild(child);
+ _childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
+ }
+
+ public override void RemoveChild(SkiaView child)
+ {
+ base.RemoveChild(child);
+ _childPositions.Remove(child);
+ }
+
+ ///
+ /// Gets the grid position of a child.
+ ///
+ public GridPosition GetPosition(SkiaView child)
+ {
+ return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
+ }
+
+ ///
+ /// Sets the grid position of a child.
+ ///
+ public void SetPosition(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
+ {
+ _childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
+ InvalidateMeasure();
+ Invalidate();
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var contentWidth = (float)(availableSize.Width - Padding.Left - Padding.Right);
+ var contentHeight = (float)(availableSize.Height - Padding.Top - Padding.Bottom);
+
+ // Handle NaN/Infinity
+ if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
+ if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
+
+ var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
+ var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
+
+ // First pass: measure children in Auto columns to get natural widths
+ var columnNaturalWidths = new float[columnCount];
+ var rowNaturalHeights = new float[rowCount];
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var pos = GetPosition(child);
+
+ // For Auto columns, measure with infinite width to get natural size
+ var def = pos.Column < _columnDefinitions.Count ? _columnDefinitions[pos.Column] : GridLength.Star;
+ if (def.IsAuto && pos.ColumnSpan == 1)
+ {
+ var childSize = child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
+ var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
+ columnNaturalWidths[pos.Column] = Math.Max(columnNaturalWidths[pos.Column], childWidth);
+ }
+ }
+
+ // Calculate column widths - handle Auto, Absolute, and Star
+ _columnWidths = CalculateSizesWithAuto(_columnDefinitions, contentWidth, ColumnSpacing, columnCount, columnNaturalWidths);
+
+ // Second pass: measure all children with calculated column widths
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var pos = GetPosition(child);
+ var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
+
+ // Give infinite height for initial measure
+ var childSize = child.Measure(new Size(cellWidth, double.PositiveInfinity));
+
+ // Track max height for each row
+ // Cap infinite/very large heights - child returning infinity means it doesn't have a natural height
+ var childHeight = (float)childSize.Height;
+ if (float.IsNaN(childHeight) || float.IsInfinity(childHeight) || childHeight > 100000)
+ {
+ // Use a default minimum - will be expanded by Star sizing if finite height is available
+ childHeight = 44; // Standard row height
+ }
+ if (pos.RowSpan == 1)
+ {
+ rowNaturalHeights[pos.Row] = Math.Max(rowNaturalHeights[pos.Row], childHeight);
+ }
+ }
+
+ // Calculate row heights - use natural heights when available height is infinite or very large
+ // (Some layouts pass float.MaxValue instead of PositiveInfinity)
+ if (float.IsInfinity(contentHeight) || contentHeight > 100000)
+ {
+ _rowHeights = rowNaturalHeights;
+ }
+ else
+ {
+ _rowHeights = CalculateSizesWithAuto(_rowDefinitions, contentHeight, RowSpacing, rowCount, rowNaturalHeights);
+ }
+
+ // Third pass: re-measure children with actual cell sizes
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var pos = GetPosition(child);
+ var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
+ var cellHeight = GetCellHeight(pos.Row, pos.RowSpan);
+
+ child.Measure(new Size(cellWidth, cellHeight));
+ }
+
+ // Calculate total size
+ var totalWidth = _columnWidths.Sum() + Math.Max(0, columnCount - 1) * ColumnSpacing;
+ var totalHeight = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
+
+ return new Size(
+ totalWidth + Padding.Left + Padding.Right,
+ totalHeight + Padding.Top + Padding.Bottom);
+ }
+
+ private int GetMaxRow()
+ {
+ int maxRow = 0;
+ foreach (var pos in _childPositions.Values)
+ {
+ maxRow = Math.Max(maxRow, pos.Row + pos.RowSpan - 1);
+ }
+ return maxRow;
+ }
+
+ private int GetMaxColumn()
+ {
+ int maxCol = 0;
+ foreach (var pos in _childPositions.Values)
+ {
+ maxCol = Math.Max(maxCol, pos.Column + pos.ColumnSpan - 1);
+ }
+ return maxCol;
+ }
+
+ private float[] CalculateSizesWithAuto(List definitions, float available, float spacing, int count, float[] naturalSizes)
+ {
+ if (count == 0) return new float[] { available };
+
+ var sizes = new float[count];
+ var totalSpacing = Math.Max(0, count - 1) * spacing;
+ var remainingSpace = available - totalSpacing;
+
+ // First pass: absolute and auto sizes
+ float starTotal = 0;
+ for (int i = 0; i < count; i++)
+ {
+ var def = i < definitions.Count ? definitions[i] : GridLength.Star;
+
+ if (def.IsAbsolute)
+ {
+ sizes[i] = def.Value;
+ remainingSpace -= def.Value;
+ }
+ else if (def.IsAuto)
+ {
+ // Use natural size from measured children
+ sizes[i] = naturalSizes[i];
+ remainingSpace -= sizes[i];
+ }
+ else if (def.IsStar)
+ {
+ starTotal += def.Value;
+ }
+ }
+
+ // Second pass: star sizes (distribute remaining space)
+ if (starTotal > 0 && remainingSpace > 0)
+ {
+ for (int i = 0; i < count; i++)
+ {
+ var def = i < definitions.Count ? definitions[i] : GridLength.Star;
+ if (def.IsStar)
+ {
+ sizes[i] = (def.Value / starTotal) * remainingSpace;
+ }
+ }
+ }
+
+ return sizes;
+ }
+
+ private float GetCellWidth(int column, int span)
+ {
+ float width = 0;
+ for (int i = column; i < Math.Min(column + span, _columnWidths.Length); i++)
+ {
+ width += _columnWidths[i];
+ if (i > column) width += ColumnSpacing;
+ }
+ return width;
+ }
+
+ private float GetCellHeight(int row, int span)
+ {
+ float height = 0;
+ for (int i = row; i < Math.Min(row + span, _rowHeights.Length); i++)
+ {
+ height += _rowHeights[i];
+ if (i > row) height += RowSpacing;
+ }
+ return height;
+ }
+
+ private float GetColumnOffset(int column)
+ {
+ float offset = 0;
+ for (int i = 0; i < Math.Min(column, _columnWidths.Length); i++)
+ {
+ offset += _columnWidths[i] + ColumnSpacing;
+ }
+ return offset;
+ }
+
+ private float GetRowOffset(int row)
+ {
+ float offset = 0;
+ for (int i = 0; i < Math.Min(row, _rowHeights.Length); i++)
+ {
+ offset += _rowHeights[i] + RowSpacing;
+ }
+ return offset;
+ }
+
+ protected override Rect ArrangeOverride(Rect bounds)
+ {
+ try
+ {
+ var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
+
+ // Recalculate row heights for arrange bounds if they differ from measurement
+ // This ensures Star rows expand to fill available space
+ var rowCount = _rowHeights.Length > 0 ? _rowHeights.Length : 1;
+ var columnCount = _columnWidths.Length > 0 ? _columnWidths.Length : 1;
+ var arrangeRowHeights = _rowHeights;
+
+ // If we have arrange height and rows need recalculating
+ if (content.Height > 0 && !float.IsInfinity(content.Height))
+ {
+ var measuredRowsTotal = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
+
+ // If arrange height is larger than measured, redistribute to Star rows
+ if (content.Height > measuredRowsTotal + 1)
+ {
+ arrangeRowHeights = new float[rowCount];
+ var extraHeight = content.Height - measuredRowsTotal;
+
+ // Count Star rows (implicit rows without definitions are Star)
+ float totalStarWeight = 0;
+ for (int i = 0; i < rowCount; i++)
+ {
+ var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
+ if (def.IsStar) totalStarWeight += def.Value;
+ }
+
+ // Distribute extra height to Star rows
+ for (int i = 0; i < rowCount; i++)
+ {
+ var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
+ arrangeRowHeights[i] = i < _rowHeights.Length ? _rowHeights[i] : 0;
+
+ if (def.IsStar && totalStarWeight > 0)
+ {
+ arrangeRowHeights[i] += extraHeight * (def.Value / totalStarWeight);
+ }
+ }
+ }
+ else
+ {
+ arrangeRowHeights = _rowHeights;
+ }
+ }
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var pos = GetPosition(child);
+
+ var x = content.Left + GetColumnOffset(pos.Column);
+
+ // Calculate y using arrange row heights
+ float y = content.Top;
+ for (int i = 0; i < Math.Min(pos.Row, arrangeRowHeights.Length); i++)
+ {
+ y += arrangeRowHeights[i] + RowSpacing;
+ }
+
+ var width = GetCellWidth(pos.Column, pos.ColumnSpan);
+
+ // Calculate height using arrange row heights
+ float height = 0;
+ for (int i = pos.Row; i < Math.Min(pos.Row + pos.RowSpan, arrangeRowHeights.Length); i++)
+ {
+ height += arrangeRowHeights[i];
+ if (i > pos.Row) height += RowSpacing;
+ }
+
+ // Clamp infinite dimensions
+ if (float.IsInfinity(width) || float.IsNaN(width))
+ width = content.Width;
+ if (float.IsInfinity(height) || float.IsNaN(height) || height <= 0)
+ height = content.Height;
+
+ // Apply child's margin
+ var margin = child.Margin;
+ var cellX = x + (float)margin.Left;
+ var cellY = y + (float)margin.Top;
+ var cellWidth = width - (float)margin.Left - (float)margin.Right;
+ var cellHeight = height - (float)margin.Top - (float)margin.Bottom;
+
+ // Get child's desired size
+ var childDesiredSize = child.Measure(new Size(cellWidth, cellHeight));
+ var childWidth = (float)childDesiredSize.Width;
+ var childHeight = (float)childDesiredSize.Height;
+
+ var vAlign = (int)child.VerticalOptions.Alignment;
+
+ // Apply HorizontalOptions
+ // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
+ float finalX = cellX;
+ float finalWidth = cellWidth;
+ var hAlign = (int)child.HorizontalOptions.Alignment;
+ if (hAlign != 3 && childWidth < cellWidth && childWidth > 0) // 3 = Fill
+ {
+ finalWidth = childWidth;
+ if (hAlign == 1) // Center
+ finalX = cellX + (cellWidth - childWidth) / 2;
+ else if (hAlign == 2) // End
+ finalX = cellX + cellWidth - childWidth;
+ }
+
+ // Apply VerticalOptions
+ float finalY = cellY;
+ float finalHeight = cellHeight;
+ // vAlign already calculated above for debug logging
+ if (vAlign != 3 && childHeight < cellHeight && childHeight > 0) // 3 = Fill
+ {
+ finalHeight = childHeight;
+ if (vAlign == 1) // Center
+ finalY = cellY + (cellHeight - childHeight) / 2;
+ else if (vAlign == 2) // End
+ finalY = cellY + cellHeight - childHeight;
+ }
+
+ child.Arrange(new Rect(finalX, finalY, finalWidth, finalHeight));
+ }
+ return bounds;
+ }
+ catch (Exception ex)
+ {
+ DiagnosticLog.Error("SkiaGrid", $"EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}", ex);
+ DiagnosticLog.Error("SkiaGrid", $"Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
+ DiagnosticLog.Error("SkiaGrid", $"Stack trace: {ex.StackTrace}");
+ throw;
+ }
+ }
+}
+
+///
+/// Grid position information.
+///
+public readonly struct GridPosition
+{
+ public int Row { get; }
+ public int Column { get; }
+ public int RowSpan { get; }
+ public int ColumnSpan { get; }
+
+ public GridPosition(int row, int column, int rowSpan = 1, int columnSpan = 1)
+ {
+ Row = row;
+ Column = column;
+ RowSpan = Math.Max(1, rowSpan);
+ ColumnSpan = Math.Max(1, columnSpan);
+ }
+}
+
+///
+/// Grid length specification.
+///
+public readonly struct GridLength
+{
+ public float Value { get; }
+ public GridUnitType GridUnitType { get; }
+
+ public bool IsAbsolute => GridUnitType == GridUnitType.Absolute;
+ public bool IsAuto => GridUnitType == GridUnitType.Auto;
+ public bool IsStar => GridUnitType == GridUnitType.Star;
+
+ public static GridLength Auto => new(1, GridUnitType.Auto);
+ public static GridLength Star => new(1, GridUnitType.Star);
+
+ public GridLength(float value, GridUnitType unitType = GridUnitType.Absolute)
+ {
+ Value = value;
+ GridUnitType = unitType;
+ }
+
+ public static GridLength FromAbsolute(float value) => new(value, GridUnitType.Absolute);
+ public static GridLength FromStar(float value = 1) => new(value, GridUnitType.Star);
+}
+
+///
+/// Grid unit type options.
+///
+public enum GridUnitType
+{
+ Absolute,
+ Star,
+ Auto
+}
diff --git a/Views/SkiaLabel.cs b/Views/SkiaLabel.cs
index 592a16e..84c23db 100644
--- a/Views/SkiaLabel.cs
+++ b/Views/SkiaLabel.cs
@@ -606,11 +606,7 @@ public class SkiaLabel : SkiaView
OnTextChanged();
}
- private SKColor ToSKColor(Color? color)
- {
- if (color == null) return SkiaTheme.TextPrimarySK;
- return color.ToSKColor();
- }
+ private SKColor ToSKColor(Color? color) => TextRenderingHelper.ToSKColor(color, SkiaTheme.TextPrimarySK);
private string GetDisplayText()
{
@@ -631,16 +627,7 @@ public class SkiaLabel : SkiaView
};
}
- private SKFontStyle GetFontStyle()
- {
- bool isBold = FontAttributes.HasFlag(FontAttributes.Bold);
- bool isItalic = FontAttributes.HasFlag(FontAttributes.Italic);
-
- return new SKFontStyle(
- isBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal,
- SKFontStyleWidth.Normal,
- isItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
- }
+ private SKFontStyle GetFontStyle() => TextRenderingHelper.GetFontStyle(FontAttributes);
///
/// Determines if text should be rendered right-to-left based on FlowDirection.
@@ -878,39 +865,7 @@ public class SkiaLabel : SkiaView
/// Draws text with font fallback for emoji, CJK, and other scripts.
///
private void DrawTextWithFallback(SKCanvas canvas, string text, float x, float y, SKPaint paint, SKTypeface preferredTypeface)
- {
- if (string.IsNullOrEmpty(text))
- {
- return;
- }
-
- // Use FontFallbackManager for mixed-script text
- var runs = FontFallbackManager.Instance.ShapeTextWithFallback(text, preferredTypeface);
-
- if (runs.Count <= 1)
- {
- // Single run or no fallback needed - draw directly
- canvas.DrawText(text, x, y, paint);
- return;
- }
-
- // Multiple runs with different fonts
- float fontSize = FontSize > 0 ? (float)FontSize : 14f;
- float currentX = x;
-
- foreach (var run in runs)
- {
- using var runFont = new SKFont(run.Typeface, fontSize);
- using var runPaint = new SKPaint(runFont)
- {
- Color = paint.Color,
- IsAntialias = true
- };
-
- canvas.DrawText(run.Text, currentX, y, runPaint);
- currentX += runPaint.MeasureText(run.Text);
- }
- }
+ => TextRenderingHelper.DrawTextWithFallback(canvas, text, x, y, paint, preferredTypeface, FontSize > 0 ? (float)FontSize : 14f);
///
/// Draws formatted span text with font fallback for emoji, CJK, and other scripts.
diff --git a/Views/SkiaLayoutView.cs b/Views/SkiaLayoutView.cs
index 745d80c..a1ef4a3 100644
--- a/Views/SkiaLayoutView.cs
+++ b/Views/SkiaLayoutView.cs
@@ -315,860 +315,3 @@ public abstract class SkiaLayoutView : SkiaView
}
}
}
-
-///
-/// Stack layout that arranges children in a horizontal or vertical line.
-///
-public class SkiaStackLayout : SkiaLayoutView
-{
- ///
- /// Bindable property for Orientation.
- ///
- public static readonly BindableProperty OrientationProperty =
- BindableProperty.Create(
- nameof(Orientation),
- typeof(StackOrientation),
- typeof(SkiaStackLayout),
- StackOrientation.Vertical,
- BindingMode.TwoWay,
- propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
-
- ///
- /// Gets or sets the orientation of the stack.
- ///
- public StackOrientation Orientation
- {
- get => (StackOrientation)GetValue(OrientationProperty);
- set => SetValue(OrientationProperty, value);
- }
-
- protected override Size MeasureOverride(Size availableSize)
- {
- // Handle NaN/Infinity in padding
- var paddingLeft = (float)(double.IsNaN(Padding.Left) ? 0 : Padding.Left);
- var paddingRight = (float)(double.IsNaN(Padding.Right) ? 0 : Padding.Right);
- var paddingTop = (float)(double.IsNaN(Padding.Top) ? 0 : Padding.Top);
- var paddingBottom = (float)(double.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom);
-
- var contentWidth = (float)availableSize.Width - paddingLeft - paddingRight;
- var contentHeight = (float)availableSize.Height - paddingTop - paddingBottom;
-
- // Clamp negative sizes to 0
- if (contentWidth < 0 || float.IsNaN(contentWidth)) contentWidth = 0;
- if (contentHeight < 0 || float.IsNaN(contentHeight)) contentHeight = 0;
-
- float totalWidth = 0;
- float totalHeight = 0;
- float maxWidth = 0;
- float maxHeight = 0;
-
- // For stack layouts, give children infinite size in the stacking direction
- // so they can measure to their natural size
- var childAvailable = Orientation == StackOrientation.Horizontal
- ? new Size(double.PositiveInfinity, contentHeight) // Horizontal: infinite width, constrained height
- : new Size(contentWidth, double.PositiveInfinity); // Vertical: constrained width, infinite height
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var childSize = child.Measure(childAvailable);
-
- // Skip NaN sizes from child measurements
- var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
- var childHeight = double.IsNaN(childSize.Height) ? 0f : (float)childSize.Height;
-
- if (Orientation == StackOrientation.Vertical)
- {
- totalHeight += childHeight;
- maxWidth = Math.Max(maxWidth, childWidth);
- }
- else
- {
- totalWidth += childWidth;
- maxHeight = Math.Max(maxHeight, childHeight);
- }
- }
-
- // Add spacing
- var visibleCount = Children.Count(c => c.IsVisible);
- var totalSpacing = (float)(Math.Max(0, visibleCount - 1) * Spacing);
-
- if (Orientation == StackOrientation.Vertical)
- {
- totalHeight += totalSpacing;
- return new Size(
- maxWidth + paddingLeft + paddingRight,
- totalHeight + paddingTop + paddingBottom);
- }
- else
- {
- totalWidth += totalSpacing;
- return new Size(
- totalWidth + paddingLeft + paddingRight,
- maxHeight + paddingTop + paddingBottom);
- }
- }
-
- protected override Rect ArrangeOverride(Rect bounds)
- {
- var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
-
- // Clamp content dimensions if infinite - use reasonable defaults
- var contentWidth = float.IsInfinity(content.Width) || float.IsNaN(content.Width) ? 800f : content.Width;
- var contentHeight = float.IsInfinity(content.Height) || float.IsNaN(content.Height) ? 600f : content.Height;
-
- float offset = 0;
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var childDesired = child.DesiredSize;
-
- // Handle NaN and Infinity in desired size
- var childWidth = double.IsNaN(childDesired.Width) || double.IsInfinity(childDesired.Width)
- ? contentWidth
- : (float)childDesired.Width;
- var childHeight = double.IsNaN(childDesired.Height) || double.IsInfinity(childDesired.Height)
- ? contentHeight
- : (float)childDesired.Height;
-
- float childBoundsLeft, childBoundsTop, childBoundsWidth, childBoundsHeight;
- if (Orientation == StackOrientation.Vertical)
- {
- // For ScrollView children, give them the remaining viewport height
- // Clamp to avoid giving them their content size
- var remainingHeight = Math.Max(0, contentHeight - offset);
- var useHeight = child is SkiaScrollView
- ? remainingHeight
- : Math.Min(childHeight, remainingHeight > 0 ? remainingHeight : childHeight);
-
- // Respect child's HorizontalOptions for vertical layouts
- var useWidth = Math.Min(childWidth, contentWidth);
- float childLeft = content.Left;
-
- var horizontalOptions = child.HorizontalOptions;
- var alignmentValue = (int)horizontalOptions.Alignment;
-
- // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
- if (alignmentValue == 1) // Center
- {
- childLeft = content.Left + (contentWidth - useWidth) / 2;
- }
- else if (alignmentValue == 2) // End
- {
- childLeft = content.Left + contentWidth - useWidth;
- }
- else if (alignmentValue == 3) // Fill
- {
- useWidth = contentWidth;
- }
-
- childBoundsLeft = childLeft;
- childBoundsTop = content.Top + offset;
- childBoundsWidth = useWidth;
- childBoundsHeight = useHeight;
- offset += useHeight + (float)Spacing;
- }
- else
- {
- // Horizontal stack: give each child its measured width
- // Don't constrain - let content overflow if needed (parent clips)
- var useWidth = childWidth;
-
- // Respect child's VerticalOptions for horizontal layouts
- var useHeight = Math.Min(childHeight, contentHeight);
- float childTop = content.Top;
- float childBottomCalc = content.Top + useHeight;
-
- var verticalOptions = child.VerticalOptions;
- var alignmentValue = (int)verticalOptions.Alignment;
-
- // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
- if (alignmentValue == 1) // Center
- {
- childTop = content.Top + (contentHeight - useHeight) / 2;
- childBottomCalc = childTop + useHeight;
- }
- else if (alignmentValue == 2) // End
- {
- childTop = content.Top + contentHeight - useHeight;
- childBottomCalc = content.Top + contentHeight;
- }
- else if (alignmentValue == 3) // Fill
- {
- childTop = content.Top;
- childBottomCalc = content.Top + contentHeight;
- }
-
- childBoundsLeft = content.Left + offset;
- childBoundsTop = childTop;
- childBoundsWidth = useWidth;
- childBoundsHeight = childBottomCalc - childTop;
- offset += useWidth + (float)Spacing;
- }
-
- // Apply child's margin
- var margin = child.Margin;
- var marginedBounds = new Rect(
- childBoundsLeft + (float)margin.Left,
- childBoundsTop + (float)margin.Top,
- childBoundsWidth - (float)margin.Left - (float)margin.Right,
- childBoundsHeight - (float)margin.Top - (float)margin.Bottom);
- child.Arrange(marginedBounds);
- }
- return bounds;
- }
-}
-
-///
-/// Stack orientation options.
-///
-public enum StackOrientation
-{
- Vertical,
- Horizontal
-}
-
-///
-/// Grid layout that arranges children in rows and columns.
-///
-public class SkiaGrid : SkiaLayoutView
-{
- #region BindableProperties
-
- ///
- /// Bindable property for RowSpacing.
- ///
- public static readonly BindableProperty RowSpacingProperty =
- BindableProperty.Create(
- nameof(RowSpacing),
- typeof(float),
- typeof(SkiaGrid),
- 0f,
- BindingMode.TwoWay,
- propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
-
- ///
- /// Bindable property for ColumnSpacing.
- ///
- public static readonly BindableProperty ColumnSpacingProperty =
- BindableProperty.Create(
- nameof(ColumnSpacing),
- typeof(float),
- typeof(SkiaGrid),
- 0f,
- BindingMode.TwoWay,
- propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
-
- #endregion
-
- private readonly List _rowDefinitions = new();
- private readonly List _columnDefinitions = new();
- private readonly Dictionary _childPositions = new();
-
- private float[] _rowHeights = Array.Empty();
- private float[] _columnWidths = Array.Empty();
-
- ///
- /// Gets the row definitions.
- ///
- public IList RowDefinitions => _rowDefinitions;
-
- ///
- /// Gets the column definitions.
- ///
- public IList ColumnDefinitions => _columnDefinitions;
-
- ///
- /// Spacing between rows.
- ///
- public float RowSpacing
- {
- get => (float)GetValue(RowSpacingProperty);
- set => SetValue(RowSpacingProperty, value);
- }
-
- ///
- /// Spacing between columns.
- ///
- public float ColumnSpacing
- {
- get => (float)GetValue(ColumnSpacingProperty);
- set => SetValue(ColumnSpacingProperty, value);
- }
-
- ///
- /// Adds a child at the specified grid position.
- ///
- public void AddChild(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
- {
- base.AddChild(child);
- _childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
- }
-
- public override void RemoveChild(SkiaView child)
- {
- base.RemoveChild(child);
- _childPositions.Remove(child);
- }
-
- ///
- /// Gets the grid position of a child.
- ///
- public GridPosition GetPosition(SkiaView child)
- {
- return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
- }
-
- ///
- /// Sets the grid position of a child.
- ///
- public void SetPosition(SkiaView child, int row, int column, int rowSpan = 1, int columnSpan = 1)
- {
- _childPositions[child] = new GridPosition(row, column, rowSpan, columnSpan);
- InvalidateMeasure();
- Invalidate();
- }
-
- protected override Size MeasureOverride(Size availableSize)
- {
- var contentWidth = (float)(availableSize.Width - Padding.Left - Padding.Right);
- var contentHeight = (float)(availableSize.Height - Padding.Top - Padding.Bottom);
-
- // Handle NaN/Infinity
- if (float.IsNaN(contentWidth) || float.IsInfinity(contentWidth)) contentWidth = 800;
- if (float.IsNaN(contentHeight) || float.IsInfinity(contentHeight)) contentHeight = float.PositiveInfinity;
-
- var rowCount = Math.Max(1, _rowDefinitions.Count > 0 ? _rowDefinitions.Count : GetMaxRow() + 1);
- var columnCount = Math.Max(1, _columnDefinitions.Count > 0 ? _columnDefinitions.Count : GetMaxColumn() + 1);
-
- // First pass: measure children in Auto columns to get natural widths
- var columnNaturalWidths = new float[columnCount];
- var rowNaturalHeights = new float[rowCount];
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var pos = GetPosition(child);
-
- // For Auto columns, measure with infinite width to get natural size
- var def = pos.Column < _columnDefinitions.Count ? _columnDefinitions[pos.Column] : GridLength.Star;
- if (def.IsAuto && pos.ColumnSpan == 1)
- {
- var childSize = child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
- var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
- columnNaturalWidths[pos.Column] = Math.Max(columnNaturalWidths[pos.Column], childWidth);
- }
- }
-
- // Calculate column widths - handle Auto, Absolute, and Star
- _columnWidths = CalculateSizesWithAuto(_columnDefinitions, contentWidth, ColumnSpacing, columnCount, columnNaturalWidths);
-
- // Second pass: measure all children with calculated column widths
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var pos = GetPosition(child);
- var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
-
- // Give infinite height for initial measure
- var childSize = child.Measure(new Size(cellWidth, double.PositiveInfinity));
-
- // Track max height for each row
- // Cap infinite/very large heights - child returning infinity means it doesn't have a natural height
- var childHeight = (float)childSize.Height;
- if (float.IsNaN(childHeight) || float.IsInfinity(childHeight) || childHeight > 100000)
- {
- // Use a default minimum - will be expanded by Star sizing if finite height is available
- childHeight = 44; // Standard row height
- }
- if (pos.RowSpan == 1)
- {
- rowNaturalHeights[pos.Row] = Math.Max(rowNaturalHeights[pos.Row], childHeight);
- }
- }
-
- // Calculate row heights - use natural heights when available height is infinite or very large
- // (Some layouts pass float.MaxValue instead of PositiveInfinity)
- if (float.IsInfinity(contentHeight) || contentHeight > 100000)
- {
- _rowHeights = rowNaturalHeights;
- }
- else
- {
- _rowHeights = CalculateSizesWithAuto(_rowDefinitions, contentHeight, RowSpacing, rowCount, rowNaturalHeights);
- }
-
- // Third pass: re-measure children with actual cell sizes
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var pos = GetPosition(child);
- var cellWidth = GetCellWidth(pos.Column, pos.ColumnSpan);
- var cellHeight = GetCellHeight(pos.Row, pos.RowSpan);
-
- child.Measure(new Size(cellWidth, cellHeight));
- }
-
- // Calculate total size
- var totalWidth = _columnWidths.Sum() + Math.Max(0, columnCount - 1) * ColumnSpacing;
- var totalHeight = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
-
- return new Size(
- totalWidth + Padding.Left + Padding.Right,
- totalHeight + Padding.Top + Padding.Bottom);
- }
-
- private int GetMaxRow()
- {
- int maxRow = 0;
- foreach (var pos in _childPositions.Values)
- {
- maxRow = Math.Max(maxRow, pos.Row + pos.RowSpan - 1);
- }
- return maxRow;
- }
-
- private int GetMaxColumn()
- {
- int maxCol = 0;
- foreach (var pos in _childPositions.Values)
- {
- maxCol = Math.Max(maxCol, pos.Column + pos.ColumnSpan - 1);
- }
- return maxCol;
- }
-
- private float[] CalculateSizesWithAuto(List definitions, float available, float spacing, int count, float[] naturalSizes)
- {
- if (count == 0) return new float[] { available };
-
- var sizes = new float[count];
- var totalSpacing = Math.Max(0, count - 1) * spacing;
- var remainingSpace = available - totalSpacing;
-
- // First pass: absolute and auto sizes
- float starTotal = 0;
- for (int i = 0; i < count; i++)
- {
- var def = i < definitions.Count ? definitions[i] : GridLength.Star;
-
- if (def.IsAbsolute)
- {
- sizes[i] = def.Value;
- remainingSpace -= def.Value;
- }
- else if (def.IsAuto)
- {
- // Use natural size from measured children
- sizes[i] = naturalSizes[i];
- remainingSpace -= sizes[i];
- }
- else if (def.IsStar)
- {
- starTotal += def.Value;
- }
- }
-
- // Second pass: star sizes (distribute remaining space)
- if (starTotal > 0 && remainingSpace > 0)
- {
- for (int i = 0; i < count; i++)
- {
- var def = i < definitions.Count ? definitions[i] : GridLength.Star;
- if (def.IsStar)
- {
- sizes[i] = (def.Value / starTotal) * remainingSpace;
- }
- }
- }
-
- return sizes;
- }
-
- private float GetCellWidth(int column, int span)
- {
- float width = 0;
- for (int i = column; i < Math.Min(column + span, _columnWidths.Length); i++)
- {
- width += _columnWidths[i];
- if (i > column) width += ColumnSpacing;
- }
- return width;
- }
-
- private float GetCellHeight(int row, int span)
- {
- float height = 0;
- for (int i = row; i < Math.Min(row + span, _rowHeights.Length); i++)
- {
- height += _rowHeights[i];
- if (i > row) height += RowSpacing;
- }
- return height;
- }
-
- private float GetColumnOffset(int column)
- {
- float offset = 0;
- for (int i = 0; i < Math.Min(column, _columnWidths.Length); i++)
- {
- offset += _columnWidths[i] + ColumnSpacing;
- }
- return offset;
- }
-
- private float GetRowOffset(int row)
- {
- float offset = 0;
- for (int i = 0; i < Math.Min(row, _rowHeights.Length); i++)
- {
- offset += _rowHeights[i] + RowSpacing;
- }
- return offset;
- }
-
- protected override Rect ArrangeOverride(Rect bounds)
- {
- try
- {
- var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
-
- // Recalculate row heights for arrange bounds if they differ from measurement
- // This ensures Star rows expand to fill available space
- var rowCount = _rowHeights.Length > 0 ? _rowHeights.Length : 1;
- var columnCount = _columnWidths.Length > 0 ? _columnWidths.Length : 1;
- var arrangeRowHeights = _rowHeights;
-
- // If we have arrange height and rows need recalculating
- if (content.Height > 0 && !float.IsInfinity(content.Height))
- {
- var measuredRowsTotal = _rowHeights.Sum() + Math.Max(0, rowCount - 1) * RowSpacing;
-
- // If arrange height is larger than measured, redistribute to Star rows
- if (content.Height > measuredRowsTotal + 1)
- {
- arrangeRowHeights = new float[rowCount];
- var extraHeight = content.Height - measuredRowsTotal;
-
- // Count Star rows (implicit rows without definitions are Star)
- float totalStarWeight = 0;
- for (int i = 0; i < rowCount; i++)
- {
- var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
- if (def.IsStar) totalStarWeight += def.Value;
- }
-
- // Distribute extra height to Star rows
- for (int i = 0; i < rowCount; i++)
- {
- var def = i < _rowDefinitions.Count ? _rowDefinitions[i] : GridLength.Star;
- arrangeRowHeights[i] = i < _rowHeights.Length ? _rowHeights[i] : 0;
-
- if (def.IsStar && totalStarWeight > 0)
- {
- arrangeRowHeights[i] += extraHeight * (def.Value / totalStarWeight);
- }
- }
- }
- else
- {
- arrangeRowHeights = _rowHeights;
- }
- }
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var pos = GetPosition(child);
-
- var x = content.Left + GetColumnOffset(pos.Column);
-
- // Calculate y using arrange row heights
- float y = content.Top;
- for (int i = 0; i < Math.Min(pos.Row, arrangeRowHeights.Length); i++)
- {
- y += arrangeRowHeights[i] + RowSpacing;
- }
-
- var width = GetCellWidth(pos.Column, pos.ColumnSpan);
-
- // Calculate height using arrange row heights
- float height = 0;
- for (int i = pos.Row; i < Math.Min(pos.Row + pos.RowSpan, arrangeRowHeights.Length); i++)
- {
- height += arrangeRowHeights[i];
- if (i > pos.Row) height += RowSpacing;
- }
-
- // Clamp infinite dimensions
- if (float.IsInfinity(width) || float.IsNaN(width))
- width = content.Width;
- if (float.IsInfinity(height) || float.IsNaN(height) || height <= 0)
- height = content.Height;
-
- // Apply child's margin
- var margin = child.Margin;
- var cellX = x + (float)margin.Left;
- var cellY = y + (float)margin.Top;
- var cellWidth = width - (float)margin.Left - (float)margin.Right;
- var cellHeight = height - (float)margin.Top - (float)margin.Bottom;
-
- // Get child's desired size
- var childDesiredSize = child.Measure(new Size(cellWidth, cellHeight));
- var childWidth = (float)childDesiredSize.Width;
- var childHeight = (float)childDesiredSize.Height;
-
- var vAlign = (int)child.VerticalOptions.Alignment;
-
- // Apply HorizontalOptions
- // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
- float finalX = cellX;
- float finalWidth = cellWidth;
- var hAlign = (int)child.HorizontalOptions.Alignment;
- if (hAlign != 3 && childWidth < cellWidth && childWidth > 0) // 3 = Fill
- {
- finalWidth = childWidth;
- if (hAlign == 1) // Center
- finalX = cellX + (cellWidth - childWidth) / 2;
- else if (hAlign == 2) // End
- finalX = cellX + cellWidth - childWidth;
- }
-
- // Apply VerticalOptions
- float finalY = cellY;
- float finalHeight = cellHeight;
- // vAlign already calculated above for debug logging
- if (vAlign != 3 && childHeight < cellHeight && childHeight > 0) // 3 = Fill
- {
- finalHeight = childHeight;
- if (vAlign == 1) // Center
- finalY = cellY + (cellHeight - childHeight) / 2;
- else if (vAlign == 2) // End
- finalY = cellY + cellHeight - childHeight;
- }
-
- child.Arrange(new Rect(finalX, finalY, finalWidth, finalHeight));
- }
- return bounds;
- }
- catch (Exception ex)
- {
- DiagnosticLog.Error("SkiaGrid", $"EXCEPTION in ArrangeOverride: {ex.GetType().Name}: {ex.Message}", ex);
- DiagnosticLog.Error("SkiaGrid", $"Bounds: {bounds}, RowHeights: {_rowHeights.Length}, RowDefs: {_rowDefinitions.Count}, Children: {Children.Count}");
- DiagnosticLog.Error("SkiaGrid", $"Stack trace: {ex.StackTrace}");
- throw;
- }
- }
-}
-
-///
-/// Grid position information.
-///
-public readonly struct GridPosition
-{
- public int Row { get; }
- public int Column { get; }
- public int RowSpan { get; }
- public int ColumnSpan { get; }
-
- public GridPosition(int row, int column, int rowSpan = 1, int columnSpan = 1)
- {
- Row = row;
- Column = column;
- RowSpan = Math.Max(1, rowSpan);
- ColumnSpan = Math.Max(1, columnSpan);
- }
-}
-
-///
-/// Grid length specification.
-///
-public readonly struct GridLength
-{
- public float Value { get; }
- public GridUnitType GridUnitType { get; }
-
- public bool IsAbsolute => GridUnitType == GridUnitType.Absolute;
- public bool IsAuto => GridUnitType == GridUnitType.Auto;
- public bool IsStar => GridUnitType == GridUnitType.Star;
-
- public static GridLength Auto => new(1, GridUnitType.Auto);
- public static GridLength Star => new(1, GridUnitType.Star);
-
- public GridLength(float value, GridUnitType unitType = GridUnitType.Absolute)
- {
- Value = value;
- GridUnitType = unitType;
- }
-
- public static GridLength FromAbsolute(float value) => new(value, GridUnitType.Absolute);
- public static GridLength FromStar(float value = 1) => new(value, GridUnitType.Star);
-}
-
-///
-/// Grid unit type options.
-///
-public enum GridUnitType
-{
- Absolute,
- Star,
- Auto
-}
-
-///
-/// Absolute layout that positions children at exact coordinates.
-///
-public class SkiaAbsoluteLayout : SkiaLayoutView
-{
- private readonly Dictionary _childBounds = new();
-
- ///
- /// Adds a child at the specified position and size.
- ///
- public void AddChild(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
- {
- base.AddChild(child);
- _childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
- }
-
- public override void RemoveChild(SkiaView child)
- {
- base.RemoveChild(child);
- _childBounds.Remove(child);
- }
-
- ///
- /// Gets the layout bounds for a child.
- ///
- public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
- {
- return _childBounds.TryGetValue(child, out var bounds)
- ? bounds
- : new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
- }
-
- ///
- /// Sets the layout bounds for a child.
- ///
- public void SetLayoutBounds(SkiaView child, SKRect bounds, AbsoluteLayoutFlags flags = AbsoluteLayoutFlags.None)
- {
- _childBounds[child] = new AbsoluteLayoutBounds(bounds, flags);
- InvalidateMeasure();
- Invalidate();
- }
-
- protected override Size MeasureOverride(Size availableSize)
- {
- float maxRight = 0;
- float maxBottom = 0;
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var layout = GetLayoutBounds(child);
- var bounds = layout.Bounds;
-
- child.Measure(new Size(bounds.Width, bounds.Height));
-
- maxRight = Math.Max(maxRight, bounds.Right);
- maxBottom = Math.Max(maxBottom, bounds.Bottom);
- }
-
- return new Size(
- maxRight + Padding.Left + Padding.Right,
- maxBottom + Padding.Top + Padding.Bottom);
- }
-
- protected override Rect ArrangeOverride(Rect bounds)
- {
- var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
-
- foreach (var child in Children)
- {
- if (!child.IsVisible) continue;
-
- var layout = GetLayoutBounds(child);
- var childBounds = layout.Bounds;
- var flags = layout.Flags;
-
- float x, y, width, height;
-
- // X position
- if (flags.HasFlag(AbsoluteLayoutFlags.XProportional))
- x = content.Left + childBounds.Left * content.Width;
- else
- x = content.Left + childBounds.Left;
-
- // Y position
- if (flags.HasFlag(AbsoluteLayoutFlags.YProportional))
- y = content.Top + childBounds.Top * content.Height;
- else
- y = content.Top + childBounds.Top;
-
- // Width
- if (flags.HasFlag(AbsoluteLayoutFlags.WidthProportional))
- width = childBounds.Width * content.Width;
- else if (childBounds.Width < 0)
- width = (float)child.DesiredSize.Width;
- else
- width = childBounds.Width;
-
- // Height
- if (flags.HasFlag(AbsoluteLayoutFlags.HeightProportional))
- height = childBounds.Height * content.Height;
- else if (childBounds.Height < 0)
- height = (float)child.DesiredSize.Height;
- else
- height = childBounds.Height;
-
- // Apply child's margin
- var margin = child.Margin;
- var marginedBounds = new Rect(
- x + (float)margin.Left,
- y + (float)margin.Top,
- width - (float)margin.Left - (float)margin.Right,
- height - (float)margin.Top - (float)margin.Bottom);
- child.Arrange(marginedBounds);
- }
- return bounds;
- }
-}
-
-///
-/// Absolute layout bounds for a child.
-///
-public readonly struct AbsoluteLayoutBounds
-{
- public SKRect Bounds { get; }
- public AbsoluteLayoutFlags Flags { get; }
-
- public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
- {
- Bounds = bounds;
- Flags = flags;
- }
-}
-
-///
-/// Flags for absolute layout positioning.
-///
-[Flags]
-public enum AbsoluteLayoutFlags
-{
- None = 0,
- XProportional = 1,
- YProportional = 2,
- WidthProportional = 4,
- HeightProportional = 8,
- PositionProportional = XProportional | YProportional,
- SizeProportional = WidthProportional | HeightProportional,
- All = XProportional | YProportional | WidthProportional | HeightProportional
-}
diff --git a/Views/SkiaStackLayout.cs b/Views/SkiaStackLayout.cs
new file mode 100644
index 0000000..5d4f091
--- /dev/null
+++ b/Views/SkiaStackLayout.cs
@@ -0,0 +1,224 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+using Microsoft.Maui;
+
+namespace Microsoft.Maui.Platform;
+
+///
+/// Stack layout that arranges children in a horizontal or vertical line.
+///
+public class SkiaStackLayout : SkiaLayoutView
+{
+ ///
+ /// Bindable property for Orientation.
+ ///
+ public static readonly BindableProperty OrientationProperty =
+ BindableProperty.Create(
+ nameof(Orientation),
+ typeof(StackOrientation),
+ typeof(SkiaStackLayout),
+ StackOrientation.Vertical,
+ BindingMode.TwoWay,
+ propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
+
+ ///
+ /// Gets or sets the orientation of the stack.
+ ///
+ public StackOrientation Orientation
+ {
+ get => (StackOrientation)GetValue(OrientationProperty);
+ set => SetValue(OrientationProperty, value);
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ // Handle NaN/Infinity in padding
+ var paddingLeft = (float)(double.IsNaN(Padding.Left) ? 0 : Padding.Left);
+ var paddingRight = (float)(double.IsNaN(Padding.Right) ? 0 : Padding.Right);
+ var paddingTop = (float)(double.IsNaN(Padding.Top) ? 0 : Padding.Top);
+ var paddingBottom = (float)(double.IsNaN(Padding.Bottom) ? 0 : Padding.Bottom);
+
+ var contentWidth = (float)availableSize.Width - paddingLeft - paddingRight;
+ var contentHeight = (float)availableSize.Height - paddingTop - paddingBottom;
+
+ // Clamp negative sizes to 0
+ if (contentWidth < 0 || float.IsNaN(contentWidth)) contentWidth = 0;
+ if (contentHeight < 0 || float.IsNaN(contentHeight)) contentHeight = 0;
+
+ float totalWidth = 0;
+ float totalHeight = 0;
+ float maxWidth = 0;
+ float maxHeight = 0;
+
+ // For stack layouts, give children infinite size in the stacking direction
+ // so they can measure to their natural size
+ var childAvailable = Orientation == StackOrientation.Horizontal
+ ? new Size(double.PositiveInfinity, contentHeight) // Horizontal: infinite width, constrained height
+ : new Size(contentWidth, double.PositiveInfinity); // Vertical: constrained width, infinite height
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var childSize = child.Measure(childAvailable);
+
+ // Skip NaN sizes from child measurements
+ var childWidth = double.IsNaN(childSize.Width) ? 0f : (float)childSize.Width;
+ var childHeight = double.IsNaN(childSize.Height) ? 0f : (float)childSize.Height;
+
+ if (Orientation == StackOrientation.Vertical)
+ {
+ totalHeight += childHeight;
+ maxWidth = Math.Max(maxWidth, childWidth);
+ }
+ else
+ {
+ totalWidth += childWidth;
+ maxHeight = Math.Max(maxHeight, childHeight);
+ }
+ }
+
+ // Add spacing
+ var visibleCount = Children.Count(c => c.IsVisible);
+ var totalSpacing = (float)(Math.Max(0, visibleCount - 1) * Spacing);
+
+ if (Orientation == StackOrientation.Vertical)
+ {
+ totalHeight += totalSpacing;
+ return new Size(
+ maxWidth + paddingLeft + paddingRight,
+ totalHeight + paddingTop + paddingBottom);
+ }
+ else
+ {
+ totalWidth += totalSpacing;
+ return new Size(
+ totalWidth + paddingLeft + paddingRight,
+ maxHeight + paddingTop + paddingBottom);
+ }
+ }
+
+ protected override Rect ArrangeOverride(Rect bounds)
+ {
+ var content = GetContentBounds(new SKRect((float)bounds.Left, (float)bounds.Top, (float)bounds.Right, (float)bounds.Bottom));
+
+ // Clamp content dimensions if infinite - use reasonable defaults
+ var contentWidth = float.IsInfinity(content.Width) || float.IsNaN(content.Width) ? 800f : content.Width;
+ var contentHeight = float.IsInfinity(content.Height) || float.IsNaN(content.Height) ? 600f : content.Height;
+
+ float offset = 0;
+
+ foreach (var child in Children)
+ {
+ if (!child.IsVisible) continue;
+
+ var childDesired = child.DesiredSize;
+
+ // Handle NaN and Infinity in desired size
+ var childWidth = double.IsNaN(childDesired.Width) || double.IsInfinity(childDesired.Width)
+ ? contentWidth
+ : (float)childDesired.Width;
+ var childHeight = double.IsNaN(childDesired.Height) || double.IsInfinity(childDesired.Height)
+ ? contentHeight
+ : (float)childDesired.Height;
+
+ float childBoundsLeft, childBoundsTop, childBoundsWidth, childBoundsHeight;
+ if (Orientation == StackOrientation.Vertical)
+ {
+ // For ScrollView children, give them the remaining viewport height
+ // Clamp to avoid giving them their content size
+ var remainingHeight = Math.Max(0, contentHeight - offset);
+ var useHeight = child is SkiaScrollView
+ ? remainingHeight
+ : Math.Min(childHeight, remainingHeight > 0 ? remainingHeight : childHeight);
+
+ // Respect child's HorizontalOptions for vertical layouts
+ var useWidth = Math.Min(childWidth, contentWidth);
+ float childLeft = content.Left;
+
+ var horizontalOptions = child.HorizontalOptions;
+ var alignmentValue = (int)horizontalOptions.Alignment;
+
+ // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
+ if (alignmentValue == 1) // Center
+ {
+ childLeft = content.Left + (contentWidth - useWidth) / 2;
+ }
+ else if (alignmentValue == 2) // End
+ {
+ childLeft = content.Left + contentWidth - useWidth;
+ }
+ else if (alignmentValue == 3) // Fill
+ {
+ useWidth = contentWidth;
+ }
+
+ childBoundsLeft = childLeft;
+ childBoundsTop = content.Top + offset;
+ childBoundsWidth = useWidth;
+ childBoundsHeight = useHeight;
+ offset += useHeight + (float)Spacing;
+ }
+ else
+ {
+ // Horizontal stack: give each child its measured width
+ // Don't constrain - let content overflow if needed (parent clips)
+ var useWidth = childWidth;
+
+ // Respect child's VerticalOptions for horizontal layouts
+ var useHeight = Math.Min(childHeight, contentHeight);
+ float childTop = content.Top;
+ float childBottomCalc = content.Top + useHeight;
+
+ var verticalOptions = child.VerticalOptions;
+ var alignmentValue = (int)verticalOptions.Alignment;
+
+ // LayoutAlignment: Start=0, Center=1, End=2, Fill=3
+ if (alignmentValue == 1) // Center
+ {
+ childTop = content.Top + (contentHeight - useHeight) / 2;
+ childBottomCalc = childTop + useHeight;
+ }
+ else if (alignmentValue == 2) // End
+ {
+ childTop = content.Top + contentHeight - useHeight;
+ childBottomCalc = content.Top + contentHeight;
+ }
+ else if (alignmentValue == 3) // Fill
+ {
+ childTop = content.Top;
+ childBottomCalc = content.Top + contentHeight;
+ }
+
+ childBoundsLeft = content.Left + offset;
+ childBoundsTop = childTop;
+ childBoundsWidth = useWidth;
+ childBoundsHeight = childBottomCalc - childTop;
+ offset += useWidth + (float)Spacing;
+ }
+
+ // Apply child's margin
+ var margin = child.Margin;
+ var marginedBounds = new Rect(
+ childBoundsLeft + (float)margin.Left,
+ childBoundsTop + (float)margin.Top,
+ childBoundsWidth - (float)margin.Left - (float)margin.Right,
+ childBoundsHeight - (float)margin.Top - (float)margin.Bottom);
+ child.Arrange(marginedBounds);
+ }
+ return bounds;
+ }
+}
+
+///
+/// Stack orientation options.
+///
+public enum StackOrientation
+{
+ Vertical,
+ Horizontal
+}
diff --git a/Views/SkiaView.Accessibility.cs b/Views/SkiaView.Accessibility.cs
new file mode 100644
index 0000000..3e1ccd8
--- /dev/null
+++ b/Views/SkiaView.Accessibility.cs
@@ -0,0 +1,311 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Platform.Linux.Rendering;
+using Microsoft.Maui.Platform.Linux.Services;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public abstract partial class SkiaView
+{
+ // Popup overlay system for dropdowns, calendars, etc.
+ private static readonly List<(SkiaView Owner, Action Draw)> _popupOverlays = new();
+
+ public static void RegisterPopupOverlay(SkiaView owner, Action drawAction)
+ {
+ _popupOverlays.RemoveAll(p => p.Owner == owner);
+ _popupOverlays.Add((owner, drawAction));
+ }
+
+ public static void UnregisterPopupOverlay(SkiaView owner)
+ {
+ _popupOverlays.RemoveAll(p => p.Owner == owner);
+ }
+
+ public static void DrawPopupOverlays(SKCanvas canvas)
+ {
+ // Restore canvas to clean state for overlay drawing
+ // Save count tells us how many unmatched Saves there are
+ while (canvas.SaveCount > 1)
+ {
+ canvas.Restore();
+ }
+
+ foreach (var (_, draw) in _popupOverlays)
+ {
+ canvas.Save();
+ draw(canvas);
+ canvas.Restore();
+ }
+ }
+
+ ///
+ /// Gets the popup owner that should receive pointer events at the given coordinates.
+ /// This allows popups to receive events even outside their normal bounds.
+ ///
+ public static SkiaView? GetPopupOwnerAt(float x, float y)
+ {
+ // Check in reverse order (topmost popup first)
+ for (int i = _popupOverlays.Count - 1; i >= 0; i--)
+ {
+ var owner = _popupOverlays[i].Owner;
+ if (owner.HitTestPopupArea(x, y))
+ {
+ return owner;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Checks if there are any active popup overlays.
+ ///
+ public static bool HasActivePopup => _popupOverlays.Count > 0;
+
+ ///
+ /// Override this to define the popup area for hit testing.
+ ///
+ protected virtual bool HitTestPopupArea(float x, float y)
+ {
+ // Default: no popup area beyond normal bounds
+ return Bounds.Contains(x, y);
+ }
+
+ #region High Contrast Support
+
+ private static HighContrastService? _highContrastService;
+ private static bool _highContrastInitialized;
+
+ ///
+ /// Gets whether high contrast mode is enabled.
+ ///
+ public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
+
+ ///
+ /// Gets the current high contrast colors, or default colors if not in high contrast mode.
+ ///
+ public static HighContrastColors GetHighContrastColors()
+ {
+ InitializeHighContrastService();
+ return _highContrastService?.GetColors() ?? new HighContrastColors
+ {
+ Background = SkiaTheme.BackgroundWhiteSK,
+ Foreground = SkiaTheme.TextPrimarySK,
+ Accent = SkiaTheme.PrimarySK,
+ Border = SkiaTheme.BorderMediumSK,
+ Error = SkiaTheme.ErrorSK,
+ Success = SkiaTheme.SuccessSK,
+ Warning = SkiaTheme.WarningSK,
+ Link = SkiaTheme.TextLinkSK,
+ LinkVisited = SkiaTheme.TextLinkVisitedSK,
+ Selection = SkiaTheme.PrimarySK,
+ SelectionText = SkiaTheme.BackgroundWhiteSK,
+ DisabledText = SkiaTheme.TextDisabledSK,
+ DisabledBackground = SkiaTheme.BackgroundDisabledSK
+ };
+ }
+
+ private static void InitializeHighContrastService()
+ {
+ if (_highContrastInitialized) return;
+ _highContrastInitialized = true;
+
+ try
+ {
+ _highContrastService = new HighContrastService();
+ _highContrastService.HighContrastChanged += OnHighContrastChanged;
+ _highContrastService.Initialize();
+ }
+ catch
+ {
+ // Ignore errors - high contrast is optional
+ }
+ }
+
+ private static void OnHighContrastChanged(object? sender, HighContrastChangedEventArgs e)
+ {
+ // Request a full repaint of the UI
+ SkiaRenderingEngine.Current?.InvalidateAll();
+ }
+
+ #endregion
+
+ #region Accessibility Support (IAccessible)
+
+ private static IAccessibilityService? _accessibilityService;
+ private static bool _accessibilityInitialized;
+ private string _accessibleId = Guid.NewGuid().ToString();
+ private List? _accessibleChildren;
+
+ ///
+ /// Gets or sets the accessibility name for screen readers.
+ ///
+ public string? SemanticName { get; set; }
+
+ ///
+ /// Gets or sets the accessibility description for screen readers.
+ ///
+ public string? SemanticDescription { get; set; }
+
+ ///
+ /// Gets or sets the accessibility hint for screen readers.
+ ///
+ public string? SemanticHint { get; set; }
+
+ ///
+ /// Gets the accessibility service instance.
+ ///
+ protected static IAccessibilityService? AccessibilityService
+ {
+ get
+ {
+ InitializeAccessibilityService();
+ return _accessibilityService;
+ }
+ }
+
+ private static void InitializeAccessibilityService()
+ {
+ if (_accessibilityInitialized) return;
+ _accessibilityInitialized = true;
+
+ try
+ {
+ _accessibilityService = AccessibilityServiceFactory.Instance;
+ _accessibilityService?.Initialize();
+ }
+ catch
+ {
+ // Ignore errors - accessibility is optional
+ }
+ }
+
+ ///
+ /// Registers this view with the accessibility service.
+ ///
+ protected void RegisterAccessibility()
+ {
+ AccessibilityService?.Register(this);
+ }
+
+ ///
+ /// Unregisters this view from the accessibility service.
+ ///
+ protected void UnregisterAccessibility()
+ {
+ AccessibilityService?.Unregister(this);
+ }
+
+ ///
+ /// Announces text to screen readers.
+ ///
+ protected void AnnounceToScreenReader(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
+ {
+ AccessibilityService?.Announce(text, priority);
+ }
+
+ // IAccessible implementation
+ string IAccessible.AccessibleId => _accessibleId;
+
+ string IAccessible.AccessibleName => SemanticName ?? GetDefaultAccessibleName();
+
+ string IAccessible.AccessibleDescription => SemanticDescription ?? SemanticHint ?? string.Empty;
+
+ AccessibleRole IAccessible.Role => GetAccessibleRole();
+
+ AccessibleStates IAccessible.States => GetAccessibleStates();
+
+ IAccessible? IAccessible.Parent => Parent as IAccessible;
+
+ IReadOnlyList IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
+
+ AccessibleRect IAccessible.Bounds => new AccessibleRect(
+ (int)ScreenBounds.Left,
+ (int)ScreenBounds.Top,
+ (int)ScreenBounds.Width,
+ (int)ScreenBounds.Height);
+
+ IReadOnlyList IAccessible.Actions => GetAccessibleActions();
+
+ double? IAccessible.Value => GetAccessibleValue();
+ double? IAccessible.MinValue => GetAccessibleMinValue();
+ double? IAccessible.MaxValue => GetAccessibleMaxValue();
+
+ bool IAccessible.DoAction(string actionName) => DoAccessibleAction(actionName);
+ bool IAccessible.SetValue(double value) => SetAccessibleValue(value);
+
+ ///
+ /// Gets the default accessible name based on view content.
+ ///
+ protected virtual string GetDefaultAccessibleName() => string.Empty;
+
+ ///
+ /// Gets the accessible role for this view.
+ ///
+ protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
+
+ ///
+ /// Gets the current accessible states.
+ ///
+ protected virtual AccessibleStates GetAccessibleStates()
+ {
+ var states = AccessibleStates.None;
+ if (IsVisible) states |= AccessibleStates.Visible;
+ if (IsEnabled) states |= AccessibleStates.Enabled;
+ if (IsFocused) states |= AccessibleStates.Focused;
+ if (IsFocusable) states |= AccessibleStates.Focusable;
+ return states;
+ }
+
+ ///
+ /// Gets the accessible children of this view.
+ ///
+ protected virtual List GetAccessibleChildren()
+ {
+ var children = new List();
+ foreach (var child in Children)
+ {
+ if (child is IAccessible accessible)
+ {
+ children.Add(accessible);
+ }
+ }
+ return children;
+ }
+
+ ///
+ /// Gets the available accessible actions.
+ ///
+ protected virtual IReadOnlyList GetAccessibleActions()
+ {
+ return Array.Empty();
+ }
+
+ ///
+ /// Performs an accessible action.
+ ///
+ protected virtual bool DoAccessibleAction(string actionName) => false;
+
+ ///
+ /// Gets the accessible value (for sliders, progress bars, etc.).
+ ///
+ protected virtual double? GetAccessibleValue() => null;
+
+ ///
+ /// Gets the minimum accessible value.
+ ///
+ protected virtual double? GetAccessibleMinValue() => null;
+
+ ///
+ /// Gets the maximum accessible value.
+ ///
+ protected virtual double? GetAccessibleMaxValue() => null;
+
+ ///
+ /// Sets the accessible value.
+ ///
+ protected virtual bool SetAccessibleValue(double value) => false;
+
+ #endregion
+}
diff --git a/Views/SkiaView.Drawing.cs b/Views/SkiaView.Drawing.cs
new file mode 100644
index 0000000..54407b3
--- /dev/null
+++ b/Views/SkiaView.Drawing.cs
@@ -0,0 +1,250 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Controls;
+using Microsoft.Maui.Controls.Shapes;
+using Microsoft.Maui.Graphics;
+using SkiaSharp;
+
+namespace Microsoft.Maui.Platform;
+
+public abstract partial class SkiaView
+{
+ ///
+ /// Draws this view and its children to the canvas.
+ ///
+ public virtual void Draw(SKCanvas canvas)
+ {
+ if (!IsVisible || Opacity <= 0)
+ {
+ return;
+ }
+
+ canvas.Save();
+
+ // Get SKRect for internal rendering
+ var skBounds = BoundsSK;
+
+ // Apply transforms if any are set
+ if (Scale != 1.0 || ScaleX != 1.0 || ScaleY != 1.0 ||
+ Rotation != 0.0 || RotationX != 0.0 || RotationY != 0.0 ||
+ TranslationX != 0.0 || TranslationY != 0.0)
+ {
+ // Calculate anchor point in absolute coordinates
+ float anchorAbsX = skBounds.Left + (float)(Bounds.Width * AnchorX);
+ float anchorAbsY = skBounds.Top + (float)(Bounds.Height * AnchorY);
+
+ // Move origin to anchor point
+ canvas.Translate(anchorAbsX, anchorAbsY);
+
+ // Apply translation
+ if (TranslationX != 0.0 || TranslationY != 0.0)
+ {
+ canvas.Translate((float)TranslationX, (float)TranslationY);
+ }
+
+ // Apply rotation
+ if (Rotation != 0.0)
+ {
+ canvas.RotateDegrees((float)Rotation);
+ }
+
+ // Apply scale
+ float scaleX = (float)(Scale * ScaleX);
+ float scaleY = (float)(Scale * ScaleY);
+ if (scaleX != 1f || scaleY != 1f)
+ {
+ canvas.Scale(scaleX, scaleY);
+ }
+
+ // Move origin back
+ canvas.Translate(-anchorAbsX, -anchorAbsY);
+ }
+
+ // Apply opacity
+ if (Opacity < 1.0f)
+ {
+ canvas.SaveLayer(new SKPaint { Color = SKColors.White.WithAlpha((byte)(Opacity * 255)) });
+ }
+
+ // Draw shadow if set
+ if (Shadow != null)
+ {
+ DrawShadow(canvas, skBounds);
+ }
+
+ // Apply clip geometry if set
+ if (Clip != null)
+ {
+ ApplyClip(canvas, skBounds);
+ }
+
+ // Draw background at absolute bounds
+ DrawBackground(canvas, skBounds);
+
+ // Draw content at absolute bounds
+ OnDraw(canvas, skBounds);
+
+ // Draw children - they draw at their own absolute bounds
+ foreach (var child in _children)
+ {
+ child.Draw(canvas);
+ }
+
+ if (Opacity < 1.0f)
+ {
+ canvas.Restore();
+ }
+
+ canvas.Restore();
+ }
+
+ ///
+ /// Override to draw custom content.
+ ///
+ protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
+ {
+ }
+
+ ///
+ /// Draws the shadow for this view.
+ ///
+ protected virtual void DrawShadow(SKCanvas canvas, SKRect bounds)
+ {
+ if (Shadow == null) return;
+
+ var shadowColor = Shadow.Brush is SolidColorBrush scb
+ ? scb.Color.ToSKColor().WithAlpha((byte)(scb.Color.Alpha * 255 * Shadow.Opacity))
+ : SKColors.Black.WithAlpha((byte)(255 * Shadow.Opacity));
+
+ using var shadowPaint = new SKPaint
+ {
+ Color = shadowColor,
+ IsAntialias = true,
+ MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, (float)Shadow.Radius / 2)
+ };
+
+ var shadowBounds = new SKRect(
+ bounds.Left + (float)Shadow.Offset.X,
+ bounds.Top + (float)Shadow.Offset.Y,
+ bounds.Right + (float)Shadow.Offset.X,
+ bounds.Bottom + (float)Shadow.Offset.Y);
+
+ canvas.DrawRect(shadowBounds, shadowPaint);
+ }
+
+ ///
+ /// Applies the clip geometry to the canvas.
+ ///
+ protected virtual void ApplyClip(SKCanvas canvas, SKRect bounds)
+ {
+ if (Clip == null) return;
+
+ // Convert MAUI Geometry to SkiaSharp path
+ var path = ConvertGeometryToPath(Clip, bounds);
+ if (path != null)
+ {
+ canvas.ClipPath(path);
+ }
+ }
+
+ ///
+ /// Converts a MAUI Geometry to a SkiaSharp path.
+ ///
+ private SKPath? ConvertGeometryToPath(Geometry geometry, SKRect bounds)
+ {
+ var path = new SKPath();
+
+ if (geometry is RectangleGeometry rect)
+ {
+ var r = rect.Rect;
+ path.AddRect(new SKRect(
+ bounds.Left + (float)r.Left,
+ bounds.Top + (float)r.Top,
+ bounds.Left + (float)r.Right,
+ bounds.Top + (float)r.Bottom));
+ }
+ else if (geometry is EllipseGeometry ellipse)
+ {
+ path.AddOval(new SKRect(
+ bounds.Left + (float)(ellipse.Center.X - ellipse.RadiusX),
+ bounds.Top + (float)(ellipse.Center.Y - ellipse.RadiusY),
+ bounds.Left + (float)(ellipse.Center.X + ellipse.RadiusX),
+ bounds.Top + (float)(ellipse.Center.Y + ellipse.RadiusY)));
+ }
+ else if (geometry is RoundRectangleGeometry roundRect)
+ {
+ var r = roundRect.Rect;
+ var cr = roundRect.CornerRadius;
+ var skRect = new SKRect(
+ bounds.Left + (float)r.Left,
+ bounds.Top + (float)r.Top,
+ bounds.Left + (float)r.Right,
+ bounds.Top + (float)r.Bottom);
+ var skRoundRect = new SKRoundRect();
+ skRoundRect.SetRectRadii(skRect, new[]
+ {
+ new SKPoint((float)cr.TopLeft, (float)cr.TopLeft),
+ new SKPoint((float)cr.TopRight, (float)cr.TopRight),
+ new SKPoint((float)cr.BottomRight, (float)cr.BottomRight),
+ new SKPoint((float)cr.BottomLeft, (float)cr.BottomLeft)
+ });
+ path.AddRoundRect(skRoundRect);
+ }
+ // Add more geometry types as needed
+
+ return path;
+ }
+
+ ///
+ /// Draws the background (color or brush) for this view.
+ ///
+ protected virtual void DrawBackground(SKCanvas canvas, SKRect bounds)
+ {
+ // First try to use Background brush
+ if (Background != null)
+ {
+ using var paint = new SKPaint { IsAntialias = true };
+
+ if (Background is SolidColorBrush scb)
+ {
+ paint.Color = scb.Color.ToSKColor();
+ canvas.DrawRect(bounds, paint);
+ }
+ else if (Background is LinearGradientBrush lgb)
+ {
+ var start = new SKPoint(
+ bounds.Left + (float)(lgb.StartPoint.X * bounds.Width),
+ bounds.Top + (float)(lgb.StartPoint.Y * bounds.Height));
+ var end = new SKPoint(
+ bounds.Left + (float)(lgb.EndPoint.X * bounds.Width),
+ bounds.Top + (float)(lgb.EndPoint.Y * bounds.Height));
+
+ var colors = lgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
+ var positions = lgb.GradientStops.Select(s => s.Offset).ToArray();
+
+ paint.Shader = SKShader.CreateLinearGradient(start, end, colors, positions, SKShaderTileMode.Clamp);
+ canvas.DrawRect(bounds, paint);
+ }
+ else if (Background is RadialGradientBrush rgb)
+ {
+ var center = new SKPoint(
+ bounds.Left + (float)(rgb.Center.X * bounds.Width),
+ bounds.Top + (float)(rgb.Center.Y * bounds.Height));
+ var radius = (float)(rgb.Radius * Math.Max(bounds.Width, bounds.Height));
+
+ var colors = rgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
+ var positions = rgb.GradientStops.Select(s => s.Offset).ToArray();
+
+ paint.Shader = SKShader.CreateRadialGradient(center, radius, colors, positions, SKShaderTileMode.Clamp);
+ canvas.DrawRect(bounds, paint);
+ }
+ }
+ // Fall back to BackgroundColor (skip if transparent)
+ else if (_backgroundColorSK.Alpha > 0)
+ {
+ using var paint = new SKPaint { Color = _backgroundColorSK };
+ canvas.DrawRect(bounds, paint);
+ }
+ }
+}
diff --git a/Views/SkiaView.Input.cs b/Views/SkiaView.Input.cs
new file mode 100644
index 0000000..c427c9e
--- /dev/null
+++ b/Views/SkiaView.Input.cs
@@ -0,0 +1,105 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Maui.Graphics;
+using Microsoft.Maui.Platform.Linux.Handlers;
+using Microsoft.Maui.Platform.Linux.Services;
+
+namespace Microsoft.Maui.Platform;
+
+public abstract partial class SkiaView
+{
+ #region Input Events
+
+ public virtual void OnPointerEntered(PointerEventArgs e)
+ {
+ if (MauiView != null)
+ {
+ GestureManager.ProcessPointerEntered(MauiView, e.X, e.Y);
+ }
+ }
+
+ public virtual void OnPointerExited(PointerEventArgs e)
+ {
+ if (MauiView != null)
+ {
+ GestureManager.ProcessPointerExited(MauiView, e.X, e.Y);
+ }
+ }
+
+ public virtual void OnPointerMoved(PointerEventArgs e)
+ {
+ if (MauiView != null)
+ {
+ GestureManager.ProcessPointerMove(MauiView, e.X, e.Y);
+ }
+ }
+
+ public virtual void OnPointerPressed(PointerEventArgs e)
+ {
+ if (MauiView != null)
+ {
+ GestureManager.ProcessPointerDown(MauiView, e.X, e.Y);
+ }
+ }
+
+ public virtual void OnPointerReleased(PointerEventArgs e)
+ {
+ DiagnosticLog.Debug("SkiaView", $"OnPointerReleased on {GetType().Name}, MauiView={MauiView?.GetType().Name ?? "null"}");
+ if (MauiView != null)
+ {
+ GestureManager.ProcessPointerUp(MauiView, e.X, e.Y);
+ }
+ }
+
+ public virtual void OnScroll(ScrollEventArgs e) { }
+ public virtual void OnKeyDown(KeyEventArgs e) { }
+ public virtual void OnKeyUp(KeyEventArgs e) { }
+ public virtual void OnTextInput(TextInputEventArgs e) { }
+
+ public virtual void OnFocusGained()
+ {
+ IsFocused = true;
+ Invalidate();
+ }
+
+ public virtual void OnFocusLost()
+ {
+ IsFocused = false;
+ Invalidate();
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ // Clean up gesture tracking to prevent memory leaks
+ if (MauiView != null)
+ {
+ GestureManager.CleanupView(MauiView);
+ }
+
+ foreach (var child in _children)
+ {
+ child.Dispose();
+ }
+ _children.Clear();
+ }
+ _disposed = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ #endregion
+}
diff --git a/Views/SkiaView.cs b/Views/SkiaView.cs
index cc56eb8..f425b16 100644
--- a/Views/SkiaView.cs
+++ b/Views/SkiaView.cs
@@ -19,308 +19,8 @@ namespace Microsoft.Maui.Platform;
/// Inherits from BindableObject to enable XAML styling, data binding, and Visual State Manager.
/// Implements IAccessible for screen reader support.
///
-public abstract class SkiaView : BindableObject, IDisposable, IAccessible
+public abstract partial class SkiaView : BindableObject, IDisposable, IAccessible
{
- // Popup overlay system for dropdowns, calendars, etc.
- private static readonly List<(SkiaView Owner, Action Draw)> _popupOverlays = new();
-
- public static void RegisterPopupOverlay(SkiaView owner, Action drawAction)
- {
- _popupOverlays.RemoveAll(p => p.Owner == owner);
- _popupOverlays.Add((owner, drawAction));
- }
-
- public static void UnregisterPopupOverlay(SkiaView owner)
- {
- _popupOverlays.RemoveAll(p => p.Owner == owner);
- }
-
- public static void DrawPopupOverlays(SKCanvas canvas)
- {
- // Restore canvas to clean state for overlay drawing
- // Save count tells us how many unmatched Saves there are
- while (canvas.SaveCount > 1)
- {
- canvas.Restore();
- }
-
- foreach (var (_, draw) in _popupOverlays)
- {
- canvas.Save();
- draw(canvas);
- canvas.Restore();
- }
- }
-
- ///
- /// Gets the popup owner that should receive pointer events at the given coordinates.
- /// This allows popups to receive events even outside their normal bounds.
- ///
- public static SkiaView? GetPopupOwnerAt(float x, float y)
- {
- // Check in reverse order (topmost popup first)
- for (int i = _popupOverlays.Count - 1; i >= 0; i--)
- {
- var owner = _popupOverlays[i].Owner;
- if (owner.HitTestPopupArea(x, y))
- {
- return owner;
- }
- }
- return null;
- }
-
- ///
- /// Checks if there are any active popup overlays.
- ///
- public static bool HasActivePopup => _popupOverlays.Count > 0;
-
- ///
- /// Override this to define the popup area for hit testing.
- ///
- protected virtual bool HitTestPopupArea(float x, float y)
- {
- // Default: no popup area beyond normal bounds
- return Bounds.Contains(x, y);
- }
-
- #region High Contrast Support
-
- private static HighContrastService? _highContrastService;
- private static bool _highContrastInitialized;
-
- ///
- /// Gets whether high contrast mode is enabled.
- ///
- public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
-
- ///
- /// Gets the current high contrast colors, or default colors if not in high contrast mode.
- ///
- public static HighContrastColors GetHighContrastColors()
- {
- InitializeHighContrastService();
- return _highContrastService?.GetColors() ?? new HighContrastColors
- {
- Background = SkiaTheme.BackgroundWhiteSK,
- Foreground = SkiaTheme.TextPrimarySK,
- Accent = SkiaTheme.PrimarySK,
- Border = SkiaTheme.BorderMediumSK,
- Error = SkiaTheme.ErrorSK,
- Success = SkiaTheme.SuccessSK,
- Warning = SkiaTheme.WarningSK,
- Link = SkiaTheme.TextLinkSK,
- LinkVisited = SkiaTheme.TextLinkVisitedSK,
- Selection = SkiaTheme.PrimarySK,
- SelectionText = SkiaTheme.BackgroundWhiteSK,
- DisabledText = SkiaTheme.TextDisabledSK,
- DisabledBackground = SkiaTheme.BackgroundDisabledSK
- };
- }
-
- private static void InitializeHighContrastService()
- {
- if (_highContrastInitialized) return;
- _highContrastInitialized = true;
-
- try
- {
- _highContrastService = new HighContrastService();
- _highContrastService.HighContrastChanged += OnHighContrastChanged;
- _highContrastService.Initialize();
- }
- catch
- {
- // Ignore errors - high contrast is optional
- }
- }
-
- private static void OnHighContrastChanged(object? sender, HighContrastChangedEventArgs e)
- {
- // Request a full repaint of the UI
- SkiaRenderingEngine.Current?.InvalidateAll();
- }
-
- #endregion
-
- #region Accessibility Support (IAccessible)
-
- private static IAccessibilityService? _accessibilityService;
- private static bool _accessibilityInitialized;
- private string _accessibleId = Guid.NewGuid().ToString();
- private List? _accessibleChildren;
-
- ///
- /// Gets or sets the accessibility name for screen readers.
- ///
- public string? SemanticName { get; set; }
-
- ///
- /// Gets or sets the accessibility description for screen readers.
- ///
- public string? SemanticDescription { get; set; }
-
- ///
- /// Gets or sets the accessibility hint for screen readers.
- ///
- public string? SemanticHint { get; set; }
-
- ///
- /// Gets the accessibility service instance.
- ///
- protected static IAccessibilityService? AccessibilityService
- {
- get
- {
- InitializeAccessibilityService();
- return _accessibilityService;
- }
- }
-
- private static void InitializeAccessibilityService()
- {
- if (_accessibilityInitialized) return;
- _accessibilityInitialized = true;
-
- try
- {
- _accessibilityService = AccessibilityServiceFactory.Instance;
- _accessibilityService?.Initialize();
- }
- catch
- {
- // Ignore errors - accessibility is optional
- }
- }
-
- ///
- /// Registers this view with the accessibility service.
- ///
- protected void RegisterAccessibility()
- {
- AccessibilityService?.Register(this);
- }
-
- ///
- /// Unregisters this view from the accessibility service.
- ///
- protected void UnregisterAccessibility()
- {
- AccessibilityService?.Unregister(this);
- }
-
- ///
- /// Announces text to screen readers.
- ///
- protected void AnnounceToScreenReader(string text, AnnouncementPriority priority = AnnouncementPriority.Polite)
- {
- AccessibilityService?.Announce(text, priority);
- }
-
- // IAccessible implementation
- string IAccessible.AccessibleId => _accessibleId;
-
- string IAccessible.AccessibleName => SemanticName ?? GetDefaultAccessibleName();
-
- string IAccessible.AccessibleDescription => SemanticDescription ?? SemanticHint ?? string.Empty;
-
- AccessibleRole IAccessible.Role => GetAccessibleRole();
-
- AccessibleStates IAccessible.States => GetAccessibleStates();
-
- IAccessible? IAccessible.Parent => Parent as IAccessible;
-
- IReadOnlyList IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
-
- AccessibleRect IAccessible.Bounds => new AccessibleRect(
- (int)ScreenBounds.Left,
- (int)ScreenBounds.Top,
- (int)ScreenBounds.Width,
- (int)ScreenBounds.Height);
-
- IReadOnlyList IAccessible.Actions => GetAccessibleActions();
-
- double? IAccessible.Value => GetAccessibleValue();
- double? IAccessible.MinValue => GetAccessibleMinValue();
- double? IAccessible.MaxValue => GetAccessibleMaxValue();
-
- bool IAccessible.DoAction(string actionName) => DoAccessibleAction(actionName);
- bool IAccessible.SetValue(double value) => SetAccessibleValue(value);
-
- ///
- /// Gets the default accessible name based on view content.
- ///
- protected virtual string GetDefaultAccessibleName() => string.Empty;
-
- ///
- /// Gets the accessible role for this view.
- ///
- protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
-
- ///
- /// Gets the current accessible states.
- ///
- protected virtual AccessibleStates GetAccessibleStates()
- {
- var states = AccessibleStates.None;
- if (IsVisible) states |= AccessibleStates.Visible;
- if (IsEnabled) states |= AccessibleStates.Enabled;
- if (IsFocused) states |= AccessibleStates.Focused;
- if (IsFocusable) states |= AccessibleStates.Focusable;
- return states;
- }
-
- ///
- /// Gets the accessible children of this view.
- ///
- protected virtual List GetAccessibleChildren()
- {
- var children = new List();
- foreach (var child in Children)
- {
- if (child is IAccessible accessible)
- {
- children.Add(accessible);
- }
- }
- return children;
- }
-
- ///
- /// Gets the available accessible actions.
- ///
- protected virtual IReadOnlyList GetAccessibleActions()
- {
- return Array.Empty();
- }
-
- ///
- /// Performs an accessible action.
- ///
- protected virtual bool DoAccessibleAction(string actionName) => false;
-
- ///
- /// Gets the accessible value (for sliders, progress bars, etc.).
- ///
- protected virtual double? GetAccessibleValue() => null;
-
- ///
- /// Gets the minimum accessible value.
- ///
- protected virtual double? GetAccessibleMinValue() => null;
-
- ///
- /// Gets the maximum accessible value.
- ///
- protected virtual double? GetAccessibleMaxValue() => null;
-
- ///
- /// Sets the accessible value.
- ///
- protected virtual bool SetAccessibleValue(double value) => false;
-
- #endregion
-
#region BindableProperties
///
@@ -1324,244 +1024,6 @@ public abstract class SkiaView : BindableObject, IDisposable, IAccessible
Invalidate();
}
- ///
- /// Draws this view and its children to the canvas.
- ///
- public virtual void Draw(SKCanvas canvas)
- {
- if (!IsVisible || Opacity <= 0)
- {
- return;
- }
-
- canvas.Save();
-
- // Get SKRect for internal rendering
- var skBounds = BoundsSK;
-
- // Apply transforms if any are set
- if (Scale != 1.0 || ScaleX != 1.0 || ScaleY != 1.0 ||
- Rotation != 0.0 || RotationX != 0.0 || RotationY != 0.0 ||
- TranslationX != 0.0 || TranslationY != 0.0)
- {
- // Calculate anchor point in absolute coordinates
- float anchorAbsX = skBounds.Left + (float)(Bounds.Width * AnchorX);
- float anchorAbsY = skBounds.Top + (float)(Bounds.Height * AnchorY);
-
- // Move origin to anchor point
- canvas.Translate(anchorAbsX, anchorAbsY);
-
- // Apply translation
- if (TranslationX != 0.0 || TranslationY != 0.0)
- {
- canvas.Translate((float)TranslationX, (float)TranslationY);
- }
-
- // Apply rotation
- if (Rotation != 0.0)
- {
- canvas.RotateDegrees((float)Rotation);
- }
-
- // Apply scale
- float scaleX = (float)(Scale * ScaleX);
- float scaleY = (float)(Scale * ScaleY);
- if (scaleX != 1f || scaleY != 1f)
- {
- canvas.Scale(scaleX, scaleY);
- }
-
- // Move origin back
- canvas.Translate(-anchorAbsX, -anchorAbsY);
- }
-
- // Apply opacity
- if (Opacity < 1.0f)
- {
- canvas.SaveLayer(new SKPaint { Color = SKColors.White.WithAlpha((byte)(Opacity * 255)) });
- }
-
- // Draw shadow if set
- if (Shadow != null)
- {
- DrawShadow(canvas, skBounds);
- }
-
- // Apply clip geometry if set
- if (Clip != null)
- {
- ApplyClip(canvas, skBounds);
- }
-
- // Draw background at absolute bounds
- DrawBackground(canvas, skBounds);
-
- // Draw content at absolute bounds
- OnDraw(canvas, skBounds);
-
- // Draw children - they draw at their own absolute bounds
- foreach (var child in _children)
- {
- child.Draw(canvas);
- }
-
- if (Opacity < 1.0f)
- {
- canvas.Restore();
- }
-
- canvas.Restore();
- }
-
- ///
- /// Override to draw custom content.
- ///
- protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
- {
- }
-
- ///
- /// Draws the shadow for this view.
- ///
- protected virtual void DrawShadow(SKCanvas canvas, SKRect bounds)
- {
- if (Shadow == null) return;
-
- var shadowColor = Shadow.Brush is SolidColorBrush scb
- ? scb.Color.ToSKColor().WithAlpha((byte)(scb.Color.Alpha * 255 * Shadow.Opacity))
- : SKColors.Black.WithAlpha((byte)(255 * Shadow.Opacity));
-
- using var shadowPaint = new SKPaint
- {
- Color = shadowColor,
- IsAntialias = true,
- MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, (float)Shadow.Radius / 2)
- };
-
- var shadowBounds = new SKRect(
- bounds.Left + (float)Shadow.Offset.X,
- bounds.Top + (float)Shadow.Offset.Y,
- bounds.Right + (float)Shadow.Offset.X,
- bounds.Bottom + (float)Shadow.Offset.Y);
-
- canvas.DrawRect(shadowBounds, shadowPaint);
- }
-
- ///
- /// Applies the clip geometry to the canvas.
- ///
- protected virtual void ApplyClip(SKCanvas canvas, SKRect bounds)
- {
- if (Clip == null) return;
-
- // Convert MAUI Geometry to SkiaSharp path
- var path = ConvertGeometryToPath(Clip, bounds);
- if (path != null)
- {
- canvas.ClipPath(path);
- }
- }
-
- ///
- /// Converts a MAUI Geometry to a SkiaSharp path.
- ///
- private SKPath? ConvertGeometryToPath(Geometry geometry, SKRect bounds)
- {
- var path = new SKPath();
-
- if (geometry is RectangleGeometry rect)
- {
- var r = rect.Rect;
- path.AddRect(new SKRect(
- bounds.Left + (float)r.Left,
- bounds.Top + (float)r.Top,
- bounds.Left + (float)r.Right,
- bounds.Top + (float)r.Bottom));
- }
- else if (geometry is EllipseGeometry ellipse)
- {
- path.AddOval(new SKRect(
- bounds.Left + (float)(ellipse.Center.X - ellipse.RadiusX),
- bounds.Top + (float)(ellipse.Center.Y - ellipse.RadiusY),
- bounds.Left + (float)(ellipse.Center.X + ellipse.RadiusX),
- bounds.Top + (float)(ellipse.Center.Y + ellipse.RadiusY)));
- }
- else if (geometry is RoundRectangleGeometry roundRect)
- {
- var r = roundRect.Rect;
- var cr = roundRect.CornerRadius;
- var skRect = new SKRect(
- bounds.Left + (float)r.Left,
- bounds.Top + (float)r.Top,
- bounds.Left + (float)r.Right,
- bounds.Top + (float)r.Bottom);
- var skRoundRect = new SKRoundRect();
- skRoundRect.SetRectRadii(skRect, new[]
- {
- new SKPoint((float)cr.TopLeft, (float)cr.TopLeft),
- new SKPoint((float)cr.TopRight, (float)cr.TopRight),
- new SKPoint((float)cr.BottomRight, (float)cr.BottomRight),
- new SKPoint((float)cr.BottomLeft, (float)cr.BottomLeft)
- });
- path.AddRoundRect(skRoundRect);
- }
- // Add more geometry types as needed
-
- return path;
- }
-
- ///
- /// Draws the background (color or brush) for this view.
- ///
- protected virtual void DrawBackground(SKCanvas canvas, SKRect bounds)
- {
- // First try to use Background brush
- if (Background != null)
- {
- using var paint = new SKPaint { IsAntialias = true };
-
- if (Background is SolidColorBrush scb)
- {
- paint.Color = scb.Color.ToSKColor();
- canvas.DrawRect(bounds, paint);
- }
- else if (Background is LinearGradientBrush lgb)
- {
- var start = new SKPoint(
- bounds.Left + (float)(lgb.StartPoint.X * bounds.Width),
- bounds.Top + (float)(lgb.StartPoint.Y * bounds.Height));
- var end = new SKPoint(
- bounds.Left + (float)(lgb.EndPoint.X * bounds.Width),
- bounds.Top + (float)(lgb.EndPoint.Y * bounds.Height));
-
- var colors = lgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
- var positions = lgb.GradientStops.Select(s => s.Offset).ToArray();
-
- paint.Shader = SKShader.CreateLinearGradient(start, end, colors, positions, SKShaderTileMode.Clamp);
- canvas.DrawRect(bounds, paint);
- }
- else if (Background is RadialGradientBrush rgb)
- {
- var center = new SKPoint(
- bounds.Left + (float)(rgb.Center.X * bounds.Width),
- bounds.Top + (float)(rgb.Center.Y * bounds.Height));
- var radius = (float)(rgb.Radius * Math.Max(bounds.Width, bounds.Height));
-
- var colors = rgb.GradientStops.Select(s => s.Color.ToSKColor()).ToArray();
- var positions = rgb.GradientStops.Select(s => s.Offset).ToArray();
-
- paint.Shader = SKShader.CreateRadialGradient(center, radius, colors, positions, SKShaderTileMode.Clamp);
- canvas.DrawRect(bounds, paint);
- }
- }
- // Fall back to BackgroundColor (skip if transparent)
- else if (_backgroundColorSK.Alpha > 0)
- {
- using var paint = new SKPaint { Color = _backgroundColorSK };
- canvas.DrawRect(bounds, paint);
- }
- }
-
///
/// Called when the bounds change.
///
@@ -1645,100 +1107,6 @@ public abstract class SkiaView : BindableObject, IDisposable, IAccessible
return this;
}
-
- #region Input Events
-
- public virtual void OnPointerEntered(PointerEventArgs e)
- {
- if (MauiView != null)
- {
- GestureManager.ProcessPointerEntered(MauiView, e.X, e.Y);
- }
- }
-
- public virtual void OnPointerExited(PointerEventArgs e)
- {
- if (MauiView != null)
- {
- GestureManager.ProcessPointerExited(MauiView, e.X, e.Y);
- }
- }
-
- public virtual void OnPointerMoved(PointerEventArgs e)
- {
- if (MauiView != null)
- {
- GestureManager.ProcessPointerMove(MauiView, e.X, e.Y);
- }
- }
-
- public virtual void OnPointerPressed(PointerEventArgs e)
- {
- if (MauiView != null)
- {
- GestureManager.ProcessPointerDown(MauiView, e.X, e.Y);
- }
- }
-
- public virtual void OnPointerReleased(PointerEventArgs e)
- {
- DiagnosticLog.Debug("SkiaView", $"OnPointerReleased on {GetType().Name}, MauiView={MauiView?.GetType().Name ?? "null"}");
- if (MauiView != null)
- {
- GestureManager.ProcessPointerUp(MauiView, e.X, e.Y);
- }
- }
-
- public virtual void OnScroll(ScrollEventArgs e) { }
- public virtual void OnKeyDown(KeyEventArgs e) { }
- public virtual void OnKeyUp(KeyEventArgs e) { }
- public virtual void OnTextInput(TextInputEventArgs e) { }
-
- public virtual void OnFocusGained()
- {
- IsFocused = true;
- Invalidate();
- }
-
- public virtual void OnFocusLost()
- {
- IsFocused = false;
- Invalidate();
- }
-
- #endregion
-
- #region IDisposable
-
- protected virtual void Dispose(bool disposing)
- {
- if (!_disposed)
- {
- if (disposing)
- {
- // Clean up gesture tracking to prevent memory leaks
- if (MauiView != null)
- {
- GestureManager.CleanupView(MauiView);
- }
-
- foreach (var child in _children)
- {
- child.Dispose();
- }
- _children.Clear();
- }
- _disposed = true;
- }
- }
-
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
- #endregion
}
///