diff --git a/Handlers/GestureManager.cs b/Handlers/GestureManager.cs index c2a47a9..a3024f8 100644 --- a/Handlers/GestureManager.cs +++ b/Handlers/GestureManager.cs @@ -9,7 +9,7 @@ namespace Microsoft.Maui.Platform.Linux.Handlers; /// /// Manages gesture recognition and processing for MAUI views on Linux. -/// Handles tap, pan, swipe, and pointer gestures. +/// Handles tap, pan, swipe, pinch, and pointer gestures. /// public static class GestureManager { @@ -22,6 +22,8 @@ public static class GestureManager public DateTime StartTime { get; set; } public bool IsPanning { get; set; } public bool IsPressed { get; set; } + public bool IsPinching { get; set; } + public double PinchScale { get; set; } = 1.0; } private enum PointerEventType @@ -34,6 +36,7 @@ public static class GestureManager } private static MethodInfo? _sendTappedMethod; + private static MethodInfo? _sendPinchMethod; private static readonly Dictionary _tapTracking = new Dictionary(); private static readonly Dictionary _gestureState = new Dictionary(); @@ -41,6 +44,7 @@ public static class GestureManager private const double SwipeMaxTime = 500.0; private const double SwipeDirectionThreshold = 0.5; private const double PanMinDistance = 10.0; + private const double PinchScrollScale = 0.1; // Scale factor per scroll unit /// /// Processes a tap gesture on the specified view. @@ -504,6 +508,142 @@ public static class GestureManager return null!; } + /// + /// Processes a scroll event that may be a pinch gesture (Ctrl+Scroll). + /// Returns true if the scroll was consumed as a pinch gesture. + /// + public static bool ProcessScrollAsPinch(View? view, double x, double y, double deltaY, bool isCtrlPressed) + { + if (view == null || !isCtrlPressed) + { + return false; + } + + // Check if view has a pinch gesture recognizer + if (!HasPinchGestureRecognizer(view)) + { + return false; + } + + // Get or create gesture state + if (!_gestureState.TryGetValue(view, out var state)) + { + state = new GestureTrackingState + { + StartX = x, + StartY = y, + CurrentX = x, + CurrentY = y, + StartTime = DateTime.UtcNow, + PinchScale = 1.0 + }; + _gestureState[view] = state; + } + + // Calculate new scale based on scroll delta + double scaleDelta = 1.0 + (deltaY * PinchScrollScale); + state.PinchScale *= scaleDelta; + + // Clamp scale to reasonable bounds + state.PinchScale = Math.Clamp(state.PinchScale, 0.1, 10.0); + + GestureStatus status; + if (!state.IsPinching) + { + state.IsPinching = true; + status = GestureStatus.Started; + } + else + { + status = GestureStatus.Running; + } + + ProcessPinchGesture(view, state.PinchScale, x, y, status); + return true; + } + + /// + /// Ends an ongoing pinch gesture. + /// + public static void EndPinchGesture(View? view) + { + if (view == null) return; + + if (_gestureState.TryGetValue(view, out var state) && state.IsPinching) + { + ProcessPinchGesture(view, state.PinchScale, state.CurrentX, state.CurrentY, GestureStatus.Completed); + state.IsPinching = false; + state.PinchScale = 1.0; + } + } + + private static void ProcessPinchGesture(View view, double scale, double originX, double originY, GestureStatus status) + { + var recognizers = view.GestureRecognizers; + if (recognizers == null) + { + return; + } + + foreach (var item in recognizers) + { + var pinchRecognizer = item as PinchGestureRecognizer; + if (pinchRecognizer == null) + { + continue; + } + + Console.WriteLine($"[GestureManager] Pinch gesture: status={status}, scale={scale:F2}, origin=({originX:F0},{originY:F0})"); + + try + { + // Cache the method lookup + if (_sendPinchMethod == null) + { + _sendPinchMethod = typeof(PinchGestureRecognizer).GetMethod("SendPinch", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + } + + if (_sendPinchMethod != null) + { + // SendPinch(IView sender, double scale, Point scaleOrigin, GestureStatus status) + var scaleOrigin = new Point(originX / view.Width, originY / view.Height); + _sendPinchMethod.Invoke(pinchRecognizer, new object[] + { + view, + scale, + scaleOrigin, + status + }); + Console.WriteLine("[GestureManager] SendPinch invoked successfully"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[GestureManager] SendPinch failed: {ex.Message}"); + } + } + } + + /// + /// Checks if the view has a pinch gesture recognizer. + /// + public static bool HasPinchGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) + { + return false; + } + foreach (var recognizer in view.GestureRecognizers) + { + if (recognizer is PinchGestureRecognizer) + { + return true; + } + } + return false; + } + /// /// Checks if the view has a swipe gesture recognizer. /// @@ -560,4 +700,146 @@ public static class GestureManager } return false; } + + /// + /// Checks if the view has a drag gesture recognizer. + /// + public static bool HasDragGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) + { + return false; + } + foreach (var recognizer in view.GestureRecognizers) + { + if (recognizer is DragGestureRecognizer) + { + return true; + } + } + return false; + } + + /// + /// Checks if the view has a drop gesture recognizer. + /// + public static bool HasDropGestureRecognizer(View? view) + { + if (view?.GestureRecognizers == null) + { + return false; + } + foreach (var recognizer in view.GestureRecognizers) + { + if (recognizer is DropGestureRecognizer) + { + return true; + } + } + return false; + } + + /// + /// Initiates a drag operation from the specified view. + /// + public static void StartDrag(View? view, double x, double y) + { + if (view == null) return; + + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var item in recognizers) + { + var dragRecognizer = item as DragGestureRecognizer; + if (dragRecognizer == null) continue; + + Console.WriteLine($"[GestureManager] Starting drag from {view.GetType().Name}"); + + try + { + // Create DragStartingEventArgs and invoke SendDragStarting + var method = typeof(DragGestureRecognizer).GetMethod("SendDragStarting", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method != null) + { + method.Invoke(dragRecognizer, new object[] { view }); + Console.WriteLine("[GestureManager] SendDragStarting invoked successfully"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[GestureManager] SendDragStarting failed: {ex.Message}"); + } + } + } + + /// + /// Processes a drag enter event on the specified view. + /// + public static void ProcessDragEnter(View? view, double x, double y, object? data) + { + if (view == null) return; + + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var item in recognizers) + { + var dropRecognizer = item as DropGestureRecognizer; + if (dropRecognizer == null) continue; + + Console.WriteLine($"[GestureManager] Drag enter on {view.GetType().Name}"); + + try + { + var method = typeof(DropGestureRecognizer).GetMethod("SendDragOver", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method != null) + { + method.Invoke(dropRecognizer, new object[] { view }); + } + } + catch (Exception ex) + { + Console.WriteLine($"[GestureManager] SendDragOver failed: {ex.Message}"); + } + } + } + + /// + /// Processes a drop event on the specified view. + /// + public static void ProcessDrop(View? view, double x, double y, object? data) + { + if (view == null) return; + + var recognizers = view.GestureRecognizers; + if (recognizers == null) return; + + foreach (var item in recognizers) + { + var dropRecognizer = item as DropGestureRecognizer; + if (dropRecognizer == null) continue; + + Console.WriteLine($"[GestureManager] Drop on {view.GetType().Name}"); + + try + { + var method = typeof(DropGestureRecognizer).GetMethod("SendDrop", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + if (method != null) + { + method.Invoke(dropRecognizer, new object[] { view }); + } + } + catch (Exception ex) + { + Console.WriteLine($"[GestureManager] SendDrop failed: {ex.Message}"); + } + } + } } diff --git a/Hosting/LinuxMauiAppBuilderExtensions.cs b/Hosting/LinuxMauiAppBuilderExtensions.cs index e52cbe5..6596bef 100644 --- a/Hosting/LinuxMauiAppBuilderExtensions.cs +++ b/Hosting/LinuxMauiAppBuilderExtensions.cs @@ -72,6 +72,7 @@ public static class LinuxMauiAppBuilderExtensions builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(_ => MonitorService.Instance); + builder.Services.TryAddSingleton(); // Register GTK host service builder.Services.TryAddSingleton(_ => GtkHostService.Instance); diff --git a/LinuxApplication.cs b/LinuxApplication.cs index 76c677e..8d1d883 100644 --- a/LinuxApplication.cs +++ b/LinuxApplication.cs @@ -929,16 +929,31 @@ public class LinuxApplication : IDisposable } } - private void OnGtkScrolled(object? sender, (double X, double Y, double DeltaX, double DeltaY) e) + private void OnGtkScrolled(object? sender, (double X, double Y, double DeltaX, double DeltaY, uint State) e) { if (_rootView == null) return; + // Convert GDK state to KeyModifiers + var modifiers = ConvertGdkStateToModifiers(e.State); + bool isCtrlPressed = (modifiers & KeyModifiers.Control) != 0; + var hitView = _rootView.HitTest((float)e.X, (float)e.Y); + + // Check for pinch gesture (Ctrl+Scroll) first + if (isCtrlPressed && hitView?.MauiView != null) + { + if (Handlers.GestureManager.ProcessScrollAsPinch(hitView.MauiView, e.X, e.Y, e.DeltaY, true)) + { + _gtkWindow?.RequestRedraw(); + return; + } + } + while (hitView != null) { if (hitView is SkiaScrollView scrollView) { - var args = new ScrollEventArgs((float)e.X, (float)e.Y, (float)e.DeltaX, (float)e.DeltaY); + var args = new ScrollEventArgs((float)e.X, (float)e.Y, (float)e.DeltaX, (float)e.DeltaY, modifiers); scrollView.OnScroll(args); _gtkWindow?.RequestRedraw(); break; @@ -947,6 +962,25 @@ public class LinuxApplication : IDisposable } } + private static KeyModifiers ConvertGdkStateToModifiers(uint state) + { + var modifiers = KeyModifiers.None; + // GDK modifier masks + const uint GDK_SHIFT_MASK = 1 << 0; + const uint GDK_CONTROL_MASK = 1 << 2; + const uint GDK_MOD1_MASK = 1 << 3; // Alt + const uint GDK_SUPER_MASK = 1 << 26; + const uint GDK_LOCK_MASK = 1 << 1; // Caps Lock + + if ((state & GDK_SHIFT_MASK) != 0) modifiers |= KeyModifiers.Shift; + if ((state & GDK_CONTROL_MASK) != 0) modifiers |= KeyModifiers.Control; + if ((state & GDK_MOD1_MASK) != 0) modifiers |= KeyModifiers.Alt; + if ((state & GDK_SUPER_MASK) != 0) modifiers |= KeyModifiers.Super; + if ((state & GDK_LOCK_MASK) != 0) modifiers |= KeyModifiers.CapsLock; + + return modifiers; + } + private void OnGtkTextInput(object? sender, string text) { if (_focusedView != null) diff --git a/Rendering/GtkSkiaSurfaceWidget.cs b/Rendering/GtkSkiaSurfaceWidget.cs index 1da2f53..0f05f8f 100644 --- a/Rendering/GtkSkiaSurfaceWidget.cs +++ b/Rendering/GtkSkiaSurfaceWidget.cs @@ -113,7 +113,7 @@ public sealed class GtkSkiaSurfaceWidget : IDisposable 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<(double X, double Y, double DeltaX, double DeltaY, uint State)>? Scrolled; public event EventHandler? TextInput; public GtkSkiaSurfaceWidget(int width, int height) @@ -314,8 +314,8 @@ public sealed class GtkSkiaSurfaceWidget : IDisposable private bool OnScroll(IntPtr widget, IntPtr eventData, IntPtr userData) { - var (x, y, deltaX, deltaY) = ParseScrollEvent(eventData); - Scrolled?.Invoke(this, (x, y, deltaX, deltaY)); + var (x, y, deltaX, deltaY, state) = ParseScrollEvent(eventData); + Scrolled?.Invoke(this, (x, y, deltaX, deltaY, state)); return true; } @@ -337,7 +337,7 @@ public sealed class GtkSkiaSurfaceWidget : IDisposable return (evt.keyval, evt.hardware_keycode, evt.state); } - private static (double x, double y, double deltaX, double deltaY) ParseScrollEvent(IntPtr eventData) + private static (double x, double y, double deltaX, double deltaY, uint state) ParseScrollEvent(IntPtr eventData) { var evt = Marshal.PtrToStructure(eventData); double deltaX = 0.0; @@ -366,7 +366,7 @@ public sealed class GtkSkiaSurfaceWidget : IDisposable break; } } - return (evt.x, evt.y, deltaX, deltaY); + return (evt.x, evt.y, deltaX, deltaY, evt.state); } public void GrabFocus() diff --git a/Views/SkiaView.cs b/Views/SkiaView.cs index e4a5985..4663a59 100644 --- a/Views/SkiaView.cs +++ b/Views/SkiaView.cs @@ -1738,14 +1738,21 @@ public class ScrollEventArgs : EventArgs public float Y { get; } public float DeltaX { get; } public float DeltaY { get; } + public KeyModifiers Modifiers { get; } public bool Handled { get; set; } - public ScrollEventArgs(float x, float y, float deltaX, float deltaY) + /// + /// Gets whether the Control key is pressed during this scroll event. + /// + public bool IsControlPressed => (Modifiers & KeyModifiers.Control) != 0; + + public ScrollEventArgs(float x, float y, float deltaX, float deltaY, KeyModifiers modifiers = KeyModifiers.None) { X = x; Y = y; DeltaX = deltaX; DeltaY = deltaY; + Modifiers = modifiers; } }