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