From f7043ab9c77ce0ad1b64a1dd2ee0abbb71d8fef3 Mon Sep 17 00:00:00 2001 From: Dave Friedel Date: Thu, 1 Jan 2026 11:19:58 -0500 Subject: [PATCH] Major production merge: GTK support, context menus, and dispatcher fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core Infrastructure: - Add Dispatching folder with LinuxDispatcher, LinuxDispatcherProvider, LinuxDispatcherTimer - Add Native folder with P/Invoke wrappers (GTK, GLib, GDK, Cairo, WebKit) - Add GTK host window system with GtkHostWindow and GtkSkiaSurfaceWidget - Update LinuxApplication with GTK mode, theme handling, and icon support - Fix duplicate LinuxDispatcher in LinuxMauiContext Handlers: - Add GtkWebViewManager and GtkWebViewPlatformView for GTK WebView - Add FlexLayoutHandler and GestureManager - Update multiple handlers with ToViewHandler fix and missing mappers - Add MauiHandlerExtensions with ToViewHandler extension method Views: - Add SkiaContextMenu with hover, keyboard, and dark theme support - Add LinuxDialogService with context menu management - Add SkiaFlexLayout for flex container support - Update SkiaShell with RefreshTheme, MauiShell, ContentRenderer - Update SkiaWebView with SetMainWindow, ProcessGtkEvents - Update SkiaImage with LoadFromBitmap method Services: - Add AppInfoService, ConnectivityService, DeviceDisplayService, DeviceInfoService - Add GtkHostService, GtkContextMenuService, MauiIconGenerator Window: - Add CursorType enum and GtkHostWindow - Update X11Window with SetIcon, SetCursor methods Build: SUCCESS (0 errors) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Dispatching/LinuxDispatcher.cs | 76 +++ Dispatching/LinuxDispatcherProvider.cs | 15 + Dispatching/LinuxDispatcherTimer.cs | 109 +++++ Handlers/BorderHandler.cs | 3 +- Handlers/CheckBoxHandler.cs | 7 + Handlers/CollectionViewHandler.cs | 5 +- Handlers/DatePickerHandler.cs | 11 + Handlers/EntryHandler.cs | 15 + Handlers/FlexLayoutHandler.cs | 105 ++++ Handlers/FrameHandler.cs | 3 +- Handlers/GestureManager.cs | 439 +++++++++++++++++ Handlers/GtkWebViewManager.cs | 60 +++ Handlers/GtkWebViewPlatformView.cs | 164 +++++++ Handlers/ImageHandler.cs | 123 +++++ Handlers/LabelHandler.cs | 86 ++++ Handlers/LayoutHandler.Linux.cs | 5 +- Handlers/LayoutHandler.cs | 5 +- Handlers/NavigationPageHandler.cs | 9 +- Handlers/PageHandler.cs | 3 +- Handlers/PickerHandler.cs | 17 + Handlers/ProgressBarHandler.cs | 61 +++ Handlers/ScrollViewHandler.cs | 3 +- Handlers/SwitchHandler.cs | 7 + Hosting/LinuxMauiContext.cs | 120 +---- Hosting/LinuxViewRenderer.cs | 19 - Hosting/MauiHandlerExtensions.cs | 123 +++++ LinuxApplication.cs | 638 ++++++++++++++++++++++--- MERGE_TRACKING.md | 260 +++++----- Native/CairoNative.cs | 80 ++++ Native/GLibNative.cs | 111 +++++ Native/GdkNative.cs | 132 +++++ Native/GtkNative.cs | 192 ++++++++ Native/WebKitNative.cs | 256 ++++++++++ Rendering/GtkSkiaSurfaceWidget.cs | 391 +++++++++++++++ Services/AppInfoService.cs | 142 ++++++ Services/ConnectivityService.cs | 170 +++++++ Services/DeviceDisplayService.cs | 124 +++++ Services/DeviceInfoService.cs | 93 ++++ Services/GtkContextMenuService.cs | 90 ++++ Services/GtkHostService.cs | 56 +++ Services/GtkMenuItem.cs | 32 ++ Services/MauiIconGenerator.cs | 158 ++++++ Views/LinuxDialogService.cs | 101 ++++ Views/SkiaAlertDialog.cs | 60 --- Views/SkiaButton.cs | 226 +++++++-- Views/SkiaContextMenu.cs | 221 +++++++++ Views/SkiaEntry.cs | 46 +- Views/SkiaFlexLayout.cs | 256 ++++++++++ Views/SkiaImage.cs | 19 + Views/SkiaLabel.cs | 224 ++++++++- Views/SkiaShell.cs | 62 +++ Views/SkiaView.cs | 317 +++++++++++- Views/SkiaWebView.cs | 31 ++ Window/CursorType.cs | 11 + Window/GtkHostWindow.cs | 343 +++++++++++++ Window/X11Window.cs | 99 +++- 56 files changed, 6061 insertions(+), 473 deletions(-) create mode 100644 Dispatching/LinuxDispatcher.cs create mode 100644 Dispatching/LinuxDispatcherProvider.cs create mode 100644 Dispatching/LinuxDispatcherTimer.cs create mode 100644 Handlers/FlexLayoutHandler.cs create mode 100644 Handlers/GestureManager.cs create mode 100644 Handlers/GtkWebViewManager.cs create mode 100644 Handlers/GtkWebViewPlatformView.cs create mode 100644 Hosting/MauiHandlerExtensions.cs create mode 100644 Native/CairoNative.cs create mode 100644 Native/GLibNative.cs create mode 100644 Native/GdkNative.cs create mode 100644 Native/GtkNative.cs create mode 100644 Native/WebKitNative.cs create mode 100644 Rendering/GtkSkiaSurfaceWidget.cs create mode 100644 Services/AppInfoService.cs create mode 100644 Services/ConnectivityService.cs create mode 100644 Services/DeviceDisplayService.cs create mode 100644 Services/DeviceInfoService.cs create mode 100644 Services/GtkContextMenuService.cs create mode 100644 Services/GtkHostService.cs create mode 100644 Services/GtkMenuItem.cs create mode 100644 Services/MauiIconGenerator.cs create mode 100644 Views/LinuxDialogService.cs create mode 100644 Views/SkiaContextMenu.cs create mode 100644 Views/SkiaFlexLayout.cs create mode 100644 Window/CursorType.cs create mode 100644 Window/GtkHostWindow.cs diff --git a/Dispatching/LinuxDispatcher.cs b/Dispatching/LinuxDispatcher.cs new file mode 100644 index 0000000..8b082be --- /dev/null +++ b/Dispatching/LinuxDispatcher.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Platform.Linux.Native; + +namespace Microsoft.Maui.Platform.Linux.Dispatching; + +public class LinuxDispatcher : IDispatcher +{ + private static int _mainThreadId; + + private static LinuxDispatcher? _mainDispatcher; + + private static readonly object _lock = new object(); + + public static LinuxDispatcher? Main => _mainDispatcher; + + public static bool IsMainThread => Environment.CurrentManagedThreadId == _mainThreadId; + + public bool IsDispatchRequired => !IsMainThread; + + public static void Initialize() + { + lock (_lock) + { + _mainThreadId = Environment.CurrentManagedThreadId; + _mainDispatcher = new LinuxDispatcher(); + Console.WriteLine($"[LinuxDispatcher] Initialized on thread {_mainThreadId}"); + } + } + + public bool Dispatch(Action action) + { + ArgumentNullException.ThrowIfNull(action, "action"); + if (!IsDispatchRequired) + { + action(); + return true; + } + GLibNative.IdleAdd(delegate + { + try + { + action(); + } + catch (Exception ex) + { + Console.WriteLine("[LinuxDispatcher] Error in dispatched action: " + ex.Message); + } + return false; + }); + return true; + } + + public bool DispatchDelayed(TimeSpan delay, Action action) + { + ArgumentNullException.ThrowIfNull(action, "action"); + GLibNative.TimeoutAdd((uint)Math.Max(0.0, delay.TotalMilliseconds), delegate + { + try + { + action(); + } + catch (Exception ex) + { + Console.WriteLine("[LinuxDispatcher] Error in delayed action: " + ex.Message); + } + return false; + }); + return true; + } + + public IDispatcherTimer CreateTimer() + { + return new LinuxDispatcherTimer(this); + } +} diff --git a/Dispatching/LinuxDispatcherProvider.cs b/Dispatching/LinuxDispatcherProvider.cs new file mode 100644 index 0000000..a93edf0 --- /dev/null +++ b/Dispatching/LinuxDispatcherProvider.cs @@ -0,0 +1,15 @@ +using Microsoft.Maui.Dispatching; + +namespace Microsoft.Maui.Platform.Linux.Dispatching; + +public class LinuxDispatcherProvider : IDispatcherProvider +{ + private static LinuxDispatcherProvider? _instance; + + public static LinuxDispatcherProvider Instance => _instance ?? (_instance = new LinuxDispatcherProvider()); + + public IDispatcher? GetForCurrentThread() + { + return LinuxDispatcher.Main; + } +} diff --git a/Dispatching/LinuxDispatcherTimer.cs b/Dispatching/LinuxDispatcherTimer.cs new file mode 100644 index 0000000..c6be388 --- /dev/null +++ b/Dispatching/LinuxDispatcherTimer.cs @@ -0,0 +1,109 @@ +using System; +using Microsoft.Maui.Dispatching; +using Microsoft.Maui.Platform.Linux.Native; + +namespace Microsoft.Maui.Platform.Linux.Dispatching; + +public class LinuxDispatcherTimer : IDispatcherTimer +{ + private readonly LinuxDispatcher _dispatcher; + + private uint _sourceId; + + private TimeSpan _interval = TimeSpan.FromMilliseconds(100); + + private bool _isRepeating = true; + + private bool _isRunning; + + public TimeSpan Interval + { + get + { + return _interval; + } + set + { + _interval = value; + if (_isRunning) + { + Stop(); + Start(); + } + } + } + + public bool IsRepeating + { + get + { + return _isRepeating; + } + set + { + _isRepeating = value; + } + } + + public bool IsRunning => _isRunning; + + public event EventHandler? Tick; + + public LinuxDispatcherTimer(LinuxDispatcher dispatcher) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public void Start() + { + if (!_isRunning) + { + _isRunning = true; + ScheduleNext(); + } + } + + public void Stop() + { + if (_isRunning) + { + _isRunning = false; + if (_sourceId != 0) + { + GLibNative.SourceRemove(_sourceId); + _sourceId = 0; + } + } + } + + private void ScheduleNext() + { + if (!_isRunning) + { + return; + } + uint intervalMs = (uint)Math.Max(1.0, _interval.TotalMilliseconds); + _sourceId = GLibNative.TimeoutAdd(intervalMs, delegate + { + if (!_isRunning) + { + return false; + } + try + { + Tick?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Console.WriteLine("[LinuxDispatcherTimer] Error in Tick handler: " + ex.Message); + } + if (_isRepeating && _isRunning) + { + return true; + } + _isRunning = false; + _sourceId = 0; + return false; + }); + } +} diff --git a/Handlers/BorderHandler.cs b/Handlers/BorderHandler.cs index a0392a9..852dce3 100644 --- a/Handlers/BorderHandler.cs +++ b/Handlers/BorderHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using Microsoft.Maui.Controls; using Microsoft.Maui.Platform; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; @@ -68,7 +69,7 @@ public partial class BorderHandler : ViewHandler if (content.Handler == null) { Console.WriteLine($"[BorderHandler] Creating handler for content: {content.GetType().Name}"); - content.Handler = content.ToHandler(handler.MauiContext); + content.Handler = content.ToViewHandler(handler.MauiContext); } if (content.Handler?.PlatformView is SkiaView skiaContent) diff --git a/Handlers/CheckBoxHandler.cs b/Handlers/CheckBoxHandler.cs index cfc966b..e520803 100644 --- a/Handlers/CheckBoxHandler.cs +++ b/Handlers/CheckBoxHandler.cs @@ -18,6 +18,7 @@ public partial class CheckBoxHandler : ViewHandler [nameof(ICheckBox.IsChecked)] = MapIsChecked, [nameof(ICheckBox.Foreground)] = MapForeground, [nameof(IView.Background)] = MapBackground, + [nameof(IView.IsEnabled)] = MapIsEnabled, [nameof(IView.VerticalLayoutAlignment)] = MapVerticalLayoutAlignment, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, }; @@ -86,6 +87,12 @@ public partial class CheckBoxHandler : ViewHandler } } + public static void MapIsEnabled(CheckBoxHandler handler, ICheckBox checkBox) + { + if (handler.PlatformView is null) return; + handler.PlatformView.IsEnabled = checkBox.IsEnabled; + } + public static void MapVerticalLayoutAlignment(CheckBoxHandler handler, ICheckBox checkBox) { if (handler.PlatformView is null) return; diff --git a/Handlers/CollectionViewHandler.cs b/Handlers/CollectionViewHandler.cs index e76a4a6..85fcbbc 100644 --- a/Handlers/CollectionViewHandler.cs +++ b/Handlers/CollectionViewHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using Microsoft.Maui.Controls; using Microsoft.Maui.Platform; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; @@ -158,7 +159,7 @@ public partial class CollectionViewHandler : ViewHandler [nameof(ITextAlignment.HorizontalTextAlignment)] = MapHorizontalTextAlignment, [nameof(ITextAlignment.VerticalTextAlignment)] = MapVerticalTextAlignment, [nameof(IView.Background)] = MapBackground, + ["BackgroundColor"] = MapBackgroundColor, }; public static CommandMapper CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -212,4 +214,17 @@ public partial class EntryHandler : ViewHandler handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor(); } } + + public static void MapBackgroundColor(EntryHandler handler, IEntry entry) + { + if (handler.PlatformView is null) return; + + if (entry is Entry ve && ve.BackgroundColor != null) + { + Console.WriteLine($"[EntryHandler] MapBackgroundColor: {ve.BackgroundColor}"); + var color = ve.BackgroundColor.ToSKColor(); + Console.WriteLine($"[EntryHandler] Setting EntryBackgroundColor to: {color}"); + handler.PlatformView.EntryBackgroundColor = color; + } + } } diff --git a/Handlers/FlexLayoutHandler.cs b/Handlers/FlexLayoutHandler.cs new file mode 100644 index 0000000..9ff66e5 --- /dev/null +++ b/Handlers/FlexLayoutHandler.cs @@ -0,0 +1,105 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Handlers; +using Microsoft.Maui.Layouts; + +namespace Microsoft.Maui.Platform.Linux.Handlers; + +public class FlexLayoutHandler : LayoutHandler +{ + public new static IPropertyMapper Mapper = new PropertyMapper(LayoutHandler.Mapper) + { + ["Direction"] = MapDirection, + ["Wrap"] = MapWrap, + ["JustifyContent"] = MapJustifyContent, + ["AlignItems"] = MapAlignItems, + ["AlignContent"] = MapAlignContent + }; + + public FlexLayoutHandler() : base(Mapper) + { + } + + protected override SkiaLayoutView CreatePlatformView() + { + return new SkiaFlexLayout(); + } + + public static void MapDirection(FlexLayoutHandler handler, FlexLayout layout) + { + if (handler.PlatformView is SkiaFlexLayout flexLayout) + { + flexLayout.Direction = layout.Direction switch + { + Microsoft.Maui.Layouts.FlexDirection.Row => FlexDirection.Row, + Microsoft.Maui.Layouts.FlexDirection.RowReverse => FlexDirection.RowReverse, + Microsoft.Maui.Layouts.FlexDirection.Column => FlexDirection.Column, + Microsoft.Maui.Layouts.FlexDirection.ColumnReverse => FlexDirection.ColumnReverse, + _ => FlexDirection.Row, + }; + } + } + + public static void MapWrap(FlexLayoutHandler handler, FlexLayout layout) + { + if (handler.PlatformView is SkiaFlexLayout flexLayout) + { + flexLayout.Wrap = layout.Wrap switch + { + Microsoft.Maui.Layouts.FlexWrap.NoWrap => FlexWrap.NoWrap, + Microsoft.Maui.Layouts.FlexWrap.Wrap => FlexWrap.Wrap, + Microsoft.Maui.Layouts.FlexWrap.Reverse => FlexWrap.WrapReverse, + _ => FlexWrap.NoWrap, + }; + } + } + + public static void MapJustifyContent(FlexLayoutHandler handler, FlexLayout layout) + { + if (handler.PlatformView is SkiaFlexLayout flexLayout) + { + flexLayout.JustifyContent = layout.JustifyContent switch + { + Microsoft.Maui.Layouts.FlexJustify.Start => FlexJustify.Start, + Microsoft.Maui.Layouts.FlexJustify.Center => FlexJustify.Center, + Microsoft.Maui.Layouts.FlexJustify.End => FlexJustify.End, + Microsoft.Maui.Layouts.FlexJustify.SpaceBetween => FlexJustify.SpaceBetween, + Microsoft.Maui.Layouts.FlexJustify.SpaceAround => FlexJustify.SpaceAround, + Microsoft.Maui.Layouts.FlexJustify.SpaceEvenly => FlexJustify.SpaceEvenly, + _ => FlexJustify.Start, + }; + } + } + + public static void MapAlignItems(FlexLayoutHandler handler, FlexLayout layout) + { + if (handler.PlatformView is SkiaFlexLayout flexLayout) + { + flexLayout.AlignItems = layout.AlignItems switch + { + Microsoft.Maui.Layouts.FlexAlignItems.Start => FlexAlignItems.Start, + Microsoft.Maui.Layouts.FlexAlignItems.Center => FlexAlignItems.Center, + Microsoft.Maui.Layouts.FlexAlignItems.End => FlexAlignItems.End, + Microsoft.Maui.Layouts.FlexAlignItems.Stretch => FlexAlignItems.Stretch, + _ => FlexAlignItems.Stretch, + }; + } + } + + public static void MapAlignContent(FlexLayoutHandler handler, FlexLayout layout) + { + if (handler.PlatformView is SkiaFlexLayout flexLayout) + { + flexLayout.AlignContent = layout.AlignContent switch + { + Microsoft.Maui.Layouts.FlexAlignContent.Start => FlexAlignContent.Start, + Microsoft.Maui.Layouts.FlexAlignContent.Center => FlexAlignContent.Center, + Microsoft.Maui.Layouts.FlexAlignContent.End => FlexAlignContent.End, + Microsoft.Maui.Layouts.FlexAlignContent.Stretch => FlexAlignContent.Stretch, + Microsoft.Maui.Layouts.FlexAlignContent.SpaceBetween => FlexAlignContent.SpaceBetween, + Microsoft.Maui.Layouts.FlexAlignContent.SpaceAround => FlexAlignContent.SpaceAround, + Microsoft.Maui.Layouts.FlexAlignContent.SpaceEvenly => FlexAlignContent.SpaceAround, + _ => FlexAlignContent.Stretch, + }; + } + } +} diff --git a/Handlers/FrameHandler.cs b/Handlers/FrameHandler.cs index 88847b7..7837892 100644 --- a/Handlers/FrameHandler.cs +++ b/Handlers/FrameHandler.cs @@ -3,6 +3,7 @@ using Microsoft.Maui.Controls; using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; @@ -92,7 +93,7 @@ public partial class FrameHandler : ViewHandler // Create handler for content if it doesn't exist if (content.Handler == null) { - content.Handler = content.ToHandler(handler.MauiContext); + content.Handler = content.ToViewHandler(handler.MauiContext); } if (content.Handler?.PlatformView is SkiaView skiaContent) diff --git a/Handlers/GestureManager.cs b/Handlers/GestureManager.cs new file mode 100644 index 0000000..a778157 --- /dev/null +++ b/Handlers/GestureManager.cs @@ -0,0 +1,439 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Windows.Input; +using Microsoft.Maui.Controls; + +namespace Microsoft.Maui.Platform.Linux.Handlers; + +/// +/// Manages gesture recognition and processing for MAUI views on Linux. +/// Handles tap, pan, swipe, and pointer gestures. +/// +public static class GestureManager +{ + private class GestureTrackingState + { + public double StartX { get; set; } + public double StartY { get; set; } + public double CurrentX { get; set; } + public double CurrentY { get; set; } + public DateTime StartTime { get; set; } + public bool IsPanning { get; set; } + public bool IsPressed { get; set; } + } + + private enum PointerEventType + { + Entered, + Exited, + Pressed, + Moved, + Released + } + + private static MethodInfo? _sendTappedMethod; + private static readonly Dictionary _tapTracking = new(); + private static readonly Dictionary _gestureState = new(); + + private const double SwipeMinDistance = 50.0; + private const double SwipeMaxTime = 500.0; + private const double SwipeDirectionThreshold = 0.5; + private const double PanMinDistance = 10.0; + + /// + /// Processes a tap gesture on the specified view. + /// + public static bool ProcessTap(View? view, double x, double y) + { + if (view == null) return false; + + var current = view; + while (current != null) + { + var recognizers = current.GestureRecognizers; + if (recognizers?.Count > 0 && ProcessTapOnView(current, x, y)) + { + return true; + } + current = current.Parent as View; + } + return false; + } + + private static bool ProcessTapOnView(View view, double x, double y) + { + var recognizers = view.GestureRecognizers; + if (recognizers == null || recognizers.Count == 0) + return false; + + bool result = false; + foreach (var recognizer in recognizers) + { + if (recognizer is not TapGestureRecognizer tapRecognizer) + continue; + + Console.WriteLine($"[GestureManager] Processing TapGestureRecognizer on {view.GetType().Name}, CommandParameter={tapRecognizer.CommandParameter}, NumberOfTapsRequired={tapRecognizer.NumberOfTapsRequired}"); + + int requiredTaps = tapRecognizer.NumberOfTapsRequired; + if (requiredTaps > 1) + { + var now = DateTime.UtcNow; + if (!_tapTracking.TryGetValue(view, out var tracking)) + { + _tapTracking[view] = (now, 1); + Console.WriteLine($"[GestureManager] First tap 1/{requiredTaps}"); + continue; + } + + if ((now - tracking.lastTap).TotalMilliseconds >= 300.0) + { + _tapTracking[view] = (now, 1); + Console.WriteLine($"[GestureManager] Tap timeout, reset to 1/{requiredTaps}"); + continue; + } + + int tapCount = tracking.tapCount + 1; + if (tapCount < requiredTaps) + { + _tapTracking[view] = (now, tapCount); + Console.WriteLine($"[GestureManager] Tap {tapCount}/{requiredTaps}, waiting for more taps"); + continue; + } + + _tapTracking.Remove(view); + } + + bool eventFired = false; + + // Try SendTapped method + try + { + _sendTappedMethod ??= typeof(TapGestureRecognizer).GetMethod("SendTapped", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (_sendTappedMethod != null) + { + Console.WriteLine($"[GestureManager] Found SendTapped method with {_sendTappedMethod.GetParameters().Length} params"); + var args = new TappedEventArgs(tapRecognizer.CommandParameter); + _sendTappedMethod.Invoke(tapRecognizer, new object[] { view, args }); + Console.WriteLine("[GestureManager] SendTapped invoked successfully"); + eventFired = true; + } + } + catch (Exception ex) + { + Console.WriteLine("[GestureManager] SendTapped failed: " + ex.Message); + } + + // Try direct event invocation + if (!eventFired) + { + try + { + var field = typeof(TapGestureRecognizer).GetField("Tapped", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? typeof(TapGestureRecognizer).GetField("_tapped", BindingFlags.Instance | BindingFlags.NonPublic); + if (field?.GetValue(tapRecognizer) is EventHandler handler) + { + Console.WriteLine("[GestureManager] Invoking Tapped event directly"); + var args = new TappedEventArgs(tapRecognizer.CommandParameter); + handler(tapRecognizer, args); + eventFired = true; + } + } + catch (Exception ex) + { + Console.WriteLine("[GestureManager] Direct event invoke failed: " + ex.Message); + } + } + + // Execute command if available + if (tapRecognizer.Command?.CanExecute(tapRecognizer.CommandParameter) == true) + { + Console.WriteLine("[GestureManager] Executing Command"); + tapRecognizer.Command.Execute(tapRecognizer.CommandParameter); + } + + result = true; + } + return result; + } + + /// + /// Checks if the view has any gesture recognizers. + /// + public static bool HasGestureRecognizers(View? view) + { + return view?.GestureRecognizers?.Count > 0; + } + + /// + /// Checks if the view has a tap gesture recognizer. + /// + public static bool HasTapGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) return false; + return view.GestureRecognizers.Any(g => g is TapGestureRecognizer); + } + + /// + /// Processes a pointer down event. + /// + public static void ProcessPointerDown(View? view, double x, double y) + { + if (view == null) return; + + _gestureState[view] = new GestureTrackingState + { + StartX = x, + StartY = y, + CurrentX = x, + CurrentY = y, + StartTime = DateTime.UtcNow, + IsPanning = false, + IsPressed = true + }; + ProcessPointerEvent(view, x, y, PointerEventType.Pressed); + } + + /// + /// Processes a pointer move event. + /// + public static void ProcessPointerMove(View? view, double x, double y) + { + if (view == null) return; + + if (!_gestureState.TryGetValue(view, out var state)) + { + ProcessPointerEvent(view, x, y, PointerEventType.Moved); + return; + } + + state.CurrentX = x; + state.CurrentY = y; + + if (!state.IsPressed) + { + ProcessPointerEvent(view, x, y, PointerEventType.Moved); + return; + } + + double deltaX = x - state.StartX; + double deltaY = y - state.StartY; + double distance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance >= PanMinDistance) + { + ProcessPanGesture(view, deltaX, deltaY, state.IsPanning ? GestureStatus.Running : GestureStatus.Started); + state.IsPanning = true; + } + + ProcessPointerEvent(view, x, y, PointerEventType.Moved); + } + + /// + /// Processes a pointer up event. + /// + public static void ProcessPointerUp(View? view, double x, double y) + { + if (view == null) return; + + if (_gestureState.TryGetValue(view, out var state)) + { + state.CurrentX = x; + state.CurrentY = y; + + double deltaX = x - state.StartX; + double deltaY = y - state.StartY; + double distance = Math.Sqrt(deltaX * deltaX + deltaY * deltaY); + double elapsed = (DateTime.UtcNow - state.StartTime).TotalMilliseconds; + + // Check for swipe + if (distance >= SwipeMinDistance && elapsed <= SwipeMaxTime) + { + var direction = DetermineSwipeDirection(deltaX, deltaY); + ProcessSwipeGesture(view, direction); + } + + // Complete pan or detect tap + if (state.IsPanning) + { + ProcessPanGesture(view, deltaX, deltaY, GestureStatus.Completed); + } + else if (distance < 15.0 && elapsed < 500.0) + { + Console.WriteLine($"[GestureManager] Detected tap on {view.GetType().Name} (distance={distance:F1}, elapsed={elapsed:F0}ms)"); + ProcessTap(view, x, y); + } + + _gestureState.Remove(view); + } + + ProcessPointerEvent(view, x, y, PointerEventType.Released); + } + + /// + /// Processes a pointer entered event. + /// + public static void ProcessPointerEntered(View? view, double x, double y) + { + if (view != null) + ProcessPointerEvent(view, x, y, PointerEventType.Entered); + } + + /// + /// Processes a pointer exited event. + /// + public static void ProcessPointerExited(View? view, double x, double y) + { + if (view != null) + ProcessPointerEvent(view, x, y, PointerEventType.Exited); + } + + private static SwipeDirection DetermineSwipeDirection(double deltaX, double deltaY) + { + double absX = Math.Abs(deltaX); + double absY = Math.Abs(deltaY); + + if (absX > absY * SwipeDirectionThreshold) + return deltaX > 0 ? SwipeDirection.Right : SwipeDirection.Left; + if (absY > absX * SwipeDirectionThreshold) + return deltaY > 0 ? SwipeDirection.Down : SwipeDirection.Up; + return deltaX > 0 ? SwipeDirection.Right : SwipeDirection.Left; + } + + private static void ProcessSwipeGesture(View view, SwipeDirection direction) + { + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var recognizer in recognizers) + { + if (recognizer is not SwipeGestureRecognizer swipeRecognizer) + continue; + + if (!swipeRecognizer.Direction.HasFlag(direction)) + continue; + + Console.WriteLine($"[GestureManager] Swipe detected: {direction}"); + + try + { + var method = typeof(SwipeGestureRecognizer).GetMethod("SendSwiped", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + method?.Invoke(swipeRecognizer, new object[] { view, direction }); + } + catch (Exception ex) + { + Console.WriteLine("[GestureManager] SendSwiped failed: " + ex.Message); + } + + if (swipeRecognizer.Command?.CanExecute(swipeRecognizer.CommandParameter) == true) + swipeRecognizer.Command.Execute(swipeRecognizer.CommandParameter); + } + } + + private static void ProcessPanGesture(View view, double totalX, double totalY, GestureStatus status) + { + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var recognizer in recognizers) + { + if (recognizer is not PanGestureRecognizer panRecognizer) + continue; + + Console.WriteLine($"[GestureManager] Pan gesture: status={status}, totalX={totalX:F1}, totalY={totalY:F1}"); + + try + { + var method = typeof(PanGestureRecognizer).GetMethod("SendPan", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + method?.Invoke(panRecognizer, new object[] { view, totalX, totalY, (int)status }); + } + catch (Exception ex) + { + Console.WriteLine("[GestureManager] SendPan failed: " + ex.Message); + } + } + } + + private static void ProcessPointerEvent(View view, double x, double y, PointerEventType eventType) + { + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var recognizer in recognizers) + { + if (recognizer is not PointerGestureRecognizer pointerRecognizer) + continue; + + try + { + string? methodName = eventType switch + { + PointerEventType.Entered => "SendPointerEntered", + PointerEventType.Exited => "SendPointerExited", + PointerEventType.Pressed => "SendPointerPressed", + PointerEventType.Moved => "SendPointerMoved", + PointerEventType.Released => "SendPointerReleased", + _ => null + }; + + if (methodName != null) + { + var method = typeof(PointerGestureRecognizer).GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (method != null) + { + var args = CreatePointerEventArgs(view, x, y); + method.Invoke(pointerRecognizer, new object[] { view, args! }); + } + } + } + catch (Exception ex) + { + Console.WriteLine("[GestureManager] Pointer event failed: " + ex.Message); + } + } + } + + private static object? CreatePointerEventArgs(View view, double x, double y) + { + try + { + var type = typeof(PointerGestureRecognizer).Assembly.GetType("Microsoft.Maui.Controls.PointerEventArgs"); + if (type != null) + { + var ctor = type.GetConstructors().FirstOrDefault(); + if (ctor != null) + return ctor.Invoke(Array.Empty()); + } + } + catch { } + return null; + } + + /// + /// Checks if the view has a swipe gesture recognizer. + /// + public static bool HasSwipeGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) return false; + return view.GestureRecognizers.Any(g => g is SwipeGestureRecognizer); + } + + /// + /// Checks if the view has a pan gesture recognizer. + /// + public static bool HasPanGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) return false; + return view.GestureRecognizers.Any(g => g is PanGestureRecognizer); + } + + /// + /// Checks if the view has a pointer gesture recognizer. + /// + public static bool HasPointerGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) return false; + return view.GestureRecognizers.Any(g => g is PointerGestureRecognizer); + } +} diff --git a/Handlers/GtkWebViewManager.cs b/Handlers/GtkWebViewManager.cs new file mode 100644 index 0000000..7f1b902 --- /dev/null +++ b/Handlers/GtkWebViewManager.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using Microsoft.Maui.Platform.Linux.Window; + +namespace Microsoft.Maui.Platform.Linux.Handlers; + +/// +/// Manages WebView instances within the GTK host window. +/// Handles creation, layout updates, and cleanup of WebKit-based web views. +/// +public sealed class GtkWebViewManager +{ + private readonly GtkHostWindow _host; + private readonly Dictionary _webViews = new(); + + public GtkWebViewManager(GtkHostWindow host) + { + _host = host; + } + + public GtkWebViewPlatformView CreateWebView(object key, int x, int y, int width, int height) + { + var webView = new GtkWebViewPlatformView(); + _webViews[key] = webView; + _host.AddWebView(webView.Widget, x, y, width, height); + return webView; + } + + public void UpdateLayout(object key, int x, int y, int width, int height) + { + if (_webViews.TryGetValue(key, out var webView)) + { + _host.MoveResizeWebView(webView.Widget, x, y, width, height); + } + } + + public GtkWebViewPlatformView? GetWebView(object key) + { + return _webViews.TryGetValue(key, out var webView) ? webView : null; + } + + public void RemoveWebView(object key) + { + if (_webViews.TryGetValue(key, out var webView)) + { + _host.RemoveWebView(webView.Widget); + webView.Dispose(); + _webViews.Remove(key); + } + } + + public void Clear() + { + foreach (var kvp in _webViews) + { + _host.RemoveWebView(kvp.Value.Widget); + kvp.Value.Dispose(); + } + _webViews.Clear(); + } +} diff --git a/Handlers/GtkWebViewPlatformView.cs b/Handlers/GtkWebViewPlatformView.cs new file mode 100644 index 0000000..7b5a875 --- /dev/null +++ b/Handlers/GtkWebViewPlatformView.cs @@ -0,0 +1,164 @@ +using System; +using Microsoft.Maui.Platform.Linux.Native; + +namespace Microsoft.Maui.Platform.Linux.Handlers; + +/// +/// GTK-based WebView platform view using WebKitGTK. +/// Provides web browsing capabilities within MAUI applications. +/// +public sealed class GtkWebViewPlatformView : IDisposable +{ + private IntPtr _widget; + private bool _disposed; + private string? _currentUri; + private ulong _loadChangedSignalId; + private WebKitNative.LoadChangedCallback? _loadChangedCallback; + + public IntPtr Widget => _widget; + public string? CurrentUri => _currentUri; + + public event EventHandler? NavigationStarted; + public event EventHandler<(string Url, bool Success)>? NavigationCompleted; + public event EventHandler? TitleChanged; + + public GtkWebViewPlatformView() + { + if (!WebKitNative.Initialize()) + { + throw new InvalidOperationException("Failed to initialize WebKitGTK. Is libwebkit2gtk-4.x installed?"); + } + _widget = WebKitNative.WebViewNew(); + if (_widget == IntPtr.Zero) + { + throw new InvalidOperationException("Failed to create WebKitWebView widget"); + } + WebKitNative.ConfigureSettings(_widget); + _loadChangedCallback = OnLoadChanged; + _loadChangedSignalId = WebKitNative.ConnectLoadChanged(_widget, _loadChangedCallback); + Console.WriteLine("[GtkWebViewPlatformView] Created WebKitWebView widget"); + } + + private void OnLoadChanged(IntPtr webView, int loadEvent, IntPtr userData) + { + try + { + string uri = WebKitNative.GetUri(webView) ?? _currentUri ?? ""; + switch ((WebKitNative.WebKitLoadEvent)loadEvent) + { + case WebKitNative.WebKitLoadEvent.Started: + Console.WriteLine("[GtkWebViewPlatformView] Load started: " + uri); + NavigationStarted?.Invoke(this, uri); + break; + case WebKitNative.WebKitLoadEvent.Finished: + _currentUri = uri; + Console.WriteLine("[GtkWebViewPlatformView] Load finished: " + uri); + NavigationCompleted?.Invoke(this, (uri, true)); + break; + case WebKitNative.WebKitLoadEvent.Committed: + _currentUri = uri; + Console.WriteLine("[GtkWebViewPlatformView] Load committed: " + uri); + break; + case WebKitNative.WebKitLoadEvent.Redirected: + break; + } + } + catch (Exception ex) + { + Console.WriteLine("[GtkWebViewPlatformView] Error in OnLoadChanged: " + ex.Message); + Console.WriteLine("[GtkWebViewPlatformView] Stack trace: " + ex.StackTrace); + } + } + + public void Navigate(string uri) + { + if (_widget != IntPtr.Zero) + { + WebKitNative.LoadUri(_widget, uri); + Console.WriteLine("[GtkWebViewPlatformView] Navigate to: " + uri); + } + } + + public void LoadHtml(string html, string? baseUri = null) + { + if (_widget != IntPtr.Zero) + { + WebKitNative.LoadHtml(_widget, html, baseUri); + Console.WriteLine("[GtkWebViewPlatformView] Load HTML content"); + } + } + + public void GoBack() + { + if (_widget != IntPtr.Zero) + { + WebKitNative.GoBack(_widget); + } + } + + public void GoForward() + { + if (_widget != IntPtr.Zero) + { + WebKitNative.GoForward(_widget); + } + } + + public bool CanGoBack() + { + return _widget != IntPtr.Zero && WebKitNative.CanGoBack(_widget); + } + + public bool CanGoForward() + { + return _widget != IntPtr.Zero && WebKitNative.CanGoForward(_widget); + } + + public void Reload() + { + if (_widget != IntPtr.Zero) + { + WebKitNative.Reload(_widget); + } + } + + public void Stop() + { + if (_widget != IntPtr.Zero) + { + WebKitNative.StopLoading(_widget); + } + } + + public string? GetTitle() + { + return _widget == IntPtr.Zero ? null : WebKitNative.GetTitle(_widget); + } + + public string? GetUri() + { + return _widget == IntPtr.Zero ? null : WebKitNative.GetUri(_widget); + } + + public void SetJavascriptEnabled(bool enabled) + { + if (_widget != IntPtr.Zero) + { + WebKitNative.SetJavascriptEnabled(_widget, enabled); + } + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + if (_widget != IntPtr.Zero) + { + WebKitNative.DisconnectLoadChanged(_widget); + } + _widget = IntPtr.Zero; + _loadChangedCallback = null; + } + } +} diff --git a/Handlers/ImageHandler.cs b/Handlers/ImageHandler.cs index ce6e3e8..a0e8ba2 100644 --- a/Handlers/ImageHandler.cs +++ b/Handlers/ImageHandler.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.IO; +using Microsoft.Maui.Controls; using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using SkiaSharp; @@ -20,6 +22,8 @@ public partial class ImageHandler : ViewHandler [nameof(IImage.IsOpaque)] = MapIsOpaque, [nameof(IImageSourcePart.Source)] = MapSource, [nameof(IView.Background)] = MapBackground, + ["Width"] = MapWidth, + ["Height"] = MapHeight, }; public static CommandMapper CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -88,6 +92,19 @@ public partial class ImageHandler : ViewHandler { if (handler.PlatformView is null) return; + // Extract width/height requests from Image control + if (image is Image img) + { + if (img.WidthRequest > 0) + { + handler.PlatformView.WidthRequest = img.WidthRequest; + } + if (img.HeightRequest > 0) + { + handler.PlatformView.HeightRequest = img.HeightRequest; + } + } + handler.SourceLoader.UpdateImageSourceAsync(); } @@ -101,6 +118,36 @@ public partial class ImageHandler : ViewHandler } } + public static void MapWidth(ImageHandler handler, IImage image) + { + if (handler.PlatformView is null) return; + + if (image is Image img && img.WidthRequest > 0) + { + handler.PlatformView.WidthRequest = img.WidthRequest; + Console.WriteLine($"[ImageHandler] MapWidth: {img.WidthRequest}"); + } + else if (image.Width > 0) + { + handler.PlatformView.WidthRequest = image.Width; + } + } + + public static void MapHeight(ImageHandler handler, IImage image) + { + if (handler.PlatformView is null) return; + + if (image is Image img && img.HeightRequest > 0) + { + handler.PlatformView.HeightRequest = img.HeightRequest; + Console.WriteLine($"[ImageHandler] MapHeight: {img.HeightRequest}"); + } + else if (image.Height > 0) + { + handler.PlatformView.HeightRequest = image.Height; + } + } + // Image source loading helper private ImageSourceServiceResultManager _sourceLoader = null!; @@ -162,6 +209,14 @@ public partial class ImageHandler : ViewHandler await _handler.PlatformView!.LoadFromStreamAsync(stream); } } + else if (source is FontImageSource fontSource) + { + var bitmap = RenderFontImageSource(fontSource, _handler.PlatformView!.WidthRequest, _handler.PlatformView.HeightRequest); + if (bitmap != null) + { + _handler.PlatformView.LoadFromBitmap(bitmap); + } + } } catch (OperationCanceledException) { @@ -176,5 +231,73 @@ public partial class ImageHandler : ViewHandler } } } + + private static SKBitmap? RenderFontImageSource(FontImageSource fontSource, double requestedWidth, double requestedHeight) + { + string glyph = fontSource.Glyph; + if (string.IsNullOrEmpty(glyph)) + { + return null; + } + + int size = (int)Math.Max(requestedWidth > 0 ? requestedWidth : 24.0, requestedHeight > 0 ? requestedHeight : 24.0); + size = Math.Max(size, 16); + + SKColor color = fontSource.Color?.ToSKColor() ?? SKColors.Black; + SKBitmap bitmap = new SKBitmap(size, size, false); + using SKCanvas canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + + SKTypeface? typeface = null; + if (!string.IsNullOrEmpty(fontSource.FontFamily)) + { + string[] fontPaths = new string[] + { + "/usr/share/fonts/truetype/" + fontSource.FontFamily + ".ttf", + "/usr/share/fonts/opentype/" + fontSource.FontFamily + ".otf", + "/usr/local/share/fonts/" + fontSource.FontFamily + ".ttf", + Path.Combine(AppContext.BaseDirectory, fontSource.FontFamily + ".ttf") + }; + + foreach (string path in fontPaths) + { + if (File.Exists(path)) + { + typeface = SKTypeface.FromFile(path, 0); + if (typeface != null) + { + break; + } + } + } + + if (typeface == null) + { + typeface = SKTypeface.FromFamilyName(fontSource.FontFamily); + } + } + + if (typeface == null) + { + typeface = SKTypeface.Default; + } + + float fontSize = size * 0.8f; + using SKFont font = new SKFont(typeface, fontSize, 1f, 0f); + using SKPaint paint = new SKPaint(font) + { + Color = color, + IsAntialias = true, + TextAlign = SKTextAlign.Center + }; + + SKRect bounds = default; + paint.MeasureText(glyph, ref bounds); + float x = size / 2f; + float y = (size - bounds.Top - bounds.Bottom) / 2f; + canvas.DrawText(glyph, x, y, paint); + + return bitmap; + } } } diff --git a/Handlers/LabelHandler.cs b/Handlers/LabelHandler.cs index a94953f..a1ecf4c 100644 --- a/Handlers/LabelHandler.cs +++ b/Handlers/LabelHandler.cs @@ -1,8 +1,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; +using Microsoft.Maui.Controls; using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform.Linux.Window; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; @@ -29,6 +32,7 @@ public partial class LabelHandler : ViewHandler [nameof(IView.Background)] = MapBackground, [nameof(IView.VerticalLayoutAlignment)] = MapVerticalLayoutAlignment, [nameof(IView.HorizontalLayoutAlignment)] = MapHorizontalLayoutAlignment, + ["FormattedText"] = MapFormattedText, }; public static CommandMapper CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -49,6 +53,39 @@ public partial class LabelHandler : ViewHandler return new SkiaLabel(); } + protected override void ConnectHandler(SkiaLabel platformView) + { + base.ConnectHandler(platformView); + + if (VirtualView is View view) + { + platformView.MauiView = view; + + // Set hand cursor if the label has tap gesture recognizers + if (view.GestureRecognizers.OfType().Any()) + { + platformView.CursorType = CursorType.Hand; + } + } + + platformView.Tapped += OnPlatformViewTapped; + } + + protected override void DisconnectHandler(SkiaLabel platformView) + { + platformView.Tapped -= OnPlatformViewTapped; + platformView.MauiView = null; + base.DisconnectHandler(platformView); + } + + private void OnPlatformViewTapped(object? sender, EventArgs e) + { + if (VirtualView is View view) + { + GestureManager.ProcessTap(view, 0, 0); + } + } + public static void MapText(LabelHandler handler, ILabel label) { if (handler.PlatformView is null) return; @@ -205,4 +242,53 @@ public partial class LabelHandler : ViewHandler _ => LayoutOptions.Start }; } + + public static void MapFormattedText(LabelHandler handler, ILabel label) + { + if (handler.PlatformView is null) return; + + if (label is not Label mauiLabel) + { + handler.PlatformView.FormattedSpans = null; + return; + } + + var formattedText = mauiLabel.FormattedText; + if (formattedText == null || formattedText.Spans.Count == 0) + { + handler.PlatformView.FormattedSpans = null; + return; + } + + var spans = new List(); + foreach (var span in formattedText.Spans) + { + var skiaSpan = new SkiaTextSpan + { + Text = span.Text ?? "", + IsBold = span.FontAttributes.HasFlag(FontAttributes.Bold), + IsItalic = span.FontAttributes.HasFlag(FontAttributes.Italic), + IsUnderline = (span.TextDecorations & TextDecorations.Underline) != 0, + IsStrikethrough = (span.TextDecorations & TextDecorations.Strikethrough) != 0, + CharacterSpacing = (float)span.CharacterSpacing, + LineHeight = (float)span.LineHeight + }; + + if (span.TextColor != null) + skiaSpan.TextColor = span.TextColor.ToSKColor(); + + if (span.BackgroundColor != null) + skiaSpan.BackgroundColor = span.BackgroundColor.ToSKColor(); + + if (!string.IsNullOrEmpty(span.FontFamily)) + skiaSpan.FontFamily = span.FontFamily; + + if (span.FontSize > 0) + skiaSpan.FontSize = (float)span.FontSize; + + spans.Add(skiaSpan); + } + + handler.PlatformView.FormattedSpans = spans; + } } diff --git a/Handlers/LayoutHandler.Linux.cs b/Handlers/LayoutHandler.Linux.cs index f5fd97a..e94f180 100644 --- a/Handlers/LayoutHandler.Linux.cs +++ b/Handlers/LayoutHandler.Linux.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Maui.Handlers; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; namespace Microsoft.Maui.Platform; @@ -78,7 +79,7 @@ public partial class LayoutHandler : ViewHandler // Create handler for child if it doesn't exist if (child.Handler == null) { - child.Handler = child.ToHandler(MauiContext); + child.Handler = child.ToViewHandler(MauiContext); } if (child.Handler?.PlatformView is SkiaView skiaChild) @@ -299,7 +300,7 @@ public partial class GridHandler : LayoutHandler // Create handler for child if it doesn't exist if (child.Handler == null) { - child.Handler = child.ToHandler(MauiContext); + child.Handler = child.ToViewHandler(MauiContext); Console.WriteLine($"[GridHandler.ConnectHandler] Created handler for child[{i}]: {child.Handler?.GetType().Name ?? "failed"}"); } diff --git a/Handlers/LayoutHandler.cs b/Handlers/LayoutHandler.cs index 3b94823..b7712c0 100644 --- a/Handlers/LayoutHandler.cs +++ b/Handlers/LayoutHandler.cs @@ -3,6 +3,7 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Handlers; @@ -64,7 +65,7 @@ public partial class LayoutHandler : ViewHandler // Create handler for child if it doesn't exist if (child.Handler == null) { - child.Handler = child.ToHandler(MauiContext); + child.Handler = child.ToViewHandler(MauiContext); } // Add child's platform view to our layout @@ -284,7 +285,7 @@ public partial class GridHandler : LayoutHandler // Create handler for child if it doesn't exist if (child.Handler == null) { - child.Handler = child.ToHandler(MauiContext); + child.Handler = child.ToViewHandler(MauiContext); } // Get grid position from attached properties diff --git a/Handlers/NavigationPageHandler.cs b/Handlers/NavigationPageHandler.cs index 8e899a4..aff9a7c 100644 --- a/Handlers/NavigationPageHandler.cs +++ b/Handlers/NavigationPageHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using Microsoft.Maui.Controls; using Microsoft.Maui.Platform; +using Microsoft.Maui.Platform.Linux.Hosting; using SkiaSharp; using System.Collections.Specialized; @@ -100,7 +101,7 @@ public partial class NavigationPageHandler : ViewHandler [nameof(IPicker.TitleColor)] = MapTitleColor, [nameof(IPicker.SelectedIndex)] = MapSelectedIndex, [nameof(IPicker.TextColor)] = MapTextColor, + [nameof(ITextStyle.Font)] = MapFont, [nameof(IPicker.CharacterSpacing)] = MapCharacterSpacing, [nameof(IPicker.HorizontalTextAlignment)] = MapHorizontalTextAlignment, [nameof(IPicker.VerticalTextAlignment)] = MapVerticalTextAlignment, @@ -129,6 +130,22 @@ public partial class PickerHandler : ViewHandler } } + public static void MapFont(PickerHandler handler, IPicker picker) + { + if (handler.PlatformView is null) return; + + var font = picker.Font; + if (!string.IsNullOrEmpty(font.Family)) + { + handler.PlatformView.FontFamily = font.Family; + } + if (font.Size > 0) + { + handler.PlatformView.FontSize = (float)font.Size; + } + handler.PlatformView.Invalidate(); + } + public static void MapCharacterSpacing(PickerHandler handler, IPicker picker) { // Character spacing could be implemented with custom text rendering diff --git a/Handlers/ProgressBarHandler.cs b/Handlers/ProgressBarHandler.cs index e02ebdc..210be1b 100644 --- a/Handlers/ProgressBarHandler.cs +++ b/Handlers/ProgressBarHandler.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel; +using Microsoft.Maui.Controls; using Microsoft.Maui.Handlers; using Microsoft.Maui.Graphics; using SkiaSharp; @@ -18,7 +20,9 @@ public partial class ProgressBarHandler : ViewHandler CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -39,6 +43,40 @@ public partial class ProgressBarHandler : ViewHandler [nameof(ISwitch.TrackColor)] = MapTrackColor, [nameof(ISwitch.ThumbColor)] = MapThumbColor, [nameof(IView.Background)] = MapBackground, + [nameof(IView.IsEnabled)] = MapIsEnabled, }; public static CommandMapper CommandMapper = new(ViewHandler.ViewCommandMapper) @@ -96,4 +97,10 @@ public partial class SwitchHandler : ViewHandler handler.PlatformView.BackgroundColor = solidPaint.Color.ToSKColor(); } } + + public static void MapIsEnabled(SwitchHandler handler, ISwitch @switch) + { + if (handler.PlatformView is null) return; + handler.PlatformView.IsEnabled = @switch.IsEnabled; + } } diff --git a/Hosting/LinuxMauiContext.cs b/Hosting/LinuxMauiContext.cs index 995c22b..5631a1d 100644 --- a/Hosting/LinuxMauiContext.cs +++ b/Hosting/LinuxMauiContext.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Animations; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Platform; +using Microsoft.Maui.Platform.Linux.Dispatching; using SkiaSharp; namespace Microsoft.Maui.Platform.Linux.Hosting; @@ -82,125 +83,6 @@ public class ScopedLinuxMauiContext : IMauiContext public IMauiHandlersFactory Handlers => _parent.Handlers; } -/// -/// Linux dispatcher for UI thread operations. -/// -internal class LinuxDispatcher : IDispatcher -{ - private readonly object _lock = new(); - private readonly Queue _queue = new(); - private bool _isDispatching; - - public bool IsDispatchRequired => false; // Linux uses single-threaded event loop - - public IDispatcherTimer CreateTimer() - { - return new LinuxDispatcherTimer(); - } - - public bool Dispatch(Action action) - { - if (action == null) - return false; - - lock (_lock) - { - _queue.Enqueue(action); - } - - ProcessQueue(); - return true; - } - - public bool DispatchDelayed(TimeSpan delay, Action action) - { - if (action == null) - return false; - - Task.Delay(delay).ContinueWith(_ => Dispatch(action)); - return true; - } - - private void ProcessQueue() - { - if (_isDispatching) - return; - - _isDispatching = true; - try - { - while (true) - { - Action? action; - lock (_lock) - { - if (_queue.Count == 0) - break; - action = _queue.Dequeue(); - } - action?.Invoke(); - } - } - finally - { - _isDispatching = false; - } - } -} - -/// -/// Linux dispatcher timer implementation. -/// -internal class LinuxDispatcherTimer : IDispatcherTimer -{ - private Timer? _timer; - private TimeSpan _interval = TimeSpan.FromMilliseconds(16); // ~60fps default - private bool _isRunning; - private bool _isRepeating = true; - - public TimeSpan Interval - { - get => _interval; - set => _interval = value; - } - - public bool IsRunning => _isRunning; - - public bool IsRepeating - { - get => _isRepeating; - set => _isRepeating = value; - } - - public event EventHandler? Tick; - - public void Start() - { - if (_isRunning) - return; - - _isRunning = true; - _timer = new Timer(OnTimerCallback, null, _interval, _isRepeating ? _interval : Timeout.InfiniteTimeSpan); - } - - public void Stop() - { - _isRunning = false; - _timer?.Dispose(); - _timer = null; - } - - private void OnTimerCallback(object? state) - { - Tick?.Invoke(this, EventArgs.Empty); - - if (!_isRepeating) - { - Stop(); - } - } -} - /// /// Linux animation manager. /// diff --git a/Hosting/LinuxViewRenderer.cs b/Hosting/LinuxViewRenderer.cs index a1bac7a..b2acc31 100644 --- a/Hosting/LinuxViewRenderer.cs +++ b/Hosting/LinuxViewRenderer.cs @@ -476,22 +476,3 @@ public class LinuxViewRenderer } } -/// -/// Extension methods for MAUI handler creation. -/// -public static class MauiHandlerExtensions -{ - /// - /// Creates a handler for the view and returns it. - /// - public static IElementHandler ToHandler(this IElement element, IMauiContext mauiContext) - { - var handler = mauiContext.Handlers.GetHandler(element.GetType()); - if (handler != null) - { - handler.SetMauiContext(mauiContext); - handler.SetVirtualView(element); - } - return handler!; - } -} diff --git a/Hosting/MauiHandlerExtensions.cs b/Hosting/MauiHandlerExtensions.cs new file mode 100644 index 0000000..6f3c6b0 --- /dev/null +++ b/Hosting/MauiHandlerExtensions.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Platform.Linux.Handlers; + +namespace Microsoft.Maui.Platform.Linux.Hosting; + +/// +/// Extension methods for creating MAUI handlers on Linux. +/// Maps MAUI types to Linux-specific handlers with fallback to MAUI defaults. +/// +public static class MauiHandlerExtensions +{ + private static readonly Dictionary> LinuxHandlerMap = new Dictionary> + { + [typeof(Button)] = () => new TextButtonHandler(), + [typeof(Label)] = () => new LabelHandler(), + [typeof(Entry)] = () => new EntryHandler(), + [typeof(Editor)] = () => new EditorHandler(), + [typeof(CheckBox)] = () => new CheckBoxHandler(), + [typeof(Switch)] = () => new SwitchHandler(), + [typeof(Slider)] = () => new SliderHandler(), + [typeof(Stepper)] = () => new StepperHandler(), + [typeof(ProgressBar)] = () => new ProgressBarHandler(), + [typeof(ActivityIndicator)] = () => new ActivityIndicatorHandler(), + [typeof(Picker)] = () => new PickerHandler(), + [typeof(DatePicker)] = () => new DatePickerHandler(), + [typeof(TimePicker)] = () => new TimePickerHandler(), + [typeof(SearchBar)] = () => new SearchBarHandler(), + [typeof(RadioButton)] = () => new RadioButtonHandler(), + [typeof(WebView)] = () => new WebViewHandler(), + [typeof(Image)] = () => new ImageHandler(), + [typeof(ImageButton)] = () => new ImageButtonHandler(), + [typeof(BoxView)] = () => new BoxViewHandler(), + [typeof(Frame)] = () => new FrameHandler(), + [typeof(Border)] = () => new BorderHandler(), + [typeof(ContentView)] = () => new BorderHandler(), + [typeof(ScrollView)] = () => new ScrollViewHandler(), + [typeof(Grid)] = () => new GridHandler(), + [typeof(StackLayout)] = () => new StackLayoutHandler(), + [typeof(VerticalStackLayout)] = () => new StackLayoutHandler(), + [typeof(HorizontalStackLayout)] = () => new StackLayoutHandler(), + [typeof(AbsoluteLayout)] = () => new LayoutHandler(), + [typeof(FlexLayout)] = () => new FlexLayoutHandler(), + [typeof(CollectionView)] = () => new CollectionViewHandler(), + [typeof(ListView)] = () => new CollectionViewHandler(), + [typeof(Page)] = () => new PageHandler(), + [typeof(ContentPage)] = () => new ContentPageHandler(), + [typeof(NavigationPage)] = () => new NavigationPageHandler(), + [typeof(Shell)] = () => new ShellHandler(), + [typeof(FlyoutPage)] = () => new FlyoutPageHandler(), + [typeof(TabbedPage)] = () => new TabbedPageHandler(), + [typeof(Application)] = () => new ApplicationHandler(), + [typeof(Microsoft.Maui.Controls.Window)] = () => new WindowHandler(), + [typeof(GraphicsView)] = () => new GraphicsViewHandler() + }; + + /// + /// Creates an element handler for the given element. + /// + public static IElementHandler ToHandler(this IElement element, IMauiContext mauiContext) + { + return CreateHandler(element, mauiContext)!; + } + + /// + /// Creates a view handler for the given view. + /// + public static IViewHandler? ToViewHandler(this IView view, IMauiContext mauiContext) + { + var handler = CreateHandler((IElement)view, mauiContext); + return handler as IViewHandler; + } + + private static IElementHandler? CreateHandler(IElement element, IMauiContext mauiContext) + { + Type type = element.GetType(); + IElementHandler? handler = null; + + // First, try exact type match + if (LinuxHandlerMap.TryGetValue(type, out Func? factory)) + { + handler = factory(); + Console.WriteLine($"[ToHandler] Using Linux handler for {type.Name}: {handler.GetType().Name}"); + } + else + { + // Try to find a base type match + Type? bestMatch = null; + Func? bestFactory = null; + + foreach (var kvp in LinuxHandlerMap) + { + if (kvp.Key.IsAssignableFrom(type) && (bestMatch == null || bestMatch.IsAssignableFrom(kvp.Key))) + { + bestMatch = kvp.Key; + bestFactory = kvp.Value; + } + } + + if (bestFactory != null) + { + handler = bestFactory(); + Console.WriteLine($"[ToHandler] Using Linux handler (via base {bestMatch!.Name}) for {type.Name}: {handler.GetType().Name}"); + } + } + + // Fall back to MAUI's default handler + if (handler == null) + { + handler = mauiContext.Handlers.GetHandler(type); + Console.WriteLine($"[ToHandler] Using MAUI handler for {type.Name}: {handler?.GetType().Name ?? "null"}"); + } + + if (handler != null) + { + handler.SetMauiContext(mauiContext); + handler.SetVirtualView(element); + } + + return handler; + } +} diff --git a/LinuxApplication.cs b/LinuxApplication.cs index fbb0fc6..afc2002 100644 --- a/LinuxApplication.cs +++ b/LinuxApplication.cs @@ -1,12 +1,24 @@ // 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.Window; using Microsoft.Maui.Platform.Linux.Services; +using Microsoft.Maui.Platform.Linux.Window; using Microsoft.Maui.Platform; +using SkiaSharp; namespace Microsoft.Maui.Platform.Linux; @@ -15,19 +27,114 @@ namespace Microsoft.Maui.Platform.Linux; /// public class LinuxApplication : IDisposable { + private static int _invalidateCount; + private static int _requestRedrawCount; + private static int _drawCount; + private static int _gtkThreadId; + private static DateTime _lastCounterReset = DateTime.Now; + private static bool _isRedrawing; + private static int _loopCounter = 0; + private X11Window? _mainWindow; + private GtkHostWindow? _gtkWindow; private SkiaRenderingEngine? _renderingEngine; private SkiaView? _rootView; private SkiaView? _focusedView; private SkiaView? _hoveredView; private SkiaView? _capturedView; // View that has captured pointer events during drag private bool _disposed; + private bool _useGtk; /// /// Gets the current application instance. /// public static LinuxApplication? Current { get; private set; } + /// + /// Gets whether the application is running in GTK mode. + /// + public static bool IsGtkMode => Current?._useGtk ?? false; + + /// + /// Logs an invalidate call for diagnostics. + /// + public static void LogInvalidate(string source) + { + int currentThread = Environment.CurrentManagedThreadId; + Interlocked.Increment(ref _invalidateCount); + if (currentThread != _gtkThreadId && _gtkThreadId != 0) + { + Console.WriteLine($"[DIAG] ⚠️ Invalidate from WRONG THREAD! GTK={_gtkThreadId}, Current={currentThread}, Source={source}"); + } + } + + /// + /// Logs a request redraw call for diagnostics. + /// + public static void LogRequestRedraw() + { + int currentThread = Environment.CurrentManagedThreadId; + Interlocked.Increment(ref _requestRedrawCount); + if (currentThread != _gtkThreadId && _gtkThreadId != 0) + { + Console.WriteLine($"[DIAG] ⚠️ RequestRedraw from WRONG THREAD! GTK={_gtkThreadId}, Current={currentThread}"); + } + } + + private static void StartHeartbeat() + { + _gtkThreadId = Environment.CurrentManagedThreadId; + Console.WriteLine($"[DIAG] GTK thread ID: {_gtkThreadId}"); + GLibNative.TimeoutAdd(250, () => + { + DateTime now = DateTime.Now; + if ((now - _lastCounterReset).TotalSeconds >= 1.0) + { + int invalidates = Interlocked.Exchange(ref _invalidateCount, 0); + int redraws = Interlocked.Exchange(ref _requestRedrawCount, 0); + int draws = Interlocked.Exchange(ref _drawCount, 0); + Console.WriteLine($"[DIAG] ❤️ Heartbeat | Invalidate={invalidates}/s, RequestRedraw={redraws}/s, Draw={draws}/s"); + _lastCounterReset = now; + } + return true; + }); + } + + /// + /// Logs a draw call for diagnostics. + /// + public static void LogDraw() + { + Interlocked.Increment(ref _drawCount); + } + + /// + /// Requests a redraw of the application. + /// + public static void RequestRedraw() + { + LogRequestRedraw(); + if (_isRedrawing) + return; + + _isRedrawing = true; + try + { + if (Current != null && Current._useGtk) + { + Current._gtkWindow?.RequestRedraw(); + } + else + { + Current?._renderingEngine?.InvalidateAll(); + } + } + finally + { + _isRedrawing = false; + } + } + /// /// Gets the main window. /// @@ -112,84 +219,99 @@ public class LinuxApplication : IDisposable /// Optional configuration action. public static void Run(MauiApp app, string[] args, Action? configure) { + // Initialize dispatcher + LinuxDispatcher.Initialize(); + DispatcherProvider.SetCurrent(LinuxDispatcherProvider.Instance); + Console.WriteLine("[LinuxApplication] Dispatcher initialized"); + var options = app.Services.GetService() ?? new LinuxApplicationOptions(); configure?.Invoke(options); ParseCommandLineOptions(args, options); - using var linuxApp = new LinuxApplication(); - linuxApp.Initialize(options); - - // Create MAUI context - var mauiContext = new Hosting.LinuxMauiContext(app.Services, linuxApp); - - // Get the application and render it - var application = app.Services.GetService(); - SkiaView? rootView = null; - - if (application is Microsoft.Maui.Controls.Application mauiApplication) + var linuxApp = new LinuxApplication(); + try { - // Force Application.Current to be this instance - // The constructor sets Current = this, but we ensure it here - var currentProperty = typeof(Microsoft.Maui.Controls.Application).GetProperty("Current"); - if (currentProperty != null && currentProperty.CanWrite) + linuxApp.Initialize(options); + + // Create MAUI context + var mauiContext = new LinuxMauiContext(app.Services, linuxApp); + + // Get the application and render it + var application = app.Services.GetService(); + SkiaView? rootView = null; + + if (application is Application mauiApplication) { - currentProperty.SetValue(null, mauiApplication); + // Force Application.Current to be this instance + var currentProperty = typeof(Application).GetProperty("Current"); + if (currentProperty != null && currentProperty.CanWrite) + { + currentProperty.SetValue(null, mauiApplication); + } + + // Handle theme changes + ((BindableObject)mauiApplication).PropertyChanged += (s, e) => + { + if (e.PropertyName == "UserAppTheme") + { + Console.WriteLine($"[LinuxApplication] Theme changed to: {mauiApplication.UserAppTheme}"); + LinuxViewRenderer.CurrentSkiaShell?.RefreshTheme(); + linuxApp._renderingEngine?.InvalidateAll(); + } + }; + + if (mauiApplication.MainPage != null) + { + var mainPage = mauiApplication.MainPage; + + var windowsField = typeof(Application).GetField("_windows", + BindingFlags.NonPublic | BindingFlags.Instance); + var windowsList = windowsField?.GetValue(mauiApplication) as List; + + if (windowsList != null && windowsList.Count == 0) + { + var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage); + windowsList.Add(mauiWindow); + mauiWindow.Parent = mauiApplication; + } + else if (windowsList != null && windowsList.Count > 0 && windowsList[0].Page == null) + { + windowsList[0].Page = mainPage; + } + + 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 (mauiApplication.MainPage != null) + if (rootView == null) { - // Create a MAUI Window and add it to the application - // This ensures Shell.Current works (it reads from Application.Current.Windows[0].Page) - var mainPage = mauiApplication.MainPage; - - // Always ensure we have a window with the Shell/Page - var windowsField = typeof(Microsoft.Maui.Controls.Application).GetField("_windows", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var windowsList = windowsField?.GetValue(mauiApplication) as System.Collections.Generic.List; - - if (windowsList != null && windowsList.Count == 0) - { - var mauiWindow = new Microsoft.Maui.Controls.Window(mainPage); - windowsList.Add(mauiWindow); - mauiWindow.Parent = mauiApplication; - } - else if (windowsList != null && windowsList.Count > 0 && windowsList[0].Page == null) - { - // Window exists but has no page - set it - windowsList[0].Page = mainPage; - } - - var renderer = new Hosting.LinuxViewRenderer(mauiContext); - rootView = renderer.RenderPage(mainPage); - - // Update window title based on app name (NavigationPage.Title takes precedence) - string windowTitle = "OpenMaui App"; - if (mainPage is Microsoft.Maui.Controls.NavigationPage navPage) - { - // Prefer NavigationPage.Title (app name) over CurrentPage.Title (page name) for window title - windowTitle = navPage.Title ?? windowTitle; - } - else if (mainPage is Microsoft.Maui.Controls.Shell shell) - { - windowTitle = shell.Title ?? windowTitle; - } - else - { - windowTitle = mainPage.Title ?? windowTitle; - } - linuxApp.SetWindowTitle(windowTitle); + rootView = LinuxProgramHost.CreateDemoView(); } - } - // Fallback to demo if no view - if (rootView == null) + linuxApp.RootView = rootView; + linuxApp.Run(); + } + finally { - rootView = Hosting.LinuxProgramHost.CreateDemoView(); + linuxApp?.Dispose(); } - - linuxApp.RootView = rootView; - linuxApp.Run(); } private static void ParseCommandLineOptions(string[] args, LinuxApplicationOptions options) @@ -218,16 +340,37 @@ public class LinuxApplication : IDisposable /// public void Initialize(LinuxApplicationOptions options) { - // Create the main window + _useGtk = options.UseGtk; + if (_useGtk) + { + InitializeGtk(options); + } + else + { + InitializeX11(options); + } + RegisterServices(); + } + + private void InitializeX11(LinuxApplicationOptions options) + { _mainWindow = new X11Window( options.Title ?? "MAUI Application", options.Width, options.Height); - // Create the rendering engine + // Set up WebView main window + SkiaWebView.SetMainWindow(_mainWindow.Display, _mainWindow.Handle); + + // Set window icon + string? iconPath = ResolveIconPath(options.IconPath); + if (!string.IsNullOrEmpty(iconPath)) + { + _mainWindow.SetIcon(iconPath); + } + _renderingEngine = new SkiaRenderingEngine(_mainWindow); - // Wire up events _mainWindow.Resized += OnWindowResized; _mainWindow.Exposed += OnWindowExposed; _mainWindow.KeyDown += OnKeyDown; @@ -238,9 +381,69 @@ public class LinuxApplication : IDisposable _mainWindow.PointerReleased += OnPointerReleased; _mainWindow.Scroll += OnScroll; _mainWindow.CloseRequested += OnCloseRequested; + } - // Register platform services - RegisterServices(); + private void InitializeGtk(LinuxApplicationOptions options) + { + _gtkWindow = GtkHostService.Instance.GetOrCreateHostWindow( + options.Title ?? "MAUI Application", + options.Width, + options.Height); + + string? iconPath = ResolveIconPath(options.IconPath); + if (!string.IsNullOrEmpty(iconPath)) + { + GtkHostService.Instance.SetWindowIcon(iconPath); + } + + if (_gtkWindow.SkiaSurface != null) + { + _gtkWindow.SkiaSurface.DrawRequested += OnGtkDrawRequested; + _gtkWindow.SkiaSurface.PointerPressed += OnGtkPointerPressed; + _gtkWindow.SkiaSurface.PointerReleased += OnGtkPointerReleased; + _gtkWindow.SkiaSurface.PointerMoved += OnGtkPointerMoved; + _gtkWindow.SkiaSurface.KeyPressed += OnGtkKeyPressed; + _gtkWindow.SkiaSurface.KeyReleased += OnGtkKeyReleased; + _gtkWindow.SkiaSurface.Scrolled += OnGtkScrolled; + _gtkWindow.SkiaSurface.TextInput += OnGtkTextInput; + } + _gtkWindow.Resized += OnGtkResized; + } + + private static string? ResolveIconPath(string? explicitPath) + { + if (!string.IsNullOrEmpty(explicitPath)) + { + if (Path.IsPathRooted(explicitPath)) + { + return File.Exists(explicitPath) ? explicitPath : null; + } + string resolved = Path.Combine(AppContext.BaseDirectory, explicitPath); + return File.Exists(resolved) ? resolved : null; + } + + string baseDir = AppContext.BaseDirectory; + + // Check for appicon.meta (generated icon) + string metaPath = Path.Combine(baseDir, "appicon.meta"); + if (File.Exists(metaPath)) + { + string? generated = MauiIconGenerator.GenerateIcon(metaPath); + if (!string.IsNullOrEmpty(generated) && File.Exists(generated)) + { + return generated; + } + } + + // Check for appicon.png + string pngPath = Path.Combine(baseDir, "appicon.png"); + if (File.Exists(pngPath)) return pngPath; + + // Check for appicon.svg + string svgPath = Path.Combine(baseDir, "appicon.svg"); + if (File.Exists(svgPath)) return svgPath; + + return null; } private void RegisterServices() @@ -261,27 +464,62 @@ public class LinuxApplication : IDisposable /// Shows the main window and runs the event loop. /// public void Run() + { + if (_useGtk) + { + RunGtk(); + } + else + { + RunX11(); + } + } + + private void RunX11() { if (_mainWindow == null) throw new InvalidOperationException("Application not initialized"); _mainWindow.Show(); - - // Initial render Render(); - // Run the event loop + Console.WriteLine("[LinuxApplication] Starting event loop"); while (_mainWindow.IsRunning) { - _mainWindow.ProcessEvents(); + _loopCounter++; + if (_loopCounter % 1000 == 0) + { + Console.WriteLine($"[LinuxApplication] Loop iteration {_loopCounter}"); + } - // Update animations and render + _mainWindow.ProcessEvents(); + SkiaWebView.ProcessGtkEvents(); UpdateAnimations(); Render(); - - // Small delay to prevent 100% CPU usage Thread.Sleep(1); } + Console.WriteLine("[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 SKSize(width, height)); + _rootView.Arrange(new SKRect(0, 0, width, height)); + } } private void UpdateAnimations() @@ -358,6 +596,13 @@ public class LinuxApplication : IDisposable 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) { @@ -384,6 +629,10 @@ public class LinuxApplication : IDisposable _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); @@ -394,6 +643,13 @@ public class LinuxApplication : IDisposable { Console.WriteLine($"[LinuxApplication] OnPointerPressed at ({e.X}, {e.Y})"); + // 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) { @@ -489,6 +745,224 @@ public class LinuxApplication : IDisposable _mainWindow?.Stop(); } + // GTK Event Handlers + private void OnGtkDrawRequested(object? sender, EventArgs e) + { + Console.WriteLine("[DIAG] >>> 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); + Console.WriteLine("[DIAG] Drawing rootView..."); + _rootView.Draw(surface.Canvas); + Console.WriteLine("[DIAG] Drawing dialogs..."); + var bounds = new SKRect(0, 0, surface.Width, surface.Height); + LinuxDialogService.DrawDialogs(surface.Canvas, bounds); + Console.WriteLine("[DIAG] <<< 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})"; + Console.WriteLine($"[LinuxApplication.GTK] PointerPressed at ({e.X:F1}, {e.Y:F1}), Button={e.Button} ({buttonName})"); + + 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) + { + Console.WriteLine("[LinuxApplication.GTK] _rootView is null!"); + return; + } + + var hitView = _rootView.HitTest((float)e.X, (float)e.Y); + Console.WriteLine($"[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); + Console.WriteLine("[DIAG] >>> Before OnPointerPressed"); + hitView.OnPointerPressed(args); + Console.WriteLine("[DIAG] <<< After OnPointerPressed, calling RequestRedraw"); + _gtkWindow?.RequestRedraw(); + Console.WriteLine("[DIAG] <<< After RequestRedraw, returning from handler"); + } + } + + private void OnGtkPointerReleased(object? sender, (double X, double Y, int Button) e) + { + Console.WriteLine("[DIAG] >>> OnGtkPointerReleased ENTER"); + 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); + Console.WriteLine($"[DIAG] Calling OnPointerReleased on {_capturedView.GetType().Name}"); + _capturedView.OnPointerReleased(args); + Console.WriteLine("[DIAG] OnPointerReleased returned"); + _capturedView = null; + _gtkWindow?.RequestRedraw(); + Console.WriteLine("[DIAG] <<< 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) + { + 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) + { + if (_focusedView != null) + { + var key = ConvertGdkKey(e.KeyVal); + var modifiers = ConvertGdkModifiers(e.State); + var args = new KeyEventArgs(key, modifiers); + _focusedView.OnKeyDown(args); + _gtkWindow?.RequestRedraw(); + } + } + + private void OnGtkKeyReleased(object? sender, (uint KeyVal, uint KeyCode, uint State) e) + { + if (_focusedView != null) + { + var key = ConvertGdkKey(e.KeyVal); + var modifiers = ConvertGdkModifiers(e.State); + var args = new KeyEventArgs(key, modifiers); + _focusedView.OnKeyUp(args); + _gtkWindow?.RequestRedraw(); + } + } + + private void OnGtkScrolled(object? sender, (double X, double Y, double DeltaX, double DeltaY) e) + { + if (_rootView == null) return; + + var hitView = _rootView.HitTest((float)e.X, (float)e.Y); + while (hitView != null) + { + if (hitView is SkiaScrollView scrollView) + { + var args = new ScrollEventArgs((float)e.X, (float)e.Y, (float)e.DeltaX, (float)e.DeltaY); + scrollView.OnScroll(args); + _gtkWindow?.RequestRedraw(); + break; + } + hitView = hitView.Parent; + } + } + + private void OnGtkTextInput(object? sender, string text) + { + if (_focusedView != null) + { + var args = new TextInputEventArgs(text); + _focusedView.OnTextInput(args); + _gtkWindow?.RequestRedraw(); + } + } + + private static Key ConvertGdkKey(uint keyval) + { + return keyval switch + { + 65288 => Key.Backspace, + 65289 => Key.Tab, + 65293 => Key.Enter, + 65307 => Key.Escape, + 65360 => Key.Home, + 65361 => Key.Left, + 65362 => Key.Up, + 65363 => Key.Right, + 65364 => Key.Down, + 65365 => Key.PageUp, + 65366 => Key.PageDown, + 65367 => Key.End, + 65535 => Key.Delete, + >= 32 and <= 126 => (Key)keyval, + _ => Key.Unknown + }; + } + + private static KeyModifiers ConvertGdkModifiers(uint state) + { + var modifiers = KeyModifiers.None; + if ((state & 1) != 0) modifiers |= KeyModifiers.Shift; + if ((state & 4) != 0) modifiers |= KeyModifiers.Control; + if ((state & 8) != 0) modifiers |= KeyModifiers.Alt; + return modifiers; + } + public void Dispose() { if (!_disposed) @@ -538,6 +1012,16 @@ public class LinuxApplicationOptions /// Gets or sets whether to force demo mode instead of loading the application's pages. /// public bool ForceDemo { get; set; } = false; + + /// + /// Gets or sets whether to use GTK mode instead of X11. + /// + public bool UseGtk { get; set; } = false; + + /// + /// Gets or sets the path to the application icon. + /// + public string? IconPath { get; set; } } /// diff --git a/MERGE_TRACKING.md b/MERGE_TRACKING.md index 830918d..d12ccf3 100644 --- a/MERGE_TRACKING.md +++ b/MERGE_TRACKING.md @@ -4,17 +4,20 @@ | Category | In Main | In Decompiled | New to Add | To Compare | Completed | |----------|---------|---------------|------------|------------|-----------| -| Handlers | 44 | 48 | 13 | 35 | 0 | -| Views/Types | 41 | 118 | 77 | 41 | 0 | -| Services | 33 | 103 | 70 | 33 | 0 | -| Hosting | 5 | 12 | 7 | 5 | 0 | -| Dispatching | 0 | 3 | 3 | 0 | 0 | -| Native | 0 | 5 | 5 | 0 | 0 | -| **TOTAL** | **123** | **289** | **175** | **114** | **0** | +| Handlers | 44 | 48 | 13 | 35 | 23 | +| Views/Types | 41 | 118 | 77 | 41 | 10 | +| Services | 33 | 103 | 70 | 33 | 7 | +| Hosting | 5 | 12 | 7 | 5 | 2 | +| Dispatching | 0 | 3 | 3 | 0 | 3 | +| Native | 0 | 5 | 5 | 0 | 5 | +| Window | 2 | 3 | 1 | 2 | 3 | +| Rendering | 1 | 2 | 1 | 1 | 1 | +| **TOTAL** | **123** | **289** | **175** | **114** | **54** | -**Branch:** `final` +**Branch:** `main` **Base:** Clean main (builds with 0 errors) -**Status:** Ready to begin +**Status:** In progress - BUILD SUCCEEDS +**Last Updated:** 2026-01-01 --- @@ -22,62 +25,62 @@ ### New Handlers (13 files) - TO ADD -- [ ] ContentPageHandler.cs -- [ ] FlexLayoutHandler.cs -- [ ] GestureManager.cs -- [ ] GridHandler.cs +- [ ] ContentPageHandler.cs - EXISTS IN PageHandler.cs, needs comparison +- [x] FlexLayoutHandler.cs - ADDED +- [x] GestureManager.cs - ADDED (tap, pan, swipe, pointer gesture processing) +- [ ] GridHandler.cs - EXISTS IN LayoutHandler.cs, needs comparison - [ ] GtkWebViewHandler.cs -- [ ] GtkWebViewManager.cs -- [ ] GtkWebViewPlatformView.cs +- [x] GtkWebViewManager.cs - ADDED +- [x] GtkWebViewPlatformView.cs - ADDED - [ ] GtkWebViewProxy.cs -- [ ] LayoutHandlerUpdate.cs +- [ ] LayoutHandlerUpdate.cs - EXISTS IN LayoutHandler.cs - [ ] LinuxApplicationContext.cs -- [ ] RelayCommand.cs +- [ ] RelayCommand.cs - EXISTS IN NavigationPageHandler.cs - [ ] SizeChangedEventArgs.cs - [ ] SkiaWindow.cs -- [ ] StackLayoutHandler.cs -- [ ] TextButtonHandler.cs +- [ ] StackLayoutHandler.cs - EXISTS IN LayoutHandler.cs, needs comparison +- [ ] TextButtonHandler.cs - EXISTS IN ButtonHandler.cs ### Existing Handlers (35 files) - TO COMPARE - [ ] ActivityIndicatorHandler.cs - [ ] ActivityIndicatorHandler.Linux.cs - [ ] ApplicationHandler.cs -- [ ] BorderHandler.cs +- [x] BorderHandler.cs - Updated to use ToViewHandler - [ ] BoxViewHandler.cs - [ ] ButtonHandler.cs - [ ] ButtonHandler.Linux.cs -- [ ] CheckBoxHandler.cs +- [x] CheckBoxHandler.cs - Updated with missing mappers - [ ] CheckBoxHandler.Linux.cs -- [ ] CollectionViewHandler.cs -- [ ] DatePickerHandler.cs +- [x] CollectionViewHandler.cs - Updated to use ToViewHandler +- [x] DatePickerHandler.cs - Updated with missing mappers - [ ] EditorHandler.cs -- [ ] EntryHandler.cs +- [x] EntryHandler.cs - Updated with missing mappers - [ ] EntryHandler.Linux.cs - [ ] FlyoutPageHandler.cs -- [ ] FrameHandler.cs +- [x] FrameHandler.cs - Updated to use ToViewHandler - [ ] GraphicsViewHandler.cs - [ ] ImageButtonHandler.cs -- [ ] ImageHandler.cs +- [x] ImageHandler.cs - Updated with LoadFromBitmap support - [ ] ItemsViewHandler.cs -- [ ] LabelHandler.cs +- [x] LabelHandler.cs - Added ConnectHandler, DisconnectHandler, OnPlatformViewTapped, MapFormattedText - [ ] LabelHandler.Linux.cs -- [ ] LayoutHandler.cs -- [ ] LayoutHandler.Linux.cs -- [ ] NavigationPageHandler.cs -- [ ] PageHandler.cs -- [ ] PickerHandler.cs -- [ ] ProgressBarHandler.cs +- [x] LayoutHandler.cs - Updated to use ToViewHandler +- [x] LayoutHandler.Linux.cs - Updated to use ToViewHandler +- [x] NavigationPageHandler.cs - Updated to use ToViewHandler +- [x] PageHandler.cs - Updated to use ToViewHandler +- [x] PickerHandler.cs - Updated with missing mappers +- [x] ProgressBarHandler.cs - Updated with missing mappers - [ ] ProgressBarHandler.Linux.cs - [ ] RadioButtonHandler.cs -- [ ] ScrollViewHandler.cs +- [x] ScrollViewHandler.cs - Updated to use ToViewHandler - [ ] SearchBarHandler.cs - [ ] SearchBarHandler.Linux.cs - [ ] ShellHandler.cs - [ ] SliderHandler.cs - [ ] SliderHandler.Linux.cs - [ ] StepperHandler.cs -- [ ] SwitchHandler.cs +- [x] SwitchHandler.cs - Updated with missing mappers - [ ] SwitchHandler.Linux.cs - [ ] TabbedPageHandler.cs - [ ] TimePickerHandler.cs @@ -91,72 +94,72 @@ ### New Types (77 files) - TO ADD -- [ ] AbsoluteLayoutBounds.cs -- [ ] AbsoluteLayoutFlags.cs +- [ ] AbsoluteLayoutBounds.cs - EXISTS IN SkiaLayoutView.cs +- [ ] AbsoluteLayoutFlags.cs - EXISTS IN SkiaLayoutView.cs - [ ] CheckedChangedEventArgs.cs - [ ] CollectionSelectionChangedEventArgs.cs - [ ] ColorExtensions.cs -- [ ] ContextMenuItem.cs -- [ ] FlexAlignContent.cs -- [ ] FlexAlignItems.cs -- [ ] FlexAlignSelf.cs -- [ ] FlexBasis.cs -- [ ] FlexDirection.cs -- [ ] FlexJustify.cs -- [ ] FlexWrap.cs +- [ ] ContextMenuItem.cs - EXISTS IN Types/ +- [ ] FlexAlignContent.cs - EXISTS IN Types/ +- [ ] FlexAlignItems.cs - EXISTS IN Types/ +- [ ] FlexAlignSelf.cs - EXISTS IN Types/ +- [ ] FlexBasis.cs - EXISTS IN Types/ +- [ ] FlexDirection.cs - EXISTS IN Types/ +- [ ] FlexJustify.cs - EXISTS IN Types/ +- [ ] FlexWrap.cs - EXISTS IN Types/ - [ ] FlyoutLayoutBehavior.cs - [ ] FontExtensions.cs -- [ ] GridLength.cs -- [ ] GridPosition.cs -- [ ] GridUnitType.cs +- [ ] GridLength.cs - EXISTS IN SkiaLayoutView.cs +- [ ] GridPosition.cs - EXISTS IN SkiaLayoutView.cs +- [ ] GridUnitType.cs - EXISTS IN SkiaLayoutView.cs - [ ] ImageLoadingErrorEventArgs.cs - [ ] IndicatorShape.cs -- [ ] ISkiaQueryAttributable.cs +- [ ] ISkiaQueryAttributable.cs - EXISTS IN Types/ - [ ] ItemsLayoutOrientation.cs - [ ] ItemsScrolledEventArgs.cs - [ ] ItemsViewItemTappedEventArgs.cs -- [ ] Key.cs -- [ ] KeyEventArgs.cs -- [ ] KeyModifiers.cs +- [ ] Key.cs - EXISTS IN SkiaView.cs +- [ ] KeyEventArgs.cs - EXISTS IN SkiaView.cs +- [ ] KeyModifiers.cs - EXISTS IN SkiaView.cs - [ ] LayoutAlignment.cs - [ ] LineBreakMode.cs - [ ] LinuxDialogService.cs -- [ ] MenuBarItem.cs -- [ ] MenuItem.cs -- [ ] MenuItemClickedEventArgs.cs -- [ ] NavigationEventArgs.cs -- [ ] PointerButton.cs -- [ ] PointerEventArgs.cs +- [ ] MenuBarItem.cs - EXISTS IN SkiaMenuBar.cs +- [ ] MenuItem.cs - EXISTS IN SkiaMenuBar.cs +- [ ] MenuItemClickedEventArgs.cs - EXISTS IN SkiaMenuBar.cs +- [ ] NavigationEventArgs.cs - EXISTS IN SkiaNavigationPage.cs +- [ ] PointerButton.cs - EXISTS IN SkiaView.cs +- [ ] PointerEventArgs.cs - EXISTS IN SkiaView.cs - [ ] PositionChangedEventArgs.cs - [ ] ProgressChangedEventArgs.cs -- [ ] ScrollBarVisibility.cs -- [ ] ScrolledEventArgs.cs -- [ ] ScrollEventArgs.cs -- [ ] ScrollOrientation.cs -- [ ] ShellContent.cs -- [ ] ShellFlyoutBehavior.cs -- [ ] ShellNavigationEventArgs.cs -- [ ] ShellSection.cs -- [ ] SkiaAbsoluteLayout.cs -- [ ] SkiaContentPage.cs +- [ ] ScrollBarVisibility.cs - EXISTS IN SkiaScrollView.cs +- [ ] ScrolledEventArgs.cs - EXISTS IN SkiaScrollView.cs +- [ ] ScrollEventArgs.cs - EXISTS IN SkiaView.cs +- [ ] ScrollOrientation.cs - EXISTS IN SkiaScrollView.cs +- [ ] ShellContent.cs - EXISTS IN SkiaShell.cs +- [ ] ShellFlyoutBehavior.cs - EXISTS IN SkiaShell.cs +- [ ] ShellNavigationEventArgs.cs - EXISTS IN SkiaShell.cs +- [ ] ShellSection.cs - EXISTS IN SkiaShell.cs +- [ ] SkiaAbsoluteLayout.cs - EXISTS IN SkiaLayoutView.cs +- [ ] SkiaContentPage.cs - EXISTS IN SkiaPage.cs - [ ] SkiaContextMenu.cs -- [ ] SkiaFlexLayout.cs -- [ ] SkiaFrame.cs -- [ ] SkiaGrid.cs +- [x] SkiaFlexLayout.cs - ADDED +- [ ] SkiaFrame.cs - EXISTS IN SkiaBorder.cs +- [ ] SkiaGrid.cs - EXISTS IN SkiaLayoutView.cs - [ ] SkiaMenuFlyout.cs - [ ] SkiaSelectionMode.cs -- [ ] SkiaStackLayout.cs +- [ ] SkiaStackLayout.cs - EXISTS IN SkiaLayoutView.cs - [ ] SkiaTextAlignment.cs -- [ ] SkiaTextSpan.cs -- [ ] SkiaToolbarItem.cs -- [ ] SkiaToolbarItemOrder.cs +- [ ] SkiaTextSpan.cs - EXISTS IN Types/ +- [ ] SkiaToolbarItem.cs - EXISTS IN SkiaPage.cs +- [ ] SkiaToolbarItemOrder.cs - EXISTS IN SkiaPage.cs - [ ] SkiaVerticalAlignment.cs - [ ] SkiaVisualState.cs - [ ] SkiaVisualStateGroup.cs - [ ] SkiaVisualStateGroupList.cs - [ ] SkiaVisualStateSetter.cs - [ ] SliderValueChangedEventArgs.cs -- [ ] StackOrientation.cs +- [ ] StackOrientation.cs - EXISTS IN SkiaLayoutView.cs - [ ] SwipeDirection.cs - [ ] SwipeEndedEventArgs.cs - [ ] SwipeItem.cs @@ -166,7 +169,7 @@ - [ ] TabItem.cs - [ ] TextAlignment.cs - [ ] TextChangedEventArgs.cs -- [ ] TextInputEventArgs.cs +- [ ] TextInputEventArgs.cs - EXISTS IN SkiaView.cs - [ ] ThicknessExtensions.cs - [ ] ToggledEventArgs.cs - [ ] WebNavigatedEventArgs.cs @@ -186,14 +189,14 @@ - [ ] SkiaContentPresenter.cs - [ ] SkiaDatePicker.cs - [ ] SkiaEditor.cs -- [ ] SkiaEntry.cs +- [x] SkiaEntry.cs - Added context menu support - [ ] SkiaFlyoutPage.cs - [ ] SkiaGraphicsView.cs -- [ ] SkiaImage.cs +- [x] SkiaImage.cs - Added LoadFromBitmap method - [ ] SkiaImageButton.cs - [ ] SkiaIndicatorView.cs - [ ] SkiaItemsView.cs -- [ ] SkiaLabel.cs +- [x] SkiaLabel.cs - Added FormattedSpans, Tapped event, formatted text rendering - [ ] SkiaLayoutView.cs - [ ] SkiaMenuBar.cs - [ ] SkiaNavigationPage.cs @@ -204,7 +207,7 @@ - [ ] SkiaRefreshView.cs - [ ] SkiaScrollView.cs - [ ] SkiaSearchBar.cs -- [ ] SkiaShell.cs +- [x] SkiaShell.cs - Added MauiShell, ContentRenderer, ColorRefresher, RefreshTheme() - [ ] SkiaSlider.cs - [ ] SkiaStepper.cs - [ ] SkiaSwipeView.cs @@ -212,9 +215,14 @@ - [ ] SkiaTabbedPage.cs - [ ] SkiaTemplatedView.cs - [ ] SkiaTimePicker.cs -- [ ] SkiaView.cs +- [x] SkiaView.cs - Added MauiView, CursorType, transforms (Scale/Rotation/Translation/Anchor), GestureManager integration, enhanced Invalidate/Draw - [ ] SkiaVisualStateManager.cs -- [ ] SkiaWebView.cs +- [x] SkiaWebView.cs - Added SetMainWindow, ProcessGtkEvents static methods + +### New Views Added This Session + +- [x] SkiaContextMenu.cs - ADDED (context menu with hover, keyboard, dark theme support) +- [x] LinuxDialogService.cs - ADDED (dialog and context menu management) --- @@ -230,13 +238,13 @@ - [ ] AccessibleState.cs - [ ] AccessibleStates.cs - [ ] AnnouncementPriority.cs -- [ ] AppInfoService.cs +- [x] AppInfoService.cs - ADDED - [ ] ColorDialogResult.cs -- [ ] ConnectivityService.cs -- [ ] DesktopEnvironment.cs -- [ ] DeviceDisplayService.cs -- [ ] DeviceInfoService.cs -- [ ] DisplayServerType.cs +- [x] ConnectivityService.cs - ADDED +- [ ] DesktopEnvironment.cs - EXISTS IN SystemThemeService.cs +- [x] DeviceDisplayService.cs - ADDED +- [x] DeviceInfoService.cs - ADDED +- [ ] DisplayServerType.cs - EXISTS IN LinuxApplication.cs - [ ] DragAction.cs - [ ] DragData.cs - [ ] DragEventArgs.cs @@ -248,7 +256,7 @@ - [ ] GtkButtonsType.cs - [ ] GtkContextMenuService.cs - [ ] GtkFileChooserAction.cs -- [ ] GtkHostService.cs +- [x] GtkHostService.cs - ADDED - [ ] GtkMenuItem.cs - [ ] GtkMessageType.cs - [ ] GtkResponseType.cs @@ -265,7 +273,7 @@ - [ ] IInputContext.cs - [ ] KeyModifiers.cs - [ ] LinuxFileResult.cs -- [ ] MauiIconGenerator.cs +- [x] MauiIconGenerator.cs - ADDED (PNG icon generator, no Svg.Skia dependency) - [ ] NotificationAction.cs - [ ] NotificationActionEventArgs.cs - [ ] NotificationClosedEventArgs.cs @@ -337,12 +345,12 @@ - [ ] GtkMauiContext.cs - [ ] HandlerMappingExtensions.cs -- [ ] LinuxAnimationManager.cs -- [ ] LinuxDispatcher.cs -- [ ] LinuxDispatcherTimer.cs -- [ ] LinuxTicker.cs -- [ ] MauiHandlerExtensions.cs -- [ ] ScopedLinuxMauiContext.cs +- [ ] LinuxAnimationManager.cs - EXISTS IN LinuxMauiContext.cs +- [ ] LinuxDispatcher.cs - EXISTS IN LinuxMauiContext.cs +- [ ] LinuxDispatcherTimer.cs - EXISTS IN LinuxMauiContext.cs +- [ ] LinuxTicker.cs - EXISTS IN LinuxMauiContext.cs +- [x] MauiHandlerExtensions.cs - ADDED (critical ToViewHandler fix) +- [ ] ScopedLinuxMauiContext.cs - EXISTS IN LinuxMauiContext.cs ### Existing Hosting (5 files) - TO COMPARE @@ -372,51 +380,39 @@ --- +## WINDOW + +### Window Files - TO COMPARE/ADD + +- [x] CursorType.cs - ADDED (Arrow, Hand, Text cursor types) +- [x] X11Window.cs - Added SetIcon, SetCursor methods, cursor initialization +- [x] GtkHostWindow.cs - ADDED (GTK-based host window with overlay support) + +--- + +## RENDERING + +### Rendering Files - TO COMPARE/ADD + +- [x] GtkSkiaSurfaceWidget.cs - ADDED (GTK drawing area for Skia) + +--- + ## CORE FILES ### Core (2 files) - TO COMPARE -- [ ] LinuxApplication.cs +- [x] LinuxApplication.cs - Massive update: GTK mode, Dispatcher init, theme handling, icon support, GTK events - [ ] LinuxApplicationOptions.cs --- -## Progress Log +## HOSTING -| Date | Files Completed | Notes | -|------|-----------------|-------| -| 2026-01-01 | 10 Types | Added FlexDirection, FlexWrap, FlexJustify, FlexAlignItems, FlexAlignContent, FlexAlignSelf, FlexBasis, ContextMenuItem, ISkiaQueryAttributable, SkiaTextSpan | -| 2026-01-01 | 1 Handler | Added FlexLayoutHandler.cs | -| 2026-01-01 | 1 View | Added SkiaFlexLayout.cs | +### Hosting Files - TO COMPARE/ADD ---- - -## ⚠️ INCORRECTLY SKIPPED - MUST COMPARE AND UPDATE - -These were WRONGLY skipped because I assumed main was correct. Main is OUTDATED - decompiled has the production fixes. - -### Files that need COMPARISON (not skipped): - -**Handlers to compare (embedded in other files):** -- [ ] GridHandler - exists in LayoutHandler.cs, COMPARE with decompiled GridHandler.cs -- [ ] StackLayoutHandler - exists in LayoutHandler.cs, COMPARE with decompiled StackLayoutHandler.cs -- [ ] ContentPageHandler - exists in PageHandler.cs, COMPARE with decompiled ContentPageHandler.cs - -**Views to compare (embedded in other files):** -- [ ] SkiaGrid - exists in SkiaLayoutView.cs, COMPARE with decompiled SkiaGrid.cs -- [ ] SkiaStackLayout - exists in SkiaLayoutView.cs, COMPARE with decompiled SkiaStackLayout.cs -- [ ] SkiaAbsoluteLayout - exists in SkiaLayoutView.cs, COMPARE with decompiled SkiaAbsoluteLayout.cs -- [ ] SkiaContentPage - exists in SkiaPage.cs, COMPARE with decompiled SkiaContentPage.cs -- [ ] SkiaFrame - exists in SkiaBorder.cs, COMPARE with decompiled SkiaFrame.cs -- [ ] SkiaContextMenu - exists in SkiaMenuBar.cs(?), COMPARE with decompiled -- [ ] SkiaMenuFlyout - exists in SkiaMenuBar.cs, COMPARE with decompiled - -**Types to compare (embedded in View files):** -- [ ] All types in SkiaView.cs (KeyEventArgs, PointerEventArgs, ScrollEventArgs, TextInputEventArgs, Key, KeyModifiers, PointerButton) -- [ ] Types in SkiaLayoutView.cs (GridLength, GridPosition, AbsoluteLayoutBounds, AbsoluteLayoutFlags, GridUnitType, StackOrientation) -- [ ] Types in SkiaMenuBar.cs (MenuItem, MenuBarItem, MenuItemClickedEventArgs) -- [ ] Types in SkiaShell.cs (ShellSection, ShellContent, ShellNavigationEventArgs, ShellFlyoutBehavior) -- [ ] And many more... +- [x] LinuxMauiContext.cs - Fixed duplicate LinuxDispatcher, uses Dispatching namespace +- [x] MauiHandlerExtensions.cs - ADDED (ToViewHandler extension) --- diff --git a/Native/CairoNative.cs b/Native/CairoNative.cs new file mode 100644 index 0000000..8ff9a0e --- /dev/null +++ b/Native/CairoNative.cs @@ -0,0 +1,80 @@ +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Native; + +internal static class CairoNative +{ + public enum cairo_format_t + { + CAIRO_FORMAT_INVALID = -1, + CAIRO_FORMAT_ARGB32, + CAIRO_FORMAT_RGB24, + CAIRO_FORMAT_A8, + CAIRO_FORMAT_A1, + CAIRO_FORMAT_RGB16_565, + CAIRO_FORMAT_RGB30 + } + + private const string Lib = "libcairo.so.2"; + + [DllImport("libcairo.so.2")] + public static extern IntPtr cairo_image_surface_create_for_data(IntPtr data, cairo_format_t format, int width, int height, int stride); + + [DllImport("libcairo.so.2")] + public static extern IntPtr cairo_image_surface_create(cairo_format_t format, int width, int height); + + [DllImport("libcairo.so.2")] + public static extern IntPtr cairo_image_surface_get_data(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern int cairo_image_surface_get_width(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern int cairo_image_surface_get_height(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern int cairo_image_surface_get_stride(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern void cairo_surface_destroy(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern void cairo_surface_flush(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern void cairo_surface_mark_dirty(IntPtr surface); + + [DllImport("libcairo.so.2")] + public static extern void cairo_surface_mark_dirty_rectangle(IntPtr surface, int x, int y, int width, int height); + + [DllImport("libcairo.so.2")] + public static extern void cairo_set_source_surface(IntPtr cr, IntPtr surface, double x, double y); + + [DllImport("libcairo.so.2")] + public static extern void cairo_set_source_rgb(IntPtr cr, double red, double green, double blue); + + [DllImport("libcairo.so.2")] + public static extern void cairo_set_source_rgba(IntPtr cr, double red, double green, double blue, double alpha); + + [DllImport("libcairo.so.2")] + public static extern void cairo_paint(IntPtr cr); + + [DllImport("libcairo.so.2")] + public static extern void cairo_paint_with_alpha(IntPtr cr, double alpha); + + [DllImport("libcairo.so.2")] + public static extern void cairo_fill(IntPtr cr); + + [DllImport("libcairo.so.2")] + public static extern void cairo_rectangle(IntPtr cr, double x, double y, double width, double height); + + [DllImport("libcairo.so.2")] + public static extern void cairo_clip(IntPtr cr); + + [DllImport("libcairo.so.2")] + public static extern void cairo_save(IntPtr cr); + + [DllImport("libcairo.so.2")] + public static extern void cairo_restore(IntPtr cr); +} diff --git a/Native/GLibNative.cs b/Native/GLibNative.cs new file mode 100644 index 0000000..491c2df --- /dev/null +++ b/Native/GLibNative.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Native; + +public static class GLibNative +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate bool GSourceFunc(IntPtr userData); + + private const string Lib = "libglib-2.0.so.0"; + + private static readonly List _callbacks = new List(); + private static readonly object _callbackLock = new object(); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_idle_add")] + private static extern uint g_idle_add_native(GSourceFunc function, IntPtr data); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_timeout_add")] + private static extern uint g_timeout_add_native(uint interval, GSourceFunc function, IntPtr data); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_source_remove")] + public static extern bool SourceRemove(uint sourceId); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_get_monotonic_time")] + public static extern long GetMonotonicTime(); + + public static uint IdleAdd(Func callback) + { + GSourceFunc wrapper = null; + wrapper = delegate + { + bool flag = false; + try + { + flag = callback(); + } + catch (Exception ex) + { + Console.WriteLine("[GLibNative] Error in idle callback: " + ex.Message); + } + if (!flag) + { + lock (_callbackLock) + { + _callbacks.Remove(wrapper); + } + } + return flag; + }; + lock (_callbackLock) + { + _callbacks.Add(wrapper); + } + return g_idle_add_native(wrapper, IntPtr.Zero); + } + + public static uint TimeoutAdd(uint intervalMs, Func callback) + { + GSourceFunc wrapper = null; + wrapper = delegate + { + bool flag = false; + try + { + flag = callback(); + } + catch (Exception ex) + { + Console.WriteLine("[GLibNative] Error in timeout callback: " + ex.Message); + } + if (!flag) + { + lock (_callbackLock) + { + _callbacks.Remove(wrapper); + } + } + return flag; + }; + lock (_callbackLock) + { + _callbacks.Add(wrapper); + } + return g_timeout_add_native(intervalMs, wrapper, IntPtr.Zero); + } + + public static void ClearCallbacks() + { + lock (_callbackLock) + { + _callbacks.Clear(); + } + } + + public static uint g_idle_add(GSourceFunc func, IntPtr data) + { + return g_idle_add_native(func, data); + } + + public static uint g_timeout_add(uint intervalMs, GSourceFunc func, IntPtr data) + { + return g_timeout_add_native(intervalMs, func, data); + } + + public static bool g_source_remove(uint tag) + { + return SourceRemove(tag); + } +} diff --git a/Native/GdkNative.cs b/Native/GdkNative.cs new file mode 100644 index 0000000..620204f --- /dev/null +++ b/Native/GdkNative.cs @@ -0,0 +1,132 @@ +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Native; + +internal static class GdkNative +{ + [Flags] + public enum GdkEventMask + { + ExposureMask = 2, + PointerMotionMask = 4, + PointerMotionHintMask = 8, + ButtonMotionMask = 0x10, + Button1MotionMask = 0x20, + Button2MotionMask = 0x40, + Button3MotionMask = 0x80, + ButtonPressMask = 0x100, + ButtonReleaseMask = 0x200, + KeyPressMask = 0x400, + KeyReleaseMask = 0x800, + EnterNotifyMask = 0x1000, + LeaveNotifyMask = 0x2000, + FocusChangeMask = 0x4000, + StructureMask = 0x8000, + PropertyChangeMask = 0x10000, + VisibilityNotifyMask = 0x20000, + ProximityInMask = 0x40000, + ProximityOutMask = 0x80000, + SubstructureMask = 0x100000, + ScrollMask = 0x200000, + TouchMask = 0x400000, + SmoothScrollMask = 0x800000, + AllEventsMask = 0xFFFFFE + } + + public enum GdkScrollDirection + { + Up, + Down, + Left, + Right, + Smooth + } + + public struct GdkEventButton + { + public int Type; + public IntPtr Window; + public sbyte SendEvent; + public uint Time; + public double X; + public double Y; + public IntPtr Axes; + public uint State; + public uint Button; + public IntPtr Device; + public double XRoot; + public double YRoot; + } + + public struct GdkEventMotion + { + public int Type; + public IntPtr Window; + public sbyte SendEvent; + public uint Time; + public double X; + public double Y; + public IntPtr Axes; + public uint State; + public short IsHint; + public IntPtr Device; + public double XRoot; + public double YRoot; + } + + public struct GdkEventKey + { + public int Type; + public IntPtr Window; + public sbyte SendEvent; + public uint Time; + public uint State; + public uint Keyval; + public int Length; + public IntPtr String; + public ushort HardwareKeycode; + public byte Group; + public uint IsModifier; + } + + public struct GdkEventScroll + { + public int Type; + public IntPtr Window; + public sbyte SendEvent; + public uint Time; + public double X; + public double Y; + public uint State; + public GdkScrollDirection Direction; + public IntPtr Device; + public double XRoot; + public double YRoot; + public double DeltaX; + public double DeltaY; + } + + private const string Lib = "libgdk-3.so.0"; + + [DllImport("libgdk-3.so.0")] + public static extern IntPtr gdk_display_get_default(); + + [DllImport("libgdk-3.so.0")] + public static extern IntPtr gdk_display_get_name(IntPtr display); + + [DllImport("libgdk-3.so.0")] + public static extern IntPtr gdk_screen_get_default(); + + [DllImport("libgdk-3.so.0")] + public static extern int gdk_screen_get_width(IntPtr screen); + + [DllImport("libgdk-3.so.0")] + public static extern int gdk_screen_get_height(IntPtr screen); + + [DllImport("libgdk-3.so.0")] + public static extern void gdk_window_invalidate_rect(IntPtr window, IntPtr rect, bool invalidateChildren); + + [DllImport("libgdk-3.so.0")] + public static extern uint gdk_keyval_to_unicode(uint keyval); +} diff --git a/Native/GtkNative.cs b/Native/GtkNative.cs new file mode 100644 index 0000000..ec61316 --- /dev/null +++ b/Native/GtkNative.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Native; + +internal static class GtkNative +{ + public struct GtkAllocation + { + public int X; + public int Y; + public int Width; + public int Height; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate bool GSourceFunc(IntPtr userData); + + private const string Lib = "libgtk-3.so.0"; + + public const int GTK_WINDOW_TOPLEVEL = 0; + public const int GTK_WINDOW_POPUP = 1; + + private const string LibGdkPixbuf = "libgdk_pixbuf-2.0.so.0"; + + public const int GDK_COLORSPACE_RGB = 0; + + private const string GLib = "libglib-2.0.so.0"; + + private static readonly List _idleCallbacks = new List(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_init(ref int argc, ref IntPtr argv); + + [DllImport("libgtk-3.so.0")] + public static extern bool gtk_init_check(ref int argc, ref IntPtr argv); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_window_new(int windowType); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_set_title(IntPtr window, string title); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_set_default_size(IntPtr window, int width, int height); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_resize(IntPtr window, int width, int height); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_move(IntPtr window, int x, int y); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_get_size(IntPtr window, out int width, out int height); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_get_position(IntPtr window, out int x, out int y); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_set_icon(IntPtr window, IntPtr pixbuf); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_window_set_icon_from_file(IntPtr window, string filename, IntPtr error); + + [DllImport("libgdk_pixbuf-2.0.so.0")] + public static extern IntPtr gdk_pixbuf_new_from_file(string filename, IntPtr error); + + [DllImport("libgdk_pixbuf-2.0.so.0")] + public static extern IntPtr gdk_pixbuf_new_from_data(IntPtr data, int colorspace, bool hasAlpha, int bitsPerSample, int width, int height, int rowstride, IntPtr destroyFn, IntPtr destroyFnData); + + [DllImport("libgdk_pixbuf-2.0.so.0")] + public static extern void g_object_unref(IntPtr obj); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_show_all(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_show(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_hide(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_destroy(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_queue_draw(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_set_size_request(IntPtr widget, int width, int height); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_get_allocation(IntPtr widget, out GtkAllocation allocation); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_main(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_main_quit(); + + [DllImport("libgtk-3.so.0")] + public static extern bool gtk_events_pending(); + + [DllImport("libgtk-3.so.0")] + public static extern bool gtk_main_iteration_do(bool blocking); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_overlay_new(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_container_add(IntPtr container, IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_container_remove(IntPtr container, IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_overlay_add_overlay(IntPtr overlay, IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_overlay_set_overlay_pass_through(IntPtr overlay, IntPtr widget, bool passThrough); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_fixed_new(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_fixed_put(IntPtr fixedWidget, IntPtr widget, int x, int y); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_fixed_move(IntPtr fixedWidget, IntPtr widget, int x, int y); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_drawing_area_new(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_set_can_focus(IntPtr widget, bool canFocus); + + [DllImport("libgtk-3.so.0")] + public static extern bool gtk_widget_grab_focus(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern bool gtk_widget_has_focus(IntPtr widget); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_add_events(IntPtr widget, int events); + + [DllImport("libgtk-3.so.0")] + public static extern ulong g_signal_connect_data(IntPtr instance, string detailedSignal, IntPtr cHandler, IntPtr data, IntPtr destroyData, int connectFlags); + + [DllImport("libgtk-3.so.0")] + public static extern void g_signal_handler_disconnect(IntPtr instance, ulong handlerId); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_widget_get_window(IntPtr widget); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_idle_add")] + public static extern uint IdleAdd(GSourceFunc function, IntPtr data); + + [DllImport("libglib-2.0.so.0", EntryPoint = "g_source_remove")] + public static extern bool SourceRemove(uint sourceId); + + public static uint IdleAdd(Func callback) + { + GSourceFunc gSourceFunc = (IntPtr _) => callback(); + _idleCallbacks.Add(gSourceFunc); + return IdleAdd(gSourceFunc, IntPtr.Zero); + } + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_menu_new(); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_menu_item_new_with_label(string label); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_separator_menu_item_new(); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_menu_shell_append(IntPtr menuShell, IntPtr child); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_menu_popup_at_pointer(IntPtr menu, IntPtr triggerEvent); + + [DllImport("libgtk-3.so.0")] + public static extern void gtk_widget_set_sensitive(IntPtr widget, bool sensitive); + + [DllImport("libgtk-3.so.0")] + public static extern IntPtr gtk_get_current_event(); + + [DllImport("libgdk-3.so.0")] + public static extern void gdk_event_free(IntPtr eventPtr); +} diff --git a/Native/WebKitNative.cs b/Native/WebKitNative.cs new file mode 100644 index 0000000..11e7b80 --- /dev/null +++ b/Native/WebKitNative.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace Microsoft.Maui.Platform.Linux.Native; + +internal static class WebKitNative +{ + private delegate IntPtr WebKitWebViewNewDelegate(); + private delegate void WebKitWebViewLoadUriDelegate(IntPtr webView, string uri); + private delegate void WebKitWebViewLoadHtmlDelegate(IntPtr webView, string content, string? baseUri); + private delegate IntPtr WebKitWebViewGetUriDelegate(IntPtr webView); + private delegate IntPtr WebKitWebViewGetTitleDelegate(IntPtr webView); + private delegate void WebKitWebViewGoBackDelegate(IntPtr webView); + private delegate void WebKitWebViewGoForwardDelegate(IntPtr webView); + private delegate bool WebKitWebViewCanGoBackDelegate(IntPtr webView); + private delegate bool WebKitWebViewCanGoForwardDelegate(IntPtr webView); + private delegate void WebKitWebViewReloadDelegate(IntPtr webView); + private delegate void WebKitWebViewStopLoadingDelegate(IntPtr webView); + private delegate IntPtr WebKitWebViewGetSettingsDelegate(IntPtr webView); + private delegate void WebKitSettingsSetHardwareAccelerationPolicyDelegate(IntPtr settings, int policy); + private delegate void WebKitSettingsSetEnableJavascriptDelegate(IntPtr settings, bool enabled); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void LoadChangedCallback(IntPtr webView, int loadEvent, IntPtr userData); + + private delegate ulong GSignalConnectDataDelegate(IntPtr instance, string signalName, LoadChangedCallback callback, IntPtr userData, IntPtr destroyNotify, int connectFlags); + + public enum WebKitLoadEvent + { + Started, + Redirected, + Committed, + Finished + } + + private static IntPtr _handle; + private static bool _initialized; + + private static readonly string[] LibraryNames = new string[4] + { + "libwebkit2gtk-4.1.so.0", + "libwebkit2gtk-4.0.so.37", + "libwebkit2gtk-4.0.so", + "libwebkit2gtk-4.1.so" + }; + + private static WebKitWebViewNewDelegate? _webkitWebViewNew; + private static WebKitWebViewLoadUriDelegate? _webkitLoadUri; + private static WebKitWebViewLoadHtmlDelegate? _webkitLoadHtml; + private static WebKitWebViewGetUriDelegate? _webkitGetUri; + private static WebKitWebViewGetTitleDelegate? _webkitGetTitle; + private static WebKitWebViewGoBackDelegate? _webkitGoBack; + private static WebKitWebViewGoForwardDelegate? _webkitGoForward; + private static WebKitWebViewCanGoBackDelegate? _webkitCanGoBack; + private static WebKitWebViewCanGoForwardDelegate? _webkitCanGoForward; + private static WebKitWebViewReloadDelegate? _webkitReload; + private static WebKitWebViewStopLoadingDelegate? _webkitStopLoading; + private static WebKitWebViewGetSettingsDelegate? _webkitGetSettings; + private static WebKitSettingsSetHardwareAccelerationPolicyDelegate? _webkitSetHardwareAccel; + private static WebKitSettingsSetEnableJavascriptDelegate? _webkitSetJavascript; + private static GSignalConnectDataDelegate? _gSignalConnectData; + + private static readonly Dictionary _loadChangedCallbacks = new Dictionary(); + + private const int RTLD_NOW = 2; + private const int RTLD_GLOBAL = 256; + + private static IntPtr _gobjectHandle; + + [DllImport("libdl.so.2")] + private static extern IntPtr dlopen(string? filename, int flags); + + [DllImport("libdl.so.2")] + private static extern IntPtr dlsym(IntPtr handle, string symbol); + + [DllImport("libdl.so.2")] + private static extern IntPtr dlerror(); + + public static bool Initialize() + { + if (_initialized) + { + return _handle != IntPtr.Zero; + } + _initialized = true; + + string[] libraryNames = LibraryNames; + foreach (string text in libraryNames) + { + _handle = dlopen(text, 258); + if (_handle != IntPtr.Zero) + { + Console.WriteLine("[WebKitNative] Loaded " + text); + break; + } + } + + if (_handle == IntPtr.Zero) + { + Console.WriteLine("[WebKitNative] Failed to load WebKitGTK library"); + return false; + } + + _webkitWebViewNew = LoadFunction("webkit_web_view_new"); + _webkitLoadUri = LoadFunction("webkit_web_view_load_uri"); + _webkitLoadHtml = LoadFunction("webkit_web_view_load_html"); + _webkitGetUri = LoadFunction("webkit_web_view_get_uri"); + _webkitGetTitle = LoadFunction("webkit_web_view_get_title"); + _webkitGoBack = LoadFunction("webkit_web_view_go_back"); + _webkitGoForward = LoadFunction("webkit_web_view_go_forward"); + _webkitCanGoBack = LoadFunction("webkit_web_view_can_go_back"); + _webkitCanGoForward = LoadFunction("webkit_web_view_can_go_forward"); + _webkitReload = LoadFunction("webkit_web_view_reload"); + _webkitStopLoading = LoadFunction("webkit_web_view_stop_loading"); + _webkitGetSettings = LoadFunction("webkit_web_view_get_settings"); + _webkitSetHardwareAccel = LoadFunction("webkit_settings_set_hardware_acceleration_policy"); + _webkitSetJavascript = LoadFunction("webkit_settings_set_enable_javascript"); + + _gobjectHandle = dlopen("libgobject-2.0.so.0", 258); + if (_gobjectHandle != IntPtr.Zero) + { + IntPtr intPtr = dlsym(_gobjectHandle, "g_signal_connect_data"); + if (intPtr != IntPtr.Zero) + { + _gSignalConnectData = Marshal.GetDelegateForFunctionPointer(intPtr); + Console.WriteLine("[WebKitNative] Loaded g_signal_connect_data"); + } + } + + return _webkitWebViewNew != null; + } + + private static T? LoadFunction(string name) where T : Delegate + { + if (_handle == IntPtr.Zero) + { + return null; + } + IntPtr intPtr = dlsym(_handle, name); + if (intPtr == IntPtr.Zero) + { + return null; + } + return Marshal.GetDelegateForFunctionPointer(intPtr); + } + + public static IntPtr WebViewNew() + { + if (!Initialize() || _webkitWebViewNew == null) + { + return IntPtr.Zero; + } + return _webkitWebViewNew(); + } + + public static void LoadUri(IntPtr webView, string uri) + { + _webkitLoadUri?.Invoke(webView, uri); + } + + public static void LoadHtml(IntPtr webView, string content, string? baseUri = null) + { + _webkitLoadHtml?.Invoke(webView, content, baseUri); + } + + public static string? GetUri(IntPtr webView) + { + IntPtr intPtr = _webkitGetUri?.Invoke(webView) ?? IntPtr.Zero; + if (intPtr == IntPtr.Zero) + { + return null; + } + return Marshal.PtrToStringUTF8(intPtr); + } + + public static string? GetTitle(IntPtr webView) + { + IntPtr intPtr = _webkitGetTitle?.Invoke(webView) ?? IntPtr.Zero; + if (intPtr == IntPtr.Zero) + { + return null; + } + return Marshal.PtrToStringUTF8(intPtr); + } + + public static void GoBack(IntPtr webView) + { + _webkitGoBack?.Invoke(webView); + } + + public static void GoForward(IntPtr webView) + { + _webkitGoForward?.Invoke(webView); + } + + public static bool CanGoBack(IntPtr webView) + { + return _webkitCanGoBack?.Invoke(webView) ?? false; + } + + public static bool CanGoForward(IntPtr webView) + { + return _webkitCanGoForward?.Invoke(webView) ?? false; + } + + public static void Reload(IntPtr webView) + { + _webkitReload?.Invoke(webView); + } + + public static void StopLoading(IntPtr webView) + { + _webkitStopLoading?.Invoke(webView); + } + + public static void ConfigureSettings(IntPtr webView, bool disableHardwareAccel = true) + { + if (_webkitGetSettings != null) + { + IntPtr intPtr = _webkitGetSettings(webView); + if (intPtr != IntPtr.Zero && disableHardwareAccel && _webkitSetHardwareAccel != null) + { + _webkitSetHardwareAccel(intPtr, 2); + } + } + } + + public static void SetJavascriptEnabled(IntPtr webView, bool enabled) + { + if (_webkitGetSettings != null && _webkitSetJavascript != null) + { + IntPtr intPtr = _webkitGetSettings(webView); + if (intPtr != IntPtr.Zero) + { + _webkitSetJavascript(intPtr, enabled); + } + } + } + + public static ulong ConnectLoadChanged(IntPtr webView, LoadChangedCallback callback) + { + if (_gSignalConnectData == null || webView == IntPtr.Zero) + { + Console.WriteLine("[WebKitNative] Cannot connect load-changed: signal connect not available"); + return 0uL; + } + _loadChangedCallbacks[webView] = callback; + return _gSignalConnectData(webView, "load-changed", callback, IntPtr.Zero, IntPtr.Zero, 0); + } + + public static void DisconnectLoadChanged(IntPtr webView) + { + _loadChangedCallbacks.Remove(webView); + } +} diff --git a/Rendering/GtkSkiaSurfaceWidget.cs b/Rendering/GtkSkiaSurfaceWidget.cs new file mode 100644 index 0000000..1da2f53 --- /dev/null +++ b/Rendering/GtkSkiaSurfaceWidget.cs @@ -0,0 +1,391 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.Maui.Platform.Linux.Native; +using SkiaSharp; + +namespace Microsoft.Maui.Platform.Linux.Rendering; + +/// +/// GTK drawing area widget that renders Skia content via Cairo. +/// Provides hardware-accelerated 2D rendering for MAUI views. +/// +public sealed class GtkSkiaSurfaceWidget : IDisposable +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool DrawCallback(IntPtr widget, IntPtr cairoContext, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool ConfigureCallback(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool ButtonEventCallback(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool MotionEventCallback(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool KeyEventCallback(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool ScrollEventCallback(IntPtr widget, IntPtr eventData, IntPtr userData); + + private struct GdkEventButton + { + public int type; + public IntPtr window; + public sbyte send_event; + public uint time; + public double x; + public double y; + public IntPtr axes; + public uint state; + public uint button; + } + + private struct GdkEventMotion + { + public int type; + public IntPtr window; + public sbyte send_event; + public uint time; + public double x; + public double y; + } + + private struct GdkEventKey + { + public int type; + public IntPtr window; + public sbyte send_event; + public uint time; + public uint state; + public uint keyval; + public int length; + public IntPtr str; + public ushort hardware_keycode; + } + + private struct GdkEventScroll + { + public int type; + public IntPtr window; + public sbyte send_event; + public uint time; + public double x; + public double y; + public uint state; + public int direction; + public IntPtr device; + public double x_root; + public double y_root; + public double delta_x; + public double delta_y; + } + + private IntPtr _widget; + private SKImageInfo _imageInfo; + private SKBitmap? _bitmap; + private SKCanvas? _canvas; + private IntPtr _cairoSurface; + private readonly DrawCallback _drawCallback; + private readonly ConfigureCallback _configureCallback; + private ulong _drawSignalId; + private ulong _configureSignalId; + private bool _isTransparent; + private readonly ButtonEventCallback _buttonPressCallback; + private readonly ButtonEventCallback _buttonReleaseCallback; + private readonly MotionEventCallback _motionCallback; + private readonly KeyEventCallback _keyPressCallback; + private readonly KeyEventCallback _keyReleaseCallback; + private readonly ScrollEventCallback _scrollCallback; + + public IntPtr Widget => _widget; + public SKCanvas? Canvas => _canvas; + public SKImageInfo ImageInfo => _imageInfo; + public int Width => _imageInfo.Width; + public int Height => _imageInfo.Height; + public bool IsTransparent => _isTransparent; + + public event EventHandler? DrawRequested; + public event EventHandler<(int Width, int Height)>? Resized; + public event EventHandler<(double X, double Y, int Button)>? PointerPressed; + public event EventHandler<(double X, double Y, int Button)>? PointerReleased; + public event EventHandler<(double X, double Y)>? PointerMoved; + public event EventHandler<(uint KeyVal, uint KeyCode, uint State)>? KeyPressed; + public event EventHandler<(uint KeyVal, uint KeyCode, uint State)>? KeyReleased; + public event EventHandler<(double X, double Y, double DeltaX, double DeltaY)>? Scrolled; + public event EventHandler? TextInput; + + public GtkSkiaSurfaceWidget(int width, int height) + { + _widget = GtkNative.gtk_drawing_area_new(); + if (_widget == IntPtr.Zero) + { + throw new InvalidOperationException("Failed to create GTK drawing area"); + } + + GtkNative.gtk_widget_set_size_request(_widget, width, height); + GtkNative.gtk_widget_add_events(_widget, 10551046); + GtkNative.gtk_widget_set_can_focus(_widget, canFocus: true); + + CreateBuffer(width, height); + + // Store delegates to prevent garbage collection + _drawCallback = OnDraw; + _configureCallback = OnConfigure; + _buttonPressCallback = OnButtonPress; + _buttonReleaseCallback = OnButtonRelease; + _motionCallback = OnMotion; + _keyPressCallback = OnKeyPress; + _keyReleaseCallback = OnKeyRelease; + _scrollCallback = OnScroll; + + // Connect signals + _drawSignalId = GtkNative.g_signal_connect_data(_widget, "draw", Marshal.GetFunctionPointerForDelegate(_drawCallback), IntPtr.Zero, IntPtr.Zero, 0); + _configureSignalId = GtkNative.g_signal_connect_data(_widget, "configure-event", Marshal.GetFunctionPointerForDelegate(_configureCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "button-press-event", Marshal.GetFunctionPointerForDelegate(_buttonPressCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "button-release-event", Marshal.GetFunctionPointerForDelegate(_buttonReleaseCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "motion-notify-event", Marshal.GetFunctionPointerForDelegate(_motionCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "key-press-event", Marshal.GetFunctionPointerForDelegate(_keyPressCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "key-release-event", Marshal.GetFunctionPointerForDelegate(_keyReleaseCallback), IntPtr.Zero, IntPtr.Zero, 0); + GtkNative.g_signal_connect_data(_widget, "scroll-event", Marshal.GetFunctionPointerForDelegate(_scrollCallback), IntPtr.Zero, IntPtr.Zero, 0); + + Console.WriteLine($"[GtkSkiaSurfaceWidget] Created with size {width}x{height}"); + } + + private void CreateBuffer(int width, int height) + { + width = Math.Max(1, width); + height = Math.Max(1, height); + + _canvas?.Dispose(); + _bitmap?.Dispose(); + + if (_cairoSurface != IntPtr.Zero) + { + CairoNative.cairo_surface_destroy(_cairoSurface); + _cairoSurface = IntPtr.Zero; + } + + _imageInfo = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); + _bitmap = new SKBitmap(_imageInfo); + _canvas = new SKCanvas(_bitmap); + + IntPtr pixels = _bitmap.GetPixels(); + _cairoSurface = CairoNative.cairo_image_surface_create_for_data( + pixels, + CairoNative.cairo_format_t.CAIRO_FORMAT_ARGB32, + _imageInfo.Width, + _imageInfo.Height, + _imageInfo.RowBytes); + + Console.WriteLine($"[GtkSkiaSurfaceWidget] Created buffer {width}x{height}, stride={_imageInfo.RowBytes}"); + } + + public void Resize(int width, int height) + { + if (width != _imageInfo.Width || height != _imageInfo.Height) + { + CreateBuffer(width, height); + Resized?.Invoke(this, (width, height)); + } + } + + public void RenderFrame(Action render) + { + if (_canvas != null && _bitmap != null) + { + render(_canvas, _imageInfo); + _canvas.Flush(); + CairoNative.cairo_surface_flush(_cairoSurface); + CairoNative.cairo_surface_mark_dirty(_cairoSurface); + GtkNative.gtk_widget_queue_draw(_widget); + } + } + + public void Invalidate() + { + GtkNative.gtk_widget_queue_draw(_widget); + } + + public void SetTransparent(bool transparent) + { + _isTransparent = transparent; + } + + private bool OnDraw(IntPtr widget, IntPtr cairoContext, IntPtr userData) + { + if (_cairoSurface == IntPtr.Zero || cairoContext == IntPtr.Zero) + { + return false; + } + + if (_isTransparent) + { + _canvas?.Clear(SKColors.Transparent); + } + + DrawRequested?.Invoke(this, EventArgs.Empty); + _canvas?.Flush(); + + CairoNative.cairo_surface_flush(_cairoSurface); + CairoNative.cairo_surface_mark_dirty(_cairoSurface); + CairoNative.cairo_set_source_surface(cairoContext, _cairoSurface, 0.0, 0.0); + CairoNative.cairo_paint(cairoContext); + + return true; + } + + private bool OnConfigure(IntPtr widget, IntPtr eventData, IntPtr userData) + { + GtkNative.gtk_widget_get_allocation(widget, out var allocation); + if (allocation.Width > 0 && allocation.Height > 0 && + (allocation.Width != _imageInfo.Width || allocation.Height != _imageInfo.Height)) + { + Resize(allocation.Width, allocation.Height); + } + return false; + } + + private bool OnButtonPress(IntPtr widget, IntPtr eventData, IntPtr userData) + { + GtkNative.gtk_widget_grab_focus(_widget); + var (x, y, button) = ParseButtonEvent(eventData); + Console.WriteLine($"[GtkSkiaSurfaceWidget] ButtonPress at ({x}, {y}), button={button}"); + PointerPressed?.Invoke(this, (x, y, button)); + return true; + } + + private bool OnButtonRelease(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y, button) = ParseButtonEvent(eventData); + PointerReleased?.Invoke(this, (x, y, button)); + return true; + } + + private bool OnMotion(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y) = ParseMotionEvent(eventData); + PointerMoved?.Invoke(this, (x, y)); + return true; + } + + public void RaisePointerPressed(double x, double y, int button) + { + Console.WriteLine($"[GtkSkiaSurfaceWidget] RaisePointerPressed at ({x}, {y}), button={button}"); + PointerPressed?.Invoke(this, (x, y, button)); + } + + public void RaisePointerReleased(double x, double y, int button) + { + PointerReleased?.Invoke(this, (x, y, button)); + } + + public void RaisePointerMoved(double x, double y) + { + PointerMoved?.Invoke(this, (x, y)); + } + + private bool OnKeyPress(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (keyval, keycode, state) = ParseKeyEvent(eventData); + KeyPressed?.Invoke(this, (keyval, keycode, state)); + + uint unicode = GdkNative.gdk_keyval_to_unicode(keyval); + if (unicode != 0 && unicode < 65536) + { + char c = (char)unicode; + if (!char.IsControl(c) || c == '\r' || c == '\n' || c == '\t') + { + string text = c.ToString(); + Console.WriteLine($"[GtkSkiaSurfaceWidget] TextInput: '{text}' (keyval={keyval}, unicode={unicode})"); + TextInput?.Invoke(this, text); + } + } + return true; + } + + private bool OnKeyRelease(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (keyval, keycode, state) = ParseKeyEvent(eventData); + KeyReleased?.Invoke(this, (keyval, keycode, state)); + return true; + } + + private bool OnScroll(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y, deltaX, deltaY) = ParseScrollEvent(eventData); + Scrolled?.Invoke(this, (x, y, deltaX, deltaY)); + return true; + } + + private static (double x, double y, int button) ParseButtonEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + return (evt.x, evt.y, (int)evt.button); + } + + private static (double x, double y) ParseMotionEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + return (evt.x, evt.y); + } + + private static (uint keyval, uint keycode, uint state) ParseKeyEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + return (evt.keyval, evt.hardware_keycode, evt.state); + } + + private static (double x, double y, double deltaX, double deltaY) ParseScrollEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + double deltaX = 0.0; + double deltaY = 0.0; + + if (evt.direction == 4) // GDK_SCROLL_SMOOTH + { + deltaX = evt.delta_x; + deltaY = evt.delta_y; + } + else + { + switch (evt.direction) + { + case 0: // GDK_SCROLL_UP + deltaY = -1.0; + break; + case 1: // GDK_SCROLL_DOWN + deltaY = 1.0; + break; + case 2: // GDK_SCROLL_LEFT + deltaX = -1.0; + break; + case 3: // GDK_SCROLL_RIGHT + deltaX = 1.0; + break; + } + } + return (evt.x, evt.y, deltaX, deltaY); + } + + public void GrabFocus() + { + GtkNative.gtk_widget_grab_focus(_widget); + } + + public void Dispose() + { + _canvas?.Dispose(); + _canvas = null; + + _bitmap?.Dispose(); + _bitmap = null; + + if (_cairoSurface != IntPtr.Zero) + { + CairoNative.cairo_surface_destroy(_cairoSurface); + _cairoSurface = IntPtr.Zero; + } + } +} diff --git a/Services/AppInfoService.cs b/Services/AppInfoService.cs new file mode 100644 index 0000000..225d628 --- /dev/null +++ b/Services/AppInfoService.cs @@ -0,0 +1,142 @@ +using System; +using System.Diagnostics; +using System.Reflection; +using Microsoft.Maui.ApplicationModel; + +namespace Microsoft.Maui.Platform.Linux.Services; + +public class AppInfoService : IAppInfo +{ + private static readonly Lazy _instance = new Lazy(() => new AppInfoService()); + + private readonly Assembly _entryAssembly; + + private readonly string _packageName; + + private readonly string _name; + + private readonly string _versionString; + + private readonly Version _version; + + private readonly string _buildString; + + public static AppInfoService Instance => _instance.Value; + + public string PackageName => _packageName; + + public string Name => _name; + + public string VersionString => _versionString; + + public Version Version => _version; + + public string BuildString => _buildString; + + public LayoutDirection RequestedLayoutDirection => LayoutDirection.LeftToRight; + + public AppTheme RequestedTheme + { + get + { + try + { + string environmentVariable = Environment.GetEnvironmentVariable("GTK_THEME"); + if (!string.IsNullOrEmpty(environmentVariable) && environmentVariable.Contains("dark", StringComparison.OrdinalIgnoreCase)) + { + return AppTheme.Dark; + } + if (GetGnomeColorScheme().Contains("dark", StringComparison.OrdinalIgnoreCase)) + { + return AppTheme.Dark; + } + return AppTheme.Light; + } + catch + { + return AppTheme.Light; + } + } + } + + public AppPackagingModel PackagingModel + { + get + { + if (Environment.GetEnvironmentVariable("FLATPAK_ID") != null) + { + return AppPackagingModel.Packaged; + } + if (Environment.GetEnvironmentVariable("SNAP") != null) + { + return AppPackagingModel.Packaged; + } + if (Environment.GetEnvironmentVariable("APPIMAGE") != null) + { + return AppPackagingModel.Packaged; + } + return AppPackagingModel.Unpackaged; + } + } + + public AppInfoService() + { + _entryAssembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + _packageName = _entryAssembly.GetName().Name ?? "Unknown"; + _name = _entryAssembly.GetCustomAttribute()?.Title ?? _packageName; + _versionString = (_version = _entryAssembly.GetName().Version ?? new Version(1, 0)).ToString(); + _buildString = _entryAssembly.GetCustomAttribute()?.InformationalVersion ?? _versionString; + } + + private string GetGnomeColorScheme() + { + try + { + using Process? process = Process.Start(new ProcessStartInfo + { + FileName = "gsettings", + Arguments = "get org.gnome.desktop.interface color-scheme", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + }); + if (process != null) + { + string text = process.StandardOutput.ReadToEnd(); + process.WaitForExit(1000); + return text.Trim().Trim('\''); + } + } + catch + { + } + return ""; + } + + public void ShowSettingsUI() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "gnome-control-center", + UseShellExecute = true + }); + } + catch + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "xdg-open", + Arguments = "x-settings:", + UseShellExecute = true + }); + } + catch + { + } + } + } +} diff --git a/Services/ConnectivityService.cs b/Services/ConnectivityService.cs new file mode 100644 index 0000000..59a8dd9 --- /dev/null +++ b/Services/ConnectivityService.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using Microsoft.Maui.Networking; + +namespace Microsoft.Maui.Platform.Linux.Services; + +public class ConnectivityService : IConnectivity, IDisposable +{ + private static readonly Lazy _instance = new Lazy(() => new ConnectivityService()); + + private NetworkAccess _networkAccess; + + private IEnumerable _connectionProfiles; + + private bool _disposed; + + public static ConnectivityService Instance => _instance.Value; + + public NetworkAccess NetworkAccess + { + get + { + RefreshConnectivity(); + return _networkAccess; + } + } + + public IEnumerable ConnectionProfiles + { + get + { + RefreshConnectivity(); + return _connectionProfiles; + } + } + + public event EventHandler? ConnectivityChanged; + + public ConnectivityService() + { + _connectionProfiles = new List(); + RefreshConnectivity(); + NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged; + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + } + + private void RefreshConnectivity() + { + try + { + IEnumerable activeInterfaces = from ni in NetworkInterface.GetAllNetworkInterfaces() + where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback + select ni; + + if (!activeInterfaces.Any()) + { + _networkAccess = NetworkAccess.None; + _connectionProfiles = Enumerable.Empty(); + return; + } + + List profiles = new List(); + foreach (var networkInterface in activeInterfaces) + { + switch (networkInterface.NetworkInterfaceType) + { + case NetworkInterfaceType.Ethernet: + case NetworkInterfaceType.FastEthernetT: + case NetworkInterfaceType.FastEthernetFx: + case NetworkInterfaceType.GigabitEthernet: + profiles.Add(ConnectionProfile.Ethernet); + break; + case NetworkInterfaceType.Wireless80211: + profiles.Add(ConnectionProfile.WiFi); + break; + case NetworkInterfaceType.Ppp: + case NetworkInterfaceType.Slip: + profiles.Add(ConnectionProfile.Cellular); + break; + default: + profiles.Add(ConnectionProfile.Unknown); + break; + } + } + + _connectionProfiles = profiles.Distinct().ToList(); + + if (CheckInternetAccess()) + { + _networkAccess = NetworkAccess.Internet; + } + else if (_connectionProfiles.Any()) + { + _networkAccess = NetworkAccess.Local; + } + else + { + _networkAccess = NetworkAccess.None; + } + } + catch + { + _networkAccess = NetworkAccess.Unknown; + _connectionProfiles = new ConnectionProfile[] { ConnectionProfile.Unknown }; + } + } + + private bool CheckInternetAccess() + { + try + { + return Dns.GetHostEntry("dns.google").AddressList.Length != 0; + } + catch + { + try + { + foreach (NetworkInterface item in from n in NetworkInterface.GetAllNetworkInterfaces() + where n.OperationalStatus == OperationalStatus.Up + select n) + { + if (item.GetIPProperties().GatewayAddresses.Any((GatewayIPAddressInformation g) => g.Address.AddressFamily == AddressFamily.InterNetwork)) + { + return true; + } + } + } + catch + { + } + return false; + } + } + + private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) + { + NetworkAccess previousAccess = _networkAccess; + List previousProfiles = _connectionProfiles.ToList(); + RefreshConnectivity(); + if (previousAccess != _networkAccess || !previousProfiles.SequenceEqual(_connectionProfiles)) + { + ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(_networkAccess, _connectionProfiles)); + } + } + + private void OnNetworkAddressChanged(object? sender, EventArgs e) + { + NetworkAccess previousAccess = _networkAccess; + List previousProfiles = _connectionProfiles.ToList(); + RefreshConnectivity(); + if (previousAccess != _networkAccess || !previousProfiles.SequenceEqual(_connectionProfiles)) + { + ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(_networkAccess, _connectionProfiles)); + } + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged; + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; + } + } +} diff --git a/Services/DeviceDisplayService.cs b/Services/DeviceDisplayService.cs new file mode 100644 index 0000000..e8572b9 --- /dev/null +++ b/Services/DeviceDisplayService.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics; +using Microsoft.Maui.Devices; +using Microsoft.Maui.Platform.Linux.Native; + +namespace Microsoft.Maui.Platform.Linux.Services; + +public class DeviceDisplayService : IDeviceDisplay +{ + private static readonly Lazy _instance = new Lazy(() => new DeviceDisplayService()); + + private DisplayInfo _mainDisplayInfo; + + private bool _keepScreenOn; + + public static DeviceDisplayService Instance => _instance.Value; + + public bool KeepScreenOn + { + get + { + return _keepScreenOn; + } + set + { + if (_keepScreenOn != value) + { + _keepScreenOn = value; + SetScreenSaverInhibit(value); + } + } + } + + public DisplayInfo MainDisplayInfo + { + get + { + RefreshDisplayInfo(); + return _mainDisplayInfo; + } + } + + public event EventHandler? MainDisplayInfoChanged; + + public DeviceDisplayService() + { + RefreshDisplayInfo(); + } + + private void RefreshDisplayInfo() + { + try + { + IntPtr screen = GdkNative.gdk_screen_get_default(); + if (screen != IntPtr.Zero) + { + int width = GdkNative.gdk_screen_get_width(screen); + int height = GdkNative.gdk_screen_get_height(screen); + double scaleFactor = GetScaleFactor(); + DisplayOrientation orientation = (width <= height) ? DisplayOrientation.Portrait : DisplayOrientation.Landscape; + _mainDisplayInfo = new DisplayInfo(width, height, scaleFactor, orientation, DisplayRotation.Rotation0, GetRefreshRate()); + } + else + { + _mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, DisplayOrientation.Landscape, DisplayRotation.Rotation0, 60f); + } + } + catch + { + _mainDisplayInfo = new DisplayInfo(1920.0, 1080.0, 1.0, DisplayOrientation.Landscape, DisplayRotation.Rotation0, 60f); + } + } + + private double GetScaleFactor() + { + string gdkScale = Environment.GetEnvironmentVariable("GDK_SCALE"); + if (!string.IsNullOrEmpty(gdkScale) && double.TryParse(gdkScale, out var result)) + { + return result; + } + + string qtScale = Environment.GetEnvironmentVariable("QT_SCALE_FACTOR"); + if (!string.IsNullOrEmpty(qtScale) && double.TryParse(qtScale, out result)) + { + return result; + } + + return 1.0; + } + + private float GetRefreshRate() + { + return 60f; + } + + private void SetScreenSaverInhibit(bool inhibit) + { + try + { + string action = inhibit ? "suspend" : "resume"; + IntPtr windowHandle = LinuxApplication.Current?.MainWindow?.Handle ?? IntPtr.Zero; + if (windowHandle != IntPtr.Zero) + { + long windowId = windowHandle.ToInt64(); + Process.Start(new ProcessStartInfo + { + FileName = "xdg-screensaver", + Arguments = $"{action} {windowId}", + UseShellExecute = false, + CreateNoWindow = true + }); + } + } + catch + { + } + } + + public void OnDisplayInfoChanged() + { + RefreshDisplayInfo(); + MainDisplayInfoChanged?.Invoke(this, new DisplayInfoChangedEventArgs(_mainDisplayInfo)); + } +} diff --git a/Services/DeviceInfoService.cs b/Services/DeviceInfoService.cs new file mode 100644 index 0000000..ba0a4fa --- /dev/null +++ b/Services/DeviceInfoService.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using Microsoft.Maui.Devices; + +namespace Microsoft.Maui.Platform.Linux.Services; + +public class DeviceInfoService : IDeviceInfo +{ + private static readonly Lazy _instance = new Lazy(() => new DeviceInfoService()); + + private string? _model; + + private string? _manufacturer; + + private string? _name; + + private string? _versionString; + + public static DeviceInfoService Instance => _instance.Value; + + public string Model => _model ?? "Linux Desktop"; + + public string Manufacturer => _manufacturer ?? "Unknown"; + + public string Name => _name ?? Environment.MachineName; + + public string VersionString => _versionString ?? Environment.OSVersion.VersionString; + + public Version Version + { + get + { + try + { + if (System.Version.TryParse(Environment.OSVersion.Version.ToString(), out Version? result)) + { + return result; + } + } + catch + { + } + return new Version(1, 0); + } + } + + public DevicePlatform Platform => DevicePlatform.Create("Linux"); + + public DeviceIdiom Idiom => DeviceIdiom.Desktop; + + public DeviceType DeviceType => DeviceType.Physical; + + public DeviceInfoService() + { + LoadDeviceInfo(); + } + + private void LoadDeviceInfo() + { + try + { + if (File.Exists("/sys/class/dmi/id/product_name")) + { + _model = File.ReadAllText("/sys/class/dmi/id/product_name").Trim(); + } + if (File.Exists("/sys/class/dmi/id/sys_vendor")) + { + _manufacturer = File.ReadAllText("/sys/class/dmi/id/sys_vendor").Trim(); + } + _name = Environment.MachineName; + _versionString = Environment.OSVersion.VersionString; + } + catch + { + if (_model == null) + { + _model = "Linux Desktop"; + } + if (_manufacturer == null) + { + _manufacturer = "Unknown"; + } + if (_name == null) + { + _name = "localhost"; + } + if (_versionString == null) + { + _versionString = "Linux"; + } + } + } +} diff --git a/Services/GtkContextMenuService.cs b/Services/GtkContextMenuService.cs new file mode 100644 index 0000000..ed576eb --- /dev/null +++ b/Services/GtkContextMenuService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.Maui.Platform.Linux.Native; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Service for displaying native GTK context menus in MAUI applications. +/// Provides popup menu functionality with action callbacks. +/// +public static class GtkContextMenuService +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void ActivateCallback(IntPtr menuItem, IntPtr userData); + + // Keep references to prevent garbage collection + private static readonly List _callbacks = new(); + private static readonly List _actions = new(); + + public static void ShowContextMenu(List items) + { + if (items == null || items.Count == 0) + { + return; + } + + _callbacks.Clear(); + _actions.Clear(); + + IntPtr menu = GtkNative.gtk_menu_new(); + if (menu == IntPtr.Zero) + { + Console.WriteLine("[GtkContextMenuService] Failed to create GTK menu"); + return; + } + + foreach (var item in items) + { + IntPtr menuItem; + + if (item.IsSeparator) + { + menuItem = GtkNative.gtk_separator_menu_item_new(); + } + else + { + menuItem = GtkNative.gtk_menu_item_new_with_label(item.Text); + GtkNative.gtk_widget_set_sensitive(menuItem, item.IsEnabled); + + if (item.IsEnabled && item.Action != null) + { + var action = item.Action; + _actions.Add(action); + int actionIndex = _actions.Count - 1; + + ActivateCallback callback = delegate + { + Console.WriteLine("[GtkContextMenuService] Menu item activated: " + item.Text); + _actions[actionIndex]?.Invoke(); + }; + _callbacks.Add(callback); + + GtkNative.g_signal_connect_data( + menuItem, + "activate", + Marshal.GetFunctionPointerForDelegate(callback), + IntPtr.Zero, + IntPtr.Zero, + 0); + } + } + + GtkNative.gtk_menu_shell_append(menu, menuItem); + GtkNative.gtk_widget_show(menuItem); + } + + GtkNative.gtk_widget_show(menu); + + IntPtr currentEvent = GtkNative.gtk_get_current_event(); + GtkNative.gtk_menu_popup_at_pointer(menu, currentEvent); + + if (currentEvent != IntPtr.Zero) + { + GtkNative.gdk_event_free(currentEvent); + } + + Console.WriteLine($"[GtkContextMenuService] Showed GTK menu with {items.Count} items"); + } +} diff --git a/Services/GtkHostService.cs b/Services/GtkHostService.cs new file mode 100644 index 0000000..3bbfa3c --- /dev/null +++ b/Services/GtkHostService.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.Maui.Platform.Linux.Handlers; +using Microsoft.Maui.Platform.Linux.Window; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Singleton service that manages the GTK host window and WebView manager. +/// Provides centralized access to the GTK infrastructure for MAUI applications. +/// +public class GtkHostService +{ + private static GtkHostService? _instance; + private GtkHostWindow? _hostWindow; + private GtkWebViewManager? _webViewManager; + + public static GtkHostService Instance => _instance ??= new GtkHostService(); + + public GtkHostWindow? HostWindow => _hostWindow; + public GtkWebViewManager? WebViewManager => _webViewManager; + public bool IsInitialized => _hostWindow != null; + + public event EventHandler? HostWindowCreated; + + public void Initialize(string title, int width, int height) + { + if (_hostWindow == null) + { + _hostWindow = new GtkHostWindow(title, width, height); + _webViewManager = new GtkWebViewManager(_hostWindow); + HostWindowCreated?.Invoke(this, _hostWindow); + } + } + + public GtkHostWindow GetOrCreateHostWindow(string title = "MAUI Application", int width = 800, int height = 600) + { + if (_hostWindow == null) + { + Initialize(title, width, height); + } + return _hostWindow!; + } + + public void SetWindowIcon(string iconPath) + { + _hostWindow?.SetIcon(iconPath); + } + + public void Shutdown() + { + _webViewManager?.Clear(); + _webViewManager = null; + _hostWindow?.Dispose(); + _hostWindow = null; + } +} diff --git a/Services/GtkMenuItem.cs b/Services/GtkMenuItem.cs new file mode 100644 index 0000000..5a45103 --- /dev/null +++ b/Services/GtkMenuItem.cs @@ -0,0 +1,32 @@ +using System; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Represents a menu item for use with GtkContextMenuService. +/// +public class GtkMenuItem +{ + public string Text { get; } + public Action? Action { get; } + public bool IsEnabled { get; } + public bool IsSeparator { get; } + + public static GtkMenuItem Separator => new GtkMenuItem(); + + public GtkMenuItem(string text, Action? action, bool isEnabled = true) + { + Text = text; + Action = action; + IsEnabled = isEnabled; + IsSeparator = false; + } + + private GtkMenuItem() + { + Text = ""; + Action = null; + IsEnabled = false; + IsSeparator = true; + } +} diff --git a/Services/MauiIconGenerator.cs b/Services/MauiIconGenerator.cs new file mode 100644 index 0000000..920f239 --- /dev/null +++ b/Services/MauiIconGenerator.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using SkiaSharp; + +namespace Microsoft.Maui.Platform.Linux.Services; + +/// +/// Generates application icons from MAUI icon metadata. +/// Creates PNG icons suitable for use as window icons on Linux. +/// Note: SVG overlay support requires Svg.Skia package (optional). +/// +public static class MauiIconGenerator +{ + private const int DefaultIconSize = 256; + + public static string? GenerateIcon(string metaFilePath) + { + if (!File.Exists(metaFilePath)) + { + Console.WriteLine("[MauiIconGenerator] Metadata file not found: " + metaFilePath); + return null; + } + + try + { + string path = Path.GetDirectoryName(metaFilePath) ?? ""; + var metadata = ParseMetadata(File.ReadAllText(metaFilePath)); + + string outputPath = Path.Combine(path, "appicon.png"); + + int size = metadata.TryGetValue("Size", out var sizeStr) && int.TryParse(sizeStr, out var sizeVal) + ? sizeVal + : DefaultIconSize; + + SKColor color = metadata.TryGetValue("Color", out var colorStr) + ? ParseColor(colorStr) + : SKColors.Purple; + + Console.WriteLine($"[MauiIconGenerator] Generating {size}x{size} icon"); + Console.WriteLine($"[MauiIconGenerator] Color: {color}"); + + using var surface = SKSurface.Create(new SKImageInfo(size, size, SKColorType.Bgra8888, SKAlphaType.Premul)); + var canvas = surface.Canvas; + + // Draw background with rounded corners + canvas.Clear(SKColors.Transparent); + float cornerRadius = size * 0.2f; + using var paint = new SKPaint { Color = color, IsAntialias = true }; + canvas.DrawRoundRect(new SKRoundRect(new SKRect(0, 0, size, size), cornerRadius), paint); + + // Try to load PNG foreground as fallback (appicon_fg.png) + string fgPngPath = Path.Combine(path, "appicon_fg.png"); + if (File.Exists(fgPngPath)) + { + try + { + using var fgBitmap = SKBitmap.Decode(fgPngPath); + if (fgBitmap != null) + { + float scale = size * 0.65f / Math.Max(fgBitmap.Width, fgBitmap.Height); + float fgWidth = fgBitmap.Width * scale; + float fgHeight = fgBitmap.Height * scale; + float offsetX = (size - fgWidth) / 2f; + float offsetY = (size - fgHeight) / 2f; + + var dstRect = new SKRect(offsetX, offsetY, offsetX + fgWidth, offsetY + fgHeight); + canvas.DrawBitmap(fgBitmap, dstRect); + } + } + catch (Exception ex) + { + Console.WriteLine("[MauiIconGenerator] Failed to load foreground PNG: " + ex.Message); + } + } + + using var image = surface.Snapshot(); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + using var fileStream = File.OpenWrite(outputPath); + data.SaveTo(fileStream); + + Console.WriteLine("[MauiIconGenerator] Generated: " + outputPath); + return outputPath; + } + catch (Exception ex) + { + Console.WriteLine("[MauiIconGenerator] Error: " + ex.Message); + return null; + } + } + + private static Dictionary ParseMetadata(string content) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var parts = line.Split('=', 2); + if (parts.Length == 2) + { + result[parts[0].Trim()] = parts[1].Trim(); + } + } + return result; + } + + private static SKColor ParseColor(string colorStr) + { + if (string.IsNullOrEmpty(colorStr)) + { + return SKColors.Purple; + } + + colorStr = colorStr.Trim(); + + if (colorStr.StartsWith("#")) + { + string hex = colorStr.Substring(1); + + // Expand 3-digit hex to 6-digit + if (hex.Length == 3) + { + hex = $"{hex[0]}{hex[0]}{hex[1]}{hex[1]}{hex[2]}{hex[2]}"; + } + + if (hex.Length == 6 && uint.TryParse(hex, NumberStyles.HexNumber, null, out var rgb)) + { + return new SKColor( + (byte)((rgb >> 16) & 0xFF), + (byte)((rgb >> 8) & 0xFF), + (byte)(rgb & 0xFF)); + } + + if (hex.Length == 8 && uint.TryParse(hex, NumberStyles.HexNumber, null, out var argb)) + { + return new SKColor( + (byte)((argb >> 16) & 0xFF), + (byte)((argb >> 8) & 0xFF), + (byte)(argb & 0xFF), + (byte)((argb >> 24) & 0xFF)); + } + } + + return colorStr.ToLowerInvariant() switch + { + "red" => SKColors.Red, + "green" => SKColors.Green, + "blue" => SKColors.Blue, + "purple" => SKColors.Purple, + "orange" => SKColors.Orange, + "white" => SKColors.White, + "black" => SKColors.Black, + _ => SKColors.Purple, + }; + } +} diff --git a/Views/LinuxDialogService.cs b/Views/LinuxDialogService.cs new file mode 100644 index 0000000..a93aad2 --- /dev/null +++ b/Views/LinuxDialogService.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SkiaSharp; + +namespace Microsoft.Maui.Platform; + +public static class LinuxDialogService +{ + private static readonly List _activeDialogs = new List(); + + private static Action? _invalidateCallback; + + private static SkiaContextMenu? _activeContextMenu; + + private static Action? _showPopupCallback; + + private static Action? _hidePopupCallback; + + public static bool HasActiveDialog => _activeDialogs.Count > 0; + + public static SkiaAlertDialog? TopDialog + { + get + { + if (_activeDialogs.Count <= 0) + { + return null; + } + return _activeDialogs[_activeDialogs.Count - 1]; + } + } + + public static SkiaContextMenu? ActiveContextMenu => _activeContextMenu; + + public static bool HasContextMenu => _activeContextMenu != null; + + public static void SetInvalidateCallback(Action callback) + { + _invalidateCallback = callback; + } + + public static Task ShowAlertAsync(string title, string message, string? accept, string? cancel) + { + var dialog = new SkiaAlertDialog(title, message, accept, cancel); + _activeDialogs.Add(dialog); + _invalidateCallback?.Invoke(); + return dialog.Result; + } + + internal static void HideDialog(SkiaAlertDialog dialog) + { + _activeDialogs.Remove(dialog); + _invalidateCallback?.Invoke(); + } + + public static void DrawDialogs(SKCanvas canvas, SKRect bounds) + { + DrawDialogsOnly(canvas, bounds); + DrawContextMenuOnly(canvas, bounds); + } + + public static void DrawDialogsOnly(SKCanvas canvas, SKRect bounds) + { + foreach (var dialog in _activeDialogs) + { + dialog.Measure(new SKSize(bounds.Width, bounds.Height)); + dialog.Arrange(bounds); + dialog.Draw(canvas); + } + } + + public static void DrawContextMenuOnly(SKCanvas canvas, SKRect bounds) + { + if (_activeContextMenu != null) + { + _activeContextMenu.Draw(canvas); + } + } + + public static void SetPopupCallbacks(Action showPopup, Action hidePopup) + { + _showPopupCallback = showPopup; + _hidePopupCallback = hidePopup; + } + + public static void ShowContextMenu(SkiaContextMenu menu) + { + Console.WriteLine("[LinuxDialogService] ShowContextMenu called"); + _activeContextMenu = menu; + _showPopupCallback?.Invoke(); + _invalidateCallback?.Invoke(); + } + + public static void HideContextMenu() + { + _activeContextMenu = null; + _hidePopupCallback?.Invoke(); + _invalidateCallback?.Invoke(); + } +} diff --git a/Views/SkiaAlertDialog.cs b/Views/SkiaAlertDialog.cs index 92ba9d6..50597c5 100644 --- a/Views/SkiaAlertDialog.cs +++ b/Views/SkiaAlertDialog.cs @@ -323,63 +323,3 @@ public class SkiaAlertDialog : SkiaView return this; } } - -/// -/// Service for showing modal dialogs in OpenMaui Linux. -/// -public static class LinuxDialogService -{ - private static readonly List _activeDialogs = new(); - private static Action? _invalidateCallback; - - /// - /// Registers the invalidation callback (called by LinuxApplication). - /// - public static void SetInvalidateCallback(Action callback) - { - _invalidateCallback = callback; - } - - /// - /// Shows an alert dialog and returns when dismissed. - /// - public static Task ShowAlertAsync(string title, string message, string? accept, string? cancel) - { - var dialog = new SkiaAlertDialog(title, message, accept, cancel); - _activeDialogs.Add(dialog); - _invalidateCallback?.Invoke(); - return dialog.Result; - } - - /// - /// Hides a dialog. - /// - internal static void HideDialog(SkiaAlertDialog dialog) - { - _activeDialogs.Remove(dialog); - _invalidateCallback?.Invoke(); - } - - /// - /// Gets whether there are active dialogs. - /// - public static bool HasActiveDialog => _activeDialogs.Count > 0; - - /// - /// Gets the topmost dialog. - /// - public static SkiaAlertDialog? TopDialog => _activeDialogs.Count > 0 ? _activeDialogs[^1] : null; - - /// - /// Draws all active dialogs. - /// - public static void DrawDialogs(SKCanvas canvas, SKRect bounds) - { - foreach (var dialog in _activeDialogs) - { - dialog.Measure(new SKSize(bounds.Width, bounds.Height)); - dialog.Arrange(bounds); - dialog.Draw(canvas); - } - } -} diff --git a/Views/SkiaButton.cs b/Views/SkiaButton.cs index 5e584f2..45cb8f9 100644 --- a/Views/SkiaButton.cs +++ b/Views/SkiaButton.cs @@ -199,6 +199,39 @@ public class SkiaButton : SkiaView typeof(SkiaButton), null); + /// + /// Bindable property for ImageSource. + /// + public static readonly BindableProperty ImageSourceProperty = + BindableProperty.Create( + nameof(ImageSource), + typeof(SKBitmap), + typeof(SkiaButton), + null, + propertyChanged: (b, o, n) => ((SkiaButton)b).Invalidate()); + + /// + /// Bindable property for ImageSpacing. + /// + public static readonly BindableProperty ImageSpacingProperty = + BindableProperty.Create( + nameof(ImageSpacing), + typeof(float), + typeof(SkiaButton), + 8f, + propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure()); + + /// + /// Bindable property for ContentLayoutPosition (0=Left, 1=Top, 2=Right, 3=Bottom). + /// + public static readonly BindableProperty ContentLayoutPositionProperty = + BindableProperty.Create( + nameof(ContentLayoutPosition), + typeof(int), + typeof(SkiaButton), + 0, + propertyChanged: (b, o, n) => ((SkiaButton)b).InvalidateMeasure()); + #endregion #region Properties @@ -356,6 +389,33 @@ public class SkiaButton : SkiaView set => SetValue(CommandParameterProperty, value); } + /// + /// Gets or sets the image source for the button. + /// + public SKBitmap? ImageSource + { + get => (SKBitmap?)GetValue(ImageSourceProperty); + set => SetValue(ImageSourceProperty, value); + } + + /// + /// Gets or sets the spacing between the image and text. + /// + public float ImageSpacing + { + get => (float)GetValue(ImageSpacingProperty); + set => SetValue(ImageSpacingProperty, value); + } + + /// + /// Gets or sets the content layout position (0=Left, 1=Top, 2=Right, 3=Bottom). + /// + public int ContentLayoutPosition + { + get => (int)GetValue(ContentLayoutPositionProperty); + set => SetValue(ContentLayoutPositionProperty, value); + } + /// /// Gets whether the button is currently pressed. /// @@ -504,53 +564,151 @@ public class SkiaButton : SkiaView canvas.DrawRoundRect(focusRect, focusPaint); } - // Draw text - if (!string.IsNullOrEmpty(Text)) + // Draw content (text and/or image) + DrawContent(canvas, bounds, isTextOnly); + } + + private void DrawContent(SKCanvas canvas, SKRect bounds, bool isTextOnly) + { + var fontStyle = new SKFontStyle( + IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) + ?? SKTypeface.Default; + + using var font = new SKFont(typeface, FontSize); + + // Determine text color + SKColor textColorToUse; + if (!IsEnabled) { - var fontStyle = new SKFontStyle( - IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, - SKFontStyleWidth.Normal, - IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); - var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle) - ?? SKTypeface.Default; + textColorToUse = TextColor.WithAlpha(128); + } + else if (isTextOnly && (IsHovered || IsPressed)) + { + textColorToUse = new SKColor( + (byte)Math.Max(0, TextColor.Red - 40), + (byte)Math.Max(0, TextColor.Green - 40), + (byte)Math.Max(0, TextColor.Blue - 40), + TextColor.Alpha); + } + else + { + textColorToUse = TextColor; + } - using var font = new SKFont(typeface, FontSize); + using var textPaint = new SKPaint(font) + { + Color = textColorToUse, + IsAntialias = true + }; - // For text-only buttons, darken text on hover/press for feedback - SKColor textColorToUse; - if (!IsEnabled) + // Measure text + var textBounds = new SKRect(); + bool hasText = !string.IsNullOrEmpty(Text); + if (hasText) + { + textPaint.MeasureText(Text, ref textBounds); + } + + // Calculate image size + bool hasImage = ImageSource != null; + float imageWidth = 0; + float imageHeight = 0; + if (hasImage) + { + float maxImageSize = Math.Min(bounds.Height - 8, 24f); + float scale = Math.Min(maxImageSize / ImageSource!.Width, maxImageSize / ImageSource.Height); + imageWidth = ImageSource.Width * scale; + imageHeight = ImageSource.Height * scale; + } + + // Calculate total content size and position + bool isHorizontal = ContentLayoutPosition == 0 || ContentLayoutPosition == 2; + float totalWidth, totalHeight; + if (hasImage && hasText) + { + if (isHorizontal) { - textColorToUse = TextColor.WithAlpha(128); - } - else if (isTextOnly && (IsHovered || IsPressed)) - { - // Darken the text color slightly for hover/press feedback - textColorToUse = new SKColor( - (byte)Math.Max(0, TextColor.Red - 40), - (byte)Math.Max(0, TextColor.Green - 40), - (byte)Math.Max(0, TextColor.Blue - 40), - TextColor.Alpha); + totalWidth = imageWidth + ImageSpacing + textBounds.Width; + totalHeight = Math.Max(imageHeight, textBounds.Height); } else { - textColorToUse = TextColor; + totalWidth = Math.Max(imageWidth, textBounds.Width); + totalHeight = imageHeight + ImageSpacing + textBounds.Height; + } + } + else if (hasImage) + { + totalWidth = imageWidth; + totalHeight = imageHeight; + } + else + { + totalWidth = textBounds.Width; + totalHeight = textBounds.Height; + } + + float startX = bounds.MidX - totalWidth / 2; + float startY = bounds.MidY - totalHeight / 2; + + // Draw image and text based on layout position + if (hasImage) + { + float imageX, imageY; + float textX = 0, textY = 0; + + switch (ContentLayoutPosition) + { + case 1: // Top - image above text + imageX = bounds.MidX - imageWidth / 2; + imageY = startY; + textX = bounds.MidX - textBounds.Width / 2; + textY = startY + imageHeight + ImageSpacing - textBounds.Top; + break; + case 2: // Right - image to right of text + textX = startX; + textY = bounds.MidY - textBounds.MidY; + imageX = startX + textBounds.Width + ImageSpacing; + imageY = bounds.MidY - imageHeight / 2; + break; + case 3: // Bottom - image below text + textX = bounds.MidX - textBounds.Width / 2; + textY = startY - textBounds.Top; + imageX = bounds.MidX - imageWidth / 2; + imageY = startY + textBounds.Height + ImageSpacing; + break; + default: // 0 = Left - image to left of text + imageX = startX; + imageY = bounds.MidY - imageHeight / 2; + textX = startX + imageWidth + ImageSpacing; + textY = bounds.MidY - textBounds.MidY; + break; } - using var paint = new SKPaint(font) + // Draw image + var imageRect = new SKRect(imageX, imageY, imageX + imageWidth, imageY + imageHeight); + using var imagePaint = new SKPaint { IsAntialias = true }; + if (!IsEnabled) { - Color = textColorToUse, - IsAntialias = true - }; + imagePaint.ColorFilter = SKColorFilter.CreateBlendMode(new SKColor(128, 128, 128, 128), SKBlendMode.Modulate); + } + canvas.DrawBitmap(ImageSource!, imageRect, imagePaint); - // Measure text - var textBounds = new SKRect(); - paint.MeasureText(Text, ref textBounds); - - // Center text + // Draw text + if (hasText) + { + canvas.DrawText(Text!, textX, textY, textPaint); + } + } + else if (hasText) + { + // Just text, centered var x = bounds.MidX - textBounds.MidX; var y = bounds.MidY - textBounds.MidY; - - canvas.DrawText(Text, x, y, paint); + canvas.DrawText(Text!, x, y, textPaint); } } diff --git a/Views/SkiaContextMenu.cs b/Views/SkiaContextMenu.cs new file mode 100644 index 0000000..e0a9b38 --- /dev/null +++ b/Views/SkiaContextMenu.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using SkiaSharp; + +namespace Microsoft.Maui.Platform; + +public class SkiaContextMenu : SkiaView +{ + private readonly List _items; + private readonly float _x; + private readonly float _y; + private int _hoveredIndex = -1; + private SKRect[] _itemBounds = Array.Empty(); + + private static readonly SKColor MenuBackground = new SKColor(255, 255, 255); + private static readonly SKColor MenuBackgroundDark = new SKColor(48, 48, 48); + private static readonly SKColor ItemHoverBackground = new SKColor(227, 242, 253); + private static readonly SKColor ItemHoverBackgroundDark = new SKColor(80, 80, 80); + private static readonly SKColor ItemTextColor = new SKColor(33, 33, 33); + private static readonly SKColor ItemTextColorDark = new SKColor(224, 224, 224); + private static readonly SKColor DisabledTextColor = new SKColor(158, 158, 158); + private static readonly SKColor SeparatorColor = new SKColor(224, 224, 224); + private static readonly SKColor ShadowColor = new SKColor(0, 0, 0, 40); + + private const float MenuPadding = 4f; + private const float ItemHeight = 32f; + private const float ItemPaddingH = 16f; + private const float SeparatorHeight = 9f; + private const float CornerRadius = 4f; + private const float MinWidth = 120f; + + private bool _isDarkTheme; + + public SkiaContextMenu(float x, float y, List items, bool isDarkTheme = false) + { + _x = x; + _y = y; + _items = items; + _isDarkTheme = isDarkTheme; + IsFocusable = true; + } + + public override void Draw(SKCanvas canvas) + { + float menuWidth = CalculateMenuWidth(); + float menuHeight = CalculateMenuHeight(); + float posX = _x; + float posY = _y; + + // Adjust position to stay within bounds + canvas.GetDeviceClipBounds(out var clipBounds); + if (posX + menuWidth > clipBounds.Right) + { + posX = clipBounds.Right - menuWidth - 4f; + } + if (posY + menuHeight > clipBounds.Bottom) + { + posY = clipBounds.Bottom - menuHeight - 4f; + } + + var menuRect = new SKRect(posX, posY, posX + menuWidth, posY + menuHeight); + + // Draw shadow + using (var shadowPaint = new SKPaint + { + Color = ShadowColor, + MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 4f) + }) + { + canvas.DrawRoundRect(menuRect.Left + 2f, menuRect.Top + 2f, menuWidth, menuHeight, CornerRadius, CornerRadius, shadowPaint); + } + + // Draw background + using (var bgPaint = new SKPaint + { + Color = _isDarkTheme ? MenuBackgroundDark : MenuBackground, + IsAntialias = true + }) + { + canvas.DrawRoundRect(menuRect, CornerRadius, CornerRadius, bgPaint); + } + + // Draw border + using (var borderPaint = new SKPaint + { + Color = SeparatorColor, + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = true + }) + { + canvas.DrawRoundRect(menuRect, CornerRadius, CornerRadius, borderPaint); + } + + // Draw items + _itemBounds = new SKRect[_items.Count]; + float itemY = posY + MenuPadding; + + for (int i = 0; i < _items.Count; i++) + { + var item = _items[i]; + + if (item.IsSeparator) + { + float separatorY = itemY + SeparatorHeight / 2f; + using (var sepPaint = new SKPaint { Color = SeparatorColor, StrokeWidth = 1f }) + { + canvas.DrawLine(posX + 8f, separatorY, posX + menuWidth - 8f, separatorY, sepPaint); + } + _itemBounds[i] = new SKRect(posX, itemY, posX + menuWidth, itemY + SeparatorHeight); + itemY += SeparatorHeight; + continue; + } + + var itemRect = new SKRect(posX + MenuPadding, itemY, posX + menuWidth - MenuPadding, itemY + ItemHeight); + _itemBounds[i] = itemRect; + + // Draw hover background + if (i == _hoveredIndex && item.IsEnabled) + { + using (var hoverPaint = new SKPaint + { + Color = _isDarkTheme ? ItemHoverBackgroundDark : ItemHoverBackground, + IsAntialias = true + }) + { + canvas.DrawRoundRect(itemRect, CornerRadius, CornerRadius, hoverPaint); + } + } + + // Draw text + using (var textPaint = new SKPaint + { + Color = !item.IsEnabled ? DisabledTextColor : (_isDarkTheme ? ItemTextColorDark : ItemTextColor), + TextSize = 14f, + IsAntialias = true, + Typeface = SKTypeface.Default + }) + { + float textY = itemRect.MidY + textPaint.TextSize / 3f; + canvas.DrawText(item.Text, itemRect.Left + ItemPaddingH, textY, textPaint); + } + + itemY += ItemHeight; + } + } + + private float CalculateMenuWidth() + { + float maxWidth = MinWidth; + using (var paint = new SKPaint { TextSize = 14f, Typeface = SKTypeface.Default }) + { + foreach (var item in _items) + { + if (!item.IsSeparator) + { + float textWidth = paint.MeasureText(item.Text) + ItemPaddingH * 2f; + maxWidth = Math.Max(maxWidth, textWidth); + } + } + } + return maxWidth + MenuPadding * 2f; + } + + private float CalculateMenuHeight() + { + float height = MenuPadding * 2f; + foreach (var item in _items) + { + height += item.IsSeparator ? SeparatorHeight : ItemHeight; + } + return height; + } + + public override void OnPointerMoved(PointerEventArgs e) + { + int oldHovered = _hoveredIndex; + _hoveredIndex = -1; + + for (int i = 0; i < _itemBounds.Length; i++) + { + if (_itemBounds[i].Contains(e.X, e.Y) && !_items[i].IsSeparator) + { + _hoveredIndex = i; + break; + } + } + + if (oldHovered != _hoveredIndex) + { + Invalidate(); + } + } + + public override void OnPointerPressed(PointerEventArgs e) + { + for (int i = 0; i < _itemBounds.Length; i++) + { + if (_itemBounds[i].Contains(e.X, e.Y)) + { + var item = _items[i]; + if (item.IsEnabled && !item.IsSeparator && item.Action != null) + { + LinuxDialogService.HideContextMenu(); + item.Action(); + return; + } + } + } + LinuxDialogService.HideContextMenu(); + } + + public override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + LinuxDialogService.HideContextMenu(); + e.Handled = true; + } + } +} diff --git a/Views/SkiaEntry.cs b/Views/SkiaEntry.cs index 22fb8f0..1c66979 100644 --- a/Views/SkiaEntry.cs +++ b/Views/SkiaEntry.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using SkiaSharp; using Microsoft.Maui.Platform.Linux.Rendering; +using Microsoft.Maui.Platform.Linux.Services; namespace Microsoft.Maui.Platform; @@ -980,11 +982,17 @@ public class SkiaEntry : SkiaView public override void OnPointerPressed(PointerEventArgs e) { - Console.WriteLine($"[SkiaEntry] OnPointerPressed - Text='{Text}', Placeholder='{Placeholder}', IsEnabled={IsEnabled}, IsFocused={IsFocused}"); - Console.WriteLine($"[SkiaEntry] Bounds={Bounds}, ScreenBounds={ScreenBounds}, e.X={e.X}, e.Y={e.Y}"); - + Console.WriteLine($"[SkiaEntry] OnPointerPressed Button={e.Button} at ({e.X}, {e.Y})"); if (!IsEnabled) return; + // Handle right-click context menu + if (e.Button == PointerButton.Right) + { + Console.WriteLine("[SkiaEntry] Right-click detected, showing context menu"); + ShowContextMenu(e.X, e.Y); + return; + } + // Check if clicked on clear button if (ShowClearButton && !string.IsNullOrEmpty(Text) && IsFocused) { @@ -1217,6 +1225,38 @@ public class SkiaEntry : SkiaView Invalidate(); } + private void ShowContextMenu(float x, float y) + { + Console.WriteLine($"[SkiaEntry] ShowContextMenu at ({x}, {y})"); + bool hasSelection = _selectionLength != 0; + bool hasText = !string.IsNullOrEmpty(Text); + bool hasClipboard = !string.IsNullOrEmpty(SystemClipboard.GetText()); + + GtkContextMenuService.ShowContextMenu(new List + { + new GtkMenuItem("Cut", () => + { + CutToClipboard(); + Invalidate(); + }, hasSelection), + new GtkMenuItem("Copy", () => + { + CopyToClipboard(); + }, hasSelection), + new GtkMenuItem("Paste", () => + { + PasteFromClipboard(); + Invalidate(); + }, hasClipboard), + GtkMenuItem.Separator, + new GtkMenuItem("Select All", () => + { + SelectAll(); + Invalidate(); + }, hasText) + }); + } + public override void OnFocusGained() { base.OnFocusGained(); diff --git a/Views/SkiaFlexLayout.cs b/Views/SkiaFlexLayout.cs new file mode 100644 index 0000000..bc894d9 --- /dev/null +++ b/Views/SkiaFlexLayout.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Maui.Controls; +using SkiaSharp; + +namespace Microsoft.Maui.Platform; + +public class SkiaFlexLayout : SkiaLayoutView +{ + public static readonly BindableProperty DirectionProperty = BindableProperty.Create( + nameof(Direction), typeof(FlexDirection), typeof(SkiaFlexLayout), FlexDirection.Row, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure()); + + public static readonly BindableProperty WrapProperty = BindableProperty.Create( + nameof(Wrap), typeof(FlexWrap), typeof(SkiaFlexLayout), FlexWrap.NoWrap, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure()); + + public static readonly BindableProperty JustifyContentProperty = BindableProperty.Create( + nameof(JustifyContent), typeof(FlexJustify), typeof(SkiaFlexLayout), FlexJustify.Start, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure()); + + public static readonly BindableProperty AlignItemsProperty = BindableProperty.Create( + nameof(AlignItems), typeof(FlexAlignItems), typeof(SkiaFlexLayout), FlexAlignItems.Stretch, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure()); + + public static readonly BindableProperty AlignContentProperty = BindableProperty.Create( + nameof(AlignContent), typeof(FlexAlignContent), typeof(SkiaFlexLayout), FlexAlignContent.Stretch, + BindingMode.TwoWay, propertyChanged: (b, o, n) => ((SkiaFlexLayout)b).InvalidateMeasure()); + + public static readonly BindableProperty OrderProperty = BindableProperty.CreateAttached( + "Order", typeof(int), typeof(SkiaFlexLayout), 0, BindingMode.TwoWay); + + public static readonly BindableProperty GrowProperty = BindableProperty.CreateAttached( + "Grow", typeof(float), typeof(SkiaFlexLayout), 0f, BindingMode.TwoWay); + + public static readonly BindableProperty ShrinkProperty = BindableProperty.CreateAttached( + "Shrink", typeof(float), typeof(SkiaFlexLayout), 1f, BindingMode.TwoWay); + + public static readonly BindableProperty BasisProperty = BindableProperty.CreateAttached( + "Basis", typeof(FlexBasis), typeof(SkiaFlexLayout), FlexBasis.Auto, BindingMode.TwoWay); + + public static readonly BindableProperty AlignSelfProperty = BindableProperty.CreateAttached( + "AlignSelf", typeof(FlexAlignSelf), typeof(SkiaFlexLayout), FlexAlignSelf.Auto, BindingMode.TwoWay); + + public FlexDirection Direction + { + get => (FlexDirection)GetValue(DirectionProperty); + set => SetValue(DirectionProperty, value); + } + + public FlexWrap Wrap + { + get => (FlexWrap)GetValue(WrapProperty); + set => SetValue(WrapProperty, value); + } + + public FlexJustify JustifyContent + { + get => (FlexJustify)GetValue(JustifyContentProperty); + set => SetValue(JustifyContentProperty, value); + } + + public FlexAlignItems AlignItems + { + get => (FlexAlignItems)GetValue(AlignItemsProperty); + set => SetValue(AlignItemsProperty, value); + } + + public FlexAlignContent AlignContent + { + get => (FlexAlignContent)GetValue(AlignContentProperty); + set => SetValue(AlignContentProperty, value); + } + + public static int GetOrder(SkiaView view) => (int)view.GetValue(OrderProperty); + public static void SetOrder(SkiaView view, int value) => view.SetValue(OrderProperty, value); + + public static float GetGrow(SkiaView view) => (float)view.GetValue(GrowProperty); + public static void SetGrow(SkiaView view, float value) => view.SetValue(GrowProperty, value); + + public static float GetShrink(SkiaView view) => (float)view.GetValue(ShrinkProperty); + public static void SetShrink(SkiaView view, float value) => view.SetValue(ShrinkProperty, value); + + public static FlexBasis GetBasis(SkiaView view) => (FlexBasis)view.GetValue(BasisProperty); + public static void SetBasis(SkiaView view, FlexBasis value) => view.SetValue(BasisProperty, value); + + public static FlexAlignSelf GetAlignSelf(SkiaView view) => (FlexAlignSelf)view.GetValue(AlignSelfProperty); + public static void SetAlignSelf(SkiaView view, FlexAlignSelf value) => view.SetValue(AlignSelfProperty, value); + + protected override SKSize MeasureOverride(SKSize availableSize) + { + bool isRow = Direction == FlexDirection.Row || Direction == FlexDirection.RowReverse; + float totalMain = 0f; + float maxCross = 0f; + + foreach (var child in Children) + { + if (!child.IsVisible) + continue; + + var childSize = child.Measure(availableSize); + if (isRow) + { + totalMain += childSize.Width; + maxCross = Math.Max(maxCross, childSize.Height); + } + else + { + totalMain += childSize.Height; + maxCross = Math.Max(maxCross, childSize.Width); + } + } + + return isRow ? new SKSize(totalMain, maxCross) : new SKSize(maxCross, totalMain); + } + + protected override SKRect ArrangeOverride(SKRect bounds) + { + if (Children.Count == 0) + return bounds; + + bool isRow = Direction == FlexDirection.Row || Direction == FlexDirection.RowReverse; + bool isReverse = Direction == FlexDirection.RowReverse || Direction == FlexDirection.ColumnReverse; + + var orderedChildren = Children.Where(c => c.IsVisible).OrderBy(c => GetOrder(c)).ToList(); + if (orderedChildren.Count == 0) + return bounds; + + float mainSize = isRow ? bounds.Width : bounds.Height; + float crossSize = isRow ? bounds.Height : bounds.Width; + + var childInfos = new List<(SkiaView child, SKSize size, float grow, float shrink)>(); + float totalBasis = 0f; + float totalGrow = 0f; + float totalShrink = 0f; + + foreach (var child in orderedChildren) + { + var basis = GetBasis(child); + float grow = GetGrow(child); + float shrink = GetShrink(child); + + SKSize size; + if (basis.IsAuto) + { + size = child.Measure(new SKSize(bounds.Width, bounds.Height)); + } + else + { + float length = basis.Length; + size = isRow + ? child.Measure(new SKSize(length, bounds.Height)) + : child.Measure(new SKSize(bounds.Width, length)); + } + + childInfos.Add((child, size, grow, shrink)); + totalBasis += isRow ? size.Width : size.Height; + totalGrow += grow; + totalShrink += shrink; + } + + float freeSpace = mainSize - totalBasis; + + var resolvedSizes = new List<(SkiaView child, float mainSize, float crossSize)>(); + foreach (var (child, size, grow, shrink) in childInfos) + { + float childMainSize = isRow ? size.Width : size.Height; + float childCrossSize = isRow ? size.Height : size.Width; + + if (freeSpace > 0f && totalGrow > 0f) + { + childMainSize += freeSpace * (grow / totalGrow); + } + else if (freeSpace < 0f && totalShrink > 0f) + { + childMainSize += freeSpace * (shrink / totalShrink); + } + + resolvedSizes.Add((child, Math.Max(0f, childMainSize), childCrossSize)); + } + + float usedSpace = resolvedSizes.Sum(s => s.mainSize); + float remainingSpace = Math.Max(0f, mainSize - usedSpace); + + float position = isRow ? bounds.Left : bounds.Top; + float spacing = 0f; + + switch (JustifyContent) + { + case FlexJustify.Center: + position += remainingSpace / 2f; + break; + case FlexJustify.End: + position += remainingSpace; + break; + case FlexJustify.SpaceBetween: + if (resolvedSizes.Count > 1) + spacing = remainingSpace / (resolvedSizes.Count - 1); + break; + case FlexJustify.SpaceAround: + if (resolvedSizes.Count > 0) + { + spacing = remainingSpace / resolvedSizes.Count; + position += spacing / 2f; + } + break; + case FlexJustify.SpaceEvenly: + if (resolvedSizes.Count > 0) + { + spacing = remainingSpace / (resolvedSizes.Count + 1); + position += spacing; + } + break; + } + + var items = isReverse ? resolvedSizes.AsEnumerable().Reverse() : resolvedSizes; + + foreach (var (child, childMainSize, childCrossSize) in items) + { + var alignSelf = GetAlignSelf(child); + var effectiveAlign = alignSelf == FlexAlignSelf.Auto ? AlignItems : (FlexAlignItems)alignSelf; + + float crossPos = isRow ? bounds.Top : bounds.Left; + float finalCrossSize = childCrossSize; + + switch (effectiveAlign) + { + case FlexAlignItems.End: + crossPos = (isRow ? bounds.Bottom : bounds.Right) - finalCrossSize; + break; + case FlexAlignItems.Center: + crossPos += (crossSize - finalCrossSize) / 2f; + break; + case FlexAlignItems.Stretch: + finalCrossSize = crossSize; + break; + } + + SKRect childBounds; + if (isRow) + { + childBounds = new SKRect(position, crossPos, position + childMainSize, crossPos + finalCrossSize); + } + else + { + childBounds = new SKRect(crossPos, position, crossPos + finalCrossSize, position + childMainSize); + } + + child.Arrange(childBounds); + position += childMainSize + spacing; + } + + return bounds; + } +} diff --git a/Views/SkiaImage.cs b/Views/SkiaImage.cs index 8cef851..b38fc5b 100644 --- a/Views/SkiaImage.cs +++ b/Views/SkiaImage.cs @@ -210,6 +210,25 @@ public class SkiaImage : SkiaView } } + /// + /// Loads the image from an SKBitmap. + /// + public void LoadFromBitmap(SKBitmap bitmap) + { + try + { + Bitmap = bitmap; + _isLoading = false; + ImageLoaded?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _isLoading = false; + ImageLoadingError?.Invoke(this, new ImageLoadingErrorEventArgs(ex)); + } + Invalidate(); + } + protected override SKSize MeasureOverride(SKSize availableSize) { if (_image == null) diff --git a/Views/SkiaLabel.cs b/Views/SkiaLabel.cs index 4d2ca5c..88fa71a 100644 --- a/Views/SkiaLabel.cs +++ b/Views/SkiaLabel.cs @@ -1,6 +1,9 @@ // 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.Linq; using SkiaSharp; using Microsoft.Maui.Platform.Linux.Rendering; @@ -24,6 +27,17 @@ public class SkiaLabel : SkiaView "", propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged()); + /// + /// Bindable property for FormattedSpans. + /// + public static readonly BindableProperty FormattedSpansProperty = + BindableProperty.Create( + nameof(FormattedSpans), + typeof(IList), + typeof(SkiaLabel), + null, + propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged()); + /// /// Bindable property for TextColor. /// @@ -191,6 +205,15 @@ public class SkiaLabel : SkiaView set => SetValue(TextProperty, value); } + /// + /// Gets or sets the formatted text spans for rich text rendering. + /// + public IList? FormattedSpans + { + get => (IList?)GetValue(FormattedSpansProperty); + set => SetValue(FormattedSpansProperty, value); + } + /// /// Gets or sets the text color. /// @@ -363,6 +386,11 @@ public class SkiaLabel : SkiaView private static SKTypeface? _cachedTypeface; + /// + /// Event raised when the label is tapped. + /// + public event EventHandler? Tapped; + private void OnTextChanged() { InvalidateMeasure(); @@ -400,6 +428,20 @@ public class SkiaLabel : SkiaView protected override void OnDraw(SKCanvas canvas, SKRect bounds) { + // Calculate content bounds with padding + var contentBounds = new SKRect( + bounds.Left + Padding.Left, + bounds.Top + Padding.Top, + bounds.Right - Padding.Right, + bounds.Bottom - Padding.Bottom); + + // Handle formatted spans first (rich text) + if (FormattedSpans != null && FormattedSpans.Count > 0) + { + DrawFormattedText(canvas, contentBounds); + return; + } + if (string.IsNullOrEmpty(Text)) return; @@ -421,13 +463,6 @@ public class SkiaLabel : SkiaView IsAntialias = true }; - // Calculate content bounds with padding - var contentBounds = new SKRect( - bounds.Left + Padding.Left, - bounds.Top + Padding.Top, - bounds.Right - Padding.Right, - bounds.Bottom - Padding.Bottom); - // Handle single line vs multiline // Use DrawMultiLineWithWrapping when: MaxLines > 1, text has newlines, OR WordWrap is enabled bool needsMultiLine = MaxLines > 1 || Text.Contains('\n') || @@ -815,6 +850,181 @@ public class SkiaLabel : SkiaView totalHeight + Padding.Top + Padding.Bottom); } } + + private void DrawFormattedText(SKCanvas canvas, SKRect bounds) + { + if (FormattedSpans == null || FormattedSpans.Count == 0) + return; + + float currentX = bounds.Left; + float currentY = bounds.Top; + float lineHeight = 0f; + + // First pass: calculate line data + var lineSpans = new List<(SkiaTextSpan span, float x, float width, float height, SKPaint paint)>(); + + foreach (var span in FormattedSpans) + { + if (string.IsNullOrEmpty(span.Text)) + continue; + + var paint = CreateSpanPaint(span); + var textBounds = new SKRect(); + paint.MeasureText(span.Text, ref textBounds); + lineHeight = Math.Max(lineHeight, textBounds.Height); + + // Word wrap + if (currentX + textBounds.Width > bounds.Right && currentX > bounds.Left) + { + currentY += lineHeight * LineHeight; + currentX = bounds.Left; + lineHeight = textBounds.Height; + } + + lineSpans.Add((span, currentX, textBounds.Width, textBounds.Height, paint)); + currentX += textBounds.Width; + } + + // Calculate vertical offset + float totalHeight = currentY + lineHeight - bounds.Top; + float verticalOffset = VerticalTextAlignment switch + { + TextAlignment.Start => 0f, + TextAlignment.Center => (bounds.Height - totalHeight) / 2f, + TextAlignment.End => bounds.Height - totalHeight, + _ => 0f + }; + + // Second pass: draw with alignment + currentX = bounds.Left; + currentY = bounds.Top + verticalOffset; + lineHeight = 0f; + + var currentLine = new List<(SkiaTextSpan span, float relX, float width, float height, SKPaint paint)>(); + float lineLeft = bounds.Left; + + foreach (var span in FormattedSpans) + { + if (string.IsNullOrEmpty(span.Text)) + continue; + + var paint = CreateSpanPaint(span); + var textBounds = new SKRect(); + paint.MeasureText(span.Text, ref textBounds); + lineHeight = Math.Max(lineHeight, textBounds.Height); + + if (currentX + textBounds.Width > bounds.Right && currentX > bounds.Left) + { + DrawFormattedLine(canvas, bounds, currentLine, currentY + lineHeight); + currentY += lineHeight * LineHeight; + currentX = bounds.Left; + lineHeight = textBounds.Height; + currentLine.Clear(); + } + + currentLine.Add((span, currentX - lineLeft, textBounds.Width, textBounds.Height, paint)); + currentX += textBounds.Width; + } + + if (currentLine.Count > 0) + { + DrawFormattedLine(canvas, bounds, currentLine, currentY + lineHeight); + } + } + + private void DrawFormattedLine(SKCanvas canvas, SKRect bounds, + List<(SkiaTextSpan span, float x, float width, float height, SKPaint paint)> lineSpans, float y) + { + if (lineSpans.Count == 0) return; + + float lineWidth = lineSpans.Sum(s => s.width); + float startX = HorizontalTextAlignment switch + { + TextAlignment.Start => bounds.Left, + TextAlignment.Center => bounds.Left + (bounds.Width - lineWidth) / 2f, + TextAlignment.End => bounds.Right - lineWidth, + _ => bounds.Left + }; + + foreach (var (span, relX, width, height, paint) in lineSpans) + { + float x = startX + relX; + + // Draw background if specified + if (span.BackgroundColor.HasValue && span.BackgroundColor.Value != SKColors.Transparent) + { + using var bgPaint = new SKPaint + { + Color = span.BackgroundColor.Value, + Style = SKPaintStyle.Fill + }; + canvas.DrawRect(x, y - height, width, height + 4f, bgPaint); + } + + canvas.DrawText(span.Text, x, y, paint); + + // Draw underline + if (span.IsUnderline) + { + using var linePaint = new SKPaint + { + Color = paint.Color, + StrokeWidth = 1f, + IsAntialias = true + }; + canvas.DrawLine(x, y + 2f, x + width, y + 2f, linePaint); + } + + // Draw strikethrough + if (span.IsStrikethrough) + { + using var linePaint = new SKPaint + { + Color = paint.Color, + StrokeWidth = 1f, + IsAntialias = true + }; + canvas.DrawLine(x, y - height / 3f, x + width, y - height / 3f, linePaint); + } + + paint.Dispose(); + } + } + + private SKPaint CreateSpanPaint(SkiaTextSpan span) + { + var fontStyle = new SKFontStyle( + span.IsBold ? SKFontStyleWeight.Bold : SKFontStyleWeight.Normal, + SKFontStyleWidth.Normal, + span.IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright); + + var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(span.FontFamily ?? FontFamily, fontStyle); + if (typeface == null || typeface == SKTypeface.Default) + { + typeface = GetLinuxTypeface(); + } + + var fontSize = span.FontSize > 0f ? span.FontSize : FontSize; + using var font = new SKFont(typeface, fontSize); + + var color = span.TextColor ?? TextColor; + if (!IsEnabled) + { + color = color.WithAlpha(128); + } + + return new SKPaint(font) + { + Color = color, + IsAntialias = true + }; + } + + public override void OnPointerPressed(PointerEventArgs e) + { + base.OnPointerPressed(e); + Tapped?.Invoke(this, EventArgs.Empty); + } } /// diff --git a/Views/SkiaShell.cs b/Views/SkiaShell.cs index c131c8f..75870c4 100644 --- a/Views/SkiaShell.cs +++ b/Views/SkiaShell.cs @@ -284,6 +284,21 @@ public class SkiaShell : SkiaLayoutView /// public int CurrentSectionIndex => _selectedSectionIndex; + /// + /// Reference to the MAUI Shell this view represents. + /// + public Shell? MauiShell { get; set; } + + /// + /// Callback to render content from a ShellContent. + /// + public Func? ContentRenderer { get; set; } + + /// + /// Callback to refresh shell colors. + /// + public Action? ColorRefresher { get; set; } + /// /// Event raised when FlyoutIsPresented changes. /// @@ -342,6 +357,48 @@ public class SkiaShell : SkiaLayoutView Invalidate(); } + /// + /// Refreshes the shell theme and re-renders all pages. + /// + public void RefreshTheme() + { + Console.WriteLine("[SkiaShell] RefreshTheme called - refreshing all pages"); + if (MauiShell != null && ColorRefresher != null) + { + Console.WriteLine("[SkiaShell] Refreshing shell colors"); + ColorRefresher(this, MauiShell); + } + if (ContentRenderer != null) + { + foreach (var section in _sections) + { + foreach (var item in section.Items) + { + if (item.MauiShellContent != null) + { + Console.WriteLine("[SkiaShell] Re-rendering: " + item.Title); + var skiaView = ContentRenderer(item.MauiShellContent); + if (skiaView != null) + { + item.Content = skiaView; + } + } + } + } + } + if (_selectedSectionIndex >= 0 && _selectedSectionIndex < _sections.Count) + { + var section = _sections[_selectedSectionIndex]; + if (_selectedItemIndex >= 0 && _selectedItemIndex < section.Items.Count) + { + var item = section.Items[_selectedItemIndex]; + SetCurrentContent(item.Content); + } + } + InvalidateMeasure(); + Invalidate(); + } + /// /// Navigates using a URI route. /// @@ -900,6 +957,11 @@ public class ShellContent /// The content view. /// public SkiaView? Content { get; set; } + + /// + /// Reference to the MAUI ShellContent this represents. + /// + public Microsoft.Maui.Controls.ShellContent? MauiShellContent { get; set; } } /// diff --git a/Views/SkiaView.cs b/Views/SkiaView.cs index bfb4e03..f38d57c 100644 --- a/Views/SkiaView.cs +++ b/Views/SkiaView.cs @@ -1,6 +1,11 @@ // 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.Platform.Linux; +using Microsoft.Maui.Platform.Linux.Handlers; +using Microsoft.Maui.Platform.Linux.Rendering; +using Microsoft.Maui.Platform.Linux.Window; using SkiaSharp; namespace Microsoft.Maui.Platform; @@ -218,6 +223,116 @@ public abstract class SkiaView : BindableObject, IDisposable typeof(SkiaView), string.Empty); + /// + /// Bindable property for Scale. + /// + public static readonly BindableProperty ScaleProperty = + BindableProperty.Create( + nameof(Scale), + typeof(double), + typeof(SkiaView), + 1.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for ScaleX. + /// + public static readonly BindableProperty ScaleXProperty = + BindableProperty.Create( + nameof(ScaleX), + typeof(double), + typeof(SkiaView), + 1.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for ScaleY. + /// + public static readonly BindableProperty ScaleYProperty = + BindableProperty.Create( + nameof(ScaleY), + typeof(double), + typeof(SkiaView), + 1.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for Rotation. + /// + public static readonly BindableProperty RotationProperty = + BindableProperty.Create( + nameof(Rotation), + typeof(double), + typeof(SkiaView), + 0.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for RotationX. + /// + public static readonly BindableProperty RotationXProperty = + BindableProperty.Create( + nameof(RotationX), + typeof(double), + typeof(SkiaView), + 0.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for RotationY. + /// + public static readonly BindableProperty RotationYProperty = + BindableProperty.Create( + nameof(RotationY), + typeof(double), + typeof(SkiaView), + 0.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for TranslationX. + /// + public static readonly BindableProperty TranslationXProperty = + BindableProperty.Create( + nameof(TranslationX), + typeof(double), + typeof(SkiaView), + 0.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for TranslationY. + /// + public static readonly BindableProperty TranslationYProperty = + BindableProperty.Create( + nameof(TranslationY), + typeof(double), + typeof(SkiaView), + 0.0, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for AnchorX. + /// + public static readonly BindableProperty AnchorXProperty = + BindableProperty.Create( + nameof(AnchorX), + typeof(double), + typeof(SkiaView), + 0.5, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + + /// + /// Bindable property for AnchorY. + /// + public static readonly BindableProperty AnchorYProperty = + BindableProperty.Create( + nameof(AnchorY), + typeof(double), + typeof(SkiaView), + 0.5, + propertyChanged: (b, o, n) => ((SkiaView)b).Invalidate()); + #endregion private bool _disposed; @@ -408,6 +523,107 @@ public abstract class SkiaView : BindableObject, IDisposable set => SetValue(NameProperty, value); } + /// + /// Gets or sets the uniform scale factor. + /// + public double Scale + { + get => (double)GetValue(ScaleProperty); + set => SetValue(ScaleProperty, value); + } + + /// + /// Gets or sets the X-axis scale factor. + /// + public double ScaleX + { + get => (double)GetValue(ScaleXProperty); + set => SetValue(ScaleXProperty, value); + } + + /// + /// Gets or sets the Y-axis scale factor. + /// + public double ScaleY + { + get => (double)GetValue(ScaleYProperty); + set => SetValue(ScaleYProperty, value); + } + + /// + /// Gets or sets the rotation in degrees around the Z-axis. + /// + public double Rotation + { + get => (double)GetValue(RotationProperty); + set => SetValue(RotationProperty, value); + } + + /// + /// Gets or sets the rotation in degrees around the X-axis. + /// + public double RotationX + { + get => (double)GetValue(RotationXProperty); + set => SetValue(RotationXProperty, value); + } + + /// + /// Gets or sets the rotation in degrees around the Y-axis. + /// + public double RotationY + { + get => (double)GetValue(RotationYProperty); + set => SetValue(RotationYProperty, value); + } + + /// + /// Gets or sets the X translation offset. + /// + public double TranslationX + { + get => (double)GetValue(TranslationXProperty); + set => SetValue(TranslationXProperty, value); + } + + /// + /// Gets or sets the Y translation offset. + /// + public double TranslationY + { + get => (double)GetValue(TranslationYProperty); + set => SetValue(TranslationYProperty, value); + } + + /// + /// Gets or sets the X anchor point for transforms (0.0 to 1.0). + /// + public double AnchorX + { + get => (double)GetValue(AnchorXProperty); + set => SetValue(AnchorXProperty, value); + } + + /// + /// Gets or sets the Y anchor point for transforms (0.0 to 1.0). + /// + public double AnchorY + { + get => (double)GetValue(AnchorYProperty); + set => SetValue(AnchorYProperty, value); + } + + /// + /// Gets or sets the cursor type when hovering over this view. + /// + public CursorType CursorType { get; set; } + + /// + /// Gets or sets the MAUI View this platform view represents. + /// Used for gesture processing. + /// + public View? MauiView { get; set; } + /// /// Gets or sets whether this view currently has keyboard focus. /// @@ -566,8 +782,23 @@ public abstract class SkiaView : BindableObject, IDisposable /// public void Invalidate() { + LinuxApplication.LogInvalidate(GetType().Name); Invalidated?.Invoke(this, EventArgs.Empty); - _parent?.Invalidate(); + + // Notify rendering engine of dirty region + if (Bounds.Width > 0 && Bounds.Height > 0) + { + SkiaRenderingEngine.Current?.InvalidateRegion(Bounds); + } + + if (_parent != null) + { + _parent.Invalidate(); + } + else + { + LinuxApplication.RequestRedraw(); + } } /// @@ -583,7 +814,7 @@ public abstract class SkiaView : BindableObject, IDisposable /// /// Draws this view and its children to the canvas. /// - public void Draw(SKCanvas canvas) + public virtual void Draw(SKCanvas canvas) { if (!IsVisible || Opacity <= 0) { @@ -592,6 +823,42 @@ public abstract class SkiaView : BindableObject, IDisposable canvas.Save(); + // 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 = Bounds.Left + (float)(Bounds.Width * AnchorX); + float anchorAbsY = Bounds.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) { @@ -706,11 +973,47 @@ public abstract class SkiaView : BindableObject, IDisposable #region Input Events - public virtual void OnPointerEntered(PointerEventArgs e) { } - public virtual void OnPointerExited(PointerEventArgs e) { } - public virtual void OnPointerMoved(PointerEventArgs e) { } - public virtual void OnPointerPressed(PointerEventArgs e) { } - public virtual void OnPointerReleased(PointerEventArgs e) { } + 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) + { + Console.WriteLine($"[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) { } diff --git a/Views/SkiaWebView.cs b/Views/SkiaWebView.cs index 371c9c1..b8c6860 100644 --- a/Views/SkiaWebView.cs +++ b/Views/SkiaWebView.cs @@ -129,6 +129,37 @@ public class SkiaWebView : SkiaView private const int RTLD_GLOBAL = 0x100; private static IntPtr _webkitHandle; + private static IntPtr _mainDisplay; + private static IntPtr _mainWindow; + private static readonly HashSet _activeWebViews = new(); + + /// + /// Sets the main window for WebView operations. + /// + public static void SetMainWindow(IntPtr display, IntPtr window) + { + _mainDisplay = display; + _mainWindow = window; + Console.WriteLine($"[WebView] Main window set: display={display}, window={window}"); + } + + /// + /// Processes pending GTK events for WebViews. + /// + public static void ProcessGtkEvents() + { + bool hasActiveWebViews; + lock (_activeWebViews) + { + hasActiveWebViews = _activeWebViews.Count > 0; + } + if (hasActiveWebViews && _gtkInitialized) + { + while (g_main_context_iteration(IntPtr.Zero, mayBlock: false)) + { + } + } + } #endregion diff --git a/Window/CursorType.cs b/Window/CursorType.cs new file mode 100644 index 0000000..d68ee86 --- /dev/null +++ b/Window/CursorType.cs @@ -0,0 +1,11 @@ +namespace Microsoft.Maui.Platform.Linux.Window; + +/// +/// Types of cursors supported on Linux. +/// +public enum CursorType +{ + Arrow, + Hand, + Text +} diff --git a/Window/GtkHostWindow.cs b/Window/GtkHostWindow.cs new file mode 100644 index 0000000..83d9404 --- /dev/null +++ b/Window/GtkHostWindow.cs @@ -0,0 +1,343 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Maui.Platform.Linux.Native; +using Microsoft.Maui.Platform.Linux.Rendering; + +namespace Microsoft.Maui.Platform.Linux.Window; + +/// +/// GTK-based host window for MAUI applications on Linux. +/// Uses GTK3 with X11 backend for windowing and event handling. +/// +public sealed class GtkHostWindow : IDisposable +{ + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool DeleteEventDelegate(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool ConfigureEventDelegate(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool ButtonEventDelegate(IntPtr widget, IntPtr eventData, IntPtr userData); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate bool MotionEventDelegate(IntPtr widget, IntPtr eventData, IntPtr userData); + + [StructLayout(LayoutKind.Explicit)] + private struct GdkEventButton + { + [FieldOffset(0)] + public int type; + + [FieldOffset(8)] + public IntPtr window; + + [FieldOffset(16)] + public sbyte send_event; + + [FieldOffset(20)] + public uint time; + + [FieldOffset(24)] + public double x; + + [FieldOffset(32)] + public double y; + + [FieldOffset(40)] + public IntPtr axes; + + [FieldOffset(48)] + public uint state; + + [FieldOffset(52)] + public uint button; + } + + [StructLayout(LayoutKind.Explicit)] + private struct GdkEventMotion + { + [FieldOffset(0)] + public int type; + + [FieldOffset(8)] + public IntPtr window; + + [FieldOffset(16)] + public sbyte send_event; + + [FieldOffset(20)] + public uint time; + + [FieldOffset(24)] + public double x; + + [FieldOffset(32)] + public double y; + } + + private IntPtr _window; + private IntPtr _overlay; + private IntPtr _webViewLayer; + private GtkSkiaSurfaceWidget? _skiaSurface; + private bool _disposed; + private bool _isRunning; + private int _width; + private int _height; + + private readonly DeleteEventDelegate _deleteEventHandler; + private readonly ConfigureEventDelegate _configureEventHandler; + private readonly ButtonEventDelegate _buttonPressHandler; + private readonly ButtonEventDelegate _buttonReleaseHandler; + private readonly MotionEventDelegate _motionHandler; + + public IntPtr Window => _window; + public IntPtr Overlay => _overlay; + public IntPtr WebViewLayer => _webViewLayer; + public GtkSkiaSurfaceWidget? SkiaSurface => _skiaSurface; + public int Width => _width; + public int Height => _height; + public bool IsRunning => _isRunning; + + public event EventHandler<(int Width, int Height)>? Resized; + public event EventHandler? CloseRequested; + public event EventHandler<(double X, double Y, int Button)>? PointerPressed; + public event EventHandler<(double X, double Y, int Button)>? PointerReleased; + public event EventHandler<(double X, double Y)>? PointerMoved; + + public GtkHostWindow(string title, int width, int height) + { + _width = width; + _height = height; + + // Configure environment for GTK/X11 + Environment.SetEnvironmentVariable("GDK_BACKEND", "x11"); + Environment.SetEnvironmentVariable("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1"); + Environment.SetEnvironmentVariable("LIBGL_ALWAYS_SOFTWARE", "1"); + + int argc = 0; + IntPtr argv = IntPtr.Zero; + if (!GtkNative.gtk_init_check(ref argc, ref argv)) + { + throw new InvalidOperationException("Failed to initialize GTK. Is a display available?"); + } + + _window = GtkNative.gtk_window_new(0); + if (_window == IntPtr.Zero) + { + throw new InvalidOperationException("Failed to create GTK window"); + } + + GtkNative.gtk_window_set_title(_window, title); + GtkNative.gtk_window_set_default_size(_window, width, height); + + // Create overlay container for layered content + _overlay = GtkNative.gtk_overlay_new(); + GtkNative.gtk_container_add(_window, _overlay); + + // Create Skia surface as base layer + _skiaSurface = new GtkSkiaSurfaceWidget(width, height); + GtkNative.gtk_container_add(_overlay, _skiaSurface.Widget); + + // Create fixed container for WebView overlays + _webViewLayer = GtkNative.gtk_fixed_new(); + GtkNative.gtk_overlay_add_overlay(_overlay, _webViewLayer); + GtkNative.gtk_widget_set_can_focus(_webViewLayer, canFocus: false); + GtkNative.gtk_overlay_set_overlay_pass_through(_overlay, _webViewLayer, passThrough: true); + + // Store delegates to prevent garbage collection + _deleteEventHandler = OnDeleteEvent; + _configureEventHandler = OnConfigureEvent; + _buttonPressHandler = OnButtonPress; + _buttonReleaseHandler = OnButtonRelease; + _motionHandler = OnMotion; + + // Connect event handlers + ConnectSignal(_window, "delete-event", Marshal.GetFunctionPointerForDelegate(_deleteEventHandler)); + ConnectSignal(_window, "configure-event", Marshal.GetFunctionPointerForDelegate(_configureEventHandler)); + + // Add pointer event masks + GtkNative.gtk_widget_add_events(_window, 772); + ConnectSignal(_window, "button-press-event", Marshal.GetFunctionPointerForDelegate(_buttonPressHandler)); + ConnectSignal(_window, "button-release-event", Marshal.GetFunctionPointerForDelegate(_buttonReleaseHandler)); + ConnectSignal(_window, "motion-notify-event", Marshal.GetFunctionPointerForDelegate(_motionHandler)); + + Console.WriteLine($"[GtkHostWindow] Created GTK window on X11: {width}x{height}"); + } + + private void ConnectSignal(IntPtr widget, string signal, IntPtr handler) + { + GtkNative.g_signal_connect_data(widget, signal, handler, IntPtr.Zero, IntPtr.Zero, 0); + } + + private bool OnDeleteEvent(IntPtr widget, IntPtr eventData, IntPtr userData) + { + CloseRequested?.Invoke(this, EventArgs.Empty); + _isRunning = false; + GtkNative.gtk_main_quit(); + return true; + } + + private bool OnConfigureEvent(IntPtr widget, IntPtr eventData, IntPtr userData) + { + GtkNative.gtk_window_get_size(_window, out var width, out var height); + if (width != _width || height != _height) + { + _width = width; + _height = height; + _skiaSurface?.Resize(width, height); + Resized?.Invoke(this, (_width, _height)); + } + return false; + } + + private bool OnButtonPress(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y, button) = ParseButtonEvent(eventData); + string buttonName = button switch + { + 3 => "Right", + 2 => "Middle", + 1 => "Left", + _ => $"Other({button})", + }; + Console.WriteLine($"[GtkHostWindow] ButtonPress at ({x:F1}, {y:F1}), button={button} ({buttonName})"); + PointerPressed?.Invoke(this, (x, y, button)); + _skiaSurface?.RaisePointerPressed(x, y, button); + return false; + } + + private bool OnButtonRelease(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y, button) = ParseButtonEvent(eventData); + PointerReleased?.Invoke(this, (x, y, button)); + _skiaSurface?.RaisePointerReleased(x, y, button); + return false; + } + + private bool OnMotion(IntPtr widget, IntPtr eventData, IntPtr userData) + { + var (x, y) = ParseMotionEvent(eventData); + PointerMoved?.Invoke(this, (x, y)); + _skiaSurface?.RaisePointerMoved(x, y); + return false; + } + + private static (double x, double y, int button) ParseButtonEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + return (evt.x, evt.y, (int)evt.button); + } + + private static (double x, double y) ParseMotionEvent(IntPtr eventData) + { + var evt = Marshal.PtrToStructure(eventData); + return (evt.x, evt.y); + } + + public void Show() + { + GtkNative.gtk_widget_show_all(_window); + _isRunning = true; + } + + public void Hide() + { + GtkNative.gtk_widget_hide(_window); + } + + public void SetTitle(string title) + { + GtkNative.gtk_window_set_title(_window, title); + } + + public void SetIcon(string iconPath) + { + if (string.IsNullOrEmpty(iconPath) || !File.Exists(iconPath)) + { + Console.WriteLine("[GtkHostWindow] Icon file not found: " + iconPath); + return; + } + try + { + IntPtr pixbuf = GtkNative.gdk_pixbuf_new_from_file(iconPath, IntPtr.Zero); + if (pixbuf != IntPtr.Zero) + { + GtkNative.gtk_window_set_icon(_window, pixbuf); + GtkNative.g_object_unref(pixbuf); + Console.WriteLine("[GtkHostWindow] Set window icon: " + iconPath); + } + } + catch (Exception ex) + { + Console.WriteLine("[GtkHostWindow] Failed to set icon: " + ex.Message); + } + } + + public void Resize(int width, int height) + { + GtkNative.gtk_window_resize(_window, width, height); + } + + public void AddWebView(IntPtr webViewWidget, int x, int y, int width, int height) + { + GtkNative.gtk_widget_set_size_request(webViewWidget, width, height); + GtkNative.gtk_fixed_put(_webViewLayer, webViewWidget, x, y); + GtkNative.gtk_widget_show(webViewWidget); + Console.WriteLine($"[GtkHostWindow] Added WebView at ({x}, {y}) size {width}x{height}"); + } + + public void MoveResizeWebView(IntPtr webViewWidget, int x, int y, int width, int height) + { + GtkNative.gtk_widget_set_size_request(webViewWidget, width, height); + GtkNative.gtk_fixed_move(_webViewLayer, webViewWidget, x, y); + } + + public void RemoveWebView(IntPtr webViewWidget) + { + GtkNative.gtk_container_remove(_webViewLayer, webViewWidget); + } + + public void RequestRedraw() + { + if (_skiaSurface != null) + { + GtkNative.gtk_widget_queue_draw(_skiaSurface.Widget); + } + } + + public void Run() + { + Show(); + GtkNative.gtk_main(); + } + + public void Stop() + { + _isRunning = false; + GtkNative.gtk_main_quit(); + } + + public void ProcessEvents() + { + while (GtkNative.gtk_events_pending()) + { + GtkNative.gtk_main_iteration_do(blocking: false); + } + } + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + _skiaSurface?.Dispose(); + if (_window != IntPtr.Zero) + { + GtkNative.gtk_widget_destroy(_window); + _window = IntPtr.Zero; + } + } + } +} diff --git a/Window/X11Window.cs b/Window/X11Window.cs index 49358fa..a28f417 100644 --- a/Window/X11Window.cs +++ b/Window/X11Window.cs @@ -21,6 +21,13 @@ public class X11Window : IDisposable private int _width; private int _height; + // Cursor handles + private IntPtr _arrowCursor; + private IntPtr _handCursor; + private IntPtr _textCursor; + private IntPtr _currentCursor; + private CursorType _currentCursorType = CursorType.Arrow; + /// /// Gets the native display handle. /// @@ -155,7 +162,97 @@ public class X11Window : IDisposable // Set up WM_DELETE_WINDOW protocol for proper close handling _wmDeleteMessage = X11.XInternAtom(_display, "WM_DELETE_WINDOW", false); - // Would need XSetWMProtocols here, simplified for now + // Initialize cursors + _arrowCursor = X11.XCreateFontCursor(_display, 68); // XC_left_ptr + _handCursor = X11.XCreateFontCursor(_display, 60); // XC_hand2 + _textCursor = X11.XCreateFontCursor(_display, 152); // XC_xterm + _currentCursor = _arrowCursor; + } + + /// + /// Sets the cursor type for this window. + /// + public void SetCursor(CursorType cursorType) + { + if (_currentCursorType != cursorType) + { + _currentCursorType = cursorType; + IntPtr cursor = cursorType switch + { + CursorType.Hand => _handCursor, + CursorType.Text => _textCursor, + _ => _arrowCursor, + }; + if (cursor != _currentCursor) + { + _currentCursor = cursor; + X11.XDefineCursor(_display, _window, _currentCursor); + X11.XFlush(_display); + } + } + } + + /// + /// Sets the window icon from a file. + /// + public unsafe void SetIcon(string iconPath) + { + if (string.IsNullOrEmpty(iconPath) || !System.IO.File.Exists(iconPath)) + { + Console.WriteLine("[X11Window] Icon file not found: " + iconPath); + return; + } + Console.WriteLine("[X11Window] SetIcon called: " + iconPath); + try + { + SkiaSharp.SKBitmap? bitmap = SkiaSharp.SKBitmap.Decode(iconPath); + if (bitmap == null) + { + Console.WriteLine("[X11Window] Failed to load icon: " + iconPath); + return; + } + Console.WriteLine($"[X11Window] Loaded bitmap: {bitmap.Width}x{bitmap.Height}"); + + // Scale to 64x64 if needed + int targetSize = 64; + if (bitmap.Width != targetSize || bitmap.Height != targetSize) + { + var scaled = new SkiaSharp.SKBitmap(targetSize, targetSize, false); + bitmap.ScalePixels(scaled, SkiaSharp.SKFilterQuality.High); + bitmap.Dispose(); + bitmap = scaled; + } + + int width = bitmap.Width; + int height = bitmap.Height; + int dataSize = 2 + width * height; + uint[] iconData = new uint[dataSize]; + iconData[0] = (uint)width; + iconData[1] = (uint)height; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = bitmap.GetPixel(x, y); + iconData[2 + y * width + x] = (uint)((pixel.Alpha << 24) | (pixel.Red << 16) | (pixel.Green << 8) | pixel.Blue); + } + } + bitmap.Dispose(); + + IntPtr property = X11.XInternAtom(_display, "_NET_WM_ICON", false); + IntPtr type = X11.XInternAtom(_display, "CARDINAL", false); + fixed (uint* data = iconData) + { + X11.XChangeProperty(_display, _window, property, type, 32, 0, (nint)data, dataSize); + } + X11.XFlush(_display); + Console.WriteLine($"[X11Window] Set window icon: {width}x{height}"); + } + catch (Exception ex) + { + Console.WriteLine("[X11Window] Failed to set icon: " + ex.Message); + } } ///