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 } ///