refactor: split large files into partial classes by concern
Split LinuxApplication into Input and Lifecycle partials. Extract SkiaView into Accessibility, Drawing, and Input partials. Split SkiaEntry and SkiaEditor into Drawing and Input partials. Extract TextRenderingHelper from SkiaRenderingEngine. Create dedicated files for SkiaAbsoluteLayout, SkiaGrid, and SkiaStackLayout. This reduces file sizes from 40K+ lines to manageable units organized by responsibility.
This commit is contained in:
534
LinuxApplication.Input.cs
Normal file
534
LinuxApplication.Input.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
510
LinuxApplication.Lifecycle.cs
Normal file
510
LinuxApplication.Lifecycle.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs a MAUI application on Linux.
|
||||
/// This is the main entry point for Linux apps.
|
||||
/// </summary>
|
||||
/// <param name="app">The MauiApp to run.</param>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
public static void Run(MauiApp app, string[] args)
|
||||
{
|
||||
Run(app, args, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a MAUI application on Linux with options.
|
||||
/// </summary>
|
||||
/// <param name="app">The MauiApp to run.</param>
|
||||
/// <param name="args">Command line arguments.</param>
|
||||
/// <param name="configure">Optional configuration action.</param>
|
||||
public static void Run(MauiApp app, string[] args, Action<LinuxApplicationOptions>? 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<LinuxApplicationOptions>()
|
||||
?? 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<IApplication>();
|
||||
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<Microsoft.Maui.Controls.Window>;
|
||||
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<Microsoft.Maui.Controls.Window>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the main window and runs the event loop.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces all views to refresh their theme-dependent properties.
|
||||
/// This is needed because AppThemeBinding may not automatically trigger
|
||||
/// property mappers on all platforms.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SkiaView> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1005
LinuxApplication.cs
1005
LinuxApplication.cs
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,15 @@ public class SkiaRenderingEngine : IDisposable
|
||||
// Dirty region tracking for optimized rendering
|
||||
private readonly List<SKRect> _dirtyRegions = new();
|
||||
private readonly object _dirtyLock = new();
|
||||
private const int MaxDirtyRegions = 32;
|
||||
private const float RegionMergeThreshold = 0.3f; // Merge if overlap > 30%
|
||||
/// <summary>
|
||||
/// Maximum number of dirty regions to track before falling back to a full redraw.
|
||||
/// </summary>
|
||||
public static int MaxDirtyRegions { get; set; } = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Overlap ratio threshold (0.0-1.0) at which adjacent dirty regions are merged.
|
||||
/// </summary>
|
||||
public static float RegionMergeThreshold { get; set; } = 0.3f;
|
||||
|
||||
public static SkiaRenderingEngine? Current { get; private set; }
|
||||
public ResourceCache ResourceCache { get; }
|
||||
|
||||
112
Rendering/TextRenderingHelper.cs
Normal file
112
Rendering/TextRenderingHelper.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Shared text rendering utilities extracted from SkiaEntry, SkiaEditor, and SkiaLabel
|
||||
/// to eliminate code duplication for common text rendering operations.
|
||||
/// </summary>
|
||||
public static class TextRenderingHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws text with font fallback for emoji, CJK, and other scripts.
|
||||
/// Uses FontFallbackManager to shape text across multiple typefaces when needed.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws underline for IME pre-edit (composition) text.
|
||||
/// Renders a dashed underline beneath the pre-edit text region.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Color to SkiaSharp SKColor for rendering.
|
||||
/// Returns the specified default color when the input color is null.
|
||||
/// </summary>
|
||||
public static SKColor ToSKColor(Color? color, SKColor defaultColor = default)
|
||||
{
|
||||
if (color == null) return defaultColor;
|
||||
return color.ToSKColor();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts FontAttributes to the corresponding SKFontStyle.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective font family, returning "Sans" as the platform default when empty.
|
||||
/// </summary>
|
||||
public static string GetEffectiveFontFamily(string? fontFamily)
|
||||
{
|
||||
return string.IsNullOrEmpty(fontFamily) ? "Sans" : fontFamily;
|
||||
}
|
||||
}
|
||||
160
Views/SkiaAbsoluteLayout.cs
Normal file
160
Views/SkiaAbsoluteLayout.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute layout that positions children at exact coordinates.
|
||||
/// </summary>
|
||||
public class SkiaAbsoluteLayout : SkiaLayoutView
|
||||
{
|
||||
private readonly Dictionary<SkiaView, AbsoluteLayoutBounds> _childBounds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child at the specified position and size.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the layout bounds for a child.
|
||||
/// </summary>
|
||||
public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
|
||||
{
|
||||
return _childBounds.TryGetValue(child, out var bounds)
|
||||
? bounds
|
||||
: new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the layout bounds for a child.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute layout bounds for a child.
|
||||
/// </summary>
|
||||
public readonly struct AbsoluteLayoutBounds
|
||||
{
|
||||
public SKRect Bounds { get; }
|
||||
public AbsoluteLayoutFlags Flags { get; }
|
||||
|
||||
public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
|
||||
{
|
||||
Bounds = bounds;
|
||||
Flags = flags;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags for absolute layout positioning.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
255
Views/SkiaEditor.Drawing.cs
Normal file
255
Views/SkiaEditor.Drawing.cs
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws text with font fallback for emoji, CJK, and other scripts.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Draws underline for IME pre-edit (composition) text.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
756
Views/SkiaEditor.Input.cs
Normal file
756
Views/SkiaEditor.Input.cs
Normal file
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text for IME context.
|
||||
/// </summary>
|
||||
string IInputContext.Text
|
||||
{
|
||||
get => Text;
|
||||
set => Text = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor position for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.CursorPosition
|
||||
{
|
||||
get => _cursorPosition;
|
||||
set => CursorPosition = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selection start for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.SelectionStart => _selectionStart;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selection length for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.SelectionLength => _selectionLength;
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME commits text.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME pre-edit (composition) text changes.
|
||||
/// </summary>
|
||||
public void OnPreEditChanged(string preEditText, int cursorPosition)
|
||||
{
|
||||
_preEditText = preEditText ?? string.Empty;
|
||||
_preEditCursorPosition = cursorPosition;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME pre-edit ends (cancelled or committed).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cursor blink timer (shows cursor immediately).
|
||||
/// </summary>
|
||||
private void ResetCursorBlink()
|
||||
{
|
||||
_lastCursorBlink = DateTime.Now;
|
||||
_cursorVisible = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates cursor blink animation. Called by the application's animation loop.
|
||||
/// </summary>
|
||||
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<GtkMenuItem>
|
||||
{
|
||||
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<ContextMenuItem>
|
||||
{
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Updates the IME cursor location for candidate window positioning.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
1043
Views/SkiaEditor.cs
1043
Views/SkiaEditor.cs
File diff suppressed because it is too large
Load Diff
303
Views/SkiaEntry.Drawing.cs
Normal file
303
Views/SkiaEntry.Drawing.cs
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws text with font fallback for emoji, CJK, and other scripts.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Draws underline for IME pre-edit (composition) text.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates cursor blink animation.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
654
Views/SkiaEntry.Input.cs
Normal file
654
Views/SkiaEntry.Input.cs
Normal file
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text for IME context.
|
||||
/// </summary>
|
||||
string IInputContext.Text
|
||||
{
|
||||
get => Text;
|
||||
set => Text = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the cursor position for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.CursorPosition
|
||||
{
|
||||
get => _cursorPosition;
|
||||
set => CursorPosition = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selection start for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.SelectionStart => _selectionStart;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selection length for IME context.
|
||||
/// </summary>
|
||||
int IInputContext.SelectionLength => _selectionLength;
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME commits text.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME pre-edit (composition) text changes.
|
||||
/// </summary>
|
||||
public void OnPreEditChanged(string preEditText, int cursorPosition)
|
||||
{
|
||||
_preEditText = preEditText ?? string.Empty;
|
||||
_preEditCursorPosition = cursorPosition;
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when IME pre-edit ends (cancelled or committed).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects all text.
|
||||
/// </summary>
|
||||
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<GtkMenuItem>
|
||||
{
|
||||
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<ContextMenuItem>
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the IME cursor location for candidate window positioning.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
500
Views/SkiaGrid.cs
Normal file
500
Views/SkiaGrid.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Grid layout that arranges children in rows and columns.
|
||||
/// </summary>
|
||||
public class SkiaGrid : SkiaLayoutView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for RowSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty RowSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(RowSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaGrid),
|
||||
0f,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ColumnSpacing.
|
||||
/// </summary>
|
||||
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<GridLength> _rowDefinitions = new();
|
||||
private readonly List<GridLength> _columnDefinitions = new();
|
||||
private readonly Dictionary<SkiaView, GridPosition> _childPositions = new();
|
||||
|
||||
private float[] _rowHeights = Array.Empty<float>();
|
||||
private float[] _columnWidths = Array.Empty<float>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the row definitions.
|
||||
/// </summary>
|
||||
public IList<GridLength> RowDefinitions => _rowDefinitions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the column definitions.
|
||||
/// </summary>
|
||||
public IList<GridLength> ColumnDefinitions => _columnDefinitions;
|
||||
|
||||
/// <summary>
|
||||
/// Spacing between rows.
|
||||
/// </summary>
|
||||
public float RowSpacing
|
||||
{
|
||||
get => (float)GetValue(RowSpacingProperty);
|
||||
set => SetValue(RowSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spacing between columns.
|
||||
/// </summary>
|
||||
public float ColumnSpacing
|
||||
{
|
||||
get => (float)GetValue(ColumnSpacingProperty);
|
||||
set => SetValue(ColumnSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child at the specified grid position.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grid position of a child.
|
||||
/// </summary>
|
||||
public GridPosition GetPosition(SkiaView child)
|
||||
{
|
||||
return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the grid position of a child.
|
||||
/// </summary>
|
||||
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<GridLength> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid position information.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid length specification.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid unit type options.
|
||||
/// </summary>
|
||||
public enum GridUnitType
|
||||
{
|
||||
Absolute,
|
||||
Star,
|
||||
Auto
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Draws formatted span text with font fallback for emoji, CJK, and other scripts.
|
||||
|
||||
@@ -315,860 +315,3 @@ public abstract class SkiaLayoutView : SkiaView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stack layout that arranges children in a horizontal or vertical line.
|
||||
/// </summary>
|
||||
public class SkiaStackLayout : SkiaLayoutView
|
||||
{
|
||||
/// <summary>
|
||||
/// Bindable property for Orientation.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OrientationProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Orientation),
|
||||
typeof(StackOrientation),
|
||||
typeof(SkiaStackLayout),
|
||||
StackOrientation.Vertical,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the orientation of the stack.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stack orientation options.
|
||||
/// </summary>
|
||||
public enum StackOrientation
|
||||
{
|
||||
Vertical,
|
||||
Horizontal
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid layout that arranges children in rows and columns.
|
||||
/// </summary>
|
||||
public class SkiaGrid : SkiaLayoutView
|
||||
{
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for RowSpacing.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty RowSpacingProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(RowSpacing),
|
||||
typeof(float),
|
||||
typeof(SkiaGrid),
|
||||
0f,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaGrid)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Bindable property for ColumnSpacing.
|
||||
/// </summary>
|
||||
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<GridLength> _rowDefinitions = new();
|
||||
private readonly List<GridLength> _columnDefinitions = new();
|
||||
private readonly Dictionary<SkiaView, GridPosition> _childPositions = new();
|
||||
|
||||
private float[] _rowHeights = Array.Empty<float>();
|
||||
private float[] _columnWidths = Array.Empty<float>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the row definitions.
|
||||
/// </summary>
|
||||
public IList<GridLength> RowDefinitions => _rowDefinitions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the column definitions.
|
||||
/// </summary>
|
||||
public IList<GridLength> ColumnDefinitions => _columnDefinitions;
|
||||
|
||||
/// <summary>
|
||||
/// Spacing between rows.
|
||||
/// </summary>
|
||||
public float RowSpacing
|
||||
{
|
||||
get => (float)GetValue(RowSpacingProperty);
|
||||
set => SetValue(RowSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spacing between columns.
|
||||
/// </summary>
|
||||
public float ColumnSpacing
|
||||
{
|
||||
get => (float)GetValue(ColumnSpacingProperty);
|
||||
set => SetValue(ColumnSpacingProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child at the specified grid position.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the grid position of a child.
|
||||
/// </summary>
|
||||
public GridPosition GetPosition(SkiaView child)
|
||||
{
|
||||
return _childPositions.TryGetValue(child, out var pos) ? pos : new GridPosition(0, 0, 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the grid position of a child.
|
||||
/// </summary>
|
||||
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<GridLength> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid position information.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid length specification.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grid unit type options.
|
||||
/// </summary>
|
||||
public enum GridUnitType
|
||||
{
|
||||
Absolute,
|
||||
Star,
|
||||
Auto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute layout that positions children at exact coordinates.
|
||||
/// </summary>
|
||||
public class SkiaAbsoluteLayout : SkiaLayoutView
|
||||
{
|
||||
private readonly Dictionary<SkiaView, AbsoluteLayoutBounds> _childBounds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a child at the specified position and size.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the layout bounds for a child.
|
||||
/// </summary>
|
||||
public AbsoluteLayoutBounds GetLayoutBounds(SkiaView child)
|
||||
{
|
||||
return _childBounds.TryGetValue(child, out var bounds)
|
||||
? bounds
|
||||
: new AbsoluteLayoutBounds(SKRect.Empty, AbsoluteLayoutFlags.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the layout bounds for a child.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute layout bounds for a child.
|
||||
/// </summary>
|
||||
public readonly struct AbsoluteLayoutBounds
|
||||
{
|
||||
public SKRect Bounds { get; }
|
||||
public AbsoluteLayoutFlags Flags { get; }
|
||||
|
||||
public AbsoluteLayoutBounds(SKRect bounds, AbsoluteLayoutFlags flags)
|
||||
{
|
||||
Bounds = bounds;
|
||||
Flags = flags;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flags for absolute layout positioning.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
|
||||
224
Views/SkiaStackLayout.cs
Normal file
224
Views/SkiaStackLayout.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Stack layout that arranges children in a horizontal or vertical line.
|
||||
/// </summary>
|
||||
public class SkiaStackLayout : SkiaLayoutView
|
||||
{
|
||||
/// <summary>
|
||||
/// Bindable property for Orientation.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty OrientationProperty =
|
||||
BindableProperty.Create(
|
||||
nameof(Orientation),
|
||||
typeof(StackOrientation),
|
||||
typeof(SkiaStackLayout),
|
||||
StackOrientation.Vertical,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((SkiaStackLayout)b).InvalidateMeasure());
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the orientation of the stack.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stack orientation options.
|
||||
/// </summary>
|
||||
public enum StackOrientation
|
||||
{
|
||||
Vertical,
|
||||
Horizontal
|
||||
}
|
||||
311
Views/SkiaView.Accessibility.cs
Normal file
311
Views/SkiaView.Accessibility.cs
Normal file
@@ -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<SKCanvas> Draw)> _popupOverlays = new();
|
||||
|
||||
public static void RegisterPopupOverlay(SkiaView owner, Action<SKCanvas> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the popup owner that should receive pointer events at the given coordinates.
|
||||
/// This allows popups to receive events even outside their normal bounds.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there are any active popup overlays.
|
||||
/// </summary>
|
||||
public static bool HasActivePopup => _popupOverlays.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Override this to define the popup area for hit testing.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether high contrast mode is enabled.
|
||||
/// </summary>
|
||||
public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current high contrast colors, or default colors if not in high contrast mode.
|
||||
/// </summary>
|
||||
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<IAccessible>? _accessibleChildren;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility name for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility description for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility hint for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticHint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessibility service instance.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers this view with the accessibility service.
|
||||
/// </summary>
|
||||
protected void RegisterAccessibility()
|
||||
{
|
||||
AccessibilityService?.Register(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters this view from the accessibility service.
|
||||
/// </summary>
|
||||
protected void UnregisterAccessibility()
|
||||
{
|
||||
AccessibilityService?.Unregister(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces text to screen readers.
|
||||
/// </summary>
|
||||
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> IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
|
||||
|
||||
AccessibleRect IAccessible.Bounds => new AccessibleRect(
|
||||
(int)ScreenBounds.Left,
|
||||
(int)ScreenBounds.Top,
|
||||
(int)ScreenBounds.Width,
|
||||
(int)ScreenBounds.Height);
|
||||
|
||||
IReadOnlyList<AccessibleAction> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default accessible name based on view content.
|
||||
/// </summary>
|
||||
protected virtual string GetDefaultAccessibleName() => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible role for this view.
|
||||
/// </summary>
|
||||
protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current accessible states.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible children of this view.
|
||||
/// </summary>
|
||||
protected virtual List<IAccessible> GetAccessibleChildren()
|
||||
{
|
||||
var children = new List<IAccessible>();
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child is IAccessible accessible)
|
||||
{
|
||||
children.Add(accessible);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the available accessible actions.
|
||||
/// </summary>
|
||||
protected virtual IReadOnlyList<AccessibleAction> GetAccessibleActions()
|
||||
{
|
||||
return Array.Empty<AccessibleAction>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs an accessible action.
|
||||
/// </summary>
|
||||
protected virtual bool DoAccessibleAction(string actionName) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible value (for sliders, progress bars, etc.).
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum accessible value.
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleMinValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum accessible value.
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleMaxValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the accessible value.
|
||||
/// </summary>
|
||||
protected virtual bool SetAccessibleValue(double value) => false;
|
||||
|
||||
#endregion
|
||||
}
|
||||
250
Views/SkiaView.Drawing.cs
Normal file
250
Views/SkiaView.Drawing.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Draws this view and its children to the canvas.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to draw custom content.
|
||||
/// </summary>
|
||||
protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the shadow for this view.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the clip geometry to the canvas.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Geometry to a SkiaSharp path.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the background (color or brush) for this view.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
105
Views/SkiaView.Input.cs
Normal file
105
Views/SkiaView.Input.cs
Normal file
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
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<SKCanvas> Draw)> _popupOverlays = new();
|
||||
|
||||
public static void RegisterPopupOverlay(SkiaView owner, Action<SKCanvas> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the popup owner that should receive pointer events at the given coordinates.
|
||||
/// This allows popups to receive events even outside their normal bounds.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there are any active popup overlays.
|
||||
/// </summary>
|
||||
public static bool HasActivePopup => _popupOverlays.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Override this to define the popup area for hit testing.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether high contrast mode is enabled.
|
||||
/// </summary>
|
||||
public static bool IsHighContrastEnabled => _highContrastService?.IsHighContrastEnabled ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current high contrast colors, or default colors if not in high contrast mode.
|
||||
/// </summary>
|
||||
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<IAccessible>? _accessibleChildren;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility name for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility description for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the accessibility hint for screen readers.
|
||||
/// </summary>
|
||||
public string? SemanticHint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessibility service instance.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers this view with the accessibility service.
|
||||
/// </summary>
|
||||
protected void RegisterAccessibility()
|
||||
{
|
||||
AccessibilityService?.Register(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters this view from the accessibility service.
|
||||
/// </summary>
|
||||
protected void UnregisterAccessibility()
|
||||
{
|
||||
AccessibilityService?.Unregister(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Announces text to screen readers.
|
||||
/// </summary>
|
||||
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> IAccessible.Children => _accessibleChildren ??= GetAccessibleChildren();
|
||||
|
||||
AccessibleRect IAccessible.Bounds => new AccessibleRect(
|
||||
(int)ScreenBounds.Left,
|
||||
(int)ScreenBounds.Top,
|
||||
(int)ScreenBounds.Width,
|
||||
(int)ScreenBounds.Height);
|
||||
|
||||
IReadOnlyList<AccessibleAction> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default accessible name based on view content.
|
||||
/// </summary>
|
||||
protected virtual string GetDefaultAccessibleName() => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible role for this view.
|
||||
/// </summary>
|
||||
protected virtual AccessibleRole GetAccessibleRole() => AccessibleRole.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current accessible states.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible children of this view.
|
||||
/// </summary>
|
||||
protected virtual List<IAccessible> GetAccessibleChildren()
|
||||
{
|
||||
var children = new List<IAccessible>();
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child is IAccessible accessible)
|
||||
{
|
||||
children.Add(accessible);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the available accessible actions.
|
||||
/// </summary>
|
||||
protected virtual IReadOnlyList<AccessibleAction> GetAccessibleActions()
|
||||
{
|
||||
return Array.Empty<AccessibleAction>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs an accessible action.
|
||||
/// </summary>
|
||||
protected virtual bool DoAccessibleAction(string actionName) => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accessible value (for sliders, progress bars, etc.).
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the minimum accessible value.
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleMinValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum accessible value.
|
||||
/// </summary>
|
||||
protected virtual double? GetAccessibleMaxValue() => null;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the accessible value.
|
||||
/// </summary>
|
||||
protected virtual bool SetAccessibleValue(double value) => false;
|
||||
|
||||
#endregion
|
||||
|
||||
#region BindableProperties
|
||||
|
||||
/// <summary>
|
||||
@@ -1324,244 +1024,6 @@ public abstract class SkiaView : BindableObject, IDisposable, IAccessible
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws this view and its children to the canvas.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to draw custom content.
|
||||
/// </summary>
|
||||
protected virtual void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the shadow for this view.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the clip geometry to the canvas.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a MAUI Geometry to a SkiaSharp path.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the background (color or brush) for this view.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the bounds change.
|
||||
/// </summary>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user