From f75ca96213c596589bf06a0af232284baacc8f5d Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 31 Jan 2026 15:36:24 +0300 Subject: [PATCH] feat: add Gesture Events types (EVENT-004) Vello-style per-frame gesture recognition: - GestureEvent struct with zoom/rotation/translation deltas - PinchType enum (Horizontal, Vertical, Proportional) - Point type with Add/Sub/Scale operations - GestureEventSource interface with OnGesture callback - NullGestureEventSource for platforms without gesture support - Comprehensive unit tests --- gesture.go | 161 +++++++++++++++++++++++++++++++++++++++ gesture_test.go | 197 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 gesture.go create mode 100644 gesture_test.go diff --git a/gesture.go b/gesture.go new file mode 100644 index 0000000..0f8d6eb --- /dev/null +++ b/gesture.go @@ -0,0 +1,161 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import "time" + +// GestureEvent contains computed gesture deltas per frame. +// +// This event follows the Vello multi-touch pattern where gesture deltas +// are computed once per frame from the set of active pointers. This approach +// avoids jitter from individual pointer moves and provides smooth, predictable +// gesture values. +// +// The event is designed for multi-touch gestures (pinch-to-zoom, rotation, pan) +// but degrades gracefully with fewer pointers: +// - 0-1 pointers: Empty event (no gesture possible) +// - 2+ pointers: Full gesture with zoom, rotation, and translation +// +// Example usage: +// +// source.OnGesture(func(ev gpucontext.GestureEvent) { +// if ev.NumPointers >= 2 { +// camera.Zoom(ev.ZoomDelta) +// camera.Rotate(ev.RotationDelta) +// camera.Pan(ev.TranslationDelta) +// } +// }) +type GestureEvent struct { + // NumPointers is the number of active touch points. + // Gestures require at least 2 pointers. + NumPointers int + + // ZoomDelta is the proportional zoom factor for this frame. + // 1.0 = no change, >1.0 = zoom in, <1.0 = zoom out. + // Computed from change in average distance from centroid. + ZoomDelta float64 + + // ZoomDelta2D provides non-proportional zoom (stretch) deltas. + // This allows independent X and Y scaling for non-uniform zoom. + // For most use cases, use ZoomDelta instead. + ZoomDelta2D Point + + // RotationDelta is the rotation change in radians for this frame. + // Positive = counter-clockwise, negative = clockwise. + // Computed from angle change of first pointer relative to centroid. + RotationDelta float64 + + // TranslationDelta is the pan movement in logical pixels for this frame. + // Computed from change in centroid position. + TranslationDelta Point + + // PinchType classifies the pinch gesture based on finger geometry. + // Useful for constraining zoom to one axis (e.g., timeline scrubbing). + PinchType PinchType + + // Center is the centroid of all active touch points. + // Use this as the zoom/rotation pivot point. + Center Point + + // Timestamp is the event time as duration since an arbitrary reference. + // Useful for velocity calculations or animation timing. + // Zero if timestamps are not available. + Timestamp time.Duration +} + +// PinchType classifies a two-finger pinch gesture based on finger geometry. +type PinchType uint8 + +const ( + // PinchNone indicates no pinch gesture (fewer than 2 pointers). + PinchNone PinchType = iota + + // PinchHorizontal indicates horizontal separation exceeds vertical by 3x. + // The fingers are spread horizontally, suggesting horizontal zoom/scrub. + PinchHorizontal + + // PinchVertical indicates vertical separation exceeds horizontal by 3x. + // The fingers are spread vertically, suggesting vertical zoom. + PinchVertical + + // PinchProportional indicates uniform pinch (default). + // Neither axis dominates, suggesting proportional zoom. + PinchProportional +) + +// String returns the pinch type name for debugging. +func (p PinchType) String() string { + switch p { + case PinchNone: + return "None" + case PinchHorizontal: + return "Horizontal" + case PinchVertical: + return "Vertical" + case PinchProportional: + return "Proportional" + default: + return "Unknown" + } +} + +// Point represents a 2D coordinate in logical pixels. +type Point struct { + X, Y float64 +} + +// Add returns the sum of two points. +func (p Point) Add(other Point) Point { + return Point{X: p.X + other.X, Y: p.Y + other.Y} +} + +// Sub returns the difference of two points. +func (p Point) Sub(other Point) Point { + return Point{X: p.X - other.X, Y: p.Y - other.Y} +} + +// Scale returns the point scaled by a factor. +func (p Point) Scale(factor float64) Point { + return Point{X: p.X * factor, Y: p.Y * factor} +} + +// GestureEventSource provides gesture event callbacks. +// +// This interface extends EventSource with high-level gesture recognition. +// The gesture recognizer computes deltas once per frame from pointer events, +// following the Vello pattern for smooth, predictable gestures. +// +// Type assertion pattern: +// +// if ges, ok := eventSource.(gpucontext.GestureEventSource); ok { +// ges.OnGesture(handleGestureEvent) +// } +// +// For applications that need gesture support: +// +// ges.OnGesture(func(ev gpucontext.GestureEvent) { +// if ev.NumPointers >= 2 { +// handlePinchZoom(ev.ZoomDelta, ev.Center) +// } +// }) +type GestureEventSource interface { + // OnGesture registers a callback for gesture events. + // The callback receives a GestureEvent containing computed deltas. + // + // Callback threading: Called on the main/UI thread at end of frame. + // Callbacks should be fast and non-blocking. + // + // Gesture events are delivered once per frame when 2+ pointers are active. + OnGesture(fn func(GestureEvent)) +} + +// NullGestureEventSource implements GestureEventSource by ignoring all registrations. +// Useful for platforms or configurations where gesture input is not available. +type NullGestureEventSource struct{} + +// OnGesture does nothing. +func (NullGestureEventSource) OnGesture(func(GestureEvent)) {} + +// Ensure NullGestureEventSource implements GestureEventSource. +var _ GestureEventSource = NullGestureEventSource{} diff --git a/gesture_test.go b/gesture_test.go new file mode 100644 index 0000000..e2899f7 --- /dev/null +++ b/gesture_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 The gogpu Authors +// SPDX-License-Identifier: MIT + +package gpucontext + +import ( + "testing" + "time" +) + +func TestGestureEvent_ZeroValue(t *testing.T) { + var ev GestureEvent + + // Zero value should represent no gesture + if ev.NumPointers != 0 { + t.Errorf("NumPointers: got %d, want 0", ev.NumPointers) + } + if ev.ZoomDelta != 0 { + t.Errorf("ZoomDelta: got %f, want 0", ev.ZoomDelta) + } + if ev.RotationDelta != 0 { + t.Errorf("RotationDelta: got %f, want 0", ev.RotationDelta) + } + if ev.TranslationDelta.X != 0 || ev.TranslationDelta.Y != 0 { + t.Errorf("TranslationDelta: got (%f, %f), want (0, 0)", + ev.TranslationDelta.X, ev.TranslationDelta.Y) + } + if ev.PinchType != PinchNone { + t.Errorf("PinchType: got %v, want PinchNone", ev.PinchType) + } + if ev.Center.X != 0 || ev.Center.Y != 0 { + t.Errorf("Center: got (%f, %f), want (0, 0)", ev.Center.X, ev.Center.Y) + } + if ev.Timestamp != 0 { + t.Errorf("Timestamp: got %v, want 0", ev.Timestamp) + } +} + +func TestGestureEvent_Fields(t *testing.T) { + ev := GestureEvent{ + NumPointers: 2, + ZoomDelta: 1.5, + ZoomDelta2D: Point{X: 1.5, Y: 1.0}, + RotationDelta: 0.1, + TranslationDelta: Point{X: 10, Y: 20}, + PinchType: PinchProportional, + Center: Point{X: 100, Y: 200}, + Timestamp: time.Second, + } + + if ev.NumPointers != 2 { + t.Errorf("NumPointers: got %d, want 2", ev.NumPointers) + } + if ev.ZoomDelta != 1.5 { + t.Errorf("ZoomDelta: got %f, want 1.5", ev.ZoomDelta) + } + if ev.ZoomDelta2D.X != 1.5 || ev.ZoomDelta2D.Y != 1.0 { + t.Errorf("ZoomDelta2D: got (%f, %f), want (1.5, 1.0)", + ev.ZoomDelta2D.X, ev.ZoomDelta2D.Y) + } + if ev.RotationDelta != 0.1 { + t.Errorf("RotationDelta: got %f, want 0.1", ev.RotationDelta) + } + if ev.TranslationDelta.X != 10 || ev.TranslationDelta.Y != 20 { + t.Errorf("TranslationDelta: got (%f, %f), want (10, 20)", + ev.TranslationDelta.X, ev.TranslationDelta.Y) + } + if ev.PinchType != PinchProportional { + t.Errorf("PinchType: got %v, want PinchProportional", ev.PinchType) + } + if ev.Center.X != 100 || ev.Center.Y != 200 { + t.Errorf("Center: got (%f, %f), want (100, 200)", ev.Center.X, ev.Center.Y) + } + if ev.Timestamp != time.Second { + t.Errorf("Timestamp: got %v, want 1s", ev.Timestamp) + } +} + +func TestPinchType_String(t *testing.T) { + tests := []struct { + pinchType PinchType + want string + }{ + {PinchNone, "None"}, + {PinchHorizontal, "Horizontal"}, + {PinchVertical, "Vertical"}, + {PinchProportional, "Proportional"}, + {PinchType(99), "Unknown"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := tt.pinchType.String() + if got != tt.want { + t.Errorf("String(): got %s, want %s", got, tt.want) + } + }) + } +} + +func TestPinchType_Classification(t *testing.T) { + // Test the classification logic that will be used in GestureRecognizer + classifyPinch := func(dx, dy float64) PinchType { + absDx := dx + if absDx < 0 { + absDx = -absDx + } + absDy := dy + if absDy < 0 { + absDy = -absDy + } + + if absDx > absDy*3 { + return PinchHorizontal + } + if absDy > absDx*3 { + return PinchVertical + } + return PinchProportional + } + + tests := []struct { + name string + dx float64 + dy float64 + want PinchType + }{ + {"horizontal dominant", 100, 10, PinchHorizontal}, + {"vertical dominant", 10, 100, PinchVertical}, + {"proportional equal", 50, 50, PinchProportional}, + {"proportional similar", 60, 50, PinchProportional}, + {"horizontal exactly 3x", 30, 10, PinchProportional}, // Not > 3x + {"horizontal over 3x", 31, 10, PinchHorizontal}, + {"vertical exactly 3x", 10, 30, PinchProportional}, // Not > 3x + {"vertical over 3x", 10, 31, PinchVertical}, + {"negative horizontal", -100, 10, PinchHorizontal}, + {"negative vertical", 10, -100, PinchVertical}, + {"both negative", -100, -100, PinchProportional}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyPinch(tt.dx, tt.dy) + if got != tt.want { + t.Errorf("classifyPinch(%f, %f): got %v, want %v", + tt.dx, tt.dy, got, tt.want) + } + }) + } +} + +func TestPoint_Operations(t *testing.T) { + p1 := Point{X: 10, Y: 20} + p2 := Point{X: 5, Y: 10} + + // Test Add + sum := p1.Add(p2) + if sum.X != 15 || sum.Y != 30 { + t.Errorf("Add: got (%f, %f), want (15, 30)", sum.X, sum.Y) + } + + // Test Sub + diff := p1.Sub(p2) + if diff.X != 5 || diff.Y != 10 { + t.Errorf("Sub: got (%f, %f), want (5, 10)", diff.X, diff.Y) + } + + // Test Scale + scaled := p1.Scale(2) + if scaled.X != 20 || scaled.Y != 40 { + t.Errorf("Scale: got (%f, %f), want (20, 40)", scaled.X, scaled.Y) + } + + // Test Scale with negative + scaledNeg := p1.Scale(-1) + if scaledNeg.X != -10 || scaledNeg.Y != -20 { + t.Errorf("Scale(-1): got (%f, %f), want (-10, -20)", scaledNeg.X, scaledNeg.Y) + } +} + +func TestNullGestureEventSource(t *testing.T) { + var source NullGestureEventSource + + // Should not panic + called := false + source.OnGesture(func(GestureEvent) { + called = true + }) + + if called { + t.Error("NullGestureEventSource should not call the callback") + } + + // Verify interface compliance + var _ GestureEventSource = source + var _ GestureEventSource = NullGestureEventSource{} +}